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:
Garry Tan
2026-04-07 00:23:36 -07:00
committed by GitHub
parent 6cc094cd41
commit 1868636f49
17 changed files with 617 additions and 152 deletions

View File

@@ -5,6 +5,7 @@
* press, scroll, wait, viewport, cookie, header, useragent
*/
import type { TabSession } from './tab-session';
import type { BrowserManager } from './browser-manager';
import { findInstalledBrowsers, importCookies, listSupportedBrowserNames } from './cookie-import-browser';
import { validateNavigationUrl } from './url-validation';
@@ -168,12 +169,13 @@ const CLEANUP_SELECTORS = {
export async function handleWriteCommand(
command: string,
args: string[],
session: TabSession,
bm: BrowserManager
): Promise<string> {
const page = bm.getPage();
const page = session.getPage();
// Frame-aware target for locator-based operations (click, fill, etc.)
const target = bm.getActiveFrameOrPage();
const inFrame = bm.getFrame() !== null;
const target = session.getActiveFrameOrPage();
const inFrame = session.getFrame() !== null;
switch (command) {
case 'goto': {
@@ -209,9 +211,9 @@ export async function handleWriteCommand(
if (!selector) throw new Error('Usage: browse click <selector>');
// Auto-route: if ref points to a real <option> inside a <select>, use selectOption
const role = bm.getRefRole(selector);
const role = session.getRefRole(selector);
if (role === 'option') {
const resolved = await bm.resolveRef(selector);
const resolved = await session.resolveRef(selector);
if ('locator' in resolved) {
const optionInfo = await resolved.locator.evaluate(el => {
if (el.tagName !== 'OPTION') return null; // custom [role=option], not real <option>
@@ -228,7 +230,7 @@ export async function handleWriteCommand(
}
}
const resolved = await bm.resolveRef(selector);
const resolved = await session.resolveRef(selector);
try {
if ('locator' in resolved) {
await resolved.locator.click({ timeout: 5000 });
@@ -258,7 +260,7 @@ export async function handleWriteCommand(
const [selector, ...valueParts] = args;
const value = valueParts.join(' ');
if (!selector || !value) throw new Error('Usage: browse fill <selector> <value>');
const resolved = await bm.resolveRef(selector);
const resolved = await session.resolveRef(selector);
if ('locator' in resolved) {
await resolved.locator.fill(value, { timeout: 5000 });
} else {
@@ -273,7 +275,7 @@ export async function handleWriteCommand(
const [selector, ...valueParts] = args;
const value = valueParts.join(' ');
if (!selector || !value) throw new Error('Usage: browse select <selector> <value>');
const resolved = await bm.resolveRef(selector);
const resolved = await session.resolveRef(selector);
if ('locator' in resolved) {
await resolved.locator.selectOption(value, { timeout: 5000 });
} else {
@@ -287,7 +289,7 @@ export async function handleWriteCommand(
case 'hover': {
const selector = args[0];
if (!selector) throw new Error('Usage: browse hover <selector>');
const resolved = await bm.resolveRef(selector);
const resolved = await session.resolveRef(selector);
if ('locator' in resolved) {
await resolved.locator.hover({ timeout: 5000 });
} else {
@@ -313,7 +315,7 @@ export async function handleWriteCommand(
case 'scroll': {
const selector = args[0];
if (selector) {
const resolved = await bm.resolveRef(selector);
const resolved = await session.resolveRef(selector);
if ('locator' in resolved) {
await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
} else {
@@ -346,7 +348,7 @@ export async function handleWriteCommand(
const MAX_WAIT_MS = 300_000;
const MIN_WAIT_MS = 1_000;
const timeout = Math.min(Math.max(args[1] ? parseInt(args[1], 10) || MIN_WAIT_MS : 15000, MIN_WAIT_MS), MAX_WAIT_MS);
const resolved = await bm.resolveRef(selector);
const resolved = await session.resolveRef(selector);
if ('locator' in resolved) {
await resolved.locator.waitFor({ state: 'visible', timeout });
} else {
@@ -423,7 +425,7 @@ export async function handleWriteCommand(
}
}
const resolved = await bm.resolveRef(selector);
const resolved = await session.resolveRef(selector);
if ('locator' in resolved) {
await resolved.locator.setInputFiles(filePaths);
} else {