@testmuai/playwright-bindings 0.1.17 → 0.1.19-beta.1
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/dist/helpers/assertion.d.ts.map +1 -1
- package/dist/helpers/assertion.js +6 -24
- package/dist/helpers/assertion.js.map +1 -1
- package/dist/helpers/devtools.d.ts +13 -1
- package/dist/helpers/devtools.d.ts.map +1 -1
- package/dist/helpers/devtools.js +454 -17
- package/dist/helpers/devtools.js.map +1 -1
- package/dist/helpers/evaluation/colors.d.ts +2 -0
- package/dist/helpers/evaluation/colors.d.ts.map +1 -0
- package/dist/helpers/evaluation/colors.js +151 -0
- package/dist/helpers/evaluation/colors.js.map +1 -0
- package/dist/helpers/evaluation/evaluate.d.ts +8 -0
- package/dist/helpers/evaluation/evaluate.d.ts.map +1 -0
- package/dist/helpers/evaluation/evaluate.js +217 -0
- package/dist/helpers/evaluation/evaluate.js.map +1 -0
- package/dist/helpers/sseParser.d.ts +34 -0
- package/dist/helpers/sseParser.d.ts.map +1 -0
- package/dist/helpers/sseParser.js +128 -0
- package/dist/helpers/sseParser.js.map +1 -0
- package/dist/session.d.ts +8 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +51 -19
- package/dist/session.js.map +1 -1
- package/package.json +1 -1
package/dist/helpers/devtools.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ClipboardStore, installClipboard, pasteViaPage, runClipboardQuery } from './clipboard.js';
|
|
2
2
|
import * as configure from '../configure.js';
|
|
3
3
|
import { log } from '../log.js';
|
|
4
|
+
import { SSEWireParser } from './sseParser.js';
|
|
4
5
|
// ── State ──
|
|
5
6
|
let networkEntries = [];
|
|
6
7
|
let networkSequence = 0;
|
|
@@ -14,14 +15,29 @@ let wsActive = false;
|
|
|
14
15
|
let wsConfigured = false; // true once WS capture was started this run
|
|
15
16
|
let wsSeq = 0; // global monotonic across all connections
|
|
16
17
|
let wsConnCounter = 0; // connection-id allocator
|
|
18
|
+
// SSE capture (CDP-based, Chromium-only). CDP sessions are NOT auto-managed
|
|
19
|
+
// like page.on — keep a page->CDPSession registry and detach each on stop.
|
|
20
|
+
let sseConnections = [];
|
|
21
|
+
let sseActive = false;
|
|
22
|
+
let sseConfigured = false; // true once SSE capture was started this run
|
|
23
|
+
let sseSeq = 0; // global monotonic across all connections
|
|
24
|
+
let sseConnCounter = 0; // connection-id allocator
|
|
25
|
+
const sseMsgCounter = new Map(); // connectionId -> per-conn messageIndex
|
|
26
|
+
const sseSessions = new Map(); // the page->session registry
|
|
27
|
+
const sseTasks = new Set(); // pending scheduled tasks (response/popup)
|
|
17
28
|
let pageRef = null;
|
|
18
29
|
let contextRef = null;
|
|
30
|
+
let pageListenerActive = false; // true once context.on('page') is wired this run
|
|
31
|
+
let extraPages = []; // tabs opened mid-run that got capture listeners
|
|
19
32
|
const MAX_NETWORK = 5000;
|
|
20
33
|
const MAX_BODY = 65536;
|
|
21
34
|
const MAX_CONSOLE = 50000;
|
|
22
35
|
const MAX_WS_CONN = 256;
|
|
23
36
|
const MAX_WS_FRAMES = 2000;
|
|
24
37
|
const WS_PRESERVE_FIRST_N = 20;
|
|
38
|
+
const MAX_SSE_CONN = 256;
|
|
39
|
+
const MAX_SSE_MSGS = 2000;
|
|
40
|
+
const SSE_PRESERVE_FIRST_N = 20;
|
|
25
41
|
// Raw-byte budget for a binary frame so its base64 stays <= MAX_BODY and decodes cleanly
|
|
26
42
|
// (multiple of 3 → no mid-stream base64 padding). Math.floor(65536/4)*3 = 49152. Matches Python.
|
|
27
43
|
const MAX_WS_RAW_BUDGET = Math.floor(MAX_BODY / 4) * 3;
|
|
@@ -47,6 +63,19 @@ export async function startCapture(page, context) {
|
|
|
47
63
|
startConsoleCapture(page);
|
|
48
64
|
if (devtools.websocket)
|
|
49
65
|
startWebSocketCapture(page);
|
|
66
|
+
if (devtools.sse) {
|
|
67
|
+
// SSE rides a CDP session (Chromium-only); attach is async. The
|
|
68
|
+
// non-Chromium hard-fail lives in session.ts run() BEFORE this runs.
|
|
69
|
+
await startSSECaptureInitial(page);
|
|
70
|
+
}
|
|
71
|
+
// Page-based captures (network/console/websocket/sse) only listen on the page
|
|
72
|
+
// they were attached to. Re-arm them on tabs opened later (window.open,
|
|
73
|
+
// target=_blank) via the context's 'page' event — otherwise those tabs are
|
|
74
|
+
// silently uncaptured. (performance/clipboard ride context init scripts and
|
|
75
|
+
// already cover every page.)
|
|
76
|
+
if (devtools.network || devtools.console || devtools.websocket || devtools.sse) {
|
|
77
|
+
attachContextPageListener(context);
|
|
78
|
+
}
|
|
50
79
|
if (devtools.performance)
|
|
51
80
|
await installPerformance(context);
|
|
52
81
|
const enabled = Object.entries(devtools).filter(([, v]) => v).map(([k]) => k);
|
|
@@ -54,14 +83,86 @@ export async function startCapture(page, context) {
|
|
|
54
83
|
log.test(`DevTools capture started: ${enabled.join(', ')}`);
|
|
55
84
|
}
|
|
56
85
|
export function stopCapture(page) {
|
|
86
|
+
detachContextPageListener();
|
|
57
87
|
if (networkActive)
|
|
58
88
|
stopNetworkCapture(page);
|
|
59
89
|
if (consoleActive)
|
|
60
90
|
stopConsoleCapture(page);
|
|
61
91
|
if (wsActive)
|
|
62
92
|
stopWebSocketCapture(page);
|
|
93
|
+
// SSE CDP sessions are detached asynchronously by stopSSECapture(); this sync
|
|
94
|
+
// stop only flips sseActive off (so handlers short-circuit). The context
|
|
95
|
+
// 'page' listener is removed above, so no new popup-attach task is scheduled
|
|
96
|
+
// before stopSSECapture() drains and detaches.
|
|
97
|
+
sseActive = false;
|
|
98
|
+
// The per-type stops above only detach the initial page; also detach the
|
|
99
|
+
// tabs opened mid-run so no listener outlives the capture session.
|
|
100
|
+
detachExtraPages();
|
|
101
|
+
}
|
|
102
|
+
// ── New-page propagation — capture tabs opened after startCapture ──
|
|
103
|
+
function attachContextPageListener(context) {
|
|
104
|
+
if (pageListenerActive || !context)
|
|
105
|
+
return;
|
|
106
|
+
try {
|
|
107
|
+
context.on('page', onNewPage);
|
|
108
|
+
pageListenerActive = true;
|
|
109
|
+
}
|
|
110
|
+
catch { /* ignore */ }
|
|
111
|
+
}
|
|
112
|
+
function detachContextPageListener() {
|
|
113
|
+
if (pageListenerActive && contextRef) {
|
|
114
|
+
try {
|
|
115
|
+
contextRef.removeListener('page', onNewPage);
|
|
116
|
+
}
|
|
117
|
+
catch { /* ignore */ }
|
|
118
|
+
}
|
|
119
|
+
pageListenerActive = false;
|
|
120
|
+
}
|
|
121
|
+
function detachExtraPages() {
|
|
122
|
+
// Removing a listener that was never attached (only network enabled, say) is
|
|
123
|
+
// a safe no-op.
|
|
124
|
+
for (const p of extraPages) {
|
|
125
|
+
detachNetworkListeners(p);
|
|
126
|
+
detachConsoleListeners(p);
|
|
127
|
+
detachWebSocketListeners(p);
|
|
128
|
+
}
|
|
129
|
+
extraPages = [];
|
|
130
|
+
}
|
|
131
|
+
function onNewPage(page) {
|
|
132
|
+
// Attach the currently-active capture listeners to a newly opened page.
|
|
133
|
+
// Attach-only — never resets accumulated state (which the start* helpers do).
|
|
134
|
+
// Each capture type is guarded independently so one failure can't skip the rest.
|
|
135
|
+
let attached = false;
|
|
136
|
+
for (const [active, attach] of [
|
|
137
|
+
[networkActive, attachNetworkListeners],
|
|
138
|
+
[consoleActive, attachConsoleListeners],
|
|
139
|
+
[wsActive, attachWebSocketListeners],
|
|
140
|
+
]) {
|
|
141
|
+
if (!active)
|
|
142
|
+
continue;
|
|
143
|
+
try {
|
|
144
|
+
attach(page);
|
|
145
|
+
attached = true;
|
|
146
|
+
}
|
|
147
|
+
catch { /* ignore */ }
|
|
148
|
+
}
|
|
149
|
+
// SSE attach is async (own CDP session) — schedule it as a tracked task.
|
|
150
|
+
// The session is registered in sseSessions and detached by stopSSECapture.
|
|
151
|
+
if (sseActive) {
|
|
152
|
+
try {
|
|
153
|
+
schedule(attachSseToPage(page));
|
|
154
|
+
}
|
|
155
|
+
catch { /* ignore */ }
|
|
156
|
+
}
|
|
157
|
+
if (attached && !extraPages.includes(page))
|
|
158
|
+
extraPages.push(page);
|
|
63
159
|
}
|
|
64
160
|
export function resetCapture() {
|
|
161
|
+
// Remove any live listeners on the (possibly reused) context/tabs before
|
|
162
|
+
// dropping our refs, so a later startCapture can't double-register them.
|
|
163
|
+
// detachContextPageListener() also clears pageListenerActive.
|
|
164
|
+
detachContextPageListener();
|
|
165
|
+
detachExtraPages();
|
|
65
166
|
networkEntries = [];
|
|
66
167
|
networkSequence = 0;
|
|
67
168
|
networkActive = false;
|
|
@@ -75,6 +176,17 @@ export function resetCapture() {
|
|
|
75
176
|
wsConnCounter = 0;
|
|
76
177
|
wsById.clear();
|
|
77
178
|
wsFrameCounter.clear();
|
|
179
|
+
// SSE: best-effort sync reset for test isolation. Live CDP sessions should be
|
|
180
|
+
// detached via stopSSECapture() in the async teardown; here we drop refs so a
|
|
181
|
+
// fresh run starts clean.
|
|
182
|
+
sseConnections = [];
|
|
183
|
+
sseActive = false;
|
|
184
|
+
sseConfigured = false;
|
|
185
|
+
sseSeq = 0;
|
|
186
|
+
sseConnCounter = 0;
|
|
187
|
+
sseMsgCounter.clear();
|
|
188
|
+
sseSessions.clear();
|
|
189
|
+
sseTasks.clear();
|
|
78
190
|
pendingRequests.clear();
|
|
79
191
|
pageRef = null;
|
|
80
192
|
contextRef = null;
|
|
@@ -84,13 +196,15 @@ function startNetworkCapture(page) {
|
|
|
84
196
|
networkEntries = [];
|
|
85
197
|
networkSequence = 0;
|
|
86
198
|
networkActive = true;
|
|
199
|
+
attachNetworkListeners(page);
|
|
200
|
+
}
|
|
201
|
+
function attachNetworkListeners(page) {
|
|
87
202
|
page.on('request', onRequest);
|
|
88
203
|
page.on('response', onResponse);
|
|
89
204
|
page.on('requestfinished', onRequestFinished);
|
|
90
205
|
page.on('requestfailed', onRequestFailed);
|
|
91
206
|
}
|
|
92
|
-
function
|
|
93
|
-
networkActive = false;
|
|
207
|
+
function detachNetworkListeners(page) {
|
|
94
208
|
try {
|
|
95
209
|
page.removeListener('request', onRequest);
|
|
96
210
|
page.removeListener('response', onResponse);
|
|
@@ -99,13 +213,27 @@ function stopNetworkCapture(page) {
|
|
|
99
213
|
}
|
|
100
214
|
catch { /* ignore */ }
|
|
101
215
|
}
|
|
216
|
+
function stopNetworkCapture(page) {
|
|
217
|
+
networkActive = false;
|
|
218
|
+
detachNetworkListeners(page);
|
|
219
|
+
}
|
|
102
220
|
function onRequest(request) {
|
|
103
221
|
if (!networkActive)
|
|
104
222
|
return;
|
|
105
223
|
const u = new URL(request.url());
|
|
106
224
|
const qp = {};
|
|
107
225
|
u.searchParams.forEach((v, k) => { qp[k] = v; });
|
|
108
|
-
|
|
226
|
+
// request.postData() decodes the body via Buffer.toString('utf-8'), which
|
|
227
|
+
// substitutes invalid bytes (it won't throw the way Python's post_data does),
|
|
228
|
+
// but guard anyway so a future Playwright change can't let an error escape
|
|
229
|
+
// this event listener — parity with the Python binding.
|
|
230
|
+
let requestBody;
|
|
231
|
+
try {
|
|
232
|
+
requestBody = request.postData() ?? null;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
requestBody = null;
|
|
236
|
+
}
|
|
109
237
|
if (requestBody && requestBody.length > MAX_BODY) {
|
|
110
238
|
requestBody = requestBody.slice(0, MAX_BODY);
|
|
111
239
|
}
|
|
@@ -147,7 +275,14 @@ async function onResponse(response) {
|
|
|
147
275
|
entry.responseStatus = response.status();
|
|
148
276
|
entry.responseHeaders = await response.allHeaders();
|
|
149
277
|
const ct = entry.responseHeaders['content-type'] ?? '';
|
|
150
|
-
|
|
278
|
+
// SSE responses (text/event-stream) are long-lived streams whose body
|
|
279
|
+
// never resolves until the stream closes — reading it here would hang the
|
|
280
|
+
// capture. SSE is captured separately via CDP, so skip the body read.
|
|
281
|
+
// Match before the generic 'text' substring (which catches event-stream).
|
|
282
|
+
if (ct.includes('text/event-stream')) {
|
|
283
|
+
entry.responseBody = null;
|
|
284
|
+
}
|
|
285
|
+
else if (TEXT_TYPES.some(t => ct.includes(t))) {
|
|
151
286
|
try {
|
|
152
287
|
let body = await response.text();
|
|
153
288
|
if (body.length > MAX_BODY)
|
|
@@ -184,17 +319,23 @@ function startConsoleCapture(page) {
|
|
|
184
319
|
consoleEntries = [];
|
|
185
320
|
consoleSequence = 0;
|
|
186
321
|
consoleActive = true;
|
|
322
|
+
attachConsoleListeners(page);
|
|
323
|
+
}
|
|
324
|
+
function attachConsoleListeners(page) {
|
|
187
325
|
page.on('console', onConsoleMessage);
|
|
188
326
|
page.on('pageerror', onPageError);
|
|
189
327
|
}
|
|
190
|
-
function
|
|
191
|
-
consoleActive = false;
|
|
328
|
+
function detachConsoleListeners(page) {
|
|
192
329
|
try {
|
|
193
330
|
page.removeListener('console', onConsoleMessage);
|
|
194
331
|
page.removeListener('pageerror', onPageError);
|
|
195
332
|
}
|
|
196
333
|
catch { /* ignore */ }
|
|
197
334
|
}
|
|
335
|
+
function stopConsoleCapture(page) {
|
|
336
|
+
consoleActive = false;
|
|
337
|
+
detachConsoleListeners(page);
|
|
338
|
+
}
|
|
198
339
|
async function onConsoleMessage(msg) {
|
|
199
340
|
if (!consoleActive)
|
|
200
341
|
return;
|
|
@@ -259,15 +400,21 @@ function startWebSocketCapture(page) {
|
|
|
259
400
|
wsConnCounter = 0;
|
|
260
401
|
wsById.clear();
|
|
261
402
|
wsFrameCounter.clear();
|
|
403
|
+
attachWebSocketListeners(page);
|
|
404
|
+
}
|
|
405
|
+
function attachWebSocketListeners(page) {
|
|
262
406
|
page.on('websocket', onWebSocket);
|
|
263
407
|
}
|
|
264
|
-
function
|
|
265
|
-
wsActive = false;
|
|
408
|
+
function detachWebSocketListeners(page) {
|
|
266
409
|
try {
|
|
267
410
|
page.removeListener('websocket', onWebSocket);
|
|
268
411
|
}
|
|
269
412
|
catch { /* ignore */ }
|
|
270
413
|
}
|
|
414
|
+
function stopWebSocketCapture(page) {
|
|
415
|
+
wsActive = false;
|
|
416
|
+
detachWebSocketListeners(page);
|
|
417
|
+
}
|
|
271
418
|
function onWebSocket(ws) {
|
|
272
419
|
if (!wsActive)
|
|
273
420
|
return;
|
|
@@ -392,6 +539,254 @@ function reindexWsById(removed) {
|
|
|
392
539
|
wsById.set(k, v - 1);
|
|
393
540
|
}
|
|
394
541
|
}
|
|
542
|
+
// ── SSE capture (CDP, Chromium-only) ──
|
|
543
|
+
function schedule(p) {
|
|
544
|
+
// Track a scheduled task so teardown can drain it; remove on completion.
|
|
545
|
+
sseTasks.add(p);
|
|
546
|
+
void p.finally(() => sseTasks.delete(p));
|
|
547
|
+
}
|
|
548
|
+
function normSseEvent(name) {
|
|
549
|
+
// CDP eventName "" or "message" -> null (default event type).
|
|
550
|
+
return !name || name === 'message' ? null : name;
|
|
551
|
+
}
|
|
552
|
+
function isEventStream(resp) {
|
|
553
|
+
const mime = (resp.mimeType ?? '').split(';')[0].trim().toLowerCase();
|
|
554
|
+
if (mime === 'text/event-stream')
|
|
555
|
+
return true;
|
|
556
|
+
const headers = resp.headers ?? {};
|
|
557
|
+
const ct = headers['content-type'] ?? headers['Content-Type'] ?? '';
|
|
558
|
+
return ct.toLowerCase().includes('text/event-stream');
|
|
559
|
+
}
|
|
560
|
+
function allocSseConn(url, transport) {
|
|
561
|
+
if (sseConnections.length >= MAX_SSE_CONN)
|
|
562
|
+
evictOldestClosedSse();
|
|
563
|
+
let domain = '';
|
|
564
|
+
let path = '/';
|
|
565
|
+
try {
|
|
566
|
+
const u = new URL(url || '');
|
|
567
|
+
domain = u.hostname;
|
|
568
|
+
path = u.pathname || '/';
|
|
569
|
+
}
|
|
570
|
+
catch { /* malformed */ }
|
|
571
|
+
const conn = {
|
|
572
|
+
connectionId: `sse_${sseConnCounter}`,
|
|
573
|
+
url: url || '', domain, path,
|
|
574
|
+
openedTsMs: Date.now(),
|
|
575
|
+
closedTsMs: null,
|
|
576
|
+
error: null,
|
|
577
|
+
transport,
|
|
578
|
+
messages: [],
|
|
579
|
+
droppedMessages: 0,
|
|
580
|
+
};
|
|
581
|
+
sseConnCounter++;
|
|
582
|
+
sseConnections.push(conn);
|
|
583
|
+
return conn;
|
|
584
|
+
}
|
|
585
|
+
function appendSseMessage(conn, msg) {
|
|
586
|
+
// per-connection ring with first-N preserved; messageIndex stays monotonic via
|
|
587
|
+
// sseMsgCounter (NOT messages.length, which repeats after eviction).
|
|
588
|
+
if (conn.messages.length >= MAX_SSE_MSGS) {
|
|
589
|
+
if (SSE_PRESERVE_FIRST_N < conn.messages.length) {
|
|
590
|
+
conn.messages.splice(SSE_PRESERVE_FIRST_N, 1);
|
|
591
|
+
conn.droppedMessages++;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
conn.messages.push(msg);
|
|
595
|
+
}
|
|
596
|
+
function evictOldestClosedSse() {
|
|
597
|
+
// Session request-maps hold object refs (not indices), so no re-index needed;
|
|
598
|
+
// just drop the evicted conn's message counter.
|
|
599
|
+
for (let i = 0; i < sseConnections.length; i++) {
|
|
600
|
+
if (sseConnections[i].closedTsMs !== null) {
|
|
601
|
+
sseMsgCounter.delete(sseConnections[i].connectionId);
|
|
602
|
+
sseConnections.splice(i, 1);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (sseConnections.length) {
|
|
607
|
+
sseMsgCounter.delete(sseConnections[0].connectionId);
|
|
608
|
+
sseConnections.splice(0, 1);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
function emitSse(conn, ev, transport) {
|
|
612
|
+
if (!sseActive)
|
|
613
|
+
return;
|
|
614
|
+
try {
|
|
615
|
+
let data = ev.data ?? '';
|
|
616
|
+
const truncated = Boolean(ev.truncated) || data.length > MAX_BODY;
|
|
617
|
+
data = data.slice(0, MAX_BODY);
|
|
618
|
+
const cid = conn.connectionId;
|
|
619
|
+
const midx = sseMsgCounter.get(cid) ?? 0;
|
|
620
|
+
sseMsgCounter.set(cid, midx + 1);
|
|
621
|
+
appendSseMessage(conn, {
|
|
622
|
+
connectionId: cid,
|
|
623
|
+
data,
|
|
624
|
+
event: ev.event,
|
|
625
|
+
id: ev.id,
|
|
626
|
+
retry: ev.retry,
|
|
627
|
+
transport,
|
|
628
|
+
truncated,
|
|
629
|
+
tsMs: Date.now(),
|
|
630
|
+
seq: sseSeq,
|
|
631
|
+
messageIndex: midx,
|
|
632
|
+
});
|
|
633
|
+
sseSeq++;
|
|
634
|
+
}
|
|
635
|
+
catch { /* ignore */ }
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Create a CDP session on `page`, wire SSE handlers, register it in the
|
|
639
|
+
* page->session registry, and return it. Handlers close over per-session request
|
|
640
|
+
* maps (requestId collides across CDP targets).
|
|
641
|
+
*/
|
|
642
|
+
export async function startSSECapture(page) {
|
|
643
|
+
const cdp = await page.context().newCDPSession(page);
|
|
644
|
+
await cdp.send('Network.enable');
|
|
645
|
+
const streams = new Map();
|
|
646
|
+
const native = new Map();
|
|
647
|
+
const getNativeConn = (rid, url = '') => {
|
|
648
|
+
let conn = native.get(rid);
|
|
649
|
+
if (!conn) {
|
|
650
|
+
conn = allocSseConn(url, 'eventsource');
|
|
651
|
+
native.set(rid, conn);
|
|
652
|
+
}
|
|
653
|
+
else if (url && !conn.url) {
|
|
654
|
+
conn.url = url;
|
|
655
|
+
try {
|
|
656
|
+
const u = new URL(url);
|
|
657
|
+
conn.domain = u.hostname;
|
|
658
|
+
conn.path = u.pathname || '/';
|
|
659
|
+
}
|
|
660
|
+
catch { /* malformed */ }
|
|
661
|
+
}
|
|
662
|
+
return conn;
|
|
663
|
+
};
|
|
664
|
+
const decodeB64 = (b64) => Buffer.from(b64, 'base64');
|
|
665
|
+
cdp.on('Network.eventSourceMessageReceived', (p) => {
|
|
666
|
+
if (!sseActive)
|
|
667
|
+
return;
|
|
668
|
+
try {
|
|
669
|
+
const conn = getNativeConn(p.requestId);
|
|
670
|
+
emitSse(conn, {
|
|
671
|
+
event: normSseEvent(p.eventName),
|
|
672
|
+
data: p.data ?? '',
|
|
673
|
+
id: p.eventId || null,
|
|
674
|
+
retry: null,
|
|
675
|
+
}, 'eventsource');
|
|
676
|
+
}
|
|
677
|
+
catch { /* ignore */ }
|
|
678
|
+
});
|
|
679
|
+
const onResponse = async (p) => {
|
|
680
|
+
if (!sseActive)
|
|
681
|
+
return;
|
|
682
|
+
try {
|
|
683
|
+
const resp = p.response ?? {};
|
|
684
|
+
if (!isEventStream(resp))
|
|
685
|
+
return;
|
|
686
|
+
const rid = p.requestId;
|
|
687
|
+
const rtype = p.type ?? 'Other';
|
|
688
|
+
if (rtype === 'EventSource') {
|
|
689
|
+
// Native EventSource is pre-parsed; never stream it (double-count).
|
|
690
|
+
getNativeConn(rid, resp.url ?? '');
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const transport = rtype.toLowerCase();
|
|
694
|
+
const conn = allocSseConn(resp.url ?? '', transport);
|
|
695
|
+
// NOT-yet-primed: dataReceived arriving during the streamResourceContent await
|
|
696
|
+
// is queued (not fed) so bufferedData (earlier bytes) parses first — ordering
|
|
697
|
+
// must not rely on event-loop scheduling (a buffered burst on connect would
|
|
698
|
+
// otherwise corrupt the parse).
|
|
699
|
+
const entry = { conn, parser: new SSEWireParser(), dec: new TextDecoder('utf-8'), transport, primed: false, pending: [] };
|
|
700
|
+
streams.set(rid, entry);
|
|
701
|
+
let buffered;
|
|
702
|
+
try {
|
|
703
|
+
const res = await cdp.send('Network.streamResourceContent', { requestId: rid });
|
|
704
|
+
buffered = res?.bufferedData;
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
buffered = undefined;
|
|
708
|
+
}
|
|
709
|
+
if (buffered) {
|
|
710
|
+
for (const ev of entry.parser.feed(entry.dec.decode(decodeB64(buffered), { stream: true })))
|
|
711
|
+
emitSse(conn, ev, transport);
|
|
712
|
+
}
|
|
713
|
+
for (const chunk of entry.pending) {
|
|
714
|
+
for (const ev of entry.parser.feed(entry.dec.decode(decodeB64(chunk), { stream: true })))
|
|
715
|
+
emitSse(conn, ev, transport);
|
|
716
|
+
}
|
|
717
|
+
entry.pending = [];
|
|
718
|
+
entry.primed = true;
|
|
719
|
+
}
|
|
720
|
+
catch { /* ignore */ }
|
|
721
|
+
};
|
|
722
|
+
cdp.on('Network.responseReceived', (p) => schedule(onResponse(p)));
|
|
723
|
+
cdp.on('Network.dataReceived', (p) => {
|
|
724
|
+
if (!sseActive)
|
|
725
|
+
return;
|
|
726
|
+
try {
|
|
727
|
+
const st = streams.get(p.requestId);
|
|
728
|
+
if (!st || !p.data)
|
|
729
|
+
return;
|
|
730
|
+
if (!st.primed) {
|
|
731
|
+
st.pending.push(p.data);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
for (const ev of st.parser.feed(st.dec.decode(decodeB64(p.data), { stream: true })))
|
|
735
|
+
emitSse(st.conn, ev, st.transport);
|
|
736
|
+
}
|
|
737
|
+
catch { /* ignore */ }
|
|
738
|
+
});
|
|
739
|
+
const markClosed = (rid, error) => {
|
|
740
|
+
try {
|
|
741
|
+
const conn = streams.get(rid)?.conn ?? native.get(rid);
|
|
742
|
+
if (!conn)
|
|
743
|
+
return;
|
|
744
|
+
conn.closedTsMs = Date.now();
|
|
745
|
+
if (error)
|
|
746
|
+
conn.error = String(error);
|
|
747
|
+
}
|
|
748
|
+
catch { /* ignore */ }
|
|
749
|
+
};
|
|
750
|
+
cdp.on('Network.loadingFinished', (p) => markClosed(p.requestId));
|
|
751
|
+
cdp.on('Network.loadingFailed', (p) => markClosed(p.requestId, p.errorText));
|
|
752
|
+
sseSessions.set(page, cdp);
|
|
753
|
+
return cdp;
|
|
754
|
+
}
|
|
755
|
+
async function startSSECaptureInitial(page) {
|
|
756
|
+
sseConnections = [];
|
|
757
|
+
sseActive = true;
|
|
758
|
+
sseConfigured = true;
|
|
759
|
+
sseSeq = 0;
|
|
760
|
+
sseConnCounter = 0;
|
|
761
|
+
sseMsgCounter.clear();
|
|
762
|
+
sseSessions.clear();
|
|
763
|
+
await startSSECapture(page);
|
|
764
|
+
}
|
|
765
|
+
async function attachSseToPage(page) {
|
|
766
|
+
// Popup attach: a tab opened after startCapture gets its own CDP session.
|
|
767
|
+
try {
|
|
768
|
+
await startSSECapture(page);
|
|
769
|
+
}
|
|
770
|
+
catch { /* ignore */ }
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Async SSE teardown: stop handlers, drain pending tasks, detach every CDP
|
|
774
|
+
* session. The context 'page' listener is removed by the sync stopCapture
|
|
775
|
+
* BEFORE this runs, so no new popup-attach task can be scheduled mid-teardown.
|
|
776
|
+
*/
|
|
777
|
+
export async function stopSSECapture() {
|
|
778
|
+
sseActive = false;
|
|
779
|
+
const pending = [...sseTasks];
|
|
780
|
+
if (pending.length)
|
|
781
|
+
await Promise.allSettled(pending);
|
|
782
|
+
for (const cdp of [...sseSessions.values()]) {
|
|
783
|
+
try {
|
|
784
|
+
await cdp.detach();
|
|
785
|
+
}
|
|
786
|
+
catch { /* ignore */ }
|
|
787
|
+
}
|
|
788
|
+
sseSessions.clear();
|
|
789
|
+
}
|
|
395
790
|
// ── Performance ──
|
|
396
791
|
const WEB_VITALS_INIT_SCRIPT =
|
|
397
792
|
// Vendored web-vitals v4 IIFE
|
|
@@ -489,6 +884,43 @@ function buildWebSocketAPI() {
|
|
|
489
884
|
get all() { return [...wsConnections]; },
|
|
490
885
|
};
|
|
491
886
|
}
|
|
887
|
+
/**
|
|
888
|
+
* Build a query API object for SSE — injected as `sse` into the user's code,
|
|
889
|
+
* alongside `network`. Light filters; the query code does the rest. Mirror of
|
|
890
|
+
* the Python _ReadOnlySSELog.
|
|
891
|
+
*/
|
|
892
|
+
function buildSSEAPI() {
|
|
893
|
+
const matchUrl = (c, url, urlGlob) => {
|
|
894
|
+
if (url && c.url !== url)
|
|
895
|
+
return false;
|
|
896
|
+
if (urlGlob && !globToRegExp(urlGlob).test(c.url))
|
|
897
|
+
return false;
|
|
898
|
+
return true;
|
|
899
|
+
};
|
|
900
|
+
return {
|
|
901
|
+
connections(filter) {
|
|
902
|
+
let results = [...sseConnections];
|
|
903
|
+
if (filter)
|
|
904
|
+
results = results.filter(c => matchUrl(c, filter.url, filter.urlGlob));
|
|
905
|
+
return results;
|
|
906
|
+
},
|
|
907
|
+
messages(filter) {
|
|
908
|
+
const out = [];
|
|
909
|
+
for (const c of sseConnections) {
|
|
910
|
+
if (filter && !matchUrl(c, filter.url, filter.urlGlob))
|
|
911
|
+
continue;
|
|
912
|
+
for (const m of c.messages) {
|
|
913
|
+
if (filter?.event !== undefined && m.event !== filter.event)
|
|
914
|
+
continue;
|
|
915
|
+
out.push(m);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
out.sort((a, b) => a.seq - b.seq); // global merge order across connections
|
|
919
|
+
return out;
|
|
920
|
+
},
|
|
921
|
+
get all() { return [...sseConnections]; },
|
|
922
|
+
};
|
|
923
|
+
}
|
|
492
924
|
/**
|
|
493
925
|
* Build a query API object for console — injected as `consoleLog` into the user's code.
|
|
494
926
|
*/
|
|
@@ -534,19 +966,24 @@ function buildStorageAPI(data) {
|
|
|
534
966
|
};
|
|
535
967
|
}
|
|
536
968
|
export async function devtoolsNetworkQuery(codeJs) {
|
|
537
|
-
const network = buildNetworkAPI();
|
|
538
969
|
try {
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
|
|
970
|
+
// Build args dynamically, gated on the PERSISTENT configured flags (never
|
|
971
|
+
// .length — eviction can splice connections to 0 mid-session, which would
|
|
972
|
+
// drop the object and silently pass + regress WS). websocket and sse are
|
|
973
|
+
// independent: a query can use network only, +ws, +sse, or all three. When
|
|
974
|
+
// neither was configured this is the exact single-arg form (byte-identical).
|
|
975
|
+
const names = ['network'];
|
|
976
|
+
const values = [buildNetworkAPI()];
|
|
542
977
|
if (wsConfigured) {
|
|
543
|
-
|
|
544
|
-
|
|
978
|
+
names.push('websocket');
|
|
979
|
+
values.push(buildWebSocketAPI());
|
|
545
980
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
981
|
+
if (sseConfigured) {
|
|
982
|
+
names.push('sse');
|
|
983
|
+
values.push(buildSSEAPI());
|
|
549
984
|
}
|
|
985
|
+
const fn = new Function(...names, `"use strict";\n${codeJs}`);
|
|
986
|
+
const result = fn(...values);
|
|
550
987
|
return result != null ? String(result) : '';
|
|
551
988
|
}
|
|
552
989
|
catch (e) {
|