pi-browser-harness 0.3.2 → 0.5.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 +45 -0
- package/README.md +150 -56
- package/package.json +1 -1
- package/skills/pi-browser-harness/SKILL.md +37 -248
- package/src/cdp/console-buffer.ts +263 -0
- package/src/cdp/network-buffer.ts +213 -0
- package/src/cdp/ownership.ts +72 -0
- package/src/cdp/session.ts +173 -26
- package/src/cdp/types.ts +1 -0
- package/src/client.ts +88 -12
- package/src/domains/click.ts +6 -5
- package/src/domains/console.ts +173 -0
- package/src/domains/js.ts +65 -6
- package/src/domains/navigate.ts +1 -1
- package/src/domains/network.ts +191 -20
- package/src/domains/screenshot.ts +8 -5
- package/src/domains/snapshot.ts +421 -0
- package/src/domains/tabs.ts +113 -39
- package/src/index.ts +17 -1
- package/src/prompt.ts +3 -0
- package/src/registry.ts +8 -3
- package/src/state.ts +6 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory aggregator for CDP Network.* events.
|
|
3
|
+
*
|
|
4
|
+
* The CDP event stream is consumed by a single internal loop in session.ts.
|
|
5
|
+
* That loop hands Network.* event params to this module, which joins them into
|
|
6
|
+
* one record per requestId (request → response → finished/failed). Tools then
|
|
7
|
+
* read records via drain(), filtered to what the caller asked for.
|
|
8
|
+
*
|
|
9
|
+
* Bounded by a fixed capacity (default 500): on overflow the oldest record is
|
|
10
|
+
* evicted FIFO. The overflow flag is reported once per drain so tools can warn
|
|
11
|
+
* the LLM that data may be missing.
|
|
12
|
+
*
|
|
13
|
+
* This module is pure (no CDP dependency) so the joining/filtering logic stays
|
|
14
|
+
* trivially testable in isolation.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type NetworkRecord = {
|
|
18
|
+
requestId: string;
|
|
19
|
+
method: string;
|
|
20
|
+
url: string;
|
|
21
|
+
status?: number;
|
|
22
|
+
statusText?: string;
|
|
23
|
+
type: string;
|
|
24
|
+
mimeType?: string;
|
|
25
|
+
/** Wall-clock ms when requestWillBeSent fired, in Date.now() units. */
|
|
26
|
+
requestStartedMs: number;
|
|
27
|
+
durationMs?: number;
|
|
28
|
+
requestBodySize: number;
|
|
29
|
+
responseBodySize?: number;
|
|
30
|
+
failed?: boolean;
|
|
31
|
+
errorText?: string;
|
|
32
|
+
/** Populated by the tool layer via Network.getResponseBody, never by the aggregator. */
|
|
33
|
+
body?: string | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type NetworkFilter = {
|
|
37
|
+
/** Substring match by default. Wrap in slashes (`/foo/`) for regex. */
|
|
38
|
+
urlPattern?: string;
|
|
39
|
+
methodFilter?: string[];
|
|
40
|
+
statusFilter?: { min?: number; max?: number };
|
|
41
|
+
resourceTypes?: string[];
|
|
42
|
+
/** Only requests whose start is at most N ms ago. */
|
|
43
|
+
sinceMs?: number;
|
|
44
|
+
/** Cap on the returned records. */
|
|
45
|
+
limit?: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type DrainResult = {
|
|
49
|
+
readonly records: NetworkRecord[];
|
|
50
|
+
/** Total matches before `limit` was applied. */
|
|
51
|
+
readonly total: number;
|
|
52
|
+
/** True if the ring buffer evicted at least one record since the last drain. */
|
|
53
|
+
readonly bufferOverflowed: boolean;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type NetworkBuffer = {
|
|
57
|
+
ingestRequestWillBeSent(p: unknown): void;
|
|
58
|
+
ingestResponseReceived(p: unknown): void;
|
|
59
|
+
ingestLoadingFinished(p: unknown): void;
|
|
60
|
+
ingestLoadingFailed(p: unknown): void;
|
|
61
|
+
drain(filter: NetworkFilter): DrainResult;
|
|
62
|
+
clear(): void;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const compileUrlMatcher = (pattern: string): ((url: string) => boolean) => {
|
|
66
|
+
if (pattern.length >= 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
|
|
67
|
+
try {
|
|
68
|
+
const re = new RegExp(pattern.slice(1, -1));
|
|
69
|
+
return (url) => re.test(url);
|
|
70
|
+
} catch {
|
|
71
|
+
// Fall through to substring match if the pattern is not a valid regex.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return (url) => url.includes(pattern);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
|
|
78
|
+
// Insertion-ordered map keyed by requestId. Map preserves insertion order in JS,
|
|
79
|
+
// so iteration gives us oldest-first eviction without a separate index.
|
|
80
|
+
const records = new Map<string, NetworkRecord>();
|
|
81
|
+
let overflowed = false;
|
|
82
|
+
|
|
83
|
+
const evictOldestIfFull = (): void => {
|
|
84
|
+
while (records.size >= capacity) {
|
|
85
|
+
const oldest = records.keys().next();
|
|
86
|
+
if (oldest.done) return;
|
|
87
|
+
records.delete(oldest.value);
|
|
88
|
+
overflowed = true;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
ingestRequestWillBeSent(p) {
|
|
94
|
+
// CDP boundary cast: Network.requestWillBeSent
|
|
95
|
+
const params = p as
|
|
96
|
+
| {
|
|
97
|
+
requestId?: string;
|
|
98
|
+
request?: {
|
|
99
|
+
url?: string;
|
|
100
|
+
method?: string;
|
|
101
|
+
postData?: string;
|
|
102
|
+
headers?: Record<string, string>;
|
|
103
|
+
};
|
|
104
|
+
type?: string;
|
|
105
|
+
timestamp?: number;
|
|
106
|
+
wallTime?: number;
|
|
107
|
+
}
|
|
108
|
+
| undefined;
|
|
109
|
+
const id = params?.requestId;
|
|
110
|
+
const url = params?.request?.url;
|
|
111
|
+
const method = params?.request?.method;
|
|
112
|
+
if (!id || !url || !method) return;
|
|
113
|
+
// Re-fired requestWillBeSent for the same id (e.g. redirect) — refresh in place.
|
|
114
|
+
records.delete(id);
|
|
115
|
+
evictOldestIfFull();
|
|
116
|
+
const postData = params.request?.postData;
|
|
117
|
+
records.set(id, {
|
|
118
|
+
requestId: id,
|
|
119
|
+
method,
|
|
120
|
+
url,
|
|
121
|
+
type: params.type ?? "Other",
|
|
122
|
+
requestStartedMs: Date.now(),
|
|
123
|
+
requestBodySize: postData ? Buffer.byteLength(postData, "utf8") : 0,
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
ingestResponseReceived(p) {
|
|
128
|
+
// CDP boundary cast: Network.responseReceived
|
|
129
|
+
const params = p as
|
|
130
|
+
| {
|
|
131
|
+
requestId?: string;
|
|
132
|
+
response?: {
|
|
133
|
+
status?: number;
|
|
134
|
+
statusText?: string;
|
|
135
|
+
mimeType?: string;
|
|
136
|
+
};
|
|
137
|
+
type?: string;
|
|
138
|
+
}
|
|
139
|
+
| undefined;
|
|
140
|
+
const id = params?.requestId;
|
|
141
|
+
if (!id) return;
|
|
142
|
+
const r = records.get(id);
|
|
143
|
+
if (!r) return;
|
|
144
|
+
if (params?.response?.status !== undefined) r.status = params.response.status;
|
|
145
|
+
if (params?.response?.statusText !== undefined && params.response.statusText !== "") r.statusText = params.response.statusText;
|
|
146
|
+
if (params?.response?.mimeType !== undefined) r.mimeType = params.response.mimeType;
|
|
147
|
+
// type is more accurate on responseReceived (CDP sometimes refines it)
|
|
148
|
+
if (params?.type !== undefined) r.type = params.type;
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
ingestLoadingFinished(p) {
|
|
152
|
+
// CDP boundary cast: Network.loadingFinished
|
|
153
|
+
const params = p as
|
|
154
|
+
| { requestId?: string; encodedDataLength?: number }
|
|
155
|
+
| undefined;
|
|
156
|
+
const id = params?.requestId;
|
|
157
|
+
if (!id) return;
|
|
158
|
+
const r = records.get(id);
|
|
159
|
+
if (!r) return;
|
|
160
|
+
if (params?.encodedDataLength !== undefined) r.responseBodySize = params.encodedDataLength;
|
|
161
|
+
r.durationMs = Date.now() - r.requestStartedMs;
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
ingestLoadingFailed(p) {
|
|
165
|
+
// CDP boundary cast: Network.loadingFailed
|
|
166
|
+
const params = p as
|
|
167
|
+
| { requestId?: string; errorText?: string; canceled?: boolean }
|
|
168
|
+
| undefined;
|
|
169
|
+
const id = params?.requestId;
|
|
170
|
+
if (!id) return;
|
|
171
|
+
const r = records.get(id);
|
|
172
|
+
if (!r) return;
|
|
173
|
+
r.failed = true;
|
|
174
|
+
if (params?.errorText !== undefined) r.errorText = params.errorText;
|
|
175
|
+
r.durationMs = Date.now() - r.requestStartedMs;
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
drain(filter) {
|
|
179
|
+
const matchUrl = filter.urlPattern !== undefined ? compileUrlMatcher(filter.urlPattern) : undefined;
|
|
180
|
+
const methods = filter.methodFilter ? new Set(filter.methodFilter.map((m) => m.toUpperCase())) : undefined;
|
|
181
|
+
const types = filter.resourceTypes ? new Set(filter.resourceTypes.map((t) => t.toLowerCase())) : undefined;
|
|
182
|
+
const minStatus = filter.statusFilter?.min;
|
|
183
|
+
const maxStatus = filter.statusFilter?.max;
|
|
184
|
+
const cutoff = filter.sinceMs !== undefined ? Date.now() - filter.sinceMs : undefined;
|
|
185
|
+
|
|
186
|
+
const matched: NetworkRecord[] = [];
|
|
187
|
+
for (const r of records.values()) {
|
|
188
|
+
if (matchUrl && !matchUrl(r.url)) continue;
|
|
189
|
+
if (methods && !methods.has(r.method.toUpperCase())) continue;
|
|
190
|
+
if (types && !types.has(r.type.toLowerCase())) continue;
|
|
191
|
+
if (cutoff !== undefined && r.requestStartedMs < cutoff) continue;
|
|
192
|
+
if (minStatus !== undefined && (r.status === undefined || r.status < minStatus)) continue;
|
|
193
|
+
if (maxStatus !== undefined && (r.status === undefined || r.status > maxStatus)) continue;
|
|
194
|
+
// Clone so callers can mutate (e.g. attach body) without polluting the buffer.
|
|
195
|
+
matched.push({ ...r });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const total = matched.length;
|
|
199
|
+
const limit = Math.min(filter.limit ?? 50, 500);
|
|
200
|
+
const limited = matched.slice(-limit); // most recent N
|
|
201
|
+
|
|
202
|
+
const bufferOverflowed = overflowed;
|
|
203
|
+
overflowed = false;
|
|
204
|
+
|
|
205
|
+
return { records: limited, total, bufferOverflowed };
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
clear() {
|
|
209
|
+
records.clear();
|
|
210
|
+
overflowed = false;
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks which tab targetIds belong to this harness session.
|
|
3
|
+
*
|
|
4
|
+
* The harness attaches to the user's running Chrome, which is also full of
|
|
5
|
+
* the user's own tabs. Without an ownership boundary the agent could attach
|
|
6
|
+
* to (or close) personal tabs by accident. The registry is the single source
|
|
7
|
+
* of truth for "did this session open that tab?".
|
|
8
|
+
*
|
|
9
|
+
* Mutations notify a single change listener so the orchestrator can persist
|
|
10
|
+
* to disk via the existing BrowserState flow without the registry needing to
|
|
11
|
+
* know about the pi storage API.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type OwnershipRegistry = {
|
|
15
|
+
add(targetId: string): void;
|
|
16
|
+
remove(targetId: string): void;
|
|
17
|
+
has(targetId: string): boolean;
|
|
18
|
+
list(): ReadonlyArray<string>;
|
|
19
|
+
/** Replace the full set — used on restoreState to re-hydrate from disk. */
|
|
20
|
+
replaceAll(ids: ReadonlyArray<string>): void;
|
|
21
|
+
setHarnessWindow(targetId: string | undefined): void;
|
|
22
|
+
harnessWindow(): string | undefined;
|
|
23
|
+
onChange(cb: () => void): void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const createOwnershipRegistry = (
|
|
27
|
+
initial?: { readonly ownedTargetIds?: ReadonlyArray<string>; readonly harnessWindowTargetId?: string },
|
|
28
|
+
): OwnershipRegistry => {
|
|
29
|
+
const owned = new Set<string>(initial?.ownedTargetIds ?? []);
|
|
30
|
+
let harnessWindow: string | undefined = initial?.harnessWindowTargetId;
|
|
31
|
+
let listener: (() => void) | null = null;
|
|
32
|
+
|
|
33
|
+
const notify = (): void => {
|
|
34
|
+
if (listener) listener();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
add(targetId) {
|
|
39
|
+
if (owned.has(targetId)) return;
|
|
40
|
+
owned.add(targetId);
|
|
41
|
+
notify();
|
|
42
|
+
},
|
|
43
|
+
remove(targetId) {
|
|
44
|
+
if (!owned.has(targetId) && harnessWindow !== targetId) return;
|
|
45
|
+
owned.delete(targetId);
|
|
46
|
+
if (harnessWindow === targetId) harnessWindow = undefined;
|
|
47
|
+
notify();
|
|
48
|
+
},
|
|
49
|
+
has(targetId) {
|
|
50
|
+
return owned.has(targetId);
|
|
51
|
+
},
|
|
52
|
+
list() {
|
|
53
|
+
return [...owned];
|
|
54
|
+
},
|
|
55
|
+
replaceAll(ids) {
|
|
56
|
+
owned.clear();
|
|
57
|
+
for (const id of ids) owned.add(id);
|
|
58
|
+
notify();
|
|
59
|
+
},
|
|
60
|
+
setHarnessWindow(targetId) {
|
|
61
|
+
if (harnessWindow === targetId) return;
|
|
62
|
+
harnessWindow = targetId;
|
|
63
|
+
notify();
|
|
64
|
+
},
|
|
65
|
+
harnessWindow() {
|
|
66
|
+
return harnessWindow;
|
|
67
|
+
},
|
|
68
|
+
onChange(cb) {
|
|
69
|
+
listener = cb;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
};
|
package/src/cdp/session.ts
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
|
-
import { type Result,
|
|
2
|
-
import {
|
|
1
|
+
import { type Result, ok } from "../util/result";
|
|
2
|
+
import type { CdpError } from "./errors";
|
|
3
3
|
import type { DialogInfo } from "./types";
|
|
4
|
-
import {
|
|
4
|
+
import type { OwnershipRegistry } from "./ownership";
|
|
5
5
|
import type { CdpTransport } from "./transport";
|
|
6
|
+
import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer";
|
|
7
|
+
import { createConsoleBuffer, type ConsoleDrainResult, type ConsoleFilter } from "./console-buffer";
|
|
8
|
+
|
|
9
|
+
// Per-tab state. One TabSession exists per known targetId.
|
|
10
|
+
// Dialog, page-info dirty flag, and CDP buffers are per-tab — switching
|
|
11
|
+
// tabs preserves the previous tab's data in its TabSession instead of
|
|
12
|
+
// clearing it.
|
|
13
|
+
type TabSession = {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
targetId: string;
|
|
16
|
+
dialog: DialogInfo | null;
|
|
17
|
+
pageInfoDirty: boolean;
|
|
18
|
+
networkBuffer: ReturnType<typeof createNetworkBuffer>;
|
|
19
|
+
consoleBuffer: ReturnType<typeof createConsoleBuffer>;
|
|
20
|
+
};
|
|
6
21
|
|
|
7
22
|
export type CdpSession = {
|
|
8
23
|
attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
|
|
@@ -13,21 +28,43 @@ export type CdpSession = {
|
|
|
13
28
|
callBrowser(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
|
|
14
29
|
takeDialog(): DialogInfo | null;
|
|
15
30
|
drainPageInfoInvalidations(): boolean;
|
|
31
|
+
drainNetworkBuffer(filter: NetworkFilter): DrainResult;
|
|
32
|
+
drainConsoleBuffer(filter: ConsoleFilter): ConsoleDrainResult;
|
|
16
33
|
};
|
|
17
34
|
|
|
18
|
-
export const createCdpSession = (
|
|
35
|
+
export const createCdpSession = (
|
|
36
|
+
transport: CdpTransport,
|
|
37
|
+
ownership?: OwnershipRegistry,
|
|
38
|
+
): CdpSession => {
|
|
19
39
|
let sessionId: string | null = null;
|
|
20
40
|
let targetId: string | null = null;
|
|
21
|
-
|
|
22
|
-
|
|
41
|
+
|
|
42
|
+
// Per-tab tracking: maps targetId → TabSession and sessionId → targetId for
|
|
43
|
+
// event routing. Lazy: a TabSession is created on the first visit to a tab.
|
|
44
|
+
const tabs = new Map<string, TabSession>();
|
|
45
|
+
const sessionIdToTargetId = new Map<string, string>();
|
|
23
46
|
|
|
24
47
|
let activeConsumer: Promise<void> = Promise.resolve();
|
|
25
48
|
|
|
49
|
+
// Event → TabSession resolver. Uses sessionId from CDP events to find the
|
|
50
|
+
// correct TabSession, falling back to the current targetId when no sessionId.
|
|
51
|
+
const resolveTab = (evSessionId?: string): TabSession | undefined => {
|
|
52
|
+
const tid = evSessionId ? sessionIdToTargetId.get(evSessionId) : targetId;
|
|
53
|
+
return tid ? tabs.get(tid) : undefined;
|
|
54
|
+
};
|
|
55
|
+
|
|
26
56
|
const consumeEvents = async (): Promise<void> => {
|
|
27
57
|
for await (const ev of transport.events()) {
|
|
58
|
+
// Filter: skip events from sessions we're not currently tracking.
|
|
59
|
+
// Target.targetDestroyed is browser-level (no sessionId) — always process.
|
|
60
|
+
if (ev.method !== "Target.targetDestroyed") {
|
|
61
|
+
if (ev.sessionId && ev.sessionId !== sessionId) continue;
|
|
62
|
+
}
|
|
28
63
|
if (ev.method === "Page.javascriptDialogOpening") {
|
|
64
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
65
|
+
if (!tab) continue;
|
|
29
66
|
const params = ev.params as Partial<DialogInfo> | undefined;
|
|
30
|
-
dialog = {
|
|
67
|
+
tab.dialog = {
|
|
31
68
|
type: (params?.type as DialogInfo["type"]) ?? "alert",
|
|
32
69
|
message: params?.message ?? "",
|
|
33
70
|
...(params?.defaultPrompt !== undefined ? { defaultPrompt: params.defaultPrompt } : {}),
|
|
@@ -39,7 +76,39 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => {
|
|
|
39
76
|
// This prevents fast dismiss flows from dropping a dialog the agent
|
|
40
77
|
// was about to read. (Fix for spec §7 predictability bug #2.)
|
|
41
78
|
if (ev.method === "Page.frameNavigated" || ev.method === "Page.loadEventFired") {
|
|
42
|
-
|
|
79
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
80
|
+
if (tab) tab.pageInfoDirty = true;
|
|
81
|
+
}
|
|
82
|
+
if (ev.method === "Target.targetDestroyed" && ownership) {
|
|
83
|
+
const params = ev.params as { targetId?: string } | undefined;
|
|
84
|
+
if (params?.targetId) {
|
|
85
|
+
ownership.remove(params.targetId);
|
|
86
|
+
// Prune per-tab state for the destroyed target
|
|
87
|
+
const tab = tabs.get(params.targetId);
|
|
88
|
+
if (tab) {
|
|
89
|
+
sessionIdToTargetId.delete(tab.sessionId);
|
|
90
|
+
tabs.delete(params.targetId);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (ev.method === "Network.requestWillBeSent") {
|
|
95
|
+
const tab = resolveTab(ev.sessionId);
|
|
96
|
+
if (tab) tab.networkBuffer.ingestRequestWillBeSent(ev.params);
|
|
97
|
+
} else if (ev.method === "Network.responseReceived") {
|
|
98
|
+
const tab = resolveTab(ev.sessionId);
|
|
99
|
+
if (tab) tab.networkBuffer.ingestResponseReceived(ev.params);
|
|
100
|
+
} else if (ev.method === "Network.loadingFinished") {
|
|
101
|
+
const tab = resolveTab(ev.sessionId);
|
|
102
|
+
if (tab) tab.networkBuffer.ingestLoadingFinished(ev.params);
|
|
103
|
+
} else if (ev.method === "Network.loadingFailed") {
|
|
104
|
+
const tab = resolveTab(ev.sessionId);
|
|
105
|
+
if (tab) tab.networkBuffer.ingestLoadingFailed(ev.params);
|
|
106
|
+
} else if (ev.method === "Runtime.consoleAPICalled") {
|
|
107
|
+
const tab = resolveTab(ev.sessionId);
|
|
108
|
+
if (tab) tab.consoleBuffer.ingestConsoleApi(ev.params);
|
|
109
|
+
} else if (ev.method === "Log.entryAdded") {
|
|
110
|
+
const tab = resolveTab(ev.sessionId);
|
|
111
|
+
if (tab) tab.consoleBuffer.ingestLogEntry(ev.params);
|
|
43
112
|
}
|
|
44
113
|
}
|
|
45
114
|
};
|
|
@@ -58,7 +127,8 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => {
|
|
|
58
127
|
transport.onClose(() => {
|
|
59
128
|
sessionId = null;
|
|
60
129
|
targetId = null;
|
|
61
|
-
|
|
130
|
+
tabs.clear();
|
|
131
|
+
sessionIdToTargetId.clear();
|
|
62
132
|
// Do NOT clear `dialog` here — same rationale as inside consumeEvents:
|
|
63
133
|
// the agent may have a pending takeDialog() call that should still see it.
|
|
64
134
|
restartConsumer();
|
|
@@ -68,7 +138,7 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => {
|
|
|
68
138
|
// Switching to Promise.all over a single WS pipelines the round-trips and
|
|
69
139
|
// saves ~3× on tab-switch latency. Defer until session.ts has tests.
|
|
70
140
|
const enableDomains = async (sid: string): Promise<void> => {
|
|
71
|
-
for (const d of ["Page", "DOM", "Runtime", "Network"]) {
|
|
141
|
+
for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility", "Log"]) {
|
|
72
142
|
await transport.request(`${d}.enable`, {}, { sessionId: sid });
|
|
73
143
|
}
|
|
74
144
|
};
|
|
@@ -81,25 +151,69 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => {
|
|
|
81
151
|
// as a clear Error in normal use.
|
|
82
152
|
return {
|
|
83
153
|
async attachFirstPage() {
|
|
154
|
+
// Subscribe to Target.* events so we can react to targetDestroyed.
|
|
155
|
+
// Best-effort: failing to enable discovery is not fatal for attach.
|
|
156
|
+
await transport.request("Target.setDiscoverTargets", { discover: true }, { sessionId: null });
|
|
157
|
+
|
|
84
158
|
const targets = await transport.request("Target.getTargets", {}, { sessionId: null });
|
|
85
159
|
if (!targets.success) return targets;
|
|
86
160
|
const data = targets.data as { targetInfos: ReadonlyArray<{ targetId: string; type: string; url: string }> };
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
161
|
+
const allPages = data.targetInfos.filter((t) => t.type === "page");
|
|
162
|
+
// Reconcile the persisted ownership set against live targets — drop dead IDs.
|
|
163
|
+
if (ownership) {
|
|
164
|
+
const live = new Set(allPages.map((p) => p.targetId));
|
|
165
|
+
const survivors = ownership.list().filter((id) => live.has(id));
|
|
166
|
+
if (survivors.length !== ownership.list().length) ownership.replaceAll(survivors);
|
|
167
|
+
const hw = ownership.harnessWindow();
|
|
168
|
+
if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
|
|
169
|
+
}
|
|
170
|
+
// Prune per-tab state for pages that no longer exist
|
|
171
|
+
const liveTargetIds = new Set(allPages.map((p) => p.targetId));
|
|
172
|
+
for (const tid of tabs.keys()) {
|
|
173
|
+
if (!liveTargetIds.has(tid)) {
|
|
174
|
+
const tab = tabs.get(tid);
|
|
175
|
+
if (tab) sessionIdToTargetId.delete(tab.sessionId);
|
|
176
|
+
tabs.delete(tid);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Prefer attaching to a tab this session already owns. Falls back to
|
|
181
|
+
// creating a fresh harness-owned tab in a dedicated window — never
|
|
182
|
+
// grabs the user's foreground tab.
|
|
183
|
+
let pickTargetId: string | undefined;
|
|
184
|
+
if (ownership) {
|
|
185
|
+
const ownedLive = ownership.list().filter((id) => allPages.some((p) => p.targetId === id));
|
|
186
|
+
pickTargetId = ownedLive[0];
|
|
187
|
+
}
|
|
188
|
+
if (!pickTargetId) {
|
|
189
|
+
const createParams: Record<string, unknown> = { url: "about:blank" };
|
|
190
|
+
if (ownership) createParams["newWindow"] = true;
|
|
191
|
+
const created = await transport.request("Target.createTarget", createParams, { sessionId: null });
|
|
90
192
|
if (!created.success) return created;
|
|
91
193
|
const c = created.data as { targetId: string };
|
|
92
|
-
|
|
194
|
+
pickTargetId = c.targetId;
|
|
195
|
+
if (ownership) {
|
|
196
|
+
ownership.setHarnessWindow(c.targetId);
|
|
197
|
+
ownership.add(c.targetId);
|
|
198
|
+
}
|
|
93
199
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const attached = await transport.request("Target.attachToTarget", { targetId: first.targetId, flatten: true }, { sessionId: null });
|
|
200
|
+
|
|
201
|
+
const attached = await transport.request("Target.attachToTarget", { targetId: pickTargetId, flatten: true }, { sessionId: null });
|
|
97
202
|
if (!attached.success) return attached;
|
|
98
203
|
const a = attached.data as { sessionId: string };
|
|
99
204
|
sessionId = a.sessionId;
|
|
100
|
-
targetId =
|
|
205
|
+
targetId = pickTargetId;
|
|
101
206
|
await enableDomains(a.sessionId);
|
|
102
|
-
|
|
207
|
+
tabs.set(pickTargetId, {
|
|
208
|
+
sessionId: a.sessionId,
|
|
209
|
+
targetId: pickTargetId,
|
|
210
|
+
dialog: null,
|
|
211
|
+
pageInfoDirty: false,
|
|
212
|
+
networkBuffer: createNetworkBuffer(),
|
|
213
|
+
consoleBuffer: createConsoleBuffer(),
|
|
214
|
+
});
|
|
215
|
+
sessionIdToTargetId.set(a.sessionId, pickTargetId);
|
|
216
|
+
return ok({ targetId: pickTargetId, sessionId: a.sessionId });
|
|
103
217
|
},
|
|
104
218
|
async switchTo(tid) {
|
|
105
219
|
const activated = await transport.request("Target.activateTarget", { targetId: tid }, { sessionId: null });
|
|
@@ -107,10 +221,29 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => {
|
|
|
107
221
|
const attached = await transport.request("Target.attachToTarget", { targetId: tid, flatten: true }, { sessionId: null });
|
|
108
222
|
if (!attached.success) return attached;
|
|
109
223
|
const a = attached.data as { sessionId: string };
|
|
110
|
-
|
|
224
|
+
// Reuse existing TabSession or create one on first visit
|
|
225
|
+
const existing = tabs.get(tid);
|
|
226
|
+
const tab: TabSession = existing ?? {
|
|
227
|
+
sessionId: a.sessionId,
|
|
228
|
+
targetId: tid,
|
|
229
|
+
dialog: null,
|
|
230
|
+
pageInfoDirty: true,
|
|
231
|
+
networkBuffer: createNetworkBuffer(),
|
|
232
|
+
consoleBuffer: createConsoleBuffer(),
|
|
233
|
+
};
|
|
234
|
+
if (existing) {
|
|
235
|
+
// Each Target.attachToTarget produces a new sessionId — update it.
|
|
236
|
+
sessionIdToTargetId.delete(existing.sessionId);
|
|
237
|
+
existing.sessionId = a.sessionId;
|
|
238
|
+
sessionIdToTargetId.set(a.sessionId, tid);
|
|
239
|
+
} else {
|
|
240
|
+
tabs.set(tid, tab);
|
|
241
|
+
await enableDomains(a.sessionId);
|
|
242
|
+
sessionIdToTargetId.set(a.sessionId, tid);
|
|
243
|
+
}
|
|
244
|
+
// Update global pointers to point at the new active tab
|
|
245
|
+
sessionId = tab.sessionId;
|
|
111
246
|
targetId = tid;
|
|
112
|
-
pageInfoDirty = true;
|
|
113
|
-
await enableDomains(a.sessionId);
|
|
114
247
|
return ok(undefined);
|
|
115
248
|
},
|
|
116
249
|
current() {
|
|
@@ -126,14 +259,28 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => {
|
|
|
126
259
|
return transport.request(method, params, { sessionId: null, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
|
|
127
260
|
},
|
|
128
261
|
takeDialog() {
|
|
129
|
-
const
|
|
130
|
-
|
|
262
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
263
|
+
if (!tab) return null;
|
|
264
|
+
const d = tab.dialog;
|
|
265
|
+
tab.dialog = null;
|
|
131
266
|
return d;
|
|
132
267
|
},
|
|
133
268
|
drainPageInfoInvalidations() {
|
|
134
|
-
const
|
|
135
|
-
|
|
269
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
270
|
+
if (!tab) return false;
|
|
271
|
+
const dirty = tab.pageInfoDirty;
|
|
272
|
+
tab.pageInfoDirty = false;
|
|
136
273
|
return dirty;
|
|
137
274
|
},
|
|
275
|
+
drainNetworkBuffer(filter) {
|
|
276
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
277
|
+
if (!tab) return { records: [], total: 0, bufferOverflowed: false };
|
|
278
|
+
return tab.networkBuffer.drain(filter);
|
|
279
|
+
},
|
|
280
|
+
drainConsoleBuffer(filter) {
|
|
281
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
282
|
+
if (!tab) return { records: [], total: 0, bufferOverflowed: false };
|
|
283
|
+
return tab.consoleBuffer.drain(filter);
|
|
284
|
+
},
|
|
138
285
|
};
|
|
139
286
|
};
|