pi-browser-harness 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,20 @@ import type { DialogInfo } from "./types";
4
4
  import type { OwnershipRegistry } from "./ownership";
5
5
  import type { CdpTransport } from "./transport";
6
6
  import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer";
7
+ import { createConsoleBuffer, type ConsoleDrainResult, type ConsoleFilter } from "./console-buffer";
8
+
9
+ // Per-tab state. One TabSession exists per known targetId.
10
+ // Dialog, page-info dirty flag, and CDP buffers are per-tab — switching
11
+ // tabs preserves the previous tab's data in its TabSession instead of
12
+ // clearing it.
13
+ type TabSession = {
14
+ sessionId: string;
15
+ targetId: string;
16
+ dialog: DialogInfo | null;
17
+ pageInfoDirty: boolean;
18
+ networkBuffer: ReturnType<typeof createNetworkBuffer>;
19
+ consoleBuffer: ReturnType<typeof createConsoleBuffer>;
20
+ };
7
21
 
8
22
  export type CdpSession = {
9
23
  attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
@@ -15,6 +29,7 @@ export type CdpSession = {
15
29
  takeDialog(): DialogInfo | null;
16
30
  drainPageInfoInvalidations(): boolean;
17
31
  drainNetworkBuffer(filter: NetworkFilter): DrainResult;
32
+ drainConsoleBuffer(filter: ConsoleFilter): ConsoleDrainResult;
18
33
  };
19
34
 
20
35
  export const createCdpSession = (
@@ -23,17 +38,33 @@ export const createCdpSession = (
23
38
  ): CdpSession => {
24
39
  let sessionId: string | null = null;
25
40
  let targetId: string | null = null;
26
- let dialog: DialogInfo | null = null;
27
- let pageInfoDirty = false;
28
- const networkBuffer = createNetworkBuffer();
41
+
42
+ // Per-tab tracking: maps targetId → TabSession and sessionId → targetId for
43
+ // event routing. Lazy: a TabSession is created on the first visit to a tab.
44
+ const tabs = new Map<string, TabSession>();
45
+ const sessionIdToTargetId = new Map<string, string>();
29
46
 
30
47
  let activeConsumer: Promise<void> = Promise.resolve();
31
48
 
49
+ // Event → TabSession resolver. Uses sessionId from CDP events to find the
50
+ // correct TabSession, falling back to the current targetId when no sessionId.
51
+ const resolveTab = (evSessionId?: string): TabSession | undefined => {
52
+ const tid = evSessionId ? sessionIdToTargetId.get(evSessionId) : targetId;
53
+ return tid ? tabs.get(tid) : undefined;
54
+ };
55
+
32
56
  const consumeEvents = async (): Promise<void> => {
33
57
  for await (const ev of transport.events()) {
58
+ // Filter: skip events from sessions we're not currently tracking.
59
+ // Target.targetDestroyed is browser-level (no sessionId) — always process.
60
+ if (ev.method !== "Target.targetDestroyed") {
61
+ if (ev.sessionId && ev.sessionId !== sessionId) continue;
62
+ }
34
63
  if (ev.method === "Page.javascriptDialogOpening") {
64
+ const tab = targetId ? tabs.get(targetId) : undefined;
65
+ if (!tab) continue;
35
66
  const params = ev.params as Partial<DialogInfo> | undefined;
36
- dialog = {
67
+ tab.dialog = {
37
68
  type: (params?.type as DialogInfo["type"]) ?? "alert",
38
69
  message: params?.message ?? "",
39
70
  ...(params?.defaultPrompt !== undefined ? { defaultPrompt: params.defaultPrompt } : {}),
@@ -45,16 +76,40 @@ export const createCdpSession = (
45
76
  // This prevents fast dismiss flows from dropping a dialog the agent
46
77
  // was about to read. (Fix for spec §7 predictability bug #2.)
47
78
  if (ev.method === "Page.frameNavigated" || ev.method === "Page.loadEventFired") {
48
- pageInfoDirty = true;
79
+ const tab = targetId ? tabs.get(targetId) : undefined;
80
+ if (tab) tab.pageInfoDirty = true;
49
81
  }
50
82
  if (ev.method === "Target.targetDestroyed" && ownership) {
51
83
  const params = ev.params as { targetId?: string } | undefined;
52
- if (params?.targetId) ownership.remove(params.targetId);
84
+ if (params?.targetId) {
85
+ ownership.remove(params.targetId);
86
+ // Prune per-tab state for the destroyed target
87
+ const tab = tabs.get(params.targetId);
88
+ if (tab) {
89
+ sessionIdToTargetId.delete(tab.sessionId);
90
+ tabs.delete(params.targetId);
91
+ }
92
+ }
93
+ }
94
+ if (ev.method === "Network.requestWillBeSent") {
95
+ const tab = resolveTab(ev.sessionId);
96
+ if (tab) tab.networkBuffer.ingestRequestWillBeSent(ev.params);
97
+ } else if (ev.method === "Network.responseReceived") {
98
+ const tab = resolveTab(ev.sessionId);
99
+ if (tab) tab.networkBuffer.ingestResponseReceived(ev.params);
100
+ } else if (ev.method === "Network.loadingFinished") {
101
+ const tab = resolveTab(ev.sessionId);
102
+ if (tab) tab.networkBuffer.ingestLoadingFinished(ev.params);
103
+ } else if (ev.method === "Network.loadingFailed") {
104
+ const tab = resolveTab(ev.sessionId);
105
+ if (tab) tab.networkBuffer.ingestLoadingFailed(ev.params);
106
+ } else if (ev.method === "Runtime.consoleAPICalled") {
107
+ const tab = resolveTab(ev.sessionId);
108
+ if (tab) tab.consoleBuffer.ingestConsoleApi(ev.params);
109
+ } else if (ev.method === "Log.entryAdded") {
110
+ const tab = resolveTab(ev.sessionId);
111
+ if (tab) tab.consoleBuffer.ingestLogEntry(ev.params);
53
112
  }
54
- if (ev.method === "Network.requestWillBeSent") networkBuffer.ingestRequestWillBeSent(ev.params);
55
- else if (ev.method === "Network.responseReceived") networkBuffer.ingestResponseReceived(ev.params);
56
- else if (ev.method === "Network.loadingFinished") networkBuffer.ingestLoadingFinished(ev.params);
57
- else if (ev.method === "Network.loadingFailed") networkBuffer.ingestLoadingFailed(ev.params);
58
113
  }
59
114
  };
60
115
 
@@ -72,7 +127,8 @@ export const createCdpSession = (
72
127
  transport.onClose(() => {
73
128
  sessionId = null;
74
129
  targetId = null;
75
- pageInfoDirty = false;
130
+ tabs.clear();
131
+ sessionIdToTargetId.clear();
76
132
  // Do NOT clear `dialog` here — same rationale as inside consumeEvents:
77
133
  // the agent may have a pending takeDialog() call that should still see it.
78
134
  restartConsumer();
@@ -82,7 +138,7 @@ export const createCdpSession = (
82
138
  // Switching to Promise.all over a single WS pipelines the round-trips and
83
139
  // saves ~3× on tab-switch latency. Defer until session.ts has tests.
84
140
  const enableDomains = async (sid: string): Promise<void> => {
85
- for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility"]) {
141
+ for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility", "Log"]) {
86
142
  await transport.request(`${d}.enable`, {}, { sessionId: sid });
87
143
  }
88
144
  };
@@ -111,6 +167,15 @@ export const createCdpSession = (
111
167
  const hw = ownership.harnessWindow();
112
168
  if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
113
169
  }
170
+ // Prune per-tab state for pages that no longer exist
171
+ const liveTargetIds = new Set(allPages.map((p) => p.targetId));
172
+ for (const tid of tabs.keys()) {
173
+ if (!liveTargetIds.has(tid)) {
174
+ const tab = tabs.get(tid);
175
+ if (tab) sessionIdToTargetId.delete(tab.sessionId);
176
+ tabs.delete(tid);
177
+ }
178
+ }
114
179
 
115
180
  // Prefer attaching to a tab this session already owns. Falls back to
116
181
  // creating a fresh harness-owned tab in a dedicated window — never
@@ -139,6 +204,15 @@ export const createCdpSession = (
139
204
  sessionId = a.sessionId;
140
205
  targetId = pickTargetId;
141
206
  await enableDomains(a.sessionId);
207
+ tabs.set(pickTargetId, {
208
+ sessionId: a.sessionId,
209
+ targetId: pickTargetId,
210
+ dialog: null,
211
+ pageInfoDirty: false,
212
+ networkBuffer: createNetworkBuffer(),
213
+ consoleBuffer: createConsoleBuffer(),
214
+ });
215
+ sessionIdToTargetId.set(a.sessionId, pickTargetId);
142
216
  return ok({ targetId: pickTargetId, sessionId: a.sessionId });
143
217
  },
144
218
  async switchTo(tid) {
@@ -147,13 +221,29 @@ export const createCdpSession = (
147
221
  const attached = await transport.request("Target.attachToTarget", { targetId: tid, flatten: true }, { sessionId: null });
148
222
  if (!attached.success) return attached;
149
223
  const a = attached.data as { sessionId: string };
150
- sessionId = a.sessionId;
224
+ // Reuse existing TabSession or create one on first visit
225
+ const existing = tabs.get(tid);
226
+ const tab: TabSession = existing ?? {
227
+ sessionId: a.sessionId,
228
+ targetId: tid,
229
+ dialog: null,
230
+ pageInfoDirty: true,
231
+ networkBuffer: createNetworkBuffer(),
232
+ consoleBuffer: createConsoleBuffer(),
233
+ };
234
+ if (existing) {
235
+ // Each Target.attachToTarget produces a new sessionId — update it.
236
+ sessionIdToTargetId.delete(existing.sessionId);
237
+ existing.sessionId = a.sessionId;
238
+ sessionIdToTargetId.set(a.sessionId, tid);
239
+ } else {
240
+ tabs.set(tid, tab);
241
+ await enableDomains(a.sessionId);
242
+ sessionIdToTargetId.set(a.sessionId, tid);
243
+ }
244
+ // Update global pointers to point at the new active tab
245
+ sessionId = tab.sessionId;
151
246
  targetId = tid;
152
- pageInfoDirty = true;
153
- // Buffer is page-scoped: on switch, the network history of the previous
154
- // tab is no longer relevant to what the agent is now looking at.
155
- networkBuffer.clear();
156
- await enableDomains(a.sessionId);
157
247
  return ok(undefined);
158
248
  },
159
249
  current() {
@@ -169,17 +259,28 @@ export const createCdpSession = (
169
259
  return transport.request(method, params, { sessionId: null, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
170
260
  },
171
261
  takeDialog() {
172
- const d = dialog;
173
- dialog = null;
262
+ const tab = targetId ? tabs.get(targetId) : undefined;
263
+ if (!tab) return null;
264
+ const d = tab.dialog;
265
+ tab.dialog = null;
174
266
  return d;
175
267
  },
176
268
  drainPageInfoInvalidations() {
177
- const dirty = pageInfoDirty;
178
- pageInfoDirty = false;
269
+ const tab = targetId ? tabs.get(targetId) : undefined;
270
+ if (!tab) return false;
271
+ const dirty = tab.pageInfoDirty;
272
+ tab.pageInfoDirty = false;
179
273
  return dirty;
180
274
  },
181
275
  drainNetworkBuffer(filter) {
182
- return networkBuffer.drain(filter);
276
+ const tab = targetId ? tabs.get(targetId) : undefined;
277
+ if (!tab) return { records: [], total: 0, bufferOverflowed: false };
278
+ return tab.networkBuffer.drain(filter);
279
+ },
280
+ drainConsoleBuffer(filter) {
281
+ const tab = targetId ? tabs.get(targetId) : undefined;
282
+ if (!tab) return { records: [], total: 0, bufferOverflowed: false };
283
+ return tab.consoleBuffer.drain(filter);
183
284
  },
184
285
  };
185
286
  };
package/src/client.ts CHANGED
@@ -11,6 +11,7 @@ import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
11
11
 
12
12
  export type BrowserClientOptions = {
13
13
  readonly namespace: string;
14
+ readonly transport?: CdpTransport;
14
15
  readonly remote?: { readonly cdpUrl: string; readonly browserId: string };
15
16
  readonly initialOwnership?: {
16
17
  readonly ownedTargetIds?: ReadonlyArray<string>;
@@ -28,6 +29,9 @@ export type BrowserClient = {
28
29
  status(): DaemonStatus;
29
30
  start(): Promise<Result<void, CdpError>>;
30
31
  stop(): Promise<void>;
32
+ /** Detach from the current page target (removes the "Chrome is being controlled" banner)
33
+ * while keeping the transport connection alive. Call on session shutdown. */
34
+ detach(): Promise<void>;
31
35
  evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>;
32
36
  pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>;
33
37
  takeDialog(): DialogInfo | null;
@@ -77,7 +81,7 @@ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => {
77
81
  };
78
82
 
79
83
  export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
80
- const transport = createCdpTransport();
84
+ const transport = opts.transport ?? createCdpTransport();
81
85
  const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
82
86
  if (opts.initialOwnership?.ownedTargetIds !== undefined) {
83
87
  ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds;
@@ -98,7 +102,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
98
102
  const session = createCdpSession(transport, ownership);
99
103
  const mutationMutex = createMutex();
100
104
  let lastHealth = 0;
101
- let pageCache: { readonly info: PageInfo; readonly at: number } | null = null;
105
+ let pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>();
102
106
  let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null;
103
107
 
104
108
  const start = async (): Promise<Result<void, CdpError>> => {
@@ -125,13 +129,13 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
125
129
  return attached;
126
130
  }
127
131
  lastHealth = Date.now();
128
- pageCache = null;
132
+ pageCaches.clear();
129
133
  return ok(undefined);
130
134
  };
131
135
 
132
136
  const stop = async (): Promise<void> => {
133
137
  await transport.close();
134
- pageCache = null;
138
+ pageCaches.clear();
135
139
  lastHealth = 0;
136
140
  };
137
141
 
@@ -155,7 +159,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
155
159
  if (!jsProbe.success && jsProbe.error.kind === "session_not_found") {
156
160
  const reattached = await session.attachFirstPage();
157
161
  if (reattached.success) {
158
- pageCache = null;
162
+ pageCaches.clear();
159
163
  return ok(undefined);
160
164
  }
161
165
  await stop();
@@ -185,7 +189,9 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
185
189
 
186
190
  const readPageInfo = async (): Promise<Result<PageInfo, CdpError>> => {
187
191
  const dirty = session.drainPageInfoInvalidations();
188
- if (pageCache && !dirty && Date.now() - pageCache.at < PAGE_INFO_TTL_MS) return ok(pageCache.info);
192
+ const currentTid = session.current()?.targetId;
193
+ const cached = currentTid ? pageCaches.get(currentTid) : undefined;
194
+ if (cached && !dirty && Date.now() - cached.at < PAGE_INFO_TTL_MS) return ok(cached.info);
189
195
  const expr = safeJs`JSON.stringify({url:location.href,title:document.title,w:innerWidth,h:innerHeight,sx:scrollX,sy:scrollY,pw:document.documentElement.scrollWidth,ph:document.documentElement.scrollHeight})`;
190
196
  const raw = await evaluateJs(expr);
191
197
  if (!raw.success) return raw;
@@ -198,7 +204,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
198
204
  }
199
205
  const info = parsePageInfoPayload(parsedRaw);
200
206
  if (!info.success) return info;
201
- pageCache = { info: info.data, at: Date.now() };
207
+ if (currentTid) pageCaches.set(currentTid, { info: info.data, at: Date.now() });
202
208
  return ok(info.data);
203
209
  };
204
210
 
@@ -228,13 +234,17 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
228
234
  if (survivors.length !== owned.length) ownership.replaceAll(survivors);
229
235
  const hw = ownership.harnessWindow();
230
236
  if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
237
+ // Prune per-tab page caches for tabs that no longer exist
238
+ for (const tid of pageCaches.keys()) {
239
+ if (!live.has(tid)) pageCaches.delete(tid);
240
+ }
231
241
  return ok(tabs);
232
242
  };
233
243
 
234
244
  const switchTab = async (targetId: string): Promise<Result<void, CdpError>> => {
235
245
  const r = await session.switchTo(targetId);
236
246
  if (!r.success) return r;
237
- pageCache = null;
247
+ // pageCache per-tab: no longer cleared — each tab retains its cache
238
248
  // Best-effort: mark the tab title with a green circle so the user can
239
249
  // see which tab the agent attached to. CSP or detached frames may
240
250
  // block the eval; we don't surface that as a switchTab failure.
@@ -274,7 +284,6 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
274
284
  if (url && url !== "about:blank") {
275
285
  const nav = await session.call("Page.navigate", { url });
276
286
  if (!nav.success) return nav;
277
- pageCache = null;
278
287
  }
279
288
  return ok(c.targetId);
280
289
  };
@@ -293,9 +302,19 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
293
302
  ...(remote?.browserId !== undefined ? { remoteBrowserId: remote.browserId } : {}),
294
303
  });
295
304
 
305
+ const detach = async (): Promise<void> => {
306
+ const cur = session.current();
307
+ if (!cur) return;
308
+ // Target.detachFromTarget removes the "Chrome is being controlled" banner
309
+ // and releases the page session. The transport (WebSocket/Unix socket)
310
+ // stays alive for reuse by the next session.
311
+ // Reference: https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-detachFromTarget
312
+ await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null });
313
+ };
314
+
296
315
  return {
297
316
  namespace: opts.namespace,
298
- ensureAlive, status, start, stop,
317
+ ensureAlive, status, start, stop, detach,
299
318
  evaluateJs, pageInfo,
300
319
  takeDialog: () => session.takeDialog(),
301
320
  listTabs, switchTab, newTab, closeTab,