leapfrog-mcp 0.6.2 → 0.6.3
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/adaptive-wait.d.ts +72 -0
- package/dist/adaptive-wait.js +695 -0
- package/dist/api-intelligence.d.ts +82 -0
- package/dist/api-intelligence.js +575 -0
- package/dist/captcha-solver.d.ts +26 -0
- package/dist/captcha-solver.js +547 -0
- package/dist/cdp-connector.d.ts +33 -0
- package/dist/cdp-connector.js +176 -0
- package/dist/consent-dismiss.d.ts +33 -0
- package/dist/consent-dismiss.js +358 -0
- package/dist/crash-recovery.d.ts +74 -0
- package/dist/crash-recovery.js +242 -0
- package/dist/domain-knowledge.d.ts +149 -0
- package/dist/domain-knowledge.js +449 -0
- package/dist/harness-intelligence.d.ts +65 -0
- package/dist/harness-intelligence.js +432 -0
- package/dist/humanize-fingerprint.d.ts +42 -0
- package/dist/humanize-fingerprint.js +161 -0
- package/dist/humanize-mouse.d.ts +95 -0
- package/dist/humanize-mouse.js +275 -0
- package/dist/humanize-pause.d.ts +48 -0
- package/dist/humanize-pause.js +111 -0
- package/dist/humanize-scroll.d.ts +67 -0
- package/dist/humanize-scroll.js +185 -0
- package/dist/humanize-typing.d.ts +60 -0
- package/dist/humanize-typing.js +258 -0
- package/dist/humanize-utils.d.ts +62 -0
- package/dist/humanize-utils.js +100 -0
- package/dist/intervention.d.ts +65 -0
- package/dist/intervention.js +591 -0
- package/dist/logger.d.ts +13 -0
- package/dist/logger.js +47 -0
- package/dist/network-intelligence.d.ts +70 -0
- package/dist/network-intelligence.js +424 -0
- package/dist/page-classifier.d.ts +33 -0
- package/dist/page-classifier.js +1000 -0
- package/dist/paginate.d.ts +42 -0
- package/dist/paginate.js +693 -0
- package/dist/recording.d.ts +72 -0
- package/dist/recording.js +934 -0
- package/dist/script-executor.d.ts +31 -0
- package/dist/script-executor.js +249 -0
- package/dist/session-hud.d.ts +20 -0
- package/dist/session-hud.js +134 -0
- package/dist/snapshot-differ.d.ts +26 -0
- package/dist/snapshot-differ.js +225 -0
- package/dist/ssrf.d.ts +28 -0
- package/dist/ssrf.js +290 -0
- package/dist/stealth-audit.d.ts +27 -0
- package/dist/stealth-audit.js +719 -0
- package/dist/stealth.d.ts +195 -0
- package/dist/stealth.js +1157 -0
- package/dist/tab-manager.d.ts +14 -0
- package/dist/tab-manager.js +306 -0
- package/dist/tiles-coordinator.d.ts +106 -0
- package/dist/tiles-coordinator.js +358 -0
- package/package.json +1 -1
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
// ─── Record / Replay Tier 1 ────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// session_export — export session action history as a replayable Recording
|
|
4
|
+
// session_replay — replay a Recording in a new (or existing) session
|
|
5
|
+
//
|
|
6
|
+
// Recording format is a JSON script of parameterized steps. Refs are resolved
|
|
7
|
+
// to stable Playwright selectors at export time so they survive across pages.
|
|
8
|
+
import { HarnessIntelligence } from "./harness-intelligence.js";
|
|
9
|
+
import { logger } from "./logger.js";
|
|
10
|
+
import { checkSSRF } from "./ssrf.js";
|
|
11
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
12
|
+
/** Tools that mutate browser state — kept in export */
|
|
13
|
+
const MUTATING_TOOLS = new Set([
|
|
14
|
+
"navigate",
|
|
15
|
+
"act",
|
|
16
|
+
"batch_actions",
|
|
17
|
+
"wait_for",
|
|
18
|
+
"add_init_script",
|
|
19
|
+
"network_intercept",
|
|
20
|
+
"execute",
|
|
21
|
+
"tab_switch",
|
|
22
|
+
"tab_close",
|
|
23
|
+
]);
|
|
24
|
+
/** Action types within the `act` tool that are mutating */
|
|
25
|
+
const MUTATING_ACTIONS = new Set([
|
|
26
|
+
"click",
|
|
27
|
+
"dblclick",
|
|
28
|
+
"fill",
|
|
29
|
+
"type",
|
|
30
|
+
"check",
|
|
31
|
+
"uncheck",
|
|
32
|
+
"select",
|
|
33
|
+
"press",
|
|
34
|
+
"scroll",
|
|
35
|
+
"hover",
|
|
36
|
+
"mousemove",
|
|
37
|
+
"drag",
|
|
38
|
+
"upload",
|
|
39
|
+
"resize",
|
|
40
|
+
"back",
|
|
41
|
+
"forward",
|
|
42
|
+
]);
|
|
43
|
+
/** Patterns for auto-detecting parameterizable values */
|
|
44
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
45
|
+
const URL_RE = /^https?:\/\/.+/;
|
|
46
|
+
const PASSWORD_FIELD_HINTS = ["password", "passwd", "pass", "secret", "pin"];
|
|
47
|
+
// ─── Ref Resolution ─────────────────────────────────────────────────────────
|
|
48
|
+
/**
|
|
49
|
+
* Resolve an @eN ref to a stable selector from the session's refMap.
|
|
50
|
+
* Returns the CSS/aria selector string, or the original value if not an @eN ref.
|
|
51
|
+
*/
|
|
52
|
+
function resolveRef(value, refMap) {
|
|
53
|
+
if (!value.startsWith("@e"))
|
|
54
|
+
return value;
|
|
55
|
+
const selector = refMap.get(value);
|
|
56
|
+
if (!selector) {
|
|
57
|
+
logger.warn("recording:ref-miss", { ref: value });
|
|
58
|
+
return value; // keep the raw ref as fallback
|
|
59
|
+
}
|
|
60
|
+
return selector;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* BUG-7 fix: Convert aria-ref=eN selectors to stable alternatives.
|
|
64
|
+
* aria-ref=eN is a Playwright-internal locator that only works within the session
|
|
65
|
+
* that created the aria snapshot — exported scripts can't use them.
|
|
66
|
+
* Use the session's refFingerprints to build a role-based selector instead.
|
|
67
|
+
*/
|
|
68
|
+
function stabilizeSelector(selector, refMap, refFingerprints) {
|
|
69
|
+
// aria-ref=eN → ephemeral, needs conversion
|
|
70
|
+
if (selector.startsWith("aria-ref=")) {
|
|
71
|
+
// Try to find the original @eN ref that resolved to this selector,
|
|
72
|
+
// then use its fingerprint to build a role-based locator
|
|
73
|
+
if (refFingerprints) {
|
|
74
|
+
for (const [ref, storedSelector] of refMap) {
|
|
75
|
+
if (storedSelector === selector) {
|
|
76
|
+
const fp = refFingerprints.get(ref);
|
|
77
|
+
if (fp) {
|
|
78
|
+
// Fingerprint format is "role:name" — convert to role=role[name="name"]
|
|
79
|
+
const colonIdx = fp.indexOf(":");
|
|
80
|
+
if (colonIdx > 0) {
|
|
81
|
+
const role = fp.slice(0, colonIdx);
|
|
82
|
+
const name = fp.slice(colonIdx + 1);
|
|
83
|
+
if (name) {
|
|
84
|
+
const escaped = name.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
85
|
+
return `role=${role}[name="${escaped}"]`;
|
|
86
|
+
}
|
|
87
|
+
return `role=${role}`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// No fingerprint available — can't stabilize, return as-is with warning
|
|
95
|
+
logger.warn("recording:unstable-selector", { selector });
|
|
96
|
+
return selector;
|
|
97
|
+
}
|
|
98
|
+
// role=button[name="OK"] → already stable
|
|
99
|
+
// CSS selectors → already stable
|
|
100
|
+
return selector;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Detect values that should be parameterized: emails, URLs in fill actions,
|
|
104
|
+
* and values going into password fields.
|
|
105
|
+
*/
|
|
106
|
+
function detectParams(steps) {
|
|
107
|
+
const detections = [];
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
let emailCount = 0;
|
|
110
|
+
let urlCount = 0;
|
|
111
|
+
let passwordCount = 0;
|
|
112
|
+
for (const step of steps) {
|
|
113
|
+
const value = step.args.value;
|
|
114
|
+
if (!value || typeof value !== "string")
|
|
115
|
+
continue;
|
|
116
|
+
// Check if this is a fill or type action
|
|
117
|
+
const isFillAction = step.tool === "act" &&
|
|
118
|
+
(step.args.action === "fill" || step.args.action === "type");
|
|
119
|
+
if (!isFillAction)
|
|
120
|
+
continue;
|
|
121
|
+
const target = step.args.target ?? "";
|
|
122
|
+
const targetLower = target.toLowerCase();
|
|
123
|
+
// Password field detection
|
|
124
|
+
const isPasswordField = PASSWORD_FIELD_HINTS.some((hint) => targetLower.includes(hint));
|
|
125
|
+
if (isPasswordField && !seen.has(value)) {
|
|
126
|
+
seen.add(value);
|
|
127
|
+
passwordCount++;
|
|
128
|
+
const name = passwordCount === 1 ? "password" : `password_${passwordCount}`;
|
|
129
|
+
detections.push({
|
|
130
|
+
paramName: name,
|
|
131
|
+
original: value,
|
|
132
|
+
param: {
|
|
133
|
+
default: "",
|
|
134
|
+
description: "Password value",
|
|
135
|
+
sensitive: true,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
// Email detection
|
|
141
|
+
if (EMAIL_RE.test(value) && !seen.has(value)) {
|
|
142
|
+
seen.add(value);
|
|
143
|
+
emailCount++;
|
|
144
|
+
const name = emailCount === 1 ? "email" : `email_${emailCount}`;
|
|
145
|
+
detections.push({
|
|
146
|
+
paramName: name,
|
|
147
|
+
original: value,
|
|
148
|
+
param: {
|
|
149
|
+
default: value,
|
|
150
|
+
description: "Email address",
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// URL detection (in fill/type values, not navigate URLs)
|
|
156
|
+
if (URL_RE.test(value) && !seen.has(value)) {
|
|
157
|
+
seen.add(value);
|
|
158
|
+
urlCount++;
|
|
159
|
+
const name = urlCount === 1 ? "url" : `url_${urlCount}`;
|
|
160
|
+
detections.push({
|
|
161
|
+
paramName: name,
|
|
162
|
+
original: value,
|
|
163
|
+
param: {
|
|
164
|
+
default: value,
|
|
165
|
+
description: "URL value",
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return detections;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Apply parameter placeholders to step values.
|
|
174
|
+
* Replaces literal values with {{paramName}} tokens.
|
|
175
|
+
*/
|
|
176
|
+
function applyParams(steps, detections) {
|
|
177
|
+
if (detections.length === 0)
|
|
178
|
+
return steps;
|
|
179
|
+
// Build a map of original value → placeholder
|
|
180
|
+
const replacements = new Map();
|
|
181
|
+
for (const d of detections) {
|
|
182
|
+
replacements.set(d.original, `{{${d.paramName}}}`);
|
|
183
|
+
}
|
|
184
|
+
return steps.map((step) => {
|
|
185
|
+
const newArgs = { ...step.args };
|
|
186
|
+
// Replace in value field
|
|
187
|
+
if (typeof newArgs.value === "string" && replacements.has(newArgs.value)) {
|
|
188
|
+
newArgs.value = replacements.get(newArgs.value);
|
|
189
|
+
}
|
|
190
|
+
// Replace in url field (for navigate steps)
|
|
191
|
+
if (typeof newArgs.url === "string") {
|
|
192
|
+
for (const [original, placeholder] of replacements) {
|
|
193
|
+
if (newArgs.url === original) {
|
|
194
|
+
newArgs.url = placeholder;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { ...step, args: newArgs };
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// ─── Export ─────────────────────────────────────────────────────────────────
|
|
202
|
+
/**
|
|
203
|
+
* Export session action history as a replayable Recording.
|
|
204
|
+
*
|
|
205
|
+
* 1. Gets action history from HarnessIntelligence
|
|
206
|
+
* 2. Filters to mutating actions only
|
|
207
|
+
* 3. Resolves @eN refs to stable selectors via session.refMap
|
|
208
|
+
* 4. Auto-detects parameters for emails, URLs, passwords
|
|
209
|
+
* 5. Returns Recording JSON
|
|
210
|
+
*/
|
|
211
|
+
export function exportSession(sessionId, session, options) {
|
|
212
|
+
const history = HarnessIntelligence.getHistory(sessionId);
|
|
213
|
+
if (history.length === 0) {
|
|
214
|
+
throw new Error("No actions recorded in this session. Perform some actions first.");
|
|
215
|
+
}
|
|
216
|
+
// Determine the source URL (first navigate or earliest URL)
|
|
217
|
+
let sourceUrl = "";
|
|
218
|
+
for (const rec of history) {
|
|
219
|
+
if (rec.url) {
|
|
220
|
+
sourceUrl = rec.url;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// ── Step 1: Filter to mutating actions ──────────────────────────────────
|
|
225
|
+
const filtered = history.filter((rec) => {
|
|
226
|
+
const toolName = rec.toolCall?.toolName ?? rec.actionType;
|
|
227
|
+
// Keep mutating tools
|
|
228
|
+
if (MUTATING_TOOLS.has(toolName))
|
|
229
|
+
return true;
|
|
230
|
+
// Keep act sub-actions (click, fill, etc.) that come through analyzePostAction
|
|
231
|
+
if (MUTATING_ACTIONS.has(rec.actionType))
|
|
232
|
+
return true;
|
|
233
|
+
// Keep extract steps if option is set
|
|
234
|
+
if (options?.keepExtracts && toolName === "extract")
|
|
235
|
+
return true;
|
|
236
|
+
return false;
|
|
237
|
+
});
|
|
238
|
+
if (filtered.length === 0) {
|
|
239
|
+
throw new Error("No replayable actions found in session history.");
|
|
240
|
+
}
|
|
241
|
+
// ── Step 2: Convert to RecordingSteps with ref resolution ─────────────
|
|
242
|
+
const refMap = session.refMap;
|
|
243
|
+
const steps = [];
|
|
244
|
+
for (const rec of filtered) {
|
|
245
|
+
const toolName = rec.toolCall?.toolName ?? rec.actionType;
|
|
246
|
+
const params = rec.toolCall?.params ?? {};
|
|
247
|
+
// Build step based on tool type
|
|
248
|
+
if (toolName === "navigate") {
|
|
249
|
+
steps.push({
|
|
250
|
+
tool: "navigate",
|
|
251
|
+
args: {
|
|
252
|
+
url: params.url ?? rec.url,
|
|
253
|
+
...(params.waitUntil ? { waitUntil: params.waitUntil } : {}),
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
else if (toolName === "act") {
|
|
258
|
+
const action = params.action ?? rec.actionType;
|
|
259
|
+
const target = params.target ?? rec.target;
|
|
260
|
+
const value = params.value ?? rec.value;
|
|
261
|
+
const args = { action };
|
|
262
|
+
if (target) {
|
|
263
|
+
args.target = resolveRef(target, refMap);
|
|
264
|
+
args.target = stabilizeSelector(args.target, refMap, session.refFingerprints);
|
|
265
|
+
}
|
|
266
|
+
if (value !== undefined && value !== null)
|
|
267
|
+
args.value = value;
|
|
268
|
+
if (params.key)
|
|
269
|
+
args.key = params.key;
|
|
270
|
+
if (params.scrollDirection)
|
|
271
|
+
args.scrollDirection = params.scrollDirection;
|
|
272
|
+
if (params.scrollAmount)
|
|
273
|
+
args.scrollAmount = params.scrollAmount;
|
|
274
|
+
if (params.target2) {
|
|
275
|
+
args.target2 = resolveRef(params.target2, refMap);
|
|
276
|
+
args.target2 = stabilizeSelector(args.target2, refMap, session.refFingerprints);
|
|
277
|
+
}
|
|
278
|
+
if (params.filePaths)
|
|
279
|
+
args.filePaths = params.filePaths;
|
|
280
|
+
if (params.width)
|
|
281
|
+
args.width = params.width;
|
|
282
|
+
if (params.height)
|
|
283
|
+
args.height = params.height;
|
|
284
|
+
steps.push({ tool: "act", args });
|
|
285
|
+
}
|
|
286
|
+
else if (toolName === "batch_actions") {
|
|
287
|
+
// Inline batch actions as individual steps for replay clarity
|
|
288
|
+
const actions = params.actions;
|
|
289
|
+
if (actions) {
|
|
290
|
+
for (const a of actions) {
|
|
291
|
+
const target = a.target;
|
|
292
|
+
const args = { ...a };
|
|
293
|
+
if (target) {
|
|
294
|
+
args.target = resolveRef(target, refMap);
|
|
295
|
+
args.target = stabilizeSelector(args.target, refMap, session.refFingerprints);
|
|
296
|
+
}
|
|
297
|
+
steps.push({ tool: "act", args });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
else if (toolName === "wait_for") {
|
|
302
|
+
const args = {};
|
|
303
|
+
if (params.condition)
|
|
304
|
+
args.condition = params.condition;
|
|
305
|
+
if (params.target) {
|
|
306
|
+
args.target = resolveRef(params.target, refMap);
|
|
307
|
+
args.target = stabilizeSelector(args.target, refMap, session.refFingerprints);
|
|
308
|
+
}
|
|
309
|
+
if (params.text)
|
|
310
|
+
args.text = params.text;
|
|
311
|
+
if (params.js)
|
|
312
|
+
args.js = params.js;
|
|
313
|
+
if (params.timeout)
|
|
314
|
+
args.timeout = params.timeout;
|
|
315
|
+
steps.push({ tool: "wait_for", args });
|
|
316
|
+
}
|
|
317
|
+
else if (toolName === "add_init_script") {
|
|
318
|
+
steps.push({
|
|
319
|
+
tool: "add_init_script",
|
|
320
|
+
args: { script: params.script ?? "" },
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
else if (toolName === "network_intercept") {
|
|
324
|
+
steps.push({
|
|
325
|
+
tool: "network_intercept",
|
|
326
|
+
args: { ...params },
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
else if (toolName === "execute") {
|
|
330
|
+
steps.push({
|
|
331
|
+
tool: "execute",
|
|
332
|
+
args: { script: params.script ?? params.code ?? "" },
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
else if (toolName === "extract" && options?.keepExtracts) {
|
|
336
|
+
const args = {};
|
|
337
|
+
if (params.type)
|
|
338
|
+
args.type = params.type;
|
|
339
|
+
if (params.target) {
|
|
340
|
+
args.target = resolveRef(params.target, refMap);
|
|
341
|
+
}
|
|
342
|
+
if (params.js)
|
|
343
|
+
args.js = params.js;
|
|
344
|
+
steps.push({ tool: "extract", args });
|
|
345
|
+
}
|
|
346
|
+
else if (toolName === "tab_switch") {
|
|
347
|
+
steps.push({ tool: "tab_switch", args: { tabIndex: params.tabIndex } });
|
|
348
|
+
}
|
|
349
|
+
else if (toolName === "tab_close") {
|
|
350
|
+
steps.push({ tool: "tab_close", args: { tabIndex: params.tabIndex } });
|
|
351
|
+
}
|
|
352
|
+
else if (MUTATING_ACTIONS.has(rec.actionType)) {
|
|
353
|
+
// Raw act sub-action from analyzePostAction (no toolCall wrapper)
|
|
354
|
+
const args = { action: rec.actionType };
|
|
355
|
+
if (rec.target) {
|
|
356
|
+
args.target = resolveRef(rec.target, refMap);
|
|
357
|
+
args.target = stabilizeSelector(args.target, refMap, session.refFingerprints);
|
|
358
|
+
}
|
|
359
|
+
if (rec.value !== undefined)
|
|
360
|
+
args.value = rec.value;
|
|
361
|
+
steps.push({ tool: "act", args });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// ── Step 2b: Deduplicate consecutive identical steps ────────────────
|
|
365
|
+
// Both recordToolCall and analyzePostAction fire for the same act call,
|
|
366
|
+
// producing duplicate entries. Remove consecutive steps with matching
|
|
367
|
+
// tool + action + target + value.
|
|
368
|
+
const deduped = [];
|
|
369
|
+
for (const step of steps) {
|
|
370
|
+
const prev = deduped[deduped.length - 1];
|
|
371
|
+
if (prev &&
|
|
372
|
+
prev.tool === step.tool &&
|
|
373
|
+
prev.args.action === step.args.action &&
|
|
374
|
+
prev.args.target === step.args.target &&
|
|
375
|
+
prev.args.value === step.args.value &&
|
|
376
|
+
prev.args.key === step.args.key) {
|
|
377
|
+
continue; // Skip duplicate
|
|
378
|
+
}
|
|
379
|
+
deduped.push(step);
|
|
380
|
+
}
|
|
381
|
+
// ── Step 3: Auto-detect parameters ────────────────────────────────────
|
|
382
|
+
const detections = detectParams(deduped);
|
|
383
|
+
const parameterizedSteps = applyParams(deduped, detections);
|
|
384
|
+
const paramDefs = {};
|
|
385
|
+
for (const d of detections) {
|
|
386
|
+
paramDefs[d.paramName] = d.param;
|
|
387
|
+
}
|
|
388
|
+
// ── Step 4: Build Recording ───────────────────────────────────────────
|
|
389
|
+
const recording = {
|
|
390
|
+
version: 1,
|
|
391
|
+
name: options?.name ?? `recording-${Date.now()}`,
|
|
392
|
+
createdAt: Date.now(),
|
|
393
|
+
sourceUrl,
|
|
394
|
+
params: paramDefs,
|
|
395
|
+
steps: parameterizedSteps,
|
|
396
|
+
};
|
|
397
|
+
// ── Step 5: Return in requested format ────────────────────────────────
|
|
398
|
+
if (options?.format === "playwright") {
|
|
399
|
+
return toPlaywrightScript(recording);
|
|
400
|
+
}
|
|
401
|
+
return recording;
|
|
402
|
+
}
|
|
403
|
+
// ─── Playwright Script Export ───────────────────────────────────────────────
|
|
404
|
+
/**
|
|
405
|
+
* Convert a Recording to a Playwright-compatible JS function body.
|
|
406
|
+
* Output is suitable for the `execute` tool.
|
|
407
|
+
*/
|
|
408
|
+
export function toPlaywrightScript(recording) {
|
|
409
|
+
const lines = [];
|
|
410
|
+
lines.push("// Auto-generated Playwright script from Leapfrog session recording");
|
|
411
|
+
lines.push(`// Name: ${recording.name}`);
|
|
412
|
+
lines.push(`// Source: ${recording.sourceUrl}`);
|
|
413
|
+
lines.push(`// Steps: ${recording.steps.length}`);
|
|
414
|
+
lines.push("");
|
|
415
|
+
// Parameter declarations
|
|
416
|
+
if (Object.keys(recording.params).length > 0) {
|
|
417
|
+
lines.push("// Parameters — override these when calling");
|
|
418
|
+
lines.push("const params = {");
|
|
419
|
+
for (const [name, param] of Object.entries(recording.params)) {
|
|
420
|
+
const val = param.sensitive ? '""' : JSON.stringify(param.default);
|
|
421
|
+
const desc = param.description ? ` // ${param.description}` : "";
|
|
422
|
+
lines.push(` ${name}: ${val},${desc}`);
|
|
423
|
+
}
|
|
424
|
+
lines.push("};");
|
|
425
|
+
lines.push("");
|
|
426
|
+
}
|
|
427
|
+
// Step conversion
|
|
428
|
+
for (let i = 0; i < recording.steps.length; i++) {
|
|
429
|
+
const step = recording.steps[i];
|
|
430
|
+
const comment = `// Step ${i + 1}: ${step.tool}`;
|
|
431
|
+
lines.push(comment);
|
|
432
|
+
switch (step.tool) {
|
|
433
|
+
case "navigate": {
|
|
434
|
+
const url = resolveParamInValue(step.args.url);
|
|
435
|
+
const waitUntil = step.args.waitUntil ? `, { waitUntil: ${JSON.stringify(step.args.waitUntil)} }` : "";
|
|
436
|
+
lines.push(`await page.goto(${url}${waitUntil});`);
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
case "act": {
|
|
440
|
+
const action = step.args.action;
|
|
441
|
+
const target = step.args.target;
|
|
442
|
+
const value = step.args.value;
|
|
443
|
+
switch (action) {
|
|
444
|
+
case "click":
|
|
445
|
+
if (target)
|
|
446
|
+
lines.push(`await page.locator(${quoteSelector(target)}).click();`);
|
|
447
|
+
break;
|
|
448
|
+
case "dblclick":
|
|
449
|
+
if (target)
|
|
450
|
+
lines.push(`await page.locator(${quoteSelector(target)}).dblclick();`);
|
|
451
|
+
break;
|
|
452
|
+
case "fill":
|
|
453
|
+
if (target && value !== undefined) {
|
|
454
|
+
lines.push(`await page.locator(${quoteSelector(target)}).fill(${resolveParamInValue(value)});`);
|
|
455
|
+
}
|
|
456
|
+
break;
|
|
457
|
+
case "type":
|
|
458
|
+
if (target && value !== undefined) {
|
|
459
|
+
lines.push(`await page.locator(${quoteSelector(target)}).pressSequentially(${resolveParamInValue(value)});`);
|
|
460
|
+
}
|
|
461
|
+
break;
|
|
462
|
+
case "check":
|
|
463
|
+
if (target)
|
|
464
|
+
lines.push(`await page.locator(${quoteSelector(target)}).check();`);
|
|
465
|
+
break;
|
|
466
|
+
case "uncheck":
|
|
467
|
+
if (target)
|
|
468
|
+
lines.push(`await page.locator(${quoteSelector(target)}).uncheck();`);
|
|
469
|
+
break;
|
|
470
|
+
case "select":
|
|
471
|
+
if (target && value !== undefined) {
|
|
472
|
+
lines.push(`await page.locator(${quoteSelector(target)}).selectOption(${JSON.stringify(value)});`);
|
|
473
|
+
}
|
|
474
|
+
break;
|
|
475
|
+
case "press": {
|
|
476
|
+
const key = step.args.key;
|
|
477
|
+
if (key)
|
|
478
|
+
lines.push(`await page.keyboard.press(${JSON.stringify(key)});`);
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
case "scroll": {
|
|
482
|
+
const dir = step.args.scrollDirection ?? "down";
|
|
483
|
+
const amount = step.args.scrollAmount ?? 300;
|
|
484
|
+
const dx = dir === "left" ? -amount : dir === "right" ? amount : 0;
|
|
485
|
+
const dy = dir === "up" ? -amount : dir === "down" ? amount : 0;
|
|
486
|
+
if (target) {
|
|
487
|
+
lines.push(`await page.locator(${quoteSelector(target)}).evaluate((el, { dx, dy }) => el.scrollBy(dx, dy), { dx: ${dx}, dy: ${dy} });`);
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
lines.push(`await page.mouse.wheel(${dx}, ${dy});`);
|
|
491
|
+
}
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
case "hover":
|
|
495
|
+
if (target)
|
|
496
|
+
lines.push(`await page.locator(${quoteSelector(target)}).hover();`);
|
|
497
|
+
break;
|
|
498
|
+
case "back":
|
|
499
|
+
lines.push("await page.goBack();");
|
|
500
|
+
break;
|
|
501
|
+
case "forward":
|
|
502
|
+
lines.push("await page.goForward();");
|
|
503
|
+
break;
|
|
504
|
+
default:
|
|
505
|
+
lines.push(`// Unsupported action: ${action}`);
|
|
506
|
+
}
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
case "wait_for": {
|
|
510
|
+
const condition = step.args.condition;
|
|
511
|
+
const target = step.args.target;
|
|
512
|
+
const text = step.args.text;
|
|
513
|
+
const js = step.args.js;
|
|
514
|
+
const timeout = step.args.timeout ?? 10000;
|
|
515
|
+
switch (condition) {
|
|
516
|
+
case "element":
|
|
517
|
+
if (target)
|
|
518
|
+
lines.push(`await page.locator(${quoteSelector(target)}).waitFor({ timeout: ${timeout} });`);
|
|
519
|
+
break;
|
|
520
|
+
case "text":
|
|
521
|
+
if (text)
|
|
522
|
+
lines.push(`await page.getByText(${JSON.stringify(text)}).waitFor({ timeout: ${timeout} });`);
|
|
523
|
+
break;
|
|
524
|
+
case "network_idle":
|
|
525
|
+
lines.push(`await page.waitForLoadState("networkidle", { timeout: ${timeout} });`);
|
|
526
|
+
break;
|
|
527
|
+
case "navigation":
|
|
528
|
+
if (text)
|
|
529
|
+
lines.push(`await page.waitForURL(${JSON.stringify(text)}, { timeout: ${timeout} });`);
|
|
530
|
+
break;
|
|
531
|
+
case "js":
|
|
532
|
+
if (js)
|
|
533
|
+
lines.push(`await page.waitForFunction(${JSON.stringify(js)}, null, { timeout: ${timeout} });`);
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
case "extract": {
|
|
539
|
+
const type = step.args.type ?? "text";
|
|
540
|
+
const target = step.args.target;
|
|
541
|
+
switch (type) {
|
|
542
|
+
case "title":
|
|
543
|
+
lines.push("const title = await page.title();");
|
|
544
|
+
break;
|
|
545
|
+
case "url":
|
|
546
|
+
lines.push("const url = page.url();");
|
|
547
|
+
break;
|
|
548
|
+
case "text":
|
|
549
|
+
if (target) {
|
|
550
|
+
lines.push(`const text = await page.locator(${quoteSelector(target)}).innerText();`);
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
lines.push("const text = await page.locator('body').innerText();");
|
|
554
|
+
}
|
|
555
|
+
break;
|
|
556
|
+
default:
|
|
557
|
+
lines.push(`// Extract type: ${type}`);
|
|
558
|
+
}
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
case "execute": {
|
|
562
|
+
const script = step.args.script;
|
|
563
|
+
lines.push(`await (async () => { ${script} })();`);
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
default:
|
|
567
|
+
lines.push(`// Unsupported tool: ${step.tool}`);
|
|
568
|
+
}
|
|
569
|
+
lines.push("");
|
|
570
|
+
}
|
|
571
|
+
return lines.join("\n");
|
|
572
|
+
}
|
|
573
|
+
/** Quote a selector for JS output — handle param placeholders */
|
|
574
|
+
function quoteSelector(selector) {
|
|
575
|
+
if (selector.includes("{{")) {
|
|
576
|
+
// Has param placeholders — use template literal
|
|
577
|
+
const escaped = selector.replace(/`/g, "\\`");
|
|
578
|
+
const replaced = escaped.replace(/\{\{(\w+)\}\}/g, "${params.$1}");
|
|
579
|
+
return "`" + replaced + "`";
|
|
580
|
+
}
|
|
581
|
+
return JSON.stringify(selector);
|
|
582
|
+
}
|
|
583
|
+
/** Resolve {{param}} in a value to JS template literal for Playwright script */
|
|
584
|
+
function resolveParamInValue(value) {
|
|
585
|
+
if (value.includes("{{")) {
|
|
586
|
+
const escaped = value.replace(/`/g, "\\`");
|
|
587
|
+
const replaced = escaped.replace(/\{\{(\w+)\}\}/g, "${params.$1}");
|
|
588
|
+
return "`" + replaced + "`";
|
|
589
|
+
}
|
|
590
|
+
return JSON.stringify(value);
|
|
591
|
+
}
|
|
592
|
+
// ─── Replay ─────────────────────────────────────────────────────────────────
|
|
593
|
+
/**
|
|
594
|
+
* Resolve {{placeholder}} params in a string value.
|
|
595
|
+
*/
|
|
596
|
+
function resolveParams(value, params) {
|
|
597
|
+
if (typeof value !== "string")
|
|
598
|
+
return value;
|
|
599
|
+
return value.replace(/\{\{(\w+)\}\}/g, (_, name) => {
|
|
600
|
+
if (name in params)
|
|
601
|
+
return params[name];
|
|
602
|
+
return `{{${name}}}`; // leave unresolved if no param provided
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Recursively resolve params in an args object.
|
|
607
|
+
*/
|
|
608
|
+
function resolveArgsParams(args, params) {
|
|
609
|
+
const resolved = {};
|
|
610
|
+
for (const [key, value] of Object.entries(args)) {
|
|
611
|
+
if (typeof value === "string") {
|
|
612
|
+
resolved[key] = resolveParams(value, params);
|
|
613
|
+
}
|
|
614
|
+
else if (Array.isArray(value)) {
|
|
615
|
+
resolved[key] = value.map((v) => typeof v === "string" ? resolveParams(v, params) : v);
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
resolved[key] = value;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return resolved;
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Replay a Recording against a live browser session.
|
|
625
|
+
*
|
|
626
|
+
* Each step is dispatched directly to Playwright page methods — no MCP
|
|
627
|
+
* round-trip. The overall replay is recorded as a single summary entry
|
|
628
|
+
* in harness intelligence (individual steps are NOT recorded).
|
|
629
|
+
*/
|
|
630
|
+
export async function replayRecording(recording, session, page, params, options) {
|
|
631
|
+
const onError = options?.onError ?? "stop";
|
|
632
|
+
const mergedParams = {};
|
|
633
|
+
// Merge defaults from recording with provided overrides
|
|
634
|
+
for (const [name, paramDef] of Object.entries(recording.params)) {
|
|
635
|
+
mergedParams[name] = paramDef.default;
|
|
636
|
+
}
|
|
637
|
+
if (params) {
|
|
638
|
+
for (const [name, value] of Object.entries(params)) {
|
|
639
|
+
mergedParams[name] = value;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const results = [];
|
|
643
|
+
const totalStart = Date.now();
|
|
644
|
+
for (let i = 0; i < recording.steps.length; i++) {
|
|
645
|
+
const step = recording.steps[i];
|
|
646
|
+
const args = resolveArgsParams(step.args, mergedParams);
|
|
647
|
+
const stepStart = Date.now();
|
|
648
|
+
try {
|
|
649
|
+
await executeStep(page, step.tool, args);
|
|
650
|
+
results.push({
|
|
651
|
+
step: i,
|
|
652
|
+
tool: step.tool,
|
|
653
|
+
status: "ok",
|
|
654
|
+
duration: Date.now() - stepStart,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
catch (e) {
|
|
658
|
+
const result = {
|
|
659
|
+
step: i,
|
|
660
|
+
tool: step.tool,
|
|
661
|
+
status: "error",
|
|
662
|
+
error: e.message,
|
|
663
|
+
duration: Date.now() - stepStart,
|
|
664
|
+
};
|
|
665
|
+
results.push(result);
|
|
666
|
+
if (onError === "stop") {
|
|
667
|
+
logger.warn("recording:replay-stopped", {
|
|
668
|
+
step: i,
|
|
669
|
+
tool: step.tool,
|
|
670
|
+
error: e.message,
|
|
671
|
+
});
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
logger.warn("recording:replay-skip", {
|
|
675
|
+
step: i,
|
|
676
|
+
tool: step.tool,
|
|
677
|
+
error: e.message,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
const totalDuration = Date.now() - totalStart;
|
|
682
|
+
const completedOk = results.filter((r) => r.status === "ok").length;
|
|
683
|
+
const hasErrors = results.some((r) => r.status === "error");
|
|
684
|
+
// Record ONE summary entry in harness intelligence (not individual steps)
|
|
685
|
+
HarnessIntelligence.recordToolCall(session.id, "session_replay", {
|
|
686
|
+
name: recording.name,
|
|
687
|
+
steps: recording.steps.length,
|
|
688
|
+
params: Object.keys(mergedParams),
|
|
689
|
+
}, `Replay "${recording.name}": ${completedOk}/${recording.steps.length} steps OK (${totalDuration}ms)`, totalDuration);
|
|
690
|
+
return {
|
|
691
|
+
status: hasErrors ? "error" : "ok",
|
|
692
|
+
stepsCompleted: completedOk,
|
|
693
|
+
stepsTotal: recording.steps.length,
|
|
694
|
+
totalDuration,
|
|
695
|
+
results,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
// ─── Step Executor ──────────────────────────────────────────────────────────
|
|
699
|
+
/**
|
|
700
|
+
* Execute a single recording step directly against the Playwright page.
|
|
701
|
+
* This bypasses the MCP tool layer — no snapshot overhead, no harness recording.
|
|
702
|
+
*/
|
|
703
|
+
async function executeStep(page, tool, args) {
|
|
704
|
+
switch (tool) {
|
|
705
|
+
case "navigate": {
|
|
706
|
+
const url = args.url;
|
|
707
|
+
if (!url)
|
|
708
|
+
throw new Error("navigate step missing url");
|
|
709
|
+
// SSRF check: validate navigate URL before replay
|
|
710
|
+
try {
|
|
711
|
+
const parsed = new URL(url);
|
|
712
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
713
|
+
throw new Error(`Blocked URL scheme during replay: ${parsed.protocol} — only http/https allowed.`);
|
|
714
|
+
}
|
|
715
|
+
const ssrfBlock = await checkSSRF(parsed.hostname);
|
|
716
|
+
if (ssrfBlock) {
|
|
717
|
+
logger.warn("security.ssrf_replay_blocked", { url, hostname: parsed.hostname });
|
|
718
|
+
throw new Error(ssrfBlock);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
catch (e) {
|
|
722
|
+
if (e.message.startsWith("Blocked"))
|
|
723
|
+
throw e;
|
|
724
|
+
throw new Error(`Invalid URL in replay navigate step: ${url}`);
|
|
725
|
+
}
|
|
726
|
+
const waitUntil = args.waitUntil ?? "load";
|
|
727
|
+
await page.goto(url, { waitUntil });
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
730
|
+
case "act": {
|
|
731
|
+
const action = args.action;
|
|
732
|
+
const target = args.target;
|
|
733
|
+
const value = args.value;
|
|
734
|
+
switch (action) {
|
|
735
|
+
case "click": {
|
|
736
|
+
if (!target)
|
|
737
|
+
throw new Error("click requires target");
|
|
738
|
+
await page.locator(target).click();
|
|
739
|
+
break;
|
|
740
|
+
}
|
|
741
|
+
case "dblclick": {
|
|
742
|
+
if (!target)
|
|
743
|
+
throw new Error("dblclick requires target");
|
|
744
|
+
await page.locator(target).dblclick();
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
case "fill": {
|
|
748
|
+
if (!target || value === undefined)
|
|
749
|
+
throw new Error("fill requires target and value");
|
|
750
|
+
await page.locator(target).fill(value);
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
case "type": {
|
|
754
|
+
if (!target || value === undefined)
|
|
755
|
+
throw new Error("type requires target and value");
|
|
756
|
+
const delay = args.typeDelay;
|
|
757
|
+
await page.locator(target).pressSequentially(value, delay ? { delay } : undefined);
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
case "check": {
|
|
761
|
+
if (!target)
|
|
762
|
+
throw new Error("check requires target");
|
|
763
|
+
await page.locator(target).check();
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
case "uncheck": {
|
|
767
|
+
if (!target)
|
|
768
|
+
throw new Error("uncheck requires target");
|
|
769
|
+
await page.locator(target).uncheck();
|
|
770
|
+
break;
|
|
771
|
+
}
|
|
772
|
+
case "select": {
|
|
773
|
+
if (!target || value === undefined)
|
|
774
|
+
throw new Error("select requires target and value");
|
|
775
|
+
await page.locator(target).selectOption(value);
|
|
776
|
+
break;
|
|
777
|
+
}
|
|
778
|
+
case "press": {
|
|
779
|
+
const key = args.key;
|
|
780
|
+
if (!key)
|
|
781
|
+
throw new Error("press requires key");
|
|
782
|
+
await page.keyboard.press(key);
|
|
783
|
+
break;
|
|
784
|
+
}
|
|
785
|
+
case "scroll": {
|
|
786
|
+
const dir = args.scrollDirection ?? "down";
|
|
787
|
+
const amount = args.scrollAmount ?? 300;
|
|
788
|
+
const dx = dir === "left" ? -amount : dir === "right" ? amount : 0;
|
|
789
|
+
const dy = dir === "up" ? -amount : dir === "down" ? amount : 0;
|
|
790
|
+
if (target) {
|
|
791
|
+
await page.locator(target).evaluate((el, { dx, dy }) => el.scrollBy(dx, dy), { dx, dy });
|
|
792
|
+
}
|
|
793
|
+
else {
|
|
794
|
+
await page.mouse.wheel(dx, dy);
|
|
795
|
+
}
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
798
|
+
case "hover": {
|
|
799
|
+
if (!target)
|
|
800
|
+
throw new Error("hover requires target");
|
|
801
|
+
await page.locator(target).hover();
|
|
802
|
+
break;
|
|
803
|
+
}
|
|
804
|
+
case "mousemove": {
|
|
805
|
+
const x = args.x;
|
|
806
|
+
const y = args.y;
|
|
807
|
+
if (x === undefined || y === undefined)
|
|
808
|
+
throw new Error("mousemove requires x and y");
|
|
809
|
+
await page.mouse.move(x, y);
|
|
810
|
+
break;
|
|
811
|
+
}
|
|
812
|
+
case "drag": {
|
|
813
|
+
if (!target)
|
|
814
|
+
throw new Error("drag requires target");
|
|
815
|
+
const target2 = args.target2;
|
|
816
|
+
if (!target2)
|
|
817
|
+
throw new Error("drag requires target2");
|
|
818
|
+
await page.locator(target).dragTo(page.locator(target2));
|
|
819
|
+
break;
|
|
820
|
+
}
|
|
821
|
+
case "upload": {
|
|
822
|
+
if (!target)
|
|
823
|
+
throw new Error("upload requires target");
|
|
824
|
+
const filePaths = args.filePaths;
|
|
825
|
+
if (!filePaths)
|
|
826
|
+
throw new Error("upload requires filePaths");
|
|
827
|
+
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
|
828
|
+
await page.locator(target).setInputFiles(paths);
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
case "resize": {
|
|
832
|
+
const width = args.width;
|
|
833
|
+
const height = args.height;
|
|
834
|
+
if (!width || !height)
|
|
835
|
+
throw new Error("resize requires width and height");
|
|
836
|
+
await page.setViewportSize({ width, height });
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
case "back":
|
|
840
|
+
await page.goBack();
|
|
841
|
+
break;
|
|
842
|
+
case "forward":
|
|
843
|
+
await page.goForward();
|
|
844
|
+
break;
|
|
845
|
+
default:
|
|
846
|
+
throw new Error(`Unknown action: ${action}`);
|
|
847
|
+
}
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
case "wait_for": {
|
|
851
|
+
const condition = args.condition;
|
|
852
|
+
const target = args.target;
|
|
853
|
+
const text = args.text;
|
|
854
|
+
const js = args.js;
|
|
855
|
+
const timeout = args.timeout ?? 10000;
|
|
856
|
+
switch (condition) {
|
|
857
|
+
case "element":
|
|
858
|
+
if (!target)
|
|
859
|
+
throw new Error("element wait requires target");
|
|
860
|
+
await page.locator(target).waitFor({ timeout });
|
|
861
|
+
break;
|
|
862
|
+
case "text":
|
|
863
|
+
if (!text)
|
|
864
|
+
throw new Error("text wait requires text");
|
|
865
|
+
await page.getByText(text).waitFor({ timeout });
|
|
866
|
+
break;
|
|
867
|
+
case "network_idle":
|
|
868
|
+
await page.waitForLoadState("networkidle", { timeout });
|
|
869
|
+
break;
|
|
870
|
+
case "navigation":
|
|
871
|
+
if (!text)
|
|
872
|
+
throw new Error("navigation wait requires text (URL pattern)");
|
|
873
|
+
await page.waitForURL(text, { timeout });
|
|
874
|
+
break;
|
|
875
|
+
case "js":
|
|
876
|
+
if (!js)
|
|
877
|
+
throw new Error("js wait requires js expression");
|
|
878
|
+
await page.waitForFunction(js, null, { timeout });
|
|
879
|
+
break;
|
|
880
|
+
default:
|
|
881
|
+
throw new Error(`Unknown wait condition: ${condition}`);
|
|
882
|
+
}
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
case "add_init_script": {
|
|
886
|
+
const script = args.script;
|
|
887
|
+
if (!script)
|
|
888
|
+
throw new Error("add_init_script requires script");
|
|
889
|
+
await page.addInitScript(script);
|
|
890
|
+
break;
|
|
891
|
+
}
|
|
892
|
+
case "execute": {
|
|
893
|
+
const script = args.script;
|
|
894
|
+
if (!script)
|
|
895
|
+
throw new Error("execute requires script");
|
|
896
|
+
const fn = new Function("page", "context", `return (async () => { ${script} })()`);
|
|
897
|
+
await fn(page, page.context());
|
|
898
|
+
break;
|
|
899
|
+
}
|
|
900
|
+
case "extract": {
|
|
901
|
+
// Extracts during replay are executed but results are not captured
|
|
902
|
+
// (they exist as checkpoint verification steps)
|
|
903
|
+
const type = args.type ?? "text";
|
|
904
|
+
const target = args.target;
|
|
905
|
+
switch (type) {
|
|
906
|
+
case "text":
|
|
907
|
+
if (target)
|
|
908
|
+
await page.locator(target).innerText();
|
|
909
|
+
else
|
|
910
|
+
await page.locator("body").innerText();
|
|
911
|
+
break;
|
|
912
|
+
case "title":
|
|
913
|
+
await page.title();
|
|
914
|
+
break;
|
|
915
|
+
case "url":
|
|
916
|
+
page.url();
|
|
917
|
+
break;
|
|
918
|
+
}
|
|
919
|
+
break;
|
|
920
|
+
}
|
|
921
|
+
case "tab_switch": {
|
|
922
|
+
// Tab operations during replay are best-effort
|
|
923
|
+
logger.debug("recording:replay-tab-switch", { tabIndex: args.tabIndex });
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
case "tab_close": {
|
|
927
|
+
logger.debug("recording:replay-tab-close", { tabIndex: args.tabIndex });
|
|
928
|
+
break;
|
|
929
|
+
}
|
|
930
|
+
default:
|
|
931
|
+
throw new Error(`Unsupported replay tool: ${tool}`);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
export default { exportSession, replayRecording, toPlaywrightScript };
|