prowl-tools 0.1.3 → 0.1.4
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/LICENSE +1 -1
- package/NOTICE +1 -1
- package/README.md +115 -3
- package/dist/{chunk-NXXGJOBG.js → chunk-MBAIGNVO.js} +38 -13
- package/dist/chunk-MBAIGNVO.js.map +1 -0
- package/dist/{chunk-T7YLXF6X.js → chunk-ZEFVTKQT.js} +1773 -756
- package/dist/chunk-ZEFVTKQT.js.map +1 -0
- package/dist/index.cjs +2008 -973
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +41 -30
- package/dist/index.js.map +1 -1
- package/dist/lib.cjs +1848 -807
- package/dist/lib.cjs.map +1 -1
- package/dist/lib.d.cts +314 -17
- package/dist/lib.d.ts +314 -17
- package/dist/lib.js +28 -4
- package/dist/{loader-5RDNTJHH.js → loader-XMTDB6OL.js} +2 -2
- package/package.json +2 -2
- package/dist/chunk-NXXGJOBG.js.map +0 -1
- package/dist/chunk-T7YLXF6X.js.map +0 -1
- /package/dist/{loader-5RDNTJHH.js.map → loader-XMTDB6OL.js.map} +0 -0
package/dist/lib.cjs
CHANGED
|
@@ -37,12 +37,12 @@ __export(visual_exports, {
|
|
|
37
37
|
ensureBaselineDir: () => ensureBaselineDir
|
|
38
38
|
});
|
|
39
39
|
async function compareScreenshots(baselinePath, currentPath, diffPath, threshold) {
|
|
40
|
-
const baselineData = import_pngjs.PNG.sync.read(
|
|
41
|
-
const currentData = import_pngjs.PNG.sync.read(
|
|
40
|
+
const baselineData = import_pngjs.PNG.sync.read(import_node_fs5.default.readFileSync(baselinePath));
|
|
41
|
+
const currentData = import_pngjs.PNG.sync.read(import_node_fs5.default.readFileSync(currentPath));
|
|
42
42
|
const { width, height } = baselineData;
|
|
43
43
|
if (currentData.width !== width || currentData.height !== height) {
|
|
44
44
|
const diff2 = new import_pngjs.PNG({ width, height });
|
|
45
|
-
|
|
45
|
+
import_node_fs5.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
|
|
46
46
|
return {
|
|
47
47
|
match: false,
|
|
48
48
|
diffPercentage: 1,
|
|
@@ -60,7 +60,7 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
|
|
|
60
60
|
);
|
|
61
61
|
const totalPixels = width * height;
|
|
62
62
|
const diffPercentage = diffPixels / totalPixels;
|
|
63
|
-
|
|
63
|
+
import_node_fs5.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
|
|
64
64
|
return {
|
|
65
65
|
match: diffPercentage <= threshold,
|
|
66
66
|
diffPercentage,
|
|
@@ -68,16 +68,16 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
|
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
70
|
function ensureBaselineDir(configDir) {
|
|
71
|
-
const baselineDir =
|
|
72
|
-
|
|
71
|
+
const baselineDir = import_node_path5.default.join(configDir, "baselines");
|
|
72
|
+
import_node_fs5.default.mkdirSync(baselineDir, { recursive: true });
|
|
73
73
|
return baselineDir;
|
|
74
74
|
}
|
|
75
|
-
var
|
|
75
|
+
var import_node_fs5, import_node_path5, import_pngjs, import_pixelmatch;
|
|
76
76
|
var init_visual = __esm({
|
|
77
77
|
"src/runner/visual.ts"() {
|
|
78
78
|
"use strict";
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
import_node_fs5 = __toESM(require("fs"), 1);
|
|
80
|
+
import_node_path5 = __toESM(require("path"), 1);
|
|
81
81
|
import_pngjs = require("pngjs");
|
|
82
82
|
import_pixelmatch = __toESM(require("pixelmatch"), 1);
|
|
83
83
|
}
|
|
@@ -87,45 +87,573 @@ var init_visual = __esm({
|
|
|
87
87
|
var src_exports = {};
|
|
88
88
|
__export(src_exports, {
|
|
89
89
|
DEFAULT_FLAKY_THRESHOLD: () => DEFAULT_FLAKY_THRESHOLD,
|
|
90
|
+
DEFAULT_REQUEST_TIMEOUT_MS: () => DEFAULT_REQUEST_TIMEOUT_MS,
|
|
91
|
+
SpawnMacHelperClient: () => SpawnMacHelperClient,
|
|
92
|
+
WEB_ONLY_STEP_TYPES: () => WEB_ONLY_STEP_TYPES,
|
|
90
93
|
analyzePage: () => analyzePage,
|
|
94
|
+
assertStepsSupportedByTarget: () => assertStepsSupportedByTarget,
|
|
95
|
+
assertTargetAppAllowed: () => assertTargetAppAllowed,
|
|
91
96
|
buildHealCandidates: () => buildHealCandidates,
|
|
97
|
+
closeMacSession: () => closeMacSession,
|
|
92
98
|
clusterFailures: () => clusterFailures,
|
|
93
99
|
computeFlakeScore: () => computeFlakeScore,
|
|
94
100
|
configSchema: () => configSchema,
|
|
101
|
+
createMacDriver: () => createMacDriver,
|
|
95
102
|
extractFailures: () => extractFailures,
|
|
96
103
|
extractSelectorIntent: () => extractSelectorIntent,
|
|
97
104
|
generateHunt: () => generateHunt,
|
|
98
105
|
healSelector: () => healSelector,
|
|
99
106
|
huntSchema: () => huntSchema,
|
|
100
107
|
interpolateHunt: () => interpolateHunt,
|
|
108
|
+
launchMacSession: () => launchMacSession,
|
|
101
109
|
listHunts: () => listHunts,
|
|
102
110
|
loadConfig: () => loadConfig,
|
|
103
111
|
loadHunt: () => loadHunt,
|
|
104
112
|
loadHuntMeta: () => loadHuntMeta,
|
|
105
113
|
loadHuntTags: () => loadHuntTags,
|
|
114
|
+
macdriverBuildInstructions: () => macdriverBuildInstructions,
|
|
115
|
+
parseMacSelector: () => parseMacSelector,
|
|
106
116
|
rankFlaky: () => rankFlaky,
|
|
107
117
|
readHistory: () => readHistory,
|
|
108
118
|
readHuntHistory: () => readHuntHistory,
|
|
119
|
+
resolveHelperBinary: () => resolveHelperBinary,
|
|
109
120
|
runHunt: () => runHunt,
|
|
110
121
|
runSuite: () => runSuite,
|
|
111
122
|
stepSchema: () => stepSchema,
|
|
112
|
-
updateBacklogFromSuite: () => updateBacklogFromSuite
|
|
123
|
+
updateBacklogFromSuite: () => updateBacklogFromSuite,
|
|
124
|
+
webOnlyReason: () => webOnlyReason
|
|
113
125
|
});
|
|
114
126
|
module.exports = __toCommonJS(src_exports);
|
|
115
127
|
|
|
116
|
-
// src/
|
|
117
|
-
var
|
|
118
|
-
|
|
128
|
+
// src/browser/mac-driver.ts
|
|
129
|
+
var MAC_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
130
|
+
"query",
|
|
131
|
+
"interact",
|
|
132
|
+
"wait",
|
|
133
|
+
"screenshot"
|
|
134
|
+
]);
|
|
135
|
+
var STATUS_ITEM_SELECTOR = "statusitem";
|
|
136
|
+
var MENU_PREFIX = "menu=";
|
|
137
|
+
function unquote(value) {
|
|
138
|
+
const trimmed = value.trim();
|
|
139
|
+
const first = trimmed[0];
|
|
140
|
+
if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
|
|
141
|
+
return trimmed.slice(1, -1);
|
|
142
|
+
}
|
|
143
|
+
return trimmed;
|
|
144
|
+
}
|
|
145
|
+
function parseMacSelector(selector) {
|
|
146
|
+
const trimmed = selector.trim();
|
|
147
|
+
const idMatch = /^id=(.+)$/s.exec(trimmed);
|
|
148
|
+
if (idMatch) {
|
|
149
|
+
return { by: "id", value: unquote(idMatch[1]) };
|
|
150
|
+
}
|
|
151
|
+
const roleMatch = /^role=([A-Za-z][\w-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
|
|
152
|
+
if (roleMatch) {
|
|
153
|
+
const name = roleMatch[2] !== void 0 ? unquote(roleMatch[2]) : void 0;
|
|
154
|
+
return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
|
|
155
|
+
}
|
|
156
|
+
const labelMatch = /^label=(.+)$/s.exec(trimmed);
|
|
157
|
+
if (labelMatch) {
|
|
158
|
+
return { by: "label", value: unquote(labelMatch[1]) };
|
|
159
|
+
}
|
|
160
|
+
const textMatch = /^text=(.+)$/s.exec(trimmed);
|
|
161
|
+
if (textMatch) {
|
|
162
|
+
return { by: "text", value: unquote(textMatch[1]) };
|
|
163
|
+
}
|
|
164
|
+
return { by: "text", value: trimmed };
|
|
165
|
+
}
|
|
166
|
+
function unwrapMacTextSelector(selector) {
|
|
167
|
+
const trimmed = selector.trim();
|
|
168
|
+
if (!trimmed.startsWith("text=")) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return unquote(trimmed.slice(5));
|
|
172
|
+
}
|
|
173
|
+
function num(value) {
|
|
174
|
+
return typeof value === "number" ? value : Number(value ?? 0);
|
|
175
|
+
}
|
|
176
|
+
function createMacDriver(client, options = {}) {
|
|
177
|
+
const unsupported = (verb) => new Error(`${verb} is not supported by the macOS target`);
|
|
178
|
+
const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
|
|
179
|
+
async function query(cmd, selector, extra) {
|
|
180
|
+
return client.request(cmd, { query: parseMacSelector(selector), ...extra });
|
|
181
|
+
}
|
|
182
|
+
async function clickSelector(selector) {
|
|
183
|
+
const trimmed = selector.trim();
|
|
184
|
+
if (trimmed.toLowerCase() === STATUS_ITEM_SELECTOR) {
|
|
185
|
+
await client.request("openMenu");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (trimmed.toLowerCase().startsWith(MENU_PREFIX)) {
|
|
189
|
+
await client.request("clickMenu", { title: trimmed.slice(MENU_PREFIX.length).trim() });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
await query("click", selector);
|
|
193
|
+
}
|
|
194
|
+
async function fillSelector(selector, value) {
|
|
195
|
+
if (selector.trim() === ":focus") {
|
|
196
|
+
await client.request("fill", { query: { by: "focused" }, value });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
await query("fill", selector, { value });
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
capabilities: MAC_CAPABILITIES,
|
|
203
|
+
// navigation -----------------------------------------------------------
|
|
204
|
+
goto(_url, _options) {
|
|
205
|
+
return rejectUnsupported("navigate");
|
|
206
|
+
},
|
|
207
|
+
currentUrl() {
|
|
208
|
+
return `macos:${options.appLabel ?? ""}`;
|
|
209
|
+
},
|
|
210
|
+
// queries --------------------------------------------------------------
|
|
211
|
+
async count(selector) {
|
|
212
|
+
const result = await query("count", selector);
|
|
213
|
+
return num(result.count);
|
|
214
|
+
},
|
|
215
|
+
async textContent(selector) {
|
|
216
|
+
const result = await query("text", selector);
|
|
217
|
+
return result.text === void 0 || result.text === null ? null : String(result.text);
|
|
218
|
+
},
|
|
219
|
+
// interactions ---------------------------------------------------------
|
|
220
|
+
click: clickSelector,
|
|
221
|
+
clickFirst: clickSelector,
|
|
222
|
+
fill: fillSelector,
|
|
223
|
+
fillFirst: fillSelector,
|
|
224
|
+
async press(selector, key) {
|
|
225
|
+
await query("press", selector, { key });
|
|
226
|
+
},
|
|
227
|
+
selectOption() {
|
|
228
|
+
return rejectUnsupported("select");
|
|
229
|
+
},
|
|
230
|
+
selectOptionFirst() {
|
|
231
|
+
return rejectUnsupported("select");
|
|
232
|
+
},
|
|
233
|
+
async hover(selector) {
|
|
234
|
+
await query("hover", selector);
|
|
235
|
+
},
|
|
236
|
+
async scrollIntoView(selector) {
|
|
237
|
+
await query("scrollTo", selector);
|
|
238
|
+
},
|
|
239
|
+
setInputFiles() {
|
|
240
|
+
return rejectUnsupported("setInputFiles");
|
|
241
|
+
},
|
|
242
|
+
// semantic locators ----------------------------------------------------
|
|
243
|
+
async countByRole(role, name) {
|
|
244
|
+
const result = await client.request("count", { query: { by: "role", role, name } });
|
|
245
|
+
return num(result.count);
|
|
246
|
+
},
|
|
247
|
+
async clickFirstByRole(role, name) {
|
|
248
|
+
await client.request("click", { query: { by: "role", role, name } });
|
|
249
|
+
},
|
|
250
|
+
async countByLabel(label) {
|
|
251
|
+
const result = await client.request("count", { query: { by: "label", value: label } });
|
|
252
|
+
return num(result.count);
|
|
253
|
+
},
|
|
254
|
+
async fillFirstByLabel(label, value) {
|
|
255
|
+
await client.request("fill", { query: { by: "label", value: label }, value });
|
|
256
|
+
},
|
|
257
|
+
selectOptionFirstByLabel() {
|
|
258
|
+
return rejectUnsupported("select");
|
|
259
|
+
},
|
|
260
|
+
// waiting --------------------------------------------------------------
|
|
261
|
+
async waitForSelector(selector, waitOptions) {
|
|
262
|
+
const extra = waitOptions?.timeout !== void 0 ? { timeout: waitOptions.timeout / 1e3 } : void 0;
|
|
263
|
+
await query("waitFor", selector, extra);
|
|
264
|
+
},
|
|
265
|
+
waitForUrl() {
|
|
266
|
+
return rejectUnsupported("waitForUrl");
|
|
267
|
+
},
|
|
268
|
+
waitForNetworkIdle() {
|
|
269
|
+
return rejectUnsupported("waitForNetworkIdle");
|
|
270
|
+
},
|
|
271
|
+
// scripting & artifacts ------------------------------------------------
|
|
272
|
+
evaluate() {
|
|
273
|
+
return rejectUnsupported("evalScript");
|
|
274
|
+
},
|
|
275
|
+
async screenshot(screenshotOptions) {
|
|
276
|
+
await client.request("screenshot", { path: screenshotOptions.path });
|
|
277
|
+
},
|
|
278
|
+
// network / dialogs / downloads (all web-only) -------------------------
|
|
279
|
+
onResponse(_handler) {
|
|
280
|
+
throw unsupported("onResponse");
|
|
281
|
+
},
|
|
282
|
+
route(_url, _handler) {
|
|
283
|
+
return rejectUnsupported("mockRoute");
|
|
284
|
+
},
|
|
285
|
+
unroute() {
|
|
286
|
+
return rejectUnsupported("unmockRoute");
|
|
287
|
+
},
|
|
288
|
+
onDialog(_action) {
|
|
289
|
+
throw unsupported("onDialog");
|
|
290
|
+
},
|
|
291
|
+
waitForDownloadEvent() {
|
|
292
|
+
return rejectUnsupported("waitForDownload");
|
|
293
|
+
},
|
|
294
|
+
parseTextSelector(selector) {
|
|
295
|
+
return unwrapMacTextSelector(selector);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
}
|
|
119
299
|
|
|
120
|
-
// src/
|
|
300
|
+
// src/browser/mac-helper.ts
|
|
301
|
+
var import_node_child_process = require("child_process");
|
|
121
302
|
var import_node_fs = __toESM(require("fs"), 1);
|
|
122
303
|
var import_node_path = __toESM(require("path"), 1);
|
|
304
|
+
var import_node_url = require("url");
|
|
305
|
+
var import_meta = {};
|
|
306
|
+
var HELPER_BINARY = "prowl-macdriver";
|
|
307
|
+
function macdriverBuildInstructions() {
|
|
308
|
+
return "The macOS target requires the experimental `prowl-macdriver` helper, which is not shipped in the npm package. Build it locally:\n cd macdriver && swift build -c release\nor point Prowl at a prebuilt binary via the PROWL_MACDRIVER_BIN environment variable.";
|
|
309
|
+
}
|
|
310
|
+
function getPackageRoot() {
|
|
311
|
+
let dir = import_node_path.default.dirname((0, import_node_url.fileURLToPath)(import_meta.url));
|
|
312
|
+
const root = import_node_path.default.parse(dir).root;
|
|
313
|
+
while (dir !== root) {
|
|
314
|
+
if (import_node_fs.default.existsSync(import_node_path.default.join(dir, "package.json"))) {
|
|
315
|
+
return dir;
|
|
316
|
+
}
|
|
317
|
+
dir = import_node_path.default.dirname(dir);
|
|
318
|
+
}
|
|
319
|
+
return root;
|
|
320
|
+
}
|
|
321
|
+
function resolveHelperBinary(env = process.env) {
|
|
322
|
+
const override = env.PROWL_MACDRIVER_BIN;
|
|
323
|
+
if (override) {
|
|
324
|
+
if (!import_node_fs.default.existsSync(override)) {
|
|
325
|
+
throw new Error(
|
|
326
|
+
`PROWL_MACDRIVER_BIN points at a missing file: ${override}
|
|
327
|
+
${macdriverBuildInstructions()}`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
return override;
|
|
331
|
+
}
|
|
332
|
+
const root = getPackageRoot();
|
|
333
|
+
const candidates = [
|
|
334
|
+
import_node_path.default.join(root, "macdriver", ".build", "release", HELPER_BINARY),
|
|
335
|
+
import_node_path.default.join(root, "macdriver", ".build", "debug", HELPER_BINARY)
|
|
336
|
+
];
|
|
337
|
+
for (const candidate of candidates) {
|
|
338
|
+
if (import_node_fs.default.existsSync(candidate)) {
|
|
339
|
+
return candidate;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
throw new Error(`Could not find the ${HELPER_BINARY} helper binary.
|
|
343
|
+
${macdriverBuildInstructions()}`);
|
|
344
|
+
}
|
|
345
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
346
|
+
var SpawnMacHelperClient = class {
|
|
347
|
+
child;
|
|
348
|
+
pending = /* @__PURE__ */ new Map();
|
|
349
|
+
requestTimeoutMs;
|
|
350
|
+
stdoutBuffer = "";
|
|
351
|
+
stderrBuffer = "";
|
|
352
|
+
nextId = 1;
|
|
353
|
+
closed = false;
|
|
354
|
+
terminalError;
|
|
355
|
+
constructor(binaryPath, options = {}) {
|
|
356
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
357
|
+
this.child = (0, import_node_child_process.spawn)(binaryPath, ["serve"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
358
|
+
this.child.stdout?.setEncoding("utf-8");
|
|
359
|
+
this.child.stderr?.setEncoding("utf-8");
|
|
360
|
+
this.child.stdout?.on("data", (chunk) => this.onStdout(chunk));
|
|
361
|
+
this.child.stderr?.on("data", (chunk) => {
|
|
362
|
+
this.stderrBuffer = (this.stderrBuffer + chunk).slice(-4e3);
|
|
363
|
+
});
|
|
364
|
+
this.child.on("error", (error) => this.recordTerminalFailure(error));
|
|
365
|
+
this.child.on("exit", (code) => {
|
|
366
|
+
if (!this.closed) {
|
|
367
|
+
const detail = this.stderrBuffer.trim();
|
|
368
|
+
this.recordTerminalFailure(
|
|
369
|
+
new Error(`prowl-macdriver exited unexpectedly (code ${code ?? "null"})${detail ? `: ${detail}` : ""}`)
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
onStdout(chunk) {
|
|
375
|
+
this.stdoutBuffer += chunk;
|
|
376
|
+
let newlineIndex = this.stdoutBuffer.indexOf("\n");
|
|
377
|
+
while (newlineIndex !== -1) {
|
|
378
|
+
const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
|
|
379
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
|
380
|
+
if (line.length > 0) {
|
|
381
|
+
this.dispatch(line);
|
|
382
|
+
}
|
|
383
|
+
newlineIndex = this.stdoutBuffer.indexOf("\n");
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
dispatch(line) {
|
|
387
|
+
let message;
|
|
388
|
+
try {
|
|
389
|
+
message = JSON.parse(line);
|
|
390
|
+
} catch {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const id = typeof message.id === "number" ? message.id : void 0;
|
|
394
|
+
if (id === void 0) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const pending = this.pending.get(id);
|
|
398
|
+
if (!pending) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
this.pending.delete(id);
|
|
402
|
+
clearTimeout(pending.timer);
|
|
403
|
+
if (message.ok === true) {
|
|
404
|
+
pending.resolve(message.result ?? {});
|
|
405
|
+
} else {
|
|
406
|
+
pending.reject(new Error(typeof message.error === "string" ? message.error : "prowl-macdriver error"));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
failAll(error) {
|
|
410
|
+
for (const pending of this.pending.values()) {
|
|
411
|
+
clearTimeout(pending.timer);
|
|
412
|
+
pending.reject(error);
|
|
413
|
+
}
|
|
414
|
+
this.pending.clear();
|
|
415
|
+
}
|
|
416
|
+
recordTerminalFailure(error) {
|
|
417
|
+
this.terminalError ??= error;
|
|
418
|
+
this.closed = true;
|
|
419
|
+
this.failAll(this.terminalError);
|
|
420
|
+
}
|
|
421
|
+
/** Number of in-flight requests awaiting a response (for teardown/tests). */
|
|
422
|
+
get pendingCount() {
|
|
423
|
+
return this.pending.size;
|
|
424
|
+
}
|
|
425
|
+
request(cmd, params = {}) {
|
|
426
|
+
if (this.terminalError) {
|
|
427
|
+
return Promise.reject(this.terminalError);
|
|
428
|
+
}
|
|
429
|
+
if (this.closed) {
|
|
430
|
+
return Promise.reject(new Error("prowl-macdriver client is closed"));
|
|
431
|
+
}
|
|
432
|
+
const id = this.nextId++;
|
|
433
|
+
const payload = JSON.stringify({ id, cmd, ...params });
|
|
434
|
+
return new Promise((resolve, reject) => {
|
|
435
|
+
const timer = setTimeout(() => {
|
|
436
|
+
if (this.pending.delete(id)) {
|
|
437
|
+
const shown = this.requestTimeoutMs >= 1e3 ? `${Math.round(this.requestTimeoutMs / 1e3)}s` : `${this.requestTimeoutMs}ms`;
|
|
438
|
+
reject(new Error(`prowl-macdriver request "${cmd}" timed out after ${shown}`));
|
|
439
|
+
}
|
|
440
|
+
}, this.requestTimeoutMs);
|
|
441
|
+
timer.unref?.();
|
|
442
|
+
this.pending.set(id, { cmd, resolve, reject, timer });
|
|
443
|
+
this.child.stdin?.write(payload + "\n", (error) => {
|
|
444
|
+
if (error && this.pending.delete(id)) {
|
|
445
|
+
clearTimeout(timer);
|
|
446
|
+
reject(error);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
async close() {
|
|
452
|
+
if (this.closed) {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
this.closed = true;
|
|
456
|
+
try {
|
|
457
|
+
this.child.stdin?.write(JSON.stringify({ cmd: "shutdown" }) + "\n");
|
|
458
|
+
this.child.stdin?.end();
|
|
459
|
+
} catch {
|
|
460
|
+
}
|
|
461
|
+
await new Promise((resolve) => {
|
|
462
|
+
if (this.child.exitCode !== null || this.child.signalCode !== null) {
|
|
463
|
+
resolve();
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const timer = setTimeout(() => {
|
|
467
|
+
this.child.kill("SIGKILL");
|
|
468
|
+
resolve();
|
|
469
|
+
}, 2e3);
|
|
470
|
+
this.child.once("exit", () => {
|
|
471
|
+
clearTimeout(timer);
|
|
472
|
+
resolve();
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
this.failAll(new Error("prowl-macdriver client is closed"));
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
async function launchMacSession(options) {
|
|
479
|
+
const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_REQUEST_TIMEOUT_MS) + 5e3;
|
|
480
|
+
const client = options.clientFactory ? options.clientFactory() : new SpawnMacHelperClient(resolveHelperBinary(), { requestTimeoutMs });
|
|
481
|
+
const timeoutSeconds = (options.timeoutMs ?? 1e4) / 1e3;
|
|
482
|
+
try {
|
|
483
|
+
const trust = await client.request("check");
|
|
484
|
+
if (trust.trusted !== true) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
"Prowl's macOS target is not trusted for Accessibility. Grant the hosting terminal/app permission in System Settings \u2192 Privacy & Security \u2192 Accessibility, then retry."
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
const launched = await client.request("launch", { app: options.app, timeout: timeoutSeconds });
|
|
490
|
+
const bundleId = String(launched.bundleId ?? options.app);
|
|
491
|
+
const driver = createMacDriver(client, { appLabel: bundleId });
|
|
492
|
+
return { client, driver, bundleId };
|
|
493
|
+
} catch (error) {
|
|
494
|
+
await client.close().catch(() => void 0);
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
async function closeMacSession(session) {
|
|
499
|
+
try {
|
|
500
|
+
await session.client.request("quit");
|
|
501
|
+
} catch {
|
|
502
|
+
} finally {
|
|
503
|
+
await session.client.close();
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// src/config/target.ts
|
|
508
|
+
var import_node_child_process2 = require("child_process");
|
|
509
|
+
var import_node_fs2 = __toESM(require("fs"), 1);
|
|
510
|
+
var import_node_path2 = __toESM(require("path"), 1);
|
|
511
|
+
var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
|
|
512
|
+
"navigate",
|
|
513
|
+
"waitForUrl",
|
|
514
|
+
"waitForNetworkIdle",
|
|
515
|
+
"mockRoute",
|
|
516
|
+
"unmockRoute",
|
|
517
|
+
"evalScript",
|
|
518
|
+
"runScript",
|
|
519
|
+
"onDialog",
|
|
520
|
+
"select",
|
|
521
|
+
"selectOption",
|
|
522
|
+
"setInputFiles",
|
|
523
|
+
"waitForDownload",
|
|
524
|
+
"scroll"
|
|
525
|
+
// directional scroll runs window.scrollBy (evaluate) — use scrollTo instead
|
|
526
|
+
]);
|
|
527
|
+
function webOnlyReason(step) {
|
|
528
|
+
for (const type of WEB_ONLY_STEP_TYPES) {
|
|
529
|
+
if (type in step) {
|
|
530
|
+
return type;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if ("assert" in step) {
|
|
534
|
+
const assertion = step.assert;
|
|
535
|
+
if (assertion.urlIncludes !== void 0 || assertion.urlEquals !== void 0) {
|
|
536
|
+
return "assert (url)";
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
function assertStepsSupportedByTarget(steps, target) {
|
|
542
|
+
if (target !== "macos") {
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
for (const step of steps) {
|
|
546
|
+
const reason = webOnlyReason(step);
|
|
547
|
+
if (reason) {
|
|
548
|
+
throw new Error(
|
|
549
|
+
`Step "${reason}" is not supported by the macOS target. It is web-only; use a portable step (click, fill, type, press, wait, assert visible, screenshot, etc.).`
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
if ("if" in step) {
|
|
553
|
+
assertStepsSupportedByTarget(step.if.then, target);
|
|
554
|
+
if (step.if.else) {
|
|
555
|
+
assertStepsSupportedByTarget(step.if.else, target);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if ("repeat" in step) {
|
|
559
|
+
assertStepsSupportedByTarget(step.repeat.steps, target);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
function assertHuntAssertionsSupportedByTarget(assertions, target) {
|
|
564
|
+
if (target !== "macos" || !assertions || assertions.length === 0) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
throw new Error(
|
|
568
|
+
"Hunt-level assertions are not supported by the macOS target. Use inline assert visible/notVisible steps instead."
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
function trimTrailingPathSeparators(value) {
|
|
572
|
+
return value.replace(/[\\/]+$/g, "");
|
|
573
|
+
}
|
|
574
|
+
function looksLikeMacosAppPath(app) {
|
|
575
|
+
const trimmed = trimTrailingPathSeparators(app);
|
|
576
|
+
return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
|
|
577
|
+
}
|
|
578
|
+
function normalizeAppPath(app) {
|
|
579
|
+
return import_node_path2.default.resolve(trimTrailingPathSeparators(app));
|
|
580
|
+
}
|
|
581
|
+
function parseBundleIdentifier(plist) {
|
|
582
|
+
const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
|
|
583
|
+
return match?.[1]?.trim() || null;
|
|
584
|
+
}
|
|
585
|
+
function readBundleIdentifier(appPath) {
|
|
586
|
+
const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath), "Contents", "Info.plist");
|
|
587
|
+
if (!import_node_fs2.default.existsSync(infoPlistPath)) {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
const parsed = parseBundleIdentifier(import_node_fs2.default.readFileSync(infoPlistPath, "utf-8"));
|
|
592
|
+
if (parsed) {
|
|
593
|
+
return parsed;
|
|
594
|
+
}
|
|
595
|
+
} catch {
|
|
596
|
+
}
|
|
597
|
+
if (process.platform !== "darwin" || !import_node_fs2.default.existsSync("/usr/bin/plutil")) {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
const output = (0, import_node_child_process2.execFileSync)(
|
|
602
|
+
"/usr/bin/plutil",
|
|
603
|
+
["-extract", "CFBundleIdentifier", "raw", "-o", "-", infoPlistPath],
|
|
604
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1e3 }
|
|
605
|
+
);
|
|
606
|
+
return output.trim() || null;
|
|
607
|
+
} catch {
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
function macosAppAllowedIdentities(app) {
|
|
612
|
+
const identities = /* @__PURE__ */ new Set([app]);
|
|
613
|
+
if (looksLikeMacosAppPath(app)) {
|
|
614
|
+
const normalizedPath = normalizeAppPath(app);
|
|
615
|
+
identities.add(trimTrailingPathSeparators(app));
|
|
616
|
+
identities.add(normalizedPath);
|
|
617
|
+
const bundleName = import_node_path2.default.basename(normalizedPath).replace(/\.app$/i, "");
|
|
618
|
+
if (bundleName) {
|
|
619
|
+
identities.add(bundleName);
|
|
620
|
+
}
|
|
621
|
+
const bundleId = readBundleIdentifier(app);
|
|
622
|
+
if (bundleId) {
|
|
623
|
+
identities.add(bundleId);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return [...identities];
|
|
627
|
+
}
|
|
628
|
+
function assertTargetAppAllowed(allowedApps, app) {
|
|
629
|
+
if (allowedApps.length === 0) {
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => macosAppAllowedIdentities(allowedApp)));
|
|
633
|
+
if (macosAppAllowedIdentities(app).some((identity) => allowedIdentities.has(identity))) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
throw new Error(
|
|
637
|
+
`Target app "${app}" is not in guardrails.allowedApps (${allowedApps.join(", ")}).`
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// src/runner/index.ts
|
|
642
|
+
var import_node_fs11 = __toESM(require("fs"), 1);
|
|
643
|
+
var import_node_path11 = __toESM(require("path"), 1);
|
|
644
|
+
|
|
645
|
+
// src/config/loader.ts
|
|
646
|
+
var import_node_fs3 = __toESM(require("fs"), 1);
|
|
647
|
+
var import_node_path3 = __toESM(require("path"), 1);
|
|
123
648
|
var import_yaml = __toESM(require("yaml"), 1);
|
|
124
649
|
var import_dotenv = __toESM(require("dotenv"), 1);
|
|
125
650
|
|
|
126
651
|
// src/config/schema.ts
|
|
127
652
|
var import_zod = require("zod");
|
|
128
653
|
|
|
654
|
+
// src/types/index.ts
|
|
655
|
+
var SUPPORTED_BROWSER_ENGINES = ["chromium", "firefox", "webkit"];
|
|
656
|
+
|
|
129
657
|
// src/config/hunt-name.ts
|
|
130
658
|
var HUNT_NAME_PATTERN = /^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/;
|
|
131
659
|
function isValidHuntName(name) {
|
|
@@ -140,15 +668,22 @@ function assertValidHuntName(name) {
|
|
|
140
668
|
}
|
|
141
669
|
|
|
142
670
|
// src/config/schema.ts
|
|
671
|
+
var webTargetSchema = import_zod.z.object({
|
|
672
|
+
type: import_zod.z.literal("web").optional(),
|
|
673
|
+
url: import_zod.z.string().min(1)
|
|
674
|
+
}).strict();
|
|
675
|
+
var macosTargetSchema = import_zod.z.object({
|
|
676
|
+
type: import_zod.z.literal("macos"),
|
|
677
|
+
app: import_zod.z.string().min(1)
|
|
678
|
+
}).strict();
|
|
679
|
+
var targetSchema = import_zod.z.union([macosTargetSchema, webTargetSchema]);
|
|
143
680
|
var configSchema = import_zod.z.object({
|
|
144
|
-
target:
|
|
145
|
-
url: import_zod.z.string().min(1)
|
|
146
|
-
}),
|
|
681
|
+
target: targetSchema,
|
|
147
682
|
browser: import_zod.z.object({
|
|
148
683
|
headless: import_zod.z.boolean().optional(),
|
|
149
684
|
slowMo: import_zod.z.number().optional(),
|
|
150
685
|
timeout: import_zod.z.number().optional(),
|
|
151
|
-
engine: import_zod.z.enum(
|
|
686
|
+
engine: import_zod.z.enum(SUPPORTED_BROWSER_ENGINES).optional(),
|
|
152
687
|
channel: import_zod.z.enum([
|
|
153
688
|
"chromium",
|
|
154
689
|
"chrome",
|
|
@@ -180,6 +715,7 @@ var configSchema = import_zod.z.object({
|
|
|
180
715
|
guardrails: import_zod.z.object({
|
|
181
716
|
maxSteps: import_zod.z.number().optional(),
|
|
182
717
|
allowedDomains: import_zod.z.array(import_zod.z.string()).optional(),
|
|
718
|
+
allowedApps: import_zod.z.array(import_zod.z.string()).optional(),
|
|
183
719
|
forbiddenSelectors: import_zod.z.array(import_zod.z.string()).optional(),
|
|
184
720
|
selfHealing: import_zod.z.boolean().optional()
|
|
185
721
|
}).optional(),
|
|
@@ -427,9 +963,11 @@ var huntSchema = import_zod.z.object({
|
|
|
427
963
|
}).strict();
|
|
428
964
|
|
|
429
965
|
// src/config/loader.ts
|
|
966
|
+
var DEFAULT_WEB_URL = "http://localhost:3000";
|
|
430
967
|
var DEFAULT_CONFIG = {
|
|
431
968
|
target: {
|
|
432
|
-
|
|
969
|
+
type: "web",
|
|
970
|
+
url: DEFAULT_WEB_URL
|
|
433
971
|
},
|
|
434
972
|
browser: {
|
|
435
973
|
headless: true,
|
|
@@ -453,6 +991,7 @@ var DEFAULT_CONFIG = {
|
|
|
453
991
|
guardrails: {
|
|
454
992
|
maxSteps: 50,
|
|
455
993
|
allowedDomains: ["localhost", "127.0.0.1", "0.0.0.0"],
|
|
994
|
+
allowedApps: [],
|
|
456
995
|
forbiddenSelectors: ["[data-danger]", ".delete-btn"],
|
|
457
996
|
selfHealing: false
|
|
458
997
|
},
|
|
@@ -479,12 +1018,12 @@ function findConfigPath(startDir) {
|
|
|
479
1018
|
let current = startDir;
|
|
480
1019
|
while (current) {
|
|
481
1020
|
for (const dir of [CONFIG_DIR, LEGACY_CONFIG_DIR]) {
|
|
482
|
-
const candidate =
|
|
483
|
-
if (
|
|
1021
|
+
const candidate = import_node_path3.default.join(current, dir, "config.yml");
|
|
1022
|
+
if (import_node_fs3.default.existsSync(candidate)) {
|
|
484
1023
|
return candidate;
|
|
485
1024
|
}
|
|
486
1025
|
}
|
|
487
|
-
const parent =
|
|
1026
|
+
const parent = import_node_path3.default.dirname(current);
|
|
488
1027
|
if (parent === current) {
|
|
489
1028
|
break;
|
|
490
1029
|
}
|
|
@@ -510,11 +1049,18 @@ function resolveViewport(value) {
|
|
|
510
1049
|
}
|
|
511
1050
|
return value;
|
|
512
1051
|
}
|
|
1052
|
+
function resolveTarget(target) {
|
|
1053
|
+
if (target && target.type === "macos") {
|
|
1054
|
+
return { type: "macos", app: target.app };
|
|
1055
|
+
}
|
|
1056
|
+
return {
|
|
1057
|
+
type: "web",
|
|
1058
|
+
url: target?.url ?? DEFAULT_WEB_URL
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
513
1061
|
function mergeConfig(partial) {
|
|
514
1062
|
return {
|
|
515
|
-
target:
|
|
516
|
-
url: partial.target?.url ?? DEFAULT_CONFIG.target.url
|
|
517
|
-
},
|
|
1063
|
+
target: resolveTarget(partial.target),
|
|
518
1064
|
browser: {
|
|
519
1065
|
headless: partial.browser?.headless ?? DEFAULT_CONFIG.browser.headless,
|
|
520
1066
|
slowMo: partial.browser?.slowMo ?? DEFAULT_CONFIG.browser.slowMo,
|
|
@@ -538,6 +1084,7 @@ function mergeConfig(partial) {
|
|
|
538
1084
|
guardrails: {
|
|
539
1085
|
maxSteps: partial.guardrails?.maxSteps ?? DEFAULT_CONFIG.guardrails.maxSteps,
|
|
540
1086
|
allowedDomains: partial.guardrails?.allowedDomains ?? DEFAULT_CONFIG.guardrails.allowedDomains,
|
|
1087
|
+
allowedApps: partial.guardrails?.allowedApps ?? DEFAULT_CONFIG.guardrails.allowedApps,
|
|
541
1088
|
forbiddenSelectors: partial.guardrails?.forbiddenSelectors ?? DEFAULT_CONFIG.guardrails.forbiddenSelectors,
|
|
542
1089
|
selfHealing: partial.guardrails?.selfHealing ?? DEFAULT_CONFIG.guardrails.selfHealing
|
|
543
1090
|
},
|
|
@@ -564,56 +1111,58 @@ function ensureAllowedDomain(allowed, urlValue) {
|
|
|
564
1111
|
return allowed;
|
|
565
1112
|
}
|
|
566
1113
|
function loadConfig(configPath) {
|
|
567
|
-
const resolvedPath = configPath ?
|
|
1114
|
+
const resolvedPath = configPath ? import_node_path3.default.resolve(configPath) : findConfigPath(process.cwd());
|
|
568
1115
|
if (!resolvedPath) {
|
|
569
1116
|
throw new Error("Could not find .prowl/config.yml. Run `prowl init` first.");
|
|
570
1117
|
}
|
|
571
|
-
if (!
|
|
1118
|
+
if (!import_node_fs3.default.existsSync(resolvedPath)) {
|
|
572
1119
|
throw new Error(`Config file not found at ${resolvedPath}`);
|
|
573
1120
|
}
|
|
574
|
-
const configDir =
|
|
575
|
-
if (
|
|
1121
|
+
const configDir = import_node_path3.default.dirname(resolvedPath);
|
|
1122
|
+
if (import_node_path3.default.basename(configDir) === LEGACY_CONFIG_DIR) {
|
|
576
1123
|
warnLegacyConfigDir();
|
|
577
1124
|
}
|
|
578
|
-
import_dotenv.default.config({ path:
|
|
579
|
-
const raw =
|
|
1125
|
+
import_dotenv.default.config({ path: import_node_path3.default.join(configDir, ".env"), override: false });
|
|
1126
|
+
const raw = import_node_fs3.default.readFileSync(resolvedPath, "utf-8");
|
|
580
1127
|
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
581
1128
|
const validated = configSchema.parse(parsed);
|
|
582
1129
|
const config = mergeConfig(validated);
|
|
583
|
-
config.
|
|
584
|
-
config.guardrails.allowedDomains
|
|
585
|
-
|
|
586
|
-
|
|
1130
|
+
if (config.target.type === "web") {
|
|
1131
|
+
config.guardrails.allowedDomains = ensureAllowedDomain(
|
|
1132
|
+
config.guardrails.allowedDomains,
|
|
1133
|
+
config.target.url
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
587
1136
|
return { config, configPath: resolvedPath, configDir };
|
|
588
1137
|
}
|
|
589
1138
|
function loadHunt(huntName, configDir) {
|
|
590
1139
|
assertValidHuntName(huntName);
|
|
591
|
-
const huntPath =
|
|
592
|
-
if (!
|
|
1140
|
+
const huntPath = import_node_path3.default.join(configDir, "hunts", `${huntName}.yml`);
|
|
1141
|
+
if (!import_node_fs3.default.existsSync(huntPath)) {
|
|
593
1142
|
throw new Error(`Hunt file not found: ${huntPath}`);
|
|
594
1143
|
}
|
|
595
|
-
const raw =
|
|
1144
|
+
const raw = import_node_fs3.default.readFileSync(huntPath, "utf-8");
|
|
596
1145
|
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
597
1146
|
const validated = huntSchema.parse(parsed);
|
|
598
1147
|
return validated;
|
|
599
1148
|
}
|
|
600
1149
|
function loadHuntTags(huntName, configDir) {
|
|
601
1150
|
assertValidHuntName(huntName);
|
|
602
|
-
const huntPath =
|
|
603
|
-
if (!
|
|
1151
|
+
const huntPath = import_node_path3.default.join(configDir, "hunts", `${huntName}.yml`);
|
|
1152
|
+
if (!import_node_fs3.default.existsSync(huntPath)) {
|
|
604
1153
|
return [];
|
|
605
1154
|
}
|
|
606
|
-
const raw =
|
|
1155
|
+
const raw = import_node_fs3.default.readFileSync(huntPath, "utf-8");
|
|
607
1156
|
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
608
1157
|
return Array.isArray(parsed.tags) ? parsed.tags : [];
|
|
609
1158
|
}
|
|
610
1159
|
function loadHuntMeta(huntName, configDir) {
|
|
611
1160
|
assertValidHuntName(huntName);
|
|
612
|
-
const huntPath =
|
|
613
|
-
if (!
|
|
1161
|
+
const huntPath = import_node_path3.default.join(configDir, "hunts", `${huntName}.yml`);
|
|
1162
|
+
if (!import_node_fs3.default.existsSync(huntPath)) {
|
|
614
1163
|
return { tags: [] };
|
|
615
1164
|
}
|
|
616
|
-
const raw =
|
|
1165
|
+
const raw = import_node_fs3.default.readFileSync(huntPath, "utf-8");
|
|
617
1166
|
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
618
1167
|
return {
|
|
619
1168
|
description: typeof parsed.description === "string" ? parsed.description : void 0,
|
|
@@ -621,24 +1170,24 @@ function loadHuntMeta(huntName, configDir) {
|
|
|
621
1170
|
};
|
|
622
1171
|
}
|
|
623
1172
|
function listHunts(configDir) {
|
|
624
|
-
const huntsDir =
|
|
625
|
-
if (!
|
|
1173
|
+
const huntsDir = import_node_path3.default.join(configDir, "hunts");
|
|
1174
|
+
if (!import_node_fs3.default.existsSync(huntsDir)) {
|
|
626
1175
|
return [];
|
|
627
1176
|
}
|
|
628
|
-
const stats =
|
|
1177
|
+
const stats = import_node_fs3.default.statSync(huntsDir);
|
|
629
1178
|
if (!stats.isDirectory()) {
|
|
630
1179
|
throw new Error(`Hunts path is not a directory: ${huntsDir}`);
|
|
631
1180
|
}
|
|
632
1181
|
const results = [];
|
|
633
1182
|
function scanDir(dir) {
|
|
634
|
-
const entries =
|
|
1183
|
+
const entries = import_node_fs3.default.readdirSync(dir, { withFileTypes: true });
|
|
635
1184
|
for (const entry of entries) {
|
|
636
1185
|
if (entry.isFile() && entry.name.endsWith(".yml")) {
|
|
637
|
-
const fullPath =
|
|
638
|
-
const relative =
|
|
1186
|
+
const fullPath = import_node_path3.default.join(dir, entry.name);
|
|
1187
|
+
const relative = import_node_path3.default.relative(huntsDir, fullPath);
|
|
639
1188
|
results.push(relative.replace(/\.yml$/, ""));
|
|
640
1189
|
} else if (entry.isDirectory()) {
|
|
641
|
-
scanDir(
|
|
1190
|
+
scanDir(import_node_path3.default.join(dir, entry.name));
|
|
642
1191
|
}
|
|
643
1192
|
}
|
|
644
1193
|
}
|
|
@@ -691,7 +1240,7 @@ function generateRandomVars(randomSource) {
|
|
|
691
1240
|
const hex = randomBytes(4).toString("hex");
|
|
692
1241
|
const firstIndex = Math.floor(random() * RANDOM_FIRST_NAMES.length);
|
|
693
1242
|
const lastIndex = Math.floor(random() * RANDOM_LAST_NAMES.length);
|
|
694
|
-
const
|
|
1243
|
+
const num2 = Math.floor(random() * 9e3) + 1e3;
|
|
695
1244
|
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
696
1245
|
let text = "";
|
|
697
1246
|
for (let i = 0; i < 8; i++) {
|
|
@@ -700,7 +1249,7 @@ function generateRandomVars(randomSource) {
|
|
|
700
1249
|
return {
|
|
701
1250
|
RANDOM_EMAIL: `prowl_${hex}@test.com`,
|
|
702
1251
|
RANDOM_NAME: `${RANDOM_FIRST_NAMES[firstIndex]} ${RANDOM_LAST_NAMES[lastIndex]}`,
|
|
703
|
-
RANDOM_NUMBER: String(
|
|
1252
|
+
RANDOM_NUMBER: String(num2),
|
|
704
1253
|
RANDOM_UUID: randomUUID(),
|
|
705
1254
|
RANDOM_TEXT: text
|
|
706
1255
|
};
|
|
@@ -1012,81 +1561,223 @@ function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
|
|
|
1012
1561
|
};
|
|
1013
1562
|
}
|
|
1014
1563
|
|
|
1015
|
-
// src/browser/
|
|
1016
|
-
var
|
|
1017
|
-
var
|
|
1564
|
+
// src/browser/playwright-driver.ts
|
|
1565
|
+
var import_node_fs4 = __toESM(require("fs"), 1);
|
|
1566
|
+
var import_node_path4 = __toESM(require("path"), 1);
|
|
1018
1567
|
var import_playwright = require("playwright");
|
|
1019
1568
|
var ENGINES = { chromium: import_playwright.chromium, firefox: import_playwright.firefox, webkit: import_playwright.webkit };
|
|
1020
1569
|
async function launchBrowser(options) {
|
|
1021
|
-
const
|
|
1570
|
+
const engineName = options.engine ?? "chromium";
|
|
1571
|
+
const engine = Object.prototype.hasOwnProperty.call(ENGINES, engineName) ? ENGINES[engineName] : void 0;
|
|
1572
|
+
if (!engine) {
|
|
1573
|
+
throw new Error(
|
|
1574
|
+
`Unsupported browser engine "${String(engineName)}". Available engines: ${Object.keys(ENGINES).join(", ")}.`
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1022
1577
|
const browser = await engine.launch({
|
|
1023
1578
|
headless: options.headless,
|
|
1024
1579
|
slowMo: options.slowMo,
|
|
1025
1580
|
channel: options.channel
|
|
1026
1581
|
});
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
if (options.storageStatePath) {
|
|
1032
|
-
if (import_node_fs2.default.existsSync(options.storageStatePath)) {
|
|
1033
|
-
contextOptions.storageState = options.storageStatePath;
|
|
1034
|
-
} else {
|
|
1035
|
-
console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
|
|
1582
|
+
try {
|
|
1583
|
+
const contextOptions = {};
|
|
1584
|
+
if (options.viewport) {
|
|
1585
|
+
contextOptions.viewport = options.viewport;
|
|
1036
1586
|
}
|
|
1587
|
+
if (options.storageStatePath) {
|
|
1588
|
+
if (import_node_fs4.default.existsSync(options.storageStatePath)) {
|
|
1589
|
+
contextOptions.storageState = options.storageStatePath;
|
|
1590
|
+
} else {
|
|
1591
|
+
console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
if (options.recordHar) {
|
|
1595
|
+
contextOptions.recordHar = { path: import_node_path4.default.join(options.runDir, "network.har") };
|
|
1596
|
+
}
|
|
1597
|
+
const context = await browser.newContext(contextOptions);
|
|
1598
|
+
const page = await context.newPage();
|
|
1599
|
+
page.setDefaultTimeout(options.timeout);
|
|
1600
|
+
page.setDefaultNavigationTimeout(options.timeout);
|
|
1601
|
+
let tracePath;
|
|
1602
|
+
if (options.trace) {
|
|
1603
|
+
tracePath = import_node_path4.default.join(options.runDir, "trace.zip");
|
|
1604
|
+
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
|
|
1605
|
+
}
|
|
1606
|
+
return { browser, context, page, tracePath };
|
|
1607
|
+
} catch (error) {
|
|
1608
|
+
try {
|
|
1609
|
+
await browser.close();
|
|
1610
|
+
} catch (closeError) {
|
|
1611
|
+
console.warn(`Failed to close browser after setup error: ${formatError(closeError)}`);
|
|
1612
|
+
}
|
|
1613
|
+
throw error;
|
|
1037
1614
|
}
|
|
1038
|
-
if (options.recordHar) {
|
|
1039
|
-
contextOptions.recordHar = { path: import_node_path2.default.join(options.runDir, "network.har") };
|
|
1040
|
-
}
|
|
1041
|
-
const context = await browser.newContext(contextOptions);
|
|
1042
|
-
const page = await context.newPage();
|
|
1043
|
-
page.setDefaultTimeout(options.timeout);
|
|
1044
|
-
page.setDefaultNavigationTimeout(options.timeout);
|
|
1045
|
-
let tracePath;
|
|
1046
|
-
if (options.trace) {
|
|
1047
|
-
tracePath = import_node_path2.default.join(options.runDir, "trace.zip");
|
|
1048
|
-
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
|
|
1049
|
-
}
|
|
1050
|
-
return { browser, context, page, tracePath };
|
|
1051
1615
|
}
|
|
1052
1616
|
async function closeBrowser(session) {
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
var
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1617
|
+
try {
|
|
1618
|
+
if (session.tracePath) {
|
|
1619
|
+
await session.context.tracing.stop({ path: session.tracePath });
|
|
1620
|
+
}
|
|
1621
|
+
await session.context.close();
|
|
1622
|
+
} finally {
|
|
1623
|
+
await session.browser.close();
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
var ALL_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
1627
|
+
"navigate",
|
|
1628
|
+
"query",
|
|
1629
|
+
"interact",
|
|
1630
|
+
"wait",
|
|
1631
|
+
"screenshot",
|
|
1632
|
+
"evaluate",
|
|
1633
|
+
"response",
|
|
1634
|
+
"route",
|
|
1635
|
+
"dialog",
|
|
1636
|
+
"files",
|
|
1637
|
+
"download"
|
|
1638
|
+
]);
|
|
1639
|
+
function formatError(error) {
|
|
1640
|
+
return error instanceof Error ? error.message : String(error);
|
|
1073
1641
|
}
|
|
1074
|
-
|
|
1075
|
-
|
|
1642
|
+
function unwrapTextSelector(value) {
|
|
1643
|
+
const trimmed = value.trim();
|
|
1644
|
+
if (!trimmed.startsWith("text=")) {
|
|
1645
|
+
return null;
|
|
1646
|
+
}
|
|
1647
|
+
const raw = trimmed.slice(5);
|
|
1648
|
+
const first = raw[0];
|
|
1649
|
+
if (first === '"' || first === "'") {
|
|
1650
|
+
const unquoted = raw.slice(1);
|
|
1651
|
+
return unquoted.endsWith(first) ? unquoted.slice(0, -1) : unquoted;
|
|
1652
|
+
}
|
|
1653
|
+
return raw;
|
|
1076
1654
|
}
|
|
1077
|
-
function
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1655
|
+
function createPlaywrightDriver(page) {
|
|
1656
|
+
return {
|
|
1657
|
+
capabilities: ALL_CAPABILITIES,
|
|
1658
|
+
async goto(url, options) {
|
|
1659
|
+
if (options?.waitUntil !== void 0) {
|
|
1660
|
+
await page.goto(url, { waitUntil: options.waitUntil });
|
|
1661
|
+
} else {
|
|
1662
|
+
await page.goto(url);
|
|
1663
|
+
}
|
|
1664
|
+
},
|
|
1665
|
+
currentUrl() {
|
|
1666
|
+
return page.url();
|
|
1667
|
+
},
|
|
1668
|
+
count(selector) {
|
|
1669
|
+
return page.locator(selector).count();
|
|
1670
|
+
},
|
|
1671
|
+
textContent(selector) {
|
|
1672
|
+
return page.locator(selector).textContent();
|
|
1673
|
+
},
|
|
1674
|
+
async click(selector) {
|
|
1675
|
+
await page.locator(selector).click();
|
|
1676
|
+
},
|
|
1677
|
+
async clickFirst(selector) {
|
|
1678
|
+
await page.locator(selector).first().click();
|
|
1679
|
+
},
|
|
1680
|
+
async fill(selector, value) {
|
|
1681
|
+
await page.locator(selector).fill(value);
|
|
1682
|
+
},
|
|
1683
|
+
async fillFirst(selector, value) {
|
|
1684
|
+
await page.locator(selector).first().fill(value);
|
|
1685
|
+
},
|
|
1686
|
+
async press(selector, key) {
|
|
1687
|
+
await page.locator(selector).press(key);
|
|
1688
|
+
},
|
|
1689
|
+
async selectOption(selector, value) {
|
|
1690
|
+
await page.locator(selector).selectOption(value);
|
|
1691
|
+
},
|
|
1692
|
+
async selectOptionFirst(selector, value) {
|
|
1693
|
+
await page.locator(selector).first().selectOption(value);
|
|
1694
|
+
},
|
|
1695
|
+
async hover(selector) {
|
|
1696
|
+
await page.locator(selector).hover();
|
|
1697
|
+
},
|
|
1698
|
+
async scrollIntoView(selector) {
|
|
1699
|
+
await page.locator(selector).scrollIntoViewIfNeeded();
|
|
1700
|
+
},
|
|
1701
|
+
async setInputFiles(selector, files) {
|
|
1702
|
+
await page.locator(selector).setInputFiles(files);
|
|
1703
|
+
},
|
|
1704
|
+
countByRole(role, name) {
|
|
1705
|
+
return page.getByRole(role, { name }).count();
|
|
1706
|
+
},
|
|
1707
|
+
async clickFirstByRole(role, name) {
|
|
1708
|
+
await page.getByRole(role, { name }).first().click();
|
|
1709
|
+
},
|
|
1710
|
+
countByLabel(label) {
|
|
1711
|
+
return page.getByLabel(label, { exact: true }).count();
|
|
1712
|
+
},
|
|
1713
|
+
async fillFirstByLabel(label, value) {
|
|
1714
|
+
await page.getByLabel(label, { exact: true }).first().fill(value);
|
|
1715
|
+
},
|
|
1716
|
+
async selectOptionFirstByLabel(label, value) {
|
|
1717
|
+
await page.getByLabel(label, { exact: true }).first().selectOption(value);
|
|
1718
|
+
},
|
|
1719
|
+
async waitForSelector(selector, options) {
|
|
1720
|
+
await page.waitForSelector(selector, { timeout: options?.timeout });
|
|
1721
|
+
},
|
|
1722
|
+
async waitForUrl(predicate, options) {
|
|
1723
|
+
await page.waitForURL((url) => predicate(url.toString()), { timeout: options?.timeout });
|
|
1724
|
+
},
|
|
1725
|
+
async waitForNetworkIdle(options) {
|
|
1726
|
+
await page.waitForLoadState("networkidle", { timeout: options?.timeout });
|
|
1727
|
+
},
|
|
1728
|
+
evaluate(pageFunction, arg) {
|
|
1729
|
+
const raw = page.evaluate;
|
|
1730
|
+
const result = arg === void 0 ? raw.call(page, pageFunction) : raw.call(page, pageFunction, arg);
|
|
1731
|
+
return result;
|
|
1732
|
+
},
|
|
1733
|
+
async screenshot(options) {
|
|
1734
|
+
await page.screenshot({ path: options.path, fullPage: options.fullPage });
|
|
1735
|
+
},
|
|
1736
|
+
onResponse(handler) {
|
|
1737
|
+
page.on("response", handler);
|
|
1738
|
+
},
|
|
1739
|
+
async route(url, handler) {
|
|
1740
|
+
await page.route(url, async (pwRoute) => {
|
|
1741
|
+
try {
|
|
1742
|
+
await handler({
|
|
1743
|
+
fulfill: (response) => pwRoute.fulfill(response)
|
|
1744
|
+
});
|
|
1745
|
+
} catch (error) {
|
|
1746
|
+
try {
|
|
1747
|
+
await pwRoute.abort("failed");
|
|
1748
|
+
} catch (abortError) {
|
|
1749
|
+
throw new Error(
|
|
1750
|
+
`Route handler failed for ${url}: ${formatError(error)}. Route abort also failed: ${formatError(abortError)}`
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1753
|
+
throw new Error(`Route handler failed for ${url}: ${formatError(error)}`);
|
|
1754
|
+
}
|
|
1755
|
+
});
|
|
1756
|
+
},
|
|
1757
|
+
async unroute(url) {
|
|
1758
|
+
await page.unroute(url);
|
|
1759
|
+
},
|
|
1760
|
+
onDialog(action) {
|
|
1761
|
+
page.once("dialog", (dialog) => {
|
|
1762
|
+
const response = action === "accept" ? dialog.accept() : dialog.dismiss();
|
|
1763
|
+
response.catch((error) => {
|
|
1764
|
+
console.warn(`Failed to ${action} dialog: ${formatError(error)}`);
|
|
1765
|
+
});
|
|
1766
|
+
});
|
|
1767
|
+
},
|
|
1768
|
+
waitForDownloadEvent(options) {
|
|
1769
|
+
return page.waitForEvent("download", { timeout: options?.timeout });
|
|
1770
|
+
},
|
|
1771
|
+
parseTextSelector(selector) {
|
|
1772
|
+
return unwrapTextSelector(selector);
|
|
1083
1773
|
}
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
async function setInputFiles(page, selector, files) {
|
|
1087
|
-
await page.locator(selector).setInputFiles(files);
|
|
1774
|
+
};
|
|
1088
1775
|
}
|
|
1089
1776
|
|
|
1777
|
+
// src/runner/steps.ts
|
|
1778
|
+
var import_node_fs6 = __toESM(require("fs"), 1);
|
|
1779
|
+
var import_node_path6 = __toESM(require("path"), 1);
|
|
1780
|
+
|
|
1090
1781
|
// src/runner/healing.ts
|
|
1091
1782
|
var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
|
|
1092
1783
|
function extractSelectorIntent(selector) {
|
|
@@ -1124,12 +1815,12 @@ function buildHealCandidates(selector) {
|
|
|
1124
1815
|
}
|
|
1125
1816
|
return candidates;
|
|
1126
1817
|
}
|
|
1127
|
-
async function healSelector(
|
|
1818
|
+
async function healSelector(probe, selector, options) {
|
|
1128
1819
|
if (!options.enabled) return null;
|
|
1129
1820
|
for (const candidate of buildHealCandidates(selector)) {
|
|
1130
1821
|
let count;
|
|
1131
1822
|
try {
|
|
1132
|
-
const locator =
|
|
1823
|
+
const locator = probe.locator(candidate.selector);
|
|
1133
1824
|
count = await locator.count();
|
|
1134
1825
|
} catch {
|
|
1135
1826
|
continue;
|
|
@@ -1141,74 +1832,104 @@ async function healSelector(page, selector, options) {
|
|
|
1141
1832
|
return null;
|
|
1142
1833
|
}
|
|
1143
1834
|
|
|
1144
|
-
// src/runner/
|
|
1835
|
+
// src/runner/policy.ts
|
|
1145
1836
|
var ALWAYS_ALLOWED_PROTOCOLS = ["about:", "data:"];
|
|
1146
|
-
function
|
|
1147
|
-
const
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
function matchesForbiddenPattern(selector, forbidden) {
|
|
1160
|
-
const selectorText = unwrapTextSelector(selector);
|
|
1161
|
-
if (selectorText === null) {
|
|
1162
|
-
return false;
|
|
1837
|
+
function createRunPolicy(driver, options) {
|
|
1838
|
+
const { forbiddenSelectors, allowedDomains, maxSteps, selfHealing } = options;
|
|
1839
|
+
const allowedApps = options.allowedApps ?? [];
|
|
1840
|
+
function matchesForbiddenPattern(selector, forbidden) {
|
|
1841
|
+
const selectorText = driver.parseTextSelector(selector);
|
|
1842
|
+
if (selectorText === null) {
|
|
1843
|
+
return false;
|
|
1844
|
+
}
|
|
1845
|
+
const forbiddenText = driver.parseTextSelector(forbidden);
|
|
1846
|
+
if (forbiddenText !== null) {
|
|
1847
|
+
return selectorText.includes(forbiddenText);
|
|
1848
|
+
}
|
|
1849
|
+
return selectorText.includes(forbidden);
|
|
1163
1850
|
}
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1851
|
+
function isForbiddenSelector(selector) {
|
|
1852
|
+
return forbiddenSelectors.some(
|
|
1853
|
+
(forbidden) => selector.includes(forbidden) || matchesForbiddenPattern(selector, forbidden)
|
|
1854
|
+
);
|
|
1167
1855
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
(forbidden) => selector.includes(forbidden) || matchesForbiddenPattern(selector, forbidden)
|
|
1173
|
-
);
|
|
1174
|
-
}
|
|
1175
|
-
function assertAllowedSelector(selector, forbiddenSelectors) {
|
|
1176
|
-
if (isForbiddenSelector(selector, forbiddenSelectors)) {
|
|
1177
|
-
throw new Error(`Forbidden selector: ${selector}`);
|
|
1856
|
+
function assertAllowedSelector(selector) {
|
|
1857
|
+
if (isForbiddenSelector(selector)) {
|
|
1858
|
+
throw new Error(`Forbidden selector: ${selector}`);
|
|
1859
|
+
}
|
|
1178
1860
|
}
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1861
|
+
function assertWithinMaxSteps(stepCount, huntName) {
|
|
1862
|
+
if (stepCount > maxSteps) {
|
|
1863
|
+
if (huntName) {
|
|
1864
|
+
throw new Error(`Hunt "${huntName}" has ${stepCount} steps. Max allowed is ${maxSteps}.`);
|
|
1865
|
+
}
|
|
1866
|
+
throw new Error(`Hunt has ${stepCount} steps. Max allowed is ${maxSteps}.`);
|
|
1867
|
+
}
|
|
1184
1868
|
}
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1869
|
+
function ensureUrlAllowed(urlValue) {
|
|
1870
|
+
for (const protocol of ALWAYS_ALLOWED_PROTOCOLS) {
|
|
1871
|
+
if (urlValue.startsWith(protocol)) {
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
let url;
|
|
1876
|
+
try {
|
|
1877
|
+
url = new URL(urlValue);
|
|
1878
|
+
} catch {
|
|
1879
|
+
throw new Error(`Navigation target is not a valid absolute URL: ${urlValue}`);
|
|
1880
|
+
}
|
|
1881
|
+
if (!allowedDomains.includes(url.hostname)) {
|
|
1882
|
+
throw new Error(`Navigation to disallowed domain: ${url.hostname}`);
|
|
1883
|
+
}
|
|
1190
1884
|
}
|
|
1191
|
-
|
|
1192
|
-
|
|
1885
|
+
function ensureAppAllowed(app) {
|
|
1886
|
+
if (!allowedApps.includes(app)) {
|
|
1887
|
+
throw new Error(`Interaction with disallowed app: ${app}`);
|
|
1888
|
+
}
|
|
1193
1889
|
}
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1890
|
+
function ensureLocationAllowed(activeDriver) {
|
|
1891
|
+
if (activeDriver.capabilities.has("navigate")) {
|
|
1892
|
+
ensureUrlAllowed(activeDriver.currentUrl());
|
|
1893
|
+
}
|
|
1197
1894
|
}
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
)
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1895
|
+
const healProbe = {
|
|
1896
|
+
locator: (selector) => ({ count: () => driver.count(selector) })
|
|
1897
|
+
};
|
|
1898
|
+
async function resolveActionSelector(selector) {
|
|
1899
|
+
assertAllowedSelector(selector);
|
|
1900
|
+
if (!selfHealing) {
|
|
1901
|
+
return { selector };
|
|
1902
|
+
}
|
|
1903
|
+
let matched = false;
|
|
1904
|
+
try {
|
|
1905
|
+
matched = await driver.count(selector) > 0;
|
|
1906
|
+
} catch {
|
|
1907
|
+
return { selector };
|
|
1208
1908
|
}
|
|
1209
|
-
|
|
1909
|
+
if (matched) {
|
|
1910
|
+
return { selector };
|
|
1911
|
+
}
|
|
1912
|
+
const healed = await healSelector(healProbe, selector, { enabled: true });
|
|
1913
|
+
if (!healed) {
|
|
1914
|
+
return { selector };
|
|
1915
|
+
}
|
|
1916
|
+
assertAllowedSelector(healed.selector);
|
|
1917
|
+
console.warn(
|
|
1918
|
+
`Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
|
|
1919
|
+
);
|
|
1920
|
+
return { selector: healed.selector, healedFrom: healed.healedFrom };
|
|
1210
1921
|
}
|
|
1922
|
+
return {
|
|
1923
|
+
assertWithinMaxSteps,
|
|
1924
|
+
ensureUrlAllowed,
|
|
1925
|
+
ensureAppAllowed,
|
|
1926
|
+
ensureLocationAllowed,
|
|
1927
|
+
assertAllowedSelector,
|
|
1928
|
+
resolveActionSelector
|
|
1929
|
+
};
|
|
1211
1930
|
}
|
|
1931
|
+
|
|
1932
|
+
// src/runner/steps.ts
|
|
1212
1933
|
function getStepType(step) {
|
|
1213
1934
|
if ("navigate" in step) return "navigate";
|
|
1214
1935
|
if ("click" in step) return "click";
|
|
@@ -1312,17 +2033,6 @@ function applyRuntimeVars(step, vars) {
|
|
|
1312
2033
|
function isExplicitFillStep(value) {
|
|
1313
2034
|
return typeof value.selector === "string" && typeof value.value === "string";
|
|
1314
2035
|
}
|
|
1315
|
-
function ensureAllowedUrl(urlValue, allowedDomains) {
|
|
1316
|
-
for (const protocol of ALWAYS_ALLOWED_PROTOCOLS) {
|
|
1317
|
-
if (urlValue.startsWith(protocol)) {
|
|
1318
|
-
return;
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
const url = new URL(urlValue);
|
|
1322
|
-
if (!allowedDomains.includes(url.hostname)) {
|
|
1323
|
-
throw new Error(`Navigation to disallowed domain: ${url.hostname}`);
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
2036
|
function resolveNavigationTarget(targetUrl, value) {
|
|
1327
2037
|
try {
|
|
1328
2038
|
return new URL(value, targetUrl).toString();
|
|
@@ -1349,56 +2059,50 @@ function getSinglePair(value, stepType) {
|
|
|
1349
2059
|
}
|
|
1350
2060
|
return entries[0];
|
|
1351
2061
|
}
|
|
1352
|
-
async function clickByTextWithFallback(
|
|
2062
|
+
async function clickByTextWithFallback(driver, policy, text) {
|
|
1353
2063
|
const roleSelector = `role=button[name="${escapeForAttribute(text)}"]`;
|
|
1354
|
-
assertAllowedSelector(roleSelector
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
await button.first().click();
|
|
2064
|
+
policy.assertAllowedSelector(roleSelector);
|
|
2065
|
+
if (await driver.countByRole("button", text)) {
|
|
2066
|
+
await driver.clickFirstByRole("button", text);
|
|
1358
2067
|
return roleSelector;
|
|
1359
2068
|
}
|
|
1360
2069
|
const selector = exactTextSelector(text);
|
|
1361
|
-
assertAllowedSelector(selector
|
|
1362
|
-
await
|
|
2070
|
+
policy.assertAllowedSelector(selector);
|
|
2071
|
+
await driver.clickFirst(selector);
|
|
1363
2072
|
return selector;
|
|
1364
2073
|
}
|
|
1365
|
-
async function fillByLabelOrPlaceholder(
|
|
2074
|
+
async function fillByLabelOrPlaceholder(driver, policy, label, value) {
|
|
1366
2075
|
const labelSelector = `label="${escapeForAttribute(label)}"`;
|
|
1367
|
-
assertAllowedSelector(labelSelector
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
await byLabel.first().fill(value);
|
|
2076
|
+
policy.assertAllowedSelector(labelSelector);
|
|
2077
|
+
if (await driver.countByLabel(label)) {
|
|
2078
|
+
await driver.fillFirstByLabel(label, value);
|
|
1371
2079
|
return labelSelector;
|
|
1372
2080
|
}
|
|
1373
2081
|
const placeholder = `input[placeholder="${escapeForAttribute(label)}"], textarea[placeholder="${escapeForAttribute(label)}"]`;
|
|
1374
|
-
assertAllowedSelector(placeholder
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
await byPlaceholder.first().fill(value);
|
|
2082
|
+
policy.assertAllowedSelector(placeholder);
|
|
2083
|
+
if (await driver.count(placeholder)) {
|
|
2084
|
+
await driver.fillFirst(placeholder, value);
|
|
1378
2085
|
return placeholder;
|
|
1379
2086
|
}
|
|
1380
2087
|
throw new Error(`Could not resolve fill shorthand for "${label}"`);
|
|
1381
2088
|
}
|
|
1382
|
-
async function selectByLabelOrFallback(
|
|
2089
|
+
async function selectByLabelOrFallback(driver, policy, label, value) {
|
|
1383
2090
|
const labelSelector = `label="${escapeForAttribute(label)}"`;
|
|
1384
|
-
assertAllowedSelector(labelSelector
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
await byLabel.first().selectOption(value);
|
|
2091
|
+
policy.assertAllowedSelector(labelSelector);
|
|
2092
|
+
if (await driver.countByLabel(label)) {
|
|
2093
|
+
await driver.selectOptionFirstByLabel(label, value);
|
|
1388
2094
|
return labelSelector;
|
|
1389
2095
|
}
|
|
1390
2096
|
const ariaSelector = `select[aria-label="${escapeForAttribute(label)}"]`;
|
|
1391
|
-
assertAllowedSelector(ariaSelector
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
await byAria.first().selectOption(value);
|
|
2097
|
+
policy.assertAllowedSelector(ariaSelector);
|
|
2098
|
+
if (await driver.count(ariaSelector)) {
|
|
2099
|
+
await driver.selectOptionFirst(ariaSelector, value);
|
|
1395
2100
|
return ariaSelector;
|
|
1396
2101
|
}
|
|
1397
2102
|
const placeholderSelector = `select[placeholder="${escapeForAttribute(label)}"]`;
|
|
1398
|
-
assertAllowedSelector(placeholderSelector
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
await byPlaceholder.first().selectOption(value);
|
|
2103
|
+
policy.assertAllowedSelector(placeholderSelector);
|
|
2104
|
+
if (await driver.count(placeholderSelector)) {
|
|
2105
|
+
await driver.selectOptionFirst(placeholderSelector, value);
|
|
1402
2106
|
return placeholderSelector;
|
|
1403
2107
|
}
|
|
1404
2108
|
throw new Error(`Could not resolve select shorthand for "${label}"`);
|
|
@@ -1526,11 +2230,11 @@ function toVisibilitySelector(value) {
|
|
|
1526
2230
|
if (looksLikeSelector(value)) return value;
|
|
1527
2231
|
return textContainsSelector(value);
|
|
1528
2232
|
}
|
|
1529
|
-
async function runInlineAssert(
|
|
2233
|
+
async function runInlineAssert(driver, policy, assertion) {
|
|
1530
2234
|
if (assertion.visible !== void 0) {
|
|
1531
2235
|
const selector = toVisibilitySelector(assertion.visible);
|
|
1532
|
-
assertAllowedSelector(selector
|
|
1533
|
-
const count = await
|
|
2236
|
+
policy.assertAllowedSelector(selector);
|
|
2237
|
+
const count = await driver.count(selector);
|
|
1534
2238
|
if (count === 0) {
|
|
1535
2239
|
throw new Error(`Expected visible: ${assertion.visible}`);
|
|
1536
2240
|
}
|
|
@@ -1538,22 +2242,22 @@ async function runInlineAssert(page, assertion, forbiddenSelectors) {
|
|
|
1538
2242
|
}
|
|
1539
2243
|
if (assertion.notVisible !== void 0) {
|
|
1540
2244
|
const selector = toVisibilitySelector(assertion.notVisible);
|
|
1541
|
-
assertAllowedSelector(selector
|
|
1542
|
-
const count = await
|
|
2245
|
+
policy.assertAllowedSelector(selector);
|
|
2246
|
+
const count = await driver.count(selector);
|
|
1543
2247
|
if (count > 0) {
|
|
1544
2248
|
throw new Error(`Expected not visible: ${assertion.notVisible}`);
|
|
1545
2249
|
}
|
|
1546
2250
|
return `notVisible:${assertion.notVisible}`;
|
|
1547
2251
|
}
|
|
1548
2252
|
if (assertion.urlIncludes !== void 0) {
|
|
1549
|
-
const current =
|
|
2253
|
+
const current = driver.currentUrl();
|
|
1550
2254
|
if (!current.includes(assertion.urlIncludes)) {
|
|
1551
2255
|
throw new Error(`URL did not include ${assertion.urlIncludes}`);
|
|
1552
2256
|
}
|
|
1553
2257
|
return `urlIncludes:${assertion.urlIncludes}`;
|
|
1554
2258
|
}
|
|
1555
2259
|
if (assertion.urlEquals !== void 0) {
|
|
1556
|
-
const current =
|
|
2260
|
+
const current = driver.currentUrl();
|
|
1557
2261
|
if (current !== assertion.urlEquals) {
|
|
1558
2262
|
throw new Error(`URL did not equal ${assertion.urlEquals}`);
|
|
1559
2263
|
}
|
|
@@ -1562,7 +2266,7 @@ async function runInlineAssert(page, assertion, forbiddenSelectors) {
|
|
|
1562
2266
|
throw new Error("assert step is missing an assertion type");
|
|
1563
2267
|
}
|
|
1564
2268
|
function screenshotPath(screenshotsDir, fileName) {
|
|
1565
|
-
return
|
|
2269
|
+
return import_node_path6.default.join(screenshotsDir, fileName);
|
|
1566
2270
|
}
|
|
1567
2271
|
function stepPath(prefix, index) {
|
|
1568
2272
|
return prefix ? `${prefix}.${index}` : `${index}`;
|
|
@@ -1570,8 +2274,8 @@ function stepPath(prefix, index) {
|
|
|
1570
2274
|
function isWaitForDownloadStep(step) {
|
|
1571
2275
|
return step !== void 0 && "waitForDownload" in step;
|
|
1572
2276
|
}
|
|
1573
|
-
function armDownloadListener(
|
|
1574
|
-
const downloadPromise =
|
|
2277
|
+
function armDownloadListener(driver, timeout) {
|
|
2278
|
+
const downloadPromise = driver.waitForDownloadEvent({ timeout });
|
|
1575
2279
|
void downloadPromise.catch(() => void 0);
|
|
1576
2280
|
return downloadPromise;
|
|
1577
2281
|
}
|
|
@@ -1579,14 +2283,14 @@ function validateDownloadFilename(suggestedFilename) {
|
|
|
1579
2283
|
const safeFilename = suggestedFilename.trim();
|
|
1580
2284
|
const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
|
|
1581
2285
|
const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
|
|
1582
|
-
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !==
|
|
2286
|
+
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path6.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
|
|
1583
2287
|
throw new Error(`Invalid download filename: "${suggestedFilename}"`);
|
|
1584
2288
|
}
|
|
1585
2289
|
return safeFilename;
|
|
1586
2290
|
}
|
|
1587
|
-
async function captureScreenshot(
|
|
2291
|
+
async function captureScreenshot(taker, filePath) {
|
|
1588
2292
|
try {
|
|
1589
|
-
await
|
|
2293
|
+
await taker.screenshot({ path: filePath, fullPage: true });
|
|
1590
2294
|
} catch (error) {
|
|
1591
2295
|
const message = error instanceof Error ? error.message : "Screenshot failed";
|
|
1592
2296
|
throw new Error(`Failed to capture screenshot at ${filePath}: ${message}`);
|
|
@@ -1605,608 +2309,784 @@ async function executeNestedSteps(context, overrides) {
|
|
|
1605
2309
|
}
|
|
1606
2310
|
return result;
|
|
1607
2311
|
}
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
screenshots.push(relative);
|
|
1622
|
-
return relative;
|
|
1623
|
-
};
|
|
1624
|
-
for (let index = 0; index < context.steps.length; index += 1) {
|
|
1625
|
-
const currentStepPath = stepPath(context.stepPathPrefix, index);
|
|
1626
|
-
if (Date.now() - runStartedAtMs > context.maxTotalTimeMs) {
|
|
1627
|
-
results.push({
|
|
1628
|
-
type: "timeout",
|
|
1629
|
-
status: "fail",
|
|
1630
|
-
durationMs: 0,
|
|
1631
|
-
error: `Max total time exceeded (${context.maxTotalTimeMs}ms)`
|
|
1632
|
-
});
|
|
1633
|
-
return { results, screenshots, failed: true, error: "Max total time exceeded" };
|
|
1634
|
-
}
|
|
1635
|
-
const runtimeVars = context.runtimeVars ?? /* @__PURE__ */ new Map();
|
|
1636
|
-
context.runtimeVars = runtimeVars;
|
|
1637
|
-
let step = context.steps[index];
|
|
1638
|
-
if (runtimeVars.size > 0) {
|
|
1639
|
-
step = applyRuntimeVars(step, runtimeVars);
|
|
2312
|
+
function unknownStep() {
|
|
2313
|
+
throw new Error("Unknown step type");
|
|
2314
|
+
}
|
|
2315
|
+
var STEP_HANDLERS = {
|
|
2316
|
+
navigate: {
|
|
2317
|
+
capabilities: ["navigate"],
|
|
2318
|
+
run: async (h) => {
|
|
2319
|
+
if (!("navigate" in h.step)) unknownStep();
|
|
2320
|
+
const destination = resolveNavigationTarget(h.context.targetUrl, h.step.navigate);
|
|
2321
|
+
h.policy.ensureUrlAllowed(destination);
|
|
2322
|
+
await h.driver.goto(destination);
|
|
2323
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2324
|
+
return { kind: "result", result: { type: "navigate", status: "pass", durationMs: Date.now() - h.stepStart } };
|
|
1640
2325
|
}
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
let healedFrom;
|
|
1661
|
-
if (typeof step.click === "string") {
|
|
1662
|
-
selector = await clickByTextWithFallback(
|
|
1663
|
-
context.page,
|
|
1664
|
-
step.click,
|
|
1665
|
-
context.forbiddenSelectors
|
|
1666
|
-
);
|
|
1667
|
-
} else {
|
|
1668
|
-
const resolved = await resolveActionSelector(context, step.click.selector);
|
|
1669
|
-
await clickElement(context.page, resolved.selector);
|
|
1670
|
-
selector = resolved.selector;
|
|
1671
|
-
healedFrom = resolved.healedFrom;
|
|
1672
|
-
}
|
|
1673
|
-
ensureAllowedUrl(context.page.url(), context.allowedDomains);
|
|
1674
|
-
stepResult = {
|
|
2326
|
+
},
|
|
2327
|
+
click: {
|
|
2328
|
+
capabilities: ["interact", "query"],
|
|
2329
|
+
run: async (h) => {
|
|
2330
|
+
if (!("click" in h.step)) unknownStep();
|
|
2331
|
+
let selector;
|
|
2332
|
+
let healedFrom;
|
|
2333
|
+
if (typeof h.step.click === "string") {
|
|
2334
|
+
selector = await clickByTextWithFallback(h.driver, h.policy, h.step.click);
|
|
2335
|
+
} else {
|
|
2336
|
+
const resolved = await h.policy.resolveActionSelector(h.step.click.selector);
|
|
2337
|
+
await h.driver.click(resolved.selector);
|
|
2338
|
+
selector = resolved.selector;
|
|
2339
|
+
healedFrom = resolved.healedFrom;
|
|
2340
|
+
}
|
|
2341
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2342
|
+
return {
|
|
2343
|
+
kind: "result",
|
|
2344
|
+
result: {
|
|
1675
2345
|
type: "click",
|
|
1676
2346
|
status: "pass",
|
|
1677
|
-
durationMs: Date.now() - stepStart,
|
|
2347
|
+
durationMs: Date.now() - h.stepStart,
|
|
1678
2348
|
selector,
|
|
1679
2349
|
...healedFrom ? { healedFrom } : {}
|
|
1680
|
-
};
|
|
1681
|
-
} else if ("fill" in step) {
|
|
1682
|
-
let selector;
|
|
1683
|
-
let value;
|
|
1684
|
-
let healedFrom;
|
|
1685
|
-
if (isExplicitFillStep(step.fill)) {
|
|
1686
|
-
const resolved = await resolveActionSelector(context, step.fill.selector);
|
|
1687
|
-
await fillElement(context.page, resolved.selector, step.fill.value);
|
|
1688
|
-
selector = resolved.selector;
|
|
1689
|
-
healedFrom = resolved.healedFrom;
|
|
1690
|
-
value = step.fill.value;
|
|
1691
|
-
} else {
|
|
1692
|
-
const [label, shorthandValue] = getSinglePair(step.fill, "fill");
|
|
1693
|
-
selector = await fillByLabelOrPlaceholder(
|
|
1694
|
-
context.page,
|
|
1695
|
-
label,
|
|
1696
|
-
shorthandValue,
|
|
1697
|
-
context.forbiddenSelectors
|
|
1698
|
-
);
|
|
1699
|
-
value = shorthandValue;
|
|
1700
2350
|
}
|
|
1701
|
-
|
|
1702
|
-
|
|
2351
|
+
};
|
|
2352
|
+
}
|
|
2353
|
+
},
|
|
2354
|
+
fill: {
|
|
2355
|
+
capabilities: ["interact", "query"],
|
|
2356
|
+
run: async (h) => {
|
|
2357
|
+
if (!("fill" in h.step)) unknownStep();
|
|
2358
|
+
let selector;
|
|
2359
|
+
let value;
|
|
2360
|
+
let healedFrom;
|
|
2361
|
+
if (isExplicitFillStep(h.step.fill)) {
|
|
2362
|
+
const resolved = await h.policy.resolveActionSelector(h.step.fill.selector);
|
|
2363
|
+
await h.driver.fill(resolved.selector, h.step.fill.value);
|
|
2364
|
+
selector = resolved.selector;
|
|
2365
|
+
healedFrom = resolved.healedFrom;
|
|
2366
|
+
value = h.step.fill.value;
|
|
2367
|
+
} else {
|
|
2368
|
+
const [label, shorthandValue] = getSinglePair(h.step.fill, "fill");
|
|
2369
|
+
selector = await fillByLabelOrPlaceholder(h.driver, h.policy, label, shorthandValue);
|
|
2370
|
+
value = shorthandValue;
|
|
2371
|
+
}
|
|
2372
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2373
|
+
return {
|
|
2374
|
+
kind: "result",
|
|
2375
|
+
result: {
|
|
1703
2376
|
type: "fill",
|
|
1704
2377
|
status: "pass",
|
|
1705
|
-
durationMs: Date.now() - stepStart,
|
|
2378
|
+
durationMs: Date.now() - h.stepStart,
|
|
1706
2379
|
selector,
|
|
1707
|
-
value: context.redactedFillSteps.has(
|
|
2380
|
+
value: h.context.redactedFillSteps.has(h.stepPath) ? "[REDACTED]" : value,
|
|
1708
2381
|
...healedFrom ? { healedFrom } : {}
|
|
1709
|
-
}
|
|
1710
|
-
}
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
2382
|
+
}
|
|
2383
|
+
};
|
|
2384
|
+
}
|
|
2385
|
+
},
|
|
2386
|
+
type: {
|
|
2387
|
+
capabilities: ["interact"],
|
|
2388
|
+
run: async (h) => {
|
|
2389
|
+
if (!("type" in h.step)) unknownStep();
|
|
2390
|
+
h.policy.assertAllowedSelector(":focus");
|
|
2391
|
+
await h.driver.fill(":focus", h.step.type);
|
|
2392
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2393
|
+
return {
|
|
2394
|
+
kind: "result",
|
|
2395
|
+
result: {
|
|
1715
2396
|
type: "type",
|
|
1716
2397
|
status: "pass",
|
|
1717
|
-
durationMs: Date.now() - stepStart,
|
|
2398
|
+
durationMs: Date.now() - h.stepStart,
|
|
1718
2399
|
selector: ":focus",
|
|
1719
|
-
value: context.redactedFillSteps.has(
|
|
1720
|
-
}
|
|
1721
|
-
}
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
2400
|
+
value: h.context.redactedFillSteps.has(h.stepPath) ? "[REDACTED]" : h.step.type
|
|
2401
|
+
}
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
},
|
|
2405
|
+
selectOption: {
|
|
2406
|
+
capabilities: ["navigate", "interact", "query"],
|
|
2407
|
+
run: async (h) => {
|
|
2408
|
+
if (!("selectOption" in h.step)) unknownStep();
|
|
2409
|
+
const resolved = await h.policy.resolveActionSelector(h.step.selectOption.selector);
|
|
2410
|
+
await h.driver.selectOption(resolved.selector, h.step.selectOption.value);
|
|
2411
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2412
|
+
return {
|
|
2413
|
+
kind: "result",
|
|
2414
|
+
result: {
|
|
1726
2415
|
type: "selectOption",
|
|
1727
2416
|
status: "pass",
|
|
1728
|
-
durationMs: Date.now() - stepStart,
|
|
2417
|
+
durationMs: Date.now() - h.stepStart,
|
|
1729
2418
|
selector: resolved.selector,
|
|
1730
|
-
value: step.selectOption.value,
|
|
2419
|
+
value: h.step.selectOption.value,
|
|
1731
2420
|
...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
2421
|
+
}
|
|
2422
|
+
};
|
|
2423
|
+
}
|
|
2424
|
+
},
|
|
2425
|
+
select: {
|
|
2426
|
+
capabilities: ["navigate", "interact", "query"],
|
|
2427
|
+
run: async (h) => {
|
|
2428
|
+
if (!("select" in h.step)) unknownStep();
|
|
2429
|
+
const [label, value] = getSinglePair(h.step.select, "select");
|
|
2430
|
+
const selector = await selectByLabelOrFallback(h.driver, h.policy, label, value);
|
|
2431
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2432
|
+
return {
|
|
2433
|
+
kind: "result",
|
|
2434
|
+
result: {
|
|
1743
2435
|
type: "select",
|
|
1744
2436
|
status: "pass",
|
|
1745
|
-
durationMs: Date.now() - stepStart,
|
|
2437
|
+
durationMs: Date.now() - h.stepStart,
|
|
1746
2438
|
selector,
|
|
1747
2439
|
value
|
|
1748
|
-
}
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
|
-
|
|
2440
|
+
}
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
},
|
|
2444
|
+
onDialog: {
|
|
2445
|
+
capabilities: ["dialog"],
|
|
2446
|
+
run: async (h) => {
|
|
2447
|
+
if (!("onDialog" in h.step)) unknownStep();
|
|
2448
|
+
h.driver.onDialog(h.step.onDialog.action);
|
|
2449
|
+
return {
|
|
2450
|
+
kind: "result",
|
|
2451
|
+
result: {
|
|
1752
2452
|
type: "onDialog",
|
|
1753
2453
|
status: "pass",
|
|
1754
|
-
durationMs: Date.now() - stepStart,
|
|
1755
|
-
value: step.onDialog.action
|
|
1756
|
-
}
|
|
1757
|
-
}
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
2454
|
+
durationMs: Date.now() - h.stepStart,
|
|
2455
|
+
value: h.step.onDialog.action
|
|
2456
|
+
}
|
|
2457
|
+
};
|
|
2458
|
+
}
|
|
2459
|
+
},
|
|
2460
|
+
setInputFiles: {
|
|
2461
|
+
capabilities: ["navigate", "interact", "query", "files"],
|
|
2462
|
+
run: async (h) => {
|
|
2463
|
+
if (!("setInputFiles" in h.step)) unknownStep();
|
|
2464
|
+
const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
|
|
2465
|
+
const rawFiles = h.step.setInputFiles.files;
|
|
2466
|
+
const resolveFile = (f) => import_node_path6.default.isAbsolute(f) ? f : import_node_path6.default.join(h.context.configDir, f);
|
|
2467
|
+
const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
|
|
2468
|
+
await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
|
|
2469
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2470
|
+
const filesLabel = Array.isArray(rawFiles) ? rawFiles.join(", ") : rawFiles;
|
|
2471
|
+
return {
|
|
2472
|
+
kind: "result",
|
|
2473
|
+
result: {
|
|
1766
2474
|
type: "setInputFiles",
|
|
1767
2475
|
status: "pass",
|
|
1768
|
-
durationMs: Date.now() - stepStart,
|
|
2476
|
+
durationMs: Date.now() - h.stepStart,
|
|
1769
2477
|
selector: resolvedInput.selector,
|
|
1770
2478
|
...resolvedInput.healedFrom ? { healedFrom: resolvedInput.healedFrom } : {},
|
|
1771
2479
|
value: filesLabel
|
|
1772
|
-
};
|
|
1773
|
-
} else if ("runHunt" in step) {
|
|
1774
|
-
const huntName = typeof step.runHunt === "string" ? step.runHunt : step.runHunt.name;
|
|
1775
|
-
const overrideVars = typeof step.runHunt === "string" ? void 0 : step.runHunt.vars;
|
|
1776
|
-
const stack = context.huntStack ?? [];
|
|
1777
|
-
if (stack.includes(huntName)) {
|
|
1778
|
-
throw new Error(`Circular hunt dependency: ${[...stack, huntName].join(" \u2192 ")}`);
|
|
1779
|
-
}
|
|
1780
|
-
const subHunt = loadHunt(huntName, context.configDir);
|
|
1781
|
-
if (overrideVars) {
|
|
1782
|
-
subHunt.vars = { ...subHunt.vars, ...overrideVars };
|
|
1783
|
-
}
|
|
1784
|
-
const {
|
|
1785
|
-
hunt: interpolatedSubHunt,
|
|
1786
|
-
redactedFillSteps: subRedacted,
|
|
1787
|
-
randomVars
|
|
1788
|
-
} = interpolateHunt(
|
|
1789
|
-
subHunt,
|
|
1790
|
-
process.env,
|
|
1791
|
-
context.randomVars
|
|
1792
|
-
);
|
|
1793
|
-
assertWithinMaxSteps(interpolatedSubHunt.steps.length, context.maxSteps, huntName);
|
|
1794
|
-
const subResult = await executeNestedSteps(context, {
|
|
1795
|
-
steps: interpolatedSubHunt.steps,
|
|
1796
|
-
redactedFillSteps: subRedacted,
|
|
1797
|
-
randomVars,
|
|
1798
|
-
stepPathPrefix: void 0,
|
|
1799
|
-
huntStack: [...stack, huntName],
|
|
1800
|
-
onStep: context.onStep
|
|
1801
|
-
});
|
|
1802
|
-
for (const sr of subResult.results) {
|
|
1803
|
-
results.push({ ...sr, type: `${huntName} > ${sr.type}` });
|
|
1804
|
-
}
|
|
1805
|
-
screenshots.push(...subResult.screenshots);
|
|
1806
|
-
if (subResult.failed) {
|
|
1807
|
-
return {
|
|
1808
|
-
results,
|
|
1809
|
-
screenshots,
|
|
1810
|
-
failed: true,
|
|
1811
|
-
error: `Sub-hunt "${huntName}" failed: ${subResult.error}`
|
|
1812
|
-
};
|
|
1813
2480
|
}
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
2481
|
+
};
|
|
2482
|
+
}
|
|
2483
|
+
},
|
|
2484
|
+
runHunt: {
|
|
2485
|
+
capabilities: [],
|
|
2486
|
+
run: async (h) => {
|
|
2487
|
+
if (!("runHunt" in h.step)) unknownStep();
|
|
2488
|
+
const huntName = typeof h.step.runHunt === "string" ? h.step.runHunt : h.step.runHunt.name;
|
|
2489
|
+
const overrideVars = typeof h.step.runHunt === "string" ? void 0 : h.step.runHunt.vars;
|
|
2490
|
+
const stack = h.context.huntStack ?? [];
|
|
2491
|
+
if (stack.includes(huntName)) {
|
|
2492
|
+
throw new Error(`Circular hunt dependency: ${[...stack, huntName].join(" \u2192 ")}`);
|
|
2493
|
+
}
|
|
2494
|
+
const subHunt = loadHunt(huntName, h.context.configDir);
|
|
2495
|
+
if (overrideVars) {
|
|
2496
|
+
subHunt.vars = { ...subHunt.vars, ...overrideVars };
|
|
2497
|
+
}
|
|
2498
|
+
const {
|
|
2499
|
+
hunt: interpolatedSubHunt,
|
|
2500
|
+
redactedFillSteps: subRedacted,
|
|
2501
|
+
randomVars
|
|
2502
|
+
} = interpolateHunt(subHunt, process.env, h.context.randomVars);
|
|
2503
|
+
const subTargetType = h.driver.capabilities.has("navigate") ? "web" : "macos";
|
|
2504
|
+
assertStepsSupportedByTarget(interpolatedSubHunt.steps, subTargetType);
|
|
2505
|
+
assertHuntAssertionsSupportedByTarget(interpolatedSubHunt.assertions, subTargetType);
|
|
2506
|
+
h.policy.assertWithinMaxSteps(interpolatedSubHunt.steps.length, huntName);
|
|
2507
|
+
const subResult = await h.executeNested({
|
|
2508
|
+
steps: interpolatedSubHunt.steps,
|
|
2509
|
+
redactedFillSteps: subRedacted,
|
|
2510
|
+
randomVars,
|
|
2511
|
+
stepPathPrefix: void 0,
|
|
2512
|
+
huntStack: [...stack, huntName],
|
|
2513
|
+
onStep: h.context.onStep
|
|
2514
|
+
});
|
|
2515
|
+
for (const sr of subResult.results) {
|
|
2516
|
+
h.results.push({ ...sr, type: `${huntName} > ${sr.type}` });
|
|
2517
|
+
}
|
|
2518
|
+
h.screenshots.push(...subResult.screenshots);
|
|
2519
|
+
if (subResult.failed) {
|
|
2520
|
+
return { kind: "abort", error: `Sub-hunt "${huntName}" failed: ${subResult.error}` };
|
|
2521
|
+
}
|
|
2522
|
+
return {
|
|
2523
|
+
kind: "result",
|
|
2524
|
+
result: { type: "runHunt", status: "pass", durationMs: Date.now() - h.stepStart, value: huntName }
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
2527
|
+
},
|
|
2528
|
+
press: {
|
|
2529
|
+
capabilities: ["interact", "query"],
|
|
2530
|
+
run: async (h) => {
|
|
2531
|
+
if (!("press" in h.step)) unknownStep();
|
|
2532
|
+
const resolved = await h.policy.resolveActionSelector(h.step.press.selector);
|
|
2533
|
+
await h.driver.press(resolved.selector, h.step.press.key);
|
|
2534
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2535
|
+
return {
|
|
2536
|
+
kind: "result",
|
|
2537
|
+
result: {
|
|
1825
2538
|
type: "press",
|
|
1826
2539
|
status: "pass",
|
|
1827
|
-
durationMs: Date.now() - stepStart,
|
|
2540
|
+
durationMs: Date.now() - h.stepStart,
|
|
1828
2541
|
selector: resolved.selector,
|
|
1829
2542
|
...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
|
|
1830
|
-
}
|
|
1831
|
-
}
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
2543
|
+
}
|
|
2544
|
+
};
|
|
2545
|
+
}
|
|
2546
|
+
},
|
|
2547
|
+
assert: {
|
|
2548
|
+
capabilities: ["query"],
|
|
2549
|
+
run: async (h) => {
|
|
2550
|
+
if (!("assert" in h.step)) unknownStep();
|
|
2551
|
+
const value = await runInlineAssert(h.driver, h.policy, h.step.assert);
|
|
2552
|
+
return {
|
|
2553
|
+
kind: "result",
|
|
2554
|
+
result: { type: "assert", status: "pass", durationMs: Date.now() - h.stepStart, value }
|
|
2555
|
+
};
|
|
2556
|
+
}
|
|
2557
|
+
},
|
|
2558
|
+
wait: {
|
|
2559
|
+
capabilities: ["wait"],
|
|
2560
|
+
run: async (h) => {
|
|
2561
|
+
if (!("wait" in h.step)) unknownStep();
|
|
2562
|
+
const text = typeof h.step.wait === "string" ? h.step.wait : h.step.wait.for;
|
|
2563
|
+
const timeout = typeof h.step.wait === "string" ? void 0 : h.step.wait.timeout;
|
|
2564
|
+
const selector = `text=${escapeForText(text)}`;
|
|
2565
|
+
h.policy.assertAllowedSelector(selector);
|
|
2566
|
+
await h.driver.waitForSelector(selector, { timeout });
|
|
2567
|
+
return {
|
|
2568
|
+
kind: "result",
|
|
2569
|
+
result: { type: "wait", status: "pass", durationMs: Date.now() - h.stepStart, selector }
|
|
2570
|
+
};
|
|
2571
|
+
}
|
|
2572
|
+
},
|
|
2573
|
+
waitForSelector: {
|
|
2574
|
+
capabilities: ["wait"],
|
|
2575
|
+
run: async (h) => {
|
|
2576
|
+
if (!("waitForSelector" in h.step)) unknownStep();
|
|
2577
|
+
h.policy.assertAllowedSelector(h.step.waitForSelector.selector);
|
|
2578
|
+
await h.driver.waitForSelector(h.step.waitForSelector.selector, {
|
|
2579
|
+
timeout: h.step.waitForSelector.timeout
|
|
2580
|
+
});
|
|
2581
|
+
return {
|
|
2582
|
+
kind: "result",
|
|
2583
|
+
result: {
|
|
1857
2584
|
type: "waitForSelector",
|
|
1858
2585
|
status: "pass",
|
|
1859
|
-
durationMs: Date.now() - stepStart,
|
|
1860
|
-
selector: step.waitForSelector.selector
|
|
1861
|
-
}
|
|
1862
|
-
}
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
2586
|
+
durationMs: Date.now() - h.stepStart,
|
|
2587
|
+
selector: h.step.waitForSelector.selector
|
|
2588
|
+
}
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
},
|
|
2592
|
+
waitForUrl: {
|
|
2593
|
+
capabilities: ["navigate", "wait"],
|
|
2594
|
+
run: async (h) => {
|
|
2595
|
+
if (!("waitForUrl" in h.step)) unknownStep();
|
|
2596
|
+
const value = h.step.waitForUrl.value;
|
|
2597
|
+
await h.driver.waitForUrl((url) => url.includes(value), { timeout: h.step.waitForUrl.timeout });
|
|
2598
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2599
|
+
return {
|
|
2600
|
+
kind: "result",
|
|
2601
|
+
result: { type: "waitForUrl", status: "pass", durationMs: Date.now() - h.stepStart, value }
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2604
|
+
},
|
|
2605
|
+
waitForNetworkIdle: {
|
|
2606
|
+
capabilities: ["wait"],
|
|
2607
|
+
run: async (h) => {
|
|
2608
|
+
if (!("waitForNetworkIdle" in h.step)) unknownStep();
|
|
2609
|
+
await h.driver.waitForNetworkIdle({ timeout: h.step.waitForNetworkIdle.timeout });
|
|
2610
|
+
return {
|
|
2611
|
+
kind: "result",
|
|
2612
|
+
result: { type: "waitForNetworkIdle", status: "pass", durationMs: Date.now() - h.stepStart }
|
|
2613
|
+
};
|
|
2614
|
+
}
|
|
2615
|
+
},
|
|
2616
|
+
hover: {
|
|
2617
|
+
capabilities: ["interact", "query"],
|
|
2618
|
+
run: async (h) => {
|
|
2619
|
+
if (!("hover" in h.step)) unknownStep();
|
|
2620
|
+
const resolved = await h.policy.resolveActionSelector(h.step.hover.selector);
|
|
2621
|
+
await h.driver.hover(resolved.selector);
|
|
2622
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2623
|
+
return {
|
|
2624
|
+
kind: "result",
|
|
2625
|
+
result: {
|
|
1888
2626
|
type: "hover",
|
|
1889
2627
|
status: "pass",
|
|
1890
|
-
durationMs: Date.now() - stepStart,
|
|
2628
|
+
durationMs: Date.now() - h.stepStart,
|
|
1891
2629
|
selector: resolved.selector,
|
|
1892
2630
|
...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
|
|
1893
|
-
}
|
|
1894
|
-
}
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
2631
|
+
}
|
|
2632
|
+
};
|
|
2633
|
+
}
|
|
2634
|
+
},
|
|
2635
|
+
scroll: {
|
|
2636
|
+
capabilities: ["evaluate"],
|
|
2637
|
+
run: async (h) => {
|
|
2638
|
+
if (!("scroll" in h.step)) unknownStep();
|
|
2639
|
+
const amount = h.step.scroll.amount ?? 500;
|
|
2640
|
+
const scrollMap = {
|
|
2641
|
+
up: [0, -amount],
|
|
2642
|
+
down: [0, amount],
|
|
2643
|
+
left: [-amount, 0],
|
|
2644
|
+
right: [amount, 0]
|
|
2645
|
+
};
|
|
2646
|
+
const [x, y] = scrollMap[h.step.scroll.direction];
|
|
2647
|
+
await h.driver.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
|
|
2648
|
+
return {
|
|
2649
|
+
kind: "result",
|
|
2650
|
+
result: {
|
|
1905
2651
|
type: "scroll",
|
|
1906
2652
|
status: "pass",
|
|
1907
|
-
durationMs: Date.now() - stepStart,
|
|
1908
|
-
value: `${step.scroll.direction} ${amount}px`
|
|
1909
|
-
}
|
|
1910
|
-
}
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
2653
|
+
durationMs: Date.now() - h.stepStart,
|
|
2654
|
+
value: `${h.step.scroll.direction} ${amount}px`
|
|
2655
|
+
}
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
},
|
|
2659
|
+
scrollTo: {
|
|
2660
|
+
capabilities: ["interact", "query"],
|
|
2661
|
+
run: async (h) => {
|
|
2662
|
+
if (!("scrollTo" in h.step)) unknownStep();
|
|
2663
|
+
const resolved = await h.policy.resolveActionSelector(h.step.scrollTo.selector);
|
|
2664
|
+
await h.driver.scrollIntoView(resolved.selector);
|
|
2665
|
+
return {
|
|
2666
|
+
kind: "result",
|
|
2667
|
+
result: {
|
|
1914
2668
|
type: "scrollTo",
|
|
1915
2669
|
status: "pass",
|
|
1916
|
-
durationMs: Date.now() - stepStart,
|
|
2670
|
+
durationMs: Date.now() - h.stepStart,
|
|
1917
2671
|
selector: resolved.selector,
|
|
1918
2672
|
...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
|
|
2673
|
+
}
|
|
2674
|
+
};
|
|
2675
|
+
}
|
|
2676
|
+
},
|
|
2677
|
+
screenshot: {
|
|
2678
|
+
capabilities: ["screenshot"],
|
|
2679
|
+
run: async (h) => {
|
|
2680
|
+
if (!("screenshot" in h.step)) unknownStep();
|
|
2681
|
+
const name = h.step.screenshot.name ?? `manual_step_${h.index + 1}.png`;
|
|
2682
|
+
if (/[/\\]|\.\./.test(name)) {
|
|
2683
|
+
throw new Error(`Invalid screenshot name: "${name}" must not contain path separators or ".."`);
|
|
2684
|
+
}
|
|
2685
|
+
const fileName = name.endsWith(".png") ? name : `${name}.png`;
|
|
2686
|
+
const relative = await h.addScreenshot(fileName);
|
|
2687
|
+
return {
|
|
2688
|
+
kind: "result",
|
|
2689
|
+
result: { type: "screenshot", status: "pass", durationMs: Date.now() - h.stepStart, screenshot: relative }
|
|
2690
|
+
};
|
|
2691
|
+
}
|
|
2692
|
+
},
|
|
2693
|
+
if: {
|
|
2694
|
+
capabilities: ["query"],
|
|
2695
|
+
run: async (h) => {
|
|
2696
|
+
if (!("if" in h.step)) unknownStep();
|
|
2697
|
+
const condition = h.step.if;
|
|
2698
|
+
const selector = condition.visible ?? condition.notVisible;
|
|
2699
|
+
h.policy.assertAllowedSelector(selector);
|
|
2700
|
+
const count = await h.driver.count(selector);
|
|
2701
|
+
const conditionMet = condition.visible !== void 0 ? count > 0 : count === 0;
|
|
2702
|
+
if (conditionMet) {
|
|
2703
|
+
const subResult = await h.executeNested({
|
|
2704
|
+
steps: condition.then,
|
|
2705
|
+
stepPathPrefix: `${h.stepPath}.if.then`
|
|
2706
|
+
});
|
|
2707
|
+
for (const sr of subResult.results) {
|
|
2708
|
+
h.results.push({ ...sr, type: `if > ${sr.type}` });
|
|
2709
|
+
}
|
|
2710
|
+
h.screenshots.push(...subResult.screenshots);
|
|
2711
|
+
if (subResult.failed) {
|
|
2712
|
+
return { kind: "abort", error: subResult.error };
|
|
2713
|
+
}
|
|
2714
|
+
return {
|
|
2715
|
+
kind: "result",
|
|
2716
|
+
result: {
|
|
2717
|
+
type: "if",
|
|
2718
|
+
status: "pass",
|
|
2719
|
+
durationMs: Date.now() - h.stepStart,
|
|
2720
|
+
value: `condition met, executed ${condition.then.length} steps`
|
|
2721
|
+
}
|
|
1919
2722
|
};
|
|
1920
|
-
}
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
2723
|
+
}
|
|
2724
|
+
if (condition.else && condition.else.length > 0) {
|
|
2725
|
+
const subResult = await h.executeNested({
|
|
2726
|
+
steps: condition.else,
|
|
2727
|
+
stepPathPrefix: `${h.stepPath}.if.else`
|
|
2728
|
+
});
|
|
2729
|
+
for (const sr of subResult.results) {
|
|
2730
|
+
h.results.push({ ...sr, type: `if > ${sr.type}` });
|
|
1924
2731
|
}
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
2732
|
+
h.screenshots.push(...subResult.screenshots);
|
|
2733
|
+
if (subResult.failed) {
|
|
2734
|
+
return { kind: "abort", error: subResult.error };
|
|
2735
|
+
}
|
|
2736
|
+
return {
|
|
2737
|
+
kind: "result",
|
|
2738
|
+
result: {
|
|
2739
|
+
type: "if",
|
|
2740
|
+
status: "pass",
|
|
2741
|
+
durationMs: Date.now() - h.stepStart,
|
|
2742
|
+
value: `condition not met, executed ${condition.else.length} else steps`
|
|
2743
|
+
}
|
|
2744
|
+
};
|
|
2745
|
+
}
|
|
2746
|
+
return {
|
|
2747
|
+
kind: "result",
|
|
2748
|
+
result: {
|
|
2749
|
+
type: "if",
|
|
1929
2750
|
status: "pass",
|
|
1930
|
-
durationMs: Date.now() - stepStart,
|
|
1931
|
-
|
|
1932
|
-
}
|
|
1933
|
-
}
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
2751
|
+
durationMs: Date.now() - h.stepStart,
|
|
2752
|
+
value: "condition not met, skipped"
|
|
2753
|
+
}
|
|
2754
|
+
};
|
|
2755
|
+
}
|
|
2756
|
+
},
|
|
2757
|
+
repeat: {
|
|
2758
|
+
capabilities: ["query"],
|
|
2759
|
+
run: async (h) => {
|
|
2760
|
+
if (!("repeat" in h.step)) unknownStep();
|
|
2761
|
+
const repeat = h.step.repeat;
|
|
2762
|
+
let totalSubSteps = 0;
|
|
2763
|
+
if (repeat.times !== void 0) {
|
|
2764
|
+
const totalPlanned = repeat.times * repeat.steps.length;
|
|
2765
|
+
if (totalPlanned + totalSubSteps > h.context.maxSteps) {
|
|
2766
|
+
throw new Error(`Repeat exceeded maxSteps guardrail (${h.context.maxSteps})`);
|
|
2767
|
+
}
|
|
2768
|
+
for (let i = 0; i < repeat.times; i++) {
|
|
2769
|
+
totalSubSteps += repeat.steps.length;
|
|
2770
|
+
const subResult = await h.executeNested({
|
|
2771
|
+
steps: repeat.steps,
|
|
2772
|
+
stepPathPrefix: `${h.stepPath}.repeat.steps`
|
|
1943
2773
|
});
|
|
1944
2774
|
for (const sr of subResult.results) {
|
|
1945
|
-
results.push({ ...sr, type: `
|
|
2775
|
+
h.results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
|
|
1946
2776
|
}
|
|
1947
|
-
screenshots.push(...subResult.screenshots);
|
|
2777
|
+
h.screenshots.push(...subResult.screenshots);
|
|
1948
2778
|
if (subResult.failed) {
|
|
1949
|
-
return {
|
|
1950
|
-
results,
|
|
1951
|
-
screenshots,
|
|
1952
|
-
failed: true,
|
|
1953
|
-
error: subResult.error
|
|
1954
|
-
};
|
|
1955
|
-
}
|
|
1956
|
-
stepResult = {
|
|
1957
|
-
type: "if",
|
|
1958
|
-
status: "pass",
|
|
1959
|
-
durationMs: Date.now() - stepStart,
|
|
1960
|
-
value: `condition met, executed ${condition.then.length} steps`
|
|
1961
|
-
};
|
|
1962
|
-
} else {
|
|
1963
|
-
if (condition.else && condition.else.length > 0) {
|
|
1964
|
-
const subResult = await executeNestedSteps(context, {
|
|
1965
|
-
steps: condition.else,
|
|
1966
|
-
stepPathPrefix: `${currentStepPath}.if.else`
|
|
1967
|
-
});
|
|
1968
|
-
for (const sr of subResult.results) {
|
|
1969
|
-
results.push({ ...sr, type: `if > ${sr.type}` });
|
|
1970
|
-
}
|
|
1971
|
-
screenshots.push(...subResult.screenshots);
|
|
1972
|
-
if (subResult.failed) {
|
|
1973
|
-
return {
|
|
1974
|
-
results,
|
|
1975
|
-
screenshots,
|
|
1976
|
-
failed: true,
|
|
1977
|
-
error: subResult.error
|
|
1978
|
-
};
|
|
1979
|
-
}
|
|
1980
|
-
stepResult = {
|
|
1981
|
-
type: "if",
|
|
1982
|
-
status: "pass",
|
|
1983
|
-
durationMs: Date.now() - stepStart,
|
|
1984
|
-
value: `condition not met, executed ${condition.else.length} else steps`
|
|
1985
|
-
};
|
|
1986
|
-
} else {
|
|
1987
|
-
stepResult = {
|
|
1988
|
-
type: "if",
|
|
1989
|
-
status: "pass",
|
|
1990
|
-
durationMs: Date.now() - stepStart,
|
|
1991
|
-
value: "condition not met, skipped"
|
|
1992
|
-
};
|
|
2779
|
+
return { kind: "abort", error: subResult.error };
|
|
1993
2780
|
}
|
|
1994
2781
|
}
|
|
1995
|
-
} else if (
|
|
1996
|
-
const
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
steps: repeat.steps,
|
|
2007
|
-
stepPathPrefix: `${currentStepPath}.repeat.steps`
|
|
2008
|
-
});
|
|
2009
|
-
for (const sr of subResult.results) {
|
|
2010
|
-
results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
|
|
2011
|
-
}
|
|
2012
|
-
screenshots.push(...subResult.screenshots);
|
|
2013
|
-
if (subResult.failed) {
|
|
2014
|
-
return {
|
|
2015
|
-
results,
|
|
2016
|
-
screenshots,
|
|
2017
|
-
failed: true,
|
|
2018
|
-
error: subResult.error
|
|
2019
|
-
};
|
|
2020
|
-
}
|
|
2782
|
+
} else if (repeat.while !== void 0) {
|
|
2783
|
+
const maxIter = repeat.maxIterations;
|
|
2784
|
+
const whileSelector = repeat.while.visible ?? repeat.while.notVisible;
|
|
2785
|
+
h.policy.assertAllowedSelector(whileSelector);
|
|
2786
|
+
for (let i = 0; i < maxIter; i++) {
|
|
2787
|
+
const whileCount = await h.driver.count(whileSelector);
|
|
2788
|
+
const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
|
|
2789
|
+
if (!shouldContinue) break;
|
|
2790
|
+
totalSubSteps += repeat.steps.length;
|
|
2791
|
+
if (totalSubSteps > h.context.maxSteps) {
|
|
2792
|
+
throw new Error(`Repeat exceeded maxSteps guardrail (${h.context.maxSteps})`);
|
|
2021
2793
|
}
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
for (
|
|
2027
|
-
|
|
2028
|
-
const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
|
|
2029
|
-
if (!shouldContinue) break;
|
|
2030
|
-
totalSubSteps += repeat.steps.length;
|
|
2031
|
-
if (totalSubSteps > context.maxSteps) {
|
|
2032
|
-
throw new Error(`Repeat exceeded maxSteps guardrail (${context.maxSteps})`);
|
|
2033
|
-
}
|
|
2034
|
-
const subResult = await executeNestedSteps(context, {
|
|
2035
|
-
steps: repeat.steps,
|
|
2036
|
-
stepPathPrefix: `${currentStepPath}.repeat.steps`
|
|
2037
|
-
});
|
|
2038
|
-
for (const sr of subResult.results) {
|
|
2039
|
-
results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
|
|
2040
|
-
}
|
|
2041
|
-
screenshots.push(...subResult.screenshots);
|
|
2042
|
-
if (subResult.failed) {
|
|
2043
|
-
return {
|
|
2044
|
-
results,
|
|
2045
|
-
screenshots,
|
|
2046
|
-
failed: true,
|
|
2047
|
-
error: subResult.error
|
|
2048
|
-
};
|
|
2049
|
-
}
|
|
2050
|
-
}
|
|
2051
|
-
}
|
|
2052
|
-
stepResult = {
|
|
2053
|
-
type: "repeat",
|
|
2054
|
-
status: "pass",
|
|
2055
|
-
durationMs: Date.now() - stepStart
|
|
2056
|
-
};
|
|
2057
|
-
} else if ("mockRoute" in step) {
|
|
2058
|
-
const mock = step.mockRoute;
|
|
2059
|
-
const mocks = context.activeMocks ?? /* @__PURE__ */ new Map();
|
|
2060
|
-
context.activeMocks = mocks;
|
|
2061
|
-
let responseBody;
|
|
2062
|
-
if (mock.response.body !== void 0) {
|
|
2063
|
-
responseBody = mock.response.body;
|
|
2064
|
-
} else {
|
|
2065
|
-
const responseFile = mock.response.file;
|
|
2066
|
-
if (!responseFile) {
|
|
2067
|
-
throw new Error("mock.response must include either body or file");
|
|
2794
|
+
const subResult = await h.executeNested({
|
|
2795
|
+
steps: repeat.steps,
|
|
2796
|
+
stepPathPrefix: `${h.stepPath}.repeat.steps`
|
|
2797
|
+
});
|
|
2798
|
+
for (const sr of subResult.results) {
|
|
2799
|
+
h.results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
|
|
2068
2800
|
}
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
const relativePath = import_node_path4.default.relative(resolvedConfigDir, resolvedFilePath);
|
|
2073
|
-
const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path4.default.sep}`) && !import_node_path4.default.isAbsolute(relativePath);
|
|
2074
|
-
if (!isWithinConfigDir) {
|
|
2075
|
-
throw new Error("mock.response.file must resolve within config directory");
|
|
2801
|
+
h.screenshots.push(...subResult.screenshots);
|
|
2802
|
+
if (subResult.failed) {
|
|
2803
|
+
return { kind: "abort", error: subResult.error };
|
|
2076
2804
|
}
|
|
2077
|
-
responseBody = await import_node_fs4.default.promises.readFile(resolvedFilePath, "utf-8");
|
|
2078
2805
|
}
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
if (!mocks || !mocks.has(url)) {
|
|
2101
|
-
throw new Error(`No active mock for URL: ${url}`);
|
|
2806
|
+
}
|
|
2807
|
+
return {
|
|
2808
|
+
kind: "result",
|
|
2809
|
+
result: { type: "repeat", status: "pass", durationMs: Date.now() - h.stepStart }
|
|
2810
|
+
};
|
|
2811
|
+
}
|
|
2812
|
+
},
|
|
2813
|
+
mockRoute: {
|
|
2814
|
+
capabilities: ["route"],
|
|
2815
|
+
run: async (h) => {
|
|
2816
|
+
if (!("mockRoute" in h.step)) unknownStep();
|
|
2817
|
+
const mock = h.step.mockRoute;
|
|
2818
|
+
const mocks = h.context.activeMocks ?? /* @__PURE__ */ new Map();
|
|
2819
|
+
h.context.activeMocks = mocks;
|
|
2820
|
+
let responseBody;
|
|
2821
|
+
if (mock.response.body !== void 0) {
|
|
2822
|
+
responseBody = mock.response.body;
|
|
2823
|
+
} else {
|
|
2824
|
+
const responseFile = mock.response.file;
|
|
2825
|
+
if (!responseFile) {
|
|
2826
|
+
throw new Error("mock.response must include either body or file");
|
|
2102
2827
|
}
|
|
2103
|
-
const
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
value: url
|
|
2111
|
-
};
|
|
2112
|
-
} else if ("evalScript" in step) {
|
|
2113
|
-
const expression = typeof step.evalScript === "string" ? step.evalScript : step.evalScript.expression;
|
|
2114
|
-
const result = await context.page.evaluate(expression);
|
|
2115
|
-
const resultStr = String(result);
|
|
2116
|
-
if (typeof step.evalScript !== "string" && step.evalScript.as) {
|
|
2117
|
-
runtimeVars.set(step.evalScript.as, resultStr);
|
|
2828
|
+
const candidateFilePath = import_node_path6.default.isAbsolute(responseFile) ? responseFile : import_node_path6.default.join(h.context.configDir, responseFile);
|
|
2829
|
+
const resolvedConfigDir = import_node_path6.default.resolve(h.context.configDir);
|
|
2830
|
+
const resolvedFilePath = import_node_path6.default.resolve(candidateFilePath);
|
|
2831
|
+
const relativePath = import_node_path6.default.relative(resolvedConfigDir, resolvedFilePath);
|
|
2832
|
+
const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path6.default.sep}`) && !import_node_path6.default.isAbsolute(relativePath);
|
|
2833
|
+
if (!isWithinConfigDir) {
|
|
2834
|
+
throw new Error("mock.response.file must resolve within config directory");
|
|
2118
2835
|
}
|
|
2119
|
-
|
|
2836
|
+
responseBody = await import_node_fs6.default.promises.readFile(resolvedFilePath, "utf-8");
|
|
2837
|
+
}
|
|
2838
|
+
const contentType = mock.response.contentType ?? "application/json";
|
|
2839
|
+
const status = mock.response.status;
|
|
2840
|
+
await h.driver.route(mock.url, async (route) => {
|
|
2841
|
+
await route.fulfill({
|
|
2842
|
+
status,
|
|
2843
|
+
contentType,
|
|
2844
|
+
body: responseBody
|
|
2845
|
+
});
|
|
2846
|
+
});
|
|
2847
|
+
mocks.set(mock.url, async () => {
|
|
2848
|
+
await h.driver.unroute(mock.url);
|
|
2849
|
+
});
|
|
2850
|
+
return {
|
|
2851
|
+
kind: "result",
|
|
2852
|
+
result: { type: "mockRoute", status: "pass", durationMs: Date.now() - h.stepStart, value: mock.url }
|
|
2853
|
+
};
|
|
2854
|
+
}
|
|
2855
|
+
},
|
|
2856
|
+
unmockRoute: {
|
|
2857
|
+
capabilities: ["route"],
|
|
2858
|
+
run: async (h) => {
|
|
2859
|
+
if (!("unmockRoute" in h.step)) unknownStep();
|
|
2860
|
+
const url = typeof h.step.unmockRoute === "string" ? h.step.unmockRoute : h.step.unmockRoute.url;
|
|
2861
|
+
const mocks = h.context.activeMocks;
|
|
2862
|
+
if (!mocks || !mocks.has(url)) {
|
|
2863
|
+
throw new Error(`No active mock for URL: ${url}`);
|
|
2864
|
+
}
|
|
2865
|
+
const cleanup = mocks.get(url);
|
|
2866
|
+
await cleanup();
|
|
2867
|
+
mocks.delete(url);
|
|
2868
|
+
return {
|
|
2869
|
+
kind: "result",
|
|
2870
|
+
result: { type: "unmockRoute", status: "pass", durationMs: Date.now() - h.stepStart, value: url }
|
|
2871
|
+
};
|
|
2872
|
+
}
|
|
2873
|
+
},
|
|
2874
|
+
evalScript: {
|
|
2875
|
+
capabilities: ["evaluate"],
|
|
2876
|
+
run: async (h) => {
|
|
2877
|
+
if (!("evalScript" in h.step)) unknownStep();
|
|
2878
|
+
const expression = typeof h.step.evalScript === "string" ? h.step.evalScript : h.step.evalScript.expression;
|
|
2879
|
+
const result = await h.driver.evaluate(expression);
|
|
2880
|
+
const resultStr = String(result);
|
|
2881
|
+
if (typeof h.step.evalScript !== "string" && h.step.evalScript.as) {
|
|
2882
|
+
h.runtimeVars.set(h.step.evalScript.as, resultStr);
|
|
2883
|
+
}
|
|
2884
|
+
return {
|
|
2885
|
+
kind: "result",
|
|
2886
|
+
result: {
|
|
2120
2887
|
type: "evalScript",
|
|
2121
2888
|
status: "pass",
|
|
2122
|
-
durationMs: Date.now() - stepStart,
|
|
2889
|
+
durationMs: Date.now() - h.stepStart,
|
|
2123
2890
|
value: resultStr.length > 200 ? resultStr.slice(0, 200) + "\u2026" : resultStr
|
|
2124
|
-
}
|
|
2125
|
-
}
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2891
|
+
}
|
|
2892
|
+
};
|
|
2893
|
+
}
|
|
2894
|
+
},
|
|
2895
|
+
runScript: {
|
|
2896
|
+
capabilities: ["evaluate"],
|
|
2897
|
+
run: async (h) => {
|
|
2898
|
+
if (!("runScript" in h.step)) unknownStep();
|
|
2899
|
+
const filePath = import_node_path6.default.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : import_node_path6.default.join(h.context.configDir, h.step.runScript.file);
|
|
2900
|
+
const fileContents = import_node_fs6.default.readFileSync(filePath, "utf-8");
|
|
2901
|
+
await h.driver.evaluate(fileContents);
|
|
2902
|
+
return {
|
|
2903
|
+
kind: "result",
|
|
2904
|
+
result: {
|
|
2130
2905
|
type: "runScript",
|
|
2131
2906
|
status: "pass",
|
|
2132
|
-
durationMs: Date.now() - stepStart,
|
|
2133
|
-
value: step.runScript.file
|
|
2907
|
+
durationMs: Date.now() - h.stepStart,
|
|
2908
|
+
value: h.step.runScript.file
|
|
2909
|
+
}
|
|
2910
|
+
};
|
|
2911
|
+
}
|
|
2912
|
+
},
|
|
2913
|
+
assertScreenshot: {
|
|
2914
|
+
capabilities: ["screenshot"],
|
|
2915
|
+
run: async (h) => {
|
|
2916
|
+
if (!("assertScreenshot" in h.step)) unknownStep();
|
|
2917
|
+
const { compareScreenshots: compareScreenshots2, ensureBaselineDir: ensureBaselineDir2 } = await Promise.resolve().then(() => (init_visual(), visual_exports));
|
|
2918
|
+
const name = h.step.assertScreenshot.name;
|
|
2919
|
+
const threshold = h.step.assertScreenshot.threshold ?? 0.1;
|
|
2920
|
+
const baselineDir = ensureBaselineDir2(h.context.configDir);
|
|
2921
|
+
const baselinePath = import_node_path6.default.join(baselineDir, `${name}.png`);
|
|
2922
|
+
const currentScreenshotPath = import_node_path6.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
|
|
2923
|
+
import_node_fs6.default.mkdirSync(import_node_path6.default.dirname(currentScreenshotPath), { recursive: true });
|
|
2924
|
+
await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
|
|
2925
|
+
h.screenshots.push(import_node_path6.default.join("screenshots", `${name}-current.png`));
|
|
2926
|
+
if (!import_node_fs6.default.existsSync(baselinePath)) {
|
|
2927
|
+
import_node_fs6.default.copyFileSync(currentScreenshotPath, baselinePath);
|
|
2928
|
+
return {
|
|
2929
|
+
kind: "result",
|
|
2930
|
+
result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
|
|
2134
2931
|
};
|
|
2135
|
-
}
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
import_node_fs4.default.mkdirSync(import_node_path4.default.dirname(currentScreenshotPath), { recursive: true });
|
|
2143
|
-
await context.page.screenshot({ path: currentScreenshotPath, fullPage: true });
|
|
2144
|
-
screenshots.push(import_node_path4.default.join("screenshots", `${name}-current.png`));
|
|
2145
|
-
if (!import_node_fs4.default.existsSync(baselinePath)) {
|
|
2146
|
-
import_node_fs4.default.copyFileSync(currentScreenshotPath, baselinePath);
|
|
2147
|
-
stepResult = {
|
|
2932
|
+
}
|
|
2933
|
+
const diffPath = import_node_path6.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
|
|
2934
|
+
const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
|
|
2935
|
+
if (comparison.match) {
|
|
2936
|
+
return {
|
|
2937
|
+
kind: "result",
|
|
2938
|
+
result: {
|
|
2148
2939
|
type: "assertScreenshot",
|
|
2149
2940
|
status: "pass",
|
|
2150
|
-
durationMs: Date.now() - stepStart,
|
|
2151
|
-
value:
|
|
2152
|
-
};
|
|
2153
|
-
} else {
|
|
2154
|
-
const diffPath = import_node_path4.default.join(context.runDir, "screenshots", `${name}-diff.png`);
|
|
2155
|
-
const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
|
|
2156
|
-
if (comparison.match) {
|
|
2157
|
-
stepResult = {
|
|
2158
|
-
type: "assertScreenshot",
|
|
2159
|
-
status: "pass",
|
|
2160
|
-
durationMs: Date.now() - stepStart,
|
|
2161
|
-
value: `diff: ${(comparison.diffPercentage * 100).toFixed(2)}%`
|
|
2162
|
-
};
|
|
2163
|
-
} else {
|
|
2164
|
-
screenshots.push(import_node_path4.default.join("screenshots", `${name}-diff.png`));
|
|
2165
|
-
throw new Error(
|
|
2166
|
-
`Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
|
|
2167
|
-
);
|
|
2941
|
+
durationMs: Date.now() - h.stepStart,
|
|
2942
|
+
value: `diff: ${(comparison.diffPercentage * 100).toFixed(2)}%`
|
|
2168
2943
|
}
|
|
2169
|
-
}
|
|
2170
|
-
}
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2944
|
+
};
|
|
2945
|
+
}
|
|
2946
|
+
h.screenshots.push(import_node_path6.default.join("screenshots", `${name}-diff.png`));
|
|
2947
|
+
throw new Error(
|
|
2948
|
+
`Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
|
|
2949
|
+
);
|
|
2950
|
+
}
|
|
2951
|
+
},
|
|
2952
|
+
copyText: {
|
|
2953
|
+
capabilities: ["query"],
|
|
2954
|
+
run: async (h) => {
|
|
2955
|
+
if (!("copyText" in h.step)) unknownStep();
|
|
2956
|
+
h.policy.assertAllowedSelector(h.step.copyText.selector);
|
|
2957
|
+
const text = await h.driver.textContent(h.step.copyText.selector);
|
|
2958
|
+
if (text === null) {
|
|
2959
|
+
throw new Error(`No text content found for selector: ${h.step.copyText.selector}`);
|
|
2960
|
+
}
|
|
2961
|
+
h.runtimeVars.set(h.step.copyText.as, text);
|
|
2962
|
+
return {
|
|
2963
|
+
kind: "result",
|
|
2964
|
+
result: {
|
|
2178
2965
|
type: "copyText",
|
|
2179
2966
|
status: "pass",
|
|
2180
|
-
durationMs: Date.now() - stepStart,
|
|
2181
|
-
selector: step.copyText.selector,
|
|
2967
|
+
durationMs: Date.now() - h.stepStart,
|
|
2968
|
+
selector: h.step.copyText.selector,
|
|
2182
2969
|
value: "[REDACTED]"
|
|
2183
|
-
};
|
|
2184
|
-
} else if ("waitForDownload" in step) {
|
|
2185
|
-
const opts = step.waitForDownload;
|
|
2186
|
-
const downloadPromise = context.pendingDownload ?? armDownloadListener(
|
|
2187
|
-
context.page,
|
|
2188
|
-
opts?.timeout ?? 3e4
|
|
2189
|
-
);
|
|
2190
|
-
context.pendingDownload = void 0;
|
|
2191
|
-
const download = await downloadPromise;
|
|
2192
|
-
const suggestedFilename = validateDownloadFilename(download.suggestedFilename());
|
|
2193
|
-
if (opts?.filename !== void 0 && suggestedFilename !== opts.filename) {
|
|
2194
|
-
throw new Error(
|
|
2195
|
-
`Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
|
|
2196
|
-
);
|
|
2197
2970
|
}
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2971
|
+
};
|
|
2972
|
+
}
|
|
2973
|
+
},
|
|
2974
|
+
waitForDownload: {
|
|
2975
|
+
capabilities: ["download"],
|
|
2976
|
+
run: async (h) => {
|
|
2977
|
+
if (!("waitForDownload" in h.step)) unknownStep();
|
|
2978
|
+
const opts = h.step.waitForDownload;
|
|
2979
|
+
const downloadPromise = h.context.pendingDownload ?? armDownloadListener(h.driver, opts?.timeout ?? 3e4);
|
|
2980
|
+
h.context.pendingDownload = void 0;
|
|
2981
|
+
const download = await downloadPromise;
|
|
2982
|
+
const suggestedFilename = validateDownloadFilename(download.suggestedFilename());
|
|
2983
|
+
if (opts?.filename !== void 0 && suggestedFilename !== opts.filename) {
|
|
2984
|
+
throw new Error(
|
|
2985
|
+
`Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
|
|
2986
|
+
);
|
|
2987
|
+
}
|
|
2988
|
+
const savePath = import_node_path6.default.join(h.context.runDir, suggestedFilename);
|
|
2989
|
+
await download.saveAs(savePath);
|
|
2990
|
+
return {
|
|
2991
|
+
kind: "result",
|
|
2992
|
+
result: {
|
|
2201
2993
|
type: "waitForDownload",
|
|
2202
2994
|
status: "pass",
|
|
2203
|
-
durationMs: Date.now() - stepStart,
|
|
2995
|
+
durationMs: Date.now() - h.stepStart,
|
|
2204
2996
|
value: suggestedFilename
|
|
2205
|
-
}
|
|
2206
|
-
}
|
|
2207
|
-
|
|
2997
|
+
}
|
|
2998
|
+
};
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
};
|
|
3002
|
+
async function executeSteps(context) {
|
|
3003
|
+
let driver = context.driver;
|
|
3004
|
+
if (!driver) {
|
|
3005
|
+
if (!context.page) {
|
|
3006
|
+
throw new Error("executeSteps requires a driver or a Playwright page");
|
|
3007
|
+
}
|
|
3008
|
+
driver = createPlaywrightDriver(context.page);
|
|
3009
|
+
}
|
|
3010
|
+
const policy = createRunPolicy(driver, {
|
|
3011
|
+
forbiddenSelectors: context.forbiddenSelectors,
|
|
3012
|
+
allowedDomains: context.allowedDomains,
|
|
3013
|
+
allowedApps: context.allowedApps,
|
|
3014
|
+
maxSteps: context.maxSteps,
|
|
3015
|
+
selfHealing: context.selfHealing
|
|
3016
|
+
});
|
|
3017
|
+
const screenshotsDir = import_node_path6.default.join(context.runDir, "screenshots");
|
|
3018
|
+
import_node_fs6.default.mkdirSync(screenshotsDir, { recursive: true });
|
|
3019
|
+
const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
|
|
3020
|
+
policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
|
|
3021
|
+
const results = [];
|
|
3022
|
+
const screenshots = [];
|
|
3023
|
+
const runStartedAtMs = context.runStartedAtMs ?? Date.now();
|
|
3024
|
+
context.runStartedAtMs = runStartedAtMs;
|
|
3025
|
+
const addScreenshot = async (fileName) => {
|
|
3026
|
+
const fullPath = screenshotPath(screenshotsDir, fileName);
|
|
3027
|
+
await captureScreenshot(driver, fullPath);
|
|
3028
|
+
const relative = import_node_path6.default.join("screenshots", fileName);
|
|
3029
|
+
screenshots.push(relative);
|
|
3030
|
+
return relative;
|
|
3031
|
+
};
|
|
3032
|
+
const executeNested = (overrides) => executeNestedSteps(context, { driver, ...overrides });
|
|
3033
|
+
for (let index = 0; index < context.steps.length; index += 1) {
|
|
3034
|
+
const currentStepPath = stepPath(context.stepPathPrefix, index);
|
|
3035
|
+
if (Date.now() - runStartedAtMs > context.maxTotalTimeMs) {
|
|
3036
|
+
results.push({
|
|
3037
|
+
type: "timeout",
|
|
3038
|
+
status: "fail",
|
|
3039
|
+
durationMs: 0,
|
|
3040
|
+
error: `Max total time exceeded (${context.maxTotalTimeMs}ms)`
|
|
3041
|
+
});
|
|
3042
|
+
return { results, screenshots, failed: true, error: "Max total time exceeded" };
|
|
3043
|
+
}
|
|
3044
|
+
const runtimeVars = context.runtimeVars ?? /* @__PURE__ */ new Map();
|
|
3045
|
+
context.runtimeVars = runtimeVars;
|
|
3046
|
+
let step = context.steps[index];
|
|
3047
|
+
if (runtimeVars.size > 0) {
|
|
3048
|
+
step = applyRuntimeVars(step, runtimeVars);
|
|
3049
|
+
}
|
|
3050
|
+
const nextStep = context.steps[index + 1];
|
|
3051
|
+
if (!isWaitForDownloadStep(step) && context.pendingDownload === void 0 && isWaitForDownloadStep(nextStep)) {
|
|
3052
|
+
context.pendingDownload = armDownloadListener(
|
|
3053
|
+
driver,
|
|
3054
|
+
nextStep.waitForDownload?.timeout ?? 3e4
|
|
3055
|
+
);
|
|
3056
|
+
}
|
|
3057
|
+
const stepStart = Date.now();
|
|
3058
|
+
const stepType = getStepType(step);
|
|
3059
|
+
let stepResult = null;
|
|
3060
|
+
try {
|
|
3061
|
+
const handler = STEP_HANDLERS[stepType];
|
|
3062
|
+
if (!handler) {
|
|
2208
3063
|
throw new Error("Unknown step type");
|
|
2209
3064
|
}
|
|
3065
|
+
for (const capability of handler.capabilities) {
|
|
3066
|
+
if (!driver.capabilities.has(capability)) {
|
|
3067
|
+
throw new Error(
|
|
3068
|
+
`Driver does not support capability "${capability}" required by step "${stepType}"`
|
|
3069
|
+
);
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
const outcome = await handler.run({
|
|
3073
|
+
driver,
|
|
3074
|
+
policy,
|
|
3075
|
+
context,
|
|
3076
|
+
step,
|
|
3077
|
+
index,
|
|
3078
|
+
stepPath: currentStepPath,
|
|
3079
|
+
stepStart,
|
|
3080
|
+
runtimeVars,
|
|
3081
|
+
results,
|
|
3082
|
+
screenshots,
|
|
3083
|
+
addScreenshot,
|
|
3084
|
+
executeNested
|
|
3085
|
+
});
|
|
3086
|
+
if (outcome.kind === "abort") {
|
|
3087
|
+
return { results, screenshots, failed: true, error: outcome.error };
|
|
3088
|
+
}
|
|
3089
|
+
stepResult = outcome.result;
|
|
2210
3090
|
if (context.screenshotsMode === "all" && stepResult.type !== "screenshot") {
|
|
2211
3091
|
const fileName = `step_${index + 1}.png`;
|
|
2212
3092
|
await addScreenshot(fileName);
|
|
@@ -2233,12 +3113,12 @@ async function executeSteps(context) {
|
|
|
2233
3113
|
return { results, screenshots, failed: false };
|
|
2234
3114
|
}
|
|
2235
3115
|
async function captureFinalScreenshot(page, runDir) {
|
|
2236
|
-
const screenshotsDir =
|
|
2237
|
-
|
|
3116
|
+
const screenshotsDir = import_node_path6.default.join(runDir, "screenshots");
|
|
3117
|
+
import_node_fs6.default.mkdirSync(screenshotsDir, { recursive: true });
|
|
2238
3118
|
const fileName = "final.png";
|
|
2239
3119
|
const filePath = screenshotPath(screenshotsDir, fileName);
|
|
2240
3120
|
await captureScreenshot(page, filePath);
|
|
2241
|
-
return
|
|
3121
|
+
return import_node_path6.default.join("screenshots", fileName);
|
|
2242
3122
|
}
|
|
2243
3123
|
|
|
2244
3124
|
// src/runner/assertions.ts
|
|
@@ -2398,18 +3278,18 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
|
|
|
2398
3278
|
}
|
|
2399
3279
|
|
|
2400
3280
|
// src/reporter/result.ts
|
|
2401
|
-
var
|
|
2402
|
-
var
|
|
3281
|
+
var import_node_fs7 = __toESM(require("fs"), 1);
|
|
3282
|
+
var import_node_path7 = __toESM(require("path"), 1);
|
|
2403
3283
|
function writeResult(runDir, result) {
|
|
2404
3284
|
const fileName = "result.json";
|
|
2405
|
-
const fullPath =
|
|
2406
|
-
|
|
3285
|
+
const fullPath = import_node_path7.default.join(runDir, fileName);
|
|
3286
|
+
import_node_fs7.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
|
|
2407
3287
|
return fileName;
|
|
2408
3288
|
}
|
|
2409
3289
|
|
|
2410
3290
|
// src/reporter/summary.ts
|
|
2411
|
-
var
|
|
2412
|
-
var
|
|
3291
|
+
var import_node_fs8 = __toESM(require("fs"), 1);
|
|
3292
|
+
var import_node_path8 = __toESM(require("path"), 1);
|
|
2413
3293
|
function escapeMd(text) {
|
|
2414
3294
|
return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
|
|
2415
3295
|
}
|
|
@@ -2487,15 +3367,15 @@ function writeSummary(runDir, result) {
|
|
|
2487
3367
|
}
|
|
2488
3368
|
}
|
|
2489
3369
|
const fileName = "summary.md";
|
|
2490
|
-
const fullPath =
|
|
2491
|
-
|
|
3370
|
+
const fullPath = import_node_path8.default.join(runDir, fileName);
|
|
3371
|
+
import_node_fs8.default.writeFileSync(fullPath, `${lines.join("\n")}
|
|
2492
3372
|
`);
|
|
2493
3373
|
return fileName;
|
|
2494
3374
|
}
|
|
2495
3375
|
|
|
2496
3376
|
// src/reporter/junit.ts
|
|
2497
|
-
var
|
|
2498
|
-
var
|
|
3377
|
+
var import_node_fs9 = __toESM(require("fs"), 1);
|
|
3378
|
+
var import_node_path9 = __toESM(require("path"), 1);
|
|
2499
3379
|
function escapeXml(text) {
|
|
2500
3380
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2501
3381
|
}
|
|
@@ -2539,8 +3419,8 @@ function writeJunit(runDir, result) {
|
|
|
2539
3419
|
lines.push(" </testsuite>");
|
|
2540
3420
|
lines.push("</testsuites>");
|
|
2541
3421
|
const fileName = "junit.xml";
|
|
2542
|
-
const fullPath =
|
|
2543
|
-
|
|
3422
|
+
const fullPath = import_node_path9.default.join(runDir, fileName);
|
|
3423
|
+
import_node_fs9.default.writeFileSync(fullPath, `${lines.join("\n")}
|
|
2544
3424
|
`);
|
|
2545
3425
|
return fileName;
|
|
2546
3426
|
}
|
|
@@ -2574,15 +3454,15 @@ function timestamp(prefix) {
|
|
|
2574
3454
|
}
|
|
2575
3455
|
|
|
2576
3456
|
// src/runner/history.ts
|
|
2577
|
-
var
|
|
2578
|
-
var
|
|
3457
|
+
var import_node_fs10 = __toESM(require("fs"), 1);
|
|
3458
|
+
var import_node_path10 = __toESM(require("path"), 1);
|
|
2579
3459
|
var HISTORY_FILE = "history.json";
|
|
2580
3460
|
var LOCK_FILE_SUFFIX = ".lock";
|
|
2581
3461
|
var LOCK_RETRY_MS = 10;
|
|
2582
3462
|
var LOCK_TIMEOUT_MS = 5e3;
|
|
2583
3463
|
var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
2584
3464
|
function historyPath(configDir) {
|
|
2585
|
-
return
|
|
3465
|
+
return import_node_path10.default.join(configDir, HISTORY_FILE);
|
|
2586
3466
|
}
|
|
2587
3467
|
function isHistoryEntry(value) {
|
|
2588
3468
|
if (!value || typeof value !== "object") {
|
|
@@ -2593,11 +3473,11 @@ function isHistoryEntry(value) {
|
|
|
2593
3473
|
}
|
|
2594
3474
|
function readHistory(configDir) {
|
|
2595
3475
|
const filePath = historyPath(configDir);
|
|
2596
|
-
if (!
|
|
3476
|
+
if (!import_node_fs10.default.existsSync(filePath)) {
|
|
2597
3477
|
return { entries: [] };
|
|
2598
3478
|
}
|
|
2599
3479
|
try {
|
|
2600
|
-
const raw =
|
|
3480
|
+
const raw = import_node_fs10.default.readFileSync(filePath, "utf-8");
|
|
2601
3481
|
const parsed = JSON.parse(raw);
|
|
2602
3482
|
if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
|
|
2603
3483
|
const validatedEntries = parsed.entries.filter(isHistoryEntry);
|
|
@@ -2636,12 +3516,12 @@ function sleepSync(ms) {
|
|
|
2636
3516
|
function withHistoryLock(configDir, fn) {
|
|
2637
3517
|
const filePath = historyPath(configDir);
|
|
2638
3518
|
const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
|
|
2639
|
-
|
|
3519
|
+
import_node_fs10.default.mkdirSync(import_node_path10.default.dirname(filePath), { recursive: true });
|
|
2640
3520
|
const startedAt = Date.now();
|
|
2641
3521
|
while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
|
|
2642
3522
|
let fd;
|
|
2643
3523
|
try {
|
|
2644
|
-
fd =
|
|
3524
|
+
fd = import_node_fs10.default.openSync(lockPath, "wx");
|
|
2645
3525
|
} catch (error) {
|
|
2646
3526
|
if (error.code === "EEXIST") {
|
|
2647
3527
|
sleepSync(LOCK_RETRY_MS);
|
|
@@ -2653,10 +3533,10 @@ function withHistoryLock(configDir, fn) {
|
|
|
2653
3533
|
return fn();
|
|
2654
3534
|
} finally {
|
|
2655
3535
|
try {
|
|
2656
|
-
|
|
3536
|
+
import_node_fs10.default.closeSync(fd);
|
|
2657
3537
|
} catch {
|
|
2658
3538
|
}
|
|
2659
|
-
|
|
3539
|
+
import_node_fs10.default.rmSync(lockPath, { force: true });
|
|
2660
3540
|
}
|
|
2661
3541
|
}
|
|
2662
3542
|
throw new Error(
|
|
@@ -2669,9 +3549,9 @@ function appendEntry(configDir, entry, maxRuns) {
|
|
|
2669
3549
|
const current = readHistory(configDir);
|
|
2670
3550
|
const next = pruneEntries([...current.entries, entry], maxRuns);
|
|
2671
3551
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
2672
|
-
|
|
3552
|
+
import_node_fs10.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
|
|
2673
3553
|
`);
|
|
2674
|
-
|
|
3554
|
+
import_node_fs10.default.renameSync(tempPath, filePath);
|
|
2675
3555
|
});
|
|
2676
3556
|
}
|
|
2677
3557
|
|
|
@@ -2684,11 +3564,11 @@ function parseViewportFlag(value) {
|
|
|
2684
3564
|
return value;
|
|
2685
3565
|
}
|
|
2686
3566
|
function resolvePath(configDir, inputPath) {
|
|
2687
|
-
if (
|
|
3567
|
+
if (import_node_path11.default.isAbsolute(inputPath)) {
|
|
2688
3568
|
return inputPath;
|
|
2689
3569
|
}
|
|
2690
|
-
const projectRoot =
|
|
2691
|
-
return
|
|
3570
|
+
const projectRoot = import_node_path11.default.dirname(configDir);
|
|
3571
|
+
return import_node_path11.default.join(projectRoot, inputPath);
|
|
2692
3572
|
}
|
|
2693
3573
|
function buildRunResult(options) {
|
|
2694
3574
|
return {
|
|
@@ -2707,12 +3587,12 @@ function buildRunResult(options) {
|
|
|
2707
3587
|
}
|
|
2708
3588
|
function writeConsoleLog(runDir, entries) {
|
|
2709
3589
|
const fileName = "console.log";
|
|
2710
|
-
const filePath =
|
|
3590
|
+
const filePath = import_node_path11.default.join(runDir, fileName);
|
|
2711
3591
|
const lines = entries.map((entry) => {
|
|
2712
3592
|
const location = entry.location ? ` (${entry.location})` : "";
|
|
2713
3593
|
return `[${entry.type}] ${entry.text}${location}`;
|
|
2714
3594
|
});
|
|
2715
|
-
|
|
3595
|
+
import_node_fs11.default.writeFileSync(filePath, `${lines.join("\n")}
|
|
2716
3596
|
`);
|
|
2717
3597
|
return fileName;
|
|
2718
3598
|
}
|
|
@@ -2720,8 +3600,8 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
2720
3600
|
const headless = options.headed ? false : config.browser.headless;
|
|
2721
3601
|
const slowMo = options.slowMo ?? config.browser.slowMo;
|
|
2722
3602
|
const maxSteps = config.guardrails.maxSteps;
|
|
2723
|
-
const runDir =
|
|
2724
|
-
|
|
3603
|
+
const runDir = import_node_path11.default.join(configDir, "runs", timestamp());
|
|
3604
|
+
import_node_fs11.default.mkdirSync(runDir, { recursive: true });
|
|
2725
3605
|
const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
|
|
2726
3606
|
const engine = options.browser ?? config.browser.engine;
|
|
2727
3607
|
const channel = options.channel ?? config.browser.channel;
|
|
@@ -2740,6 +3620,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
2740
3620
|
});
|
|
2741
3621
|
let result;
|
|
2742
3622
|
try {
|
|
3623
|
+
const driver = createPlaywrightDriver(session.page);
|
|
2743
3624
|
const consoleEntries = [];
|
|
2744
3625
|
const networkEntries = [];
|
|
2745
3626
|
const traceCorrelations = [];
|
|
@@ -2751,7 +3632,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
2751
3632
|
location: message.location().url
|
|
2752
3633
|
});
|
|
2753
3634
|
});
|
|
2754
|
-
|
|
3635
|
+
driver.onResponse((response) => {
|
|
2755
3636
|
if (response.status() >= 400) {
|
|
2756
3637
|
networkEntries.push({ url: response.url(), status: response.status() });
|
|
2757
3638
|
captureTraceCorrelation(response, traceHeader, traceCorrelations, redactionValues);
|
|
@@ -2765,6 +3646,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
2765
3646
|
try {
|
|
2766
3647
|
const stepExecution = await executeSteps({
|
|
2767
3648
|
page: session.page,
|
|
3649
|
+
driver,
|
|
2768
3650
|
steps: interpolatedHunt.steps,
|
|
2769
3651
|
targetUrl,
|
|
2770
3652
|
runDir,
|
|
@@ -2797,7 +3679,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
2797
3679
|
}
|
|
2798
3680
|
let finalScreenshot;
|
|
2799
3681
|
try {
|
|
2800
|
-
finalScreenshot = await captureFinalScreenshot(
|
|
3682
|
+
finalScreenshot = await captureFinalScreenshot(driver, runDir);
|
|
2801
3683
|
} catch {
|
|
2802
3684
|
finalScreenshot = void 0;
|
|
2803
3685
|
}
|
|
@@ -2841,6 +3723,9 @@ function delay(ms) {
|
|
|
2841
3723
|
}
|
|
2842
3724
|
async function runHunt(options) {
|
|
2843
3725
|
const { config, configDir } = loadConfig(options.configPath);
|
|
3726
|
+
if (config.target.type === "macos") {
|
|
3727
|
+
return runMacHunt(options, config, configDir, config.target);
|
|
3728
|
+
}
|
|
2844
3729
|
const hunt = loadHunt(options.huntName, configDir);
|
|
2845
3730
|
const {
|
|
2846
3731
|
hunt: interpolatedHunt,
|
|
@@ -2891,9 +3776,124 @@ async function runHunt(options) {
|
|
|
2891
3776
|
}
|
|
2892
3777
|
return lastResult;
|
|
2893
3778
|
}
|
|
3779
|
+
async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
|
|
3780
|
+
const maxSteps = config.guardrails.maxSteps;
|
|
3781
|
+
const runDir = import_node_path11.default.join(configDir, "runs", timestamp());
|
|
3782
|
+
import_node_fs11.default.mkdirSync(runDir, { recursive: true });
|
|
3783
|
+
const session = await launchMacSession({
|
|
3784
|
+
app: target.app,
|
|
3785
|
+
timeoutMs: config.browser.timeout,
|
|
3786
|
+
clientFactory: options.macClientFactory
|
|
3787
|
+
});
|
|
3788
|
+
let result;
|
|
3789
|
+
try {
|
|
3790
|
+
const targetLabel = `macos:${session.bundleId}`;
|
|
3791
|
+
const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, target.app, session.bundleId])];
|
|
3792
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3793
|
+
const startTime = Date.now();
|
|
3794
|
+
let stepResults = [];
|
|
3795
|
+
let stepScreenshots = [];
|
|
3796
|
+
let stepFailed = false;
|
|
3797
|
+
try {
|
|
3798
|
+
const stepExecution = await executeSteps({
|
|
3799
|
+
driver: session.driver,
|
|
3800
|
+
steps: interpolatedHunt.steps,
|
|
3801
|
+
targetUrl: targetLabel,
|
|
3802
|
+
runDir,
|
|
3803
|
+
screenshotsMode: config.artifacts.screenshots,
|
|
3804
|
+
forbiddenSelectors: config.guardrails.forbiddenSelectors,
|
|
3805
|
+
allowedDomains: [],
|
|
3806
|
+
allowedApps: effectiveAllowedApps,
|
|
3807
|
+
maxSteps,
|
|
3808
|
+
maxTotalTimeMs: config.assertions.maxTotalTimeMs,
|
|
3809
|
+
selfHealing: config.guardrails.selfHealing,
|
|
3810
|
+
redactedFillSteps,
|
|
3811
|
+
randomVars,
|
|
3812
|
+
configDir,
|
|
3813
|
+
huntStack: [options.huntName],
|
|
3814
|
+
onStep: options.onStep
|
|
3815
|
+
});
|
|
3816
|
+
stepResults = stepExecution.results;
|
|
3817
|
+
stepScreenshots = stepExecution.screenshots;
|
|
3818
|
+
stepFailed = stepExecution.failed;
|
|
3819
|
+
} catch (error) {
|
|
3820
|
+
const message = error instanceof Error ? error.message : "Step execution failed";
|
|
3821
|
+
stepResults = [{ type: "steps", status: "fail", durationMs: 0, error: message }];
|
|
3822
|
+
stepFailed = true;
|
|
3823
|
+
}
|
|
3824
|
+
let finalScreenshot;
|
|
3825
|
+
try {
|
|
3826
|
+
finalScreenshot = await captureFinalScreenshot(session.driver, runDir);
|
|
3827
|
+
} catch {
|
|
3828
|
+
finalScreenshot = void 0;
|
|
3829
|
+
}
|
|
3830
|
+
const durationMs = Date.now() - startTime;
|
|
3831
|
+
const status = stepFailed ? "fail" : "pass";
|
|
3832
|
+
const artifacts = {
|
|
3833
|
+
screenshots: finalScreenshot ? [...stepScreenshots, finalScreenshot] : stepScreenshots
|
|
3834
|
+
};
|
|
3835
|
+
const runResult = buildRunResult({
|
|
3836
|
+
status,
|
|
3837
|
+
startedAt,
|
|
3838
|
+
durationMs,
|
|
3839
|
+
hunt: options.huntName,
|
|
3840
|
+
targetUrl: targetLabel,
|
|
3841
|
+
steps: stepResults,
|
|
3842
|
+
assertions: [],
|
|
3843
|
+
artifacts
|
|
3844
|
+
});
|
|
3845
|
+
result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
|
|
3846
|
+
} finally {
|
|
3847
|
+
await closeMacSession(session);
|
|
3848
|
+
}
|
|
3849
|
+
return { result, runDir, steps: interpolatedHunt.steps };
|
|
3850
|
+
}
|
|
3851
|
+
async function runMacHunt(options, config, configDir, target) {
|
|
3852
|
+
const hunt = loadHunt(options.huntName, configDir);
|
|
3853
|
+
const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
|
|
3854
|
+
assertStepsSupportedByTarget(interpolatedHunt.steps, "macos");
|
|
3855
|
+
assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, "macos");
|
|
3856
|
+
assertTargetAppAllowed(config.guardrails.allowedApps, target.app);
|
|
3857
|
+
const maxSteps = config.guardrails.maxSteps;
|
|
3858
|
+
if (interpolatedHunt.steps.length > maxSteps) {
|
|
3859
|
+
throw new Error(`Hunt has ${interpolatedHunt.steps.length} steps. Max allowed is ${maxSteps}.`);
|
|
3860
|
+
}
|
|
3861
|
+
const maxRetries = hunt.retry?.maxRetries ?? 0;
|
|
3862
|
+
const retryDelay = hunt.retry?.delay ?? 0;
|
|
3863
|
+
let lastResult;
|
|
3864
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
3865
|
+
if (attempt > 0 && retryDelay > 0) {
|
|
3866
|
+
await delay(retryDelay);
|
|
3867
|
+
}
|
|
3868
|
+
lastResult = await executeMacHuntAttempt(
|
|
3869
|
+
options,
|
|
3870
|
+
config,
|
|
3871
|
+
configDir,
|
|
3872
|
+
target,
|
|
3873
|
+
interpolatedHunt,
|
|
3874
|
+
redactedFillSteps,
|
|
3875
|
+
randomVars,
|
|
3876
|
+
config.guardrails.allowedApps
|
|
3877
|
+
);
|
|
3878
|
+
if (lastResult.result.status === "pass") {
|
|
3879
|
+
if (attempt > 0) {
|
|
3880
|
+
lastResult.result.artifacts.summary = `Passed on attempt ${attempt + 1} of ${maxRetries + 1}`;
|
|
3881
|
+
}
|
|
3882
|
+
recordHistory(configDir, lastResult, config.history.maxRuns);
|
|
3883
|
+
return lastResult;
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3886
|
+
if (maxRetries > 0 && lastResult) {
|
|
3887
|
+
lastResult.result.artifacts.summary = `Failed after ${maxRetries + 1} attempts`;
|
|
3888
|
+
}
|
|
3889
|
+
if (lastResult) {
|
|
3890
|
+
recordHistory(configDir, lastResult, config.history.maxRuns);
|
|
3891
|
+
}
|
|
3892
|
+
return lastResult;
|
|
3893
|
+
}
|
|
2894
3894
|
function recordHistory(configDir, outcome, maxRuns) {
|
|
2895
3895
|
try {
|
|
2896
|
-
const relativeRunDir =
|
|
3896
|
+
const relativeRunDir = import_node_path11.default.relative(configDir, outcome.runDir);
|
|
2897
3897
|
appendEntry(
|
|
2898
3898
|
configDir,
|
|
2899
3899
|
{
|
|
@@ -2910,11 +3910,11 @@ function recordHistory(configDir, outcome, maxRuns) {
|
|
|
2910
3910
|
}
|
|
2911
3911
|
|
|
2912
3912
|
// src/runner/suite.ts
|
|
2913
|
-
var
|
|
3913
|
+
var import_node_path14 = __toESM(require("path"), 1);
|
|
2914
3914
|
|
|
2915
3915
|
// src/reporter/ci-summary.ts
|
|
2916
|
-
var
|
|
2917
|
-
var
|
|
3916
|
+
var import_node_fs12 = __toESM(require("fs"), 1);
|
|
3917
|
+
var import_node_path12 = __toESM(require("path"), 1);
|
|
2918
3918
|
var import_chalk = __toESM(require("chalk"), 1);
|
|
2919
3919
|
function countCiResults(results) {
|
|
2920
3920
|
return {
|
|
@@ -2944,9 +3944,9 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
|
|
|
2944
3944
|
...flaky.length > 0 ? { flaky } : {},
|
|
2945
3945
|
...clusters.length > 0 ? { clusters } : {}
|
|
2946
3946
|
};
|
|
2947
|
-
|
|
2948
|
-
const filePath =
|
|
2949
|
-
|
|
3947
|
+
import_node_fs12.default.mkdirSync(ciRunDir, { recursive: true });
|
|
3948
|
+
const filePath = import_node_path12.default.join(ciRunDir, "ci-result.json");
|
|
3949
|
+
import_node_fs12.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
|
|
2950
3950
|
return filePath;
|
|
2951
3951
|
}
|
|
2952
3952
|
|
|
@@ -3080,8 +4080,8 @@ function clusterFailures(failures) {
|
|
|
3080
4080
|
}
|
|
3081
4081
|
|
|
3082
4082
|
// src/backlog/index.ts
|
|
3083
|
-
var
|
|
3084
|
-
var
|
|
4083
|
+
var import_node_fs13 = __toESM(require("fs"), 1);
|
|
4084
|
+
var import_node_path13 = __toESM(require("path"), 1);
|
|
3085
4085
|
|
|
3086
4086
|
// src/backlog/parse.ts
|
|
3087
4087
|
var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
|
|
@@ -3171,7 +4171,7 @@ ${after}`;
|
|
|
3171
4171
|
// src/backlog/index.ts
|
|
3172
4172
|
function readFileOrEmpty(filePath) {
|
|
3173
4173
|
try {
|
|
3174
|
-
return
|
|
4174
|
+
return import_node_fs13.default.readFileSync(filePath, "utf-8");
|
|
3175
4175
|
} catch (error) {
|
|
3176
4176
|
const err = error;
|
|
3177
4177
|
if (err.code === "ENOENT") return "";
|
|
@@ -3187,7 +4187,7 @@ function buildFailure(hunt) {
|
|
|
3187
4187
|
if (!hunt.runDir) return failure;
|
|
3188
4188
|
let run;
|
|
3189
4189
|
try {
|
|
3190
|
-
const resultJson = readFileOrEmpty(
|
|
4190
|
+
const resultJson = readFileOrEmpty(import_node_path13.default.join(hunt.runDir, "result.json"));
|
|
3191
4191
|
if (!resultJson) return failure;
|
|
3192
4192
|
run = JSON.parse(resultJson);
|
|
3193
4193
|
} catch (error) {
|
|
@@ -3216,8 +4216,8 @@ function extractFailures(suiteResult) {
|
|
|
3216
4216
|
}
|
|
3217
4217
|
function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
3218
4218
|
const projectRoot = options.projectRoot ?? process.cwd();
|
|
3219
|
-
const backlogPath = options.backlogPath ??
|
|
3220
|
-
const resolvedPath = options.resolvedPath ??
|
|
4219
|
+
const backlogPath = options.backlogPath ?? import_node_path13.default.join(projectRoot, "docs", "backlog.md");
|
|
4220
|
+
const resolvedPath = options.resolvedPath ?? import_node_path13.default.join(projectRoot, "docs", "resolved.md");
|
|
3221
4221
|
const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
3222
4222
|
const summary = { created: [], regressions: [], skipped: [], backlogPath };
|
|
3223
4223
|
const failures = extractFailures(suiteResult);
|
|
@@ -3249,8 +4249,8 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
|
3249
4249
|
}
|
|
3250
4250
|
}
|
|
3251
4251
|
if (ticketsToAdd.length > 0) {
|
|
3252
|
-
|
|
3253
|
-
|
|
4252
|
+
import_node_fs13.default.mkdirSync(import_node_path13.default.dirname(backlogPath), { recursive: true });
|
|
4253
|
+
import_node_fs13.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
|
|
3254
4254
|
}
|
|
3255
4255
|
return summary;
|
|
3256
4256
|
}
|
|
@@ -3411,7 +4411,7 @@ async function runSuite(options = {}) {
|
|
|
3411
4411
|
const clusters = clusterFailures(
|
|
3412
4412
|
extractFailures({ result: { hunts: results }, resultPath: null })
|
|
3413
4413
|
).filter((cluster) => cluster.count > 1);
|
|
3414
|
-
const ciRunDir =
|
|
4414
|
+
const ciRunDir = import_node_path14.default.join(configDir, "runs", timestamp("ci"));
|
|
3415
4415
|
const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
|
|
3416
4416
|
const { passed, failed, skipped } = countCiResults(results);
|
|
3417
4417
|
return {
|
|
@@ -3548,7 +4548,20 @@ async function analyzePage(page) {
|
|
|
3548
4548
|
|
|
3549
4549
|
// src/generator/index.ts
|
|
3550
4550
|
var import_yaml2 = __toESM(require("yaml"), 1);
|
|
3551
|
-
|
|
4551
|
+
|
|
4552
|
+
// src/browser/engines.ts
|
|
4553
|
+
function formatSupportedBrowserEngines() {
|
|
4554
|
+
return SUPPORTED_BROWSER_ENGINES.join(", ");
|
|
4555
|
+
}
|
|
4556
|
+
function parseBrowserEngine(value, fallback = "chromium") {
|
|
4557
|
+
if (value === void 0 || value.length === 0) {
|
|
4558
|
+
return fallback;
|
|
4559
|
+
}
|
|
4560
|
+
if (SUPPORTED_BROWSER_ENGINES.includes(value)) {
|
|
4561
|
+
return value;
|
|
4562
|
+
}
|
|
4563
|
+
throw new Error(`Unsupported browser engine "${value}". Use ${formatSupportedBrowserEngines()}.`);
|
|
4564
|
+
}
|
|
3552
4565
|
|
|
3553
4566
|
// src/generator/prompt.ts
|
|
3554
4567
|
var STEP_REFERENCE = `
|
|
@@ -3710,18 +4723,34 @@ async function generateWithOpenAi(prompt, config) {
|
|
|
3710
4723
|
}
|
|
3711
4724
|
|
|
3712
4725
|
// src/generator/index.ts
|
|
4726
|
+
function parseViewportFlag2(value) {
|
|
4727
|
+
const match = /^(\d+)x(\d+)$/i.exec(value);
|
|
4728
|
+
if (match) {
|
|
4729
|
+
return { width: Number(match[1]), height: Number(match[2]) };
|
|
4730
|
+
}
|
|
4731
|
+
return value;
|
|
4732
|
+
}
|
|
3713
4733
|
async function generateHunt(options) {
|
|
3714
4734
|
let analysis = options.analysis;
|
|
3715
4735
|
if (!analysis && options.url) {
|
|
3716
|
-
const
|
|
3717
|
-
const
|
|
3718
|
-
const
|
|
4736
|
+
const engine = parseBrowserEngine(options.browser);
|
|
4737
|
+
const viewport = options.viewport ? resolveViewport(parseViewportFlag2(options.viewport)) : resolveViewport(void 0);
|
|
4738
|
+
const session = await launchBrowser({
|
|
4739
|
+
headless: true,
|
|
4740
|
+
slowMo: 0,
|
|
4741
|
+
timeout: 3e4,
|
|
4742
|
+
trace: false,
|
|
4743
|
+
recordHar: false,
|
|
4744
|
+
runDir: process.cwd(),
|
|
4745
|
+
engine,
|
|
4746
|
+
viewport
|
|
4747
|
+
});
|
|
4748
|
+
const driver = createPlaywrightDriver(session.page);
|
|
3719
4749
|
try {
|
|
3720
|
-
await
|
|
3721
|
-
analysis = await analyzePage(
|
|
4750
|
+
await driver.goto(options.url, { waitUntil: "networkidle" });
|
|
4751
|
+
analysis = await analyzePage(driver);
|
|
3722
4752
|
} finally {
|
|
3723
|
-
await
|
|
3724
|
-
await browser.close();
|
|
4753
|
+
await closeBrowser(session);
|
|
3725
4754
|
}
|
|
3726
4755
|
}
|
|
3727
4756
|
if (!analysis) {
|
|
@@ -3738,28 +4767,40 @@ async function generateHunt(options) {
|
|
|
3738
4767
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3739
4768
|
0 && (module.exports = {
|
|
3740
4769
|
DEFAULT_FLAKY_THRESHOLD,
|
|
4770
|
+
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
4771
|
+
SpawnMacHelperClient,
|
|
4772
|
+
WEB_ONLY_STEP_TYPES,
|
|
3741
4773
|
analyzePage,
|
|
4774
|
+
assertStepsSupportedByTarget,
|
|
4775
|
+
assertTargetAppAllowed,
|
|
3742
4776
|
buildHealCandidates,
|
|
4777
|
+
closeMacSession,
|
|
3743
4778
|
clusterFailures,
|
|
3744
4779
|
computeFlakeScore,
|
|
3745
4780
|
configSchema,
|
|
4781
|
+
createMacDriver,
|
|
3746
4782
|
extractFailures,
|
|
3747
4783
|
extractSelectorIntent,
|
|
3748
4784
|
generateHunt,
|
|
3749
4785
|
healSelector,
|
|
3750
4786
|
huntSchema,
|
|
3751
4787
|
interpolateHunt,
|
|
4788
|
+
launchMacSession,
|
|
3752
4789
|
listHunts,
|
|
3753
4790
|
loadConfig,
|
|
3754
4791
|
loadHunt,
|
|
3755
4792
|
loadHuntMeta,
|
|
3756
4793
|
loadHuntTags,
|
|
4794
|
+
macdriverBuildInstructions,
|
|
4795
|
+
parseMacSelector,
|
|
3757
4796
|
rankFlaky,
|
|
3758
4797
|
readHistory,
|
|
3759
4798
|
readHuntHistory,
|
|
4799
|
+
resolveHelperBinary,
|
|
3760
4800
|
runHunt,
|
|
3761
4801
|
runSuite,
|
|
3762
4802
|
stepSchema,
|
|
3763
|
-
updateBacklogFromSuite
|
|
4803
|
+
updateBacklogFromSuite,
|
|
4804
|
+
webOnlyReason
|
|
3764
4805
|
});
|
|
3765
4806
|
//# sourceMappingURL=lib.cjs.map
|