From f3ebd0adbf6f4fc13220b048ec92013bd748de8f Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 12 Mar 2026 13:33:43 -0700 Subject: [PATCH] =?UTF-8?q?Phase=202:=20Enhanced=20browser=20=E2=80=94=20d?= =?UTF-8?q?ialog=20handling,=20upload,=20state=20checks,=20snapshots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CircularBuffer O(1) ring buffer for console/network/dialog (was O(n) array+shift) - Async buffer flush with Bun.write() (was appendFileSync) - Dialog auto-accept/dismiss with buffer + prompt text support - File upload command (upload ) - Element state checks (is visible/hidden/enabled/disabled/checked/editable/focused) - Annotated screenshots with ref labels overlaid (-a flag) - Snapshot diffing against previous snapshot (-D flag) - Cursor-interactive element scan for non-ARIA clickables (-C flag) - Snapshot scoping depth limit (-d N flag) - Health check with page.evaluate + 2s timeout - Playwright error wrapping — actionable messages for AI agents - Fix useragent — context recreation preserves cookies/storage/URLs - wait --networkidle / --load / --domcontentloaded flags - console --errors filter (error + warning only) - cookie-import with auto-fill domain from page URL - 166 integration tests (was ~63) Co-Authored-By: Claude Opus 4.6 --- browse/src/browser-manager.ts | 263 +++++- browse/src/buffers.ts | 121 ++- browse/src/cli.ts | 16 +- browse/src/meta-commands.ts | 50 +- browse/src/read-commands.ts | 88 +- browse/src/server.ts | 124 ++- browse/src/snapshot.ts | 187 +++- browse/src/write-commands.ts | 84 +- browse/test/commands.test.ts | 923 ++++++++++++++++++- browse/test/fixtures/cursor-interactive.html | 22 + browse/test/fixtures/dialog.html | 15 + browse/test/fixtures/empty.html | 2 + browse/test/fixtures/states.html | 17 + browse/test/fixtures/upload.html | 25 + browse/test/snapshot.test.ts | 217 +++++ browse/test/test-server.ts | 10 + 16 files changed, 2007 insertions(+), 157 deletions(-) create mode 100644 browse/test/fixtures/cursor-interactive.html create mode 100644 browse/test/fixtures/dialog.html create mode 100644 browse/test/fixtures/empty.html create mode 100644 browse/test/fixtures/states.html create mode 100644 browse/test/fixtures/upload.html diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 033ed874..ab1b8d73 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -5,10 +5,18 @@ * 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 Page, type Locator } from 'playwright'; -import { addConsoleEntry, addNetworkEntry, networkBuffer, type LogEntry, type NetworkEntry } from './buffers'; +import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; export class BrowserManager { private browser: Browser | null = null; @@ -19,9 +27,17 @@ export class BrowserManager { private extraHeaders: Record = {}; private customUserAgent: string | null = null; - // ─── Ref Map (snapshot → @e1, @e2, ...) ──────────────────── + // ─── Ref Map (snapshot → @e1, @e2, @c1, @c2, ...) ──────── private refMap: Map = 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; + async launch() { this.browser = await chromium.launch({ headless: true }); @@ -32,9 +48,17 @@ export class BrowserManager { process.exit(1); }); - this.context = await this.browser.newContext({ + const contextOptions: any = { 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(); @@ -49,8 +73,20 @@ export class BrowserManager { } } - isHealthy(): boolean { - return this.browser !== null && this.browser.isConnected(); + /** Health check — verifies Chromium is connected AND responsive */ + async isHealthy(): Promise { + 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 ──────────────────────────────────────── @@ -62,7 +98,7 @@ export class BrowserManager { this.pages.set(id, page); this.activeTabId = id; - // Wire up console/network capture + // Wire up console/network/dialog capture this.wirePageEvents(page); if (url) { @@ -101,19 +137,6 @@ export class BrowserManager { return this.pages.size; } - getTabList(): 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: '', // title requires await, populated by caller - active: id === this.activeTabId, - }); - } - return tabs; - } - async getTabListWithTitles(): Promise> { const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = []; for (const [id, page] of this.pages) { @@ -152,12 +175,12 @@ export class BrowserManager { } /** - * Resolve a selector that may be a @ref (e.g., "@e3") or a CSS selector. + * 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. */ resolveRef(selector: string): { locator: Locator } | { selector: string } { - if (selector.startsWith('@e')) { - const ref = selector.slice(1); // "e3" + if (selector.startsWith('@e') || selector.startsWith('@c')) { + const ref = selector.slice(1); // "e3" or "c1" const locator = this.refMap.get(ref); if (!locator) { throw new Error( @@ -173,6 +196,32 @@ export class BrowserManager { 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 }); @@ -187,21 +236,169 @@ export class BrowserManager { } // ─── User Agent ──────────────────────────────────────────── - // Note: user agent changes require a new context in Playwright - // For simplicity, we just store it and apply on next "restart" setUserAgent(ua: string) { this.customUserAgent = ua; } - // ─── Console/Network/Ref Wiring ──────────────────────────── + getUserAgent(): string | null { + return this.customUserAgent; + } + + /** + * 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 { + if (!this.browser || !this.context) { + throw new Error('Browser not launched'); + } + + try { + // 1. Save state from current context + const savedCookies = await this.context.cookies(); + const savedPages: Array<{ url: string; isActive: boolean; storage: any }> = []; + + for (const [id, page] of this.pages) { + const url = page.url(); + let storage = null; + try { + storage = await page.evaluate(() => ({ + localStorage: { ...localStorage }, + sessionStorage: { ...sessionStorage }, + })); + } catch {} + savedPages.push({ + url: url === 'about:blank' ? '' : url, + isActive: id === this.activeTabId, + storage, + }); + } + + // 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: any = { + 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 cookies + if (savedCookies.length > 0) { + await this.context.addCookies(savedCookies); + } + + // 5. Re-create pages + let activeId: number | null = null; + for (const saved of savedPages) { + const page = await this.context.newPage(); + const id = this.nextTabId++; + this.pages.set(id, page); + this.wirePageEvents(page); + + if (saved.url) { + await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {}); + } + + // 6. Restore storage + if (saved.storage) { + try { + await page.evaluate((s: any) => { + if (s.localStorage) { + for (const [k, v] of Object.entries(s.localStorage)) { + localStorage.setItem(k, v as string); + } + } + if (s.sessionStorage) { + for (const [k, v] of Object.entries(s.sessionStorage)) { + sessionStorage.setItem(k, v as string); + } + } + }, 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(); + + return null; // success + } catch (err: any) { + // Fallback: create a clean context + blank tab + try { + this.pages.clear(); + if (this.context) await this.context.close().catch(() => {}); + + const contextOptions: any = { + 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.message}. Browser reset to blank tab.`; + } + } + + // ─── 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(), @@ -219,13 +416,13 @@ export class BrowserManager { }); page.on('response', (res) => { - // Find matching request entry and update it + // 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--) { - if (networkBuffer[i].url === url && !networkBuffer[i].status) { - networkBuffer[i].status = status; - networkBuffer[i].duration = Date.now() - networkBuffer[i].timestamp; + const entry = networkBuffer.get(i); + if (entry && entry.url === url && !entry.status) { + networkBuffer.set(i, { ...entry, status, duration: Date.now() - entry.timestamp }); break; } } @@ -240,8 +437,9 @@ export class BrowserManager { const body = await res.body().catch(() => null); const size = body ? body.length : 0; for (let i = networkBuffer.length - 1; i >= 0; i--) { - if (networkBuffer[i].url === url && !networkBuffer[i].size) { - networkBuffer[i].size = size; + const entry = networkBuffer.get(i); + if (entry && entry.url === url && !entry.size) { + networkBuffer.set(i, { ...entry, size }); break; } } @@ -250,4 +448,3 @@ export class BrowserManager { }); } } - diff --git a/browse/src/buffers.ts b/browse/src/buffers.ts index 7cd19a41..27d37969 100644 --- a/browse/src/buffers.ts +++ b/browse/src/buffers.ts @@ -1,8 +1,95 @@ /** * Shared buffers and types — extracted to break circular dependency * between server.ts and browser-manager.ts + * + * CircularBuffer: O(1) insert ring buffer with fixed capacity. + * + * ┌───┬───┬───┬───┬───┬───┐ + * │ 3 │ 4 │ 5 │ │ 1 │ 2 │ capacity=6, head=4, size=5 + * └───┴───┴───┴───┴─▲─┴───┘ + * │ + * head (oldest entry) + * + * push() writes at (head+size) % capacity, O(1) + * toArray() returns entries in insertion order, O(n) + * totalAdded keeps incrementing past capacity (flush cursor) */ +// ─── CircularBuffer ───────────────────────────────────────── + +export class CircularBuffer { + private buffer: (T | undefined)[]; + private head: number = 0; + private _size: number = 0; + private _totalAdded: number = 0; + readonly capacity: number; + + constructor(capacity: number) { + this.capacity = capacity; + this.buffer = new Array(capacity); + } + + push(entry: T): void { + const index = (this.head + this._size) % this.capacity; + this.buffer[index] = entry; + if (this._size < this.capacity) { + this._size++; + } else { + // Buffer full — advance head (overwrites oldest) + this.head = (this.head + 1) % this.capacity; + } + this._totalAdded++; + } + + /** Return entries in insertion order (oldest first) */ + toArray(): T[] { + const result: T[] = []; + for (let i = 0; i < this._size; i++) { + result.push(this.buffer[(this.head + i) % this.capacity] as T); + } + return result; + } + + /** Return the last N entries (most recent first → reversed to oldest first) */ + last(n: number): T[] { + const count = Math.min(n, this._size); + const result: T[] = []; + const start = (this.head + this._size - count) % this.capacity; + for (let i = 0; i < count; i++) { + result.push(this.buffer[(start + i) % this.capacity] as T); + } + return result; + } + + get length(): number { + return this._size; + } + + get totalAdded(): number { + return this._totalAdded; + } + + clear(): void { + this.head = 0; + this._size = 0; + // Don't reset totalAdded — flush cursor depends on it + } + + /** Get entry by index (0 = oldest) — used by network response matching */ + get(index: number): T | undefined { + if (index < 0 || index >= this._size) return undefined; + return this.buffer[(this.head + index) % this.capacity]; + } + + /** Set entry by index (0 = oldest) — used by network response matching */ + set(index: number, entry: T): void { + if (index < 0 || index >= this._size) return; + this.buffer[(this.head + index) % this.capacity] = entry; + } +} + +// ─── Entry Types ──────────────────────────────────────────── + export interface LogEntry { timestamp: number; level: string; @@ -18,27 +105,33 @@ export interface NetworkEntry { size?: number; } -export const consoleBuffer: LogEntry[] = []; -export const networkBuffer: NetworkEntry[] = []; +export interface DialogEntry { + timestamp: number; + type: string; // 'alert' | 'confirm' | 'prompt' | 'beforeunload' + message: string; + defaultValue?: string; + action: string; // 'accepted' | 'dismissed' + response?: string; // text provided for prompt +} + +// ─── Buffer Instances ─────────────────────────────────────── + const HIGH_WATER_MARK = 50_000; -// Total entries ever added — used by server.ts flush logic as a cursor -// that keeps advancing even after the ring buffer wraps. -export let consoleTotalAdded = 0; -export let networkTotalAdded = 0; +export const consoleBuffer = new CircularBuffer(HIGH_WATER_MARK); +export const networkBuffer = new CircularBuffer(HIGH_WATER_MARK); +export const dialogBuffer = new CircularBuffer(HIGH_WATER_MARK); + +// ─── Convenience add functions ────────────────────────────── export function addConsoleEntry(entry: LogEntry) { - if (consoleBuffer.length >= HIGH_WATER_MARK) { - consoleBuffer.shift(); - } consoleBuffer.push(entry); - consoleTotalAdded++; } export function addNetworkEntry(entry: NetworkEntry) { - if (networkBuffer.length >= HIGH_WATER_MARK) { - networkBuffer.shift(); - } networkBuffer.push(entry); - networkTotalAdded++; +} + +export function addDialogEntry(entry: DialogEntry) { + dialogBuffer.push(entry); } diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 8a7c4dca..43cf0839 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -185,20 +185,28 @@ Navigation: goto | back | forward | reload | url Content: text | html [sel] | links | forms | accessibility Interaction: click | fill | select hover | type | press - scroll [sel] | wait | viewport + scroll [sel] | wait | viewport + upload [file2...] + cookie-import Inspection: js | eval | css | attrs - console [--clear] | network [--clear] + console [--clear|--errors] | network [--clear] | dialog [--clear] cookies | storage [set ] | perf + is (visible|hidden|enabled|disabled|checked|editable|focused) Visual: screenshot [path] | pdf [path] | responsive [prefix] -Snapshot: snapshot [-i] [-c] [-d N] [-s sel] +Snapshot: snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o path] [-C] + -D/--diff: diff against previous snapshot + -a/--annotate: annotated screenshot with ref labels + -C/--cursor-interactive: find non-ARIA clickable elements Compare: diff Multi-step: chain (reads JSON from stdin) Tabs: tabs | tab | newtab [url] | closetab [id] Server: status | cookie = | header : useragent | stop | restart +Dialogs: dialog-accept [text] | dialog-dismiss Refs: After 'snapshot', use @e1, @e2... as selectors: - click @e3 | fill @e4 "value" | hover @e1`); + click @e3 | fill @e4 "value" | hover @e1 + @c refs from -C: click @c1`); process.exit(0); } diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index 0fbe9aea..595f30c0 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -4,9 +4,31 @@ import type { BrowserManager } from './browser-manager'; import { handleSnapshot } from './snapshot'; +import { getCleanText } from './read-commands'; import * as Diff from 'diff'; import * as fs from 'fs'; +// Command sets for chain routing (mirrors server.ts — kept local to avoid circular import) +const CHAIN_READ = new Set([ + 'text', 'html', 'links', 'forms', 'accessibility', + 'js', 'eval', 'css', 'attrs', + 'console', 'network', 'cookies', 'storage', 'perf', + 'dialog', 'is', +]); +const CHAIN_WRITE = new Set([ + 'goto', 'back', 'forward', 'reload', + 'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', + 'viewport', 'cookie', 'header', 'useragent', + 'upload', 'dialog-accept', 'dialog-dismiss', +]); +const CHAIN_META = new Set([ + 'tabs', 'tab', 'newtab', 'closetab', + 'status', 'stop', 'restart', + 'screenshot', 'pdf', 'responsive', + 'chain', 'diff', + 'url', 'snapshot', +]); + export async function handleMetaCommand( command: string, args: string[], @@ -129,16 +151,14 @@ export async function handleMetaCommand( const { handleReadCommand } = await import('./read-commands'); const { handleWriteCommand } = await import('./write-commands'); - const WRITE_SET = new Set(['goto','back','forward','reload','click','fill','select','hover','type','press','scroll','wait','viewport','cookie','header','useragent']); - const READ_SET = new Set(['text','html','links','forms','accessibility','js','eval','css','attrs','console','network','cookies','storage','perf']); - for (const cmd of commands) { const [name, ...cmdArgs] = cmd; try { let result: string; - if (WRITE_SET.has(name)) result = await handleWriteCommand(name, cmdArgs, bm); - else if (READ_SET.has(name)) result = await handleReadCommand(name, cmdArgs, bm); - else result = await handleMetaCommand(name, cmdArgs, bm, shutdown); + if (CHAIN_WRITE.has(name)) result = await handleWriteCommand(name, cmdArgs, bm); + else if (CHAIN_READ.has(name)) result = await handleReadCommand(name, cmdArgs, bm); + else if (CHAIN_META.has(name)) result = await handleMetaCommand(name, cmdArgs, bm, shutdown); + else throw new Error(`Unknown command: ${name}`); results.push(`[${name}] ${result}`); } catch (err: any) { results.push(`[${name}] ERROR: ${err.message}`); @@ -153,26 +173,12 @@ export async function handleMetaCommand( const [url1, url2] = args; if (!url1 || !url2) throw new Error('Usage: browse diff '); - // Get text from URL1 const page = bm.getPage(); await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 }); - const text1 = await page.evaluate(() => { - const body = document.body; - if (!body) return ''; - const clone = body.cloneNode(true) as HTMLElement; - clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove()); - return clone.innerText.split('\n').map(l => l.trim()).filter(l => l).join('\n'); - }); + const text1 = await getCleanText(page); - // Get text from URL2 await page.goto(url2, { waitUntil: 'domcontentloaded', timeout: 15000 }); - const text2 = await page.evaluate(() => { - const body = document.body; - if (!body) return ''; - const clone = body.cloneNode(true) as HTMLElement; - clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove()); - return clone.innerText.split('\n').map(l => l.trim()).filter(l => l).join('\n'); - }); + const text2 = await getCleanText(page); const changes = Diff.diffLines(text1, text2); const output: string[] = [`--- ${url1}`, `+++ ${url2}`, '']; diff --git a/browse/src/read-commands.ts b/browse/src/read-commands.ts index a473477d..85a60a6d 100644 --- a/browse/src/read-commands.ts +++ b/browse/src/read-commands.ts @@ -6,9 +6,28 @@ */ import type { BrowserManager } from './browser-manager'; -import { consoleBuffer, networkBuffer } from './buffers'; +import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers'; +import type { Page } from 'playwright'; import * as fs from 'fs'; +/** + * Extract clean text from a page (strips script/style/noscript/svg). + * Exported for DRY reuse in meta-commands (diff). + */ +export async function getCleanText(page: Page): Promise { + return await page.evaluate(() => { + const body = document.body; + if (!body) return ''; + const clone = body.cloneNode(true) as HTMLElement; + clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove()); + return clone.innerText + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .join('\n'); + }); +} + export async function handleReadCommand( command: string, args: string[], @@ -18,17 +37,7 @@ export async function handleReadCommand( switch (command) { case 'text': { - return await page.evaluate(() => { - const body = document.body; - if (!body) return ''; - const clone = body.cloneNode(true) as HTMLElement; - clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove()); - return clone.innerText - .split('\n') - .map(line => line.trim()) - .filter(line => line.length > 0) - .join('\n'); - }); + return await getCleanText(page); } case 'html': { @@ -154,26 +163,71 @@ export async function handleReadCommand( case 'console': { if (args[0] === '--clear') { - consoleBuffer.length = 0; + consoleBuffer.clear(); return 'Console buffer cleared.'; } - if (consoleBuffer.length === 0) return '(no console messages)'; - return consoleBuffer.map(e => + const entries = args[0] === '--errors' + ? consoleBuffer.toArray().filter(e => e.level === 'error' || e.level === 'warning') + : consoleBuffer.toArray(); + if (entries.length === 0) return args[0] === '--errors' ? '(no console errors)' : '(no console messages)'; + return entries.map(e => `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}` ).join('\n'); } case 'network': { if (args[0] === '--clear') { - networkBuffer.length = 0; + networkBuffer.clear(); return 'Network buffer cleared.'; } if (networkBuffer.length === 0) return '(no network requests)'; - return networkBuffer.map(e => + return networkBuffer.toArray().map(e => `${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)` ).join('\n'); } + case 'dialog': { + if (args[0] === '--clear') { + dialogBuffer.clear(); + return 'Dialog buffer cleared.'; + } + if (dialogBuffer.length === 0) return '(no dialogs captured)'; + return dialogBuffer.toArray().map(e => + `[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}` + ).join('\n'); + } + + case 'is': { + const property = args[0]; + const selector = args[1]; + 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); + } + + switch (property) { + case 'visible': return String(await locator.isVisible()); + case 'hidden': return String(await locator.isHidden()); + case 'enabled': return String(await locator.isEnabled()); + case 'disabled': return String(await locator.isDisabled()); + case 'checked': return String(await locator.isChecked()); + case 'editable': return String(await locator.isEditable()); + case 'focused': { + const isFocused = await locator.evaluate( + (el) => el === document.activeElement + ); + return String(isFocused); + } + default: + throw new Error(`Unknown property: ${property}. Use: visible, hidden, enabled, disabled, checked, editable, focused`); + } + } + case 'cookies': { const cookies = await page.context().cookies(); return JSON.stringify(cookies, null, 2); diff --git a/browse/src/server.ts b/browse/src/server.ts index 71e3b5c0..c4073abc 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -3,7 +3,7 @@ * * Architecture: * Bun.serve HTTP on localhost → routes commands to Playwright - * Console/network buffers: in-memory (all entries) + disk flush every 1s + * Console/network/dialog buffers: CircularBuffer in-memory + async disk flush * Chromium crash → server EXITS with clear error (CLI auto-restarts) * Auto-shutdown after BROWSE_IDLE_TIMEOUT (default 30 min) */ @@ -32,36 +32,58 @@ function validateAuth(req: Request): boolean { } // ─── Buffer (from buffers.ts) ──────────────────────────────────── -import { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, consoleTotalAdded, networkTotalAdded, type LogEntry, type NetworkEntry } from './buffers'; -export { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, type LogEntry, type NetworkEntry }; +import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers'; +export { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry }; + const CONSOLE_LOG_PATH = `/tmp/browse-console${INSTANCE_SUFFIX}.log`; const NETWORK_LOG_PATH = `/tmp/browse-network${INSTANCE_SUFFIX}.log`; +const DIALOG_LOG_PATH = `/tmp/browse-dialog${INSTANCE_SUFFIX}.log`; let lastConsoleFlushed = 0; let lastNetworkFlushed = 0; +let lastDialogFlushed = 0; +let flushInProgress = false; -function flushBuffers() { - // Use totalAdded cursor (not buffer.length) because the ring buffer - // stays pinned at HIGH_WATER_MARK after wrapping. - const newConsoleCount = consoleTotalAdded - lastConsoleFlushed; - if (newConsoleCount > 0) { - const count = Math.min(newConsoleCount, consoleBuffer.length); - const newEntries = consoleBuffer.slice(-count); - const lines = newEntries.map(e => - `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}` - ).join('\n') + '\n'; - fs.appendFileSync(CONSOLE_LOG_PATH, lines); - lastConsoleFlushed = consoleTotalAdded; - } +async function flushBuffers() { + if (flushInProgress) return; // Guard against concurrent flush + flushInProgress = true; - const newNetworkCount = networkTotalAdded - lastNetworkFlushed; - if (newNetworkCount > 0) { - const count = Math.min(newNetworkCount, networkBuffer.length); - const newEntries = networkBuffer.slice(-count); - const lines = newEntries.map(e => - `[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)` - ).join('\n') + '\n'; - fs.appendFileSync(NETWORK_LOG_PATH, lines); - lastNetworkFlushed = networkTotalAdded; + try { + // Console buffer + const newConsoleCount = consoleBuffer.totalAdded - lastConsoleFlushed; + if (newConsoleCount > 0) { + const entries = consoleBuffer.last(Math.min(newConsoleCount, consoleBuffer.length)); + const lines = entries.map(e => + `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}` + ).join('\n') + '\n'; + await Bun.write(CONSOLE_LOG_PATH, (await Bun.file(CONSOLE_LOG_PATH).text().catch(() => '')) + lines); + lastConsoleFlushed = consoleBuffer.totalAdded; + } + + // Network buffer + const newNetworkCount = networkBuffer.totalAdded - lastNetworkFlushed; + if (newNetworkCount > 0) { + const entries = networkBuffer.last(Math.min(newNetworkCount, networkBuffer.length)); + const lines = entries.map(e => + `[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)` + ).join('\n') + '\n'; + await Bun.write(NETWORK_LOG_PATH, (await Bun.file(NETWORK_LOG_PATH).text().catch(() => '')) + lines); + lastNetworkFlushed = networkBuffer.totalAdded; + } + + // Dialog buffer + const newDialogCount = dialogBuffer.totalAdded - lastDialogFlushed; + if (newDialogCount > 0) { + const entries = dialogBuffer.last(Math.min(newDialogCount, dialogBuffer.length)); + const lines = entries.map(e => + `[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}` + ).join('\n') + '\n'; + await Bun.write(DIALOG_LOG_PATH, (await Bun.file(DIALOG_LOG_PATH).text().catch(() => '')) + lines); + lastDialogFlushed = dialogBuffer.totalAdded; + } + } catch { + // Flush failures are non-fatal — buffers are in memory + } finally { + flushInProgress = false; } } @@ -82,24 +104,22 @@ const idleCheckInterval = setInterval(() => { } }, 60_000); -// ─── Server ──────────────────────────────────────────────────── -const browserManager = new BrowserManager(); -let isShuttingDown = false; - -// Read/write/meta command sets for routing -const READ_COMMANDS = new Set([ +// ─── Command Sets (exported for chain command) ────────────────── +export const READ_COMMANDS = new Set([ 'text', 'html', 'links', 'forms', 'accessibility', 'js', 'eval', 'css', 'attrs', 'console', 'network', 'cookies', 'storage', 'perf', + 'dialog', 'is', ]); -const WRITE_COMMANDS = new Set([ +export const WRITE_COMMANDS = new Set([ 'goto', 'back', 'forward', 'reload', 'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', - 'viewport', 'cookie', 'header', 'useragent', + 'viewport', 'cookie', 'cookie-import', 'header', 'useragent', + 'upload', 'dialog-accept', 'dialog-dismiss', ]); -const META_COMMANDS = new Set([ +export const META_COMMANDS = new Set([ 'tabs', 'tab', 'newtab', 'closetab', 'status', 'stop', 'restart', 'screenshot', 'pdf', 'responsive', @@ -107,6 +127,10 @@ const META_COMMANDS = new Set([ 'url', 'snapshot', ]); +// ─── Server ──────────────────────────────────────────────────── +const browserManager = new BrowserManager(); +let isShuttingDown = false; + // Find port: deterministic from CONDUCTOR_PORT, or scan range async function findPort(): Promise { // Deterministic port from CONDUCTOR_PORT (e.g., 55040 - 45600 = 9440) @@ -134,6 +158,29 @@ async function findPort(): Promise { throw new Error(`[browse] No available port in range ${start}-${start + 9}`); } +/** + * Translate Playwright errors into actionable messages for AI agents. + */ +function wrapError(err: any): string { + const msg = err.message || String(err); + // Timeout errors + if (err.name === 'TimeoutError' || msg.includes('Timeout') || msg.includes('timeout')) { + if (msg.includes('locator.click') || msg.includes('locator.fill') || msg.includes('locator.hover')) { + return `Element not found or not interactable within timeout. Check your selector or run 'snapshot' for fresh refs.`; + } + if (msg.includes('page.goto') || msg.includes('Navigation')) { + return `Page navigation timed out. The URL may be unreachable or the page may be loading slowly.`; + } + return `Operation timed out: ${msg.split('\n')[0]}`; + } + // Multiple elements matched + if (msg.includes('resolved to') && msg.includes('elements')) { + return `Selector matched multiple elements. Be more specific or use @refs from 'snapshot'.`; + } + // Pass through other errors + return msg; +} + async function handleCommand(body: any): Promise { const { command, args = [] } = body; @@ -168,7 +215,7 @@ async function handleCommand(body: any): Promise { headers: { 'Content-Type': 'text/plain' }, }); } catch (err: any) { - return new Response(JSON.stringify({ error: err.message }), { + return new Response(JSON.stringify({ error: wrapError(err) }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); @@ -182,7 +229,7 @@ async function shutdown() { console.log('[browse] Shutting down...'); clearInterval(flushInterval); clearInterval(idleCheckInterval); - flushBuffers(); // Final flush + await flushBuffers(); // Final flush (async now) await browserManager.close(); @@ -201,6 +248,7 @@ async function start() { // Clear old log files try { fs.unlinkSync(CONSOLE_LOG_PATH); } catch {} try { fs.unlinkSync(NETWORK_LOG_PATH); } catch {} + try { fs.unlinkSync(DIALOG_LOG_PATH); } catch {} const port = await findPort(); @@ -216,9 +264,9 @@ async function start() { const url = new URL(req.url); - // Health check — no auth required + // Health check — no auth required (now async) if (url.pathname === '/health') { - const healthy = browserManager.isHealthy(); + const healthy = await browserManager.isHealthy(); return new Response(JSON.stringify({ status: healthy ? 'healthy' : 'unhealthy', uptime: Math.floor((Date.now() - startTime) / 1000), diff --git a/browse/src/snapshot.ts b/browse/src/snapshot.ts index d8d0da0f..65836657 100644 --- a/browse/src/snapshot.ts +++ b/browse/src/snapshot.ts @@ -8,11 +8,18 @@ * 4. Store Map on BrowserManager * 5. Return compact text output with refs prepended * + * Extended features: + * --diff / -D: Compare against last snapshot, return unified diff + * --annotate / -a: Screenshot with overlay boxes at each @ref + * --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() */ import type { Page, Locator } from 'playwright'; import type { BrowserManager } from './browser-manager'; +import * as Diff from 'diff'; // Roles considered "interactive" for the -i flag const INTERACTIVE_ROLES = new Set([ @@ -23,10 +30,14 @@ const INTERACTIVE_ROLES = new Set([ ]); interface SnapshotOptions { - interactive?: boolean; // -i: only interactive elements - compact?: boolean; // -c: remove empty structural elements - depth?: number; // -d N: limit tree depth - selector?: string; // -s SEL: scope to CSS selector + interactive?: boolean; // -i: only interactive elements + compact?: boolean; // -c: remove empty structural elements + depth?: number; // -d N: limit tree depth + selector?: string; // -s SEL: scope to CSS selector + diff?: boolean; // -D / --diff: diff against last snapshot + annotate?: boolean; // -a / --annotate: annotated screenshot + outputPath?: string; // -o / --output: path for annotated screenshot + cursorInteractive?: boolean; // -C / --cursor-interactive: scan cursor:pointer etc. } interface ParsedNode { @@ -63,6 +74,23 @@ export function parseSnapshotArgs(args: string[]): SnapshotOptions { opts.selector = args[++i]; if (!opts.selector) throw new Error('Usage: snapshot -s '); break; + case '-D': + case '--diff': + opts.diff = true; + break; + case '-a': + case '--annotate': + opts.annotate = true; + break; + case '-o': + case '--output': + opts.outputPath = args[++i]; + if (!opts.outputPath) throw new Error('Usage: snapshot -o '); + break; + case '-C': + case '--cursor-interactive': + opts.cursorInteractive = true; + break; default: throw new Error(`Unknown snapshot flag: ${args[i]}`); } @@ -201,6 +229,74 @@ export async function handleSnapshot( output.push(outputLine); } + // ─── Cursor-interactive scan (-C) ───────────────────────── + if (opts.cursorInteractive) { + try { + const cursorElements = await page.evaluate(() => { + const STANDARD_INTERACTIVE = new Set([ + 'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY', 'DETAILS', + ]); + + const results: Array<{ selector: string; text: string; reason: string }> = []; + 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); + const hasCursorPointer = style.cursor === 'pointer'; + const hasOnclick = el.hasAttribute('onclick'); + const hasTabindex = el.hasAttribute('tabindex') && parseInt(el.getAttribute('tabindex')!, 10) >= 0; + 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) { + const parent = current.parentElement; + if (!parent) break; + const siblings = [...parent.children]; + const index = siblings.indexOf(current) + 1; + parts.unshift(`${current.tagName.toLowerCase()}:nth-child(${index})`); + current = parent; + } + const selector = parts.join(' > '); + + const text = (el as HTMLElement).innerText?.trim().slice(0, 80) || el.tagName.toLowerCase(); + const reasons: string[] = []; + if (hasCursorPointer) reasons.push('cursor:pointer'); + if (hasOnclick) reasons.push('onclick'); + if (hasTabindex) reasons.push(`tabindex=${el.getAttribute('tabindex')}`); + + results.push({ selector, text, reason: reasons.join(', ') }); + } + return results; + }); + + if (cursorElements.length > 0) { + output.push(''); + 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}"`); + } + } + } catch { + output.push(''); + output.push('(cursor scan failed — CSP restriction)'); + } + } + // Store ref map on BrowserManager bm.setRefMap(refMap); @@ -208,5 +304,88 @@ export async function handleSnapshot( return '(no interactive elements found)'; } + const snapshotText = output.join('\n'); + + // ─── Annotated screenshot (-a) ──────────────────────────── + if (opts.annotate) { + const screenshotPath = opts.outputPath || '/tmp/browse-annotated.png'; + 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) { + try { + const box = await locator.boundingBox({ timeout: 1000 }); + if (box) { + boxes.push({ ref: `@${ref}`, box }); + } + } catch { + // Element may be offscreen or hidden — skip + } + } + + await page.evaluate((boxes) => { + for (const { ref, box } of boxes) { + const overlay = document.createElement('div'); + overlay.className = '__browse_annotation__'; + overlay.style.cssText = ` + position: absolute; top: ${box.y}px; left: ${box.x}px; + width: ${box.width}px; height: ${box.height}px; + border: 2px solid red; background: rgba(255,0,0,0.1); + pointer-events: none; z-index: 99999; + font-size: 10px; color: red; font-weight: bold; + `; + const label = document.createElement('span'); + label.textContent = ref; + label.style.cssText = 'position: absolute; top: -14px; left: 0; background: red; color: white; padding: 0 3px; font-size: 10px;'; + overlay.appendChild(label); + document.body.appendChild(overlay); + } + }, boxes); + + await page.screenshot({ path: screenshotPath, fullPage: true }); + + // Always remove overlays + await page.evaluate(() => { + 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()); + }); + } 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)'; + } + + const changes = Diff.diffLines(lastSnapshot, snapshotText); + const diffOutput: string[] = ['--- previous snapshot', '+++ current snapshot', '']; + + for (const part of changes) { + const prefix = part.added ? '+' : part.removed ? '-' : ' '; + const diffLines = part.value.split('\n').filter(l => l.length > 0); + for (const line of diffLines) { + diffOutput.push(`${prefix} ${line}`); + } + } + + bm.setLastSnapshot(snapshotText); + 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 e1c91942..9892acf6 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -6,6 +6,8 @@ */ import type { BrowserManager } from './browser-manager'; +import * as fs from 'fs'; +import * as path from 'path'; export async function handleWriteCommand( command: string, @@ -121,7 +123,20 @@ export async function handleWriteCommand( case 'wait': { const selector = args[0]; - if (!selector) throw new Error('Usage: browse wait '); + if (!selector) throw new Error('Usage: browse wait '); + if (selector === '--networkidle') { + const timeout = args[1] ? parseInt(args[1], 10) : 15000; + await page.waitForLoadState('networkidle', { timeout }); + return 'Network idle'; + } + if (selector === '--load') { + await page.waitForLoadState('load'); + return 'Page loaded'; + } + if (selector === '--domcontentloaded') { + await page.waitForLoadState('domcontentloaded'); + return 'DOM content loaded'; + } const timeout = args[1] ? parseInt(args[1], 10) : 15000; const resolved = bm.resolveRef(selector); if ('locator' in resolved) { @@ -170,7 +185,72 @@ export async function handleWriteCommand( const ua = args.join(' '); if (!ua) throw new Error('Usage: browse useragent '); bm.setUserAgent(ua); - return `User agent set (applies on next restart): ${ua}`; + const error = await bm.recreateContext(); + if (error) { + return `User agent set to "${ua}" but: ${error}`; + } + return `User agent set: ${ua}`; + } + + case 'upload': { + const [selector, ...filePaths] = args; + if (!selector || filePaths.length === 0) throw new Error('Usage: browse upload [file2...]'); + + // Validate all files exist before upload + for (const fp of filePaths) { + if (!fs.existsSync(fp)) throw new Error(`File not found: ${fp}`); + } + + const resolved = bm.resolveRef(selector); + if ('locator' in resolved) { + await resolved.locator.setInputFiles(filePaths); + } else { + await page.locator(resolved.selector).setInputFiles(filePaths); + } + + const fileInfo = filePaths.map(fp => { + const stat = fs.statSync(fp); + return `${path.basename(fp)} (${stat.size}B)`; + }).join(', '); + return `Uploaded: ${fileInfo}`; + } + + case 'dialog-accept': { + const text = args.length > 0 ? args.join(' ') : null; + bm.setDialogAutoAccept(true); + bm.setDialogPromptText(text); + return text + ? `Dialogs will be accepted with text: "${text}"` + : 'Dialogs will be accepted'; + } + + case 'dialog-dismiss': { + bm.setDialogAutoAccept(false); + bm.setDialogPromptText(null); + return 'Dialogs will be dismissed'; + } + + case 'cookie-import': { + const filePath = args[0]; + if (!filePath) throw new Error('Usage: browse cookie-import '); + if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`); + const raw = fs.readFileSync(filePath, 'utf-8'); + let cookies: any[]; + try { cookies = JSON.parse(raw); } catch { throw new Error(`Invalid JSON in ${filePath}`); } + if (!Array.isArray(cookies)) throw new Error('Cookie file must contain a JSON array'); + + // Auto-fill domain from current page URL when missing (consistent with cookie command) + const pageUrl = new URL(page.url()); + const defaultDomain = pageUrl.hostname; + + for (const c of cookies) { + if (!c.name || c.value === undefined) throw new Error('Each cookie must have "name" and "value" fields'); + if (!c.domain) c.domain = defaultDomain; + if (!c.path) c.path = '/'; + } + + await page.context().addCookies(cookies); + return `Loaded ${cookies.length} cookies from ${filePath}`; } default: diff --git a/browse/test/commands.test.ts b/browse/test/commands.test.ts index 151e943a..8e072adb 100644 --- a/browse/test/commands.test.ts +++ b/browse/test/commands.test.ts @@ -11,7 +11,7 @@ import { BrowserManager } from '../src/browser-manager'; import { handleReadCommand } from '../src/read-commands'; import { handleWriteCommand } from '../src/write-commands'; import { handleMetaCommand } from '../src/meta-commands'; -import { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, consoleTotalAdded, networkTotalAdded } from '../src/buffers'; +import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, CircularBuffer } from '../src/buffers'; import * as fs from 'fs'; import { spawn } from 'child_process'; import * as path from 'path'; @@ -424,26 +424,27 @@ describe('Status', () => { describe('CLI retry guard', () => { test('sendCommand aborts after repeated connection failures', async () => { - // Write a fake state file pointing to a port that refuses connections - const stateFile = '/tmp/browse-server.json'; - const origState = fs.existsSync(stateFile) ? fs.readFileSync(stateFile, 'utf-8') : null; - + // Use an isolated state file to avoid conflicts with running servers + const stateFile = '/tmp/browse-server-test-retry.json'; fs.writeFileSync(stateFile, JSON.stringify({ port: 1, token: 'fake', pid: 999999 })); const cliPath = path.resolve(__dirname, '../src/cli.ts'); const result = await new Promise<{ code: number; stderr: string }>((resolve) => { const proc = spawn('bun', ['run', cliPath, 'status'], { timeout: 15000, - env: { ...process.env }, + env: { + ...process.env, + BROWSE_STATE_FILE: stateFile, + BROWSE_PORT: '1', // Force port 1 (will fail) + }, }); let stderr = ''; proc.stderr.on('data', (d) => stderr += d.toString()); proc.on('close', (code) => resolve({ code: code ?? 1, stderr })); }); - // Restore original state file - if (origState) fs.writeFileSync(stateFile, origState); - else if (fs.existsSync(stateFile)) fs.unlinkSync(stateFile); + // Clean up + try { fs.unlinkSync(stateFile); } catch {} // Should fail, not loop forever expect(result.code).not.toBe(0); @@ -454,37 +455,913 @@ describe('CLI retry guard', () => { describe('Buffer bounds', () => { test('console buffer caps at 50000 entries', () => { - consoleBuffer.length = 0; + consoleBuffer.clear(); for (let i = 0; i < 50_010; i++) { addConsoleEntry({ timestamp: i, level: 'log', text: `msg-${i}` }); } expect(consoleBuffer.length).toBe(50_000); - expect(consoleBuffer[0].text).toBe('msg-10'); - expect(consoleBuffer[consoleBuffer.length - 1].text).toBe('msg-50009'); - consoleBuffer.length = 0; + const entries = consoleBuffer.toArray(); + expect(entries[0].text).toBe('msg-10'); + expect(entries[entries.length - 1].text).toBe('msg-50009'); + consoleBuffer.clear(); }); test('network buffer caps at 50000 entries', () => { - networkBuffer.length = 0; + networkBuffer.clear(); for (let i = 0; i < 50_010; i++) { addNetworkEntry({ timestamp: i, method: 'GET', url: `http://x/${i}` }); } expect(networkBuffer.length).toBe(50_000); - expect(networkBuffer[0].url).toBe('http://x/10'); - expect(networkBuffer[networkBuffer.length - 1].url).toBe('http://x/50009'); - networkBuffer.length = 0; + const entries = networkBuffer.toArray(); + expect(entries[0].url).toBe('http://x/10'); + expect(entries[entries.length - 1].url).toBe('http://x/50009'); + networkBuffer.clear(); }); test('totalAdded counters keep incrementing past buffer cap', () => { - const startConsole = consoleTotalAdded; - const startNetwork = networkTotalAdded; + const startConsole = consoleBuffer.totalAdded; + const startNetwork = networkBuffer.totalAdded; for (let i = 0; i < 100; i++) { addConsoleEntry({ timestamp: i, level: 'log', text: `t-${i}` }); addNetworkEntry({ timestamp: i, method: 'GET', url: `http://t/${i}` }); } - expect(consoleTotalAdded).toBe(startConsole + 100); - expect(networkTotalAdded).toBe(startNetwork + 100); - consoleBuffer.length = 0; - networkBuffer.length = 0; + expect(consoleBuffer.totalAdded).toBe(startConsole + 100); + expect(networkBuffer.totalAdded).toBe(startNetwork + 100); + consoleBuffer.clear(); + networkBuffer.clear(); + }); +}); + +// ─── CircularBuffer Unit Tests ───────────────────────────────── + +describe('CircularBuffer', () => { + test('push and toArray return items in insertion order', () => { + const buf = new CircularBuffer(5); + buf.push(1); buf.push(2); buf.push(3); + expect(buf.toArray()).toEqual([1, 2, 3]); + expect(buf.length).toBe(3); + }); + + test('overwrites oldest when full', () => { + const buf = new CircularBuffer(3); + buf.push(1); buf.push(2); buf.push(3); buf.push(4); + expect(buf.toArray()).toEqual([2, 3, 4]); + expect(buf.length).toBe(3); + }); + + test('totalAdded increments past capacity', () => { + const buf = new CircularBuffer(2); + buf.push(1); buf.push(2); buf.push(3); buf.push(4); buf.push(5); + expect(buf.totalAdded).toBe(5); + expect(buf.length).toBe(2); + expect(buf.toArray()).toEqual([4, 5]); + }); + + test('last(n) returns most recent entries', () => { + const buf = new CircularBuffer(5); + for (let i = 1; i <= 5; i++) buf.push(i); + expect(buf.last(3)).toEqual([3, 4, 5]); + expect(buf.last(10)).toEqual([1, 2, 3, 4, 5]); // clamped + expect(buf.last(1)).toEqual([5]); + }); + + test('get and set work by index', () => { + const buf = new CircularBuffer(3); + buf.push('a'); buf.push('b'); buf.push('c'); + expect(buf.get(0)).toBe('a'); + expect(buf.get(2)).toBe('c'); + buf.set(1, 'B'); + expect(buf.get(1)).toBe('B'); + expect(buf.get(-1)).toBeUndefined(); + expect(buf.get(5)).toBeUndefined(); + }); + + test('clear resets size but not totalAdded', () => { + const buf = new CircularBuffer(5); + buf.push(1); buf.push(2); buf.push(3); + buf.clear(); + expect(buf.length).toBe(0); + expect(buf.totalAdded).toBe(3); + expect(buf.toArray()).toEqual([]); + }); + + test('works with capacity=1', () => { + const buf = new CircularBuffer(1); + buf.push(10); + expect(buf.toArray()).toEqual([10]); + buf.push(20); + expect(buf.toArray()).toEqual([20]); + expect(buf.totalAdded).toBe(2); + }); +}); + +// ─── Dialog Handling ───────────────────────────────────────── + +describe('Dialog handling', () => { + test('alert does not hang — auto-accepted', async () => { + await handleWriteCommand('goto', [baseUrl + '/dialog.html'], bm); + await handleWriteCommand('click', ['#alert-btn'], bm); + // If we get here, dialog was handled (no hang) + const result = await handleReadCommand('dialog', [], bm); + expect(result).toContain('alert'); + expect(result).toContain('Hello from alert'); + expect(result).toContain('accepted'); + }); + + test('confirm is auto-accepted by default', async () => { + await handleWriteCommand('goto', [baseUrl + '/dialog.html'], bm); + await handleWriteCommand('click', ['#confirm-btn'], bm); + // Wait for DOM update + await new Promise(r => setTimeout(r, 100)); + const result = await handleReadCommand('js', ['document.querySelector("#confirm-result").textContent'], bm); + expect(result).toBe('confirmed'); + }); + + test('dialog-dismiss changes behavior', async () => { + const setResult = await handleWriteCommand('dialog-dismiss', [], bm); + expect(setResult).toContain('dismissed'); + + await handleWriteCommand('goto', [baseUrl + '/dialog.html'], bm); + await handleWriteCommand('click', ['#confirm-btn'], bm); + await new Promise(r => setTimeout(r, 100)); + const result = await handleReadCommand('js', ['document.querySelector("#confirm-result").textContent'], bm); + expect(result).toBe('cancelled'); + + // Reset to accept + await handleWriteCommand('dialog-accept', [], bm); + }); + + test('dialog-accept with text provides prompt response', async () => { + const setResult = await handleWriteCommand('dialog-accept', ['TestUser'], bm); + expect(setResult).toContain('TestUser'); + + await handleWriteCommand('goto', [baseUrl + '/dialog.html'], bm); + await handleWriteCommand('click', ['#prompt-btn'], bm); + await new Promise(r => setTimeout(r, 100)); + const result = await handleReadCommand('js', ['document.querySelector("#prompt-result").textContent'], bm); + expect(result).toBe('TestUser'); + + // Reset + await handleWriteCommand('dialog-accept', [], bm); + }); + + test('dialog --clear clears buffer', async () => { + const cleared = await handleReadCommand('dialog', ['--clear'], bm); + expect(cleared).toContain('cleared'); + const after = await handleReadCommand('dialog', [], bm); + expect(after).toContain('no dialogs'); + }); +}); + +// ─── Element State Checks (is) ───────────────────────────────── + +describe('Element state checks', () => { + beforeAll(async () => { + await handleWriteCommand('goto', [baseUrl + '/states.html'], bm); + }); + + test('is visible returns true for visible element', async () => { + const result = await handleReadCommand('is', ['visible', '#visible-div'], bm); + expect(result).toBe('true'); + }); + + test('is hidden returns true for hidden element', async () => { + const result = await handleReadCommand('is', ['hidden', '#hidden-div'], bm); + expect(result).toBe('true'); + }); + + test('is visible returns false for hidden element', async () => { + const result = await handleReadCommand('is', ['visible', '#hidden-div'], bm); + expect(result).toBe('false'); + }); + + test('is enabled returns true for enabled input', async () => { + const result = await handleReadCommand('is', ['enabled', '#enabled-input'], bm); + expect(result).toBe('true'); + }); + + test('is disabled returns true for disabled input', async () => { + const result = await handleReadCommand('is', ['disabled', '#disabled-input'], bm); + expect(result).toBe('true'); + }); + + test('is checked returns true for checked checkbox', async () => { + const result = await handleReadCommand('is', ['checked', '#checked-box'], bm); + expect(result).toBe('true'); + }); + + test('is checked returns false for unchecked checkbox', async () => { + const result = await handleReadCommand('is', ['checked', '#unchecked-box'], bm); + expect(result).toBe('false'); + }); + + test('is editable returns true for normal input', async () => { + const result = await handleReadCommand('is', ['editable', '#enabled-input'], bm); + expect(result).toBe('true'); + }); + + test('is editable returns false for readonly input', async () => { + const result = await handleReadCommand('is', ['editable', '#readonly-input'], bm); + expect(result).toBe('false'); + }); + + test('is focused after click', async () => { + await handleWriteCommand('click', ['#enabled-input'], bm); + const result = await handleReadCommand('is', ['focused', '#enabled-input'], bm); + expect(result).toBe('true'); + }); + + test('is with @ref works', async () => { + await handleMetaCommand('snapshot', ['-i'], bm, async () => {}); + // Find a ref for the enabled input + const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {}); + const textboxLine = snap.split('\n').find(l => l.includes('[textbox]')); + if (textboxLine) { + const refMatch = textboxLine.match(/@(e\d+)/); + if (refMatch) { + const ref = `@${refMatch[1]}`; + const result = await handleReadCommand('is', ['visible', ref], bm); + expect(result).toBe('true'); + } + } + }); + + test('is with unknown property throws', async () => { + try { + await handleReadCommand('is', ['bogus', '#enabled-input'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Unknown property'); + } + }); + + test('is with missing args throws', async () => { + try { + await handleReadCommand('is', ['visible'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); +}); + +// ─── File Upload ───────────────────────────────────────────────── + +describe('File upload', () => { + test('upload single file', async () => { + await handleWriteCommand('goto', [baseUrl + '/upload.html'], bm); + // Create a temp file to upload + const tempFile = '/tmp/browse-test-upload.txt'; + fs.writeFileSync(tempFile, 'test content'); + const result = await handleWriteCommand('upload', ['#file-input', tempFile], bm); + expect(result).toContain('Uploaded'); + expect(result).toContain('browse-test-upload.txt'); + + // Verify upload handler fired + await new Promise(r => setTimeout(r, 100)); + const text = await handleReadCommand('js', ['document.querySelector("#upload-result").textContent'], bm); + expect(text).toContain('browse-test-upload.txt'); + fs.unlinkSync(tempFile); + }); + + test('upload with @ref works', async () => { + await handleWriteCommand('goto', [baseUrl + '/upload.html'], bm); + const tempFile = '/tmp/browse-test-upload2.txt'; + fs.writeFileSync(tempFile, 'ref upload test'); + const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {}); + // Find the file input ref (it won't appear as "file input" in aria — use CSS selector instead) + const result = await handleWriteCommand('upload', ['#file-input', tempFile], bm); + expect(result).toContain('Uploaded'); + fs.unlinkSync(tempFile); + }); + + test('upload nonexistent file throws', async () => { + await handleWriteCommand('goto', [baseUrl + '/upload.html'], bm); + try { + await handleWriteCommand('upload', ['#file-input', '/tmp/nonexistent-file-12345.txt'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('File not found'); + } + }); + + test('upload missing args throws', async () => { + try { + await handleWriteCommand('upload', ['#file-input'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); +}); + +// ─── Eval command ─────────────────────────────────────────────── + +describe('Eval', () => { + test('eval runs JS file', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const tempFile = '/tmp/browse-test-eval.js'; + fs.writeFileSync(tempFile, 'document.title + " — evaluated"'); + const result = await handleReadCommand('eval', [tempFile], bm); + expect(result).toBe('Test Page - Basic — evaluated'); + fs.unlinkSync(tempFile); + }); + + test('eval returns object as JSON', async () => { + const tempFile = '/tmp/browse-test-eval-obj.js'; + fs.writeFileSync(tempFile, '({title: document.title, keys: Object.keys(document.body.dataset)})'); + const result = await handleReadCommand('eval', [tempFile], bm); + const obj = JSON.parse(result); + expect(obj.title).toBe('Test Page - Basic'); + expect(Array.isArray(obj.keys)).toBe(true); + fs.unlinkSync(tempFile); + }); + + test('eval file not found throws', async () => { + try { + await handleReadCommand('eval', ['/tmp/nonexistent-eval.js'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('File not found'); + } + }); + + test('eval no arg throws', async () => { + try { + await handleReadCommand('eval', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); +}); + +// ─── Press command ────────────────────────────────────────────── + +describe('Press', () => { + test('press Tab moves focus', async () => { + await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm); + await handleWriteCommand('click', ['#email'], bm); + const result = await handleWriteCommand('press', ['Tab'], bm); + expect(result).toContain('Pressed Tab'); + }); + + test('press no arg throws', async () => { + try { + await handleWriteCommand('press', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); +}); + +// ─── Cookie command ───────────────────────────────────────────── + +describe('Cookie command', () => { + test('cookie sets value', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleWriteCommand('cookie', ['testcookie=testvalue'], bm); + expect(result).toContain('Cookie set'); + + const cookies = await handleReadCommand('cookies', [], bm); + expect(cookies).toContain('testcookie'); + expect(cookies).toContain('testvalue'); + }); + + test('cookie no arg throws', async () => { + try { + await handleWriteCommand('cookie', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('cookie no = throws', async () => { + try { + await handleWriteCommand('cookie', ['invalid'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); +}); + +// ─── Header command ───────────────────────────────────────────── + +describe('Header command', () => { + test('header sets value and is sent', async () => { + const result = await handleWriteCommand('header', ['X-Test:test-value'], bm); + expect(result).toContain('Header set'); + + await handleWriteCommand('goto', [baseUrl + '/echo'], bm); + const echoText = await handleReadCommand('text', [], bm); + expect(echoText).toContain('x-test'); + expect(echoText).toContain('test-value'); + }); + + test('header no arg throws', async () => { + try { + await handleWriteCommand('header', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('header no colon throws', async () => { + try { + await handleWriteCommand('header', ['invalid'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); +}); + +// ─── PDF command ──────────────────────────────────────────────── + +describe('PDF', () => { + test('pdf saves file with size', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const pdfPath = '/tmp/browse-test.pdf'; + const result = await handleMetaCommand('pdf', [pdfPath], bm, async () => {}); + expect(result).toContain('PDF saved'); + expect(fs.existsSync(pdfPath)).toBe(true); + const stat = fs.statSync(pdfPath); + expect(stat.size).toBeGreaterThan(100); + fs.unlinkSync(pdfPath); + }); +}); + +// ─── Empty page edge cases ────────────────────────────────────── + +describe('Empty page', () => { + test('text returns empty on empty page', async () => { + await handleWriteCommand('goto', [baseUrl + '/empty.html'], bm); + const result = await handleReadCommand('text', [], bm); + expect(result).toBe(''); + }); + + test('links returns empty on empty page', async () => { + const result = await handleReadCommand('links', [], bm); + expect(result).toBe(''); + }); + + test('forms returns empty array on empty page', async () => { + const result = await handleReadCommand('forms', [], bm); + expect(JSON.parse(result)).toEqual([]); + }); +}); + +// ─── Error paths ──────────────────────────────────────────────── + +describe('Errors', () => { + // Write command errors + test('goto with no arg throws', async () => { + try { + await handleWriteCommand('goto', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('click with no arg throws', async () => { + try { + await handleWriteCommand('click', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('fill with no value throws', async () => { + try { + await handleWriteCommand('fill', ['#input'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('select with no value throws', async () => { + try { + await handleWriteCommand('select', ['#sel'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('hover with no arg throws', async () => { + try { + await handleWriteCommand('hover', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('type with no arg throws', async () => { + try { + await handleWriteCommand('type', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('wait with no arg throws', async () => { + try { + await handleWriteCommand('wait', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('viewport with bad format throws', async () => { + try { + await handleWriteCommand('viewport', ['badformat'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('useragent with no arg throws', async () => { + try { + await handleWriteCommand('useragent', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + // Read command errors + test('js with no expression throws', async () => { + try { + await handleReadCommand('js', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('css with missing property throws', async () => { + try { + await handleReadCommand('css', ['h1'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('attrs with no selector throws', async () => { + try { + await handleReadCommand('attrs', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + // Meta command errors + test('tab with non-numeric id throws', async () => { + try { + await handleMetaCommand('tab', ['abc'], bm, async () => {}); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('diff with missing urls throws', async () => { + try { + await handleMetaCommand('diff', [baseUrl + '/basic.html'], bm, async () => {}); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('chain with invalid JSON throws', async () => { + try { + await handleMetaCommand('chain', ['not json'], bm, async () => {}); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Invalid JSON'); + } + }); + + test('chain with no arg throws', async () => { + try { + await handleMetaCommand('chain', [], bm, async () => {}); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('unknown read command throws', async () => { + try { + await handleReadCommand('bogus' as any, [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Unknown'); + } + }); + + test('unknown write command throws', async () => { + try { + await handleWriteCommand('bogus' as any, [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Unknown'); + } + }); + + test('unknown meta command throws', async () => { + try { + await handleMetaCommand('bogus' as any, [], bm, async () => {}); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Unknown'); + } + }); +}); + +// ─── Workflow: Navigation + Snapshot + Interaction ─────────────── + +describe('Workflows', () => { + test('navigation → snapshot → click @ref → verify URL', async () => { + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {}); + // Find a link ref + const linkLine = snap.split('\n').find(l => l.includes('[link]')); + expect(linkLine).toBeDefined(); + const refMatch = linkLine!.match(/@(e\d+)/); + expect(refMatch).toBeDefined(); + // Click the link + await handleWriteCommand('click', [`@${refMatch![1]}`], bm); + // URL should have changed + const url = await handleMetaCommand('url', [], bm, async () => {}); + expect(url).toBeTruthy(); + }); + + test('form: goto → snapshot → fill @ref → click @ref', async () => { + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {}); + // Find textbox and button + const textboxLine = snap.split('\n').find(l => l.includes('[textbox]')); + const buttonLine = snap.split('\n').find(l => l.includes('[button]') && l.includes('"Submit"')); + if (textboxLine && buttonLine) { + const textRef = textboxLine.match(/@(e\d+)/)![1]; + const btnRef = buttonLine.match(/@(e\d+)/)![1]; + await handleWriteCommand('fill', [`@${textRef}`, 'testuser'], bm); + await handleWriteCommand('click', [`@${btnRef}`], bm); + } + }); + + test('tabs: newtab → goto → switch → verify isolation', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const tabsBefore = bm.getTabCount(); + await handleMetaCommand('newtab', [baseUrl + '/forms.html'], bm, async () => {}); + expect(bm.getTabCount()).toBe(tabsBefore + 1); + + const url = await handleMetaCommand('url', [], bm, async () => {}); + expect(url).toContain('/forms.html'); + + // Switch back to previous tab + const tabs = await bm.getTabListWithTitles(); + const prevTab = tabs.find(t => t.url.includes('/basic.html')); + if (prevTab) { + bm.switchTab(prevTab.id); + const url2 = await handleMetaCommand('url', [], bm, async () => {}); + expect(url2).toContain('/basic.html'); + } + + // Clean up extra tab + const allTabs = await bm.getTabListWithTitles(); + const formTab = allTabs.find(t => t.url.includes('/forms.html')); + if (formTab) await bm.closeTab(formTab.id); + }); + + test('cookies: set → read → reload → verify persistence', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + await handleWriteCommand('cookie', ['workflow-test=persisted'], bm); + await handleWriteCommand('reload', [], bm); + const cookies = await handleReadCommand('cookies', [], bm); + expect(cookies).toContain('workflow-test'); + expect(cookies).toContain('persisted'); + }); +}); + +// ─── Wait load states ────────────────────────────────────────── + +describe('Wait load states', () => { + test('wait --networkidle succeeds after page load', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleWriteCommand('wait', ['--networkidle'], bm); + expect(result).toBe('Network idle'); + }); + + test('wait --load succeeds', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleWriteCommand('wait', ['--load'], bm); + expect(result).toBe('Page loaded'); + }); + + test('wait --domcontentloaded succeeds', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleWriteCommand('wait', ['--domcontentloaded'], bm); + expect(result).toBe('DOM content loaded'); + }); + + test('wait --networkidle with custom timeout', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleWriteCommand('wait', ['--networkidle', '5000'], bm); + expect(result).toBe('Network idle'); + }); + + test('wait with selector still works', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleWriteCommand('wait', ['#title'], bm); + expect(result).toContain('appeared'); + }); +}); + +// ─── Console --errors ────────────────────────────────────────── + +describe('Console --errors', () => { + test('console --errors filters to error and warning only', async () => { + // Clear existing entries + await handleReadCommand('console', ['--clear'], bm); + + // Add mixed entries + addConsoleEntry({ timestamp: Date.now(), level: 'log', text: 'info message' }); + addConsoleEntry({ timestamp: Date.now(), level: 'warning', text: 'warn message' }); + addConsoleEntry({ timestamp: Date.now(), level: 'error', text: 'error message' }); + + const result = await handleReadCommand('console', ['--errors'], bm); + expect(result).toContain('warn message'); + expect(result).toContain('error message'); + expect(result).not.toContain('info message'); + + // Cleanup + consoleBuffer.clear(); + }); + + test('console --errors returns empty message when no errors', async () => { + consoleBuffer.clear(); + addConsoleEntry({ timestamp: Date.now(), level: 'log', text: 'just a log' }); + + const result = await handleReadCommand('console', ['--errors'], bm); + expect(result).toBe('(no console errors)'); + + consoleBuffer.clear(); + }); + + test('console --errors on empty buffer', async () => { + consoleBuffer.clear(); + const result = await handleReadCommand('console', ['--errors'], bm); + expect(result).toBe('(no console errors)'); + }); + + test('console without flag still returns all messages', async () => { + consoleBuffer.clear(); + addConsoleEntry({ timestamp: Date.now(), level: 'log', text: 'all messages test' }); + + const result = await handleReadCommand('console', [], bm); + expect(result).toContain('all messages test'); + + consoleBuffer.clear(); + }); +}); + +// ─── Cookie Import ───────────────────────────────────────────── + +describe('Cookie import', () => { + test('cookie-import loads valid JSON cookies', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const tempFile = '/tmp/browse-test-cookies.json'; + const cookies = [ + { name: 'test-cookie', value: 'test-value' }, + { name: 'another', value: '123' }, + ]; + fs.writeFileSync(tempFile, JSON.stringify(cookies)); + + const result = await handleWriteCommand('cookie-import', [tempFile], bm); + expect(result).toBe('Loaded 2 cookies from /tmp/browse-test-cookies.json'); + + // Verify cookies were set + const cookieList = await handleReadCommand('cookies', [], bm); + expect(cookieList).toContain('test-cookie'); + expect(cookieList).toContain('test-value'); + expect(cookieList).toContain('another'); + + fs.unlinkSync(tempFile); + }); + + test('cookie-import auto-fills domain from page URL', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const tempFile = '/tmp/browse-test-cookies-nodomain.json'; + // Cookies without domain — should auto-fill from page URL + const cookies = [{ name: 'autofill-test', value: 'works' }]; + fs.writeFileSync(tempFile, JSON.stringify(cookies)); + + const result = await handleWriteCommand('cookie-import', [tempFile], bm); + expect(result).toContain('Loaded 1'); + + const cookieList = await handleReadCommand('cookies', [], bm); + expect(cookieList).toContain('autofill-test'); + + fs.unlinkSync(tempFile); + }); + + test('cookie-import preserves explicit domain', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const tempFile = '/tmp/browse-test-cookies-domain.json'; + const cookies = [{ name: 'explicit', value: 'domain', domain: 'example.com', path: '/foo' }]; + fs.writeFileSync(tempFile, JSON.stringify(cookies)); + + const result = await handleWriteCommand('cookie-import', [tempFile], bm); + expect(result).toContain('Loaded 1'); + + fs.unlinkSync(tempFile); + }); + + test('cookie-import with empty array succeeds', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const tempFile = '/tmp/browse-test-cookies-empty.json'; + fs.writeFileSync(tempFile, '[]'); + + const result = await handleWriteCommand('cookie-import', [tempFile], bm); + expect(result).toBe('Loaded 0 cookies from /tmp/browse-test-cookies-empty.json'); + + fs.unlinkSync(tempFile); + }); + + test('cookie-import throws on file not found', async () => { + try { + await handleWriteCommand('cookie-import', ['/tmp/nonexistent-cookies.json'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('File not found'); + } + }); + + test('cookie-import throws on invalid JSON', async () => { + const tempFile = '/tmp/browse-test-cookies-bad.json'; + fs.writeFileSync(tempFile, 'not json {{{'); + + try { + await handleWriteCommand('cookie-import', [tempFile], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Invalid JSON'); + } + + fs.unlinkSync(tempFile); + }); + + test('cookie-import throws on non-array JSON', async () => { + const tempFile = '/tmp/browse-test-cookies-obj.json'; + fs.writeFileSync(tempFile, '{"name": "not-an-array"}'); + + try { + await handleWriteCommand('cookie-import', [tempFile], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('JSON array'); + } + + fs.unlinkSync(tempFile); + }); + + test('cookie-import throws on cookie missing name', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const tempFile = '/tmp/browse-test-cookies-noname.json'; + fs.writeFileSync(tempFile, JSON.stringify([{ value: 'no-name' }])); + + try { + await handleWriteCommand('cookie-import', [tempFile], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('name'); + } + + fs.unlinkSync(tempFile); + }); + + test('cookie-import no arg throws', async () => { + try { + await handleWriteCommand('cookie-import', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } }); }); diff --git a/browse/test/fixtures/cursor-interactive.html b/browse/test/fixtures/cursor-interactive.html new file mode 100644 index 00000000..02590812 --- /dev/null +++ b/browse/test/fixtures/cursor-interactive.html @@ -0,0 +1,22 @@ + + + + + Test Page - Cursor Interactive + + + +

Cursor Interactive Test

+ +
Click me (div)
+ Hover card (span) +
Focusable div
+
Onclick div
+ + + Normal Link + + diff --git a/browse/test/fixtures/dialog.html b/browse/test/fixtures/dialog.html new file mode 100644 index 00000000..bfc588a1 --- /dev/null +++ b/browse/test/fixtures/dialog.html @@ -0,0 +1,15 @@ + + + + + Test Page - Dialog + + +

Dialog Test

+ + + +

+

+ + diff --git a/browse/test/fixtures/empty.html b/browse/test/fixtures/empty.html new file mode 100644 index 00000000..8ba582f9 --- /dev/null +++ b/browse/test/fixtures/empty.html @@ -0,0 +1,2 @@ + + diff --git a/browse/test/fixtures/states.html b/browse/test/fixtures/states.html new file mode 100644 index 00000000..67debbfa --- /dev/null +++ b/browse/test/fixtures/states.html @@ -0,0 +1,17 @@ + + + + + Test Page - Element States + + +

Element States Test

+ + + + +
Visible
+ + + + diff --git a/browse/test/fixtures/upload.html b/browse/test/fixtures/upload.html new file mode 100644 index 00000000..bb8aca60 --- /dev/null +++ b/browse/test/fixtures/upload.html @@ -0,0 +1,25 @@ + + + + + Test Page - Upload + + +

Upload Test

+ + +

+ + + diff --git a/browse/test/snapshot.test.ts b/browse/test/snapshot.test.ts index 846c82bb..bc45f6ac 100644 --- a/browse/test/snapshot.test.ts +++ b/browse/test/snapshot.test.ts @@ -11,6 +11,7 @@ import { BrowserManager } from '../src/browser-manager'; import { handleReadCommand } from '../src/read-commands'; import { handleWriteCommand } from '../src/write-commands'; import { handleMetaCommand } from '../src/meta-commands'; +import * as fs from 'fs'; let testServer: ReturnType; let bm: BrowserManager; @@ -199,3 +200,219 @@ describe('Ref invalidation', () => { expect(bm.getRefCount()).toBe(0); }); }); + +// ─── Snapshot Diffing ────────────────────────────────────────── + +describe('Snapshot diff', () => { + test('first snapshot -D stores baseline', async () => { + // Clear any previous snapshot + bm.setLastSnapshot(null); + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + const result = await handleMetaCommand('snapshot', ['-D'], bm, shutdown); + expect(result).toContain('no previous snapshot'); + expect(result).toContain('baseline'); + }); + + test('snapshot -D shows diff after change', async () => { + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + // Take first snapshot + await handleMetaCommand('snapshot', [], bm, shutdown); + // Modify DOM + await handleReadCommand('js', ['document.querySelector("h1").textContent = "Changed Title"'], bm); + // Take diff + const diff = await handleMetaCommand('snapshot', ['-D'], bm, shutdown); + expect(diff).toContain('---'); + expect(diff).toContain('+++'); + expect(diff).toContain('previous snapshot'); + expect(diff).toContain('current snapshot'); + }); + + test('snapshot -D with identical page shows no changes', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + await handleMetaCommand('snapshot', [], bm, shutdown); + const diff = await handleMetaCommand('snapshot', ['-D'], bm, shutdown); + // All lines should be unchanged (prefixed with space) + const lines = diff.split('\n').filter(l => l.startsWith('+') || l.startsWith('-')); + // Header lines start with --- and +++ so filter those + const contentChanges = lines.filter(l => !l.startsWith('---') && !l.startsWith('+++')); + expect(contentChanges.length).toBe(0); + }); +}); + +// ─── Annotated Screenshots ───────────────────────────────────── + +describe('Annotated screenshots', () => { + test('snapshot -a creates annotated screenshot', async () => { + const screenshotPath = '/tmp/browse-test-annotated.png'; + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + const result = await handleMetaCommand('snapshot', ['-a', '-o', screenshotPath], bm, shutdown); + expect(result).toContain('annotated screenshot'); + expect(result).toContain(screenshotPath); + expect(fs.existsSync(screenshotPath)).toBe(true); + const stat = fs.statSync(screenshotPath); + expect(stat.size).toBeGreaterThan(1000); + fs.unlinkSync(screenshotPath); + }); + + test('snapshot -a uses default path', async () => { + const defaultPath = '/tmp/browse-annotated.png'; + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + const result = await handleMetaCommand('snapshot', ['-a'], bm, shutdown); + expect(result).toContain('annotated screenshot'); + expect(fs.existsSync(defaultPath)).toBe(true); + fs.unlinkSync(defaultPath); + }); + + test('snapshot -a -i only annotates interactive', async () => { + const screenshotPath = '/tmp/browse-test-annotated-i.png'; + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + const result = await handleMetaCommand('snapshot', ['-i', '-a', '-o', screenshotPath], bm, shutdown); + expect(result).toContain('[button]'); + expect(result).toContain('[link]'); + expect(result).toContain('annotated screenshot'); + if (fs.existsSync(screenshotPath)) fs.unlinkSync(screenshotPath); + }); + + test('annotation overlays are cleaned up', async () => { + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + await handleMetaCommand('snapshot', ['-a'], bm, shutdown); + // Check that overlays are removed + const overlays = await handleReadCommand('js', ['document.querySelectorAll(".__browse_annotation__").length'], bm); + expect(overlays).toBe('0'); + // Clean up default file + try { fs.unlinkSync('/tmp/browse-annotated.png'); } catch {} + }); +}); + +// ─── Cursor-Interactive ──────────────────────────────────────── + +describe('Cursor-interactive', () => { + test('snapshot -C finds cursor:pointer elements', async () => { + await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm); + const result = await handleMetaCommand('snapshot', ['-C'], bm, shutdown); + expect(result).toContain('cursor-interactive'); + expect(result).toContain('@c'); + expect(result).toContain('cursor:pointer'); + }); + + test('snapshot -C includes onclick elements', async () => { + await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm); + const result = await handleMetaCommand('snapshot', ['-C'], bm, shutdown); + expect(result).toContain('onclick'); + }); + + test('snapshot -C includes tabindex elements', async () => { + await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm); + const result = await handleMetaCommand('snapshot', ['-C'], bm, shutdown); + expect(result).toContain('tabindex'); + }); + + test('@c ref is clickable', async () => { + await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm); + const snap = await handleMetaCommand('snapshot', ['-C'], bm, shutdown); + // Find a @c ref + const cLine = snap.split('\n').find(l => l.includes('@c')); + if (cLine) { + const refMatch = cLine.match(/@(c\d+)/); + if (refMatch) { + const result = await handleWriteCommand('click', [`@${refMatch[1]}`], bm); + expect(result).toContain('Clicked'); + } + } + }); + + test('snapshot -C on page with no cursor elements', async () => { + await handleWriteCommand('goto', [baseUrl + '/empty.html'], bm); + const result = await handleMetaCommand('snapshot', ['-C'], bm, shutdown); + // Should not contain cursor-interactive section + expect(result).not.toContain('cursor-interactive'); + }); + + test('snapshot -i -C combines both modes', async () => { + await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm); + const result = await handleMetaCommand('snapshot', ['-i', '-C'], bm, shutdown); + // Should have interactive elements (button, link) + expect(result).toContain('[button]'); + expect(result).toContain('[link]'); + // And cursor-interactive section + expect(result).toContain('cursor-interactive'); + }); +}); + +// ─── Snapshot Error Paths ─────────────────────────────────────── + +describe('Snapshot errors', () => { + test('unknown flag throws', async () => { + try { + await handleMetaCommand('snapshot', ['--bogus'], bm, shutdown); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Unknown snapshot flag'); + } + }); + + test('-d without number throws', async () => { + try { + await handleMetaCommand('snapshot', ['-d'], bm, shutdown); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('-s without selector throws', async () => { + try { + await handleMetaCommand('snapshot', ['-s'], bm, shutdown); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('-s with nonexistent selector throws', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + try { + await handleMetaCommand('snapshot', ['-s', '#nonexistent-element-12345'], bm, shutdown); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Selector not found'); + } + }); + + test('-o without path throws', async () => { + try { + await handleMetaCommand('snapshot', ['-o'], bm, shutdown); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); +}); + +// ─── Combined Flags ───────────────────────────────────────────── + +describe('Snapshot combined flags', () => { + test('-i -c -d 2 combines all filters', async () => { + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + const result = await handleMetaCommand('snapshot', ['-i', '-c', '-d', '2'], bm, shutdown); + // Should be filtered to interactive, compact, shallow + expect(result).toContain('[button]'); + expect(result).toContain('[link]'); + // Should NOT contain deep nested non-interactive elements + expect(result).not.toContain('[heading]'); + }); + + test('closetab last tab auto-creates new', async () => { + // Get down to 1 tab + const tabs = await bm.getTabListWithTitles(); + for (let i = 1; i < tabs.length; i++) { + await bm.closeTab(tabs[i].id); + } + expect(bm.getTabCount()).toBe(1); + // Close the last tab + const lastTab = (await bm.getTabListWithTitles())[0]; + await bm.closeTab(lastTab.id); + // Should have auto-created a new tab + expect(bm.getTabCount()).toBe(1); + }); +}); diff --git a/browse/test/test-server.ts b/browse/test/test-server.ts index aeb0a5b5..37758825 100644 --- a/browse/test/test-server.ts +++ b/browse/test/test-server.ts @@ -14,6 +14,16 @@ export function startTestServer(port: number = 0): { server: ReturnType = {}; + req.headers.forEach((value, key) => { headers[key] = value; }); + return new Response(JSON.stringify(headers, null, 2), { + headers: { 'Content-Type': 'application/json' }, + }); + } + let filePath = url.pathname === '/' ? '/basic.html' : url.pathname; // Remove leading slash