mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-17 01:31:26 +08:00
refactor: extract TabSession for per-tab state isolation (v0.15.16.0) (#873)
* plan: batch command endpoint + multi-tab parallel execution for GStack Browser * refactor: extract TabSession from BrowserManager for per-tab state Move per-tab state (refMap, lastSnapshot, frame) into a new TabSession class. BrowserManager delegates to the active TabSession via getActiveSession(). Zero behavior change — all existing tests pass. This is the foundation for the /batch endpoint: both /command and /batch will use the same handler functions with TabSession, eliminating shared state races during parallel tab execution. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: update handler signatures to use TabSession Change handleReadCommand and handleSnapshot to take TabSession instead of BrowserManager. Change handleWriteCommand to take both TabSession (per-tab ops) and BrowserManager (global ops like viewport, headers, dialog). handleMetaCommand keeps BrowserManager for tab management. Tests use thin wrapper functions that bridge the old 3-arg call pattern to the new signatures via bm.getActiveSession(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add POST /batch endpoint for parallel multi-tab execution Execute multiple commands across tabs in a single HTTP request. Commands targeting different tabs run concurrently via Promise.allSettled. Commands targeting the same tab run sequentially within that group. Features: - Batch-safe command subset (text, goto, click, snapshot, screenshot, etc.) - newtab/closetab as special commands within batch - SSE streaming mode (stream: true) for partial results - Per-command error isolation (one tab failing doesn't abort the batch) - Max 50 commands per batch, soft batch-level timeout A 143-page crawl drops from ~45 min (serial HTTP) to ~5 min (20 tabs in parallel, batched commands). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add batch endpoint integration tests 10 tests covering: - Multi-tab parallel execution (goto + text on different tabs) - Same-tab sequential ordering - Per-command error isolation (one tab fails, others succeed) - Page-scoped refs (snapshot refs are per-session, not global) - Per-tab lastSnapshot (snapshot -D with independent baselines) - getSession/getActiveSession API - Batch-safe command subset validation - closeTab via page.close preserves at-least-one-page invariant - Parallel goto on 3 tabs simultaneously Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: harden codex-review E2E — extract SKILL.md section, bump maxTurns to 25 The test was copying the full 55KB/1075-line codex SKILL.md into the fixture, requiring 8 Read calls just to consume it and exhausting the 15-turn budget before reaching the actual codex review command. Now extracts only the review-relevant section (~6KB/148 lines), reducing Read calls from 8 to 1. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: move batch endpoint plan into BROWSER.md as feature documentation The batch endpoint is implemented — document it as an actual feature in BROWSER.md (architecture, API shape, design decisions, usage pattern) and remove the standalone plan file. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.15.16.0) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: gstack <ship@gstack.dev> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
140
browse/src/tab-session.ts
Normal file
140
browse/src/tab-session.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Per-tab session state.
|
||||
*
|
||||
* Extracted from BrowserManager to enable parallel tab execution in /batch.
|
||||
* Each TabSession holds the state that is scoped to a single browser tab:
|
||||
* page reference, element refs, snapshot baseline, and frame context.
|
||||
*
|
||||
* BrowserManager (global)
|
||||
* └── tabSessions: Map<number, TabSession>
|
||||
* ├── TabSession(page1) ← refMap, lastSnapshot, frame
|
||||
* ├── TabSession(page2) ← refMap, lastSnapshot, frame
|
||||
* └── TabSession(page3) ← refMap, lastSnapshot, frame
|
||||
*
|
||||
* The /command path gets the active session via bm.getActiveSession().
|
||||
* The /batch path gets specific sessions via bm.getSession(tabId).
|
||||
* Both paths pass TabSession to the same handler functions.
|
||||
*/
|
||||
|
||||
import type { Page, Locator, Frame } from 'playwright';
|
||||
|
||||
export interface RefEntry {
|
||||
locator: Locator;
|
||||
role: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class TabSession {
|
||||
readonly page: Page;
|
||||
|
||||
// ─── Ref Map (snapshot → @e1, @e2, @c1, @c2, ...) ────────
|
||||
private refMap: Map<string, RefEntry> = new Map();
|
||||
|
||||
// ─── Snapshot Diffing ─────────────────────────────────────
|
||||
// NOT cleared on navigation — it's a text baseline for diffing
|
||||
private lastSnapshot: string | null = null;
|
||||
|
||||
// ─── Frame context ─────────────────────────────────────────
|
||||
private activeFrame: Frame | null = null;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
// ─── Page Access ───────────────────────────────────────────
|
||||
getPage(): Page {
|
||||
return this.page;
|
||||
}
|
||||
|
||||
// ─── Ref Map ──────────────────────────────────────────────
|
||||
setRefMap(refs: Map<string, RefEntry>) {
|
||||
this.refMap = refs;
|
||||
}
|
||||
|
||||
clearRefs() {
|
||||
this.refMap.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a selector that may be a @ref (e.g., "@e3", "@c1") or a CSS selector.
|
||||
* Returns { locator } for refs or { selector } for CSS selectors.
|
||||
*/
|
||||
async resolveRef(selector: string): Promise<{ locator: Locator } | { selector: string }> {
|
||||
if (selector.startsWith('@e') || selector.startsWith('@c')) {
|
||||
const ref = selector.slice(1); // "e3" or "c1"
|
||||
const entry = this.refMap.get(ref);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Ref ${selector} not found. Run 'snapshot' to get fresh refs.`
|
||||
);
|
||||
}
|
||||
const count = await entry.locator.count();
|
||||
if (count === 0) {
|
||||
throw new Error(
|
||||
`Ref ${selector} (${entry.role} "${entry.name}") is stale — element no longer exists. ` +
|
||||
`Run 'snapshot' for fresh refs.`
|
||||
);
|
||||
}
|
||||
return { locator: entry.locator };
|
||||
}
|
||||
return { selector };
|
||||
}
|
||||
|
||||
/** Get the ARIA role for a ref selector, or null for CSS selectors / unknown refs. */
|
||||
getRefRole(selector: string): string | null {
|
||||
if (selector.startsWith('@e') || selector.startsWith('@c')) {
|
||||
const entry = this.refMap.get(selector.slice(1));
|
||||
return entry?.role ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getRefCount(): number {
|
||||
return this.refMap.size;
|
||||
}
|
||||
|
||||
/** Get all ref entries for the /refs endpoint. */
|
||||
getRefEntries(): Array<{ ref: string; role: string; name: string }> {
|
||||
return Array.from(this.refMap.entries()).map(([ref, entry]) => ({
|
||||
ref, role: entry.role, name: entry.name,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Snapshot Diffing ─────────────────────────────────────
|
||||
setLastSnapshot(text: string | null) {
|
||||
this.lastSnapshot = text;
|
||||
}
|
||||
|
||||
getLastSnapshot(): string | null {
|
||||
return this.lastSnapshot;
|
||||
}
|
||||
|
||||
// ─── Frame context ─────────────────────────────────────────
|
||||
setFrame(frame: Frame | null): void {
|
||||
this.activeFrame = frame;
|
||||
}
|
||||
|
||||
getFrame(): 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(): Page | Frame {
|
||||
// Auto-recover from detached frames (iframe removed/navigated)
|
||||
if (this.activeFrame?.isDetached()) {
|
||||
this.activeFrame = null;
|
||||
}
|
||||
return this.activeFrame ?? this.page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on main-frame navigation to clear stale refs and frame context.
|
||||
*/
|
||||
onMainFrameNavigated(): void {
|
||||
this.clearRefs();
|
||||
this.activeFrame = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user