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.
- package/CHANGELOG.md +56 -0
- package/README.md +132 -0
- package/package.json +15 -7
- package/skills/pi-browser-harness/SKILL.md +89 -2
- package/src/cdp/commands.ts +205 -14
- package/src/cdp/console-buffer.ts +37 -23
- package/src/cdp/discovery.ts +57 -25
- package/src/cdp/events.ts +9 -3
- package/src/cdp/network-buffer.ts +38 -32
- package/src/cdp/ownership.ts +22 -4
- package/src/cdp/record-store.ts +2 -2
- package/src/cdp/session.ts +358 -216
- package/src/cdp/target-factory.ts +34 -15
- package/src/cdp/types.ts +17 -1
- package/src/client.ts +127 -54
- package/src/daemon/bridge.ts +110 -349
- package/src/daemon/connection.ts +323 -0
- package/src/daemon/index.ts +77 -48
- package/src/daemon/lines.ts +40 -0
- package/src/daemon/main.ts +12 -0
- package/src/daemon/protocol.ts +55 -25
- package/src/daemon/server.ts +100 -63
- package/src/daemon/socket.ts +10 -0
- package/src/daemon/spawn.ts +21 -22
- package/src/daemon/transport.ts +80 -49
- package/src/domains/ax-tree.ts +27 -8
- package/src/domains/cdp-call.ts +14 -7
- package/src/domains/click.ts +2 -2
- package/src/domains/concurrency.ts +50 -0
- package/src/domains/console.ts +20 -64
- package/src/domains/drag.ts +26 -28
- package/src/domains/element-call.ts +102 -68
- package/src/domains/files.ts +13 -9
- package/src/domains/form-batch.ts +13 -10
- package/src/domains/form.ts +78 -107
- package/src/domains/headers.ts +2 -2
- package/src/domains/history.ts +4 -4
- package/src/domains/io.ts +35 -0
- package/src/domains/isolated-tab.ts +6 -12
- package/src/domains/js.ts +67 -46
- package/src/domains/keyboard.ts +14 -11
- package/src/domains/navigate.ts +102 -66
- package/src/domains/network.ts +55 -95
- package/src/domains/page/fill.page.ts +139 -0
- package/src/domains/page/form.page.ts +61 -0
- package/src/domains/page/inject.ts +37 -0
- package/src/domains/page/keyboard.page.ts +16 -0
- package/src/domains/page/live-value.page.ts +10 -0
- package/src/domains/page/read-page.page.ts +51 -0
- package/src/domains/page/recipe.page.ts +82 -0
- package/src/domains/page/serp.page.ts +42 -0
- package/src/domains/page.ts +18 -51
- package/src/domains/poll.ts +42 -0
- package/src/domains/readpage/read-page.ts +48 -53
- package/src/domains/ref-resolve.ts +43 -29
- package/src/domains/ref-signature.ts +11 -0
- package/src/domains/render.ts +12 -23
- package/src/domains/screenshot-capture.ts +4 -2
- package/src/domains/screenshot.ts +13 -10
- package/src/domains/scroll.ts +4 -4
- package/src/domains/search/google-serp.ts +78 -8
- package/src/domains/search/redirect-resolver.ts +41 -0
- package/src/domains/search/web-search.ts +103 -40
- package/src/domains/snapshot.ts +81 -78
- package/src/domains/tab-resolve.ts +36 -0
- package/src/domains/tabs.ts +18 -69
- package/src/domains/target.ts +1 -1
- package/src/index.ts +101 -33
- package/src/profile/bind.ts +45 -14
- package/src/profile/browser-process.ts +50 -12
- package/src/profile/command.ts +20 -12
- package/src/profile/launch.ts +13 -9
- package/src/profile/list.ts +9 -13
- package/src/profile/store.ts +2 -1
- package/src/prompt.ts +34 -8
- package/src/recipes/command.ts +96 -0
- package/src/recipes/compile.ts +198 -0
- package/src/recipes/find.ts +49 -0
- package/src/recipes/journal.ts +125 -0
- package/src/recipes/recipe.ts +215 -0
- package/src/recipes/recordable.ts +359 -0
- package/src/recipes/replay.ts +354 -0
- package/src/recipes/run-tool.ts +132 -0
- package/src/recipes/save-tool.ts +247 -0
- package/src/recipes/store.ts +103 -0
- package/src/recipes/trust.ts +172 -0
- package/src/registry.ts +69 -4
- package/src/schemas/parse.ts +2 -2
- package/src/setup.ts +28 -19
- package/src/state.ts +2 -1
- package/src/util/debug.ts +7 -1
- package/src/util/guards.ts +3 -0
- package/src/util/js-source.ts +28 -0
- package/src/util/mutex.ts +1 -1
- package/src/util/paths.ts +3 -2
- package/src/util/result.ts +8 -0
- package/src/util/sharp-shim.ts +18 -11
- package/src/util/sleep.ts +18 -0
- package/src/util/tool.ts +185 -114
- package/src/util/truncate.ts +1 -1
- package/tsconfig.json +2 -1
- package/src/domains/fill-engine.ts +0 -80
package/src/cdp/session.ts
CHANGED
|
@@ -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
|
|
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:
|
|
17
|
-
consoleBuffer:
|
|
18
|
-
|
|
19
|
-
|
|
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:
|
|
65
|
+
setRefMap(refMap: ReadonlyMap<string, number>, refSig: ReadonlyMap<string, RefSignature>): void;
|
|
32
66
|
resolveRef(ref: string): number | undefined;
|
|
33
|
-
refSignatures(): ReadonlyMap<string,
|
|
67
|
+
refSignatures(): ReadonlyMap<string, RefSignature>;
|
|
34
68
|
refMappings(): ReadonlyMap<string, number>;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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:
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
273
|
-
sessionId
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
} else {
|
|
310
|
-
tabs.set(tid, tab);
|
|
429
|
+
setCurrent(tid, existing.sessionId);
|
|
430
|
+
return ok(undefined);
|
|
311
431
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
327
|
-
tab.
|
|
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
|
-
|
|
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,
|
|
488
|
+
callBrowser(method, ...args) {
|
|
489
|
+
const [params, opts] = args;
|
|
348
490
|
return req(method, params, null, opts);
|
|
349
491
|
},
|
|
350
492
|
takeDialog() {
|