browser-pilot 0.0.14 → 0.0.16

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.
Files changed (44) hide show
  1. package/README.md +89 -667
  2. package/dist/actions.cjs +1073 -41
  3. package/dist/actions.d.cts +11 -3
  4. package/dist/actions.d.ts +11 -3
  5. package/dist/actions.mjs +1 -1
  6. package/dist/browser-ZCR6AA4D.mjs +11 -0
  7. package/dist/browser.cjs +1431 -62
  8. package/dist/browser.d.cts +4 -4
  9. package/dist/browser.d.ts +4 -4
  10. package/dist/browser.mjs +4 -4
  11. package/dist/cdp.cjs +5 -1
  12. package/dist/cdp.d.cts +1 -1
  13. package/dist/cdp.d.ts +1 -1
  14. package/dist/cdp.mjs +1 -1
  15. package/dist/{chunk-7NDR6V7S.mjs → chunk-6GBYX7C2.mjs} +1405 -528
  16. package/dist/{chunk-KIFB526Y.mjs → chunk-BVZALQT4.mjs} +5 -1
  17. package/dist/chunk-DTVRFXKI.mjs +35 -0
  18. package/dist/chunk-EZNZ72VA.mjs +563 -0
  19. package/dist/{chunk-SPSZZH22.mjs → chunk-LCNFBXB5.mjs} +9 -33
  20. package/dist/{chunk-IN5HPAPB.mjs → chunk-NNEHWWHL.mjs} +28 -10
  21. package/dist/chunk-TJ5B56NV.mjs +804 -0
  22. package/dist/{chunk-XMJABKCF.mjs → chunk-V3VLBQAM.mjs} +1073 -41
  23. package/dist/cli.mjs +2799 -1176
  24. package/dist/{client-Ck2nQksT.d.cts → client-B5QBRgIy.d.cts} +2 -0
  25. package/dist/{client-Ck2nQksT.d.ts → client-B5QBRgIy.d.ts} +2 -0
  26. package/dist/{client-3AFV2IAF.mjs → client-JWWZWO6L.mjs} +4 -2
  27. package/dist/index.cjs +1441 -52
  28. package/dist/index.d.cts +5 -5
  29. package/dist/index.d.ts +5 -5
  30. package/dist/index.mjs +19 -7
  31. package/dist/page-IUUTJ3SW.mjs +7 -0
  32. package/dist/providers.cjs +637 -2
  33. package/dist/providers.d.cts +2 -2
  34. package/dist/providers.d.ts +2 -2
  35. package/dist/providers.mjs +17 -3
  36. package/dist/{types-CjT0vClo.d.ts → types-BflRmiDz.d.cts} +17 -3
  37. package/dist/{types-BSoh5v1Y.d.cts → types-BzM-IfsL.d.ts} +17 -3
  38. package/dist/types-DeVSWhXj.d.cts +142 -0
  39. package/dist/types-DeVSWhXj.d.ts +142 -0
  40. package/package.json +1 -1
  41. package/dist/browser-LZTEHUDI.mjs +0 -9
  42. package/dist/chunk-BRAFQUMG.mjs +0 -229
  43. package/dist/types--wXNHUwt.d.cts +0 -56
  44. package/dist/types--wXNHUwt.d.ts +0 -56
@@ -838,6 +838,624 @@ var NavigationError = class extends Error {
838
838
  }
839
839
  };
840
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(
864
+ (event) => event.channel === "ws" || event.event.startsWith("ws.")
865
+ );
866
+ const connections = /* @__PURE__ */ new Map();
867
+ for (const event of relevant) {
868
+ const id = event.connectionId ?? event.requestId ?? event.traceId;
869
+ let connection = connections.get(id);
870
+ if (!connection) {
871
+ connection = { id, sent: 0, received: 0, lastMessages: [] };
872
+ connections.set(id, connection);
873
+ }
874
+ connection.url = event.url ?? connection.url;
875
+ if (event.event === "ws.connection.created") {
876
+ connection.createdAt = event.ts;
877
+ }
878
+ if (event.event === "ws.connection.closed") {
879
+ connection.closedAt = event.ts;
880
+ }
881
+ if (event.event === "ws.frame.sent") {
882
+ connection.sent += 1;
883
+ const payload = typeof event.data["payload"] === "string" ? event.data["payload"] : "";
884
+ if (payload) connection.lastMessages.push(`sent: ${payload}`);
885
+ }
886
+ if (event.event === "ws.frame.received") {
887
+ connection.received += 1;
888
+ const payload = typeof event.data["payload"] === "string" ? event.data["payload"] : "";
889
+ if (payload) connection.lastMessages.push(`recv: ${payload}`);
890
+ }
891
+ connection.lastMessages = connection.lastMessages.slice(-3);
892
+ }
893
+ const values = [...connections.values()];
894
+ const reconnects = values.reduce((count, connection) => {
895
+ return connection.closedAt && !connection.createdAt ? count + 1 : count;
896
+ }, 0);
897
+ return {
898
+ view: "ws",
899
+ totalEvents: relevant.length,
900
+ connections: values.map((connection) => ({
901
+ id: connection.id,
902
+ url: connection.url ?? null,
903
+ createdAt: connection.createdAt ?? null,
904
+ closedAt: connection.closedAt ?? null,
905
+ sent: connection.sent,
906
+ received: connection.received,
907
+ lastMessages: connection.lastMessages,
908
+ connectedButSilent: !!connection.createdAt && !connection.closedAt && connection.sent + connection.received === 0
909
+ })),
910
+ reconnects,
911
+ recent: takeRecent(relevant)
912
+ };
913
+ }
914
+ function summarizeConsole(events) {
915
+ const relevant = events.filter(
916
+ (event) => event.channel === "console" || event.event.startsWith("console.") || event.event === "runtime.exception" || event.event === "runtime.unhandledRejection"
917
+ );
918
+ return {
919
+ view: "console",
920
+ errors: relevant.filter(
921
+ (event) => event.event === "console.error" || event.event === "runtime.exception" || event.event === "runtime.unhandledRejection"
922
+ ).length,
923
+ warnings: relevant.filter((event) => event.event === "console.warn").length,
924
+ logs: relevant.filter((event) => event.event === "console.log").length,
925
+ recent: takeRecent(relevant)
926
+ };
927
+ }
928
+ function summarizePermissions(events) {
929
+ const relevant = events.filter(
930
+ (event) => event.channel === "permission" || event.event.startsWith("permission.")
931
+ );
932
+ const latest = /* @__PURE__ */ new Map();
933
+ for (const event of relevant) {
934
+ const name = typeof event.data["name"] === "string" ? event.data["name"] : null;
935
+ const state = typeof event.data["state"] === "string" ? event.data["state"] : null;
936
+ if (name && state) {
937
+ latest.set(name, state);
938
+ }
939
+ }
940
+ return {
941
+ view: "permissions",
942
+ states: Object.fromEntries(latest),
943
+ changes: relevant.filter((event) => event.event === "permission.changed").length,
944
+ recent: takeRecent(relevant)
945
+ };
946
+ }
947
+ function summarizeMedia(events) {
948
+ const relevant = events.filter(
949
+ (event) => event.channel === "media" || event.event.startsWith("media.")
950
+ );
951
+ const liveTracks = /* @__PURE__ */ new Map();
952
+ for (const event of relevant) {
953
+ const label = typeof event.data["label"] === "string" ? event.data["label"] : "";
954
+ const kind = typeof event.data["kind"] === "string" ? event.data["kind"] : "";
955
+ const key = `${kind}:${label}`;
956
+ if (event.event === "media.track.started") {
957
+ liveTracks.set(key, kind);
958
+ }
959
+ if (event.event === "media.track.ended") {
960
+ liveTracks.delete(key);
961
+ }
962
+ }
963
+ return {
964
+ view: "media",
965
+ tracksStarted: relevant.filter((event) => event.event === "media.track.started").length,
966
+ tracksEnded: relevant.filter((event) => event.event === "media.track.ended").length,
967
+ playbackStarted: relevant.filter((event) => event.event === "media.playback.started").length,
968
+ playbackStopped: relevant.filter((event) => event.event === "media.playback.stopped").length,
969
+ liveTracks: [...liveTracks.values()],
970
+ recent: takeRecent(relevant)
971
+ };
972
+ }
973
+ function summarizeVoice(events) {
974
+ const relevant = events.filter(
975
+ (event) => event.channel === "voice" || event.event.startsWith("voice.")
976
+ );
977
+ return {
978
+ view: "voice",
979
+ ready: relevant.filter((event) => event.event === "voice.pipeline.ready").length,
980
+ notReady: relevant.filter((event) => event.event === "voice.pipeline.notReady").length,
981
+ captureStarted: relevant.filter((event) => event.event === "voice.capture.started").length,
982
+ captureStopped: relevant.filter((event) => event.event === "voice.capture.stopped").length,
983
+ detectedAudio: relevant.filter((event) => event.event === "voice.capture.detectedAudio").length,
984
+ recent: takeRecent(relevant)
985
+ };
986
+ }
987
+ function summarizeUi(events) {
988
+ const relevant = events.filter(
989
+ (event) => event.channel === "dom" || event.event.startsWith("dom.") || event.channel === "action"
990
+ );
991
+ return {
992
+ view: "ui",
993
+ actions: relevant.filter((event) => event.channel === "action").length,
994
+ domChanges: relevant.filter((event) => event.channel === "dom").length,
995
+ recent: takeRecent(relevant)
996
+ };
997
+ }
998
+ function summarizeSession(events) {
999
+ const byChannel = /* @__PURE__ */ new Map();
1000
+ const failedActions = events.filter((event) => event.event === "action.failed").length;
1001
+ for (const event of events) {
1002
+ byChannel.set(event.channel, (byChannel.get(event.channel) ?? 0) + 1);
1003
+ }
1004
+ return {
1005
+ view: "session",
1006
+ totalEvents: events.length,
1007
+ byChannel: Object.fromEntries(byChannel),
1008
+ failedActions,
1009
+ recent: takeRecent(events)
1010
+ };
1011
+ }
1012
+
1013
+ // src/recording/manifest.ts
1014
+ function isCanonicalRecordingManifest(value) {
1015
+ return Boolean(
1016
+ value && typeof value === "object" && value.version === 2 && typeof value.session === "object"
1017
+ );
1018
+ }
1019
+ function isLegacyRecordingManifest(value) {
1020
+ return Boolean(
1021
+ value && typeof value === "object" && value.version === 1 && Array.isArray(value.frames)
1022
+ );
1023
+ }
1024
+ function createRecordingManifest(input) {
1025
+ const actions = input.frames.map((frame) => {
1026
+ const actionId = frame.actionId ?? `action-${frame.seq}`;
1027
+ return {
1028
+ id: actionId,
1029
+ stepIndex: frame.stepIndex ?? Math.max(0, frame.seq - 1),
1030
+ action: frame.action,
1031
+ selector: frame.selector,
1032
+ selectorUsed: frame.selectorUsed ?? frame.selector,
1033
+ value: frame.value,
1034
+ url: frame.url,
1035
+ success: frame.success,
1036
+ durationMs: frame.durationMs,
1037
+ error: frame.error,
1038
+ ts: new Date(frame.timestamp).toISOString(),
1039
+ pageUrl: frame.pageUrl,
1040
+ pageTitle: frame.pageTitle,
1041
+ coordinates: frame.coordinates,
1042
+ boundingBox: frame.boundingBox
1043
+ };
1044
+ });
1045
+ const screenshots = input.frames.map((frame) => ({
1046
+ id: `shot-${frame.seq}`,
1047
+ stepIndex: frame.stepIndex ?? Math.max(0, frame.seq - 1),
1048
+ actionId: frame.actionId ?? `action-${frame.seq}`,
1049
+ file: frame.screenshot,
1050
+ ts: new Date(frame.timestamp).toISOString(),
1051
+ success: frame.success,
1052
+ pageUrl: frame.pageUrl,
1053
+ pageTitle: frame.pageTitle,
1054
+ coordinates: frame.coordinates,
1055
+ boundingBox: frame.boundingBox
1056
+ }));
1057
+ return {
1058
+ version: 2,
1059
+ recordedAt: input.recordedAt,
1060
+ session: {
1061
+ id: input.sessionId,
1062
+ startUrl: input.startUrl,
1063
+ endUrl: input.endUrl,
1064
+ targetId: input.targetId,
1065
+ profile: input.profile
1066
+ },
1067
+ recipe: {
1068
+ steps: input.steps
1069
+ },
1070
+ actions,
1071
+ screenshots,
1072
+ trace: {
1073
+ events: input.traceEvents,
1074
+ summaries: buildTraceSummaries(input.traceEvents)
1075
+ },
1076
+ assertions: input.assertions ?? [],
1077
+ notes: input.notes ?? [],
1078
+ artifacts: {
1079
+ recordingManifest: input.recordingManifest ?? "recording.json",
1080
+ screenshotDir: input.screenshotDir ?? "screenshots/"
1081
+ }
1082
+ };
1083
+ }
1084
+ function canonicalizeRecordingArtifact(value) {
1085
+ if (isCanonicalRecordingManifest(value)) {
1086
+ return value;
1087
+ }
1088
+ if (!isLegacyRecordingManifest(value)) {
1089
+ throw new Error("Unsupported recording artifact");
1090
+ }
1091
+ const traceEvents = buildTraceEventsFromLegacy(value);
1092
+ const steps = value.frames.map((frame) => frameToStep(frame));
1093
+ return createRecordingManifest({
1094
+ recordedAt: value.recordedAt,
1095
+ sessionId: value.sessionId,
1096
+ startUrl: value.startUrl,
1097
+ endUrl: value.endUrl,
1098
+ steps,
1099
+ frames: value.frames,
1100
+ traceEvents,
1101
+ notes: ["Converted from legacy recording manifest"]
1102
+ });
1103
+ }
1104
+ function buildTraceEventsFromLegacy(value) {
1105
+ const events = [];
1106
+ for (const frame of value.frames) {
1107
+ events.push({
1108
+ traceId: frame.actionId ?? `legacy-${frame.seq}`,
1109
+ sessionId: value.sessionId,
1110
+ ts: new Date(frame.timestamp).toISOString(),
1111
+ elapsedMs: frame.timestamp - new Date(value.recordedAt).getTime(),
1112
+ channel: "action",
1113
+ event: frame.success ? "action.succeeded" : "action.failed",
1114
+ severity: frame.success ? "info" : "error",
1115
+ summary: `${frame.action}${frame.selector ? ` ${frame.selector}` : ""}`,
1116
+ data: {
1117
+ action: frame.action,
1118
+ selector: frame.selector,
1119
+ value: frame.value ?? null,
1120
+ pageUrl: frame.pageUrl ?? null,
1121
+ pageTitle: frame.pageTitle ?? null,
1122
+ screenshot: frame.screenshot
1123
+ },
1124
+ actionId: frame.actionId ?? `action-${frame.seq}`,
1125
+ stepIndex: frame.stepIndex ?? Math.max(0, frame.seq - 1),
1126
+ selector: frame.selector,
1127
+ selectorUsed: frame.selectorUsed ?? frame.selector,
1128
+ url: frame.pageUrl ?? frame.url
1129
+ });
1130
+ }
1131
+ return events;
1132
+ }
1133
+ function frameToStep(frame) {
1134
+ switch (frame.action) {
1135
+ case "fill":
1136
+ return { action: "fill", selector: frame.selector, value: frame.value };
1137
+ case "submit":
1138
+ return { action: "submit", selector: frame.selector };
1139
+ case "goto":
1140
+ return { action: "goto", url: frame.url ?? frame.pageUrl };
1141
+ case "press":
1142
+ return { action: "press", key: frame.value ?? "Enter" };
1143
+ default:
1144
+ return { action: "click", selector: frame.selector };
1145
+ }
1146
+ }
1147
+
1148
+ // src/trace/model.ts
1149
+ function createTraceId(prefix = "evt") {
1150
+ const random = Math.random().toString(36).slice(2, 10);
1151
+ return `${prefix}-${Date.now().toString(36)}-${random}`;
1152
+ }
1153
+ function normalizeTraceEvent(event) {
1154
+ return {
1155
+ traceId: event.traceId ?? createTraceId(event.channel),
1156
+ ts: event.ts ?? (/* @__PURE__ */ new Date()).toISOString(),
1157
+ elapsedMs: event.elapsedMs ?? 0,
1158
+ severity: event.severity ?? inferSeverity(event.event),
1159
+ data: event.data ?? {},
1160
+ ...event
1161
+ };
1162
+ }
1163
+ function inferSeverity(eventName) {
1164
+ if (eventName.includes(".failed") || eventName.includes(".error") || eventName.includes("exception") || eventName.includes("notReady")) {
1165
+ return "error";
1166
+ }
1167
+ if (eventName.includes(".closed") || eventName.includes(".warn") || eventName.includes(".changed")) {
1168
+ return "warn";
1169
+ }
1170
+ return "info";
1171
+ }
1172
+
1173
+ // src/trace/script.ts
1174
+ var TRACE_BINDING_NAME = "__bpTraceBinding";
1175
+ var TRACE_SCRIPT = `
1176
+ (() => {
1177
+ if (window.__bpTraceInstalled) return;
1178
+ window.__bpTraceInstalled = true;
1179
+
1180
+ const binding = globalThis.${TRACE_BINDING_NAME};
1181
+ if (typeof binding !== 'function') return;
1182
+
1183
+ const emit = (event, data = {}, severity = 'info', summary) => {
1184
+ try {
1185
+ globalThis.__bpTraceRecentEvents = globalThis.__bpTraceRecentEvents || [];
1186
+ const payload = {
1187
+ event,
1188
+ severity,
1189
+ summary: summary || event,
1190
+ ts: Date.now(),
1191
+ data,
1192
+ };
1193
+ globalThis.__bpTraceRecentEvents.push(payload);
1194
+ if (globalThis.__bpTraceRecentEvents.length > 200) {
1195
+ globalThis.__bpTraceRecentEvents.splice(0, globalThis.__bpTraceRecentEvents.length - 200);
1196
+ }
1197
+ binding(JSON.stringify(payload));
1198
+ } catch {}
1199
+ };
1200
+
1201
+ const patchWebSocket = () => {
1202
+ const NativeWebSocket = window.WebSocket;
1203
+ if (typeof NativeWebSocket !== 'function' || window.__bpTraceWebSocketInstalled) return;
1204
+ window.__bpTraceWebSocketInstalled = true;
1205
+
1206
+ const nextId = () => Math.random().toString(36).slice(2, 10);
1207
+
1208
+ const patchInstance = (socket, urlValue) => {
1209
+ if (!socket || socket.__bpTracePatched) return socket;
1210
+ socket.__bpTracePatched = true;
1211
+ socket.__bpTraceId = socket.__bpTraceId || nextId();
1212
+ socket.__bpTraceUrl = String(urlValue || socket.url || '');
1213
+ globalThis.__bpTrackedWebSockets = globalThis.__bpTrackedWebSockets || new Set();
1214
+ globalThis.__bpTrackedWebSockets.add(socket);
1215
+
1216
+ emit(
1217
+ 'ws.connection.created',
1218
+ { connectionId: socket.__bpTraceId, url: socket.__bpTraceUrl },
1219
+ 'info',
1220
+ 'WebSocket opened ' + socket.__bpTraceUrl
1221
+ );
1222
+
1223
+ const originalSend = socket.send;
1224
+ socket.send = function(data) {
1225
+ const payload =
1226
+ typeof data === 'string'
1227
+ ? data
1228
+ : data && typeof data.toString === 'function'
1229
+ ? data.toString()
1230
+ : '[binary]';
1231
+ emit(
1232
+ 'ws.frame.sent',
1233
+ {
1234
+ connectionId: socket.__bpTraceId,
1235
+ url: socket.__bpTraceUrl,
1236
+ payload,
1237
+ length: payload.length,
1238
+ },
1239
+ 'info',
1240
+ 'WebSocket frame sent'
1241
+ );
1242
+ return originalSend.call(this, data);
1243
+ };
1244
+
1245
+ socket.addEventListener('message', (event) => {
1246
+ if (socket.__bpOfflineNotified || socket.__bpTraceClosed) {
1247
+ return;
1248
+ }
1249
+ const data = event && 'data' in event ? event.data : '';
1250
+ const payload =
1251
+ typeof data === 'string'
1252
+ ? data
1253
+ : data && typeof data.toString === 'function'
1254
+ ? data.toString()
1255
+ : '[binary]';
1256
+ emit(
1257
+ 'ws.frame.received',
1258
+ {
1259
+ connectionId: socket.__bpTraceId,
1260
+ url: socket.__bpTraceUrl,
1261
+ payload,
1262
+ length: payload.length,
1263
+ },
1264
+ 'info',
1265
+ 'WebSocket frame received'
1266
+ );
1267
+ });
1268
+
1269
+ socket.addEventListener('close', (event) => {
1270
+ if (socket.__bpTraceClosed) {
1271
+ return;
1272
+ }
1273
+ socket.__bpTraceClosed = true;
1274
+ try {
1275
+ globalThis.__bpTrackedWebSockets.delete(socket);
1276
+ } catch {}
1277
+ emit(
1278
+ 'ws.connection.closed',
1279
+ {
1280
+ connectionId: socket.__bpTraceId,
1281
+ url: socket.__bpTraceUrl,
1282
+ code: event.code,
1283
+ reason: event.reason,
1284
+ },
1285
+ 'warn',
1286
+ 'WebSocket closed'
1287
+ );
1288
+ });
1289
+
1290
+ return socket;
1291
+ };
1292
+
1293
+ const TracedWebSocket = function(url, protocols) {
1294
+ return arguments.length > 1
1295
+ ? patchInstance(new NativeWebSocket(url, protocols), url)
1296
+ : patchInstance(new NativeWebSocket(url), url);
1297
+ };
1298
+ TracedWebSocket.prototype = NativeWebSocket.prototype;
1299
+ Object.setPrototypeOf(TracedWebSocket, NativeWebSocket);
1300
+ window.WebSocket = TracedWebSocket;
1301
+ };
1302
+
1303
+ window.addEventListener('error', (errorEvent) => {
1304
+ emit(
1305
+ 'runtime.exception',
1306
+ {
1307
+ message: errorEvent.message,
1308
+ filename: errorEvent.filename,
1309
+ line: errorEvent.lineno,
1310
+ column: errorEvent.colno,
1311
+ },
1312
+ 'error',
1313
+ errorEvent.message || 'Uncaught error'
1314
+ );
1315
+ });
1316
+
1317
+ window.addEventListener('unhandledrejection', (event) => {
1318
+ const reason = event && 'reason' in event ? String(event.reason) : 'Unhandled rejection';
1319
+ emit('runtime.unhandledRejection', { reason }, 'error', reason);
1320
+ });
1321
+
1322
+ const patchPermissions = async () => {
1323
+ if (!navigator.permissions || !navigator.permissions.query) return;
1324
+
1325
+ const names = ['geolocation', 'microphone', 'camera', 'notifications'];
1326
+ for (const name of names) {
1327
+ try {
1328
+ const status = await navigator.permissions.query({ name });
1329
+ emit(
1330
+ 'permission.state',
1331
+ { name, state: status.state },
1332
+ status.state === 'denied' ? 'warn' : 'info',
1333
+ name + ': ' + status.state
1334
+ );
1335
+ status.addEventListener('change', () => {
1336
+ emit(
1337
+ 'permission.changed',
1338
+ { name, state: status.state },
1339
+ status.state === 'denied' ? 'warn' : 'info',
1340
+ name + ': ' + status.state
1341
+ );
1342
+ });
1343
+ } catch {}
1344
+ }
1345
+ };
1346
+
1347
+ const patchMediaElement = (element) => {
1348
+ if (!element || element.__bpTracePatched) return;
1349
+ element.__bpTracePatched = true;
1350
+
1351
+ element.addEventListener('play', () => {
1352
+ emit(
1353
+ 'media.playback.started',
1354
+ { tag: element.tagName.toLowerCase(), src: element.currentSrc || element.src || null },
1355
+ 'info',
1356
+ 'Media playback started'
1357
+ );
1358
+ });
1359
+
1360
+ const onStop = () => {
1361
+ emit(
1362
+ 'media.playback.stopped',
1363
+ { tag: element.tagName.toLowerCase(), src: element.currentSrc || element.src || null },
1364
+ 'warn',
1365
+ 'Media playback stopped'
1366
+ );
1367
+ };
1368
+
1369
+ element.addEventListener('pause', onStop);
1370
+ element.addEventListener('ended', onStop);
1371
+ };
1372
+
1373
+ const patchMediaElements = () => {
1374
+ document.querySelectorAll('audio,video').forEach(patchMediaElement);
1375
+ };
1376
+
1377
+ patchMediaElements();
1378
+ patchWebSocket();
1379
+
1380
+ if (document.documentElement) {
1381
+ const observer = new MutationObserver(() => {
1382
+ patchMediaElements();
1383
+ });
1384
+ observer.observe(document.documentElement, { childList: true, subtree: true });
1385
+ }
1386
+
1387
+ if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
1388
+ const original = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
1389
+ navigator.mediaDevices.getUserMedia = async (...args) => {
1390
+ emit('voice.capture.started', { constraints: args[0] || null }, 'info', 'Voice capture started');
1391
+ try {
1392
+ const stream = await original(...args);
1393
+ const tracks = stream.getTracks();
1394
+
1395
+ for (const track of tracks) {
1396
+ emit(
1397
+ 'media.track.started',
1398
+ { kind: track.kind, label: track.label, readyState: track.readyState },
1399
+ 'info',
1400
+ track.kind + ' track started'
1401
+ );
1402
+ track.addEventListener('ended', () => {
1403
+ emit(
1404
+ 'media.track.ended',
1405
+ { kind: track.kind, label: track.label, readyState: track.readyState },
1406
+ 'warn',
1407
+ track.kind + ' track ended'
1408
+ );
1409
+ emit(
1410
+ 'voice.capture.stopped',
1411
+ { kind: track.kind, label: track.label, readyState: track.readyState },
1412
+ 'warn',
1413
+ 'Voice capture stopped'
1414
+ );
1415
+ });
1416
+ }
1417
+
1418
+ emit(
1419
+ 'voice.capture.detectedAudio',
1420
+ { trackCount: tracks.length, kinds: tracks.map((track) => track.kind) },
1421
+ 'info',
1422
+ 'Voice capture detected audio'
1423
+ );
1424
+
1425
+ return stream;
1426
+ } catch (error) {
1427
+ emit(
1428
+ 'voice.pipeline.notReady',
1429
+ { message: String(error && error.message ? error.message : error) },
1430
+ 'error',
1431
+ String(error && error.message ? error.message : error)
1432
+ );
1433
+ throw error;
1434
+ }
1435
+ };
1436
+ }
1437
+
1438
+ document.addEventListener('visibilitychange', () => {
1439
+ emit(
1440
+ 'dom.state.changed',
1441
+ { visibilityState: document.visibilityState },
1442
+ document.visibilityState === 'hidden' ? 'warn' : 'info',
1443
+ 'Visibility ' + document.visibilityState
1444
+ );
1445
+ });
1446
+
1447
+ patchPermissions();
1448
+ emit('voice.pipeline.ready', { url: location.href }, 'info', 'Trace hooks ready');
1449
+ })();
1450
+ `;
1451
+
1452
+ // src/trace/live.ts
1453
+ function globToRegex(pattern) {
1454
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
1455
+ const withWildcards = escaped.replace(/\*/g, ".*");
1456
+ return new RegExp(`^${withWildcards}$`);
1457
+ }
1458
+
841
1459
  // src/actions/executor.ts
842
1460
  var DEFAULT_TIMEOUT = 3e4;
843
1461
  var DEFAULT_RECORDING_SKIP_ACTIONS = [
@@ -847,6 +1465,61 @@ var DEFAULT_RECORDING_SKIP_ACTIONS = [
847
1465
  "text",
848
1466
  "screenshot"
849
1467
  ];
1468
+ function readString(value) {
1469
+ return typeof value === "string" ? value : void 0;
1470
+ }
1471
+ function readStringOr(value, fallback = "") {
1472
+ return readString(value) ?? fallback;
1473
+ }
1474
+ function formatConsoleArg(entry) {
1475
+ return readString(entry["value"]) ?? readString(entry["description"]) ?? "";
1476
+ }
1477
+ function loadExistingRecording(manifestPath) {
1478
+ try {
1479
+ const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
1480
+ if (raw.version === 1) {
1481
+ const legacy = raw;
1482
+ return {
1483
+ frames: Array.isArray(legacy.frames) ? legacy.frames : [],
1484
+ traceEvents: [],
1485
+ recordedAt: legacy.recordedAt,
1486
+ startUrl: legacy.startUrl
1487
+ };
1488
+ }
1489
+ const artifact = canonicalizeRecordingArtifact(raw);
1490
+ const screenshotsByAction = new Map(artifact.screenshots.map((shot) => [shot.actionId, shot]));
1491
+ const frames = artifact.actions.map((action, index) => {
1492
+ const screenshot = screenshotsByAction.get(action.id);
1493
+ return {
1494
+ seq: index + 1,
1495
+ timestamp: Date.parse(action.ts),
1496
+ action: action.action,
1497
+ selector: action.selector,
1498
+ selectorUsed: action.selectorUsed,
1499
+ value: action.value,
1500
+ url: action.url,
1501
+ coordinates: action.coordinates,
1502
+ boundingBox: action.boundingBox,
1503
+ success: action.success,
1504
+ durationMs: action.durationMs,
1505
+ error: action.error,
1506
+ screenshot: screenshot?.file ?? "",
1507
+ pageUrl: action.pageUrl,
1508
+ pageTitle: action.pageTitle,
1509
+ stepIndex: action.stepIndex,
1510
+ actionId: action.id
1511
+ };
1512
+ });
1513
+ return {
1514
+ frames,
1515
+ traceEvents: artifact.trace.events,
1516
+ recordedAt: artifact.recordedAt,
1517
+ startUrl: artifact.session.startUrl
1518
+ };
1519
+ } catch {
1520
+ return { frames: [], traceEvents: [] };
1521
+ }
1522
+ }
850
1523
  function classifyFailure(error) {
851
1524
  if (error instanceof ElementNotFoundError) {
852
1525
  return { reason: "missing" };
@@ -927,6 +1600,9 @@ var BatchExecutor = class {
927
1600
  const results = [];
928
1601
  const startTime = Date.now();
929
1602
  const recording = options.record ? this.createRecordingContext(options.record) : null;
1603
+ if (steps.some((step) => step.action === "waitForWsMessage")) {
1604
+ await this.ensureTraceHooks();
1605
+ }
930
1606
  const startUrl = recording ? await this.getPageUrlSafe() : "";
931
1607
  let stoppedAtIndex;
932
1608
  for (let i = 0; i < steps.length; i++) {
@@ -936,6 +1612,26 @@ var BatchExecutor = class {
936
1612
  const retryDelay = step.retryDelay ?? 500;
937
1613
  let lastError;
938
1614
  let succeeded = false;
1615
+ if (recording) {
1616
+ recording.traceEvents.push(
1617
+ normalizeTraceEvent({
1618
+ traceId: createTraceId("action"),
1619
+ elapsedMs: Date.now() - startTime,
1620
+ channel: "action",
1621
+ event: "action.started",
1622
+ summary: `${step.action}${step.selector ? ` ${Array.isArray(step.selector) ? step.selector[0] : step.selector}` : ""}`,
1623
+ data: {
1624
+ action: step.action,
1625
+ selector: step.selector ?? null,
1626
+ url: step.url ?? null
1627
+ },
1628
+ actionId: `action-${i + 1}`,
1629
+ stepIndex: i,
1630
+ selector: step.selector,
1631
+ url: step.url
1632
+ })
1633
+ );
1634
+ }
939
1635
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
940
1636
  if (attempt > 0) {
941
1637
  await new Promise((resolve) => setTimeout(resolve, retryDelay));
@@ -959,6 +1655,28 @@ var BatchExecutor = class {
959
1655
  if (recording && !recording.skipActions.has(step.action)) {
960
1656
  await this.captureRecordingFrame(step, stepResult, recording);
961
1657
  }
1658
+ if (recording) {
1659
+ recording.traceEvents.push(
1660
+ normalizeTraceEvent({
1661
+ traceId: createTraceId("action"),
1662
+ elapsedMs: Date.now() - startTime,
1663
+ channel: "action",
1664
+ event: "action.succeeded",
1665
+ summary: `${step.action} succeeded`,
1666
+ data: {
1667
+ action: step.action,
1668
+ selector: step.selector ?? null,
1669
+ selectorUsed: result.selectorUsed ?? null,
1670
+ durationMs: Date.now() - stepStart
1671
+ },
1672
+ actionId: `action-${i + 1}`,
1673
+ stepIndex: i,
1674
+ selector: step.selector,
1675
+ selectorUsed: result.selectorUsed,
1676
+ url: step.url
1677
+ })
1678
+ );
1679
+ }
962
1680
  results.push(stepResult);
963
1681
  succeeded = true;
964
1682
  break;
@@ -996,6 +1714,28 @@ var BatchExecutor = class {
996
1714
  if (recording && !recording.skipActions.has(step.action)) {
997
1715
  await this.captureRecordingFrame(step, failedResult, recording);
998
1716
  }
1717
+ if (recording) {
1718
+ recording.traceEvents.push(
1719
+ normalizeTraceEvent({
1720
+ traceId: createTraceId("action"),
1721
+ elapsedMs: Date.now() - startTime,
1722
+ channel: "action",
1723
+ event: "action.failed",
1724
+ severity: "error",
1725
+ summary: `${step.action} failed: ${errorMessage}`,
1726
+ data: {
1727
+ action: step.action,
1728
+ selector: step.selector ?? null,
1729
+ error: errorMessage,
1730
+ reason
1731
+ },
1732
+ actionId: `action-${i + 1}`,
1733
+ stepIndex: i,
1734
+ selector: step.selector,
1735
+ url: step.url
1736
+ })
1737
+ );
1738
+ }
999
1739
  results.push(failedResult);
1000
1740
  if (onFail === "stop" && !step.optional) {
1001
1741
  stoppedAtIndex = i;
@@ -1011,7 +1751,8 @@ var BatchExecutor = class {
1011
1751
  recording,
1012
1752
  startTime,
1013
1753
  startUrl,
1014
- allSuccess
1754
+ allSuccess,
1755
+ steps
1015
1756
  );
1016
1757
  }
1017
1758
  return {
@@ -1026,20 +1767,14 @@ var BatchExecutor = class {
1026
1767
  const baseDir = record.outputDir ?? join(process.cwd(), ".browser-pilot");
1027
1768
  const screenshotDir = join(baseDir, "screenshots");
1028
1769
  const manifestPath = join(baseDir, "recording.json");
1029
- let existingFrames = [];
1030
- try {
1031
- const existing = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
1032
- if (existing.frames && Array.isArray(existing.frames)) {
1033
- existingFrames = existing.frames;
1034
- }
1035
- } catch {
1036
- }
1770
+ const existing = loadExistingRecording(manifestPath);
1037
1771
  fs.mkdirSync(screenshotDir, { recursive: true });
1038
1772
  return {
1039
1773
  baseDir,
1040
1774
  screenshotDir,
1041
1775
  sessionId: record.sessionId ?? this.page.targetId,
1042
- frames: existingFrames,
1776
+ frames: existing.frames,
1777
+ traceEvents: existing.traceEvents,
1043
1778
  format: record.format ?? "webp",
1044
1779
  quality: Math.max(0, Math.min(100, record.quality ?? 40)),
1045
1780
  highlights: record.highlights !== false,
@@ -1095,6 +1830,7 @@ var BatchExecutor = class {
1095
1830
  timestamp: ts,
1096
1831
  action: stepResult.action,
1097
1832
  selector: stepResult.selectorUsed ?? (Array.isArray(step.selector) ? step.selector[0] : step.selector),
1833
+ selectorUsed: stepResult.selectorUsed,
1098
1834
  value: redactValueForRecording(
1099
1835
  typeof step.value === "string" ? step.value : void 0,
1100
1836
  targetMetadata
@@ -1107,7 +1843,9 @@ var BatchExecutor = class {
1107
1843
  error: stepResult.error,
1108
1844
  screenshot: filename,
1109
1845
  pageUrl,
1110
- pageTitle
1846
+ pageTitle,
1847
+ stepIndex: stepResult.index,
1848
+ actionId: `action-${stepResult.index + 1}`
1111
1849
  });
1112
1850
  } catch {
1113
1851
  } finally {
@@ -1119,45 +1857,31 @@ var BatchExecutor = class {
1119
1857
  /**
1120
1858
  * Write recording manifest to disk
1121
1859
  */
1122
- async writeRecordingManifest(recording, startTime, startUrl, success) {
1860
+ async writeRecordingManifest(recording, startTime, startUrl, success, steps) {
1123
1861
  let endUrl = startUrl;
1124
- let viewport = { width: 1280, height: 720 };
1125
1862
  try {
1126
1863
  endUrl = await this.page.url();
1127
1864
  } catch {
1128
1865
  }
1129
- try {
1130
- const metrics = await this.page.cdpClient.send("Page.getLayoutMetrics");
1131
- viewport = {
1132
- width: metrics.cssVisualViewport.clientWidth,
1133
- height: metrics.cssVisualViewport.clientHeight
1134
- };
1135
- } catch {
1136
- }
1137
1866
  const manifestPath = join(recording.baseDir, "recording.json");
1138
1867
  let recordedAt = new Date(startTime).toISOString();
1139
1868
  let originalStartUrl = startUrl;
1140
- try {
1141
- const existing = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
1142
- if (existing.recordedAt) recordedAt = existing.recordedAt;
1143
- if (existing.startUrl) originalStartUrl = existing.startUrl;
1144
- } catch {
1145
- }
1146
- const firstFrameTime = recording.frames[0]?.timestamp ?? startTime;
1147
- const totalDurationMs = Date.now() - Math.min(firstFrameTime, startTime);
1148
- const manifest = {
1149
- version: 1,
1869
+ const existing = loadExistingRecording(manifestPath);
1870
+ if (existing.recordedAt) recordedAt = existing.recordedAt;
1871
+ if (existing.startUrl) originalStartUrl = existing.startUrl;
1872
+ const manifest = createRecordingManifest({
1150
1873
  recordedAt,
1151
1874
  sessionId: recording.sessionId,
1152
1875
  startUrl: originalStartUrl,
1153
1876
  endUrl,
1154
- viewport,
1155
- format: recording.format,
1156
- quality: recording.quality,
1157
- totalDurationMs,
1158
- success,
1159
- frames: recording.frames
1160
- };
1877
+ targetId: this.page.targetId,
1878
+ steps,
1879
+ frames: recording.frames,
1880
+ traceEvents: recording.traceEvents,
1881
+ notes: success ? [] : ["Replay ended with at least one failed action."],
1882
+ recordingManifest: "recording.json",
1883
+ screenshotDir: "screenshots/"
1884
+ });
1161
1885
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
1162
1886
  return manifestPath;
1163
1887
  }
@@ -1440,6 +2164,39 @@ var BatchExecutor = class {
1440
2164
  }
1441
2165
  return { selectorUsed: usedSelector, value: actual };
1442
2166
  }
2167
+ case "waitForWsMessage": {
2168
+ if (typeof step.match !== "string") {
2169
+ throw new Error("waitForWsMessage requires match");
2170
+ }
2171
+ const message = await this.waitForWsMessage(step.match, step.where, timeout);
2172
+ return { value: message };
2173
+ }
2174
+ case "assertNoConsoleErrors": {
2175
+ await this.assertNoConsoleErrors(step.windowMs ?? timeout);
2176
+ return {};
2177
+ }
2178
+ case "assertTextChanged": {
2179
+ const selector = Array.isArray(step.selector) ? step.selector[0] : step.selector;
2180
+ if (typeof step.to !== "string") {
2181
+ throw new Error("assertTextChanged requires to");
2182
+ }
2183
+ const text = await this.assertTextChanged(selector, step.from, step.to, timeout);
2184
+ return { selectorUsed: selector, text };
2185
+ }
2186
+ case "assertPermission": {
2187
+ if (!step.name || !step.state) {
2188
+ throw new Error("assertPermission requires name and state");
2189
+ }
2190
+ const permission = await this.assertPermission(step.name, step.state);
2191
+ return { value: permission };
2192
+ }
2193
+ case "assertMediaTrackLive": {
2194
+ if (!step.kind) {
2195
+ throw new Error("assertMediaTrackLive requires kind");
2196
+ }
2197
+ const media = await this.assertMediaTrackLive(step.kind);
2198
+ return { value: media };
2199
+ }
1443
2200
  default: {
1444
2201
  const action = step.action;
1445
2202
  const aliases = {
@@ -1493,7 +2250,7 @@ var BatchExecutor = class {
1493
2250
  };
1494
2251
  const suggestion = aliases[action.toLowerCase()];
1495
2252
  const hint = suggestion ? ` Did you mean "${suggestion}"?` : "";
1496
- 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";
2253
+ 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";
1497
2254
  throw new Error(`Unknown action "${action}".${hint}
1498
2255
 
1499
2256
  Valid actions: ${valid}`);
@@ -1509,6 +2266,237 @@ Valid actions: ${valid}`);
1509
2266
  if (matched) return matched;
1510
2267
  return Array.isArray(selector) ? selector[0] : selector;
1511
2268
  }
2269
+ async ensureTraceHooks() {
2270
+ await this.page.cdpClient.send("Runtime.enable");
2271
+ await this.page.cdpClient.send("Page.enable");
2272
+ await this.page.cdpClient.send("Network.enable");
2273
+ try {
2274
+ await this.page.cdpClient.send("Runtime.addBinding", { name: TRACE_BINDING_NAME });
2275
+ } catch {
2276
+ }
2277
+ await this.page.cdpClient.send("Page.addScriptToEvaluateOnNewDocument", {
2278
+ source: TRACE_SCRIPT
2279
+ });
2280
+ await this.page.cdpClient.send("Runtime.evaluate", {
2281
+ expression: TRACE_SCRIPT,
2282
+ awaitPromise: false
2283
+ });
2284
+ }
2285
+ async waitForWsMessage(match, where, timeout) {
2286
+ await this.ensureTraceHooks();
2287
+ const regex = globToRegex(match);
2288
+ const wsUrls = /* @__PURE__ */ new Map();
2289
+ const recentMatch = await this.findRecentWsMessage(regex, where);
2290
+ if (recentMatch) {
2291
+ return recentMatch;
2292
+ }
2293
+ return new Promise((resolve, reject) => {
2294
+ const cleanup = () => {
2295
+ this.page.cdpClient.off("Network.webSocketCreated", onCreated);
2296
+ this.page.cdpClient.off("Network.webSocketFrameReceived", onFrame);
2297
+ this.page.cdpClient.off("Runtime.bindingCalled", onBinding);
2298
+ clearTimeout(timer);
2299
+ };
2300
+ const onCreated = (params) => {
2301
+ wsUrls.set(readStringOr(params["requestId"]), readStringOr(params["url"]));
2302
+ };
2303
+ const onFrame = (params) => {
2304
+ const requestId = readStringOr(params["requestId"]);
2305
+ const response = params["response"] ?? {};
2306
+ const payload = response.payloadData ?? "";
2307
+ const url = wsUrls.get(requestId) ?? "";
2308
+ if (!regex.test(url) && !regex.test(payload)) {
2309
+ return;
2310
+ }
2311
+ if (where && !this.payloadMatchesWhere(payload, where)) {
2312
+ return;
2313
+ }
2314
+ cleanup();
2315
+ resolve({ requestId, url, payload });
2316
+ };
2317
+ const onBinding = (params) => {
2318
+ if (params["name"] !== TRACE_BINDING_NAME) {
2319
+ return;
2320
+ }
2321
+ try {
2322
+ const parsed = JSON.parse(readStringOr(params["payload"]));
2323
+ if (parsed.event !== "ws.frame.received") {
2324
+ return;
2325
+ }
2326
+ const data = parsed.data ?? {};
2327
+ const payload = readStringOr(data["payload"]);
2328
+ const url = readStringOr(data["url"]);
2329
+ if (!regex.test(url) && !regex.test(payload)) {
2330
+ return;
2331
+ }
2332
+ if (where && !this.payloadMatchesWhere(payload, where)) {
2333
+ return;
2334
+ }
2335
+ cleanup();
2336
+ resolve({
2337
+ requestId: readStringOr(data["connectionId"]),
2338
+ url,
2339
+ payload
2340
+ });
2341
+ } catch {
2342
+ }
2343
+ };
2344
+ const timer = setTimeout(() => {
2345
+ cleanup();
2346
+ reject(new Error(`Timed out waiting for WebSocket message matching ${match}`));
2347
+ }, timeout);
2348
+ this.page.cdpClient.on("Network.webSocketCreated", onCreated);
2349
+ this.page.cdpClient.on("Network.webSocketFrameReceived", onFrame);
2350
+ this.page.cdpClient.on("Runtime.bindingCalled", onBinding);
2351
+ });
2352
+ }
2353
+ payloadMatchesWhere(payload, where) {
2354
+ try {
2355
+ const parsed = JSON.parse(payload);
2356
+ return Object.entries(where).every(([key, expected]) => {
2357
+ const actual = key.split(".").reduce((current, part) => {
2358
+ if (!current || typeof current !== "object") {
2359
+ return void 0;
2360
+ }
2361
+ return current[part];
2362
+ }, parsed);
2363
+ return actual === expected;
2364
+ });
2365
+ } catch {
2366
+ return false;
2367
+ }
2368
+ }
2369
+ async findRecentWsMessage(regex, where) {
2370
+ const recent = await this.page.evaluate(
2371
+ "(() => Array.isArray(globalThis.__bpTraceRecentEvents) ? globalThis.__bpTraceRecentEvents : [])()"
2372
+ );
2373
+ if (!Array.isArray(recent)) {
2374
+ return null;
2375
+ }
2376
+ for (let i = recent.length - 1; i >= 0; i--) {
2377
+ const entry = recent[i];
2378
+ if (!entry || typeof entry !== "object") {
2379
+ continue;
2380
+ }
2381
+ const record = entry;
2382
+ const event = readStringOr(record["event"]);
2383
+ if (event !== "ws.frame.received") {
2384
+ continue;
2385
+ }
2386
+ const data = record["data"] ?? {};
2387
+ const payload = readStringOr(data["payload"]);
2388
+ const url = readStringOr(data["url"]);
2389
+ if (!regex.test(url) && !regex.test(payload)) {
2390
+ continue;
2391
+ }
2392
+ if (where && !this.payloadMatchesWhere(payload, where)) {
2393
+ continue;
2394
+ }
2395
+ return {
2396
+ requestId: readStringOr(data["connectionId"]),
2397
+ url,
2398
+ payload
2399
+ };
2400
+ }
2401
+ return null;
2402
+ }
2403
+ async assertNoConsoleErrors(windowMs) {
2404
+ await this.page.cdpClient.send("Runtime.enable");
2405
+ return new Promise((resolve, reject) => {
2406
+ const errors = [];
2407
+ const cleanup = () => {
2408
+ this.page.cdpClient.off("Runtime.consoleAPICalled", onConsole);
2409
+ this.page.cdpClient.off("Runtime.exceptionThrown", onException);
2410
+ clearTimeout(timer);
2411
+ };
2412
+ const onConsole = (params) => {
2413
+ if (params["type"] !== "error") {
2414
+ return;
2415
+ }
2416
+ const args = Array.isArray(params["args"]) ? params["args"] : [];
2417
+ errors.push(args.map(formatConsoleArg).filter(Boolean).join(" "));
2418
+ };
2419
+ const onException = (params) => {
2420
+ const details = params["exceptionDetails"] ?? {};
2421
+ errors.push(readString(details["text"]) ?? "Runtime exception");
2422
+ };
2423
+ const timer = setTimeout(() => {
2424
+ cleanup();
2425
+ if (errors.length > 0) {
2426
+ reject(new Error(`Console errors detected: ${errors.join(" | ")}`));
2427
+ return;
2428
+ }
2429
+ resolve();
2430
+ }, windowMs);
2431
+ this.page.cdpClient.on("Runtime.consoleAPICalled", onConsole);
2432
+ this.page.cdpClient.on("Runtime.exceptionThrown", onException);
2433
+ });
2434
+ }
2435
+ async assertTextChanged(selector, from, to, timeout) {
2436
+ const initialText = from ?? await this.page.text(selector);
2437
+ const deadline = Date.now() + timeout;
2438
+ while (Date.now() < deadline) {
2439
+ const text = await this.page.text(selector);
2440
+ if (text !== initialText && text.includes(to)) {
2441
+ return text;
2442
+ }
2443
+ await new Promise((resolve) => setTimeout(resolve, 200));
2444
+ }
2445
+ throw new Error(`Text did not change to include ${JSON.stringify(to)}`);
2446
+ }
2447
+ async assertPermission(name, state) {
2448
+ const result = await this.page.evaluate(
2449
+ `(() => navigator.permissions.query({ name: ${JSON.stringify(name)} }).then((status) => ({ name: ${JSON.stringify(name)}, state: status.state })))()`
2450
+ );
2451
+ if (!result || typeof result !== "object" || result.state !== state) {
2452
+ throw new Error(`Permission ${name} is not ${state}`);
2453
+ }
2454
+ return result;
2455
+ }
2456
+ async assertMediaTrackLive(kind) {
2457
+ const result = await this.page.evaluate(
2458
+ `(() => {
2459
+ const requestedKind = ${JSON.stringify(kind)};
2460
+ const mediaElements = Array.from(document.querySelectorAll('audio,video')).map((el) => {
2461
+ const tracks = [];
2462
+ if (el.srcObject && typeof el.srcObject.getTracks === 'function') {
2463
+ tracks.push(...el.srcObject.getTracks());
2464
+ }
2465
+ return {
2466
+ tag: el.tagName.toLowerCase(),
2467
+ paused: !!el.paused,
2468
+ tracks: tracks.map((track) => ({
2469
+ kind: track.kind,
2470
+ readyState: track.readyState,
2471
+ enabled: track.enabled,
2472
+ label: track.label,
2473
+ })),
2474
+ };
2475
+ });
2476
+
2477
+ const globalTracks =
2478
+ window.__bpStream && typeof window.__bpStream.getTracks === 'function'
2479
+ ? window.__bpStream.getTracks().map((track) => ({
2480
+ kind: track.kind,
2481
+ readyState: track.readyState,
2482
+ enabled: track.enabled,
2483
+ label: track.label,
2484
+ }))
2485
+ : [];
2486
+
2487
+ const liveTracks = mediaElements
2488
+ .flatMap((entry) => entry.tracks)
2489
+ .concat(globalTracks)
2490
+ .filter((track) => track.kind === requestedKind && track.readyState === 'live');
2491
+
2492
+ return { live: liveTracks.length > 0, mediaElements, globalTracks, liveTracks };
2493
+ })()`
2494
+ );
2495
+ if (!result || typeof result !== "object" || !result.live) {
2496
+ throw new Error(`No live ${kind} media track detected`);
2497
+ }
2498
+ return result;
2499
+ }
1512
2500
  };
1513
2501
  function addBatchToPage(page) {
1514
2502
  const executor = new BatchExecutor(page);
@@ -1639,7 +2627,7 @@ var ACTION_RULES = {
1639
2627
  value: { type: "string|string[]" },
1640
2628
  trigger: { type: "string|string[]" },
1641
2629
  option: { type: "string|string[]" },
1642
- match: { type: "string", enum: ["text", "value", "contains"] }
2630
+ match: { type: "string" }
1643
2631
  }
1644
2632
  },
1645
2633
  check: {
@@ -1770,6 +2758,38 @@ var ACTION_RULES = {
1770
2758
  expect: { type: "string" },
1771
2759
  value: { type: "string" }
1772
2760
  }
2761
+ },
2762
+ waitForWsMessage: {
2763
+ required: { match: { type: "string" } },
2764
+ optional: {
2765
+ where: { type: "object" }
2766
+ }
2767
+ },
2768
+ assertNoConsoleErrors: {
2769
+ required: {},
2770
+ optional: {
2771
+ windowMs: { type: "number" }
2772
+ }
2773
+ },
2774
+ assertTextChanged: {
2775
+ required: { to: { type: "string" } },
2776
+ optional: {
2777
+ selector: { type: "string|string[]" },
2778
+ from: { type: "string" }
2779
+ }
2780
+ },
2781
+ assertPermission: {
2782
+ required: {
2783
+ name: { type: "string" },
2784
+ state: { type: "string" }
2785
+ },
2786
+ optional: {}
2787
+ },
2788
+ assertMediaTrackLive: {
2789
+ required: {
2790
+ kind: { type: "string", enum: ["audio", "video"] }
2791
+ },
2792
+ optional: {}
1773
2793
  }
1774
2794
  };
1775
2795
  var VALID_ACTIONS = Object.keys(ACTION_RULES);
@@ -1793,6 +2813,7 @@ var KNOWN_STEP_FIELDS = /* @__PURE__ */ new Set([
1793
2813
  "trigger",
1794
2814
  "option",
1795
2815
  "match",
2816
+ "where",
1796
2817
  "x",
1797
2818
  "y",
1798
2819
  "direction",
@@ -1802,7 +2823,13 @@ var KNOWN_STEP_FIELDS = /* @__PURE__ */ new Set([
1802
2823
  "fullPage",
1803
2824
  "expect",
1804
2825
  "retry",
1805
- "retryDelay"
2826
+ "retryDelay",
2827
+ "from",
2828
+ "to",
2829
+ "name",
2830
+ "state",
2831
+ "kind",
2832
+ "windowMs"
1806
2833
  ]);
1807
2834
  function resolveAction(name) {
1808
2835
  if (VALID_ACTIONS.includes(name)) {
@@ -1875,6 +2902,11 @@ function checkFieldType(value, rule) {
1875
2902
  return `expected boolean or "auto", got ${typeof value}`;
1876
2903
  }
1877
2904
  return null;
2905
+ case "object":
2906
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2907
+ return `expected object, got ${Array.isArray(value) ? "array" : typeof value}`;
2908
+ }
2909
+ return null;
1878
2910
  default: {
1879
2911
  const _exhaustive = rule.type;
1880
2912
  return `unknown type: ${_exhaustive}`;