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>
This commit is contained in:
Garry Tan 2026-04-06 16:46:30 -07:00
parent 72df88d87e
commit 9f45acb074
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
2 changed files with 207 additions and 81 deletions

View File

@ -18,12 +18,12 @@
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright'; import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright';
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
import { validateNavigationUrl } from './url-validation'; import { validateNavigationUrl } from './url-validation';
import { TabSession, type RefEntry } from './tab-session';
export interface RefEntry { export type { RefEntry };
locator: Locator;
role: string; // Re-export TabSession for consumers
name: string; export { TabSession };
}
export interface BrowserState { export interface BrowserState {
cookies: Cookie[]; cookies: Cookie[];
@ -38,6 +38,7 @@ export class BrowserManager {
private browser: Browser | null = null; private browser: Browser | null = null;
private context: BrowserContext | null = null; private context: BrowserContext | null = null;
private pages: Map<number, Page> = new Map(); private pages: Map<number, Page> = new Map();
private tabSessions: Map<number, TabSession> = new Map();
private activeTabId: number = 0; private activeTabId: number = 0;
private nextTabId: number = 1; private nextTabId: number = 1;
private extraHeaders: Record<string, string> = {}; private extraHeaders: Record<string, string> = {};
@ -46,14 +47,7 @@ export class BrowserManager {
/** Server port — set after server starts, used by cookie-import-browser command */ /** Server port — set after server starts, used by cookie-import-browser command */
public serverPort: number = 0; public serverPort: number = 0;
// ─── Ref Map (snapshot → @e1, @e2, @c1, @c2, ...) ──────── // ─── Dialog Handling (global, not per-tab) ──────────────────
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;
// ─── Dialog Handling ──────────────────────────────────────
private dialogAutoAccept: boolean = true; private dialogAutoAccept: boolean = true;
private dialogPromptText: string | null = null; private dialogPromptText: string | null = null;
@ -138,11 +132,11 @@ export class BrowserManager {
* Get the ref map for external consumers (e.g., /refs endpoint). * Get the ref map for external consumers (e.g., /refs endpoint).
*/ */
getRefMap(): Array<{ ref: string; role: string; name: string }> { getRefMap(): Array<{ ref: string; role: string; name: string }> {
const refs: Array<{ ref: string; role: string; name: string }> = []; try {
for (const [ref, entry] of this.refMap) { return this.getActiveSession().getRefEntries();
refs.push({ ref, role: entry.role, name: entry.name }); } catch {
return [];
} }
return refs;
} }
async launch() { async launch() {
@ -216,7 +210,7 @@ export class BrowserManager {
async launchHeaded(authToken?: string): Promise<void> { async launchHeaded(authToken?: string): Promise<void> {
// Clear old state before repopulating // Clear old state before repopulating
this.pages.clear(); this.pages.clear();
this.refMap.clear(); this.tabSessions.clear();
this.nextTabId = 1; this.nextTabId = 1;
// Find the gstack extension directory for auto-loading // Find the gstack extension directory for auto-loading
@ -430,6 +424,7 @@ export class BrowserManager {
this.context.on('page', (page) => { this.context.on('page', (page) => {
const id = this.nextTabId++; const id = this.nextTabId++;
this.pages.set(id, page); this.pages.set(id, page);
this.tabSessions.set(id, new TabSession(page));
this.activeTabId = id; this.activeTabId = id;
this.wirePageEvents(page); this.wirePageEvents(page);
// Inject indicator on the new tab // Inject indicator on the new tab
@ -443,6 +438,7 @@ export class BrowserManager {
const page = existingPages[0]; const page = existingPages[0];
const id = this.nextTabId++; const id = this.nextTabId++;
this.pages.set(id, page); this.pages.set(id, page);
this.tabSessions.set(id, new TabSession(page));
this.activeTabId = id; this.activeTabId = id;
this.wirePageEvents(page); this.wirePageEvents(page);
// Inject indicator on restored page (addInitScript only fires on new navigations) // Inject indicator on restored page (addInitScript only fires on new navigations)
@ -517,6 +513,7 @@ export class BrowserManager {
const page = await this.context.newPage(); const page = await this.context.newPage();
const id = this.nextTabId++; const id = this.nextTabId++;
this.pages.set(id, page); this.pages.set(id, page);
this.tabSessions.set(id, new TabSession(page));
this.activeTabId = id; this.activeTabId = id;
// Wire up console/network/dialog capture // Wire up console/network/dialog capture
@ -536,6 +533,7 @@ export class BrowserManager {
await page.close(); await page.close();
this.pages.delete(tabId); this.pages.delete(tabId);
this.tabSessions.delete(tabId);
// Switch to another tab if we closed the active one // Switch to another tab if we closed the active one
if (tabId === this.activeTabId) { if (tabId === this.activeTabId) {
@ -550,9 +548,8 @@ export class BrowserManager {
} }
switchTab(id: number, opts?: { bringToFront?: boolean }): void { switchTab(id: number, opts?: { bringToFront?: boolean }): void {
if (!this.pages.has(id)) throw new Error(`Tab ${id} not found`); if (!this.tabSessions.has(id)) throw new Error(`Tab ${id} not found`);
this.activeTabId = id; this.activeTabId = id;
this.activeFrame = null; // Frame context is per-tab
// Only bring to front when explicitly requested (user-initiated tab switch). // Only bring to front when explicitly requested (user-initiated tab switch).
// Internal tab pinning (BROWSE_TAB) should NOT steal focus. // Internal tab pinning (BROWSE_TAB) should NOT steal focus.
if (opts?.bringToFront !== false) { if (opts?.bringToFront !== false) {
@ -582,7 +579,6 @@ export class BrowserManager {
// Exact match — best case // Exact match — best case
if (pageUrl === activeUrl && id !== this.activeTabId) { if (pageUrl === activeUrl && id !== this.activeTabId) {
this.activeTabId = id; this.activeTabId = id;
this.activeFrame = null;
return; return;
} }
// Fuzzy match — origin+pathname (handles query param / fragment differences) // Fuzzy match — origin+pathname (handles query param / fragment differences)
@ -599,7 +595,6 @@ export class BrowserManager {
// Fall back to fuzzy match // Fall back to fuzzy match
if (fuzzyId !== null) { if (fuzzyId !== null) {
this.activeTabId = fuzzyId; this.activeTabId = fuzzyId;
this.activeFrame = null;
} }
} }
@ -624,11 +619,24 @@ export class BrowserManager {
return tabs; return tabs;
} }
// ─── Page Access ─────────────────────────────────────────── // ─── Session Access ────────────────────────────────────────
/** Get the TabSession for the active tab. */
getActiveSession(): TabSession {
const session = this.tabSessions.get(this.activeTabId);
if (!session) throw new Error('No active page. Use "browse goto <url>" first.');
return session;
}
/** Get a TabSession by tab ID. Used by /batch for parallel tab execution. */
getSession(tabId: number): TabSession {
const session = this.tabSessions.get(tabId);
if (!session) throw new Error(`Tab ${tabId} not found`);
return session;
}
// ─── Page Access (delegates to active session) ─────────────
getPage(): Page { getPage(): Page {
const page = this.pages.get(this.activeTabId); return this.getActiveSession().page;
if (!page) throw new Error('No active page. Use "browse goto <url>" first.');
return page;
} }
getCurrentUrl(): string { getCurrentUrl(): string {
@ -639,60 +647,34 @@ export class BrowserManager {
} }
} }
// ─── Ref Map ────────────────────────────────────────────── // ─── Ref Map (delegates to active session) ──────────────────
setRefMap(refs: Map<string, RefEntry>) { setRefMap(refs: Map<string, RefEntry>) {
this.refMap = refs; this.getActiveSession().setRefMap(refs);
} }
clearRefs() { clearRefs() {
this.refMap.clear(); this.getActiveSession().clearRefs();
} }
/**
* 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 }> { async resolveRef(selector: string): Promise<{ locator: Locator } | { selector: string }> {
if (selector.startsWith('@e') || selector.startsWith('@c')) { return this.getActiveSession().resolveRef(selector);
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 { getRefRole(selector: string): string | null {
if (selector.startsWith('@e') || selector.startsWith('@c')) { return this.getActiveSession().getRefRole(selector);
const entry = this.refMap.get(selector.slice(1));
return entry?.role ?? null;
}
return null;
} }
getRefCount(): number { getRefCount(): number {
return this.refMap.size; return this.getActiveSession().getRefCount();
} }
// ─── Snapshot Diffing ───────────────────────────────────── // ─── Snapshot Diffing (delegates to active session) ─────────
setLastSnapshot(text: string | null) { setLastSnapshot(text: string | null) {
this.lastSnapshot = text; this.getActiveSession().setLastSnapshot(text);
} }
getLastSnapshot(): string | null { getLastSnapshot(): string | null {
return this.lastSnapshot; return this.getActiveSession().getLastSnapshot();
} }
// ─── Dialog Control ─────────────────────────────────────── // ─── Dialog Control ───────────────────────────────────────
@ -744,30 +726,20 @@ export class BrowserManager {
await page.close().catch(() => {}); await page.close().catch(() => {});
} }
this.pages.clear(); this.pages.clear();
this.clearRefs(); this.tabSessions.clear();
} }
// ─── Frame context ───────────────────────────────── // ─── Frame context (delegates to active session) ────────────
private activeFrame: import('playwright').Frame | null = null;
setFrame(frame: import('playwright').Frame | null): void { setFrame(frame: import('playwright').Frame | null): void {
this.activeFrame = frame; this.getActiveSession().setFrame(frame);
} }
getFrame(): import('playwright').Frame | null { getFrame(): import('playwright').Frame | null {
return this.activeFrame; return this.getActiveSession().getFrame();
} }
/**
* 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 { getActiveFrameOrPage(): import('playwright').Page | import('playwright').Frame {
// Auto-recover from detached frames (iframe removed/navigated) return this.getActiveSession().getActiveFrameOrPage();
if (this.activeFrame?.isDetached()) {
this.activeFrame = null;
}
return this.activeFrame ?? this.getPage();
} }
// ─── State Save/Restore (shared by recreateContext + handoff) ─ // ─── State Save/Restore (shared by recreateContext + handoff) ─
@ -819,6 +791,7 @@ export class BrowserManager {
const page = await this.context.newPage(); const page = await this.context.newPage();
const id = this.nextTabId++; const id = this.nextTabId++;
this.pages.set(id, page); this.pages.set(id, page);
this.tabSessions.set(id, new TabSession(page));
this.wirePageEvents(page); this.wirePageEvents(page);
if (saved.url) { if (saved.url) {
@ -886,6 +859,7 @@ export class BrowserManager {
await page.close().catch(() => {}); await page.close().catch(() => {});
} }
this.pages.clear(); this.pages.clear();
this.tabSessions.clear();
await this.context.close().catch(() => {}); await this.context.close().catch(() => {});
// 3. Create new context with updated settings // 3. Create new context with updated settings
@ -909,6 +883,7 @@ export class BrowserManager {
// Fallback: create a clean context + blank tab // Fallback: create a clean context + blank tab
try { try {
this.pages.clear(); this.pages.clear();
this.tabSessions.clear();
if (this.context) await this.context.close().catch(() => {}); if (this.context) await this.context.close().catch(() => {});
const contextOptions: BrowserContextOptions = { const contextOptions: BrowserContextOptions = {
@ -994,6 +969,7 @@ export class BrowserManager {
this.context = newContext; this.context = newContext;
this.browser = newContext.browser(); this.browser = newContext.browser();
this.pages.clear(); this.pages.clear();
this.tabSessions.clear();
this.connectionMode = 'headed'; this.connectionMode = 'headed';
if (Object.keys(this.extraHeaders).length > 0) { if (Object.keys(this.extraHeaders).length > 0) {
@ -1036,9 +1012,13 @@ export class BrowserManager {
* The meta-command handler calls handleSnapshot() after this. * The meta-command handler calls handleSnapshot() after this.
*/ */
resume(): void { resume(): void {
this.clearRefs(); // Clear refs and frame on the active session
try {
const session = this.getActiveSession();
session.clearRefs();
session.setFrame(null);
} catch {}
this.resetFailures(); this.resetFailures();
this.activeFrame = null;
} }
getIsHeaded(): boolean { getIsHeaded(): boolean {
@ -1063,11 +1043,12 @@ export class BrowserManager {
// ─── Console/Network/Dialog/Ref Wiring ──────────────────── // ─── Console/Network/Dialog/Ref Wiring ────────────────────
private wirePageEvents(page: Page) { private wirePageEvents(page: Page) {
// Track tab close — remove from pages map, switch to another tab // Track tab close — remove from pages and sessions maps, switch to another tab
page.on('close', () => { page.on('close', () => {
for (const [id, p] of this.pages) { for (const [id, p] of this.pages) {
if (p === page) { if (p === page) {
this.pages.delete(id); this.pages.delete(id);
this.tabSessions.delete(id);
console.log(`[browse] Tab closed (id=${id}, remaining=${this.pages.size})`); console.log(`[browse] Tab closed (id=${id}, remaining=${this.pages.size})`);
// If the closed tab was active, switch to another // If the closed tab was active, switch to another
if (this.activeTabId === id) { if (this.activeTabId === id) {
@ -1083,8 +1064,13 @@ export class BrowserManager {
// (lastSnapshot is NOT cleared — it's a text baseline for diffing) // (lastSnapshot is NOT cleared — it's a text baseline for diffing)
page.on('framenavigated', (frame) => { page.on('framenavigated', (frame) => {
if (frame === page.mainFrame()) { if (frame === page.mainFrame()) {
this.clearRefs(); // Find the TabSession for this page and clear its per-tab state
this.activeFrame = null; // Navigation invalidates frame context for (const session of this.tabSessions.values()) {
if (session.page === page) {
session.onMainFrameNavigated();
break;
}
}
} }
}); });

140
browse/src/tab-session.ts Normal file
View 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;
}
}