diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index f6726e1..7c3ffc2 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -15,8 +15,26 @@ * restores state. Falls back to clean slate on any failure. */ -import { chromium, type Browser, type BrowserContext, type Page, type Locator } from 'playwright'; -import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; +import { + chromium, + type Browser, + type BrowserContext, + type ElementHandle, + type Page, + type Request, +} from 'playwright'; +import { + addConsoleEntry, + addDialogEntry, + addNetworkEntry, + type DialogEntry, + type NetworkEntry, +} from './buffers'; +import * as fs from 'fs'; + +interface BrowserSettings { + userAgent?: string | null; +} export class BrowserManager { private browser: Browser | null = null; @@ -26,12 +44,15 @@ export class BrowserManager { private nextTabId: number = 1; private extraHeaders: Record = {}; private customUserAgent: string | null = null; + private readonly settingsFile: string | 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 = new Map(); + // ─── Ref Map (tab → snapshot refs → frozen element handles) ───────────── + private refMaps: Map>> = new Map(); + // Request identity is stable even when multiple requests share the same URL. + private requestEntries: WeakMap = new WeakMap(); // ─── Snapshot Diffing ───────────────────────────────────── // NOT cleared on navigation — it's a text baseline for diffing @@ -41,7 +62,12 @@ export class BrowserManager { private dialogAutoAccept: boolean = true; private dialogPromptText: string | null = null; + constructor(settingsFile?: string | null) { + this.settingsFile = settingsFile ?? process.env.BROWSE_SETTINGS_FILE ?? null; + } + async launch() { + this.loadSettings(); this.browser = await chromium.launch({ headless: true }); // Chromium crash → exit with clear message @@ -51,7 +77,7 @@ export class BrowserManager { process.exit(1); }); - const contextOptions: any = { + const contextOptions: Record = { viewport: { width: 1280, height: 720 }, }; if (this.customUserAgent) { @@ -68,12 +94,17 @@ export class BrowserManager { } async close() { + this.clearAllRefs(); if (this.browser) { // Remove disconnect handler to avoid exit during intentional close this.browser.removeAllListeners('disconnected'); await this.browser.close(); this.browser = null; } + this.context = null; + this.pages.clear(); + this.activeTabId = 0; + this.nextTabId = 1; } /** Health check — verifies Chromium is connected AND responsive */ @@ -102,7 +133,7 @@ export class BrowserManager { this.activeTabId = id; // Wire up console/network/dialog capture - this.wirePageEvents(page); + this.wirePageEvents(id, page); if (url) { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); @@ -116,6 +147,7 @@ export class BrowserManager { const page = this.pages.get(tabId); if (!page) throw new Error(`Tab ${tabId} not found`); + this.clearRefs(tabId); await page.close(); this.pages.delete(tabId); @@ -169,34 +201,59 @@ export class BrowserManager { } // ─── Ref Map ────────────────────────────────────────────── - setRefMap(refs: Map) { - this.refMap = refs; + setRefMap(refs: Map>, tabId: number = this.activeTabId) { + this.clearRefs(tabId); + if (refs.size > 0) { + this.refMaps.set(tabId, refs); + } } - clearRefs() { - this.refMap.clear(); + clearRefs(tabId: number = this.activeTabId) { + const refs = this.refMaps.get(tabId); + if (!refs) return; + for (const handle of refs.values()) { + void handle.dispose().catch(() => {}); + } + this.refMaps.delete(tabId); } /** * 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. + * Returns { handle } for refs or { selector } for CSS selectors. */ - resolveRef(selector: string): { locator: Locator } | { selector: string } { + resolveRef(selector: string): { handle: ElementHandle } | { selector: string } { if (selector.startsWith('@e') || selector.startsWith('@c')) { - const ref = selector.slice(1); // "e3" or "c1" - const locator = this.refMap.get(ref); - if (!locator) { + const ref = selector.slice(1); + const refMap = this.refMaps.get(this.activeTabId); + const handle = refMap?.get(ref); + if (!handle) { throw new Error( `Ref ${selector} not found. Page may have changed — run 'snapshot' to get fresh refs.` ); } - return { locator }; + return { handle }; } return { selector }; } - getRefCount(): number { - return this.refMap.size; + getRefCount(tabId: number = this.activeTabId): number { + return this.refMaps.get(tabId)?.size ?? 0; + } + + rethrowIfStaleRef(selector: string, err: unknown): never { + const message = err instanceof Error ? err.message : String(err); + const isStale = + message.includes('Element is not attached to the DOM') || + message.includes('Execution context was destroyed') || + message.includes('JSHandle is disposed') || + message.includes('Target page, context or browser has been closed'); + + if ((selector.startsWith('@e') || selector.startsWith('@c')) && isStale) { + // Normalize detached-handle errors back to the same stale-ref guidance. + this.removeRef(selector); + throw new Error(`Ref ${selector} not found. Page may have changed — run 'snapshot' to get fresh refs.`); + } + throw err; } // ─── Snapshot Diffing ───────────────────────────────────── @@ -241,6 +298,7 @@ export class BrowserManager { // ─── User Agent ──────────────────────────────────────────── setUserAgent(ua: string) { this.customUserAgent = ua; + this.persistSettings(); } getUserAgent(): string | null { @@ -278,6 +336,8 @@ export class BrowserManager { }); } + this.clearAllRefs(); + // 2. Close old pages and context for (const page of this.pages.values()) { await page.close().catch(() => {}); @@ -286,7 +346,7 @@ export class BrowserManager { await this.context.close().catch(() => {}); // 3. Create new context with updated settings - const contextOptions: any = { + const contextOptions: Record = { viewport: { width: 1280, height: 720 }, }; if (this.customUserAgent) { @@ -309,7 +369,7 @@ export class BrowserManager { const page = await this.context.newPage(); const id = this.nextTabId++; this.pages.set(id, page); - this.wirePageEvents(page); + this.wirePageEvents(id, page); if (saved.url) { await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {}); @@ -343,25 +403,26 @@ export class BrowserManager { this.activeTabId = activeId ?? [...this.pages.keys()][0]; } - // Clear refs — pages are new, locators are stale - this.clearRefs(); - - return null; // success + return null; } catch (err: any) { // Fallback: create a clean context + blank tab try { + this.clearAllRefs(); this.pages.clear(); if (this.context) await this.context.close().catch(() => {}); - const contextOptions: any = { + const contextOptions: Record = { viewport: { width: 1280, height: 720 }, }; if (this.customUserAgent) { contextOptions.userAgent = this.customUserAgent; } - this.context = await this.browser!.newContext(contextOptions); + this.context = await this.browser.newContext(contextOptions); + if (Object.keys(this.extraHeaders).length > 0) { + await this.context.setExtraHTTPHeaders(this.extraHeaders); + } + this.activeTabId = 0; await this.newTab(); - this.clearRefs(); } catch { // If even the fallback fails, we're in trouble — but browser is still alive } @@ -370,15 +431,20 @@ export class BrowserManager { } // ─── 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) + private wirePageEvents(tabId: number, page: Page) { + // Clear this tab's ref map on navigation — refs point to stale elements + // after page change. lastSnapshot is not cleared because it is a text + // baseline for diffing, not a live DOM pointer. page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) { - this.clearRefs(); + this.clearRefs(tabId); } }); + page.on('close', () => { + this.clearRefs(tabId); + }); + // ─── Dialog auto-handling (prevents browser lockup) ───── page.on('dialog', async (dialog) => { const entry: DialogEntry = { @@ -411,43 +477,86 @@ export class BrowserManager { }); page.on('request', (req) => { - addNetworkEntry({ + const entry: NetworkEntry = { timestamp: Date.now(), method: req.method(), url: req.url(), - }); + }; + addNetworkEntry(entry); + this.requestEntries.set(req, entry); }); 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; - } + const entry = this.requestEntries.get(res.request()); + if (entry) { + entry.status = res.status(); } }); - // Capture response sizes via response finished page.on('requestfinished', async (req) => { + const entry = this.requestEntries.get(req); + if (!entry) return; + 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; - } - } + const timing = req.timing(); + if (timing.responseEnd >= 0) { + entry.duration = Math.round(timing.responseEnd); + } + const sizes = await req.sizes().catch(() => null); + if (sizes) { + entry.size = sizes.responseBodySize; } - } catch {} + } catch { + } finally { + this.requestEntries.delete(req); + } }); + + page.on('requestfailed', (req) => { + const entry = this.requestEntries.get(req); + if (entry) { + const timing = req.timing(); + if (timing.responseEnd >= 0) { + entry.duration = Math.round(timing.responseEnd); + } + } + this.requestEntries.delete(req); + }); + } + + private clearAllRefs() { + for (const tabId of [...this.refMaps.keys()]) { + this.clearRefs(tabId); + } + } + + private removeRef(selector: string, tabId: number = this.activeTabId) { + if (!selector.startsWith('@')) return; + const ref = selector.slice(1); + const refs = this.refMaps.get(tabId); + const handle = refs?.get(ref); + if (!refs || !handle) return; + void handle.dispose().catch(() => {}); + refs.delete(ref); + if (refs.size === 0) { + this.refMaps.delete(tabId); + } + } + + private loadSettings() { + if (!this.settingsFile) return; + try { + const settings = JSON.parse(fs.readFileSync(this.settingsFile, 'utf-8')) as BrowserSettings; + this.customUserAgent = settings.userAgent ?? null; + } catch {} + } + + private persistSettings() { + if (!this.settingsFile) return; + fs.writeFileSync( + this.settingsFile, + JSON.stringify({ userAgent: this.customUserAgent } satisfies BrowserSettings, null, 2), + { mode: 0o600 } + ); } } diff --git a/browse/src/cli.ts b/browse/src/cli.ts index dae76fb..2047b7f 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -18,7 +18,10 @@ const BROWSE_PORT = process.env.CONDUCTOR_PORT : parseInt(process.env.BROWSE_PORT || '0', 10); const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : ''; const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`; +// Serialize startup so parallel agent shells don't spawn duplicate daemons. +const LOCK_FILE = `${STATE_FILE}.lock`; const MAX_START_WAIT = 8000; // 8 seconds to start +const LOCK_STALE_MS = 30_000; export function resolveServerScript( env: Record = process.env, @@ -59,6 +62,11 @@ interface ServerState { serverPath: string; } +interface StartLock { + pid: number; + createdAt: number; +} + // ─── State File ──────────────────────────────────────────────── function readState(): ServerState | null { try { @@ -78,12 +86,74 @@ function isProcessAlive(pid: number): boolean { } } +function readLock(): StartLock | null { + try { + return JSON.parse(fs.readFileSync(LOCK_FILE, 'utf-8')) as StartLock; + } catch { + return null; + } +} + +function tryAcquireStartLock(): boolean { + try { + fs.writeFileSync( + LOCK_FILE, + JSON.stringify({ pid: process.pid, createdAt: Date.now() } satisfies StartLock), + { flag: 'wx', mode: 0o600 } + ); + return true; + } catch (err: any) { + if (err.code === 'EEXIST') return false; + throw err; + } +} + +function clearOwnedStartLock() { + const lock = readLock(); + if (lock?.pid === process.pid) { + try { fs.unlinkSync(LOCK_FILE); } catch {} + } +} + +function clearStaleStartLock() { + const lock = readLock(); + if (!lock) return; + if (!isProcessAlive(lock.pid) || Date.now() - lock.createdAt > LOCK_STALE_MS) { + try { fs.unlinkSync(LOCK_FILE); } catch {} + } +} + +async function fetchHealth(state: ServerState, timeout = 2000): Promise<{ status: string } | null> { + try { + const resp = await fetch(`http://127.0.0.1:${state.port}/health`, { + signal: AbortSignal.timeout(timeout), + }); + if (!resp.ok) return null; + return await resp.json() as { status: string }; + } catch { + return null; + } +} + +async function getHealthyState(): Promise { + const state = readState(); + if (!state || !isProcessAlive(state.pid)) { + return null; + } + const health = await fetchHealth(state); + if (health?.status === 'healthy') { + return state; + } + return null; +} + // ─── Server Lifecycle ────────────────────────────────────────── -async function startServer(): Promise { - // Clean up stale state file - try { fs.unlinkSync(STATE_FILE); } catch {} +async function spawnServerProcess(): Promise { + const existing = readState(); + if (existing && !isProcessAlive(existing.pid)) { + try { fs.unlinkSync(STATE_FILE); } catch {} + } - // Start server as detached background process const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env }, @@ -92,18 +162,18 @@ async function startServer(): Promise { // Don't hold the CLI open proc.unref(); - // Wait for state file to appear const start = Date.now(); while (Date.now() - start < MAX_START_WAIT) { const state = readState(); if (state && isProcessAlive(state.pid)) { - return state; + const health = await fetchHealth(state, 1000); + if (health?.status === 'healthy') { + return state; + } } await Bun.sleep(100); } - // If we get here, server didn't start in time - // Try to read stderr for error message const stderr = proc.stderr; if (stderr) { const reader = stderr.getReader(); @@ -116,31 +186,59 @@ async function startServer(): Promise { throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`); } -async function ensureServer(): Promise { - const state = readState(); +async function startServer(): Promise { + const start = Date.now(); + while (Date.now() - start < MAX_START_WAIT) { + const healthy = await getHealthyState(); + if (healthy) return healthy; - if (state && isProcessAlive(state.pid)) { - // Server appears alive — do a health check - try { - const resp = await fetch(`http://127.0.0.1:${state.port}/health`, { - signal: AbortSignal.timeout(2000), - }); - if (resp.ok) { - const health = await resp.json() as any; - if (health.status === 'healthy') { - return state; - } + // Another CLI process may already be starting the daemon. Wait for it + // unless the lock is stale, then take over. + clearStaleStartLock(); + if (tryAcquireStartLock()) { + try { + const healthyAfterLock = await getHealthyState(); + if (healthyAfterLock) return healthyAfterLock; + return await spawnServerProcess(); + } finally { + clearOwnedStartLock(); } - } catch { - // Health check failed — server is dead or unhealthy } + + await Bun.sleep(100); + } + + const healthy = await getHealthyState(); + if (healthy) return healthy; + throw new Error('[browse] Timed out waiting for server startup'); +} + +async function ensureServer(): Promise { + const healthy = await getHealthyState(); + if (healthy) { + return healthy; } - // Need to (re)start console.error('[browse] Starting server...'); return startServer(); } +async function waitForServerStop(pid: number, timeout = MAX_START_WAIT): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (!isProcessAlive(pid)) { + return; + } + await Bun.sleep(100); + } + throw new Error('[browse] Timed out waiting for server shutdown'); +} + +function writeCommandOutput(text: string) { + process.stdout.write(text); + if (!text.endsWith('\n')) process.stdout.write('\n'); +} + // ─── Command Dispatch ────────────────────────────────────────── async function sendCommand(state: ServerState, command: string, args: string[], retries = 0): Promise { const body = JSON.stringify({ command, args }); @@ -169,19 +267,38 @@ async function sendCommand(state: ServerState, command: string, args: string[], const text = await resp.text(); if (resp.ok) { - process.stdout.write(text); - if (!text.endsWith('\n')) process.stdout.write('\n'); - } else { - // Try to parse as JSON error - try { - const err = JSON.parse(text); - console.error(err.error || text); - if (err.hint) console.error(err.hint); - } catch { - console.error(text); + writeCommandOutput(text); + + if (command === 'stop') { + // "stop" returns success before the server exits. Wait for the daemon + // and state file to disappear so the CLI only exits green on a real stop. + await waitForServerStop(state.pid); + return; } - process.exit(1); + + if (command === 'restart') { + // "restart" is "clean stop, then ensure a fresh daemon exists" — not + // "drop the socket and hope the next command recovers it." + await waitForServerStop(state.pid); + const newState = await ensureServer(); + if (newState.pid === state.pid) { + throw new Error('[browse] Restart did not replace the server process'); + } + return; + } + + return; } + + // Try to parse as JSON error + try { + const err = JSON.parse(text); + console.error(err.error || text); + if (err.hint) console.error(err.hint); + } catch { + console.error(text); + } + process.exit(1); } catch (err: any) { if (err.name === 'AbortError') { console.error('[browse] Command timed out after 30s'); @@ -227,7 +344,7 @@ Snapshot: snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o path] [-C] Compare: diff Multi-step: chain (reads JSON from stdin) Tabs: tabs | tab | newtab [url] | closetab [id] -Server: status | cookie = | header : +Server: status | cookie = [origin] | header : useragent | stop | restart Dialogs: dialog-accept [text] | dialog-dismiss diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index 8d3f9eb..02ee4bb 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -93,14 +93,20 @@ export async function handleMetaCommand( } case 'stop': { - await shutdown(); + // Return the HTTP response first so the CLI sees a clean stop, + // then shut down on the next tick. + setTimeout(() => { + void shutdown(); + }, 0); return 'Server stopped'; } case 'restart': { - // Signal that we want a restart — the CLI will detect exit and restart - console.log('[browse] Restart requested. Exiting for CLI to restart.'); - await shutdown(); + // Signal that we want a restart — return success first, then exit. + // The CLI waits for shutdown and starts a fresh daemon immediately. + setTimeout(() => { + void shutdown(); + }, 0); return 'Restarting...'; } diff --git a/browse/src/read-commands.ts b/browse/src/read-commands.ts index 31d1018..1ea2564 100644 --- a/browse/src/read-commands.ts +++ b/browse/src/read-commands.ts @@ -62,8 +62,12 @@ export async function handleReadCommand( const selector = args[0]; if (selector) { const resolved = bm.resolveRef(selector); - if ('locator' in resolved) { - return await resolved.locator.innerHTML({ timeout: 5000 }); + if ('handle' in resolved) { + try { + return await resolved.handle.innerHTML(); + } catch (err) { + bm.rethrowIfStaleRef(selector, err); + } } return await page.innerHTML(resolved.selector); } @@ -136,12 +140,16 @@ export async function handleReadCommand( const [selector, property] = args; if (!selector || !property) throw new Error('Usage: browse css '); const resolved = bm.resolveRef(selector); - if ('locator' in resolved) { - const value = await resolved.locator.evaluate( - (el, prop) => getComputedStyle(el).getPropertyValue(prop), - property - ); - return value; + if ('handle' in resolved) { + try { + const value = await resolved.handle.evaluate( + (el, prop) => getComputedStyle(el as Element).getPropertyValue(prop), + property + ); + return value; + } catch (err) { + bm.rethrowIfStaleRef(selector, err); + } } const value = await page.evaluate( ([sel, prop]) => { @@ -158,15 +166,19 @@ export async function handleReadCommand( const selector = args[0]; if (!selector) throw new Error('Usage: browse attrs '); const resolved = bm.resolveRef(selector); - if ('locator' in resolved) { - const attrs = await resolved.locator.evaluate((el) => { - const result: Record = {}; - for (const attr of el.attributes) { - result[attr.name] = attr.value; - } - return result; - }); - return JSON.stringify(attrs, null, 2); + if ('handle' in resolved) { + try { + const attrs = await resolved.handle.evaluate((el) => { + const result: Record = {}; + for (const attr of (el as Element).attributes) { + result[attr.name] = attr.value; + } + return result; + }); + return JSON.stringify(attrs, null, 2); + } catch (err) { + bm.rethrowIfStaleRef(selector, err); + } } const attrs = await page.evaluate((sel) => { const el = document.querySelector(sel); @@ -222,13 +234,48 @@ export async function handleReadCommand( if (!property || !selector) throw new Error('Usage: browse is \nProperties: visible, hidden, enabled, disabled, checked, editable, focused'); const resolved = bm.resolveRef(selector); - let locator; - if ('locator' in resolved) { - locator = resolved.locator; - } else { - locator = page.locator(resolved.selector); + if ('handle' in resolved) { + try { + const state = await resolved.handle.evaluate((el, prop) => { + const node = el as HTMLElement & { + disabled?: boolean; + checked?: boolean; + readOnly?: boolean; + isContentEditable?: boolean; + }; + const style = window.getComputedStyle(node); + const visible = + style.visibility !== 'hidden' && + style.display !== 'none' && + (node.getClientRects().length > 0 || style.position === 'fixed'); + + switch (prop) { + case 'visible': + return visible; + case 'hidden': + return !visible; + case 'enabled': + return !node.disabled; + case 'disabled': + return Boolean(node.disabled); + case 'checked': + return Boolean(node.checked); + case 'editable': + return !node.disabled && !node.readOnly && (node.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(node.tagName)); + case 'focused': + return node === document.activeElement; + default: + throw new Error(`Unknown property: ${prop}. Use: visible, hidden, enabled, disabled, checked, editable, focused`); + } + }, property); + return String(state); + } catch (err) { + bm.rethrowIfStaleRef(selector, err); + } } + const locator = page.locator(resolved.selector); + switch (property) { case 'visible': return String(await locator.isVisible()); case 'hidden': return String(await locator.isHidden()); diff --git a/browse/src/server.ts b/browse/src/server.ts index 0825b17..188f469 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -25,7 +25,9 @@ const BROWSE_PORT = process.env.CONDUCTOR_PORT : parseInt(process.env.BROWSE_PORT || '0', 10); // 0 = auto-scan const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : ''; const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`; +const SETTINGS_FILE = process.env.BROWSE_SETTINGS_FILE || `${STATE_FILE}.settings.json`; const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min +const SHUTDOWN_GRACE_MS = 1000; function validateAuth(req: Request): boolean { const header = req.headers.get('authorization'); @@ -129,7 +131,7 @@ export const META_COMMANDS = new Set([ ]); // ─── Server ──────────────────────────────────────────────────── -const browserManager = new BrowserManager(); +const browserManager = new BrowserManager(SETTINGS_FILE); let isShuttingDown = false; // Find port: deterministic from CONDUCTOR_PORT, or scan range @@ -232,9 +234,18 @@ async function shutdown() { clearInterval(idleCheckInterval); await flushBuffers(); // Final flush (async now) - await browserManager.close(); - - // Clean up state file + try { + // Graceful close is best-effort here. If Chromium hangs, still exit so + // stop/restart cannot wedge forever. + await Promise.race([ + browserManager.close(), + Bun.sleep(SHUTDOWN_GRACE_MS), + ]); + } catch {} + + // Keep the state file until exit. Other CLI processes treat it as proof that + // this PID still owns the port, so deleting it early can trigger a bogus + // replacement start while the old daemon is still shutting down. try { fs.unlinkSync(STATE_FILE); } catch {} process.exit(0); diff --git a/browse/src/snapshot.ts b/browse/src/snapshot.ts index b0c7b80..6e9addf 100644 --- a/browse/src/snapshot.ts +++ b/browse/src/snapshot.ts @@ -1,11 +1,11 @@ /** * Snapshot command — accessibility tree with ref-based element selection * - * Architecture (Locator map — no DOM mutation): + * Architecture (frozen handle map — no DOM mutation): * 1. page.locator(scope).ariaSnapshot() → YAML-like accessibility tree * 2. Parse tree, assign refs @e1, @e2, ... * 3. Build Playwright Locator for each ref (getByRole + nth) - * 4. Store Map on BrowserManager + * 4. Resolve each locator to an ElementHandle and store it per tab * 5. Return compact text output with refs prepended * * Extended features: @@ -14,12 +14,13 @@ * --output / -o: Output path for annotated screenshot * -C / --cursor-interactive: Scan for cursor:pointer/onclick/tabindex elements * - * Later: "click @e3" → look up Locator → locator.click() + * Later: "click @e3" → look up frozen handle → handle.click() */ -import type { Page, Locator } from 'playwright'; +import type { ElementHandle, Locator } from 'playwright'; import type { BrowserManager } from './browser-manager'; import * as Diff from 'diff'; +import * as path from 'path'; // Roles considered "interactive" for the -i flag const INTERACTIVE_ROLES = new Set([ @@ -44,11 +45,15 @@ interface ParsedNode { indent: number; role: string; name: string | null; - props: string; // e.g., "[level=1]" - children: string; // inline text content after ":" + props: string; + children: string; rawLine: string; } +function unescapeQuotedText(value: string): string { + return value.replace(/\\\\/g, '\\').replace(/\\"/g, '"'); +} + /** * Parse CLI args into SnapshotOptions */ @@ -110,16 +115,14 @@ export function parseSnapshotArgs(args: string[]): SnapshotOptions { * - combobox "Role": */ function parseLine(line: string): ParsedNode | null { - // Match: (indent)(- )(role)( "name")?( [props])?(: inline)? - const match = line.match(/^(\s*)-\s+(\w+)(?:\s+"([^"]*)")?(?:\s+(\[.*?\]))?\s*(?::\s*(.*))?$/); + const match = line.match(/^(\s*)-\s+(\w+)(?:\s+"((?:[^"\\]|\\.)*)")?(?:\s+(\[.*?\]))?\s*(?::\s*(.*))?$/); if (!match) { - // Skip metadata lines like "- /url: /a" return null; } return { indent: match[1].length, role: match[2], - name: match[3] ?? null, + name: match[3] ? unescapeQuotedText(match[3]) : null, props: match[4] || '', children: match[5]?.trim() || '', rawLine: line, @@ -136,7 +139,6 @@ export async function handleSnapshot( const opts = parseSnapshotArgs(args); const page = bm.getPage(); - // Get accessibility tree via ariaSnapshot let rootLocator: Locator; if (opts.selector) { rootLocator = page.locator(opts.selector); @@ -152,17 +154,14 @@ export async function handleSnapshot( return '(no accessible elements found)'; } - // Parse the ariaSnapshot output const lines = ariaText.split('\n'); - const refMap = new Map(); + const refMap = new Map>(); const output: string[] = []; let refCounter = 1; - // Track role+name occurrences for nth() disambiguation const roleNameCounts = new Map(); const roleNameSeen = new Map(); - // First pass: count role+name pairs for disambiguation for (const line of lines) { const node = parseLine(line); if (!node) continue; @@ -170,7 +169,11 @@ export async function handleSnapshot( roleNameCounts.set(key, (roleNameCounts.get(key) || 0) + 1); } - // Second pass: assign refs and build locators + function markRoleNameSeen(node: ParsedNode) { + const key = `${node.role}:${node.name || ''}`; + roleNameSeen.set(key, (roleNameSeen.get(key) || 0) + 1); + } + for (const line of lines) { const node = parseLine(line); if (!node) continue; @@ -178,25 +181,22 @@ export async function handleSnapshot( const depth = Math.floor(node.indent / 2); const isInteractive = INTERACTIVE_ROLES.has(node.role); - // Depth filter - if (opts.depth !== undefined && depth > opts.depth) continue; + if (opts.depth !== undefined && depth > opts.depth) { + markRoleNameSeen(node); + continue; + } - // Interactive filter: skip non-interactive but still count for locator indices if (opts.interactive && !isInteractive) { - // Still track for nth() counts - const key = `${node.role}:${node.name || ''}`; - roleNameSeen.set(key, (roleNameSeen.get(key) || 0) + 1); + markRoleNameSeen(node); continue; } - // Compact filter: skip elements with no name and no inline content that aren't interactive - if (opts.compact && !isInteractive && !node.name && !node.children) continue; + if (opts.compact && !isInteractive && !node.name && !node.children) { + markRoleNameSeen(node); + continue; + } - // Assign ref - const ref = `e${refCounter++}`; const indent = ' '.repeat(depth); - - // Build Playwright locator const key = `${node.role}:${node.name || ''}`; const seenIndex = roleNameSeen.get(key) || 0; roleNameSeen.set(key, seenIndex + 1); @@ -213,23 +213,30 @@ export async function handleSnapshot( }); } - // Disambiguate with nth() if multiple elements share role+name if (totalCount > 1) { locator = locator.nth(seenIndex); } - refMap.set(ref, locator); + let handle: ElementHandle | null = null; + try { + const count = await locator.count(); + if (count !== 1) continue; + handle = await locator.elementHandle({ timeout: 100 }); + } catch { + continue; + } + if (!handle) continue; + + const ref = `e${refCounter++}`; + refMap.set(ref, handle); - // Format output line let outputLine = `${indent}@${ref} [${node.role}]`; if (node.name) outputLine += ` "${node.name}"`; if (node.props) outputLine += ` ${node.props}`; if (node.children) outputLine += `: ${node.children}`; - output.push(outputLine); } - // ─── Cursor-interactive scan (-C) ───────────────────────── if (opts.cursorInteractive) { try { const cursorElements = await page.evaluate(() => { @@ -241,9 +248,7 @@ export async function handleSnapshot( const allElements = document.querySelectorAll('*'); for (const el of allElements) { - // Skip standard interactive elements (already in ARIA tree) if (STANDARD_INTERACTIVE.has(el.tagName)) continue; - // Skip hidden elements if (!(el as HTMLElement).offsetParent && el.tagName !== 'BODY') continue; const style = getComputedStyle(el); @@ -253,10 +258,8 @@ export async function handleSnapshot( const hasRole = el.hasAttribute('role'); if (!hasCursorPointer && !hasOnclick && !hasTabindex) continue; - // Skip if it has an ARIA role (likely already captured) if (hasRole) continue; - // Build deterministic nth-child CSS path const parts: string[] = []; let current: Element | null = el; while (current && current !== document.documentElement) { @@ -285,10 +288,18 @@ export async function handleSnapshot( output.push('── cursor-interactive (not in ARIA tree) ──'); let cRefCounter = 1; for (const elem of cursorElements) { - const ref = `c${cRefCounter++}`; - const locator = page.locator(elem.selector); - refMap.set(ref, locator); - output.push(`@${ref} [${elem.reason}] "${elem.text}"`); + try { + const locator = page.locator(elem.selector); + const count = await locator.count(); + if (count !== 1) continue; + const handle = await locator.elementHandle({ timeout: 100 }); + if (!handle) continue; + const ref = `c${cRefCounter++}`; + refMap.set(ref, handle); + output.push(`@${ref} [${elem.reason}] "${elem.text}"`); + } catch { + continue; + } } } } catch { @@ -297,7 +308,6 @@ export async function handleSnapshot( } } - // Store ref map on BrowserManager bm.setRefMap(refMap); if (output.length === 0) { @@ -306,31 +316,28 @@ export async function handleSnapshot( const snapshotText = output.join('\n'); - // ─── Annotated screenshot (-a) ──────────────────────────── if (opts.annotate) { const screenshotPath = opts.outputPath || '/tmp/browse-annotated.png'; - // Validate output path (consistent with screenshot/pdf/responsive) - const resolvedPath = require('path').resolve(screenshotPath); + const resolvedPath = path.resolve(screenshotPath); const safeDirs = ['/tmp', process.cwd()]; - if (!safeDirs.some((dir: string) => resolvedPath === dir || resolvedPath.startsWith(dir + '/'))) { + if (!safeDirs.some((dir) => resolvedPath === dir || resolvedPath.startsWith(dir + '/'))) { throw new Error(`Path must be within: ${safeDirs.join(', ')}`); } try { - // Inject overlay divs at each ref's bounding box const boxes: Array<{ ref: string; box: { x: number; y: number; width: number; height: number } }> = []; - for (const [ref, locator] of refMap) { + for (const [ref, handle] of refMap) { try { - const box = await locator.boundingBox({ timeout: 1000 }); + const box = await handle.boundingBox(); if (box) { boxes.push({ ref: `@${ref}`, box }); } } catch { - // Element may be offscreen or hidden — skip + continue; } } - await page.evaluate((boxes) => { - for (const { ref, box } of boxes) { + await page.evaluate((boxesToDraw) => { + for (const { ref, box } of boxesToDraw) { const overlay = document.createElement('div'); overlay.className = '__browse_annotation__'; overlay.style.cssText = ` @@ -350,29 +357,26 @@ export async function handleSnapshot( await page.screenshot({ path: screenshotPath, fullPage: true }); - // Always remove overlays await page.evaluate(() => { - document.querySelectorAll('.__browse_annotation__').forEach(el => el.remove()); + document.querySelectorAll('.__browse_annotation__').forEach((el) => el.remove()); }); output.push(''); output.push(`[annotated screenshot: ${screenshotPath}]`); } catch { - // Remove overlays even on screenshot failure try { await page.evaluate(() => { - document.querySelectorAll('.__browse_annotation__').forEach(el => el.remove()); + document.querySelectorAll('.__browse_annotation__').forEach((el) => el.remove()); }); } catch {} } } - // ─── Diff mode (-D) ─────────────────────────────────────── if (opts.diff) { const lastSnapshot = bm.getLastSnapshot(); if (!lastSnapshot) { bm.setLastSnapshot(snapshotText); - return snapshotText + '\n\n(no previous snapshot to diff against — this snapshot stored as baseline)'; + return `${snapshotText}\n\n(no previous snapshot to diff against — this snapshot stored as baseline)`; } const changes = Diff.diffLines(lastSnapshot, snapshotText); @@ -380,7 +384,7 @@ export async function handleSnapshot( for (const part of changes) { const prefix = part.added ? '+' : part.removed ? '-' : ' '; - const diffLines = part.value.split('\n').filter(l => l.length > 0); + const diffLines = part.value.split('\n').filter((line) => line.length > 0); for (const line of diffLines) { diffOutput.push(`${prefix} ${line}`); } @@ -390,8 +394,6 @@ export async function handleSnapshot( return diffOutput.join('\n'); } - // Store for future diffs bm.setLastSnapshot(snapshotText); - return output.join('\n'); } diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 08c9425..79967aa 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -45,8 +45,22 @@ export async function handleWriteCommand( const selector = args[0]; if (!selector) throw new Error('Usage: browse click '); const resolved = bm.resolveRef(selector); - if ('locator' in resolved) { - await resolved.locator.click({ timeout: 5000 }); + if ('handle' in resolved) { + const beforeUrl = page.url(); + try { + await resolved.handle.click({ timeout: 5000 }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const maybeNavigationSettled = + (message.includes('Execution context was destroyed') || + message.includes('Target page, context or browser has been closed')) && + page.url() !== beforeUrl; + if (maybeNavigationSettled) { + await page.waitForLoadState('domcontentloaded').catch(() => {}); + } else { + bm.rethrowIfStaleRef(selector, err); + } + } } else { await page.click(resolved.selector, { timeout: 5000 }); } @@ -58,10 +72,14 @@ export async function handleWriteCommand( case 'fill': { const [selector, ...valueParts] = args; const value = valueParts.join(' '); - if (!selector || !value) throw new Error('Usage: browse fill '); + if (!selector || args.length < 2) throw new Error('Usage: browse fill '); const resolved = bm.resolveRef(selector); - if ('locator' in resolved) { - await resolved.locator.fill(value, { timeout: 5000 }); + if ('handle' in resolved) { + try { + await resolved.handle.fill(value, { timeout: 5000 }); + } catch (err) { + bm.rethrowIfStaleRef(selector, err); + } } else { await page.fill(resolved.selector, value, { timeout: 5000 }); } @@ -71,10 +89,14 @@ export async function handleWriteCommand( case 'select': { const [selector, ...valueParts] = args; const value = valueParts.join(' '); - if (!selector || !value) throw new Error('Usage: browse select '); + if (!selector || args.length < 2) throw new Error('Usage: browse select '); const resolved = bm.resolveRef(selector); - if ('locator' in resolved) { - await resolved.locator.selectOption(value, { timeout: 5000 }); + if ('handle' in resolved) { + try { + await resolved.handle.selectOption(value, { timeout: 5000 }); + } catch (err) { + bm.rethrowIfStaleRef(selector, err); + } } else { await page.selectOption(resolved.selector, value, { timeout: 5000 }); } @@ -85,8 +107,12 @@ export async function handleWriteCommand( const selector = args[0]; if (!selector) throw new Error('Usage: browse hover '); const resolved = bm.resolveRef(selector); - if ('locator' in resolved) { - await resolved.locator.hover({ timeout: 5000 }); + if ('handle' in resolved) { + try { + await resolved.handle.hover({ timeout: 5000 }); + } catch (err) { + bm.rethrowIfStaleRef(selector, err); + } } else { await page.hover(resolved.selector, { timeout: 5000 }); } @@ -111,8 +137,12 @@ export async function handleWriteCommand( const selector = args[0]; if (selector) { const resolved = bm.resolveRef(selector); - if ('locator' in resolved) { - await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 }); + if ('handle' in resolved) { + try { + await resolved.handle.scrollIntoViewIfNeeded({ timeout: 5000 }); + } catch (err) { + bm.rethrowIfStaleRef(selector, err); + } } else { await page.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: 5000 }); } @@ -140,8 +170,12 @@ export async function handleWriteCommand( } const timeout = args[1] ? parseInt(args[1], 10) : 15000; const resolved = bm.resolveRef(selector); - if ('locator' in resolved) { - await resolved.locator.waitFor({ state: 'visible', timeout }); + if ('handle' in resolved) { + try { + await resolved.handle.waitForElementState('visible', { timeout }); + } catch (err) { + bm.rethrowIfStaleRef(selector, err); + } } else { await page.waitForSelector(resolved.selector, { timeout }); } @@ -158,16 +192,28 @@ export async function handleWriteCommand( case 'cookie': { const cookieStr = args[0]; - if (!cookieStr || !cookieStr.includes('=')) throw new Error('Usage: browse cookie ='); + if (!cookieStr || !cookieStr.includes('=')) throw new Error('Usage: browse cookie = [origin]'); const eq = cookieStr.indexOf('='); const name = cookieStr.slice(0, eq); const value = cookieStr.slice(eq + 1); - const url = new URL(page.url()); + let cookieUrl: string; + if (args[1]) { + try { + cookieUrl = new URL(args[1]).origin; + } catch { + throw new Error('Usage: browse cookie = [origin]'); + } + } else { + const currentUrl = page.url(); + if (currentUrl === 'about:blank') { + throw new Error('Usage: browse cookie = [origin]'); + } + cookieUrl = new URL(currentUrl).origin; + } await page.context().addCookies([{ name, value, - domain: url.hostname, - path: '/', + url: cookieUrl, }]); return `Cookie set: ${name}=****`; } @@ -205,8 +251,8 @@ export async function handleWriteCommand( } const resolved = bm.resolveRef(selector); - if ('locator' in resolved) { - await resolved.locator.setInputFiles(filePaths); + if ('handle' in resolved) { + await resolved.handle.setInputFiles(filePaths); } else { await page.locator(resolved.selector).setInputFiles(filePaths); } diff --git a/browse/test/commands.test.ts b/browse/test/commands.test.ts index 312b8ce..8baf1c1 100644 --- a/browse/test/commands.test.ts +++ b/browse/test/commands.test.ts @@ -36,6 +36,86 @@ afterAll(() => { setTimeout(() => process.exit(0), 500); }); +interface CliResult { + code: number; + stdout: string; + stderr: string; +} + +function reservePort(): number { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch: () => new Response('ok'), + }); + const { port } = server; + server.stop(); + return port; +} + +function readJson(filePath: string): T | null { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as T; + } catch { + return null; + } +} + +async function runCliCommand(args: string[], envOverrides: Record, timeout = 20000): Promise { + const cliPath = path.resolve(__dirname, '../src/cli.ts'); + return await new Promise((resolve) => { + const proc = spawn('bun', ['run', cliPath, ...args], { + timeout, + env: { + ...process.env, + ...envOverrides, + }, + }); + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (d) => stdout += d.toString()); + proc.stderr.on('data', (d) => stderr += d.toString()); + proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr })); + }); +} + +async function waitFor(fn: () => T | null | undefined, timeout = 5000, interval = 50): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + const value = fn(); + if (value) return value; + await Bun.sleep(interval); + } + return null; +} + +async function cleanupCliState(stateFile: string) { + const state = readJson<{ pid?: number }>(stateFile); + if (state?.pid) { + try { process.kill(state.pid, 'SIGTERM'); } catch {} + await waitFor(() => { + try { + process.kill(state.pid!, 0); + return null; + } catch { + return true; + } + }, 3000); + } + try { fs.unlinkSync(stateFile); } catch {} + try { fs.unlinkSync(`${stateFile}.lock`); } catch {} + try { fs.unlinkSync(`${stateFile}.settings.json`); } catch {} +} + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + // ─── Navigation ───────────────────────────────────────────────── describe('Navigation', () => { @@ -230,6 +310,28 @@ describe('Interaction', () => { const val = await handleReadCommand('js', ['document.querySelector("#name").value'], bm); expect(val).toBe('John Doe'); }); + + test('fill accepts empty string values', async () => { + await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm); + await handleWriteCommand('fill', ['#email', 'filled@example.com'], bm); + + const result = await handleWriteCommand('fill', ['#email', ''], bm); + expect(result).toContain('Filled'); + + const value = await handleReadCommand('js', ['document.querySelector("#email").value'], bm); + expect(value).toBe(''); + }); + + test('select accepts empty string values', async () => { + await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm); + await handleWriteCommand('select', ['#role', 'admin'], bm); + + const result = await handleWriteCommand('select', ['#role', ''], bm); + expect(result).toContain('Selected'); + + const value = await handleReadCommand('js', ['document.querySelector("#role").value'], bm); + expect(value).toBe(''); + }); }); // ─── SPA / Console / Network ─────────────────────────────────── @@ -268,6 +370,51 @@ describe('SPA and buffers', () => { const result = await handleReadCommand('network', ['--clear'], bm); expect(result).toContain('cleared'); }); + + test('network keeps same-url requests paired with their own size and duration', async () => { + networkBuffer.clear(); + let apiCount = 0; + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + async fetch(req) { + const url = new URL(req.url); + if (url.pathname === '/double.html') { + return new Response(``, { + headers: { 'Content-Type': 'text/html' }, + }); + } + if (url.pathname === '/api/data') { + apiCount += 1; + if (apiCount === 1) { + await Bun.sleep(15); + return new Response('small', { + headers: { 'Content-Type': 'text/plain' }, + }); + } + await Bun.sleep(200); + return new Response('X'.repeat(5000), { + headers: { 'Content-Type': 'text/plain' }, + }); + } + return new Response('Not Found', { status: 404 }); + }, + }); + + await handleWriteCommand('goto', [`http://127.0.0.1:${server.port}/double.html`], bm); + await Bun.sleep(700); + + const entries = networkBuffer.toArray().filter((entry) => entry.url.endsWith('/api/data')); + try { server.stop(); } catch {} + + expect(entries).toHaveLength(2); + expect(entries[0].size).toBe(5); + expect(entries[1].size).toBe(5000); + expect(entries[0].duration).toBeLessThan(entries[1].duration!); + }); }); // ─── Cookies / Storage ────────────────────────────────────────── @@ -286,6 +433,27 @@ describe('Cookies and storage', () => { const storage = JSON.parse(result); expect(storage.localStorage.testKey).toBe('testValue'); }); + + test('cookie supports explicit origin before first navigation', async () => { + const freshBrowser = new BrowserManager(); + await freshBrowser.launch(); + + const result = await handleWriteCommand('cookie', ['session=abc123', baseUrl], freshBrowser); + expect(result).toContain('Cookie set'); + + await handleWriteCommand('goto', [baseUrl + '/basic.html'], freshBrowser); + const cookies = JSON.parse(await handleReadCommand('cookies', [], freshBrowser)); + expect(cookies.some((cookie: any) => cookie.name === 'session' && cookie.value === 'abc123')).toBe(true); + }); + + test('cookie on about:blank without explicit origin returns guidance error', async () => { + const freshBrowser = new BrowserManager(); + await freshBrowser.launch(); + + await expect(handleWriteCommand('cookie', ['session=abc123'], freshBrowser)).rejects.toThrow( + 'Usage: browse cookie = [origin]' + ); + }); }); // ─── Performance ──────────────────────────────────────────────── @@ -490,6 +658,214 @@ describe('CLI lifecycle', () => { expect(result.stdout).toContain('Status: healthy'); expect(result.stderr).toContain('Starting server'); }, 20000); + + test('stop exits cleanly and removes the state file', async () => { + const stateFile = `/tmp/browse-stop-state-${Date.now()}.json`; + const port = reservePort(); + const env = { + BROWSE_STATE_FILE: stateFile, + BROWSE_PORT: String(port), + }; + + try { + const started = await runCliCommand(['status'], env); + const startedState = readJson<{ pid: number }>(stateFile); + expect(started.code).toBe(0); + expect(startedState?.pid).toBeTruthy(); + + const stop = await runCliCommand(['stop'], env); + const pidGone = await waitFor(() => { + try { + process.kill(startedState!.pid, 0); + return null; + } catch { + return true; + } + }, 5000); + + expect(stop.code).toBe(0); + expect(stop.stdout).toContain('Server stopped'); + expect(fs.existsSync(stateFile)).toBe(false); + expect(pidGone).toBe(true); + } finally { + await cleanupCliState(stateFile); + } + }, 20000); + + test('restart exits cleanly and replaces the daemon pid', async () => { + const stateFile = `/tmp/browse-restart-state-${Date.now()}.json`; + const port = reservePort(); + const env = { + BROWSE_STATE_FILE: stateFile, + BROWSE_PORT: String(port), + }; + + try { + const started = await runCliCommand(['status'], env); + const beforeState = readJson<{ pid: number }>(stateFile); + expect(started.code).toBe(0); + expect(beforeState?.pid).toBeTruthy(); + + const restart = await runCliCommand(['restart'], env); + const afterState = await waitFor(() => { + const state = readJson<{ pid: number }>(stateFile); + return state && state.pid !== beforeState!.pid ? state : null; + }, 8000); + + expect(restart.code).toBe(0); + expect(restart.stdout).toContain('Restarting'); + expect(afterState?.pid).toBeTruthy(); + expect(afterState?.pid).not.toBe(beforeState?.pid); + } finally { + await cleanupCliState(stateFile); + } + }, 25000); + + test('parallel status calls start the daemon once', async () => { + const root = fs.mkdtempSync('/tmp/gstack-browse-wrapper-'); + const stateFile = path.join(root, 'browse-state.json'); + const startLog = path.join(root, 'server-starts.log'); + const wrapperPath = path.join(root, 'server-wrapper.ts'); + const realServerPath = path.resolve(__dirname, '../src/server.ts'); + const port = reservePort(); + + fs.writeFileSync(wrapperPath, ` + import * as fs from 'fs'; + fs.appendFileSync(${JSON.stringify(startLog)}, 'start\\n'); + await import(${JSON.stringify(realServerPath)}); + `); + + const env = { + BROWSE_STATE_FILE: stateFile, + BROWSE_PORT: String(port), + BROWSE_SERVER_SCRIPT: wrapperPath, + }; + + try { + const [first, second] = await Promise.all([ + runCliCommand(['status'], env), + runCliCommand(['status'], env), + ]); + + const state = readJson<{ pid: number }>(stateFile); + const starts = fs.readFileSync(startLog, 'utf-8').trim().split('\n').filter(Boolean); + + expect(first.code).toBe(0); + expect(second.code).toBe(0); + expect(state?.pid).toBeTruthy(); + expect(starts).toHaveLength(1); + } finally { + await cleanupCliState(stateFile); + fs.rmSync(root, { recursive: true, force: true }); + } + }, 25000); + + test('useragent applies after restart', async () => { + const stateFile = `/tmp/browse-useragent-state-${Date.now()}.json`; + const port = reservePort(); + const env = { + BROWSE_STATE_FILE: stateFile, + BROWSE_PORT: String(port), + }; + + try { + expect((await runCliCommand(['status'], env)).code).toBe(0); + expect((await runCliCommand(['useragent', 'MyAgent/1.0'], env)).code).toBe(0); + expect((await runCliCommand(['restart'], env)).code).toBe(0); + + const js = await runCliCommand(['js', 'navigator.userAgent'], env); + expect(js.code).toBe(0); + expect(js.stdout).toContain('MyAgent/1.0'); + } finally { + await cleanupCliState(stateFile); + } + }, 25000); + + test('stop ignores recreated state file once the target pid exits', async () => { + const stateFile = `/tmp/browse-stop-recreated-state-${Date.now()}.json`; + const port = reservePort(); + const env = { + BROWSE_STATE_FILE: stateFile, + BROWSE_PORT: String(port), + }; + + try { + const started = await runCliCommand(['status'], env); + const startedState = readJson<{ pid: number; port: number; token: string; startedAt: string; serverPath: string }>(stateFile); + expect(started.code).toBe(0); + expect(startedState?.pid).toBeTruthy(); + + const stopPromise = runCliCommand(['stop'], env); + await waitFor(() => !fs.existsSync(stateFile) ? true : null, 5000); + + fs.writeFileSync(stateFile, JSON.stringify({ + pid: 999999, + port, + token: 'fake-token', + startedAt: new Date().toISOString(), + serverPath: '/tmp/fake-server.ts', + })); + + const stop = await stopPromise; + expect(stop.code).toBe(0); + expect(stop.stdout).toContain('Server stopped'); + } finally { + await cleanupCliState(stateFile); + } + }, 20000); + + test('status during shutdown reuses the live daemon instead of racing a replacement', async () => { + const root = fs.mkdtempSync('/tmp/gstack-browse-shutdown-window-'); + const stateFile = path.join(root, 'browse-state.json'); + const shutdownMarker = path.join(root, 'shutdown-started'); + const wrapperPath = path.join(root, 'server-wrapper.ts'); + const realServerPath = path.resolve(__dirname, '../src/server.ts'); + const browserManagerPath = path.resolve(__dirname, '../src/browser-manager.ts'); + const port = reservePort(); + + fs.writeFileSync(wrapperPath, ` + import * as fs from 'fs'; + import { BrowserManager } from ${JSON.stringify(browserManagerPath)}; + + const originalClose = BrowserManager.prototype.close; + BrowserManager.prototype.close = async function (...args) { + fs.writeFileSync(${JSON.stringify(shutdownMarker)}, 'closing'); + await Bun.sleep(1500); + return await originalClose.apply(this, args); + }; + + await import(${JSON.stringify(realServerPath)}); + `); + + const env = { + BROWSE_STATE_FILE: stateFile, + BROWSE_PORT: String(port), + BROWSE_SERVER_SCRIPT: wrapperPath, + }; + + try { + const started = await runCliCommand(['status'], env); + const startedState = readJson<{ pid: number }>(stateFile); + expect(started.code).toBe(0); + expect(startedState?.pid).toBeTruthy(); + + const stopPromise = runCliCommand(['stop'], env, 15000); + const shutdownStarted = await waitFor(() => fs.existsSync(shutdownMarker) ? true : null, 5000); + expect(shutdownStarted).toBe(true); + expect(isPidAlive(startedState!.pid)).toBe(true); + + const status = await runCliCommand(['status'], env, 12000); + const stop = await stopPromise; + + expect(status.code).toBe(0); + expect(status.stdout).toContain('Status: healthy'); + expect(stop.code).toBe(0); + expect(stop.stdout).toContain('Server stopped'); + } finally { + await cleanupCliState(stateFile); + fs.rmSync(root, { recursive: true, force: true }); + } + }, 30000); }); // ─── Buffer bounds ────────────────────────────────────────────── diff --git a/browse/test/snapshot.test.ts b/browse/test/snapshot.test.ts index bc45f6a..0400c19 100644 --- a/browse/test/snapshot.test.ts +++ b/browse/test/snapshot.test.ts @@ -18,6 +18,22 @@ let bm: BrowserManager; let baseUrl: string; const shutdown = async () => {}; +function extractRef(snapshot: string, predicate: (line: string) => boolean): string { + const line = snapshot.split('\n').find(predicate); + expect(line).toBeDefined(); + const refMatch = line!.match(/@(e\d+)/); + expect(refMatch).toBeDefined(); + return `@${refMatch![1]}`; +} + +function extractRefNumbers(snapshot: string): number[] { + return snapshot + .split('\n') + .map((line) => line.match(/@e(\d+)/)) + .filter((match): match is RegExpMatchArray => Boolean(match)) + .map((match) => Number(match[1])); +} + beforeAll(async () => { testServer = startTestServer(0); baseUrl = testServer.url; @@ -98,6 +114,23 @@ describe('Snapshot', () => { expect(snap1).toContain('@e1'); expect(snap2).toContain('@e1'); }); + + test('snapshot preserves accessible names with escaped quotes', async () => { + const page = bm.getPage(); + await page.setContent(``); + const result = await handleMetaCommand('snapshot', ['-i'], bm, shutdown); + expect(result).toContain('[button]'); + expect(result).toContain('Say "Hello"'); + }); + + test('snapshot emits contiguous refs when skipped nodes are not materialized', async () => { + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + const result = await handleMetaCommand('snapshot', [], bm, shutdown); + const refs = extractRefNumbers(result); + + expect(refs.length).toBeGreaterThan(0); + expect(refs).toEqual(Array.from({ length: refs.length }, (_, index) => index + 1)); + }); }); // ─── Ref-Based Interaction ────────────────────────────────────── @@ -176,6 +209,74 @@ describe('Ref resolution', () => { // ─── Ref Invalidation ─────────────────────────────────────────── describe('Ref invalidation', () => { + test('ref from tab 1 cannot be used from blank tab 2', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown); + const ref = extractRef(snap, (line) => line.includes('[link]') && line.includes('"Page 1"')); + + await handleMetaCommand('newtab', [], bm, shutdown); + + await expect(handleWriteCommand('click', [ref], bm)).rejects.toThrow('snapshot'); + + const tabs = await bm.getTabListWithTitles(); + const tabOne = tabs.find((tab) => tab.id === 1); + const tabTwo = tabs.find((tab) => tab.active); + expect(tabOne?.url).toContain('/basic.html'); + expect(tabTwo?.url).toBe('about:blank'); + }); + + test('tab 1 refs still work after tab 2 navigates when switched back', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown); + const ref = extractRef(snap, (line) => line.includes('[link]') && line.includes('"Page 1"')); + + const newTabResult = await handleMetaCommand('newtab', [baseUrl + '/forms.html'], bm, shutdown); + const tabIdMatch = newTabResult.match(/Opened tab (\d+)/); + expect(tabIdMatch).toBeDefined(); + + await handleMetaCommand('tab', ['1'], bm, shutdown); + + const result = await handleWriteCommand('click', [ref], bm); + expect(result).toContain('Clicked'); + expect(bm.getCurrentUrl()).toContain('/page1'); + }); + + test('reordering same-name elements does not retarget an existing ref', async () => { + const page = bm.getPage(); + await page.setContent(` + + + `); + const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown); + const ref = extractRef(snap, (line) => line.includes('[button]') && line.includes('"Delete"')); + + await page.evaluate(() => { + const btn = document.createElement('button'); + btn.id = 'new'; + btn.textContent = 'Delete'; + btn.onclick = () => { (window as any).clicked = 'new'; }; + document.body.prepend(btn); + }); + + await handleWriteCommand('click', [ref], bm); + const clicked = await handleReadCommand('js', ['window.clicked'], bm); + expect(clicked).toBe('a'); + }); + + test('removing a referenced element returns a stale ref error', async () => { + const page = bm.getPage(); + await page.setContent(` + + + `); + const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown); + const ref = extractRef(snap, (line) => line.includes('[button]') && line.includes('"Delete"')); + + await page.evaluate(() => document.getElementById('a')?.remove()); + + await expect(handleWriteCommand('click', [ref], bm)).rejects.toThrow('snapshot'); + }); + test('stale ref after goto returns clear error', async () => { await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); await handleMetaCommand('snapshot', ['-i'], bm, shutdown); @@ -199,6 +300,35 @@ describe('Ref invalidation', () => { await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); expect(bm.getRefCount()).toBe(0); }); + + test('depth filter still freezes the correct nth same-name element', async () => { + const page = bm.getPage(); + await page.setContent(` +
+ + `); + + const snap = await handleMetaCommand('snapshot', ['-i', '-d', '1'], bm, shutdown); + const ref = extractRef(snap, (line) => line.includes('[button]') && line.includes('"Save"')); + + await handleWriteCommand('click', [ref], bm); + const clicked = await handleReadCommand('js', ['window.clicked'], bm); + expect(clicked).toBe('outer'); + }); + + test('compact filter still freezes the correct nth unnamed element', async () => { + const page = bm.getPage(); + await page.setContent(` +

+

Hello

+ `); + + const snap = await handleMetaCommand('snapshot', ['-c'], bm, shutdown); + const ref = extractRef(snap, (line) => line.includes('[paragraph]') && line.includes('Hello')); + + const html = await handleReadCommand('html', [ref], bm); + expect(html).toBe('Hello'); + }); }); // ─── Snapshot Diffing ──────────────────────────────────────────