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
|
@@ -2,6 +2,230 @@ import {
|
|
|
2
2
|
CDPError
|
|
3
3
|
} from "./chunk-JXAUPHZM.mjs";
|
|
4
4
|
|
|
5
|
+
// src/actions/executor.ts
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
|
|
9
|
+
// src/recording/redaction.ts
|
|
10
|
+
var REDACTED_VALUE = "[REDACTED]";
|
|
11
|
+
var SENSITIVE_AUTOCOMPLETE_TOKENS = [
|
|
12
|
+
"current-password",
|
|
13
|
+
"new-password",
|
|
14
|
+
"one-time-code",
|
|
15
|
+
"cc-number",
|
|
16
|
+
"cc-csc",
|
|
17
|
+
"cc-exp",
|
|
18
|
+
"cc-exp-month",
|
|
19
|
+
"cc-exp-year"
|
|
20
|
+
];
|
|
21
|
+
function autocompleteTokens(autocomplete) {
|
|
22
|
+
if (!autocomplete) return [];
|
|
23
|
+
return autocomplete.toLowerCase().split(/\s+/).map((token) => token.trim()).filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
function isSensitiveFieldMetadata(metadata) {
|
|
26
|
+
if (!metadata) return false;
|
|
27
|
+
if (metadata.sensitiveValue) return true;
|
|
28
|
+
const inputType = metadata.inputType?.toLowerCase();
|
|
29
|
+
if (inputType === "password" || inputType === "hidden") {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
const sensitiveAutocompleteTokens = new Set(SENSITIVE_AUTOCOMPLETE_TOKENS);
|
|
33
|
+
return autocompleteTokens(metadata.autocomplete).some(
|
|
34
|
+
(token) => sensitiveAutocompleteTokens.has(token)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
function redactValueForRecording(value, metadata) {
|
|
38
|
+
if (value === void 0) return void 0;
|
|
39
|
+
return isSensitiveFieldMetadata(metadata) ? REDACTED_VALUE : value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/browser/action-highlight.ts
|
|
43
|
+
var HIGHLIGHT_STYLES = {
|
|
44
|
+
click: { outline: "3px solid rgba(229,57,53,0.8)", badge: "#e53935", marker: "crosshair" },
|
|
45
|
+
fill: { outline: "3px solid rgba(33,150,243,0.8)", badge: "#2196f3" },
|
|
46
|
+
type: { outline: "3px solid rgba(33,150,243,0.6)", badge: "#2196f3" },
|
|
47
|
+
select: { outline: "3px solid rgba(156,39,176,0.8)", badge: "#9c27b0" },
|
|
48
|
+
hover: { outline: "2px dashed rgba(158,158,158,0.5)", badge: "#9e9e9e" },
|
|
49
|
+
scroll: { outline: "none", badge: "#607d8b", marker: "arrow" },
|
|
50
|
+
navigate: { outline: "none", badge: "#4caf50" },
|
|
51
|
+
submit: { outline: "3px solid rgba(255,152,0,0.8)", badge: "#ff9800" },
|
|
52
|
+
"assert-pass": { outline: "3px solid rgba(76,175,80,0.8)", badge: "#4caf50", marker: "check" },
|
|
53
|
+
"assert-fail": { outline: "3px solid rgba(244,67,54,0.8)", badge: "#f44336", marker: "cross" },
|
|
54
|
+
evaluate: { outline: "none", badge: "#ffc107" },
|
|
55
|
+
focus: { outline: "3px dotted rgba(33,150,243,0.6)", badge: "#2196f3" }
|
|
56
|
+
};
|
|
57
|
+
function buildHighlightScript(options) {
|
|
58
|
+
const style = HIGHLIGHT_STYLES[options.kind];
|
|
59
|
+
const label = options.label ? options.label.slice(0, 80) : void 0;
|
|
60
|
+
const escapedLabel = label ? label.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n") : "";
|
|
61
|
+
return `(function() {
|
|
62
|
+
// Remove any existing highlight
|
|
63
|
+
var existing = document.getElementById('__bp-action-highlight');
|
|
64
|
+
if (existing) existing.remove();
|
|
65
|
+
|
|
66
|
+
var container = document.createElement('div');
|
|
67
|
+
container.id = '__bp-action-highlight';
|
|
68
|
+
container.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:99999;';
|
|
69
|
+
|
|
70
|
+
${options.bbox ? `
|
|
71
|
+
// Element outline
|
|
72
|
+
var outline = document.createElement('div');
|
|
73
|
+
outline.style.cssText = 'position:fixed;' +
|
|
74
|
+
'left:${options.bbox.x}px;top:${options.bbox.y}px;' +
|
|
75
|
+
'width:${options.bbox.width}px;height:${options.bbox.height}px;' +
|
|
76
|
+
'${style.outline !== "none" ? `outline:${style.outline};outline-offset:-1px;` : ""}' +
|
|
77
|
+
'pointer-events:none;box-sizing:border-box;';
|
|
78
|
+
container.appendChild(outline);
|
|
79
|
+
` : ""}
|
|
80
|
+
|
|
81
|
+
${options.point && style.marker === "crosshair" ? `
|
|
82
|
+
// Crosshair at click point
|
|
83
|
+
var hLine = document.createElement('div');
|
|
84
|
+
hLine.style.cssText = 'position:fixed;left:${options.point.x - 12}px;top:${options.point.y}px;' +
|
|
85
|
+
'width:24px;height:2px;background:${style.badge};pointer-events:none;';
|
|
86
|
+
var vLine = document.createElement('div');
|
|
87
|
+
vLine.style.cssText = 'position:fixed;left:${options.point.x}px;top:${options.point.y - 12}px;' +
|
|
88
|
+
'width:2px;height:24px;background:${style.badge};pointer-events:none;';
|
|
89
|
+
// Dot at center
|
|
90
|
+
var dot = document.createElement('div');
|
|
91
|
+
dot.style.cssText = 'position:fixed;left:${options.point.x - 4}px;top:${options.point.y - 4}px;' +
|
|
92
|
+
'width:8px;height:8px;border-radius:50%;background:${style.badge};pointer-events:none;';
|
|
93
|
+
container.appendChild(hLine);
|
|
94
|
+
container.appendChild(vLine);
|
|
95
|
+
container.appendChild(dot);
|
|
96
|
+
` : ""}
|
|
97
|
+
|
|
98
|
+
${label ? `
|
|
99
|
+
// Badge with label
|
|
100
|
+
var badge = document.createElement('div');
|
|
101
|
+
badge.style.cssText = 'position:fixed;' +
|
|
102
|
+
${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;'"} +
|
|
103
|
+
'background:${style.badge};color:white;padding:4px 8px;' +
|
|
104
|
+
'font-family:monospace;font-size:12px;font-weight:bold;' +
|
|
105
|
+
'border-radius:3px;white-space:nowrap;max-width:400px;overflow:hidden;text-overflow:ellipsis;' +
|
|
106
|
+
'pointer-events:none;';
|
|
107
|
+
badge.textContent = '${escapedLabel}';
|
|
108
|
+
container.appendChild(badge);
|
|
109
|
+
` : ""}
|
|
110
|
+
|
|
111
|
+
${style.marker === "check" && options.bbox ? `
|
|
112
|
+
// Checkmark
|
|
113
|
+
var check = document.createElement('div');
|
|
114
|
+
check.style.cssText = 'position:fixed;left:${options.bbox.x + options.bbox.width / 2 - 10}px;' +
|
|
115
|
+
'top:${options.bbox.y + options.bbox.height / 2 - 10}px;' +
|
|
116
|
+
'width:20px;height:20px;font-size:18px;color:${style.badge};pointer-events:none;text-align:center;line-height:20px;';
|
|
117
|
+
check.textContent = '\\u2713';
|
|
118
|
+
container.appendChild(check);
|
|
119
|
+
` : ""}
|
|
120
|
+
|
|
121
|
+
${style.marker === "cross" && options.bbox ? `
|
|
122
|
+
// Cross mark
|
|
123
|
+
var cross = document.createElement('div');
|
|
124
|
+
cross.style.cssText = 'position:fixed;left:${options.bbox.x + options.bbox.width / 2 - 10}px;' +
|
|
125
|
+
'top:${options.bbox.y + options.bbox.height / 2 - 10}px;' +
|
|
126
|
+
'width:20px;height:20px;font-size:18px;color:${style.badge};pointer-events:none;text-align:center;line-height:20px;font-weight:bold;';
|
|
127
|
+
cross.textContent = '\\u2717';
|
|
128
|
+
container.appendChild(cross);
|
|
129
|
+
` : ""}
|
|
130
|
+
|
|
131
|
+
document.body.appendChild(container);
|
|
132
|
+
window.__bpRemoveActionHighlight = function() {
|
|
133
|
+
var el = document.getElementById('__bp-action-highlight');
|
|
134
|
+
if (el) el.remove();
|
|
135
|
+
delete window.__bpRemoveActionHighlight;
|
|
136
|
+
};
|
|
137
|
+
})();`;
|
|
138
|
+
}
|
|
139
|
+
async function injectActionHighlight(page, options) {
|
|
140
|
+
try {
|
|
141
|
+
await page.evaluate(buildHighlightScript(options));
|
|
142
|
+
} catch {
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function removeActionHighlight(page) {
|
|
146
|
+
try {
|
|
147
|
+
await page.evaluate(`(function() {
|
|
148
|
+
if (window.__bpRemoveActionHighlight) {
|
|
149
|
+
window.__bpRemoveActionHighlight();
|
|
150
|
+
}
|
|
151
|
+
})()`);
|
|
152
|
+
} catch {
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function stepToHighlightKind(step) {
|
|
156
|
+
switch (step.action) {
|
|
157
|
+
case "click":
|
|
158
|
+
return "click";
|
|
159
|
+
case "fill":
|
|
160
|
+
return "fill";
|
|
161
|
+
case "type":
|
|
162
|
+
return "type";
|
|
163
|
+
case "select":
|
|
164
|
+
return "select";
|
|
165
|
+
case "hover":
|
|
166
|
+
return "hover";
|
|
167
|
+
case "scroll":
|
|
168
|
+
return "scroll";
|
|
169
|
+
case "goto":
|
|
170
|
+
return "navigate";
|
|
171
|
+
case "submit":
|
|
172
|
+
return "submit";
|
|
173
|
+
case "focus":
|
|
174
|
+
return "focus";
|
|
175
|
+
case "evaluate":
|
|
176
|
+
case "press":
|
|
177
|
+
case "shortcut":
|
|
178
|
+
return "evaluate";
|
|
179
|
+
case "assertVisible":
|
|
180
|
+
case "assertExists":
|
|
181
|
+
case "assertText":
|
|
182
|
+
case "assertUrl":
|
|
183
|
+
case "assertValue":
|
|
184
|
+
return step.success ? "assert-pass" : "assert-fail";
|
|
185
|
+
// Observation-only actions — no highlight
|
|
186
|
+
case "wait":
|
|
187
|
+
case "snapshot":
|
|
188
|
+
case "forms":
|
|
189
|
+
case "text":
|
|
190
|
+
case "screenshot":
|
|
191
|
+
case "newTab":
|
|
192
|
+
case "closeTab":
|
|
193
|
+
case "switchFrame":
|
|
194
|
+
case "switchToMain":
|
|
195
|
+
return null;
|
|
196
|
+
default:
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function getHighlightLabel(step, result, targetMetadata) {
|
|
201
|
+
switch (step.action) {
|
|
202
|
+
case "fill":
|
|
203
|
+
case "type":
|
|
204
|
+
return typeof step.value === "string" ? `"${redactValueForRecording(step.value, targetMetadata)}"` : void 0;
|
|
205
|
+
case "select":
|
|
206
|
+
return redactValueForRecording(
|
|
207
|
+
typeof step.value === "string" ? step.value : void 0,
|
|
208
|
+
targetMetadata
|
|
209
|
+
);
|
|
210
|
+
case "goto":
|
|
211
|
+
return step.url;
|
|
212
|
+
case "evaluate":
|
|
213
|
+
return "JS";
|
|
214
|
+
case "press":
|
|
215
|
+
return step.key;
|
|
216
|
+
case "shortcut":
|
|
217
|
+
return step.combo;
|
|
218
|
+
case "assertText":
|
|
219
|
+
case "assertUrl":
|
|
220
|
+
case "assertValue":
|
|
221
|
+
case "assertVisible":
|
|
222
|
+
case "assertExists":
|
|
223
|
+
return result.success ? "\u2713" : "\u2717";
|
|
224
|
+
default:
|
|
225
|
+
return void 0;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
5
229
|
// src/browser/actionability.ts
|
|
6
230
|
var ActionabilityError = class extends Error {
|
|
7
231
|
failureType;
|
|
@@ -614,8 +838,677 @@ var NavigationError = class extends Error {
|
|
|
614
838
|
}
|
|
615
839
|
};
|
|
616
840
|
|
|
841
|
+
// src/trace/views.ts
|
|
842
|
+
function takeRecent(events, limit = 5) {
|
|
843
|
+
return events.slice(-limit).map((event) => ({
|
|
844
|
+
ts: event.ts,
|
|
845
|
+
event: event.event,
|
|
846
|
+
summary: event.summary,
|
|
847
|
+
severity: event.severity,
|
|
848
|
+
url: event.url
|
|
849
|
+
}));
|
|
850
|
+
}
|
|
851
|
+
function buildTraceSummaries(events) {
|
|
852
|
+
return {
|
|
853
|
+
ws: summarizeWs(events),
|
|
854
|
+
voice: summarizeVoice(events),
|
|
855
|
+
console: summarizeConsole(events),
|
|
856
|
+
permissions: summarizePermissions(events),
|
|
857
|
+
media: summarizeMedia(events),
|
|
858
|
+
ui: summarizeUi(events),
|
|
859
|
+
session: summarizeSession(events)
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
function summarizeWs(events) {
|
|
863
|
+
const relevant = events.filter((event) => event.channel === "ws" || event.event.startsWith("ws."));
|
|
864
|
+
const connections = /* @__PURE__ */ new Map();
|
|
865
|
+
for (const event of relevant) {
|
|
866
|
+
const id = event.connectionId ?? event.requestId ?? event.traceId;
|
|
867
|
+
let connection = connections.get(id);
|
|
868
|
+
if (!connection) {
|
|
869
|
+
connection = { id, sent: 0, received: 0, lastMessages: [] };
|
|
870
|
+
connections.set(id, connection);
|
|
871
|
+
}
|
|
872
|
+
connection.url = event.url ?? connection.url;
|
|
873
|
+
if (event.event === "ws.connection.created") {
|
|
874
|
+
connection.createdAt = event.ts;
|
|
875
|
+
}
|
|
876
|
+
if (event.event === "ws.connection.closed") {
|
|
877
|
+
connection.closedAt = event.ts;
|
|
878
|
+
}
|
|
879
|
+
if (event.event === "ws.frame.sent") {
|
|
880
|
+
connection.sent += 1;
|
|
881
|
+
const payload = typeof event.data["payload"] === "string" ? event.data["payload"] : "";
|
|
882
|
+
if (payload) connection.lastMessages.push(`sent: ${payload}`);
|
|
883
|
+
}
|
|
884
|
+
if (event.event === "ws.frame.received") {
|
|
885
|
+
connection.received += 1;
|
|
886
|
+
const payload = typeof event.data["payload"] === "string" ? event.data["payload"] : "";
|
|
887
|
+
if (payload) connection.lastMessages.push(`recv: ${payload}`);
|
|
888
|
+
}
|
|
889
|
+
connection.lastMessages = connection.lastMessages.slice(-3);
|
|
890
|
+
}
|
|
891
|
+
const values = [...connections.values()];
|
|
892
|
+
const reconnects = values.reduce((count, connection) => {
|
|
893
|
+
return connection.closedAt && !connection.createdAt ? count : count;
|
|
894
|
+
}, 0);
|
|
895
|
+
return {
|
|
896
|
+
view: "ws",
|
|
897
|
+
totalEvents: relevant.length,
|
|
898
|
+
connections: values.map((connection) => ({
|
|
899
|
+
id: connection.id,
|
|
900
|
+
url: connection.url ?? null,
|
|
901
|
+
createdAt: connection.createdAt ?? null,
|
|
902
|
+
closedAt: connection.closedAt ?? null,
|
|
903
|
+
sent: connection.sent,
|
|
904
|
+
received: connection.received,
|
|
905
|
+
lastMessages: connection.lastMessages,
|
|
906
|
+
connectedButSilent: !!connection.createdAt && !connection.closedAt && connection.sent + connection.received === 0
|
|
907
|
+
})),
|
|
908
|
+
reconnects,
|
|
909
|
+
recent: takeRecent(relevant)
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
function summarizeConsole(events) {
|
|
913
|
+
const relevant = events.filter(
|
|
914
|
+
(event) => event.channel === "console" || event.event.startsWith("console.") || event.event === "runtime.exception" || event.event === "runtime.unhandledRejection"
|
|
915
|
+
);
|
|
916
|
+
return {
|
|
917
|
+
view: "console",
|
|
918
|
+
errors: relevant.filter(
|
|
919
|
+
(event) => event.event === "console.error" || event.event === "runtime.exception" || event.event === "runtime.unhandledRejection"
|
|
920
|
+
).length,
|
|
921
|
+
warnings: relevant.filter((event) => event.event === "console.warn").length,
|
|
922
|
+
logs: relevant.filter((event) => event.event === "console.log").length,
|
|
923
|
+
recent: takeRecent(relevant)
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
function summarizePermissions(events) {
|
|
927
|
+
const relevant = events.filter(
|
|
928
|
+
(event) => event.channel === "permission" || event.event.startsWith("permission.")
|
|
929
|
+
);
|
|
930
|
+
const latest = /* @__PURE__ */ new Map();
|
|
931
|
+
for (const event of relevant) {
|
|
932
|
+
const name = typeof event.data["name"] === "string" ? event.data["name"] : null;
|
|
933
|
+
const state = typeof event.data["state"] === "string" ? event.data["state"] : null;
|
|
934
|
+
if (name && state) {
|
|
935
|
+
latest.set(name, state);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return {
|
|
939
|
+
view: "permissions",
|
|
940
|
+
states: Object.fromEntries(latest),
|
|
941
|
+
changes: relevant.filter((event) => event.event === "permission.changed").length,
|
|
942
|
+
recent: takeRecent(relevant)
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
function summarizeMedia(events) {
|
|
946
|
+
const relevant = events.filter(
|
|
947
|
+
(event) => event.channel === "media" || event.event.startsWith("media.")
|
|
948
|
+
);
|
|
949
|
+
const liveTracks = /* @__PURE__ */ new Map();
|
|
950
|
+
for (const event of relevant) {
|
|
951
|
+
const label = typeof event.data["label"] === "string" ? event.data["label"] : "";
|
|
952
|
+
const kind = typeof event.data["kind"] === "string" ? event.data["kind"] : "";
|
|
953
|
+
const key = `${kind}:${label}`;
|
|
954
|
+
if (event.event === "media.track.started") {
|
|
955
|
+
liveTracks.set(key, kind);
|
|
956
|
+
}
|
|
957
|
+
if (event.event === "media.track.ended") {
|
|
958
|
+
liveTracks.delete(key);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
return {
|
|
962
|
+
view: "media",
|
|
963
|
+
tracksStarted: relevant.filter((event) => event.event === "media.track.started").length,
|
|
964
|
+
tracksEnded: relevant.filter((event) => event.event === "media.track.ended").length,
|
|
965
|
+
playbackStarted: relevant.filter((event) => event.event === "media.playback.started").length,
|
|
966
|
+
playbackStopped: relevant.filter((event) => event.event === "media.playback.stopped").length,
|
|
967
|
+
liveTracks: [...liveTracks.values()],
|
|
968
|
+
recent: takeRecent(relevant)
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
function summarizeVoice(events) {
|
|
972
|
+
const relevant = events.filter(
|
|
973
|
+
(event) => event.channel === "voice" || event.event.startsWith("voice.")
|
|
974
|
+
);
|
|
975
|
+
return {
|
|
976
|
+
view: "voice",
|
|
977
|
+
ready: relevant.filter((event) => event.event === "voice.pipeline.ready").length,
|
|
978
|
+
notReady: relevant.filter((event) => event.event === "voice.pipeline.notReady").length,
|
|
979
|
+
captureStarted: relevant.filter((event) => event.event === "voice.capture.started").length,
|
|
980
|
+
captureStopped: relevant.filter((event) => event.event === "voice.capture.stopped").length,
|
|
981
|
+
detectedAudio: relevant.filter((event) => event.event === "voice.capture.detectedAudio").length,
|
|
982
|
+
recent: takeRecent(relevant)
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
function summarizeUi(events) {
|
|
986
|
+
const relevant = events.filter(
|
|
987
|
+
(event) => event.channel === "dom" || event.event.startsWith("dom.") || event.channel === "action"
|
|
988
|
+
);
|
|
989
|
+
return {
|
|
990
|
+
view: "ui",
|
|
991
|
+
actions: relevant.filter((event) => event.channel === "action").length,
|
|
992
|
+
domChanges: relevant.filter((event) => event.channel === "dom").length,
|
|
993
|
+
recent: takeRecent(relevant)
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
function summarizeSession(events) {
|
|
997
|
+
const byChannel = /* @__PURE__ */ new Map();
|
|
998
|
+
const failedActions = events.filter((event) => event.event === "action.failed").length;
|
|
999
|
+
for (const event of events) {
|
|
1000
|
+
byChannel.set(event.channel, (byChannel.get(event.channel) ?? 0) + 1);
|
|
1001
|
+
}
|
|
1002
|
+
return {
|
|
1003
|
+
view: "session",
|
|
1004
|
+
totalEvents: events.length,
|
|
1005
|
+
byChannel: Object.fromEntries(byChannel),
|
|
1006
|
+
failedActions,
|
|
1007
|
+
recent: takeRecent(events)
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// src/recording/manifest.ts
|
|
1012
|
+
function isCanonicalRecordingManifest(value) {
|
|
1013
|
+
return Boolean(
|
|
1014
|
+
value && typeof value === "object" && value.version === 2 && typeof value.session === "object"
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
function isLegacyRecordingManifest(value) {
|
|
1018
|
+
return Boolean(
|
|
1019
|
+
value && typeof value === "object" && value.version === 1 && Array.isArray(value.frames)
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
function createRecordingManifest(input) {
|
|
1023
|
+
const actions = input.frames.map((frame) => {
|
|
1024
|
+
const actionId = frame.actionId ?? `action-${frame.seq}`;
|
|
1025
|
+
return {
|
|
1026
|
+
id: actionId,
|
|
1027
|
+
stepIndex: frame.stepIndex ?? Math.max(0, frame.seq - 1),
|
|
1028
|
+
action: frame.action,
|
|
1029
|
+
selector: frame.selector,
|
|
1030
|
+
selectorUsed: frame.selectorUsed ?? frame.selector,
|
|
1031
|
+
value: frame.value,
|
|
1032
|
+
url: frame.url,
|
|
1033
|
+
success: frame.success,
|
|
1034
|
+
durationMs: frame.durationMs,
|
|
1035
|
+
error: frame.error,
|
|
1036
|
+
ts: new Date(frame.timestamp).toISOString(),
|
|
1037
|
+
pageUrl: frame.pageUrl,
|
|
1038
|
+
pageTitle: frame.pageTitle,
|
|
1039
|
+
coordinates: frame.coordinates,
|
|
1040
|
+
boundingBox: frame.boundingBox
|
|
1041
|
+
};
|
|
1042
|
+
});
|
|
1043
|
+
const screenshots = input.frames.map((frame) => ({
|
|
1044
|
+
id: `shot-${frame.seq}`,
|
|
1045
|
+
stepIndex: frame.stepIndex ?? Math.max(0, frame.seq - 1),
|
|
1046
|
+
actionId: frame.actionId ?? `action-${frame.seq}`,
|
|
1047
|
+
file: frame.screenshot,
|
|
1048
|
+
ts: new Date(frame.timestamp).toISOString(),
|
|
1049
|
+
success: frame.success,
|
|
1050
|
+
pageUrl: frame.pageUrl,
|
|
1051
|
+
pageTitle: frame.pageTitle,
|
|
1052
|
+
coordinates: frame.coordinates,
|
|
1053
|
+
boundingBox: frame.boundingBox
|
|
1054
|
+
}));
|
|
1055
|
+
return {
|
|
1056
|
+
version: 2,
|
|
1057
|
+
recordedAt: input.recordedAt,
|
|
1058
|
+
session: {
|
|
1059
|
+
id: input.sessionId,
|
|
1060
|
+
startUrl: input.startUrl,
|
|
1061
|
+
endUrl: input.endUrl,
|
|
1062
|
+
targetId: input.targetId,
|
|
1063
|
+
profile: input.profile
|
|
1064
|
+
},
|
|
1065
|
+
recipe: {
|
|
1066
|
+
steps: input.steps
|
|
1067
|
+
},
|
|
1068
|
+
actions,
|
|
1069
|
+
screenshots,
|
|
1070
|
+
trace: {
|
|
1071
|
+
events: input.traceEvents,
|
|
1072
|
+
summaries: buildTraceSummaries(input.traceEvents)
|
|
1073
|
+
},
|
|
1074
|
+
assertions: input.assertions ?? [],
|
|
1075
|
+
notes: input.notes ?? [],
|
|
1076
|
+
artifacts: {
|
|
1077
|
+
recordingManifest: input.recordingManifest ?? "recording.json",
|
|
1078
|
+
screenshotDir: input.screenshotDir ?? "screenshots/"
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
function canonicalizeRecordingArtifact(value) {
|
|
1083
|
+
if (isCanonicalRecordingManifest(value)) {
|
|
1084
|
+
return value;
|
|
1085
|
+
}
|
|
1086
|
+
if (!isLegacyRecordingManifest(value)) {
|
|
1087
|
+
throw new Error("Unsupported recording artifact");
|
|
1088
|
+
}
|
|
1089
|
+
const traceEvents = buildTraceEventsFromLegacy(value);
|
|
1090
|
+
const steps = value.frames.map((frame) => frameToStep(frame));
|
|
1091
|
+
return createRecordingManifest({
|
|
1092
|
+
recordedAt: value.recordedAt,
|
|
1093
|
+
sessionId: value.sessionId,
|
|
1094
|
+
startUrl: value.startUrl,
|
|
1095
|
+
endUrl: value.endUrl,
|
|
1096
|
+
steps,
|
|
1097
|
+
frames: value.frames,
|
|
1098
|
+
traceEvents,
|
|
1099
|
+
notes: ["Converted from legacy recording manifest"]
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
function buildTraceEventsFromLegacy(value) {
|
|
1103
|
+
const events = [];
|
|
1104
|
+
for (const frame of value.frames) {
|
|
1105
|
+
events.push({
|
|
1106
|
+
traceId: frame.actionId ?? `legacy-${frame.seq}`,
|
|
1107
|
+
sessionId: value.sessionId,
|
|
1108
|
+
ts: new Date(frame.timestamp).toISOString(),
|
|
1109
|
+
elapsedMs: frame.timestamp - new Date(value.recordedAt).getTime(),
|
|
1110
|
+
channel: "action",
|
|
1111
|
+
event: frame.success ? "action.succeeded" : "action.failed",
|
|
1112
|
+
severity: frame.success ? "info" : "error",
|
|
1113
|
+
summary: `${frame.action}${frame.selector ? ` ${frame.selector}` : ""}`,
|
|
1114
|
+
data: {
|
|
1115
|
+
action: frame.action,
|
|
1116
|
+
selector: frame.selector,
|
|
1117
|
+
value: frame.value ?? null,
|
|
1118
|
+
pageUrl: frame.pageUrl ?? null,
|
|
1119
|
+
pageTitle: frame.pageTitle ?? null,
|
|
1120
|
+
screenshot: frame.screenshot
|
|
1121
|
+
},
|
|
1122
|
+
actionId: frame.actionId ?? `action-${frame.seq}`,
|
|
1123
|
+
stepIndex: frame.stepIndex ?? Math.max(0, frame.seq - 1),
|
|
1124
|
+
selector: frame.selector,
|
|
1125
|
+
selectorUsed: frame.selectorUsed ?? frame.selector,
|
|
1126
|
+
url: frame.pageUrl ?? frame.url
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
return events;
|
|
1130
|
+
}
|
|
1131
|
+
function frameToStep(frame) {
|
|
1132
|
+
switch (frame.action) {
|
|
1133
|
+
case "fill":
|
|
1134
|
+
return { action: "fill", selector: frame.selector, value: frame.value };
|
|
1135
|
+
case "submit":
|
|
1136
|
+
return { action: "submit", selector: frame.selector };
|
|
1137
|
+
case "goto":
|
|
1138
|
+
return { action: "goto", url: frame.url ?? frame.pageUrl };
|
|
1139
|
+
case "press":
|
|
1140
|
+
return { action: "press", key: frame.value ?? "Enter" };
|
|
1141
|
+
default:
|
|
1142
|
+
return { action: "click", selector: frame.selector };
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// src/trace/script.ts
|
|
1147
|
+
var TRACE_BINDING_NAME = "__bpTraceBinding";
|
|
1148
|
+
var TRACE_SCRIPT = `
|
|
1149
|
+
(() => {
|
|
1150
|
+
if (window.__bpTraceInstalled) return;
|
|
1151
|
+
window.__bpTraceInstalled = true;
|
|
1152
|
+
|
|
1153
|
+
const binding = globalThis.${TRACE_BINDING_NAME};
|
|
1154
|
+
if (typeof binding !== 'function') return;
|
|
1155
|
+
|
|
1156
|
+
const emit = (event, data = {}, severity = 'info', summary) => {
|
|
1157
|
+
try {
|
|
1158
|
+
globalThis.__bpTraceRecentEvents = globalThis.__bpTraceRecentEvents || [];
|
|
1159
|
+
const payload = {
|
|
1160
|
+
event,
|
|
1161
|
+
severity,
|
|
1162
|
+
summary: summary || event,
|
|
1163
|
+
ts: Date.now(),
|
|
1164
|
+
data,
|
|
1165
|
+
};
|
|
1166
|
+
globalThis.__bpTraceRecentEvents.push(payload);
|
|
1167
|
+
if (globalThis.__bpTraceRecentEvents.length > 200) {
|
|
1168
|
+
globalThis.__bpTraceRecentEvents.splice(0, globalThis.__bpTraceRecentEvents.length - 200);
|
|
1169
|
+
}
|
|
1170
|
+
binding(JSON.stringify(payload));
|
|
1171
|
+
} catch {}
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
const patchWebSocket = () => {
|
|
1175
|
+
const NativeWebSocket = window.WebSocket;
|
|
1176
|
+
if (typeof NativeWebSocket !== 'function' || window.__bpTraceWebSocketInstalled) return;
|
|
1177
|
+
window.__bpTraceWebSocketInstalled = true;
|
|
1178
|
+
|
|
1179
|
+
const nextId = () => Math.random().toString(36).slice(2, 10);
|
|
1180
|
+
|
|
1181
|
+
const patchInstance = (socket, urlValue) => {
|
|
1182
|
+
if (!socket || socket.__bpTracePatched) return socket;
|
|
1183
|
+
socket.__bpTracePatched = true;
|
|
1184
|
+
socket.__bpTraceId = socket.__bpTraceId || nextId();
|
|
1185
|
+
socket.__bpTraceUrl = String(urlValue || socket.url || '');
|
|
1186
|
+
globalThis.__bpTrackedWebSockets = globalThis.__bpTrackedWebSockets || new Set();
|
|
1187
|
+
globalThis.__bpTrackedWebSockets.add(socket);
|
|
1188
|
+
|
|
1189
|
+
emit(
|
|
1190
|
+
'ws.connection.created',
|
|
1191
|
+
{ connectionId: socket.__bpTraceId, url: socket.__bpTraceUrl },
|
|
1192
|
+
'info',
|
|
1193
|
+
'WebSocket opened ' + socket.__bpTraceUrl
|
|
1194
|
+
);
|
|
1195
|
+
|
|
1196
|
+
const originalSend = socket.send;
|
|
1197
|
+
socket.send = function(data) {
|
|
1198
|
+
const payload =
|
|
1199
|
+
typeof data === 'string'
|
|
1200
|
+
? data
|
|
1201
|
+
: data && typeof data.toString === 'function'
|
|
1202
|
+
? data.toString()
|
|
1203
|
+
: '[binary]';
|
|
1204
|
+
emit(
|
|
1205
|
+
'ws.frame.sent',
|
|
1206
|
+
{
|
|
1207
|
+
connectionId: socket.__bpTraceId,
|
|
1208
|
+
url: socket.__bpTraceUrl,
|
|
1209
|
+
payload,
|
|
1210
|
+
length: payload.length,
|
|
1211
|
+
},
|
|
1212
|
+
'info',
|
|
1213
|
+
'WebSocket frame sent'
|
|
1214
|
+
);
|
|
1215
|
+
return originalSend.call(this, data);
|
|
1216
|
+
};
|
|
1217
|
+
|
|
1218
|
+
socket.addEventListener('message', (event) => {
|
|
1219
|
+
if (socket.__bpOfflineNotified || socket.__bpTraceClosed) {
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
const data = event && 'data' in event ? event.data : '';
|
|
1223
|
+
const payload =
|
|
1224
|
+
typeof data === 'string'
|
|
1225
|
+
? data
|
|
1226
|
+
: data && typeof data.toString === 'function'
|
|
1227
|
+
? data.toString()
|
|
1228
|
+
: '[binary]';
|
|
1229
|
+
emit(
|
|
1230
|
+
'ws.frame.received',
|
|
1231
|
+
{
|
|
1232
|
+
connectionId: socket.__bpTraceId,
|
|
1233
|
+
url: socket.__bpTraceUrl,
|
|
1234
|
+
payload,
|
|
1235
|
+
length: payload.length,
|
|
1236
|
+
},
|
|
1237
|
+
'info',
|
|
1238
|
+
'WebSocket frame received'
|
|
1239
|
+
);
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
socket.addEventListener('close', (event) => {
|
|
1243
|
+
if (socket.__bpTraceClosed) {
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
socket.__bpTraceClosed = true;
|
|
1247
|
+
try {
|
|
1248
|
+
globalThis.__bpTrackedWebSockets.delete(socket);
|
|
1249
|
+
} catch {}
|
|
1250
|
+
emit(
|
|
1251
|
+
'ws.connection.closed',
|
|
1252
|
+
{
|
|
1253
|
+
connectionId: socket.__bpTraceId,
|
|
1254
|
+
url: socket.__bpTraceUrl,
|
|
1255
|
+
code: event.code,
|
|
1256
|
+
reason: event.reason,
|
|
1257
|
+
},
|
|
1258
|
+
'warn',
|
|
1259
|
+
'WebSocket closed'
|
|
1260
|
+
);
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
return socket;
|
|
1264
|
+
};
|
|
1265
|
+
|
|
1266
|
+
const TracedWebSocket = function(url, protocols) {
|
|
1267
|
+
return arguments.length > 1
|
|
1268
|
+
? patchInstance(new NativeWebSocket(url, protocols), url)
|
|
1269
|
+
: patchInstance(new NativeWebSocket(url), url);
|
|
1270
|
+
};
|
|
1271
|
+
TracedWebSocket.prototype = NativeWebSocket.prototype;
|
|
1272
|
+
Object.setPrototypeOf(TracedWebSocket, NativeWebSocket);
|
|
1273
|
+
window.WebSocket = TracedWebSocket;
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
window.addEventListener('error', (errorEvent) => {
|
|
1277
|
+
emit(
|
|
1278
|
+
'runtime.exception',
|
|
1279
|
+
{
|
|
1280
|
+
message: errorEvent.message,
|
|
1281
|
+
filename: errorEvent.filename,
|
|
1282
|
+
line: errorEvent.lineno,
|
|
1283
|
+
column: errorEvent.colno,
|
|
1284
|
+
},
|
|
1285
|
+
'error',
|
|
1286
|
+
errorEvent.message || 'Uncaught error'
|
|
1287
|
+
);
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
window.addEventListener('unhandledrejection', (event) => {
|
|
1291
|
+
const reason = event && 'reason' in event ? String(event.reason) : 'Unhandled rejection';
|
|
1292
|
+
emit('runtime.unhandledRejection', { reason }, 'error', reason);
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
const patchPermissions = async () => {
|
|
1296
|
+
if (!navigator.permissions || !navigator.permissions.query) return;
|
|
1297
|
+
|
|
1298
|
+
const names = ['geolocation', 'microphone', 'camera', 'notifications'];
|
|
1299
|
+
for (const name of names) {
|
|
1300
|
+
try {
|
|
1301
|
+
const status = await navigator.permissions.query({ name });
|
|
1302
|
+
emit(
|
|
1303
|
+
'permission.state',
|
|
1304
|
+
{ name, state: status.state },
|
|
1305
|
+
status.state === 'denied' ? 'warn' : 'info',
|
|
1306
|
+
name + ': ' + status.state
|
|
1307
|
+
);
|
|
1308
|
+
status.addEventListener('change', () => {
|
|
1309
|
+
emit(
|
|
1310
|
+
'permission.changed',
|
|
1311
|
+
{ name, state: status.state },
|
|
1312
|
+
status.state === 'denied' ? 'warn' : 'info',
|
|
1313
|
+
name + ': ' + status.state
|
|
1314
|
+
);
|
|
1315
|
+
});
|
|
1316
|
+
} catch {}
|
|
1317
|
+
}
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1320
|
+
const patchMediaElement = (element) => {
|
|
1321
|
+
if (!element || element.__bpTracePatched) return;
|
|
1322
|
+
element.__bpTracePatched = true;
|
|
1323
|
+
|
|
1324
|
+
element.addEventListener('play', () => {
|
|
1325
|
+
emit(
|
|
1326
|
+
'media.playback.started',
|
|
1327
|
+
{ tag: element.tagName.toLowerCase(), src: element.currentSrc || element.src || null },
|
|
1328
|
+
'info',
|
|
1329
|
+
'Media playback started'
|
|
1330
|
+
);
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
const onStop = () => {
|
|
1334
|
+
emit(
|
|
1335
|
+
'media.playback.stopped',
|
|
1336
|
+
{ tag: element.tagName.toLowerCase(), src: element.currentSrc || element.src || null },
|
|
1337
|
+
'warn',
|
|
1338
|
+
'Media playback stopped'
|
|
1339
|
+
);
|
|
1340
|
+
};
|
|
1341
|
+
|
|
1342
|
+
element.addEventListener('pause', onStop);
|
|
1343
|
+
element.addEventListener('ended', onStop);
|
|
1344
|
+
};
|
|
1345
|
+
|
|
1346
|
+
const patchMediaElements = () => {
|
|
1347
|
+
document.querySelectorAll('audio,video').forEach(patchMediaElement);
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
patchMediaElements();
|
|
1351
|
+
patchWebSocket();
|
|
1352
|
+
|
|
1353
|
+
if (document.documentElement) {
|
|
1354
|
+
const observer = new MutationObserver(() => {
|
|
1355
|
+
patchMediaElements();
|
|
1356
|
+
});
|
|
1357
|
+
observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
|
1361
|
+
const original = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
|
|
1362
|
+
navigator.mediaDevices.getUserMedia = async (...args) => {
|
|
1363
|
+
emit('voice.capture.started', { constraints: args[0] || null }, 'info', 'Voice capture started');
|
|
1364
|
+
try {
|
|
1365
|
+
const stream = await original(...args);
|
|
1366
|
+
const tracks = stream.getTracks();
|
|
1367
|
+
|
|
1368
|
+
for (const track of tracks) {
|
|
1369
|
+
emit(
|
|
1370
|
+
'media.track.started',
|
|
1371
|
+
{ kind: track.kind, label: track.label, readyState: track.readyState },
|
|
1372
|
+
'info',
|
|
1373
|
+
track.kind + ' track started'
|
|
1374
|
+
);
|
|
1375
|
+
track.addEventListener('ended', () => {
|
|
1376
|
+
emit(
|
|
1377
|
+
'media.track.ended',
|
|
1378
|
+
{ kind: track.kind, label: track.label, readyState: track.readyState },
|
|
1379
|
+
'warn',
|
|
1380
|
+
track.kind + ' track ended'
|
|
1381
|
+
);
|
|
1382
|
+
emit(
|
|
1383
|
+
'voice.capture.stopped',
|
|
1384
|
+
{ kind: track.kind, label: track.label, readyState: track.readyState },
|
|
1385
|
+
'warn',
|
|
1386
|
+
'Voice capture stopped'
|
|
1387
|
+
);
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
emit(
|
|
1392
|
+
'voice.capture.detectedAudio',
|
|
1393
|
+
{ trackCount: tracks.length, kinds: tracks.map((track) => track.kind) },
|
|
1394
|
+
'info',
|
|
1395
|
+
'Voice capture detected audio'
|
|
1396
|
+
);
|
|
1397
|
+
|
|
1398
|
+
return stream;
|
|
1399
|
+
} catch (error) {
|
|
1400
|
+
emit(
|
|
1401
|
+
'voice.pipeline.notReady',
|
|
1402
|
+
{ message: String(error && error.message ? error.message : error) },
|
|
1403
|
+
'error',
|
|
1404
|
+
String(error && error.message ? error.message : error)
|
|
1405
|
+
);
|
|
1406
|
+
throw error;
|
|
1407
|
+
}
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
document.addEventListener('visibilitychange', () => {
|
|
1412
|
+
emit(
|
|
1413
|
+
'dom.state.changed',
|
|
1414
|
+
{ visibilityState: document.visibilityState },
|
|
1415
|
+
document.visibilityState === 'hidden' ? 'warn' : 'info',
|
|
1416
|
+
'Visibility ' + document.visibilityState
|
|
1417
|
+
);
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
patchPermissions();
|
|
1421
|
+
emit('voice.pipeline.ready', { url: location.href }, 'info', 'Trace hooks ready');
|
|
1422
|
+
})();
|
|
1423
|
+
`;
|
|
1424
|
+
|
|
1425
|
+
// src/trace/model.ts
|
|
1426
|
+
function createTraceId(prefix = "evt") {
|
|
1427
|
+
const random = Math.random().toString(36).slice(2, 10);
|
|
1428
|
+
return `${prefix}-${Date.now().toString(36)}-${random}`;
|
|
1429
|
+
}
|
|
1430
|
+
function normalizeTraceEvent(event) {
|
|
1431
|
+
return {
|
|
1432
|
+
traceId: event.traceId ?? createTraceId(event.channel),
|
|
1433
|
+
ts: event.ts ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1434
|
+
elapsedMs: event.elapsedMs ?? 0,
|
|
1435
|
+
severity: event.severity ?? inferSeverity(event.event),
|
|
1436
|
+
data: event.data ?? {},
|
|
1437
|
+
...event
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
function inferSeverity(eventName) {
|
|
1441
|
+
if (eventName.includes(".failed") || eventName.includes(".error") || eventName.includes("exception") || eventName.includes("notReady")) {
|
|
1442
|
+
return "error";
|
|
1443
|
+
}
|
|
1444
|
+
if (eventName.includes(".closed") || eventName.includes(".warn") || eventName.includes(".changed")) {
|
|
1445
|
+
return "warn";
|
|
1446
|
+
}
|
|
1447
|
+
return "info";
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// src/trace/live.ts
|
|
1451
|
+
function globToRegex(pattern) {
|
|
1452
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
1453
|
+
const withWildcards = escaped.replace(/\*/g, ".*");
|
|
1454
|
+
return new RegExp(`^${withWildcards}$`);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
617
1457
|
// src/actions/executor.ts
|
|
618
1458
|
var DEFAULT_TIMEOUT = 3e4;
|
|
1459
|
+
var DEFAULT_RECORDING_SKIP_ACTIONS = [
|
|
1460
|
+
"wait",
|
|
1461
|
+
"snapshot",
|
|
1462
|
+
"forms",
|
|
1463
|
+
"text",
|
|
1464
|
+
"screenshot"
|
|
1465
|
+
];
|
|
1466
|
+
function loadExistingRecording(manifestPath) {
|
|
1467
|
+
try {
|
|
1468
|
+
const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
1469
|
+
if (raw.version === 1) {
|
|
1470
|
+
const legacy = raw;
|
|
1471
|
+
return {
|
|
1472
|
+
frames: Array.isArray(legacy.frames) ? legacy.frames : [],
|
|
1473
|
+
traceEvents: [],
|
|
1474
|
+
recordedAt: legacy.recordedAt,
|
|
1475
|
+
startUrl: legacy.startUrl
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
const artifact = canonicalizeRecordingArtifact(raw);
|
|
1479
|
+
const screenshotsByAction = new Map(artifact.screenshots.map((shot) => [shot.actionId, shot]));
|
|
1480
|
+
const frames = artifact.actions.map((action, index) => {
|
|
1481
|
+
const screenshot = screenshotsByAction.get(action.id);
|
|
1482
|
+
return {
|
|
1483
|
+
seq: index + 1,
|
|
1484
|
+
timestamp: Date.parse(action.ts),
|
|
1485
|
+
action: action.action,
|
|
1486
|
+
selector: action.selector,
|
|
1487
|
+
selectorUsed: action.selectorUsed,
|
|
1488
|
+
value: action.value,
|
|
1489
|
+
url: action.url,
|
|
1490
|
+
coordinates: action.coordinates,
|
|
1491
|
+
boundingBox: action.boundingBox,
|
|
1492
|
+
success: action.success,
|
|
1493
|
+
durationMs: action.durationMs,
|
|
1494
|
+
error: action.error,
|
|
1495
|
+
screenshot: screenshot?.file ?? "",
|
|
1496
|
+
pageUrl: action.pageUrl,
|
|
1497
|
+
pageTitle: action.pageTitle,
|
|
1498
|
+
stepIndex: action.stepIndex,
|
|
1499
|
+
actionId: action.id
|
|
1500
|
+
};
|
|
1501
|
+
});
|
|
1502
|
+
return {
|
|
1503
|
+
frames,
|
|
1504
|
+
traceEvents: artifact.trace.events,
|
|
1505
|
+
recordedAt: artifact.recordedAt,
|
|
1506
|
+
startUrl: artifact.session.startUrl
|
|
1507
|
+
};
|
|
1508
|
+
} catch {
|
|
1509
|
+
return { frames: [], traceEvents: [] };
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
619
1512
|
function classifyFailure(error) {
|
|
620
1513
|
if (error instanceof ElementNotFoundError) {
|
|
621
1514
|
return { reason: "missing" };
|
|
@@ -695,6 +1588,12 @@ var BatchExecutor = class {
|
|
|
695
1588
|
const { timeout = DEFAULT_TIMEOUT, onFail = "stop" } = options;
|
|
696
1589
|
const results = [];
|
|
697
1590
|
const startTime = Date.now();
|
|
1591
|
+
const recording = options.record ? this.createRecordingContext(options.record) : null;
|
|
1592
|
+
if (steps.some((step) => step.action === "waitForWsMessage")) {
|
|
1593
|
+
await this.ensureTraceHooks();
|
|
1594
|
+
}
|
|
1595
|
+
const startUrl = recording ? await this.getPageUrlSafe() : "";
|
|
1596
|
+
let stoppedAtIndex;
|
|
698
1597
|
for (let i = 0; i < steps.length; i++) {
|
|
699
1598
|
const step = steps[i];
|
|
700
1599
|
const stepStart = Date.now();
|
|
@@ -702,13 +1601,34 @@ var BatchExecutor = class {
|
|
|
702
1601
|
const retryDelay = step.retryDelay ?? 500;
|
|
703
1602
|
let lastError;
|
|
704
1603
|
let succeeded = false;
|
|
1604
|
+
if (recording) {
|
|
1605
|
+
recording.traceEvents.push(
|
|
1606
|
+
normalizeTraceEvent({
|
|
1607
|
+
traceId: createTraceId("action"),
|
|
1608
|
+
elapsedMs: Date.now() - startTime,
|
|
1609
|
+
channel: "action",
|
|
1610
|
+
event: "action.started",
|
|
1611
|
+
summary: `${step.action}${step.selector ? ` ${Array.isArray(step.selector) ? step.selector[0] : step.selector}` : ""}`,
|
|
1612
|
+
data: {
|
|
1613
|
+
action: step.action,
|
|
1614
|
+
selector: step.selector ?? null,
|
|
1615
|
+
url: step.url ?? null
|
|
1616
|
+
},
|
|
1617
|
+
actionId: `action-${i + 1}`,
|
|
1618
|
+
stepIndex: i,
|
|
1619
|
+
selector: step.selector,
|
|
1620
|
+
url: step.url
|
|
1621
|
+
})
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
705
1624
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
706
1625
|
if (attempt > 0) {
|
|
707
1626
|
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
708
1627
|
}
|
|
709
1628
|
try {
|
|
1629
|
+
this.page.resetLastActionPosition();
|
|
710
1630
|
const result = await this.executeStep(step, timeout);
|
|
711
|
-
|
|
1631
|
+
const stepResult = {
|
|
712
1632
|
index: i,
|
|
713
1633
|
action: step.action,
|
|
714
1634
|
selector: step.selector,
|
|
@@ -716,8 +1636,37 @@ var BatchExecutor = class {
|
|
|
716
1636
|
success: true,
|
|
717
1637
|
durationMs: Date.now() - stepStart,
|
|
718
1638
|
result: result.value,
|
|
719
|
-
text: result.text
|
|
720
|
-
|
|
1639
|
+
text: result.text,
|
|
1640
|
+
timestamp: Date.now(),
|
|
1641
|
+
coordinates: this.page.getLastActionCoordinates() ?? void 0,
|
|
1642
|
+
boundingBox: this.page.getLastActionBoundingBox() ?? void 0
|
|
1643
|
+
};
|
|
1644
|
+
if (recording && !recording.skipActions.has(step.action)) {
|
|
1645
|
+
await this.captureRecordingFrame(step, stepResult, recording);
|
|
1646
|
+
}
|
|
1647
|
+
if (recording) {
|
|
1648
|
+
recording.traceEvents.push(
|
|
1649
|
+
normalizeTraceEvent({
|
|
1650
|
+
traceId: createTraceId("action"),
|
|
1651
|
+
elapsedMs: Date.now() - startTime,
|
|
1652
|
+
channel: "action",
|
|
1653
|
+
event: "action.succeeded",
|
|
1654
|
+
summary: `${step.action} succeeded`,
|
|
1655
|
+
data: {
|
|
1656
|
+
action: step.action,
|
|
1657
|
+
selector: step.selector ?? null,
|
|
1658
|
+
selectorUsed: result.selectorUsed ?? null,
|
|
1659
|
+
durationMs: Date.now() - stepStart
|
|
1660
|
+
},
|
|
1661
|
+
actionId: `action-${i + 1}`,
|
|
1662
|
+
stepIndex: i,
|
|
1663
|
+
selector: step.selector,
|
|
1664
|
+
selectorUsed: result.selectorUsed,
|
|
1665
|
+
url: step.url
|
|
1666
|
+
})
|
|
1667
|
+
);
|
|
1668
|
+
}
|
|
1669
|
+
results.push(stepResult);
|
|
721
1670
|
succeeded = true;
|
|
722
1671
|
break;
|
|
723
1672
|
} catch (error) {
|
|
@@ -738,7 +1687,7 @@ var BatchExecutor = class {
|
|
|
738
1687
|
} catch {
|
|
739
1688
|
}
|
|
740
1689
|
}
|
|
741
|
-
|
|
1690
|
+
const failedResult = {
|
|
742
1691
|
index: i,
|
|
743
1692
|
action: step.action,
|
|
744
1693
|
selector: step.selector,
|
|
@@ -748,25 +1697,183 @@ var BatchExecutor = class {
|
|
|
748
1697
|
hints,
|
|
749
1698
|
failureReason: reason,
|
|
750
1699
|
coveringElement,
|
|
751
|
-
suggestion: getSuggestion(reason)
|
|
752
|
-
|
|
1700
|
+
suggestion: getSuggestion(reason),
|
|
1701
|
+
timestamp: Date.now()
|
|
1702
|
+
};
|
|
1703
|
+
if (recording && !recording.skipActions.has(step.action)) {
|
|
1704
|
+
await this.captureRecordingFrame(step, failedResult, recording);
|
|
1705
|
+
}
|
|
1706
|
+
if (recording) {
|
|
1707
|
+
recording.traceEvents.push(
|
|
1708
|
+
normalizeTraceEvent({
|
|
1709
|
+
traceId: createTraceId("action"),
|
|
1710
|
+
elapsedMs: Date.now() - startTime,
|
|
1711
|
+
channel: "action",
|
|
1712
|
+
event: "action.failed",
|
|
1713
|
+
severity: "error",
|
|
1714
|
+
summary: `${step.action} failed: ${errorMessage}`,
|
|
1715
|
+
data: {
|
|
1716
|
+
action: step.action,
|
|
1717
|
+
selector: step.selector ?? null,
|
|
1718
|
+
error: errorMessage,
|
|
1719
|
+
reason
|
|
1720
|
+
},
|
|
1721
|
+
actionId: `action-${i + 1}`,
|
|
1722
|
+
stepIndex: i,
|
|
1723
|
+
selector: step.selector,
|
|
1724
|
+
url: step.url
|
|
1725
|
+
})
|
|
1726
|
+
);
|
|
1727
|
+
}
|
|
1728
|
+
results.push(failedResult);
|
|
753
1729
|
if (onFail === "stop" && !step.optional) {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
stoppedAtIndex: i,
|
|
757
|
-
steps: results,
|
|
758
|
-
totalDurationMs: Date.now() - startTime
|
|
759
|
-
};
|
|
1730
|
+
stoppedAtIndex = i;
|
|
1731
|
+
break;
|
|
760
1732
|
}
|
|
761
1733
|
}
|
|
762
1734
|
}
|
|
763
|
-
const
|
|
1735
|
+
const totalDurationMs = Date.now() - startTime;
|
|
1736
|
+
const allSuccess = stoppedAtIndex === void 0 && results.every((result) => result.success || steps[result.index]?.optional);
|
|
1737
|
+
let recordingManifest;
|
|
1738
|
+
if (recording) {
|
|
1739
|
+
recordingManifest = await this.writeRecordingManifest(
|
|
1740
|
+
recording,
|
|
1741
|
+
startTime,
|
|
1742
|
+
startUrl,
|
|
1743
|
+
allSuccess,
|
|
1744
|
+
steps
|
|
1745
|
+
);
|
|
1746
|
+
}
|
|
764
1747
|
return {
|
|
765
1748
|
success: allSuccess,
|
|
1749
|
+
stoppedAtIndex,
|
|
766
1750
|
steps: results,
|
|
767
|
-
totalDurationMs
|
|
1751
|
+
totalDurationMs,
|
|
1752
|
+
recordingManifest
|
|
768
1753
|
};
|
|
769
1754
|
}
|
|
1755
|
+
createRecordingContext(record) {
|
|
1756
|
+
const baseDir = record.outputDir ?? join(process.cwd(), ".browser-pilot");
|
|
1757
|
+
const screenshotDir = join(baseDir, "screenshots");
|
|
1758
|
+
const manifestPath = join(baseDir, "recording.json");
|
|
1759
|
+
const existing = loadExistingRecording(manifestPath);
|
|
1760
|
+
fs.mkdirSync(screenshotDir, { recursive: true });
|
|
1761
|
+
return {
|
|
1762
|
+
baseDir,
|
|
1763
|
+
screenshotDir,
|
|
1764
|
+
sessionId: record.sessionId ?? this.page.targetId,
|
|
1765
|
+
frames: existing.frames,
|
|
1766
|
+
traceEvents: existing.traceEvents,
|
|
1767
|
+
format: record.format ?? "webp",
|
|
1768
|
+
quality: Math.max(0, Math.min(100, record.quality ?? 40)),
|
|
1769
|
+
highlights: record.highlights !== false,
|
|
1770
|
+
skipActions: new Set(record.skipActions ?? DEFAULT_RECORDING_SKIP_ACTIONS)
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
async getPageUrlSafe() {
|
|
1774
|
+
try {
|
|
1775
|
+
return await this.page.url();
|
|
1776
|
+
} catch {
|
|
1777
|
+
return "";
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
/**
|
|
1781
|
+
* Capture a recording screenshot frame with optional highlight overlay
|
|
1782
|
+
*/
|
|
1783
|
+
async captureRecordingFrame(step, stepResult, recording) {
|
|
1784
|
+
const targetMetadata = this.page.getLastActionTargetMetadata();
|
|
1785
|
+
let highlightInjected = false;
|
|
1786
|
+
try {
|
|
1787
|
+
const ts = Date.now();
|
|
1788
|
+
const seq = String(recording.frames.length + 1).padStart(4, "0");
|
|
1789
|
+
const filename = `${seq}-${ts}-${stepResult.action}.${recording.format}`;
|
|
1790
|
+
const filepath = join(recording.screenshotDir, filename);
|
|
1791
|
+
if (recording.highlights) {
|
|
1792
|
+
const kind = stepToHighlightKind(stepResult);
|
|
1793
|
+
if (kind) {
|
|
1794
|
+
await injectActionHighlight(this.page, {
|
|
1795
|
+
kind,
|
|
1796
|
+
bbox: stepResult.boundingBox,
|
|
1797
|
+
point: stepResult.coordinates,
|
|
1798
|
+
label: getHighlightLabel(step, stepResult, targetMetadata)
|
|
1799
|
+
});
|
|
1800
|
+
highlightInjected = true;
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
const base64 = await this.page.screenshot({
|
|
1804
|
+
format: recording.format,
|
|
1805
|
+
quality: recording.quality
|
|
1806
|
+
});
|
|
1807
|
+
const buffer = Buffer.from(base64, "base64");
|
|
1808
|
+
fs.writeFileSync(filepath, buffer);
|
|
1809
|
+
stepResult.screenshotPath = filepath;
|
|
1810
|
+
let pageUrl;
|
|
1811
|
+
let pageTitle;
|
|
1812
|
+
try {
|
|
1813
|
+
pageUrl = await this.page.url();
|
|
1814
|
+
pageTitle = await this.page.title();
|
|
1815
|
+
} catch {
|
|
1816
|
+
}
|
|
1817
|
+
recording.frames.push({
|
|
1818
|
+
seq: recording.frames.length + 1,
|
|
1819
|
+
timestamp: ts,
|
|
1820
|
+
action: stepResult.action,
|
|
1821
|
+
selector: stepResult.selectorUsed ?? (Array.isArray(step.selector) ? step.selector[0] : step.selector),
|
|
1822
|
+
selectorUsed: stepResult.selectorUsed,
|
|
1823
|
+
value: redactValueForRecording(
|
|
1824
|
+
typeof step.value === "string" ? step.value : void 0,
|
|
1825
|
+
targetMetadata
|
|
1826
|
+
),
|
|
1827
|
+
url: step.url,
|
|
1828
|
+
coordinates: stepResult.coordinates,
|
|
1829
|
+
boundingBox: stepResult.boundingBox,
|
|
1830
|
+
success: stepResult.success,
|
|
1831
|
+
durationMs: stepResult.durationMs,
|
|
1832
|
+
error: stepResult.error,
|
|
1833
|
+
screenshot: filename,
|
|
1834
|
+
pageUrl,
|
|
1835
|
+
pageTitle,
|
|
1836
|
+
stepIndex: stepResult.index,
|
|
1837
|
+
actionId: `action-${stepResult.index + 1}`
|
|
1838
|
+
});
|
|
1839
|
+
} catch {
|
|
1840
|
+
} finally {
|
|
1841
|
+
if (recording.highlights || highlightInjected) {
|
|
1842
|
+
await removeActionHighlight(this.page);
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* Write recording manifest to disk
|
|
1848
|
+
*/
|
|
1849
|
+
async writeRecordingManifest(recording, startTime, startUrl, success, steps) {
|
|
1850
|
+
let endUrl = startUrl;
|
|
1851
|
+
try {
|
|
1852
|
+
endUrl = await this.page.url();
|
|
1853
|
+
} catch {
|
|
1854
|
+
}
|
|
1855
|
+
const manifestPath = join(recording.baseDir, "recording.json");
|
|
1856
|
+
let recordedAt = new Date(startTime).toISOString();
|
|
1857
|
+
let originalStartUrl = startUrl;
|
|
1858
|
+
const existing = loadExistingRecording(manifestPath);
|
|
1859
|
+
if (existing.recordedAt) recordedAt = existing.recordedAt;
|
|
1860
|
+
if (existing.startUrl) originalStartUrl = existing.startUrl;
|
|
1861
|
+
const manifest = createRecordingManifest({
|
|
1862
|
+
recordedAt,
|
|
1863
|
+
sessionId: recording.sessionId,
|
|
1864
|
+
startUrl: originalStartUrl,
|
|
1865
|
+
endUrl,
|
|
1866
|
+
targetId: this.page.targetId,
|
|
1867
|
+
steps,
|
|
1868
|
+
frames: recording.frames,
|
|
1869
|
+
traceEvents: recording.traceEvents,
|
|
1870
|
+
notes: success ? [] : ["Replay ended with at least one failed action."],
|
|
1871
|
+
recordingManifest: "recording.json",
|
|
1872
|
+
screenshotDir: "screenshots/"
|
|
1873
|
+
});
|
|
1874
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
1875
|
+
return manifestPath;
|
|
1876
|
+
}
|
|
770
1877
|
/**
|
|
771
1878
|
* Execute a single step
|
|
772
1879
|
*/
|
|
@@ -1046,6 +2153,39 @@ var BatchExecutor = class {
|
|
|
1046
2153
|
}
|
|
1047
2154
|
return { selectorUsed: usedSelector, value: actual };
|
|
1048
2155
|
}
|
|
2156
|
+
case "waitForWsMessage": {
|
|
2157
|
+
if (typeof step.match !== "string") {
|
|
2158
|
+
throw new Error("waitForWsMessage requires match");
|
|
2159
|
+
}
|
|
2160
|
+
const message = await this.waitForWsMessage(step.match, step.where, timeout);
|
|
2161
|
+
return { value: message };
|
|
2162
|
+
}
|
|
2163
|
+
case "assertNoConsoleErrors": {
|
|
2164
|
+
await this.assertNoConsoleErrors(step.windowMs ?? timeout);
|
|
2165
|
+
return {};
|
|
2166
|
+
}
|
|
2167
|
+
case "assertTextChanged": {
|
|
2168
|
+
const selector = Array.isArray(step.selector) ? step.selector[0] : step.selector;
|
|
2169
|
+
if (typeof step.to !== "string") {
|
|
2170
|
+
throw new Error("assertTextChanged requires to");
|
|
2171
|
+
}
|
|
2172
|
+
const text = await this.assertTextChanged(selector, step.from, step.to, timeout);
|
|
2173
|
+
return { selectorUsed: selector, text };
|
|
2174
|
+
}
|
|
2175
|
+
case "assertPermission": {
|
|
2176
|
+
if (!step.name || !step.state) {
|
|
2177
|
+
throw new Error("assertPermission requires name and state");
|
|
2178
|
+
}
|
|
2179
|
+
const permission = await this.assertPermission(step.name, step.state);
|
|
2180
|
+
return { value: permission };
|
|
2181
|
+
}
|
|
2182
|
+
case "assertMediaTrackLive": {
|
|
2183
|
+
if (!step.kind) {
|
|
2184
|
+
throw new Error("assertMediaTrackLive requires kind");
|
|
2185
|
+
}
|
|
2186
|
+
const media = await this.assertMediaTrackLive(step.kind);
|
|
2187
|
+
return { value: media };
|
|
2188
|
+
}
|
|
1049
2189
|
default: {
|
|
1050
2190
|
const action = step.action;
|
|
1051
2191
|
const aliases = {
|
|
@@ -1099,7 +2239,7 @@ var BatchExecutor = class {
|
|
|
1099
2239
|
};
|
|
1100
2240
|
const suggestion = aliases[action.toLowerCase()];
|
|
1101
2241
|
const hint = suggestion ? ` Did you mean "${suggestion}"?` : "";
|
|
1102
|
-
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";
|
|
2242
|
+
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";
|
|
1103
2243
|
throw new Error(`Unknown action "${action}".${hint}
|
|
1104
2244
|
|
|
1105
2245
|
Valid actions: ${valid}`);
|
|
@@ -1115,6 +2255,233 @@ Valid actions: ${valid}`);
|
|
|
1115
2255
|
if (matched) return matched;
|
|
1116
2256
|
return Array.isArray(selector) ? selector[0] : selector;
|
|
1117
2257
|
}
|
|
2258
|
+
async ensureTraceHooks() {
|
|
2259
|
+
await this.page.cdpClient.send("Runtime.enable");
|
|
2260
|
+
await this.page.cdpClient.send("Page.enable");
|
|
2261
|
+
await this.page.cdpClient.send("Network.enable");
|
|
2262
|
+
try {
|
|
2263
|
+
await this.page.cdpClient.send("Runtime.addBinding", { name: TRACE_BINDING_NAME });
|
|
2264
|
+
} catch {
|
|
2265
|
+
}
|
|
2266
|
+
await this.page.cdpClient.send("Page.addScriptToEvaluateOnNewDocument", { source: TRACE_SCRIPT });
|
|
2267
|
+
await this.page.cdpClient.send("Runtime.evaluate", { expression: TRACE_SCRIPT, awaitPromise: false });
|
|
2268
|
+
}
|
|
2269
|
+
async waitForWsMessage(match, where, timeout) {
|
|
2270
|
+
await this.ensureTraceHooks();
|
|
2271
|
+
const regex = globToRegex(match);
|
|
2272
|
+
const wsUrls = /* @__PURE__ */ new Map();
|
|
2273
|
+
const recentMatch = await this.findRecentWsMessage(regex, where);
|
|
2274
|
+
if (recentMatch) {
|
|
2275
|
+
return recentMatch;
|
|
2276
|
+
}
|
|
2277
|
+
return new Promise((resolve, reject) => {
|
|
2278
|
+
const cleanup = () => {
|
|
2279
|
+
this.page.cdpClient.off("Network.webSocketCreated", onCreated);
|
|
2280
|
+
this.page.cdpClient.off("Network.webSocketFrameReceived", onFrame);
|
|
2281
|
+
this.page.cdpClient.off("Runtime.bindingCalled", onBinding);
|
|
2282
|
+
clearTimeout(timer);
|
|
2283
|
+
};
|
|
2284
|
+
const onCreated = (params) => {
|
|
2285
|
+
wsUrls.set(String(params["requestId"] ?? ""), String(params["url"] ?? ""));
|
|
2286
|
+
};
|
|
2287
|
+
const onFrame = (params) => {
|
|
2288
|
+
const requestId = String(params["requestId"] ?? "");
|
|
2289
|
+
const response = params["response"] ?? {};
|
|
2290
|
+
const payload = String(response.payloadData ?? "");
|
|
2291
|
+
const url = wsUrls.get(requestId) ?? "";
|
|
2292
|
+
if (!regex.test(url) && !regex.test(payload)) {
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
if (where && !this.payloadMatchesWhere(payload, where)) {
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
cleanup();
|
|
2299
|
+
resolve({ requestId, url, payload });
|
|
2300
|
+
};
|
|
2301
|
+
const onBinding = (params) => {
|
|
2302
|
+
if (params["name"] !== TRACE_BINDING_NAME) {
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2305
|
+
try {
|
|
2306
|
+
const parsed = JSON.parse(String(params["payload"] ?? ""));
|
|
2307
|
+
if (parsed.event !== "ws.frame.received") {
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
const data = parsed.data ?? {};
|
|
2311
|
+
const payload = String(data["payload"] ?? "");
|
|
2312
|
+
const url = String(data["url"] ?? "");
|
|
2313
|
+
if (!regex.test(url) && !regex.test(payload)) {
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
if (where && !this.payloadMatchesWhere(payload, where)) {
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
cleanup();
|
|
2320
|
+
resolve({
|
|
2321
|
+
requestId: String(data["connectionId"] ?? ""),
|
|
2322
|
+
url,
|
|
2323
|
+
payload
|
|
2324
|
+
});
|
|
2325
|
+
} catch {
|
|
2326
|
+
}
|
|
2327
|
+
};
|
|
2328
|
+
const timer = setTimeout(() => {
|
|
2329
|
+
cleanup();
|
|
2330
|
+
reject(new Error(`Timed out waiting for WebSocket message matching ${match}`));
|
|
2331
|
+
}, timeout);
|
|
2332
|
+
this.page.cdpClient.on("Network.webSocketCreated", onCreated);
|
|
2333
|
+
this.page.cdpClient.on("Network.webSocketFrameReceived", onFrame);
|
|
2334
|
+
this.page.cdpClient.on("Runtime.bindingCalled", onBinding);
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
payloadMatchesWhere(payload, where) {
|
|
2338
|
+
try {
|
|
2339
|
+
const parsed = JSON.parse(payload);
|
|
2340
|
+
return Object.entries(where).every(([key, expected]) => {
|
|
2341
|
+
const actual = key.split(".").reduce((current, part) => {
|
|
2342
|
+
if (!current || typeof current !== "object") {
|
|
2343
|
+
return void 0;
|
|
2344
|
+
}
|
|
2345
|
+
return current[part];
|
|
2346
|
+
}, parsed);
|
|
2347
|
+
return actual === expected;
|
|
2348
|
+
});
|
|
2349
|
+
} catch {
|
|
2350
|
+
return false;
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
async findRecentWsMessage(regex, where) {
|
|
2354
|
+
const recent = await this.page.evaluate(
|
|
2355
|
+
"(() => Array.isArray(globalThis.__bpTraceRecentEvents) ? globalThis.__bpTraceRecentEvents : [])()"
|
|
2356
|
+
);
|
|
2357
|
+
if (!Array.isArray(recent)) {
|
|
2358
|
+
return null;
|
|
2359
|
+
}
|
|
2360
|
+
for (let i = recent.length - 1; i >= 0; i--) {
|
|
2361
|
+
const entry = recent[i];
|
|
2362
|
+
if (!entry || typeof entry !== "object") {
|
|
2363
|
+
continue;
|
|
2364
|
+
}
|
|
2365
|
+
const event = String(entry["event"] ?? "");
|
|
2366
|
+
if (event !== "ws.frame.received") {
|
|
2367
|
+
continue;
|
|
2368
|
+
}
|
|
2369
|
+
const data = entry["data"] ?? {};
|
|
2370
|
+
const payload = String(data["payload"] ?? "");
|
|
2371
|
+
const url = String(data["url"] ?? "");
|
|
2372
|
+
if (!regex.test(url) && !regex.test(payload)) {
|
|
2373
|
+
continue;
|
|
2374
|
+
}
|
|
2375
|
+
if (where && !this.payloadMatchesWhere(payload, where)) {
|
|
2376
|
+
continue;
|
|
2377
|
+
}
|
|
2378
|
+
return {
|
|
2379
|
+
requestId: String(data["connectionId"] ?? ""),
|
|
2380
|
+
url,
|
|
2381
|
+
payload
|
|
2382
|
+
};
|
|
2383
|
+
}
|
|
2384
|
+
return null;
|
|
2385
|
+
}
|
|
2386
|
+
async assertNoConsoleErrors(windowMs) {
|
|
2387
|
+
await this.page.cdpClient.send("Runtime.enable");
|
|
2388
|
+
return new Promise((resolve, reject) => {
|
|
2389
|
+
const errors = [];
|
|
2390
|
+
const cleanup = () => {
|
|
2391
|
+
this.page.cdpClient.off("Runtime.consoleAPICalled", onConsole);
|
|
2392
|
+
this.page.cdpClient.off("Runtime.exceptionThrown", onException);
|
|
2393
|
+
clearTimeout(timer);
|
|
2394
|
+
};
|
|
2395
|
+
const onConsole = (params) => {
|
|
2396
|
+
if (params["type"] !== "error") {
|
|
2397
|
+
return;
|
|
2398
|
+
}
|
|
2399
|
+
const args = Array.isArray(params["args"]) ? params["args"] : [];
|
|
2400
|
+
errors.push(
|
|
2401
|
+
args.map((entry) => String(entry["value"] ?? entry["description"] ?? "")).filter(Boolean).join(" ")
|
|
2402
|
+
);
|
|
2403
|
+
};
|
|
2404
|
+
const onException = (params) => {
|
|
2405
|
+
const details = params["exceptionDetails"] ?? {};
|
|
2406
|
+
errors.push(String(details["text"] ?? "Runtime exception"));
|
|
2407
|
+
};
|
|
2408
|
+
const timer = setTimeout(() => {
|
|
2409
|
+
cleanup();
|
|
2410
|
+
if (errors.length > 0) {
|
|
2411
|
+
reject(new Error(`Console errors detected: ${errors.join(" | ")}`));
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
resolve();
|
|
2415
|
+
}, windowMs);
|
|
2416
|
+
this.page.cdpClient.on("Runtime.consoleAPICalled", onConsole);
|
|
2417
|
+
this.page.cdpClient.on("Runtime.exceptionThrown", onException);
|
|
2418
|
+
});
|
|
2419
|
+
}
|
|
2420
|
+
async assertTextChanged(selector, from, to, timeout) {
|
|
2421
|
+
const initialText = from ?? await this.page.text(selector);
|
|
2422
|
+
const deadline = Date.now() + timeout;
|
|
2423
|
+
while (Date.now() < deadline) {
|
|
2424
|
+
const text = await this.page.text(selector);
|
|
2425
|
+
if (text !== initialText && text.includes(to)) {
|
|
2426
|
+
return text;
|
|
2427
|
+
}
|
|
2428
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2429
|
+
}
|
|
2430
|
+
throw new Error(`Text did not change to include ${JSON.stringify(to)}`);
|
|
2431
|
+
}
|
|
2432
|
+
async assertPermission(name, state) {
|
|
2433
|
+
const result = await this.page.evaluate(
|
|
2434
|
+
`(() => navigator.permissions.query({ name: ${JSON.stringify(name)} }).then((status) => ({ name: ${JSON.stringify(name)}, state: status.state })))()`
|
|
2435
|
+
);
|
|
2436
|
+
if (!result || typeof result !== "object" || result.state !== state) {
|
|
2437
|
+
throw new Error(`Permission ${name} is not ${state}`);
|
|
2438
|
+
}
|
|
2439
|
+
return result;
|
|
2440
|
+
}
|
|
2441
|
+
async assertMediaTrackLive(kind) {
|
|
2442
|
+
const result = await this.page.evaluate(
|
|
2443
|
+
`(() => {
|
|
2444
|
+
const requestedKind = ${JSON.stringify(kind)};
|
|
2445
|
+
const mediaElements = Array.from(document.querySelectorAll('audio,video')).map((el) => {
|
|
2446
|
+
const tracks = [];
|
|
2447
|
+
if (el.srcObject && typeof el.srcObject.getTracks === 'function') {
|
|
2448
|
+
tracks.push(...el.srcObject.getTracks());
|
|
2449
|
+
}
|
|
2450
|
+
return {
|
|
2451
|
+
tag: el.tagName.toLowerCase(),
|
|
2452
|
+
paused: !!el.paused,
|
|
2453
|
+
tracks: tracks.map((track) => ({
|
|
2454
|
+
kind: track.kind,
|
|
2455
|
+
readyState: track.readyState,
|
|
2456
|
+
enabled: track.enabled,
|
|
2457
|
+
label: track.label,
|
|
2458
|
+
})),
|
|
2459
|
+
};
|
|
2460
|
+
});
|
|
2461
|
+
|
|
2462
|
+
const globalTracks =
|
|
2463
|
+
window.__bpStream && typeof window.__bpStream.getTracks === 'function'
|
|
2464
|
+
? window.__bpStream.getTracks().map((track) => ({
|
|
2465
|
+
kind: track.kind,
|
|
2466
|
+
readyState: track.readyState,
|
|
2467
|
+
enabled: track.enabled,
|
|
2468
|
+
label: track.label,
|
|
2469
|
+
}))
|
|
2470
|
+
: [];
|
|
2471
|
+
|
|
2472
|
+
const liveTracks = mediaElements
|
|
2473
|
+
.flatMap((entry) => entry.tracks)
|
|
2474
|
+
.concat(globalTracks)
|
|
2475
|
+
.filter((track) => track.kind === requestedKind && track.readyState === 'live');
|
|
2476
|
+
|
|
2477
|
+
return { live: liveTracks.length > 0, mediaElements, globalTracks, liveTracks };
|
|
2478
|
+
})()`
|
|
2479
|
+
);
|
|
2480
|
+
if (!result || typeof result !== "object" || !result.live) {
|
|
2481
|
+
throw new Error(`No live ${kind} media track detected`);
|
|
2482
|
+
}
|
|
2483
|
+
return result;
|
|
2484
|
+
}
|
|
1118
2485
|
};
|
|
1119
2486
|
function addBatchToPage(page) {
|
|
1120
2487
|
const executor = new BatchExecutor(page);
|
|
@@ -1245,7 +2612,7 @@ var ACTION_RULES = {
|
|
|
1245
2612
|
value: { type: "string|string[]" },
|
|
1246
2613
|
trigger: { type: "string|string[]" },
|
|
1247
2614
|
option: { type: "string|string[]" },
|
|
1248
|
-
match: { type: "string"
|
|
2615
|
+
match: { type: "string" }
|
|
1249
2616
|
}
|
|
1250
2617
|
},
|
|
1251
2618
|
check: {
|
|
@@ -1376,6 +2743,38 @@ var ACTION_RULES = {
|
|
|
1376
2743
|
expect: { type: "string" },
|
|
1377
2744
|
value: { type: "string" }
|
|
1378
2745
|
}
|
|
2746
|
+
},
|
|
2747
|
+
waitForWsMessage: {
|
|
2748
|
+
required: { match: { type: "string" } },
|
|
2749
|
+
optional: {
|
|
2750
|
+
where: { type: "object" }
|
|
2751
|
+
}
|
|
2752
|
+
},
|
|
2753
|
+
assertNoConsoleErrors: {
|
|
2754
|
+
required: {},
|
|
2755
|
+
optional: {
|
|
2756
|
+
windowMs: { type: "number" }
|
|
2757
|
+
}
|
|
2758
|
+
},
|
|
2759
|
+
assertTextChanged: {
|
|
2760
|
+
required: { to: { type: "string" } },
|
|
2761
|
+
optional: {
|
|
2762
|
+
selector: { type: "string|string[]" },
|
|
2763
|
+
from: { type: "string" }
|
|
2764
|
+
}
|
|
2765
|
+
},
|
|
2766
|
+
assertPermission: {
|
|
2767
|
+
required: {
|
|
2768
|
+
name: { type: "string" },
|
|
2769
|
+
state: { type: "string" }
|
|
2770
|
+
},
|
|
2771
|
+
optional: {}
|
|
2772
|
+
},
|
|
2773
|
+
assertMediaTrackLive: {
|
|
2774
|
+
required: {
|
|
2775
|
+
kind: { type: "string", enum: ["audio", "video"] }
|
|
2776
|
+
},
|
|
2777
|
+
optional: {}
|
|
1379
2778
|
}
|
|
1380
2779
|
};
|
|
1381
2780
|
var VALID_ACTIONS = Object.keys(ACTION_RULES);
|
|
@@ -1399,6 +2798,7 @@ var KNOWN_STEP_FIELDS = /* @__PURE__ */ new Set([
|
|
|
1399
2798
|
"trigger",
|
|
1400
2799
|
"option",
|
|
1401
2800
|
"match",
|
|
2801
|
+
"where",
|
|
1402
2802
|
"x",
|
|
1403
2803
|
"y",
|
|
1404
2804
|
"direction",
|
|
@@ -1408,7 +2808,13 @@ var KNOWN_STEP_FIELDS = /* @__PURE__ */ new Set([
|
|
|
1408
2808
|
"fullPage",
|
|
1409
2809
|
"expect",
|
|
1410
2810
|
"retry",
|
|
1411
|
-
"retryDelay"
|
|
2811
|
+
"retryDelay",
|
|
2812
|
+
"from",
|
|
2813
|
+
"to",
|
|
2814
|
+
"name",
|
|
2815
|
+
"state",
|
|
2816
|
+
"kind",
|
|
2817
|
+
"windowMs"
|
|
1412
2818
|
]);
|
|
1413
2819
|
function resolveAction(name) {
|
|
1414
2820
|
if (VALID_ACTIONS.includes(name)) {
|
|
@@ -1481,6 +2887,11 @@ function checkFieldType(value, rule) {
|
|
|
1481
2887
|
return `expected boolean or "auto", got ${typeof value}`;
|
|
1482
2888
|
}
|
|
1483
2889
|
return null;
|
|
2890
|
+
case "object":
|
|
2891
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2892
|
+
return `expected object, got ${Array.isArray(value) ? "array" : typeof value}`;
|
|
2893
|
+
}
|
|
2894
|
+
return null;
|
|
1484
2895
|
default: {
|
|
1485
2896
|
const _exhaustive = rule.type;
|
|
1486
2897
|
return `unknown type: ${_exhaustive}`;
|