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
package/dist/actions.cjs CHANGED
@@ -560,6 +560,624 @@ var CDPError = class extends Error {
560
560
  }
561
561
  };
562
562
 
563
+ // src/trace/views.ts
564
+ function takeRecent(events, limit = 5) {
565
+ return events.slice(-limit).map((event) => ({
566
+ ts: event.ts,
567
+ event: event.event,
568
+ summary: event.summary,
569
+ severity: event.severity,
570
+ url: event.url
571
+ }));
572
+ }
573
+ function buildTraceSummaries(events) {
574
+ return {
575
+ ws: summarizeWs(events),
576
+ voice: summarizeVoice(events),
577
+ console: summarizeConsole(events),
578
+ permissions: summarizePermissions(events),
579
+ media: summarizeMedia(events),
580
+ ui: summarizeUi(events),
581
+ session: summarizeSession(events)
582
+ };
583
+ }
584
+ function summarizeWs(events) {
585
+ const relevant = events.filter(
586
+ (event) => event.channel === "ws" || event.event.startsWith("ws.")
587
+ );
588
+ const connections = /* @__PURE__ */ new Map();
589
+ for (const event of relevant) {
590
+ const id = event.connectionId ?? event.requestId ?? event.traceId;
591
+ let connection = connections.get(id);
592
+ if (!connection) {
593
+ connection = { id, sent: 0, received: 0, lastMessages: [] };
594
+ connections.set(id, connection);
595
+ }
596
+ connection.url = event.url ?? connection.url;
597
+ if (event.event === "ws.connection.created") {
598
+ connection.createdAt = event.ts;
599
+ }
600
+ if (event.event === "ws.connection.closed") {
601
+ connection.closedAt = event.ts;
602
+ }
603
+ if (event.event === "ws.frame.sent") {
604
+ connection.sent += 1;
605
+ const payload = typeof event.data["payload"] === "string" ? event.data["payload"] : "";
606
+ if (payload) connection.lastMessages.push(`sent: ${payload}`);
607
+ }
608
+ if (event.event === "ws.frame.received") {
609
+ connection.received += 1;
610
+ const payload = typeof event.data["payload"] === "string" ? event.data["payload"] : "";
611
+ if (payload) connection.lastMessages.push(`recv: ${payload}`);
612
+ }
613
+ connection.lastMessages = connection.lastMessages.slice(-3);
614
+ }
615
+ const values = [...connections.values()];
616
+ const reconnects = values.reduce((count, connection) => {
617
+ return connection.closedAt && !connection.createdAt ? count + 1 : count;
618
+ }, 0);
619
+ return {
620
+ view: "ws",
621
+ totalEvents: relevant.length,
622
+ connections: values.map((connection) => ({
623
+ id: connection.id,
624
+ url: connection.url ?? null,
625
+ createdAt: connection.createdAt ?? null,
626
+ closedAt: connection.closedAt ?? null,
627
+ sent: connection.sent,
628
+ received: connection.received,
629
+ lastMessages: connection.lastMessages,
630
+ connectedButSilent: !!connection.createdAt && !connection.closedAt && connection.sent + connection.received === 0
631
+ })),
632
+ reconnects,
633
+ recent: takeRecent(relevant)
634
+ };
635
+ }
636
+ function summarizeConsole(events) {
637
+ const relevant = events.filter(
638
+ (event) => event.channel === "console" || event.event.startsWith("console.") || event.event === "runtime.exception" || event.event === "runtime.unhandledRejection"
639
+ );
640
+ return {
641
+ view: "console",
642
+ errors: relevant.filter(
643
+ (event) => event.event === "console.error" || event.event === "runtime.exception" || event.event === "runtime.unhandledRejection"
644
+ ).length,
645
+ warnings: relevant.filter((event) => event.event === "console.warn").length,
646
+ logs: relevant.filter((event) => event.event === "console.log").length,
647
+ recent: takeRecent(relevant)
648
+ };
649
+ }
650
+ function summarizePermissions(events) {
651
+ const relevant = events.filter(
652
+ (event) => event.channel === "permission" || event.event.startsWith("permission.")
653
+ );
654
+ const latest = /* @__PURE__ */ new Map();
655
+ for (const event of relevant) {
656
+ const name = typeof event.data["name"] === "string" ? event.data["name"] : null;
657
+ const state = typeof event.data["state"] === "string" ? event.data["state"] : null;
658
+ if (name && state) {
659
+ latest.set(name, state);
660
+ }
661
+ }
662
+ return {
663
+ view: "permissions",
664
+ states: Object.fromEntries(latest),
665
+ changes: relevant.filter((event) => event.event === "permission.changed").length,
666
+ recent: takeRecent(relevant)
667
+ };
668
+ }
669
+ function summarizeMedia(events) {
670
+ const relevant = events.filter(
671
+ (event) => event.channel === "media" || event.event.startsWith("media.")
672
+ );
673
+ const liveTracks = /* @__PURE__ */ new Map();
674
+ for (const event of relevant) {
675
+ const label = typeof event.data["label"] === "string" ? event.data["label"] : "";
676
+ const kind = typeof event.data["kind"] === "string" ? event.data["kind"] : "";
677
+ const key = `${kind}:${label}`;
678
+ if (event.event === "media.track.started") {
679
+ liveTracks.set(key, kind);
680
+ }
681
+ if (event.event === "media.track.ended") {
682
+ liveTracks.delete(key);
683
+ }
684
+ }
685
+ return {
686
+ view: "media",
687
+ tracksStarted: relevant.filter((event) => event.event === "media.track.started").length,
688
+ tracksEnded: relevant.filter((event) => event.event === "media.track.ended").length,
689
+ playbackStarted: relevant.filter((event) => event.event === "media.playback.started").length,
690
+ playbackStopped: relevant.filter((event) => event.event === "media.playback.stopped").length,
691
+ liveTracks: [...liveTracks.values()],
692
+ recent: takeRecent(relevant)
693
+ };
694
+ }
695
+ function summarizeVoice(events) {
696
+ const relevant = events.filter(
697
+ (event) => event.channel === "voice" || event.event.startsWith("voice.")
698
+ );
699
+ return {
700
+ view: "voice",
701
+ ready: relevant.filter((event) => event.event === "voice.pipeline.ready").length,
702
+ notReady: relevant.filter((event) => event.event === "voice.pipeline.notReady").length,
703
+ captureStarted: relevant.filter((event) => event.event === "voice.capture.started").length,
704
+ captureStopped: relevant.filter((event) => event.event === "voice.capture.stopped").length,
705
+ detectedAudio: relevant.filter((event) => event.event === "voice.capture.detectedAudio").length,
706
+ recent: takeRecent(relevant)
707
+ };
708
+ }
709
+ function summarizeUi(events) {
710
+ const relevant = events.filter(
711
+ (event) => event.channel === "dom" || event.event.startsWith("dom.") || event.channel === "action"
712
+ );
713
+ return {
714
+ view: "ui",
715
+ actions: relevant.filter((event) => event.channel === "action").length,
716
+ domChanges: relevant.filter((event) => event.channel === "dom").length,
717
+ recent: takeRecent(relevant)
718
+ };
719
+ }
720
+ function summarizeSession(events) {
721
+ const byChannel = /* @__PURE__ */ new Map();
722
+ const failedActions = events.filter((event) => event.event === "action.failed").length;
723
+ for (const event of events) {
724
+ byChannel.set(event.channel, (byChannel.get(event.channel) ?? 0) + 1);
725
+ }
726
+ return {
727
+ view: "session",
728
+ totalEvents: events.length,
729
+ byChannel: Object.fromEntries(byChannel),
730
+ failedActions,
731
+ recent: takeRecent(events)
732
+ };
733
+ }
734
+
735
+ // src/recording/manifest.ts
736
+ function isCanonicalRecordingManifest(value) {
737
+ return Boolean(
738
+ value && typeof value === "object" && value.version === 2 && typeof value.session === "object"
739
+ );
740
+ }
741
+ function isLegacyRecordingManifest(value) {
742
+ return Boolean(
743
+ value && typeof value === "object" && value.version === 1 && Array.isArray(value.frames)
744
+ );
745
+ }
746
+ function createRecordingManifest(input) {
747
+ const actions = input.frames.map((frame) => {
748
+ const actionId = frame.actionId ?? `action-${frame.seq}`;
749
+ return {
750
+ id: actionId,
751
+ stepIndex: frame.stepIndex ?? Math.max(0, frame.seq - 1),
752
+ action: frame.action,
753
+ selector: frame.selector,
754
+ selectorUsed: frame.selectorUsed ?? frame.selector,
755
+ value: frame.value,
756
+ url: frame.url,
757
+ success: frame.success,
758
+ durationMs: frame.durationMs,
759
+ error: frame.error,
760
+ ts: new Date(frame.timestamp).toISOString(),
761
+ pageUrl: frame.pageUrl,
762
+ pageTitle: frame.pageTitle,
763
+ coordinates: frame.coordinates,
764
+ boundingBox: frame.boundingBox
765
+ };
766
+ });
767
+ const screenshots = input.frames.map((frame) => ({
768
+ id: `shot-${frame.seq}`,
769
+ stepIndex: frame.stepIndex ?? Math.max(0, frame.seq - 1),
770
+ actionId: frame.actionId ?? `action-${frame.seq}`,
771
+ file: frame.screenshot,
772
+ ts: new Date(frame.timestamp).toISOString(),
773
+ success: frame.success,
774
+ pageUrl: frame.pageUrl,
775
+ pageTitle: frame.pageTitle,
776
+ coordinates: frame.coordinates,
777
+ boundingBox: frame.boundingBox
778
+ }));
779
+ return {
780
+ version: 2,
781
+ recordedAt: input.recordedAt,
782
+ session: {
783
+ id: input.sessionId,
784
+ startUrl: input.startUrl,
785
+ endUrl: input.endUrl,
786
+ targetId: input.targetId,
787
+ profile: input.profile
788
+ },
789
+ recipe: {
790
+ steps: input.steps
791
+ },
792
+ actions,
793
+ screenshots,
794
+ trace: {
795
+ events: input.traceEvents,
796
+ summaries: buildTraceSummaries(input.traceEvents)
797
+ },
798
+ assertions: input.assertions ?? [],
799
+ notes: input.notes ?? [],
800
+ artifacts: {
801
+ recordingManifest: input.recordingManifest ?? "recording.json",
802
+ screenshotDir: input.screenshotDir ?? "screenshots/"
803
+ }
804
+ };
805
+ }
806
+ function canonicalizeRecordingArtifact(value) {
807
+ if (isCanonicalRecordingManifest(value)) {
808
+ return value;
809
+ }
810
+ if (!isLegacyRecordingManifest(value)) {
811
+ throw new Error("Unsupported recording artifact");
812
+ }
813
+ const traceEvents = buildTraceEventsFromLegacy(value);
814
+ const steps = value.frames.map((frame) => frameToStep(frame));
815
+ return createRecordingManifest({
816
+ recordedAt: value.recordedAt,
817
+ sessionId: value.sessionId,
818
+ startUrl: value.startUrl,
819
+ endUrl: value.endUrl,
820
+ steps,
821
+ frames: value.frames,
822
+ traceEvents,
823
+ notes: ["Converted from legacy recording manifest"]
824
+ });
825
+ }
826
+ function buildTraceEventsFromLegacy(value) {
827
+ const events = [];
828
+ for (const frame of value.frames) {
829
+ events.push({
830
+ traceId: frame.actionId ?? `legacy-${frame.seq}`,
831
+ sessionId: value.sessionId,
832
+ ts: new Date(frame.timestamp).toISOString(),
833
+ elapsedMs: frame.timestamp - new Date(value.recordedAt).getTime(),
834
+ channel: "action",
835
+ event: frame.success ? "action.succeeded" : "action.failed",
836
+ severity: frame.success ? "info" : "error",
837
+ summary: `${frame.action}${frame.selector ? ` ${frame.selector}` : ""}`,
838
+ data: {
839
+ action: frame.action,
840
+ selector: frame.selector,
841
+ value: frame.value ?? null,
842
+ pageUrl: frame.pageUrl ?? null,
843
+ pageTitle: frame.pageTitle ?? null,
844
+ screenshot: frame.screenshot
845
+ },
846
+ actionId: frame.actionId ?? `action-${frame.seq}`,
847
+ stepIndex: frame.stepIndex ?? Math.max(0, frame.seq - 1),
848
+ selector: frame.selector,
849
+ selectorUsed: frame.selectorUsed ?? frame.selector,
850
+ url: frame.pageUrl ?? frame.url
851
+ });
852
+ }
853
+ return events;
854
+ }
855
+ function frameToStep(frame) {
856
+ switch (frame.action) {
857
+ case "fill":
858
+ return { action: "fill", selector: frame.selector, value: frame.value };
859
+ case "submit":
860
+ return { action: "submit", selector: frame.selector };
861
+ case "goto":
862
+ return { action: "goto", url: frame.url ?? frame.pageUrl };
863
+ case "press":
864
+ return { action: "press", key: frame.value ?? "Enter" };
865
+ default:
866
+ return { action: "click", selector: frame.selector };
867
+ }
868
+ }
869
+
870
+ // src/trace/model.ts
871
+ function createTraceId(prefix = "evt") {
872
+ const random = Math.random().toString(36).slice(2, 10);
873
+ return `${prefix}-${Date.now().toString(36)}-${random}`;
874
+ }
875
+ function normalizeTraceEvent(event) {
876
+ return {
877
+ traceId: event.traceId ?? createTraceId(event.channel),
878
+ ts: event.ts ?? (/* @__PURE__ */ new Date()).toISOString(),
879
+ elapsedMs: event.elapsedMs ?? 0,
880
+ severity: event.severity ?? inferSeverity(event.event),
881
+ data: event.data ?? {},
882
+ ...event
883
+ };
884
+ }
885
+ function inferSeverity(eventName) {
886
+ if (eventName.includes(".failed") || eventName.includes(".error") || eventName.includes("exception") || eventName.includes("notReady")) {
887
+ return "error";
888
+ }
889
+ if (eventName.includes(".closed") || eventName.includes(".warn") || eventName.includes(".changed")) {
890
+ return "warn";
891
+ }
892
+ return "info";
893
+ }
894
+
895
+ // src/trace/script.ts
896
+ var TRACE_BINDING_NAME = "__bpTraceBinding";
897
+ var TRACE_SCRIPT = `
898
+ (() => {
899
+ if (window.__bpTraceInstalled) return;
900
+ window.__bpTraceInstalled = true;
901
+
902
+ const binding = globalThis.${TRACE_BINDING_NAME};
903
+ if (typeof binding !== 'function') return;
904
+
905
+ const emit = (event, data = {}, severity = 'info', summary) => {
906
+ try {
907
+ globalThis.__bpTraceRecentEvents = globalThis.__bpTraceRecentEvents || [];
908
+ const payload = {
909
+ event,
910
+ severity,
911
+ summary: summary || event,
912
+ ts: Date.now(),
913
+ data,
914
+ };
915
+ globalThis.__bpTraceRecentEvents.push(payload);
916
+ if (globalThis.__bpTraceRecentEvents.length > 200) {
917
+ globalThis.__bpTraceRecentEvents.splice(0, globalThis.__bpTraceRecentEvents.length - 200);
918
+ }
919
+ binding(JSON.stringify(payload));
920
+ } catch {}
921
+ };
922
+
923
+ const patchWebSocket = () => {
924
+ const NativeWebSocket = window.WebSocket;
925
+ if (typeof NativeWebSocket !== 'function' || window.__bpTraceWebSocketInstalled) return;
926
+ window.__bpTraceWebSocketInstalled = true;
927
+
928
+ const nextId = () => Math.random().toString(36).slice(2, 10);
929
+
930
+ const patchInstance = (socket, urlValue) => {
931
+ if (!socket || socket.__bpTracePatched) return socket;
932
+ socket.__bpTracePatched = true;
933
+ socket.__bpTraceId = socket.__bpTraceId || nextId();
934
+ socket.__bpTraceUrl = String(urlValue || socket.url || '');
935
+ globalThis.__bpTrackedWebSockets = globalThis.__bpTrackedWebSockets || new Set();
936
+ globalThis.__bpTrackedWebSockets.add(socket);
937
+
938
+ emit(
939
+ 'ws.connection.created',
940
+ { connectionId: socket.__bpTraceId, url: socket.__bpTraceUrl },
941
+ 'info',
942
+ 'WebSocket opened ' + socket.__bpTraceUrl
943
+ );
944
+
945
+ const originalSend = socket.send;
946
+ socket.send = function(data) {
947
+ const payload =
948
+ typeof data === 'string'
949
+ ? data
950
+ : data && typeof data.toString === 'function'
951
+ ? data.toString()
952
+ : '[binary]';
953
+ emit(
954
+ 'ws.frame.sent',
955
+ {
956
+ connectionId: socket.__bpTraceId,
957
+ url: socket.__bpTraceUrl,
958
+ payload,
959
+ length: payload.length,
960
+ },
961
+ 'info',
962
+ 'WebSocket frame sent'
963
+ );
964
+ return originalSend.call(this, data);
965
+ };
966
+
967
+ socket.addEventListener('message', (event) => {
968
+ if (socket.__bpOfflineNotified || socket.__bpTraceClosed) {
969
+ return;
970
+ }
971
+ const data = event && 'data' in event ? event.data : '';
972
+ const payload =
973
+ typeof data === 'string'
974
+ ? data
975
+ : data && typeof data.toString === 'function'
976
+ ? data.toString()
977
+ : '[binary]';
978
+ emit(
979
+ 'ws.frame.received',
980
+ {
981
+ connectionId: socket.__bpTraceId,
982
+ url: socket.__bpTraceUrl,
983
+ payload,
984
+ length: payload.length,
985
+ },
986
+ 'info',
987
+ 'WebSocket frame received'
988
+ );
989
+ });
990
+
991
+ socket.addEventListener('close', (event) => {
992
+ if (socket.__bpTraceClosed) {
993
+ return;
994
+ }
995
+ socket.__bpTraceClosed = true;
996
+ try {
997
+ globalThis.__bpTrackedWebSockets.delete(socket);
998
+ } catch {}
999
+ emit(
1000
+ 'ws.connection.closed',
1001
+ {
1002
+ connectionId: socket.__bpTraceId,
1003
+ url: socket.__bpTraceUrl,
1004
+ code: event.code,
1005
+ reason: event.reason,
1006
+ },
1007
+ 'warn',
1008
+ 'WebSocket closed'
1009
+ );
1010
+ });
1011
+
1012
+ return socket;
1013
+ };
1014
+
1015
+ const TracedWebSocket = function(url, protocols) {
1016
+ return arguments.length > 1
1017
+ ? patchInstance(new NativeWebSocket(url, protocols), url)
1018
+ : patchInstance(new NativeWebSocket(url), url);
1019
+ };
1020
+ TracedWebSocket.prototype = NativeWebSocket.prototype;
1021
+ Object.setPrototypeOf(TracedWebSocket, NativeWebSocket);
1022
+ window.WebSocket = TracedWebSocket;
1023
+ };
1024
+
1025
+ window.addEventListener('error', (errorEvent) => {
1026
+ emit(
1027
+ 'runtime.exception',
1028
+ {
1029
+ message: errorEvent.message,
1030
+ filename: errorEvent.filename,
1031
+ line: errorEvent.lineno,
1032
+ column: errorEvent.colno,
1033
+ },
1034
+ 'error',
1035
+ errorEvent.message || 'Uncaught error'
1036
+ );
1037
+ });
1038
+
1039
+ window.addEventListener('unhandledrejection', (event) => {
1040
+ const reason = event && 'reason' in event ? String(event.reason) : 'Unhandled rejection';
1041
+ emit('runtime.unhandledRejection', { reason }, 'error', reason);
1042
+ });
1043
+
1044
+ const patchPermissions = async () => {
1045
+ if (!navigator.permissions || !navigator.permissions.query) return;
1046
+
1047
+ const names = ['geolocation', 'microphone', 'camera', 'notifications'];
1048
+ for (const name of names) {
1049
+ try {
1050
+ const status = await navigator.permissions.query({ name });
1051
+ emit(
1052
+ 'permission.state',
1053
+ { name, state: status.state },
1054
+ status.state === 'denied' ? 'warn' : 'info',
1055
+ name + ': ' + status.state
1056
+ );
1057
+ status.addEventListener('change', () => {
1058
+ emit(
1059
+ 'permission.changed',
1060
+ { name, state: status.state },
1061
+ status.state === 'denied' ? 'warn' : 'info',
1062
+ name + ': ' + status.state
1063
+ );
1064
+ });
1065
+ } catch {}
1066
+ }
1067
+ };
1068
+
1069
+ const patchMediaElement = (element) => {
1070
+ if (!element || element.__bpTracePatched) return;
1071
+ element.__bpTracePatched = true;
1072
+
1073
+ element.addEventListener('play', () => {
1074
+ emit(
1075
+ 'media.playback.started',
1076
+ { tag: element.tagName.toLowerCase(), src: element.currentSrc || element.src || null },
1077
+ 'info',
1078
+ 'Media playback started'
1079
+ );
1080
+ });
1081
+
1082
+ const onStop = () => {
1083
+ emit(
1084
+ 'media.playback.stopped',
1085
+ { tag: element.tagName.toLowerCase(), src: element.currentSrc || element.src || null },
1086
+ 'warn',
1087
+ 'Media playback stopped'
1088
+ );
1089
+ };
1090
+
1091
+ element.addEventListener('pause', onStop);
1092
+ element.addEventListener('ended', onStop);
1093
+ };
1094
+
1095
+ const patchMediaElements = () => {
1096
+ document.querySelectorAll('audio,video').forEach(patchMediaElement);
1097
+ };
1098
+
1099
+ patchMediaElements();
1100
+ patchWebSocket();
1101
+
1102
+ if (document.documentElement) {
1103
+ const observer = new MutationObserver(() => {
1104
+ patchMediaElements();
1105
+ });
1106
+ observer.observe(document.documentElement, { childList: true, subtree: true });
1107
+ }
1108
+
1109
+ if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
1110
+ const original = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
1111
+ navigator.mediaDevices.getUserMedia = async (...args) => {
1112
+ emit('voice.capture.started', { constraints: args[0] || null }, 'info', 'Voice capture started');
1113
+ try {
1114
+ const stream = await original(...args);
1115
+ const tracks = stream.getTracks();
1116
+
1117
+ for (const track of tracks) {
1118
+ emit(
1119
+ 'media.track.started',
1120
+ { kind: track.kind, label: track.label, readyState: track.readyState },
1121
+ 'info',
1122
+ track.kind + ' track started'
1123
+ );
1124
+ track.addEventListener('ended', () => {
1125
+ emit(
1126
+ 'media.track.ended',
1127
+ { kind: track.kind, label: track.label, readyState: track.readyState },
1128
+ 'warn',
1129
+ track.kind + ' track ended'
1130
+ );
1131
+ emit(
1132
+ 'voice.capture.stopped',
1133
+ { kind: track.kind, label: track.label, readyState: track.readyState },
1134
+ 'warn',
1135
+ 'Voice capture stopped'
1136
+ );
1137
+ });
1138
+ }
1139
+
1140
+ emit(
1141
+ 'voice.capture.detectedAudio',
1142
+ { trackCount: tracks.length, kinds: tracks.map((track) => track.kind) },
1143
+ 'info',
1144
+ 'Voice capture detected audio'
1145
+ );
1146
+
1147
+ return stream;
1148
+ } catch (error) {
1149
+ emit(
1150
+ 'voice.pipeline.notReady',
1151
+ { message: String(error && error.message ? error.message : error) },
1152
+ 'error',
1153
+ String(error && error.message ? error.message : error)
1154
+ );
1155
+ throw error;
1156
+ }
1157
+ };
1158
+ }
1159
+
1160
+ document.addEventListener('visibilitychange', () => {
1161
+ emit(
1162
+ 'dom.state.changed',
1163
+ { visibilityState: document.visibilityState },
1164
+ document.visibilityState === 'hidden' ? 'warn' : 'info',
1165
+ 'Visibility ' + document.visibilityState
1166
+ );
1167
+ });
1168
+
1169
+ patchPermissions();
1170
+ emit('voice.pipeline.ready', { url: location.href }, 'info', 'Trace hooks ready');
1171
+ })();
1172
+ `;
1173
+
1174
+ // src/trace/live.ts
1175
+ function globToRegex(pattern) {
1176
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
1177
+ const withWildcards = escaped.replace(/\*/g, ".*");
1178
+ return new RegExp(`^${withWildcards}$`);
1179
+ }
1180
+
563
1181
  // src/actions/executor.ts
564
1182
  var DEFAULT_TIMEOUT = 3e4;
565
1183
  var DEFAULT_RECORDING_SKIP_ACTIONS = [
@@ -569,6 +1187,61 @@ var DEFAULT_RECORDING_SKIP_ACTIONS = [
569
1187
  "text",
570
1188
  "screenshot"
571
1189
  ];
1190
+ function readString(value) {
1191
+ return typeof value === "string" ? value : void 0;
1192
+ }
1193
+ function readStringOr(value, fallback = "") {
1194
+ return readString(value) ?? fallback;
1195
+ }
1196
+ function formatConsoleArg(entry) {
1197
+ return readString(entry["value"]) ?? readString(entry["description"]) ?? "";
1198
+ }
1199
+ function loadExistingRecording(manifestPath) {
1200
+ try {
1201
+ const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
1202
+ if (raw.version === 1) {
1203
+ const legacy = raw;
1204
+ return {
1205
+ frames: Array.isArray(legacy.frames) ? legacy.frames : [],
1206
+ traceEvents: [],
1207
+ recordedAt: legacy.recordedAt,
1208
+ startUrl: legacy.startUrl
1209
+ };
1210
+ }
1211
+ const artifact = canonicalizeRecordingArtifact(raw);
1212
+ const screenshotsByAction = new Map(artifact.screenshots.map((shot) => [shot.actionId, shot]));
1213
+ const frames = artifact.actions.map((action, index) => {
1214
+ const screenshot = screenshotsByAction.get(action.id);
1215
+ return {
1216
+ seq: index + 1,
1217
+ timestamp: Date.parse(action.ts),
1218
+ action: action.action,
1219
+ selector: action.selector,
1220
+ selectorUsed: action.selectorUsed,
1221
+ value: action.value,
1222
+ url: action.url,
1223
+ coordinates: action.coordinates,
1224
+ boundingBox: action.boundingBox,
1225
+ success: action.success,
1226
+ durationMs: action.durationMs,
1227
+ error: action.error,
1228
+ screenshot: screenshot?.file ?? "",
1229
+ pageUrl: action.pageUrl,
1230
+ pageTitle: action.pageTitle,
1231
+ stepIndex: action.stepIndex,
1232
+ actionId: action.id
1233
+ };
1234
+ });
1235
+ return {
1236
+ frames,
1237
+ traceEvents: artifact.trace.events,
1238
+ recordedAt: artifact.recordedAt,
1239
+ startUrl: artifact.session.startUrl
1240
+ };
1241
+ } catch {
1242
+ return { frames: [], traceEvents: [] };
1243
+ }
1244
+ }
572
1245
  function classifyFailure(error) {
573
1246
  if (error instanceof ElementNotFoundError) {
574
1247
  return { reason: "missing" };
@@ -649,6 +1322,9 @@ var BatchExecutor = class {
649
1322
  const results = [];
650
1323
  const startTime = Date.now();
651
1324
  const recording = options.record ? this.createRecordingContext(options.record) : null;
1325
+ if (steps.some((step) => step.action === "waitForWsMessage")) {
1326
+ await this.ensureTraceHooks();
1327
+ }
652
1328
  const startUrl = recording ? await this.getPageUrlSafe() : "";
653
1329
  let stoppedAtIndex;
654
1330
  for (let i = 0; i < steps.length; i++) {
@@ -658,6 +1334,26 @@ var BatchExecutor = class {
658
1334
  const retryDelay = step.retryDelay ?? 500;
659
1335
  let lastError;
660
1336
  let succeeded = false;
1337
+ if (recording) {
1338
+ recording.traceEvents.push(
1339
+ normalizeTraceEvent({
1340
+ traceId: createTraceId("action"),
1341
+ elapsedMs: Date.now() - startTime,
1342
+ channel: "action",
1343
+ event: "action.started",
1344
+ summary: `${step.action}${step.selector ? ` ${Array.isArray(step.selector) ? step.selector[0] : step.selector}` : ""}`,
1345
+ data: {
1346
+ action: step.action,
1347
+ selector: step.selector ?? null,
1348
+ url: step.url ?? null
1349
+ },
1350
+ actionId: `action-${i + 1}`,
1351
+ stepIndex: i,
1352
+ selector: step.selector,
1353
+ url: step.url
1354
+ })
1355
+ );
1356
+ }
661
1357
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
662
1358
  if (attempt > 0) {
663
1359
  await new Promise((resolve) => setTimeout(resolve, retryDelay));
@@ -681,6 +1377,28 @@ var BatchExecutor = class {
681
1377
  if (recording && !recording.skipActions.has(step.action)) {
682
1378
  await this.captureRecordingFrame(step, stepResult, recording);
683
1379
  }
1380
+ if (recording) {
1381
+ recording.traceEvents.push(
1382
+ normalizeTraceEvent({
1383
+ traceId: createTraceId("action"),
1384
+ elapsedMs: Date.now() - startTime,
1385
+ channel: "action",
1386
+ event: "action.succeeded",
1387
+ summary: `${step.action} succeeded`,
1388
+ data: {
1389
+ action: step.action,
1390
+ selector: step.selector ?? null,
1391
+ selectorUsed: result.selectorUsed ?? null,
1392
+ durationMs: Date.now() - stepStart
1393
+ },
1394
+ actionId: `action-${i + 1}`,
1395
+ stepIndex: i,
1396
+ selector: step.selector,
1397
+ selectorUsed: result.selectorUsed,
1398
+ url: step.url
1399
+ })
1400
+ );
1401
+ }
684
1402
  results.push(stepResult);
685
1403
  succeeded = true;
686
1404
  break;
@@ -718,6 +1436,28 @@ var BatchExecutor = class {
718
1436
  if (recording && !recording.skipActions.has(step.action)) {
719
1437
  await this.captureRecordingFrame(step, failedResult, recording);
720
1438
  }
1439
+ if (recording) {
1440
+ recording.traceEvents.push(
1441
+ normalizeTraceEvent({
1442
+ traceId: createTraceId("action"),
1443
+ elapsedMs: Date.now() - startTime,
1444
+ channel: "action",
1445
+ event: "action.failed",
1446
+ severity: "error",
1447
+ summary: `${step.action} failed: ${errorMessage}`,
1448
+ data: {
1449
+ action: step.action,
1450
+ selector: step.selector ?? null,
1451
+ error: errorMessage,
1452
+ reason
1453
+ },
1454
+ actionId: `action-${i + 1}`,
1455
+ stepIndex: i,
1456
+ selector: step.selector,
1457
+ url: step.url
1458
+ })
1459
+ );
1460
+ }
721
1461
  results.push(failedResult);
722
1462
  if (onFail === "stop" && !step.optional) {
723
1463
  stoppedAtIndex = i;
@@ -733,7 +1473,8 @@ var BatchExecutor = class {
733
1473
  recording,
734
1474
  startTime,
735
1475
  startUrl,
736
- allSuccess
1476
+ allSuccess,
1477
+ steps
737
1478
  );
738
1479
  }
739
1480
  return {
@@ -748,20 +1489,14 @@ var BatchExecutor = class {
748
1489
  const baseDir = record.outputDir ?? (0, import_node_path.join)(process.cwd(), ".browser-pilot");
749
1490
  const screenshotDir = (0, import_node_path.join)(baseDir, "screenshots");
750
1491
  const manifestPath = (0, import_node_path.join)(baseDir, "recording.json");
751
- let existingFrames = [];
752
- try {
753
- const existing = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
754
- if (existing.frames && Array.isArray(existing.frames)) {
755
- existingFrames = existing.frames;
756
- }
757
- } catch {
758
- }
1492
+ const existing = loadExistingRecording(manifestPath);
759
1493
  fs.mkdirSync(screenshotDir, { recursive: true });
760
1494
  return {
761
1495
  baseDir,
762
1496
  screenshotDir,
763
1497
  sessionId: record.sessionId ?? this.page.targetId,
764
- frames: existingFrames,
1498
+ frames: existing.frames,
1499
+ traceEvents: existing.traceEvents,
765
1500
  format: record.format ?? "webp",
766
1501
  quality: Math.max(0, Math.min(100, record.quality ?? 40)),
767
1502
  highlights: record.highlights !== false,
@@ -817,6 +1552,7 @@ var BatchExecutor = class {
817
1552
  timestamp: ts,
818
1553
  action: stepResult.action,
819
1554
  selector: stepResult.selectorUsed ?? (Array.isArray(step.selector) ? step.selector[0] : step.selector),
1555
+ selectorUsed: stepResult.selectorUsed,
820
1556
  value: redactValueForRecording(
821
1557
  typeof step.value === "string" ? step.value : void 0,
822
1558
  targetMetadata
@@ -829,7 +1565,9 @@ var BatchExecutor = class {
829
1565
  error: stepResult.error,
830
1566
  screenshot: filename,
831
1567
  pageUrl,
832
- pageTitle
1568
+ pageTitle,
1569
+ stepIndex: stepResult.index,
1570
+ actionId: `action-${stepResult.index + 1}`
833
1571
  });
834
1572
  } catch {
835
1573
  } finally {
@@ -841,45 +1579,31 @@ var BatchExecutor = class {
841
1579
  /**
842
1580
  * Write recording manifest to disk
843
1581
  */
844
- async writeRecordingManifest(recording, startTime, startUrl, success) {
1582
+ async writeRecordingManifest(recording, startTime, startUrl, success, steps) {
845
1583
  let endUrl = startUrl;
846
- let viewport = { width: 1280, height: 720 };
847
1584
  try {
848
1585
  endUrl = await this.page.url();
849
1586
  } catch {
850
1587
  }
851
- try {
852
- const metrics = await this.page.cdpClient.send("Page.getLayoutMetrics");
853
- viewport = {
854
- width: metrics.cssVisualViewport.clientWidth,
855
- height: metrics.cssVisualViewport.clientHeight
856
- };
857
- } catch {
858
- }
859
1588
  const manifestPath = (0, import_node_path.join)(recording.baseDir, "recording.json");
860
1589
  let recordedAt = new Date(startTime).toISOString();
861
1590
  let originalStartUrl = startUrl;
862
- try {
863
- const existing = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
864
- if (existing.recordedAt) recordedAt = existing.recordedAt;
865
- if (existing.startUrl) originalStartUrl = existing.startUrl;
866
- } catch {
867
- }
868
- const firstFrameTime = recording.frames[0]?.timestamp ?? startTime;
869
- const totalDurationMs = Date.now() - Math.min(firstFrameTime, startTime);
870
- const manifest = {
871
- version: 1,
1591
+ const existing = loadExistingRecording(manifestPath);
1592
+ if (existing.recordedAt) recordedAt = existing.recordedAt;
1593
+ if (existing.startUrl) originalStartUrl = existing.startUrl;
1594
+ const manifest = createRecordingManifest({
872
1595
  recordedAt,
873
1596
  sessionId: recording.sessionId,
874
1597
  startUrl: originalStartUrl,
875
1598
  endUrl,
876
- viewport,
877
- format: recording.format,
878
- quality: recording.quality,
879
- totalDurationMs,
880
- success,
881
- frames: recording.frames
882
- };
1599
+ targetId: this.page.targetId,
1600
+ steps,
1601
+ frames: recording.frames,
1602
+ traceEvents: recording.traceEvents,
1603
+ notes: success ? [] : ["Replay ended with at least one failed action."],
1604
+ recordingManifest: "recording.json",
1605
+ screenshotDir: "screenshots/"
1606
+ });
883
1607
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
884
1608
  return manifestPath;
885
1609
  }
@@ -1162,6 +1886,39 @@ var BatchExecutor = class {
1162
1886
  }
1163
1887
  return { selectorUsed: usedSelector, value: actual };
1164
1888
  }
1889
+ case "waitForWsMessage": {
1890
+ if (typeof step.match !== "string") {
1891
+ throw new Error("waitForWsMessage requires match");
1892
+ }
1893
+ const message = await this.waitForWsMessage(step.match, step.where, timeout);
1894
+ return { value: message };
1895
+ }
1896
+ case "assertNoConsoleErrors": {
1897
+ await this.assertNoConsoleErrors(step.windowMs ?? timeout);
1898
+ return {};
1899
+ }
1900
+ case "assertTextChanged": {
1901
+ const selector = Array.isArray(step.selector) ? step.selector[0] : step.selector;
1902
+ if (typeof step.to !== "string") {
1903
+ throw new Error("assertTextChanged requires to");
1904
+ }
1905
+ const text = await this.assertTextChanged(selector, step.from, step.to, timeout);
1906
+ return { selectorUsed: selector, text };
1907
+ }
1908
+ case "assertPermission": {
1909
+ if (!step.name || !step.state) {
1910
+ throw new Error("assertPermission requires name and state");
1911
+ }
1912
+ const permission = await this.assertPermission(step.name, step.state);
1913
+ return { value: permission };
1914
+ }
1915
+ case "assertMediaTrackLive": {
1916
+ if (!step.kind) {
1917
+ throw new Error("assertMediaTrackLive requires kind");
1918
+ }
1919
+ const media = await this.assertMediaTrackLive(step.kind);
1920
+ return { value: media };
1921
+ }
1165
1922
  default: {
1166
1923
  const action = step.action;
1167
1924
  const aliases = {
@@ -1215,7 +1972,7 @@ var BatchExecutor = class {
1215
1972
  };
1216
1973
  const suggestion = aliases[action.toLowerCase()];
1217
1974
  const hint = suggestion ? ` Did you mean "${suggestion}"?` : "";
1218
- 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";
1975
+ 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";
1219
1976
  throw new Error(`Unknown action "${action}".${hint}
1220
1977
 
1221
1978
  Valid actions: ${valid}`);
@@ -1231,6 +1988,237 @@ Valid actions: ${valid}`);
1231
1988
  if (matched) return matched;
1232
1989
  return Array.isArray(selector) ? selector[0] : selector;
1233
1990
  }
1991
+ async ensureTraceHooks() {
1992
+ await this.page.cdpClient.send("Runtime.enable");
1993
+ await this.page.cdpClient.send("Page.enable");
1994
+ await this.page.cdpClient.send("Network.enable");
1995
+ try {
1996
+ await this.page.cdpClient.send("Runtime.addBinding", { name: TRACE_BINDING_NAME });
1997
+ } catch {
1998
+ }
1999
+ await this.page.cdpClient.send("Page.addScriptToEvaluateOnNewDocument", {
2000
+ source: TRACE_SCRIPT
2001
+ });
2002
+ await this.page.cdpClient.send("Runtime.evaluate", {
2003
+ expression: TRACE_SCRIPT,
2004
+ awaitPromise: false
2005
+ });
2006
+ }
2007
+ async waitForWsMessage(match, where, timeout) {
2008
+ await this.ensureTraceHooks();
2009
+ const regex = globToRegex(match);
2010
+ const wsUrls = /* @__PURE__ */ new Map();
2011
+ const recentMatch = await this.findRecentWsMessage(regex, where);
2012
+ if (recentMatch) {
2013
+ return recentMatch;
2014
+ }
2015
+ return new Promise((resolve, reject) => {
2016
+ const cleanup = () => {
2017
+ this.page.cdpClient.off("Network.webSocketCreated", onCreated);
2018
+ this.page.cdpClient.off("Network.webSocketFrameReceived", onFrame);
2019
+ this.page.cdpClient.off("Runtime.bindingCalled", onBinding);
2020
+ clearTimeout(timer);
2021
+ };
2022
+ const onCreated = (params) => {
2023
+ wsUrls.set(readStringOr(params["requestId"]), readStringOr(params["url"]));
2024
+ };
2025
+ const onFrame = (params) => {
2026
+ const requestId = readStringOr(params["requestId"]);
2027
+ const response = params["response"] ?? {};
2028
+ const payload = response.payloadData ?? "";
2029
+ const url = wsUrls.get(requestId) ?? "";
2030
+ if (!regex.test(url) && !regex.test(payload)) {
2031
+ return;
2032
+ }
2033
+ if (where && !this.payloadMatchesWhere(payload, where)) {
2034
+ return;
2035
+ }
2036
+ cleanup();
2037
+ resolve({ requestId, url, payload });
2038
+ };
2039
+ const onBinding = (params) => {
2040
+ if (params["name"] !== TRACE_BINDING_NAME) {
2041
+ return;
2042
+ }
2043
+ try {
2044
+ const parsed = JSON.parse(readStringOr(params["payload"]));
2045
+ if (parsed.event !== "ws.frame.received") {
2046
+ return;
2047
+ }
2048
+ const data = parsed.data ?? {};
2049
+ const payload = readStringOr(data["payload"]);
2050
+ const url = readStringOr(data["url"]);
2051
+ if (!regex.test(url) && !regex.test(payload)) {
2052
+ return;
2053
+ }
2054
+ if (where && !this.payloadMatchesWhere(payload, where)) {
2055
+ return;
2056
+ }
2057
+ cleanup();
2058
+ resolve({
2059
+ requestId: readStringOr(data["connectionId"]),
2060
+ url,
2061
+ payload
2062
+ });
2063
+ } catch {
2064
+ }
2065
+ };
2066
+ const timer = setTimeout(() => {
2067
+ cleanup();
2068
+ reject(new Error(`Timed out waiting for WebSocket message matching ${match}`));
2069
+ }, timeout);
2070
+ this.page.cdpClient.on("Network.webSocketCreated", onCreated);
2071
+ this.page.cdpClient.on("Network.webSocketFrameReceived", onFrame);
2072
+ this.page.cdpClient.on("Runtime.bindingCalled", onBinding);
2073
+ });
2074
+ }
2075
+ payloadMatchesWhere(payload, where) {
2076
+ try {
2077
+ const parsed = JSON.parse(payload);
2078
+ return Object.entries(where).every(([key, expected]) => {
2079
+ const actual = key.split(".").reduce((current, part) => {
2080
+ if (!current || typeof current !== "object") {
2081
+ return void 0;
2082
+ }
2083
+ return current[part];
2084
+ }, parsed);
2085
+ return actual === expected;
2086
+ });
2087
+ } catch {
2088
+ return false;
2089
+ }
2090
+ }
2091
+ async findRecentWsMessage(regex, where) {
2092
+ const recent = await this.page.evaluate(
2093
+ "(() => Array.isArray(globalThis.__bpTraceRecentEvents) ? globalThis.__bpTraceRecentEvents : [])()"
2094
+ );
2095
+ if (!Array.isArray(recent)) {
2096
+ return null;
2097
+ }
2098
+ for (let i = recent.length - 1; i >= 0; i--) {
2099
+ const entry = recent[i];
2100
+ if (!entry || typeof entry !== "object") {
2101
+ continue;
2102
+ }
2103
+ const record = entry;
2104
+ const event = readStringOr(record["event"]);
2105
+ if (event !== "ws.frame.received") {
2106
+ continue;
2107
+ }
2108
+ const data = record["data"] ?? {};
2109
+ const payload = readStringOr(data["payload"]);
2110
+ const url = readStringOr(data["url"]);
2111
+ if (!regex.test(url) && !regex.test(payload)) {
2112
+ continue;
2113
+ }
2114
+ if (where && !this.payloadMatchesWhere(payload, where)) {
2115
+ continue;
2116
+ }
2117
+ return {
2118
+ requestId: readStringOr(data["connectionId"]),
2119
+ url,
2120
+ payload
2121
+ };
2122
+ }
2123
+ return null;
2124
+ }
2125
+ async assertNoConsoleErrors(windowMs) {
2126
+ await this.page.cdpClient.send("Runtime.enable");
2127
+ return new Promise((resolve, reject) => {
2128
+ const errors = [];
2129
+ const cleanup = () => {
2130
+ this.page.cdpClient.off("Runtime.consoleAPICalled", onConsole);
2131
+ this.page.cdpClient.off("Runtime.exceptionThrown", onException);
2132
+ clearTimeout(timer);
2133
+ };
2134
+ const onConsole = (params) => {
2135
+ if (params["type"] !== "error") {
2136
+ return;
2137
+ }
2138
+ const args = Array.isArray(params["args"]) ? params["args"] : [];
2139
+ errors.push(args.map(formatConsoleArg).filter(Boolean).join(" "));
2140
+ };
2141
+ const onException = (params) => {
2142
+ const details = params["exceptionDetails"] ?? {};
2143
+ errors.push(readString(details["text"]) ?? "Runtime exception");
2144
+ };
2145
+ const timer = setTimeout(() => {
2146
+ cleanup();
2147
+ if (errors.length > 0) {
2148
+ reject(new Error(`Console errors detected: ${errors.join(" | ")}`));
2149
+ return;
2150
+ }
2151
+ resolve();
2152
+ }, windowMs);
2153
+ this.page.cdpClient.on("Runtime.consoleAPICalled", onConsole);
2154
+ this.page.cdpClient.on("Runtime.exceptionThrown", onException);
2155
+ });
2156
+ }
2157
+ async assertTextChanged(selector, from, to, timeout) {
2158
+ const initialText = from ?? await this.page.text(selector);
2159
+ const deadline = Date.now() + timeout;
2160
+ while (Date.now() < deadline) {
2161
+ const text = await this.page.text(selector);
2162
+ if (text !== initialText && text.includes(to)) {
2163
+ return text;
2164
+ }
2165
+ await new Promise((resolve) => setTimeout(resolve, 200));
2166
+ }
2167
+ throw new Error(`Text did not change to include ${JSON.stringify(to)}`);
2168
+ }
2169
+ async assertPermission(name, state) {
2170
+ const result = await this.page.evaluate(
2171
+ `(() => navigator.permissions.query({ name: ${JSON.stringify(name)} }).then((status) => ({ name: ${JSON.stringify(name)}, state: status.state })))()`
2172
+ );
2173
+ if (!result || typeof result !== "object" || result.state !== state) {
2174
+ throw new Error(`Permission ${name} is not ${state}`);
2175
+ }
2176
+ return result;
2177
+ }
2178
+ async assertMediaTrackLive(kind) {
2179
+ const result = await this.page.evaluate(
2180
+ `(() => {
2181
+ const requestedKind = ${JSON.stringify(kind)};
2182
+ const mediaElements = Array.from(document.querySelectorAll('audio,video')).map((el) => {
2183
+ const tracks = [];
2184
+ if (el.srcObject && typeof el.srcObject.getTracks === 'function') {
2185
+ tracks.push(...el.srcObject.getTracks());
2186
+ }
2187
+ return {
2188
+ tag: el.tagName.toLowerCase(),
2189
+ paused: !!el.paused,
2190
+ tracks: tracks.map((track) => ({
2191
+ kind: track.kind,
2192
+ readyState: track.readyState,
2193
+ enabled: track.enabled,
2194
+ label: track.label,
2195
+ })),
2196
+ };
2197
+ });
2198
+
2199
+ const globalTracks =
2200
+ window.__bpStream && typeof window.__bpStream.getTracks === 'function'
2201
+ ? window.__bpStream.getTracks().map((track) => ({
2202
+ kind: track.kind,
2203
+ readyState: track.readyState,
2204
+ enabled: track.enabled,
2205
+ label: track.label,
2206
+ }))
2207
+ : [];
2208
+
2209
+ const liveTracks = mediaElements
2210
+ .flatMap((entry) => entry.tracks)
2211
+ .concat(globalTracks)
2212
+ .filter((track) => track.kind === requestedKind && track.readyState === 'live');
2213
+
2214
+ return { live: liveTracks.length > 0, mediaElements, globalTracks, liveTracks };
2215
+ })()`
2216
+ );
2217
+ if (!result || typeof result !== "object" || !result.live) {
2218
+ throw new Error(`No live ${kind} media track detected`);
2219
+ }
2220
+ return result;
2221
+ }
1234
2222
  };
1235
2223
  function addBatchToPage(page) {
1236
2224
  const executor = new BatchExecutor(page);
@@ -1361,7 +2349,7 @@ var ACTION_RULES = {
1361
2349
  value: { type: "string|string[]" },
1362
2350
  trigger: { type: "string|string[]" },
1363
2351
  option: { type: "string|string[]" },
1364
- match: { type: "string", enum: ["text", "value", "contains"] }
2352
+ match: { type: "string" }
1365
2353
  }
1366
2354
  },
1367
2355
  check: {
@@ -1492,6 +2480,38 @@ var ACTION_RULES = {
1492
2480
  expect: { type: "string" },
1493
2481
  value: { type: "string" }
1494
2482
  }
2483
+ },
2484
+ waitForWsMessage: {
2485
+ required: { match: { type: "string" } },
2486
+ optional: {
2487
+ where: { type: "object" }
2488
+ }
2489
+ },
2490
+ assertNoConsoleErrors: {
2491
+ required: {},
2492
+ optional: {
2493
+ windowMs: { type: "number" }
2494
+ }
2495
+ },
2496
+ assertTextChanged: {
2497
+ required: { to: { type: "string" } },
2498
+ optional: {
2499
+ selector: { type: "string|string[]" },
2500
+ from: { type: "string" }
2501
+ }
2502
+ },
2503
+ assertPermission: {
2504
+ required: {
2505
+ name: { type: "string" },
2506
+ state: { type: "string" }
2507
+ },
2508
+ optional: {}
2509
+ },
2510
+ assertMediaTrackLive: {
2511
+ required: {
2512
+ kind: { type: "string", enum: ["audio", "video"] }
2513
+ },
2514
+ optional: {}
1495
2515
  }
1496
2516
  };
1497
2517
  var VALID_ACTIONS = Object.keys(ACTION_RULES);
@@ -1515,6 +2535,7 @@ var KNOWN_STEP_FIELDS = /* @__PURE__ */ new Set([
1515
2535
  "trigger",
1516
2536
  "option",
1517
2537
  "match",
2538
+ "where",
1518
2539
  "x",
1519
2540
  "y",
1520
2541
  "direction",
@@ -1524,7 +2545,13 @@ var KNOWN_STEP_FIELDS = /* @__PURE__ */ new Set([
1524
2545
  "fullPage",
1525
2546
  "expect",
1526
2547
  "retry",
1527
- "retryDelay"
2548
+ "retryDelay",
2549
+ "from",
2550
+ "to",
2551
+ "name",
2552
+ "state",
2553
+ "kind",
2554
+ "windowMs"
1528
2555
  ]);
1529
2556
  function resolveAction(name) {
1530
2557
  if (VALID_ACTIONS.includes(name)) {
@@ -1597,6 +2624,11 @@ function checkFieldType(value, rule) {
1597
2624
  return `expected boolean or "auto", got ${typeof value}`;
1598
2625
  }
1599
2626
  return null;
2627
+ case "object":
2628
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2629
+ return `expected object, got ${Array.isArray(value) ? "array" : typeof value}`;
2630
+ }
2631
+ return null;
1600
2632
  default: {
1601
2633
  const _exhaustive = rule.type;
1602
2634
  return `unknown type: ${_exhaustive}`;