browser-pilot 0.0.13 → 0.0.15
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/README.md +82 -617
- package/dist/actions.cjs +1438 -17
- package/dist/actions.d.cts +21 -3
- package/dist/actions.d.ts +21 -3
- package/dist/actions.mjs +1 -1
- package/dist/browser-MEWT75IB.mjs +11 -0
- package/dist/browser.cjs +1583 -22
- package/dist/browser.d.cts +12 -3
- package/dist/browser.d.ts +12 -3
- package/dist/browser.mjs +3 -3
- package/dist/cdp.cjs +36 -3
- package/dist/cdp.d.cts +1 -1
- package/dist/cdp.d.ts +1 -1
- package/dist/cdp.mjs +3 -1
- package/dist/{chunk-A2ZRAEO3.mjs → chunk-7YVCOL2W.mjs} +1428 -17
- package/dist/chunk-BVZALQT4.mjs +303 -0
- package/dist/chunk-DTVRFXKI.mjs +35 -0
- package/dist/{chunk-HP6R3W32.mjs → chunk-LCNFBXB5.mjs} +33 -6
- package/dist/chunk-LUGLEMVR.mjs +11 -0
- package/dist/chunk-USYSHCI3.mjs +8640 -0
- package/dist/chunk-WPNW23CE.mjs +466 -0
- package/dist/{chunk-VDAMDOS6.mjs → chunk-ZAXQ5OTV.mjs} +151 -7
- package/dist/cli.mjs +3225 -8434
- package/dist/{client-DRqxBdHv.d.ts → client-B5QBRgIy.d.cts} +10 -6
- package/dist/{client-DRqxBdHv.d.cts → client-B5QBRgIy.d.ts} +10 -6
- package/dist/client-JWWZWO6L.mjs +12 -0
- package/dist/index.cjs +1629 -24
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.mjs +3 -3
- package/dist/page-XPS6IC6V.mjs +7 -0
- package/dist/transport-WHEBAZUP.mjs +83 -0
- package/dist/{types-CzgQjai9.d.ts → types-C9ySEdOX.d.cts} +78 -4
- package/dist/{types-BXMGFtnB.d.cts → types-Cvvf0oGu.d.ts} +78 -4
- package/package.json +2 -2
package/dist/actions.cjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
2
3
|
var __defProp = Object.defineProperty;
|
|
3
4
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
5
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
8
|
var __export = (target, all) => {
|
|
7
9
|
for (var name in all)
|
|
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
15
17
|
}
|
|
16
18
|
return to;
|
|
17
19
|
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
18
28
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
29
|
|
|
20
30
|
// src/actions/index.ts
|
|
@@ -26,6 +36,230 @@ __export(actions_exports, {
|
|
|
26
36
|
});
|
|
27
37
|
module.exports = __toCommonJS(actions_exports);
|
|
28
38
|
|
|
39
|
+
// src/actions/executor.ts
|
|
40
|
+
var fs = __toESM(require("fs"), 1);
|
|
41
|
+
var import_node_path = require("path");
|
|
42
|
+
|
|
43
|
+
// src/recording/redaction.ts
|
|
44
|
+
var REDACTED_VALUE = "[REDACTED]";
|
|
45
|
+
var SENSITIVE_AUTOCOMPLETE_TOKENS = [
|
|
46
|
+
"current-password",
|
|
47
|
+
"new-password",
|
|
48
|
+
"one-time-code",
|
|
49
|
+
"cc-number",
|
|
50
|
+
"cc-csc",
|
|
51
|
+
"cc-exp",
|
|
52
|
+
"cc-exp-month",
|
|
53
|
+
"cc-exp-year"
|
|
54
|
+
];
|
|
55
|
+
function autocompleteTokens(autocomplete) {
|
|
56
|
+
if (!autocomplete) return [];
|
|
57
|
+
return autocomplete.toLowerCase().split(/\s+/).map((token) => token.trim()).filter(Boolean);
|
|
58
|
+
}
|
|
59
|
+
function isSensitiveFieldMetadata(metadata) {
|
|
60
|
+
if (!metadata) return false;
|
|
61
|
+
if (metadata.sensitiveValue) return true;
|
|
62
|
+
const inputType = metadata.inputType?.toLowerCase();
|
|
63
|
+
if (inputType === "password" || inputType === "hidden") {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
const sensitiveAutocompleteTokens = new Set(SENSITIVE_AUTOCOMPLETE_TOKENS);
|
|
67
|
+
return autocompleteTokens(metadata.autocomplete).some(
|
|
68
|
+
(token) => sensitiveAutocompleteTokens.has(token)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
function redactValueForRecording(value, metadata) {
|
|
72
|
+
if (value === void 0) return void 0;
|
|
73
|
+
return isSensitiveFieldMetadata(metadata) ? REDACTED_VALUE : value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/browser/action-highlight.ts
|
|
77
|
+
var HIGHLIGHT_STYLES = {
|
|
78
|
+
click: { outline: "3px solid rgba(229,57,53,0.8)", badge: "#e53935", marker: "crosshair" },
|
|
79
|
+
fill: { outline: "3px solid rgba(33,150,243,0.8)", badge: "#2196f3" },
|
|
80
|
+
type: { outline: "3px solid rgba(33,150,243,0.6)", badge: "#2196f3" },
|
|
81
|
+
select: { outline: "3px solid rgba(156,39,176,0.8)", badge: "#9c27b0" },
|
|
82
|
+
hover: { outline: "2px dashed rgba(158,158,158,0.5)", badge: "#9e9e9e" },
|
|
83
|
+
scroll: { outline: "none", badge: "#607d8b", marker: "arrow" },
|
|
84
|
+
navigate: { outline: "none", badge: "#4caf50" },
|
|
85
|
+
submit: { outline: "3px solid rgba(255,152,0,0.8)", badge: "#ff9800" },
|
|
86
|
+
"assert-pass": { outline: "3px solid rgba(76,175,80,0.8)", badge: "#4caf50", marker: "check" },
|
|
87
|
+
"assert-fail": { outline: "3px solid rgba(244,67,54,0.8)", badge: "#f44336", marker: "cross" },
|
|
88
|
+
evaluate: { outline: "none", badge: "#ffc107" },
|
|
89
|
+
focus: { outline: "3px dotted rgba(33,150,243,0.6)", badge: "#2196f3" }
|
|
90
|
+
};
|
|
91
|
+
function buildHighlightScript(options) {
|
|
92
|
+
const style = HIGHLIGHT_STYLES[options.kind];
|
|
93
|
+
const label = options.label ? options.label.slice(0, 80) : void 0;
|
|
94
|
+
const escapedLabel = label ? label.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n") : "";
|
|
95
|
+
return `(function() {
|
|
96
|
+
// Remove any existing highlight
|
|
97
|
+
var existing = document.getElementById('__bp-action-highlight');
|
|
98
|
+
if (existing) existing.remove();
|
|
99
|
+
|
|
100
|
+
var container = document.createElement('div');
|
|
101
|
+
container.id = '__bp-action-highlight';
|
|
102
|
+
container.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:99999;';
|
|
103
|
+
|
|
104
|
+
${options.bbox ? `
|
|
105
|
+
// Element outline
|
|
106
|
+
var outline = document.createElement('div');
|
|
107
|
+
outline.style.cssText = 'position:fixed;' +
|
|
108
|
+
'left:${options.bbox.x}px;top:${options.bbox.y}px;' +
|
|
109
|
+
'width:${options.bbox.width}px;height:${options.bbox.height}px;' +
|
|
110
|
+
'${style.outline !== "none" ? `outline:${style.outline};outline-offset:-1px;` : ""}' +
|
|
111
|
+
'pointer-events:none;box-sizing:border-box;';
|
|
112
|
+
container.appendChild(outline);
|
|
113
|
+
` : ""}
|
|
114
|
+
|
|
115
|
+
${options.point && style.marker === "crosshair" ? `
|
|
116
|
+
// Crosshair at click point
|
|
117
|
+
var hLine = document.createElement('div');
|
|
118
|
+
hLine.style.cssText = 'position:fixed;left:${options.point.x - 12}px;top:${options.point.y}px;' +
|
|
119
|
+
'width:24px;height:2px;background:${style.badge};pointer-events:none;';
|
|
120
|
+
var vLine = document.createElement('div');
|
|
121
|
+
vLine.style.cssText = 'position:fixed;left:${options.point.x}px;top:${options.point.y - 12}px;' +
|
|
122
|
+
'width:2px;height:24px;background:${style.badge};pointer-events:none;';
|
|
123
|
+
// Dot at center
|
|
124
|
+
var dot = document.createElement('div');
|
|
125
|
+
dot.style.cssText = 'position:fixed;left:${options.point.x - 4}px;top:${options.point.y - 4}px;' +
|
|
126
|
+
'width:8px;height:8px;border-radius:50%;background:${style.badge};pointer-events:none;';
|
|
127
|
+
container.appendChild(hLine);
|
|
128
|
+
container.appendChild(vLine);
|
|
129
|
+
container.appendChild(dot);
|
|
130
|
+
` : ""}
|
|
131
|
+
|
|
132
|
+
${label ? `
|
|
133
|
+
// Badge with label
|
|
134
|
+
var badge = document.createElement('div');
|
|
135
|
+
badge.style.cssText = 'position:fixed;' +
|
|
136
|
+
${options.bbox ? `'left:${options.bbox.x}px;top:${Math.max(0, options.bbox.y - 28)}px;'` : options.kind === "navigate" ? "'left:50%;top:8px;transform:translateX(-50%);'" : "'right:8px;top:8px;'"} +
|
|
137
|
+
'background:${style.badge};color:white;padding:4px 8px;' +
|
|
138
|
+
'font-family:monospace;font-size:12px;font-weight:bold;' +
|
|
139
|
+
'border-radius:3px;white-space:nowrap;max-width:400px;overflow:hidden;text-overflow:ellipsis;' +
|
|
140
|
+
'pointer-events:none;';
|
|
141
|
+
badge.textContent = '${escapedLabel}';
|
|
142
|
+
container.appendChild(badge);
|
|
143
|
+
` : ""}
|
|
144
|
+
|
|
145
|
+
${style.marker === "check" && options.bbox ? `
|
|
146
|
+
// Checkmark
|
|
147
|
+
var check = document.createElement('div');
|
|
148
|
+
check.style.cssText = 'position:fixed;left:${options.bbox.x + options.bbox.width / 2 - 10}px;' +
|
|
149
|
+
'top:${options.bbox.y + options.bbox.height / 2 - 10}px;' +
|
|
150
|
+
'width:20px;height:20px;font-size:18px;color:${style.badge};pointer-events:none;text-align:center;line-height:20px;';
|
|
151
|
+
check.textContent = '\\u2713';
|
|
152
|
+
container.appendChild(check);
|
|
153
|
+
` : ""}
|
|
154
|
+
|
|
155
|
+
${style.marker === "cross" && options.bbox ? `
|
|
156
|
+
// Cross mark
|
|
157
|
+
var cross = document.createElement('div');
|
|
158
|
+
cross.style.cssText = 'position:fixed;left:${options.bbox.x + options.bbox.width / 2 - 10}px;' +
|
|
159
|
+
'top:${options.bbox.y + options.bbox.height / 2 - 10}px;' +
|
|
160
|
+
'width:20px;height:20px;font-size:18px;color:${style.badge};pointer-events:none;text-align:center;line-height:20px;font-weight:bold;';
|
|
161
|
+
cross.textContent = '\\u2717';
|
|
162
|
+
container.appendChild(cross);
|
|
163
|
+
` : ""}
|
|
164
|
+
|
|
165
|
+
document.body.appendChild(container);
|
|
166
|
+
window.__bpRemoveActionHighlight = function() {
|
|
167
|
+
var el = document.getElementById('__bp-action-highlight');
|
|
168
|
+
if (el) el.remove();
|
|
169
|
+
delete window.__bpRemoveActionHighlight;
|
|
170
|
+
};
|
|
171
|
+
})();`;
|
|
172
|
+
}
|
|
173
|
+
async function injectActionHighlight(page, options) {
|
|
174
|
+
try {
|
|
175
|
+
await page.evaluate(buildHighlightScript(options));
|
|
176
|
+
} catch {
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function removeActionHighlight(page) {
|
|
180
|
+
try {
|
|
181
|
+
await page.evaluate(`(function() {
|
|
182
|
+
if (window.__bpRemoveActionHighlight) {
|
|
183
|
+
window.__bpRemoveActionHighlight();
|
|
184
|
+
}
|
|
185
|
+
})()`);
|
|
186
|
+
} catch {
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function stepToHighlightKind(step) {
|
|
190
|
+
switch (step.action) {
|
|
191
|
+
case "click":
|
|
192
|
+
return "click";
|
|
193
|
+
case "fill":
|
|
194
|
+
return "fill";
|
|
195
|
+
case "type":
|
|
196
|
+
return "type";
|
|
197
|
+
case "select":
|
|
198
|
+
return "select";
|
|
199
|
+
case "hover":
|
|
200
|
+
return "hover";
|
|
201
|
+
case "scroll":
|
|
202
|
+
return "scroll";
|
|
203
|
+
case "goto":
|
|
204
|
+
return "navigate";
|
|
205
|
+
case "submit":
|
|
206
|
+
return "submit";
|
|
207
|
+
case "focus":
|
|
208
|
+
return "focus";
|
|
209
|
+
case "evaluate":
|
|
210
|
+
case "press":
|
|
211
|
+
case "shortcut":
|
|
212
|
+
return "evaluate";
|
|
213
|
+
case "assertVisible":
|
|
214
|
+
case "assertExists":
|
|
215
|
+
case "assertText":
|
|
216
|
+
case "assertUrl":
|
|
217
|
+
case "assertValue":
|
|
218
|
+
return step.success ? "assert-pass" : "assert-fail";
|
|
219
|
+
// Observation-only actions — no highlight
|
|
220
|
+
case "wait":
|
|
221
|
+
case "snapshot":
|
|
222
|
+
case "forms":
|
|
223
|
+
case "text":
|
|
224
|
+
case "screenshot":
|
|
225
|
+
case "newTab":
|
|
226
|
+
case "closeTab":
|
|
227
|
+
case "switchFrame":
|
|
228
|
+
case "switchToMain":
|
|
229
|
+
return null;
|
|
230
|
+
default:
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function getHighlightLabel(step, result, targetMetadata) {
|
|
235
|
+
switch (step.action) {
|
|
236
|
+
case "fill":
|
|
237
|
+
case "type":
|
|
238
|
+
return typeof step.value === "string" ? `"${redactValueForRecording(step.value, targetMetadata)}"` : void 0;
|
|
239
|
+
case "select":
|
|
240
|
+
return redactValueForRecording(
|
|
241
|
+
typeof step.value === "string" ? step.value : void 0,
|
|
242
|
+
targetMetadata
|
|
243
|
+
);
|
|
244
|
+
case "goto":
|
|
245
|
+
return step.url;
|
|
246
|
+
case "evaluate":
|
|
247
|
+
return "JS";
|
|
248
|
+
case "press":
|
|
249
|
+
return step.key;
|
|
250
|
+
case "shortcut":
|
|
251
|
+
return step.combo;
|
|
252
|
+
case "assertText":
|
|
253
|
+
case "assertUrl":
|
|
254
|
+
case "assertValue":
|
|
255
|
+
case "assertVisible":
|
|
256
|
+
case "assertExists":
|
|
257
|
+
return result.success ? "\u2713" : "\u2717";
|
|
258
|
+
default:
|
|
259
|
+
return void 0;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
29
263
|
// src/browser/actionability.ts
|
|
30
264
|
var ActionabilityError = class extends Error {
|
|
31
265
|
failureType;
|
|
@@ -326,8 +560,677 @@ var CDPError = class extends Error {
|
|
|
326
560
|
}
|
|
327
561
|
};
|
|
328
562
|
|
|
563
|
+
// src/trace/views.ts
|
|
564
|
+
function takeRecent(events, limit = 5) {
|
|
565
|
+
return events.slice(-limit).map((event) => ({
|
|
566
|
+
ts: event.ts,
|
|
567
|
+
event: event.event,
|
|
568
|
+
summary: event.summary,
|
|
569
|
+
severity: event.severity,
|
|
570
|
+
url: event.url
|
|
571
|
+
}));
|
|
572
|
+
}
|
|
573
|
+
function buildTraceSummaries(events) {
|
|
574
|
+
return {
|
|
575
|
+
ws: summarizeWs(events),
|
|
576
|
+
voice: summarizeVoice(events),
|
|
577
|
+
console: summarizeConsole(events),
|
|
578
|
+
permissions: summarizePermissions(events),
|
|
579
|
+
media: summarizeMedia(events),
|
|
580
|
+
ui: summarizeUi(events),
|
|
581
|
+
session: summarizeSession(events)
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
function summarizeWs(events) {
|
|
585
|
+
const relevant = events.filter((event) => event.channel === "ws" || event.event.startsWith("ws."));
|
|
586
|
+
const connections = /* @__PURE__ */ new Map();
|
|
587
|
+
for (const event of relevant) {
|
|
588
|
+
const id = event.connectionId ?? event.requestId ?? event.traceId;
|
|
589
|
+
let connection = connections.get(id);
|
|
590
|
+
if (!connection) {
|
|
591
|
+
connection = { id, sent: 0, received: 0, lastMessages: [] };
|
|
592
|
+
connections.set(id, connection);
|
|
593
|
+
}
|
|
594
|
+
connection.url = event.url ?? connection.url;
|
|
595
|
+
if (event.event === "ws.connection.created") {
|
|
596
|
+
connection.createdAt = event.ts;
|
|
597
|
+
}
|
|
598
|
+
if (event.event === "ws.connection.closed") {
|
|
599
|
+
connection.closedAt = event.ts;
|
|
600
|
+
}
|
|
601
|
+
if (event.event === "ws.frame.sent") {
|
|
602
|
+
connection.sent += 1;
|
|
603
|
+
const payload = typeof event.data["payload"] === "string" ? event.data["payload"] : "";
|
|
604
|
+
if (payload) connection.lastMessages.push(`sent: ${payload}`);
|
|
605
|
+
}
|
|
606
|
+
if (event.event === "ws.frame.received") {
|
|
607
|
+
connection.received += 1;
|
|
608
|
+
const payload = typeof event.data["payload"] === "string" ? event.data["payload"] : "";
|
|
609
|
+
if (payload) connection.lastMessages.push(`recv: ${payload}`);
|
|
610
|
+
}
|
|
611
|
+
connection.lastMessages = connection.lastMessages.slice(-3);
|
|
612
|
+
}
|
|
613
|
+
const values = [...connections.values()];
|
|
614
|
+
const reconnects = values.reduce((count, connection) => {
|
|
615
|
+
return connection.closedAt && !connection.createdAt ? count : count;
|
|
616
|
+
}, 0);
|
|
617
|
+
return {
|
|
618
|
+
view: "ws",
|
|
619
|
+
totalEvents: relevant.length,
|
|
620
|
+
connections: values.map((connection) => ({
|
|
621
|
+
id: connection.id,
|
|
622
|
+
url: connection.url ?? null,
|
|
623
|
+
createdAt: connection.createdAt ?? null,
|
|
624
|
+
closedAt: connection.closedAt ?? null,
|
|
625
|
+
sent: connection.sent,
|
|
626
|
+
received: connection.received,
|
|
627
|
+
lastMessages: connection.lastMessages,
|
|
628
|
+
connectedButSilent: !!connection.createdAt && !connection.closedAt && connection.sent + connection.received === 0
|
|
629
|
+
})),
|
|
630
|
+
reconnects,
|
|
631
|
+
recent: takeRecent(relevant)
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
function summarizeConsole(events) {
|
|
635
|
+
const relevant = events.filter(
|
|
636
|
+
(event) => event.channel === "console" || event.event.startsWith("console.") || event.event === "runtime.exception" || event.event === "runtime.unhandledRejection"
|
|
637
|
+
);
|
|
638
|
+
return {
|
|
639
|
+
view: "console",
|
|
640
|
+
errors: relevant.filter(
|
|
641
|
+
(event) => event.event === "console.error" || event.event === "runtime.exception" || event.event === "runtime.unhandledRejection"
|
|
642
|
+
).length,
|
|
643
|
+
warnings: relevant.filter((event) => event.event === "console.warn").length,
|
|
644
|
+
logs: relevant.filter((event) => event.event === "console.log").length,
|
|
645
|
+
recent: takeRecent(relevant)
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
function summarizePermissions(events) {
|
|
649
|
+
const relevant = events.filter(
|
|
650
|
+
(event) => event.channel === "permission" || event.event.startsWith("permission.")
|
|
651
|
+
);
|
|
652
|
+
const latest = /* @__PURE__ */ new Map();
|
|
653
|
+
for (const event of relevant) {
|
|
654
|
+
const name = typeof event.data["name"] === "string" ? event.data["name"] : null;
|
|
655
|
+
const state = typeof event.data["state"] === "string" ? event.data["state"] : null;
|
|
656
|
+
if (name && state) {
|
|
657
|
+
latest.set(name, state);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return {
|
|
661
|
+
view: "permissions",
|
|
662
|
+
states: Object.fromEntries(latest),
|
|
663
|
+
changes: relevant.filter((event) => event.event === "permission.changed").length,
|
|
664
|
+
recent: takeRecent(relevant)
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
function summarizeMedia(events) {
|
|
668
|
+
const relevant = events.filter(
|
|
669
|
+
(event) => event.channel === "media" || event.event.startsWith("media.")
|
|
670
|
+
);
|
|
671
|
+
const liveTracks = /* @__PURE__ */ new Map();
|
|
672
|
+
for (const event of relevant) {
|
|
673
|
+
const label = typeof event.data["label"] === "string" ? event.data["label"] : "";
|
|
674
|
+
const kind = typeof event.data["kind"] === "string" ? event.data["kind"] : "";
|
|
675
|
+
const key = `${kind}:${label}`;
|
|
676
|
+
if (event.event === "media.track.started") {
|
|
677
|
+
liveTracks.set(key, kind);
|
|
678
|
+
}
|
|
679
|
+
if (event.event === "media.track.ended") {
|
|
680
|
+
liveTracks.delete(key);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
view: "media",
|
|
685
|
+
tracksStarted: relevant.filter((event) => event.event === "media.track.started").length,
|
|
686
|
+
tracksEnded: relevant.filter((event) => event.event === "media.track.ended").length,
|
|
687
|
+
playbackStarted: relevant.filter((event) => event.event === "media.playback.started").length,
|
|
688
|
+
playbackStopped: relevant.filter((event) => event.event === "media.playback.stopped").length,
|
|
689
|
+
liveTracks: [...liveTracks.values()],
|
|
690
|
+
recent: takeRecent(relevant)
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
function summarizeVoice(events) {
|
|
694
|
+
const relevant = events.filter(
|
|
695
|
+
(event) => event.channel === "voice" || event.event.startsWith("voice.")
|
|
696
|
+
);
|
|
697
|
+
return {
|
|
698
|
+
view: "voice",
|
|
699
|
+
ready: relevant.filter((event) => event.event === "voice.pipeline.ready").length,
|
|
700
|
+
notReady: relevant.filter((event) => event.event === "voice.pipeline.notReady").length,
|
|
701
|
+
captureStarted: relevant.filter((event) => event.event === "voice.capture.started").length,
|
|
702
|
+
captureStopped: relevant.filter((event) => event.event === "voice.capture.stopped").length,
|
|
703
|
+
detectedAudio: relevant.filter((event) => event.event === "voice.capture.detectedAudio").length,
|
|
704
|
+
recent: takeRecent(relevant)
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
function summarizeUi(events) {
|
|
708
|
+
const relevant = events.filter(
|
|
709
|
+
(event) => event.channel === "dom" || event.event.startsWith("dom.") || event.channel === "action"
|
|
710
|
+
);
|
|
711
|
+
return {
|
|
712
|
+
view: "ui",
|
|
713
|
+
actions: relevant.filter((event) => event.channel === "action").length,
|
|
714
|
+
domChanges: relevant.filter((event) => event.channel === "dom").length,
|
|
715
|
+
recent: takeRecent(relevant)
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
function summarizeSession(events) {
|
|
719
|
+
const byChannel = /* @__PURE__ */ new Map();
|
|
720
|
+
const failedActions = events.filter((event) => event.event === "action.failed").length;
|
|
721
|
+
for (const event of events) {
|
|
722
|
+
byChannel.set(event.channel, (byChannel.get(event.channel) ?? 0) + 1);
|
|
723
|
+
}
|
|
724
|
+
return {
|
|
725
|
+
view: "session",
|
|
726
|
+
totalEvents: events.length,
|
|
727
|
+
byChannel: Object.fromEntries(byChannel),
|
|
728
|
+
failedActions,
|
|
729
|
+
recent: takeRecent(events)
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// src/recording/manifest.ts
|
|
734
|
+
function isCanonicalRecordingManifest(value) {
|
|
735
|
+
return Boolean(
|
|
736
|
+
value && typeof value === "object" && value.version === 2 && typeof value.session === "object"
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
function isLegacyRecordingManifest(value) {
|
|
740
|
+
return Boolean(
|
|
741
|
+
value && typeof value === "object" && value.version === 1 && Array.isArray(value.frames)
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
function createRecordingManifest(input) {
|
|
745
|
+
const actions = input.frames.map((frame) => {
|
|
746
|
+
const actionId = frame.actionId ?? `action-${frame.seq}`;
|
|
747
|
+
return {
|
|
748
|
+
id: actionId,
|
|
749
|
+
stepIndex: frame.stepIndex ?? Math.max(0, frame.seq - 1),
|
|
750
|
+
action: frame.action,
|
|
751
|
+
selector: frame.selector,
|
|
752
|
+
selectorUsed: frame.selectorUsed ?? frame.selector,
|
|
753
|
+
value: frame.value,
|
|
754
|
+
url: frame.url,
|
|
755
|
+
success: frame.success,
|
|
756
|
+
durationMs: frame.durationMs,
|
|
757
|
+
error: frame.error,
|
|
758
|
+
ts: new Date(frame.timestamp).toISOString(),
|
|
759
|
+
pageUrl: frame.pageUrl,
|
|
760
|
+
pageTitle: frame.pageTitle,
|
|
761
|
+
coordinates: frame.coordinates,
|
|
762
|
+
boundingBox: frame.boundingBox
|
|
763
|
+
};
|
|
764
|
+
});
|
|
765
|
+
const screenshots = input.frames.map((frame) => ({
|
|
766
|
+
id: `shot-${frame.seq}`,
|
|
767
|
+
stepIndex: frame.stepIndex ?? Math.max(0, frame.seq - 1),
|
|
768
|
+
actionId: frame.actionId ?? `action-${frame.seq}`,
|
|
769
|
+
file: frame.screenshot,
|
|
770
|
+
ts: new Date(frame.timestamp).toISOString(),
|
|
771
|
+
success: frame.success,
|
|
772
|
+
pageUrl: frame.pageUrl,
|
|
773
|
+
pageTitle: frame.pageTitle,
|
|
774
|
+
coordinates: frame.coordinates,
|
|
775
|
+
boundingBox: frame.boundingBox
|
|
776
|
+
}));
|
|
777
|
+
return {
|
|
778
|
+
version: 2,
|
|
779
|
+
recordedAt: input.recordedAt,
|
|
780
|
+
session: {
|
|
781
|
+
id: input.sessionId,
|
|
782
|
+
startUrl: input.startUrl,
|
|
783
|
+
endUrl: input.endUrl,
|
|
784
|
+
targetId: input.targetId,
|
|
785
|
+
profile: input.profile
|
|
786
|
+
},
|
|
787
|
+
recipe: {
|
|
788
|
+
steps: input.steps
|
|
789
|
+
},
|
|
790
|
+
actions,
|
|
791
|
+
screenshots,
|
|
792
|
+
trace: {
|
|
793
|
+
events: input.traceEvents,
|
|
794
|
+
summaries: buildTraceSummaries(input.traceEvents)
|
|
795
|
+
},
|
|
796
|
+
assertions: input.assertions ?? [],
|
|
797
|
+
notes: input.notes ?? [],
|
|
798
|
+
artifacts: {
|
|
799
|
+
recordingManifest: input.recordingManifest ?? "recording.json",
|
|
800
|
+
screenshotDir: input.screenshotDir ?? "screenshots/"
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
function canonicalizeRecordingArtifact(value) {
|
|
805
|
+
if (isCanonicalRecordingManifest(value)) {
|
|
806
|
+
return value;
|
|
807
|
+
}
|
|
808
|
+
if (!isLegacyRecordingManifest(value)) {
|
|
809
|
+
throw new Error("Unsupported recording artifact");
|
|
810
|
+
}
|
|
811
|
+
const traceEvents = buildTraceEventsFromLegacy(value);
|
|
812
|
+
const steps = value.frames.map((frame) => frameToStep(frame));
|
|
813
|
+
return createRecordingManifest({
|
|
814
|
+
recordedAt: value.recordedAt,
|
|
815
|
+
sessionId: value.sessionId,
|
|
816
|
+
startUrl: value.startUrl,
|
|
817
|
+
endUrl: value.endUrl,
|
|
818
|
+
steps,
|
|
819
|
+
frames: value.frames,
|
|
820
|
+
traceEvents,
|
|
821
|
+
notes: ["Converted from legacy recording manifest"]
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
function buildTraceEventsFromLegacy(value) {
|
|
825
|
+
const events = [];
|
|
826
|
+
for (const frame of value.frames) {
|
|
827
|
+
events.push({
|
|
828
|
+
traceId: frame.actionId ?? `legacy-${frame.seq}`,
|
|
829
|
+
sessionId: value.sessionId,
|
|
830
|
+
ts: new Date(frame.timestamp).toISOString(),
|
|
831
|
+
elapsedMs: frame.timestamp - new Date(value.recordedAt).getTime(),
|
|
832
|
+
channel: "action",
|
|
833
|
+
event: frame.success ? "action.succeeded" : "action.failed",
|
|
834
|
+
severity: frame.success ? "info" : "error",
|
|
835
|
+
summary: `${frame.action}${frame.selector ? ` ${frame.selector}` : ""}`,
|
|
836
|
+
data: {
|
|
837
|
+
action: frame.action,
|
|
838
|
+
selector: frame.selector,
|
|
839
|
+
value: frame.value ?? null,
|
|
840
|
+
pageUrl: frame.pageUrl ?? null,
|
|
841
|
+
pageTitle: frame.pageTitle ?? null,
|
|
842
|
+
screenshot: frame.screenshot
|
|
843
|
+
},
|
|
844
|
+
actionId: frame.actionId ?? `action-${frame.seq}`,
|
|
845
|
+
stepIndex: frame.stepIndex ?? Math.max(0, frame.seq - 1),
|
|
846
|
+
selector: frame.selector,
|
|
847
|
+
selectorUsed: frame.selectorUsed ?? frame.selector,
|
|
848
|
+
url: frame.pageUrl ?? frame.url
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
return events;
|
|
852
|
+
}
|
|
853
|
+
function frameToStep(frame) {
|
|
854
|
+
switch (frame.action) {
|
|
855
|
+
case "fill":
|
|
856
|
+
return { action: "fill", selector: frame.selector, value: frame.value };
|
|
857
|
+
case "submit":
|
|
858
|
+
return { action: "submit", selector: frame.selector };
|
|
859
|
+
case "goto":
|
|
860
|
+
return { action: "goto", url: frame.url ?? frame.pageUrl };
|
|
861
|
+
case "press":
|
|
862
|
+
return { action: "press", key: frame.value ?? "Enter" };
|
|
863
|
+
default:
|
|
864
|
+
return { action: "click", selector: frame.selector };
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// src/trace/script.ts
|
|
869
|
+
var TRACE_BINDING_NAME = "__bpTraceBinding";
|
|
870
|
+
var TRACE_SCRIPT = `
|
|
871
|
+
(() => {
|
|
872
|
+
if (window.__bpTraceInstalled) return;
|
|
873
|
+
window.__bpTraceInstalled = true;
|
|
874
|
+
|
|
875
|
+
const binding = globalThis.${TRACE_BINDING_NAME};
|
|
876
|
+
if (typeof binding !== 'function') return;
|
|
877
|
+
|
|
878
|
+
const emit = (event, data = {}, severity = 'info', summary) => {
|
|
879
|
+
try {
|
|
880
|
+
globalThis.__bpTraceRecentEvents = globalThis.__bpTraceRecentEvents || [];
|
|
881
|
+
const payload = {
|
|
882
|
+
event,
|
|
883
|
+
severity,
|
|
884
|
+
summary: summary || event,
|
|
885
|
+
ts: Date.now(),
|
|
886
|
+
data,
|
|
887
|
+
};
|
|
888
|
+
globalThis.__bpTraceRecentEvents.push(payload);
|
|
889
|
+
if (globalThis.__bpTraceRecentEvents.length > 200) {
|
|
890
|
+
globalThis.__bpTraceRecentEvents.splice(0, globalThis.__bpTraceRecentEvents.length - 200);
|
|
891
|
+
}
|
|
892
|
+
binding(JSON.stringify(payload));
|
|
893
|
+
} catch {}
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
const patchWebSocket = () => {
|
|
897
|
+
const NativeWebSocket = window.WebSocket;
|
|
898
|
+
if (typeof NativeWebSocket !== 'function' || window.__bpTraceWebSocketInstalled) return;
|
|
899
|
+
window.__bpTraceWebSocketInstalled = true;
|
|
900
|
+
|
|
901
|
+
const nextId = () => Math.random().toString(36).slice(2, 10);
|
|
902
|
+
|
|
903
|
+
const patchInstance = (socket, urlValue) => {
|
|
904
|
+
if (!socket || socket.__bpTracePatched) return socket;
|
|
905
|
+
socket.__bpTracePatched = true;
|
|
906
|
+
socket.__bpTraceId = socket.__bpTraceId || nextId();
|
|
907
|
+
socket.__bpTraceUrl = String(urlValue || socket.url || '');
|
|
908
|
+
globalThis.__bpTrackedWebSockets = globalThis.__bpTrackedWebSockets || new Set();
|
|
909
|
+
globalThis.__bpTrackedWebSockets.add(socket);
|
|
910
|
+
|
|
911
|
+
emit(
|
|
912
|
+
'ws.connection.created',
|
|
913
|
+
{ connectionId: socket.__bpTraceId, url: socket.__bpTraceUrl },
|
|
914
|
+
'info',
|
|
915
|
+
'WebSocket opened ' + socket.__bpTraceUrl
|
|
916
|
+
);
|
|
917
|
+
|
|
918
|
+
const originalSend = socket.send;
|
|
919
|
+
socket.send = function(data) {
|
|
920
|
+
const payload =
|
|
921
|
+
typeof data === 'string'
|
|
922
|
+
? data
|
|
923
|
+
: data && typeof data.toString === 'function'
|
|
924
|
+
? data.toString()
|
|
925
|
+
: '[binary]';
|
|
926
|
+
emit(
|
|
927
|
+
'ws.frame.sent',
|
|
928
|
+
{
|
|
929
|
+
connectionId: socket.__bpTraceId,
|
|
930
|
+
url: socket.__bpTraceUrl,
|
|
931
|
+
payload,
|
|
932
|
+
length: payload.length,
|
|
933
|
+
},
|
|
934
|
+
'info',
|
|
935
|
+
'WebSocket frame sent'
|
|
936
|
+
);
|
|
937
|
+
return originalSend.call(this, data);
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
socket.addEventListener('message', (event) => {
|
|
941
|
+
if (socket.__bpOfflineNotified || socket.__bpTraceClosed) {
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
const data = event && 'data' in event ? event.data : '';
|
|
945
|
+
const payload =
|
|
946
|
+
typeof data === 'string'
|
|
947
|
+
? data
|
|
948
|
+
: data && typeof data.toString === 'function'
|
|
949
|
+
? data.toString()
|
|
950
|
+
: '[binary]';
|
|
951
|
+
emit(
|
|
952
|
+
'ws.frame.received',
|
|
953
|
+
{
|
|
954
|
+
connectionId: socket.__bpTraceId,
|
|
955
|
+
url: socket.__bpTraceUrl,
|
|
956
|
+
payload,
|
|
957
|
+
length: payload.length,
|
|
958
|
+
},
|
|
959
|
+
'info',
|
|
960
|
+
'WebSocket frame received'
|
|
961
|
+
);
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
socket.addEventListener('close', (event) => {
|
|
965
|
+
if (socket.__bpTraceClosed) {
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
socket.__bpTraceClosed = true;
|
|
969
|
+
try {
|
|
970
|
+
globalThis.__bpTrackedWebSockets.delete(socket);
|
|
971
|
+
} catch {}
|
|
972
|
+
emit(
|
|
973
|
+
'ws.connection.closed',
|
|
974
|
+
{
|
|
975
|
+
connectionId: socket.__bpTraceId,
|
|
976
|
+
url: socket.__bpTraceUrl,
|
|
977
|
+
code: event.code,
|
|
978
|
+
reason: event.reason,
|
|
979
|
+
},
|
|
980
|
+
'warn',
|
|
981
|
+
'WebSocket closed'
|
|
982
|
+
);
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
return socket;
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
const TracedWebSocket = function(url, protocols) {
|
|
989
|
+
return arguments.length > 1
|
|
990
|
+
? patchInstance(new NativeWebSocket(url, protocols), url)
|
|
991
|
+
: patchInstance(new NativeWebSocket(url), url);
|
|
992
|
+
};
|
|
993
|
+
TracedWebSocket.prototype = NativeWebSocket.prototype;
|
|
994
|
+
Object.setPrototypeOf(TracedWebSocket, NativeWebSocket);
|
|
995
|
+
window.WebSocket = TracedWebSocket;
|
|
996
|
+
};
|
|
997
|
+
|
|
998
|
+
window.addEventListener('error', (errorEvent) => {
|
|
999
|
+
emit(
|
|
1000
|
+
'runtime.exception',
|
|
1001
|
+
{
|
|
1002
|
+
message: errorEvent.message,
|
|
1003
|
+
filename: errorEvent.filename,
|
|
1004
|
+
line: errorEvent.lineno,
|
|
1005
|
+
column: errorEvent.colno,
|
|
1006
|
+
},
|
|
1007
|
+
'error',
|
|
1008
|
+
errorEvent.message || 'Uncaught error'
|
|
1009
|
+
);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
window.addEventListener('unhandledrejection', (event) => {
|
|
1013
|
+
const reason = event && 'reason' in event ? String(event.reason) : 'Unhandled rejection';
|
|
1014
|
+
emit('runtime.unhandledRejection', { reason }, 'error', reason);
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
const patchPermissions = async () => {
|
|
1018
|
+
if (!navigator.permissions || !navigator.permissions.query) return;
|
|
1019
|
+
|
|
1020
|
+
const names = ['geolocation', 'microphone', 'camera', 'notifications'];
|
|
1021
|
+
for (const name of names) {
|
|
1022
|
+
try {
|
|
1023
|
+
const status = await navigator.permissions.query({ name });
|
|
1024
|
+
emit(
|
|
1025
|
+
'permission.state',
|
|
1026
|
+
{ name, state: status.state },
|
|
1027
|
+
status.state === 'denied' ? 'warn' : 'info',
|
|
1028
|
+
name + ': ' + status.state
|
|
1029
|
+
);
|
|
1030
|
+
status.addEventListener('change', () => {
|
|
1031
|
+
emit(
|
|
1032
|
+
'permission.changed',
|
|
1033
|
+
{ name, state: status.state },
|
|
1034
|
+
status.state === 'denied' ? 'warn' : 'info',
|
|
1035
|
+
name + ': ' + status.state
|
|
1036
|
+
);
|
|
1037
|
+
});
|
|
1038
|
+
} catch {}
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
const patchMediaElement = (element) => {
|
|
1043
|
+
if (!element || element.__bpTracePatched) return;
|
|
1044
|
+
element.__bpTracePatched = true;
|
|
1045
|
+
|
|
1046
|
+
element.addEventListener('play', () => {
|
|
1047
|
+
emit(
|
|
1048
|
+
'media.playback.started',
|
|
1049
|
+
{ tag: element.tagName.toLowerCase(), src: element.currentSrc || element.src || null },
|
|
1050
|
+
'info',
|
|
1051
|
+
'Media playback started'
|
|
1052
|
+
);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
const onStop = () => {
|
|
1056
|
+
emit(
|
|
1057
|
+
'media.playback.stopped',
|
|
1058
|
+
{ tag: element.tagName.toLowerCase(), src: element.currentSrc || element.src || null },
|
|
1059
|
+
'warn',
|
|
1060
|
+
'Media playback stopped'
|
|
1061
|
+
);
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
element.addEventListener('pause', onStop);
|
|
1065
|
+
element.addEventListener('ended', onStop);
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
const patchMediaElements = () => {
|
|
1069
|
+
document.querySelectorAll('audio,video').forEach(patchMediaElement);
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
patchMediaElements();
|
|
1073
|
+
patchWebSocket();
|
|
1074
|
+
|
|
1075
|
+
if (document.documentElement) {
|
|
1076
|
+
const observer = new MutationObserver(() => {
|
|
1077
|
+
patchMediaElements();
|
|
1078
|
+
});
|
|
1079
|
+
observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
|
1083
|
+
const original = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
|
|
1084
|
+
navigator.mediaDevices.getUserMedia = async (...args) => {
|
|
1085
|
+
emit('voice.capture.started', { constraints: args[0] || null }, 'info', 'Voice capture started');
|
|
1086
|
+
try {
|
|
1087
|
+
const stream = await original(...args);
|
|
1088
|
+
const tracks = stream.getTracks();
|
|
1089
|
+
|
|
1090
|
+
for (const track of tracks) {
|
|
1091
|
+
emit(
|
|
1092
|
+
'media.track.started',
|
|
1093
|
+
{ kind: track.kind, label: track.label, readyState: track.readyState },
|
|
1094
|
+
'info',
|
|
1095
|
+
track.kind + ' track started'
|
|
1096
|
+
);
|
|
1097
|
+
track.addEventListener('ended', () => {
|
|
1098
|
+
emit(
|
|
1099
|
+
'media.track.ended',
|
|
1100
|
+
{ kind: track.kind, label: track.label, readyState: track.readyState },
|
|
1101
|
+
'warn',
|
|
1102
|
+
track.kind + ' track ended'
|
|
1103
|
+
);
|
|
1104
|
+
emit(
|
|
1105
|
+
'voice.capture.stopped',
|
|
1106
|
+
{ kind: track.kind, label: track.label, readyState: track.readyState },
|
|
1107
|
+
'warn',
|
|
1108
|
+
'Voice capture stopped'
|
|
1109
|
+
);
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
emit(
|
|
1114
|
+
'voice.capture.detectedAudio',
|
|
1115
|
+
{ trackCount: tracks.length, kinds: tracks.map((track) => track.kind) },
|
|
1116
|
+
'info',
|
|
1117
|
+
'Voice capture detected audio'
|
|
1118
|
+
);
|
|
1119
|
+
|
|
1120
|
+
return stream;
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
emit(
|
|
1123
|
+
'voice.pipeline.notReady',
|
|
1124
|
+
{ message: String(error && error.message ? error.message : error) },
|
|
1125
|
+
'error',
|
|
1126
|
+
String(error && error.message ? error.message : error)
|
|
1127
|
+
);
|
|
1128
|
+
throw error;
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
document.addEventListener('visibilitychange', () => {
|
|
1134
|
+
emit(
|
|
1135
|
+
'dom.state.changed',
|
|
1136
|
+
{ visibilityState: document.visibilityState },
|
|
1137
|
+
document.visibilityState === 'hidden' ? 'warn' : 'info',
|
|
1138
|
+
'Visibility ' + document.visibilityState
|
|
1139
|
+
);
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
patchPermissions();
|
|
1143
|
+
emit('voice.pipeline.ready', { url: location.href }, 'info', 'Trace hooks ready');
|
|
1144
|
+
})();
|
|
1145
|
+
`;
|
|
1146
|
+
|
|
1147
|
+
// src/trace/model.ts
|
|
1148
|
+
function createTraceId(prefix = "evt") {
|
|
1149
|
+
const random = Math.random().toString(36).slice(2, 10);
|
|
1150
|
+
return `${prefix}-${Date.now().toString(36)}-${random}`;
|
|
1151
|
+
}
|
|
1152
|
+
function normalizeTraceEvent(event) {
|
|
1153
|
+
return {
|
|
1154
|
+
traceId: event.traceId ?? createTraceId(event.channel),
|
|
1155
|
+
ts: event.ts ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1156
|
+
elapsedMs: event.elapsedMs ?? 0,
|
|
1157
|
+
severity: event.severity ?? inferSeverity(event.event),
|
|
1158
|
+
data: event.data ?? {},
|
|
1159
|
+
...event
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
function inferSeverity(eventName) {
|
|
1163
|
+
if (eventName.includes(".failed") || eventName.includes(".error") || eventName.includes("exception") || eventName.includes("notReady")) {
|
|
1164
|
+
return "error";
|
|
1165
|
+
}
|
|
1166
|
+
if (eventName.includes(".closed") || eventName.includes(".warn") || eventName.includes(".changed")) {
|
|
1167
|
+
return "warn";
|
|
1168
|
+
}
|
|
1169
|
+
return "info";
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// src/trace/live.ts
|
|
1173
|
+
function globToRegex(pattern) {
|
|
1174
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
1175
|
+
const withWildcards = escaped.replace(/\*/g, ".*");
|
|
1176
|
+
return new RegExp(`^${withWildcards}$`);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
329
1179
|
// src/actions/executor.ts
|
|
330
1180
|
var DEFAULT_TIMEOUT = 3e4;
|
|
1181
|
+
var DEFAULT_RECORDING_SKIP_ACTIONS = [
|
|
1182
|
+
"wait",
|
|
1183
|
+
"snapshot",
|
|
1184
|
+
"forms",
|
|
1185
|
+
"text",
|
|
1186
|
+
"screenshot"
|
|
1187
|
+
];
|
|
1188
|
+
function loadExistingRecording(manifestPath) {
|
|
1189
|
+
try {
|
|
1190
|
+
const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
1191
|
+
if (raw.version === 1) {
|
|
1192
|
+
const legacy = raw;
|
|
1193
|
+
return {
|
|
1194
|
+
frames: Array.isArray(legacy.frames) ? legacy.frames : [],
|
|
1195
|
+
traceEvents: [],
|
|
1196
|
+
recordedAt: legacy.recordedAt,
|
|
1197
|
+
startUrl: legacy.startUrl
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
const artifact = canonicalizeRecordingArtifact(raw);
|
|
1201
|
+
const screenshotsByAction = new Map(artifact.screenshots.map((shot) => [shot.actionId, shot]));
|
|
1202
|
+
const frames = artifact.actions.map((action, index) => {
|
|
1203
|
+
const screenshot = screenshotsByAction.get(action.id);
|
|
1204
|
+
return {
|
|
1205
|
+
seq: index + 1,
|
|
1206
|
+
timestamp: Date.parse(action.ts),
|
|
1207
|
+
action: action.action,
|
|
1208
|
+
selector: action.selector,
|
|
1209
|
+
selectorUsed: action.selectorUsed,
|
|
1210
|
+
value: action.value,
|
|
1211
|
+
url: action.url,
|
|
1212
|
+
coordinates: action.coordinates,
|
|
1213
|
+
boundingBox: action.boundingBox,
|
|
1214
|
+
success: action.success,
|
|
1215
|
+
durationMs: action.durationMs,
|
|
1216
|
+
error: action.error,
|
|
1217
|
+
screenshot: screenshot?.file ?? "",
|
|
1218
|
+
pageUrl: action.pageUrl,
|
|
1219
|
+
pageTitle: action.pageTitle,
|
|
1220
|
+
stepIndex: action.stepIndex,
|
|
1221
|
+
actionId: action.id
|
|
1222
|
+
};
|
|
1223
|
+
});
|
|
1224
|
+
return {
|
|
1225
|
+
frames,
|
|
1226
|
+
traceEvents: artifact.trace.events,
|
|
1227
|
+
recordedAt: artifact.recordedAt,
|
|
1228
|
+
startUrl: artifact.session.startUrl
|
|
1229
|
+
};
|
|
1230
|
+
} catch {
|
|
1231
|
+
return { frames: [], traceEvents: [] };
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
331
1234
|
function classifyFailure(error) {
|
|
332
1235
|
if (error instanceof ElementNotFoundError) {
|
|
333
1236
|
return { reason: "missing" };
|
|
@@ -407,6 +1310,12 @@ var BatchExecutor = class {
|
|
|
407
1310
|
const { timeout = DEFAULT_TIMEOUT, onFail = "stop" } = options;
|
|
408
1311
|
const results = [];
|
|
409
1312
|
const startTime = Date.now();
|
|
1313
|
+
const recording = options.record ? this.createRecordingContext(options.record) : null;
|
|
1314
|
+
if (steps.some((step) => step.action === "waitForWsMessage")) {
|
|
1315
|
+
await this.ensureTraceHooks();
|
|
1316
|
+
}
|
|
1317
|
+
const startUrl = recording ? await this.getPageUrlSafe() : "";
|
|
1318
|
+
let stoppedAtIndex;
|
|
410
1319
|
for (let i = 0; i < steps.length; i++) {
|
|
411
1320
|
const step = steps[i];
|
|
412
1321
|
const stepStart = Date.now();
|
|
@@ -414,13 +1323,34 @@ var BatchExecutor = class {
|
|
|
414
1323
|
const retryDelay = step.retryDelay ?? 500;
|
|
415
1324
|
let lastError;
|
|
416
1325
|
let succeeded = false;
|
|
1326
|
+
if (recording) {
|
|
1327
|
+
recording.traceEvents.push(
|
|
1328
|
+
normalizeTraceEvent({
|
|
1329
|
+
traceId: createTraceId("action"),
|
|
1330
|
+
elapsedMs: Date.now() - startTime,
|
|
1331
|
+
channel: "action",
|
|
1332
|
+
event: "action.started",
|
|
1333
|
+
summary: `${step.action}${step.selector ? ` ${Array.isArray(step.selector) ? step.selector[0] : step.selector}` : ""}`,
|
|
1334
|
+
data: {
|
|
1335
|
+
action: step.action,
|
|
1336
|
+
selector: step.selector ?? null,
|
|
1337
|
+
url: step.url ?? null
|
|
1338
|
+
},
|
|
1339
|
+
actionId: `action-${i + 1}`,
|
|
1340
|
+
stepIndex: i,
|
|
1341
|
+
selector: step.selector,
|
|
1342
|
+
url: step.url
|
|
1343
|
+
})
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
417
1346
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
418
1347
|
if (attempt > 0) {
|
|
419
1348
|
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
420
1349
|
}
|
|
421
1350
|
try {
|
|
1351
|
+
this.page.resetLastActionPosition();
|
|
422
1352
|
const result = await this.executeStep(step, timeout);
|
|
423
|
-
|
|
1353
|
+
const stepResult = {
|
|
424
1354
|
index: i,
|
|
425
1355
|
action: step.action,
|
|
426
1356
|
selector: step.selector,
|
|
@@ -428,8 +1358,37 @@ var BatchExecutor = class {
|
|
|
428
1358
|
success: true,
|
|
429
1359
|
durationMs: Date.now() - stepStart,
|
|
430
1360
|
result: result.value,
|
|
431
|
-
text: result.text
|
|
432
|
-
|
|
1361
|
+
text: result.text,
|
|
1362
|
+
timestamp: Date.now(),
|
|
1363
|
+
coordinates: this.page.getLastActionCoordinates() ?? void 0,
|
|
1364
|
+
boundingBox: this.page.getLastActionBoundingBox() ?? void 0
|
|
1365
|
+
};
|
|
1366
|
+
if (recording && !recording.skipActions.has(step.action)) {
|
|
1367
|
+
await this.captureRecordingFrame(step, stepResult, recording);
|
|
1368
|
+
}
|
|
1369
|
+
if (recording) {
|
|
1370
|
+
recording.traceEvents.push(
|
|
1371
|
+
normalizeTraceEvent({
|
|
1372
|
+
traceId: createTraceId("action"),
|
|
1373
|
+
elapsedMs: Date.now() - startTime,
|
|
1374
|
+
channel: "action",
|
|
1375
|
+
event: "action.succeeded",
|
|
1376
|
+
summary: `${step.action} succeeded`,
|
|
1377
|
+
data: {
|
|
1378
|
+
action: step.action,
|
|
1379
|
+
selector: step.selector ?? null,
|
|
1380
|
+
selectorUsed: result.selectorUsed ?? null,
|
|
1381
|
+
durationMs: Date.now() - stepStart
|
|
1382
|
+
},
|
|
1383
|
+
actionId: `action-${i + 1}`,
|
|
1384
|
+
stepIndex: i,
|
|
1385
|
+
selector: step.selector,
|
|
1386
|
+
selectorUsed: result.selectorUsed,
|
|
1387
|
+
url: step.url
|
|
1388
|
+
})
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
results.push(stepResult);
|
|
433
1392
|
succeeded = true;
|
|
434
1393
|
break;
|
|
435
1394
|
} catch (error) {
|
|
@@ -450,7 +1409,7 @@ var BatchExecutor = class {
|
|
|
450
1409
|
} catch {
|
|
451
1410
|
}
|
|
452
1411
|
}
|
|
453
|
-
|
|
1412
|
+
const failedResult = {
|
|
454
1413
|
index: i,
|
|
455
1414
|
action: step.action,
|
|
456
1415
|
selector: step.selector,
|
|
@@ -460,25 +1419,183 @@ var BatchExecutor = class {
|
|
|
460
1419
|
hints,
|
|
461
1420
|
failureReason: reason,
|
|
462
1421
|
coveringElement,
|
|
463
|
-
suggestion: getSuggestion(reason)
|
|
464
|
-
|
|
1422
|
+
suggestion: getSuggestion(reason),
|
|
1423
|
+
timestamp: Date.now()
|
|
1424
|
+
};
|
|
1425
|
+
if (recording && !recording.skipActions.has(step.action)) {
|
|
1426
|
+
await this.captureRecordingFrame(step, failedResult, recording);
|
|
1427
|
+
}
|
|
1428
|
+
if (recording) {
|
|
1429
|
+
recording.traceEvents.push(
|
|
1430
|
+
normalizeTraceEvent({
|
|
1431
|
+
traceId: createTraceId("action"),
|
|
1432
|
+
elapsedMs: Date.now() - startTime,
|
|
1433
|
+
channel: "action",
|
|
1434
|
+
event: "action.failed",
|
|
1435
|
+
severity: "error",
|
|
1436
|
+
summary: `${step.action} failed: ${errorMessage}`,
|
|
1437
|
+
data: {
|
|
1438
|
+
action: step.action,
|
|
1439
|
+
selector: step.selector ?? null,
|
|
1440
|
+
error: errorMessage,
|
|
1441
|
+
reason
|
|
1442
|
+
},
|
|
1443
|
+
actionId: `action-${i + 1}`,
|
|
1444
|
+
stepIndex: i,
|
|
1445
|
+
selector: step.selector,
|
|
1446
|
+
url: step.url
|
|
1447
|
+
})
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
results.push(failedResult);
|
|
465
1451
|
if (onFail === "stop" && !step.optional) {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
stoppedAtIndex: i,
|
|
469
|
-
steps: results,
|
|
470
|
-
totalDurationMs: Date.now() - startTime
|
|
471
|
-
};
|
|
1452
|
+
stoppedAtIndex = i;
|
|
1453
|
+
break;
|
|
472
1454
|
}
|
|
473
1455
|
}
|
|
474
1456
|
}
|
|
475
|
-
const
|
|
1457
|
+
const totalDurationMs = Date.now() - startTime;
|
|
1458
|
+
const allSuccess = stoppedAtIndex === void 0 && results.every((result) => result.success || steps[result.index]?.optional);
|
|
1459
|
+
let recordingManifest;
|
|
1460
|
+
if (recording) {
|
|
1461
|
+
recordingManifest = await this.writeRecordingManifest(
|
|
1462
|
+
recording,
|
|
1463
|
+
startTime,
|
|
1464
|
+
startUrl,
|
|
1465
|
+
allSuccess,
|
|
1466
|
+
steps
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
476
1469
|
return {
|
|
477
1470
|
success: allSuccess,
|
|
1471
|
+
stoppedAtIndex,
|
|
478
1472
|
steps: results,
|
|
479
|
-
totalDurationMs
|
|
1473
|
+
totalDurationMs,
|
|
1474
|
+
recordingManifest
|
|
480
1475
|
};
|
|
481
1476
|
}
|
|
1477
|
+
createRecordingContext(record) {
|
|
1478
|
+
const baseDir = record.outputDir ?? (0, import_node_path.join)(process.cwd(), ".browser-pilot");
|
|
1479
|
+
const screenshotDir = (0, import_node_path.join)(baseDir, "screenshots");
|
|
1480
|
+
const manifestPath = (0, import_node_path.join)(baseDir, "recording.json");
|
|
1481
|
+
const existing = loadExistingRecording(manifestPath);
|
|
1482
|
+
fs.mkdirSync(screenshotDir, { recursive: true });
|
|
1483
|
+
return {
|
|
1484
|
+
baseDir,
|
|
1485
|
+
screenshotDir,
|
|
1486
|
+
sessionId: record.sessionId ?? this.page.targetId,
|
|
1487
|
+
frames: existing.frames,
|
|
1488
|
+
traceEvents: existing.traceEvents,
|
|
1489
|
+
format: record.format ?? "webp",
|
|
1490
|
+
quality: Math.max(0, Math.min(100, record.quality ?? 40)),
|
|
1491
|
+
highlights: record.highlights !== false,
|
|
1492
|
+
skipActions: new Set(record.skipActions ?? DEFAULT_RECORDING_SKIP_ACTIONS)
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
async getPageUrlSafe() {
|
|
1496
|
+
try {
|
|
1497
|
+
return await this.page.url();
|
|
1498
|
+
} catch {
|
|
1499
|
+
return "";
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Capture a recording screenshot frame with optional highlight overlay
|
|
1504
|
+
*/
|
|
1505
|
+
async captureRecordingFrame(step, stepResult, recording) {
|
|
1506
|
+
const targetMetadata = this.page.getLastActionTargetMetadata();
|
|
1507
|
+
let highlightInjected = false;
|
|
1508
|
+
try {
|
|
1509
|
+
const ts = Date.now();
|
|
1510
|
+
const seq = String(recording.frames.length + 1).padStart(4, "0");
|
|
1511
|
+
const filename = `${seq}-${ts}-${stepResult.action}.${recording.format}`;
|
|
1512
|
+
const filepath = (0, import_node_path.join)(recording.screenshotDir, filename);
|
|
1513
|
+
if (recording.highlights) {
|
|
1514
|
+
const kind = stepToHighlightKind(stepResult);
|
|
1515
|
+
if (kind) {
|
|
1516
|
+
await injectActionHighlight(this.page, {
|
|
1517
|
+
kind,
|
|
1518
|
+
bbox: stepResult.boundingBox,
|
|
1519
|
+
point: stepResult.coordinates,
|
|
1520
|
+
label: getHighlightLabel(step, stepResult, targetMetadata)
|
|
1521
|
+
});
|
|
1522
|
+
highlightInjected = true;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
const base64 = await this.page.screenshot({
|
|
1526
|
+
format: recording.format,
|
|
1527
|
+
quality: recording.quality
|
|
1528
|
+
});
|
|
1529
|
+
const buffer = Buffer.from(base64, "base64");
|
|
1530
|
+
fs.writeFileSync(filepath, buffer);
|
|
1531
|
+
stepResult.screenshotPath = filepath;
|
|
1532
|
+
let pageUrl;
|
|
1533
|
+
let pageTitle;
|
|
1534
|
+
try {
|
|
1535
|
+
pageUrl = await this.page.url();
|
|
1536
|
+
pageTitle = await this.page.title();
|
|
1537
|
+
} catch {
|
|
1538
|
+
}
|
|
1539
|
+
recording.frames.push({
|
|
1540
|
+
seq: recording.frames.length + 1,
|
|
1541
|
+
timestamp: ts,
|
|
1542
|
+
action: stepResult.action,
|
|
1543
|
+
selector: stepResult.selectorUsed ?? (Array.isArray(step.selector) ? step.selector[0] : step.selector),
|
|
1544
|
+
selectorUsed: stepResult.selectorUsed,
|
|
1545
|
+
value: redactValueForRecording(
|
|
1546
|
+
typeof step.value === "string" ? step.value : void 0,
|
|
1547
|
+
targetMetadata
|
|
1548
|
+
),
|
|
1549
|
+
url: step.url,
|
|
1550
|
+
coordinates: stepResult.coordinates,
|
|
1551
|
+
boundingBox: stepResult.boundingBox,
|
|
1552
|
+
success: stepResult.success,
|
|
1553
|
+
durationMs: stepResult.durationMs,
|
|
1554
|
+
error: stepResult.error,
|
|
1555
|
+
screenshot: filename,
|
|
1556
|
+
pageUrl,
|
|
1557
|
+
pageTitle,
|
|
1558
|
+
stepIndex: stepResult.index,
|
|
1559
|
+
actionId: `action-${stepResult.index + 1}`
|
|
1560
|
+
});
|
|
1561
|
+
} catch {
|
|
1562
|
+
} finally {
|
|
1563
|
+
if (recording.highlights || highlightInjected) {
|
|
1564
|
+
await removeActionHighlight(this.page);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Write recording manifest to disk
|
|
1570
|
+
*/
|
|
1571
|
+
async writeRecordingManifest(recording, startTime, startUrl, success, steps) {
|
|
1572
|
+
let endUrl = startUrl;
|
|
1573
|
+
try {
|
|
1574
|
+
endUrl = await this.page.url();
|
|
1575
|
+
} catch {
|
|
1576
|
+
}
|
|
1577
|
+
const manifestPath = (0, import_node_path.join)(recording.baseDir, "recording.json");
|
|
1578
|
+
let recordedAt = new Date(startTime).toISOString();
|
|
1579
|
+
let originalStartUrl = startUrl;
|
|
1580
|
+
const existing = loadExistingRecording(manifestPath);
|
|
1581
|
+
if (existing.recordedAt) recordedAt = existing.recordedAt;
|
|
1582
|
+
if (existing.startUrl) originalStartUrl = existing.startUrl;
|
|
1583
|
+
const manifest = createRecordingManifest({
|
|
1584
|
+
recordedAt,
|
|
1585
|
+
sessionId: recording.sessionId,
|
|
1586
|
+
startUrl: originalStartUrl,
|
|
1587
|
+
endUrl,
|
|
1588
|
+
targetId: this.page.targetId,
|
|
1589
|
+
steps,
|
|
1590
|
+
frames: recording.frames,
|
|
1591
|
+
traceEvents: recording.traceEvents,
|
|
1592
|
+
notes: success ? [] : ["Replay ended with at least one failed action."],
|
|
1593
|
+
recordingManifest: "recording.json",
|
|
1594
|
+
screenshotDir: "screenshots/"
|
|
1595
|
+
});
|
|
1596
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
1597
|
+
return manifestPath;
|
|
1598
|
+
}
|
|
482
1599
|
/**
|
|
483
1600
|
* Execute a single step
|
|
484
1601
|
*/
|
|
@@ -758,6 +1875,39 @@ var BatchExecutor = class {
|
|
|
758
1875
|
}
|
|
759
1876
|
return { selectorUsed: usedSelector, value: actual };
|
|
760
1877
|
}
|
|
1878
|
+
case "waitForWsMessage": {
|
|
1879
|
+
if (typeof step.match !== "string") {
|
|
1880
|
+
throw new Error("waitForWsMessage requires match");
|
|
1881
|
+
}
|
|
1882
|
+
const message = await this.waitForWsMessage(step.match, step.where, timeout);
|
|
1883
|
+
return { value: message };
|
|
1884
|
+
}
|
|
1885
|
+
case "assertNoConsoleErrors": {
|
|
1886
|
+
await this.assertNoConsoleErrors(step.windowMs ?? timeout);
|
|
1887
|
+
return {};
|
|
1888
|
+
}
|
|
1889
|
+
case "assertTextChanged": {
|
|
1890
|
+
const selector = Array.isArray(step.selector) ? step.selector[0] : step.selector;
|
|
1891
|
+
if (typeof step.to !== "string") {
|
|
1892
|
+
throw new Error("assertTextChanged requires to");
|
|
1893
|
+
}
|
|
1894
|
+
const text = await this.assertTextChanged(selector, step.from, step.to, timeout);
|
|
1895
|
+
return { selectorUsed: selector, text };
|
|
1896
|
+
}
|
|
1897
|
+
case "assertPermission": {
|
|
1898
|
+
if (!step.name || !step.state) {
|
|
1899
|
+
throw new Error("assertPermission requires name and state");
|
|
1900
|
+
}
|
|
1901
|
+
const permission = await this.assertPermission(step.name, step.state);
|
|
1902
|
+
return { value: permission };
|
|
1903
|
+
}
|
|
1904
|
+
case "assertMediaTrackLive": {
|
|
1905
|
+
if (!step.kind) {
|
|
1906
|
+
throw new Error("assertMediaTrackLive requires kind");
|
|
1907
|
+
}
|
|
1908
|
+
const media = await this.assertMediaTrackLive(step.kind);
|
|
1909
|
+
return { value: media };
|
|
1910
|
+
}
|
|
761
1911
|
default: {
|
|
762
1912
|
const action = step.action;
|
|
763
1913
|
const aliases = {
|
|
@@ -811,7 +1961,7 @@ var BatchExecutor = class {
|
|
|
811
1961
|
};
|
|
812
1962
|
const suggestion = aliases[action.toLowerCase()];
|
|
813
1963
|
const hint = suggestion ? ` Did you mean "${suggestion}"?` : "";
|
|
814
|
-
const valid = "goto, click, fill, type, select, check, uncheck, submit, press, shortcut, focus, hover, scroll, wait, snapshot, forms, screenshot, evaluate, text, newTab, closeTab, switchFrame, switchToMain, assertVisible, assertExists, assertText, assertUrl, assertValue";
|
|
1964
|
+
const valid = "goto, click, fill, type, select, check, uncheck, submit, press, shortcut, focus, hover, scroll, wait, snapshot, forms, screenshot, evaluate, text, newTab, closeTab, switchFrame, switchToMain, assertVisible, assertExists, assertText, assertUrl, assertValue, waitForWsMessage, assertNoConsoleErrors, assertTextChanged, assertPermission, assertMediaTrackLive";
|
|
815
1965
|
throw new Error(`Unknown action "${action}".${hint}
|
|
816
1966
|
|
|
817
1967
|
Valid actions: ${valid}`);
|
|
@@ -827,6 +1977,233 @@ Valid actions: ${valid}`);
|
|
|
827
1977
|
if (matched) return matched;
|
|
828
1978
|
return Array.isArray(selector) ? selector[0] : selector;
|
|
829
1979
|
}
|
|
1980
|
+
async ensureTraceHooks() {
|
|
1981
|
+
await this.page.cdpClient.send("Runtime.enable");
|
|
1982
|
+
await this.page.cdpClient.send("Page.enable");
|
|
1983
|
+
await this.page.cdpClient.send("Network.enable");
|
|
1984
|
+
try {
|
|
1985
|
+
await this.page.cdpClient.send("Runtime.addBinding", { name: TRACE_BINDING_NAME });
|
|
1986
|
+
} catch {
|
|
1987
|
+
}
|
|
1988
|
+
await this.page.cdpClient.send("Page.addScriptToEvaluateOnNewDocument", { source: TRACE_SCRIPT });
|
|
1989
|
+
await this.page.cdpClient.send("Runtime.evaluate", { expression: TRACE_SCRIPT, awaitPromise: false });
|
|
1990
|
+
}
|
|
1991
|
+
async waitForWsMessage(match, where, timeout) {
|
|
1992
|
+
await this.ensureTraceHooks();
|
|
1993
|
+
const regex = globToRegex(match);
|
|
1994
|
+
const wsUrls = /* @__PURE__ */ new Map();
|
|
1995
|
+
const recentMatch = await this.findRecentWsMessage(regex, where);
|
|
1996
|
+
if (recentMatch) {
|
|
1997
|
+
return recentMatch;
|
|
1998
|
+
}
|
|
1999
|
+
return new Promise((resolve, reject) => {
|
|
2000
|
+
const cleanup = () => {
|
|
2001
|
+
this.page.cdpClient.off("Network.webSocketCreated", onCreated);
|
|
2002
|
+
this.page.cdpClient.off("Network.webSocketFrameReceived", onFrame);
|
|
2003
|
+
this.page.cdpClient.off("Runtime.bindingCalled", onBinding);
|
|
2004
|
+
clearTimeout(timer);
|
|
2005
|
+
};
|
|
2006
|
+
const onCreated = (params) => {
|
|
2007
|
+
wsUrls.set(String(params["requestId"] ?? ""), String(params["url"] ?? ""));
|
|
2008
|
+
};
|
|
2009
|
+
const onFrame = (params) => {
|
|
2010
|
+
const requestId = String(params["requestId"] ?? "");
|
|
2011
|
+
const response = params["response"] ?? {};
|
|
2012
|
+
const payload = String(response.payloadData ?? "");
|
|
2013
|
+
const url = wsUrls.get(requestId) ?? "";
|
|
2014
|
+
if (!regex.test(url) && !regex.test(payload)) {
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
if (where && !this.payloadMatchesWhere(payload, where)) {
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
cleanup();
|
|
2021
|
+
resolve({ requestId, url, payload });
|
|
2022
|
+
};
|
|
2023
|
+
const onBinding = (params) => {
|
|
2024
|
+
if (params["name"] !== TRACE_BINDING_NAME) {
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
try {
|
|
2028
|
+
const parsed = JSON.parse(String(params["payload"] ?? ""));
|
|
2029
|
+
if (parsed.event !== "ws.frame.received") {
|
|
2030
|
+
return;
|
|
2031
|
+
}
|
|
2032
|
+
const data = parsed.data ?? {};
|
|
2033
|
+
const payload = String(data["payload"] ?? "");
|
|
2034
|
+
const url = String(data["url"] ?? "");
|
|
2035
|
+
if (!regex.test(url) && !regex.test(payload)) {
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
if (where && !this.payloadMatchesWhere(payload, where)) {
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
cleanup();
|
|
2042
|
+
resolve({
|
|
2043
|
+
requestId: String(data["connectionId"] ?? ""),
|
|
2044
|
+
url,
|
|
2045
|
+
payload
|
|
2046
|
+
});
|
|
2047
|
+
} catch {
|
|
2048
|
+
}
|
|
2049
|
+
};
|
|
2050
|
+
const timer = setTimeout(() => {
|
|
2051
|
+
cleanup();
|
|
2052
|
+
reject(new Error(`Timed out waiting for WebSocket message matching ${match}`));
|
|
2053
|
+
}, timeout);
|
|
2054
|
+
this.page.cdpClient.on("Network.webSocketCreated", onCreated);
|
|
2055
|
+
this.page.cdpClient.on("Network.webSocketFrameReceived", onFrame);
|
|
2056
|
+
this.page.cdpClient.on("Runtime.bindingCalled", onBinding);
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
payloadMatchesWhere(payload, where) {
|
|
2060
|
+
try {
|
|
2061
|
+
const parsed = JSON.parse(payload);
|
|
2062
|
+
return Object.entries(where).every(([key, expected]) => {
|
|
2063
|
+
const actual = key.split(".").reduce((current, part) => {
|
|
2064
|
+
if (!current || typeof current !== "object") {
|
|
2065
|
+
return void 0;
|
|
2066
|
+
}
|
|
2067
|
+
return current[part];
|
|
2068
|
+
}, parsed);
|
|
2069
|
+
return actual === expected;
|
|
2070
|
+
});
|
|
2071
|
+
} catch {
|
|
2072
|
+
return false;
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
async findRecentWsMessage(regex, where) {
|
|
2076
|
+
const recent = await this.page.evaluate(
|
|
2077
|
+
"(() => Array.isArray(globalThis.__bpTraceRecentEvents) ? globalThis.__bpTraceRecentEvents : [])()"
|
|
2078
|
+
);
|
|
2079
|
+
if (!Array.isArray(recent)) {
|
|
2080
|
+
return null;
|
|
2081
|
+
}
|
|
2082
|
+
for (let i = recent.length - 1; i >= 0; i--) {
|
|
2083
|
+
const entry = recent[i];
|
|
2084
|
+
if (!entry || typeof entry !== "object") {
|
|
2085
|
+
continue;
|
|
2086
|
+
}
|
|
2087
|
+
const event = String(entry["event"] ?? "");
|
|
2088
|
+
if (event !== "ws.frame.received") {
|
|
2089
|
+
continue;
|
|
2090
|
+
}
|
|
2091
|
+
const data = entry["data"] ?? {};
|
|
2092
|
+
const payload = String(data["payload"] ?? "");
|
|
2093
|
+
const url = String(data["url"] ?? "");
|
|
2094
|
+
if (!regex.test(url) && !regex.test(payload)) {
|
|
2095
|
+
continue;
|
|
2096
|
+
}
|
|
2097
|
+
if (where && !this.payloadMatchesWhere(payload, where)) {
|
|
2098
|
+
continue;
|
|
2099
|
+
}
|
|
2100
|
+
return {
|
|
2101
|
+
requestId: String(data["connectionId"] ?? ""),
|
|
2102
|
+
url,
|
|
2103
|
+
payload
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
return null;
|
|
2107
|
+
}
|
|
2108
|
+
async assertNoConsoleErrors(windowMs) {
|
|
2109
|
+
await this.page.cdpClient.send("Runtime.enable");
|
|
2110
|
+
return new Promise((resolve, reject) => {
|
|
2111
|
+
const errors = [];
|
|
2112
|
+
const cleanup = () => {
|
|
2113
|
+
this.page.cdpClient.off("Runtime.consoleAPICalled", onConsole);
|
|
2114
|
+
this.page.cdpClient.off("Runtime.exceptionThrown", onException);
|
|
2115
|
+
clearTimeout(timer);
|
|
2116
|
+
};
|
|
2117
|
+
const onConsole = (params) => {
|
|
2118
|
+
if (params["type"] !== "error") {
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
const args = Array.isArray(params["args"]) ? params["args"] : [];
|
|
2122
|
+
errors.push(
|
|
2123
|
+
args.map((entry) => String(entry["value"] ?? entry["description"] ?? "")).filter(Boolean).join(" ")
|
|
2124
|
+
);
|
|
2125
|
+
};
|
|
2126
|
+
const onException = (params) => {
|
|
2127
|
+
const details = params["exceptionDetails"] ?? {};
|
|
2128
|
+
errors.push(String(details["text"] ?? "Runtime exception"));
|
|
2129
|
+
};
|
|
2130
|
+
const timer = setTimeout(() => {
|
|
2131
|
+
cleanup();
|
|
2132
|
+
if (errors.length > 0) {
|
|
2133
|
+
reject(new Error(`Console errors detected: ${errors.join(" | ")}`));
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
resolve();
|
|
2137
|
+
}, windowMs);
|
|
2138
|
+
this.page.cdpClient.on("Runtime.consoleAPICalled", onConsole);
|
|
2139
|
+
this.page.cdpClient.on("Runtime.exceptionThrown", onException);
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
async assertTextChanged(selector, from, to, timeout) {
|
|
2143
|
+
const initialText = from ?? await this.page.text(selector);
|
|
2144
|
+
const deadline = Date.now() + timeout;
|
|
2145
|
+
while (Date.now() < deadline) {
|
|
2146
|
+
const text = await this.page.text(selector);
|
|
2147
|
+
if (text !== initialText && text.includes(to)) {
|
|
2148
|
+
return text;
|
|
2149
|
+
}
|
|
2150
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2151
|
+
}
|
|
2152
|
+
throw new Error(`Text did not change to include ${JSON.stringify(to)}`);
|
|
2153
|
+
}
|
|
2154
|
+
async assertPermission(name, state) {
|
|
2155
|
+
const result = await this.page.evaluate(
|
|
2156
|
+
`(() => navigator.permissions.query({ name: ${JSON.stringify(name)} }).then((status) => ({ name: ${JSON.stringify(name)}, state: status.state })))()`
|
|
2157
|
+
);
|
|
2158
|
+
if (!result || typeof result !== "object" || result.state !== state) {
|
|
2159
|
+
throw new Error(`Permission ${name} is not ${state}`);
|
|
2160
|
+
}
|
|
2161
|
+
return result;
|
|
2162
|
+
}
|
|
2163
|
+
async assertMediaTrackLive(kind) {
|
|
2164
|
+
const result = await this.page.evaluate(
|
|
2165
|
+
`(() => {
|
|
2166
|
+
const requestedKind = ${JSON.stringify(kind)};
|
|
2167
|
+
const mediaElements = Array.from(document.querySelectorAll('audio,video')).map((el) => {
|
|
2168
|
+
const tracks = [];
|
|
2169
|
+
if (el.srcObject && typeof el.srcObject.getTracks === 'function') {
|
|
2170
|
+
tracks.push(...el.srcObject.getTracks());
|
|
2171
|
+
}
|
|
2172
|
+
return {
|
|
2173
|
+
tag: el.tagName.toLowerCase(),
|
|
2174
|
+
paused: !!el.paused,
|
|
2175
|
+
tracks: tracks.map((track) => ({
|
|
2176
|
+
kind: track.kind,
|
|
2177
|
+
readyState: track.readyState,
|
|
2178
|
+
enabled: track.enabled,
|
|
2179
|
+
label: track.label,
|
|
2180
|
+
})),
|
|
2181
|
+
};
|
|
2182
|
+
});
|
|
2183
|
+
|
|
2184
|
+
const globalTracks =
|
|
2185
|
+
window.__bpStream && typeof window.__bpStream.getTracks === 'function'
|
|
2186
|
+
? window.__bpStream.getTracks().map((track) => ({
|
|
2187
|
+
kind: track.kind,
|
|
2188
|
+
readyState: track.readyState,
|
|
2189
|
+
enabled: track.enabled,
|
|
2190
|
+
label: track.label,
|
|
2191
|
+
}))
|
|
2192
|
+
: [];
|
|
2193
|
+
|
|
2194
|
+
const liveTracks = mediaElements
|
|
2195
|
+
.flatMap((entry) => entry.tracks)
|
|
2196
|
+
.concat(globalTracks)
|
|
2197
|
+
.filter((track) => track.kind === requestedKind && track.readyState === 'live');
|
|
2198
|
+
|
|
2199
|
+
return { live: liveTracks.length > 0, mediaElements, globalTracks, liveTracks };
|
|
2200
|
+
})()`
|
|
2201
|
+
);
|
|
2202
|
+
if (!result || typeof result !== "object" || !result.live) {
|
|
2203
|
+
throw new Error(`No live ${kind} media track detected`);
|
|
2204
|
+
}
|
|
2205
|
+
return result;
|
|
2206
|
+
}
|
|
830
2207
|
};
|
|
831
2208
|
function addBatchToPage(page) {
|
|
832
2209
|
const executor = new BatchExecutor(page);
|
|
@@ -957,7 +2334,7 @@ var ACTION_RULES = {
|
|
|
957
2334
|
value: { type: "string|string[]" },
|
|
958
2335
|
trigger: { type: "string|string[]" },
|
|
959
2336
|
option: { type: "string|string[]" },
|
|
960
|
-
match: { type: "string"
|
|
2337
|
+
match: { type: "string" }
|
|
961
2338
|
}
|
|
962
2339
|
},
|
|
963
2340
|
check: {
|
|
@@ -1088,6 +2465,38 @@ var ACTION_RULES = {
|
|
|
1088
2465
|
expect: { type: "string" },
|
|
1089
2466
|
value: { type: "string" }
|
|
1090
2467
|
}
|
|
2468
|
+
},
|
|
2469
|
+
waitForWsMessage: {
|
|
2470
|
+
required: { match: { type: "string" } },
|
|
2471
|
+
optional: {
|
|
2472
|
+
where: { type: "object" }
|
|
2473
|
+
}
|
|
2474
|
+
},
|
|
2475
|
+
assertNoConsoleErrors: {
|
|
2476
|
+
required: {},
|
|
2477
|
+
optional: {
|
|
2478
|
+
windowMs: { type: "number" }
|
|
2479
|
+
}
|
|
2480
|
+
},
|
|
2481
|
+
assertTextChanged: {
|
|
2482
|
+
required: { to: { type: "string" } },
|
|
2483
|
+
optional: {
|
|
2484
|
+
selector: { type: "string|string[]" },
|
|
2485
|
+
from: { type: "string" }
|
|
2486
|
+
}
|
|
2487
|
+
},
|
|
2488
|
+
assertPermission: {
|
|
2489
|
+
required: {
|
|
2490
|
+
name: { type: "string" },
|
|
2491
|
+
state: { type: "string" }
|
|
2492
|
+
},
|
|
2493
|
+
optional: {}
|
|
2494
|
+
},
|
|
2495
|
+
assertMediaTrackLive: {
|
|
2496
|
+
required: {
|
|
2497
|
+
kind: { type: "string", enum: ["audio", "video"] }
|
|
2498
|
+
},
|
|
2499
|
+
optional: {}
|
|
1091
2500
|
}
|
|
1092
2501
|
};
|
|
1093
2502
|
var VALID_ACTIONS = Object.keys(ACTION_RULES);
|
|
@@ -1111,6 +2520,7 @@ var KNOWN_STEP_FIELDS = /* @__PURE__ */ new Set([
|
|
|
1111
2520
|
"trigger",
|
|
1112
2521
|
"option",
|
|
1113
2522
|
"match",
|
|
2523
|
+
"where",
|
|
1114
2524
|
"x",
|
|
1115
2525
|
"y",
|
|
1116
2526
|
"direction",
|
|
@@ -1120,7 +2530,13 @@ var KNOWN_STEP_FIELDS = /* @__PURE__ */ new Set([
|
|
|
1120
2530
|
"fullPage",
|
|
1121
2531
|
"expect",
|
|
1122
2532
|
"retry",
|
|
1123
|
-
"retryDelay"
|
|
2533
|
+
"retryDelay",
|
|
2534
|
+
"from",
|
|
2535
|
+
"to",
|
|
2536
|
+
"name",
|
|
2537
|
+
"state",
|
|
2538
|
+
"kind",
|
|
2539
|
+
"windowMs"
|
|
1124
2540
|
]);
|
|
1125
2541
|
function resolveAction(name) {
|
|
1126
2542
|
if (VALID_ACTIONS.includes(name)) {
|
|
@@ -1193,6 +2609,11 @@ function checkFieldType(value, rule) {
|
|
|
1193
2609
|
return `expected boolean or "auto", got ${typeof value}`;
|
|
1194
2610
|
}
|
|
1195
2611
|
return null;
|
|
2612
|
+
case "object":
|
|
2613
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2614
|
+
return `expected object, got ${Array.isArray(value) ? "array" : typeof value}`;
|
|
2615
|
+
}
|
|
2616
|
+
return null;
|
|
1196
2617
|
default: {
|
|
1197
2618
|
const _exhaustive = rule.type;
|
|
1198
2619
|
return `unknown type: ${_exhaustive}`;
|