mirror of https://github.com/garrytan/gstack.git
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:
parent
e497d996c5
commit
5c6cbeaeff
|
|
@ -402,6 +402,7 @@ export class BrowserManager {
|
||||||
switchTab(id: number): void {
|
switchTab(id: number): void {
|
||||||
if (!this.pages.has(id)) throw new Error(`Tab ${id} not found`);
|
if (!this.pages.has(id)) throw new Error(`Tab ${id} not found`);
|
||||||
this.activeTabId = id;
|
this.activeTabId = id;
|
||||||
|
this.activeFrame = null; // Frame context is per-tab
|
||||||
}
|
}
|
||||||
|
|
||||||
getTabCount(): number {
|
getTabCount(): number {
|
||||||
|
|
@ -531,6 +532,38 @@ export class BrowserManager {
|
||||||
return this.customUserAgent;
|
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) ─
|
// ─── State Save/Restore (shared by recreateContext + handoff) ─
|
||||||
/**
|
/**
|
||||||
* Capture browser state: cookies, localStorage, sessionStorage, URLs, active tab.
|
* Capture browser state: cookies, localStorage, sessionStorage, URLs, active tab.
|
||||||
|
|
@ -789,6 +822,7 @@ export class BrowserManager {
|
||||||
resume(): void {
|
resume(): void {
|
||||||
this.clearRefs();
|
this.clearRefs();
|
||||||
this.resetFailures();
|
this.resetFailures();
|
||||||
|
this.activeFrame = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getIsHeaded(): boolean {
|
getIsHeaded(): boolean {
|
||||||
|
|
@ -818,6 +852,7 @@ export class BrowserManager {
|
||||||
page.on('framenavigated', (frame) => {
|
page.on('framenavigated', (frame) => {
|
||||||
if (frame === page.mainFrame()) {
|
if (frame === page.mainFrame()) {
|
||||||
this.clearRefs();
|
this.clearRefs();
|
||||||
|
this.activeFrame = null; // Navigation invalidates frame context
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ export const META_COMMANDS = new Set([
|
||||||
'connect', 'disconnect', 'focus',
|
'connect', 'disconnect', 'focus',
|
||||||
'inbox',
|
'inbox',
|
||||||
'watch',
|
'watch',
|
||||||
|
'state',
|
||||||
|
'frame',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
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]' },
|
'inbox': { category: 'Meta', description: 'List messages from sidebar scout inbox', usage: 'inbox [--clear]' },
|
||||||
// Watch
|
// Watch
|
||||||
'watch': { category: 'Meta', description: 'Passive observation — periodic snapshots while user browses', usage: 'watch [stop]' },
|
'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
|
// Load-time validation: descriptions must cover exactly the command sets
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import type { BrowserManager } from './browser-manager';
|
import type { BrowserManager } from './browser-manager';
|
||||||
import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
|
import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
|
||||||
import type { Page } from 'playwright';
|
import type { Page, Frame } from 'playwright';
|
||||||
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';
|
||||||
|
|
@ -57,7 +57,7 @@ export function validateReadPath(filePath: string): void {
|
||||||
* Extract clean text from a page (strips script/style/noscript/svg).
|
* Extract clean text from a page (strips script/style/noscript/svg).
|
||||||
* Exported for DRY reuse in meta-commands (diff).
|
* 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(() => {
|
return await page.evaluate(() => {
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
if (!body) return '';
|
if (!body) return '';
|
||||||
|
|
@ -77,10 +77,12 @@ export async function handleReadCommand(
|
||||||
bm: BrowserManager
|
bm: BrowserManager
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const page = bm.getPage();
|
const page = bm.getPage();
|
||||||
|
// Frame-aware target for content extraction
|
||||||
|
const target = bm.getActiveFrameOrPage();
|
||||||
|
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case 'text': {
|
case 'text': {
|
||||||
return await getCleanText(page);
|
return await getCleanText(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'html': {
|
case 'html': {
|
||||||
|
|
@ -90,13 +92,19 @@ export async function handleReadCommand(
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
return await resolved.locator.innerHTML({ timeout: 5000 });
|
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': {
|
case 'links': {
|
||||||
const links = await page.evaluate(() =>
|
const links = await target.evaluate(() =>
|
||||||
[...document.querySelectorAll('a[href]')].map(a => ({
|
[...document.querySelectorAll('a[href]')].map(a => ({
|
||||||
text: a.textContent?.trim().slice(0, 120) || '',
|
text: a.textContent?.trim().slice(0, 120) || '',
|
||||||
href: (a as HTMLAnchorElement).href,
|
href: (a as HTMLAnchorElement).href,
|
||||||
|
|
@ -106,7 +114,7 @@ export async function handleReadCommand(
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'forms': {
|
case 'forms': {
|
||||||
const forms = await page.evaluate(() => {
|
const forms = await target.evaluate(() => {
|
||||||
return [...document.querySelectorAll('form')].map((form, i) => {
|
return [...document.querySelectorAll('form')].map((form, i) => {
|
||||||
const fields = [...form.querySelectorAll('input, select, textarea')].map(el => {
|
const fields = [...form.querySelectorAll('input, select, textarea')].map(el => {
|
||||||
const input = el as HTMLInputElement;
|
const input = el as HTMLInputElement;
|
||||||
|
|
@ -136,7 +144,7 @@ export async function handleReadCommand(
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'accessibility': {
|
case 'accessibility': {
|
||||||
const snapshot = await page.locator("body").ariaSnapshot();
|
const snapshot = await target.locator("body").ariaSnapshot();
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,7 +152,7 @@ export async function handleReadCommand(
|
||||||
const expr = args[0];
|
const expr = args[0];
|
||||||
if (!expr) throw new Error('Usage: browse js <expression>');
|
if (!expr) throw new Error('Usage: browse js <expression>');
|
||||||
const wrapped = wrapForEvaluate(expr);
|
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 ?? '');
|
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}`);
|
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
||||||
const code = fs.readFileSync(filePath, 'utf-8');
|
const code = fs.readFileSync(filePath, 'utf-8');
|
||||||
const wrapped = wrapForEvaluate(code);
|
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 ?? '');
|
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,7 +178,7 @@ export async function handleReadCommand(
|
||||||
);
|
);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
const value = await page.evaluate(
|
const value = await target.evaluate(
|
||||||
([sel, prop]) => {
|
([sel, prop]) => {
|
||||||
const el = document.querySelector(sel);
|
const el = document.querySelector(sel);
|
||||||
if (!el) return `Element not found: ${sel}`;
|
if (!el) return `Element not found: ${sel}`;
|
||||||
|
|
@ -195,7 +203,7 @@ export async function handleReadCommand(
|
||||||
});
|
});
|
||||||
return JSON.stringify(attrs, null, 2);
|
return JSON.stringify(attrs, null, 2);
|
||||||
}
|
}
|
||||||
const attrs = await page.evaluate((sel) => {
|
const attrs = await target.evaluate((sel: string) => {
|
||||||
const el = document.querySelector(sel);
|
const el = document.querySelector(sel);
|
||||||
if (!el) return `Element not found: ${sel}`;
|
if (!el) return `Element not found: ${sel}`;
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
|
|
@ -253,7 +261,7 @@ export async function handleReadCommand(
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
locator = resolved.locator;
|
locator = resolved.locator;
|
||||||
} else {
|
} else {
|
||||||
locator = page.locator(resolved.selector);
|
locator = target.locator(resolved.selector);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (property) {
|
switch (property) {
|
||||||
|
|
@ -283,10 +291,10 @@ export async function handleReadCommand(
|
||||||
if (args[0] === 'set' && args[1]) {
|
if (args[0] === 'set' && args[1]) {
|
||||||
const key = args[1];
|
const key = args[1];
|
||||||
const value = args[2] || '';
|
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}"]`;
|
return `Set localStorage["${key}"]`;
|
||||||
}
|
}
|
||||||
const storage = await page.evaluate(() => ({
|
const storage = await target.evaluate(() => ({
|
||||||
localStorage: { ...localStorage },
|
localStorage: { ...localStorage },
|
||||||
sessionStorage: { ...sessionStorage },
|
sessionStorage: { ...sessionStorage },
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
* Later: "click @e3" → look up Locator → locator.click()
|
* 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 type { BrowserManager, RefEntry } from './browser-manager';
|
||||||
import * as Diff from 'diff';
|
import * as Diff from 'diff';
|
||||||
import { TEMP_DIR, isPathWithin } from './platform';
|
import { TEMP_DIR, isPathWithin } from './platform';
|
||||||
|
|
@ -136,15 +136,18 @@ export async function handleSnapshot(
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const opts = parseSnapshotArgs(args);
|
const opts = parseSnapshotArgs(args);
|
||||||
const page = bm.getPage();
|
const page = bm.getPage();
|
||||||
|
// Frame-aware target for accessibility tree
|
||||||
|
const target = bm.getActiveFrameOrPage();
|
||||||
|
const inFrame = bm.getFrame() !== null;
|
||||||
|
|
||||||
// Get accessibility tree via ariaSnapshot
|
// Get accessibility tree via ariaSnapshot
|
||||||
let rootLocator: Locator;
|
let rootLocator: Locator;
|
||||||
if (opts.selector) {
|
if (opts.selector) {
|
||||||
rootLocator = page.locator(opts.selector);
|
rootLocator = target.locator(opts.selector);
|
||||||
const count = await rootLocator.count();
|
const count = await rootLocator.count();
|
||||||
if (count === 0) throw new Error(`Selector not found: ${opts.selector}`);
|
if (count === 0) throw new Error(`Selector not found: ${opts.selector}`);
|
||||||
} else {
|
} else {
|
||||||
rootLocator = page.locator('body');
|
rootLocator = target.locator('body');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ariaText = await rootLocator.ariaSnapshot();
|
const ariaText = await rootLocator.ariaSnapshot();
|
||||||
|
|
@ -394,5 +397,11 @@ export async function handleSnapshot(
|
||||||
// Store for future diffs
|
// Store for future diffs
|
||||||
bm.setLastSnapshot(snapshotText);
|
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');
|
return output.join('\n');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue