From 33d7684c06dc39f9d838d32c0918e4fa6c2e85ee Mon Sep 17 00:00:00 2001 From: Vinaywho Date: Wed, 22 Jul 2026 20:29:18 +0530 Subject: [PATCH 1/2] fix(detect): use system Chrome on Windows to stop GPU crash-loop window (#372) On Windows, `impeccable detect ` flashed a persistent black window during scans. The scan uses puppeteer's bundled Chrome, which runs from an untrusted user-cache path; Windows blocks its GPU process, so it crash-loops and flashes a compositor surface on every retry. It is not a real application window (not in Alt+Tab, not clickable, invisible to window enumeration) and not malware. Prefer the system-installed Chrome via channel:'chrome' on Windows, which runs from a trusted location with a healthy GPU: no crash loop, no window. Fall back to the bundled browser when Chrome is not installed. Scoped to Windows only, so mac and linux keep the pinned bundled build for consistent measurement. Both render on hardware GPU, so contrast measurement is unaffected. Also routes both launch sites through one helper and fixes a pre-existing bug where detectUrl hardcoded headless:true instead of honoring options.headless. Tests: new tests/detect-url-launch.test.mjs covers the launch choice per platform (Windows prefers channel:'chrome' and falls back to bundled; non-Windows never attempts it), wired into the detector suite. Verified on Windows 11 / Chrome 150: zero GPU crashes, window gone, findings unchanged. This change was prepared with AI assistance. --- cli/engine/engines/browser/detect-url.mjs | 28 +++++++- scripts/test-suites.mjs | 3 +- tests/detect-url-launch.test.mjs | 81 +++++++++++++++++++++++ 3 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 tests/detect-url-launch.test.mjs diff --git a/cli/engine/engines/browser/detect-url.mjs b/cli/engine/engines/browser/detect-url.mjs index c25d5d3df..f06627924 100644 --- a/cli/engine/engines/browser/detect-url.mjs +++ b/cli/engine/engines/browser/detect-url.mjs @@ -7,6 +7,28 @@ 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). +async function launchBrowser(puppeteer, { headless = true, args = [] } = {}) { + if (process.platform === 'win32') { + try { + return await puppeteer.default.launch({ channel: 'chrome', headless, args }); + } catch { + // No system Chrome available; fall through to the bundled browser. + } + } + return await puppeteer.default.launch({ headless, args }); +} + // 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 +200,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 +334,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 +359,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 ea77cbf25..5c59876ce 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -86,13 +86,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|inline-ignores|extension-build|fixtures\/antipatterns)/, + /^tests\/(detect-antipatterns|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); + }); +}); From a4b691c5a2cead5953bc10cf406ac9c64de978cd Mon Sep 17 00:00:00 2001 From: Vinaywho Date: Mon, 27 Jul 2026 15:12:29 +0530 Subject: [PATCH 2/2] detect: preserve system-Chrome launch error as fallback cause --- cli/engine/engines/browser/detect-url.mjs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/cli/engine/engines/browser/detect-url.mjs b/cli/engine/engines/browser/detect-url.mjs index f06627924..5e3d5446b 100644 --- a/cli/engine/engines/browser/detect-url.mjs +++ b/cli/engine/engines/browser/detect-url.mjs @@ -17,16 +17,26 @@ import { checkContentHiddenAtRest } from '../../rules/checks.mjs'; // 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). +// 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 { - // No system Chrome available; fall through to the bundled browser. + } 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; } } - return await puppeteer.default.launch({ headless, args }); + 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