@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 +40 -7
- package/package.json +1 -1
- package/src/capture/playwright-runner.js +7 -7
- package/src/capture/spec-loader.js +37 -19
- package/src/cli/generate-options.js +1 -0
- package/src/cli/generate.js +2 -0
- package/src/common.js +2 -0
- package/src/createSteps.js +356 -23
- package/src/selector-filter.js +3 -1
- package/src/step-commands.js +33 -0
- package/src/validation.js +4 -0
- package/src/viewport.js +99 -0
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 →
|
|
129
|
-
bun run test:
|
|
163
|
+
# Full pipeline: build test image → run all E2E scenarios
|
|
164
|
+
bun run test:e2e:full
|
|
130
165
|
|
|
131
|
-
#
|
|
132
|
-
bun run test:
|
|
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
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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).
|
package/src/cli/generate.js
CHANGED
|
@@ -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({
|
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.");
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
488
|
+
throw new Error(`Unsupported block command: "${command}".`);
|
|
156
489
|
}
|
|
157
490
|
}
|
|
158
491
|
}
|
package/src/selector-filter.js
CHANGED
|
@@ -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
|
-
|
|
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`,
|
package/src/viewport.js
ADDED
|
@@ -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
|
+
}
|