storebound/tests/chrome-guard.test.ts

140 lines
9.2 KiB
TypeScript

// cli/console/chrome-guard.ts: a Chrome storebound starts never outlives its run (October 2026: an orphaned headless
// Chrome kept the owner's own Chrome from opening from the Dock). Pure parts (the process list, which Chromes are
// stale orphans, the watchdog's command line, the headless executable order) and, on macOS / Linux, real processes
// standing in for Chrome (sh + sleep, no browser, about a second): the watchdog ends the child when its parent dies and
// never touches a reused pid; the reaper ends a real orphan with a storebound profile on its command line.
import { describe, expect, test } from 'bun:test';
import { guardChrome, guardedPids, isOrphan, parsePs, reapStaleChromes, staleChromes, startWatchdog, userDataDirOf, watchdogArgv } from '../cli/console/chrome-guard';
import { persistentExecutable } from '../cli/shots/browser';
import * as rt from '../cli/lib/rt';
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const PS = [
' 1 0 /sbin/launchd',
' 500 1 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome', // the owner's own Chrome
` 600 1 ${CHROME} --user-data-dir=/var/folders/x/T/storebound-profile-apple-AbC --remote-debugging-port=0 --no-first-run --restore-last-session --disable-blink-features=AutomationControlled --headless=new`,
` 601 600 ${CHROME} Helper --type=renderer --user-data-dir=/var/folders/x/T/storebound-profile-apple-AbC --remote-debugging-port=0 --headless=new`,
` 700 1 ${CHROME} --user-data-dir=/Users/me/.storebound/console-profile/play --remote-debugging-port=0 --restore-last-session`, // a window
` 800 799 ${CHROME} --user-data-dir=/var/folders/x/T/storebound-chrome-Q1 --remote-debugging-port=0 --headless=new`, // its run is alive
' 799 300 bun storebound console fill apple-new-app',
` 900 1 ${CHROME} --user-data-dir=/tmp/other-tool --remote-debugging-port=0 --headless=new`, // not storebound's
` 910 1 ${CHROME} --user-data-dir=/var/folders/x/T/storebound-profile-play-Z --headless=new`, // no DevTools port: not ours
' 1000 1 /lib/systemd/systemd --user',
` 1010 1000 /opt/google/chrome/chrome --user-data-dir=/tmp/storebound-profile-play-L --remote-debugging-port=0 --headless=new`, // Linux subreaper
'garbage line',
].join('\n');
describe('which Chromes are stale', () => {
test('process list rows; the user data dir, also with spaces', () => {
const rows = parsePs(PS);
expect(rows).toHaveLength(11); // the garbage line is skipped
expect(rows[2]).toMatchObject({ pid: 600, ppid: 1 });
expect(userDataDirOf(`${CHROME} --user-data-dir=/Users/you/my apps/.storebound/console-profile/apple --remote-debugging-port=0`)).toBe('/Users/you/my apps/.storebound/console-profile/apple');
expect(userDataDirOf('chrome --headless')).toBeNull();
});
test('orphans: parent PID 1, or a subreaper (systemd --user); never a live storebound run', () => {
const rows = parsePs(PS), byPid = new Map(rows.map(r => [r.pid, r]));
expect(isOrphan(byPid.get(600)!, byPid)).toBe(true);
expect(isOrphan(byPid.get(1010)!, byPid)).toBe(true);
expect(isOrphan(byPid.get(800)!, byPid)).toBe(false);
});
test('reaped: headless orphans on storebound profiles; windows only named; the owner\'s Chrome, helpers, live runs and other tools never', () => {
const { reap, windows } = staleChromes(parsePs(PS));
expect(reap.map(r => r.pid)).toEqual([600, 1010]);
expect(windows.map(r => r.pid)).toEqual([700]);
});
test('reapStaleChromes says what it ended and which window it left', async () => {
const said: string[] = [];
const out = await reapStaleChromes({ list: () => PS.replace(/^\s+(600|1010)\s.*$/gm, ''), say: m => said.push(m) });
expect(out).toEqual({ reaped: [], windows: [700] });
expect(said.join('\n')).toContain('process 700');
expect(said.join('\n')).toContain('/Users/me/.storebound/console-profile/play');
});
});
describe('watchdog command line', () => {
test('POSIX sh with the parent, the child and its profile switch; PowerShell on Windows holding the child\'s handle', () => {
const a = watchdogArgv('darwin', 10, 20, '--user-data-dir=/p q', 0.5);
expect(a.slice(0, 2)).toEqual(['/bin/sh', '-c']);
expect(a.slice(-4)).toEqual(['10', '20', '--user-data-dir=/p q', '0.5']);
expect(a[2]).toContain('grep -qF -- "$m"'); // a reused pid (another command line) is never killed
const w = watchdogArgv('win32', 10, 20, 'x');
expect(w[0]).toBe('powershell');
expect(w.at(-1)).toContain('Get-Process -Id 20');
expect(w.at(-1)).toContain('Get-Process -Id 10');
});
test('guardChrome hooks SIGINT / SIGTERM / SIGHUP only while a Chrome runs', () => {
const before = ['SIGINT', 'SIGTERM', 'SIGHUP'].map(s => process.listenerCount(s));
const r1 = guardChrome(999_991, 'x', async () => {}, { watchdog: false });
const r2 = guardChrome(999_992, 'x', async () => {}, { watchdog: false });
expect(guardedPids()).toEqual([999_991, 999_992]);
expect(['SIGINT', 'SIGTERM', 'SIGHUP'].map(s => process.listenerCount(s))).toEqual(before.map(n => n + 1));
r1(); r1();
expect(process.listenerCount('SIGHUP')).toBe(before[2]! + 1);
r2();
expect(guardedPids()).toEqual([]);
expect(['SIGINT', 'SIGTERM', 'SIGHUP'].map(s => process.listenerCount(s))).toEqual(before);
});
});
describe('headless runs on macOS never use Google Chrome.app when another Chromium is there', () => {
const i = { storeboundChrome: null, chrome: CHROME, chromium: '/pw/Google Chrome for Testing' };
test('headless on macOS: Chromium first; a window: Google Chrome first; Linux unchanged; only Chrome: Chrome', () => {
expect(persistentExecutable('visible', i, {}, { headless: true, platform: 'darwin' }).path).toBe(i.chromium);
expect(persistentExecutable('visible', i, {}, { headless: false, platform: 'darwin' }).path).toBe(CHROME);
expect(persistentExecutable('visible', i, {}, { headless: true, platform: 'linux' }).path).toBe(CHROME);
expect(persistentExecutable('visible', { ...i, chromium: null }, {}, { headless: true, platform: 'darwin' }).path).toBe(CHROME);
expect(persistentExecutable('chrome', i, {}, { headless: true, platform: 'darwin' }).path).toBe(CHROME); // the copy needs real Chrome
});
});
// ---- real processes (sh + sleep stand in for Chrome and its run) ----
const posix = process.platform !== 'win32';
const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
async function until(what: string, ok: () => boolean, ms = 5000): Promise<void> {
const end = Date.now() + ms;
while (!ok()) { if (Date.now() > end) throw new Error(`timed out after ${ms} ms waiting for: ${what}`); await rt.sleep(50); }
}
const tag = () => `storebound-profile-guardtest-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
describe.skipIf(!posix)('real processes', () => {
test.concurrent('the watchdog ends the child once its parent dies (a SIGKILL included)', async () => {
const marker = `--user-data-dir=/tmp/${tag()}`;
const parent = rt.spawn(['sleep', '30'], { stdout: 'ignore', stderr: 'ignore' });
const child = rt.spawn(['/bin/sh', '-c', `sleep 30; : ${marker}`], { stdout: 'ignore', stderr: 'ignore' });
const dog = startWatchdog(child.pid!, marker, { parent: parent.pid!, intervalS: 0.1 })!;
try {
await rt.sleep(300);
expect(alive(child.pid!)).toBe(true); // parent alive: nothing happens
parent.kill('SIGKILL');
await until('the child ended', () => !alive(child.pid!));
expect(await dog.exited).toBe(0);
} finally { parent.kill('SIGKILL'); child.kill('SIGKILL'); dog.kill('SIGKILL'); }
}, 10_000);
test.concurrent('a pid whose command line no longer names the profile (reused) is left alone', async () => {
const parent = rt.spawn(['sleep', '30'], { stdout: 'ignore', stderr: 'ignore' });
const other = rt.spawn(['/bin/sh', '-c', 'sleep 30; : unrelated'], { stdout: 'ignore', stderr: 'ignore' });
const dog = startWatchdog(other.pid!, `--user-data-dir=/tmp/${tag()}`, { parent: parent.pid!, intervalS: 0.1 })!;
try {
expect(await dog.exited).toBe(0);
parent.kill('SIGKILL');
await rt.sleep(300);
expect(alive(other.pid!)).toBe(true);
} finally { parent.kill('SIGKILL'); other.kill('SIGKILL'); }
}, 10_000);
test.concurrent('the reaper ends a real headless orphan with a storebound profile', async () => {
const t = tag();
const fake = `sleep 20; : --user-data-dir=/tmp/${t} --remote-debugging-port=0 --headless=new`;
// the outer sh exits at once: the inner one is re-parented (PID 1 or a subreaper), like a Chrome whose run died
await rt.spawn(['/bin/sh', '-c', `/bin/sh -c '${fake}' >/dev/null 2>&1 &`], { stdout: 'ignore', stderr: 'ignore' }).exited;
const find = () => staleChromes(parsePs(rt.spawnSync(['ps', '-A', '-ww', '-o', 'pid=,ppid=,command=']).stdout.toString()), [t]).reap;
await until('the orphan shows up', () => find().length === 1);
const [orphan] = find();
const said: string[] = [];
const out = await reapStaleChromes({ markers: [t], say: m => said.push(m) });
expect(out.reaped).toEqual([orphan!.pid]);
expect(alive(orphan!.pid)).toBe(false);
expect(said[0]).toContain(`process ${orphan!.pid}`);
}, 15_000);
});