mirror of https://github.com/garrytan/gstack.git
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:
parent
9daf33c84b
commit
e497d996c5
|
|
@ -11,6 +11,8 @@ import * as Diff from 'diff';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { TEMP_DIR, isPathWithin } from './platform';
|
import { TEMP_DIR, isPathWithin } from './platform';
|
||||||
|
import { resolveConfig } from './config';
|
||||||
|
import type { Frame } from 'playwright';
|
||||||
|
|
||||||
// Security: Path validation to prevent path traversal attacks
|
// Security: Path validation to prevent path traversal attacks
|
||||||
const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()];
|
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(
|
export async function handleMetaCommand(
|
||||||
command: string,
|
command: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
|
|
@ -187,35 +208,52 @@ export async function handleMetaCommand(
|
||||||
case 'chain': {
|
case 'chain': {
|
||||||
// Read JSON array from args[0] (if provided) or expect it was passed as body
|
// Read JSON array from args[0] (if provided) or expect it was passed as body
|
||||||
const jsonStr = args[0];
|
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[][];
|
let commands: string[][];
|
||||||
try {
|
try {
|
||||||
commands = JSON.parse(jsonStr);
|
commands = JSON.parse(jsonStr);
|
||||||
|
if (!Array.isArray(commands)) throw new Error('not array');
|
||||||
} catch {
|
} 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 results: string[] = [];
|
||||||
const { handleReadCommand } = await import('./read-commands');
|
const { handleReadCommand } = await import('./read-commands');
|
||||||
const { handleWriteCommand } = await import('./write-commands');
|
const { handleWriteCommand } = await import('./write-commands');
|
||||||
|
|
||||||
|
let lastWasWrite = false;
|
||||||
for (const cmd of commands) {
|
for (const cmd of commands) {
|
||||||
const [name, ...cmdArgs] = cmd;
|
const [name, ...cmdArgs] = cmd;
|
||||||
try {
|
try {
|
||||||
let result: string;
|
let result: string;
|
||||||
if (WRITE_COMMANDS.has(name)) result = await handleWriteCommand(name, cmdArgs, bm);
|
if (WRITE_COMMANDS.has(name)) {
|
||||||
else if (READ_COMMANDS.has(name)) result = await handleReadCommand(name, cmdArgs, bm);
|
result = await handleWriteCommand(name, cmdArgs, bm);
|
||||||
else if (META_COMMANDS.has(name)) result = await handleMetaCommand(name, cmdArgs, bm, shutdown);
|
lastWasWrite = true;
|
||||||
else throw new Error(`Unknown command: ${name}`);
|
} 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}`);
|
results.push(`[${name}] ${result}`);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
results.push(`[${name}] ERROR: ${err.message}`);
|
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');
|
return results.join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -410,6 +448,82 @@ export async function handleMetaCommand(
|
||||||
return lines.join('\n');
|
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:
|
default:
|
||||||
throw new Error(`Unknown meta command: ${command}`);
|
throw new Error(`Unknown meta command: ${command}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,13 @@ export async function handleWriteCommand(
|
||||||
bm: BrowserManager
|
bm: BrowserManager
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const page = bm.getPage();
|
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) {
|
switch (command) {
|
||||||
case 'goto': {
|
case 'goto': {
|
||||||
|
if (inFrame) throw new Error('Cannot use goto inside a frame. Run \'frame main\' first.');
|
||||||
const url = args[0];
|
const url = args[0];
|
||||||
if (!url) throw new Error('Usage: browse goto <url>');
|
if (!url) throw new Error('Usage: browse goto <url>');
|
||||||
await validateNavigationUrl(url);
|
await validateNavigationUrl(url);
|
||||||
|
|
@ -30,16 +34,19 @@ export async function handleWriteCommand(
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'back': {
|
case 'back': {
|
||||||
|
if (inFrame) throw new Error('Cannot use back inside a frame. Run \'frame main\' first.');
|
||||||
await page.goBack({ waitUntil: 'domcontentloaded', timeout: 15000 });
|
await page.goBack({ waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||||
return `Back → ${page.url()}`;
|
return `Back → ${page.url()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'forward': {
|
case 'forward': {
|
||||||
|
if (inFrame) throw new Error('Cannot use forward inside a frame. Run \'frame main\' first.');
|
||||||
await page.goForward({ waitUntil: 'domcontentloaded', timeout: 15000 });
|
await page.goForward({ waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||||
return `Forward → ${page.url()}`;
|
return `Forward → ${page.url()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'reload': {
|
case 'reload': {
|
||||||
|
if (inFrame) throw new Error('Cannot use reload inside a frame. Run \'frame main\' first.');
|
||||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 });
|
await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||||
return `Reloaded ${page.url()}`;
|
return `Reloaded ${page.url()}`;
|
||||||
}
|
}
|
||||||
|
|
@ -73,15 +80,14 @@ export async function handleWriteCommand(
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
await resolved.locator.click({ timeout: 5000 });
|
await resolved.locator.click({ timeout: 5000 });
|
||||||
} else {
|
} else {
|
||||||
await page.click(resolved.selector, { timeout: 5000 });
|
await target.locator(resolved.selector).click({ timeout: 5000 });
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Enhanced error guidance: clicking <option> elements always fails (not visible / timeout)
|
// Enhanced error guidance: clicking <option> elements always fails (not visible / timeout)
|
||||||
const isOption = 'locator' in resolved
|
const isOption = 'locator' in resolved
|
||||||
? await resolved.locator.evaluate(el => el.tagName === 'OPTION').catch(() => false)
|
? await resolved.locator.evaluate(el => el.tagName === 'OPTION').catch(() => false)
|
||||||
: await page.evaluate(
|
: await target.locator(resolved.selector).evaluate(
|
||||||
(sel: string) => document.querySelector(sel)?.tagName === 'OPTION',
|
el => el.tagName === 'OPTION'
|
||||||
(resolved as { selector: string }).selector
|
|
||||||
).catch(() => false);
|
).catch(() => false);
|
||||||
if (isOption) {
|
if (isOption) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -90,8 +96,8 @@ export async function handleWriteCommand(
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
// Wait briefly for any navigation/DOM update
|
// Wait for network to settle (catches XHR/fetch triggered by clicks)
|
||||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
|
||||||
return `Clicked ${selector} → now at ${page.url()}`;
|
return `Clicked ${selector} → now at ${page.url()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,8 +109,10 @@ export async function handleWriteCommand(
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
await resolved.locator.fill(value, { timeout: 5000 });
|
await resolved.locator.fill(value, { timeout: 5000 });
|
||||||
} else {
|
} 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}`;
|
return `Filled ${selector}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,8 +124,10 @@ export async function handleWriteCommand(
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
await resolved.locator.selectOption(value, { timeout: 5000 });
|
await resolved.locator.selectOption(value, { timeout: 5000 });
|
||||||
} else {
|
} 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}`;
|
return `Selected "${value}" in ${selector}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,7 +138,7 @@ export async function handleWriteCommand(
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
await resolved.locator.hover({ timeout: 5000 });
|
await resolved.locator.hover({ timeout: 5000 });
|
||||||
} else {
|
} else {
|
||||||
await page.hover(resolved.selector, { timeout: 5000 });
|
await target.locator(resolved.selector).hover({ timeout: 5000 });
|
||||||
}
|
}
|
||||||
return `Hovered ${selector}`;
|
return `Hovered ${selector}`;
|
||||||
}
|
}
|
||||||
|
|
@ -154,11 +164,11 @@ export async function handleWriteCommand(
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
|
await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
|
||||||
} else {
|
} else {
|
||||||
await page.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: 5000 });
|
await target.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: 5000 });
|
||||||
}
|
}
|
||||||
return `Scrolled ${selector} into view`;
|
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';
|
return 'Scrolled to bottom';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,7 +193,7 @@ export async function handleWriteCommand(
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
await resolved.locator.waitFor({ state: 'visible', timeout });
|
await resolved.locator.waitFor({ state: 'visible', timeout });
|
||||||
} else {
|
} else {
|
||||||
await page.waitForSelector(resolved.selector, { timeout });
|
await target.locator(resolved.selector).waitFor({ state: 'visible', timeout });
|
||||||
}
|
}
|
||||||
return `Element ${selector} appeared`;
|
return `Element ${selector} appeared`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1323,13 +1323,12 @@ describe('Errors', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('chain with invalid JSON throws', async () => {
|
test('chain with invalid JSON falls back to pipe format', async () => {
|
||||||
try {
|
// Non-JSON input is now treated as pipe-delimited format
|
||||||
await handleMetaCommand('chain', ['not json'], bm, async () => {});
|
// 'not json' → [["not", "json"]] → "not" is unknown command → error in result
|
||||||
expect(true).toBe(false);
|
const result = await handleMetaCommand('chain', ['not json'], bm, async () => {});
|
||||||
} catch (err: any) {
|
expect(result).toContain('ERROR');
|
||||||
expect(err.message).toContain('Invalid JSON');
|
expect(result).toContain('Unknown command: not');
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('chain with no arg throws', async () => {
|
test('chain with no arg throws', async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue