From 5c6cbeaeff0585914895af1616cd5378103254cc Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 26 Mar 2026 00:46:56 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20$B=20state=20save/load=20+=20$B=20frame?= =?UTF-8?q?=20=E2=80=94=20new=20browse=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - state save/load: persist cookies + URLs to .gstack/browse-states/{name}.json File perms 0o600, name sanitized to [a-zA-Z0-9_-]. V1 skips localStorage (breaks on load-before-navigate). Load replaces session via closeAllPages(). - frame: switch command context to iframe via CSS selector, @ref, --name, or --url. 'frame main' returns to main frame. Execution target abstraction (getActiveFrameOrPage) across read-commands, snapshot, and write-commands. - Frame context cleared on tab switch, navigation, resume, and handoff. - Snapshot shows [Context: iframe src="..."] header when in frame. Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/src/browser-manager.ts | 35 ++++++++++++++++++++++++++++++++ browse/src/commands.ts | 6 ++++++ browse/src/read-commands.ts | 38 +++++++++++++++++++++-------------- browse/src/snapshot.ts | 15 +++++++++++--- 4 files changed, 76 insertions(+), 18 deletions(-) diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 23da95d8..401b6cb0 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,38 @@ 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 { + return this.activeFrame ?? this.getPage(); + } + // ─── State Save/Restore (shared by recreateContext + handoff) ─ /** * Capture browser state: cookies, localStorage, sessionStorage, URLs, active tab. @@ -789,6 +822,7 @@ export class BrowserManager { resume(): void { this.clearRefs(); this.resetFailures(); + this.activeFrame = null; } getIsHeaded(): boolean { @@ -818,6 +852,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/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..ae07b6b8 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(); @@ -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'); }