From 2d97ab993166ddbe1a21a973215ebc9bb0dad717 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 19 Mar 2026 00:38:58 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20browse=20handoff=20=E2=80=94=20headless?= =?UTF-8?q?-to-headed=20browser=20switching=20(v0.7.4)=20(#201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: browse handoff — headless-to-headed browser switching Add `handoff` and `resume` commands that let users take over a visible Chrome when the headless browser gets stuck (CAPTCHAs, auth walls, MFA). Architecture: launch-first-close-second for safe rollback. State transfer via extracted saveState()/restoreState() helpers (DRY with recreateContext). Auto-handoff hint after 3 consecutive command failures. * test: handoff unit + integration tests (15 tests) Covers saveState/restoreState, failure tracking, edge cases (already headed, resume without handoff), and full integration flow with cookie and tab preservation across headless-to-headed switch. * docs: handoff section in browse template + TODOS update Add User Handoff section to browse/SKILL.md.tmpl with usage examples. Update State Persistence TODO noting saveState/restoreState reusability. * chore: bump version and changelog (v0.7.4) Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- CHANGELOG.md | 13 ++ SKILL.md | 2 + TODOS.md | 4 +- VERSION | 2 +- browse/SKILL.md | 28 ++++ browse/SKILL.md.tmpl | 26 +++ browse/src/browser-manager.ts | 289 ++++++++++++++++++++++++++-------- browse/src/commands.ts | 4 + browse/src/meta-commands.ts | 13 ++ browse/src/server.ts | 7 +- browse/test/handoff.test.ts | 235 +++++++++++++++++++++++++++ 11 files changed, 552 insertions(+), 71 deletions(-) create mode 100644 browse/test/handoff.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e05d64df..20735e5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [0.8.2] - 2026-03-19 + +### Added + +- **Hand off to a real Chrome when the headless browser gets stuck.** Hit a CAPTCHA, auth wall, or MFA prompt? Run `$B handoff "reason"` and a visible Chrome opens at the exact same page with all your cookies and tabs intact. Solve the problem, tell Claude you're done, and `$B resume` picks up right where you left off with a fresh snapshot. +- **Auto-handoff hint after 3 consecutive failures.** If the browse tool fails 3 times in a row, it suggests using `handoff` — so you don't waste time watching the AI retry a CAPTCHA. +- **15 new tests for the handoff feature.** Unit tests for state save/restore, failure tracking, edge cases, plus integration tests for the full headless-to-headed flow with cookie and tab preservation. + +### Changed + +- `recreateContext()` refactored to use shared `saveState()`/`restoreState()` helpers — same behavior, less code, ready for future state persistence features. +- `browser.close()` now has a 5-second timeout to prevent hangs when closing headed browsers on macOS. + ## [0.8.1] - 2026-03-19 ### Fixed diff --git a/SKILL.md b/SKILL.md index 96b6970c..afd90d1c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -529,7 +529,9 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. ### Server | Command | Description | |---------|-------------| +| `handoff [message]` | Open visible Chrome at current page for user takeover | | `restart` | Restart server | +| `resume` | Re-snapshot after user takeover, return control to AI | | `status` | Health check | | `stop` | Shutdown server | diff --git a/TODOS.md b/TODOS.md index bb85a56d..e77b8f47 100644 --- a/TODOS.md +++ b/TODOS.md @@ -52,7 +52,9 @@ **Why:** Enables "resume where I left off" for QA sessions and repeatable auth states. -**Effort:** M +**Context:** The `saveState()`/`restoreState()` helpers from the handoff feature (browser-manager.ts) already capture cookies + localStorage + sessionStorage + URLs. Adding file I/O on top is ~20 lines. + +**Effort:** S **Priority:** P3 **Depends on:** Sessions diff --git a/VERSION b/VERSION index 6f4eebdf..100435be 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.1 +0.8.2 diff --git a/browse/SKILL.md b/browse/SKILL.md index 9e6dbfe7..2c827aa6 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -259,6 +259,32 @@ $B diff https://staging.app.com https://prod.app.com ### 11. Show screenshots to the user After `$B screenshot`, `$B snapshot -a -o`, or `$B responsive`, always use the Read tool on the output PNG(s) so the user can see them. Without this, screenshots are invisible. +## User Handoff + +When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor +login), hand off to the user: + +```bash +# 1. Open a visible Chrome at the current page +$B handoff "Stuck on CAPTCHA at login page" + +# 2. Tell the user what happened (via AskUserQuestion) +# "I've opened Chrome at the login page. Please solve the CAPTCHA +# and let me know when you're done." + +# 3. When user says "done", re-snapshot and continue +$B resume +``` + +**When to use handoff:** +- CAPTCHAs or bot detection +- Multi-factor authentication (SMS, authenticator app) +- OAuth flows that require user interaction +- Complex interactions the AI can't handle after 3 attempts + +The browser preserves all state (cookies, localStorage, tabs) across the handoff. +After `resume`, you get a fresh snapshot of wherever the user left off. + ## Snapshot Flags The snapshot is your primary tool for understanding and interacting with pages. @@ -381,6 +407,8 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. ### Server | Command | Description | |---------|-------------| +| `handoff [message]` | Open visible Chrome at current page for user takeover | | `restart` | Restart server | +| `resume` | Re-snapshot after user takeover, return control to AI | | `status` | Health check | | `stop` | Shutdown server | diff --git a/browse/SKILL.md.tmpl b/browse/SKILL.md.tmpl index 7030eac2..9c722f50 100644 --- a/browse/SKILL.md.tmpl +++ b/browse/SKILL.md.tmpl @@ -106,6 +106,32 @@ $B diff https://staging.app.com https://prod.app.com ### 11. Show screenshots to the user After `$B screenshot`, `$B snapshot -a -o`, or `$B responsive`, always use the Read tool on the output PNG(s) so the user can see them. Without this, screenshots are invisible. +## User Handoff + +When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor +login), hand off to the user: + +```bash +# 1. Open a visible Chrome at the current page +$B handoff "Stuck on CAPTCHA at login page" + +# 2. Tell the user what happened (via AskUserQuestion) +# "I've opened Chrome at the login page. Please solve the CAPTCHA +# and let me know when you're done." + +# 3. When user says "done", re-snapshot and continue +$B resume +``` + +**When to use handoff:** +- CAPTCHAs or bot detection +- Multi-factor authentication (SMS, authenticator app) +- OAuth flows that require user interaction +- Complex interactions the AI can't handle after 3 attempts + +The browser preserves all state (cookies, localStorage, tabs) across the handoff. +After `resume`, you get a fresh snapshot of wherever the user left off. + ## Snapshot Flags {{SNAPSHOT_FLAGS}} diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index e094f3a5..24cfda64 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -15,7 +15,7 @@ * restores state. Falls back to clean slate on any failure. */ -import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator } from 'playwright'; +import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright'; import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; export interface RefEntry { @@ -24,6 +24,15 @@ export interface RefEntry { name: string; } +export interface BrowserState { + cookies: Cookie[]; + pages: Array<{ + url: string; + isActive: boolean; + storage: { localStorage: Record; sessionStorage: Record } | null; + }>; +} + export class BrowserManager { private browser: Browser | null = null; private context: BrowserContext | null = null; @@ -47,6 +56,10 @@ export class BrowserManager { private dialogAutoAccept: boolean = true; private dialogPromptText: string | null = null; + // ─── Handoff State ───────────────────────────────────────── + private isHeaded: boolean = false; + private consecutiveFailures: number = 0; + async launch() { this.browser = await chromium.launch({ headless: true }); @@ -77,7 +90,11 @@ export class BrowserManager { if (this.browser) { // Remove disconnect handler to avoid exit during intentional close this.browser.removeAllListeners('disconnected'); - await this.browser.close(); + // Timeout: headed browser.close() can hang on macOS + await Promise.race([ + this.browser.close(), + new Promise(resolve => setTimeout(resolve, 5000)), + ]).catch(() => {}); this.browser = null; } } @@ -269,6 +286,92 @@ export class BrowserManager { return this.customUserAgent; } + // ─── State Save/Restore (shared by recreateContext + handoff) ─ + /** + * Capture browser state: cookies, localStorage, sessionStorage, URLs, active tab. + * Skips pages that fail storage reads (e.g., already closed). + */ + async saveState(): Promise { + if (!this.context) throw new Error('Browser not launched'); + + const cookies = await this.context.cookies(); + const pages: BrowserState['pages'] = []; + + for (const [id, page] of this.pages) { + const url = page.url(); + let storage = null; + try { + storage = await page.evaluate(() => ({ + localStorage: { ...localStorage }, + sessionStorage: { ...sessionStorage }, + })); + } catch {} + pages.push({ + url: url === 'about:blank' ? '' : url, + isActive: id === this.activeTabId, + storage, + }); + } + + return { cookies, pages }; + } + + /** + * Restore browser state into the current context: cookies, pages, storage. + * Navigates to saved URLs, restores storage, wires page events. + * Failures on individual pages are swallowed — partial restore is better than none. + */ + async restoreState(state: BrowserState): Promise { + if (!this.context) throw new Error('Browser not launched'); + + // Restore cookies + if (state.cookies.length > 0) { + await this.context.addCookies(state.cookies); + } + + // Re-create pages + let activeId: number | null = null; + for (const saved of state.pages) { + const page = await this.context.newPage(); + const id = this.nextTabId++; + this.pages.set(id, page); + this.wirePageEvents(page); + + if (saved.url) { + await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {}); + } + + if (saved.storage) { + try { + await page.evaluate((s: { localStorage: Record; sessionStorage: Record }) => { + if (s.localStorage) { + for (const [k, v] of Object.entries(s.localStorage)) { + localStorage.setItem(k, v); + } + } + if (s.sessionStorage) { + for (const [k, v] of Object.entries(s.sessionStorage)) { + sessionStorage.setItem(k, v); + } + } + }, saved.storage); + } catch {} + } + + if (saved.isActive) activeId = id; + } + + // If no pages were saved, create a blank one + if (this.pages.size === 0) { + await this.newTab(); + } else { + this.activeTabId = activeId ?? [...this.pages.keys()][0]; + } + + // Clear refs — pages are new, locators are stale + this.clearRefs(); + } + /** * Recreate the browser context to apply user agent changes. * Saves and restores cookies, localStorage, sessionStorage, and open pages. @@ -280,25 +383,8 @@ export class BrowserManager { } try { - // 1. Save state from current context - const savedCookies = await this.context.cookies(); - const savedPages: Array<{ url: string; isActive: boolean; storage: { localStorage: Record; sessionStorage: Record } | null }> = []; - - for (const [id, page] of this.pages) { - const url = page.url(); - let storage = null; - try { - storage = await page.evaluate(() => ({ - localStorage: { ...localStorage }, - sessionStorage: { ...sessionStorage }, - })); - } catch {} - savedPages.push({ - url: url === 'about:blank' ? '' : url, - isActive: id === this.activeTabId, - storage, - }); - } + // 1. Save state + const state = await this.saveState(); // 2. Close old pages and context for (const page of this.pages.values()) { @@ -320,53 +406,8 @@ export class BrowserManager { await this.context.setExtraHTTPHeaders(this.extraHeaders); } - // 4. Restore cookies - if (savedCookies.length > 0) { - await this.context.addCookies(savedCookies); - } - - // 5. Re-create pages - let activeId: number | null = null; - for (const saved of savedPages) { - const page = await this.context.newPage(); - const id = this.nextTabId++; - this.pages.set(id, page); - this.wirePageEvents(page); - - if (saved.url) { - await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {}); - } - - // 6. Restore storage - if (saved.storage) { - try { - await page.evaluate((s: { localStorage: Record; sessionStorage: Record }) => { - if (s.localStorage) { - for (const [k, v] of Object.entries(s.localStorage)) { - localStorage.setItem(k, v); - } - } - if (s.sessionStorage) { - for (const [k, v] of Object.entries(s.sessionStorage)) { - sessionStorage.setItem(k, v); - } - } - }, saved.storage); - } catch {} - } - - if (saved.isActive) activeId = id; - } - - // If no pages were saved, create a blank one - if (this.pages.size === 0) { - await this.newTab(); - } else { - this.activeTabId = activeId ?? [...this.pages.keys()][0]; - } - - // Clear refs — pages are new, locators are stale - this.clearRefs(); + // 4. Restore state + await this.restoreState(state); return null; // success } catch (err: unknown) { @@ -391,6 +432,118 @@ export class BrowserManager { } } + // ─── Handoff: Headless → Headed ───────────────────────────── + /** + * Hand off browser control to the user by relaunching in headed mode. + * + * Flow (launch-first-close-second for safe rollback): + * 1. Save state from current headless browser + * 2. Launch NEW headed browser + * 3. Restore state into new browser + * 4. Close OLD headless browser + * If step 2 fails → return error, headless browser untouched + */ + async handoff(message: string): Promise { + if (this.isHeaded) { + return `HANDOFF: Already in headed mode at ${this.getCurrentUrl()}`; + } + if (!this.browser || !this.context) { + throw new Error('Browser not launched'); + } + + // 1. Save state from current browser + const state = await this.saveState(); + const currentUrl = this.getCurrentUrl(); + + // 2. Launch new headed browser (try-catch — if this fails, headless stays running) + let newBrowser: Browser; + try { + newBrowser = await chromium.launch({ headless: false, timeout: 15000 }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return `ERROR: Cannot open headed browser — ${msg}. Headless browser still running.`; + } + + // 3. Create context and restore state into new headed browser + try { + const contextOptions: BrowserContextOptions = { + viewport: { width: 1280, height: 720 }, + }; + if (this.customUserAgent) { + contextOptions.userAgent = this.customUserAgent; + } + const newContext = await newBrowser.newContext(contextOptions); + + if (Object.keys(this.extraHeaders).length > 0) { + await newContext.setExtraHTTPHeaders(this.extraHeaders); + } + + // Swap to new browser/context before restoreState (it uses this.context) + const oldBrowser = this.browser; + const oldContext = this.context; + + this.browser = newBrowser; + this.context = newContext; + this.pages.clear(); + + // Register crash handler on new browser + this.browser.on('disconnected', () => { + console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.'); + console.error('[browse] Console/network logs flushed to .gstack/browse-*.log'); + process.exit(1); + }); + + await this.restoreState(state); + this.isHeaded = true; + + // 4. Close old headless browser (fire-and-forget — close() can hang + // when another Playwright instance is active, so we don't await it) + oldBrowser.removeAllListeners('disconnected'); + oldBrowser.close().catch(() => {}); + + return [ + `HANDOFF: Browser opened at ${currentUrl}`, + `MESSAGE: ${message}`, + `STATUS: Waiting for user. Run 'resume' when done.`, + ].join('\n'); + } catch (err: unknown) { + // Restore failed — close the new browser, keep old one + await newBrowser.close().catch(() => {}); + const msg = err instanceof Error ? err.message : String(err); + return `ERROR: Handoff failed during state restore — ${msg}. Headless browser still running.`; + } + } + + /** + * Resume AI control after user handoff. + * Clears stale refs and resets failure counter. + * The meta-command handler calls handleSnapshot() after this. + */ + resume(): void { + this.clearRefs(); + this.resetFailures(); + } + + getIsHeaded(): boolean { + return this.isHeaded; + } + + // ─── Auto-handoff Hint (consecutive failure tracking) ─────── + incrementFailures(): void { + this.consecutiveFailures++; + } + + resetFailures(): void { + this.consecutiveFailures = 0; + } + + getFailureHint(): string | null { + if (this.consecutiveFailures >= 3 && !this.isHeaded) { + return `HINT: ${this.consecutiveFailures} consecutive failures. Consider using 'handoff' to let the user help.`; + } + return null; + } + // ─── Console/Network/Dialog/Ref Wiring ──────────────────── private wirePageEvents(page: Page) { // Clear ref map on navigation — refs point to stale elements after page change diff --git a/browse/src/commands.ts b/browse/src/commands.ts index aa86d1f1..c3509af1 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -30,6 +30,7 @@ export const META_COMMANDS = new Set([ 'screenshot', 'pdf', 'responsive', 'chain', 'diff', 'url', 'snapshot', + 'handoff', 'resume', ]); export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]); @@ -94,6 +95,9 @@ export const COMMAND_DESCRIPTIONS: Record { }); } + browserManager.resetFailures(); return new Response(result, { status: 200, headers: { 'Content-Type': 'text/plain' }, }); } catch (err: any) { - return new Response(JSON.stringify({ error: wrapError(err) }), { + browserManager.incrementFailures(); + let errorMsg = wrapError(err); + const hint = browserManager.getFailureHint(); + if (hint) errorMsg += '\n' + hint; + return new Response(JSON.stringify({ error: errorMsg }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); diff --git a/browse/test/handoff.test.ts b/browse/test/handoff.test.ts new file mode 100644 index 00000000..587f2f42 --- /dev/null +++ b/browse/test/handoff.test.ts @@ -0,0 +1,235 @@ +/** + * 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 } from '../src/write-commands'; +import { handleMetaCommand } from '../src/meta-commands'; + +let testServer: ReturnType; +let bm: BrowserManager; +let baseUrl: string; + +beforeAll(async () => { + testServer = startTestServer(0); + baseUrl = testServer.url; + + bm = new BrowserManager(); + await bm.launch(); +}); + +afterAll(() => { + try { testServer.server.stop(); } catch {} + setTimeout(() => process.exit(0), 500); +}); + +// ─── 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. + +describe('handoff integration', () => { + test('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('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('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); +});