Skill Tracer
Skills › gstack › browse › tab-session.ts

tab-session.ts

210 lines · browse/src/tab-session.ts at 3f79d81

1/**
2 * Per-tab session state.
3 *
4 * Extracted from BrowserManager to enable parallel tab execution in /batch.
5 * Each TabSession holds the state that is scoped to a single browser tab:
6 * page reference, element refs, snapshot baseline, and frame context.
7 *
8 * BrowserManager (global)
9 * └── tabSessions: Map<number, TabSession>
10 * ├── TabSession(page1) ← refMap, lastSnapshot, frame
11 * ├── TabSession(page2) ← refMap, lastSnapshot, frame
12 * └── TabSession(page3) ← refMap, lastSnapshot, frame
13 *
14 * The /command path gets the active session via bm.getActiveSession().
15 * The /batch path gets specific sessions via bm.getSession(tabId).
16 * Both paths pass TabSession to the same handler functions.
17 */
19import type { Page, Locator, Frame } from 'playwright';
21export interface RefEntry {
22 locator: Locator;
23 role: string;
24 name: string;
27export type SetContentWaitUntil = 'load' | 'domcontentloaded' | 'networkidle';
29export class TabSession {
30 readonly page: Page;
32 // ─── Ref Map (snapshot → @e1, @e2, @c1, @c2, ...) ────────
33 private refMap: Map<string, RefEntry> = new Map();
35 // ─── Snapshot Diffing ─────────────────────────────────────
36 // NOT cleared on navigation — it's a text baseline for diffing
37 private lastSnapshot: string | null = null;
39 // ─── Frame context ─────────────────────────────────────────
40 private activeFrame: Frame | null = null;
42 // ─── Loaded HTML (for load-html replay across context recreation) ─
43 //
44 // loadedHtml lifecycle:
45 //
46 // load-html cmd ──▶ session.setTabContent(html, opts)
47 // ├─▶ page.setContent(html, opts)
48 // └─▶ this.loadedHtml = html
49 // this.loadedHtmlWaitUntil = opts.waitUntil
50 //
51 // goto/back/forward/reload ──▶ session.clearLoadedHtml()
52 // (BEFORE Playwright call, so timeouts
53 // don't leave stale state)
54 //
55 // viewport --scale ──▶ recreateContext()
56 // ├─▶ saveState() captures { url, loadedHtml } per tab
57 // │ (in-memory only, never to disk)
58 // └─▶ restoreState():
59 // for each tab with loadedHtml:
60 // newSession.setTabContent(html, opts)
61 // (NOT page.setContent — must rehydrate
62 // TabSession.loadedHtml too)
63 private loadedHtml: string | null = null;
64 private loadedHtmlWaitUntil: SetContentWaitUntil | undefined;
66 constructor(page: Page) {
67 this.page = page;
68 }
70 // ─── Page Access ───────────────────────────────────────────
71 getPage(): Page {
72 return this.page;
73 }
75 // ─── Ref Map ──────────────────────────────────────────────
76 setRefMap(refs: Map<string, RefEntry>) {
77 this.refMap = refs;
78 }
80 clearRefs() {
81 this.refMap.clear();
82 }
84 /**
85 * Resolve a selector that may be a @ref (e.g., "@e3", "@c1") or a CSS selector.
86 * Returns { locator } for refs or { selector } for CSS selectors.
87 */
88 async resolveRef(selector: string): Promise<{ locator: Locator } | { selector: string }> {
89 if (selector.startsWith('@e') || selector.startsWith('@c')) {
90 const ref = selector.slice(1); // "e3" or "c1"
91 const entry = this.refMap.get(ref);
92 if (!entry) {
93 throw new Error(
94 `Ref ${selector} not found. Run 'snapshot' to get fresh refs.`
95 );
96 }
97 const count = await entry.locator.count();
98 if (count === 0) {
99 throw new Error(
100 `Ref ${selector} (${entry.role} "${entry.name}") is stale — element no longer exists. ` +
101 `Run 'snapshot' for fresh refs.`
102 );
103 }
104 return { locator: entry.locator };
105 }
106 return { selector };
107 }
109 /** Get the ARIA role for a ref selector, or null for CSS selectors / unknown refs. */
110 getRefRole(selector: string): string | null {
111 if (selector.startsWith('@e') || selector.startsWith('@c')) {
112 const entry = this.refMap.get(selector.slice(1));
113 return entry?.role ?? null;
114 }
115 return null;
116 }
118 getRefCount(): number {
119 return this.refMap.size;
120 }
122 /** Get all ref entries for the /refs endpoint. */
123 getRefEntries(): Array<{ ref: string; role: string; name: string }> {
124 return Array.from(this.refMap.entries()).map(([ref, entry]) => ({
125 ref, role: entry.role, name: entry.name,
126 }));
127 }
129 // ─── Snapshot Diffing ─────────────────────────────────────
130 setLastSnapshot(text: string | null) {
131 this.lastSnapshot = text;
132 }
134 getLastSnapshot(): string | null {
135 return this.lastSnapshot;
136 }
138 // ─── Frame context ─────────────────────────────────────────
139 setFrame(frame: Frame | null): void {
140 this.activeFrame = frame;
141 }
143 getFrame(): Frame | null {
144 return this.activeFrame;
145 }
147 /**
148 * Returns the active frame if set, otherwise the current page.
149 * Use this for operations that work on both Page and Frame (locator, evaluate, etc.).
150 */
151 getActiveFrameOrPage(): Page | Frame {
152 // Auto-recover from detached frames (iframe removed/navigated). Clear
153 // refs alongside the activeFrame — same staleness condition as
154 // onMainFrameNavigated() below: refs were captured against a frame
155 // that no longer exists. Without this, refMap entries linger against
156 // a dead frame after silently falling back to the main page; the
157 // next snapshot's role+name keys collide with stale entries and the
158 // resolver picks one at random.
159 if (this.activeFrame?.isDetached()) {
160 this.activeFrame = null;
161 this.clearRefs();
162 }
163 return this.activeFrame ?? this.page;
164 }
166 /**
167 * Called on main-frame navigation to clear stale refs, frame context, and any
168 * load-html replay metadata. Runs for every main-frame nav — explicit goto/back/
169 * forward/reload AND browser-emitted navigations (link clicks, form submits, JS
170 * redirects, OAuth). Without clearing loadedHtml here, a user who load-html'd and
171 * then clicked a link would silently revert to the original HTML on the next
172 * viewport --scale.
173 */
174 onMainFrameNavigated(): void {
175 this.clearRefs();
176 this.activeFrame = null;
177 this.loadedHtml = null;
178 this.loadedHtmlWaitUntil = undefined;
179 }
181 // ─── Loaded HTML (load-html replay) ───────────────────────
183 /**
184 * Load HTML content into the tab AND store it for replay after context recreation
185 * (e.g. viewport --scale). Unlike page.setContent() alone, this rehydrates
186 * TabSession.loadedHtml so the next saveState()/restoreState() round-trip preserves
187 * the content.
188 */
189 async setTabContent(html: string, opts: { waitUntil?: SetContentWaitUntil } = {}): Promise<void> {
190 const waitUntil = opts.waitUntil ?? 'domcontentloaded';
191 // Call setContent FIRST — only record the replay metadata after a successful load.
192 // If setContent throws (timeout, crash), we must not leave phantom HTML that a
193 // later viewport --scale would replay.
194 await this.page.setContent(html, { waitUntil, timeout: 15000 });
195 this.loadedHtml = html;
196 this.loadedHtmlWaitUntil = waitUntil;
197 }
199 /** Get stored HTML + waitUntil for state replay. Returns null if no load-html happened. */
200 getLoadedHtml(): { html: string; waitUntil?: SetContentWaitUntil } | null {
201 if (this.loadedHtml === null) return null;
202 return { html: this.loadedHtml, waitUntil: this.loadedHtmlWaitUntil };
203 }
205 /** Clear stored HTML. Called BEFORE goto/back/forward/reload navigation. */
206 clearLoadedHtml(): void {
207 this.loadedHtml = null;
208 this.loadedHtmlWaitUntil = undefined;
209 }