mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-20 11:52:20 +02:00
Merge origin/main (v1.67.0.0) — reconcile convergent iOS Release-guard fixes
main's v1.67.0.0 independently landed the DebugBridgeTouch Release compile-out with a stronger shape (`#if !defined(DEBUG)` short-circuit before the platform gate, measured via nm -j on a real Release binary) than this branch's `#if TARGET_OS_IOS && DEBUG`. Resolution: take main's templates/fixtures, keep this branch's free-tier static tripwire and adapt it to pin main's shape (short-circuit present, ordered before the platform branch, cSettings DEBUG define intact, no bare platform-only gate). VERSION/package.json stay 1.67.1.0; CHANGELOG keeps both entries with 1.67.1.0 on top, its iOS claims reworded to the residual contribution (the tripwire, not the compile-out itself). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -382,3 +382,42 @@ describe.skipIf(SKIP_SPAWN)('spawnSkill: lifecycle', () => {
|
||||
expect(result.stdout.length).toBeLessThanOrEqual(1024 * 1024);
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe('subprocess capture goes through temp files, not pipes', () => {
|
||||
// Tripwire. Capturing a child's output through `stdout: 'pipe'` is lossy
|
||||
// here: under a loaded parent, the first piped spawn in the process
|
||||
// intermittently yields an empty stderr even though the child wrote it and
|
||||
// exited 0. Neither draining before awaiting exit nor a manual getReader()
|
||||
// loop avoids it — both were measured losing the same bytes. It flaked
|
||||
// `$B skill test` (a dropped stderr left only bun's banner) and would blank
|
||||
// a skill's JSON result on `$B skill run` while still reporting success.
|
||||
//
|
||||
// runToFiles() points the child's fds at temp files instead, so the kernel
|
||||
// has flushed everything by the time the child exits. This test fails if a
|
||||
// refactor reintroduces pipe capture in this module.
|
||||
//
|
||||
// Comments are stripped first, so the module's own prose — which names the
|
||||
// banned pattern in order to explain it — doesn't trip checks meant for code.
|
||||
const src = fs.readFileSync(
|
||||
path.join(import.meta.dir, '..', 'src', 'browser-skill-commands.ts'), 'utf-8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/^\s*\/\/.*$/gm, '');
|
||||
|
||||
it("does not spawn with stdout/stderr: 'pipe'", () => {
|
||||
expect(src).not.toMatch(/std(out|err):\s*'pipe'/);
|
||||
});
|
||||
|
||||
it('does not read child output via Response(proc.stdout/stderr) or getReader', () => {
|
||||
expect(src).not.toMatch(/new Response\(\s*proc\.(stdout|stderr)/);
|
||||
expect(src).not.toMatch(/proc\.(stdout|stderr)[\s\S]{0,40}getReader\(/);
|
||||
});
|
||||
|
||||
it('every spawn site routes through runToFiles', () => {
|
||||
// The structural invariant: runToFiles owns the module's only Bun.spawn,
|
||||
// so any present or future spawn site inherits the file-based capture.
|
||||
// Counted rather than name-checked so adding a spawn site that bypasses
|
||||
// the helper fails here instead of silently reintroducing the bug.
|
||||
expect(src.match(/Bun\.spawn\(/g) ?? []).toHaveLength(1);
|
||||
expect((src.match(/await runToFiles\(/g) ?? []).length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +85,17 @@ describe('browser-skills E2E — bundled hackernews-frontpage', () => {
|
||||
// It takes ~1s. Run it last so other assertions are quick.
|
||||
test('$B skill test hackernews-frontpage runs script.test.ts and reports pass', async () => {
|
||||
const result = await handleSkillCommand(['test', 'hackernews-frontpage'], { port: 0 });
|
||||
// bun test prints summary to stderr; handleSkillCommand returns stderr || stdout
|
||||
expect(result).toMatch(/13 pass|0 fail|tests passed/);
|
||||
// `bun test` splits its report across streams: the version banner goes to
|
||||
// stdout, the pass/fail summary to stderr. handleSkillCommand must return
|
||||
// both, so assert on each stream's half.
|
||||
//
|
||||
// This used to flake under full-suite load: capturing the child through
|
||||
// pipes dropped stderr on the first piped spawn in the process, so the
|
||||
// result was just the banner. The old `13 pass|0 fail|tests passed` regex
|
||||
// also had a `tests passed` alternative that matched a synthetic fallback
|
||||
// string, which would have passed vacuously on an empty capture.
|
||||
expect(result).toMatch(/bun test v/); // stdout half
|
||||
expect(result).toMatch(/\b0 fail\b/); // stderr half
|
||||
expect(result).toMatch(/Ran \d+ tests/);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* #2219 IRON RULE regression tests (E5): an alive daemon pid is NEVER
|
||||
* auto-killed. Killing a live daemon loses the session's tabs, cookies, and
|
||||
* logins — strictly worse than a slow command. Only an explicit
|
||||
* --force-restart may replace a live daemon.
|
||||
*
|
||||
* Integration legs follow the busy-daemon-recovery.test.ts pattern: a fake
|
||||
* HTTP daemon + a live `sleep` child standing in for the daemon PID, wired
|
||||
* through BROWSE_STATE_FILE. Unit legs pin the pure decision function.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, afterEach } from 'bun:test';
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
import { isProcessAlive } from '../src/error-handling';
|
||||
import { decideDaemonRestart, HEALTH_PROBE_TOTAL_BUDGET_MS } from '../src/cli';
|
||||
|
||||
// ─── Unit: the pure restart decision (decision 9 / F10) ──────────────────
|
||||
|
||||
describe('decideDaemonRestart (pure)', () => {
|
||||
test('healthy after probe → retry against the SAME daemon', () => {
|
||||
expect(decideDaemonRestart({ pidAlive: true, healthyAfterProbe: true, forceRestart: false }))
|
||||
.toBe('retry-command');
|
||||
// Even with --force-restart in hand, a healthy daemon is retried, not killed.
|
||||
expect(decideDaemonRestart({ pidAlive: true, healthyAfterProbe: true, forceRestart: true }))
|
||||
.toBe('retry-command');
|
||||
});
|
||||
|
||||
test('IRON RULE: alive + unhealthy + no flag → report busy, never kill', () => {
|
||||
expect(decideDaemonRestart({ pidAlive: true, healthyAfterProbe: false, forceRestart: false }))
|
||||
.toBe('report-busy');
|
||||
});
|
||||
|
||||
test('alive + unhealthy + explicit --force-restart → force-restart', () => {
|
||||
expect(decideDaemonRestart({ pidAlive: true, healthyAfterProbe: false, forceRestart: true }))
|
||||
.toBe('force-restart');
|
||||
});
|
||||
|
||||
test('dead pid → restart, with or without the flag', () => {
|
||||
expect(decideDaemonRestart({ pidAlive: false, healthyAfterProbe: false, forceRestart: false }))
|
||||
.toBe('restart-dead');
|
||||
expect(decideDaemonRestart({ pidAlive: false, healthyAfterProbe: false, forceRestart: true }))
|
||||
.toBe('restart-dead');
|
||||
});
|
||||
|
||||
test('probe budget is ~8s (F10) — long enough for heavy-page busy windows', () => {
|
||||
expect(HEALTH_PROBE_TOTAL_BUDGET_MS).toBeGreaterThanOrEqual(7_000);
|
||||
expect(HEALTH_PROBE_TOTAL_BUDGET_MS).toBeLessThanOrEqual(10_000);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Integration: real spawned CLI vs fake daemons ───────────────────────
|
||||
|
||||
/** A daemon whose /health always answers healthy but never serves /command. */
|
||||
async function startHealthyDaemon(): Promise<{ port: number; close: () => Promise<void> }> {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'healthy' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('ok');
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('fake daemon: bad address');
|
||||
return { port: addr.port, close: () => new Promise((r) => server.close(() => r())) };
|
||||
}
|
||||
|
||||
/** A WEDGED daemon: alive socket, but /health always answers unhealthy. */
|
||||
async function startWedgedDaemon(): Promise<{ port: number; close: () => Promise<void> }> {
|
||||
const server = http.createServer((req, res) => {
|
||||
res.writeHead(503, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'wedged' }));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('fake daemon: bad address');
|
||||
return { port: addr.port, close: () => new Promise((r) => server.close(() => r())) };
|
||||
}
|
||||
|
||||
function runCli(args: string[], env: Record<string, string>, timeoutMs = 30_000):
|
||||
Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
const cliPath = path.resolve(import.meta.dir, '../src/cli.ts');
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('bun', ['run', cliPath, ...args], { timeout: timeoutMs, env });
|
||||
let stdout = ''; let stderr = '';
|
||||
proc.stdout.on('data', (d) => stdout += d.toString());
|
||||
proc.stderr.on('data', (d) => stderr += d.toString());
|
||||
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
function baseEnv(stateFile: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(process.env)) {
|
||||
if (v !== undefined) env[k] = v;
|
||||
}
|
||||
env.BROWSE_STATE_FILE = stateFile;
|
||||
return env;
|
||||
}
|
||||
|
||||
let pidChild: ChildProcess | null = null;
|
||||
afterEach(() => { pidChild?.kill('SIGKILL'); pidChild = null; });
|
||||
|
||||
describe('#2219 iron rule (CLI integration)', () => {
|
||||
test('healthy daemon SURVIVES `browse connect` — refused with guidance, no kill', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-iron-'));
|
||||
const stateFile = path.join(tmpDir, 'browse.json');
|
||||
const daemon = await startHealthyDaemon();
|
||||
try {
|
||||
pidChild = spawn('sleep', ['60'], { stdio: 'ignore' });
|
||||
const daemonPid = pidChild.pid!;
|
||||
const stateContent = {
|
||||
pid: daemonPid,
|
||||
port: daemon.port,
|
||||
token: 'iron-rule-token',
|
||||
startedAt: new Date().toISOString(),
|
||||
serverPath: '',
|
||||
mode: 'launched' as const,
|
||||
};
|
||||
fs.writeFileSync(stateFile, JSON.stringify(stateContent, null, 2));
|
||||
|
||||
const result = await runCli(['connect'], baseEnv(stateFile));
|
||||
|
||||
expect(result.code).not.toBe(0);
|
||||
expect(result.stderr).toContain('healthy daemon is already running');
|
||||
expect(result.stderr).toContain('--force-restart');
|
||||
// THE IRON RULE: the daemon process was not killed.
|
||||
expect(isProcessAlive(daemonPid)).toBe(true);
|
||||
// And the state file was not clobbered.
|
||||
expect(JSON.parse(fs.readFileSync(stateFile, 'utf-8'))).toEqual(stateContent);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test('wedged-alive daemon + plain command → busy report + nonzero exit, NO kill', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-iron-'));
|
||||
const stateFile = path.join(tmpDir, 'browse.json');
|
||||
const daemon = await startWedgedDaemon();
|
||||
try {
|
||||
pidChild = spawn('sleep', ['120'], { stdio: 'ignore' });
|
||||
const daemonPid = pidChild.pid!;
|
||||
fs.writeFileSync(stateFile, JSON.stringify({
|
||||
pid: daemonPid,
|
||||
port: daemon.port,
|
||||
token: 'iron-rule-token',
|
||||
startedAt: new Date().toISOString(),
|
||||
serverPath: '',
|
||||
mode: 'launched' as const,
|
||||
}, null, 2));
|
||||
|
||||
const result = await runCli(['status'], baseEnv(stateFile));
|
||||
|
||||
expect(result.code).not.toBe(0);
|
||||
expect(result.stderr).toContain('Daemon busy');
|
||||
expect(result.stderr).toContain('--force-restart');
|
||||
// Never killed, never restarted.
|
||||
expect(isProcessAlive(daemonPid)).toBe(true);
|
||||
expect(result.stderr).not.toContain('Restarting');
|
||||
} finally {
|
||||
await daemon.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 45_000);
|
||||
|
||||
test('wedged-alive daemon + --force-restart IS killed (explicit consent path)', async () => {
|
||||
// Unix lanes only: the consent path must boot a REAL replacement daemon
|
||||
// to answer the command, which the secretless/browserless Windows lane
|
||||
// cannot do (no Chromium install), and the teardown relies on setsid
|
||||
// process-group semantics Windows lacks. The Windows-relevant half of
|
||||
// the iron rule — busy → refusal, never an implicit kill — runs above.
|
||||
if (process.platform === 'win32') return;
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-iron-'));
|
||||
const stateFile = path.join(tmpDir, 'browse.json');
|
||||
const daemon = await startWedgedDaemon();
|
||||
try {
|
||||
pidChild = spawn('sleep', ['120'], { stdio: 'ignore' });
|
||||
const daemonPid = pidChild.pid!;
|
||||
fs.writeFileSync(stateFile, JSON.stringify({
|
||||
pid: daemonPid,
|
||||
port: daemon.port,
|
||||
token: 'iron-rule-token',
|
||||
startedAt: new Date().toISOString(),
|
||||
serverPath: '',
|
||||
mode: 'launched' as const,
|
||||
}, null, 2));
|
||||
|
||||
// `status` with --force-restart: the wedged "daemon" must be killed and
|
||||
// a REAL daemon started in its place.
|
||||
const result = await runCli(['--force-restart', 'status'], baseEnv(stateFile), 60_000);
|
||||
|
||||
// The wedged pid was killed — the explicit consent path.
|
||||
expect(isProcessAlive(daemonPid)).toBe(false);
|
||||
expect(result.stderr).toContain('--force-restart');
|
||||
// The replacement daemon answered the command.
|
||||
expect(result.code).toBe(0);
|
||||
} finally {
|
||||
// Kill the REAL daemon's whole PROCESS GROUP, not just its pid.
|
||||
// startServer spawns the daemon detached (setsid — its own group
|
||||
// leader), so a bare SIGKILL on the pid orphans its Chromium child,
|
||||
// which then squats memory for the REST of the suite (~100 files) —
|
||||
// enough pressure on a loaded box for the OS to kill a LATER test's
|
||||
// in-process Chromium, whose disconnect handler process.exit(1)s the
|
||||
// whole bun run mid-suite with no summary.
|
||||
try {
|
||||
const newState = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
if (newState?.pid && isProcessAlive(newState.pid)) {
|
||||
try {
|
||||
process.kill(-newState.pid, 'SIGKILL'); // group: daemon + Chromium
|
||||
} catch {
|
||||
process.kill(newState.pid, 'SIGKILL'); // fallback: pid only
|
||||
}
|
||||
}
|
||||
} catch { /* state file gone — nothing started */ }
|
||||
await daemon.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -70,6 +70,18 @@ describe('CDP allowlist (T2: deny-default)', () => {
|
||||
expect(isCdpMethodAllowed('Page.captureScreenshot')).toBe(true);
|
||||
});
|
||||
|
||||
it('Emulation.setEmulatedMedia is allowed, tab-scoped, trusted (#2419)', () => {
|
||||
// Media type/feature override (prefers-color-scheme, prefers-reduced-motion,
|
||||
// prefers-contrast, forced-colors) so a11y and dark-mode CSS branches are
|
||||
// testable via $B cdp. Returns an empty result — no page content, so
|
||||
// trusted output is correct.
|
||||
expect(isCdpMethodAllowed('Emulation.setEmulatedMedia')).toBe(true);
|
||||
const e = lookupCdpMethod('Emulation.setEmulatedMedia');
|
||||
expect(e).not.toBeNull();
|
||||
expect(e!.scope).toBe('tab');
|
||||
expect(e!.output).toBe('trusted');
|
||||
});
|
||||
|
||||
it('untrusted-output methods cover the read-everything-attacker-controlled cases', () => {
|
||||
// Anything that reads attacker-controlled strings (DOM/AX/CSS selectors)
|
||||
// should be tagged untrusted so the envelope wraps the result.
|
||||
|
||||
@@ -18,6 +18,12 @@ import { startTestServer } from './test-server';
|
||||
import { BrowserManager } from '../src/browser-manager';
|
||||
|
||||
const TMP_HOME = path.join(os.tmpdir(), `gstack-cdp-e2e-${process.pid}-${Date.now()}`);
|
||||
// Shard runs execute many test files in ONE bun process: a module-scope env
|
||||
// mutation without restore leaks into every LATER file in the shard. This
|
||||
// exact leak once pointed a sibling test's GSTACK_HOME at our temp dir,
|
||||
// which then got baked into artifacts that outlived it (dangling symlinks
|
||||
// into a deleted render dir). Save + restore in afterAll.
|
||||
const ORIGINAL_GSTACK_HOME = process.env.GSTACK_HOME;
|
||||
process.env.GSTACK_HOME = TMP_HOME;
|
||||
process.env.GSTACK_TELEMETRY_OFF = '1'; // don't pollute analytics during tests
|
||||
|
||||
@@ -36,6 +42,8 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (ORIGINAL_GSTACK_HOME === undefined) delete process.env.GSTACK_HOME;
|
||||
else process.env.GSTACK_HOME = ORIGINAL_GSTACK_HOME;
|
||||
try { await bm.cleanup?.(); } catch {}
|
||||
try { testServer.server.stop(); } catch {}
|
||||
await fs.rm(TMP_HOME, { recursive: true, force: true });
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* #2461 daemon crash log + F6 log hygiene needles.
|
||||
*
|
||||
* The detached daemon's stdout/stderr now land in <stateDir>/browse-daemon.log
|
||||
* (both spawn paths) instead of 'ignore'. That makes crashes diagnosable —
|
||||
* and makes it load-bearing that NOTHING secret or page-derived reaches the
|
||||
* daemon's console streams:
|
||||
*
|
||||
* - No console.* call anywhere in src/ may pass a token VALUE (AUTH_TOKEN,
|
||||
* state.token, attachToken, INTERNAL_TOKEN, setup keys). Names like
|
||||
* tokenInfo.clientId are fine — the needle targets expressions whose
|
||||
* value IS a token.
|
||||
* - The page-content carrier modules (tab-session, buffers,
|
||||
* content-security, activity) stay console-free, so raw page-derived
|
||||
* strings can't be echoed into the log unsanitized.
|
||||
*
|
||||
* Source-level, same style as windows-spawn-hide.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const SRC_DIR = path.join(import.meta.dir, '../src');
|
||||
const SRC = (f: string) => fs.readFileSync(path.join(SRC_DIR, f), 'utf-8');
|
||||
|
||||
describe('#2461 daemon log wiring', () => {
|
||||
test('both daemon spawn paths capture stdout/stderr to browse-daemon.log', () => {
|
||||
const cli = SRC('cli.ts');
|
||||
// Unix path: fd from openDaemonLogSink wired into stdio.
|
||||
expect(cli).toContain("stdio: ['ignore', daemonLogFd, daemonLogFd]");
|
||||
expect(cli).toMatch(/openDaemonLogSink/);
|
||||
// Windows path: the fd must be opened INSIDE the node -e launcher (an fd
|
||||
// opened in cli.ts wouldn't cross the spawn boundary).
|
||||
expect(cli).toContain("stdio:['ignore',logFd,logFd]");
|
||||
expect(cli).toContain('browse-daemon.log');
|
||||
// The old fully-discarded wiring must not come back on either daemon path.
|
||||
expect(cli).not.toContain("stdio:['ignore','ignore','ignore']");
|
||||
});
|
||||
|
||||
test('log sink is append-mode (accumulates across respawns)', () => {
|
||||
const cli = SRC('cli.ts');
|
||||
// Both spawn paths open through the single daemonLogPath() source (M4),
|
||||
// which itself must build from the state dir.
|
||||
expect(cli).toMatch(/openSync\(daemonLogPath\(\), 'a'\)/);
|
||||
expect(cli).toMatch(/path\.join\(config\.stateDir, 'browse-daemon\.log'\)/);
|
||||
expect(cli).toMatch(/openSync\(\$\{daemonLogPathStr\},'a'\)/);
|
||||
});
|
||||
|
||||
test('append-mode log is growth-bounded: rotated at 10MB before daemon start', () => {
|
||||
const cli = SRC('cli.ts');
|
||||
expect(cli).toMatch(/DAEMON_LOG_MAX_BYTES = 10 \* 1024 \* 1024/);
|
||||
expect(cli).toMatch(/rotateDaemonLogIfOversized\(\);/);
|
||||
// Single generation: rename to .1, matching the repo's 10MB conventions.
|
||||
expect(cli).toMatch(/renameSync\(p, `\$\{p\}\.1`\)/);
|
||||
});
|
||||
|
||||
test('rotation behavior: oversized rotates to a single .1 generation, small/missing are no-ops', () => {
|
||||
const os = require('os');
|
||||
const { rotateDaemonLogIfOversized } = require('../src/cli');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-daemon-log-'));
|
||||
try {
|
||||
const p = path.join(tmp, 'browse-daemon.log');
|
||||
// Missing log: no throw (first launch).
|
||||
rotateDaemonLogIfOversized(p, 1024);
|
||||
// Under the cap: untouched, no generation created.
|
||||
fs.writeFileSync(p, 'x'.repeat(10));
|
||||
rotateDaemonLogIfOversized(p, 1024);
|
||||
expect(fs.existsSync(p)).toBe(true);
|
||||
expect(fs.existsSync(`${p}.1`)).toBe(false);
|
||||
// Over the cap: rotated out of the way so the daemon starts fresh.
|
||||
fs.writeFileSync(p, 'y'.repeat(2048));
|
||||
rotateDaemonLogIfOversized(p, 1024);
|
||||
expect(fs.existsSync(p)).toBe(false);
|
||||
expect(fs.readFileSync(`${p}.1`, 'utf-8')).toContain('y');
|
||||
// Single generation: the next rotation REPLACES .1 (bounded at ~2x cap
|
||||
// total, never a .2).
|
||||
fs.writeFileSync(p, 'z'.repeat(2048));
|
||||
rotateDaemonLogIfOversized(p, 1024);
|
||||
expect(fs.readFileSync(`${p}.1`, 'utf-8')).toContain('z');
|
||||
expect(fs.existsSync(`${p}.2`)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('bun-polyfill routes Windows spawns through cross-spawn (ENOENT + cmd.exe injection fix)', () => {
|
||||
const polyfill = SRC('bun-polyfill.cjs');
|
||||
expect(polyfill).toContain("require('cross-spawn')");
|
||||
expect(polyfill).toMatch(/process\.platform === 'win32' \? crossSpawn\.sync : nodeSpawnSync/);
|
||||
expect(polyfill).toMatch(/process\.platform === 'win32' \? crossSpawn : nodeSpawn/);
|
||||
// The rejected-for-cause alternative must not creep back in: shell:true
|
||||
// on Windows routes through cmd.exe and does NOT neutralize & | ^ % < >.
|
||||
// (Strip comments — the header documents WHY shell:true was rejected.)
|
||||
const code = polyfill.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
expect(code).not.toMatch(/shell:\s*true/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('F6 log hygiene: nothing secret or page-derived reaches daemon console', () => {
|
||||
const files = fs.readdirSync(SRC_DIR).filter((f) => f.endsWith('.ts') || f.endsWith('.cjs'));
|
||||
|
||||
test('no console.* call passes a token value', () => {
|
||||
const offenders: string[] = [];
|
||||
for (const file of files) {
|
||||
const content = SRC(file);
|
||||
for (const [idx, line] of content.split('\n').entries()) {
|
||||
if (!/console\.(log|error|warn|info)\(/.test(line)) continue;
|
||||
// Interpolated token values: ${...token} / ${...Token} — the
|
||||
// expression ENDS in token, i.e. the value IS the token. Names like
|
||||
// ${tokenInfo.clientId} don't match.
|
||||
if (/\$\{[^}]*[tT]oken\s*\}/.test(line)) {
|
||||
offenders.push(`${file}:${idx + 1}: ${line.trim().slice(0, 120)}`);
|
||||
continue;
|
||||
}
|
||||
// Bare token args: console.log('x', token) / (..., authToken)
|
||||
if (/console\.(log|error|warn|info)\([^)]*[^a-zA-Z_.][tT]oken\s*[,)]/.test(line)) {
|
||||
offenders.push(`${file}:${idx + 1}: ${line.trim().slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('page-content carrier modules are console-free', () => {
|
||||
// Page-derived strings flow through these modules. Keeping them
|
||||
// console-free guarantees raw page content can't be echoed into
|
||||
// browse-daemon.log without passing an egress sanitizer first.
|
||||
for (const file of ['tab-session.ts', 'buffers.ts', 'content-security.ts', 'activity.ts']) {
|
||||
const content = SRC(file);
|
||||
const calls = content.match(/console\.(log|error|warn|info)\(/g) || [];
|
||||
expect({ file, count: calls.length }).toEqual({ file, count: 0 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Static tripwire for #2220: every Playwright launch site must disable
|
||||
* Playwright's process-level signal handlers (handleSIGINT / handleSIGTERM /
|
||||
* handleSIGHUP), and server.ts must own the SIGHUP cleanup those flags
|
||||
* remove.
|
||||
*
|
||||
* WHY handleSIGTERM:false is correct here: server.ts DELIBERATELY ignores
|
||||
* SIGTERM in normal headless mode (the process.on('SIGTERM') handler —
|
||||
* Claude Code's Bash sandbox fires SIGTERM when the parent shell exits
|
||||
* between tool invocations, and the daemon must survive it). Playwright's
|
||||
* default handleSIGTERM:true registers its OWN handler that closes Chromium
|
||||
* on the same signal — so the daemon survived but its browser died out from
|
||||
* under it. With the flag false, the daemon's signal policy is the only
|
||||
* signal policy: the signals server.ts honors route through activeShutdown,
|
||||
* which closes Chromium itself.
|
||||
*
|
||||
* WHY server.ts needs a SIGHUP handler (ENG-OV4): before #2220 the daemon
|
||||
* had NO process-level SIGHUP handler — Playwright's default handleSIGHUP
|
||||
* was the only thing closing Chromium on hangup. Flipping the flag without
|
||||
* adding a handler would leak a live Chromium on every hangup.
|
||||
*
|
||||
* Source-level, same style as windows-spawn-hide.test.ts: cheap,
|
||||
* deterministic, runs on every platform.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const SRC = (f: string) => fs.readFileSync(path.join(import.meta.dir, '../src', f), 'utf-8');
|
||||
|
||||
/** Every occurrence of `needle` must carry all three handleSIG* flags within
|
||||
* the next `window` chars (the launch options object). */
|
||||
function expectSignalFlagsNearEvery(src: string, needle: string, window = 1200): number {
|
||||
let idx = src.indexOf(needle);
|
||||
expect(idx).toBeGreaterThanOrEqual(0);
|
||||
let count = 0;
|
||||
while (idx !== -1) {
|
||||
const slice = src.slice(idx, idx + window);
|
||||
expect(slice).toMatch(/handleSIGINT:\s*false/);
|
||||
expect(slice).toMatch(/handleSIGTERM:\s*false/);
|
||||
expect(slice).toMatch(/handleSIGHUP:\s*false/);
|
||||
count++;
|
||||
idx = src.indexOf(needle, idx + needle.length);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
describe('Playwright launch sites disable signal handlers (#2220)', () => {
|
||||
test('all chromium.launch / launchPersistentContext sites carry the three flags', () => {
|
||||
const src = SRC('browser-manager.ts');
|
||||
const launchCount = expectSignalFlagsNearEvery(src, 'chromium.launch({');
|
||||
const persistentCount = expectSignalFlagsNearEvery(src, 'chromium.launchPersistentContext(');
|
||||
// Three launch sites today: headless launch(), headed launchHeaded(),
|
||||
// and the handoff relaunch. A NEW launch site must carry the flags too —
|
||||
// bump this only after adding them.
|
||||
expect(launchCount + persistentCount).toBe(3);
|
||||
});
|
||||
|
||||
test('server.ts owns SIGHUP cleanup now that Playwright does not (ENG-OV4)', () => {
|
||||
const src = SRC('server.ts');
|
||||
// The SIGHUP handler must route to the same shutdown path Chromium
|
||||
// cleanup uses (activeShutdown), like SIGINT does.
|
||||
expect(src).toMatch(/process\.on\('SIGHUP',\s*\(\)\s*=>\s*activeShutdown\?\.\(\)\)/);
|
||||
});
|
||||
|
||||
test('the deliberate headless SIGTERM-ignore still exists (the reason handleSIGTERM:false is safe)', () => {
|
||||
const src = SRC('server.ts');
|
||||
// If this handler ever disappears, revisit handleSIGTERM:false — the
|
||||
// flag is correct BECAUSE server.ts owns SIGTERM policy.
|
||||
expect(src).toContain("process.on('SIGTERM'");
|
||||
expect(src).toContain('Received SIGTERM (ignoring');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* #2160/#1989: playwright-core is bun-patched to pass windowsHide at its two
|
||||
* Windows-visible child_process sites — the browser launch spawn (Node
|
||||
* defaults windowsHide to FALSE for spawn, so browser children could flash a
|
||||
* console window) and the force-kill taskkill spawnSync. This is the repo's
|
||||
* first patchedDependencies entry; these static checks pin the three-legged
|
||||
* coherence (patch file ↔ package.json ↔ installed tree) so a playwright
|
||||
* bump that forgets to re-target the patch fails CI instead of silently
|
||||
* dropping it. NOTE: bumping playwright (c25-style) REQUIRES regenerating
|
||||
* this patch against the new version — see the revert pairing in the wave
|
||||
* plan (reverting the bump means dropping the patch too).
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..', '..');
|
||||
|
||||
function installedPlaywrightCoreVersion(): string {
|
||||
const pkg = JSON.parse(fs.readFileSync(
|
||||
path.join(ROOT, 'node_modules', 'playwright-core', 'package.json'), 'utf-8',
|
||||
));
|
||||
return pkg.version as string;
|
||||
}
|
||||
|
||||
describe('playwright-core windowsHide patch (#2160, #1989)', () => {
|
||||
test('package.json declares the patch for the installed version', () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'));
|
||||
const version = installedPlaywrightCoreVersion();
|
||||
const key = `playwright-core@${version}`;
|
||||
expect(pkg.patchedDependencies).toBeDefined();
|
||||
// Version-keyed on purpose: if playwright is bumped without re-targeting
|
||||
// the patch, this fails with the exact key that needs regenerating.
|
||||
expect(pkg.patchedDependencies[key]).toBe(`patches/playwright-core@${version}.patch`);
|
||||
});
|
||||
|
||||
test('the patch file exists and carries both windowsHide sites', () => {
|
||||
const version = installedPlaywrightCoreVersion();
|
||||
const patchPath = path.join(ROOT, 'patches', `playwright-core@${version}.patch`);
|
||||
expect(fs.existsSync(patchPath)).toBe(true);
|
||||
const patch = fs.readFileSync(patchPath, 'utf-8');
|
||||
// Launch spawnOptions site.
|
||||
expect(patch).toContain('+ windowsHide: true,');
|
||||
// taskkill force-kill site.
|
||||
expect(patch).toContain('shell: true, windowsHide: true');
|
||||
});
|
||||
|
||||
test('bun.lock is coherent: the lockfile records the patched dependency', () => {
|
||||
const lock = fs.readFileSync(path.join(ROOT, 'bun.lock'), 'utf-8');
|
||||
const version = installedPlaywrightCoreVersion();
|
||||
expect(lock).toContain(`patches/playwright-core@${version}.patch`);
|
||||
});
|
||||
|
||||
test('the INSTALLED tree actually has the patch applied (bun install ran it)', () => {
|
||||
const bundle = fs.readFileSync(
|
||||
path.join(ROOT, 'node_modules', 'playwright-core', 'lib', 'coreBundle.js'), 'utf-8',
|
||||
);
|
||||
expect(bundle).toContain('gstack patch (#2160/#1989)');
|
||||
expect(bundle).toContain('shell: true, windowsHide: true');
|
||||
});
|
||||
});
|
||||
@@ -54,16 +54,24 @@ describe('process liveness probe (Windows terminal-agent leak)', () => {
|
||||
expect(isProcessAlive(2147483646)).toBe(false);
|
||||
});
|
||||
|
||||
test('3. isProcessAlive spawns NO subprocess on POSIX (signal-0 path)', () => {
|
||||
test('2b. EPERM means ALIVE: an unsignalable-but-existing PID is not dead (T2)', () => {
|
||||
// signal-0 to a process we lack permission over throws EPERM — the
|
||||
// process EXISTS, we just can't signal it. Treating EPERM as "dead" is
|
||||
// the false negative that leaked agents. PID 1 (launchd/init) on POSIX
|
||||
// and PID 4 (System) on Windows always exist and are either signalable
|
||||
// or EPERM — both must read as alive.
|
||||
expect(isProcessAlive(process.platform === 'win32' ? 4 : 1)).toBe(true);
|
||||
});
|
||||
|
||||
test('3. isProcessAlive spawns NO subprocess on ANY platform (signal-0, #1952)', () => {
|
||||
// The heart of the bug: a liveness probe that forks is slow enough to
|
||||
// time out, and a timed-out probe silently answers "dead". Signal 0
|
||||
// cannot time out because it never leaves the process.
|
||||
//
|
||||
// Merged design note: on win32 the helper DOES keep a single hardened
|
||||
// tasklist probe (windowsHide, bounded timeout, quoted-CSV PID match)
|
||||
// because Bun's process.kill(pid, 0) throws ESRCH for live Windows PIDs
|
||||
// in compiled binaries. The POSIX path stays subprocess-free.
|
||||
if (process.platform === 'win32') return;
|
||||
// cannot time out because it never leaves the process. Node maps
|
||||
// process.kill(pid, 0) to an OpenProcess existence check on Windows —
|
||||
// and the Windows daemon runs under Node (server-node.mjs +
|
||||
// bun-polyfill), so the POSIX idiom is portable and the win32 tasklist
|
||||
// branch is GONE (it caused both the false negatives above and the
|
||||
// per-tick console flash of #1952).
|
||||
const origSpawn = (Bun as any).spawn;
|
||||
const origSpawnSync = (Bun as any).spawnSync;
|
||||
const spawns: string[] = [];
|
||||
@@ -79,16 +87,14 @@ describe('process liveness probe (Windows terminal-agent leak)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('4. no source file probes liveness via tasklist outside the central helper', () => {
|
||||
// Static tripwire: ad-hoc tasklist existence checks scattered across src/
|
||||
// resurrect the false-negative class (each call site re-invents the
|
||||
// timeout/parse handling and gets it subtly wrong). The ONE sanctioned
|
||||
// site is error-handling.ts's isProcessAlive win32 branch — centralized,
|
||||
// windowsHide, bounded timeout, quoted-CSV `"${pid}"` match. Every other
|
||||
// file must route through the helper.
|
||||
test('4. no source file probes liveness via tasklist — signal-0 is the only probe (#1952)', () => {
|
||||
// Static tripwire: a tasklist existence check ANYWHERE in src/
|
||||
// resurrects both the false-negative class (#2414: a timed-out spawnSync
|
||||
// still returns, with partial stdout, so a live process reads as dead)
|
||||
// and the per-tick console flash (#1952). isProcessAlive uses
|
||||
// process.kill(pid, 0) on every platform; nothing gets an exemption.
|
||||
const offenders: string[] = [];
|
||||
for (const { file, content } of readAllSourceFiles()) {
|
||||
if (file === 'error-handling.ts') continue; // the canonical helper
|
||||
const code = stripComments(content);
|
||||
// `PID eq` is the existence-probe form specifically. Other tasklist
|
||||
// uses (e.g. IMAGENAME filters for browser detection) are unaffected.
|
||||
|
||||
@@ -10,18 +10,12 @@
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
THRESHOLDS,
|
||||
combineVerdict,
|
||||
generateCanary,
|
||||
injectCanary,
|
||||
checkCanaryInStructure,
|
||||
writeSessionState,
|
||||
readSessionState,
|
||||
getStatus,
|
||||
extractDomain,
|
||||
type LayerSignal,
|
||||
} from '../src/security';
|
||||
@@ -244,57 +238,13 @@ describe('canary', () => {
|
||||
// ─── Attack log + rotation ───────────────────────────────────
|
||||
|
||||
|
||||
// ─── Session state (cross-process, atomic) ───────────────────
|
||||
|
||||
describe('session state', () => {
|
||||
test('write + read round-trip', () => {
|
||||
const state = {
|
||||
sessionId: 'test-session-123',
|
||||
canary: 'CANARY-TEST',
|
||||
warnedDomains: ['example.com'],
|
||||
classifierStatus: { testsavant: 'ok' as const },
|
||||
lastUpdated: '2026-04-19T12:34:56Z',
|
||||
};
|
||||
writeSessionState(state);
|
||||
const got = readSessionState();
|
||||
expect(got).not.toBeNull();
|
||||
expect(got!.sessionId).toBe('test-session-123');
|
||||
expect(got!.canary).toBe('CANARY-TEST');
|
||||
expect(got!.warnedDomains).toEqual(['example.com']);
|
||||
});
|
||||
|
||||
test('tolerates stale transcript field from pre-rip on-disk state', () => {
|
||||
// SessionState is a disk format. Files written before the Haiku
|
||||
// transcript layer was removed carry classifierStatus.transcript —
|
||||
// getStatus must read them fine, not require transcript for
|
||||
// 'protected', and never leak the stale key into /health.
|
||||
const stateFile = path.join(os.homedir(), '.gstack', 'security', 'session-state.json');
|
||||
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
||||
fs.writeFileSync(stateFile, JSON.stringify({
|
||||
sessionId: 'legacy-session',
|
||||
canary: 'CANARY-LEGACY',
|
||||
warnedDomains: [],
|
||||
classifierStatus: { testsavant: 'ok', transcript: 'degraded' },
|
||||
lastUpdated: '2026-04-19T12:34:56Z',
|
||||
}));
|
||||
const s = getStatus();
|
||||
expect(s.status).toBe('protected');
|
||||
expect('transcript' in s.layers).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Status reporting for shield icon ────────────────────────
|
||||
|
||||
describe('getStatus', () => {
|
||||
test('returns a valid SecurityStatus shape', () => {
|
||||
const s = getStatus();
|
||||
expect(['protected', 'degraded', 'inactive']).toContain(s.status);
|
||||
expect(s.layers).toBeDefined();
|
||||
expect(['ok', 'degraded', 'off']).toContain(s.layers.testsavant);
|
||||
expect(['ok', 'off']).toContain(s.layers.canary);
|
||||
expect(s.lastUpdated).toBeTruthy();
|
||||
});
|
||||
});
|
||||
// NOTE (#2557): the session-state + getStatus tests that lived here wrote
|
||||
// REAL fixture data into ~/.gstack/security/session-state.json — after which
|
||||
// /health reported a false-green 'protected' indefinitely. The surfaces they
|
||||
// covered (SessionState, read/writeSessionState, getStatus, the /health
|
||||
// security field, the sidepanel SEC shield) were dead since the PTY terminal
|
||||
// rewrite and are now removed. server-security-surface.test.ts pins the
|
||||
// removal + the live L4 wiring.
|
||||
|
||||
// ─── URL domain extraction ───────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* #2557 / ENG-OV9: pins the dead-shield removal AND the live L4 wiring.
|
||||
*
|
||||
* The removed surface: /health's `security` field read getStatus(), whose
|
||||
* only data source (~/.gstack/security/session-state.json) lost its only
|
||||
* writer when sidebar-agent.ts was ripped — so /health reported a permanent
|
||||
* 'inactive' or, wherever an old state file survived, a stale FALSE-GREEN
|
||||
* 'protected' ("no threats detected" when the real state was "not
|
||||
* measured"). Same fail-open class as #2026.
|
||||
*
|
||||
* The kept surface (ENG-OV9): security.ts is NOT dead — server.ts's
|
||||
* /pty-inject-scan path is the live L4 consumer (sidecar scan + URL
|
||||
* blocklist + datamark envelope), and security.ts's pure combiner/canary
|
||||
* exports stay. This test pins both directions so a future "cleanup" can't
|
||||
* silently take the live half, and a future re-feed of /health.security
|
||||
* from LIVE signals (isSidecarAvailable, content filters) must update this
|
||||
* test deliberately rather than resurrect the state-file path.
|
||||
*
|
||||
* Source-level, same style as windows-spawn-hide.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const SRC = (f: string) => fs.readFileSync(path.join(import.meta.dir, '../src', f), 'utf-8');
|
||||
|
||||
describe('#2557: dead shield surface stays dead', () => {
|
||||
test('/health carries no security field and server.ts does not import getStatus', () => {
|
||||
const server = SRC('server.ts');
|
||||
expect(server).not.toMatch(/security:\s*getSecurityStatus\(\)/);
|
||||
expect(server).not.toMatch(/getStatus as getSecurityStatus/);
|
||||
// The SECURITY session-state file must not be read anywhere in src/ —
|
||||
// that file has no writer, so any reader is a false-signal feed.
|
||||
// (session-persist.ts's per-project <stateDir>/session-state.json is a
|
||||
// different, live file — only the ~/.gstack/security/ one is dead.)
|
||||
for (const f of fs.readdirSync(path.join(import.meta.dir, '../src')).filter((x) => x.endsWith('.ts'))) {
|
||||
const code = SRC(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '').replace(/^\s*\*.*$/gm, '');
|
||||
const refs = /security[/'",\s][^\n]{0,80}session-state\.json/.test(code);
|
||||
expect({ file: f, refs }).toEqual({ file: f, refs: false });
|
||||
}
|
||||
});
|
||||
|
||||
test('security.ts no longer exports the unfed status surface', () => {
|
||||
const security = SRC('security.ts');
|
||||
expect(security).not.toMatch(/export function getStatus/);
|
||||
expect(security).not.toMatch(/export function (read|write)SessionState/);
|
||||
expect(security).not.toMatch(/export interface SessionState/);
|
||||
expect(security).not.toMatch(/export interface StatusDetail/);
|
||||
});
|
||||
|
||||
test('the sidepanel shield markup is gone', () => {
|
||||
const html = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.html'), 'utf-8');
|
||||
const css = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.css'), 'utf-8');
|
||||
expect(html).not.toContain('security-shield');
|
||||
expect(css).not.toMatch(/\.security-shield\s*\{/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ENG-OV9: the LIVE L4 path is untouched', () => {
|
||||
test('server.ts still consumes the sidecar on the inject-scan path', () => {
|
||||
const server = SRC('server.ts');
|
||||
expect(server).toContain("from './security-sidecar-client'");
|
||||
expect(server).toMatch(/isSidecarAvailable/);
|
||||
expect(server).toMatch(/scanWithSidecar\(/);
|
||||
});
|
||||
|
||||
test('security.ts keeps the pure combiner + canary exports', () => {
|
||||
const security = SRC('security.ts');
|
||||
expect(security).toMatch(/export const THRESHOLDS/);
|
||||
expect(security).toMatch(/export function combineVerdict/);
|
||||
expect(security).toMatch(/export function generateCanary/);
|
||||
expect(security).toMatch(/export function injectCanary/);
|
||||
expect(security).toMatch(/export function checkCanaryInStructure/);
|
||||
expect(security).toMatch(/export function extractDomain/);
|
||||
});
|
||||
|
||||
test('/health stays liveness-only: no token in any mode (regression wall from v1.63)', () => {
|
||||
const server = SRC('server.ts');
|
||||
// The /health handler block must not interpolate a token.
|
||||
const healthIdx = server.indexOf("url.pathname === '/health'");
|
||||
expect(healthIdx).toBeGreaterThan(0);
|
||||
const healthBlock = server.slice(healthIdx, healthIdx + 1500);
|
||||
expect(healthBlock).not.toMatch(/token:\s*[^n]/i);
|
||||
});
|
||||
});
|
||||
@@ -315,5 +315,11 @@ describe('applyStealth — persistent context (headed + handoff parity)', () =>
|
||||
await ctx.close();
|
||||
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}, 45000);
|
||||
// ^ 45s: this is the one HEADED persistent-context launch in the free
|
||||
// suite. A cold headed launch on macOS runs 8-25s — worse on the first
|
||||
// launch of a freshly downloaded Chromium (XProtect scans the new bundle,
|
||||
// the #2554 class) and under shard concurrency. bun's 5s default made this
|
||||
// the suite's most reliable false-negative: it timed out on the pre-wave
|
||||
// baseline run of main too, and passes at 15/15 with an honest budget.
|
||||
});
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* #2254: `browse stop` against a daemon that isn't running must report
|
||||
* success and exit 0 — WITHOUT starting a daemon just to stop it. The old
|
||||
* flow routed stop through ensureServer(), which booted a fresh daemon +
|
||||
* Chromium (multi-second, resource churn) and then shut it down — or
|
||||
* crash-restarted on a stale state file.
|
||||
*
|
||||
* Integration pattern follows busy-daemon-recovery.test.ts: a scratch
|
||||
* BROWSE_STATE_FILE + a real spawned CLI.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawn } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as net from 'net';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { isProcessAlive } from '../src/error-handling';
|
||||
|
||||
function runCli(args: string[], env: Record<string, string>, timeoutMs = 30_000):
|
||||
Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
const cliPath = path.resolve(import.meta.dir, '../src/cli.ts');
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('bun', ['run', cliPath, ...args], { timeout: timeoutMs, env });
|
||||
let stdout = ''; let stderr = '';
|
||||
proc.stdout.on('data', (d) => stdout += d.toString());
|
||||
proc.stderr.on('data', (d) => stderr += d.toString());
|
||||
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
function baseEnv(stateFile: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(process.env)) {
|
||||
if (v !== undefined) env[k] = v;
|
||||
}
|
||||
env.BROWSE_STATE_FILE = stateFile;
|
||||
return env;
|
||||
}
|
||||
|
||||
/** Grab a port that is definitely closed (bind, read, release). */
|
||||
async function closedPort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.once('error', reject);
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === 'string') { reject(new Error('bad address')); return; }
|
||||
const port = addr.port;
|
||||
srv.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('#2254 stop on a dead daemon', () => {
|
||||
test('no daemon state at all → exit 0, nothing spawned', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-stop-'));
|
||||
const stateFile = path.join(tmpDir, 'browse.json');
|
||||
try {
|
||||
const result = await runCli(['stop'], baseEnv(stateFile));
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('nothing to stop');
|
||||
// The load-bearing half: NO daemon was started to serve the stop —
|
||||
// a spawned daemon would have written the state file.
|
||||
expect(fs.existsSync(stateFile)).toBe(false);
|
||||
expect(result.stderr).not.toContain('Starting server');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test('stale state (dead pid + closed port) → exit 0, state cleaned, nothing spawned', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-stop-'));
|
||||
const stateFile = path.join(tmpDir, 'browse.json');
|
||||
try {
|
||||
// A pid that is certainly not alive and a port nothing listens on.
|
||||
const port = await closedPort();
|
||||
fs.writeFileSync(stateFile, JSON.stringify({
|
||||
pid: 2147483646,
|
||||
port,
|
||||
token: 'stale-token',
|
||||
startedAt: new Date().toISOString(),
|
||||
serverPath: '',
|
||||
mode: 'launched' as const,
|
||||
}, null, 2));
|
||||
|
||||
const result = await runCli(['stop'], baseEnv(stateFile));
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('nothing to stop');
|
||||
// Stale state cleaned, and no daemon spawned to replace it.
|
||||
expect(fs.existsSync(stateFile)).toBe(false);
|
||||
expect(result.stderr).not.toContain('Starting server');
|
||||
expect(result.stderr).not.toContain('Restarting');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('stop --force-restart on a LIVE daemon', () => {
|
||||
test('kills it directly — never boots a fresh daemon just to stop it', async () => {
|
||||
// A live-but-BUSY daemon: the pid is alive but nothing answers /health.
|
||||
// Pre-fix, stop --force-restart fell through to ensureServer(), whose
|
||||
// force-restart path killed the daemon and then STARTED a fresh one
|
||||
// (daemon + Chromium) so sendCommand('stop') could stop it again —
|
||||
// exactly what gstack-upgrade Step 4.8 triggers on a stale-busy daemon.
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-stop-force-'));
|
||||
const stateFile = path.join(tmpDir, 'browse.json');
|
||||
// Portable long-lived child standing in for the wedged daemon process.
|
||||
const wedged = spawn('bun', ['-e', 'await Bun.sleep(300000)'], { stdio: 'ignore' });
|
||||
try {
|
||||
const port = await closedPort();
|
||||
fs.writeFileSync(stateFile, JSON.stringify({
|
||||
pid: wedged.pid,
|
||||
port,
|
||||
token: 'busy-token',
|
||||
startedAt: new Date().toISOString(),
|
||||
serverPath: '',
|
||||
mode: 'launched' as const,
|
||||
}, null, 2));
|
||||
|
||||
const result = await runCli(['stop', '--force-restart'], baseEnv(stateFile));
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('Daemon stopped (forced');
|
||||
// The load-bearing half: NO fresh daemon was booted to serve the stop.
|
||||
// A spawned daemon would have re-written the state file.
|
||||
expect(fs.existsSync(stateFile)).toBe(false);
|
||||
expect(result.stderr).not.toContain('Starting server');
|
||||
expect(result.stdout + result.stderr).not.toContain('Restarting');
|
||||
// And the live pid is actually gone.
|
||||
const deadline = Date.now() + 3000;
|
||||
while (Date.now() < deadline && isProcessAlive(wedged.pid!)) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
expect(isProcessAlive(wedged.pid!)).toBe(false);
|
||||
} finally {
|
||||
try { wedged.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* #2314: the terminal-agent must allocate its port from the SAME fixed
|
||||
* 10000-49151 scan range the main server uses (port-allocator.ts,
|
||||
* decision 8) — never `port: 0`. Binding 0 drew from the OS ephemeral range
|
||||
* (49152-65535 on macOS), where the weeks-lived agent squatted ports that
|
||||
* short-lived `app.listen(0)` test servers expected to receive, absorbing
|
||||
* their traffic as phantom 404s across every Node test suite on the machine.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as net from 'net';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
findAvailablePort,
|
||||
RANDOM_PORT_MIN,
|
||||
RANDOM_PORT_MAX,
|
||||
} from '../src/port-allocator';
|
||||
|
||||
const AGENT_TS = path.resolve(import.meta.dir, '..', 'src', 'terminal-agent.ts');
|
||||
const SERVER_TS = path.resolve(import.meta.dir, '..', 'src', 'server.ts');
|
||||
|
||||
describe('shared port allocator (#2314)', () => {
|
||||
test('allocates inside the fixed scan range, never the ephemeral range', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const port = await findAvailablePort();
|
||||
expect(port).toBeGreaterThanOrEqual(RANDOM_PORT_MIN);
|
||||
expect(port).toBeLessThan(RANDOM_PORT_MAX);
|
||||
// The load-bearing property: the WHOLE range sits below the ephemeral
|
||||
// floor (49152). The original 60000 cap left ~22% of picks inside the
|
||||
// pool this allocator exists to avoid.
|
||||
expect(RANDOM_PORT_MAX).toBeLessThan(49152);
|
||||
expect(RANDOM_PORT_MIN).toBeGreaterThanOrEqual(1024);
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit free port is honored', async () => {
|
||||
// Find a free port by binding 0, then ask the allocator for exactly it.
|
||||
const free = await new Promise<number>((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.once('error', reject);
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const p = (srv.address() as net.AddressInfo).port;
|
||||
srv.close(() => resolve(p));
|
||||
});
|
||||
});
|
||||
expect(await findAvailablePort(free)).toBe(free);
|
||||
});
|
||||
|
||||
test('explicit occupied port throws an actionable error', async () => {
|
||||
const srv = net.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
srv.once('error', reject);
|
||||
srv.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const occupied = (srv.address() as net.AddressInfo).port;
|
||||
try {
|
||||
await expect(findAvailablePort(occupied)).rejects.toThrow(/in use/);
|
||||
} finally {
|
||||
await new Promise<void>((r) => srv.close(() => r()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal-agent uses the shared allocator (static tripwire)', () => {
|
||||
test('terminal-agent.ts never binds port: 0', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// Strip comments so the explanatory history above the bind doesn't trip.
|
||||
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
expect(code).not.toMatch(/port:\s*0\b/);
|
||||
expect(src).toContain("from './port-allocator'");
|
||||
expect(src).toContain('findAvailablePort');
|
||||
});
|
||||
|
||||
test('server.ts routes findPort through the same allocator', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
expect(src).toContain("from './port-allocator'");
|
||||
expect(src).toMatch(/findAvailablePort\(BROWSE_PORT\)/);
|
||||
});
|
||||
});
|
||||
@@ -39,8 +39,9 @@ describe('windowsHide on Windows-reachable spawns (#1835)', () => {
|
||||
});
|
||||
|
||||
test('Windows-only process probes pass windowsHide', () => {
|
||||
// tasklist in isProcessAlive — runs in polling loops.
|
||||
expectHideNearEvery(SRC('error-handling.ts'), "'tasklist'");
|
||||
// isProcessAlive no longer spawns anything (signal-0 on every platform,
|
||||
// #1952) — process-liveness-windows.test.ts pins that it stays
|
||||
// subprocess-free, which is stronger than hiding a window.
|
||||
// powershell DPAPI + tasklist in cookie import.
|
||||
const cookie = SRC('cookie-import-browser.ts');
|
||||
expectHideNearEvery(cookie, "'powershell'");
|
||||
@@ -61,4 +62,69 @@ describe('windowsHide on Windows-reachable spawns (#1835)', () => {
|
||||
// spawn's options object carries the full env wiring before the flag.
|
||||
expectHideNearEvery(SRC('terminal-agent-control.ts'), '(Bun as any).spawn(', 700);
|
||||
});
|
||||
|
||||
test('SWEEP: every direct child_process call in src/ passes windowsHide (#2160, #2415)', () => {
|
||||
// Full-census tripwire: a NEW child_process call site without windowsHide
|
||||
// fails CI. Each exemption carries a reason — an interactive console
|
||||
// child must NOT get CREATE_NO_WINDOW.
|
||||
const EXEMPT: Array<{ file: string; needle: string; reason: string }> = [
|
||||
{
|
||||
file: 'domain-skill-commands.ts',
|
||||
needle: 'spawnSync(editor',
|
||||
reason: "interactive $EDITOR with stdio:'inherit' — windowsHide would detach a console editor into an invisible console",
|
||||
},
|
||||
];
|
||||
|
||||
const srcDir = path.join(import.meta.dir, '../src');
|
||||
const offenders: string[] = [];
|
||||
for (const file of fs.readdirSync(srcDir).filter((f) => f.endsWith('.ts'))) {
|
||||
const raw = fs.readFileSync(path.join(srcDir, file), 'utf-8');
|
||||
if (!raw.includes('child_process')) continue;
|
||||
// Strip comments so documented history doesn't trip the census.
|
||||
const code = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
|
||||
// Collect the callable names this file binds to child_process:
|
||||
// import { spawn as nodeSpawn } from 'child_process'
|
||||
// const { execSync } = await import('child_process') / require(...)
|
||||
// import * as cp from 'child_process' → cp.<fn>( pattern
|
||||
const names = new Set<string>();
|
||||
const namespaces = new Set<string>();
|
||||
const importRe = /import\s*\{([^}]*)\}\s*from\s*['"](?:node:)?child_process['"]/g;
|
||||
const dynRe = /(?:const|let|var)\s*\{([^}]*)\}\s*=\s*(?:await\s+import\(|require\()['"](?:node:)?child_process['"]\)/g;
|
||||
const nsRe = /import\s*\*\s*as\s*(\w+)\s*from\s*['"](?:node:)?child_process['"]/g;
|
||||
for (const m of code.matchAll(importRe)) {
|
||||
for (const part of m[1].split(',')) {
|
||||
const alias = part.split(/\s+as\s+/).map((s) => s.trim()).filter(Boolean);
|
||||
const name = alias[alias.length - 1];
|
||||
if (name && /^(spawn|spawnSync|exec|execSync|execFile|execFileSync|nodeSpawn|cpSpawn)/.test(alias[0].trim())) names.add(name);
|
||||
}
|
||||
}
|
||||
for (const m of code.matchAll(dynRe)) {
|
||||
for (const part of m[1].split(',')) {
|
||||
const alias = part.split(':').map((s) => s.trim()).filter(Boolean);
|
||||
const name = alias[alias.length - 1];
|
||||
if (name && /^(spawn|spawnSync|exec|execSync|execFile|execFileSync)/.test(alias[0].trim())) names.add(name);
|
||||
}
|
||||
}
|
||||
for (const m of code.matchAll(nsRe)) namespaces.add(m[1]);
|
||||
|
||||
const patterns: RegExp[] = [];
|
||||
for (const n of names) patterns.push(new RegExp(`(?<![.\\w'"\`])${n}\\(`, 'g'));
|
||||
for (const ns of namespaces) {
|
||||
patterns.push(new RegExp(`(?<![\\w'"\`])${ns}\\.(?:spawn|spawnSync|exec|execSync|execFile|execFileSync)\\(`, 'g'));
|
||||
}
|
||||
|
||||
for (const re of patterns) {
|
||||
for (const m of code.matchAll(re)) {
|
||||
const slice = code.slice(m.index!, m.index! + 700);
|
||||
const exempt = EXEMPT.some((e) => e.file === file && slice.startsWith(e.needle));
|
||||
if (exempt) continue;
|
||||
if (!/windowsHide:\s*true/.test(slice)) {
|
||||
offenders.push(`${file}: ${slice.split('\n')[0].slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* XProtect launch-kill self-heal (P0 #2554) — unit tests.
|
||||
*
|
||||
* F9: the classifier is tested with POSITIVE signatures (sourced from the
|
||||
* #2554 report + Playwright's launch-error format) AND NEGATIVES (missing
|
||||
* executable, EPERM/EACCES, sandbox denial, plain crash) so a generic launch
|
||||
* failure can never trigger a pointless reinstall.
|
||||
*
|
||||
* F4: the one-shot guard is pinned — at most one heal attempt per process,
|
||||
* even when the heal fails.
|
||||
*
|
||||
* ENG-OV3/F9: the post-heal verification target is REGISTRY-derived (the
|
||||
* revision playwright-core's browsers.json expects), not disk-derived, and
|
||||
* the install-root finder rejects roots pinning a different revision.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { chromium } from 'playwright';
|
||||
import {
|
||||
isXProtectKillSignature,
|
||||
findPlaywrightRevisionDir,
|
||||
expectedChromiumRevision,
|
||||
findGstackInstallRoot,
|
||||
clearQuarantineOnPlaywrightCache,
|
||||
maybeHealXProtectKill,
|
||||
launchWithXProtectHeal,
|
||||
resetXProtectHealForTests,
|
||||
buildXProtectGuidance,
|
||||
runBoundedChromiumReinstall,
|
||||
} from '../src/xprotect-heal';
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dir, '..', '..');
|
||||
|
||||
// ─── Fixtures: POSITIVE signatures (real Playwright error shapes for an
|
||||
// OS-level SIGKILL at spawn — what xprotectd does per the #2554 report) ────
|
||||
|
||||
const SIGKILL_BROWSER_CLOSED = `browserType.launch: Browser closed.
|
||||
==================== Browser output: ====================
|
||||
<launched> pid=48213
|
||||
[pid=48213] <process did exit: exitCode=null, signal=SIGKILL>
|
||||
[pid=48213] starting temporary directories cleanup
|
||||
=========================== logs ===========================`;
|
||||
|
||||
const SIGKILL_PERSISTENT_CONTEXT = `browserType.launchPersistentContext: Target page, context or browser has been closed
|
||||
Browser logs:
|
||||
<launched> pid=9021
|
||||
[pid=9021] <process did exit: exitCode=null, signal=SIGKILL>`;
|
||||
|
||||
// The #2554 report's visible symptom: the kill surfaces as a launch timeout
|
||||
// where the process DID spawn (<launched>) but never became ready.
|
||||
const LAUNCH_TIMEOUT_AFTER_SPAWN = `browserType.launch: Timeout 180000ms exceeded.
|
||||
=========================== logs ===========================
|
||||
<launched> pid=51677
|
||||
============================================================`;
|
||||
|
||||
// ─── Fixtures: NEGATIVE signatures (F9) ──────────────────────────────────
|
||||
|
||||
const MISSING_EXECUTABLE = `browserType.launch: Executable doesn't exist at /Users/dev/Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-mac-arm64/headless_shell
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download browsers:║
|
||||
║ bunx playwright install ║
|
||||
╚═══════════════════════════════════════════════════════╝`;
|
||||
|
||||
const SPAWN_EACCES = `browserType.launch: spawn /Users/dev/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing EACCES`;
|
||||
|
||||
const EPERM_FAILURE = `browserType.launch: Browser closed.
|
||||
==================== Browser output: ====================
|
||||
Error: EPERM: operation not permitted, open '/Users/dev/Library/Caches/ms-playwright/.links/lock'`;
|
||||
|
||||
const SANDBOX_DENIAL = `browserType.launch: Browser closed.
|
||||
==================== Browser output: ====================
|
||||
<launched> pid=7211
|
||||
[pid=7211][err] Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permitted
|
||||
[pid=7211] <process did exit: exitCode=1, signal=null>`;
|
||||
|
||||
const PLAIN_CRASH_EXIT_1 = `browserType.launch: Browser closed.
|
||||
==================== Browser output: ====================
|
||||
<launched> pid=3300
|
||||
[pid=3300] <process did exit: exitCode=1, signal=null>`;
|
||||
|
||||
// ─── Classifier ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('isXProtectKillSignature — positives (darwin)', () => {
|
||||
it('classifies SIGKILL in a Browser closed error', () => {
|
||||
expect(isXProtectKillSignature(SIGKILL_BROWSER_CLOSED, 'darwin')).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies SIGKILL in a launchPersistentContext error', () => {
|
||||
expect(isXProtectKillSignature(SIGKILL_PERSISTENT_CONTEXT, 'darwin')).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies a launch timeout where the process spawned (<launched>)', () => {
|
||||
expect(isXProtectKillSignature(LAUNCH_TIMEOUT_AFTER_SPAWN, 'darwin')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isXProtectKillSignature — negatives (F9)', () => {
|
||||
it('rejects a missing executable', () => {
|
||||
expect(isXProtectKillSignature(MISSING_EXECUTABLE, 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects spawn EACCES', () => {
|
||||
expect(isXProtectKillSignature(SPAWN_EACCES, 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects EPERM failures', () => {
|
||||
expect(isXProtectKillSignature(EPERM_FAILURE, 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects Linux sandbox denials even with a <launched> marker', () => {
|
||||
expect(isXProtectKillSignature(SANDBOX_DENIAL, 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a plain crash (exitCode=1, no signal)', () => {
|
||||
expect(isXProtectKillSignature(PLAIN_CRASH_EXIT_1, 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a bare timeout with no <launched> marker (process never spawned)', () => {
|
||||
expect(isXProtectKillSignature('browserType.launch: Timeout 180000ms exceeded.', 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty messages', () => {
|
||||
expect(isXProtectKillSignature('', 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('is platform-gated: the SIGKILL signature on linux/win32 is NOT XProtect', () => {
|
||||
expect(isXProtectKillSignature(SIGKILL_BROWSER_CLOSED, 'linux')).toBe(false);
|
||||
expect(isXProtectKillSignature(SIGKILL_BROWSER_CLOSED, 'win32')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cache path helpers ──────────────────────────────────────────────────
|
||||
|
||||
describe('findPlaywrightRevisionDir', () => {
|
||||
it('finds the revision dir for the headed bundle layout', () => {
|
||||
const p = '/Users/dev/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing';
|
||||
expect(findPlaywrightRevisionDir(p)).toBe('/Users/dev/Library/Caches/ms-playwright/chromium-1234');
|
||||
});
|
||||
|
||||
it('finds the revision dir for the headless shell layout', () => {
|
||||
const p = '/Users/dev/Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-mac-arm64/headless_shell';
|
||||
expect(findPlaywrightRevisionDir(p)).toBe('/Users/dev/Library/Caches/ms-playwright/chromium_headless_shell-1234');
|
||||
});
|
||||
|
||||
it('returns null outside the Playwright cache layout', () => {
|
||||
expect(findPlaywrightRevisionDir('/Applications/GStack Browser.app/Contents/MacOS/Chromium')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expectedChromiumRevision — registry-derived expectation (F9/ENG-OV3)', () => {
|
||||
it('matches the revision playwright-core browsers.json declares for chromium', () => {
|
||||
const browsersJson = JSON.parse(fs.readFileSync(
|
||||
path.join(REPO_ROOT, 'node_modules', 'playwright-core', 'browsers.json'), 'utf-8',
|
||||
));
|
||||
const registryRevision = browsersJson.browsers.find((b: { name: string }) => b.name === 'chromium').revision;
|
||||
// chromium.executablePath() is computed from the embedded registry (not
|
||||
// read from disk) — the heal's post-install verification target is
|
||||
// therefore the revision dir playwright-core EXPECTS, which is exactly
|
||||
// what a wrong-revision heal would fail.
|
||||
expect(expectedChromiumRevision(chromium.executablePath())).toBe(registryRevision);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findGstackInstallRoot (ENG-OV3: revision-matched roots only)', () => {
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xprotect-root-'));
|
||||
const pwCore = path.join(tmpRoot, 'node_modules', 'playwright-core');
|
||||
fs.mkdirSync(pwCore, { recursive: true });
|
||||
fs.writeFileSync(path.join(pwCore, 'browsers.json'), JSON.stringify({
|
||||
browsers: [{ name: 'chromium', revision: '1234' }],
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('accepts a root whose pinned playwright-core expects the same revision', () => {
|
||||
expect(findGstackInstallRoot('1234', [tmpRoot])).toBe(tmpRoot);
|
||||
});
|
||||
|
||||
it('rejects a root pinning a DIFFERENT revision (wrong-revision heal guard)', () => {
|
||||
expect(findGstackInstallRoot('9999', [tmpRoot])).toBe(null);
|
||||
});
|
||||
|
||||
it('rejects roots without node_modules/playwright-core', () => {
|
||||
const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'xprotect-bare-'));
|
||||
try {
|
||||
expect(findGstackInstallRoot('1234', [bare])).toBe(null);
|
||||
} finally {
|
||||
fs.rmSync(bare, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves the dev checkout by default (its node_modules pins our revision)', () => {
|
||||
const browsersJson = JSON.parse(fs.readFileSync(
|
||||
path.join(REPO_ROOT, 'node_modules', 'playwright-core', 'browsers.json'), 'utf-8',
|
||||
));
|
||||
const registryRevision = browsersJson.browsers.find((b: { name: string }) => b.name === 'chromium').revision;
|
||||
const root = findGstackInstallRoot(registryRevision);
|
||||
expect(root).not.toBe(null);
|
||||
expect(fs.existsSync(path.join(root!, 'node_modules', 'playwright-core', 'browsers.json'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Quarantine-clear scope contract ─────────────────────────────────────
|
||||
|
||||
describe('clearQuarantineOnPlaywrightCache', () => {
|
||||
let tmpCache: string;
|
||||
let execPath: string;
|
||||
const savedCustomPath = process.env.GSTACK_CHROMIUM_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpCache = fs.mkdtempSync(path.join(os.tmpdir(), 'xprotect-cache-'));
|
||||
for (const dir of ['chromium-1234', 'chromium_headless_shell-1234', 'firefox-5678', 'webkit-2222']) {
|
||||
fs.mkdirSync(path.join(tmpCache, dir), { recursive: true });
|
||||
}
|
||||
execPath = path.join(tmpCache, 'chromium-1234', 'chrome-mac-arm64', 'App.app', 'Contents', 'MacOS', 'chromium');
|
||||
delete process.env.GSTACK_CHROMIUM_PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpCache, { recursive: true, force: true });
|
||||
if (savedCustomPath === undefined) delete process.env.GSTACK_CHROMIUM_PATH;
|
||||
else process.env.GSTACK_CHROMIUM_PATH = savedCustomPath;
|
||||
});
|
||||
|
||||
it('clears every chromium* revision dir, never firefox/webkit', () => {
|
||||
const cleared: string[] = [];
|
||||
const ok = clearQuarantineOnPlaywrightCache(execPath, (target) => {
|
||||
cleared.push(path.basename(target));
|
||||
return 0;
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(cleared.sort()).toEqual(['chromium-1234', 'chromium_headless_shell-1234']);
|
||||
});
|
||||
|
||||
it('NEVER touches a GSTACK_CHROMIUM_PATH bundle (embedder scope contract)', () => {
|
||||
process.env.GSTACK_CHROMIUM_PATH = execPath;
|
||||
const cleared: string[] = [];
|
||||
const ok = clearQuarantineOnPlaywrightCache(execPath, (target) => {
|
||||
cleared.push(target);
|
||||
return 0;
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(cleared).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips executables outside the Playwright cache layout', () => {
|
||||
const cleared: string[] = [];
|
||||
const ok = clearQuarantineOnPlaywrightCache('/Applications/Foo.app/Contents/MacOS/foo', (target) => {
|
||||
cleared.push(target);
|
||||
return 0;
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(cleared).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── One-shot heal orchestration (F4) ────────────────────────────────────
|
||||
|
||||
function makeDeps(counters: { installs: number; quarantines: number }, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
platform: 'darwin' as NodeJS.Platform,
|
||||
executablePath: () => '/tmp/ms-playwright/chromium-1234/chrome-mac-arm64/App.app/Contents/MacOS/chromium',
|
||||
clearQuarantine: () => { counters.quarantines++; return true; },
|
||||
installRoot: () => '/tmp/fake-gstack-root',
|
||||
runReinstall: async () => { counters.installs++; return { ok: true }; },
|
||||
verifyInstalled: () => true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('maybeHealXProtectKill', () => {
|
||||
beforeEach(() => resetXProtectHealForTests());
|
||||
|
||||
it('heals a classified failure: quarantine-clear + reinstall + verify', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
const healed = await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters));
|
||||
expect(healed).toBe(true);
|
||||
expect(counters.quarantines).toBe(1);
|
||||
expect(counters.installs).toBe(1);
|
||||
});
|
||||
|
||||
it('F4: runs AT MOST ONCE per process, even across distinct errors', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters))).toBe(true);
|
||||
expect(await maybeHealXProtectKill(new Error(LAUNCH_TIMEOUT_AFTER_SPAWN), {}, makeDeps(counters))).toBe(false);
|
||||
expect(counters.installs).toBe(1);
|
||||
});
|
||||
|
||||
it('F4: a FAILED heal also consumes the one-shot (no reinstall loops)', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
const failing = makeDeps(counters, { runReinstall: async () => { counters.installs++; return { ok: false, reason: 'timeout' }; } });
|
||||
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, failing)).toBe(false);
|
||||
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters))).toBe(false);
|
||||
expect(counters.installs).toBe(1);
|
||||
});
|
||||
|
||||
it('an unclassified error does NOT consume the one-shot', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
expect(await maybeHealXProtectKill(new Error(MISSING_EXECUTABLE), {}, makeDeps(counters))).toBe(false);
|
||||
expect(counters.installs).toBe(0);
|
||||
// Guard not consumed — a real signature afterwards still heals.
|
||||
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters))).toBe(true);
|
||||
});
|
||||
|
||||
it('never heals over a custom executable (GSTACK_CHROMIUM_PATH scope)', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
const healed = await maybeHealXProtectKill(
|
||||
new Error(SIGKILL_BROWSER_CLOSED),
|
||||
{ usesCustomExecutable: true },
|
||||
makeDeps(counters),
|
||||
);
|
||||
expect(healed).toBe(false);
|
||||
expect(counters.quarantines).toBe(0);
|
||||
expect(counters.installs).toBe(0);
|
||||
});
|
||||
|
||||
it('fails the heal when no install root pins our revision (ENG-OV3)', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
const deps = makeDeps(counters, { installRoot: () => null });
|
||||
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, deps)).toBe(false);
|
||||
expect(counters.installs).toBe(0);
|
||||
});
|
||||
|
||||
it('fails the heal when post-install verification misses the expected revision dir (F9)', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
const deps = makeDeps(counters, { verifyInstalled: () => false });
|
||||
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, deps)).toBe(false);
|
||||
expect(counters.installs).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Launch wrapper ──────────────────────────────────────────────────────
|
||||
|
||||
describe('launchWithXProtectHeal', () => {
|
||||
beforeEach(() => resetXProtectHealForTests());
|
||||
|
||||
it('retries the launch exactly once after a successful heal', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
let attempts = 0;
|
||||
const result = await launchWithXProtectHeal(async () => {
|
||||
attempts++;
|
||||
if (attempts === 1) throw new Error(SIGKILL_BROWSER_CLOSED);
|
||||
return 'browser';
|
||||
}, {}, makeDeps(counters));
|
||||
expect(result).toBe('browser');
|
||||
expect(attempts).toBe(2);
|
||||
expect(counters.installs).toBe(1);
|
||||
});
|
||||
|
||||
it('surfaces the ORIGINAL error + manual guidance when the heal fails (E1)', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
const deps = makeDeps(counters, { runReinstall: async () => ({ ok: false, reason: 'timeout' }) });
|
||||
let thrown: Error | null = null;
|
||||
try {
|
||||
await launchWithXProtectHeal(async () => { throw new Error(SIGKILL_BROWSER_CLOSED); }, {}, deps);
|
||||
} catch (err) {
|
||||
thrown = err as Error;
|
||||
}
|
||||
expect(thrown).not.toBe(null);
|
||||
// Original launch error text preserved…
|
||||
expect(thrown!.message).toContain('signal=SIGKILL');
|
||||
// …plus the manual remediation.
|
||||
expect(thrown!.message).toContain('bunx playwright install chromium');
|
||||
});
|
||||
|
||||
it('passes unclassified failures through untouched', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
let thrown: Error | null = null;
|
||||
try {
|
||||
await launchWithXProtectHeal(async () => { throw new Error(MISSING_EXECUTABLE); }, {}, makeDeps(counters));
|
||||
} catch (err) {
|
||||
thrown = err as Error;
|
||||
}
|
||||
expect(thrown!.message).toBe(MISSING_EXECUTABLE);
|
||||
expect(counters.installs).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildXProtectGuidance', () => {
|
||||
it('carries both the original message and the manual command', () => {
|
||||
const out = buildXProtectGuidance('original launch error');
|
||||
expect(out).toContain('original launch error');
|
||||
expect(out).toContain('bunx playwright install chromium');
|
||||
expect(out).toContain('#2554');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── runBoundedChromiumReinstall (T3) — previously zero coverage ──────────
|
||||
//
|
||||
// Exercised end-to-end against a STUB `bunx` on a prepended PATH: real spawn,
|
||||
// real process group, real timer — only the binary is fake. Shell stubs
|
||||
// don't exist on Windows, and the group-kill path is POSIX (`kill(-pid)`),
|
||||
// so the suite is Unix-only like the shape it tests.
|
||||
describe.skipIf(process.platform === 'win32')('runBoundedChromiumReinstall', () => {
|
||||
let stubDir: string;
|
||||
let savedPath: string | undefined;
|
||||
|
||||
function installStubBunx(script: string): void {
|
||||
const stub = path.join(stubDir, 'bunx');
|
||||
fs.writeFileSync(stub, `#!/bin/sh\n${script}\n`, { mode: 0o755 });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stubDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xprotect-stub-'));
|
||||
savedPath = process.env.PATH;
|
||||
process.env.PATH = `${stubDir}${path.delimiter}${process.env.PATH ?? ''}`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PATH = savedPath;
|
||||
fs.rmSync(stubDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('resolves ok on exit 0', async () => {
|
||||
installStubBunx('exit 0');
|
||||
const result = await runBoundedChromiumReinstall(stubDir, 10_000);
|
||||
expect(result).toEqual({ ok: true, exitCode: 0 });
|
||||
});
|
||||
|
||||
it('reports install-exit-N with the stderr tail on a nonzero exit', async () => {
|
||||
installStubBunx('echo "download failed: mirror unreachable" >&2\nexit 7');
|
||||
const result = await runBoundedChromiumReinstall(stubDir, 10_000);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.exitCode).toBe(7);
|
||||
expect(result.reason).toStartWith('install-exit-7');
|
||||
expect(result.reason).toContain('download failed: mirror unreachable');
|
||||
});
|
||||
|
||||
it('group-kills a hung install at timeoutMs and reports timeout', async () => {
|
||||
// The stub spawns its own child (like bunx → playwright CLI → download
|
||||
// workers) and sleeps well past the bound; the detached process group
|
||||
// must take BOTH down, and the result must arrive at ~timeoutMs, not
|
||||
// after the sleep.
|
||||
installStubBunx('sleep 30 &\nsleep 30');
|
||||
const started = Date.now();
|
||||
const result = await runBoundedChromiumReinstall(stubDir, 500);
|
||||
const elapsed = Date.now() - started;
|
||||
expect(result).toEqual({ ok: false, reason: 'timeout' });
|
||||
expect(elapsed).toBeLessThan(5_000); // resolved at the bound, not the sleep
|
||||
});
|
||||
|
||||
it('reports spawn-error when the binary cannot be executed', async () => {
|
||||
// No stub installed and PATH reduced to the empty stub dir only.
|
||||
process.env.PATH = stubDir;
|
||||
const result = await runBoundedChromiumReinstall(stubDir, 10_000);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toStartWith('spawn-error:');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user