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
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import type { BrowserClient } from "../client";
|
|
5
5
|
import { type Result, err, ok } from "../util/result";
|
|
6
|
+
import { safeJs } from "../util/js-template";
|
|
6
7
|
import { type CdpError, cdpError } from "./errors";
|
|
8
|
+
import { reconcileOwnership } from "./ownership";
|
|
7
9
|
|
|
8
10
|
const SPAWN_DEADLINE_MS = 8_000;
|
|
9
11
|
const SPAWN_POLL_MS = 100;
|
|
@@ -26,30 +28,46 @@ const listPageTargets = async (client: BrowserClient): Promise<Result<ReadonlyAr
|
|
|
26
28
|
return ok(r.data.targetInfos.filter((t) => t.type === "page"));
|
|
27
29
|
};
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
export type BorrowedSession = { readonly sessionId: string; readonly release: () => Promise<void> };
|
|
32
|
+
|
|
33
|
+
// Reuses the tracked, domain-enabled session when the harness holds one for the target; otherwise attaches a raw throwaway session that `release` detaches, so no session outlives its single use.
|
|
34
|
+
export const borrowSession = async (client: BrowserClient, targetId: string): Promise<Result<BorrowedSession, CdpError>> => {
|
|
35
|
+
const tracked = client.session().sessionFor(targetId);
|
|
36
|
+
if (tracked !== undefined) return ok({ sessionId: tracked, release: () => Promise.resolve() });
|
|
37
|
+
const attached = await client.session().attach(targetId);
|
|
38
|
+
if (!attached.success) return attached;
|
|
39
|
+
const sessionId = attached.data;
|
|
40
|
+
return ok({
|
|
41
|
+
sessionId,
|
|
42
|
+
release: async () => {
|
|
43
|
+
await client.session().detach(sessionId);
|
|
44
|
+
},
|
|
45
|
+
});
|
|
33
46
|
};
|
|
34
47
|
|
|
35
48
|
const spawnTabViaOpener = async (
|
|
36
49
|
client: BrowserClient,
|
|
37
50
|
openerTargetId: string,
|
|
38
51
|
): Promise<Result<string, CdpError>> => {
|
|
39
|
-
const
|
|
40
|
-
if (!
|
|
52
|
+
const borrowed = await borrowSession(client, openerTargetId);
|
|
53
|
+
if (!borrowed.success) return borrowed;
|
|
41
54
|
|
|
42
55
|
const token = `pi-${randomUUID()}`;
|
|
43
56
|
// userGesture: true is what gets this past the popup blocker.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
let opened;
|
|
58
|
+
try {
|
|
59
|
+
opened = await client.session().callOnTarget(
|
|
60
|
+
"Runtime.evaluate",
|
|
61
|
+
{
|
|
62
|
+
expression: safeJs`!!window.open(${`about:blank#${token}`}, '_blank')`,
|
|
63
|
+
returnByValue: true,
|
|
64
|
+
userGesture: true,
|
|
65
|
+
},
|
|
66
|
+
borrowed.data.sessionId,
|
|
67
|
+
);
|
|
68
|
+
} finally {
|
|
69
|
+
await borrowed.data.release();
|
|
70
|
+
}
|
|
53
71
|
if (!opened.success) return opened;
|
|
54
72
|
if (opened.data.result.value !== true) {
|
|
55
73
|
return err(cdpError(
|
|
@@ -93,6 +111,7 @@ const ensureHarnessWindowUncoordinated = async (
|
|
|
93
111
|
const live = new Set(targets.data.map((t) => t.targetId));
|
|
94
112
|
|
|
95
113
|
const ownership = client.ownership();
|
|
114
|
+
reconcileOwnership(ownership, live);
|
|
96
115
|
const pinnedCtx = client.profileContextId();
|
|
97
116
|
// With a pin set but no context resolved yet, a recorded tab's profile cannot be verified, so seed a new window rather than risk adopting one from the wrong profile.
|
|
98
117
|
const canReuse = client.profilePin() === null || pinnedCtx !== undefined;
|
package/src/cdp/types.ts
CHANGED
|
@@ -11,7 +11,7 @@ export type CdpEvent = {
|
|
|
11
11
|
export type CdpRawMessage = {
|
|
12
12
|
readonly id?: number;
|
|
13
13
|
readonly method?: string;
|
|
14
|
-
readonly params?: Record<string, unknown
|
|
14
|
+
readonly params?: Readonly<Record<string, unknown>>;
|
|
15
15
|
readonly result?: unknown;
|
|
16
16
|
readonly error?: { readonly message: string; readonly code?: number };
|
|
17
17
|
readonly sessionId?: string;
|
|
@@ -30,6 +30,15 @@ export type TabInfo = {
|
|
|
30
30
|
readonly owned?: boolean;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
// The identity of an interactive element as seen in one snapshot. Kept on the tab session per ref so the next
|
|
34
|
+
// mutation can report what changed without re-running a full snapshot.
|
|
35
|
+
export type RefSignature = {
|
|
36
|
+
readonly role: string;
|
|
37
|
+
readonly name: string;
|
|
38
|
+
readonly value: string;
|
|
39
|
+
readonly state: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
33
42
|
export type PageInfo = {
|
|
34
43
|
readonly url: string;
|
|
35
44
|
readonly title: string;
|
|
@@ -41,6 +50,11 @@ export type PageInfo = {
|
|
|
41
50
|
readonly pageHeight: number;
|
|
42
51
|
};
|
|
43
52
|
|
|
53
|
+
// Discriminated on `kind`.
|
|
54
|
+
export type PageInfoResult =
|
|
55
|
+
| ({ readonly kind: "page" } & PageInfo)
|
|
56
|
+
| { readonly kind: "dialog"; readonly dialog: DialogInfo };
|
|
57
|
+
|
|
44
58
|
export type DaemonStatus = {
|
|
45
59
|
readonly alive: boolean;
|
|
46
60
|
readonly sessionId: string | null;
|
|
@@ -56,6 +70,8 @@ export const isCdpRawMessage = (v: unknown): v is CdpRawMessage => {
|
|
|
56
70
|
if (method !== undefined && typeof method !== "string") return false;
|
|
57
71
|
const sessionId = v["sessionId"];
|
|
58
72
|
if (sessionId !== undefined && typeof sessionId !== "string") return false;
|
|
73
|
+
const params = v["params"];
|
|
74
|
+
if (params !== undefined && !isRecord(params)) return false;
|
|
59
75
|
const errVal = v["error"];
|
|
60
76
|
if (errVal !== undefined) {
|
|
61
77
|
if (!isRecord(errVal)) return false;
|
package/src/client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DAEMON_RECONNECT_METHOD } from "./daemon/protocol";
|
|
1
2
|
import { Type } from "typebox";
|
|
2
3
|
import { Compile } from "typebox/compile";
|
|
3
4
|
import { parseJson } from "./schemas/parse";
|
|
@@ -8,11 +9,26 @@ import { discoverEndpoint } from "./cdp/discovery";
|
|
|
8
9
|
import { type CdpError, cdpError } from "./cdp/errors";
|
|
9
10
|
import type { CdpTransport } from "./cdp/types";
|
|
10
11
|
import { type CdpSession, createCdpSession } from "./cdp/session";
|
|
11
|
-
import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership";
|
|
12
|
+
import { type OwnershipRegistry, createOwnershipRegistry, reconcileOwnership } from "./cdp/ownership";
|
|
12
13
|
import { ensureHarnessWindow, openHarnessTab } from "./cdp/target-factory";
|
|
13
14
|
import { seedProfileWindow } from "./profile/bind";
|
|
14
15
|
import type { ProfilePin } from "./profile/store";
|
|
15
|
-
import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
|
|
16
|
+
import type { DaemonStatus, DialogInfo, PageInfo, PageInfoResult, TabInfo } from "./cdp/types";
|
|
17
|
+
import { pageExpression } from "./util/js-source";
|
|
18
|
+
|
|
19
|
+
// REPL mode lets a later call declare the same const or let again, as the console does.
|
|
20
|
+
export const evaluateParams = (
|
|
21
|
+
expression: string,
|
|
22
|
+
): { readonly expression: string; readonly returnByValue: true; readonly awaitPromise: true; readonly replMode: true } => ({
|
|
23
|
+
expression: pageExpression(expression),
|
|
24
|
+
returnByValue: true,
|
|
25
|
+
awaitPromise: true,
|
|
26
|
+
replMode: true,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const CLOSE_ATTEMPTS = 2;
|
|
30
|
+
const CLOSE_CONFIRM_MS = 2_500;
|
|
31
|
+
const CLOSE_POLL_MS = 100;
|
|
16
32
|
|
|
17
33
|
export type BrowserClientOptions = {
|
|
18
34
|
readonly namespace: string;
|
|
@@ -24,6 +40,8 @@ export type BrowserClientOptions = {
|
|
|
24
40
|
readonly harnessWindowId?: number;
|
|
25
41
|
};
|
|
26
42
|
readonly profilePin?: ProfilePin | null;
|
|
43
|
+
// How long one close may take to show in Chrome's target list before it is sent again; tests shorten it.
|
|
44
|
+
readonly closeConfirmMs?: number;
|
|
27
45
|
readonly onOwnershipChange?: (snapshot: {
|
|
28
46
|
readonly ownedTargetIds: ReadonlyArray<string>;
|
|
29
47
|
readonly harnessWindowTargetId: string | undefined;
|
|
@@ -35,12 +53,14 @@ export type BrowserClient = {
|
|
|
35
53
|
readonly namespace: string;
|
|
36
54
|
ensureAlive(): Promise<Result<void, CdpError>>;
|
|
37
55
|
status(): DaemonStatus;
|
|
38
|
-
|
|
56
|
+
// `reconnectNow` is for explicit human demand (/browser-setup, /browser-reload-daemon): it asks the
|
|
57
|
+
// daemon to dial Chrome immediately even if it is cooling down after a failed attempt.
|
|
58
|
+
start(opts?: { readonly reconnectNow?: boolean }): Promise<Result<void, CdpError>>;
|
|
39
59
|
stop(): Promise<void>;
|
|
40
60
|
detach(): Promise<void>;
|
|
41
61
|
closeOwnedTabs(): Promise<void>;
|
|
42
62
|
evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>;
|
|
43
|
-
pageInfo(): Promise<Result<
|
|
63
|
+
pageInfo(): Promise<Result<PageInfoResult, CdpError>>;
|
|
44
64
|
takeDialog(): DialogInfo | null;
|
|
45
65
|
listTabs(includeInternal?: boolean): Promise<Result<ReadonlyArray<TabInfo>, CdpError>>;
|
|
46
66
|
switchTab(targetId: string): Promise<Result<void, CdpError>>;
|
|
@@ -123,7 +143,8 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
123
143
|
});
|
|
124
144
|
const mutationMutex = createMutex();
|
|
125
145
|
let lastHealth = 0;
|
|
126
|
-
let
|
|
146
|
+
let healthCheck: Promise<Result<void, CdpError>> | null = null;
|
|
147
|
+
const pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>();
|
|
127
148
|
let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null;
|
|
128
149
|
let userDataDir: string | undefined;
|
|
129
150
|
let profilePin: ProfilePin | null = opts.profilePin ?? null;
|
|
@@ -137,7 +158,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
137
158
|
return ok(undefined);
|
|
138
159
|
};
|
|
139
160
|
|
|
140
|
-
const start = async (): Promise<Result<void, CdpError>> => {
|
|
161
|
+
const start = async (opts: { readonly reconnectNow?: boolean } = {}): Promise<Result<void, CdpError>> => {
|
|
141
162
|
if (transport.state() === "open" && session.current()) return ok(undefined);
|
|
142
163
|
let wsUrl: string;
|
|
143
164
|
if (remote?.cdpUrl) {
|
|
@@ -150,9 +171,18 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
150
171
|
}
|
|
151
172
|
const connected = await transport.connect(wsUrl, { timeoutMs: 10_000 });
|
|
152
173
|
if (!connected.success) return connected;
|
|
174
|
+
if (opts.reconnectNow) {
|
|
175
|
+
// Only the daemon understands this; a direct Chrome transport answers "method not found",
|
|
176
|
+
// which is fine because it has no cooldown to clear.
|
|
177
|
+
const dialed = await transport.request(DAEMON_RECONNECT_METHOD, {}, { timeoutMs: 15_000 });
|
|
178
|
+
if (!dialed.success && dialed.error.kind !== "remote_error") {
|
|
179
|
+
await transport.close();
|
|
180
|
+
return dialed;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
153
183
|
|
|
154
184
|
const newBrowserId: string = wsUrl.split("/").pop() ?? "unknown";
|
|
155
|
-
if (
|
|
185
|
+
if (remote?.browserId !== newBrowserId) {
|
|
156
186
|
profileContextId = undefined;
|
|
157
187
|
}
|
|
158
188
|
remote = { cdpUrl: wsUrl, browserId: newBrowserId };
|
|
@@ -177,43 +207,62 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
177
207
|
lastHealth = 0;
|
|
178
208
|
};
|
|
179
209
|
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
210
|
+
const restart = async (): Promise<Result<void, CdpError>> => {
|
|
211
|
+
await stop();
|
|
212
|
+
return start();
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const reattach = async (): Promise<Result<void, CdpError>> => {
|
|
216
|
+
const seeded = await seedIfPinned();
|
|
217
|
+
if (!seeded.success) return seeded;
|
|
218
|
+
const reattached = await session.attachFirstPage();
|
|
219
|
+
if (reattached.success) {
|
|
220
|
+
pageCaches.clear();
|
|
221
|
+
return ok(undefined);
|
|
184
222
|
}
|
|
223
|
+
return restart();
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// Two probes: the browser one proves the socket works, the page one proves the current session does.
|
|
227
|
+
// Only transport-level failures (closed socket, timeout) mean the connection is dead. A page probe that
|
|
228
|
+
// fails for a page-level reason (navigation in flight, a throwing page) still proves the session routes,
|
|
229
|
+
// so it counts as alive; only a dead session id triggers a re-attach.
|
|
230
|
+
const checkHealth = async (): Promise<Result<void, CdpError>> => {
|
|
231
|
+
if (transport.state() !== "open" || !session.current()) return restart();
|
|
185
232
|
if (Date.now() - lastHealth < HEALTH_TTL_MS) return ok(undefined);
|
|
186
|
-
const probe = await
|
|
187
|
-
if (!probe.success)
|
|
188
|
-
await stop();
|
|
189
|
-
return start();
|
|
190
|
-
}
|
|
233
|
+
const probe = await session.callBrowser("Target.getTargets", {}, { timeoutMs: 2_000 });
|
|
234
|
+
if (!probe.success) return restart();
|
|
191
235
|
lastHealth = Date.now();
|
|
192
236
|
const jsProbe = await session.call("Runtime.evaluate", {
|
|
193
237
|
expression: "1", returnByValue: true,
|
|
194
238
|
}, { timeoutMs: 2_000 });
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
239
|
+
if (jsProbe.success) return ok(undefined);
|
|
240
|
+
switch (jsProbe.error.kind) {
|
|
241
|
+
case "session_not_found":
|
|
242
|
+
return reattach();
|
|
243
|
+
case "transport_closed":
|
|
244
|
+
case "timeout":
|
|
245
|
+
return restart();
|
|
246
|
+
case "discovery_failed":
|
|
247
|
+
case "invalid_response":
|
|
248
|
+
case "remote_error":
|
|
201
249
|
return ok(undefined);
|
|
202
|
-
}
|
|
203
|
-
await stop();
|
|
204
|
-
return start();
|
|
205
250
|
}
|
|
206
|
-
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// Concurrent callers share one in-flight check, so two tools racing after a disconnect cannot both restart the transport.
|
|
254
|
+
const ensureAlive = (): Promise<Result<void, CdpError>> => {
|
|
255
|
+
if (healthCheck) return healthCheck;
|
|
256
|
+
const running = checkHealth().finally(() => {
|
|
257
|
+
healthCheck = null;
|
|
258
|
+
});
|
|
259
|
+
healthCheck = running;
|
|
260
|
+
return running;
|
|
207
261
|
};
|
|
208
262
|
|
|
209
263
|
const evaluateJs = async (expression: string, sessionId?: string): Promise<Result<unknown, CdpError>> => {
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
const isReturnStatement = /^return[\s(]/.test(trimmed);
|
|
213
|
-
const wrapped = isReturnStatement ? `(function(){${expression}})()` : expression;
|
|
214
|
-
const r = sessionId
|
|
215
|
-
? await session.callOnTarget("Runtime.evaluate", { expression: wrapped, returnByValue: true, awaitPromise: true }, sessionId)
|
|
216
|
-
: await session.call("Runtime.evaluate", { expression: wrapped, returnByValue: true, awaitPromise: true });
|
|
264
|
+
const params = evaluateParams(expression);
|
|
265
|
+
const r = sessionId ? await session.callOnTarget("Runtime.evaluate", params, sessionId) : await session.call("Runtime.evaluate", params);
|
|
217
266
|
if (!r.success) return r;
|
|
218
267
|
if (r.data.exceptionDetails !== undefined) {
|
|
219
268
|
return err(cdpError("remote_error", `JS evaluation failed: ${JSON.stringify(r.data.exceptionDetails)}`, "Runtime.evaluate"));
|
|
@@ -236,10 +285,12 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
236
285
|
return ok(info.data);
|
|
237
286
|
};
|
|
238
287
|
|
|
239
|
-
const pageInfo = async (): Promise<Result<
|
|
288
|
+
const pageInfo = async (): Promise<Result<PageInfoResult, CdpError>> => {
|
|
240
289
|
const d = session.takeDialog();
|
|
241
|
-
if (d) return ok({ dialog: d });
|
|
242
|
-
|
|
290
|
+
if (d) return ok({ kind: "dialog", dialog: d });
|
|
291
|
+
const info = await readPageInfo();
|
|
292
|
+
if (!info.success) return info;
|
|
293
|
+
return ok({ kind: "page", ...info.data });
|
|
243
294
|
};
|
|
244
295
|
|
|
245
296
|
const listTabs = async (includeInternal = true): Promise<Result<ReadonlyArray<TabInfo>, CdpError>> => {
|
|
@@ -255,11 +306,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
255
306
|
owned: ownership.has(t.targetId),
|
|
256
307
|
}));
|
|
257
308
|
const live = new Set(tabs.map((t) => t.targetId));
|
|
258
|
-
|
|
259
|
-
const survivors = owned.filter((id) => live.has(id));
|
|
260
|
-
if (survivors.length !== owned.length) ownership.replaceAll(survivors);
|
|
261
|
-
const hw = ownership.harnessWindow();
|
|
262
|
-
if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
|
|
309
|
+
reconcileOwnership(ownership, live);
|
|
263
310
|
for (const tid of pageCaches.keys()) {
|
|
264
311
|
if (!live.has(tid)) pageCaches.delete(tid);
|
|
265
312
|
}
|
|
@@ -276,9 +323,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
276
323
|
};
|
|
277
324
|
|
|
278
325
|
const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
|
|
279
|
-
|
|
280
|
-
if (!tabsResult.success) return tabsResult;
|
|
281
|
-
|
|
326
|
+
// ensureHarnessWindow lists live targets and reconciles ownership itself, so no listing is needed here.
|
|
282
327
|
const window = await ensureHarnessWindow(api);
|
|
283
328
|
if (!window.success) return window;
|
|
284
329
|
let tabId: string;
|
|
@@ -299,17 +344,46 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
299
344
|
return ok(tabId);
|
|
300
345
|
};
|
|
301
346
|
|
|
347
|
+
// True once Chrome no longer lists the target, false if it still does at the deadline, undefined when the list
|
|
348
|
+
// cannot be read (then the close is taken at its word, as before).
|
|
349
|
+
const targetGone = async (targetId: string, timeoutMs: number): Promise<boolean | undefined> => {
|
|
350
|
+
const deadline = Date.now() + timeoutMs;
|
|
351
|
+
for (;;) {
|
|
352
|
+
const r = await session.callBrowser("Target.getTargets", {}, { timeoutMs: 2_000 });
|
|
353
|
+
if (!r.success) return undefined;
|
|
354
|
+
if (!r.data.targetInfos.some((t) => t.targetId === targetId)) return true;
|
|
355
|
+
if (Date.now() >= deadline) return false;
|
|
356
|
+
await new Promise((resolve) => setTimeout(resolve, CLOSE_POLL_MS));
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// Chrome answers Target.closeTarget with success even when it then keeps the tab: a tab closed while its page is
|
|
361
|
+
// still loading stays open. So the close is checked against Chrome's own target list and sent once more if the
|
|
362
|
+
// tab is still there; the tab leaves the owned list only once it is gone, so a tab left open is not lost track of.
|
|
302
363
|
const closeTab = async (targetId: string): Promise<Result<void, CdpError>> => {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
364
|
+
for (let attempt = 1; attempt <= CLOSE_ATTEMPTS; attempt++) {
|
|
365
|
+
const r = await session.callBrowser("Target.closeTarget", { targetId });
|
|
366
|
+
if (!r.success) return r;
|
|
367
|
+
const gone = await targetGone(targetId, opts.closeConfirmMs ?? CLOSE_CONFIRM_MS);
|
|
368
|
+
if (gone !== false) {
|
|
369
|
+
ownership.remove(targetId);
|
|
370
|
+
return ok(undefined);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return err(
|
|
374
|
+
cdpError(
|
|
375
|
+
"timeout",
|
|
376
|
+
`Chrome accepted the close but tab ${targetId} is still open after ${CLOSE_ATTEMPTS} attempts (a page that is still loading can ignore a close); wait for it to load, then close it again`,
|
|
377
|
+
"Target.closeTarget",
|
|
378
|
+
),
|
|
379
|
+
);
|
|
307
380
|
};
|
|
308
381
|
|
|
309
382
|
const closeOwnedTabs = async (): Promise<void> => {
|
|
310
383
|
for (const id of ownership.list()) {
|
|
311
|
-
await session.callBrowser("Target.closeTarget", { targetId: id })
|
|
312
|
-
|
|
384
|
+
const closed = await session.callBrowser("Target.closeTarget", { targetId: id });
|
|
385
|
+
// A remote error means the browser answered (typically "no target with given id"): the tab is gone either way. A transport failure means nothing was closed, so keep the record for the next attempt.
|
|
386
|
+
if (closed.success || closed.error.kind === "remote_error") ownership.remove(id);
|
|
313
387
|
}
|
|
314
388
|
ownership.setHarnessWindow(undefined);
|
|
315
389
|
ownership.setHarnessWindowId(undefined);
|
|
@@ -323,9 +397,8 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
323
397
|
});
|
|
324
398
|
|
|
325
399
|
const detach = async (): Promise<void> => {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null });
|
|
400
|
+
if (transport.state() !== "open") return;
|
|
401
|
+
await session.detachAll();
|
|
329
402
|
};
|
|
330
403
|
|
|
331
404
|
const api: BrowserClient = {
|
|
@@ -344,7 +417,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
344
417
|
setProfilePin: (pin) => {
|
|
345
418
|
const changed = pin === null
|
|
346
419
|
? profilePin !== null
|
|
347
|
-
: profilePin
|
|
420
|
+
: profilePin?.userDataDir !== pin.userDataDir || profilePin.profileDir !== pin.profileDir;
|
|
348
421
|
profilePin = pin;
|
|
349
422
|
if (changed) {
|
|
350
423
|
profileContextId = undefined;
|