@rettangoli/vt 1.0.0-rc1 → 1.0.0-rc3

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 CHANGED
@@ -77,6 +77,10 @@ vt:
77
77
  concurrency: 4
78
78
  timeout: 30000
79
79
  waitEvent: vt:ready
80
+ viewport:
81
+ id: desktop
82
+ width: 1280
83
+ height: 720
80
84
  sections:
81
85
  - title: components_basic
82
86
  files: components
@@ -86,7 +90,9 @@ Notes:
86
90
 
87
91
  - `vt.sections` is required.
88
92
  - Section page keys (`title` for flat sections and group `items[].title`) allow only letters, numbers, `-`, `_`.
93
+ - `vt.viewport` supports object or array; each viewport requires `id`, `width`, `height`.
89
94
  - `vt.capture` is internal and must be omitted.
95
+ - Viewport contract details: `docs/viewport-contract.md`.
90
96
 
91
97
  ## Spec Frontmatter
92
98
 
@@ -99,18 +105,47 @@ Supported frontmatter keys per spec file:
99
105
  - `waitEvent`
100
106
  - `waitSelector`
101
107
  - `waitStrategy` (`networkidle` | `load` | `event` | `selector`)
108
+ - `viewport` (object or array of viewport objects)
102
109
  - `skipScreenshot`
103
110
  - `specs`
104
111
  - `steps`
105
112
 
113
+ Step action reference:
114
+
115
+ - `docs/step-actions.md`
116
+
106
117
  Screenshot naming:
107
118
 
108
119
  - First screenshot is `-01`.
109
120
  - Then `-02`, `-03`, up to `-99`.
121
+ - When viewport id is configured, filenames include `--<viewportId>` before ordinal (for example `pages/home--mobile-01.webp`).
122
+
123
+ ## Docker
124
+
125
+ A pre-built Docker image with `rtgl` and Playwright browsers is available:
126
+
127
+ ```bash
128
+ docker pull han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc4
129
+ ```
130
+
131
+ Run commands against a local project:
132
+
133
+ ```bash
134
+ docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc4 rtgl vt generate
135
+ docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc4 rtgl vt report
136
+ docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc4 rtgl vt accept
137
+ ```
138
+
139
+ Note:
140
+
141
+ - Image default working directory is `/workspace`.
142
+ - Use `-w /workspace/<subdir>` only when running commands from a subfolder within the mounted project.
143
+
144
+ Supports `linux/amd64` and `linux/arm64`.
110
145
 
111
146
  ## Development
112
147
 
113
- Run tests:
148
+ Run unit tests:
114
149
 
115
150
  ```bash
116
151
  bun test
@@ -125,15 +160,13 @@ VT_E2E=1 bun test spec/e2e-smoke.spec.js
125
160
  Run Docker E2E tests (requires Docker daemon running):
126
161
 
127
162
  ```bash
128
- # Full pipeline: build image → verify → run all Docker E2E tests
129
- bun run test:docker:full
163
+ # Full pipeline: build test image → run all E2E scenarios
164
+ bun run test:e2e:full
130
165
 
131
- # Tests only (skip image build, assumes image already exists)
132
- bun run test:docker
166
+ # Scenarios only (skip image build, assumes image already exists)
167
+ bun run test:e2e
133
168
  ```
134
169
 
135
- The Docker E2E suite builds a local `rtgl-local-test:latest` image, then runs generate/report/accept in temp directories inside containers. Tests validate WebP screenshot headers, report.json schema, metrics.json schema, HTML content, directory structure, accept file copies, multi-spec fixtures, multi-screenshot ordinals, and pixelmatch diff detection.
136
-
137
170
  Optional benchmark fixture:
138
171
 
139
172
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rettangoli/vt",
3
- "version": "1.0.0-rc1",
3
+ "version": "1.0.0-rc3",
4
4
  "description": "Rettangoli Visual Testing",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -3,11 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import sharp from "sharp";
4
4
  import { createSteps } from "../createSteps.js";
5
5
  import { formatScreenshotOrdinal } from "./screenshot-naming.js";
6
-
7
- const DEFAULT_VIEWPORT = Object.freeze({
8
- width: 1280,
9
- height: 720,
10
- });
6
+ import { DEFAULT_VIEWPORT } from "../viewport.js";
11
7
 
12
8
  function nowMs() {
13
9
  return performance.now();
@@ -128,8 +124,11 @@ export class PlaywrightRunner {
128
124
  return context;
129
125
  }
130
126
 
131
- async configurePage(page) {
132
- await page.setViewportSize(DEFAULT_VIEWPORT);
127
+ async configurePage(page, viewport = DEFAULT_VIEWPORT) {
128
+ await page.setViewportSize({
129
+ width: viewport.width,
130
+ height: viewport.height,
131
+ });
133
132
  await page.setDefaultNavigationTimeout(this.navigationTimeout);
134
133
  await page.setDefaultTimeout(this.readyTimeout);
135
134
  await page.emulateMedia({
@@ -348,6 +347,7 @@ export class PlaywrightRunner {
348
347
 
349
348
  try {
350
349
  resetMs = await resetSession();
350
+ await this.configurePage(page, task.viewport ?? DEFAULT_VIEWPORT);
351
351
  const readyState = await this.navigateToReadyState(page, task, registeredReadyEvents);
352
352
  strategy = readyState.strategy;
353
353
  navigationMs = readyState.navigationMs;
@@ -1,3 +1,5 @@
1
+ import { appendViewportToBaseName, resolveViewports } from "../viewport.js";
2
+
1
3
  const removeExtension = (filePath) => filePath.replace(/\.[^/.]+$/, "");
2
4
 
3
5
  const toHtmlPath = (filePath) => {
@@ -58,9 +60,13 @@ export function createCaptureTasks(generatedFiles, options) {
58
60
  waitEvent,
59
61
  waitSelector,
60
62
  waitStrategy,
63
+ viewport,
61
64
  } = options;
62
65
 
63
- return generatedFiles.map((file, index) => {
66
+ const tasks = [];
67
+ let taskIndex = 0;
68
+
69
+ generatedFiles.forEach((file, fileIndex) => {
64
70
  const frontMatter = file.frontMatter || {};
65
71
  const normalizedPath = normalizePathForUrl(file.path);
66
72
  const constructedUrl = toHtmlPath(`${serverUrl}/candidate/${normalizedPath}`);
@@ -75,25 +81,37 @@ export function createCaptureTasks(generatedFiles, options) {
75
81
  waitStrategy,
76
82
  });
77
83
 
78
- const task = {
79
- id: `${index}:${file.path}`,
80
- index,
81
- path: file.path,
82
- url,
83
- baseName: removeExtension(file.path),
84
- frontMatter,
85
- steps: frontMatter.steps || [],
86
- waitStrategy: resolvedWaitStrategy,
87
- estimatedCost: estimateTaskCost(frontMatter.steps || [], resolvedWaitStrategy),
88
- };
84
+ const resolvedViewports = resolveViewports(frontMatter.viewport, viewport);
85
+ resolvedViewports.forEach((resolvedViewport) => {
86
+ const viewportId = resolvedViewport.id;
87
+ const task = {
88
+ id: `${fileIndex}:${file.path}:${viewportId ?? "default"}`,
89
+ index: taskIndex,
90
+ path: file.path,
91
+ url,
92
+ baseName: appendViewportToBaseName(removeExtension(file.path), viewportId),
93
+ frontMatter,
94
+ steps: frontMatter.steps || [],
95
+ waitStrategy: resolvedWaitStrategy,
96
+ estimatedCost: estimateTaskCost(frontMatter.steps || [], resolvedWaitStrategy),
97
+ viewport: {
98
+ id: viewportId,
99
+ width: resolvedViewport.width,
100
+ height: resolvedViewport.height,
101
+ },
102
+ };
89
103
 
90
- if (resolvedWaitEvent !== undefined && resolvedWaitEvent !== null) {
91
- task.waitEvent = resolvedWaitEvent;
92
- }
93
- if (resolvedWaitSelector !== undefined && resolvedWaitSelector !== null) {
94
- task.waitSelector = resolvedWaitSelector;
95
- }
104
+ if (resolvedWaitEvent !== undefined && resolvedWaitEvent !== null) {
105
+ task.waitEvent = resolvedWaitEvent;
106
+ }
107
+ if (resolvedWaitSelector !== undefined && resolvedWaitSelector !== null) {
108
+ task.waitSelector = resolvedWaitSelector;
109
+ }
96
110
 
97
- return task;
111
+ tasks.push(task);
112
+ taskIndex += 1;
113
+ });
98
114
  });
115
+
116
+ return tasks;
99
117
  }
@@ -32,6 +32,7 @@ export function resolveGenerateOptions(options = {}, configData = {}) {
32
32
  waitEvent,
33
33
  headless: cliHeadless ?? true,
34
34
  configUrl: cliUrl ?? configData.url,
35
+ ...(configData.viewport !== undefined ? { viewport: configData.viewport } : {}),
35
36
  selectors,
36
37
 
37
38
  // Internal capture defaults (not user-configurable).
@@ -39,6 +39,7 @@ export function buildCaptureOptions({
39
39
  isolationMode,
40
40
  metricsPath,
41
41
  headless,
42
+ viewport,
42
43
  } = resolvedOptions;
43
44
 
44
45
  return {
@@ -59,6 +60,7 @@ export function buildCaptureOptions({
59
60
  isolationMode,
60
61
  metricsPath,
61
62
  headless,
63
+ viewport,
62
64
  };
63
65
  }
64
66
 
package/src/common.js CHANGED
@@ -273,6 +273,7 @@ async function takeScreenshots(options) {
273
273
  isolationMode = "fast",
274
274
  metricsPath = join(".rettangoli", "vt", "metrics.json"),
275
275
  headless = true,
276
+ viewport = undefined,
276
277
  } = options;
277
278
 
278
279
  if (!Array.isArray(generatedFiles)) {
@@ -324,6 +325,7 @@ async function takeScreenshots(options) {
324
325
  waitEvent,
325
326
  waitSelector,
326
327
  waitStrategy,
328
+ viewport,
327
329
  });
328
330
 
329
331
  const { summary, failures } = await runCaptureScheduler({
@@ -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.");
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;
85
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(
@@ -109,30 +427,45 @@ export function createSteps(page, context) {
109
427
  }
110
428
 
111
429
  const actionHandlers = {
430
+ assert,
431
+ blur,
432
+ check,
433
+ clear,
112
434
  click,
113
435
  customEvent,
436
+ dblclick,
437
+ focus,
114
438
  goto,
439
+ hover,
115
440
  keypress,
116
441
  mouseDown,
117
442
  mouseUp,
118
443
  move,
119
444
  rclick,
445
+ rightMouseDown,
446
+ rightMouseUp,
120
447
  scroll,
121
- rMouseDown,
122
- rMouseUp,
448
+ setViewport,
123
449
  screenshot,
124
450
  select,
451
+ selectOption,
452
+ uncheck,
453
+ upload,
125
454
  wait,
455
+ waitFor,
126
456
  write,
127
457
  };
128
458
 
129
459
  async function executeSingleStep(stepString, selectedElement) {
130
- const [command, ...args] = stepString.split(" ");
460
+ const { command, args } = parseStepCommand(stepString);
461
+ if (!command) {
462
+ return;
463
+ }
131
464
  const actionFn = actionHandlers[command];
132
465
  if (actionFn) {
133
466
  await actionFn(page, args, context, selectedElement);
134
467
  } else {
135
- console.warn(`Unknown step command: "${command}"`);
468
+ throw new Error(`Unknown step command: "${command}"`);
136
469
  }
137
470
  }
138
471
 
@@ -143,7 +476,7 @@ export function createSteps(page, context) {
143
476
  } else if (typeof step === 'object' && step !== null) {
144
477
  const blockCommandString = Object.keys(step)[0];
145
478
  const nestedStepStrings = step[blockCommandString];
146
- const [command, ...args] = blockCommandString.split(" ");
479
+ const { command, args } = parseStepCommand(blockCommandString);
147
480
 
148
481
  const blockFn = actionHandlers[command];
149
482
  if (blockFn) {
@@ -152,7 +485,7 @@ export function createSteps(page, context) {
152
485
  await executeSingleStep(nestedStep, selectedElement);
153
486
  }
154
487
  } else {
155
- console.warn(`Unsupported block command: "${command}".`);
488
+ throw new Error(`Unsupported block command: "${command}".`);
156
489
  }
157
490
  }
158
491
  }
@@ -1,4 +1,5 @@
1
1
  import path from "path";
2
+ import { stripViewportSuffix } from "./viewport.js";
2
3
 
3
4
  function toList(value) {
4
5
  if (value === undefined || value === null) return [];
@@ -114,7 +115,8 @@ export function filterGeneratedFilesBySelectors(generatedFiles, selectors, confi
114
115
 
115
116
  function toScreenshotItemKey(relativeScreenshotPath) {
116
117
  const normalized = normalizePathValue(relativeScreenshotPath).replace(/\.webp$/i, "");
117
- return normalized.replace(/-\d{1,3}$/i, "");
118
+ const withoutOrdinal = normalized.replace(/-\d{1,3}$/i, "");
119
+ return stripViewportSuffix(withoutOrdinal);
118
120
  }
119
121
 
120
122
  export function filterRelativeScreenshotPathsBySelectors(relativePaths, selectors, configSections = []) {
@@ -0,0 +1,33 @@
1
+ export const STEP_COMMANDS = Object.freeze([
2
+ "assert",
3
+ "blur",
4
+ "check",
5
+ "clear",
6
+ "click",
7
+ "customEvent",
8
+ "dblclick",
9
+ "focus",
10
+ "goto",
11
+ "hover",
12
+ "keypress",
13
+ "mouseDown",
14
+ "mouseUp",
15
+ "move",
16
+ "rclick",
17
+ "rightMouseDown",
18
+ "rightMouseUp",
19
+ "setViewport",
20
+ "screenshot",
21
+ "scroll",
22
+ "select",
23
+ "selectOption",
24
+ "uncheck",
25
+ "upload",
26
+ "wait",
27
+ "waitFor",
28
+ "write",
29
+ ]);
30
+
31
+ export const BLOCK_COMMANDS = Object.freeze([
32
+ "select",
33
+ ]);
package/src/validation.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { normalizeViewportField } from "./viewport.js";
2
+
1
3
  function isPlainObject(value) {
2
4
  return value !== null && typeof value === "object" && !Array.isArray(value);
3
5
  }
@@ -223,6 +225,7 @@ export function validateVtConfig(vtConfig, sourcePath = "rettangoli.config.yaml"
223
225
  validateOptionalNumber(vtConfig.concurrency, "vt.concurrency", { integer: true, min: 1 });
224
226
  validateOptionalNumber(vtConfig.timeout, "vt.timeout", { integer: true, min: 1 });
225
227
  validateOptionalString(vtConfig.waitEvent, "vt.waitEvent");
228
+ normalizeViewportField(vtConfig.viewport, "vt.viewport");
226
229
  validateOptionalNumber(vtConfig.colorThreshold, "vt.colorThreshold", { min: 0, max: 1 });
227
230
  validateOptionalNumber(vtConfig.diffThreshold, "vt.diffThreshold", { min: 0, max: 100 });
228
231
  assertNoLegacyCaptureFields(vtConfig, sourcePath);
@@ -252,6 +255,7 @@ export function validateFrontMatter(frontMatter, specPath) {
252
255
  validateOptionalString(frontMatter.url, `${specPath}: frontMatter.url`);
253
256
  validateOptionalString(frontMatter.waitEvent, `${specPath}: frontMatter.waitEvent`);
254
257
  validateOptionalString(frontMatter.waitSelector, `${specPath}: frontMatter.waitSelector`);
258
+ normalizeViewportField(frontMatter.viewport, `${specPath}: frontMatter.viewport`);
255
259
  validateOptionalEnum(
256
260
  frontMatter.waitStrategy,
257
261
  `${specPath}: frontMatter.waitStrategy`,
@@ -0,0 +1,99 @@
1
+ const VIEWPORT_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
2
+
3
+ export const DEFAULT_VIEWPORT = Object.freeze({
4
+ width: 1280,
5
+ height: 720,
6
+ });
7
+
8
+ function valueType(value) {
9
+ if (Array.isArray(value)) return "array";
10
+ if (value === null) return "null";
11
+ return typeof value;
12
+ }
13
+
14
+ function assert(condition, message) {
15
+ if (!condition) {
16
+ throw new Error(message);
17
+ }
18
+ }
19
+
20
+ function validateViewportEntry(entry, path) {
21
+ assert(
22
+ entry !== null && typeof entry === "object" && !Array.isArray(entry),
23
+ `"${path}" must be an object, got ${valueType(entry)}.`,
24
+ );
25
+
26
+ assert(
27
+ typeof entry.id === "string" && entry.id.trim().length > 0,
28
+ `"${path}.id" is required and must be a non-empty string.`,
29
+ );
30
+ assert(
31
+ VIEWPORT_ID_PATTERN.test(entry.id),
32
+ `"${path}.id" must contain only letters, numbers, "-" or "_".`,
33
+ );
34
+ assert(
35
+ typeof entry.width === "number" && Number.isInteger(entry.width) && entry.width >= 1,
36
+ `"${path}.width" must be an integer >= 1.`,
37
+ );
38
+ assert(
39
+ typeof entry.height === "number" && Number.isInteger(entry.height) && entry.height >= 1,
40
+ `"${path}.height" must be an integer >= 1.`,
41
+ );
42
+
43
+ return {
44
+ id: entry.id,
45
+ width: entry.width,
46
+ height: entry.height,
47
+ };
48
+ }
49
+
50
+ export function normalizeViewportField(rawViewport, path = "viewport") {
51
+ if (rawViewport === undefined || rawViewport === null) {
52
+ return undefined;
53
+ }
54
+
55
+ const rawEntries = Array.isArray(rawViewport) ? rawViewport : [rawViewport];
56
+ assert(rawEntries.length > 0, `"${path}" cannot be an empty array.`);
57
+
58
+ const entries = rawEntries.map((entry, index) =>
59
+ validateViewportEntry(entry, `${path}[${index}]`),
60
+ );
61
+
62
+ const seen = new Map();
63
+ entries.forEach((entry, index) => {
64
+ const canonicalId = entry.id.toLowerCase();
65
+ const existingIndex = seen.get(canonicalId);
66
+ assert(
67
+ existingIndex === undefined,
68
+ `"${path}[${index}].id" duplicates "${path}[${existingIndex}].id" (case-insensitive).`,
69
+ );
70
+ seen.set(canonicalId, index);
71
+ });
72
+
73
+ return entries;
74
+ }
75
+
76
+ export function resolveViewports(frontMatterViewport, configViewport) {
77
+ const selected = frontMatterViewport ?? configViewport;
78
+ if (selected === undefined || selected === null) {
79
+ return [
80
+ {
81
+ id: null,
82
+ width: DEFAULT_VIEWPORT.width,
83
+ height: DEFAULT_VIEWPORT.height,
84
+ },
85
+ ];
86
+ }
87
+ return normalizeViewportField(selected, "viewport");
88
+ }
89
+
90
+ export function appendViewportToBaseName(baseName, viewportId) {
91
+ if (!viewportId) {
92
+ return baseName;
93
+ }
94
+ return `${baseName}--${viewportId}`;
95
+ }
96
+
97
+ export function stripViewportSuffix(itemKey) {
98
+ return itemKey.replace(/--[A-Za-z0-9_-]+$/, "");
99
+ }