Files
gstack/browse/test/handoff.test.ts
T
01593aa67c v1.91.2.0 fix: consolidate gstack reliability wave (#2959)
* fix(memory-ingest): --scan-secrets scans the rendered page and fails closed

--scan-secrets ran gitleaks on the raw transcript .jsonl, then imported a
page rendered from it. gitleaks' assignment rules don't match across a
JSON-escaped quote (KEY=\"v\" on disk), so a secret the rendered page
shows as KEY="v" was imported unflagged. And the gate skipped a file only
on scanner "gitleaks" with findings, so a scan that errored (non-zero
exit, 16MB maxBuffer overflow on a file with many findings, unparseable
report) or could not run (gitleaks missing, slow-probe cooldown) imported
the file unscanned.

Scan the rendered page body, the exact bytes writeStaged() writes, via a
new secretScanText() helper, and skip the file whenever the scan did not
complete. Skipped files stay out of the state file, so the next run
retries them. Reword the helper warnings and setup-gbrain/memory.md,
which described the fail-open as intended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(test): reconcile Bun failure markers and footer counts

* fix(sync-gbrain): verify source-scoped reads without mutation

* fix(test): recognize grounded TTHW target choices structurally

* fix(aside): make the readiness probe work under zsh and report why it failed

The probe built its deadline into `_T` and expanded it unquoted, so
`$_T aside repl …` only worked in a shell that word-splits. zsh does not: it
looked for a command literally named "gtimeout 30", the probe answered
ASIDE_NOT_RUNNING with Aside installed and ready, and every browsing skill
fell back to the bundled Chromium in silence. zsh is the macOS default and
Aside is macOS-only, so on a stock Mac the probe could never report READY.

The deadline becomes a function, `_gs_d`. It receives the command as "$@",
already split, so sh, bash and zsh all behave the same, and the gtimeout →
timeout → perl alarm chain is unchanged. A 4th arm runs the call unbounded
when none of the three is present, which is what the empty `_T` did before.
Not `eval`: it re-parses the string, so the parens and `;` of the perl arm
become syntax and that arm dies in bash *and* zsh — on a stock Mac, the arm
that actually runs.

On failure the probe now prints the CLI's reason after ASIDE_NOT_RUNNING:,
the shape gstack-render already uses: the first line that starts with a
capital letter, i.e. the CLI's own sentence or Node's `Error:` line below its
loader frame. "Not running" covers states with different fixes — no window
open for the profile, a NODE_OPTIONS preload that kills the CLI — and a bare
verdict sent all of them to "open the Aside app". The BROWSER SETUP prose
quotes that reason before asking the user to open the app.

The text pin asserted the broken invocation verbatim, so it now pins the
function and asserts neither `$_T aside repl` nor an eval form comes back. A
second test executes the rendered probe in sh, bash and zsh on each of the
four deadline arms with stubbed binaries on a narrowed PATH, plus two failing
CLIs: one that prints its own sentence, one that crashes like Node with the
useful line below the frame.

The deadline function costs zero bytes against the lines it replaces; the
reason costs 53 per copy of the probe (44 where the reworded BROWSER SETUP
line gives 9 back). That moves four guards by the measured amount:
plan-devex-review's skeleton cap to 68,550 (measured 68,544), plan-ceo-review's
skeleton cap to 80,150 (measured 80,111) and union ratio to 1.081 (measured
1.0803), and plan-eng-review's union ratio to 1.151 (measured 1.1504).

Fixes #2842, #2941.

* Clarify engineering review startup and decision flow

* Fix Windows readiness fixture PATH and command shim

* fix(test): recognize grounded TTHW target choices structurally

* Clarify engineering review startup and decision flow

* fix(test): restrict QA-only fixture tools to its no-Edit contract

* v1.90.0.0 fix(sync-gbrain): guard readiness verdicts and refresh metadata

* fix(browse): validate canonical upload targets

* fix(gbrain): classify structured PGLite busy response

* fix(browse): preserve native extension runtime APIs

* Fix displayless browser handoff ownership

* Accept unique installed autoplan methodology aliases

* fix(skills): preserve positional literals during installation

* fix(browse): checksum installer contents through stdin

* fix(test): normalize Windows checksum fixture paths

* test: emulate unavailable shasum in Windows checksum fixture

* fix(investigate): preserve owned freeze lifecycle

* fix(review): preserve N+1 retry and Red Team completion

* fix: bound Aside readiness and preserve safe fallback

* test: exercise setup and Chromium on native ARM

* fix: preserve install ownership and ARM browser selection

* Fix gbrain ingest scan boundaries and seed observation

* Refresh managed ship hooks and supervise expanded paid census

* Reject resumed gbrain pages excluded by current policy

* Recover zombie agent locks safely and enable CI Python venv

* Repair paid actor declarations and Aside pitch assertions

* Bump consolidated wave to next free minor release

* Clarify CEO review admin choices and option tradeoffs

* Preserve CEO mode handoff anchors in clarified workflow

* Make Windows portability fixtures use shell-native paths

* Restore ARM Bun alias and clarify ship review gates

* Refresh ship workflow golden snapshots

* Fix Windows DX documentation controls without piped stdin

* Decode Codex child pipes without Bun's encoded-stream stall

* Bound DX pre-review audit before product questions

* Clarify trusted review-start read in paid revalidation

* Bump consolidated wave to next free minor release

* Clarify CEO review admin choices and option tradeoffs

* Preserve CEO mode handoff anchors in clarified workflow

* Make Windows portability fixtures use shell-native paths

* Restore ARM Bun alias and clarify ship review gates

* Refresh ship workflow golden snapshots

* Fix Windows DX documentation controls without piped stdin

* Decode Codex child pipes without Bun's encoded-stream stall

* Bound DX pre-review audit before product questions

* Clarify trusted review-start read in paid revalidation

* Reconcile new main planning flow and paid judge census

* fix: reconcile rebased planning and source-bound validation

* test: pin cookie workflow judge to scored Sonnet model

* fix: keep terminal agent boot out of module imports

* fix: preserve pending-question uncertainty in engineering review

* fix: stabilize Windows reliability-wave fixtures

* fix: clarify design consultation research workflow

* fix: preserve independent design consultation inputs

* fix: resolve design taste scope and browser research guidance

* fix: make consultation opt-in preflight unambiguous

* test: await native Edge owner readiness or terminal result

---------

Co-authored-by: Bruce Krysiak <brucek@alum.mit.edu>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Antonio Vitalic <antoninte99@gmail.com>
2026-09-26 18:57:53 -04:00

507 lines
20 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 * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { afterAll, beforeAll, afterEach, beforeEach, describe, expect, test } 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';
import { spawnXvfb, pickFreeDisplay, isOurXvfb, type XvfbHandle } from '../src/xvfb';
// Per-FILE Chromium profile: this file launches an in-process persistent
// context (BrowserManager.launch()), and sharing a profile dir with the
// long-lived browse daemon a sibling file may have spawned kills one side's
// Chromium (ProcessSingleton on user-data-dir). Scoped via hooks, never
// module scope (see test/gstack-home-module-scope.test.ts's rationale).
const ORIGINAL_CHROMIUM_PROFILE = process.env.CHROMIUM_PROFILE;
let CHROMIUM_PROFILE_DIR: string | undefined;
beforeAll(() => {
CHROMIUM_PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-profile-'));
process.env.CHROMIUM_PROFILE = CHROMIUM_PROFILE_DIR;
});
afterAll(() => {
if (ORIGINAL_CHROMIUM_PROFILE === undefined) delete process.env.CHROMIUM_PROFILE;
else process.env.CHROMIUM_PROFILE = ORIGINAL_CHROMIUM_PROFILE;
if (CHROMIUM_PROFILE_DIR) { try { fs.rmSync(CHROMIUM_PROFILE_DIR, { recursive: true, force: true }); } catch {} }
});
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(true); } catch {} // force-close keep-alives — a lingering Chromium connection otherwise blocks stop() forever
// 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(process.platform !== 'linux')('restore failure after candidate assignment leaves the original manager and pages usable', async () => {
const displayNum = pickFreeDisplay();
expect(displayNum).not.toBeNull();
const display = await spawnXvfb(displayNum!);
const originalDisplay = process.env.DISPLAY;
process.env.DISPLAY = display.display;
const hbm = new BrowserManager();
let originalBrowser: any;
try {
await hbm.launch();
originalBrowser = (hbm as any).browser;
const originalContext = (hbm as any).context;
await handleWriteCommand('goto', [baseUrl + '/basic.html'], hbm);
await hbm.newTab(baseUrl + '/form.html', 'owner-control');
const oldPage = hbm.getPage();
const oldSession = hbm.getActiveSession();
const oldTabs = (hbm as any).pages;
const oldOwnership = new Map((hbm as any).tabOwnership);
const oldNextId = (hbm as any).nextTabId;
let promoted = 0;
hbm.onHeadedPromotion = () => { promoted++; };
const restore = hbm.restoreState.bind(hbm);
hbm.restoreState = async (state) => {
await restore(state);
expect((hbm as any).context).not.toBe(originalContext);
expect(hbm.getPage()).not.toBe(oldPage);
throw new Error('injected after actual restore');
};
const result = await hbm.handoff('rollback control');
expect(result).toContain('injected after actual restore');
expect((hbm as any).context).toBe(originalContext);
expect(hbm.getPage()).toBe(oldPage);
expect(hbm.getActiveSession()).toBe(oldSession);
expect((hbm as any).pages).toBe(oldTabs);
expect((hbm as any).tabOwnership).toEqual(oldOwnership);
expect((hbm as any).nextTabId).toBe(oldNextId);
expect(hbm.getConnectionMode()).toBe('launched');
expect(hbm.getIsHeaded()).toBe(false);
expect(promoted).toBe(0);
expect(await hbm.isHealthy()).toBe(true);
await handleWriteCommand('goto', [baseUrl + '/basic.html'], hbm);
expect(hbm.getPage().url()).toBe(baseUrl + '/basic.html');
expect(isOurXvfb(display.pid, display.startTime)).toBe(true);
} finally {
await hbm.close();
await originalBrowser?.close().catch(() => {});
expect(isOurXvfb(display.pid, display.startTime)).toBe(true);
display.close();
if (originalDisplay === undefined) delete process.env.DISPLAY;
else process.env.DISPLAY = originalDisplay;
}
}, 30000);
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);
});
describe.skipIf(process.platform !== 'linux')('lazy owned display lifecycle', () => {
let savedEnv: NodeJS.ProcessEnv;
let root: string;
let hbm: BrowserManager;
const displays = () => {
const result = Bun.spawnSync(['ps', '--ppid', String(process.pid), '-o', 'comm='], {
stdout: 'pipe', stderr: 'pipe', timeout: 2000,
});
return result.stdout.toString().split('\n').filter(line => line.trim() === 'Xvfb').length;
};
beforeEach(async () => {
savedEnv = { ...process.env };
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-handoff-display-'));
delete process.env.DISPLAY;
delete process.env.WAYLAND_DISPLAY;
delete process.env.BROWSE_HEADED;
process.env.CHROMIUM_PROFILE = path.join(root, 'profile');
hbm = new BrowserManager();
await hbm.launch();
await handleWriteCommand('goto', [baseUrl + '/basic.html'], hbm);
});
afterEach(async () => {
await hbm?.close();
for (const key of ['DISPLAY', 'WAYLAND_DISPLAY', 'BROWSE_HEADED', 'CHROMIUM_PROFILE', 'PATH']) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
}
fs.rmSync(root, { recursive: true, force: true });
}, 15000);
test('ordinary headless commands allocate no display', async () => {
await hbm.newTab(baseUrl + '/form.html');
expect(hbm.getXvfbHandle()).toBeNull();
expect(displays()).toBe(0);
expect(await hbm.isHealthy()).toBe(true);
}, 15000);
test('shutdown cleans a display that finishes allocation after teardown starts', async () => {
const allocation = hbm.ensureHeadedDisplay();
const outcome = allocation.then(() => 'resolved', error => String(error));
await hbm.close();
expect(await outcome).toContain('Browser is shutting down');
expect(hbm.getXvfbHandle()).toBeNull();
expect(displays()).toBe(0);
}, 15000);
test('concurrent promotion owns one display, preserves commands, and cleans it on shutdown', async () => {
expect(displays()).toBe(0);
const results = await Promise.all([hbm.handoff('one promotion'), hbm.handoff('same promotion')]);
expect(results[0]).toBe(results[1]);
expect(results[0]).toContain('Off-screen Xvfb');
expect(results[0]).toContain('separate remote desktop');
const handle = hbm.getXvfbHandle()!;
expect(handle).not.toBeNull();
expect(isOurXvfb(handle.pid, handle.startTime)).toBe(true);
expect(displays()).toBe(1);
expect(process.env.DISPLAY).toBeUndefined();
await hbm.newTab(baseUrl + '/form.html');
await handleWriteCommand('goto', [baseUrl + '/basic.html'], hbm);
expect(hbm.getPage().url()).toBe(baseUrl + '/basic.html');
expect(await hbm.getPage().evaluate(() => typeof (window as any).chrome?.runtime?.sendMessage)).toBe('undefined');
expect(await handleMetaCommand('resume', [], hbm, () => {})).toContain('RESUMED');
expect(await hbm.handoff('again')).toContain('Already in headed mode');
expect(displays()).toBe(1);
await hbm.close();
expect(hbm.getXvfbHandle()).toBeNull();
expect(isOurXvfb(handle.pid, handle.startTime)).toBe(false);
}, 30000);
for (const phase of ['before launch', 'after restore'] as const) {
test(`failure ${phase} rolls back and releases only the allocated display`, async () => {
const oldPage = hbm.getPage();
const oldContext = (hbm as any).context;
const oldSession = hbm.getActiveSession();
let handle: XvfbHandle | null = null;
const ensure = hbm.ensureHeadedDisplay.bind(hbm);
hbm.ensureHeadedDisplay = async () => { await ensure(); handle = hbm.getXvfbHandle(); };
if (phase === 'before launch') {
process.env.CHROMIUM_PROFILE = path.join(root, 'not-a-directory');
fs.writeFileSync(process.env.CHROMIUM_PROFILE, 'fixture');
} else {
const restore = hbm.restoreState.bind(hbm);
hbm.restoreState = async (state) => {
await restore(state);
expect((hbm as any).context).not.toBe(oldContext);
throw new Error('injected after restore');
};
}
let promotions = 0;
hbm.onHeadedPromotion = () => { promotions++; };
const result = await hbm.handoff('failure control');
expect(result).toStartWith('ERROR:');
expect(handle).not.toBeNull();
expect(isOurXvfb(handle!.pid, handle!.startTime)).toBe(false);
expect(hbm.getXvfbHandle()).toBeNull();
expect(hbm.getPage()).toBe(oldPage);
expect(hbm.getActiveSession()).toBe(oldSession);
expect((hbm as any).context).toBe(oldContext);
expect(hbm.getConnectionMode()).toBe('launched');
expect(hbm.getIsHeaded()).toBe(false);
expect(promotions).toBe(0);
expect(await hbm.isHealthy()).toBe(true);
await handleWriteCommand('goto', [baseUrl + '/form.html'], hbm);
expect(hbm.getPage().url()).toBe(baseUrl + '/form.html');
}, 30000);
}
for (const phase of ['capture', 'restore'] as const) {
test(`shutdown cancels stalled ${phase} without a late promotion`, async () => {
const entered = Promise.withResolvers<void>();
const release = Promise.withResolvers<void>();
const originalBrowser = (hbm as any).browser;
const oldPage = hbm.getPage();
(hbm as any).closeRaceMs = 100;
let handle: XvfbHandle | null = null;
let promotions = 0;
hbm.onHeadedPromotion = () => { promotions++; };
if (phase === 'capture') {
const save = hbm.saveState.bind(hbm);
hbm.saveState = async () => {
const state = await save();
entered.resolve();
await release.promise;
return state;
};
} else {
const restore = hbm.restoreState.bind(hbm);
hbm.restoreState = async (state) => {
await restore(state);
handle = hbm.getXvfbHandle();
entered.resolve();
await release.promise;
};
}
const promotion = hbm.handoff('stalled renderer');
await entered.promise;
const closing = hbm.close();
try {
expect(await Promise.race([closing.then(() => true), Bun.sleep(2000).then(() => false)])).toBe(true);
expect(oldPage.isClosed()).toBe(true);
if (handle) expect(isOurXvfb(handle.pid, handle.startTime)).toBe(false);
} finally {
release.resolve();
await promotion.catch(() => {});
await closing;
await originalBrowser.close().catch(() => {});
}
expect(promotions).toBe(0);
expect((hbm as any).browser).toBeNull();
expect(hbm.getXvfbHandle()).toBeNull();
}, 30000);
}
test('rollback retains original tab close and navigation events during candidate restore', async () => {
const remainingPage = hbm.getPage();
const remainingSession = hbm.getActiveSession();
remainingSession.setRefMap(new Map([['e1', { locator: remainingPage.locator('body'), role: 'document', name: '' }]]));
await hbm.newTab(baseUrl + '/form.html');
const closingPage = hbm.getPage();
const restore = hbm.restoreState.bind(hbm);
hbm.restoreState = async (state) => {
await restore(state);
await closingPage.close();
await remainingPage.goto(baseUrl + '/form.html');
throw new Error('rollback after original tab events');
};
expect(await hbm.handoff('event rollback')).toContain('rollback after original tab events');
expect(hbm.getTabCount()).toBe(1);
expect(hbm.getPage()).toBe(remainingPage);
expect(hbm.getActiveSession()).toBe(remainingSession);
expect(hbm.getRefCount()).toBe(0);
expect(await hbm.isHealthy()).toBe(true);
await handleWriteCommand('goto', [baseUrl + '/basic.html'], hbm);
}, 30000);
});