mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +01:00
* test: warm PowerShell 5.1 outside setup-ps1 legacy cases; xvfb round trip uses a private display range (GSTA-57) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.166.0 test: warm PowerShell 5.1 outside the setup-ps1 legacy cases; xvfb round trip on a private display range (GSTA-57) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
603 lines
29 KiB
TypeScript
603 lines
29 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');
|
|
// A range no other allocator in the suite uses: headed daemons in parallel
|
|
// shards take displays from the default :99-:120 between this pick and the spawn.
|
|
const display = pickFreeDisplay(25000, 25100);
|
|
expect(display).not.toBeNull();
|
|
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(23000, 23100); // a range no other allocator in the suite uses
|
|
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 {
|
|
// Ubicloud run 23: a concurrent daemon took a default-range display between
|
|
// this pick and the stub's exit, so startup read as a lost race instead.
|
|
const display = pickFreeDisplay(22000, 22100);
|
|
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 });
|
|
}
|
|
});
|
|
|
|
test.skipIf(!HAS_XVFB)('a display whose short-lived holder is gone by the exit check still counts as taken', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-xvfb-gone-'));
|
|
const realXvfb = Bun.which('Xvfb')!;
|
|
const first = pickFreeDisplay(26000, 26100);
|
|
expect(first).not.toBeNull();
|
|
// Run 10 (1ffd38e): another test's Xvfb held the display just long enough
|
|
// to win the lock, then closed before the exit check looked for it.
|
|
fs.writeFileSync(path.join(root, 'Xvfb'), [
|
|
'#!/bin/sh',
|
|
`if [ "$1" = ":${first}" ]; then printf '(EE) Server is already active for display ${first}\\n' >&2; exit 1; fi`,
|
|
`exec ${JSON.stringify(realXvfb)} "$@"`, '',
|
|
].join('\n'), { mode: 0o755 });
|
|
try {
|
|
const child = Bun.spawn([process.execPath, '-e', `
|
|
import { spawnFreeXvfb } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/xvfb.ts'))};
|
|
try { const handle = await spawnFreeXvfb(${first}, ${first! + 3}); console.log(handle.display); handle.close(); }
|
|
catch (err) { console.log(err.message); }
|
|
`], { env: { ...process.env, PATH: `${root}:${process.env.PATH}` }, stdout: 'pipe', stderr: 'pipe', timeout: 20000 });
|
|
const [stdout, exitCode] = await Promise.all([new Response(child.stdout).text(), child.exited]);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout.trim()).toBe(`:${first! + 1}`);
|
|
expect(isDisplayFree(first!)).toBe(true);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}, 30000);
|
|
|
|
test.skipIf(!HAS_XVFB)('concurrent allocators that pick the same display both get one', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-xvfb-race-'));
|
|
const realXvfb = Bun.which('Xvfb')!;
|
|
fs.writeFileSync(path.join(root, 'Xvfb'), `#!/bin/sh\nsleep 0.3\nexec ${JSON.stringify(realXvfb)} "$@"\n`, { mode: 0o755 });
|
|
try {
|
|
const first = pickFreeDisplay(24000, 24100);
|
|
expect(first).not.toBeNull();
|
|
const child = Bun.spawn([process.execPath, '-e', `
|
|
import { spawnFreeXvfb } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/xvfb.ts'))};
|
|
const results = await Promise.allSettled([spawnFreeXvfb(${first}, ${first! + 5}), spawnFreeXvfb(${first}, ${first! + 5})]);
|
|
console.log(JSON.stringify(results.map(r => r.status === 'fulfilled' ? r.value.display : r.reason.message)));
|
|
for (const r of results) if (r.status === 'fulfilled') r.value.close();
|
|
`], { env: { ...process.env, PATH: `${root}:${process.env.PATH}` }, stdout: 'pipe', stderr: 'pipe', timeout: 20000 });
|
|
const [stdout, exitCode] = await Promise.all([new Response(child.stdout).text(), child.exited]);
|
|
expect(exitCode).toBe(0);
|
|
const displays: string[] = JSON.parse(stdout.trim());
|
|
expect(displays[0]).toBe(`:${first}`);
|
|
expect(displays[1]).toMatch(/^:\d+$/);
|
|
expect(displays[1]).not.toBe(displays[0]);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}, 30000);
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|