prowl-tools 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/NOTICE +63 -0
- package/README.md +945 -0
- package/dist/chunk-NXXGJOBG.js +546 -0
- package/dist/chunk-NXXGJOBG.js.map +1 -0
- package/dist/chunk-T7YLXF6X.js +3158 -0
- package/dist/chunk-T7YLXF6X.js.map +1 -0
- package/dist/index.cjs +5143 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +1366 -0
- package/dist/index.js.map +1 -0
- package/dist/lib.cjs +3765 -0
- package/dist/lib.cjs.map +1 -0
- package/dist/lib.d.cts +935 -0
- package/dist/lib.d.ts +935 -0
- package/dist/lib.js +55 -0
- package/dist/lib.js.map +1 -0
- package/dist/loader-5RDNTJHH.js +27 -0
- package/dist/loader-5RDNTJHH.js.map +1 -0
- package/dist/visual-FSARM2JS.js +46 -0
- package/dist/visual-FSARM2JS.js.map +1 -0
- package/examples/config.yml +41 -0
- package/examples/hunts/hello.yml +20 -0
- package/package.json +83 -0
package/dist/lib.cjs
ADDED
|
@@ -0,0 +1,3765 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __esm = (fn, res) => function __init() {
|
|
9
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
10
|
+
};
|
|
11
|
+
var __export = (target, all) => {
|
|
12
|
+
for (var name in all)
|
|
13
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
14
|
+
};
|
|
15
|
+
var __copyProps = (to, from, except, desc) => {
|
|
16
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
17
|
+
for (let key of __getOwnPropNames(from))
|
|
18
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
19
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
20
|
+
}
|
|
21
|
+
return to;
|
|
22
|
+
};
|
|
23
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
24
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
25
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
26
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
27
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
28
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
29
|
+
mod
|
|
30
|
+
));
|
|
31
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
32
|
+
|
|
33
|
+
// src/runner/visual.ts
|
|
34
|
+
var visual_exports = {};
|
|
35
|
+
__export(visual_exports, {
|
|
36
|
+
compareScreenshots: () => compareScreenshots,
|
|
37
|
+
ensureBaselineDir: () => ensureBaselineDir
|
|
38
|
+
});
|
|
39
|
+
async function compareScreenshots(baselinePath, currentPath, diffPath, threshold) {
|
|
40
|
+
const baselineData = import_pngjs.PNG.sync.read(import_node_fs3.default.readFileSync(baselinePath));
|
|
41
|
+
const currentData = import_pngjs.PNG.sync.read(import_node_fs3.default.readFileSync(currentPath));
|
|
42
|
+
const { width, height } = baselineData;
|
|
43
|
+
if (currentData.width !== width || currentData.height !== height) {
|
|
44
|
+
const diff2 = new import_pngjs.PNG({ width, height });
|
|
45
|
+
import_node_fs3.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
|
|
46
|
+
return {
|
|
47
|
+
match: false,
|
|
48
|
+
diffPercentage: 1,
|
|
49
|
+
diffImagePath: diffPath
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const diff = new import_pngjs.PNG({ width, height });
|
|
53
|
+
const diffPixels = (0, import_pixelmatch.default)(
|
|
54
|
+
baselineData.data,
|
|
55
|
+
currentData.data,
|
|
56
|
+
diff.data,
|
|
57
|
+
width,
|
|
58
|
+
height,
|
|
59
|
+
{ threshold: 0.1 }
|
|
60
|
+
);
|
|
61
|
+
const totalPixels = width * height;
|
|
62
|
+
const diffPercentage = diffPixels / totalPixels;
|
|
63
|
+
import_node_fs3.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
|
|
64
|
+
return {
|
|
65
|
+
match: diffPercentage <= threshold,
|
|
66
|
+
diffPercentage,
|
|
67
|
+
diffImagePath: diffPath
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function ensureBaselineDir(configDir) {
|
|
71
|
+
const baselineDir = import_node_path3.default.join(configDir, "baselines");
|
|
72
|
+
import_node_fs3.default.mkdirSync(baselineDir, { recursive: true });
|
|
73
|
+
return baselineDir;
|
|
74
|
+
}
|
|
75
|
+
var import_node_fs3, import_node_path3, import_pngjs, import_pixelmatch;
|
|
76
|
+
var init_visual = __esm({
|
|
77
|
+
"src/runner/visual.ts"() {
|
|
78
|
+
"use strict";
|
|
79
|
+
import_node_fs3 = __toESM(require("fs"), 1);
|
|
80
|
+
import_node_path3 = __toESM(require("path"), 1);
|
|
81
|
+
import_pngjs = require("pngjs");
|
|
82
|
+
import_pixelmatch = __toESM(require("pixelmatch"), 1);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// src/index.ts
|
|
87
|
+
var src_exports = {};
|
|
88
|
+
__export(src_exports, {
|
|
89
|
+
DEFAULT_FLAKY_THRESHOLD: () => DEFAULT_FLAKY_THRESHOLD,
|
|
90
|
+
analyzePage: () => analyzePage,
|
|
91
|
+
buildHealCandidates: () => buildHealCandidates,
|
|
92
|
+
clusterFailures: () => clusterFailures,
|
|
93
|
+
computeFlakeScore: () => computeFlakeScore,
|
|
94
|
+
configSchema: () => configSchema,
|
|
95
|
+
extractFailures: () => extractFailures,
|
|
96
|
+
extractSelectorIntent: () => extractSelectorIntent,
|
|
97
|
+
generateHunt: () => generateHunt,
|
|
98
|
+
healSelector: () => healSelector,
|
|
99
|
+
huntSchema: () => huntSchema,
|
|
100
|
+
interpolateHunt: () => interpolateHunt,
|
|
101
|
+
listHunts: () => listHunts,
|
|
102
|
+
loadConfig: () => loadConfig,
|
|
103
|
+
loadHunt: () => loadHunt,
|
|
104
|
+
loadHuntMeta: () => loadHuntMeta,
|
|
105
|
+
loadHuntTags: () => loadHuntTags,
|
|
106
|
+
rankFlaky: () => rankFlaky,
|
|
107
|
+
readHistory: () => readHistory,
|
|
108
|
+
readHuntHistory: () => readHuntHistory,
|
|
109
|
+
runHunt: () => runHunt,
|
|
110
|
+
runSuite: () => runSuite,
|
|
111
|
+
stepSchema: () => stepSchema,
|
|
112
|
+
updateBacklogFromSuite: () => updateBacklogFromSuite
|
|
113
|
+
});
|
|
114
|
+
module.exports = __toCommonJS(src_exports);
|
|
115
|
+
|
|
116
|
+
// src/runner/index.ts
|
|
117
|
+
var import_node_fs9 = __toESM(require("fs"), 1);
|
|
118
|
+
var import_node_path9 = __toESM(require("path"), 1);
|
|
119
|
+
|
|
120
|
+
// src/config/loader.ts
|
|
121
|
+
var import_node_fs = __toESM(require("fs"), 1);
|
|
122
|
+
var import_node_path = __toESM(require("path"), 1);
|
|
123
|
+
var import_yaml = __toESM(require("yaml"), 1);
|
|
124
|
+
var import_dotenv = __toESM(require("dotenv"), 1);
|
|
125
|
+
|
|
126
|
+
// src/config/schema.ts
|
|
127
|
+
var import_zod = require("zod");
|
|
128
|
+
|
|
129
|
+
// src/config/hunt-name.ts
|
|
130
|
+
var HUNT_NAME_PATTERN = /^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/;
|
|
131
|
+
function isValidHuntName(name) {
|
|
132
|
+
return HUNT_NAME_PATTERN.test(name);
|
|
133
|
+
}
|
|
134
|
+
function assertValidHuntName(name) {
|
|
135
|
+
if (!isValidHuntName(name)) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Invalid hunt name: "${name}". Use only letters, numbers, hyphens, underscores, and forward slashes.`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// src/config/schema.ts
|
|
143
|
+
var configSchema = import_zod.z.object({
|
|
144
|
+
target: import_zod.z.object({
|
|
145
|
+
url: import_zod.z.string().min(1)
|
|
146
|
+
}),
|
|
147
|
+
browser: import_zod.z.object({
|
|
148
|
+
headless: import_zod.z.boolean().optional(),
|
|
149
|
+
slowMo: import_zod.z.number().optional(),
|
|
150
|
+
timeout: import_zod.z.number().optional(),
|
|
151
|
+
engine: import_zod.z.enum(["chromium", "firefox", "webkit"]).optional(),
|
|
152
|
+
channel: import_zod.z.enum([
|
|
153
|
+
"chromium",
|
|
154
|
+
"chrome",
|
|
155
|
+
"chrome-beta",
|
|
156
|
+
"chrome-canary",
|
|
157
|
+
"chrome-dev",
|
|
158
|
+
"msedge",
|
|
159
|
+
"msedge-beta",
|
|
160
|
+
"msedge-canary",
|
|
161
|
+
"msedge-dev"
|
|
162
|
+
]).optional(),
|
|
163
|
+
viewport: import_zod.z.union([
|
|
164
|
+
import_zod.z.enum(["mobile", "tablet", "desktop"]),
|
|
165
|
+
import_zod.z.object({ width: import_zod.z.number().int().positive(), height: import_zod.z.number().int().positive() }).strict()
|
|
166
|
+
]).optional()
|
|
167
|
+
}).optional(),
|
|
168
|
+
artifacts: import_zod.z.object({
|
|
169
|
+
screenshots: import_zod.z.enum(["on-failure", "all"]).optional(),
|
|
170
|
+
networkHar: import_zod.z.boolean().optional(),
|
|
171
|
+
console: import_zod.z.boolean().optional(),
|
|
172
|
+
junit: import_zod.z.boolean().optional()
|
|
173
|
+
}).optional(),
|
|
174
|
+
assertions: import_zod.z.object({
|
|
175
|
+
noConsoleErrors: import_zod.z.boolean().optional(),
|
|
176
|
+
noNetworkErrors: import_zod.z.boolean().optional(),
|
|
177
|
+
maxTotalTimeMs: import_zod.z.number().optional(),
|
|
178
|
+
networkIgnorePatterns: import_zod.z.array(import_zod.z.string()).optional()
|
|
179
|
+
}).optional(),
|
|
180
|
+
guardrails: import_zod.z.object({
|
|
181
|
+
maxSteps: import_zod.z.number().optional(),
|
|
182
|
+
allowedDomains: import_zod.z.array(import_zod.z.string()).optional(),
|
|
183
|
+
forbiddenSelectors: import_zod.z.array(import_zod.z.string()).optional(),
|
|
184
|
+
selfHealing: import_zod.z.boolean().optional()
|
|
185
|
+
}).optional(),
|
|
186
|
+
auth: import_zod.z.object({
|
|
187
|
+
storageStatePath: import_zod.z.string().optional()
|
|
188
|
+
}).optional(),
|
|
189
|
+
history: import_zod.z.object({
|
|
190
|
+
maxRuns: import_zod.z.number().int().positive().optional()
|
|
191
|
+
}).optional(),
|
|
192
|
+
bugLog: import_zod.z.object({
|
|
193
|
+
enabled: import_zod.z.boolean().optional(),
|
|
194
|
+
backlogPath: import_zod.z.string().min(1).optional(),
|
|
195
|
+
resolvedPath: import_zod.z.string().min(1).optional()
|
|
196
|
+
}).strict().optional(),
|
|
197
|
+
tracing: import_zod.z.object({
|
|
198
|
+
header: import_zod.z.string().min(1).optional()
|
|
199
|
+
}).strict().optional(),
|
|
200
|
+
reliability: import_zod.z.object({
|
|
201
|
+
flakyThreshold: import_zod.z.number().min(0).max(1).optional()
|
|
202
|
+
}).strict().optional()
|
|
203
|
+
}).strict();
|
|
204
|
+
var navigateStepSchema = import_zod.z.object({ navigate: import_zod.z.string().min(1) }).strict();
|
|
205
|
+
var clickStepSchema = import_zod.z.object({
|
|
206
|
+
click: import_zod.z.union([import_zod.z.object({ selector: import_zod.z.string().min(1) }).strict(), import_zod.z.string().min(1)])
|
|
207
|
+
}).strict();
|
|
208
|
+
var singleKeyValueSchema = import_zod.z.record(import_zod.z.string().min(1), import_zod.z.string()).refine((record) => Object.keys(record).length === 1, {
|
|
209
|
+
message: "Expected exactly one key-value pair"
|
|
210
|
+
});
|
|
211
|
+
var fillStepSchema = import_zod.z.object({
|
|
212
|
+
fill: import_zod.z.union([
|
|
213
|
+
import_zod.z.object({ selector: import_zod.z.string().min(1), value: import_zod.z.string() }).strict(),
|
|
214
|
+
singleKeyValueSchema
|
|
215
|
+
])
|
|
216
|
+
}).strict();
|
|
217
|
+
var typeStepSchema = import_zod.z.object({ type: import_zod.z.string() }).strict();
|
|
218
|
+
var pressStepSchema = import_zod.z.object({
|
|
219
|
+
press: import_zod.z.object({ selector: import_zod.z.string().min(1), key: import_zod.z.string().min(1) }).strict()
|
|
220
|
+
}).strict();
|
|
221
|
+
var waitForSelectorStepSchema = import_zod.z.object({
|
|
222
|
+
waitForSelector: import_zod.z.object({ selector: import_zod.z.string().min(1), timeout: import_zod.z.number().optional() }).strict()
|
|
223
|
+
}).strict();
|
|
224
|
+
var waitStepSchema = import_zod.z.object({
|
|
225
|
+
wait: import_zod.z.union([
|
|
226
|
+
import_zod.z.string().min(1),
|
|
227
|
+
import_zod.z.object({ for: import_zod.z.string().min(1), timeout: import_zod.z.number().optional() }).strict()
|
|
228
|
+
])
|
|
229
|
+
}).strict();
|
|
230
|
+
var waitForUrlStepSchema = import_zod.z.object({
|
|
231
|
+
waitForUrl: import_zod.z.object({ value: import_zod.z.string().min(1), timeout: import_zod.z.number().optional() }).strict()
|
|
232
|
+
}).strict();
|
|
233
|
+
var waitForNetworkIdleStepSchema = import_zod.z.object({
|
|
234
|
+
waitForNetworkIdle: import_zod.z.object({ timeout: import_zod.z.number().optional() }).strict()
|
|
235
|
+
}).strict();
|
|
236
|
+
var selectOptionStepSchema = import_zod.z.object({
|
|
237
|
+
selectOption: import_zod.z.object({ selector: import_zod.z.string().min(1), value: import_zod.z.string() }).strict()
|
|
238
|
+
}).strict();
|
|
239
|
+
var selectStepSchema = import_zod.z.object({
|
|
240
|
+
select: singleKeyValueSchema
|
|
241
|
+
}).strict();
|
|
242
|
+
var onDialogStepSchema = import_zod.z.object({
|
|
243
|
+
onDialog: import_zod.z.object({ action: import_zod.z.enum(["accept", "dismiss"]) }).strict()
|
|
244
|
+
}).strict();
|
|
245
|
+
var setInputFilesStepSchema = import_zod.z.object({
|
|
246
|
+
setInputFiles: import_zod.z.object({
|
|
247
|
+
selector: import_zod.z.string().min(1),
|
|
248
|
+
files: import_zod.z.union([import_zod.z.string().min(1), import_zod.z.array(import_zod.z.string().min(1)).min(1)])
|
|
249
|
+
}).strict()
|
|
250
|
+
}).strict();
|
|
251
|
+
var inlineAssertStepSchema = import_zod.z.object({
|
|
252
|
+
assert: import_zod.z.object({
|
|
253
|
+
visible: import_zod.z.string().min(1).optional(),
|
|
254
|
+
notVisible: import_zod.z.string().min(1).optional(),
|
|
255
|
+
urlIncludes: import_zod.z.string().min(1).optional(),
|
|
256
|
+
urlEquals: import_zod.z.string().min(1).optional()
|
|
257
|
+
}).strict().refine(
|
|
258
|
+
(value) => [value.visible, value.notVisible, value.urlIncludes, value.urlEquals].filter(
|
|
259
|
+
(entry) => entry !== void 0
|
|
260
|
+
).length === 1,
|
|
261
|
+
{
|
|
262
|
+
message: "assert requires exactly one of visible, notVisible, urlIncludes, urlEquals"
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
}).strict();
|
|
266
|
+
var runHuntStepSchema = import_zod.z.object({
|
|
267
|
+
runHunt: import_zod.z.union([
|
|
268
|
+
import_zod.z.string().min(1).refine(isValidHuntName, {
|
|
269
|
+
message: "Invalid hunt name. Use only letters, numbers, hyphens, underscores, and forward slashes."
|
|
270
|
+
}),
|
|
271
|
+
import_zod.z.object({
|
|
272
|
+
name: import_zod.z.string().min(1).refine(isValidHuntName, {
|
|
273
|
+
message: "Invalid hunt name. Use only letters, numbers, hyphens, underscores, and forward slashes."
|
|
274
|
+
}),
|
|
275
|
+
vars: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).optional()
|
|
276
|
+
}).strict()
|
|
277
|
+
])
|
|
278
|
+
}).strict();
|
|
279
|
+
var hoverStepSchema = import_zod.z.object({
|
|
280
|
+
hover: import_zod.z.object({ selector: import_zod.z.string().min(1) }).strict()
|
|
281
|
+
}).strict();
|
|
282
|
+
var scrollStepSchema = import_zod.z.object({
|
|
283
|
+
scroll: import_zod.z.object({
|
|
284
|
+
direction: import_zod.z.enum(["up", "down", "left", "right"]),
|
|
285
|
+
amount: import_zod.z.number().optional()
|
|
286
|
+
}).strict()
|
|
287
|
+
}).strict();
|
|
288
|
+
var scrollToStepSchema = import_zod.z.object({
|
|
289
|
+
scrollTo: import_zod.z.object({ selector: import_zod.z.string().min(1) }).strict()
|
|
290
|
+
}).strict();
|
|
291
|
+
var screenshotStepSchema = import_zod.z.object({
|
|
292
|
+
screenshot: import_zod.z.object({ name: import_zod.z.string().optional() }).strict()
|
|
293
|
+
}).strict();
|
|
294
|
+
var ifStepSchema = import_zod.z.object({
|
|
295
|
+
if: import_zod.z.object({
|
|
296
|
+
visible: import_zod.z.string().min(1).optional(),
|
|
297
|
+
notVisible: import_zod.z.string().min(1).optional(),
|
|
298
|
+
then: import_zod.z.lazy(() => import_zod.z.array(stepSchema).min(1)),
|
|
299
|
+
else: import_zod.z.lazy(() => import_zod.z.array(stepSchema).min(1)).optional()
|
|
300
|
+
}).strict().refine(
|
|
301
|
+
(value) => [value.visible, value.notVisible].filter((v) => v !== void 0).length === 1,
|
|
302
|
+
{ message: "if requires exactly one of visible or notVisible" }
|
|
303
|
+
)
|
|
304
|
+
}).strict();
|
|
305
|
+
var repeatStepSchema = import_zod.z.object({
|
|
306
|
+
repeat: import_zod.z.object({
|
|
307
|
+
times: import_zod.z.number().int().positive().optional(),
|
|
308
|
+
while: import_zod.z.object({
|
|
309
|
+
visible: import_zod.z.string().min(1).optional(),
|
|
310
|
+
notVisible: import_zod.z.string().min(1).optional()
|
|
311
|
+
}).strict().refine(
|
|
312
|
+
(value) => [value.visible, value.notVisible].filter((v) => v !== void 0).length === 1,
|
|
313
|
+
{ message: "while requires exactly one of visible or notVisible" }
|
|
314
|
+
).optional(),
|
|
315
|
+
maxIterations: import_zod.z.number().int().positive().optional(),
|
|
316
|
+
steps: import_zod.z.lazy(() => import_zod.z.array(stepSchema).min(1))
|
|
317
|
+
}).strict().refine((value) => !(value.times !== void 0 && value.while !== void 0), {
|
|
318
|
+
message: "repeat requires either times or while, not both"
|
|
319
|
+
}).refine((value) => value.times !== void 0 || value.while !== void 0, {
|
|
320
|
+
message: "repeat requires either times or while"
|
|
321
|
+
}).refine((value) => !(value.while !== void 0 && value.maxIterations === void 0), {
|
|
322
|
+
message: "while requires maxIterations"
|
|
323
|
+
})
|
|
324
|
+
}).strict();
|
|
325
|
+
var mockRouteStepSchema = import_zod.z.object({
|
|
326
|
+
mockRoute: import_zod.z.object({
|
|
327
|
+
url: import_zod.z.string().min(1),
|
|
328
|
+
response: import_zod.z.object({
|
|
329
|
+
status: import_zod.z.number().int(),
|
|
330
|
+
contentType: import_zod.z.string().min(1).optional(),
|
|
331
|
+
body: import_zod.z.string().min(1).optional(),
|
|
332
|
+
file: import_zod.z.string().min(1).optional()
|
|
333
|
+
}).strict().refine(
|
|
334
|
+
(value) => [value.body, value.file].filter((v) => v !== void 0).length === 1,
|
|
335
|
+
{ message: "response requires exactly one of body or file" }
|
|
336
|
+
)
|
|
337
|
+
}).strict()
|
|
338
|
+
}).strict();
|
|
339
|
+
var unmockRouteStepSchema = import_zod.z.object({
|
|
340
|
+
unmockRoute: import_zod.z.union([
|
|
341
|
+
import_zod.z.string().min(1),
|
|
342
|
+
import_zod.z.object({ url: import_zod.z.string().min(1) }).strict()
|
|
343
|
+
])
|
|
344
|
+
}).strict();
|
|
345
|
+
var evalScriptStepSchema = import_zod.z.object({
|
|
346
|
+
evalScript: import_zod.z.union([
|
|
347
|
+
import_zod.z.string().min(1),
|
|
348
|
+
import_zod.z.object({
|
|
349
|
+
expression: import_zod.z.string().min(1),
|
|
350
|
+
as: import_zod.z.string().min(1).optional()
|
|
351
|
+
}).strict()
|
|
352
|
+
])
|
|
353
|
+
}).strict();
|
|
354
|
+
var runScriptStepSchema = import_zod.z.object({
|
|
355
|
+
runScript: import_zod.z.object({ file: import_zod.z.string().min(1) }).strict()
|
|
356
|
+
}).strict();
|
|
357
|
+
var assertScreenshotStepSchema = import_zod.z.object({
|
|
358
|
+
assertScreenshot: import_zod.z.object({
|
|
359
|
+
name: import_zod.z.string().min(1),
|
|
360
|
+
threshold: import_zod.z.number().min(0).max(1).optional()
|
|
361
|
+
}).strict()
|
|
362
|
+
}).strict();
|
|
363
|
+
var copyTextStepSchema = import_zod.z.object({
|
|
364
|
+
copyText: import_zod.z.object({
|
|
365
|
+
selector: import_zod.z.string().min(1),
|
|
366
|
+
as: import_zod.z.string().min(1)
|
|
367
|
+
}).strict()
|
|
368
|
+
}).strict();
|
|
369
|
+
var waitForDownloadStepSchema = import_zod.z.object({
|
|
370
|
+
waitForDownload: import_zod.z.union([
|
|
371
|
+
import_zod.z.object({
|
|
372
|
+
filename: import_zod.z.string().min(1).optional(),
|
|
373
|
+
timeout: import_zod.z.number().int().positive().optional()
|
|
374
|
+
}).strict(),
|
|
375
|
+
import_zod.z.null()
|
|
376
|
+
])
|
|
377
|
+
}).strict();
|
|
378
|
+
var stepSchema = import_zod.z.union([
|
|
379
|
+
navigateStepSchema,
|
|
380
|
+
clickStepSchema,
|
|
381
|
+
fillStepSchema,
|
|
382
|
+
typeStepSchema,
|
|
383
|
+
pressStepSchema,
|
|
384
|
+
waitStepSchema,
|
|
385
|
+
selectOptionStepSchema,
|
|
386
|
+
selectStepSchema,
|
|
387
|
+
onDialogStepSchema,
|
|
388
|
+
setInputFilesStepSchema,
|
|
389
|
+
inlineAssertStepSchema,
|
|
390
|
+
runHuntStepSchema,
|
|
391
|
+
waitForSelectorStepSchema,
|
|
392
|
+
waitForUrlStepSchema,
|
|
393
|
+
waitForNetworkIdleStepSchema,
|
|
394
|
+
hoverStepSchema,
|
|
395
|
+
scrollStepSchema,
|
|
396
|
+
scrollToStepSchema,
|
|
397
|
+
screenshotStepSchema,
|
|
398
|
+
ifStepSchema,
|
|
399
|
+
repeatStepSchema,
|
|
400
|
+
mockRouteStepSchema,
|
|
401
|
+
unmockRouteStepSchema,
|
|
402
|
+
evalScriptStepSchema,
|
|
403
|
+
runScriptStepSchema,
|
|
404
|
+
assertScreenshotStepSchema,
|
|
405
|
+
copyTextStepSchema,
|
|
406
|
+
waitForDownloadStepSchema
|
|
407
|
+
]);
|
|
408
|
+
var assertionSchema = import_zod.z.union([
|
|
409
|
+
import_zod.z.object({ selectorExists: import_zod.z.string().min(1) }).strict(),
|
|
410
|
+
import_zod.z.object({ selectorNotExists: import_zod.z.string().min(1) }).strict(),
|
|
411
|
+
import_zod.z.object({ urlIncludes: import_zod.z.string().min(1) }).strict(),
|
|
412
|
+
import_zod.z.object({ urlEquals: import_zod.z.string().min(1) }).strict(),
|
|
413
|
+
import_zod.z.object({ noConsoleErrors: import_zod.z.boolean() }).strict(),
|
|
414
|
+
import_zod.z.object({ noNetworkErrors: import_zod.z.boolean() }).strict()
|
|
415
|
+
]);
|
|
416
|
+
var huntSchema = import_zod.z.object({
|
|
417
|
+
name: import_zod.z.string().optional(),
|
|
418
|
+
description: import_zod.z.string().optional(),
|
|
419
|
+
tags: import_zod.z.array(import_zod.z.string().min(1)).optional(),
|
|
420
|
+
vars: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).optional(),
|
|
421
|
+
steps: import_zod.z.array(stepSchema),
|
|
422
|
+
assertions: import_zod.z.array(assertionSchema).optional(),
|
|
423
|
+
retry: import_zod.z.object({
|
|
424
|
+
maxRetries: import_zod.z.number().int().min(0),
|
|
425
|
+
delay: import_zod.z.number().int().min(0).optional()
|
|
426
|
+
}).strict().optional()
|
|
427
|
+
}).strict();
|
|
428
|
+
|
|
429
|
+
// src/config/loader.ts
|
|
430
|
+
var DEFAULT_CONFIG = {
|
|
431
|
+
target: {
|
|
432
|
+
url: "http://localhost:3000"
|
|
433
|
+
},
|
|
434
|
+
browser: {
|
|
435
|
+
headless: true,
|
|
436
|
+
slowMo: 0,
|
|
437
|
+
timeout: 3e4,
|
|
438
|
+
engine: "chromium",
|
|
439
|
+
viewport: { width: 1280, height: 720 }
|
|
440
|
+
},
|
|
441
|
+
artifacts: {
|
|
442
|
+
screenshots: "on-failure",
|
|
443
|
+
networkHar: false,
|
|
444
|
+
console: true,
|
|
445
|
+
junit: false
|
|
446
|
+
},
|
|
447
|
+
assertions: {
|
|
448
|
+
noConsoleErrors: true,
|
|
449
|
+
noNetworkErrors: true,
|
|
450
|
+
maxTotalTimeMs: 3e4,
|
|
451
|
+
networkIgnorePatterns: []
|
|
452
|
+
},
|
|
453
|
+
guardrails: {
|
|
454
|
+
maxSteps: 50,
|
|
455
|
+
allowedDomains: ["localhost", "127.0.0.1", "0.0.0.0"],
|
|
456
|
+
forbiddenSelectors: ["[data-danger]", ".delete-btn"],
|
|
457
|
+
selfHealing: false
|
|
458
|
+
},
|
|
459
|
+
auth: {
|
|
460
|
+
storageStatePath: ".prowl/auth-state.json"
|
|
461
|
+
},
|
|
462
|
+
history: {
|
|
463
|
+
maxRuns: 100
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
var CONFIG_DIR = ".prowl";
|
|
467
|
+
var LEGACY_CONFIG_DIR = ".prowlqa";
|
|
468
|
+
var legacyDirWarned = false;
|
|
469
|
+
function warnLegacyConfigDir() {
|
|
470
|
+
if (legacyDirWarned) {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
legacyDirWarned = true;
|
|
474
|
+
console.warn(
|
|
475
|
+
'Warning: the ".prowlqa/" config directory is deprecated; rename it to ".prowl/". Support for ".prowlqa/" will be removed in a future release.'
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
function findConfigPath(startDir) {
|
|
479
|
+
let current = startDir;
|
|
480
|
+
while (current) {
|
|
481
|
+
for (const dir of [CONFIG_DIR, LEGACY_CONFIG_DIR]) {
|
|
482
|
+
const candidate = import_node_path.default.join(current, dir, "config.yml");
|
|
483
|
+
if (import_node_fs.default.existsSync(candidate)) {
|
|
484
|
+
return candidate;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const parent = import_node_path.default.dirname(current);
|
|
488
|
+
if (parent === current) {
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
current = parent;
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
var VIEWPORT_PRESETS = {
|
|
496
|
+
mobile: { width: 375, height: 812 },
|
|
497
|
+
tablet: { width: 768, height: 1024 },
|
|
498
|
+
desktop: { width: 1280, height: 720 }
|
|
499
|
+
};
|
|
500
|
+
function resolveViewport(value) {
|
|
501
|
+
if (value === void 0) {
|
|
502
|
+
return DEFAULT_CONFIG.browser.viewport;
|
|
503
|
+
}
|
|
504
|
+
if (typeof value === "string") {
|
|
505
|
+
const preset = VIEWPORT_PRESETS[value];
|
|
506
|
+
if (!preset) {
|
|
507
|
+
throw new Error(`Unknown viewport preset: "${value}". Use mobile, tablet, or desktop.`);
|
|
508
|
+
}
|
|
509
|
+
return preset;
|
|
510
|
+
}
|
|
511
|
+
return value;
|
|
512
|
+
}
|
|
513
|
+
function mergeConfig(partial) {
|
|
514
|
+
return {
|
|
515
|
+
target: {
|
|
516
|
+
url: partial.target?.url ?? DEFAULT_CONFIG.target.url
|
|
517
|
+
},
|
|
518
|
+
browser: {
|
|
519
|
+
headless: partial.browser?.headless ?? DEFAULT_CONFIG.browser.headless,
|
|
520
|
+
slowMo: partial.browser?.slowMo ?? DEFAULT_CONFIG.browser.slowMo,
|
|
521
|
+
timeout: partial.browser?.timeout ?? DEFAULT_CONFIG.browser.timeout,
|
|
522
|
+
engine: partial.browser?.engine ?? DEFAULT_CONFIG.browser.engine,
|
|
523
|
+
channel: partial.browser?.channel,
|
|
524
|
+
viewport: resolveViewport(partial.browser?.viewport)
|
|
525
|
+
},
|
|
526
|
+
artifacts: {
|
|
527
|
+
screenshots: partial.artifacts?.screenshots ?? DEFAULT_CONFIG.artifacts.screenshots,
|
|
528
|
+
networkHar: partial.artifacts?.networkHar ?? DEFAULT_CONFIG.artifacts.networkHar,
|
|
529
|
+
console: partial.artifacts?.console ?? DEFAULT_CONFIG.artifacts.console,
|
|
530
|
+
junit: partial.artifacts?.junit ?? DEFAULT_CONFIG.artifacts.junit
|
|
531
|
+
},
|
|
532
|
+
assertions: {
|
|
533
|
+
noConsoleErrors: partial.assertions?.noConsoleErrors ?? DEFAULT_CONFIG.assertions.noConsoleErrors,
|
|
534
|
+
noNetworkErrors: partial.assertions?.noNetworkErrors ?? DEFAULT_CONFIG.assertions.noNetworkErrors,
|
|
535
|
+
maxTotalTimeMs: partial.assertions?.maxTotalTimeMs ?? DEFAULT_CONFIG.assertions.maxTotalTimeMs,
|
|
536
|
+
networkIgnorePatterns: partial.assertions?.networkIgnorePatterns ?? DEFAULT_CONFIG.assertions.networkIgnorePatterns
|
|
537
|
+
},
|
|
538
|
+
guardrails: {
|
|
539
|
+
maxSteps: partial.guardrails?.maxSteps ?? DEFAULT_CONFIG.guardrails.maxSteps,
|
|
540
|
+
allowedDomains: partial.guardrails?.allowedDomains ?? DEFAULT_CONFIG.guardrails.allowedDomains,
|
|
541
|
+
forbiddenSelectors: partial.guardrails?.forbiddenSelectors ?? DEFAULT_CONFIG.guardrails.forbiddenSelectors,
|
|
542
|
+
selfHealing: partial.guardrails?.selfHealing ?? DEFAULT_CONFIG.guardrails.selfHealing
|
|
543
|
+
},
|
|
544
|
+
auth: {
|
|
545
|
+
storageStatePath: partial.auth?.storageStatePath ?? (partial.auth !== void 0 ? DEFAULT_CONFIG.auth.storageStatePath : void 0)
|
|
546
|
+
},
|
|
547
|
+
history: {
|
|
548
|
+
maxRuns: partial.history?.maxRuns ?? DEFAULT_CONFIG.history.maxRuns
|
|
549
|
+
},
|
|
550
|
+
bugLog: partial.bugLog,
|
|
551
|
+
tracing: partial.tracing,
|
|
552
|
+
reliability: partial.reliability
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
function ensureAllowedDomain(allowed, urlValue) {
|
|
556
|
+
try {
|
|
557
|
+
const host = new URL(urlValue).hostname;
|
|
558
|
+
if (!allowed.includes(host)) {
|
|
559
|
+
return [...allowed, host];
|
|
560
|
+
}
|
|
561
|
+
} catch {
|
|
562
|
+
return allowed;
|
|
563
|
+
}
|
|
564
|
+
return allowed;
|
|
565
|
+
}
|
|
566
|
+
function loadConfig(configPath) {
|
|
567
|
+
const resolvedPath = configPath ? import_node_path.default.resolve(configPath) : findConfigPath(process.cwd());
|
|
568
|
+
if (!resolvedPath) {
|
|
569
|
+
throw new Error("Could not find .prowl/config.yml. Run `prowl init` first.");
|
|
570
|
+
}
|
|
571
|
+
if (!import_node_fs.default.existsSync(resolvedPath)) {
|
|
572
|
+
throw new Error(`Config file not found at ${resolvedPath}`);
|
|
573
|
+
}
|
|
574
|
+
const configDir = import_node_path.default.dirname(resolvedPath);
|
|
575
|
+
if (import_node_path.default.basename(configDir) === LEGACY_CONFIG_DIR) {
|
|
576
|
+
warnLegacyConfigDir();
|
|
577
|
+
}
|
|
578
|
+
import_dotenv.default.config({ path: import_node_path.default.join(configDir, ".env"), override: false });
|
|
579
|
+
const raw = import_node_fs.default.readFileSync(resolvedPath, "utf-8");
|
|
580
|
+
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
581
|
+
const validated = configSchema.parse(parsed);
|
|
582
|
+
const config = mergeConfig(validated);
|
|
583
|
+
config.guardrails.allowedDomains = ensureAllowedDomain(
|
|
584
|
+
config.guardrails.allowedDomains,
|
|
585
|
+
config.target.url
|
|
586
|
+
);
|
|
587
|
+
return { config, configPath: resolvedPath, configDir };
|
|
588
|
+
}
|
|
589
|
+
function loadHunt(huntName, configDir) {
|
|
590
|
+
assertValidHuntName(huntName);
|
|
591
|
+
const huntPath = import_node_path.default.join(configDir, "hunts", `${huntName}.yml`);
|
|
592
|
+
if (!import_node_fs.default.existsSync(huntPath)) {
|
|
593
|
+
throw new Error(`Hunt file not found: ${huntPath}`);
|
|
594
|
+
}
|
|
595
|
+
const raw = import_node_fs.default.readFileSync(huntPath, "utf-8");
|
|
596
|
+
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
597
|
+
const validated = huntSchema.parse(parsed);
|
|
598
|
+
return validated;
|
|
599
|
+
}
|
|
600
|
+
function loadHuntTags(huntName, configDir) {
|
|
601
|
+
assertValidHuntName(huntName);
|
|
602
|
+
const huntPath = import_node_path.default.join(configDir, "hunts", `${huntName}.yml`);
|
|
603
|
+
if (!import_node_fs.default.existsSync(huntPath)) {
|
|
604
|
+
return [];
|
|
605
|
+
}
|
|
606
|
+
const raw = import_node_fs.default.readFileSync(huntPath, "utf-8");
|
|
607
|
+
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
608
|
+
return Array.isArray(parsed.tags) ? parsed.tags : [];
|
|
609
|
+
}
|
|
610
|
+
function loadHuntMeta(huntName, configDir) {
|
|
611
|
+
assertValidHuntName(huntName);
|
|
612
|
+
const huntPath = import_node_path.default.join(configDir, "hunts", `${huntName}.yml`);
|
|
613
|
+
if (!import_node_fs.default.existsSync(huntPath)) {
|
|
614
|
+
return { tags: [] };
|
|
615
|
+
}
|
|
616
|
+
const raw = import_node_fs.default.readFileSync(huntPath, "utf-8");
|
|
617
|
+
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
618
|
+
return {
|
|
619
|
+
description: typeof parsed.description === "string" ? parsed.description : void 0,
|
|
620
|
+
tags: Array.isArray(parsed.tags) ? parsed.tags : []
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
function listHunts(configDir) {
|
|
624
|
+
const huntsDir = import_node_path.default.join(configDir, "hunts");
|
|
625
|
+
if (!import_node_fs.default.existsSync(huntsDir)) {
|
|
626
|
+
return [];
|
|
627
|
+
}
|
|
628
|
+
const stats = import_node_fs.default.statSync(huntsDir);
|
|
629
|
+
if (!stats.isDirectory()) {
|
|
630
|
+
throw new Error(`Hunts path is not a directory: ${huntsDir}`);
|
|
631
|
+
}
|
|
632
|
+
const results = [];
|
|
633
|
+
function scanDir(dir) {
|
|
634
|
+
const entries = import_node_fs.default.readdirSync(dir, { withFileTypes: true });
|
|
635
|
+
for (const entry of entries) {
|
|
636
|
+
if (entry.isFile() && entry.name.endsWith(".yml")) {
|
|
637
|
+
const fullPath = import_node_path.default.join(dir, entry.name);
|
|
638
|
+
const relative = import_node_path.default.relative(huntsDir, fullPath);
|
|
639
|
+
results.push(relative.replace(/\.yml$/, ""));
|
|
640
|
+
} else if (entry.isDirectory()) {
|
|
641
|
+
scanDir(import_node_path.default.join(dir, entry.name));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
scanDir(huntsDir);
|
|
646
|
+
return results.sort((a, b) => a.localeCompare(b));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// src/config/interpolate.ts
|
|
650
|
+
var import_node_crypto = __toESM(require("crypto"), 1);
|
|
651
|
+
var VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
|
|
652
|
+
function collectInterpolatedValues(input, vars, values) {
|
|
653
|
+
if (typeof input === "string") {
|
|
654
|
+
for (const match of input.matchAll(VAR_PATTERN)) {
|
|
655
|
+
const varValue = vars[match[1]];
|
|
656
|
+
if (varValue) values.add(varValue);
|
|
657
|
+
}
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (Array.isArray(input)) {
|
|
661
|
+
for (const item of input) {
|
|
662
|
+
collectInterpolatedValues(item, vars, values);
|
|
663
|
+
}
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (input && typeof input === "object") {
|
|
667
|
+
for (const [key, value] of Object.entries(input)) {
|
|
668
|
+
collectInterpolatedValues(key, vars, values);
|
|
669
|
+
collectInterpolatedValues(value, vars, values);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function interpolateString(input, vars) {
|
|
674
|
+
const usedVars = [];
|
|
675
|
+
const value = input.replace(VAR_PATTERN, (_, name) => {
|
|
676
|
+
const varValue = vars[name];
|
|
677
|
+
if (varValue === void 0) {
|
|
678
|
+
throw new Error(`Missing variable: ${name}`);
|
|
679
|
+
}
|
|
680
|
+
usedVars.push(name);
|
|
681
|
+
return varValue;
|
|
682
|
+
});
|
|
683
|
+
return { value, usedVars };
|
|
684
|
+
}
|
|
685
|
+
var RANDOM_FIRST_NAMES = ["Alex", "Jordan", "Morgan", "Taylor", "Casey", "Riley", "Quinn", "Avery"];
|
|
686
|
+
var RANDOM_LAST_NAMES = ["Smith", "Johnson", "Brown", "Davis", "Wilson", "Clark", "Hall", "Young"];
|
|
687
|
+
function generateRandomVars(randomSource) {
|
|
688
|
+
const random = randomSource?.random ?? Math.random;
|
|
689
|
+
const randomBytes = randomSource?.randomBytes ?? import_node_crypto.default.randomBytes;
|
|
690
|
+
const randomUUID = randomSource?.randomUUID ?? import_node_crypto.default.randomUUID;
|
|
691
|
+
const hex = randomBytes(4).toString("hex");
|
|
692
|
+
const firstIndex = Math.floor(random() * RANDOM_FIRST_NAMES.length);
|
|
693
|
+
const lastIndex = Math.floor(random() * RANDOM_LAST_NAMES.length);
|
|
694
|
+
const num = Math.floor(random() * 9e3) + 1e3;
|
|
695
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
696
|
+
let text = "";
|
|
697
|
+
for (let i = 0; i < 8; i++) {
|
|
698
|
+
text += chars[Math.floor(random() * chars.length)];
|
|
699
|
+
}
|
|
700
|
+
return {
|
|
701
|
+
RANDOM_EMAIL: `prowl_${hex}@test.com`,
|
|
702
|
+
RANDOM_NAME: `${RANDOM_FIRST_NAMES[firstIndex]} ${RANDOM_LAST_NAMES[lastIndex]}`,
|
|
703
|
+
RANDOM_NUMBER: String(num),
|
|
704
|
+
RANDOM_UUID: randomUUID(),
|
|
705
|
+
RANDOM_TEXT: text
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
function interpolateStep(step, vars, stepPath2, redacted) {
|
|
709
|
+
const isExplicitFill = (value) => typeof value.selector === "string" && typeof value.value === "string";
|
|
710
|
+
const interpolateSinglePair = (record) => {
|
|
711
|
+
const entries = Object.entries(record);
|
|
712
|
+
if (entries.length !== 1) {
|
|
713
|
+
throw new Error("Shorthand step expects exactly one key-value pair");
|
|
714
|
+
}
|
|
715
|
+
const [key, value] = entries[0];
|
|
716
|
+
return {
|
|
717
|
+
[interpolateString(key, vars).value]: interpolateString(value, vars).value
|
|
718
|
+
};
|
|
719
|
+
};
|
|
720
|
+
if ("navigate" in step) {
|
|
721
|
+
const result = interpolateString(step.navigate, vars);
|
|
722
|
+
return { navigate: result.value };
|
|
723
|
+
}
|
|
724
|
+
if ("click" in step) {
|
|
725
|
+
if (typeof step.click === "string") {
|
|
726
|
+
return { click: interpolateString(step.click, vars).value };
|
|
727
|
+
}
|
|
728
|
+
const result = interpolateString(step.click.selector, vars);
|
|
729
|
+
return { click: { selector: result.value } };
|
|
730
|
+
}
|
|
731
|
+
if ("fill" in step) {
|
|
732
|
+
if (isExplicitFill(step.fill)) {
|
|
733
|
+
const selectorResult = interpolateString(step.fill.selector, vars);
|
|
734
|
+
const valueResult2 = interpolateString(step.fill.value, vars);
|
|
735
|
+
if (valueResult2.usedVars.length > 0) {
|
|
736
|
+
redacted.add(stepPath2);
|
|
737
|
+
}
|
|
738
|
+
return { fill: { selector: selectorResult.value, value: valueResult2.value } };
|
|
739
|
+
}
|
|
740
|
+
const [rawLabel, rawValue] = Object.entries(step.fill)[0] ?? [];
|
|
741
|
+
if (rawLabel === void 0 || rawValue === void 0) {
|
|
742
|
+
throw new Error("Shorthand fill expects exactly one key-value pair");
|
|
743
|
+
}
|
|
744
|
+
const labelResult = interpolateString(rawLabel, vars);
|
|
745
|
+
const valueResult = interpolateString(rawValue, vars);
|
|
746
|
+
if (valueResult.usedVars.length > 0) {
|
|
747
|
+
redacted.add(stepPath2);
|
|
748
|
+
}
|
|
749
|
+
return {
|
|
750
|
+
fill: {
|
|
751
|
+
[labelResult.value]: valueResult.value
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
if ("type" in step) {
|
|
756
|
+
const valueResult = interpolateString(step.type, vars);
|
|
757
|
+
if (valueResult.usedVars.length > 0) {
|
|
758
|
+
redacted.add(stepPath2);
|
|
759
|
+
}
|
|
760
|
+
return { type: valueResult.value };
|
|
761
|
+
}
|
|
762
|
+
if ("selectOption" in step) {
|
|
763
|
+
const selectorResult = interpolateString(step.selectOption.selector, vars);
|
|
764
|
+
const valueResult = interpolateString(step.selectOption.value, vars);
|
|
765
|
+
return { selectOption: { selector: selectorResult.value, value: valueResult.value } };
|
|
766
|
+
}
|
|
767
|
+
if ("select" in step) {
|
|
768
|
+
return { select: interpolateSinglePair(step.select) };
|
|
769
|
+
}
|
|
770
|
+
if ("press" in step) {
|
|
771
|
+
const selectorResult = interpolateString(step.press.selector, vars);
|
|
772
|
+
const keyResult = interpolateString(step.press.key, vars);
|
|
773
|
+
return { press: { selector: selectorResult.value, key: keyResult.value } };
|
|
774
|
+
}
|
|
775
|
+
if ("onDialog" in step) {
|
|
776
|
+
return { onDialog: { action: step.onDialog.action } };
|
|
777
|
+
}
|
|
778
|
+
if ("setInputFiles" in step) {
|
|
779
|
+
const selectorResult = interpolateString(step.setInputFiles.selector, vars);
|
|
780
|
+
const rawFiles = step.setInputFiles.files;
|
|
781
|
+
const files = Array.isArray(rawFiles) ? rawFiles.map((f) => interpolateString(f, vars).value) : interpolateString(rawFiles, vars).value;
|
|
782
|
+
return { setInputFiles: { selector: selectorResult.value, files } };
|
|
783
|
+
}
|
|
784
|
+
if ("runHunt" in step) {
|
|
785
|
+
if (typeof step.runHunt === "string") {
|
|
786
|
+
return { runHunt: interpolateString(step.runHunt, vars).value };
|
|
787
|
+
}
|
|
788
|
+
const nameResult = interpolateString(step.runHunt.name, vars);
|
|
789
|
+
const interpolatedVars = {};
|
|
790
|
+
for (const [key, value] of Object.entries(step.runHunt.vars ?? {})) {
|
|
791
|
+
interpolatedVars[key] = interpolateString(value, vars).value;
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
runHunt: {
|
|
795
|
+
name: nameResult.value,
|
|
796
|
+
...Object.keys(interpolatedVars).length > 0 ? { vars: interpolatedVars } : {}
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
if ("assert" in step) {
|
|
801
|
+
if (step.assert.visible !== void 0) {
|
|
802
|
+
return { assert: { visible: interpolateString(step.assert.visible, vars).value } };
|
|
803
|
+
}
|
|
804
|
+
if (step.assert.notVisible !== void 0) {
|
|
805
|
+
return { assert: { notVisible: interpolateString(step.assert.notVisible, vars).value } };
|
|
806
|
+
}
|
|
807
|
+
if (step.assert.urlIncludes !== void 0) {
|
|
808
|
+
return { assert: { urlIncludes: interpolateString(step.assert.urlIncludes, vars).value } };
|
|
809
|
+
}
|
|
810
|
+
if (step.assert.urlEquals !== void 0) {
|
|
811
|
+
return { assert: { urlEquals: interpolateString(step.assert.urlEquals, vars).value } };
|
|
812
|
+
}
|
|
813
|
+
return step;
|
|
814
|
+
}
|
|
815
|
+
if ("wait" in step) {
|
|
816
|
+
if (typeof step.wait === "string") {
|
|
817
|
+
return { wait: interpolateString(step.wait, vars).value };
|
|
818
|
+
}
|
|
819
|
+
return {
|
|
820
|
+
wait: {
|
|
821
|
+
for: interpolateString(step.wait.for, vars).value,
|
|
822
|
+
timeout: step.wait.timeout
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
if ("waitForSelector" in step) {
|
|
827
|
+
const selectorResult = interpolateString(step.waitForSelector.selector, vars);
|
|
828
|
+
return {
|
|
829
|
+
waitForSelector: {
|
|
830
|
+
selector: selectorResult.value,
|
|
831
|
+
timeout: step.waitForSelector.timeout
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
if ("waitForUrl" in step) {
|
|
836
|
+
const valueResult = interpolateString(step.waitForUrl.value, vars);
|
|
837
|
+
return {
|
|
838
|
+
waitForUrl: {
|
|
839
|
+
value: valueResult.value,
|
|
840
|
+
timeout: step.waitForUrl.timeout
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
if ("waitForNetworkIdle" in step) {
|
|
845
|
+
return { waitForNetworkIdle: { timeout: step.waitForNetworkIdle.timeout } };
|
|
846
|
+
}
|
|
847
|
+
if ("hover" in step) {
|
|
848
|
+
const selectorResult = interpolateString(step.hover.selector, vars);
|
|
849
|
+
return { hover: { selector: selectorResult.value } };
|
|
850
|
+
}
|
|
851
|
+
if ("scroll" in step) {
|
|
852
|
+
return { scroll: { direction: step.scroll.direction, amount: step.scroll.amount } };
|
|
853
|
+
}
|
|
854
|
+
if ("scrollTo" in step) {
|
|
855
|
+
const selectorResult = interpolateString(step.scrollTo.selector, vars);
|
|
856
|
+
return { scrollTo: { selector: selectorResult.value } };
|
|
857
|
+
}
|
|
858
|
+
if ("screenshot" in step) {
|
|
859
|
+
return { screenshot: { name: step.screenshot.name } };
|
|
860
|
+
}
|
|
861
|
+
if ("if" in step) {
|
|
862
|
+
const condition = step.if;
|
|
863
|
+
const thenSteps = condition.then.map(
|
|
864
|
+
(s, i) => interpolateStep(s, vars, `${stepPath2}.if.then.${i}`, redacted)
|
|
865
|
+
);
|
|
866
|
+
const elseSteps = condition.else?.map(
|
|
867
|
+
(s, i) => interpolateStep(s, vars, `${stepPath2}.if.else.${i}`, redacted)
|
|
868
|
+
);
|
|
869
|
+
return {
|
|
870
|
+
if: {
|
|
871
|
+
...condition.visible !== void 0 ? { visible: interpolateString(condition.visible, vars).value } : {},
|
|
872
|
+
...condition.notVisible !== void 0 ? { notVisible: interpolateString(condition.notVisible, vars).value } : {},
|
|
873
|
+
then: thenSteps,
|
|
874
|
+
...elseSteps !== void 0 ? { else: elseSteps } : {}
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
if ("repeat" in step) {
|
|
879
|
+
const repeat = step.repeat;
|
|
880
|
+
const subSteps = repeat.steps.map(
|
|
881
|
+
(s, i) => interpolateStep(s, vars, `${stepPath2}.repeat.steps.${i}`, redacted)
|
|
882
|
+
);
|
|
883
|
+
return {
|
|
884
|
+
repeat: {
|
|
885
|
+
...repeat.times !== void 0 ? { times: repeat.times } : {},
|
|
886
|
+
...repeat.while !== void 0 ? {
|
|
887
|
+
while: {
|
|
888
|
+
...repeat.while.visible !== void 0 ? { visible: interpolateString(repeat.while.visible, vars).value } : {},
|
|
889
|
+
...repeat.while.notVisible !== void 0 ? { notVisible: interpolateString(repeat.while.notVisible, vars).value } : {}
|
|
890
|
+
}
|
|
891
|
+
} : {},
|
|
892
|
+
...repeat.maxIterations !== void 0 ? { maxIterations: repeat.maxIterations } : {},
|
|
893
|
+
steps: subSteps
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
if ("mockRoute" in step) {
|
|
898
|
+
const mock = step.mockRoute;
|
|
899
|
+
return {
|
|
900
|
+
mockRoute: {
|
|
901
|
+
url: interpolateString(mock.url, vars).value,
|
|
902
|
+
response: {
|
|
903
|
+
status: mock.response.status,
|
|
904
|
+
...mock.response.contentType !== void 0 ? { contentType: interpolateString(mock.response.contentType, vars).value } : {},
|
|
905
|
+
...mock.response.body !== void 0 ? { body: interpolateString(mock.response.body, vars).value } : {},
|
|
906
|
+
...mock.response.file !== void 0 ? { file: interpolateString(mock.response.file, vars).value } : {}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
if ("unmockRoute" in step) {
|
|
912
|
+
if (typeof step.unmockRoute === "string") {
|
|
913
|
+
return { unmockRoute: interpolateString(step.unmockRoute, vars).value };
|
|
914
|
+
}
|
|
915
|
+
return {
|
|
916
|
+
unmockRoute: { url: interpolateString(step.unmockRoute.url, vars).value }
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
if ("evalScript" in step) {
|
|
920
|
+
if (typeof step.evalScript === "string") {
|
|
921
|
+
return { evalScript: interpolateString(step.evalScript, vars).value };
|
|
922
|
+
}
|
|
923
|
+
return {
|
|
924
|
+
evalScript: {
|
|
925
|
+
expression: interpolateString(step.evalScript.expression, vars).value,
|
|
926
|
+
...step.evalScript.as !== void 0 ? { as: step.evalScript.as } : {}
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
if ("runScript" in step) {
|
|
931
|
+
return {
|
|
932
|
+
runScript: { file: interpolateString(step.runScript.file, vars).value }
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
if ("assertScreenshot" in step) {
|
|
936
|
+
return {
|
|
937
|
+
assertScreenshot: {
|
|
938
|
+
name: interpolateString(step.assertScreenshot.name, vars).value,
|
|
939
|
+
...step.assertScreenshot.threshold !== void 0 ? { threshold: step.assertScreenshot.threshold } : {}
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
if ("copyText" in step) {
|
|
944
|
+
return {
|
|
945
|
+
copyText: {
|
|
946
|
+
selector: interpolateString(step.copyText.selector, vars).value,
|
|
947
|
+
as: step.copyText.as
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
if ("waitForDownload" in step) {
|
|
952
|
+
if (step.waitForDownload === null) {
|
|
953
|
+
return { waitForDownload: null };
|
|
954
|
+
}
|
|
955
|
+
return {
|
|
956
|
+
waitForDownload: {
|
|
957
|
+
...step.waitForDownload.filename !== void 0 ? { filename: interpolateString(step.waitForDownload.filename, vars).value } : {},
|
|
958
|
+
...step.waitForDownload.timeout !== void 0 ? { timeout: step.waitForDownload.timeout } : {}
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
return step;
|
|
963
|
+
}
|
|
964
|
+
function interpolateAssertion(assertion, vars) {
|
|
965
|
+
if ("selectorExists" in assertion) {
|
|
966
|
+
return { selectorExists: interpolateString(assertion.selectorExists, vars).value };
|
|
967
|
+
}
|
|
968
|
+
if ("selectorNotExists" in assertion) {
|
|
969
|
+
return { selectorNotExists: interpolateString(assertion.selectorNotExists, vars).value };
|
|
970
|
+
}
|
|
971
|
+
if ("urlIncludes" in assertion) {
|
|
972
|
+
return { urlIncludes: interpolateString(assertion.urlIncludes, vars).value };
|
|
973
|
+
}
|
|
974
|
+
if ("urlEquals" in assertion) {
|
|
975
|
+
return { urlEquals: interpolateString(assertion.urlEquals, vars).value };
|
|
976
|
+
}
|
|
977
|
+
if ("noConsoleErrors" in assertion) {
|
|
978
|
+
return { noConsoleErrors: assertion.noConsoleErrors };
|
|
979
|
+
}
|
|
980
|
+
if ("noNetworkErrors" in assertion) {
|
|
981
|
+
return { noNetworkErrors: assertion.noNetworkErrors };
|
|
982
|
+
}
|
|
983
|
+
return assertion;
|
|
984
|
+
}
|
|
985
|
+
function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
|
|
986
|
+
const redactedFillSteps = /* @__PURE__ */ new Set();
|
|
987
|
+
const envVars = Object.fromEntries(
|
|
988
|
+
Object.entries(env).filter(([, value]) => value !== void 0)
|
|
989
|
+
);
|
|
990
|
+
const baseVars = { ...randomVars, ...envVars };
|
|
991
|
+
const resolvedHuntVars = {};
|
|
992
|
+
for (const [key, value] of Object.entries(hunt.vars ?? {})) {
|
|
993
|
+
resolvedHuntVars[key] = interpolateString(value, baseVars).value;
|
|
994
|
+
}
|
|
995
|
+
const vars = { ...baseVars, ...resolvedHuntVars };
|
|
996
|
+
const redactionValues = /* @__PURE__ */ new Set();
|
|
997
|
+
collectInterpolatedValues(hunt.steps, vars, redactionValues);
|
|
998
|
+
collectInterpolatedValues(hunt.assertions, vars, redactionValues);
|
|
999
|
+
const steps = hunt.steps.map(
|
|
1000
|
+
(step, index) => interpolateStep(step, vars, `${index}`, redactedFillSteps)
|
|
1001
|
+
);
|
|
1002
|
+
const assertions = hunt.assertions?.map((assertion) => interpolateAssertion(assertion, vars));
|
|
1003
|
+
return {
|
|
1004
|
+
hunt: {
|
|
1005
|
+
...hunt,
|
|
1006
|
+
steps,
|
|
1007
|
+
assertions
|
|
1008
|
+
},
|
|
1009
|
+
redactedFillSteps,
|
|
1010
|
+
randomVars,
|
|
1011
|
+
redactionValues: [...redactionValues]
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// src/browser/controller.ts
|
|
1016
|
+
var import_node_fs2 = __toESM(require("fs"), 1);
|
|
1017
|
+
var import_node_path2 = __toESM(require("path"), 1);
|
|
1018
|
+
var import_playwright = require("playwright");
|
|
1019
|
+
var ENGINES = { chromium: import_playwright.chromium, firefox: import_playwright.firefox, webkit: import_playwright.webkit };
|
|
1020
|
+
async function launchBrowser(options) {
|
|
1021
|
+
const engine = ENGINES[options.engine ?? "chromium"];
|
|
1022
|
+
const browser = await engine.launch({
|
|
1023
|
+
headless: options.headless,
|
|
1024
|
+
slowMo: options.slowMo,
|
|
1025
|
+
channel: options.channel
|
|
1026
|
+
});
|
|
1027
|
+
const contextOptions = {};
|
|
1028
|
+
if (options.viewport) {
|
|
1029
|
+
contextOptions.viewport = options.viewport;
|
|
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.`);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
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
|
+
}
|
|
1052
|
+
async function closeBrowser(session) {
|
|
1053
|
+
if (session.tracePath) {
|
|
1054
|
+
await session.context.tracing.stop({ path: session.tracePath });
|
|
1055
|
+
}
|
|
1056
|
+
await session.context.close();
|
|
1057
|
+
await session.browser.close();
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// src/runner/steps.ts
|
|
1061
|
+
var import_node_fs4 = __toESM(require("fs"), 1);
|
|
1062
|
+
var import_node_path4 = __toESM(require("path"), 1);
|
|
1063
|
+
|
|
1064
|
+
// src/browser/actions.ts
|
|
1065
|
+
async function clickElement(page, selector) {
|
|
1066
|
+
await page.locator(selector).click();
|
|
1067
|
+
}
|
|
1068
|
+
async function fillElement(page, selector, value) {
|
|
1069
|
+
await page.locator(selector).fill(value);
|
|
1070
|
+
}
|
|
1071
|
+
async function pressKey(page, selector, key) {
|
|
1072
|
+
await page.locator(selector).press(key);
|
|
1073
|
+
}
|
|
1074
|
+
async function selectOption(page, selector, value) {
|
|
1075
|
+
await page.locator(selector).selectOption(value);
|
|
1076
|
+
}
|
|
1077
|
+
function setupDialogHandler(page, action) {
|
|
1078
|
+
page.once("dialog", async (dialog) => {
|
|
1079
|
+
if (action === "accept") {
|
|
1080
|
+
await dialog.accept();
|
|
1081
|
+
} else {
|
|
1082
|
+
await dialog.dismiss();
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
async function setInputFiles(page, selector, files) {
|
|
1087
|
+
await page.locator(selector).setInputFiles(files);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// src/runner/healing.ts
|
|
1091
|
+
var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
|
|
1092
|
+
function extractSelectorIntent(selector) {
|
|
1093
|
+
const raw = [];
|
|
1094
|
+
for (const match of selector.matchAll(/[#.]([A-Za-z_][\w-]*)/g)) {
|
|
1095
|
+
raw.push(match[1]);
|
|
1096
|
+
}
|
|
1097
|
+
for (const match of selector.matchAll(/\[[A-Za-z_:-]+\s*[~|^$*]?=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\]/g)) {
|
|
1098
|
+
const value = match[1] ?? match[2] ?? match[3];
|
|
1099
|
+
if (value) raw.push(value);
|
|
1100
|
+
}
|
|
1101
|
+
const words = [];
|
|
1102
|
+
for (const token of raw) {
|
|
1103
|
+
for (const part of splitToken(token)) {
|
|
1104
|
+
const lower = part.toLowerCase();
|
|
1105
|
+
if (lower.length > 0 && !words.includes(lower)) {
|
|
1106
|
+
words.push(lower);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return { words, label: words.join(" ") };
|
|
1111
|
+
}
|
|
1112
|
+
function splitToken(token) {
|
|
1113
|
+
return token.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[\s\-_.:]+/).filter((part) => part.length > 0);
|
|
1114
|
+
}
|
|
1115
|
+
function buildHealCandidates(selector) {
|
|
1116
|
+
const { words, label } = extractSelectorIntent(selector);
|
|
1117
|
+
if (words.length === 0) return [];
|
|
1118
|
+
const escaped = label.replace(/"/g, '\\"');
|
|
1119
|
+
const candidates = [];
|
|
1120
|
+
candidates.push({ selector: `text=${label}`, strategy: "text" });
|
|
1121
|
+
candidates.push({ selector: `[aria-label*="${escaped}" i]`, strategy: "aria" });
|
|
1122
|
+
for (const tag of INTERACTIVE_TAGS) {
|
|
1123
|
+
candidates.push({ selector: `${tag}:has-text("${escaped}")`, strategy: "structural" });
|
|
1124
|
+
}
|
|
1125
|
+
return candidates;
|
|
1126
|
+
}
|
|
1127
|
+
async function healSelector(page, selector, options) {
|
|
1128
|
+
if (!options.enabled) return null;
|
|
1129
|
+
for (const candidate of buildHealCandidates(selector)) {
|
|
1130
|
+
let count;
|
|
1131
|
+
try {
|
|
1132
|
+
const locator = page.locator(candidate.selector);
|
|
1133
|
+
count = await locator.count();
|
|
1134
|
+
} catch {
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
if (count === 1) {
|
|
1138
|
+
return { selector: candidate.selector, healedFrom: selector, strategy: candidate.strategy };
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
return null;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// src/runner/steps.ts
|
|
1145
|
+
var ALWAYS_ALLOWED_PROTOCOLS = ["about:", "data:"];
|
|
1146
|
+
function unwrapTextSelector(value) {
|
|
1147
|
+
const trimmed = value.trim();
|
|
1148
|
+
if (trimmed.startsWith('text="') && trimmed.endsWith('"')) {
|
|
1149
|
+
return trimmed.slice(6, -1);
|
|
1150
|
+
}
|
|
1151
|
+
if (trimmed.startsWith("text='") && trimmed.endsWith("'")) {
|
|
1152
|
+
return trimmed.slice(6, -1);
|
|
1153
|
+
}
|
|
1154
|
+
if (trimmed.startsWith("text=")) {
|
|
1155
|
+
return trimmed.slice(5);
|
|
1156
|
+
}
|
|
1157
|
+
return null;
|
|
1158
|
+
}
|
|
1159
|
+
function matchesForbiddenPattern(selector, forbidden) {
|
|
1160
|
+
const selectorText = unwrapTextSelector(selector);
|
|
1161
|
+
if (selectorText === null) {
|
|
1162
|
+
return false;
|
|
1163
|
+
}
|
|
1164
|
+
const forbiddenText = unwrapTextSelector(forbidden);
|
|
1165
|
+
if (forbiddenText !== null) {
|
|
1166
|
+
return selectorText.includes(forbiddenText);
|
|
1167
|
+
}
|
|
1168
|
+
return selectorText.includes(forbidden);
|
|
1169
|
+
}
|
|
1170
|
+
function isForbiddenSelector(selector, forbiddenSelectors) {
|
|
1171
|
+
return forbiddenSelectors.some(
|
|
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}`);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
async function resolveActionSelector(context, selector) {
|
|
1181
|
+
assertAllowedSelector(selector, context.forbiddenSelectors);
|
|
1182
|
+
if (!context.selfHealing) {
|
|
1183
|
+
return { selector };
|
|
1184
|
+
}
|
|
1185
|
+
let matched = false;
|
|
1186
|
+
try {
|
|
1187
|
+
matched = await context.page.locator(selector).count() > 0;
|
|
1188
|
+
} catch {
|
|
1189
|
+
return { selector };
|
|
1190
|
+
}
|
|
1191
|
+
if (matched) {
|
|
1192
|
+
return { selector };
|
|
1193
|
+
}
|
|
1194
|
+
const healed = await healSelector(context.page, selector, { enabled: true });
|
|
1195
|
+
if (!healed) {
|
|
1196
|
+
return { selector };
|
|
1197
|
+
}
|
|
1198
|
+
assertAllowedSelector(healed.selector, context.forbiddenSelectors);
|
|
1199
|
+
console.warn(
|
|
1200
|
+
`Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
|
|
1201
|
+
);
|
|
1202
|
+
return { selector: healed.selector, healedFrom: healed.healedFrom };
|
|
1203
|
+
}
|
|
1204
|
+
function assertWithinMaxSteps(stepCount, maxSteps, huntName) {
|
|
1205
|
+
if (stepCount > maxSteps) {
|
|
1206
|
+
if (huntName) {
|
|
1207
|
+
throw new Error(`Hunt "${huntName}" has ${stepCount} steps. Max allowed is ${maxSteps}.`);
|
|
1208
|
+
}
|
|
1209
|
+
throw new Error(`Hunt has ${stepCount} steps. Max allowed is ${maxSteps}.`);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
function getStepType(step) {
|
|
1213
|
+
if ("navigate" in step) return "navigate";
|
|
1214
|
+
if ("click" in step) return "click";
|
|
1215
|
+
if ("fill" in step) return "fill";
|
|
1216
|
+
if ("type" in step) return "type";
|
|
1217
|
+
if ("selectOption" in step) return "selectOption";
|
|
1218
|
+
if ("select" in step) return "select";
|
|
1219
|
+
if ("onDialog" in step) return "onDialog";
|
|
1220
|
+
if ("setInputFiles" in step) return "setInputFiles";
|
|
1221
|
+
if ("runHunt" in step) return "runHunt";
|
|
1222
|
+
if ("assert" in step) return "assert";
|
|
1223
|
+
if ("press" in step) return "press";
|
|
1224
|
+
if ("wait" in step) return "wait";
|
|
1225
|
+
if ("waitForSelector" in step) return "waitForSelector";
|
|
1226
|
+
if ("waitForUrl" in step) return "waitForUrl";
|
|
1227
|
+
if ("waitForNetworkIdle" in step) return "waitForNetworkIdle";
|
|
1228
|
+
if ("hover" in step) return "hover";
|
|
1229
|
+
if ("scroll" in step) return "scroll";
|
|
1230
|
+
if ("scrollTo" in step) return "scrollTo";
|
|
1231
|
+
if ("screenshot" in step) return "screenshot";
|
|
1232
|
+
if ("if" in step) return "if";
|
|
1233
|
+
if ("repeat" in step) return "repeat";
|
|
1234
|
+
if ("mockRoute" in step) return "mockRoute";
|
|
1235
|
+
if ("unmockRoute" in step) return "unmockRoute";
|
|
1236
|
+
if ("evalScript" in step) return "evalScript";
|
|
1237
|
+
if ("runScript" in step) return "runScript";
|
|
1238
|
+
if ("assertScreenshot" in step) return "assertScreenshot";
|
|
1239
|
+
if ("copyText" in step) return "copyText";
|
|
1240
|
+
if ("waitForDownload" in step) return "waitForDownload";
|
|
1241
|
+
return "step";
|
|
1242
|
+
}
|
|
1243
|
+
var RUNTIME_VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
|
|
1244
|
+
function substituteRuntimeVars(input, vars) {
|
|
1245
|
+
return input.replace(RUNTIME_VAR_PATTERN, (match, name) => {
|
|
1246
|
+
const value = vars.get(name);
|
|
1247
|
+
return value !== void 0 ? value : match;
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
function applyRuntimeVars(step, vars) {
|
|
1251
|
+
const sub = (s) => substituteRuntimeVars(s, vars);
|
|
1252
|
+
if ("navigate" in step) return { navigate: sub(step.navigate) };
|
|
1253
|
+
if ("click" in step) {
|
|
1254
|
+
if (typeof step.click === "string") return { click: sub(step.click) };
|
|
1255
|
+
return { click: { selector: sub(step.click.selector) } };
|
|
1256
|
+
}
|
|
1257
|
+
if ("fill" in step) {
|
|
1258
|
+
if ("selector" in step.fill && "value" in step.fill) {
|
|
1259
|
+
const f = step.fill;
|
|
1260
|
+
return { fill: { selector: sub(f.selector), value: sub(f.value) } };
|
|
1261
|
+
}
|
|
1262
|
+
const [key, value] = Object.entries(step.fill)[0];
|
|
1263
|
+
return { fill: { [sub(key)]: sub(value) } };
|
|
1264
|
+
}
|
|
1265
|
+
if ("type" in step) return { type: sub(step.type) };
|
|
1266
|
+
if ("assert" in step) {
|
|
1267
|
+
const a = step.assert;
|
|
1268
|
+
if (a.visible !== void 0) return { assert: { visible: sub(a.visible) } };
|
|
1269
|
+
if (a.notVisible !== void 0) return { assert: { notVisible: sub(a.notVisible) } };
|
|
1270
|
+
if (a.urlIncludes !== void 0) return { assert: { urlIncludes: sub(a.urlIncludes) } };
|
|
1271
|
+
if (a.urlEquals !== void 0) return { assert: { urlEquals: sub(a.urlEquals) } };
|
|
1272
|
+
return step;
|
|
1273
|
+
}
|
|
1274
|
+
if ("wait" in step) {
|
|
1275
|
+
if (typeof step.wait === "string") return { wait: sub(step.wait) };
|
|
1276
|
+
return { wait: { for: sub(step.wait.for), timeout: step.wait.timeout } };
|
|
1277
|
+
}
|
|
1278
|
+
if ("waitForSelector" in step) {
|
|
1279
|
+
return { waitForSelector: { selector: sub(step.waitForSelector.selector), timeout: step.waitForSelector.timeout } };
|
|
1280
|
+
}
|
|
1281
|
+
if ("evalScript" in step) {
|
|
1282
|
+
if (typeof step.evalScript === "string") return { evalScript: sub(step.evalScript) };
|
|
1283
|
+
return {
|
|
1284
|
+
evalScript: {
|
|
1285
|
+
expression: sub(step.evalScript.expression),
|
|
1286
|
+
...step.evalScript.as !== void 0 ? { as: step.evalScript.as } : {}
|
|
1287
|
+
}
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
if ("assertScreenshot" in step) {
|
|
1291
|
+
return {
|
|
1292
|
+
assertScreenshot: {
|
|
1293
|
+
name: sub(step.assertScreenshot.name),
|
|
1294
|
+
...step.assertScreenshot.threshold !== void 0 ? { threshold: step.assertScreenshot.threshold } : {}
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
if ("copyText" in step) {
|
|
1299
|
+
return { copyText: { selector: sub(step.copyText.selector), as: step.copyText.as } };
|
|
1300
|
+
}
|
|
1301
|
+
if ("waitForDownload" in step) {
|
|
1302
|
+
if (step.waitForDownload === null) return step;
|
|
1303
|
+
return {
|
|
1304
|
+
waitForDownload: {
|
|
1305
|
+
...step.waitForDownload.filename !== void 0 ? { filename: sub(step.waitForDownload.filename) } : {},
|
|
1306
|
+
...step.waitForDownload.timeout !== void 0 ? { timeout: step.waitForDownload.timeout } : {}
|
|
1307
|
+
}
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
return step;
|
|
1311
|
+
}
|
|
1312
|
+
function isExplicitFillStep(value) {
|
|
1313
|
+
return typeof value.selector === "string" && typeof value.value === "string";
|
|
1314
|
+
}
|
|
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
|
+
function resolveNavigationTarget(targetUrl, value) {
|
|
1327
|
+
try {
|
|
1328
|
+
return new URL(value, targetUrl).toString();
|
|
1329
|
+
} catch {
|
|
1330
|
+
return value;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
function escapeForText(value) {
|
|
1334
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
1335
|
+
}
|
|
1336
|
+
function escapeForAttribute(value) {
|
|
1337
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
1338
|
+
}
|
|
1339
|
+
function exactTextSelector(text) {
|
|
1340
|
+
return `text="${escapeForText(text)}"`;
|
|
1341
|
+
}
|
|
1342
|
+
function textContainsSelector(text) {
|
|
1343
|
+
return `text=${escapeForText(text)}`;
|
|
1344
|
+
}
|
|
1345
|
+
function getSinglePair(value, stepType) {
|
|
1346
|
+
const entries = Object.entries(value);
|
|
1347
|
+
if (entries.length !== 1) {
|
|
1348
|
+
throw new Error(`${stepType} shorthand expects exactly one key-value pair`);
|
|
1349
|
+
}
|
|
1350
|
+
return entries[0];
|
|
1351
|
+
}
|
|
1352
|
+
async function clickByTextWithFallback(page, text, forbiddenSelectors) {
|
|
1353
|
+
const roleSelector = `role=button[name="${escapeForAttribute(text)}"]`;
|
|
1354
|
+
assertAllowedSelector(roleSelector, forbiddenSelectors);
|
|
1355
|
+
const button = page.getByRole("button", { name: text });
|
|
1356
|
+
if (await button.count()) {
|
|
1357
|
+
await button.first().click();
|
|
1358
|
+
return roleSelector;
|
|
1359
|
+
}
|
|
1360
|
+
const selector = exactTextSelector(text);
|
|
1361
|
+
assertAllowedSelector(selector, forbiddenSelectors);
|
|
1362
|
+
await page.locator(selector).first().click();
|
|
1363
|
+
return selector;
|
|
1364
|
+
}
|
|
1365
|
+
async function fillByLabelOrPlaceholder(page, label, value, forbiddenSelectors) {
|
|
1366
|
+
const labelSelector = `label="${escapeForAttribute(label)}"`;
|
|
1367
|
+
assertAllowedSelector(labelSelector, forbiddenSelectors);
|
|
1368
|
+
const byLabel = page.getByLabel(label, { exact: true });
|
|
1369
|
+
if (await byLabel.count()) {
|
|
1370
|
+
await byLabel.first().fill(value);
|
|
1371
|
+
return labelSelector;
|
|
1372
|
+
}
|
|
1373
|
+
const placeholder = `input[placeholder="${escapeForAttribute(label)}"], textarea[placeholder="${escapeForAttribute(label)}"]`;
|
|
1374
|
+
assertAllowedSelector(placeholder, forbiddenSelectors);
|
|
1375
|
+
const byPlaceholder = page.locator(placeholder);
|
|
1376
|
+
if (await byPlaceholder.count()) {
|
|
1377
|
+
await byPlaceholder.first().fill(value);
|
|
1378
|
+
return placeholder;
|
|
1379
|
+
}
|
|
1380
|
+
throw new Error(`Could not resolve fill shorthand for "${label}"`);
|
|
1381
|
+
}
|
|
1382
|
+
async function selectByLabelOrFallback(page, label, value, forbiddenSelectors) {
|
|
1383
|
+
const labelSelector = `label="${escapeForAttribute(label)}"`;
|
|
1384
|
+
assertAllowedSelector(labelSelector, forbiddenSelectors);
|
|
1385
|
+
const byLabel = page.getByLabel(label, { exact: true });
|
|
1386
|
+
if (await byLabel.count()) {
|
|
1387
|
+
await byLabel.first().selectOption(value);
|
|
1388
|
+
return labelSelector;
|
|
1389
|
+
}
|
|
1390
|
+
const ariaSelector = `select[aria-label="${escapeForAttribute(label)}"]`;
|
|
1391
|
+
assertAllowedSelector(ariaSelector, forbiddenSelectors);
|
|
1392
|
+
const byAria = page.locator(ariaSelector);
|
|
1393
|
+
if (await byAria.count()) {
|
|
1394
|
+
await byAria.first().selectOption(value);
|
|
1395
|
+
return ariaSelector;
|
|
1396
|
+
}
|
|
1397
|
+
const placeholderSelector = `select[placeholder="${escapeForAttribute(label)}"]`;
|
|
1398
|
+
assertAllowedSelector(placeholderSelector, forbiddenSelectors);
|
|
1399
|
+
const byPlaceholder = page.locator(placeholderSelector);
|
|
1400
|
+
if (await byPlaceholder.count()) {
|
|
1401
|
+
await byPlaceholder.first().selectOption(value);
|
|
1402
|
+
return placeholderSelector;
|
|
1403
|
+
}
|
|
1404
|
+
throw new Error(`Could not resolve select shorthand for "${label}"`);
|
|
1405
|
+
}
|
|
1406
|
+
var SELECTOR_ENGINE_PREFIX = /^(?:css|xpath|text|id|role|data-testid)=/i;
|
|
1407
|
+
var HTML_TYPE_SELECTORS = /* @__PURE__ */ new Set([
|
|
1408
|
+
"a",
|
|
1409
|
+
"article",
|
|
1410
|
+
"aside",
|
|
1411
|
+
"body",
|
|
1412
|
+
"button",
|
|
1413
|
+
"canvas",
|
|
1414
|
+
"dialog",
|
|
1415
|
+
"div",
|
|
1416
|
+
"fieldset",
|
|
1417
|
+
"footer",
|
|
1418
|
+
"form",
|
|
1419
|
+
"h1",
|
|
1420
|
+
"h2",
|
|
1421
|
+
"h3",
|
|
1422
|
+
"h4",
|
|
1423
|
+
"h5",
|
|
1424
|
+
"h6",
|
|
1425
|
+
"header",
|
|
1426
|
+
"html",
|
|
1427
|
+
"iframe",
|
|
1428
|
+
"img",
|
|
1429
|
+
"input",
|
|
1430
|
+
"label",
|
|
1431
|
+
"li",
|
|
1432
|
+
"main",
|
|
1433
|
+
"nav",
|
|
1434
|
+
"ol",
|
|
1435
|
+
"option",
|
|
1436
|
+
"p",
|
|
1437
|
+
"section",
|
|
1438
|
+
"select",
|
|
1439
|
+
"span",
|
|
1440
|
+
"table",
|
|
1441
|
+
"tbody",
|
|
1442
|
+
"td",
|
|
1443
|
+
"textarea",
|
|
1444
|
+
"th",
|
|
1445
|
+
"thead",
|
|
1446
|
+
"tr",
|
|
1447
|
+
"ul"
|
|
1448
|
+
]);
|
|
1449
|
+
function isKnownCssTypeSelector(value) {
|
|
1450
|
+
return value === "*" || value.includes("-") || HTML_TYPE_SELECTORS.has(value.toLowerCase());
|
|
1451
|
+
}
|
|
1452
|
+
function readCssTypeSelector(value, start) {
|
|
1453
|
+
const match = /^(?:[A-Za-z][\w-]*|\*)/.exec(value.slice(start));
|
|
1454
|
+
if (!match) return null;
|
|
1455
|
+
return { end: start + match[0].length, isKnown: isKnownCssTypeSelector(match[0]) };
|
|
1456
|
+
}
|
|
1457
|
+
function readCssStructuralSelectorPart(value, start) {
|
|
1458
|
+
const rest = value.slice(start);
|
|
1459
|
+
const classOrId = /^[.#][A-Za-z_][\w-]*/.exec(rest);
|
|
1460
|
+
if (classOrId) return start + classOrId[0].length;
|
|
1461
|
+
const attribute = /^\[[A-Za-z_][\w:-]*(?:\s*(?:[~|^$*]?=)\s*(?:"[^"]*"|'[^']*'|[^\]\s]+))?\]/.exec(rest);
|
|
1462
|
+
if (attribute) return start + attribute[0].length;
|
|
1463
|
+
return null;
|
|
1464
|
+
}
|
|
1465
|
+
function readCssCompoundSelector(value, start) {
|
|
1466
|
+
let cursor = start;
|
|
1467
|
+
const type = readCssTypeSelector(value, cursor);
|
|
1468
|
+
if (type) {
|
|
1469
|
+
cursor = type.end;
|
|
1470
|
+
}
|
|
1471
|
+
let hasStructuralPart = false;
|
|
1472
|
+
for (; ; ) {
|
|
1473
|
+
const next = readCssStructuralSelectorPart(value, cursor);
|
|
1474
|
+
if (next === null) break;
|
|
1475
|
+
hasStructuralPart = true;
|
|
1476
|
+
cursor = next;
|
|
1477
|
+
}
|
|
1478
|
+
if (cursor === start) return null;
|
|
1479
|
+
if (type && !type.isKnown) return null;
|
|
1480
|
+
return { end: cursor, hasStructuralPart };
|
|
1481
|
+
}
|
|
1482
|
+
function readCssSelectorSeparator(value, start) {
|
|
1483
|
+
let cursor = start;
|
|
1484
|
+
let sawWhitespace = false;
|
|
1485
|
+
while (/\s/.test(value[cursor] ?? "")) {
|
|
1486
|
+
sawWhitespace = true;
|
|
1487
|
+
cursor += 1;
|
|
1488
|
+
}
|
|
1489
|
+
if (/[>+~]/.test(value[cursor] ?? "")) {
|
|
1490
|
+
cursor += 1;
|
|
1491
|
+
while (/\s/.test(value[cursor] ?? "")) {
|
|
1492
|
+
cursor += 1;
|
|
1493
|
+
}
|
|
1494
|
+
return cursor;
|
|
1495
|
+
}
|
|
1496
|
+
return sawWhitespace ? cursor : null;
|
|
1497
|
+
}
|
|
1498
|
+
function isCssSelectorSequence(value) {
|
|
1499
|
+
const first = readCssCompoundSelector(value, 0);
|
|
1500
|
+
if (!first) return false;
|
|
1501
|
+
let cursor = first.end;
|
|
1502
|
+
let sawSeparator = false;
|
|
1503
|
+
let hasStructuralPart = first.hasStructuralPart;
|
|
1504
|
+
while (cursor < value.length) {
|
|
1505
|
+
const afterSeparator = readCssSelectorSeparator(value, cursor);
|
|
1506
|
+
if (afterSeparator === null) return false;
|
|
1507
|
+
const next = readCssCompoundSelector(value, afterSeparator);
|
|
1508
|
+
if (!next) return false;
|
|
1509
|
+
sawSeparator = true;
|
|
1510
|
+
hasStructuralPart = hasStructuralPart || next.hasStructuralPart;
|
|
1511
|
+
cursor = next.end;
|
|
1512
|
+
}
|
|
1513
|
+
return sawSeparator && hasStructuralPart;
|
|
1514
|
+
}
|
|
1515
|
+
function looksLikeSelector(value) {
|
|
1516
|
+
const trimmed = value.trim();
|
|
1517
|
+
if (trimmed.length === 0) return false;
|
|
1518
|
+
if (SELECTOR_ENGINE_PREFIX.test(trimmed) || trimmed.startsWith("//")) return true;
|
|
1519
|
+
if (/^[.#]/.test(trimmed)) return true;
|
|
1520
|
+
const compound = readCssCompoundSelector(trimmed, 0);
|
|
1521
|
+
if (compound?.end === trimmed.length && compound.hasStructuralPart) return true;
|
|
1522
|
+
if (isCssSelectorSequence(trimmed)) return true;
|
|
1523
|
+
return false;
|
|
1524
|
+
}
|
|
1525
|
+
function toVisibilitySelector(value) {
|
|
1526
|
+
if (looksLikeSelector(value)) return value;
|
|
1527
|
+
return textContainsSelector(value);
|
|
1528
|
+
}
|
|
1529
|
+
async function runInlineAssert(page, assertion, forbiddenSelectors) {
|
|
1530
|
+
if (assertion.visible !== void 0) {
|
|
1531
|
+
const selector = toVisibilitySelector(assertion.visible);
|
|
1532
|
+
assertAllowedSelector(selector, forbiddenSelectors);
|
|
1533
|
+
const count = await page.locator(selector).count();
|
|
1534
|
+
if (count === 0) {
|
|
1535
|
+
throw new Error(`Expected visible: ${assertion.visible}`);
|
|
1536
|
+
}
|
|
1537
|
+
return `visible:${assertion.visible}`;
|
|
1538
|
+
}
|
|
1539
|
+
if (assertion.notVisible !== void 0) {
|
|
1540
|
+
const selector = toVisibilitySelector(assertion.notVisible);
|
|
1541
|
+
assertAllowedSelector(selector, forbiddenSelectors);
|
|
1542
|
+
const count = await page.locator(selector).count();
|
|
1543
|
+
if (count > 0) {
|
|
1544
|
+
throw new Error(`Expected not visible: ${assertion.notVisible}`);
|
|
1545
|
+
}
|
|
1546
|
+
return `notVisible:${assertion.notVisible}`;
|
|
1547
|
+
}
|
|
1548
|
+
if (assertion.urlIncludes !== void 0) {
|
|
1549
|
+
const current = page.url();
|
|
1550
|
+
if (!current.includes(assertion.urlIncludes)) {
|
|
1551
|
+
throw new Error(`URL did not include ${assertion.urlIncludes}`);
|
|
1552
|
+
}
|
|
1553
|
+
return `urlIncludes:${assertion.urlIncludes}`;
|
|
1554
|
+
}
|
|
1555
|
+
if (assertion.urlEquals !== void 0) {
|
|
1556
|
+
const current = page.url();
|
|
1557
|
+
if (current !== assertion.urlEquals) {
|
|
1558
|
+
throw new Error(`URL did not equal ${assertion.urlEquals}`);
|
|
1559
|
+
}
|
|
1560
|
+
return `urlEquals:${assertion.urlEquals}`;
|
|
1561
|
+
}
|
|
1562
|
+
throw new Error("assert step is missing an assertion type");
|
|
1563
|
+
}
|
|
1564
|
+
function screenshotPath(screenshotsDir, fileName) {
|
|
1565
|
+
return import_node_path4.default.join(screenshotsDir, fileName);
|
|
1566
|
+
}
|
|
1567
|
+
function stepPath(prefix, index) {
|
|
1568
|
+
return prefix ? `${prefix}.${index}` : `${index}`;
|
|
1569
|
+
}
|
|
1570
|
+
function isWaitForDownloadStep(step) {
|
|
1571
|
+
return step !== void 0 && "waitForDownload" in step;
|
|
1572
|
+
}
|
|
1573
|
+
function armDownloadListener(page, timeout) {
|
|
1574
|
+
const downloadPromise = page.waitForEvent("download", { timeout });
|
|
1575
|
+
void downloadPromise.catch(() => void 0);
|
|
1576
|
+
return downloadPromise;
|
|
1577
|
+
}
|
|
1578
|
+
function validateDownloadFilename(suggestedFilename) {
|
|
1579
|
+
const safeFilename = suggestedFilename.trim();
|
|
1580
|
+
const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
|
|
1581
|
+
const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
|
|
1582
|
+
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path4.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
|
|
1583
|
+
throw new Error(`Invalid download filename: "${suggestedFilename}"`);
|
|
1584
|
+
}
|
|
1585
|
+
return safeFilename;
|
|
1586
|
+
}
|
|
1587
|
+
async function captureScreenshot(page, filePath) {
|
|
1588
|
+
try {
|
|
1589
|
+
await page.screenshot({ path: filePath, fullPage: true });
|
|
1590
|
+
} catch (error) {
|
|
1591
|
+
const message = error instanceof Error ? error.message : "Screenshot failed";
|
|
1592
|
+
throw new Error(`Failed to capture screenshot at ${filePath}: ${message}`);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
async function executeNestedSteps(context, overrides) {
|
|
1596
|
+
const nestedContext = {
|
|
1597
|
+
...context,
|
|
1598
|
+
...overrides,
|
|
1599
|
+
pendingDownload: context.pendingDownload
|
|
1600
|
+
};
|
|
1601
|
+
const result = await executeSteps(nestedContext);
|
|
1602
|
+
context.pendingDownload = nestedContext.pendingDownload;
|
|
1603
|
+
if (nestedContext.randomVars !== void 0) {
|
|
1604
|
+
context.randomVars = nestedContext.randomVars;
|
|
1605
|
+
}
|
|
1606
|
+
return result;
|
|
1607
|
+
}
|
|
1608
|
+
async function executeSteps(context) {
|
|
1609
|
+
const screenshotsDir = import_node_path4.default.join(context.runDir, "screenshots");
|
|
1610
|
+
import_node_fs4.default.mkdirSync(screenshotsDir, { recursive: true });
|
|
1611
|
+
const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
|
|
1612
|
+
assertWithinMaxSteps(context.steps.length, context.maxSteps, currentHuntName);
|
|
1613
|
+
const results = [];
|
|
1614
|
+
const screenshots = [];
|
|
1615
|
+
const runStartedAtMs = context.runStartedAtMs ?? Date.now();
|
|
1616
|
+
context.runStartedAtMs = runStartedAtMs;
|
|
1617
|
+
const addScreenshot = async (fileName) => {
|
|
1618
|
+
const fullPath = screenshotPath(screenshotsDir, fileName);
|
|
1619
|
+
await captureScreenshot(context.page, fullPath);
|
|
1620
|
+
const relative = import_node_path4.default.join("screenshots", fileName);
|
|
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);
|
|
1640
|
+
}
|
|
1641
|
+
const nextStep = context.steps[index + 1];
|
|
1642
|
+
if (!isWaitForDownloadStep(step) && context.pendingDownload === void 0 && isWaitForDownloadStep(nextStep)) {
|
|
1643
|
+
context.pendingDownload = armDownloadListener(
|
|
1644
|
+
context.page,
|
|
1645
|
+
nextStep.waitForDownload?.timeout ?? 3e4
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1648
|
+
const stepStart = Date.now();
|
|
1649
|
+
const stepType = getStepType(step);
|
|
1650
|
+
let stepResult = null;
|
|
1651
|
+
try {
|
|
1652
|
+
if ("navigate" in step) {
|
|
1653
|
+
const destination = resolveNavigationTarget(context.targetUrl, step.navigate);
|
|
1654
|
+
ensureAllowedUrl(destination, context.allowedDomains);
|
|
1655
|
+
await context.page.goto(destination);
|
|
1656
|
+
ensureAllowedUrl(context.page.url(), context.allowedDomains);
|
|
1657
|
+
stepResult = { type: "navigate", status: "pass", durationMs: Date.now() - stepStart };
|
|
1658
|
+
} else if ("click" in step) {
|
|
1659
|
+
let selector;
|
|
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 = {
|
|
1675
|
+
type: "click",
|
|
1676
|
+
status: "pass",
|
|
1677
|
+
durationMs: Date.now() - stepStart,
|
|
1678
|
+
selector,
|
|
1679
|
+
...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
|
+
}
|
|
1701
|
+
ensureAllowedUrl(context.page.url(), context.allowedDomains);
|
|
1702
|
+
stepResult = {
|
|
1703
|
+
type: "fill",
|
|
1704
|
+
status: "pass",
|
|
1705
|
+
durationMs: Date.now() - stepStart,
|
|
1706
|
+
selector,
|
|
1707
|
+
value: context.redactedFillSteps.has(currentStepPath) ? "[REDACTED]" : value,
|
|
1708
|
+
...healedFrom ? { healedFrom } : {}
|
|
1709
|
+
};
|
|
1710
|
+
} else if ("type" in step) {
|
|
1711
|
+
assertAllowedSelector(":focus", context.forbiddenSelectors);
|
|
1712
|
+
await fillElement(context.page, ":focus", step.type);
|
|
1713
|
+
ensureAllowedUrl(context.page.url(), context.allowedDomains);
|
|
1714
|
+
stepResult = {
|
|
1715
|
+
type: "type",
|
|
1716
|
+
status: "pass",
|
|
1717
|
+
durationMs: Date.now() - stepStart,
|
|
1718
|
+
selector: ":focus",
|
|
1719
|
+
value: context.redactedFillSteps.has(currentStepPath) ? "[REDACTED]" : step.type
|
|
1720
|
+
};
|
|
1721
|
+
} else if ("selectOption" in step) {
|
|
1722
|
+
const resolved = await resolveActionSelector(context, step.selectOption.selector);
|
|
1723
|
+
await selectOption(context.page, resolved.selector, step.selectOption.value);
|
|
1724
|
+
ensureAllowedUrl(context.page.url(), context.allowedDomains);
|
|
1725
|
+
stepResult = {
|
|
1726
|
+
type: "selectOption",
|
|
1727
|
+
status: "pass",
|
|
1728
|
+
durationMs: Date.now() - stepStart,
|
|
1729
|
+
selector: resolved.selector,
|
|
1730
|
+
value: step.selectOption.value,
|
|
1731
|
+
...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
|
|
1732
|
+
};
|
|
1733
|
+
} else if ("select" in step) {
|
|
1734
|
+
const [label, value] = getSinglePair(step.select, "select");
|
|
1735
|
+
const selector = await selectByLabelOrFallback(
|
|
1736
|
+
context.page,
|
|
1737
|
+
label,
|
|
1738
|
+
value,
|
|
1739
|
+
context.forbiddenSelectors
|
|
1740
|
+
);
|
|
1741
|
+
ensureAllowedUrl(context.page.url(), context.allowedDomains);
|
|
1742
|
+
stepResult = {
|
|
1743
|
+
type: "select",
|
|
1744
|
+
status: "pass",
|
|
1745
|
+
durationMs: Date.now() - stepStart,
|
|
1746
|
+
selector,
|
|
1747
|
+
value
|
|
1748
|
+
};
|
|
1749
|
+
} else if ("onDialog" in step) {
|
|
1750
|
+
setupDialogHandler(context.page, step.onDialog.action);
|
|
1751
|
+
stepResult = {
|
|
1752
|
+
type: "onDialog",
|
|
1753
|
+
status: "pass",
|
|
1754
|
+
durationMs: Date.now() - stepStart,
|
|
1755
|
+
value: step.onDialog.action
|
|
1756
|
+
};
|
|
1757
|
+
} else if ("setInputFiles" in step) {
|
|
1758
|
+
const resolvedInput = await resolveActionSelector(context, step.setInputFiles.selector);
|
|
1759
|
+
const rawFiles = step.setInputFiles.files;
|
|
1760
|
+
const resolveFile = (f) => import_node_path4.default.isAbsolute(f) ? f : import_node_path4.default.join(context.configDir, f);
|
|
1761
|
+
const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
|
|
1762
|
+
await setInputFiles(context.page, resolvedInput.selector, resolvedFiles);
|
|
1763
|
+
ensureAllowedUrl(context.page.url(), context.allowedDomains);
|
|
1764
|
+
const filesLabel = Array.isArray(rawFiles) ? rawFiles.join(", ") : rawFiles;
|
|
1765
|
+
stepResult = {
|
|
1766
|
+
type: "setInputFiles",
|
|
1767
|
+
status: "pass",
|
|
1768
|
+
durationMs: Date.now() - stepStart,
|
|
1769
|
+
selector: resolvedInput.selector,
|
|
1770
|
+
...resolvedInput.healedFrom ? { healedFrom: resolvedInput.healedFrom } : {},
|
|
1771
|
+
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
|
+
}
|
|
1814
|
+
stepResult = {
|
|
1815
|
+
type: "runHunt",
|
|
1816
|
+
status: "pass",
|
|
1817
|
+
durationMs: Date.now() - stepStart,
|
|
1818
|
+
value: huntName
|
|
1819
|
+
};
|
|
1820
|
+
} else if ("press" in step) {
|
|
1821
|
+
const resolved = await resolveActionSelector(context, step.press.selector);
|
|
1822
|
+
await pressKey(context.page, resolved.selector, step.press.key);
|
|
1823
|
+
ensureAllowedUrl(context.page.url(), context.allowedDomains);
|
|
1824
|
+
stepResult = {
|
|
1825
|
+
type: "press",
|
|
1826
|
+
status: "pass",
|
|
1827
|
+
durationMs: Date.now() - stepStart,
|
|
1828
|
+
selector: resolved.selector,
|
|
1829
|
+
...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
|
|
1830
|
+
};
|
|
1831
|
+
} else if ("assert" in step) {
|
|
1832
|
+
const value = await runInlineAssert(context.page, step.assert, context.forbiddenSelectors);
|
|
1833
|
+
stepResult = {
|
|
1834
|
+
type: "assert",
|
|
1835
|
+
status: "pass",
|
|
1836
|
+
durationMs: Date.now() - stepStart,
|
|
1837
|
+
value
|
|
1838
|
+
};
|
|
1839
|
+
} else if ("wait" in step) {
|
|
1840
|
+
const text = typeof step.wait === "string" ? step.wait : step.wait.for;
|
|
1841
|
+
const timeout = typeof step.wait === "string" ? void 0 : step.wait.timeout;
|
|
1842
|
+
const selector = `text=${escapeForText(text)}`;
|
|
1843
|
+
assertAllowedSelector(selector, context.forbiddenSelectors);
|
|
1844
|
+
await context.page.waitForSelector(selector, { timeout });
|
|
1845
|
+
stepResult = {
|
|
1846
|
+
type: "wait",
|
|
1847
|
+
status: "pass",
|
|
1848
|
+
durationMs: Date.now() - stepStart,
|
|
1849
|
+
selector
|
|
1850
|
+
};
|
|
1851
|
+
} else if ("waitForSelector" in step) {
|
|
1852
|
+
assertAllowedSelector(step.waitForSelector.selector, context.forbiddenSelectors);
|
|
1853
|
+
await context.page.waitForSelector(step.waitForSelector.selector, {
|
|
1854
|
+
timeout: step.waitForSelector.timeout
|
|
1855
|
+
});
|
|
1856
|
+
stepResult = {
|
|
1857
|
+
type: "waitForSelector",
|
|
1858
|
+
status: "pass",
|
|
1859
|
+
durationMs: Date.now() - stepStart,
|
|
1860
|
+
selector: step.waitForSelector.selector
|
|
1861
|
+
};
|
|
1862
|
+
} else if ("waitForUrl" in step) {
|
|
1863
|
+
await context.page.waitForURL(
|
|
1864
|
+
(url) => url.toString().includes(step.waitForUrl.value),
|
|
1865
|
+
{ timeout: step.waitForUrl.timeout }
|
|
1866
|
+
);
|
|
1867
|
+
ensureAllowedUrl(context.page.url(), context.allowedDomains);
|
|
1868
|
+
stepResult = {
|
|
1869
|
+
type: "waitForUrl",
|
|
1870
|
+
status: "pass",
|
|
1871
|
+
durationMs: Date.now() - stepStart,
|
|
1872
|
+
value: step.waitForUrl.value
|
|
1873
|
+
};
|
|
1874
|
+
} else if ("waitForNetworkIdle" in step) {
|
|
1875
|
+
await context.page.waitForLoadState("networkidle", {
|
|
1876
|
+
timeout: step.waitForNetworkIdle.timeout
|
|
1877
|
+
});
|
|
1878
|
+
stepResult = {
|
|
1879
|
+
type: "waitForNetworkIdle",
|
|
1880
|
+
status: "pass",
|
|
1881
|
+
durationMs: Date.now() - stepStart
|
|
1882
|
+
};
|
|
1883
|
+
} else if ("hover" in step) {
|
|
1884
|
+
const resolved = await resolveActionSelector(context, step.hover.selector);
|
|
1885
|
+
await context.page.locator(resolved.selector).hover();
|
|
1886
|
+
ensureAllowedUrl(context.page.url(), context.allowedDomains);
|
|
1887
|
+
stepResult = {
|
|
1888
|
+
type: "hover",
|
|
1889
|
+
status: "pass",
|
|
1890
|
+
durationMs: Date.now() - stepStart,
|
|
1891
|
+
selector: resolved.selector,
|
|
1892
|
+
...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
|
|
1893
|
+
};
|
|
1894
|
+
} else if ("scroll" in step) {
|
|
1895
|
+
const amount = step.scroll.amount ?? 500;
|
|
1896
|
+
const scrollMap = {
|
|
1897
|
+
up: [0, -amount],
|
|
1898
|
+
down: [0, amount],
|
|
1899
|
+
left: [-amount, 0],
|
|
1900
|
+
right: [amount, 0]
|
|
1901
|
+
};
|
|
1902
|
+
const [x, y] = scrollMap[step.scroll.direction];
|
|
1903
|
+
await context.page.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
|
|
1904
|
+
stepResult = {
|
|
1905
|
+
type: "scroll",
|
|
1906
|
+
status: "pass",
|
|
1907
|
+
durationMs: Date.now() - stepStart,
|
|
1908
|
+
value: `${step.scroll.direction} ${amount}px`
|
|
1909
|
+
};
|
|
1910
|
+
} else if ("scrollTo" in step) {
|
|
1911
|
+
const resolved = await resolveActionSelector(context, step.scrollTo.selector);
|
|
1912
|
+
await context.page.locator(resolved.selector).scrollIntoViewIfNeeded();
|
|
1913
|
+
stepResult = {
|
|
1914
|
+
type: "scrollTo",
|
|
1915
|
+
status: "pass",
|
|
1916
|
+
durationMs: Date.now() - stepStart,
|
|
1917
|
+
selector: resolved.selector,
|
|
1918
|
+
...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
|
|
1919
|
+
};
|
|
1920
|
+
} else if ("screenshot" in step) {
|
|
1921
|
+
const name = step.screenshot.name ?? `manual_step_${index + 1}.png`;
|
|
1922
|
+
if (/[/\\]|\.\./.test(name)) {
|
|
1923
|
+
throw new Error(`Invalid screenshot name: "${name}" must not contain path separators or ".."`);
|
|
1924
|
+
}
|
|
1925
|
+
const fileName = name.endsWith(".png") ? name : `${name}.png`;
|
|
1926
|
+
const relative = await addScreenshot(fileName);
|
|
1927
|
+
stepResult = {
|
|
1928
|
+
type: "screenshot",
|
|
1929
|
+
status: "pass",
|
|
1930
|
+
durationMs: Date.now() - stepStart,
|
|
1931
|
+
screenshot: relative
|
|
1932
|
+
};
|
|
1933
|
+
} else if ("if" in step) {
|
|
1934
|
+
const condition = step.if;
|
|
1935
|
+
const selector = condition.visible ?? condition.notVisible;
|
|
1936
|
+
assertAllowedSelector(selector, context.forbiddenSelectors);
|
|
1937
|
+
const count = await context.page.locator(selector).count();
|
|
1938
|
+
const conditionMet = condition.visible !== void 0 ? count > 0 : count === 0;
|
|
1939
|
+
if (conditionMet) {
|
|
1940
|
+
const subResult = await executeNestedSteps(context, {
|
|
1941
|
+
steps: condition.then,
|
|
1942
|
+
stepPathPrefix: `${currentStepPath}.if.then`
|
|
1943
|
+
});
|
|
1944
|
+
for (const sr of subResult.results) {
|
|
1945
|
+
results.push({ ...sr, type: `if > ${sr.type}` });
|
|
1946
|
+
}
|
|
1947
|
+
screenshots.push(...subResult.screenshots);
|
|
1948
|
+
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
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
} else if ("repeat" in step) {
|
|
1996
|
+
const repeat = step.repeat;
|
|
1997
|
+
let totalSubSteps = 0;
|
|
1998
|
+
if (repeat.times !== void 0) {
|
|
1999
|
+
const totalPlanned = repeat.times * repeat.steps.length;
|
|
2000
|
+
if (totalPlanned + totalSubSteps > context.maxSteps) {
|
|
2001
|
+
throw new Error(`Repeat exceeded maxSteps guardrail (${context.maxSteps})`);
|
|
2002
|
+
}
|
|
2003
|
+
for (let i = 0; i < repeat.times; i++) {
|
|
2004
|
+
totalSubSteps += repeat.steps.length;
|
|
2005
|
+
const subResult = await executeNestedSteps(context, {
|
|
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
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
} else if (repeat.while !== void 0) {
|
|
2023
|
+
const maxIter = repeat.maxIterations;
|
|
2024
|
+
const whileSelector = repeat.while.visible ?? repeat.while.notVisible;
|
|
2025
|
+
assertAllowedSelector(whileSelector, context.forbiddenSelectors);
|
|
2026
|
+
for (let i = 0; i < maxIter; i++) {
|
|
2027
|
+
const whileCount = await context.page.locator(whileSelector).count();
|
|
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");
|
|
2068
|
+
}
|
|
2069
|
+
const candidateFilePath = import_node_path4.default.isAbsolute(responseFile) ? responseFile : import_node_path4.default.join(context.configDir, responseFile);
|
|
2070
|
+
const resolvedConfigDir = import_node_path4.default.resolve(context.configDir);
|
|
2071
|
+
const resolvedFilePath = import_node_path4.default.resolve(candidateFilePath);
|
|
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");
|
|
2076
|
+
}
|
|
2077
|
+
responseBody = await import_node_fs4.default.promises.readFile(resolvedFilePath, "utf-8");
|
|
2078
|
+
}
|
|
2079
|
+
const contentType = mock.response.contentType ?? "application/json";
|
|
2080
|
+
const status = mock.response.status;
|
|
2081
|
+
await context.page.route(mock.url, (route) => {
|
|
2082
|
+
route.fulfill({
|
|
2083
|
+
status,
|
|
2084
|
+
contentType,
|
|
2085
|
+
body: responseBody
|
|
2086
|
+
});
|
|
2087
|
+
});
|
|
2088
|
+
mocks.set(mock.url, async () => {
|
|
2089
|
+
await context.page.unroute(mock.url);
|
|
2090
|
+
});
|
|
2091
|
+
stepResult = {
|
|
2092
|
+
type: "mockRoute",
|
|
2093
|
+
status: "pass",
|
|
2094
|
+
durationMs: Date.now() - stepStart,
|
|
2095
|
+
value: mock.url
|
|
2096
|
+
};
|
|
2097
|
+
} else if ("unmockRoute" in step) {
|
|
2098
|
+
const url = typeof step.unmockRoute === "string" ? step.unmockRoute : step.unmockRoute.url;
|
|
2099
|
+
const mocks = context.activeMocks;
|
|
2100
|
+
if (!mocks || !mocks.has(url)) {
|
|
2101
|
+
throw new Error(`No active mock for URL: ${url}`);
|
|
2102
|
+
}
|
|
2103
|
+
const cleanup = mocks.get(url);
|
|
2104
|
+
await cleanup();
|
|
2105
|
+
mocks.delete(url);
|
|
2106
|
+
stepResult = {
|
|
2107
|
+
type: "unmockRoute",
|
|
2108
|
+
status: "pass",
|
|
2109
|
+
durationMs: Date.now() - stepStart,
|
|
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);
|
|
2118
|
+
}
|
|
2119
|
+
stepResult = {
|
|
2120
|
+
type: "evalScript",
|
|
2121
|
+
status: "pass",
|
|
2122
|
+
durationMs: Date.now() - stepStart,
|
|
2123
|
+
value: resultStr.length > 200 ? resultStr.slice(0, 200) + "\u2026" : resultStr
|
|
2124
|
+
};
|
|
2125
|
+
} else if ("runScript" in step) {
|
|
2126
|
+
const filePath = import_node_path4.default.isAbsolute(step.runScript.file) ? step.runScript.file : import_node_path4.default.join(context.configDir, step.runScript.file);
|
|
2127
|
+
const fileContents = import_node_fs4.default.readFileSync(filePath, "utf-8");
|
|
2128
|
+
await context.page.evaluate(fileContents);
|
|
2129
|
+
stepResult = {
|
|
2130
|
+
type: "runScript",
|
|
2131
|
+
status: "pass",
|
|
2132
|
+
durationMs: Date.now() - stepStart,
|
|
2133
|
+
value: step.runScript.file
|
|
2134
|
+
};
|
|
2135
|
+
} else if ("assertScreenshot" in step) {
|
|
2136
|
+
const { compareScreenshots: compareScreenshots2, ensureBaselineDir: ensureBaselineDir2 } = await Promise.resolve().then(() => (init_visual(), visual_exports));
|
|
2137
|
+
const name = step.assertScreenshot.name;
|
|
2138
|
+
const threshold = step.assertScreenshot.threshold ?? 0.1;
|
|
2139
|
+
const baselineDir = ensureBaselineDir2(context.configDir);
|
|
2140
|
+
const baselinePath = import_node_path4.default.join(baselineDir, `${name}.png`);
|
|
2141
|
+
const currentScreenshotPath = import_node_path4.default.join(context.runDir, "screenshots", `${name}-current.png`);
|
|
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 = {
|
|
2148
|
+
type: "assertScreenshot",
|
|
2149
|
+
status: "pass",
|
|
2150
|
+
durationMs: Date.now() - stepStart,
|
|
2151
|
+
value: "baseline created"
|
|
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
|
+
);
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
} else if ("copyText" in step) {
|
|
2171
|
+
assertAllowedSelector(step.copyText.selector, context.forbiddenSelectors);
|
|
2172
|
+
const text = await context.page.locator(step.copyText.selector).textContent();
|
|
2173
|
+
if (text === null) {
|
|
2174
|
+
throw new Error(`No text content found for selector: ${step.copyText.selector}`);
|
|
2175
|
+
}
|
|
2176
|
+
runtimeVars.set(step.copyText.as, text);
|
|
2177
|
+
stepResult = {
|
|
2178
|
+
type: "copyText",
|
|
2179
|
+
status: "pass",
|
|
2180
|
+
durationMs: Date.now() - stepStart,
|
|
2181
|
+
selector: step.copyText.selector,
|
|
2182
|
+
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
|
+
}
|
|
2198
|
+
const savePath = import_node_path4.default.join(context.runDir, suggestedFilename);
|
|
2199
|
+
await download.saveAs(savePath);
|
|
2200
|
+
stepResult = {
|
|
2201
|
+
type: "waitForDownload",
|
|
2202
|
+
status: "pass",
|
|
2203
|
+
durationMs: Date.now() - stepStart,
|
|
2204
|
+
value: suggestedFilename
|
|
2205
|
+
};
|
|
2206
|
+
}
|
|
2207
|
+
if (!stepResult) {
|
|
2208
|
+
throw new Error("Unknown step type");
|
|
2209
|
+
}
|
|
2210
|
+
if (context.screenshotsMode === "all" && stepResult.type !== "screenshot") {
|
|
2211
|
+
const fileName = `step_${index + 1}.png`;
|
|
2212
|
+
await addScreenshot(fileName);
|
|
2213
|
+
}
|
|
2214
|
+
results.push(stepResult);
|
|
2215
|
+
context.onStep?.(stepResult, step, index);
|
|
2216
|
+
} catch (error) {
|
|
2217
|
+
const message = error instanceof Error ? error.message : "Step failed";
|
|
2218
|
+
stepResult = {
|
|
2219
|
+
type: stepResult?.type ?? stepType,
|
|
2220
|
+
status: "fail",
|
|
2221
|
+
durationMs: Date.now() - stepStart,
|
|
2222
|
+
error: message
|
|
2223
|
+
};
|
|
2224
|
+
if (context.screenshotsMode === "on-failure") {
|
|
2225
|
+
const fileName = `failure_step_${index + 1}.png`;
|
|
2226
|
+
await addScreenshot(fileName);
|
|
2227
|
+
}
|
|
2228
|
+
results.push(stepResult);
|
|
2229
|
+
context.onStep?.(stepResult, step, index);
|
|
2230
|
+
return { results, screenshots, failed: true, error: message };
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
return { results, screenshots, failed: false };
|
|
2234
|
+
}
|
|
2235
|
+
async function captureFinalScreenshot(page, runDir) {
|
|
2236
|
+
const screenshotsDir = import_node_path4.default.join(runDir, "screenshots");
|
|
2237
|
+
import_node_fs4.default.mkdirSync(screenshotsDir, { recursive: true });
|
|
2238
|
+
const fileName = "final.png";
|
|
2239
|
+
const filePath = screenshotPath(screenshotsDir, fileName);
|
|
2240
|
+
await captureScreenshot(page, filePath);
|
|
2241
|
+
return import_node_path4.default.join("screenshots", fileName);
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
// src/runner/assertions.ts
|
|
2245
|
+
function shouldIgnoreNetwork(url, patterns) {
|
|
2246
|
+
return patterns.some((pattern) => url.includes(pattern));
|
|
2247
|
+
}
|
|
2248
|
+
function filterNetworkEntries(entries, patterns) {
|
|
2249
|
+
if (patterns.length === 0) {
|
|
2250
|
+
return entries;
|
|
2251
|
+
}
|
|
2252
|
+
return entries.filter((entry) => !shouldIgnoreNetwork(entry.url, patterns));
|
|
2253
|
+
}
|
|
2254
|
+
function mergeAssertions(config, huntAssertions = []) {
|
|
2255
|
+
let noConsoleErrors = config.assertions.noConsoleErrors;
|
|
2256
|
+
let noNetworkErrors = config.assertions.noNetworkErrors;
|
|
2257
|
+
for (const assertion of huntAssertions) {
|
|
2258
|
+
if ("noConsoleErrors" in assertion) {
|
|
2259
|
+
noConsoleErrors = assertion.noConsoleErrors;
|
|
2260
|
+
}
|
|
2261
|
+
if ("noNetworkErrors" in assertion) {
|
|
2262
|
+
noNetworkErrors = assertion.noNetworkErrors;
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
const merged = [];
|
|
2266
|
+
if (noConsoleErrors) {
|
|
2267
|
+
merged.push({ noConsoleErrors: true });
|
|
2268
|
+
}
|
|
2269
|
+
if (noNetworkErrors) {
|
|
2270
|
+
merged.push({ noNetworkErrors: true });
|
|
2271
|
+
}
|
|
2272
|
+
for (const assertion of huntAssertions) {
|
|
2273
|
+
if ("noConsoleErrors" in assertion || "noNetworkErrors" in assertion) {
|
|
2274
|
+
continue;
|
|
2275
|
+
}
|
|
2276
|
+
merged.push(assertion);
|
|
2277
|
+
}
|
|
2278
|
+
return merged;
|
|
2279
|
+
}
|
|
2280
|
+
async function evaluateAssertions(options) {
|
|
2281
|
+
const assertions = mergeAssertions(options.config, options.huntAssertions);
|
|
2282
|
+
const results = [];
|
|
2283
|
+
const networkEntries = filterNetworkEntries(
|
|
2284
|
+
options.networkEntries,
|
|
2285
|
+
options.config.assertions.networkIgnorePatterns
|
|
2286
|
+
);
|
|
2287
|
+
for (const assertion of assertions) {
|
|
2288
|
+
try {
|
|
2289
|
+
if ("selectorExists" in assertion) {
|
|
2290
|
+
const count = await options.page.locator(assertion.selectorExists).count();
|
|
2291
|
+
results.push({
|
|
2292
|
+
type: "selectorExists",
|
|
2293
|
+
value: assertion.selectorExists,
|
|
2294
|
+
status: count > 0 ? "pass" : "fail",
|
|
2295
|
+
error: count > 0 ? void 0 : "Selector not found"
|
|
2296
|
+
});
|
|
2297
|
+
continue;
|
|
2298
|
+
}
|
|
2299
|
+
if ("selectorNotExists" in assertion) {
|
|
2300
|
+
const count = await options.page.locator(assertion.selectorNotExists).count();
|
|
2301
|
+
results.push({
|
|
2302
|
+
type: "selectorNotExists",
|
|
2303
|
+
value: assertion.selectorNotExists,
|
|
2304
|
+
status: count === 0 ? "pass" : "fail",
|
|
2305
|
+
error: count === 0 ? void 0 : "Selector exists"
|
|
2306
|
+
});
|
|
2307
|
+
continue;
|
|
2308
|
+
}
|
|
2309
|
+
if ("urlIncludes" in assertion) {
|
|
2310
|
+
const current = options.page.url();
|
|
2311
|
+
const pass = current.includes(assertion.urlIncludes);
|
|
2312
|
+
results.push({
|
|
2313
|
+
type: "urlIncludes",
|
|
2314
|
+
value: assertion.urlIncludes,
|
|
2315
|
+
status: pass ? "pass" : "fail",
|
|
2316
|
+
error: pass ? void 0 : `URL did not include ${assertion.urlIncludes}`
|
|
2317
|
+
});
|
|
2318
|
+
continue;
|
|
2319
|
+
}
|
|
2320
|
+
if ("urlEquals" in assertion) {
|
|
2321
|
+
const current = options.page.url();
|
|
2322
|
+
const pass = current === assertion.urlEquals;
|
|
2323
|
+
results.push({
|
|
2324
|
+
type: "urlEquals",
|
|
2325
|
+
value: assertion.urlEquals,
|
|
2326
|
+
status: pass ? "pass" : "fail",
|
|
2327
|
+
error: pass ? void 0 : `URL did not equal ${assertion.urlEquals}`
|
|
2328
|
+
});
|
|
2329
|
+
continue;
|
|
2330
|
+
}
|
|
2331
|
+
if ("noConsoleErrors" in assertion) {
|
|
2332
|
+
const errors = options.consoleEntries.filter((entry) => entry.type === "error");
|
|
2333
|
+
const pass = errors.length === 0;
|
|
2334
|
+
results.push({
|
|
2335
|
+
type: "noConsoleErrors",
|
|
2336
|
+
value: true,
|
|
2337
|
+
status: pass ? "pass" : "fail",
|
|
2338
|
+
error: pass ? void 0 : `${errors.length} console error(s)`
|
|
2339
|
+
});
|
|
2340
|
+
continue;
|
|
2341
|
+
}
|
|
2342
|
+
if ("noNetworkErrors" in assertion) {
|
|
2343
|
+
const pass = networkEntries.length === 0;
|
|
2344
|
+
results.push({
|
|
2345
|
+
type: "noNetworkErrors",
|
|
2346
|
+
value: true,
|
|
2347
|
+
status: pass ? "pass" : "fail",
|
|
2348
|
+
error: pass ? void 0 : `${networkEntries.length} network error(s)`
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
} catch (error) {
|
|
2352
|
+
const message = error instanceof Error ? error.message : "Assertion failed";
|
|
2353
|
+
const type = Object.keys(assertion)[0] ?? "assertion";
|
|
2354
|
+
results.push({
|
|
2355
|
+
type,
|
|
2356
|
+
status: "fail",
|
|
2357
|
+
error: message
|
|
2358
|
+
});
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
return results;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
// src/runner/tracing.ts
|
|
2365
|
+
var DEFAULT_TRACE_HEADER = "traceparent";
|
|
2366
|
+
function parseTraceId(headerValue) {
|
|
2367
|
+
const raw = headerValue.trim();
|
|
2368
|
+
if (raw.length === 0) return void 0;
|
|
2369
|
+
const parts = raw.split("-");
|
|
2370
|
+
if (parts.length >= 3 && /^[0-9a-f]{32}$/i.test(parts[1])) {
|
|
2371
|
+
return parts[1];
|
|
2372
|
+
}
|
|
2373
|
+
return raw;
|
|
2374
|
+
}
|
|
2375
|
+
function readHeader(headers, headerName) {
|
|
2376
|
+
const normalizedName = headerName.toLowerCase();
|
|
2377
|
+
return headers[normalizedName];
|
|
2378
|
+
}
|
|
2379
|
+
function redactValues(text, values) {
|
|
2380
|
+
let redacted = text;
|
|
2381
|
+
for (const value of values) {
|
|
2382
|
+
if (value.length === 0) continue;
|
|
2383
|
+
redacted = redacted.split(value).join("[REDACTED]");
|
|
2384
|
+
}
|
|
2385
|
+
return redacted;
|
|
2386
|
+
}
|
|
2387
|
+
function captureTraceCorrelation(response, headerName, sink, redactionValues = []) {
|
|
2388
|
+
const value = readHeader(response.headers(), headerName);
|
|
2389
|
+
if (!value) return;
|
|
2390
|
+
const traceId = parseTraceId(value);
|
|
2391
|
+
if (!traceId) return;
|
|
2392
|
+
sink.push({
|
|
2393
|
+
url: redactValues(response.url(), redactionValues),
|
|
2394
|
+
status: response.status(),
|
|
2395
|
+
traceId,
|
|
2396
|
+
header: value
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
// src/reporter/result.ts
|
|
2401
|
+
var import_node_fs5 = __toESM(require("fs"), 1);
|
|
2402
|
+
var import_node_path5 = __toESM(require("path"), 1);
|
|
2403
|
+
function writeResult(runDir, result) {
|
|
2404
|
+
const fileName = "result.json";
|
|
2405
|
+
const fullPath = import_node_path5.default.join(runDir, fileName);
|
|
2406
|
+
import_node_fs5.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
|
|
2407
|
+
return fileName;
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
// src/reporter/summary.ts
|
|
2411
|
+
var import_node_fs6 = __toESM(require("fs"), 1);
|
|
2412
|
+
var import_node_path6 = __toESM(require("path"), 1);
|
|
2413
|
+
function escapeMd(text) {
|
|
2414
|
+
return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
|
|
2415
|
+
}
|
|
2416
|
+
function formatStep(step) {
|
|
2417
|
+
const base = `- [${step.status.toUpperCase()}] ${step.type} (${step.durationMs}ms)`;
|
|
2418
|
+
const selector = step.selector ? ` selector=${step.selector}` : "";
|
|
2419
|
+
const healed = step.healedFrom ? ` healed-from=${escapeMd(step.healedFrom)}` : "";
|
|
2420
|
+
const value = step.value ? ` value=${escapeMd(step.value)}` : "";
|
|
2421
|
+
const error = step.error ? ` error=${escapeMd(step.error)}` : "";
|
|
2422
|
+
return `${base}${selector}${healed}${value}${error}`;
|
|
2423
|
+
}
|
|
2424
|
+
function formatAssertion(assertion) {
|
|
2425
|
+
const value = assertion.value !== void 0 ? ` value=${typeof assertion.value === "string" ? escapeMd(assertion.value) : assertion.value}` : "";
|
|
2426
|
+
const error = assertion.error ? ` error=${escapeMd(assertion.error)}` : "";
|
|
2427
|
+
return `- [${assertion.status.toUpperCase()}] ${assertion.type}${value}${error}`;
|
|
2428
|
+
}
|
|
2429
|
+
function writeSummary(runDir, result) {
|
|
2430
|
+
const lines = [];
|
|
2431
|
+
lines.push("# Prowl Run Summary");
|
|
2432
|
+
lines.push("");
|
|
2433
|
+
lines.push(`Status: ${result.status.toUpperCase()}`);
|
|
2434
|
+
lines.push(`Hunt: ${result.hunt}`);
|
|
2435
|
+
lines.push(`Target: ${result.targetUrl}`);
|
|
2436
|
+
lines.push(`Started: ${result.startedAt}`);
|
|
2437
|
+
lines.push(`Duration: ${result.durationMs}ms`);
|
|
2438
|
+
lines.push("");
|
|
2439
|
+
lines.push("## Steps");
|
|
2440
|
+
for (const step of result.steps) {
|
|
2441
|
+
lines.push(formatStep(step));
|
|
2442
|
+
}
|
|
2443
|
+
const healed = result.steps.filter((step) => step.healedFrom);
|
|
2444
|
+
if (healed.length > 0) {
|
|
2445
|
+
lines.push("");
|
|
2446
|
+
lines.push("## Self-Healed Selectors");
|
|
2447
|
+
lines.push("These selectors no longer matched and were auto-healed. Update your hunt to use the healed selector (or a stable `data-testid`):");
|
|
2448
|
+
for (const step of healed) {
|
|
2449
|
+
lines.push(`- ${escapeMd(step.healedFrom ?? "")} \u2192 ${escapeMd(step.selector ?? "")}`);
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
lines.push("");
|
|
2453
|
+
lines.push("## Assertions");
|
|
2454
|
+
for (const assertion of result.assertions) {
|
|
2455
|
+
lines.push(formatAssertion(assertion));
|
|
2456
|
+
}
|
|
2457
|
+
if (result.traceCorrelations && result.traceCorrelations.length > 0) {
|
|
2458
|
+
lines.push("");
|
|
2459
|
+
lines.push("## Trace Correlations");
|
|
2460
|
+
for (const correlation of result.traceCorrelations) {
|
|
2461
|
+
lines.push(
|
|
2462
|
+
`- [${correlation.status}] ${escapeMd(correlation.url)} traceId=${escapeMd(correlation.traceId)}`
|
|
2463
|
+
);
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
lines.push("");
|
|
2467
|
+
lines.push("## Artifacts");
|
|
2468
|
+
const artifacts = result.artifacts;
|
|
2469
|
+
if (artifacts.summary) {
|
|
2470
|
+
lines.push(`- summary: ${artifacts.summary}`);
|
|
2471
|
+
}
|
|
2472
|
+
if (artifacts.console) {
|
|
2473
|
+
lines.push(`- console: ${artifacts.console}`);
|
|
2474
|
+
}
|
|
2475
|
+
if (artifacts.trace) {
|
|
2476
|
+
lines.push(`- trace: ${artifacts.trace}`);
|
|
2477
|
+
}
|
|
2478
|
+
if (artifacts.networkHar) {
|
|
2479
|
+
lines.push(`- network: ${artifacts.networkHar}`);
|
|
2480
|
+
}
|
|
2481
|
+
if (artifacts.junit) {
|
|
2482
|
+
lines.push(`- junit: ${artifacts.junit}`);
|
|
2483
|
+
}
|
|
2484
|
+
if (artifacts.screenshots && artifacts.screenshots.length > 0) {
|
|
2485
|
+
for (const screenshot of artifacts.screenshots) {
|
|
2486
|
+
lines.push(`- screenshot: ${screenshot}`);
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
const fileName = "summary.md";
|
|
2490
|
+
const fullPath = import_node_path6.default.join(runDir, fileName);
|
|
2491
|
+
import_node_fs6.default.writeFileSync(fullPath, `${lines.join("\n")}
|
|
2492
|
+
`);
|
|
2493
|
+
return fileName;
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
// src/reporter/junit.ts
|
|
2497
|
+
var import_node_fs7 = __toESM(require("fs"), 1);
|
|
2498
|
+
var import_node_path7 = __toESM(require("path"), 1);
|
|
2499
|
+
function escapeXml(text) {
|
|
2500
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2501
|
+
}
|
|
2502
|
+
function writeJunit(runDir, result) {
|
|
2503
|
+
const totalTests = result.steps.length + result.assertions.length;
|
|
2504
|
+
const failures = result.steps.filter((s) => s.status === "fail").length + result.assertions.filter((a) => a.status === "fail").length;
|
|
2505
|
+
const timeSeconds = (result.durationMs / 1e3).toFixed(3);
|
|
2506
|
+
const huntName = escapeXml(result.hunt);
|
|
2507
|
+
const lines = [];
|
|
2508
|
+
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
2509
|
+
lines.push("<testsuites>");
|
|
2510
|
+
lines.push(
|
|
2511
|
+
` <testsuite name="${huntName}" tests="${totalTests}" failures="${failures}" errors="0" time="${timeSeconds}" timestamp="${escapeXml(result.startedAt)}">`
|
|
2512
|
+
);
|
|
2513
|
+
for (let i = 0; i < result.steps.length; i++) {
|
|
2514
|
+
const step = result.steps[i];
|
|
2515
|
+
const stepTime = (step.durationMs / 1e3).toFixed(3);
|
|
2516
|
+
const caseName = escapeXml(`step ${i + 1}: ${step.type}`);
|
|
2517
|
+
if (step.status === "fail") {
|
|
2518
|
+
const failureText = step.error ?? `Step ${step.type} failed with no error provided`;
|
|
2519
|
+
const escapedFailureText = escapeXml(failureText);
|
|
2520
|
+
lines.push(` <testcase name="${caseName}" classname="${huntName}" time="${stepTime}">`);
|
|
2521
|
+
lines.push(` <failure message="${escapedFailureText}" type="step">${escapedFailureText}</failure>`);
|
|
2522
|
+
lines.push(" </testcase>");
|
|
2523
|
+
} else {
|
|
2524
|
+
lines.push(` <testcase name="${caseName}" classname="${huntName}" time="${stepTime}"/>`);
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
for (const assertion of result.assertions) {
|
|
2528
|
+
const caseName = escapeXml(`assertion: ${assertion.type}`);
|
|
2529
|
+
if (assertion.status === "fail") {
|
|
2530
|
+
const failureText = assertion.error ?? `Assertion ${assertion.type} failed with no error provided`;
|
|
2531
|
+
const escapedFailureText = escapeXml(failureText);
|
|
2532
|
+
lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0">`);
|
|
2533
|
+
lines.push(` <failure message="${escapedFailureText}" type="assertion">${escapedFailureText}</failure>`);
|
|
2534
|
+
lines.push(" </testcase>");
|
|
2535
|
+
} else {
|
|
2536
|
+
lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0"/>`);
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
lines.push(" </testsuite>");
|
|
2540
|
+
lines.push("</testsuites>");
|
|
2541
|
+
const fileName = "junit.xml";
|
|
2542
|
+
const fullPath = import_node_path7.default.join(runDir, fileName);
|
|
2543
|
+
import_node_fs7.default.writeFileSync(fullPath, `${lines.join("\n")}
|
|
2544
|
+
`);
|
|
2545
|
+
return fileName;
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
// src/reporter/index.ts
|
|
2549
|
+
function writeReports(runDir, result, options) {
|
|
2550
|
+
const summary = writeSummary(runDir, result);
|
|
2551
|
+
const updated = {
|
|
2552
|
+
...result,
|
|
2553
|
+
artifacts: {
|
|
2554
|
+
...result.artifacts,
|
|
2555
|
+
summary
|
|
2556
|
+
}
|
|
2557
|
+
};
|
|
2558
|
+
if (options?.junit) {
|
|
2559
|
+
updated.artifacts.junit = writeJunit(runDir, updated);
|
|
2560
|
+
}
|
|
2561
|
+
writeResult(runDir, updated);
|
|
2562
|
+
return updated;
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
// src/utils/timestamp.ts
|
|
2566
|
+
function timestamp(prefix) {
|
|
2567
|
+
const now = /* @__PURE__ */ new Date();
|
|
2568
|
+
const pad = (value) => value.toString().padStart(2, "0");
|
|
2569
|
+
const pad3 = (value) => value.toString().padStart(3, "0");
|
|
2570
|
+
const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(
|
|
2571
|
+
now.getHours()
|
|
2572
|
+
)}-${pad(now.getMinutes())}-${pad(now.getSeconds())}-${pad3(now.getMilliseconds())}`;
|
|
2573
|
+
return prefix ? `${prefix}-${ts}` : ts;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
// src/runner/history.ts
|
|
2577
|
+
var import_node_fs8 = __toESM(require("fs"), 1);
|
|
2578
|
+
var import_node_path8 = __toESM(require("path"), 1);
|
|
2579
|
+
var HISTORY_FILE = "history.json";
|
|
2580
|
+
var LOCK_FILE_SUFFIX = ".lock";
|
|
2581
|
+
var LOCK_RETRY_MS = 10;
|
|
2582
|
+
var LOCK_TIMEOUT_MS = 5e3;
|
|
2583
|
+
var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
2584
|
+
function historyPath(configDir) {
|
|
2585
|
+
return import_node_path8.default.join(configDir, HISTORY_FILE);
|
|
2586
|
+
}
|
|
2587
|
+
function isHistoryEntry(value) {
|
|
2588
|
+
if (!value || typeof value !== "object") {
|
|
2589
|
+
return false;
|
|
2590
|
+
}
|
|
2591
|
+
const entry = value;
|
|
2592
|
+
return typeof entry.hunt === "string" && (entry.status === "pass" || entry.status === "fail") && typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && typeof entry.startedAt === "string" && (entry.runDir === void 0 || typeof entry.runDir === "string");
|
|
2593
|
+
}
|
|
2594
|
+
function readHistory(configDir) {
|
|
2595
|
+
const filePath = historyPath(configDir);
|
|
2596
|
+
if (!import_node_fs8.default.existsSync(filePath)) {
|
|
2597
|
+
return { entries: [] };
|
|
2598
|
+
}
|
|
2599
|
+
try {
|
|
2600
|
+
const raw = import_node_fs8.default.readFileSync(filePath, "utf-8");
|
|
2601
|
+
const parsed = JSON.parse(raw);
|
|
2602
|
+
if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
|
|
2603
|
+
const validatedEntries = parsed.entries.filter(isHistoryEntry);
|
|
2604
|
+
return { entries: validatedEntries };
|
|
2605
|
+
}
|
|
2606
|
+
return { entries: [] };
|
|
2607
|
+
} catch (error) {
|
|
2608
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2609
|
+
console.warn(`Failed to read history file at ${filePath}: ${message}`);
|
|
2610
|
+
return { entries: [] };
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
function readHuntHistory(configDir, huntName) {
|
|
2614
|
+
const { entries } = readHistory(configDir);
|
|
2615
|
+
return entries.filter((entry) => entry.hunt === huntName);
|
|
2616
|
+
}
|
|
2617
|
+
function pruneEntries(entries, maxRuns) {
|
|
2618
|
+
const perHunt = /* @__PURE__ */ new Map();
|
|
2619
|
+
for (const entry of entries) {
|
|
2620
|
+
const list = perHunt.get(entry.hunt) ?? [];
|
|
2621
|
+
list.push(entry);
|
|
2622
|
+
perHunt.set(entry.hunt, list);
|
|
2623
|
+
}
|
|
2624
|
+
const keptEntries = /* @__PURE__ */ new Set();
|
|
2625
|
+
for (const list of perHunt.values()) {
|
|
2626
|
+
const kept = list.length > maxRuns ? list.slice(list.length - maxRuns) : list;
|
|
2627
|
+
for (const entry of kept) {
|
|
2628
|
+
keptEntries.add(entry);
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
return entries.filter((entry) => keptEntries.has(entry));
|
|
2632
|
+
}
|
|
2633
|
+
function sleepSync(ms) {
|
|
2634
|
+
Atomics.wait(SLEEP_BUFFER, 0, 0, ms);
|
|
2635
|
+
}
|
|
2636
|
+
function withHistoryLock(configDir, fn) {
|
|
2637
|
+
const filePath = historyPath(configDir);
|
|
2638
|
+
const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
|
|
2639
|
+
import_node_fs8.default.mkdirSync(import_node_path8.default.dirname(filePath), { recursive: true });
|
|
2640
|
+
const startedAt = Date.now();
|
|
2641
|
+
while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
|
|
2642
|
+
let fd;
|
|
2643
|
+
try {
|
|
2644
|
+
fd = import_node_fs8.default.openSync(lockPath, "wx");
|
|
2645
|
+
} catch (error) {
|
|
2646
|
+
if (error.code === "EEXIST") {
|
|
2647
|
+
sleepSync(LOCK_RETRY_MS);
|
|
2648
|
+
continue;
|
|
2649
|
+
}
|
|
2650
|
+
throw error;
|
|
2651
|
+
}
|
|
2652
|
+
try {
|
|
2653
|
+
return fn();
|
|
2654
|
+
} finally {
|
|
2655
|
+
try {
|
|
2656
|
+
import_node_fs8.default.closeSync(fd);
|
|
2657
|
+
} catch {
|
|
2658
|
+
}
|
|
2659
|
+
import_node_fs8.default.rmSync(lockPath, { force: true });
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
throw new Error(
|
|
2663
|
+
`Failed to acquire history lock before timeout (${LOCK_TIMEOUT_MS}ms): ${lockPath}; started waiting at ${new Date(startedAt).toISOString()}`
|
|
2664
|
+
);
|
|
2665
|
+
}
|
|
2666
|
+
function appendEntry(configDir, entry, maxRuns) {
|
|
2667
|
+
const filePath = historyPath(configDir);
|
|
2668
|
+
withHistoryLock(configDir, () => {
|
|
2669
|
+
const current = readHistory(configDir);
|
|
2670
|
+
const next = pruneEntries([...current.entries, entry], maxRuns);
|
|
2671
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
2672
|
+
import_node_fs8.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
|
|
2673
|
+
`);
|
|
2674
|
+
import_node_fs8.default.renameSync(tempPath, filePath);
|
|
2675
|
+
});
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
// src/runner/index.ts
|
|
2679
|
+
function parseViewportFlag(value) {
|
|
2680
|
+
const match = /^(\d+)x(\d+)$/i.exec(value);
|
|
2681
|
+
if (match) {
|
|
2682
|
+
return { width: Number(match[1]), height: Number(match[2]) };
|
|
2683
|
+
}
|
|
2684
|
+
return value;
|
|
2685
|
+
}
|
|
2686
|
+
function resolvePath(configDir, inputPath) {
|
|
2687
|
+
if (import_node_path9.default.isAbsolute(inputPath)) {
|
|
2688
|
+
return inputPath;
|
|
2689
|
+
}
|
|
2690
|
+
const projectRoot = import_node_path9.default.dirname(configDir);
|
|
2691
|
+
return import_node_path9.default.join(projectRoot, inputPath);
|
|
2692
|
+
}
|
|
2693
|
+
function buildRunResult(options) {
|
|
2694
|
+
return {
|
|
2695
|
+
status: options.status,
|
|
2696
|
+
exitCode: options.status === "pass" ? 0 : 1,
|
|
2697
|
+
startedAt: options.startedAt,
|
|
2698
|
+
durationMs: options.durationMs,
|
|
2699
|
+
hunt: options.hunt,
|
|
2700
|
+
targetUrl: options.targetUrl,
|
|
2701
|
+
steps: options.steps,
|
|
2702
|
+
assertions: options.assertions,
|
|
2703
|
+
artifacts: options.artifacts,
|
|
2704
|
+
// Omit entirely when there are no correlations, so passing/clean runs stay tidy.
|
|
2705
|
+
...options.traceCorrelations && options.traceCorrelations.length > 0 ? { traceCorrelations: options.traceCorrelations } : {}
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
function writeConsoleLog(runDir, entries) {
|
|
2709
|
+
const fileName = "console.log";
|
|
2710
|
+
const filePath = import_node_path9.default.join(runDir, fileName);
|
|
2711
|
+
const lines = entries.map((entry) => {
|
|
2712
|
+
const location = entry.location ? ` (${entry.location})` : "";
|
|
2713
|
+
return `[${entry.type}] ${entry.text}${location}`;
|
|
2714
|
+
});
|
|
2715
|
+
import_node_fs9.default.writeFileSync(filePath, `${lines.join("\n")}
|
|
2716
|
+
`);
|
|
2717
|
+
return fileName;
|
|
2718
|
+
}
|
|
2719
|
+
async function executeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, redactionValues, targetUrl, allowedDomains) {
|
|
2720
|
+
const headless = options.headed ? false : config.browser.headless;
|
|
2721
|
+
const slowMo = options.slowMo ?? config.browser.slowMo;
|
|
2722
|
+
const maxSteps = config.guardrails.maxSteps;
|
|
2723
|
+
const runDir = import_node_path9.default.join(configDir, "runs", timestamp());
|
|
2724
|
+
import_node_fs9.default.mkdirSync(runDir, { recursive: true });
|
|
2725
|
+
const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
|
|
2726
|
+
const engine = options.browser ?? config.browser.engine;
|
|
2727
|
+
const channel = options.channel ?? config.browser.channel;
|
|
2728
|
+
const viewport = options.viewport ? resolveViewport(parseViewportFlag(options.viewport)) : config.browser.viewport;
|
|
2729
|
+
const session = await launchBrowser({
|
|
2730
|
+
headless,
|
|
2731
|
+
slowMo,
|
|
2732
|
+
timeout: config.browser.timeout,
|
|
2733
|
+
storageStatePath,
|
|
2734
|
+
trace: Boolean(options.trace),
|
|
2735
|
+
recordHar: config.artifacts.networkHar,
|
|
2736
|
+
runDir,
|
|
2737
|
+
engine,
|
|
2738
|
+
channel,
|
|
2739
|
+
viewport
|
|
2740
|
+
});
|
|
2741
|
+
let result;
|
|
2742
|
+
try {
|
|
2743
|
+
const consoleEntries = [];
|
|
2744
|
+
const networkEntries = [];
|
|
2745
|
+
const traceCorrelations = [];
|
|
2746
|
+
const traceHeader = config.tracing?.header ?? DEFAULT_TRACE_HEADER;
|
|
2747
|
+
session.page.on("console", (message) => {
|
|
2748
|
+
consoleEntries.push({
|
|
2749
|
+
type: message.type(),
|
|
2750
|
+
text: message.text(),
|
|
2751
|
+
location: message.location().url
|
|
2752
|
+
});
|
|
2753
|
+
});
|
|
2754
|
+
session.page.on("response", (response) => {
|
|
2755
|
+
if (response.status() >= 400) {
|
|
2756
|
+
networkEntries.push({ url: response.url(), status: response.status() });
|
|
2757
|
+
captureTraceCorrelation(response, traceHeader, traceCorrelations, redactionValues);
|
|
2758
|
+
}
|
|
2759
|
+
});
|
|
2760
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2761
|
+
const startTime = Date.now();
|
|
2762
|
+
let stepResults = [];
|
|
2763
|
+
let stepScreenshots = [];
|
|
2764
|
+
let stepFailed = false;
|
|
2765
|
+
try {
|
|
2766
|
+
const stepExecution = await executeSteps({
|
|
2767
|
+
page: session.page,
|
|
2768
|
+
steps: interpolatedHunt.steps,
|
|
2769
|
+
targetUrl,
|
|
2770
|
+
runDir,
|
|
2771
|
+
screenshotsMode: config.artifacts.screenshots,
|
|
2772
|
+
forbiddenSelectors: config.guardrails.forbiddenSelectors,
|
|
2773
|
+
allowedDomains,
|
|
2774
|
+
maxSteps,
|
|
2775
|
+
maxTotalTimeMs: config.assertions.maxTotalTimeMs,
|
|
2776
|
+
selfHealing: config.guardrails.selfHealing,
|
|
2777
|
+
redactedFillSteps,
|
|
2778
|
+
randomVars,
|
|
2779
|
+
configDir,
|
|
2780
|
+
huntStack: [options.huntName],
|
|
2781
|
+
onStep: options.onStep
|
|
2782
|
+
});
|
|
2783
|
+
stepResults = stepExecution.results;
|
|
2784
|
+
stepScreenshots = stepExecution.screenshots;
|
|
2785
|
+
stepFailed = stepExecution.failed;
|
|
2786
|
+
} catch (error) {
|
|
2787
|
+
const message = error instanceof Error ? error.message : "Step execution failed";
|
|
2788
|
+
stepResults = [
|
|
2789
|
+
{
|
|
2790
|
+
type: "steps",
|
|
2791
|
+
status: "fail",
|
|
2792
|
+
durationMs: 0,
|
|
2793
|
+
error: message
|
|
2794
|
+
}
|
|
2795
|
+
];
|
|
2796
|
+
stepFailed = true;
|
|
2797
|
+
}
|
|
2798
|
+
let finalScreenshot;
|
|
2799
|
+
try {
|
|
2800
|
+
finalScreenshot = await captureFinalScreenshot(session.page, runDir);
|
|
2801
|
+
} catch {
|
|
2802
|
+
finalScreenshot = void 0;
|
|
2803
|
+
}
|
|
2804
|
+
const assertionResults = await evaluateAssertions({
|
|
2805
|
+
page: session.page,
|
|
2806
|
+
config,
|
|
2807
|
+
huntAssertions: interpolatedHunt.assertions,
|
|
2808
|
+
consoleEntries,
|
|
2809
|
+
networkEntries
|
|
2810
|
+
});
|
|
2811
|
+
const durationMs = Date.now() - startTime;
|
|
2812
|
+
const assertionsFailed = assertionResults.some((assertion) => assertion.status === "fail");
|
|
2813
|
+
const status = stepFailed || assertionsFailed ? "fail" : "pass";
|
|
2814
|
+
const artifacts = {
|
|
2815
|
+
screenshots: finalScreenshot ? [...stepScreenshots, finalScreenshot] : stepScreenshots,
|
|
2816
|
+
trace: session.tracePath ? "trace.zip" : void 0,
|
|
2817
|
+
networkHar: config.artifacts.networkHar ? "network.har" : void 0
|
|
2818
|
+
};
|
|
2819
|
+
if (config.artifacts.console) {
|
|
2820
|
+
artifacts.console = writeConsoleLog(runDir, consoleEntries);
|
|
2821
|
+
}
|
|
2822
|
+
const runResult = buildRunResult({
|
|
2823
|
+
status,
|
|
2824
|
+
startedAt,
|
|
2825
|
+
durationMs,
|
|
2826
|
+
hunt: options.huntName,
|
|
2827
|
+
targetUrl,
|
|
2828
|
+
steps: stepResults,
|
|
2829
|
+
assertions: assertionResults,
|
|
2830
|
+
artifacts,
|
|
2831
|
+
traceCorrelations
|
|
2832
|
+
});
|
|
2833
|
+
result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
|
|
2834
|
+
} finally {
|
|
2835
|
+
await closeBrowser(session);
|
|
2836
|
+
}
|
|
2837
|
+
return { result, runDir, steps: interpolatedHunt.steps };
|
|
2838
|
+
}
|
|
2839
|
+
function delay(ms) {
|
|
2840
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2841
|
+
}
|
|
2842
|
+
async function runHunt(options) {
|
|
2843
|
+
const { config, configDir } = loadConfig(options.configPath);
|
|
2844
|
+
const hunt = loadHunt(options.huntName, configDir);
|
|
2845
|
+
const {
|
|
2846
|
+
hunt: interpolatedHunt,
|
|
2847
|
+
redactedFillSteps,
|
|
2848
|
+
randomVars,
|
|
2849
|
+
redactionValues = []
|
|
2850
|
+
} = interpolateHunt(
|
|
2851
|
+
hunt,
|
|
2852
|
+
process.env
|
|
2853
|
+
);
|
|
2854
|
+
const targetUrl = options.urlOverride ?? config.target.url;
|
|
2855
|
+
const allowedDomains = ensureAllowedDomain([...config.guardrails.allowedDomains], targetUrl);
|
|
2856
|
+
const maxSteps = config.guardrails.maxSteps;
|
|
2857
|
+
if (interpolatedHunt.steps.length > maxSteps) {
|
|
2858
|
+
throw new Error(`Hunt has ${interpolatedHunt.steps.length} steps. Max allowed is ${maxSteps}.`);
|
|
2859
|
+
}
|
|
2860
|
+
const maxRetries = hunt.retry?.maxRetries ?? 0;
|
|
2861
|
+
const retryDelay = hunt.retry?.delay ?? 0;
|
|
2862
|
+
let lastResult;
|
|
2863
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
2864
|
+
if (attempt > 0 && retryDelay > 0) {
|
|
2865
|
+
await delay(retryDelay);
|
|
2866
|
+
}
|
|
2867
|
+
lastResult = await executeHuntAttempt(
|
|
2868
|
+
options,
|
|
2869
|
+
config,
|
|
2870
|
+
configDir,
|
|
2871
|
+
interpolatedHunt,
|
|
2872
|
+
redactedFillSteps,
|
|
2873
|
+
randomVars,
|
|
2874
|
+
redactionValues,
|
|
2875
|
+
targetUrl,
|
|
2876
|
+
allowedDomains
|
|
2877
|
+
);
|
|
2878
|
+
if (lastResult.result.status === "pass") {
|
|
2879
|
+
if (attempt > 0) {
|
|
2880
|
+
lastResult.result.artifacts.summary = `Passed on attempt ${attempt + 1} of ${maxRetries + 1}`;
|
|
2881
|
+
}
|
|
2882
|
+
recordHistory(configDir, lastResult, config.history.maxRuns);
|
|
2883
|
+
return lastResult;
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
if (maxRetries > 0 && lastResult) {
|
|
2887
|
+
lastResult.result.artifacts.summary = `Failed after ${maxRetries + 1} attempts`;
|
|
2888
|
+
}
|
|
2889
|
+
if (lastResult) {
|
|
2890
|
+
recordHistory(configDir, lastResult, config.history.maxRuns);
|
|
2891
|
+
}
|
|
2892
|
+
return lastResult;
|
|
2893
|
+
}
|
|
2894
|
+
function recordHistory(configDir, outcome, maxRuns) {
|
|
2895
|
+
try {
|
|
2896
|
+
const relativeRunDir = import_node_path9.default.relative(configDir, outcome.runDir);
|
|
2897
|
+
appendEntry(
|
|
2898
|
+
configDir,
|
|
2899
|
+
{
|
|
2900
|
+
hunt: outcome.result.hunt,
|
|
2901
|
+
status: outcome.result.status,
|
|
2902
|
+
durationMs: outcome.result.durationMs,
|
|
2903
|
+
startedAt: outcome.result.startedAt,
|
|
2904
|
+
runDir: relativeRunDir || void 0
|
|
2905
|
+
},
|
|
2906
|
+
maxRuns
|
|
2907
|
+
);
|
|
2908
|
+
} catch {
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
// src/runner/suite.ts
|
|
2913
|
+
var import_node_path12 = __toESM(require("path"), 1);
|
|
2914
|
+
|
|
2915
|
+
// src/reporter/ci-summary.ts
|
|
2916
|
+
var import_node_fs10 = __toESM(require("fs"), 1);
|
|
2917
|
+
var import_node_path10 = __toESM(require("path"), 1);
|
|
2918
|
+
var import_chalk = __toESM(require("chalk"), 1);
|
|
2919
|
+
function countCiResults(results) {
|
|
2920
|
+
return {
|
|
2921
|
+
passed: results.filter((r) => r.status === "pass").length,
|
|
2922
|
+
failed: results.filter((r) => r.status === "fail").length,
|
|
2923
|
+
skipped: results.filter((r) => r.status === "skipped").length
|
|
2924
|
+
};
|
|
2925
|
+
}
|
|
2926
|
+
function resolveCiStatus(results) {
|
|
2927
|
+
if (results.length === 0) return "no-hunts";
|
|
2928
|
+
const { failed, passed } = countCiResults(results);
|
|
2929
|
+
if (failed > 0) return "fail";
|
|
2930
|
+
if (passed > 0) return "pass";
|
|
2931
|
+
return "all-skipped";
|
|
2932
|
+
}
|
|
2933
|
+
function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = [], clusters = []) {
|
|
2934
|
+
const { passed, failed, skipped } = countCiResults(results);
|
|
2935
|
+
const ciResult = {
|
|
2936
|
+
status: resolveCiStatus(results),
|
|
2937
|
+
startedAt,
|
|
2938
|
+
durationMs: totalDurationMs,
|
|
2939
|
+
totalHunts: results.length,
|
|
2940
|
+
passed,
|
|
2941
|
+
failed,
|
|
2942
|
+
skipped,
|
|
2943
|
+
hunts: results,
|
|
2944
|
+
...flaky.length > 0 ? { flaky } : {},
|
|
2945
|
+
...clusters.length > 0 ? { clusters } : {}
|
|
2946
|
+
};
|
|
2947
|
+
import_node_fs10.default.mkdirSync(ciRunDir, { recursive: true });
|
|
2948
|
+
const filePath = import_node_path10.default.join(ciRunDir, "ci-result.json");
|
|
2949
|
+
import_node_fs10.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
|
|
2950
|
+
return filePath;
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
// src/utils/concurrency.ts
|
|
2954
|
+
async function runWithConcurrency(tasks, concurrency) {
|
|
2955
|
+
const normalizedConcurrency = Number.isFinite(concurrency) && concurrency > 0 ? Math.floor(concurrency) : 1;
|
|
2956
|
+
const results = new Array(tasks.length);
|
|
2957
|
+
let nextIndex = 0;
|
|
2958
|
+
async function worker() {
|
|
2959
|
+
while (nextIndex < tasks.length) {
|
|
2960
|
+
const index = nextIndex;
|
|
2961
|
+
nextIndex += 1;
|
|
2962
|
+
try {
|
|
2963
|
+
const value = await tasks[index]();
|
|
2964
|
+
results[index] = { status: "fulfilled", value };
|
|
2965
|
+
} catch (reason) {
|
|
2966
|
+
results[index] = { status: "rejected", reason };
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
const workers = Array.from(
|
|
2971
|
+
{ length: Math.min(normalizedConcurrency, tasks.length) },
|
|
2972
|
+
() => worker()
|
|
2973
|
+
);
|
|
2974
|
+
await Promise.all(workers);
|
|
2975
|
+
return results;
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
// src/runner/flaky.ts
|
|
2979
|
+
var DEFAULT_FLAKY_THRESHOLD = 0.3;
|
|
2980
|
+
function computeFlakeScore(entries, lastN) {
|
|
2981
|
+
const slice = lastN !== void 0 && lastN > 0 ? entries.slice(-lastN) : entries;
|
|
2982
|
+
if (slice.length < 2) return 0;
|
|
2983
|
+
let transitions = 0;
|
|
2984
|
+
for (let i = 1; i < slice.length; i++) {
|
|
2985
|
+
if (slice[i].status !== slice[i - 1].status) {
|
|
2986
|
+
transitions += 1;
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
return transitions / (slice.length - 1);
|
|
2990
|
+
}
|
|
2991
|
+
function rankFlaky(configDir, options = {}) {
|
|
2992
|
+
const threshold = options.threshold ?? DEFAULT_FLAKY_THRESHOLD;
|
|
2993
|
+
const { entries } = readHistory(configDir);
|
|
2994
|
+
const byHunt = /* @__PURE__ */ new Map();
|
|
2995
|
+
for (const entry of entries) {
|
|
2996
|
+
const list = byHunt.get(entry.hunt) ?? [];
|
|
2997
|
+
list.push(entry);
|
|
2998
|
+
byHunt.set(entry.hunt, list);
|
|
2999
|
+
}
|
|
3000
|
+
const scores = [];
|
|
3001
|
+
for (const [hunt, huntEntries] of byHunt) {
|
|
3002
|
+
const considered = options.lastN !== void 0 && options.lastN > 0 ? huntEntries.slice(-options.lastN) : huntEntries;
|
|
3003
|
+
const score = computeFlakeScore(considered);
|
|
3004
|
+
scores.push({
|
|
3005
|
+
hunt,
|
|
3006
|
+
score,
|
|
3007
|
+
runs: considered.length,
|
|
3008
|
+
flaky: score >= threshold
|
|
3009
|
+
});
|
|
3010
|
+
}
|
|
3011
|
+
scores.sort((a, b) => b.score - a.score || b.runs - a.runs || a.hunt.localeCompare(b.hunt));
|
|
3012
|
+
return scores;
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
// src/backlog/fingerprint.ts
|
|
3016
|
+
var import_node_crypto2 = require("crypto");
|
|
3017
|
+
function normalizeError(error) {
|
|
3018
|
+
return error.toLowerCase().replace(/0x[0-9a-f]+/g, "").replace(/\b\d+(?:\.\d+)?\s*(?:ms|s|px)\b/g, "").replace(/\s+/g, " ").trim();
|
|
3019
|
+
}
|
|
3020
|
+
function stepLabel(failure) {
|
|
3021
|
+
if (failure.stepType === void 0 && failure.selector === void 0) {
|
|
3022
|
+
return "-";
|
|
3023
|
+
}
|
|
3024
|
+
const index = failure.stepIndex === void 0 ? "?" : String(failure.stepIndex);
|
|
3025
|
+
const type = failure.stepType ?? "?";
|
|
3026
|
+
const selector = failure.selector ? `@${failure.selector}` : "";
|
|
3027
|
+
return `${index}:${type}${selector}`;
|
|
3028
|
+
}
|
|
3029
|
+
function sanitizeMarkerValue(value) {
|
|
3030
|
+
return value.replace(/\r?\n/g, " ").replace(/-->/g, "-->").trim();
|
|
3031
|
+
}
|
|
3032
|
+
function computeFingerprint(failure) {
|
|
3033
|
+
const parts = [
|
|
3034
|
+
failure.hunt,
|
|
3035
|
+
failure.stepType ?? "",
|
|
3036
|
+
failure.selector ?? "",
|
|
3037
|
+
normalizeError(failure.error)
|
|
3038
|
+
];
|
|
3039
|
+
return (0, import_node_crypto2.createHash)("sha1").update(parts.join("|")).digest("hex").slice(0, 8);
|
|
3040
|
+
}
|
|
3041
|
+
function buildMarker(failure, hash) {
|
|
3042
|
+
const hunt = sanitizeMarkerValue(failure.hunt);
|
|
3043
|
+
const step = sanitizeMarkerValue(stepLabel(failure));
|
|
3044
|
+
return `<!-- prowl:fp=${hash} hunt=${hunt} step=${step} -->`;
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
// src/runner/clustering.ts
|
|
3048
|
+
function clusterKey(failure, normalizedError) {
|
|
3049
|
+
return [failure.stepType ?? "", failure.selector ?? "", normalizedError].join("|");
|
|
3050
|
+
}
|
|
3051
|
+
function describeCause(failure, error) {
|
|
3052
|
+
const where = failure.stepType ? `${failure.stepType}${failure.selector ? ` (${failure.selector})` : ""}` : "run";
|
|
3053
|
+
return `${where}: ${error}`;
|
|
3054
|
+
}
|
|
3055
|
+
function clusterFailures(failures) {
|
|
3056
|
+
const groups = /* @__PURE__ */ new Map();
|
|
3057
|
+
for (const failure of failures) {
|
|
3058
|
+
const error = normalizeError(failure.error);
|
|
3059
|
+
const key = clusterKey(failure, error);
|
|
3060
|
+
const existing = groups.get(key);
|
|
3061
|
+
if (existing) {
|
|
3062
|
+
existing.hunts.add(failure.hunt);
|
|
3063
|
+
} else {
|
|
3064
|
+
groups.set(key, { sample: failure, error, hunts: /* @__PURE__ */ new Set([failure.hunt]) });
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
const clusters = [];
|
|
3068
|
+
for (const { sample, error, hunts } of groups.values()) {
|
|
3069
|
+
clusters.push({
|
|
3070
|
+
cause: describeCause(sample, error),
|
|
3071
|
+
stepType: sample.stepType,
|
|
3072
|
+
selector: sample.selector,
|
|
3073
|
+
error,
|
|
3074
|
+
count: hunts.size,
|
|
3075
|
+
hunts: [...hunts].sort()
|
|
3076
|
+
});
|
|
3077
|
+
}
|
|
3078
|
+
clusters.sort((a, b) => b.count - a.count || a.cause.localeCompare(b.cause));
|
|
3079
|
+
return clusters;
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
// src/backlog/index.ts
|
|
3083
|
+
var import_node_fs11 = __toESM(require("fs"), 1);
|
|
3084
|
+
var import_node_path11 = __toESM(require("path"), 1);
|
|
3085
|
+
|
|
3086
|
+
// src/backlog/parse.ts
|
|
3087
|
+
var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
|
|
3088
|
+
var TICKET_ID = /\bQA-(\d+)\b/;
|
|
3089
|
+
var TICKET_ID_GLOBAL = /\bQA-(\d+)\b/g;
|
|
3090
|
+
var HEADING = /^#{1,6}\s/;
|
|
3091
|
+
function extractFingerprints(content) {
|
|
3092
|
+
const map = /* @__PURE__ */ new Map();
|
|
3093
|
+
let currentId;
|
|
3094
|
+
for (const line of content.split("\n")) {
|
|
3095
|
+
if (HEADING.test(line)) {
|
|
3096
|
+
const idMatch = TICKET_ID.exec(line);
|
|
3097
|
+
currentId = idMatch ? `QA-${idMatch[1]}` : void 0;
|
|
3098
|
+
}
|
|
3099
|
+
const fpMatch = MARKER_FP.exec(line);
|
|
3100
|
+
if (fpMatch && currentId) {
|
|
3101
|
+
map.set(fpMatch[1], currentId);
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
return map;
|
|
3105
|
+
}
|
|
3106
|
+
function nextTicketId(contents) {
|
|
3107
|
+
let max = 0;
|
|
3108
|
+
for (const content of contents) {
|
|
3109
|
+
for (const match of content.matchAll(TICKET_ID_GLOBAL)) {
|
|
3110
|
+
const n = Number(match[1]);
|
|
3111
|
+
if (n > max) max = n;
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
return `QA-${String(max + 1).padStart(3, "0")}`;
|
|
3115
|
+
}
|
|
3116
|
+
function classifyFingerprint(fp, activeFps, resolvedFps) {
|
|
3117
|
+
const openId = activeFps.get(fp);
|
|
3118
|
+
if (openId) return { kind: "open", ticketId: openId };
|
|
3119
|
+
const resolvedId = resolvedFps.get(fp);
|
|
3120
|
+
if (resolvedId) return { kind: "regression", resolvedId };
|
|
3121
|
+
return { kind: "new" };
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
// src/backlog/write.ts
|
|
3125
|
+
var SECTION_HEADING = "## QA Findings (automated)";
|
|
3126
|
+
function renderTicket(opts) {
|
|
3127
|
+
const { id, failure, marker, regressionOf, date } = opts;
|
|
3128
|
+
const spot = failure.stepType ? `${failure.stepType}${failure.selector ? ` (${failure.selector})` : ""}` : "run failed before steps executed";
|
|
3129
|
+
const lines = [];
|
|
3130
|
+
lines.push(`### ${id}: ${failure.hunt} \u2014 ${spot}`);
|
|
3131
|
+
lines.push(marker);
|
|
3132
|
+
lines.push(`**Logged**: ${date}`);
|
|
3133
|
+
if (regressionOf) {
|
|
3134
|
+
lines.push(`**Regression of**: ${regressionOf} (previously resolved \u2014 see resolved.md)`);
|
|
3135
|
+
}
|
|
3136
|
+
lines.push(`**Hunt**: ${failure.hunt}`);
|
|
3137
|
+
const stepDesc = failure.stepType ? `step ${failure.stepIndex ?? "?"} \u2014 ${failure.stepType}${failure.selector ? ` ${failure.selector}` : ""}` : "n/a (hunt did not produce step results)";
|
|
3138
|
+
lines.push(`**Failing step**: ${stepDesc}`);
|
|
3139
|
+
lines.push(`**Error**: ${failure.error}`);
|
|
3140
|
+
if (failure.runDir) {
|
|
3141
|
+
lines.push(`**Artifacts**: ${failure.runDir}`);
|
|
3142
|
+
}
|
|
3143
|
+
return lines.join("\n");
|
|
3144
|
+
}
|
|
3145
|
+
function insertTickets(content, tickets) {
|
|
3146
|
+
if (tickets.length === 0) return content;
|
|
3147
|
+
const block = tickets.join("\n\n");
|
|
3148
|
+
const headingIndex = content.indexOf(SECTION_HEADING);
|
|
3149
|
+
if (headingIndex === -1) {
|
|
3150
|
+
const base = content.replace(/\n*$/, "");
|
|
3151
|
+
const prefix = base ? `${base}
|
|
3152
|
+
|
|
3153
|
+
` : "";
|
|
3154
|
+
return `${prefix}${SECTION_HEADING}
|
|
3155
|
+
|
|
3156
|
+
${block}
|
|
3157
|
+
`;
|
|
3158
|
+
}
|
|
3159
|
+
const afterHeading = headingIndex + SECTION_HEADING.length;
|
|
3160
|
+
const rest = content.slice(afterHeading);
|
|
3161
|
+
const nextHeadingRel = rest.search(/\n## /);
|
|
3162
|
+
const insertAt = nextHeadingRel === -1 ? content.length : afterHeading + nextHeadingRel;
|
|
3163
|
+
const before = content.slice(0, insertAt).replace(/\n*$/, "");
|
|
3164
|
+
const after = content.slice(insertAt);
|
|
3165
|
+
return `${before}
|
|
3166
|
+
|
|
3167
|
+
${block}
|
|
3168
|
+
${after}`;
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
// src/backlog/index.ts
|
|
3172
|
+
function readFileOrEmpty(filePath) {
|
|
3173
|
+
try {
|
|
3174
|
+
return import_node_fs11.default.readFileSync(filePath, "utf-8");
|
|
3175
|
+
} catch (error) {
|
|
3176
|
+
const err = error;
|
|
3177
|
+
if (err.code === "ENOENT") return "";
|
|
3178
|
+
throw new Error(`Failed to read "${filePath}": ${err.message}`);
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
function buildFailure(hunt) {
|
|
3182
|
+
const failure = {
|
|
3183
|
+
hunt: hunt.hunt,
|
|
3184
|
+
error: hunt.error ?? "Run failed",
|
|
3185
|
+
runDir: hunt.runDir
|
|
3186
|
+
};
|
|
3187
|
+
if (!hunt.runDir) return failure;
|
|
3188
|
+
let run;
|
|
3189
|
+
try {
|
|
3190
|
+
const resultJson = readFileOrEmpty(import_node_path11.default.join(hunt.runDir, "result.json"));
|
|
3191
|
+
if (!resultJson) return failure;
|
|
3192
|
+
run = JSON.parse(resultJson);
|
|
3193
|
+
} catch (error) {
|
|
3194
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
3195
|
+
return failure;
|
|
3196
|
+
}
|
|
3197
|
+
if (!run || !Array.isArray(run.steps)) return failure;
|
|
3198
|
+
const stepIndex = run.steps.findIndex((step) => step.status === "fail");
|
|
3199
|
+
if (stepIndex !== -1) {
|
|
3200
|
+
const step = run.steps[stepIndex];
|
|
3201
|
+
failure.stepIndex = stepIndex;
|
|
3202
|
+
failure.stepType = step.type;
|
|
3203
|
+
failure.selector = step.selector;
|
|
3204
|
+
if (step.error) failure.error = step.error;
|
|
3205
|
+
return failure;
|
|
3206
|
+
}
|
|
3207
|
+
const failedAssertion = run.assertions?.find((assertion) => assertion.status === "fail");
|
|
3208
|
+
if (failedAssertion) {
|
|
3209
|
+
failure.stepType = `assert:${failedAssertion.type}`;
|
|
3210
|
+
if (failedAssertion.error) failure.error = failedAssertion.error;
|
|
3211
|
+
}
|
|
3212
|
+
return failure;
|
|
3213
|
+
}
|
|
3214
|
+
function extractFailures(suiteResult) {
|
|
3215
|
+
return suiteResult.result.hunts.filter((hunt) => hunt.status === "fail").map(buildFailure);
|
|
3216
|
+
}
|
|
3217
|
+
function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
3218
|
+
const projectRoot = options.projectRoot ?? process.cwd();
|
|
3219
|
+
const backlogPath = options.backlogPath ?? import_node_path11.default.join(projectRoot, "docs", "backlog.md");
|
|
3220
|
+
const resolvedPath = options.resolvedPath ?? import_node_path11.default.join(projectRoot, "docs", "resolved.md");
|
|
3221
|
+
const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
3222
|
+
const summary = { created: [], regressions: [], skipped: [], backlogPath };
|
|
3223
|
+
const failures = extractFailures(suiteResult);
|
|
3224
|
+
if (failures.length === 0) return summary;
|
|
3225
|
+
const backlogContent = readFileOrEmpty(backlogPath);
|
|
3226
|
+
const resolvedContent = readFileOrEmpty(resolvedPath);
|
|
3227
|
+
const activeFps = extractFingerprints(backlogContent);
|
|
3228
|
+
const resolvedFps = extractFingerprints(resolvedContent);
|
|
3229
|
+
let counter = Number(nextTicketId([backlogContent, resolvedContent]).slice(3));
|
|
3230
|
+
const makeId = () => `QA-${String(counter++).padStart(3, "0")}`;
|
|
3231
|
+
const seenThisRun = /* @__PURE__ */ new Set();
|
|
3232
|
+
const ticketsToAdd = [];
|
|
3233
|
+
for (const failure of failures) {
|
|
3234
|
+
const fp = computeFingerprint(failure);
|
|
3235
|
+
if (seenThisRun.has(fp)) continue;
|
|
3236
|
+
seenThisRun.add(fp);
|
|
3237
|
+
const classification = classifyFingerprint(fp, activeFps, resolvedFps);
|
|
3238
|
+
if (classification.kind === "open") {
|
|
3239
|
+
summary.skipped.push(classification.ticketId);
|
|
3240
|
+
continue;
|
|
3241
|
+
}
|
|
3242
|
+
const id = makeId();
|
|
3243
|
+
const regressionOf = classification.kind === "regression" ? classification.resolvedId : void 0;
|
|
3244
|
+
ticketsToAdd.push(renderTicket({ id, failure, marker: buildMarker(failure, fp), regressionOf, date }));
|
|
3245
|
+
if (regressionOf) {
|
|
3246
|
+
summary.regressions.push(id);
|
|
3247
|
+
} else {
|
|
3248
|
+
summary.created.push(id);
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
if (ticketsToAdd.length > 0) {
|
|
3252
|
+
import_node_fs11.default.mkdirSync(import_node_path11.default.dirname(backlogPath), { recursive: true });
|
|
3253
|
+
import_node_fs11.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
|
|
3254
|
+
}
|
|
3255
|
+
return summary;
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
// src/runner/suite.ts
|
|
3259
|
+
function normalizeTagFilter(tags) {
|
|
3260
|
+
const normalized = tags?.map((tag) => tag.trim()).filter(Boolean);
|
|
3261
|
+
return normalized && normalized.length > 0 ? normalized : void 0;
|
|
3262
|
+
}
|
|
3263
|
+
async function callHook(callback) {
|
|
3264
|
+
if (!callback) return;
|
|
3265
|
+
try {
|
|
3266
|
+
await callback();
|
|
3267
|
+
} catch {
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
function safeOnStep(hooks) {
|
|
3271
|
+
if (!hooks.onStep) return void 0;
|
|
3272
|
+
return (result, step, index) => {
|
|
3273
|
+
try {
|
|
3274
|
+
hooks.onStep?.(result, step, index);
|
|
3275
|
+
} catch {
|
|
3276
|
+
}
|
|
3277
|
+
};
|
|
3278
|
+
}
|
|
3279
|
+
function firstRunFailureMessage(result) {
|
|
3280
|
+
const failedStep = result.steps.find((step) => step.status === "fail" && step.error);
|
|
3281
|
+
if (failedStep?.error) return failedStep.error;
|
|
3282
|
+
const failedAssertion = result.assertions.find((assertion) => assertion.status === "fail" && assertion.error);
|
|
3283
|
+
return failedAssertion?.error;
|
|
3284
|
+
}
|
|
3285
|
+
async function runSuite(options = {}) {
|
|
3286
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3287
|
+
const startTime = Date.now();
|
|
3288
|
+
const hooks = options.hooks ?? {};
|
|
3289
|
+
const { config, configDir } = loadConfig(options.configPath);
|
|
3290
|
+
const hunts = listHunts(configDir);
|
|
3291
|
+
if (hunts.length === 0) {
|
|
3292
|
+
return {
|
|
3293
|
+
result: {
|
|
3294
|
+
status: "no-hunts",
|
|
3295
|
+
startedAt,
|
|
3296
|
+
durationMs: 0,
|
|
3297
|
+
totalHunts: 0,
|
|
3298
|
+
passed: 0,
|
|
3299
|
+
failed: 0,
|
|
3300
|
+
skipped: 0,
|
|
3301
|
+
hunts: []
|
|
3302
|
+
},
|
|
3303
|
+
resultPath: null
|
|
3304
|
+
};
|
|
3305
|
+
}
|
|
3306
|
+
const includeTags = normalizeTagFilter(options.includeTags);
|
|
3307
|
+
const excludeTags = normalizeTagFilter(options.excludeTags);
|
|
3308
|
+
const resultsByIndex = new Array(hunts.length);
|
|
3309
|
+
const onStep = safeOnStep(hooks);
|
|
3310
|
+
const huntsToRun = [];
|
|
3311
|
+
for (let index = 0; index < hunts.length; index++) {
|
|
3312
|
+
const huntName = hunts[index];
|
|
3313
|
+
if (includeTags || excludeTags) {
|
|
3314
|
+
const tags = loadHuntTags(huntName, configDir);
|
|
3315
|
+
if (includeTags && !includeTags.some((t) => tags.includes(t))) {
|
|
3316
|
+
await callHook(() => hooks.onHuntSkipped?.(huntName, "include"));
|
|
3317
|
+
resultsByIndex[index] = { hunt: huntName, status: "skipped", durationMs: 0 };
|
|
3318
|
+
continue;
|
|
3319
|
+
}
|
|
3320
|
+
if (excludeTags && excludeTags.some((t) => tags.includes(t))) {
|
|
3321
|
+
await callHook(() => hooks.onHuntSkipped?.(huntName, "exclude"));
|
|
3322
|
+
resultsByIndex[index] = { hunt: huntName, status: "skipped", durationMs: 0 };
|
|
3323
|
+
continue;
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
huntsToRun.push({ huntName, index });
|
|
3327
|
+
}
|
|
3328
|
+
const buildTask = (huntName) => async () => {
|
|
3329
|
+
const huntStart = Date.now();
|
|
3330
|
+
try {
|
|
3331
|
+
await callHook(() => hooks.onHuntStart?.(huntName));
|
|
3332
|
+
const { result, runDir } = await runHunt({
|
|
3333
|
+
huntName,
|
|
3334
|
+
urlOverride: options.urlOverride,
|
|
3335
|
+
headed: options.headed,
|
|
3336
|
+
slowMo: options.slowMo,
|
|
3337
|
+
trace: options.trace,
|
|
3338
|
+
browser: options.browser,
|
|
3339
|
+
channel: options.channel,
|
|
3340
|
+
viewport: options.viewport,
|
|
3341
|
+
junit: options.junit,
|
|
3342
|
+
configPath: options.configPath,
|
|
3343
|
+
onStep
|
|
3344
|
+
});
|
|
3345
|
+
const error = result.status === "fail" ? firstRunFailureMessage(result) ?? "Run failed" : void 0;
|
|
3346
|
+
if (error) {
|
|
3347
|
+
await callHook(() => hooks.onHuntFailure?.(huntName, error));
|
|
3348
|
+
} else {
|
|
3349
|
+
await callHook(() => hooks.onHuntSuccess?.(huntName, result, runDir));
|
|
3350
|
+
}
|
|
3351
|
+
return {
|
|
3352
|
+
hunt: huntName,
|
|
3353
|
+
status: result.status,
|
|
3354
|
+
durationMs: result.durationMs,
|
|
3355
|
+
runDir,
|
|
3356
|
+
error
|
|
3357
|
+
};
|
|
3358
|
+
} catch (error) {
|
|
3359
|
+
const durationMs = Date.now() - huntStart;
|
|
3360
|
+
const message = error instanceof Error ? error.message : "Run failed";
|
|
3361
|
+
await callHook(() => hooks.onHuntFailure?.(huntName, message));
|
|
3362
|
+
return {
|
|
3363
|
+
hunt: huntName,
|
|
3364
|
+
status: "fail",
|
|
3365
|
+
durationMs,
|
|
3366
|
+
error: message
|
|
3367
|
+
};
|
|
3368
|
+
}
|
|
3369
|
+
};
|
|
3370
|
+
const parallel = options.parallel;
|
|
3371
|
+
if (parallel !== void 0 && parallel > 1) {
|
|
3372
|
+
const tasks = huntsToRun.map((entry) => ({ ...entry, task: buildTask(entry.huntName) }));
|
|
3373
|
+
const parallelResults = await runWithConcurrency(
|
|
3374
|
+
tasks.map((entry) => entry.task),
|
|
3375
|
+
parallel
|
|
3376
|
+
);
|
|
3377
|
+
for (let i = 0; i < parallelResults.length; i++) {
|
|
3378
|
+
const pr = parallelResults[i];
|
|
3379
|
+
const task = tasks[i];
|
|
3380
|
+
if (pr.status === "fulfilled") {
|
|
3381
|
+
resultsByIndex[task.index] = pr.value;
|
|
3382
|
+
} else {
|
|
3383
|
+
const message = pr.reason instanceof Error ? pr.reason.message : "Run failed";
|
|
3384
|
+
resultsByIndex[task.index] = {
|
|
3385
|
+
hunt: task.huntName,
|
|
3386
|
+
status: "fail",
|
|
3387
|
+
durationMs: 0,
|
|
3388
|
+
error: message
|
|
3389
|
+
};
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
} else {
|
|
3393
|
+
for (const { huntName, index } of huntsToRun) {
|
|
3394
|
+
resultsByIndex[index] = await buildTask(huntName)();
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
const totalDurationMs = Date.now() - startTime;
|
|
3398
|
+
const results = resultsByIndex.map((result, index) => {
|
|
3399
|
+
return result ?? {
|
|
3400
|
+
hunt: hunts[index],
|
|
3401
|
+
status: "fail",
|
|
3402
|
+
durationMs: 0,
|
|
3403
|
+
error: "Run did not produce a result"
|
|
3404
|
+
};
|
|
3405
|
+
});
|
|
3406
|
+
const threshold = config.reliability?.flakyThreshold ?? DEFAULT_FLAKY_THRESHOLD;
|
|
3407
|
+
const ranThisSuite = new Set(
|
|
3408
|
+
results.filter((r) => r.status !== "skipped").map((r) => r.hunt)
|
|
3409
|
+
);
|
|
3410
|
+
const flaky = rankFlaky(configDir, { threshold }).filter((entry) => entry.flaky && ranThisSuite.has(entry.hunt)).map((entry) => ({ hunt: entry.hunt, score: entry.score }));
|
|
3411
|
+
const clusters = clusterFailures(
|
|
3412
|
+
extractFailures({ result: { hunts: results }, resultPath: null })
|
|
3413
|
+
).filter((cluster) => cluster.count > 1);
|
|
3414
|
+
const ciRunDir = import_node_path12.default.join(configDir, "runs", timestamp("ci"));
|
|
3415
|
+
const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
|
|
3416
|
+
const { passed, failed, skipped } = countCiResults(results);
|
|
3417
|
+
return {
|
|
3418
|
+
result: {
|
|
3419
|
+
status: resolveCiStatus(results),
|
|
3420
|
+
startedAt,
|
|
3421
|
+
durationMs: totalDurationMs,
|
|
3422
|
+
totalHunts: results.length,
|
|
3423
|
+
passed,
|
|
3424
|
+
failed,
|
|
3425
|
+
skipped,
|
|
3426
|
+
hunts: results,
|
|
3427
|
+
...flaky.length > 0 ? { flaky } : {},
|
|
3428
|
+
...clusters.length > 0 ? { clusters } : {}
|
|
3429
|
+
},
|
|
3430
|
+
resultPath
|
|
3431
|
+
};
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
// src/analyzer/index.ts
|
|
3435
|
+
async function analyzePage(page) {
|
|
3436
|
+
const raw = await page.evaluate(() => {
|
|
3437
|
+
const forms2 = Array.from(document.querySelectorAll("form"));
|
|
3438
|
+
const formData = forms2.map((form, index) => ({
|
|
3439
|
+
index,
|
|
3440
|
+
action: form.getAttribute("action") || void 0,
|
|
3441
|
+
method: (form.getAttribute("method") || "GET").toUpperCase(),
|
|
3442
|
+
fieldCount: form.querySelectorAll("input, textarea, select").length
|
|
3443
|
+
}));
|
|
3444
|
+
function getFormIndex(el) {
|
|
3445
|
+
const form = el.closest("form");
|
|
3446
|
+
if (!form) return -1;
|
|
3447
|
+
return forms2.indexOf(form);
|
|
3448
|
+
}
|
|
3449
|
+
function getLabel(el) {
|
|
3450
|
+
const id = el.getAttribute("id");
|
|
3451
|
+
if (id) {
|
|
3452
|
+
const label = document.querySelector(`label[for="${id}"]`);
|
|
3453
|
+
if (label) return label.textContent?.trim() || void 0;
|
|
3454
|
+
}
|
|
3455
|
+
const parentLabel = el.closest("label");
|
|
3456
|
+
if (parentLabel) return parentLabel.textContent?.trim() || void 0;
|
|
3457
|
+
return void 0;
|
|
3458
|
+
}
|
|
3459
|
+
const selectors = "input, textarea, select, button, [role=button], a";
|
|
3460
|
+
const rawElements = Array.from(document.querySelectorAll(selectors));
|
|
3461
|
+
const elements2 = rawElements.filter((el) => {
|
|
3462
|
+
if (el.tagName.toLowerCase() === "input" && el.getAttribute("type") === "hidden") {
|
|
3463
|
+
return false;
|
|
3464
|
+
}
|
|
3465
|
+
return true;
|
|
3466
|
+
}).map((el) => {
|
|
3467
|
+
const tag = el.tagName.toLowerCase();
|
|
3468
|
+
const type = el.getAttribute("type") || void 0;
|
|
3469
|
+
const testId = el.getAttribute("data-testid") || void 0;
|
|
3470
|
+
const ariaLabel = el.getAttribute("aria-label") || void 0;
|
|
3471
|
+
const role = el.getAttribute("role") || void 0;
|
|
3472
|
+
const id = el.getAttribute("id") || void 0;
|
|
3473
|
+
const name = el.getAttribute("name") || void 0;
|
|
3474
|
+
const label = getLabel(el);
|
|
3475
|
+
const placeholder = el.getAttribute("placeholder") || void 0;
|
|
3476
|
+
const required = el.hasAttribute("required");
|
|
3477
|
+
const formIndex = getFormIndex(el);
|
|
3478
|
+
const text = el.textContent?.trim() || void 0;
|
|
3479
|
+
const href = el.getAttribute("href") || void 0;
|
|
3480
|
+
return {
|
|
3481
|
+
tag,
|
|
3482
|
+
type: type || void 0,
|
|
3483
|
+
testId,
|
|
3484
|
+
ariaLabel,
|
|
3485
|
+
role,
|
|
3486
|
+
id,
|
|
3487
|
+
name,
|
|
3488
|
+
label,
|
|
3489
|
+
placeholder,
|
|
3490
|
+
required,
|
|
3491
|
+
formIndex,
|
|
3492
|
+
text: tag === "a" || tag === "button" || role === "button" ? text : void 0,
|
|
3493
|
+
href: tag === "a" ? href : void 0
|
|
3494
|
+
};
|
|
3495
|
+
});
|
|
3496
|
+
return {
|
|
3497
|
+
title: document.title,
|
|
3498
|
+
url: window.location.href,
|
|
3499
|
+
elements: elements2,
|
|
3500
|
+
forms: formData
|
|
3501
|
+
};
|
|
3502
|
+
});
|
|
3503
|
+
const elements = raw.elements.filter((el) => el.tag !== "a").map((el) => {
|
|
3504
|
+
const selectors = {};
|
|
3505
|
+
if (el.testId) selectors.testId = `[data-testid="${el.testId}"]`;
|
|
3506
|
+
if (el.ariaLabel) selectors.ariaLabel = el.ariaLabel;
|
|
3507
|
+
if (el.label) selectors.label = el.label;
|
|
3508
|
+
if (el.id) selectors.css = `#${el.id}`;
|
|
3509
|
+
if (el.name) selectors.name = `[name="${el.name}"]`;
|
|
3510
|
+
if (el.placeholder) selectors.placeholder = el.placeholder;
|
|
3511
|
+
if (el.text) selectors.text = el.text;
|
|
3512
|
+
if (el.role) selectors.role = el.role;
|
|
3513
|
+
return {
|
|
3514
|
+
tag: el.tag,
|
|
3515
|
+
...el.type ? { type: el.type } : {},
|
|
3516
|
+
selectors,
|
|
3517
|
+
...el.role ? { role: el.role } : {},
|
|
3518
|
+
...el.label ? { label: el.label } : {},
|
|
3519
|
+
...el.placeholder ? { placeholder: el.placeholder } : {},
|
|
3520
|
+
required: el.required,
|
|
3521
|
+
...el.formIndex >= 0 ? { formGroup: el.formIndex } : {}
|
|
3522
|
+
};
|
|
3523
|
+
});
|
|
3524
|
+
const links = raw.elements.filter((el) => el.tag === "a" && el.href).map((el) => {
|
|
3525
|
+
let selector;
|
|
3526
|
+
if (el.testId) {
|
|
3527
|
+
selector = `[data-testid="${el.testId}"]`;
|
|
3528
|
+
} else if (el.href) {
|
|
3529
|
+
selector = `a[href="${el.href}"]`;
|
|
3530
|
+
} else {
|
|
3531
|
+
selector = `a`;
|
|
3532
|
+
}
|
|
3533
|
+
return {
|
|
3534
|
+
text: el.text || "",
|
|
3535
|
+
href: el.href,
|
|
3536
|
+
selector
|
|
3537
|
+
};
|
|
3538
|
+
});
|
|
3539
|
+
const forms = raw.forms;
|
|
3540
|
+
return {
|
|
3541
|
+
url: raw.url,
|
|
3542
|
+
title: raw.title,
|
|
3543
|
+
elements,
|
|
3544
|
+
forms,
|
|
3545
|
+
links
|
|
3546
|
+
};
|
|
3547
|
+
}
|
|
3548
|
+
|
|
3549
|
+
// src/generator/index.ts
|
|
3550
|
+
var import_yaml2 = __toESM(require("yaml"), 1);
|
|
3551
|
+
var import_playwright2 = require("playwright");
|
|
3552
|
+
|
|
3553
|
+
// src/generator/prompt.ts
|
|
3554
|
+
var STEP_REFERENCE = `
|
|
3555
|
+
## Prowl Step Types
|
|
3556
|
+
|
|
3557
|
+
### Navigation & Waiting
|
|
3558
|
+
- navigate: "/path" \u2014 navigate to URL (relative to target)
|
|
3559
|
+
- wait: "Text" \u2014 wait for text to appear
|
|
3560
|
+
- wait: { for: "Text", timeout: 5000 } \u2014 with timeout
|
|
3561
|
+
- waitForSelector: { selector: "#el", timeout: 5000 }
|
|
3562
|
+
- waitForUrl: { value: "/path", timeout: 5000 }
|
|
3563
|
+
- waitForNetworkIdle: { timeout: 5000 }
|
|
3564
|
+
|
|
3565
|
+
### Interaction
|
|
3566
|
+
- click: "Button Text" \u2014 click by text (tries role=button first)
|
|
3567
|
+
- click: { selector: "[data-testid=btn]" } \u2014 click by selector
|
|
3568
|
+
- fill: { "Label": "value" } \u2014 fill by label/placeholder
|
|
3569
|
+
- fill: { selector: "#input", value: "text" } \u2014 fill by selector
|
|
3570
|
+
- type: "text" \u2014 type into focused element
|
|
3571
|
+
- press: { selector: "#input", key: "Enter" }
|
|
3572
|
+
- hover: { selector: "#menu" }
|
|
3573
|
+
- selectOption: { selector: "select", value: "option" }
|
|
3574
|
+
- select: { "Label": "value" } \u2014 select by label
|
|
3575
|
+
- setInputFiles: { selector: "#file", files: "path.png" }
|
|
3576
|
+
- onDialog: { action: "accept" } \u2014 handle browser dialogs
|
|
3577
|
+
|
|
3578
|
+
### Assertions
|
|
3579
|
+
- assert: { visible: "Text" }
|
|
3580
|
+
- assert: { notVisible: "Error" }
|
|
3581
|
+
- assert: { urlIncludes: "/dashboard" }
|
|
3582
|
+
- assert: { urlEquals: "https://..." }
|
|
3583
|
+
|
|
3584
|
+
### Scrolling & Screenshots
|
|
3585
|
+
- scroll: { direction: "down", amount: 500 }
|
|
3586
|
+
- scrollTo: { selector: "#section" }
|
|
3587
|
+
- screenshot: { name: "step-name" }
|
|
3588
|
+
|
|
3589
|
+
### Script Execution
|
|
3590
|
+
- evalScript: "document.title" \u2014 evaluate JS expression
|
|
3591
|
+
- evalScript: { expression: "expr", as: "VAR" } \u2014 capture to variable
|
|
3592
|
+
- runScript: { file: "scripts/setup.js" }
|
|
3593
|
+
|
|
3594
|
+
### Visual Regression
|
|
3595
|
+
- assertScreenshot: { name: "baseline-name", threshold: 0.1 }
|
|
3596
|
+
|
|
3597
|
+
### Control Flow
|
|
3598
|
+
- if: { visible: ".banner", then: [steps...] }
|
|
3599
|
+
- repeat: { times: 3, steps: [steps...] }
|
|
3600
|
+
- repeat: { while: { visible: ".more" }, maxIterations: 10, steps: [steps...] }
|
|
3601
|
+
- runHunt: "other-hunt" \u2014 run another hunt file
|
|
3602
|
+
- mockRoute: { url: "**/api/data", response: { status: 200, body: "{}" } }
|
|
3603
|
+
- unmockRoute: { url: "**/api/data" }
|
|
3604
|
+
`.trim();
|
|
3605
|
+
function buildGenerationPrompt(analysis, intent) {
|
|
3606
|
+
return `You are a QA test generator for Prowl. Generate a YAML hunt file that tests the described intent using the page analysis data below.
|
|
3607
|
+
|
|
3608
|
+
${STEP_REFERENCE}
|
|
3609
|
+
|
|
3610
|
+
## Page Analysis
|
|
3611
|
+
\`\`\`json
|
|
3612
|
+
${JSON.stringify(analysis, null, 2)}
|
|
3613
|
+
\`\`\`
|
|
3614
|
+
|
|
3615
|
+
## Test Intent
|
|
3616
|
+
${intent}
|
|
3617
|
+
|
|
3618
|
+
## Instructions
|
|
3619
|
+
1. Output ONLY a valid Prowl YAML hunt between \`\`\`yaml fences
|
|
3620
|
+
2. Use shorthand syntax when possible (click: "Text", fill: { "Label": "value" })
|
|
3621
|
+
3. Prefer stable selectors: data-testid > aria-label > text > CSS selectors
|
|
3622
|
+
4. Include assertions to verify expected outcomes
|
|
3623
|
+
5. Add a descriptive name and description
|
|
3624
|
+
6. Keep steps focused and minimal \u2014 test exactly what the intent describes
|
|
3625
|
+
|
|
3626
|
+
\`\`\`yaml
|
|
3627
|
+
`;
|
|
3628
|
+
}
|
|
3629
|
+
function extractYamlFromResponse(response) {
|
|
3630
|
+
const fenceMatch = response.match(/```ya?ml\n?([\s\S]*?)```/);
|
|
3631
|
+
if (fenceMatch) {
|
|
3632
|
+
return fenceMatch[1].trim();
|
|
3633
|
+
}
|
|
3634
|
+
return response.trim();
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3637
|
+
// src/generator/ai.ts
|
|
3638
|
+
function resolveAiConfig() {
|
|
3639
|
+
const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
|
|
3640
|
+
if (provider !== "anthropic" && provider !== "openai") {
|
|
3641
|
+
throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
|
|
3642
|
+
}
|
|
3643
|
+
const apiKey = process.env.PROWL_AI_KEY;
|
|
3644
|
+
if (!apiKey) {
|
|
3645
|
+
throw new Error(
|
|
3646
|
+
"PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
|
|
3647
|
+
);
|
|
3648
|
+
}
|
|
3649
|
+
const defaultModel = provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
|
|
3650
|
+
const model = process.env.PROWL_AI_MODEL ?? defaultModel;
|
|
3651
|
+
return { provider, model, apiKey };
|
|
3652
|
+
}
|
|
3653
|
+
async function generateWithAi(prompt, config) {
|
|
3654
|
+
if (config.provider === "anthropic") {
|
|
3655
|
+
return generateWithAnthropic(prompt, config);
|
|
3656
|
+
}
|
|
3657
|
+
return generateWithOpenAi(prompt, config);
|
|
3658
|
+
}
|
|
3659
|
+
async function generateWithAnthropic(prompt, config) {
|
|
3660
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
3661
|
+
method: "POST",
|
|
3662
|
+
headers: {
|
|
3663
|
+
"Content-Type": "application/json",
|
|
3664
|
+
"x-api-key": config.apiKey,
|
|
3665
|
+
"anthropic-version": "2023-06-01"
|
|
3666
|
+
},
|
|
3667
|
+
body: JSON.stringify({
|
|
3668
|
+
model: config.model,
|
|
3669
|
+
max_tokens: 4096,
|
|
3670
|
+
messages: [
|
|
3671
|
+
{ role: "user", content: prompt }
|
|
3672
|
+
]
|
|
3673
|
+
})
|
|
3674
|
+
});
|
|
3675
|
+
if (!response.ok) {
|
|
3676
|
+
const body = await response.text();
|
|
3677
|
+
throw new Error(`Anthropic API error (${response.status}): ${body}`);
|
|
3678
|
+
}
|
|
3679
|
+
const data = await response.json();
|
|
3680
|
+
const textBlock = data.content.find((c) => c.type === "text");
|
|
3681
|
+
if (!textBlock?.text) {
|
|
3682
|
+
throw new Error("Anthropic API returned no text content");
|
|
3683
|
+
}
|
|
3684
|
+
return textBlock.text;
|
|
3685
|
+
}
|
|
3686
|
+
async function generateWithOpenAi(prompt, config) {
|
|
3687
|
+
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
3688
|
+
method: "POST",
|
|
3689
|
+
headers: {
|
|
3690
|
+
"Content-Type": "application/json",
|
|
3691
|
+
"Authorization": `Bearer ${config.apiKey}`
|
|
3692
|
+
},
|
|
3693
|
+
body: JSON.stringify({
|
|
3694
|
+
model: config.model,
|
|
3695
|
+
messages: [
|
|
3696
|
+
{ role: "user", content: prompt }
|
|
3697
|
+
],
|
|
3698
|
+
max_tokens: 4096
|
|
3699
|
+
})
|
|
3700
|
+
});
|
|
3701
|
+
if (!response.ok) {
|
|
3702
|
+
const body = await response.text();
|
|
3703
|
+
throw new Error(`OpenAI API error (${response.status}): ${body}`);
|
|
3704
|
+
}
|
|
3705
|
+
const data = await response.json();
|
|
3706
|
+
if (!data.choices?.[0]?.message?.content) {
|
|
3707
|
+
throw new Error("OpenAI API returned no content");
|
|
3708
|
+
}
|
|
3709
|
+
return data.choices[0].message.content;
|
|
3710
|
+
}
|
|
3711
|
+
|
|
3712
|
+
// src/generator/index.ts
|
|
3713
|
+
async function generateHunt(options) {
|
|
3714
|
+
let analysis = options.analysis;
|
|
3715
|
+
if (!analysis && options.url) {
|
|
3716
|
+
const browser = await import_playwright2.chromium.launch({ headless: true });
|
|
3717
|
+
const context = await browser.newContext();
|
|
3718
|
+
const page = await context.newPage();
|
|
3719
|
+
try {
|
|
3720
|
+
await page.goto(options.url, { waitUntil: "networkidle" });
|
|
3721
|
+
analysis = await analyzePage(page);
|
|
3722
|
+
} finally {
|
|
3723
|
+
await context.close();
|
|
3724
|
+
await browser.close();
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
if (!analysis) {
|
|
3728
|
+
throw new Error("Either --url or piped analysis JSON is required");
|
|
3729
|
+
}
|
|
3730
|
+
const config = options.aiConfig ?? resolveAiConfig();
|
|
3731
|
+
const prompt = buildGenerationPrompt(analysis, options.intent);
|
|
3732
|
+
const response = await generateWithAi(prompt, config);
|
|
3733
|
+
const yamlStr = extractYamlFromResponse(response);
|
|
3734
|
+
const parsed = import_yaml2.default.parse(yamlStr);
|
|
3735
|
+
huntSchema.parse(parsed);
|
|
3736
|
+
return yamlStr;
|
|
3737
|
+
}
|
|
3738
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
3739
|
+
0 && (module.exports = {
|
|
3740
|
+
DEFAULT_FLAKY_THRESHOLD,
|
|
3741
|
+
analyzePage,
|
|
3742
|
+
buildHealCandidates,
|
|
3743
|
+
clusterFailures,
|
|
3744
|
+
computeFlakeScore,
|
|
3745
|
+
configSchema,
|
|
3746
|
+
extractFailures,
|
|
3747
|
+
extractSelectorIntent,
|
|
3748
|
+
generateHunt,
|
|
3749
|
+
healSelector,
|
|
3750
|
+
huntSchema,
|
|
3751
|
+
interpolateHunt,
|
|
3752
|
+
listHunts,
|
|
3753
|
+
loadConfig,
|
|
3754
|
+
loadHunt,
|
|
3755
|
+
loadHuntMeta,
|
|
3756
|
+
loadHuntTags,
|
|
3757
|
+
rankFlaky,
|
|
3758
|
+
readHistory,
|
|
3759
|
+
readHuntHistory,
|
|
3760
|
+
runHunt,
|
|
3761
|
+
runSuite,
|
|
3762
|
+
stepSchema,
|
|
3763
|
+
updateBacklogFromSuite
|
|
3764
|
+
});
|
|
3765
|
+
//# sourceMappingURL=lib.cjs.map
|