From e497d996c5a1e48c1fde1dd2d98f3cbe557adacb Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 26 Mar 2026 00:46:51 -0600 Subject: [PATCH] feat: network idle detection + chain pipe format - Upgrade click/fill/select from domcontentloaded to networkidle wait (2s timeout, best-effort). Catches XHR/fetch triggered by interactions. - Add pipe-delimited format to chain as JSON fallback: $B chain 'goto url | click @e5 | snapshot -ic' - Add post-loop networkidle wait in chain when last command was a write. - Frame-aware: commands use target (getActiveFrameOrPage) for locator ops, page-only ops (goto/back/forward/reload) guard against frame context. Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/src/meta-commands.ts | 130 ++++++++++++++++++++++++++++++++--- browse/src/write-commands.ts | 34 +++++---- browse/test/commands.test.ts | 13 ++-- 3 files changed, 150 insertions(+), 27 deletions(-) diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index e56b95be..b6f89def 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -11,6 +11,8 @@ import * as Diff from 'diff'; import * as fs from 'fs'; import * as path from 'path'; import { TEMP_DIR, isPathWithin } from './platform'; +import { resolveConfig } from './config'; +import type { Frame } from 'playwright'; // Security: Path validation to prevent path traversal attacks const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()]; @@ -23,6 +25,25 @@ export function validateOutputPath(filePath: string): void { } } +/** Tokenize a pipe segment respecting double-quoted strings. */ +function tokenizePipeSegment(segment: string): string[] { + const tokens: string[] = []; + let current = ''; + let inQuote = false; + for (let i = 0; i < segment.length; i++) { + const ch = segment[i]; + if (ch === '"') { + inQuote = !inQuote; + } else if (ch === ' ' && !inQuote) { + if (current) { tokens.push(current); current = ''; } + } else { + current += ch; + } + } + if (current) tokens.push(current); + return tokens; +} + export async function handleMetaCommand( command: string, args: string[], @@ -187,35 +208,52 @@ export async function handleMetaCommand( case 'chain': { // Read JSON array from args[0] (if provided) or expect it was passed as body const jsonStr = args[0]; - if (!jsonStr) throw new Error('Usage: echo \'[["goto","url"],["text"]]\' | browse chain'); + if (!jsonStr) throw new Error( + 'Usage: echo \'[["goto","url"],["text"]]\' | browse chain\n' + + ' or: browse chain \'goto url | click @e5 | snapshot -ic\'' + ); let commands: string[][]; try { commands = JSON.parse(jsonStr); + if (!Array.isArray(commands)) throw new Error('not array'); } catch { - throw new Error('Invalid JSON. Expected: [["command", "arg1", "arg2"], ...]'); + // Fallback: pipe-delimited format "goto url | click @e5 | snapshot -ic" + commands = jsonStr.split(' | ').map(seg => tokenizePipeSegment(seg.trim())); } - if (!Array.isArray(commands)) throw new Error('Expected JSON array of commands'); - const results: string[] = []; const { handleReadCommand } = await import('./read-commands'); const { handleWriteCommand } = await import('./write-commands'); + let lastWasWrite = false; for (const cmd of commands) { const [name, ...cmdArgs] = cmd; try { let result: string; - if (WRITE_COMMANDS.has(name)) result = await handleWriteCommand(name, cmdArgs, bm); - else if (READ_COMMANDS.has(name)) result = await handleReadCommand(name, cmdArgs, bm); - else if (META_COMMANDS.has(name)) result = await handleMetaCommand(name, cmdArgs, bm, shutdown); - else throw new Error(`Unknown command: ${name}`); + if (WRITE_COMMANDS.has(name)) { + result = await handleWriteCommand(name, cmdArgs, bm); + lastWasWrite = true; + } else if (READ_COMMANDS.has(name)) { + result = await handleReadCommand(name, cmdArgs, bm); + lastWasWrite = false; + } else if (META_COMMANDS.has(name)) { + result = await handleMetaCommand(name, cmdArgs, bm, shutdown); + lastWasWrite = false; + } else { + throw new Error(`Unknown command: ${name}`); + } results.push(`[${name}] ${result}`); } catch (err: any) { results.push(`[${name}] ERROR: ${err.message}`); } } + // Wait for network to settle after write commands before returning + if (lastWasWrite) { + await bm.getPage().waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {}); + } + return results.join('\n\n'); } @@ -410,6 +448,82 @@ export async function handleMetaCommand( return lines.join('\n'); } + // ─── State ──────────────────────────────────────── + case 'state': { + const [action, name] = args; + if (!action || !name) throw new Error('Usage: state save|load '); + + // Sanitize name: alphanumeric + hyphens + underscores only + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + throw new Error('State name must be alphanumeric (a-z, 0-9, _, -)'); + } + + const config = resolveConfig(); + const stateDir = path.join(config.stateDir, 'browse-states'); + fs.mkdirSync(stateDir, { recursive: true }); + const statePath = path.join(stateDir, `${name}.json`); + + if (action === 'save') { + const state = await bm.saveState(); + // V1: cookies + URLs only (not localStorage — breaks on load-before-navigate) + const saveData = { + version: 1, + cookies: state.cookies, + pages: state.pages.map(p => ({ url: p.url, isActive: p.isActive })), + }; + fs.writeFileSync(statePath, JSON.stringify(saveData, null, 2), { mode: 0o600 }); + return `State saved: ${statePath} (${state.cookies.length} cookies, ${state.pages.length} pages — treat as sensitive)`; + } + + if (action === 'load') { + if (!fs.existsSync(statePath)) throw new Error(`State not found: ${statePath}`); + const data = JSON.parse(fs.readFileSync(statePath, 'utf-8')); + // Close existing pages, then restore (replace, not merge) + await bm.closeAllPages(); + await bm.restoreState({ + cookies: data.cookies, + pages: data.pages.map((p: any) => ({ ...p, storage: null })), + }); + return `State loaded: ${data.cookies.length} cookies, ${data.pages.length} pages`; + } + + throw new Error('Usage: state save|load '); + } + + // ─── Frame ─────────────────────────────────────── + case 'frame': { + const target = args[0]; + if (!target) throw new Error('Usage: frame '); + + if (target === 'main') { + bm.setFrame(null); + bm.clearRefs(); + return 'Switched to main frame'; + } + + const page = bm.getPage(); + let frame: Frame | null = null; + + if (target === '--name') { + if (!args[1]) throw new Error('Usage: frame --name '); + frame = page.frame({ name: args[1] }); + } else if (target === '--url') { + if (!args[1]) throw new Error('Usage: frame --url '); + frame = page.frame({ url: new RegExp(args[1]) }); + } else { + // CSS selector or @ref for the iframe element + const resolved = await bm.resolveRef(target); + const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector); + const elementHandle = await locator.elementHandle({ timeout: 5000 }); + frame = await elementHandle?.contentFrame() ?? null; + } + + if (!frame) throw new Error(`Frame not found: ${target}`); + bm.setFrame(frame); + bm.clearRefs(); + return `Switched to frame: ${frame.url()}`; + } + default: throw new Error(`Unknown meta command: ${command}`); } diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 3e80c7fd..f8e113b9 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -18,9 +18,13 @@ export async function handleWriteCommand( bm: BrowserManager ): Promise { const page = bm.getPage(); + // Frame-aware target for locator-based operations (click, fill, etc.) + const target = bm.getActiveFrameOrPage(); + const inFrame = bm.getFrame() !== null; switch (command) { case 'goto': { + if (inFrame) throw new Error('Cannot use goto inside a frame. Run \'frame main\' first.'); const url = args[0]; if (!url) throw new Error('Usage: browse goto '); await validateNavigationUrl(url); @@ -30,16 +34,19 @@ export async function handleWriteCommand( } case 'back': { + if (inFrame) throw new Error('Cannot use back inside a frame. Run \'frame main\' first.'); await page.goBack({ waitUntil: 'domcontentloaded', timeout: 15000 }); return `Back → ${page.url()}`; } case 'forward': { + if (inFrame) throw new Error('Cannot use forward inside a frame. Run \'frame main\' first.'); await page.goForward({ waitUntil: 'domcontentloaded', timeout: 15000 }); return `Forward → ${page.url()}`; } case 'reload': { + if (inFrame) throw new Error('Cannot use reload inside a frame. Run \'frame main\' first.'); await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 }); return `Reloaded ${page.url()}`; } @@ -73,15 +80,14 @@ export async function handleWriteCommand( if ('locator' in resolved) { await resolved.locator.click({ timeout: 5000 }); } else { - await page.click(resolved.selector, { timeout: 5000 }); + await target.locator(resolved.selector).click({ timeout: 5000 }); } } catch (err: any) { // Enhanced error guidance: clicking