browser-pilot 0.0.13 → 0.0.15

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