mirror of https://github.com/garrytan/gstack.git
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:
parent
72df88d87e
commit
9f45acb074
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue