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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user