browser-pilot 0.0.13 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,230 @@ import {
2
2
  CDPError
3
3
  } from "./chunk-JXAUPHZM.mjs";
4
4
 
5
+ // src/actions/executor.ts
6
+ import * as fs from "fs";
7
+ import { join } from "path";
8
+
9
+ // src/recording/redaction.ts
10
+ var REDACTED_VALUE = "[REDACTED]";
11
+ var SENSITIVE_AUTOCOMPLETE_TOKENS = [
12
+ "current-password",
13
+ "new-password",
14
+ "one-time-code",
15
+ "cc-number",
16
+ "cc-csc",
17
+ "cc-exp",
18
+ "cc-exp-month",
19
+ "cc-exp-year"
20
+ ];
21
+ function autocompleteTokens(autocomplete) {
22
+ if (!autocomplete) return [];
23
+ return autocomplete.toLowerCase().split(/\s+/).map((token) => token.trim()).filter(Boolean);
24
+ }
25
+ function isSensitiveFieldMetadata(metadata) {
26
+ if (!metadata) return false;
27
+ if (metadata.sensitiveValue) return true;
28
+ const inputType = metadata.inputType?.toLowerCase();
29
+ if (inputType === "password" || inputType === "hidden") {
30
+ return true;
31
+ }
32
+ const sensitiveAutocompleteTokens = new Set(SENSITIVE_AUTOCOMPLETE_TOKENS);
33
+ return autocompleteTokens(metadata.autocomplete).some(
34
+ (token) => sensitiveAutocompleteTokens.has(token)
35
+ );
36
+ }
37
+ function redactValueForRecording(value, metadata) {
38
+ if (value === void 0) return void 0;
39
+ return isSensitiveFieldMetadata(metadata) ? REDACTED_VALUE : value;
40
+ }
41
+
42
+ // src/browser/action-highlight.ts
43
+ var HIGHLIGHT_STYLES = {
44
+ click: { outline: "3px solid rgba(229,57,53,0.8)", badge: "#e53935", marker: "crosshair" },
45
+ fill: { outline: "3px solid rgba(33,150,243,0.8)", badge: "#2196f3" },
46
+ type: { outline: "3px solid rgba(33,150,243,0.6)", badge: "#2196f3" },
47
+ select: { outline: "3px solid rgba(156,39,176,0.8)", badge: "#9c27b0" },
48
+ hover: { outline: "2px dashed rgba(158,158,158,0.5)", badge: "#9e9e9e" },
49
+ scroll: { outline: "none", badge: "#607d8b", marker: "arrow" },
50
+ navigate: { outline: "none", badge: "#4caf50" },
51
+ submit: { outline: "3px solid rgba(255,152,0,0.8)", badge: "#ff9800" },
52
+ "assert-pass": { outline: "3px solid rgba(76,175,80,0.8)", badge: "#4caf50", marker: "check" },
53
+ "assert-fail": { outline: "3px solid rgba(244,67,54,0.8)", badge: "#f44336", marker: "cross" },
54
+ evaluate: { outline: "none", badge: "#ffc107" },
55
+ focus: { outline: "3px dotted rgba(33,150,243,0.6)", badge: "#2196f3" }
56
+ };
57
+ function buildHighlightScript(options) {
58
+ const style = HIGHLIGHT_STYLES[options.kind];
59
+ const label = options.label ? options.label.slice(0, 80) : void 0;
60
+ const escapedLabel = label ? label.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n") : "";
61
+ return `(function() {
62
+ // Remove any existing highlight
63
+ var existing = document.getElementById('__bp-action-highlight');
64
+ if (existing) existing.remove();
65
+
66
+ var container = document.createElement('div');
67
+ container.id = '__bp-action-highlight';
68
+ container.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:99999;';
69
+
70
+ ${options.bbox ? `
71
+ // Element outline
72
+ var outline = document.createElement('div');
73
+ outline.style.cssText = 'position:fixed;' +
74
+ 'left:${options.bbox.x}px;top:${options.bbox.y}px;' +
75
+ 'width:${options.bbox.width}px;height:${options.bbox.height}px;' +
76
+ '${style.outline !== "none" ? `outline:${style.outline};outline-offset:-1px;` : ""}' +
77
+ 'pointer-events:none;box-sizing:border-box;';
78
+ container.appendChild(outline);
79
+ ` : ""}
80
+
81
+ ${options.point && style.marker === "crosshair" ? `
82
+ // Crosshair at click point
83
+ var hLine = document.createElement('div');
84
+ hLine.style.cssText = 'position:fixed;left:${options.point.x - 12}px;top:${options.point.y}px;' +
85
+ 'width:24px;height:2px;background:${style.badge};pointer-events:none;';
86
+ var vLine = document.createElement('div');
87
+ vLine.style.cssText = 'position:fixed;left:${options.point.x}px;top:${options.point.y - 12}px;' +
88
+ 'width:2px;height:24px;background:${style.badge};pointer-events:none;';
89
+ // Dot at center
90
+ var dot = document.createElement('div');
91
+ dot.style.cssText = 'position:fixed;left:${options.point.x - 4}px;top:${options.point.y - 4}px;' +
92
+ 'width:8px;height:8px;border-radius:50%;background:${style.badge};pointer-events:none;';
93
+ container.appendChild(hLine);
94
+ container.appendChild(vLine);
95
+ container.appendChild(dot);
96
+ ` : ""}
97
+
98
+ ${label ? `
99
+ // Badge with label
100
+ var badge = document.createElement('div');
101
+ badge.style.cssText = 'position:fixed;' +
102
+ ${options.bbox ? `'left:${options.bbox.x}px;top:${Math.max(0, options.bbox.y - 28)}px;'` : options.kind === "navigate" ? "'left:50%;top:8px;transform:translateX(-50%);'" : "'right:8px;top:8px;'"} +
103
+ 'background:${style.badge};color:white;padding:4px 8px;' +
104
+ 'font-family:monospace;font-size:12px;font-weight:bold;' +
105
+ 'border-radius:3px;white-space:nowrap;max-width:400px;overflow:hidden;text-overflow:ellipsis;' +
106
+ 'pointer-events:none;';
107
+ badge.textContent = '${escapedLabel}';
108
+ container.appendChild(badge);
109
+ ` : ""}
110
+
111
+ ${style.marker === "check" && options.bbox ? `
112
+ // Checkmark
113
+ var check = document.createElement('div');
114
+ check.style.cssText = 'position:fixed;left:${options.bbox.x + options.bbox.width / 2 - 10}px;' +
115
+ 'top:${options.bbox.y + options.bbox.height / 2 - 10}px;' +
116
+ 'width:20px;height:20px;font-size:18px;color:${style.badge};pointer-events:none;text-align:center;line-height:20px;';
117
+ check.textContent = '\\u2713';
118
+ container.appendChild(check);
119
+ ` : ""}
120
+
121
+ ${style.marker === "cross" && options.bbox ? `
122
+ // Cross mark
123
+ var cross = document.createElement('div');
124
+ cross.style.cssText = 'position:fixed;left:${options.bbox.x + options.bbox.width / 2 - 10}px;' +
125
+ 'top:${options.bbox.y + options.bbox.height / 2 - 10}px;' +
126
+ 'width:20px;height:20px;font-size:18px;color:${style.badge};pointer-events:none;text-align:center;line-height:20px;font-weight:bold;';
127
+ cross.textContent = '\\u2717';
128
+ container.appendChild(cross);
129
+ ` : ""}
130
+
131
+ document.body.appendChild(container);
132
+ window.__bpRemoveActionHighlight = function() {
133
+ var el = document.getElementById('__bp-action-highlight');
134
+ if (el) el.remove();
135
+ delete window.__bpRemoveActionHighlight;
136
+ };
137
+ })();`;
138
+ }
139
+ async function injectActionHighlight(page, options) {
140
+ try {
141
+ await page.evaluate(buildHighlightScript(options));
142
+ } catch {
143
+ }
144
+ }
145
+ async function removeActionHighlight(page) {
146
+ try {
147
+ await page.evaluate(`(function() {
148
+ if (window.__bpRemoveActionHighlight) {
149
+ window.__bpRemoveActionHighlight();
150
+ }
151
+ })()`);
152
+ } catch {
153
+ }
154
+ }
155
+ function stepToHighlightKind(step) {
156
+ switch (step.action) {
157
+ case "click":
158
+ return "click";
159
+ case "fill":
160
+ return "fill";
161
+ case "type":
162
+ return "type";
163
+ case "select":
164
+ return "select";
165
+ case "hover":
166
+ return "hover";
167
+ case "scroll":
168
+ return "scroll";
169
+ case "goto":
170
+ return "navigate";
171
+ case "submit":
172
+ return "submit";
173
+ case "focus":
174
+ return "focus";
175
+ case "evaluate":
176
+ case "press":
177
+ case "shortcut":
178
+ return "evaluate";
179
+ case "assertVisible":
180
+ case "assertExists":
181
+ case "assertText":
182
+ case "assertUrl":
183
+ case "assertValue":
184
+ return step.success ? "assert-pass" : "assert-fail";
185
+ // Observation-only actions — no highlight
186
+ case "wait":
187
+ case "snapshot":
188
+ case "forms":
189
+ case "text":
190
+ case "screenshot":
191
+ case "newTab":
192
+ case "closeTab":
193
+ case "switchFrame":
194
+ case "switchToMain":
195
+ return null;
196
+ default:
197
+ return null;
198
+ }
199
+ }
200
+ function getHighlightLabel(step, result, targetMetadata) {
201
+ switch (step.action) {
202
+ case "fill":
203
+ case "type":
204
+ return typeof step.value === "string" ? `"${redactValueForRecording(step.value, targetMetadata)}"` : void 0;
205
+ case "select":
206
+ return redactValueForRecording(
207
+ typeof step.value === "string" ? step.value : void 0,
208
+ targetMetadata
209
+ );
210
+ case "goto":
211
+ return step.url;
212
+ case "evaluate":
213
+ return "JS";
214
+ case "press":
215
+ return step.key;
216
+ case "shortcut":
217
+ return step.combo;
218
+ case "assertText":
219
+ case "assertUrl":
220
+ case "assertValue":
221
+ case "assertVisible":
222
+ case "assertExists":
223
+ return result.success ? "\u2713" : "\u2717";
224
+ default:
225
+ return void 0;
226
+ }
227
+ }
228
+
5
229
  // src/browser/actionability.ts
6
230
  var ActionabilityError = class extends Error {
7
231
  failureType;
@@ -616,6 +840,13 @@ var NavigationError = class extends Error {
616
840
 
617
841
  // src/actions/executor.ts
618
842
  var DEFAULT_TIMEOUT = 3e4;
843
+ var DEFAULT_RECORDING_SKIP_ACTIONS = [
844
+ "wait",
845
+ "snapshot",
846
+ "forms",
847
+ "text",
848
+ "screenshot"
849
+ ];
619
850
  function classifyFailure(error) {
620
851
  if (error instanceof ElementNotFoundError) {
621
852
  return { reason: "missing" };
@@ -695,6 +926,9 @@ var BatchExecutor = class {
695
926
  const { timeout = DEFAULT_TIMEOUT, onFail = "stop" } = options;
696
927
  const results = [];
697
928
  const startTime = Date.now();
929
+ const recording = options.record ? this.createRecordingContext(options.record) : null;
930
+ const startUrl = recording ? await this.getPageUrlSafe() : "";
931
+ let stoppedAtIndex;
698
932
  for (let i = 0; i < steps.length; i++) {
699
933
  const step = steps[i];
700
934
  const stepStart = Date.now();
@@ -707,8 +941,9 @@ var BatchExecutor = class {
707
941
  await new Promise((resolve) => setTimeout(resolve, retryDelay));
708
942
  }
709
943
  try {
944
+ this.page.resetLastActionPosition();
710
945
  const result = await this.executeStep(step, timeout);
711
- results.push({
946
+ const stepResult = {
712
947
  index: i,
713
948
  action: step.action,
714
949
  selector: step.selector,
@@ -716,8 +951,15 @@ var BatchExecutor = class {
716
951
  success: true,
717
952
  durationMs: Date.now() - stepStart,
718
953
  result: result.value,
719
- text: result.text
720
- });
954
+ text: result.text,
955
+ timestamp: Date.now(),
956
+ coordinates: this.page.getLastActionCoordinates() ?? void 0,
957
+ boundingBox: this.page.getLastActionBoundingBox() ?? void 0
958
+ };
959
+ if (recording && !recording.skipActions.has(step.action)) {
960
+ await this.captureRecordingFrame(step, stepResult, recording);
961
+ }
962
+ results.push(stepResult);
721
963
  succeeded = true;
722
964
  break;
723
965
  } catch (error) {
@@ -738,7 +980,7 @@ var BatchExecutor = class {
738
980
  } catch {
739
981
  }
740
982
  }
741
- results.push({
983
+ const failedResult = {
742
984
  index: i,
743
985
  action: step.action,
744
986
  selector: step.selector,
@@ -748,24 +990,176 @@ var BatchExecutor = class {
748
990
  hints,
749
991
  failureReason: reason,
750
992
  coveringElement,
751
- suggestion: getSuggestion(reason)
752
- });
993
+ suggestion: getSuggestion(reason),
994
+ timestamp: Date.now()
995
+ };
996
+ if (recording && !recording.skipActions.has(step.action)) {
997
+ await this.captureRecordingFrame(step, failedResult, recording);
998
+ }
999
+ results.push(failedResult);
753
1000
  if (onFail === "stop" && !step.optional) {
754
- return {
755
- success: false,
756
- stoppedAtIndex: i,
757
- steps: results,
758
- totalDurationMs: Date.now() - startTime
759
- };
1001
+ stoppedAtIndex = i;
1002
+ break;
760
1003
  }
761
1004
  }
762
1005
  }
763
- const allSuccess = results.every((r) => r.success || steps[r.index]?.optional);
1006
+ const totalDurationMs = Date.now() - startTime;
1007
+ const allSuccess = stoppedAtIndex === void 0 && results.every((result) => result.success || steps[result.index]?.optional);
1008
+ let recordingManifest;
1009
+ if (recording) {
1010
+ recordingManifest = await this.writeRecordingManifest(
1011
+ recording,
1012
+ startTime,
1013
+ startUrl,
1014
+ allSuccess
1015
+ );
1016
+ }
764
1017
  return {
765
1018
  success: allSuccess,
1019
+ stoppedAtIndex,
766
1020
  steps: results,
767
- totalDurationMs: Date.now() - startTime
1021
+ totalDurationMs,
1022
+ recordingManifest
1023
+ };
1024
+ }
1025
+ createRecordingContext(record) {
1026
+ const baseDir = record.outputDir ?? join(process.cwd(), ".browser-pilot");
1027
+ const screenshotDir = join(baseDir, "screenshots");
1028
+ const manifestPath = join(baseDir, "recording.json");
1029
+ let existingFrames = [];
1030
+ try {
1031
+ const existing = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
1032
+ if (existing.frames && Array.isArray(existing.frames)) {
1033
+ existingFrames = existing.frames;
1034
+ }
1035
+ } catch {
1036
+ }
1037
+ fs.mkdirSync(screenshotDir, { recursive: true });
1038
+ return {
1039
+ baseDir,
1040
+ screenshotDir,
1041
+ sessionId: record.sessionId ?? this.page.targetId,
1042
+ frames: existingFrames,
1043
+ format: record.format ?? "webp",
1044
+ quality: Math.max(0, Math.min(100, record.quality ?? 40)),
1045
+ highlights: record.highlights !== false,
1046
+ skipActions: new Set(record.skipActions ?? DEFAULT_RECORDING_SKIP_ACTIONS)
1047
+ };
1048
+ }
1049
+ async getPageUrlSafe() {
1050
+ try {
1051
+ return await this.page.url();
1052
+ } catch {
1053
+ return "";
1054
+ }
1055
+ }
1056
+ /**
1057
+ * Capture a recording screenshot frame with optional highlight overlay
1058
+ */
1059
+ async captureRecordingFrame(step, stepResult, recording) {
1060
+ const targetMetadata = this.page.getLastActionTargetMetadata();
1061
+ let highlightInjected = false;
1062
+ try {
1063
+ const ts = Date.now();
1064
+ const seq = String(recording.frames.length + 1).padStart(4, "0");
1065
+ const filename = `${seq}-${ts}-${stepResult.action}.${recording.format}`;
1066
+ const filepath = join(recording.screenshotDir, filename);
1067
+ if (recording.highlights) {
1068
+ const kind = stepToHighlightKind(stepResult);
1069
+ if (kind) {
1070
+ await injectActionHighlight(this.page, {
1071
+ kind,
1072
+ bbox: stepResult.boundingBox,
1073
+ point: stepResult.coordinates,
1074
+ label: getHighlightLabel(step, stepResult, targetMetadata)
1075
+ });
1076
+ highlightInjected = true;
1077
+ }
1078
+ }
1079
+ const base64 = await this.page.screenshot({
1080
+ format: recording.format,
1081
+ quality: recording.quality
1082
+ });
1083
+ const buffer = Buffer.from(base64, "base64");
1084
+ fs.writeFileSync(filepath, buffer);
1085
+ stepResult.screenshotPath = filepath;
1086
+ let pageUrl;
1087
+ let pageTitle;
1088
+ try {
1089
+ pageUrl = await this.page.url();
1090
+ pageTitle = await this.page.title();
1091
+ } catch {
1092
+ }
1093
+ recording.frames.push({
1094
+ seq: recording.frames.length + 1,
1095
+ timestamp: ts,
1096
+ action: stepResult.action,
1097
+ selector: stepResult.selectorUsed ?? (Array.isArray(step.selector) ? step.selector[0] : step.selector),
1098
+ value: redactValueForRecording(
1099
+ typeof step.value === "string" ? step.value : void 0,
1100
+ targetMetadata
1101
+ ),
1102
+ url: step.url,
1103
+ coordinates: stepResult.coordinates,
1104
+ boundingBox: stepResult.boundingBox,
1105
+ success: stepResult.success,
1106
+ durationMs: stepResult.durationMs,
1107
+ error: stepResult.error,
1108
+ screenshot: filename,
1109
+ pageUrl,
1110
+ pageTitle
1111
+ });
1112
+ } catch {
1113
+ } finally {
1114
+ if (recording.highlights || highlightInjected) {
1115
+ await removeActionHighlight(this.page);
1116
+ }
1117
+ }
1118
+ }
1119
+ /**
1120
+ * Write recording manifest to disk
1121
+ */
1122
+ async writeRecordingManifest(recording, startTime, startUrl, success) {
1123
+ let endUrl = startUrl;
1124
+ let viewport = { width: 1280, height: 720 };
1125
+ try {
1126
+ endUrl = await this.page.url();
1127
+ } catch {
1128
+ }
1129
+ try {
1130
+ const metrics = await this.page.cdpClient.send("Page.getLayoutMetrics");
1131
+ viewport = {
1132
+ width: metrics.cssVisualViewport.clientWidth,
1133
+ height: metrics.cssVisualViewport.clientHeight
1134
+ };
1135
+ } catch {
1136
+ }
1137
+ const manifestPath = join(recording.baseDir, "recording.json");
1138
+ let recordedAt = new Date(startTime).toISOString();
1139
+ let originalStartUrl = startUrl;
1140
+ try {
1141
+ const existing = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
1142
+ if (existing.recordedAt) recordedAt = existing.recordedAt;
1143
+ if (existing.startUrl) originalStartUrl = existing.startUrl;
1144
+ } catch {
1145
+ }
1146
+ const firstFrameTime = recording.frames[0]?.timestamp ?? startTime;
1147
+ const totalDurationMs = Date.now() - Math.min(firstFrameTime, startTime);
1148
+ const manifest = {
1149
+ version: 1,
1150
+ recordedAt,
1151
+ sessionId: recording.sessionId,
1152
+ startUrl: originalStartUrl,
1153
+ endUrl,
1154
+ viewport,
1155
+ format: recording.format,
1156
+ quality: recording.quality,
1157
+ totalDurationMs,
1158
+ success,
1159
+ frames: recording.frames
768
1160
  };
1161
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
1162
+ return manifestPath;
769
1163
  }
770
1164
  /**
771
1165
  * Execute a single step