diff --git a/browse/test/playwright-launcher-patch.test.ts b/browse/test/playwright-launcher-patch.test.ts new file mode 100644 index 000000000..a53eee0a4 --- /dev/null +++ b/browse/test/playwright-launcher-patch.test.ts @@ -0,0 +1,52 @@ +import { describe, test, expect } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Windows console-flash guard for the Playwright browser launcher (#2151 +// follow-up; siblings: #1835, #1989). +// +// On Windows the browse server runs console-less (node server-node.mjs, +// detached from the CLI). chrome-headless-shell is a console-subsystem +// exe, so playwright-core's processLauncher spawning it without +// windowsHide allocates a visible console window per launch, and its +// force-kill path shells out to `taskkill` (another window). Upstream +// playwright doesn't set windowsHide, so we carry a bun +// `patchedDependencies` patch. These tripwires pin: +// 1. package.json declares the patch for the installed version — if a +// playwright bump orphans the patch key, test 3 catches the silent +// regression (bun applies patches per exact version). +// 2. the patch file itself still contains both windowsHide sites. +// 3. the INSTALLED node_modules file actually got patched. + +const ROOT = path.resolve(import.meta.dir, '..', '..'); +const INSTALLED_LAUNCHER = path.join( + ROOT, 'node_modules', 'playwright-core', 'lib', 'server', 'utils', 'processLauncher.js', +); + +describe('playwright-core processLauncher windowsHide patch', () => { + test('1. package.json declares a patchedDependencies entry matching the installed playwright-core version', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8')); + const installed = JSON.parse(fs.readFileSync( + path.join(ROOT, 'node_modules', 'playwright-core', 'package.json'), 'utf-8', + )); + const key = `playwright-core@${installed.version}`; + expect(pkg.patchedDependencies?.[key]).toBeDefined(); + expect(fs.existsSync(path.join(ROOT, pkg.patchedDependencies[key]))).toBe(true); + }); + + test('2. the patch covers both spawn sites (browser launch + taskkill)', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8')); + const patchRel = Object.entries(pkg.patchedDependencies ?? {}) + .find(([k]) => k.startsWith('playwright-core@'))?.[1] as string; + const patch = fs.readFileSync(path.join(ROOT, patchRel), 'utf-8'); + expect(patch).toContain('+ windowsHide: true,'); + expect(patch).toContain('{ shell: true, windowsHide: true }'); + }); + + test('3. the installed processLauncher.js is actually patched', () => { + const src = fs.readFileSync(INSTALLED_LAUNCHER, 'utf-8'); + const launchBlock = src.slice(src.indexOf('async function launchProcess'), src.indexOf('const spawnedProcess')); + expect(launchBlock).toContain('windowsHide: true'); + expect(src).toMatch(/taskkill[^\n]*\{ shell: true, windowsHide: true \}/); + }); +}); diff --git a/bun.lock b/bun.lock index 7a1833e94..1e3bdd44a 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,9 @@ }, }, }, + "patchedDependencies": { + "playwright-core@1.58.2": "patches/playwright-core@1.58.2.patch", + }, "packages": { "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.117", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.117", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.117", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.117", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.117" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-pVBss1Vu0w87nKCBhWtjMggSgCh6GVUtdRmuE58ZvXv0E2q0JcnUCQHehmn92BAW0+VCwPY8q/k7uKWkgwz/gA=="], diff --git a/package.json b/package.json index c5168d648..b950bb72c 100644 --- a/package.json +++ b/package.json @@ -76,5 +76,8 @@ "@anthropic-ai/sdk": "^0.78.0", "xterm": "5", "xterm-addon-fit": "^0.8.0" + }, + "patchedDependencies": { + "playwright-core@1.58.2": "patches/playwright-core@1.58.2.patch" } } diff --git a/patches/playwright-core@1.58.2.patch b/patches/playwright-core@1.58.2.patch new file mode 100644 index 000000000..d45440742 --- /dev/null +++ b/patches/playwright-core@1.58.2.patch @@ -0,0 +1,24 @@ +diff --git a/lib/server/utils/processLauncher.js b/lib/server/utils/processLauncher.js +index 954d5e6fbc527e640ccec5709263374101c6340b..6913f7e9cafe6bcc57ce5a501ce05edc01d36e0c 100644 +--- a/lib/server/utils/processLauncher.js ++++ b/lib/server/utils/processLauncher.js +@@ -112,6 +112,10 @@ async function launchProcess(options) { + env: options.env, + cwd: options.cwd, + shell: options.shell, ++ // gstack patch (#2151 follow-up): chrome-headless-shell is a console- ++ // subsystem exe; launched from the console-less browse daemon on ++ // Windows it allocates a visible console window without CREATE_NO_WINDOW. ++ windowsHide: true, + stdio + }; + const spawnedProcess = childProcess.spawn(options.command, options.args || [], spawnOptions); +@@ -191,7 +195,7 @@ async function launchProcess(options) { + options.log(`[pid=${spawnedProcess.pid}] `); + try { + if (process.platform === "win32") { +- const taskkillProcess = childProcess.spawnSync(`taskkill /pid ${spawnedProcess.pid} /T /F`, { shell: true }); ++ const taskkillProcess = childProcess.spawnSync(`taskkill /pid ${spawnedProcess.pid} /T /F`, { shell: true, windowsHide: true }); + const [stdout2, stderr2] = [taskkillProcess.stdout.toString(), taskkillProcess.stderr.toString()]; + if (stdout2) + options.log(`[pid=${spawnedProcess.pid}] taskkill stdout: ${stdout2}`);