mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-06 21:46:40 +02:00
3501f5dd03
* fix: Windows browse — health-check-first ensureServer, detached startServer, Windows process mgmt
Three compounding bugs made browse completely broken on Windows:
1. Bun.spawn().unref() doesn't truly detach on Windows — server dies when
CLI exits. Fix: use Node's child_process.spawn with { detached: true }
via a launcher script. Credit: PR #191 by @fqueiro for the approach.
2. process.kill(pid, 0) is broken in Bun's compiled binary on Windows —
ensureServer() never reaches the health check. Fix: restructure to
health-check-first (HTTP is definitive proof on all platforms). Extract
isServerHealthy() helper for DRY (4 call sites).
3. Windows process management: isProcessAlive() falls back to tasklist,
killServer() uses taskkill /T /F (kills process tree including Chromium),
cleanupLegacyState() skips on Windows (no /tmp, no ps).
Also: hard-fail on Windows if server-node.mjs is missing instead of
silently falling back to the known-broken Bun path.
Fixes #342.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: disable Chromium sandbox on Windows
Chromium's sandbox fails when the server is spawned through the
Bun→Node process chain on Windows (GitHub #276). Disable
chromiumSandbox on Windows at both launch sites (headless + headed).
Safe: local daemon browsing user-specified URLs, Playwright docs
recommend disabling in CI/container environments.
Fixes #276.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: startup error log + Windows exit handler for browse server
On Windows, the CLI can't capture stderr from the server (stdio: 'ignore'
required for process detachment). Write startup errors to
.gstack/browse-startup-error.log so the CLI can report them on timeout.
Also add process.on('exit') handler on Windows as defense-in-depth for
state file cleanup (primary mechanism is CLI's stale-state detection).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: add isServerHealthy + startup error log tests
Tests for the new cross-platform health check helper (isServerHealthy)
that replaces PID-based liveness checks in all polling loops. Covers
healthy, unhealthy, unreachable, and error response cases.
Also tests the startup error log write/read format used on Windows.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v0.11.11.0)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: sync ARCHITECTURE.md with health-check-first ensureServer
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
671 lines
22 KiB
TypeScript
671 lines
22 KiB
TypeScript
/**
|
|
* Browser lifecycle manager
|
|
*
|
|
* Chromium crash handling:
|
|
* browser.on('disconnected') → log error → process.exit(1)
|
|
* CLI detects dead server → auto-restarts on next command
|
|
* We do NOT try to self-heal — don't hide failure.
|
|
*
|
|
* Dialog handling:
|
|
* page.on('dialog') → auto-accept by default → store in dialog buffer
|
|
* Prevents browser lockup from alert/confirm/prompt
|
|
*
|
|
* Context recreation (useragent):
|
|
* recreateContext() saves cookies/storage/URLs, creates new context,
|
|
* restores state. Falls back to clean slate on any failure.
|
|
*/
|
|
|
|
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';
|
|
import { validateNavigationUrl } from './url-validation';
|
|
|
|
export interface RefEntry {
|
|
locator: Locator;
|
|
role: string;
|
|
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;
|
|
private pages: Map<number, Page> = new Map();
|
|
private activeTabId: number = 0;
|
|
private nextTabId: number = 1;
|
|
private extraHeaders: Record<string, string> = {};
|
|
private customUserAgent: string | null = null;
|
|
|
|
/** Server port — set after server starts, used by cookie-import-browser command */
|
|
public serverPort: number = 0;
|
|
|
|
// ─── Ref Map (snapshot → @e1, @e2, @c1, @c2, ...) ────────
|
|
private refMap: Map<string, RefEntry> = new Map();
|
|
|
|
// ─── Snapshot Diffing ─────────────────────────────────────
|
|
// NOT cleared on navigation — it's a text baseline for diffing
|
|
private lastSnapshot: string | null = null;
|
|
|
|
// ─── Dialog Handling ──────────────────────────────────────
|
|
private dialogAutoAccept: boolean = true;
|
|
private dialogPromptText: string | null = null;
|
|
|
|
// ─── Handoff State ─────────────────────────────────────────
|
|
private isHeaded: boolean = false;
|
|
private consecutiveFailures: number = 0;
|
|
|
|
async launch() {
|
|
// ─── Extension Support ────────────────────────────────────
|
|
// BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory.
|
|
// Extensions only work in headed mode, so we use an off-screen window.
|
|
const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR;
|
|
const launchArgs: string[] = [];
|
|
let useHeadless = true;
|
|
|
|
// Docker/CI: Chromium sandbox requires unprivileged user namespaces which
|
|
// are typically disabled in containers. Detect container environment and
|
|
// add --no-sandbox automatically.
|
|
if (process.env.CI || process.env.CONTAINER) {
|
|
launchArgs.push('--no-sandbox');
|
|
}
|
|
|
|
if (extensionsDir) {
|
|
launchArgs.push(
|
|
`--disable-extensions-except=${extensionsDir}`,
|
|
`--load-extension=${extensionsDir}`,
|
|
'--window-position=-9999,-9999',
|
|
'--window-size=1,1',
|
|
);
|
|
useHeadless = false; // extensions require headed mode; off-screen window simulates headless
|
|
console.log(`[browse] Extensions loaded from: ${extensionsDir}`);
|
|
}
|
|
|
|
this.browser = await chromium.launch({
|
|
headless: useHeadless,
|
|
// On Windows, Chromium's sandbox fails when the server is spawned through
|
|
// the Bun→Node process chain (GitHub #276). Disable it — local daemon
|
|
// browsing user-specified URLs has marginal sandbox benefit.
|
|
chromiumSandbox: process.platform !== 'win32',
|
|
...(launchArgs.length > 0 ? { args: launchArgs } : {}),
|
|
});
|
|
|
|
// Chromium crash → exit with clear message
|
|
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);
|
|
});
|
|
|
|
const contextOptions: BrowserContextOptions = {
|
|
viewport: { width: 1280, height: 720 },
|
|
};
|
|
if (this.customUserAgent) {
|
|
contextOptions.userAgent = this.customUserAgent;
|
|
}
|
|
this.context = await this.browser.newContext(contextOptions);
|
|
|
|
if (Object.keys(this.extraHeaders).length > 0) {
|
|
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
|
}
|
|
|
|
// Create first tab
|
|
await this.newTab();
|
|
}
|
|
|
|
async close() {
|
|
if (this.browser) {
|
|
// Remove disconnect handler to avoid exit during intentional close
|
|
this.browser.removeAllListeners('disconnected');
|
|
// Timeout: headed browser.close() can hang on macOS
|
|
await Promise.race([
|
|
this.browser.close(),
|
|
new Promise(resolve => setTimeout(resolve, 5000)),
|
|
]).catch(() => {});
|
|
this.browser = null;
|
|
}
|
|
}
|
|
|
|
/** Health check — verifies Chromium is connected AND responsive */
|
|
async isHealthy(): Promise<boolean> {
|
|
if (!this.browser || !this.browser.isConnected()) return false;
|
|
try {
|
|
const page = this.pages.get(this.activeTabId);
|
|
if (!page) return true; // connected but no pages — still healthy
|
|
await Promise.race([
|
|
page.evaluate('1'),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 2000)),
|
|
]);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ─── Tab Management ────────────────────────────────────────
|
|
async newTab(url?: string): Promise<number> {
|
|
if (!this.context) throw new Error('Browser not launched');
|
|
|
|
// Validate URL before allocating page to avoid zombie tabs on rejection
|
|
if (url) {
|
|
await validateNavigationUrl(url);
|
|
}
|
|
|
|
const page = await this.context.newPage();
|
|
const id = this.nextTabId++;
|
|
this.pages.set(id, page);
|
|
this.activeTabId = id;
|
|
|
|
// Wire up console/network/dialog capture
|
|
this.wirePageEvents(page);
|
|
|
|
if (url) {
|
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
}
|
|
|
|
return id;
|
|
}
|
|
|
|
async closeTab(id?: number): Promise<void> {
|
|
const tabId = id ?? this.activeTabId;
|
|
const page = this.pages.get(tabId);
|
|
if (!page) throw new Error(`Tab ${tabId} not found`);
|
|
|
|
await page.close();
|
|
this.pages.delete(tabId);
|
|
|
|
// Switch to another tab if we closed the active one
|
|
if (tabId === this.activeTabId) {
|
|
const remaining = [...this.pages.keys()];
|
|
if (remaining.length > 0) {
|
|
this.activeTabId = remaining[remaining.length - 1];
|
|
} else {
|
|
// No tabs left — create a new blank one
|
|
await this.newTab();
|
|
}
|
|
}
|
|
}
|
|
|
|
switchTab(id: number): void {
|
|
if (!this.pages.has(id)) throw new Error(`Tab ${id} not found`);
|
|
this.activeTabId = id;
|
|
}
|
|
|
|
getTabCount(): number {
|
|
return this.pages.size;
|
|
}
|
|
|
|
async getTabListWithTitles(): Promise<Array<{ id: number; url: string; title: string; active: boolean }>> {
|
|
const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = [];
|
|
for (const [id, page] of this.pages) {
|
|
tabs.push({
|
|
id,
|
|
url: page.url(),
|
|
title: await page.title().catch(() => ''),
|
|
active: id === this.activeTabId,
|
|
});
|
|
}
|
|
return tabs;
|
|
}
|
|
|
|
// ─── Page Access ───────────────────────────────────────────
|
|
getPage(): Page {
|
|
const page = this.pages.get(this.activeTabId);
|
|
if (!page) throw new Error('No active page. Use "browse goto <url>" first.');
|
|
return page;
|
|
}
|
|
|
|
getCurrentUrl(): string {
|
|
try {
|
|
return this.getPage().url();
|
|
} catch {
|
|
return 'about:blank';
|
|
}
|
|
}
|
|
|
|
// ─── Ref Map ──────────────────────────────────────────────
|
|
setRefMap(refs: Map<string, RefEntry>) {
|
|
this.refMap = refs;
|
|
}
|
|
|
|
clearRefs() {
|
|
this.refMap.clear();
|
|
}
|
|
|
|
/**
|
|
* Resolve a selector that may be a @ref (e.g., "@e3", "@c1") or a CSS selector.
|
|
* Returns { locator } for refs or { selector } for CSS selectors.
|
|
*/
|
|
async resolveRef(selector: string): Promise<{ locator: Locator } | { selector: string }> {
|
|
if (selector.startsWith('@e') || selector.startsWith('@c')) {
|
|
const ref = selector.slice(1); // "e3" or "c1"
|
|
const entry = this.refMap.get(ref);
|
|
if (!entry) {
|
|
throw new Error(
|
|
`Ref ${selector} not found. Run 'snapshot' to get fresh refs.`
|
|
);
|
|
}
|
|
const count = await entry.locator.count();
|
|
if (count === 0) {
|
|
throw new Error(
|
|
`Ref ${selector} (${entry.role} "${entry.name}") is stale — element no longer exists. ` +
|
|
`Run 'snapshot' for fresh refs.`
|
|
);
|
|
}
|
|
return { locator: entry.locator };
|
|
}
|
|
return { selector };
|
|
}
|
|
|
|
/** Get the ARIA role for a ref selector, or null for CSS selectors / unknown refs. */
|
|
getRefRole(selector: string): string | null {
|
|
if (selector.startsWith('@e') || selector.startsWith('@c')) {
|
|
const entry = this.refMap.get(selector.slice(1));
|
|
return entry?.role ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
getRefCount(): number {
|
|
return this.refMap.size;
|
|
}
|
|
|
|
// ─── Snapshot Diffing ─────────────────────────────────────
|
|
setLastSnapshot(text: string | null) {
|
|
this.lastSnapshot = text;
|
|
}
|
|
|
|
getLastSnapshot(): string | null {
|
|
return this.lastSnapshot;
|
|
}
|
|
|
|
// ─── Dialog Control ───────────────────────────────────────
|
|
setDialogAutoAccept(accept: boolean) {
|
|
this.dialogAutoAccept = accept;
|
|
}
|
|
|
|
getDialogAutoAccept(): boolean {
|
|
return this.dialogAutoAccept;
|
|
}
|
|
|
|
setDialogPromptText(text: string | null) {
|
|
this.dialogPromptText = text;
|
|
}
|
|
|
|
getDialogPromptText(): string | null {
|
|
return this.dialogPromptText;
|
|
}
|
|
|
|
// ─── Viewport ──────────────────────────────────────────────
|
|
async setViewport(width: number, height: number) {
|
|
await this.getPage().setViewportSize({ width, height });
|
|
}
|
|
|
|
// ─── Extra Headers ─────────────────────────────────────────
|
|
async setExtraHeader(name: string, value: string) {
|
|
this.extraHeaders[name] = value;
|
|
if (this.context) {
|
|
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
|
}
|
|
}
|
|
|
|
// ─── User Agent ────────────────────────────────────────────
|
|
setUserAgent(ua: string) {
|
|
this.customUserAgent = ua;
|
|
}
|
|
|
|
getUserAgent(): string | null {
|
|
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.
|
|
* Falls back to a clean slate on any failure.
|
|
*/
|
|
async recreateContext(): Promise<string | null> {
|
|
if (!this.browser || !this.context) {
|
|
throw new Error('Browser not launched');
|
|
}
|
|
|
|
try {
|
|
// 1. Save state
|
|
const state = await this.saveState();
|
|
|
|
// 2. Close old pages and context
|
|
for (const page of this.pages.values()) {
|
|
await page.close().catch(() => {});
|
|
}
|
|
this.pages.clear();
|
|
await this.context.close().catch(() => {});
|
|
|
|
// 3. Create new context with updated settings
|
|
const contextOptions: BrowserContextOptions = {
|
|
viewport: { width: 1280, height: 720 },
|
|
};
|
|
if (this.customUserAgent) {
|
|
contextOptions.userAgent = this.customUserAgent;
|
|
}
|
|
this.context = await this.browser.newContext(contextOptions);
|
|
|
|
if (Object.keys(this.extraHeaders).length > 0) {
|
|
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
|
}
|
|
|
|
// 4. Restore state
|
|
await this.restoreState(state);
|
|
|
|
return null; // success
|
|
} catch (err: unknown) {
|
|
// Fallback: create a clean context + blank tab
|
|
try {
|
|
this.pages.clear();
|
|
if (this.context) await this.context.close().catch(() => {});
|
|
|
|
const contextOptions: BrowserContextOptions = {
|
|
viewport: { width: 1280, height: 720 },
|
|
};
|
|
if (this.customUserAgent) {
|
|
contextOptions.userAgent = this.customUserAgent;
|
|
}
|
|
this.context = await this.browser!.newContext(contextOptions);
|
|
await this.newTab();
|
|
this.clearRefs();
|
|
} catch {
|
|
// If even the fallback fails, we're in trouble — but browser is still alive
|
|
}
|
|
return `Context recreation failed: ${err instanceof Error ? err.message : String(err)}. Browser reset to blank tab.`;
|
|
}
|
|
}
|
|
|
|
// ─── 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,
|
|
chromiumSandbox: process.platform !== 'win32',
|
|
});
|
|
} 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
|
|
// (lastSnapshot is NOT cleared — it's a text baseline for diffing)
|
|
page.on('framenavigated', (frame) => {
|
|
if (frame === page.mainFrame()) {
|
|
this.clearRefs();
|
|
}
|
|
});
|
|
|
|
// ─── Dialog auto-handling (prevents browser lockup) ─────
|
|
page.on('dialog', async (dialog) => {
|
|
const entry: DialogEntry = {
|
|
timestamp: Date.now(),
|
|
type: dialog.type(),
|
|
message: dialog.message(),
|
|
defaultValue: dialog.defaultValue() || undefined,
|
|
action: this.dialogAutoAccept ? 'accepted' : 'dismissed',
|
|
response: this.dialogAutoAccept ? (this.dialogPromptText ?? undefined) : undefined,
|
|
};
|
|
addDialogEntry(entry);
|
|
|
|
try {
|
|
if (this.dialogAutoAccept) {
|
|
await dialog.accept(this.dialogPromptText ?? undefined);
|
|
} else {
|
|
await dialog.dismiss();
|
|
}
|
|
} catch {
|
|
// Dialog may have been dismissed by navigation — ignore
|
|
}
|
|
});
|
|
|
|
page.on('console', (msg) => {
|
|
addConsoleEntry({
|
|
timestamp: Date.now(),
|
|
level: msg.type(),
|
|
text: msg.text(),
|
|
});
|
|
});
|
|
|
|
page.on('request', (req) => {
|
|
addNetworkEntry({
|
|
timestamp: Date.now(),
|
|
method: req.method(),
|
|
url: req.url(),
|
|
});
|
|
});
|
|
|
|
page.on('response', (res) => {
|
|
// Find matching request entry and update it (backward scan)
|
|
const url = res.url();
|
|
const status = res.status();
|
|
for (let i = networkBuffer.length - 1; i >= 0; i--) {
|
|
const entry = networkBuffer.get(i);
|
|
if (entry && entry.url === url && !entry.status) {
|
|
networkBuffer.set(i, { ...entry, status, duration: Date.now() - entry.timestamp });
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Capture response sizes via response finished
|
|
page.on('requestfinished', async (req) => {
|
|
try {
|
|
const res = await req.response();
|
|
if (res) {
|
|
const url = req.url();
|
|
const body = await res.body().catch(() => null);
|
|
const size = body ? body.length : 0;
|
|
for (let i = networkBuffer.length - 1; i >= 0; i--) {
|
|
const entry = networkBuffer.get(i);
|
|
if (entry && entry.url === url && !entry.size) {
|
|
networkBuffer.set(i, { ...entry, size });
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
});
|
|
}
|
|
}
|