mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-21 12:18:24 +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:
@@ -84,6 +84,9 @@ export async function handleMetaCommand(
|
||||
tokenInfo?: TokenInfo | null,
|
||||
opts?: MetaCommandOpts,
|
||||
): Promise<string> {
|
||||
// Per-tab operations use the active session; global operations use bm directly
|
||||
const session = bm.getActiveSession();
|
||||
|
||||
switch (command) {
|
||||
// ─── Tabs ──────────────────────────────────────────
|
||||
case 'tabs': {
|
||||
@@ -114,7 +117,7 @@ export async function handleMetaCommand(
|
||||
|
||||
// ─── Server Control ────────────────────────────────
|
||||
case 'status': {
|
||||
const page = bm.getPage();
|
||||
const page = session.getPage();
|
||||
const tabs = bm.getTabCount();
|
||||
const mode = bm.getConnectionMode();
|
||||
return [
|
||||
@@ -145,7 +148,7 @@ export async function handleMetaCommand(
|
||||
// ─── Visual ────────────────────────────────────────
|
||||
case 'screenshot': {
|
||||
// Parse priority: flags (--viewport, --clip) → selector (@ref, CSS) → output path
|
||||
const page = bm.getPage();
|
||||
const page = session.getPage();
|
||||
let outputPath = `${TEMP_DIR}/browse-screenshot.png`;
|
||||
let clipRect: { x: number; y: number; width: number; height: number } | undefined;
|
||||
let targetSelector: string | undefined;
|
||||
@@ -192,7 +195,7 @@ export async function handleMetaCommand(
|
||||
}
|
||||
|
||||
if (targetSelector) {
|
||||
const resolved = await bm.resolveRef(targetSelector);
|
||||
const resolved = await session.resolveRef(targetSelector);
|
||||
const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
|
||||
await locator.screenshot({ path: outputPath, timeout: 5000 });
|
||||
return `Screenshot saved (element): ${outputPath}`;
|
||||
@@ -208,7 +211,7 @@ export async function handleMetaCommand(
|
||||
}
|
||||
|
||||
case 'pdf': {
|
||||
const page = bm.getPage();
|
||||
const page = session.getPage();
|
||||
const pdfPath = args[0] || `${TEMP_DIR}/browse-page.pdf`;
|
||||
validateOutputPath(pdfPath);
|
||||
await page.pdf({ path: pdfPath, format: 'A4' });
|
||||
@@ -216,7 +219,7 @@ export async function handleMetaCommand(
|
||||
}
|
||||
|
||||
case 'responsive': {
|
||||
const page = bm.getPage();
|
||||
const page = session.getPage();
|
||||
const prefix = args[0] || `${TEMP_DIR}/browse-responsive`;
|
||||
validateOutputPath(prefix);
|
||||
const viewports = [
|
||||
@@ -317,11 +320,11 @@ export async function handleMetaCommand(
|
||||
if (bm.isWatching()) {
|
||||
result = 'BLOCKED: write commands disabled in watch mode';
|
||||
} else {
|
||||
result = await handleWriteCommand(name, cmdArgs, bm);
|
||||
result = await handleWriteCommand(name, cmdArgs, session, bm);
|
||||
}
|
||||
lastWasWrite = true;
|
||||
} else if (READ_COMMANDS.has(name)) {
|
||||
result = await handleReadCommand(name, cmdArgs, bm);
|
||||
result = await handleReadCommand(name, cmdArgs, session);
|
||||
if (PAGE_CONTENT_COMMANDS.has(name)) {
|
||||
result = wrapUntrustedContent(result, bm.getCurrentUrl());
|
||||
}
|
||||
@@ -341,7 +344,7 @@ export async function handleMetaCommand(
|
||||
|
||||
// Wait for network to settle after write commands before returning
|
||||
if (lastWasWrite) {
|
||||
await bm.getPage().waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
|
||||
await session.getPage().waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
|
||||
}
|
||||
|
||||
return results.join('\n\n');
|
||||
@@ -352,7 +355,7 @@ export async function handleMetaCommand(
|
||||
const [url1, url2] = args;
|
||||
if (!url1 || !url2) throw new Error('Usage: browse diff <url1> <url2>');
|
||||
|
||||
const page = bm.getPage();
|
||||
const page = session.getPage();
|
||||
await validateNavigationUrl(url1);
|
||||
await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
const text1 = await getCleanText(page);
|
||||
@@ -378,7 +381,7 @@ export async function handleMetaCommand(
|
||||
// ─── Snapshot ─────────────────────────────────────
|
||||
case 'snapshot': {
|
||||
const isScoped = tokenInfo && tokenInfo.clientId !== 'root';
|
||||
const snapshotResult = await handleSnapshot(args, bm, {
|
||||
const snapshotResult = await handleSnapshot(args, session, {
|
||||
splitForScoped: !!isScoped,
|
||||
});
|
||||
// Scoped tokens get split format (refs outside envelope); root gets basic wrapping
|
||||
@@ -398,7 +401,7 @@ export async function handleMetaCommand(
|
||||
bm.resume();
|
||||
// Re-snapshot to capture current page state after human interaction
|
||||
const isScoped2 = tokenInfo && tokenInfo.clientId !== 'root';
|
||||
const snapshot = await handleSnapshot(['-i'], bm, { splitForScoped: !!isScoped2 });
|
||||
const snapshot = await handleSnapshot(['-i'], session, { splitForScoped: !!isScoped2 });
|
||||
if (isScoped2) {
|
||||
return `RESUMED\n${snapshot}`;
|
||||
}
|
||||
@@ -451,7 +454,7 @@ export async function handleMetaCommand(
|
||||
// If a ref was passed, scroll it into view
|
||||
if (args.length > 0 && args[0].startsWith('@')) {
|
||||
try {
|
||||
const resolved = await bm.resolveRef(args[0]);
|
||||
const resolved = await session.resolveRef(args[0]);
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
|
||||
return `Browser activated. Scrolled ${args[0]} into view.`;
|
||||
@@ -608,7 +611,7 @@ export async function handleMetaCommand(
|
||||
}
|
||||
}
|
||||
// Close existing pages, then restore (replace, not merge)
|
||||
bm.setFrame(null);
|
||||
session.setFrame(null);
|
||||
await bm.closeAllPages();
|
||||
await bm.restoreState({
|
||||
cookies: validatedCookies,
|
||||
@@ -626,12 +629,12 @@ export async function handleMetaCommand(
|
||||
if (!target) throw new Error('Usage: frame <selector|@ref|--name name|--url pattern|main>');
|
||||
|
||||
if (target === 'main') {
|
||||
bm.setFrame(null);
|
||||
bm.clearRefs();
|
||||
session.setFrame(null);
|
||||
session.clearRefs();
|
||||
return 'Switched to main frame';
|
||||
}
|
||||
|
||||
const page = bm.getPage();
|
||||
const page = session.getPage();
|
||||
let frame: Frame | null = null;
|
||||
|
||||
if (target === '--name') {
|
||||
@@ -642,7 +645,7 @@ export async function handleMetaCommand(
|
||||
frame = page.frame({ url: new RegExp(escapeRegExp(args[1])) });
|
||||
} else {
|
||||
// CSS selector or @ref for the iframe element
|
||||
const resolved = await bm.resolveRef(target);
|
||||
const resolved = await session.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;
|
||||
@@ -650,8 +653,8 @@ export async function handleMetaCommand(
|
||||
}
|
||||
|
||||
if (!frame) throw new Error(`Frame not found: ${target}`);
|
||||
bm.setFrame(frame);
|
||||
bm.clearRefs();
|
||||
session.setFrame(frame);
|
||||
session.clearRefs();
|
||||
return `Switched to frame: ${frame.url()}`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user