diff --git a/CHANGELOG.md b/CHANGELOG.md index b286c6d3..2f989493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## [0.12.1.0] - 2026-03-26 — Smarter Browsing: Network Idle, State Persistence, Iframes + +Every click, fill, and select now waits for the page to settle before returning. No more stale snapshots because an XHR was still in-flight. Chain accepts pipe-delimited format for faster multi-step flows. You can save and restore browser sessions (cookies + open tabs). And iframe content is now reachable. + +### Added + +- **Network idle detection.** `click`, `fill`, and `select` auto-wait up to 2s for network requests to settle before returning. Catches XHR/fetch triggered by interactions. Uses Playwright's built-in `waitForLoadState('networkidle')`, not a custom tracker. + +- **`$B state save/load`.** Save your browser session (cookies + open tabs) to a named file, load it back later. Files stored at `.gstack/browse-states/{name}.json` with 0o600 permissions. V1 saves cookies + URLs only (not localStorage, which breaks on load-before-navigate). Load replaces the current session, not merge. + +- **`$B frame` command.** Switch command context into an iframe: `$B frame iframe`, `$B frame --name checkout`, `$B frame --url stripe`, or `$B frame @e5`. All subsequent commands (click, fill, snapshot, etc.) operate inside the iframe. `$B frame main` returns to the main page. Snapshot shows `[Context: iframe src="..."]` header. Detached frames auto-recover. + +- **Chain pipe format.** Chain now accepts `$B chain 'goto url | click @e5 | snapshot -ic'` as a fallback when JSON parsing fails. Pipe-delimited with quote-aware tokenization. + +### Changed + +- **Chain post-loop idle wait.** After executing all commands in a chain, if the last was a write command, chain waits for network idle before returning. + +### Fixed + +- **Iframe ref scoping.** Snapshot ref locators, cursor-interactive scan, and cursor locators now use the frame-aware target instead of always scoping to the main page. +- **Detached frame recovery.** `getActiveFrameOrPage()` checks `isDetached()` and auto-recovers. +- **State load resets frame context.** Loading a saved state clears the active frame reference. +- **elementHandle leak in frame command.** Now properly disposed after getting contentFrame. +- **Upload command frame-aware.** `upload` uses the frame-aware target for file input locators. + ## [0.12.0.0] - 2026-03-26 — Headed Mode + Sidebar Agent You can now watch Claude work in a real Chrome window and direct it from a sidebar chat. diff --git a/SKILL.md b/SKILL.md index 26f01927..b3f1ce3d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -591,6 +591,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. | Command | Description | |---------|-------------| | `chain` | Run commands from JSON stdin. Format: [["cmd","arg1",...],...] | +| `frame ` | Switch to iframe context (or main to return) | | `inbox [--clear]` | List messages from sidebar scout inbox | | `watch [stop]` | Passive observation — periodic snapshots while user browses | @@ -611,6 +612,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. | `handoff [message]` | Open visible Chrome at current page for user takeover | | `restart` | Restart server | | `resume` | Re-snapshot after user takeover, return control to AI | +| `state save|load ` | Save/load browser state (cookies + URLs) | | `status` | Health check | | `stop` | Shutdown server | diff --git a/TODOS.md b/TODOS.md index b1ade477..8458a98a 100644 --- a/TODOS.md +++ b/TODOS.md @@ -80,17 +80,14 @@ May replace `/setup-browser-cookies` for most use cases since the user's real co **Effort:** S **Priority:** P3 -### State persistence +### State persistence — SHIPPED -**What:** Save/load cookies + localStorage to JSON files for reproducible test sessions. +~~**What:** Save/load cookies + localStorage to JSON files for reproducible test sessions.~~ -**Why:** Enables "resume where I left off" for QA sessions and repeatable auth states. +`$B state save/load` ships in v0.12.1.0. V1 saves cookies + URLs only (not localStorage, which breaks on load-before-navigate). Files at `.gstack/browse-states/{name}.json` with 0o600 permissions. Load replaces session (closes all pages first). Name sanitized to `[a-zA-Z0-9_-]`. -**Context:** The `saveState()`/`restoreState()` helpers from the handoff feature (browser-manager.ts) already capture cookies + localStorage + sessionStorage + URLs. Adding file I/O on top is ~20 lines. - -**Effort:** S -**Priority:** P3 -**Depends on:** Sessions +**Remaining:** V2 localStorage support (needs pre-navigation injection strategy). +**Completed:** v0.12.1.0 (2026-03-26) ### Auth vault @@ -102,14 +99,13 @@ May replace `/setup-browser-cookies` for most use cases since the user's real co **Priority:** P3 **Depends on:** Sessions, state persistence -### Iframe support +### Iframe support — SHIPPED -**What:** `frame ` and `frame main` commands for cross-frame interaction. +~~**What:** `frame ` and `frame main` commands for cross-frame interaction.~~ -**Why:** Many web apps use iframes (embeds, payment forms, ads). Currently invisible to browse. +`$B frame` ships in v0.12.1.0. Supports CSS selector, @ref, `--name`, and `--url` pattern matching. Execution target abstraction (`getActiveFrameOrPage()`) across all read/write/snapshot commands. Frame context cleared on navigation, tab switch, resume. Detached frame auto-recovery. Page-only operations (goto, screenshot, viewport) throw clear error when in frame context. -**Effort:** M -**Priority:** P4 +**Completed:** v0.12.1.0 (2026-03-26) ### Semantic locators diff --git a/VERSION b/VERSION index 6ca5e048..ba9b59b5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.12.0.0 +0.12.1.0 diff --git a/browse/SKILL.md b/browse/SKILL.md index 928f474f..399aec3a 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -474,6 +474,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. | Command | Description | |---------|-------------| | `chain` | Run commands from JSON stdin. Format: [["cmd","arg1",...],...] | +| `frame ` | Switch to iframe context (or main to return) | | `inbox [--clear]` | List messages from sidebar scout inbox | | `watch [stop]` | Passive observation — periodic snapshots while user browses | @@ -494,5 +495,6 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. | `handoff [message]` | Open visible Chrome at current page for user takeover | | `restart` | Restart server | | `resume` | Re-snapshot after user takeover, return control to AI | +| `state save|load ` | Save/load browser state (cookies + URLs) | | `status` | Health check | | `stop` | Shutdown server | diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 23da95d8..1ef58e36 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -402,6 +402,7 @@ export class BrowserManager { switchTab(id: number): void { if (!this.pages.has(id)) throw new Error(`Tab ${id} not found`); this.activeTabId = id; + this.activeFrame = null; // Frame context is per-tab } getTabCount(): number { @@ -531,6 +532,42 @@ export class BrowserManager { return this.customUserAgent; } + // ─── Lifecycle helpers ─────────────────────────────── + /** + * Close all open pages and clear the pages map. + * Used by state load to replace the current session. + */ + async closeAllPages(): Promise { + for (const page of this.pages.values()) { + await page.close().catch(() => {}); + } + this.pages.clear(); + this.clearRefs(); + } + + // ─── Frame context ───────────────────────────────── + private activeFrame: import('playwright').Frame | null = null; + + setFrame(frame: import('playwright').Frame | null): void { + this.activeFrame = frame; + } + + getFrame(): import('playwright').Frame | null { + return this.activeFrame; + } + + /** + * Returns the active frame if set, otherwise the current page. + * Use this for operations that work on both Page and Frame (locator, evaluate, etc.). + */ + getActiveFrameOrPage(): import('playwright').Page | import('playwright').Frame { + // Auto-recover from detached frames (iframe removed/navigated) + if (this.activeFrame?.isDetached()) { + this.activeFrame = null; + } + return this.activeFrame ?? this.getPage(); + } + // ─── State Save/Restore (shared by recreateContext + handoff) ─ /** * Capture browser state: cookies, localStorage, sessionStorage, URLs, active tab. @@ -789,6 +826,7 @@ export class BrowserManager { resume(): void { this.clearRefs(); this.resetFailures(); + this.activeFrame = null; } getIsHeaded(): boolean { @@ -818,6 +856,7 @@ export class BrowserManager { page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) { this.clearRefs(); + this.activeFrame = null; // Navigation invalidates frame context } }); diff --git a/browse/src/commands.ts b/browse/src/commands.ts index 5bd4e2c6..15244538 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -34,6 +34,8 @@ export const META_COMMANDS = new Set([ 'connect', 'disconnect', 'focus', 'inbox', 'watch', + 'state', + 'frame', ]); export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]); @@ -109,6 +111,10 @@ export const COMMAND_DESCRIPTIONS: Record' }, + // Frame + 'frame': { category: 'Meta', description: 'Switch to iframe context (or main to return)', usage: 'frame ' }, }; // Load-time validation: descriptions must cover exactly the command sets diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index e56b95be..4388491a 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,54 @@ 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(' | ') + .filter(seg => seg.trim().length > 0) + .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 +450,87 @@ 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')); + if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) { + throw new Error('Invalid state file: expected cookies and pages arrays'); + } + // Close existing pages, then restore (replace, not merge) + bm.setFrame(null); + 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; + await elementHandle?.dispose(); + } + + 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/read-commands.ts b/browse/src/read-commands.ts index 5d93156c..802c3813 100644 --- a/browse/src/read-commands.ts +++ b/browse/src/read-commands.ts @@ -7,7 +7,7 @@ import type { BrowserManager } from './browser-manager'; import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers'; -import type { Page } from 'playwright'; +import type { Page, Frame } from 'playwright'; import * as fs from 'fs'; import * as path from 'path'; import { TEMP_DIR, isPathWithin } from './platform'; @@ -57,7 +57,7 @@ export function validateReadPath(filePath: string): void { * 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 { +export async function getCleanText(page: Page | Frame): Promise { return await page.evaluate(() => { const body = document.body; if (!body) return ''; @@ -77,10 +77,12 @@ export async function handleReadCommand( bm: BrowserManager ): Promise { const page = bm.getPage(); + // Frame-aware target for content extraction + const target = bm.getActiveFrameOrPage(); switch (command) { case 'text': { - return await getCleanText(page); + return await getCleanText(target); } case 'html': { @@ -90,13 +92,19 @@ export async function handleReadCommand( if ('locator' in resolved) { return await resolved.locator.innerHTML({ timeout: 5000 }); } - return await page.innerHTML(resolved.selector); + return await target.locator(resolved.selector).innerHTML({ timeout: 5000 }); } - return await page.content(); + // page.content() is page-only; use evaluate for frame compat + const doctype = await target.evaluate(() => { + const dt = document.doctype; + return dt ? `` : ''; + }); + const html = await target.evaluate(() => document.documentElement.outerHTML); + return doctype ? `${doctype}\n${html}` : html; } case 'links': { - const links = await page.evaluate(() => + const links = await target.evaluate(() => [...document.querySelectorAll('a[href]')].map(a => ({ text: a.textContent?.trim().slice(0, 120) || '', href: (a as HTMLAnchorElement).href, @@ -106,7 +114,7 @@ export async function handleReadCommand( } case 'forms': { - const forms = await page.evaluate(() => { + const forms = await target.evaluate(() => { return [...document.querySelectorAll('form')].map((form, i) => { const fields = [...form.querySelectorAll('input, select, textarea')].map(el => { const input = el as HTMLInputElement; @@ -136,7 +144,7 @@ export async function handleReadCommand( } case 'accessibility': { - const snapshot = await page.locator("body").ariaSnapshot(); + const snapshot = await target.locator("body").ariaSnapshot(); return snapshot; } @@ -144,7 +152,7 @@ export async function handleReadCommand( const expr = args[0]; if (!expr) throw new Error('Usage: browse js '); const wrapped = wrapForEvaluate(expr); - const result = await page.evaluate(wrapped); + const result = await target.evaluate(wrapped); return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? ''); } @@ -155,7 +163,7 @@ export async function handleReadCommand( if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`); const code = fs.readFileSync(filePath, 'utf-8'); const wrapped = wrapForEvaluate(code); - const result = await page.evaluate(wrapped); + const result = await target.evaluate(wrapped); return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? ''); } @@ -170,7 +178,7 @@ export async function handleReadCommand( ); return value; } - const value = await page.evaluate( + const value = await target.evaluate( ([sel, prop]) => { const el = document.querySelector(sel); if (!el) return `Element not found: ${sel}`; @@ -195,7 +203,7 @@ export async function handleReadCommand( }); return JSON.stringify(attrs, null, 2); } - const attrs = await page.evaluate((sel) => { + const attrs = await target.evaluate((sel: string) => { const el = document.querySelector(sel); if (!el) return `Element not found: ${sel}`; const result: Record = {}; @@ -253,7 +261,7 @@ export async function handleReadCommand( if ('locator' in resolved) { locator = resolved.locator; } else { - locator = page.locator(resolved.selector); + locator = target.locator(resolved.selector); } switch (property) { @@ -283,10 +291,10 @@ export async function handleReadCommand( if (args[0] === 'set' && args[1]) { const key = args[1]; const value = args[2] || ''; - await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]); + await target.evaluate(([k, v]: string[]) => localStorage.setItem(k, v), [key, value]); return `Set localStorage["${key}"]`; } - const storage = await page.evaluate(() => ({ + const storage = await target.evaluate(() => ({ localStorage: { ...localStorage }, sessionStorage: { ...sessionStorage }, })); diff --git a/browse/src/snapshot.ts b/browse/src/snapshot.ts index 24380bad..840cd686 100644 --- a/browse/src/snapshot.ts +++ b/browse/src/snapshot.ts @@ -17,7 +17,7 @@ * Later: "click @e3" → look up Locator → locator.click() */ -import type { Page, Locator } from 'playwright'; +import type { Page, Frame, Locator } from 'playwright'; import type { BrowserManager, RefEntry } from './browser-manager'; import * as Diff from 'diff'; import { TEMP_DIR, isPathWithin } from './platform'; @@ -136,15 +136,18 @@ export async function handleSnapshot( ): Promise { const opts = parseSnapshotArgs(args); const page = bm.getPage(); + // Frame-aware target for accessibility tree + const target = bm.getActiveFrameOrPage(); + const inFrame = bm.getFrame() !== null; // Get accessibility tree via ariaSnapshot let rootLocator: Locator; if (opts.selector) { - rootLocator = page.locator(opts.selector); + rootLocator = target.locator(opts.selector); const count = await rootLocator.count(); if (count === 0) throw new Error(`Selector not found: ${opts.selector}`); } else { - rootLocator = page.locator('body'); + rootLocator = target.locator('body'); } const ariaText = await rootLocator.ariaSnapshot(); @@ -205,11 +208,11 @@ export async function handleSnapshot( let locator: Locator; if (opts.selector) { - locator = page.locator(opts.selector).getByRole(node.role as any, { + locator = target.locator(opts.selector).getByRole(node.role as any, { name: node.name || undefined, }); } else { - locator = page.getByRole(node.role as any, { + locator = target.getByRole(node.role as any, { name: node.name || undefined, }); } @@ -233,7 +236,7 @@ export async function handleSnapshot( // ─── Cursor-interactive scan (-C) ───────────────────────── if (opts.cursorInteractive) { try { - const cursorElements = await page.evaluate(() => { + const cursorElements = await target.evaluate(() => { const STANDARD_INTERACTIVE = new Set([ 'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY', 'DETAILS', ]); @@ -287,7 +290,7 @@ export async function handleSnapshot( let cRefCounter = 1; for (const elem of cursorElements) { const ref = `c${cRefCounter++}`; - const locator = page.locator(elem.selector); + const locator = target.locator(elem.selector); refMap.set(ref, { locator, role: 'cursor-interactive', name: elem.text }); output.push(`@${ref} [${elem.reason}] "${elem.text}"`); } @@ -394,5 +397,11 @@ export async function handleSnapshot( // Store for future diffs bm.setLastSnapshot(snapshotText); + // Add frame context header when operating inside an iframe + if (inFrame) { + const frameUrl = bm.getFrame()?.url() ?? 'unknown'; + output.unshift(`[Context: iframe src="${frameUrl}"]`); + } + return output.join('\n'); } diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 3e80c7fd..02413daf 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