Merge pull request #425 from vinaypokharkar/fix/detect-system-chrome-gpu-window

fix(detect): use system Chrome on Windows to stop GPU crash-loop window (#372)
This commit is contained in:
Paul Bakaus
2026-07-28 09:02:01 -07:00
committed by GitHub
3 changed files with 118 additions and 4 deletions
+35 -3
View File
@@ -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 <url>`
// (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 };
+2 -1
View File
@@ -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',
],
+81
View File
@@ -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);
});
});