mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 05:05:08 +02:00
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:
+122
-8
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user