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) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-26 00:46:51 -06:00
parent 9daf33c84b
commit e497d996c5
3 changed files with 150 additions and 27 deletions
+122 -8
View File
@@ -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 <name>');
// 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 <name>');
}
// ─── Frame ───────────────────────────────────────
case 'frame': {
const target = args[0];
if (!target) throw new Error('Usage: frame <selector|@ref|--name name|--url pattern|main>');
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 <name>');
frame = page.frame({ name: args[1] });
} else if (target === '--url') {
if (!args[1]) throw new Error('Usage: frame --url <pattern>');
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}`);
}
+22 -12
View File
@@ -18,9 +18,13 @@ export async function handleWriteCommand(
bm: BrowserManager
): Promise<string> {
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 <url>');
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 <option> elements always fails (not visible / timeout)
const isOption = 'locator' in resolved
? await resolved.locator.evaluate(el => el.tagName === 'OPTION').catch(() => false)
: await page.evaluate(
(sel: string) => document.querySelector(sel)?.tagName === 'OPTION',
(resolved as { selector: string }).selector
: await target.locator(resolved.selector).evaluate(
el => el.tagName === 'OPTION'
).catch(() => false);
if (isOption) {
throw new Error(
@@ -90,8 +96,8 @@ export async function handleWriteCommand(
}
throw err;
}
// Wait briefly for any navigation/DOM update
await page.waitForLoadState('domcontentloaded').catch(() => {});
// Wait for network to settle (catches XHR/fetch triggered by clicks)
await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
return `Clicked ${selector} → now at ${page.url()}`;
}
@@ -103,8 +109,10 @@ export async function handleWriteCommand(
if ('locator' in resolved) {
await resolved.locator.fill(value, { timeout: 5000 });
} else {
await page.fill(resolved.selector, value, { timeout: 5000 });
await target.locator(resolved.selector).fill(value, { timeout: 5000 });
}
// Wait for network to settle (form validation XHRs)
await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
return `Filled ${selector}`;
}
@@ -116,8 +124,10 @@ export async function handleWriteCommand(
if ('locator' in resolved) {
await resolved.locator.selectOption(value, { timeout: 5000 });
} else {
await page.selectOption(resolved.selector, value, { timeout: 5000 });
await target.locator(resolved.selector).selectOption(value, { timeout: 5000 });
}
// Wait for network to settle (dropdown-triggered requests)
await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
return `Selected "${value}" in ${selector}`;
}
@@ -128,7 +138,7 @@ export async function handleWriteCommand(
if ('locator' in resolved) {
await resolved.locator.hover({ timeout: 5000 });
} else {
await page.hover(resolved.selector, { timeout: 5000 });
await target.locator(resolved.selector).hover({ timeout: 5000 });
}
return `Hovered ${selector}`;
}
@@ -154,11 +164,11 @@ export async function handleWriteCommand(
if ('locator' in resolved) {
await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
} else {
await page.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: 5000 });
await target.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: 5000 });
}
return `Scrolled ${selector} into view`;
}
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await target.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
return 'Scrolled to bottom';
}
@@ -183,7 +193,7 @@ export async function handleWriteCommand(
if ('locator' in resolved) {
await resolved.locator.waitFor({ state: 'visible', timeout });
} else {
await page.waitForSelector(resolved.selector, { timeout });
await target.locator(resolved.selector).waitFor({ state: 'visible', timeout });
}
return `Element ${selector} appeared`;
}
+6 -7
View File
@@ -1323,13 +1323,12 @@ describe('Errors', () => {
}
});
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 invalid JSON falls back to pipe format', async () => {
// Non-JSON input is now treated as pipe-delimited format
// 'not json' → [["not", "json"]] → "not" is unknown command → error in result
const result = await handleMetaCommand('chain', ['not json'], bm, async () => {});
expect(result).toContain('ERROR');
expect(result).toContain('Unknown command: not');
});
test('chain with no arg throws', async () => {