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.
@@ -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
- results.push({
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
- results.push({
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
- return {
755
- success: false,
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 allSuccess = results.every((r) => r.success || steps[r.index]?.optional);
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: Date.now() - startTime
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", enum: ["text", "value", "contains"] }
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}`;