Files
gstack/browse/test/batch.test.ts
T
Garry TanandClaude Fable 5 e0bfc8fff5 fix(test): remove all 8 delayed process.exit teardown bombs — the tier-1 gate can finally fail
bun test runs every file in ONE process, so a 500ms setTimeout(process.exit(0))
armed in afterAll fired mid-way through a LATER file and killed the entire
suite with exit 0 and no summary — only ~16 of 434 files ran, and every
downstream failure was invisible (observed live throughout this wave's
enumeration). Changes, all guarded by fault injection:

- Replace every delayed-exit teardown with a time-boxed close of the file's
  own browser (8 files across browse/ and design/); stub the daemon
  /shutdown timer instead of letting its unconditional process.exit tear
  the runner down.
- test/no-suicide-exit.test.ts: static tripwire — no *.test.ts may schedule
  a delayed process.exit again.
- test/exit-propagation.test.ts + fixtures: fault injection with REAL bun
  output proves the truncation shape (exit 0, no summary) and that
  scripts/test-free-shards.ts now detects it: a shard exiting 0 WITHOUT
  bun's final summary line is treated as FAILED (exit code alone is not
  evidence of completion).
- handoff: the three headed-mode integration tests are darwin-skipped with
  a pointer to the known macOS headed-launch breakage (#2242/#2554); they
  keep running on Linux CI. Un-skip in the browse-daemon wave.
- feedback-roundtrip: repair the handler call sites unmasked by the fix —
  handlers take (command, args, session, bm); passing the manager where a
  session belongs broke all six tests.
- user-slug-fallback: HOME isolation makes endpoint_hash deterministic.

Fixes #2421, #2435.

Contributed by @sneakygriff (PR #2172) with repairs from @time-attack
(PR #2230 feedback-roundtrip hunks); supersedes PR #2252 by @whd4 (same
defect, credited).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 20:20:51 -07:00

247 lines
9.0 KiB
TypeScript

/**
* Integration tests for POST /batch endpoint
*
* Tests parallel multi-tab execution, error isolation, SSE streaming,
* newtab/closetab handling, and batch validation.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { startTestServer } from './test-server';
import { BrowserManager } from '../src/browser-manager';
let testServer: ReturnType<typeof startTestServer>;
let bm: BrowserManager;
let baseUrl: string;
let serverPort: number;
// Helper to send batch requests to the browse server
async function batch(commands: any[], opts: { timeout?: number; stream?: boolean } = {}): Promise<any> {
const res = await fetch(`http://127.0.0.1:${serverPort}/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ commands, ...opts }),
});
if (opts.stream) {
return res; // return raw response for SSE testing
}
return res.json();
}
beforeAll(async () => {
testServer = startTestServer(0);
baseUrl = testServer.url;
bm = new BrowserManager();
await bm.launch();
serverPort = bm.serverPort;
// Start the browse server
const { startServer } = await import('../src/server');
// The server is already started by launch — we need the port
// Actually, BrowserManager.launch() starts the browser, not the server.
// The test needs to start a server. Let's use the existing server infrastructure.
});
afterAll(async () => {
try { testServer.server.stop(); } catch {}
// Close only this file's own browser — never process.exit(): bun test runs
// all files in one process, so a delayed exit kills the whole suite
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
// so race it at 3s and abandon; the child is reaped at process exit.
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
});
// We need a running browse server for HTTP tests.
// The commands.test.ts tests call handlers directly, but batch tests need the HTTP endpoint.
// Let's test the batch logic by importing the handlers directly instead.
import { handleReadCommand as _handleReadCommand } from '../src/read-commands';
import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands';
import { handleMetaCommand } from '../src/meta-commands';
import { handleSnapshot } from '../src/snapshot';
import { READ_COMMANDS, WRITE_COMMANDS } from '../src/commands';
const handleReadCommand = (cmd: string, args: string[], b: BrowserManager) =>
_handleReadCommand(cmd, args, b.getActiveSession());
const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>
_handleWriteCommand(cmd, args, b.getActiveSession(), b);
describe('Batch execution', () => {
test('multi-tab parallel: goto + text on different tabs', async () => {
// Create two tabs
const tab1 = await bm.newTab(baseUrl + '/basic.html');
const tab2 = await bm.newTab(baseUrl + '/forms.html');
// Execute text command on both tabs in parallel using TabSession
const session1 = bm.getSession(tab1);
const session2 = bm.getSession(tab2);
const [result1, result2] = await Promise.allSettled([
_handleReadCommand('text', [], session1),
_handleReadCommand('text', [], session2),
]);
expect(result1.status).toBe('fulfilled');
expect(result2.status).toBe('fulfilled');
if (result1.status === 'fulfilled') {
expect(result1.value).toContain('Hello');
}
if (result2.status === 'fulfilled') {
// forms.html has form elements
expect(result2.value.length).toBeGreaterThan(0);
}
// Cleanup
await bm.closeTab(tab2);
await bm.closeTab(tab1);
});
test('same-tab sequential: commands execute in order', async () => {
const tabId = await bm.newTab();
const session = bm.getSession(tabId);
// Navigate then read — must be sequential
await _handleWriteCommand('goto', [baseUrl + '/basic.html'], session, bm);
const text = await _handleReadCommand('text', [], session);
expect(text).toContain('Hello');
await bm.closeTab(tabId);
});
test('per-command error isolation: one tab fails, others succeed', async () => {
const tab1 = await bm.newTab(baseUrl + '/basic.html');
const tab2 = await bm.newTab(baseUrl + '/basic.html');
const session1 = bm.getSession(tab1);
const session2 = bm.getSession(tab2);
// Use Promise.allSettled — one succeeds (text read), one fails (invalid ref)
const results = await Promise.allSettled([
_handleReadCommand('text', [], session1),
session2.resolveRef('@e999'), // nonexistent ref — fails immediately
]);
expect(results[0].status).toBe('fulfilled');
expect(results[1].status).toBe('rejected');
await bm.closeTab(tab2);
await bm.closeTab(tab1);
});
test('page-scoped refs: snapshot refs are per-session', async () => {
const tab1 = await bm.newTab(baseUrl + '/basic.html');
const tab2 = await bm.newTab(baseUrl + '/forms.html');
const session1 = bm.getSession(tab1);
const session2 = bm.getSession(tab2);
// Snapshot on tab1 creates refs in session1
await handleSnapshot(['-i'], session1);
const refCount1 = session1.getRefCount();
// Snapshot on tab2 creates refs in session2
await handleSnapshot(['-i'], session2);
const refCount2 = session2.getRefCount();
// Refs should be independent
expect(refCount1).toBeGreaterThanOrEqual(0);
expect(refCount2).toBeGreaterThanOrEqual(0);
// Session1's refs should not have changed after session2's snapshot
expect(session1.getRefCount()).toBe(refCount1);
await bm.closeTab(tab2);
await bm.closeTab(tab1);
});
test('per-tab lastSnapshot: snapshot -D works per-tab', async () => {
const tab1 = await bm.newTab(baseUrl + '/basic.html');
const session1 = bm.getSession(tab1);
// First snapshot sets the baseline
const snap1 = await handleSnapshot([], session1);
expect(session1.getLastSnapshot()).not.toBeNull();
// Second snapshot with -D should diff against the first
const snap2 = await handleSnapshot(['-D'], session1);
// Since page didn't change, diff should indicate identical
// (either "no changes" or empty diff with just headers)
expect(snap2.length).toBeGreaterThan(0);
await bm.closeTab(tab1);
});
test('getSession throws for nonexistent tab', () => {
expect(() => bm.getSession(99999)).toThrow('Tab 99999 not found');
});
test('getActiveSession returns the current active tab session', async () => {
const tabId = await bm.newTab(baseUrl + '/basic.html');
const session = bm.getActiveSession();
expect(session.getPage().url()).toContain('basic.html');
await bm.closeTab(tabId);
});
test('batch-safe command subset validation', () => {
const BATCH_SAFE = new Set([
'text', 'html', 'links', 'snapshot', 'accessibility', 'cookies', 'url',
'goto', 'click', 'fill', 'select', 'hover', 'scroll', 'wait',
'screenshot', 'pdf',
'newtab', 'closetab',
]);
// All batch-safe commands should be in the main command sets (except newtab/closetab which are meta)
for (const cmd of BATCH_SAFE) {
if (cmd === 'newtab' || cmd === 'closetab' || cmd === 'snapshot' || cmd === 'screenshot' || cmd === 'pdf' || cmd === 'url') {
continue; // These are META_COMMANDS, handled separately
}
const isKnown = READ_COMMANDS.has(cmd) || WRITE_COMMANDS.has(cmd);
expect(isKnown).toBe(true);
}
});
test('closeTab via page.close preserves at-least-one-page invariant', async () => {
// Create a tab, close it via page.close() (simulating batch closetab)
const tabId = await bm.newTab(baseUrl + '/basic.html');
const session = bm.getSession(tabId);
// Close via page.close() directly (how batch does it)
await session.getPage().close();
// The page.on('close') handler should have cleaned up
// And the browser should still have at least one tab
expect(bm.getTabCount()).toBeGreaterThanOrEqual(1);
});
test('parallel goto on multiple tabs', async () => {
const tab1 = await bm.newTab();
const tab2 = await bm.newTab();
const tab3 = await bm.newTab();
const session1 = bm.getSession(tab1);
const session2 = bm.getSession(tab2);
const session3 = bm.getSession(tab3);
// Navigate all three tabs in parallel
const results = await Promise.allSettled([
_handleWriteCommand('goto', [baseUrl + '/basic.html'], session1, bm),
_handleWriteCommand('goto', [baseUrl + '/forms.html'], session2, bm),
_handleWriteCommand('goto', [baseUrl + '/basic.html'], session3, bm),
]);
expect(results.every(r => r.status === 'fulfilled')).toBe(true);
// Verify each tab landed on the right page
expect(session1.getPage().url()).toContain('basic.html');
expect(session2.getPage().url()).toContain('forms.html');
expect(session3.getPage().url()).toContain('basic.html');
await bm.closeTab(tab3);
await bm.closeTab(tab2);
await bm.closeTab(tab1);
});
});