mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-27 15:11:47 +02:00
* fix(memory-ingest): --scan-secrets scans the rendered page and fails closed --scan-secrets ran gitleaks on the raw transcript .jsonl, then imported a page rendered from it. gitleaks' assignment rules don't match across a JSON-escaped quote (KEY=\"v\" on disk), so a secret the rendered page shows as KEY="v" was imported unflagged. And the gate skipped a file only on scanner "gitleaks" with findings, so a scan that errored (non-zero exit, 16MB maxBuffer overflow on a file with many findings, unparseable report) or could not run (gitleaks missing, slow-probe cooldown) imported the file unscanned. Scan the rendered page body, the exact bytes writeStaged() writes, via a new secretScanText() helper, and skip the file whenever the scan did not complete. Skipped files stay out of the state file, so the next run retries them. Reword the helper warnings and setup-gbrain/memory.md, which described the fail-open as intended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(test): reconcile Bun failure markers and footer counts * fix(sync-gbrain): verify source-scoped reads without mutation * fix(test): recognize grounded TTHW target choices structurally * fix(aside): make the readiness probe work under zsh and report why it failed The probe built its deadline into `_T` and expanded it unquoted, so `$_T aside repl …` only worked in a shell that word-splits. zsh does not: it looked for a command literally named "gtimeout 30", the probe answered ASIDE_NOT_RUNNING with Aside installed and ready, and every browsing skill fell back to the bundled Chromium in silence. zsh is the macOS default and Aside is macOS-only, so on a stock Mac the probe could never report READY. The deadline becomes a function, `_gs_d`. It receives the command as "$@", already split, so sh, bash and zsh all behave the same, and the gtimeout → timeout → perl alarm chain is unchanged. A 4th arm runs the call unbounded when none of the three is present, which is what the empty `_T` did before. Not `eval`: it re-parses the string, so the parens and `;` of the perl arm become syntax and that arm dies in bash *and* zsh — on a stock Mac, the arm that actually runs. On failure the probe now prints the CLI's reason after ASIDE_NOT_RUNNING:, the shape gstack-render already uses: the first line that starts with a capital letter, i.e. the CLI's own sentence or Node's `Error:` line below its loader frame. "Not running" covers states with different fixes — no window open for the profile, a NODE_OPTIONS preload that kills the CLI — and a bare verdict sent all of them to "open the Aside app". The BROWSER SETUP prose quotes that reason before asking the user to open the app. The text pin asserted the broken invocation verbatim, so it now pins the function and asserts neither `$_T aside repl` nor an eval form comes back. A second test executes the rendered probe in sh, bash and zsh on each of the four deadline arms with stubbed binaries on a narrowed PATH, plus two failing CLIs: one that prints its own sentence, one that crashes like Node with the useful line below the frame. The deadline function costs zero bytes against the lines it replaces; the reason costs 53 per copy of the probe (44 where the reworded BROWSER SETUP line gives 9 back). That moves four guards by the measured amount: plan-devex-review's skeleton cap to 68,550 (measured 68,544), plan-ceo-review's skeleton cap to 80,150 (measured 80,111) and union ratio to 1.081 (measured 1.0803), and plan-eng-review's union ratio to 1.151 (measured 1.1504). Fixes #2842, #2941. * Clarify engineering review startup and decision flow * Fix Windows readiness fixture PATH and command shim * fix(test): recognize grounded TTHW target choices structurally * Clarify engineering review startup and decision flow * fix(test): restrict QA-only fixture tools to its no-Edit contract * v1.90.0.0 fix(sync-gbrain): guard readiness verdicts and refresh metadata * fix(browse): validate canonical upload targets * fix(gbrain): classify structured PGLite busy response * fix(browse): preserve native extension runtime APIs * Fix displayless browser handoff ownership * Accept unique installed autoplan methodology aliases * fix(skills): preserve positional literals during installation * fix(browse): checksum installer contents through stdin * fix(test): normalize Windows checksum fixture paths * test: emulate unavailable shasum in Windows checksum fixture * fix(investigate): preserve owned freeze lifecycle * fix(review): preserve N+1 retry and Red Team completion * fix: bound Aside readiness and preserve safe fallback * test: exercise setup and Chromium on native ARM * fix: preserve install ownership and ARM browser selection * Fix gbrain ingest scan boundaries and seed observation * Refresh managed ship hooks and supervise expanded paid census * Reject resumed gbrain pages excluded by current policy * Recover zombie agent locks safely and enable CI Python venv * Repair paid actor declarations and Aside pitch assertions * Bump consolidated wave to next free minor release * Clarify CEO review admin choices and option tradeoffs * Preserve CEO mode handoff anchors in clarified workflow * Make Windows portability fixtures use shell-native paths * Restore ARM Bun alias and clarify ship review gates * Refresh ship workflow golden snapshots * Fix Windows DX documentation controls without piped stdin * Decode Codex child pipes without Bun's encoded-stream stall * Bound DX pre-review audit before product questions * Clarify trusted review-start read in paid revalidation * Bump consolidated wave to next free minor release * Clarify CEO review admin choices and option tradeoffs * Preserve CEO mode handoff anchors in clarified workflow * Make Windows portability fixtures use shell-native paths * Restore ARM Bun alias and clarify ship review gates * Refresh ship workflow golden snapshots * Fix Windows DX documentation controls without piped stdin * Decode Codex child pipes without Bun's encoded-stream stall * Bound DX pre-review audit before product questions * Clarify trusted review-start read in paid revalidation * Reconcile new main planning flow and paid judge census * fix: reconcile rebased planning and source-bound validation * test: pin cookie workflow judge to scored Sonnet model * fix: keep terminal agent boot out of module imports * fix: preserve pending-question uncertainty in engineering review * fix: stabilize Windows reliability-wave fixtures * fix: clarify design consultation research workflow * fix: preserve independent design consultation inputs * fix: resolve design taste scope and browser research guidance * fix: make consultation opt-in preflight unambiguous * test: await native Edge owner readiness or terminal result --------- Co-authored-by: Bruce Krysiak <brucek@alum.mit.edu> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Antonio Vitalic <antoninte99@gmail.com>
551 lines
26 KiB
TypeScript
551 lines
26 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import {
|
|
shouldSpawnXvfb,
|
|
isOurXvfb,
|
|
readPidStartTime,
|
|
readPidCmdline,
|
|
cleanupXvfb,
|
|
pickFreeDisplay,
|
|
isDisplayFree,
|
|
} from '../src/xvfb';
|
|
|
|
const HAS_XVFB = (() => {
|
|
if (process.platform !== 'linux') return false;
|
|
const result = Bun.spawnSync(['which', 'Xvfb'], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
|
return result.exitCode === 0;
|
|
})();
|
|
|
|
describe('shouldSpawnXvfb', () => {
|
|
test('skips when not headed', () => {
|
|
const d = shouldSpawnXvfb({}, 'linux');
|
|
expect(d.spawn).toBe(false);
|
|
expect(d.reason).toContain('not headed');
|
|
});
|
|
|
|
test('skips on macOS even when headed', () => {
|
|
const d = shouldSpawnXvfb({ BROWSE_HEADED: '1' }, 'darwin');
|
|
expect(d.spawn).toBe(false);
|
|
expect(d.reason).toContain('darwin');
|
|
});
|
|
|
|
test('skips on Windows even when headed', () => {
|
|
const d = shouldSpawnXvfb({ BROWSE_HEADED: '1' }, 'win32');
|
|
expect(d.spawn).toBe(false);
|
|
expect(d.reason).toContain('win32');
|
|
});
|
|
|
|
test('skips on Linux when DISPLAY already set', () => {
|
|
const d = shouldSpawnXvfb({ BROWSE_HEADED: '1', DISPLAY: ':0' }, 'linux');
|
|
expect(d.spawn).toBe(false);
|
|
expect(d.reason).toContain('DISPLAY=:0');
|
|
});
|
|
|
|
test('skips on Linux when WAYLAND_DISPLAY set (codex F2)', () => {
|
|
const d = shouldSpawnXvfb({ BROWSE_HEADED: '1', WAYLAND_DISPLAY: 'wayland-0' }, 'linux');
|
|
expect(d.spawn).toBe(false);
|
|
expect(d.reason).toContain('Wayland');
|
|
});
|
|
|
|
test('spawns on Linux + headed + no DISPLAY/WAYLAND_DISPLAY', () => {
|
|
const d = shouldSpawnXvfb({ BROWSE_HEADED: '1' }, 'linux');
|
|
expect(d.spawn).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('isOurXvfb (PID validation)', () => {
|
|
test('returns false when pid is 0', () => {
|
|
expect(isOurXvfb(0, 'whatever')).toBe(false);
|
|
});
|
|
|
|
test('returns false when startTime is empty', () => {
|
|
expect(isOurXvfb(process.pid, '')).toBe(false);
|
|
});
|
|
|
|
test('returns false when cmdline does not contain Xvfb', () => {
|
|
// Current bun process is not Xvfb. PID-correct, cmdline-wrong → reject.
|
|
// NOTE: this very suite's argv CONTAINS "xvfb.test.ts" — a substring
|
|
// match over the whole cmdline identified the test runner as our Xvfb
|
|
// on the first Linux CI run. Identity rests on argv[0]'s basename.
|
|
const myStart = readPidStartTime(process.pid);
|
|
expect(isOurXvfb(process.pid, myStart)).toBe(false);
|
|
});
|
|
|
|
test('a process whose ARGUMENTS mention xvfb is not ours (argv0 identity)', async () => {
|
|
// sh's $0 trick plants "xvfb" in the child's args while argv[0] stays sh.
|
|
// Killing this process because its arguments mention xvfb is the exact
|
|
// sibling-kill class the identity check exists to prevent.
|
|
const child = Bun.spawn(['/bin/sh', '-c', 'sleep 2', 'xvfb-lookalike-arg']);
|
|
try {
|
|
const start = readPidStartTime(child.pid);
|
|
// On non-Linux, /proc is absent and both reads return '' → false either way.
|
|
expect(isOurXvfb(child.pid, start || 'recorded')).toBe(false);
|
|
} finally {
|
|
child.kill();
|
|
await child.exited;
|
|
}
|
|
});
|
|
|
|
test('returns false when start-time differs (PID reuse defense)', () => {
|
|
// Even if we somehow had the right PID, a stale start-time means it's a
|
|
// different process. We never fake the cmdline test, so this assertion
|
|
// is structural: the function must not pass on stale start-time alone.
|
|
expect(isOurXvfb(process.pid, 'Mon Jan 1 00:00:00 1970')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('readPidStartTime', () => {
|
|
test('returns non-empty for current process', () => {
|
|
if (process.platform === 'win32') return; // ps not available
|
|
const t = readPidStartTime(process.pid);
|
|
expect(t.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('returns empty string for nonexistent PID', () => {
|
|
expect(readPidStartTime(99999999)).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('readPidCmdline', () => {
|
|
test('returns non-empty for current process on Linux', () => {
|
|
if (process.platform !== 'linux') return; // /proc unavailable
|
|
const c = readPidCmdline(process.pid);
|
|
expect(c.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('returns empty for nonexistent PID', () => {
|
|
expect(readPidCmdline(99999999)).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('cleanupXvfb', () => {
|
|
test('no-op when pid is 0', () => {
|
|
expect(() => cleanupXvfb({ pid: 0, startTime: '', display: ':99' })).not.toThrow();
|
|
});
|
|
|
|
test('no-op when not our Xvfb (won\'t kill unrelated process)', () => {
|
|
// Pass the current bun process's PID + a stale start-time. cleanupXvfb
|
|
// should refuse to send signals because cmdline doesn't match Xvfb.
|
|
expect(() => cleanupXvfb({
|
|
pid: process.pid,
|
|
startTime: 'Mon Jan 1 00:00:00 1970',
|
|
display: ':99',
|
|
})).not.toThrow();
|
|
// The current process is still alive after the no-op cleanup attempt.
|
|
expect(process.kill(process.pid, 0)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('pickFreeDisplay (Xvfb installed)', () => {
|
|
test.skipIf(!HAS_XVFB)('returns a number in the requested range', () => {
|
|
const n = pickFreeDisplay(99, 105);
|
|
if (n != null) {
|
|
expect(n).toBeGreaterThanOrEqual(99);
|
|
expect(n).toBeLessThanOrEqual(105);
|
|
}
|
|
// null means all displays in range are busy — also valid.
|
|
});
|
|
|
|
test.skipIf(!HAS_XVFB)('isDisplayFree returns boolean', () => {
|
|
const result = isDisplayFree(99);
|
|
expect(typeof result).toBe('boolean');
|
|
});
|
|
});
|
|
|
|
describe('xvfb spawn → cleanup round trip (Linux + Xvfb only)', () => {
|
|
test.skipIf(!HAS_XVFB)('spawn, validate ownership, cleanup', async () => {
|
|
const { spawnXvfb } = await import('../src/xvfb');
|
|
const display = pickFreeDisplay(99, 110);
|
|
if (display == null) {
|
|
// No free display in range — skip.
|
|
return;
|
|
}
|
|
const handle = await spawnXvfb(display);
|
|
try {
|
|
expect(handle.pid).toBeGreaterThan(0);
|
|
expect(handle.display).toBe(`:${display}`);
|
|
expect(handle.startTime.length).toBeGreaterThan(0);
|
|
// Validation should pass.
|
|
expect(isOurXvfb(handle.pid, handle.startTime)).toBe(true);
|
|
const lockPath = `/tmp/.X${display}-lock`;
|
|
const lock = fs.readFileSync(lockPath, 'utf8');
|
|
cleanupXvfb({ ...handle, startTime: 'stale-start-time' });
|
|
expect(isOurXvfb(handle.pid, handle.startTime)).toBe(true);
|
|
await expect(spawnXvfb(display)).rejects.toThrow('already reserved');
|
|
expect(fs.readFileSync(lockPath, 'utf8')).toBe(lock);
|
|
expect(isOurXvfb(handle.pid, handle.startTime)).toBe(true);
|
|
} finally {
|
|
handle.close();
|
|
// After cleanup, our Xvfb should be gone.
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
expect(isOurXvfb(handle.pid, handle.startTime)).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe.skipIf(process.platform !== 'linux')('display allocation failure controls', () => {
|
|
test('missing ownership tooling fails before spawning Xvfb', () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-xvfb-no-ps-'));
|
|
const marker = path.join(root, 'spawned');
|
|
fs.writeFileSync(path.join(root, 'Xvfb'), `#!/bin/sh\nprintf started > ${JSON.stringify(marker)}\nexit 0\n`, { mode: 0o755 });
|
|
try {
|
|
const display = pickFreeDisplay();
|
|
expect(display).not.toBeNull();
|
|
const child = Bun.spawnSync([process.execPath, '-e', `
|
|
import { spawnXvfb } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/xvfb.ts'))};
|
|
try { const handle = await spawnXvfb(${display}); handle.close(); process.exitCode = 1; }
|
|
catch (err) { console.log(err.message); }
|
|
`], { env: { ...process.env, PATH: root }, stdout: 'pipe', stderr: 'pipe', timeout: 10000 });
|
|
expect(child.exitCode).toBe(0);
|
|
expect(child.stdout.toString()).toContain('without process start-time ownership checks');
|
|
expect(fs.existsSync(marker)).toBe(false);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('an unreachable reserved display is not free and its lock is not removed', async () => {
|
|
const display = pickFreeDisplay(20000, 20100);
|
|
expect(display).not.toBeNull();
|
|
const lockPath = `/tmp/.X${display}-lock`;
|
|
fs.writeFileSync(lockPath, `${process.pid}\n`, { flag: 'wx' });
|
|
const inode = fs.statSync(lockPath).ino;
|
|
try {
|
|
expect(isDisplayFree(display!)).toBe(false);
|
|
expect(pickFreeDisplay(display!, display!)).toBeNull();
|
|
const { spawnXvfb } = await import('../src/xvfb');
|
|
await expect(spawnXvfb(display!)).rejects.toThrow('already reserved');
|
|
expect(fs.statSync(lockPath).ino).toBe(inode);
|
|
expect(fs.readFileSync(lockPath, 'utf8')).toBe(`${process.pid}\n`);
|
|
} finally {
|
|
if (fs.statSync(lockPath).ino === inode) fs.unlinkSync(lockPath);
|
|
}
|
|
});
|
|
|
|
test('a dangling display lock remains reserved and is not replaced', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-display-link-'));
|
|
const display = pickFreeDisplay(21000, 21100);
|
|
expect(display).not.toBeNull();
|
|
const lockPath = `/tmp/.X${display}-lock`;
|
|
const target = path.join(root, 'missing-owner');
|
|
fs.symlinkSync(target, lockPath);
|
|
try {
|
|
expect(isDisplayFree(display!)).toBe(false);
|
|
const { spawnXvfb } = await import('../src/xvfb');
|
|
await expect(spawnXvfb(display!)).rejects.toThrow('already reserved');
|
|
expect(fs.readlinkSync(lockPath)).toBe(target);
|
|
} finally {
|
|
if (fs.readlinkSync(lockPath) === target) fs.unlinkSync(lockPath);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('a failed Xvfb process reports startup failure without claiming a display', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-xvfb-failure-'));
|
|
fs.writeFileSync(path.join(root, 'Xvfb'), '#!/bin/sh\nexit 42\n', { mode: 0o755 });
|
|
try {
|
|
const display = pickFreeDisplay();
|
|
expect(display).not.toBeNull();
|
|
const child = Bun.spawnSync([process.execPath, '-e', `
|
|
import { spawnXvfb } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/xvfb.ts'))};
|
|
try { const handle = await spawnXvfb(${display}); handle.close(); process.exitCode = 1; }
|
|
catch (err) { console.log(err.message); }
|
|
`], { env: { ...process.env, PATH: `${root}:${process.env.PATH}` }, stdout: 'pipe', stderr: 'pipe', timeout: 10000 });
|
|
expect(child.exitCode).toBe(0);
|
|
expect(child.stdout.toString()).toContain('exited during startup (code 42)');
|
|
expect(isDisplayFree(display!)).toBe(true);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe.skipIf(process.platform !== 'linux')('daemon-owned display lifecycle', () => {
|
|
test('registered shutdown waits for owned display allocation before process exit', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-display-exit-'));
|
|
const marker = path.join(root, 'xvfb.pid');
|
|
const realXvfb = Bun.which('Xvfb');
|
|
expect(realXvfb).not.toBeNull();
|
|
fs.writeFileSync(path.join(root, 'Xvfb'), `#!/bin/sh\nprintf '%s' "$$" > ${JSON.stringify(marker)}\n/bin/sleep 0.8\nexec ${JSON.stringify(realXvfb)} "$@"\n`, { mode: 0o755 });
|
|
const script = path.join(root, 'shutdown.ts');
|
|
fs.writeFileSync(script, `
|
|
import { BrowserManager } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/browser-manager.ts'))};
|
|
import { buildFetchHandler, resolveConfigFromEnv } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/server.ts'))};
|
|
const manager = new BrowserManager();
|
|
await manager.launch();
|
|
manager.closeRaceMs = 50;
|
|
const handler = buildFetchHandler({ ...resolveConfigFromEnv(), browserManager: manager });
|
|
void manager.ensureHeadedDisplay().catch(() => {});
|
|
await handler.shutdown();
|
|
`);
|
|
const child = Bun.spawn([process.execPath, script], {
|
|
cwd: root,
|
|
env: {
|
|
...process.env, HOME: root, PATH: `${root}:${process.env.PATH}`,
|
|
DISPLAY: '', WAYLAND_DISPLAY: '', BROWSE_HEADED: '', BROWSE_PARENT_PID: '0',
|
|
GSTACK_HOME: path.join(root, 'home-state'), BROWSE_STATE_FILE: path.join(root, 'state', 'browse.json'),
|
|
CHROMIUM_PROFILE: path.join(root, 'profile'), GSTACK_CHROMIUM_NO_SANDBOX: '1',
|
|
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright'),
|
|
}, stdin: 'ignore', stdout: 'ignore', stderr: 'ignore',
|
|
});
|
|
let pid = 0;
|
|
let startTime = '';
|
|
try {
|
|
const deadline = Date.now() + 10000;
|
|
while (!fs.existsSync(marker) && Date.now() < deadline) await Bun.sleep(20);
|
|
expect(fs.existsSync(marker)).toBe(true);
|
|
pid = Number(fs.readFileSync(marker, 'utf8'));
|
|
startTime = readPidStartTime(pid);
|
|
expect(startTime).not.toBe('');
|
|
expect(await Promise.race([child.exited, Bun.sleep(10000).then(() => 'timeout')])).toBe(0);
|
|
await Bun.sleep(1000);
|
|
expect(isOurXvfb(pid, startTime)).toBe(false);
|
|
} finally {
|
|
if (child.exitCode === null) { child.kill('SIGKILL'); await child.exited; }
|
|
if (pid && startTime) cleanupXvfb({ pid, startTime, display: '' });
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}, 30000);
|
|
|
|
for (const mode of ['welcome', 'welcome failure', 'shutdown', 'restored page', 'headless'] as const) {
|
|
test(`${mode}: startup settles welcome before publishing daemon readiness`, async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-welcome-ready-'));
|
|
const stateFile = path.join(root, 'state', 'browse.json');
|
|
const entered = path.join(root, 'entered');
|
|
const release = path.join(root, 'release');
|
|
const fixture = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response('<h1>Requested page remains usable</h1>', { headers: { 'Content-Type': 'text/html' } }) });
|
|
const requestedUrl = `http://127.0.0.1:${fixture.port}/requested`;
|
|
const preload = path.join(root, 'preload.ts');
|
|
fs.writeFileSync(preload, `
|
|
import * as fs from 'node:fs';
|
|
import { BrowserManager } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/browser-manager.ts'))};
|
|
const getPage = BrowserManager.prototype.getPage;
|
|
const wrapped = new WeakSet();
|
|
let rejectWelcome;
|
|
BrowserManager.prototype.getPage = function(...args) {
|
|
const page = getPage.apply(this, args);
|
|
if (!wrapped.has(page)) {
|
|
wrapped.add(page);
|
|
const goto = page.goto.bind(page);
|
|
page.goto = async (url, options) => {
|
|
if (new URL(url).pathname === '/welcome') {
|
|
fs.writeFileSync(${JSON.stringify(entered)}, JSON.stringify(this.getXvfbHandle()));
|
|
if (${JSON.stringify(mode)} === 'shutdown') {
|
|
return new Promise((resolve, reject) => {
|
|
rejectWelcome = () => reject(new Error('Welcome interrupted during shutdown'));
|
|
});
|
|
}
|
|
const deadline = Date.now() + 15000;
|
|
while (!fs.existsSync(${JSON.stringify(release)})) {
|
|
if (Date.now() >= deadline) throw new Error('Welcome test barrier expired');
|
|
await Bun.sleep(10);
|
|
}
|
|
if (${JSON.stringify(mode)} === 'welcome failure') throw new Error('Welcome test navigation failure');
|
|
}
|
|
return goto(url, options);
|
|
};
|
|
}
|
|
return page;
|
|
};
|
|
if (${JSON.stringify(mode)} === 'shutdown') {
|
|
const close = BrowserManager.prototype.close;
|
|
BrowserManager.prototype.close = async function(...args) {
|
|
rejectWelcome?.();
|
|
await Bun.sleep(0);
|
|
return close.apply(this, args);
|
|
};
|
|
}
|
|
if (${JSON.stringify(mode)} === 'restored page') {
|
|
const launch = BrowserManager.prototype.launchHeaded;
|
|
BrowserManager.prototype.launchHeaded = async function(...args) {
|
|
await launch.apply(this, args);
|
|
await this.getPage().goto(${JSON.stringify(requestedUrl)});
|
|
};
|
|
}
|
|
`);
|
|
const env: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(process.env)) {
|
|
if (value !== undefined && !/^(BROWSE_|GSTACK_|CHROMIUM_PROFILE$|CLAUDE_PLUGIN_DATA$|DISPLAY$|WAYLAND_DISPLAY$)/.test(key)) env[key] = value;
|
|
}
|
|
Object.assign(env, {
|
|
HOME: root, GSTACK_HOME: path.join(root, 'home-state'),
|
|
CHROMIUM_PROFILE: path.join(root, 'profile'), BROWSE_STATE_FILE: stateFile,
|
|
BROWSE_PORT: '0', BROWSE_PARENT_PID: '0', GSTACK_STATE_WATCH_MS: '0',
|
|
BROWSE_HEADED: mode === 'headless' ? '' : '1',
|
|
GSTACK_CHROMIUM_NO_SANDBOX: '1', GSTACK_SECURITY_OFF: '1',
|
|
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright'),
|
|
});
|
|
const log = fs.openSync(path.join(root, 'daemon.log'), 'w', 0o600);
|
|
const child = Bun.spawn([process.execPath, '--preload', preload, path.resolve(import.meta.dir, '../src/server.ts')], {
|
|
cwd: root, env, stdin: 'ignore', stdout: log, stderr: log,
|
|
});
|
|
let owned: { pid: number; startTime: string; display: string } | undefined;
|
|
const waitUntil = async (check: () => boolean) => {
|
|
const deadline = Date.now() + 15000;
|
|
while (!check() && Date.now() < deadline) await Bun.sleep(20);
|
|
expect(check()).toBe(true);
|
|
};
|
|
try {
|
|
if (mode === 'welcome' || mode === 'welcome failure' || mode === 'shutdown') {
|
|
await waitUntil(() => fs.existsSync(entered));
|
|
owned = JSON.parse(fs.readFileSync(entered, 'utf8'));
|
|
expect(fs.existsSync(stateFile)).toBe(false);
|
|
if (mode === 'shutdown') {
|
|
const foreign = `${JSON.stringify({ pid: process.pid, instanceId: 'foreign-fixture-instance' })}\n`;
|
|
fs.writeFileSync(stateFile, foreign, { mode: 0o600 });
|
|
child.kill('SIGTERM');
|
|
await waitUntil(() => child.exitCode !== null);
|
|
expect(await child.exited).toBe(0);
|
|
expect(fs.readFileSync(stateFile, 'utf8') === foreign).toBe(true);
|
|
expect(owned).toBeDefined();
|
|
expect(isOurXvfb(owned!.pid, owned!.startTime)).toBe(false);
|
|
return;
|
|
}
|
|
fs.writeFileSync(release, 'release');
|
|
}
|
|
await waitUntil(() => fs.existsSync(stateFile));
|
|
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
if (state.xvfbPid) owned = { pid: state.xvfbPid, startTime: state.xvfbStartTime, display: state.xvfbDisplay };
|
|
const health = await fetch(`http://127.0.0.1:${state.port}/health`, { signal: AbortSignal.timeout(3000) });
|
|
expect((await health.json() as { status: string }).status).toBe('healthy');
|
|
const command = async (name: string, args: string[] = []) => {
|
|
const response = await fetch(`http://127.0.0.1:${state.port}/command`, {
|
|
method: 'POST', headers: { Authorization: `Bearer ${state.token}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ command: name, args }), signal: AbortSignal.timeout(15000),
|
|
});
|
|
if (!response.ok) throw new Error(`${name}: ${response.status}: ${await response.text()}`);
|
|
return response.text();
|
|
};
|
|
if (mode === 'welcome') expect(await command('url')).toContain('/welcome');
|
|
if (mode === 'welcome failure') {
|
|
expect(fs.readFileSync(path.join(root, 'daemon.log'), 'utf8')).toContain('Welcome test navigation failure');
|
|
expect(await command('url')).toContain('about:blank');
|
|
}
|
|
if (mode === 'restored page' || mode === 'headless') expect(fs.existsSync(entered)).toBe(false);
|
|
if (mode === 'restored page') expect(await command('text')).toContain('Requested page remains usable');
|
|
await command('goto', [requestedUrl]);
|
|
expect(await command('text')).toContain('Requested page remains usable');
|
|
expect(await command('url')).toContain(requestedUrl);
|
|
await command('stop');
|
|
await waitUntil(() => child.exitCode !== null);
|
|
expect(await child.exited).toBe(0);
|
|
if (owned) expect(isOurXvfb(owned.pid, owned.startTime)).toBe(false);
|
|
} finally {
|
|
fs.writeFileSync(release, 'release');
|
|
if (child.exitCode === null) {
|
|
child.kill('SIGTERM');
|
|
await Promise.race([child.exited, Bun.sleep(10000)]);
|
|
if (child.exitCode === null) child.kill('SIGKILL');
|
|
await child.exited;
|
|
}
|
|
if (owned) cleanupXvfb(owned);
|
|
fs.closeSync(log);
|
|
fixture.stop(true);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}, 60000);
|
|
}
|
|
|
|
for (const mode of ['lazy', 'existing', 'headed boot', 'exhausted'] as const) {
|
|
test(`${mode}: registered daemon commands and shutdown respect display ownership`, async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-display-daemon-'));
|
|
const stateFile = path.join(root, 'state', 'browse.json');
|
|
const fixture = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response('<title>Display fixture</title><h1>Promotion remains usable</h1>', { headers: { 'Content-Type': 'text/html' } }) });
|
|
let external: Awaited<ReturnType<typeof import('../src/xvfb').spawnXvfb>> | undefined;
|
|
let owned: { pid: number; startTime: string; display: string } | undefined;
|
|
let child: ReturnType<typeof Bun.spawn> | undefined;
|
|
const log = fs.openSync(path.join(root, 'daemon.log'), 'w', 0o600);
|
|
const waitUntil = async (check: () => boolean) => {
|
|
const deadline = Date.now() + 15000;
|
|
while (!check() && Date.now() < deadline) await Bun.sleep(50);
|
|
expect(check()).toBe(true);
|
|
};
|
|
try {
|
|
const env: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(process.env)) {
|
|
if (value !== undefined && !/^(BROWSE_|GSTACK_|CHROMIUM_PROFILE$|CLAUDE_PLUGIN_DATA$|DISPLAY$|WAYLAND_DISPLAY$)/.test(key)) env[key] = value;
|
|
}
|
|
Object.assign(env, {
|
|
HOME: root, GSTACK_HOME: path.join(root, 'home-state'),
|
|
CHROMIUM_PROFILE: path.join(root, 'profile'), BROWSE_STATE_FILE: stateFile,
|
|
BROWSE_PORT: '0', BROWSE_PARENT_PID: '0', GSTACK_STATE_WATCH_MS: '0',
|
|
GSTACK_CHROMIUM_NO_SANDBOX: '1', GSTACK_SECURITY_OFF: '1',
|
|
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright'),
|
|
});
|
|
if (mode === 'existing') {
|
|
const display = pickFreeDisplay();
|
|
expect(display).not.toBeNull();
|
|
external = await (await import('../src/xvfb')).spawnXvfb(display!);
|
|
env.DISPLAY = external.display;
|
|
}
|
|
if (mode === 'headed boot') env.BROWSE_HEADED = '1';
|
|
if (mode === 'exhausted') {
|
|
const bin = path.join(root, 'bin');
|
|
fs.mkdirSync(bin);
|
|
fs.writeFileSync(path.join(bin, 'xdpyinfo'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
env.PATH = `${bin}:${env.PATH}`;
|
|
}
|
|
child = Bun.spawn([process.execPath, path.resolve(import.meta.dir, '../src/server.ts')], {
|
|
cwd: root, env, stdin: 'ignore', stdout: log, stderr: log,
|
|
});
|
|
await waitUntil(() => fs.existsSync(stateFile));
|
|
const before = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
expect(before.mode).toBe(mode === 'headed boot' ? 'headed' : 'launched');
|
|
if (mode !== 'headed boot') expect(before.xvfbPid).toBeUndefined();
|
|
const command = async (name: string, args: string[] = []) => {
|
|
const response = await fetch(`http://127.0.0.1:${before.port}/command`, {
|
|
method: 'POST', headers: { Authorization: `Bearer ${before.token}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ command: name, args }), signal: AbortSignal.timeout(15000),
|
|
});
|
|
if (!response.ok) throw new Error(`${name}: ${response.status}: ${await response.text()}`);
|
|
return response.text();
|
|
};
|
|
await command('goto', [`http://127.0.0.1:${fixture.port}`]);
|
|
const handoff = await command('handoff', ['display fixture']);
|
|
const after = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
if (mode === 'exhausted') {
|
|
expect(handoff).toContain('no free X display');
|
|
expect(after.mode).toBe('launched');
|
|
expect(after.xvfbPid).toBeUndefined();
|
|
} else {
|
|
expect(handoff).toContain('HANDOFF:');
|
|
expect(handoff).not.toContain('ERROR:');
|
|
expect(after.mode).toBe('headed');
|
|
}
|
|
if (mode === 'existing') {
|
|
expect(after.xvfbPid).toBeUndefined();
|
|
expect(isOurXvfb(external!.pid, external!.startTime)).toBe(true);
|
|
} else if (mode !== 'exhausted') {
|
|
owned = { pid: after.xvfbPid, startTime: after.xvfbStartTime, display: after.xvfbDisplay };
|
|
expect(isOurXvfb(owned.pid, owned.startTime)).toBe(true);
|
|
if (mode === 'lazy') expect(handoff).toContain('Off-screen Xvfb');
|
|
else expect(after.xvfbPid).toBe(before.xvfbPid);
|
|
}
|
|
expect(await command('text')).toContain('Promotion remains usable');
|
|
expect(await command('resume')).toContain('RESUMED');
|
|
await command('handoff', ['idempotent']);
|
|
expect(JSON.parse(fs.readFileSync(stateFile, 'utf8')).xvfbPid).toBe(after.xvfbPid);
|
|
await command('stop');
|
|
await waitUntil(() => child!.exitCode !== null);
|
|
expect(await child.exited).toBe(0);
|
|
if (owned) expect(isOurXvfb(owned.pid, owned.startTime)).toBe(false);
|
|
if (external) expect(isOurXvfb(external.pid, external.startTime)).toBe(true);
|
|
} finally {
|
|
if (child && child.exitCode === null) {
|
|
child.kill('SIGTERM');
|
|
await Promise.race([child.exited, Bun.sleep(10000)]);
|
|
if (child.exitCode === null) child.kill('SIGKILL');
|
|
await child.exited;
|
|
}
|
|
if (owned) cleanupXvfb(owned);
|
|
external?.close();
|
|
fs.closeSync(log);
|
|
fixture.stop(true);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}, 60000);
|
|
}
|
|
});
|