/** * 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; 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); });