diff --git a/cli/engine/engines/browser/detect-url.mjs b/cli/engine/engines/browser/detect-url.mjs index c25d5d3df..5e3d5446b 100644 --- a/cli/engine/engines/browser/detect-url.mjs +++ b/cli/engine/engines/browser/detect-url.mjs @@ -7,6 +7,38 @@ import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profi import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs'; import { checkContentHiddenAtRest } from '../../rules/checks.mjs'; +// On Windows, puppeteer's bundled Chrome lives in a user-writable cache +// directory. Its GPU process can be denied (STATUS_ACCESS_DENIED) by security +// software or the GPU sandbox because it launches from an untrusted path. +// Chrome then crash-loops the GPU process, and each relaunch briefly flashes a +// compositor surface, the black window users report during `detect ` +// (issue #372). The system-installed Chrome runs from a trusted location with a +// healthy GPU, so channel:'chrome' avoids the crash entirely; both use hardware +// GPU, so contrast measurement is unaffected. Scope this to Windows only: other +// platforms do not have the bug, so they keep the pinned bundled build for +// consistent measurement across machines. Fall back to bundled when the switch +// fails (Chrome not installed, or channel resolution fails). If the bundled +// launch then also fails, surface the original system-Chrome error as the +// cause so the real failure is not lost. +async function launchBrowser(puppeteer, { headless = true, args = [] } = {}) { + let channelError; + if (process.platform === 'win32') { + try { + return await puppeteer.default.launch({ channel: 'chrome', headless, args }); + } catch (err) { + // System Chrome unavailable or unlaunchable; fall through to the bundled + // browser, but keep the error in case the fallback fails too. + channelError = err; + } + } + try { + return await puppeteer.default.launch({ headless, args }); + } catch (err) { + if (channelError && err && err.cause === undefined) err.cause = channelError; + throw err; + } +} + // Reveal sweep + invisible-text measurement for the content-hidden-at-rest // rule. Scrolls through the document with instant jumps (bypasses CSS // scroll-behavior: smooth) so IntersectionObserver / scroll reveal handlers @@ -178,7 +210,7 @@ async function detectUrl(url, options = {}) { phase: 'load', ruleId: 'launch-browser', target: url, - }, () => puppeteer.default.launch({ headless: true, args: launchArgs })); + }, () => launchBrowser(puppeteer, { headless: options?.headless ?? true, args: launchArgs })); const page = await profileStepAsync(profile, { engine: 'browser', phase: 'load', @@ -312,7 +344,7 @@ async function createBrowserDetector(options = {}) { throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer'); } const launchArgs = options.launchArgs || (process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []); - const browser = options.browser || await puppeteer.default.launch({ + const browser = options.browser || await launchBrowser(puppeteer, { headless: options.headless ?? true, args: launchArgs, }); @@ -337,4 +369,4 @@ async function createBrowserDetector(options = {}) { }; } -export { runVisualContrastFallback, detectUrl, createBrowserDetector }; +export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser }; diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index d8ef865bd..0c51a6bff 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -87,13 +87,14 @@ export const SUITES = { /^scripts\/(benchmark-detector|build-browser-detector|build-extension)\.js$/, /^site\/(pages\/detector|public\/antipattern|data\/anti-patterns-catalog\.js)/, /^tests\/design-system\.test\.mjs$/, - /^tests\/(detect-antipatterns|detect-cli-design-contamination|inline-ignores|extension-build|fixtures\/antipatterns)/, + /^tests\/(detect-antipatterns|detect-cli-design-contamination|detect-url-launch|inline-ignores|extension-build|fixtures\/antipatterns)/, ], commands: [ { runner: 'bun', files: [ 'tests/detect-antipatterns.test.js', + 'tests/detect-url-launch.test.mjs', 'tests/inline-ignores.test.mjs', 'tests/lib/detector-bundle.test.js', ], diff --git a/tests/detect-url-launch.test.mjs b/tests/detect-url-launch.test.mjs new file mode 100644 index 000000000..53a147722 --- /dev/null +++ b/tests/detect-url-launch.test.mjs @@ -0,0 +1,81 @@ +import { describe, test, expect, afterEach } from 'bun:test'; +import { launchBrowser } from '../cli/engine/engines/browser/detect-url.mjs'; + +// launchBrowser prefers the system-installed Chrome on Windows to dodge the +// bundled-Chrome GPU crash-loop (issue #372), and keeps the pinned bundled +// build everywhere else. The function takes the puppeteer module as a +// parameter, so a fake lets us assert the launch strategy without a real +// browser or a real OS. + +const realPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + +function setPlatform(value) { + Object.defineProperty(process, 'platform', { value, configurable: true }); +} + +afterEach(() => { + Object.defineProperty(process, 'platform', realPlatform); +}); + +function makePuppeteer({ failChannel = false } = {}) { + const calls = []; + const fakeBrowser = { __fake: true }; + return { + calls, + fakeBrowser, + mod: { + default: { + async launch(opts) { + calls.push(opts); + if (failChannel && opts.channel === 'chrome') { + throw new Error('Could not find Chrome (channel: chrome)'); + } + return fakeBrowser; + }, + }, + }, + }; +} + +describe('launchBrowser', () => { + test('Windows: prefers system Chrome via channel:chrome', async () => { + setPlatform('win32'); + const p = makePuppeteer(); + const browser = await launchBrowser(p.mod, { headless: true, args: ['--foo'] }); + + expect(browser).toBe(p.fakeBrowser); + expect(p.calls).toHaveLength(1); + expect(p.calls[0].channel).toBe('chrome'); + expect(p.calls[0].headless).toBe(true); + expect(p.calls[0].args).toEqual(['--foo']); + }); + + test('Windows: falls back to bundled when system Chrome is unavailable', async () => { + setPlatform('win32'); + const p = makePuppeteer({ failChannel: true }); + const browser = await launchBrowser(p.mod, { headless: true, args: [] }); + + expect(browser).toBe(p.fakeBrowser); + expect(p.calls).toHaveLength(2); + expect(p.calls[0].channel).toBe('chrome'); // first attempt + expect(p.calls[1].channel).toBeUndefined(); // fallback: bundled, no channel + }); + + test('non-Windows: uses bundled Chrome directly, no channel', async () => { + setPlatform('linux'); + const p = makePuppeteer(); + const browser = await launchBrowser(p.mod, { headless: true, args: [] }); + + expect(browser).toBe(p.fakeBrowser); + expect(p.calls).toHaveLength(1); + expect(p.calls[0].channel).toBeUndefined(); + }); + + test('non-Windows: never attempts channel:chrome even if it would succeed', async () => { + setPlatform('darwin'); + const p = makePuppeteer(); + await launchBrowser(p.mod, {}); + + expect(p.calls.every(c => c.channel === undefined)).toBe(true); + }); +});