mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-16 18:05:31 +02:00
* fix(browse): isolate headless lifecycle from shared profiles * test(cso): let Windows integration finish within subprocess budgets * docs: format browser isolation release summary * docs: update browser isolation documentation for v1.87.2.0 --------- Co-authored-by: garrytan <19957+garrytan@users.noreply.github.com>
219 lines
8.3 KiB
TypeScript
219 lines
8.3 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { isProcessAlive, safeKill } from '../src/error-handling';
|
|
import { readPidStartTime } from '../src/xvfb';
|
|
|
|
const CLI = path.resolve(import.meta.dir, '../src/cli.ts');
|
|
const LOCKS = ['SingletonLock', 'SingletonSocket', 'SingletonCookie'];
|
|
|
|
describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817)', () => {
|
|
let scratch: string;
|
|
let profile: string;
|
|
let stateFile: string;
|
|
let env: Record<string, string>;
|
|
let children: ReturnType<typeof Bun.spawn>[];
|
|
let daemonPid: number | undefined;
|
|
|
|
beforeEach(() => {
|
|
scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-profile-isolation-'));
|
|
profile = path.join(scratch, '.gstack', 'chromium-profile');
|
|
stateFile = path.join(scratch, 'project-b', '.gstack', 'browse.json');
|
|
fs.mkdirSync(profile, { recursive: true });
|
|
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
env = {};
|
|
for (const [key, value] of Object.entries(process.env)) {
|
|
if (value !== undefined && !/^(BROWSE_|GSTACK_|CHROMIUM_PROFILE$|CLAUDE_PLUGIN_DATA$)/.test(key)) {
|
|
env[key] = value;
|
|
}
|
|
}
|
|
Object.assign(env, {
|
|
HOME: scratch,
|
|
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(
|
|
process.env.XDG_CACHE_HOME || path.join(os.homedir(), process.platform === 'darwin' ? 'Library/Caches' : '.cache'),
|
|
'ms-playwright',
|
|
),
|
|
GSTACK_HOME: path.join(scratch, '.gstack'),
|
|
GSTACK_SECURITY_OFF: '1',
|
|
BROWSE_STATE_FILE: stateFile,
|
|
BROWSE_PORT: '0',
|
|
BROWSE_PARENT_PID: '0',
|
|
BROWSE_START_TIMEOUT: '30000',
|
|
});
|
|
children = [];
|
|
daemonPid = undefined;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (fs.existsSync(stateFile)) {
|
|
daemonPid = JSON.parse(fs.readFileSync(stateFile, 'utf-8')).pid;
|
|
}
|
|
if (daemonPid) safeKill(-daemonPid, 'SIGKILL');
|
|
for (const child of children) child.kill('SIGKILL');
|
|
await Promise.all(children.map(child => child.exited));
|
|
fs.rmSync(scratch, { recursive: true, force: true });
|
|
});
|
|
|
|
async function spawnChromiumHolder() {
|
|
const script = path.join(scratch, `chromium-holder-${children.length}.ts`);
|
|
fs.writeFileSync(script, 'console.log("ready"); await Bun.sleep(60000);');
|
|
const child = Bun.spawn([process.execPath, script], {
|
|
env,
|
|
stdin: 'ignore',
|
|
stdout: 'pipe',
|
|
stderr: 'ignore',
|
|
});
|
|
children.push(child);
|
|
const reader = child.stdout.getReader();
|
|
const ready = await reader.read();
|
|
reader.releaseLock();
|
|
expect(new TextDecoder().decode(ready.value)).toContain('ready');
|
|
return child;
|
|
}
|
|
|
|
function seedLocks(pid: number) {
|
|
fs.symlinkSync(`${os.hostname()}-${pid}`, path.join(profile, LOCKS[0]));
|
|
fs.symlinkSync('socket-target', path.join(profile, LOCKS[1]));
|
|
fs.symlinkSync('cookie-target', path.join(profile, LOCKS[2]));
|
|
}
|
|
|
|
function expectLocksIntact(pid: number) {
|
|
expect(isProcessAlive(pid)).toBe(true);
|
|
expect(fs.readlinkSync(path.join(profile, LOCKS[0]))).toBe(`${os.hostname()}-${pid}`);
|
|
expect(fs.readlinkSync(path.join(profile, LOCKS[1]))).toBe('socket-target');
|
|
expect(fs.readlinkSync(path.join(profile, LOCKS[2]))).toBe('cookie-target');
|
|
}
|
|
|
|
async function runCli(args: string[]) {
|
|
const child = Bun.spawn([process.execPath, CLI, ...args], {
|
|
cwd: path.join(scratch, 'project-b'),
|
|
env,
|
|
stdin: 'ignore',
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
});
|
|
children.push(child);
|
|
const [code, stdout, stderr] = await Promise.all([
|
|
child.exited,
|
|
new Response(child.stdout).text(),
|
|
new Response(child.stderr).text(),
|
|
]);
|
|
if (fs.existsSync(stateFile)) daemonPid = JSON.parse(fs.readFileSync(stateFile, 'utf-8')).pid;
|
|
const log = path.join(path.dirname(stateFile), 'browse-daemon.log');
|
|
expect(code, stdout + stderr + (fs.existsSync(log) ? fs.readFileSync(log, 'utf-8') : '')).toBe(0);
|
|
}
|
|
|
|
test('headless startup leaves another project\'s live headed lock holder and locks alone', async () => {
|
|
const holder = await spawnChromiumHolder();
|
|
seedLocks(holder.pid);
|
|
|
|
await runCli(['status']);
|
|
|
|
expect(daemonPid).toBeGreaterThan(0);
|
|
expect(isProcessAlive(daemonPid!)).toBe(true);
|
|
expectLocksIntact(holder.pid);
|
|
}, 60_000);
|
|
|
|
test('headless startup still reaps its own recorded orphan without touching the headed profile', async () => {
|
|
const holder = await spawnChromiumHolder();
|
|
const orphan = await spawnChromiumHolder();
|
|
seedLocks(holder.pid);
|
|
fs.writeFileSync(stateFile, JSON.stringify({
|
|
pid: 999_999_999,
|
|
port: 0,
|
|
token: 'test-token',
|
|
mode: 'launched',
|
|
chromiumPid: orphan.pid,
|
|
chromiumStartTime: readPidStartTime(orphan.pid),
|
|
}));
|
|
|
|
await runCli(['status']);
|
|
|
|
expect(isProcessAlive(orphan.pid)).toBe(false);
|
|
expectLocksIntact(holder.pid);
|
|
}, 60_000);
|
|
|
|
for (const mode of ['flag', 'environment'] as const) {
|
|
test(`headed startup via ${mode} still reaps a profile orphan and removes stale locks`, async () => {
|
|
const orphan = await spawnChromiumHolder();
|
|
seedLocks(orphan.pid);
|
|
env.BROWSE_HEADED = mode === 'environment' ? '1' : '0';
|
|
const serverScript = path.join(scratch, 'stub-server.ts');
|
|
fs.writeFileSync(serverScript, `
|
|
import * as fs from 'node:fs';
|
|
const server = Bun.serve({
|
|
hostname: '127.0.0.1', port: 0,
|
|
fetch: () => Response.json({ status: 'healthy' }),
|
|
});
|
|
fs.writeFileSync(process.env.BROWSE_STATE_FILE!, JSON.stringify({
|
|
pid: process.pid, port: server.port, token: 'test-token',
|
|
mode: process.env.BROWSE_HEADED === '1' ? 'headed' : 'launched',
|
|
}));
|
|
`);
|
|
env.BROWSE_SERVER_SCRIPT = serverScript;
|
|
|
|
await runCli(mode === 'flag' ? ['--headed', 'status'] : ['status']);
|
|
|
|
expect(JSON.parse(fs.readFileSync(stateFile, 'utf-8')).mode).toBe('headed');
|
|
expect(isProcessAlive(orphan.pid)).toBe(false);
|
|
expect(fs.readdirSync(profile)).not.toContain(LOCKS[0]);
|
|
expect(fs.readdirSync(profile)).not.toContain(LOCKS[1]);
|
|
expect(fs.readdirSync(profile)).not.toContain(LOCKS[2]);
|
|
}, 60_000);
|
|
}
|
|
|
|
for (const args of [['stop'], ['--force-restart', 'stop'], ['disconnect']]) {
|
|
test(`headless ${args.join(' ')} preserves another project's headed profile`, async () => {
|
|
await runCli(['status']);
|
|
if (args[0] === 'disconnect') {
|
|
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
state.configHash = 'proxy-only-config';
|
|
fs.writeFileSync(stateFile, JSON.stringify(state));
|
|
}
|
|
const holder = await spawnChromiumHolder();
|
|
seedLocks(holder.pid);
|
|
|
|
await runCli(args);
|
|
const deadline = Date.now() + 10000;
|
|
while (fs.existsSync(stateFile) && Date.now() < deadline) await Bun.sleep(50);
|
|
|
|
expect(fs.existsSync(stateFile)).toBe(false);
|
|
expectLocksIntact(holder.pid);
|
|
}, 60_000);
|
|
}
|
|
|
|
for (const mode of ['launched', 'headed'] as const) {
|
|
test(`${mode} factory shutdown scopes profile cleanup to the active browser mode`, async () => {
|
|
const holder = await spawnChromiumHolder();
|
|
seedLocks(holder.pid);
|
|
const child = Bun.spawn([process.execPath, '-e', `
|
|
import { buildFetchHandler } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/server.ts'))};
|
|
import { resolveConfig } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/config.ts'))};
|
|
const handle = buildFetchHandler({
|
|
authToken: 'profile-isolation-test-token',
|
|
browsePort: 0,
|
|
idleTimeoutMs: 1800000,
|
|
config: resolveConfig(),
|
|
ownsTerminalAgent: false,
|
|
browserManager: {
|
|
getConnectionMode: () => ${JSON.stringify(mode)},
|
|
isWatching: () => false,
|
|
close: async () => {},
|
|
},
|
|
});
|
|
await handle.shutdown();
|
|
`], { env, stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' });
|
|
children.push(child);
|
|
const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
|
|
|
|
expect(code, stderr).toBe(0);
|
|
if (mode === 'headed') {
|
|
for (const lock of LOCKS) expect(fs.readdirSync(profile)).not.toContain(lock);
|
|
} else {
|
|
expectLocksIntact(holder.pid);
|
|
}
|
|
}, 60_000);
|
|
}
|
|
});
|