Files
gstack/browse/test/handoff.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

251 lines
9.2 KiB
TypeScript

/**
* Tests for handoff/resume commands — headless-to-headed browser switching.
*
* Unit tests cover saveState/restoreState, failure tracking, and edge cases.
* Integration tests cover the full handoff flow with real Playwright browsers.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { startTestServer } from './test-server';
import { BrowserManager, type BrowserState } from '../src/browser-manager';
import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands';
import { handleMetaCommand } from '../src/meta-commands';
const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>
_handleWriteCommand(cmd, args, b.getActiveSession(), b);
let testServer: ReturnType<typeof startTestServer>;
let bm: BrowserManager;
let baseUrl: string;
beforeAll(async () => {
testServer = startTestServer(0);
baseUrl = testServer.url;
bm = new BrowserManager();
await bm.launch();
});
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 {}
});
// ─── Unit Tests: Failure Tracking (no browser needed) ────────────
describe('failure tracking', () => {
test('getFailureHint returns null when below threshold', () => {
const tracker = new BrowserManager();
tracker.incrementFailures();
tracker.incrementFailures();
expect(tracker.getFailureHint()).toBeNull();
});
test('getFailureHint returns hint after 3 consecutive failures', () => {
const tracker = new BrowserManager();
tracker.incrementFailures();
tracker.incrementFailures();
tracker.incrementFailures();
const hint = tracker.getFailureHint();
expect(hint).not.toBeNull();
expect(hint).toContain('handoff');
expect(hint).toContain('3');
});
test('hint suppressed when already headed', () => {
const tracker = new BrowserManager();
(tracker as any).isHeaded = true;
tracker.incrementFailures();
tracker.incrementFailures();
tracker.incrementFailures();
expect(tracker.getFailureHint()).toBeNull();
});
test('resetFailures clears the counter', () => {
const tracker = new BrowserManager();
tracker.incrementFailures();
tracker.incrementFailures();
tracker.incrementFailures();
expect(tracker.getFailureHint()).not.toBeNull();
tracker.resetFailures();
expect(tracker.getFailureHint()).toBeNull();
});
test('getIsHeaded returns false by default', () => {
const tracker = new BrowserManager();
expect(tracker.getIsHeaded()).toBe(false);
});
});
// ─── Unit Tests: State Save/Restore (shared browser) ─────────────
describe('saveState', () => {
test('captures cookies and page URLs', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
await handleWriteCommand('cookie', ['testcookie=testvalue'], bm);
const state = await bm.saveState();
expect(state.cookies.length).toBeGreaterThan(0);
expect(state.cookies.some(c => c.name === 'testcookie')).toBe(true);
expect(state.pages.length).toBeGreaterThanOrEqual(1);
expect(state.pages.some(p => p.url.includes('/basic.html'))).toBe(true);
}, 15000);
test('captures localStorage and sessionStorage', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
const page = bm.getPage();
await page.evaluate(() => {
localStorage.setItem('lsKey', 'lsValue');
sessionStorage.setItem('ssKey', 'ssValue');
});
const state = await bm.saveState();
const activePage = state.pages.find(p => p.isActive);
expect(activePage).toBeDefined();
expect(activePage!.storage).not.toBeNull();
expect(activePage!.storage!.localStorage).toHaveProperty('lsKey', 'lsValue');
expect(activePage!.storage!.sessionStorage).toHaveProperty('ssKey', 'ssValue');
}, 15000);
test('captures multiple tabs', async () => {
while (bm.getTabCount() > 1) {
await bm.closeTab();
}
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
await handleMetaCommand('newtab', [baseUrl + '/form.html'], bm, () => {});
const state = await bm.saveState();
expect(state.pages.length).toBe(2);
const activePage = state.pages.find(p => p.isActive);
expect(activePage).toBeDefined();
expect(activePage!.url).toContain('/form.html');
await bm.closeTab();
}, 15000);
});
describe('restoreState', () => {
test('state survives recreateContext round-trip', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
await handleWriteCommand('cookie', ['restored=yes'], bm);
const stateBefore = await bm.saveState();
expect(stateBefore.cookies.some(c => c.name === 'restored')).toBe(true);
await bm.recreateContext();
const stateAfter = await bm.saveState();
expect(stateAfter.cookies.some(c => c.name === 'restored')).toBe(true);
expect(stateAfter.pages.length).toBeGreaterThanOrEqual(1);
}, 30000);
});
// ─── Unit Tests: Handoff Edge Cases ──────────────────────────────
describe('handoff edge cases', () => {
test('handoff when already headed returns no-op', async () => {
(bm as any).isHeaded = true;
const result = await bm.handoff('test');
expect(result).toContain('Already in headed mode');
(bm as any).isHeaded = false;
}, 10000);
test('resume clears refs and resets failures', () => {
bm.incrementFailures();
bm.incrementFailures();
bm.incrementFailures();
bm.resume();
expect(bm.getFailureHint()).toBeNull();
expect(bm.getRefCount()).toBe(0);
});
test('resume without prior handoff works via meta command', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
const result = await handleMetaCommand('resume', [], bm, () => {});
expect(result).toContain('RESUMED');
}, 15000);
});
// ─── Integration Tests: Full Handoff Flow ────────────────────────
// Each handoff test creates its own BrowserManager since handoff swaps the browser.
// These tests run sequentially (one browser at a time) to avoid resource issues.
// Headed-mode launch is broken on current macOS (the rebrand invalidates the
// Chrome-for-Testing bundle signature and XProtect kills the relaunch —
// #2242, #2554, #2138). These three integration tests drive a real headed
// handoff and fail ~5s in on any darwin box. They stay ENABLED on Linux CI.
// Un-skip when the browse-daemon lifecycle wave lands the signature fix.
const HEADED_BROKEN_ON_DARWIN = process.platform === 'darwin';
describe('handoff integration', () => {
test.skipIf(HEADED_BROKEN_ON_DARWIN)('full handoff: cookies preserved, headed mode active, commands work', async () => {
const hbm = new BrowserManager();
await hbm.launch();
try {
// Set up state
await handleWriteCommand('goto', [baseUrl + '/basic.html'], hbm);
await handleWriteCommand('cookie', ['handoff_test=preserved'], hbm);
// Handoff
const result = await hbm.handoff('Testing handoff');
expect(result).toContain('HANDOFF:');
expect(result).toContain('Testing handoff');
expect(result).toContain('resume');
expect(hbm.getIsHeaded()).toBe(true);
// Verify cookies survived
const { handleReadCommand } = await import('../src/read-commands');
const cookiesResult = await handleReadCommand('cookies', [], hbm);
expect(cookiesResult).toContain('handoff_test');
// Verify commands still work
const text = await handleReadCommand('text', [], hbm);
expect(text.length).toBeGreaterThan(0);
// Resume
const resumeResult = await handleMetaCommand('resume', [], hbm, () => {});
expect(resumeResult).toContain('RESUMED');
} finally {
await hbm.close();
}
}, 45000);
test.skipIf(HEADED_BROKEN_ON_DARWIN)('multi-tab handoff preserves all tabs', async () => {
const hbm = new BrowserManager();
await hbm.launch();
try {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], hbm);
await handleMetaCommand('newtab', [baseUrl + '/form.html'], hbm, () => {});
expect(hbm.getTabCount()).toBe(2);
await hbm.handoff('multi-tab test');
expect(hbm.getTabCount()).toBe(2);
expect(hbm.getIsHeaded()).toBe(true);
} finally {
await hbm.close();
}
}, 45000);
test.skipIf(HEADED_BROKEN_ON_DARWIN)('handoff meta command joins args as message', async () => {
const hbm = new BrowserManager();
await hbm.launch();
try {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], hbm);
const result = await handleMetaCommand('handoff', ['CAPTCHA', 'stuck'], hbm, () => {});
expect(result).toContain('CAPTCHA stuck');
} finally {
await hbm.close();
}
}, 45000);
});