pi-browser-harness 0.12.0 → 0.14.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.
Files changed (102) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +132 -0
  3. package/package.json +15 -7
  4. package/skills/pi-browser-harness/SKILL.md +89 -2
  5. package/src/cdp/commands.ts +205 -14
  6. package/src/cdp/console-buffer.ts +37 -23
  7. package/src/cdp/discovery.ts +57 -25
  8. package/src/cdp/events.ts +9 -3
  9. package/src/cdp/network-buffer.ts +38 -32
  10. package/src/cdp/ownership.ts +22 -4
  11. package/src/cdp/record-store.ts +2 -2
  12. package/src/cdp/session.ts +358 -216
  13. package/src/cdp/target-factory.ts +34 -15
  14. package/src/cdp/types.ts +17 -1
  15. package/src/client.ts +127 -54
  16. package/src/daemon/bridge.ts +110 -349
  17. package/src/daemon/connection.ts +323 -0
  18. package/src/daemon/index.ts +77 -48
  19. package/src/daemon/lines.ts +40 -0
  20. package/src/daemon/main.ts +12 -0
  21. package/src/daemon/protocol.ts +55 -25
  22. package/src/daemon/server.ts +100 -63
  23. package/src/daemon/socket.ts +10 -0
  24. package/src/daemon/spawn.ts +21 -22
  25. package/src/daemon/transport.ts +80 -49
  26. package/src/domains/ax-tree.ts +27 -8
  27. package/src/domains/cdp-call.ts +14 -7
  28. package/src/domains/click.ts +2 -2
  29. package/src/domains/concurrency.ts +50 -0
  30. package/src/domains/console.ts +20 -64
  31. package/src/domains/drag.ts +26 -28
  32. package/src/domains/element-call.ts +102 -68
  33. package/src/domains/files.ts +13 -9
  34. package/src/domains/form-batch.ts +13 -10
  35. package/src/domains/form.ts +78 -107
  36. package/src/domains/headers.ts +2 -2
  37. package/src/domains/history.ts +4 -4
  38. package/src/domains/io.ts +35 -0
  39. package/src/domains/isolated-tab.ts +6 -12
  40. package/src/domains/js.ts +67 -46
  41. package/src/domains/keyboard.ts +14 -11
  42. package/src/domains/navigate.ts +102 -66
  43. package/src/domains/network.ts +55 -95
  44. package/src/domains/page/fill.page.ts +139 -0
  45. package/src/domains/page/form.page.ts +61 -0
  46. package/src/domains/page/inject.ts +37 -0
  47. package/src/domains/page/keyboard.page.ts +16 -0
  48. package/src/domains/page/live-value.page.ts +10 -0
  49. package/src/domains/page/read-page.page.ts +51 -0
  50. package/src/domains/page/recipe.page.ts +82 -0
  51. package/src/domains/page/serp.page.ts +42 -0
  52. package/src/domains/page.ts +18 -51
  53. package/src/domains/poll.ts +42 -0
  54. package/src/domains/readpage/read-page.ts +48 -53
  55. package/src/domains/ref-resolve.ts +43 -29
  56. package/src/domains/ref-signature.ts +11 -0
  57. package/src/domains/render.ts +12 -23
  58. package/src/domains/screenshot-capture.ts +4 -2
  59. package/src/domains/screenshot.ts +13 -10
  60. package/src/domains/scroll.ts +4 -4
  61. package/src/domains/search/google-serp.ts +78 -8
  62. package/src/domains/search/redirect-resolver.ts +41 -0
  63. package/src/domains/search/web-search.ts +103 -40
  64. package/src/domains/snapshot.ts +81 -78
  65. package/src/domains/tab-resolve.ts +36 -0
  66. package/src/domains/tabs.ts +18 -69
  67. package/src/domains/target.ts +1 -1
  68. package/src/index.ts +101 -33
  69. package/src/profile/bind.ts +45 -14
  70. package/src/profile/browser-process.ts +50 -12
  71. package/src/profile/command.ts +20 -12
  72. package/src/profile/launch.ts +13 -9
  73. package/src/profile/list.ts +9 -13
  74. package/src/profile/store.ts +2 -1
  75. package/src/prompt.ts +34 -8
  76. package/src/recipes/command.ts +96 -0
  77. package/src/recipes/compile.ts +198 -0
  78. package/src/recipes/find.ts +49 -0
  79. package/src/recipes/journal.ts +125 -0
  80. package/src/recipes/recipe.ts +215 -0
  81. package/src/recipes/recordable.ts +359 -0
  82. package/src/recipes/replay.ts +354 -0
  83. package/src/recipes/run-tool.ts +132 -0
  84. package/src/recipes/save-tool.ts +247 -0
  85. package/src/recipes/store.ts +103 -0
  86. package/src/recipes/trust.ts +172 -0
  87. package/src/registry.ts +69 -4
  88. package/src/schemas/parse.ts +2 -2
  89. package/src/setup.ts +28 -19
  90. package/src/state.ts +2 -1
  91. package/src/util/debug.ts +7 -1
  92. package/src/util/guards.ts +3 -0
  93. package/src/util/js-source.ts +28 -0
  94. package/src/util/mutex.ts +1 -1
  95. package/src/util/paths.ts +3 -2
  96. package/src/util/result.ts +8 -0
  97. package/src/util/sharp-shim.ts +18 -11
  98. package/src/util/sleep.ts +18 -0
  99. package/src/util/tool.ts +185 -114
  100. package/src/util/truncate.ts +1 -1
  101. package/tsconfig.json +2 -1
  102. package/src/domains/fill-engine.ts +0 -80
@@ -1,40 +1,81 @@
1
1
  import { map, type Result, err, ok } from "../util/result";
2
+ import { isRecord } from "../util/guards";
2
3
  import { type CdpError, cdpError } from "./errors";
3
- import type { DialogInfo } from "./types";
4
- import type { OwnershipRegistry } from "./ownership";
4
+ import type { CdpEvent, DialogInfo, RefSignature } from "./types";
5
+ import { type OwnershipRegistry, reconcileOwnership } from "./ownership";
5
6
  import type { CdpTransport } from "./types";
6
7
  import { type CdpMethod, type ParamsOf, type ResultOf, decodeResult } from "./commands";
8
+
9
+ export type CallOpts = { readonly timeoutMs?: number };
10
+
11
+ // True when every key of P is optional (so `{}` is a valid P); false when at least one key is required.
12
+ type HasOnlyOptionalKeys<P> = Partial<P> extends P ? true : false;
13
+
14
+ // The trailing arguments of `call`/`callBrowser`: params may be omitted exactly when the method's params object has
15
+ // no required key, so `call("Page.enable")` compiles and `call("Page.navigate")` does not.
16
+ export type CallArgs<M extends CdpMethod> = HasOnlyOptionalKeys<ParamsOf<M>> extends true
17
+ ? [params?: ParamsOf<M>, opts?: CallOpts]
18
+ : [params: ParamsOf<M>, opts?: CallOpts];
7
19
  import { decodeEvent } from "./events";
8
- import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer";
9
- import { createConsoleBuffer, type ConsoleDrainResult, type ConsoleFilter } from "./console-buffer";
20
+ import { createNetworkBuffer, type DrainResult, type NetworkBuffer, type NetworkFilter } from "./network-buffer";
21
+ import { createConsoleBuffer, type ConsoleBuffer, type ConsoleDrainResult, type ConsoleFilter } from "./console-buffer";
10
22
 
11
23
  type TabSession = {
12
24
  sessionId: string;
13
- targetId: string;
25
+ readonly targetId: string;
14
26
  dialog: DialogInfo | null;
15
27
  pageInfoDirty: boolean;
16
- networkBuffer: ReturnType<typeof createNetworkBuffer>;
17
- consoleBuffer: ReturnType<typeof createConsoleBuffer>;
18
- refMap: Map<string, number>;
19
- refSig: Map<string, string>;
28
+ readonly networkBuffer: NetworkBuffer;
29
+ readonly consoleBuffer: ConsoleBuffer;
30
+ downloads: number;
31
+ writes: number;
32
+ refMap: ReadonlyMap<string, number>;
33
+ refSig: ReadonlyMap<string, RefSignature>;
34
+ };
35
+
36
+ type PageTarget = { readonly targetId: string; readonly type: string };
37
+
38
+ const NAVIGATES: ReadonlySet<string> = new Set(["Page.navigate", "Page.reload", "Page.navigateToHistoryEntry"]);
39
+
40
+ const SAFE_METHODS: ReadonlySet<string> = new Set(["GET", "HEAD", "OPTIONS"]);
41
+
42
+ const requestMethod = (params: unknown): string => {
43
+ const request = isRecord(params) ? params["request"] : undefined;
44
+ const method = isRecord(request) ? request["method"] : undefined;
45
+ return typeof method === "string" ? method.toUpperCase() : "";
20
46
  };
21
47
 
48
+ const ENABLED_DOMAINS: ReadonlyArray<string> = ["Page", "DOM", "Runtime", "Network", "Accessibility", "Log"];
49
+
22
50
  const dialogType = (raw: string): DialogInfo["type"] =>
23
51
  raw === "confirm" || raw === "prompt" || raw === "beforeunload" ? raw : "alert";
24
52
 
25
53
  export type CdpSession = {
26
54
  attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
55
+ // Raw attach: the returned session is NOT tracked (no domains enabled, no event routing). Pair it with `detach`.
27
56
  attach(targetId: string): Promise<Result<string, CdpError>>;
57
+ detach(sessionId: string): Promise<Result<void, CdpError>>;
58
+ // Detaches every tracked session and forgets them all.
59
+ detachAll(): Promise<void>;
60
+ // The tracked, domain-enabled session for a target, if this instance holds one.
61
+ sessionFor(targetId: string): string | undefined;
28
62
  windowId(targetId: string): Promise<Result<number, CdpError>>;
29
63
  switchTo(targetId: string): Promise<Result<void, CdpError>>;
30
64
  current(): { readonly sessionId: string; readonly targetId: string } | null;
31
- setRefMap(refMap: Map<string, number>, refSig: Map<string, string>): void;
65
+ setRefMap(refMap: ReadonlyMap<string, number>, refSig: ReadonlyMap<string, RefSignature>): void;
32
66
  resolveRef(ref: string): number | undefined;
33
- refSignatures(): ReadonlyMap<string, string>;
67
+ refSignatures(): ReadonlyMap<string, RefSignature>;
34
68
  refMappings(): ReadonlyMap<string, number>;
35
- call<M extends CdpMethod>(method: M, params?: ParamsOf<M>, opts?: { timeoutMs?: number }): Promise<Result<ResultOf<M>, CdpError>>;
36
- callOnTarget<M extends CdpMethod>(method: M, params: ParamsOf<M>, sessionId: string, opts?: { timeoutMs?: number }): Promise<Result<ResultOf<M>, CdpError>>;
37
- callBrowser<M extends CdpMethod>(method: M, params?: ParamsOf<M>, opts?: { timeoutMs?: number }): Promise<Result<ResultOf<M>, CdpError>>;
69
+ // Downloads Chrome has started from the current tab; compare before and after an action to see whether it began one.
70
+ downloadCount(): number;
71
+ // Requests the current tab has sent with a method that can change server state (anything but GET, HEAD, OPTIONS).
72
+ writeRequestCount(): number;
73
+ // Sends on the current page session. Params are required only when the method has a required key (see CallArgs).
74
+ call<M extends CdpMethod>(method: M, ...args: CallArgs<M>): Promise<Result<ResultOf<M>, CdpError>>;
75
+ // Sends on an explicit session id (a borrowed or background tab). Params always come first because the session id follows them.
76
+ callOnTarget<M extends CdpMethod>(method: M, params: ParamsOf<M>, sessionId: string, opts?: CallOpts): Promise<Result<ResultOf<M>, CdpError>>;
77
+ // Sends on the browser-level connection (no session id).
78
+ callBrowser<M extends CdpMethod>(method: M, ...args: CallArgs<M>): Promise<Result<ResultOf<M>, CdpError>>;
38
79
  takeDialog(): DialogInfo | null;
39
80
  drainPageInfoInvalidations(): boolean;
40
81
  drainNetworkBuffer(filter: NetworkFilter): DrainResult;
@@ -57,94 +98,176 @@ export const createCdpSession = (
57
98
 
58
99
  let activeConsumer: Promise<void> = Promise.resolve();
59
100
 
101
+ const clearCurrent = (): void => {
102
+ sessionId = null;
103
+ targetId = null;
104
+ };
105
+ const setCurrent = (tid: string, sid: string): void => {
106
+ sessionId = sid;
107
+ targetId = tid;
108
+ };
109
+
110
+ const freshTab = (tid: string, sid: string, pageInfoDirty: boolean): TabSession => ({
111
+ sessionId: sid,
112
+ targetId: tid,
113
+ dialog: null,
114
+ pageInfoDirty,
115
+ networkBuffer: createNetworkBuffer(),
116
+ consoleBuffer: createConsoleBuffer(),
117
+ downloads: 0,
118
+ writes: 0,
119
+ refMap: new Map(),
120
+ refSig: new Map(),
121
+ });
122
+
123
+ // Binds a session to a target. An existing tab keeps its buffers, refs and dialog; only its session id changes.
124
+ const bindSession = (tid: string, sid: string, pageInfoDirty: boolean): TabSession => {
125
+ const existing = tabs.get(tid);
126
+ if (existing) {
127
+ sessionIdToTargetId.delete(existing.sessionId);
128
+ existing.sessionId = sid;
129
+ sessionIdToTargetId.set(sid, tid);
130
+ return existing;
131
+ }
132
+ const tab = freshTab(tid, sid, pageInfoDirty);
133
+ tabs.set(tid, tab);
134
+ sessionIdToTargetId.set(sid, tid);
135
+ return tab;
136
+ };
137
+
138
+ // A session is gone (detached by us, by Chrome, or with its target). Only the tab still bound to it is dropped, so a stale notice for a replaced session is harmless.
139
+ const forgetSession = (sid: string): void => {
140
+ const tid = sessionIdToTargetId.get(sid);
141
+ sessionIdToTargetId.delete(sid);
142
+ if (tid === undefined) return;
143
+ const tab = tabs.get(tid);
144
+ if (tab?.sessionId === sid) tabs.delete(tid);
145
+ if (sessionId === sid) clearCurrent();
146
+ };
147
+
148
+ const forgetTarget = (tid: string): void => {
149
+ const tab = tabs.get(tid);
150
+ if (tab) {
151
+ sessionIdToTargetId.delete(tab.sessionId);
152
+ tabs.delete(tid);
153
+ }
154
+ if (targetId === tid) clearCurrent();
155
+ };
156
+
60
157
  const resolveTab = (evSessionId?: string): TabSession | undefined => {
61
158
  const tid = evSessionId ? sessionIdToTargetId.get(evSessionId) : targetId;
62
159
  return tid ? tabs.get(tid) : undefined;
63
160
  };
64
161
 
65
- const consumeEvents = async (): Promise<void> => {
66
- for await (const ev of transport.events()) {
67
- if (ev.method !== "Target.targetDestroyed") {
68
- if (ev.sessionId && !sessionIdToTargetId.has(ev.sessionId)) continue;
162
+ const handleEvent = (ev: CdpEvent): void => {
163
+ // Lifecycle notices about a session we no longer track are exactly the ones that must still be handled.
164
+ const lifecycle = ev.method === "Target.targetDestroyed" || ev.method === "Target.detachedFromTarget";
165
+ if (!lifecycle && ev.sessionId && !sessionIdToTargetId.has(ev.sessionId)) return;
166
+ switch (ev.method) {
167
+ case "Page.javascriptDialogOpening": {
168
+ const tab = resolveTab(ev.sessionId);
169
+ if (!tab) break;
170
+ const decoded = decodeEvent(ev.method, ev.params);
171
+ tab.dialog = decoded.success
172
+ ? {
173
+ type: dialogType(decoded.data.type),
174
+ message: decoded.data.message,
175
+ ...(decoded.data.defaultPrompt !== undefined
176
+ ? { defaultPrompt: decoded.data.defaultPrompt }
177
+ : {}),
178
+ }
179
+ : { type: "alert", message: "" };
180
+ break;
69
181
  }
70
- switch (ev.method) {
71
- case "Page.javascriptDialogOpening": {
72
- const tab = resolveTab(ev.sessionId);
73
- if (!tab) break;
74
- const decoded = decodeEvent(ev.method, ev.params);
75
- tab.dialog = decoded.success
76
- ? {
77
- type: dialogType(decoded.data.type),
78
- message: decoded.data.message,
79
- ...(decoded.data.defaultPrompt !== undefined
80
- ? { defaultPrompt: decoded.data.defaultPrompt }
81
- : {}),
82
- }
83
- : { type: "alert", message: "" };
84
- break;
85
- }
86
- // Intentionally NOT cleared on javascriptDialogClosed: a fast dismiss would otherwise drop a dialog the agent was about to read.
87
- case "Page.frameNavigated":
88
- case "Page.loadEventFired": {
89
- const tab = resolveTab(ev.sessionId);
90
- if (tab) tab.pageInfoDirty = true;
91
- break;
92
- }
93
- case "Target.targetCreated": {
94
- if (!ownership) break;
95
- const decoded = decodeEvent(ev.method, ev.params);
96
- if (!decoded.success) break;
97
- const info = decoded.data.targetInfo;
98
- if (info.type === "page" && info.targetId && info.openerId && ownership.has(info.openerId)) {
99
- ownership.add(info.targetId);
100
- }
101
- break;
102
- }
103
- case "Target.targetDestroyed": {
104
- if (!ownership) break;
105
- const decoded = decodeEvent(ev.method, ev.params);
106
- if (!decoded.success || !decoded.data.targetId) break;
107
- const destroyed = decoded.data.targetId;
108
- ownership.remove(destroyed);
109
- const tab = tabs.get(destroyed);
110
- if (tab) {
111
- sessionIdToTargetId.delete(tab.sessionId);
112
- tabs.delete(destroyed);
113
- }
114
- break;
115
- }
116
- case "Network.requestWillBeSent": {
117
- const tab = resolveTab(ev.sessionId);
118
- if (tab) tab.networkBuffer.ingestRequestWillBeSent(ev.params);
119
- break;
120
- }
121
- case "Network.responseReceived": {
122
- const tab = resolveTab(ev.sessionId);
123
- if (tab) tab.networkBuffer.ingestResponseReceived(ev.params);
124
- break;
125
- }
126
- case "Network.loadingFinished": {
127
- const tab = resolveTab(ev.sessionId);
128
- if (tab) tab.networkBuffer.ingestLoadingFinished(ev.params);
129
- break;
130
- }
131
- case "Network.loadingFailed": {
132
- const tab = resolveTab(ev.sessionId);
133
- if (tab) tab.networkBuffer.ingestLoadingFailed(ev.params);
134
- break;
135
- }
136
- case "Runtime.consoleAPICalled": {
137
- const tab = resolveTab(ev.sessionId);
138
- if (tab) tab.consoleBuffer.ingestConsoleApi(ev.params);
139
- break;
182
+ // Chrome is the authority on whether a dialog is still blocking the page: handled, navigated away or dismissed by the user, it is gone.
183
+ case "Page.javascriptDialogClosed": {
184
+ const tab = resolveTab(ev.sessionId);
185
+ if (tab) tab.dialog = null;
186
+ break;
187
+ }
188
+ case "Page.frameNavigated":
189
+ case "Page.loadEventFired": {
190
+ const tab = resolveTab(ev.sessionId);
191
+ if (tab) tab.pageInfoDirty = true;
192
+ break;
193
+ }
194
+ case "Page.downloadWillBegin": {
195
+ const tab = resolveTab(ev.sessionId);
196
+ if (tab) tab.downloads += 1;
197
+ break;
198
+ }
199
+ case "Target.targetCreated": {
200
+ if (!ownership) break;
201
+ const decoded = decodeEvent(ev.method, ev.params);
202
+ if (!decoded.success) break;
203
+ const info = decoded.data.targetInfo;
204
+ if (info.type === "page" && info.targetId && info.openerId && ownership.has(info.openerId)) {
205
+ ownership.add(info.targetId);
140
206
  }
141
- case "Log.entryAdded": {
142
- const tab = resolveTab(ev.sessionId);
143
- if (tab) tab.consoleBuffer.ingestLogEntry(ev.params);
144
- break;
207
+ break;
208
+ }
209
+ case "Target.targetDestroyed": {
210
+ const decoded = decodeEvent(ev.method, ev.params);
211
+ if (!decoded.success || !decoded.data.targetId) break;
212
+ const destroyed = decoded.data.targetId;
213
+ ownership?.remove(destroyed);
214
+ forgetTarget(destroyed);
215
+ break;
216
+ }
217
+ case "Target.detachedFromTarget": {
218
+ const decoded = decodeEvent(ev.method, ev.params);
219
+ if (decoded.success) forgetSession(decoded.data.sessionId);
220
+ break;
221
+ }
222
+ case "Inspector.detached": {
223
+ if (ev.sessionId !== undefined) forgetSession(ev.sessionId);
224
+ break;
225
+ }
226
+ case "Network.requestWillBeSent": {
227
+ const tab = resolveTab(ev.sessionId);
228
+ if (tab) {
229
+ tab.networkBuffer.ingestRequestWillBeSent(ev.params);
230
+ if (!SAFE_METHODS.has(requestMethod(ev.params))) tab.writes += 1;
145
231
  }
146
- default:
147
- break;
232
+ break;
233
+ }
234
+ case "Network.responseReceived": {
235
+ const tab = resolveTab(ev.sessionId);
236
+ if (tab) tab.networkBuffer.ingestResponseReceived(ev.params);
237
+ break;
238
+ }
239
+ case "Network.loadingFinished": {
240
+ const tab = resolveTab(ev.sessionId);
241
+ if (tab) tab.networkBuffer.ingestLoadingFinished(ev.params);
242
+ break;
243
+ }
244
+ case "Network.loadingFailed": {
245
+ const tab = resolveTab(ev.sessionId);
246
+ if (tab) tab.networkBuffer.ingestLoadingFailed(ev.params);
247
+ break;
248
+ }
249
+ case "Runtime.consoleAPICalled": {
250
+ const tab = resolveTab(ev.sessionId);
251
+ if (tab) tab.consoleBuffer.ingestConsoleApi(ev.params);
252
+ break;
253
+ }
254
+ case "Log.entryAdded": {
255
+ const tab = resolveTab(ev.sessionId);
256
+ if (tab) tab.consoleBuffer.ingestLogEntry(ev.params);
257
+ break;
258
+ }
259
+ default:
260
+ break;
261
+ }
262
+ };
263
+
264
+ const consumeEvents = async (): Promise<void> => {
265
+ for await (const ev of transport.events()) {
266
+ // One bad event must not end the loop: nothing restarts it until the transport closes.
267
+ try {
268
+ handleEvent(ev);
269
+ } catch (e: unknown) {
270
+ console.warn(`[pi-browser-harness] CDP event handler failed for ${ev.method}:`, e);
148
271
  }
149
272
  }
150
273
  };
@@ -157,30 +280,36 @@ export const createCdpSession = (
157
280
 
158
281
  restartConsumer();
159
282
  transport.onClose(() => {
160
- sessionId = null;
161
- targetId = null;
283
+ // Everything, pending dialogs included, dies with the socket: the sessions those dialogs belonged to no longer exist, so a later takeDialog() correctly sees nothing.
284
+ clearCurrent();
162
285
  tabs.clear();
163
286
  sessionIdToTargetId.clear();
164
- // Do NOT clear `dialog` here: a pending takeDialog() must still see it.
165
287
  restartConsumer();
166
288
  });
167
289
 
168
- const enableDomains = async (sid: string): Promise<void> => {
169
- for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility", "Log"]) {
170
- await transport.request(`${d}.enable`, {}, { sessionId: sid });
171
- }
290
+ // Not in the typed command table (only Page.enable is), so the raw transport is used and only success is checked.
291
+ // The enables are independent, so they go out together; the first failure wins.
292
+ const enableDomains = async (sid: string): Promise<Result<void, CdpError>> => {
293
+ const results = await Promise.all(ENABLED_DOMAINS.map((d) => transport.request(`${d}.enable`, {}, { sessionId: sid })));
294
+ return results.find((r) => !r.success) ?? ok(undefined);
172
295
  };
173
296
 
174
297
  const req = async <M extends CdpMethod>(
175
298
  method: M,
176
299
  params: ParamsOf<M> | undefined,
177
300
  sid: string | null,
178
- opts: { timeoutMs?: number } = {},
301
+ opts: CallOpts = {},
179
302
  ): Promise<Result<ResultOf<M>, CdpError>> => {
180
303
  const raw = await transport.request(method, { ...(params ?? {}) }, {
181
304
  sessionId: sid,
182
305
  ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
183
306
  });
307
+ // A navigation command can return before its Page.frameNavigated event arrives, and a page info cached just
308
+ // before it would then still describe the page it left.
309
+ if (NAVIGATES.has(method)) {
310
+ const tab = resolveTab(sid ?? undefined);
311
+ if (tab) tab.pageInfoDirty = true;
312
+ }
184
313
  if (!raw.success) return raw;
185
314
  return decodeResult(method, raw.data);
186
315
  };
@@ -188,134 +317,138 @@ export const createCdpSession = (
188
317
  const attach = async (tid: string): Promise<Result<string, CdpError>> =>
189
318
  map(await req("Target.attachToTarget", { targetId: tid, flatten: true }, null), (d) => d.sessionId);
190
319
 
320
+ // Callers that ignore the Result are best effort: the session may already be gone, which is the outcome they wanted anyway.
321
+ const detachSession = async (sid: string): Promise<Result<void, CdpError>> =>
322
+ map(await req("Target.detachFromTarget", { sessionId: sid }, null), () => undefined);
323
+
191
324
  const windowId = async (tid: string): Promise<Result<number, CdpError>> =>
192
325
  map(await req("Browser.getWindowForTarget", { targetId: tid }, null), (d) => d.windowId);
193
326
 
327
+ // Attaches a new session to `tid` with every domain enabled. A previous session on the same target is detached only once the new one is live, so the tab's buffers and refs carry over without leaking the old session.
328
+ const attachFresh = async (tid: string, pageInfoDirty: boolean): Promise<Result<string, CdpError>> => {
329
+ const previous = tabs.get(tid)?.sessionId;
330
+ const attached = await attach(tid);
331
+ if (!attached.success) return attached;
332
+ const sid = attached.data;
333
+ bindSession(tid, sid, pageInfoDirty);
334
+ const enabled = await enableDomains(sid);
335
+ if (!enabled.success) {
336
+ forgetSession(sid);
337
+ await detachSession(sid);
338
+ return enabled;
339
+ }
340
+ if (previous !== undefined && previous !== sid) await detachSession(previous);
341
+ return ok(sid);
342
+ };
343
+
344
+ const discoverPages = async (): Promise<Result<ReadonlyArray<PageTarget>, CdpError>> => {
345
+ const discovering = await req("Target.setDiscoverTargets", { discover: true }, null);
346
+ if (!discovering.success) return discovering;
347
+ return map(await req("Target.getTargets", {}, null), (r) => r.targetInfos.filter((t) => t.type === "page"));
348
+ };
349
+
350
+ // Drops dead owned targets, then adopts every live page that shares a window with the first surviving owned tab.
351
+ const adoptHarnessWindowTabs = async (
352
+ registry: OwnershipRegistry,
353
+ allPages: ReadonlyArray<PageTarget>,
354
+ live: ReadonlySet<string>,
355
+ ): Promise<void> => {
356
+ const survivors = reconcileOwnership(registry, live);
357
+ const anchorId = survivors[0];
358
+ if (anchorId === undefined) {
359
+ registry.setHarnessWindowId(undefined);
360
+ return;
361
+ }
362
+ const anchorWin = await windowId(anchorId);
363
+ if (anchorWin.success) {
364
+ const anchorWindowId = anchorWin.data;
365
+ registry.setHarnessWindowId(anchorWindowId);
366
+ const windows = await Promise.all(
367
+ allPages.map((p) => (p.targetId === anchorId ? Promise.resolve(anchorWin) : windowId(p.targetId))),
368
+ );
369
+ // One replaceAll, one change notification: adding per page would persist state once per tab.
370
+ const adopted = allPages.filter((_, i) => {
371
+ const r = windows[i];
372
+ return r?.success === true && r.data === anchorWindowId;
373
+ }).map((p) => p.targetId);
374
+ registry.replaceAll([...new Set([...registry.list(), ...adopted])]);
375
+ }
376
+ if (!registry.harnessWindow()) registry.setHarnessWindow(anchorId);
377
+ };
378
+
379
+ const pruneDeadTabs = (live: ReadonlySet<string>): void => {
380
+ for (const tid of [...tabs.keys()]) {
381
+ if (!live.has(tid)) forgetTarget(tid);
382
+ }
383
+ };
384
+
385
+ // First live owned page; otherwise a seeded one; otherwise a fresh about:blank (in its own window when ownership is tracked).
386
+ const pickOrCreateTarget = async (live: ReadonlySet<string>): Promise<Result<string, CdpError>> => {
387
+ const ownedLive = ownership?.list().find((id) => live.has(id));
388
+ if (ownedLive !== undefined) return ok(ownedLive);
389
+ if (createSeedTarget) return createSeedTarget();
390
+ const created = await req(
391
+ "Target.createTarget",
392
+ { url: "about:blank", ...(ownership ? { newWindow: true } : {}) },
393
+ null,
394
+ );
395
+ if (!created.success) return created;
396
+ const tid = created.data.targetId;
397
+ if (ownership) {
398
+ ownership.setHarnessWindow(tid);
399
+ ownership.add(tid);
400
+ const win = await windowId(tid);
401
+ if (win.success) ownership.setHarnessWindowId(win.data);
402
+ }
403
+ return ok(tid);
404
+ };
405
+
194
406
  const currentTab = (): TabSession | undefined => (targetId ? tabs.get(targetId) : undefined);
195
407
 
196
408
  const session: CdpSession = {
197
409
  async attachFirstPage() {
198
- await req("Target.setDiscoverTargets", { discover: true }, null);
199
-
200
- const targets = await req("Target.getTargets", {}, null);
201
- if (!targets.success) return targets;
202
- const allPages = targets.data.targetInfos.filter((t) => t.type === "page");
203
- if (ownership) {
204
- const live = new Set(allPages.map((p) => p.targetId));
205
- const survivors = ownership.list().filter((id) => live.has(id));
206
- if (survivors.length !== ownership.list().length) ownership.replaceAll(survivors);
207
- const hw = ownership.harnessWindow();
208
- if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
209
-
210
- const anchorId = survivors[0];
211
- if (anchorId === undefined) {
212
- ownership.setHarnessWindowId(undefined);
213
- } else {
214
- const anchorWin = await windowId(anchorId);
215
- if (anchorWin.success) {
216
- const anchorWindowId = anchorWin.data;
217
- ownership.setHarnessWindowId(anchorWindowId);
218
- const windows = await Promise.all(
219
- allPages.map((p) =>
220
- p.targetId === anchorId
221
- ? Promise.resolve(anchorWin)
222
- : windowId(p.targetId),
223
- ),
224
- );
225
- allPages.forEach((p, i) => {
226
- const r = windows[i];
227
- if (r?.success && r.data === anchorWindowId) {
228
- ownership.add(p.targetId);
229
- }
230
- });
231
- }
232
- if (!ownership.harnessWindow()) ownership.setHarnessWindow(anchorId);
233
- }
234
- }
235
- const liveTargetIds = new Set(allPages.map((p) => p.targetId));
236
- for (const tid of tabs.keys()) {
237
- if (!liveTargetIds.has(tid)) {
238
- const tab = tabs.get(tid);
239
- if (tab) sessionIdToTargetId.delete(tab.sessionId);
240
- tabs.delete(tid);
241
- }
242
- }
243
-
244
- let pickTargetId: string | undefined;
245
- if (ownership) {
246
- const ownedLive = ownership.list().filter((id) => allPages.some((p) => p.targetId === id));
247
- pickTargetId = ownedLive[0];
248
- }
249
- if (!pickTargetId && createSeedTarget) {
250
- const seeded = await createSeedTarget();
251
- if (!seeded.success) return seeded;
252
- pickTargetId = seeded.data;
253
- }
254
- if (!pickTargetId) {
255
- const created = await req(
256
- "Target.createTarget",
257
- { url: "about:blank", ...(ownership ? { newWindow: true } : {}) },
258
- null,
259
- );
260
- if (!created.success) return created;
261
- pickTargetId = created.data.targetId;
262
- if (ownership) {
263
- ownership.setHarnessWindow(created.data.targetId);
264
- ownership.add(created.data.targetId);
265
- const win = await windowId(created.data.targetId);
266
- if (win.success) ownership.setHarnessWindowId(win.data);
267
- }
268
- }
269
-
270
- const attached = await attach(pickTargetId);
410
+ const pages = await discoverPages();
411
+ if (!pages.success) return pages;
412
+ const live: ReadonlySet<string> = new Set(pages.data.map((p) => p.targetId));
413
+ if (ownership) await adoptHarnessWindowTabs(ownership, pages.data, live);
414
+ pruneDeadTabs(live);
415
+ const picked = await pickOrCreateTarget(live);
416
+ if (!picked.success) return picked;
417
+ const tid = picked.data;
418
+ // Always a fresh session: callers reach this on start or after the current session was reported dead.
419
+ const attached = await attachFresh(tid, false);
271
420
  if (!attached.success) return attached;
272
- const attachedSessionId = attached.data;
273
- sessionId = attachedSessionId;
274
- targetId = pickTargetId;
275
- tabs.set(pickTargetId, {
276
- sessionId: attachedSessionId,
277
- targetId: pickTargetId,
278
- dialog: null,
279
- pageInfoDirty: false,
280
- networkBuffer: createNetworkBuffer(),
281
- consoleBuffer: createConsoleBuffer(),
282
- refMap: new Map(),
283
- refSig: new Map(),
284
- });
285
- sessionIdToTargetId.set(attachedSessionId, pickTargetId);
286
- await enableDomains(attachedSessionId);
287
- return ok({ targetId: pickTargetId, sessionId: attachedSessionId });
421
+ setCurrent(tid, attached.data);
422
+ return ok({ targetId: tid, sessionId: attached.data });
288
423
  },
289
424
  async switchTo(tid) {
290
425
  const activated = await req("Target.activateTarget", { targetId: tid }, null);
291
426
  if (!activated.success) return activated;
292
- const attached = await attach(tid);
293
- if (!attached.success) return attached;
294
- const attachedSessionId = attached.data;
295
427
  const existing = tabs.get(tid);
296
- const tab: TabSession = existing ?? {
297
- sessionId: attachedSessionId,
298
- targetId: tid,
299
- dialog: null,
300
- pageInfoDirty: true,
301
- networkBuffer: createNetworkBuffer(),
302
- consoleBuffer: createConsoleBuffer(),
303
- refMap: new Map(),
304
- refSig: new Map(),
305
- };
306
428
  if (existing) {
307
- sessionIdToTargetId.delete(existing.sessionId);
308
- existing.sessionId = attachedSessionId;
309
- } else {
310
- tabs.set(tid, tab);
429
+ setCurrent(tid, existing.sessionId);
430
+ return ok(undefined);
311
431
  }
312
- sessionIdToTargetId.set(attachedSessionId, tid);
313
- await enableDomains(attachedSessionId);
314
- sessionId = tab.sessionId;
315
- targetId = tid;
432
+ const attached = await attachFresh(tid, true);
433
+ if (!attached.success) return attached;
434
+ setCurrent(tid, attached.data);
316
435
  return ok(undefined);
317
436
  },
318
437
  attach,
438
+ async detach(sid) {
439
+ forgetSession(sid);
440
+ return detachSession(sid);
441
+ },
442
+ async detachAll() {
443
+ const sids = [...sessionIdToTargetId.keys()];
444
+ clearCurrent();
445
+ tabs.clear();
446
+ sessionIdToTargetId.clear();
447
+ await Promise.all(sids.map(detachSession));
448
+ },
449
+ sessionFor(tid) {
450
+ return tabs.get(tid)?.sessionId;
451
+ },
319
452
  windowId,
320
453
  current() {
321
454
  return sessionId && targetId ? { sessionId, targetId } : null;
@@ -323,8 +456,9 @@ export const createCdpSession = (
323
456
  setRefMap(refMap, refSig) {
324
457
  const tab = currentTab();
325
458
  if (!tab) return;
326
- tab.refMap = refMap;
327
- tab.refSig = refSig;
459
+ // Copied so later mutation of the caller's maps cannot change what resolveRef sees.
460
+ tab.refMap = new Map(refMap);
461
+ tab.refSig = new Map(refSig);
328
462
  },
329
463
  resolveRef(ref) {
330
464
  const tab = currentTab();
@@ -338,13 +472,21 @@ export const createCdpSession = (
338
472
  const tab = currentTab();
339
473
  return tab?.refMap ?? new Map();
340
474
  },
341
- call(method, params, opts) {
475
+ downloadCount() {
476
+ return currentTab()?.downloads ?? 0;
477
+ },
478
+ writeRequestCount() {
479
+ return currentTab()?.writes ?? 0;
480
+ },
481
+ call(method, ...args) {
482
+ const [params, opts] = args;
342
483
  return req(method, params, sessionId, opts);
343
484
  },
344
485
  callOnTarget(method, params, sid, opts) {
345
486
  return req(method, params, sid, opts);
346
487
  },
347
- callBrowser(method, params, opts) {
488
+ callBrowser(method, ...args) {
489
+ const [params, opts] = args;
348
490
  return req(method, params, null, opts);
349
491
  },
350
492
  takeDialog() {