mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 05:05:08 +02:00
feat: $B state save/load + $B frame — new browse commands
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<void> {
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, { category: string; descriptio
|
||||
'inbox': { category: 'Meta', description: 'List messages from sidebar scout inbox', usage: 'inbox [--clear]' },
|
||||
// Watch
|
||||
'watch': { category: 'Meta', description: 'Passive observation — periodic snapshots while user browses', usage: 'watch [stop]' },
|
||||
// State
|
||||
'state': { category: 'Server', description: 'Save/load browser state (cookies + URLs)', usage: 'state save|load <name>' },
|
||||
// Frame
|
||||
'frame': { category: 'Meta', description: 'Switch to iframe context (or main to return)', usage: 'frame <sel|@ref|--name n|--url pattern|main>' },
|
||||
};
|
||||
|
||||
// Load-time validation: descriptions must cover exactly the command sets
|
||||
|
||||
+23
-15
@@ -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<string> {
|
||||
export async function getCleanText(page: Page | Frame): Promise<string> {
|
||||
return await page.evaluate(() => {
|
||||
const body = document.body;
|
||||
if (!body) return '';
|
||||
@@ -77,10 +77,12 @@ export async function handleReadCommand(
|
||||
bm: BrowserManager
|
||||
): Promise<string> {
|
||||
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 ? `<!DOCTYPE ${dt.name}>` : '';
|
||||
});
|
||||
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 <expression>');
|
||||
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<string, string> = {};
|
||||
@@ -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 },
|
||||
}));
|
||||
|
||||
+12
-3
@@ -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<string> {
|
||||
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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user