M CHANGELOG.md => CHANGELOG.md +13 -0
@@ 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
M SKILL.md => SKILL.md +2 -0
@@ 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 |
M TODOS.md => TODOS.md +3 -1
@@ 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
M VERSION => VERSION +1 -1
@@ 1,1 1,1 @@
-0.8.1
+0.8.2
M browse/SKILL.md => browse/SKILL.md +28 -0
@@ 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 |
M browse/SKILL.md.tmpl => browse/SKILL.md.tmpl +26 -0
@@ 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}}
M browse/src/browser-manager.ts => browse/src/browser-manager.ts +221 -68
@@ 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<string, string>; sessionStorage: Record<string, string> } | 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<BrowserState> {
+ 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<void> {
+ 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<string, string>; sessionStorage: Record<string, string> }) => {
+ 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<string, string>; sessionStorage: Record<string, string> } | 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<string, string>; sessionStorage: Record<string, string> }) => {
- 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<string> {
+ 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
M browse/src/commands.ts => browse/src/commands.ts +4 -0
@@ 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<string, { category: string; descriptio
// Meta
'snapshot':{ category: 'Snapshot', description: 'Accessibility tree with @e refs for element selection. Flags: -i interactive only, -c compact, -d N depth limit, -s sel scope, -D diff vs previous, -a annotated screenshot, -o path output, -C cursor-interactive @c refs', usage: 'snapshot [flags]' },
'chain': { category: 'Meta', description: 'Run commands from JSON stdin. Format: [["cmd","arg1",...],...]' },
+ // Handoff
+ 'handoff': { category: 'Server', description: 'Open visible Chrome at current page for user takeover', usage: 'handoff [message]' },
+ 'resume': { category: 'Server', description: 'Re-snapshot after user takeover, return control to AI', usage: 'resume' },
};
// Load-time validation: descriptions must cover exactly the command sets
M browse/src/meta-commands.ts => browse/src/meta-commands.ts +13 -0
@@ 246,6 246,19 @@ export async function handleMetaCommand(
return await handleSnapshot(args, bm);
}
+ // ─── Handoff ────────────────────────────────────
+ case 'handoff': {
+ const message = args.join(' ') || 'User takeover requested';
+ return await bm.handoff(message);
+ }
+
+ case 'resume': {
+ bm.resume();
+ // Re-snapshot to capture current page state after human interaction
+ const snapshot = await handleSnapshot(['-i'], bm);
+ return `RESUMED\n${snapshot}`;
+ }
+
default:
throw new Error(`Unknown meta command: ${command}`);
}
M browse/src/server.ts => browse/src/server.ts +6 -1
@@ 249,12 249,17 @@ async function handleCommand(body: any): Promise<Response> {
});
}
+ 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' },
});
A browse/test/handoff.test.ts => browse/test/handoff.test.ts +235 -0
@@ 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<typeof startTestServer>;
+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);
+});