@rettangoli/vt 1.0.0-rc5 → 1.0.2

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
@@ -39,7 +39,7 @@ Behavior split:
39
39
  Use selectors to run only part of VT in both `screenshot` and `report`:
40
40
 
41
41
  - `folder`: matches specs by folder prefix under `vt/specs` (example: `components/forms`)
42
- - `group`: matches section page key from `vt.sections` (`title` for flat sections, `items[].title` for grouped sections)
42
+ - `group`: matches derived section page key from `vt.sections` titles (`kebab-case(title)`)
43
43
  - `item`: matches a single spec path relative to `vt/specs` (with or without extension)
44
44
 
45
45
  Selector rules:
@@ -54,18 +54,18 @@ Examples:
54
54
  rtgl vt screenshot --folder components/forms
55
55
 
56
56
  # Only one section/group key from vt.sections
57
- rtgl vt screenshot --group components_basic
57
+ rtgl vt screenshot --group components-basic
58
58
 
59
59
  # Only one spec item (extension optional)
60
60
  rtgl vt screenshot --item components/forms/login
61
61
  rtgl vt screenshot --item components/forms/login.html
62
62
 
63
63
  # Combine selectors (union)
64
- rtgl vt screenshot --group components_basic --item pages/home
64
+ rtgl vt screenshot --group components-basic --item pages/home
65
65
 
66
66
  # Same selectors for report
67
67
  rtgl vt report --folder components/forms
68
- rtgl vt report --group components_basic
68
+ rtgl vt report --group components-basic
69
69
  rtgl vt report --item components/forms/login
70
70
  ```
71
71
 
@@ -90,7 +90,7 @@ vt:
90
90
  width: 1280
91
91
  height: 720
92
92
  sections:
93
- - title: components_basic
93
+ - title: Components Basic
94
94
  files: components
95
95
  ```
96
96
 
@@ -99,7 +99,8 @@ Notes:
99
99
  - `vt.sections` is required.
100
100
  - `vt.service` is optional. When set, VT starts the command before capture, waits for `vt.url`, then stops it after capture.
101
101
  - when `vt.service` is omitted and `vt.url` is set, VT expects that URL to already be running.
102
- - Section page keys (`title` for flat sections and group `items[].title`) allow only letters, numbers, `-`, `_`.
102
+ - Section page keys are derived as `kebab-case(title)` for flat sections and group `items[].title`.
103
+ - Derived section page keys must be unique case-insensitively.
103
104
  - `vt.viewport` supports object or array; each viewport requires `id`, `width`, `height`.
104
105
  - `vt.capture` is internal and must be omitted.
105
106
  - Viewport contract details: `docs/viewport-contract.md`.
@@ -117,18 +118,22 @@ Supported frontmatter keys per spec file:
117
118
  - `waitStrategy` (`networkidle` | `load` | `event` | `selector`)
118
119
  - `viewport` (object or array of viewport objects)
119
120
  - `skipScreenshot`
121
+ - `skipInitialScreenshot`
120
122
  - `specs`
121
123
  - `steps`
122
124
 
123
125
  Step action reference:
124
126
 
125
127
  - `docs/step-actions.md`
126
- - canonical format is structured action objects (`- action: ...`), with legacy string/block forms still supported.
128
+ - canonical format is structured action objects (`- action: ...`); legacy one-line string steps are not supported.
129
+ - `action: select` accepts exactly one of `testId` or `selector` for interaction targeting.
127
130
  - `assert` supports `js` deep-equal checks for object/array values.
128
131
 
129
132
  Screenshot naming:
130
133
 
131
- - First screenshot is `-01`.
134
+ - By default, VT takes an immediate first screenshot before running `steps`.
135
+ - Set `skipInitialScreenshot: true` in frontmatter to skip that immediate first screenshot.
136
+ - First captured screenshot is `-01`.
132
137
  - Then `-02`, `-03`, up to `-99`.
133
138
  - When viewport id is configured, filenames include `--<viewportId>` before ordinal (for example `pages/home--mobile-01.webp`).
134
139
 
@@ -137,15 +142,15 @@ Screenshot naming:
137
142
  A pre-built Docker image with `rtgl` and Playwright browsers is available:
138
143
 
139
144
  ```bash
140
- docker pull han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc10
145
+ docker pull han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc27
141
146
  ```
142
147
 
143
148
  Run commands against a local project:
144
149
 
145
150
  ```bash
146
- docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc10 rtgl vt screenshot
147
- docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc10 rtgl vt report
148
- docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc10 rtgl vt accept
151
+ docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc27 rtgl vt screenshot
152
+ docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc27 rtgl vt report
153
+ docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc27 rtgl vt accept
149
154
  ```
150
155
 
151
156
  Note:
package/bun.lock ADDED
@@ -0,0 +1,125 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "workspaces": {
4
+ "": {
5
+ "name": "rviz",
6
+ "dependencies": {
7
+ "commander": "^13.1.0",
8
+ "js-yaml": "^4.1.0",
9
+ "liquidjs": "^10.21.0",
10
+ "pixelmatch": "^7.1.0",
11
+ "playwright": "^1.52.0",
12
+ "shiki": "^3.3.0"
13
+ }
14
+ }
15
+ },
16
+ "packages": {
17
+ "@shikijs/core": ["@shikijs/core@3.3.0", "", { "dependencies": { "@shikijs/types": "3.3.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-CovkFL2WVaHk6PCrwv6ctlmD4SS1qtIfN8yEyDXDYWh4ONvomdM9MaFw20qHuqJOcb8/xrkqoWQRJ//X10phOQ=="],
18
+
19
+ "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.3.0", "", { "dependencies": { "@shikijs/types": "3.3.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.2.0" } }, "sha512-XlhnFGv0glq7pfsoN0KyBCz9FJU678LZdQ2LqlIdAj6JKsg5xpYKay3DkazXWExp3DTJJK9rMOuGzU2911pg7Q=="],
20
+
21
+ "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.3.0", "", { "dependencies": { "@shikijs/types": "3.3.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-l0vIw+GxeNU7uGnsu6B+Crpeqf+WTQ2Va71cHb5ZYWEVEPdfYwY5kXwYqRJwHrxz9WH+pjSpXQz+TJgAsrkA5A=="],
22
+
23
+ "@shikijs/langs": ["@shikijs/langs@3.3.0", "", { "dependencies": { "@shikijs/types": "3.3.0" } }, "sha512-zt6Kf/7XpBQKSI9eqku+arLkAcDQ3NHJO6zFjiChI8w0Oz6Jjjay7pToottjQGjSDCFk++R85643WbyINcuL+g=="],
24
+
25
+ "@shikijs/themes": ["@shikijs/themes@3.3.0", "", { "dependencies": { "@shikijs/types": "3.3.0" } }, "sha512-tXeCvLXBnqq34B0YZUEaAD1lD4lmN6TOHAhnHacj4Owh7Ptb/rf5XCDeROZt2rEOk5yuka3OOW2zLqClV7/SOg=="],
26
+
27
+ "@shikijs/types": ["@shikijs/types@3.3.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-KPCGnHG6k06QG/2pnYGbFtFvpVJmC3uIpXrAiPrawETifujPBv0Se2oUxm5qYgjCvGJS9InKvjytOdN+bGuX+Q=="],
28
+
29
+ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
30
+
31
+ "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
32
+
33
+ "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
34
+
35
+ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
36
+
37
+ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
38
+
39
+ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
40
+
41
+ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
42
+
43
+ "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
44
+
45
+ "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
46
+
47
+ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
48
+
49
+ "commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
50
+
51
+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
52
+
53
+ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
54
+
55
+ "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
56
+
57
+ "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
58
+
59
+ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
60
+
61
+ "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
62
+
63
+ "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
64
+
65
+ "liquidjs": ["liquidjs@10.21.0", "", { "dependencies": { "commander": "^10.0.0" }, "bin": { "liquidjs": "bin/liquid.js", "liquid": "bin/liquid.js" } }, "sha512-DouqxNU2jfoZzb1LinVjOc/f6ssitGIxiDJT+kEKyYqPSSSd+WmGOAhtWbVm1/n75svu4aQ+FyQ3ctd3wh1bbw=="],
66
+
67
+ "mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="],
68
+
69
+ "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
70
+
71
+ "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
72
+
73
+ "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
74
+
75
+ "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
76
+
77
+ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
78
+
79
+ "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
80
+
81
+ "oniguruma-to-es": ["oniguruma-to-es@4.3.3", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg=="],
82
+
83
+ "pixelmatch": ["pixelmatch@7.1.0", "", { "dependencies": { "pngjs": "^7.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng=="],
84
+
85
+ "playwright": ["playwright@1.52.0", "", { "dependencies": { "playwright-core": "1.52.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw=="],
86
+
87
+ "playwright-core": ["playwright-core@1.52.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg=="],
88
+
89
+ "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
90
+
91
+ "property-information": ["property-information@7.0.0", "", {}, "sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg=="],
92
+
93
+ "regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="],
94
+
95
+ "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
96
+
97
+ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
98
+
99
+ "shiki": ["shiki@3.3.0", "", { "dependencies": { "@shikijs/core": "3.3.0", "@shikijs/engine-javascript": "3.3.0", "@shikijs/engine-oniguruma": "3.3.0", "@shikijs/langs": "3.3.0", "@shikijs/themes": "3.3.0", "@shikijs/types": "3.3.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-j0Z1tG5vlOFGW8JVj0Cpuatzvshes7VJy5ncDmmMaYcmnGW0Js1N81TOW98ivTFNZfKRn9uwEg/aIm638o368g=="],
100
+
101
+ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
102
+
103
+ "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
104
+
105
+ "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
106
+
107
+ "unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="],
108
+
109
+ "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
110
+
111
+ "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
112
+
113
+ "unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
114
+
115
+ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="],
116
+
117
+ "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
118
+
119
+ "vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="],
120
+
121
+ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
122
+
123
+ "liquidjs/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="]
124
+ }
125
+ }
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@rettangoli/vt",
3
- "version": "1.0.0-rc5",
3
+ "version": "1.0.2",
4
4
  "description": "Rettangoli Visual Testing",
5
5
  "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/yuusoft-org/rettangoli",
9
+ "directory": "packages/rettangoli-vt"
10
+ },
6
11
  "main": "./src/index.js",
7
12
  "exports": {
8
13
  ".": "./src/index.js",
@@ -359,10 +359,12 @@ export class PlaywrightRunner {
359
359
  }
360
360
  settleMs = nowMs() - settleStart;
361
361
 
362
- const firstScreenshotStart = nowMs();
363
- const firstScreenshotPath = await wrappedScreenshot(page, task.baseName);
364
- initialScreenshotMs = nowMs() - firstScreenshotStart;
365
- console.log(`Screenshot saved: ${firstScreenshotPath}`);
362
+ if (!task.frontMatter?.skipInitialScreenshot) {
363
+ const firstScreenshotStart = nowMs();
364
+ const firstScreenshotPath = await wrappedScreenshot(page, task.baseName);
365
+ initialScreenshotMs = nowMs() - firstScreenshotStart;
366
+ console.log(`Screenshot saved: ${firstScreenshotPath}`);
367
+ }
366
368
 
367
369
  const stepsStart = nowMs();
368
370
  const stepsExecutor = createSteps(page, {
package/src/cli/report.js CHANGED
@@ -2,10 +2,11 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import crypto from "crypto";
4
4
  import { cp } from "node:fs/promises";
5
+ import { load as loadYaml } from "js-yaml";
5
6
  import pixelmatch from "pixelmatch";
6
7
  import sharp from "sharp";
7
- import { readYaml } from "../common.js";
8
- import { validateVtConfig } from "../validation.js";
8
+ import { extractFrontMatter, readYaml } from "../common.js";
9
+ import { validateFiniteNumber, validateVtConfig } from "../validation.js";
9
10
  import { resolveReportOptions } from "./report-options.js";
10
11
  import {
11
12
  buildAllRelativePaths,
@@ -17,6 +18,7 @@ import {
17
18
  filterRelativeScreenshotPathsBySelectors,
18
19
  hasSelectors,
19
20
  } from "../selector-filter.js";
21
+ import { stripViewportSuffix } from "../viewport.js";
20
22
 
21
23
  const libraryTemplatesPath = new URL("./templates", import.meta.url).pathname;
22
24
 
@@ -35,6 +37,64 @@ function getAllFiles(dir, fileList = []) {
35
37
  return fileList;
36
38
  }
37
39
 
40
+ function normalizePathForLookup(filePath) {
41
+ return String(filePath)
42
+ .replace(/\\/g, "/")
43
+ .replace(/^\.?\//, "");
44
+ }
45
+
46
+ function toSpecItemKey(relativeSpecPath) {
47
+ const normalized = normalizePathForLookup(relativeSpecPath);
48
+ const ext = path.extname(normalized);
49
+ return normalized.slice(0, normalized.length - ext.length);
50
+ }
51
+
52
+ function toScreenshotItemKey(relativeScreenshotPath) {
53
+ const normalized = normalizePathForLookup(relativeScreenshotPath).replace(/\.webp$/i, "");
54
+ const withoutOrdinal = normalized.replace(/-\d{1,3}$/i, "");
55
+ return stripViewportSuffix(withoutOrdinal);
56
+ }
57
+
58
+ function loadFrontMatterDiffThresholdOverrides(specsDir) {
59
+ const overrides = new Map();
60
+ if (!fs.existsSync(specsDir)) {
61
+ return overrides;
62
+ }
63
+
64
+ const specFiles = getAllFiles(specsDir);
65
+ for (const specFilePath of specFiles) {
66
+ const fileContent = fs.readFileSync(specFilePath, "utf8");
67
+ const { frontMatter } = extractFrontMatter(fileContent);
68
+ if (!frontMatter) {
69
+ continue;
70
+ }
71
+
72
+ const relativePath = path.relative(specsDir, specFilePath);
73
+ const frontMatterData = loadYaml(frontMatter);
74
+ if (
75
+ frontMatterData === null
76
+ || frontMatterData === undefined
77
+ || typeof frontMatterData !== "object"
78
+ || Array.isArray(frontMatterData)
79
+ ) {
80
+ continue;
81
+ }
82
+
83
+ if (frontMatterData.diffThreshold === undefined || frontMatterData.diffThreshold === null) {
84
+ continue;
85
+ }
86
+
87
+ validateFiniteNumber(
88
+ frontMatterData.diffThreshold,
89
+ `${relativePath}: frontMatter.diffThreshold`,
90
+ { min: 0, max: 100 },
91
+ );
92
+ overrides.set(toSpecItemKey(relativePath), frontMatterData.diffThreshold);
93
+ }
94
+
95
+ return overrides;
96
+ }
97
+
38
98
  async function calculateImageHash(imagePath) {
39
99
  const imageBuffer = fs.readFileSync(imagePath);
40
100
  const hash = crypto.createHash("md5").update(imageBuffer).digest("hex");
@@ -137,10 +197,19 @@ async function main(options = {}) {
137
197
  const templatePath = path.join(libraryTemplatesPath, "report.html");
138
198
  const outputPath = path.join(siteOutputPath, "report.html");
139
199
  const jsonReportPath = path.join(".rettangoli", "vt", "report.json");
200
+ const specsDir = path.join(vtPath, "specs");
201
+
202
+ let diffThresholdOverridesBySpec = new Map();
203
+ if (compareMethod === "pixelmatch") {
204
+ diffThresholdOverridesBySpec = loadFrontMatterDiffThresholdOverrides(specsDir);
205
+ }
140
206
 
141
207
  console.log(`Comparison method: ${compareMethod}`);
142
208
  if (compareMethod === "pixelmatch") {
143
209
  console.log(` color threshold: ${colorThreshold}, diff threshold: ${diffThreshold}%`);
210
+ if (diffThresholdOverridesBySpec.size > 0) {
211
+ console.log(` frontmatter diff threshold overrides: ${diffThresholdOverridesBySpec.size}`);
212
+ }
144
213
  }
145
214
 
146
215
  if (!fs.existsSync(originalReferenceDir)) {
@@ -204,6 +273,9 @@ async function main(options = {}) {
204
273
  let error = false;
205
274
  let similarity = null;
206
275
  let diffPixels = null;
276
+ const itemKey = toScreenshotItemKey(relativePath);
277
+ const itemDiffThreshold = diffThresholdOverridesBySpec.get(itemKey);
278
+ const effectiveDiffThreshold = itemDiffThreshold ?? diffThreshold;
207
279
 
208
280
  if (candidateExists && referenceExists) {
209
281
  const diffDirPath = path.dirname(diffPath);
@@ -216,7 +288,7 @@ async function main(options = {}) {
216
288
  referencePath,
217
289
  compareMethod,
218
290
  diffPath,
219
- { colorThreshold, diffThreshold },
291
+ { colorThreshold, diffThreshold: effectiveDiffThreshold },
220
292
  );
221
293
  if (comparison.error) {
222
294
  comparisonErrors.push(
@@ -3,8 +3,8 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc6/dist/themes/base.css">
7
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc6/dist/themes/theme-rtgl-slate.css">
6
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc15/dist/themes/base.css">
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc15/dist/themes/theme-rtgl-slate.css">
8
8
  <script>
9
9
  window.rtglIcons = {
10
10
  text: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 12H20M4 8H20M4 16H12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
@@ -40,7 +40,7 @@
40
40
  }
41
41
  </script>
42
42
  <script src="https://cdn.jsdelivr.net/npm/construct-style-sheets-polyfill@3.1.0/dist/adoptedStyleSheets.min.js"></script>
43
- <script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc6/dist/rettangoli-iife-ui.min.js"></script>
43
+ <script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc15/dist/rettangoli-iife-ui.min.js"></script>
44
44
  <script src="/public/main.js"></script>
45
45
  </head>
46
46
  <body class="dark">
@@ -4,8 +4,8 @@
4
4
  <head>
5
5
  <meta charset="UTF-8">
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc6/dist/themes/base.css">
8
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc6/dist/themes/theme-rtgl-slate.css">
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc15/dist/themes/base.css">
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc15/dist/themes/theme-rtgl-slate.css">
9
9
  <script>
10
10
  window.addEventListener('DOMContentLoaded', () => {
11
11
  if (location.hash) {
@@ -17,7 +17,7 @@
17
17
  });
18
18
  </script>
19
19
  <script src="https://cdn.jsdelivr.net/npm/construct-style-sheets-polyfill@3.1.0/dist/adoptedStyleSheets.min.js"></script>
20
- <script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc6/dist/rettangoli-iife-ui.min.js"></script>
20
+ <script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc15/dist/rettangoli-iife-ui.min.js"></script>
21
21
 
22
22
  <style>
23
23
  pre {
@@ -4,10 +4,10 @@
4
4
  <head>
5
5
  <meta charset="UTF-8">
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc6/dist/themes/base.css">
8
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc6/dist/themes/theme-rtgl-slate.css">
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc15/dist/themes/base.css">
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc15/dist/themes/theme-rtgl-slate.css">
9
9
  <script src="https://cdn.jsdelivr.net/npm/construct-style-sheets-polyfill@3.1.0/dist/adoptedStyleSheets.min.js"></script>
10
- <script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc6/dist/rettangoli-iife-ui.min.js"></script>
10
+ <script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc15/dist/rettangoli-iife-ui.min.js"></script>
11
11
  <style>
12
12
  code {
13
13
  white-space: pre-wrap;
package/src/common.js CHANGED
@@ -15,6 +15,7 @@ import path from "path";
15
15
  import { validateFiniteNumber, validateFrontMatter } from "./validation.js";
16
16
  import { createCaptureTasks } from "./capture/spec-loader.js";
17
17
  import { runCaptureScheduler } from "./capture/capture-scheduler.js";
18
+ import { deriveSectionPageKey } from "./section-page-key.js";
18
19
 
19
20
  const removeExtension = (filePath) => filePath.replace(/\.[^/.]+$/, "");
20
21
 
@@ -248,7 +249,7 @@ function getContentType(filePath) {
248
249
  }
249
250
 
250
251
  function toSectionPageKey(sectionLike) {
251
- return String(sectionLike.title || "").toLowerCase();
252
+ return deriveSectionPageKey(sectionLike);
252
253
  }
253
254
 
254
255
  /**
@@ -160,7 +160,7 @@ function assertStructuredKeys(stepObject, allowedKeys, actionName) {
160
160
 
161
161
  function requireStepAction(stepObject) {
162
162
  if (!isPlainObject(stepObject)) {
163
- throw new Error("Invalid step: expected string or object.");
163
+ throw new Error("Invalid step: expected an object.");
164
164
  }
165
165
  if (typeof stepObject.action !== "string" || stepObject.action.trim().length === 0) {
166
166
  throw new Error("Structured step requires non-empty string `action`.");
@@ -180,6 +180,27 @@ function requireStructuredString(stepObject, key, actionName) {
180
180
  return value;
181
181
  }
182
182
 
183
+ function resolveStructuredSelectTarget(stepObject, actionName) {
184
+ const hasTestId = Object.prototype.hasOwnProperty.call(stepObject, "testId");
185
+ const hasSelector = Object.prototype.hasOwnProperty.call(stepObject, "selector");
186
+
187
+ if (hasTestId === hasSelector) {
188
+ throw new Error(`Structured action "${actionName}" requires exactly one of \`testId\` or \`selector\`.`);
189
+ }
190
+
191
+ if (hasTestId) {
192
+ return {
193
+ type: "testId",
194
+ value: requireStructuredString(stepObject, "testId", actionName),
195
+ };
196
+ }
197
+
198
+ return {
199
+ type: "selector",
200
+ value: requireStructuredString(stepObject, "selector", actionName),
201
+ };
202
+ }
203
+
183
204
  function requireStructuredNumber(stepObject, key, actionName) {
184
205
  const value = stepObject[key];
185
206
  if (typeof value !== "number" || !Number.isFinite(value)) {
@@ -224,13 +245,13 @@ function normalizeStructuredActionStep(stepObject) {
224
245
  }
225
246
 
226
247
  if (action === "select") {
227
- assertStructuredKeys(stepObject, new Set(["action", "testId", "steps"]), action);
228
- const testId = requireStructuredString(stepObject, "testId", action);
248
+ assertStructuredKeys(stepObject, new Set(["action", "testId", "selector", "steps"]), action);
249
+ const target = resolveStructuredSelectTarget(stepObject, action);
229
250
  if (!Array.isArray(stepObject.steps)) {
230
251
  throw new Error('Structured action "select" requires array `steps`.');
231
252
  }
232
253
  const nestedSteps = stepObject.steps.map((nestedStep) => normalizeStepValue(nestedStep));
233
- return { kind: "block", command: "select", args: [testId], nestedSteps };
254
+ return { kind: "block", command: "select", args: [`${target.type}=${target.value}`], nestedSteps };
234
255
  }
235
256
 
236
257
  if (action === "click" || action === "dblclick" || action === "hover" || action === "rclick") {
@@ -411,12 +432,8 @@ function normalizeLegacyBlockStep(stepObject) {
411
432
  }
412
433
 
413
434
  function normalizeStepValue(step) {
414
- if (typeof step === "string") {
415
- const { command, args } = parseStepCommand(step);
416
- return { kind: "command", command, args };
417
- }
418
435
  if (!isPlainObject(step)) {
419
- throw new Error("Invalid step: expected string or object.");
436
+ throw new Error("Invalid step: expected an object.");
420
437
  }
421
438
  if (Object.prototype.hasOwnProperty.call(step, "action")) {
422
439
  return normalizeStructuredActionStep(step);
@@ -832,22 +849,32 @@ async function assertStructured(page, assertionConfig, selectedElement) {
832
849
  }
833
850
 
834
851
  async function select(page, args) {
835
- const testId = args[0];
836
- if (!testId) {
837
- throw new Error("`select` requires a test id.");
838
- }
839
- const hostElementLocator = page.getByTestId(testId);
840
-
841
- const interactiveElementLocator = hostElementLocator.locator(
842
- 'input, textarea, button, select, a'
843
- ).first();
844
-
852
+ const { named, positional } = parseNamedArgs(args);
853
+ const testId = typeof named.testId === "string" && named.testId.length > 0
854
+ ? named.testId
855
+ : positional[0];
856
+ const selector = typeof named.selector === "string" && named.selector.length > 0
857
+ ? named.selector
858
+ : undefined;
859
+
860
+ if ((testId ? 1 : 0) + (selector ? 1 : 0) !== 1) {
861
+ throw new Error("`select` requires exactly one target: `testId` or `selector`.");
862
+ }
863
+
864
+ const hostElementLocator = selector
865
+ ? page.locator(selector)
866
+ : page.getByTestId(testId);
867
+
868
+ const interactiveElementLocator = hostElementLocator
869
+ .locator('input, textarea, button, select, a')
870
+ .first();
871
+
845
872
  const count = await interactiveElementLocator.count();
846
-
873
+
847
874
  if (count > 0) {
848
875
  return interactiveElementLocator;
849
876
  }
850
-
877
+
851
878
  return hostElementLocator;
852
879
  }
853
880
 
@@ -890,11 +917,6 @@ export function createSteps(page, context) {
890
917
  if (!command) {
891
918
  return;
892
919
  }
893
- if (command === "assert") {
894
- throw new Error(
895
- "Inline `assert` step strings are no longer supported. Use structured syntax: `- assert: { type: ..., ... }`.",
896
- );
897
- }
898
920
  const actionFn = actionHandlers[command];
899
921
  if (actionFn) {
900
922
  await actionFn(page, args, context, selectedElement);
@@ -0,0 +1,14 @@
1
+ function normalizeString(value) {
2
+ if (typeof value !== "string") {
3
+ return "";
4
+ }
5
+ return value.trim();
6
+ }
7
+
8
+ export function deriveSectionPageKey(sectionLike) {
9
+ return normalizeString(sectionLike?.title)
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, "-")
12
+ .replace(/-+/g, "-")
13
+ .replace(/^-+|-+$/g, "");
14
+ }
@@ -1,5 +1,6 @@
1
1
  import path from "path";
2
2
  import { stripViewportSuffix } from "./viewport.js";
3
+ import { deriveSectionPageKey } from "./section-page-key.js";
3
4
 
4
5
  function toList(value) {
5
6
  if (value === undefined || value === null) return [];
@@ -25,7 +26,7 @@ export function normalizeSelectors(raw = {}) {
25
26
  .map(normalizePathValue)
26
27
  .filter((item) => item.length > 0);
27
28
  const groups = toList(raw.group)
28
- .map((item) => String(item).trim().toLowerCase())
29
+ .map((item) => deriveSectionPageKey({ title: String(item) }))
29
30
  .filter((item) => item.length > 0);
30
31
  const items = toList(raw.item)
31
32
  .map(normalizeItemKey)
@@ -59,12 +60,12 @@ function resolveGroupFolders(configSections = [], groupSelectors = []) {
59
60
  for (const section of configSections) {
60
61
  if (section.type === "groupLabel" && Array.isArray(section.items)) {
61
62
  for (const item of section.items) {
62
- groupFolderMap.set(String(item.title).toLowerCase(), normalizePathValue(item.files));
63
+ groupFolderMap.set(deriveSectionPageKey(item), normalizePathValue(item.files));
63
64
  }
64
65
  continue;
65
66
  }
66
67
  if (section.files) {
67
- groupFolderMap.set(String(section.title).toLowerCase(), normalizePathValue(section.files));
68
+ groupFolderMap.set(deriveSectionPageKey(section), normalizePathValue(section.files));
68
69
  }
69
70
  }
70
71
 
package/src/validation.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { normalizeViewportField } from "./viewport.js";
2
+ import { deriveSectionPageKey } from "./section-page-key.js";
2
3
 
3
4
  function isPlainObject(value) {
4
5
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -121,8 +122,6 @@ const LEGACY_CAPTURE_FIELDS = {
121
122
  headless: true,
122
123
  };
123
124
 
124
- const SECTION_PAGE_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
125
-
126
125
  function assertNoLegacyCaptureFields(vtConfig, sourcePath) {
127
126
  for (const legacyField of Object.keys(LEGACY_CAPTURE_FIELDS)) {
128
127
  if (Object.prototype.hasOwnProperty.call(vtConfig, legacyField)) {
@@ -133,14 +132,11 @@ function assertNoLegacyCaptureFields(vtConfig, sourcePath) {
133
132
  }
134
133
  }
135
134
 
136
- function assertValidSectionPageKey(value, path) {
137
- assert(
138
- typeof value === "string" && value.trim().length > 0,
139
- `"${path}" is required.`,
140
- );
135
+ function assertDerivableSectionPageKey(sectionLike, path) {
136
+ const pageKey = deriveSectionPageKey(sectionLike);
141
137
  assert(
142
- SECTION_PAGE_KEY_PATTERN.test(value),
143
- `"${path}" must contain only letters, numbers, "-" or "_", and cannot include spaces.`,
138
+ pageKey.length > 0,
139
+ `"${path}" must contain at least one letter or number.`,
144
140
  );
145
141
  }
146
142
 
@@ -173,25 +169,25 @@ function validateSection(section, index) {
173
169
 
174
170
  assert(typeof item.title === "string" && item.title.trim().length > 0, `"${itemPath}.title" is required.`);
175
171
  assert(typeof item.files === "string" && item.files.trim().length > 0, `"${itemPath}.files" is required.`);
176
- assertValidSectionPageKey(item.title, `${itemPath}.title`);
172
+ assertDerivableSectionPageKey(item, `${itemPath}.title`);
177
173
  });
178
174
  return;
179
175
  }
180
176
 
181
177
  validateOptionalString(section.files, `${sectionPath}.files`);
182
178
  assert(typeof section.files === "string" && section.files.trim().length > 0, `"${sectionPath}.files" is required.`);
183
- assertValidSectionPageKey(section.title, `${sectionPath}.title`);
179
+ assertDerivableSectionPageKey(section, `${sectionPath}.title`);
184
180
  }
185
181
 
186
182
  function collectSectionPageKeys(vtConfig) {
187
183
  const keys = [];
188
184
  vtConfig.sections.forEach((section) => {
189
185
  if (section.type === "groupLabel" && Array.isArray(section.items)) {
190
- section.items.forEach((item) => keys.push(item.title));
186
+ section.items.forEach((item) => keys.push(deriveSectionPageKey(item)));
191
187
  return;
192
188
  }
193
189
  if (section.files) {
194
- keys.push(section.title);
190
+ keys.push(deriveSectionPageKey(section));
195
191
  }
196
192
  });
197
193
  return keys;
@@ -277,19 +273,22 @@ function validateStructuredActionStep(step, stepPath) {
277
273
  }
278
274
 
279
275
  if (action === "select") {
280
- assertNoUnknownStepKeys(step, stepPath, new Set(["action", "testId", "steps"]));
276
+ assertNoUnknownStepKeys(step, stepPath, new Set(["action", "testId", "selector", "steps"]));
281
277
  validateOptionalString(step.testId, `${stepPath}.testId`);
278
+ validateOptionalString(step.selector, `${stepPath}.selector`);
282
279
  assert(
283
- typeof step.testId === "string" && step.testId.trim().length > 0,
284
- `"${stepPath}.testId" is required for action=select.`,
280
+ (
281
+ typeof step.testId === "string"
282
+ && step.testId.trim().length > 0
283
+ ) !== (
284
+ typeof step.selector === "string"
285
+ && step.selector.trim().length > 0
286
+ ),
287
+ `"${stepPath}" for action=select requires exactly one of "testId" or "selector".`,
285
288
  );
286
289
  assert(Array.isArray(step.steps), `"${stepPath}.steps" must be an array for action=select.`);
287
290
  step.steps.forEach((nestedStep, nestedIndex) => {
288
291
  const nestedPath = `${stepPath}.steps[${nestedIndex}]`;
289
- if (typeof nestedStep === "string") {
290
- assert(nestedStep.trim().length > 0, `"${nestedPath}" cannot be empty.`);
291
- return;
292
- }
293
292
  validateStepObject(nestedStep, nestedPath);
294
293
  });
295
294
  return;
@@ -434,7 +433,7 @@ function validateStructuredActionStep(step, stepPath) {
434
433
  }
435
434
 
436
435
  function validateStepObject(step, stepPath) {
437
- assert(isPlainObject(step), `"${stepPath}" must be an object with one key or a string.`);
436
+ assert(isPlainObject(step), `"${stepPath}" must be an object.`);
438
437
 
439
438
  if (Object.prototype.hasOwnProperty.call(step, "action")) {
440
439
  validateStructuredActionStep(step, stepPath);
@@ -459,10 +458,6 @@ function validateStepObject(step, stepPath) {
459
458
  assert(Array.isArray(nestedSteps), `"${stepPath}.${stepKey}" must be an array of step values.`);
460
459
  nestedSteps.forEach((nestedStep, nestedIndex) => {
461
460
  const nestedPath = `${stepPath}.${stepKey}[${nestedIndex}]`;
462
- if (typeof nestedStep === "string") {
463
- assert(nestedStep.trim().length > 0, `"${nestedPath}" cannot be empty.`);
464
- return;
465
- }
466
461
  validateStepObject(nestedStep, nestedPath);
467
462
  });
468
463
  }
@@ -606,6 +601,10 @@ export function validateFrontMatter(frontMatter, specPath) {
606
601
  ["networkidle", "load", "event", "selector"],
607
602
  );
608
603
  validateOptionalBoolean(frontMatter.skipScreenshot, `${specPath}: frontMatter.skipScreenshot`);
604
+ validateOptionalBoolean(
605
+ frontMatter.skipInitialScreenshot,
606
+ `${specPath}: frontMatter.skipInitialScreenshot`,
607
+ );
609
608
 
610
609
  if (frontMatter.waitStrategy === "event") {
611
610
  assert(
@@ -633,10 +632,6 @@ export function validateFrontMatter(frontMatter, specPath) {
633
632
  assert(Array.isArray(frontMatter.steps), `"${specPath}: frontMatter.steps" must be an array.`);
634
633
  frontMatter.steps.forEach((step, index) => {
635
634
  const stepPath = `${specPath}: frontMatter.steps[${index}]`;
636
- if (typeof step === "string") {
637
- assert(step.trim().length > 0, `"${stepPath}" cannot be empty.`);
638
- return;
639
- }
640
635
  validateStepObject(step, stepPath);
641
636
  });
642
637
  }