@rettangoli/vt 0.0.14 → 1.0.0-rc2
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/README.md +135 -64
- package/package.json +9 -2
- package/src/capture/capture-scheduler.js +206 -0
- package/src/capture/playwright-runner.js +403 -0
- package/src/capture/result-collector.js +234 -0
- package/src/capture/screenshot-naming.js +13 -0
- package/src/capture/spec-loader.js +117 -0
- package/src/capture/worker-plan.js +66 -0
- package/src/cli/generate-options.js +81 -0
- package/src/cli/generate.js +95 -28
- package/src/cli/report-options.js +43 -0
- package/src/cli/report.js +88 -151
- package/src/cli/templates/index.html +2 -2
- package/src/cli/templates/report.html +4 -4
- package/src/common.js +123 -185
- package/src/createSteps.js +358 -28
- package/src/report/report-model.js +76 -0
- package/src/report/report-render.js +22 -0
- package/src/selector-filter.js +139 -0
- package/src/step-commands.js +33 -0
- package/src/validation.js +304 -0
- package/src/viewport.js +99 -0
- package/docker/Dockerfile +0 -21
- package/docker/build-and-push.sh +0 -16
package/src/createSteps.js
CHANGED
|
@@ -1,18 +1,86 @@
|
|
|
1
|
+
function parseStepCommand(stepString) {
|
|
2
|
+
const tokens = stepString.trim().split(/\s+/).filter(Boolean);
|
|
3
|
+
const [command, ...args] = tokens;
|
|
4
|
+
return { command, args };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function parseNamedArgs(args) {
|
|
8
|
+
const named = {};
|
|
9
|
+
const positional = [];
|
|
10
|
+
args.forEach((token) => {
|
|
11
|
+
const separatorIndex = token.indexOf("=");
|
|
12
|
+
if (separatorIndex <= 0) {
|
|
13
|
+
positional.push(token);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const key = token.slice(0, separatorIndex);
|
|
17
|
+
const value = token.slice(separatorIndex + 1);
|
|
18
|
+
named[key] = value;
|
|
19
|
+
});
|
|
20
|
+
return { named, positional };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toNumber(value, fieldName) {
|
|
24
|
+
const parsed = Number(value);
|
|
25
|
+
if (!Number.isFinite(parsed)) {
|
|
26
|
+
throw new Error(`Invalid ${fieldName}: expected a finite number, got "${value}".`);
|
|
27
|
+
}
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toPositiveInteger(value, fieldName) {
|
|
32
|
+
const parsed = toNumber(value, fieldName);
|
|
33
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
34
|
+
throw new Error(`Invalid ${fieldName}: expected an integer >= 1, got "${value}".`);
|
|
35
|
+
}
|
|
36
|
+
return parsed;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseTimeoutValue(value) {
|
|
40
|
+
if (value === undefined) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const timeout = toNumber(value, "timeout");
|
|
44
|
+
if (timeout < 0) {
|
|
45
|
+
throw new Error(`Invalid timeout: expected >= 0, got ${timeout}.`);
|
|
46
|
+
}
|
|
47
|
+
return timeout;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function requireSelectedElement(command, selectedElement) {
|
|
51
|
+
if (!selectedElement) {
|
|
52
|
+
throw new Error(`\`${command}\` requires a \`select\` block target.`);
|
|
53
|
+
}
|
|
54
|
+
return selectedElement;
|
|
55
|
+
}
|
|
56
|
+
|
|
1
57
|
async function click(page, args, context, selectedElement) {
|
|
2
58
|
if (selectedElement) {
|
|
3
59
|
await selectedElement.click();
|
|
4
60
|
} else if (args.length >= 2) {
|
|
5
|
-
await page.mouse.click(
|
|
61
|
+
await page.mouse.click(
|
|
62
|
+
toNumber(args[0], "x"),
|
|
63
|
+
toNumber(args[1], "y"),
|
|
64
|
+
{ button: "left" },
|
|
65
|
+
);
|
|
6
66
|
} else {
|
|
7
|
-
|
|
67
|
+
throw new Error("`click` requires a `select` block target or `x y` coordinates.");
|
|
8
68
|
}
|
|
9
69
|
}
|
|
10
70
|
|
|
11
71
|
async function customEvent(page, args) {
|
|
72
|
+
if (args.length === 0) {
|
|
73
|
+
throw new Error("`customEvent` requires an event name.");
|
|
74
|
+
}
|
|
12
75
|
const [eventName, ...params] = args;
|
|
13
76
|
const payload = {};
|
|
14
|
-
params.forEach(param => {
|
|
15
|
-
const [key, value] = param.split(
|
|
77
|
+
params.forEach((param) => {
|
|
78
|
+
const [key, value] = param.split("=");
|
|
79
|
+
if (!key || value === undefined) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Invalid customEvent argument "${param}". Expected key=value.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
16
84
|
payload[key] = value;
|
|
17
85
|
});
|
|
18
86
|
await page.evaluate(({ eventName, payload }) => {
|
|
@@ -21,6 +89,9 @@ async function customEvent(page, args) {
|
|
|
21
89
|
}
|
|
22
90
|
|
|
23
91
|
async function goto(page, args) {
|
|
92
|
+
if (!args[0]) {
|
|
93
|
+
throw new Error("`goto` requires a URL argument.");
|
|
94
|
+
}
|
|
24
95
|
await page.goto(args[0], { waitUntil: "networkidle" });
|
|
25
96
|
// Normalize font rendering for consistent screenshots
|
|
26
97
|
await page.addStyleTag({
|
|
@@ -35,6 +106,9 @@ async function goto(page, args) {
|
|
|
35
106
|
}
|
|
36
107
|
|
|
37
108
|
async function keypress(page, args) {
|
|
109
|
+
if (!args[0]) {
|
|
110
|
+
throw new Error("`keypress` requires a key argument.");
|
|
111
|
+
}
|
|
38
112
|
await page.keyboard.press(args[0]);
|
|
39
113
|
}
|
|
40
114
|
|
|
@@ -46,47 +120,291 @@ async function mouseUp(page) {
|
|
|
46
120
|
await page.mouse.up();
|
|
47
121
|
}
|
|
48
122
|
|
|
49
|
-
async function
|
|
123
|
+
async function rightMouseDown(page) {
|
|
50
124
|
await page.mouse.down({ button: 'right' });
|
|
51
125
|
}
|
|
52
126
|
|
|
53
|
-
async function
|
|
127
|
+
async function rightMouseUp(page) {
|
|
54
128
|
await page.mouse.up({ button: 'right' });
|
|
55
129
|
}
|
|
56
130
|
|
|
57
131
|
async function move(page, args) {
|
|
58
|
-
|
|
132
|
+
if (args.length < 2) {
|
|
133
|
+
throw new Error("`move` requires `x y` coordinates.");
|
|
134
|
+
}
|
|
135
|
+
await page.mouse.move(toNumber(args[0], "x"), toNumber(args[1], "y"));
|
|
59
136
|
}
|
|
60
137
|
|
|
61
|
-
async function scroll(page, args){
|
|
62
|
-
|
|
138
|
+
async function scroll(page, args) {
|
|
139
|
+
if (args.length < 2) {
|
|
140
|
+
throw new Error("`scroll` requires `deltaX deltaY` values.");
|
|
141
|
+
}
|
|
142
|
+
await page.mouse.wheel(toNumber(args[0], "deltaX"), toNumber(args[1], "deltaY"));
|
|
63
143
|
}
|
|
64
144
|
|
|
65
145
|
async function rclick(page, args, context, selectedElement) {
|
|
66
146
|
if (selectedElement) {
|
|
67
|
-
await selectedElement.click({ button:
|
|
147
|
+
await selectedElement.click({ button: "right" });
|
|
68
148
|
} else if (args.length >= 2) {
|
|
69
|
-
await page.mouse.click(
|
|
149
|
+
await page.mouse.click(
|
|
150
|
+
toNumber(args[0], "x"),
|
|
151
|
+
toNumber(args[1], "y"),
|
|
152
|
+
{ button: "right" },
|
|
153
|
+
);
|
|
70
154
|
} else {
|
|
71
|
-
|
|
155
|
+
throw new Error("`rclick` requires a `select` block target or `x y` coordinates.");
|
|
72
156
|
}
|
|
73
157
|
}
|
|
74
158
|
|
|
75
159
|
async function wait(page, args) {
|
|
76
|
-
|
|
160
|
+
if (!args[0]) {
|
|
161
|
+
throw new Error("`wait` requires a millisecond duration.");
|
|
162
|
+
}
|
|
163
|
+
await page.waitForTimeout(toNumber(args[0], "ms"));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function setViewport(page, args) {
|
|
167
|
+
if (args.length < 2) {
|
|
168
|
+
throw new Error("`setViewport` requires `width height`.");
|
|
169
|
+
}
|
|
170
|
+
await page.setViewportSize({
|
|
171
|
+
width: toPositiveInteger(args[0], "width"),
|
|
172
|
+
height: toPositiveInteger(args[1], "height"),
|
|
173
|
+
});
|
|
77
174
|
}
|
|
78
175
|
|
|
79
176
|
async function write(page, args, context, selectedElement) {
|
|
177
|
+
const target = requireSelectedElement("write", selectedElement);
|
|
178
|
+
const textToWrite = args.join(" ");
|
|
179
|
+
await target.fill(textToWrite);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function hover(page, args, context, selectedElement) {
|
|
80
183
|
if (selectedElement) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
184
|
+
await selectedElement.hover();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (args.length >= 2) {
|
|
188
|
+
await page.mouse.move(toNumber(args[0], "x"), toNumber(args[1], "y"));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
throw new Error("`hover` requires a `select` block target or `x y` coordinates.");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function dblclick(page, args, context, selectedElement) {
|
|
195
|
+
if (selectedElement) {
|
|
196
|
+
await selectedElement.dblclick();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (args.length >= 2) {
|
|
200
|
+
await page.mouse.dblclick(toNumber(args[0], "x"), toNumber(args[1], "y"));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
throw new Error("`dblclick` requires a `select` block target or `x y` coordinates.");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function focus(page, args, context, selectedElement) {
|
|
207
|
+
const target = requireSelectedElement("focus", selectedElement);
|
|
208
|
+
await target.focus();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function blur(page, args, context, selectedElement) {
|
|
212
|
+
const target = requireSelectedElement("blur", selectedElement);
|
|
213
|
+
await target.evaluate((element) => element.blur());
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function clear(page, args, context, selectedElement) {
|
|
217
|
+
const target = requireSelectedElement("clear", selectedElement);
|
|
218
|
+
await target.fill("");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function check(page, args, context, selectedElement) {
|
|
222
|
+
const target = requireSelectedElement("check", selectedElement);
|
|
223
|
+
await target.check();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function uncheck(page, args, context, selectedElement) {
|
|
227
|
+
const target = requireSelectedElement("uncheck", selectedElement);
|
|
228
|
+
await target.uncheck();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function selectOption(page, args, context, selectedElement) {
|
|
232
|
+
const target = requireSelectedElement("selectOption", selectedElement);
|
|
233
|
+
const { named, positional } = parseNamedArgs(args);
|
|
234
|
+
|
|
235
|
+
const hasNamed =
|
|
236
|
+
named.value !== undefined
|
|
237
|
+
|| named.label !== undefined
|
|
238
|
+
|| named.index !== undefined;
|
|
239
|
+
|
|
240
|
+
if (hasNamed) {
|
|
241
|
+
const option = {};
|
|
242
|
+
if (named.value !== undefined) {
|
|
243
|
+
option.value = named.value;
|
|
244
|
+
}
|
|
245
|
+
if (named.label !== undefined) {
|
|
246
|
+
option.label = named.label;
|
|
247
|
+
}
|
|
248
|
+
if (named.index !== undefined) {
|
|
249
|
+
option.index = toNumber(named.index, "index");
|
|
250
|
+
}
|
|
251
|
+
await target.selectOption(option);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (positional.length === 0) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
"`selectOption` requires an option value/label or key=value args (value=, label=, index=).",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
await target.selectOption(positional[0]);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function upload(page, args, context, selectedElement) {
|
|
264
|
+
const target = requireSelectedElement("upload", selectedElement);
|
|
265
|
+
const files = args.filter((token) => token.length > 0);
|
|
266
|
+
if (files.length === 0) {
|
|
267
|
+
throw new Error("`upload` requires one or more file paths.");
|
|
85
268
|
}
|
|
269
|
+
await target.setInputFiles(files);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function waitFor(page, args, context, selectedElement) {
|
|
273
|
+
const { named, positional } = parseNamedArgs(args);
|
|
274
|
+
const state = named.state ?? positional[1] ?? "visible";
|
|
275
|
+
const timeout = parseTimeoutValue(named.timeoutMs ?? named.timeout ?? positional[2]);
|
|
276
|
+
const waitOptions = { state };
|
|
277
|
+
if (timeout !== undefined) {
|
|
278
|
+
waitOptions.timeout = timeout;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (selectedElement) {
|
|
282
|
+
await selectedElement.waitFor(waitOptions);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const selector = named.selector ?? positional[0];
|
|
287
|
+
if (!selector) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
"`waitFor` requires a selector (or a selected element in a `select` block).",
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
await page.waitForSelector(selector, waitOptions);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function assert(page, args, context, selectedElement) {
|
|
296
|
+
const [assertion, ...rest] = args;
|
|
297
|
+
if (!assertion) {
|
|
298
|
+
throw new Error("`assert` requires an assertion type.");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (assertion === "url" || assertion === "urlExact") {
|
|
302
|
+
const expected = rest.join(" ");
|
|
303
|
+
if (!expected) {
|
|
304
|
+
throw new Error(`\`assert ${assertion}\` requires an expected URL string.`);
|
|
305
|
+
}
|
|
306
|
+
const currentUrl = page.url();
|
|
307
|
+
if (assertion === "url") {
|
|
308
|
+
if (!currentUrl.includes(expected)) {
|
|
309
|
+
throw new Error(`assert url failed: expected "${currentUrl}" to include "${expected}".`);
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (currentUrl !== expected) {
|
|
314
|
+
throw new Error(`assert urlExact failed: expected "${expected}", got "${currentUrl}".`);
|
|
315
|
+
}
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const { named, positional } = parseNamedArgs(rest);
|
|
320
|
+
const timeout = parseTimeoutValue(named.timeoutMs ?? named.timeout);
|
|
321
|
+
const timeoutOptions = timeout === undefined ? {} : { timeout };
|
|
322
|
+
|
|
323
|
+
if (assertion === "exists") {
|
|
324
|
+
if (selectedElement) {
|
|
325
|
+
const count = await selectedElement.count();
|
|
326
|
+
if (count < 1) {
|
|
327
|
+
throw new Error("assert exists failed: selected element was not found.");
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const selector = named.selector ?? positional[0];
|
|
332
|
+
if (!selector) {
|
|
333
|
+
throw new Error("`assert exists` requires a selector when not in a `select` block.");
|
|
334
|
+
}
|
|
335
|
+
const count = await page.locator(selector).count();
|
|
336
|
+
if (count < 1) {
|
|
337
|
+
throw new Error(`assert exists failed: selector "${selector}" matched 0 elements.`);
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (assertion === "visible") {
|
|
343
|
+
if (selectedElement) {
|
|
344
|
+
await selectedElement.waitFor({ state: "visible", ...timeoutOptions });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const selector = named.selector ?? positional[0];
|
|
348
|
+
if (!selector) {
|
|
349
|
+
throw new Error("`assert visible` requires a selector when not in a `select` block.");
|
|
350
|
+
}
|
|
351
|
+
await page.waitForSelector(selector, { state: "visible", ...timeoutOptions });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (assertion === "hidden") {
|
|
356
|
+
if (selectedElement) {
|
|
357
|
+
await selectedElement.waitFor({ state: "hidden", ...timeoutOptions });
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const selector = named.selector ?? positional[0];
|
|
361
|
+
if (!selector) {
|
|
362
|
+
throw new Error("`assert hidden` requires a selector when not in a `select` block.");
|
|
363
|
+
}
|
|
364
|
+
await page.waitForSelector(selector, { state: "hidden", ...timeoutOptions });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (assertion === "text") {
|
|
369
|
+
if (selectedElement) {
|
|
370
|
+
const expected = positional.join(" ");
|
|
371
|
+
if (!expected) {
|
|
372
|
+
throw new Error("`assert text` requires expected text.");
|
|
373
|
+
}
|
|
374
|
+
const actualText = (await selectedElement.textContent()) ?? "";
|
|
375
|
+
if (!actualText.includes(expected)) {
|
|
376
|
+
throw new Error(
|
|
377
|
+
`assert text failed: expected selected element text to include "${expected}", got "${actualText}".`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const selector = named.selector ?? positional[0];
|
|
383
|
+
const expected = positional.slice(1).join(" ");
|
|
384
|
+
if (!selector || !expected) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
"`assert text` requires `<selector> <expected...>` when not in a `select` block.",
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
const actualText = (await page.locator(selector).first().textContent()) ?? "";
|
|
390
|
+
if (!actualText.includes(expected)) {
|
|
391
|
+
throw new Error(
|
|
392
|
+
`assert text failed: expected selector "${selector}" text to include "${expected}", got "${actualText}".`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
throw new Error(
|
|
399
|
+
`Unsupported assert type "${assertion}". Supported: url, urlExact, exists, visible, hidden, text.`,
|
|
400
|
+
);
|
|
86
401
|
}
|
|
87
402
|
|
|
88
403
|
async function select(page, args) {
|
|
89
404
|
const testId = args[0];
|
|
405
|
+
if (!testId) {
|
|
406
|
+
throw new Error("`select` requires a test id.");
|
|
407
|
+
}
|
|
90
408
|
const hostElementLocator = page.getByTestId(testId);
|
|
91
409
|
|
|
92
410
|
const interactiveElementLocator = hostElementLocator.locator(
|
|
@@ -103,39 +421,51 @@ async function select(page, args) {
|
|
|
103
421
|
}
|
|
104
422
|
|
|
105
423
|
export function createSteps(page, context) {
|
|
106
|
-
let screenshotIndex = 0;
|
|
107
|
-
|
|
108
424
|
async function screenshot() {
|
|
109
|
-
|
|
110
|
-
const screenshotPath = await context.takeAndSaveScreenshot(page, `${context.baseName}-${screenshotIndex}`);
|
|
425
|
+
const screenshotPath = await context.takeAndSaveScreenshot(page, context.baseName);
|
|
111
426
|
console.log(`Screenshot saved: ${screenshotPath}`);
|
|
112
427
|
}
|
|
113
428
|
|
|
114
429
|
const actionHandlers = {
|
|
430
|
+
assert,
|
|
431
|
+
blur,
|
|
432
|
+
check,
|
|
433
|
+
clear,
|
|
115
434
|
click,
|
|
116
435
|
customEvent,
|
|
436
|
+
dblclick,
|
|
437
|
+
focus,
|
|
117
438
|
goto,
|
|
439
|
+
hover,
|
|
118
440
|
keypress,
|
|
119
441
|
mouseDown,
|
|
120
442
|
mouseUp,
|
|
121
443
|
move,
|
|
122
444
|
rclick,
|
|
445
|
+
rightMouseDown,
|
|
446
|
+
rightMouseUp,
|
|
123
447
|
scroll,
|
|
124
|
-
|
|
125
|
-
rMouseUp,
|
|
448
|
+
setViewport,
|
|
126
449
|
screenshot,
|
|
127
450
|
select,
|
|
451
|
+
selectOption,
|
|
452
|
+
uncheck,
|
|
453
|
+
upload,
|
|
128
454
|
wait,
|
|
455
|
+
waitFor,
|
|
129
456
|
write,
|
|
130
457
|
};
|
|
131
458
|
|
|
132
459
|
async function executeSingleStep(stepString, selectedElement) {
|
|
133
|
-
const
|
|
460
|
+
const { command, args } = parseStepCommand(stepString);
|
|
461
|
+
if (!command) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
134
464
|
const actionFn = actionHandlers[command];
|
|
135
465
|
if (actionFn) {
|
|
136
466
|
await actionFn(page, args, context, selectedElement);
|
|
137
467
|
} else {
|
|
138
|
-
|
|
468
|
+
throw new Error(`Unknown step command: "${command}"`);
|
|
139
469
|
}
|
|
140
470
|
}
|
|
141
471
|
|
|
@@ -146,7 +476,7 @@ export function createSteps(page, context) {
|
|
|
146
476
|
} else if (typeof step === 'object' && step !== null) {
|
|
147
477
|
const blockCommandString = Object.keys(step)[0];
|
|
148
478
|
const nestedStepStrings = step[blockCommandString];
|
|
149
|
-
const
|
|
479
|
+
const { command, args } = parseStepCommand(blockCommandString);
|
|
150
480
|
|
|
151
481
|
const blockFn = actionHandlers[command];
|
|
152
482
|
if (blockFn) {
|
|
@@ -155,9 +485,9 @@ export function createSteps(page, context) {
|
|
|
155
485
|
await executeSingleStep(nestedStep, selectedElement);
|
|
156
486
|
}
|
|
157
487
|
} else {
|
|
158
|
-
|
|
488
|
+
throw new Error(`Unsupported block command: "${command}".`);
|
|
159
489
|
}
|
|
160
490
|
}
|
|
161
491
|
}
|
|
162
492
|
};
|
|
163
|
-
}
|
|
493
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
|
|
3
|
+
export function extractParts(filePath) {
|
|
4
|
+
const dir = path.dirname(filePath);
|
|
5
|
+
const filename = path.basename(filePath, ".webp");
|
|
6
|
+
const lastHyphenIndex = filename.lastIndexOf("-");
|
|
7
|
+
|
|
8
|
+
if (lastHyphenIndex > -1) {
|
|
9
|
+
const suffix = filename.substring(lastHyphenIndex + 1);
|
|
10
|
+
if (/^\d+$/.test(suffix)) {
|
|
11
|
+
const number = parseInt(suffix, 10);
|
|
12
|
+
const name = path.join(dir, filename.substring(0, lastHyphenIndex));
|
|
13
|
+
return { name, number };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return { name: path.join(dir, filename), number: -1 };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function sortPaths(a, b) {
|
|
21
|
+
const partsA = extractParts(a);
|
|
22
|
+
const partsB = extractParts(b);
|
|
23
|
+
|
|
24
|
+
if (partsA.name < partsB.name) return -1;
|
|
25
|
+
if (partsA.name > partsB.name) return 1;
|
|
26
|
+
|
|
27
|
+
return partsA.number - partsB.number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildAllRelativePaths(candidateRelativePaths, referenceRelativePaths) {
|
|
31
|
+
const allPaths = [
|
|
32
|
+
...new Set([...candidateRelativePaths, ...referenceRelativePaths]),
|
|
33
|
+
];
|
|
34
|
+
allPaths.sort(sortPaths);
|
|
35
|
+
return allPaths;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function toMismatchingItems(results, siteOutputPath) {
|
|
39
|
+
return results
|
|
40
|
+
.filter(
|
|
41
|
+
(result) =>
|
|
42
|
+
!result.equal || result.onlyInCandidate || result.onlyInReference,
|
|
43
|
+
)
|
|
44
|
+
.map((result) => {
|
|
45
|
+
return {
|
|
46
|
+
candidatePath: result.candidatePath
|
|
47
|
+
? path.relative(siteOutputPath, result.candidatePath)
|
|
48
|
+
: null,
|
|
49
|
+
referencePath: result.referencePath
|
|
50
|
+
? path.relative(siteOutputPath, result.referencePath)
|
|
51
|
+
: null,
|
|
52
|
+
equal: result.equal,
|
|
53
|
+
similarity: result.similarity,
|
|
54
|
+
diffPixels: result.diffPixels,
|
|
55
|
+
onlyInCandidate: result.onlyInCandidate,
|
|
56
|
+
onlyInReference: result.onlyInReference,
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildJsonReport({ total, mismatchingItems, timestamp = new Date().toISOString() }) {
|
|
62
|
+
return {
|
|
63
|
+
timestamp,
|
|
64
|
+
total,
|
|
65
|
+
mismatched: mismatchingItems.length,
|
|
66
|
+
items: mismatchingItems.map((item) => ({
|
|
67
|
+
path: item.candidatePath || item.referencePath,
|
|
68
|
+
candidatePath: item.candidatePath,
|
|
69
|
+
referencePath: item.referencePath,
|
|
70
|
+
equal: item.equal,
|
|
71
|
+
similarity: item.similarity,
|
|
72
|
+
onlyInCandidate: item.onlyInCandidate,
|
|
73
|
+
onlyInReference: item.onlyInReference,
|
|
74
|
+
})),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { Liquid } from "liquidjs";
|
|
3
|
+
|
|
4
|
+
const engine = new Liquid();
|
|
5
|
+
|
|
6
|
+
engine.registerFilter("slug", (value) => {
|
|
7
|
+
if (typeof value !== "string") return "";
|
|
8
|
+
return value.toLowerCase().replace(/\s+/g, "-");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export async function renderHtmlReport({ results, templatePath, outputPath }) {
|
|
12
|
+
try {
|
|
13
|
+
const templateContent = fs.readFileSync(templatePath, "utf8");
|
|
14
|
+
const renderedHtml = await engine.parseAndRender(templateContent, {
|
|
15
|
+
files: results,
|
|
16
|
+
});
|
|
17
|
+
fs.writeFileSync(outputPath, renderedHtml);
|
|
18
|
+
console.log(`Report generated successfully at ${outputPath}`);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
throw new Error(`Failed to generate HTML report: ${error.message}`, { cause: error });
|
|
21
|
+
}
|
|
22
|
+
}
|