@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.
@@ -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(Number(args[0]), Number(args[1]), { button: "left" });
61
+ await page.mouse.click(
62
+ toNumber(args[0], "x"),
63
+ toNumber(args[1], "y"),
64
+ { button: "left" },
65
+ );
6
66
  } else {
7
- console.warn('`click` command needs a `select` block or coordinates.');
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 rMouseDown(page){
123
+ async function rightMouseDown(page) {
50
124
  await page.mouse.down({ button: 'right' });
51
125
  }
52
126
 
53
- async function rMouseUp(page){
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
- await page.mouse.move(Number(args[0]), Number(args[1]));
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
- await page.mouse.wheel(Number(args[0]), Number(args[1]));
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: 'right' });
147
+ await selectedElement.click({ button: "right" });
68
148
  } else if (args.length >= 2) {
69
- await page.mouse.click(Number(args[0]), Number(args[1]), { button: "right" });
149
+ await page.mouse.click(
150
+ toNumber(args[0], "x"),
151
+ toNumber(args[1], "y"),
152
+ { button: "right" },
153
+ );
70
154
  } else {
71
- console.warn('`rclick` command needs a `select` block or coordinates.');
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
- await page.waitForTimeout(Number(args[0]));
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
- const textToWrite = args.join(' ');
82
- await selectedElement.fill(textToWrite);
83
- } else {
84
- console.warn('`write` command called without a `select` block.');
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
- screenshotIndex++;
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
- rMouseDown,
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 [command, ...args] = stepString.split(" ");
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
- console.warn(`Unknown step command: "${command}"`);
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 [command, ...args] = blockCommandString.split(" ");
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
- console.warn(`Unsupported block command: "${command}".`);
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
+ }