@fusion.dev/helix-cli 0.223.0 → 0.224.0
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 +1 -1
- package/bin.mjs +4 -5
- package/package.json +9 -9
- package/visual/README.md +112 -11
- package/visual/driver.mjs +224 -22
- package/visual/index.d.ts +41 -8
- package/visual/words.mjs +41 -0
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ helix mcp init --client claude
|
|
|
31
31
|
| `scan` | Reads your project's stylesheets, templates, and modules, and prints each finding with its file and line. `--build <dir>` weighs each Helix family in your bundle, `--out <path>` writes the run to the CI file, `--config <path>` names the settings, `--file <path>` reports one file's findings, and `--stdin <language>` checks a snippet |
|
|
32
32
|
| `api` | Looks up the installed Helix packages: `find` a family, a `family`'s API, an `example` with its imports, the `imports` for a list of tags, and `tokens` |
|
|
33
33
|
| `manifest` | `diff` compares two custom elements manifests and prints each difference. Exits 1 on any difference |
|
|
34
|
-
| `visual` | Pictures each page in a real browser and compares it with a baseline kept in git: `test`, `update` the baselines, open the `report`, and `compare` two PNG files. Reads `visual.config.ts`, and needs Playwright installed. [The guide](https://github.com/project-fusion/fusion/blob/main/helix/cli/npm/visual/README.md) has the config and the GitHub Action |
|
|
34
|
+
| `visual` | Pictures each page in a real browser and compares it with a baseline kept in git: `test`, `update` the baselines, open the `report`, and `compare` two PNG files. Reads `visual.config.ts`, and needs Playwright installed. [The guide](https://github.com/project-fusion/fusion/blob/main/helix/cli/npm/visual/README.md), also in this package at `visual/README.md`, has the config and the GitHub Action |
|
|
35
35
|
| `mcp` | Serves the same lookups, the scan, and the theme derivation to an agent over stdio, as an MCP server. `mcp init --client <client>` writes the configuration for Claude Code, Cursor, VS Code, Gemini CLI, opencode, Roo Code, or Junie |
|
|
36
36
|
|
|
37
37
|
`--json` is on every verb, in a shape a script can read without parsing
|
package/bin.mjs
CHANGED
|
@@ -231,12 +231,11 @@ const run = (binary) => {
|
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
/**
|
|
234
|
-
* What a build here asks cargo for: the selection `rs:build` and CI build,
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* CI's runner its `glib-sys` found no `glib-2.0` and every rule check failed.
|
|
234
|
+
* What a build here asks cargo for: the selection `rs:build` and CI build, the
|
|
235
|
+
* whole workspace. Kept in one place, so the build this runs and the build
|
|
236
|
+
* `rs:build` runs unify the same features and neither undoes the other's work.
|
|
238
237
|
*/
|
|
239
|
-
const SELECTION = ['--workspace'
|
|
238
|
+
const SELECTION = ['--workspace']
|
|
240
239
|
|
|
241
240
|
/* **In this repository, cargo is asked first, and asking is cheap.** A binary
|
|
242
241
|
under `target/` is whatever the last build left, so after an edit to a rule,
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fusion.dev/helix-cli",
|
|
3
3
|
"description": "The Helix command line, for a workspace that reaches it through npm, and the visual tests it runs.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.224.0",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"author": "
|
|
6
|
+
"author": "Fusion Development Group",
|
|
7
7
|
"repository": {
|
|
8
8
|
"directory": "helix/cli/npm",
|
|
9
9
|
"type": "git",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"bugs": "https://github.com/project-fusion/fusion/issues",
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@fusion.dev/helix-templates": "0.
|
|
33
|
+
"@fusion.dev/helix-templates": "0.224.0"
|
|
34
34
|
},
|
|
35
35
|
"homepage": "https://fusion.dev",
|
|
36
36
|
"keywords": [
|
|
@@ -44,11 +44,11 @@
|
|
|
44
44
|
"visual-regression"
|
|
45
45
|
],
|
|
46
46
|
"optionalDependencies": {
|
|
47
|
-
"@fusion.dev/helix-cli-darwin-arm64": "0.
|
|
48
|
-
"@fusion.dev/helix-cli-darwin-x64": "0.
|
|
49
|
-
"@fusion.dev/helix-cli-linux-arm64": "0.
|
|
50
|
-
"@fusion.dev/helix-cli-linux-x64": "0.
|
|
51
|
-
"@fusion.dev/helix-cli-win32-x64": "0.
|
|
47
|
+
"@fusion.dev/helix-cli-darwin-arm64": "0.224.0",
|
|
48
|
+
"@fusion.dev/helix-cli-darwin-x64": "0.224.0",
|
|
49
|
+
"@fusion.dev/helix-cli-linux-arm64": "0.224.0",
|
|
50
|
+
"@fusion.dev/helix-cli-linux-x64": "0.224.0",
|
|
51
|
+
"@fusion.dev/helix-cli-win32-x64": "0.224.0"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"@anthropic-ai/sdk": ">=0.132",
|
|
@@ -63,6 +63,6 @@
|
|
|
63
63
|
}
|
|
64
64
|
},
|
|
65
65
|
"publishConfig": {
|
|
66
|
-
"access": "
|
|
66
|
+
"access": "public"
|
|
67
67
|
}
|
|
68
68
|
}
|
package/visual/README.md
CHANGED
|
@@ -16,7 +16,14 @@ npx playwright install --with-deps chromium
|
|
|
16
16
|
|
|
17
17
|
Capture runs in Playwright, which you install beside the command line. The
|
|
18
18
|
rest of `helix` needs no browser, so Playwright is an optional peer, and
|
|
19
|
-
`helix visual test` names it when it is missing.
|
|
19
|
+
`helix visual test` names it when it is missing. A config in TypeScript needs
|
|
20
|
+
Node 22.18 or later. On an older Node, write it as `visual.config.mjs`.
|
|
21
|
+
|
|
22
|
+
**On macOS and Windows, a run skips until you choose where baselines are taken.**
|
|
23
|
+
`test` and `update` say so, compare nothing, and exit 0, because text draws
|
|
24
|
+
differently on each platform. Take baselines on Linux, as
|
|
25
|
+
[Baselines on Linux](#baselines-on-linux) shows, or set `platform: 'any'` to
|
|
26
|
+
compare on the machine in front of you.
|
|
20
27
|
|
|
21
28
|
## Commands
|
|
22
29
|
|
|
@@ -81,6 +88,85 @@ the report's `run.json`, `report` prints the last one, `accept` prints what it
|
|
|
81
88
|
wrote and what it left, and `compare` prints whether the two pictures match and
|
|
82
89
|
how many pixels differ.
|
|
83
90
|
|
|
91
|
+
## Picture a site
|
|
92
|
+
|
|
93
|
+
Picture a production build, because a dev server draws its own overlays, and they
|
|
94
|
+
differ from run to run. `visual.config.ts`, for a Next.js site:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { defineConfig } from '@fusion.dev/helix-cli/visual'
|
|
98
|
+
|
|
99
|
+
export default defineConfig({
|
|
100
|
+
sources: [
|
|
101
|
+
{
|
|
102
|
+
baselines: 'visual/baselines',
|
|
103
|
+
kind: 'server',
|
|
104
|
+
name: 'site',
|
|
105
|
+
routes: ['/', '/pricing', { name: 'not-found', path: '/nope', status: 404 }],
|
|
106
|
+
start: 'npm run start -- --port 3100',
|
|
107
|
+
url: 'http://localhost:3100',
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
npm run build
|
|
115
|
+
npx helix visual update
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`update` writes the baselines and the fingerprint, and its report shows each
|
|
119
|
+
picture it wrote. Look at them, commit them, and run `helix visual test` from then
|
|
120
|
+
on. Build before each run, since `start` serves the last build. Any page that
|
|
121
|
+
answers 400 or above fails the entry, unless its route names that `status`.
|
|
122
|
+
|
|
123
|
+
## Pages that draw late
|
|
124
|
+
|
|
125
|
+
A page can look finished before it is: a map's tiles arrive after the page loads.
|
|
126
|
+
Name a selector that matches once it has drawn, and the run waits for it after each
|
|
127
|
+
load. The entry fails when nothing matches within `settle.timeout`, 30 seconds
|
|
128
|
+
unless you set it.
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
settle: { selector: '[data-map-ready]', timeout: 60 },
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
A route can name its own `selector`, for a page the others do not resemble:
|
|
135
|
+
`{ path: '/map', selector: '[data-map-ready]' }`.
|
|
136
|
+
|
|
137
|
+
**Requests to other hosts are refused**, and the run names each host in its log and
|
|
138
|
+
in `run.json`, under `refused`. A page that draws from another host needs one of
|
|
139
|
+
these:
|
|
140
|
+
|
|
141
|
+
- **`network: { allow: ['tiles.example.com', '*.example.org'] }`** lets those hosts
|
|
142
|
+
through. A baseline then records what they returned, and a host that changes its
|
|
143
|
+
pictures fails the run.
|
|
144
|
+
- **`network: 'open'`** lets every host through, with the same result.
|
|
145
|
+
- **`mask`** paints over the content and needs no network. It paints over the
|
|
146
|
+
element's whole box and anything drawn on top of it, so name the smallest element
|
|
147
|
+
that holds the content. The run names a mask that matches nothing on a page, or
|
|
148
|
+
only elements with no box, such as an element with `display: contents`.
|
|
149
|
+
- **`style`** applies a stylesheet while each picture is taken, to hide an element
|
|
150
|
+
that a mask would paint too much around.
|
|
151
|
+
|
|
152
|
+
A clock on the page, such as "4 minutes ago", changes on its own. Mask that element.
|
|
153
|
+
|
|
154
|
+
## Baselines on Linux
|
|
155
|
+
|
|
156
|
+
Linux is the default, so a Mac can take baselines in Playwright's image. Install
|
|
157
|
+
inside the container, which fetches the command's Linux binary, use the image that
|
|
158
|
+
matches your Playwright version, and add `--platform linux/amd64` when your CI runs
|
|
159
|
+
on x86-64:
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
docker run --rm -v "$PWD":/work -v /work/node_modules -w /work \
|
|
163
|
+
mcr.microsoft.com/playwright:v1.64.1-noble \
|
|
164
|
+
sh -c 'npm ci && npm run build && npx helix visual update'
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The fingerprint records the image's browser and fonts, so CI has to run in the same
|
|
168
|
+
image.
|
|
169
|
+
|
|
84
170
|
## The config
|
|
85
171
|
|
|
86
172
|
`visual.config.ts`, in the directory you run `helix visual` from:
|
|
@@ -118,8 +204,9 @@ export default defineConfig({
|
|
|
118
204
|
- `pairs` compares two renders of each entry with each other, described below.
|
|
119
205
|
|
|
120
206
|
The three on a server take its `url`, a `start` command that runs only when
|
|
121
|
-
nothing answers there and is stopped when the run ends,
|
|
122
|
-
|
|
207
|
+
nothing answers there and is stopped when the run ends, `timeout`, the seconds
|
|
208
|
+
to wait for it to answer, 120 unless you set it, and `baselines`, the directory
|
|
209
|
+
their baselines are written in. An `html` source reads every file
|
|
123
210
|
its `glob` matches under `root`. `{name}` is the path segment that names the
|
|
124
211
|
entry, `*` matches within a segment, and `**` matches any number of
|
|
125
212
|
directories. Each file is pictured inside a document that links `styles`,
|
|
@@ -133,9 +220,14 @@ export default defineConfig({
|
|
|
133
220
|
- **`serve`**: URL paths served from disk, relative to the config. A path that
|
|
134
221
|
ends in `/` serves a directory.
|
|
135
222
|
- **`settle.readyProperty`**: a property your elements carry holding a promise
|
|
136
|
-
that resolves once they have rendered. `helix visual` awaits it before each shot
|
|
137
|
-
|
|
138
|
-
|
|
223
|
+
that resolves once they have rendered. `helix visual` awaits it before each shot,
|
|
224
|
+
for 5 seconds, and then names the tags still waiting and takes the picture.
|
|
225
|
+
- **`settle.selector`** and **`settle.timeout`**: what a page matches once it has
|
|
226
|
+
drawn, and how long to wait for it. See [Pages that draw late](#pages-that-draw-late).
|
|
227
|
+
- **`settle.elements`**: limits the custom elements a run waits for to one tag
|
|
228
|
+
prefix, for a framework that writes hyphenated tags it never defines, such as
|
|
229
|
+
Angular's component tags. A tag a script gives a shadow root while nothing defines
|
|
230
|
+
it is a plain host, and is never waited for.
|
|
139
231
|
- **`aria`**: keeps each entry's accessibility snapshot, Playwright's
|
|
140
232
|
`ariaSnapshot()` of its page, beside its baselines as `<stem>.aria.yml`, and
|
|
141
233
|
compares it as text, so a lost label fails even when no pixel moved. A
|
|
@@ -220,6 +312,8 @@ export default defineConfig({
|
|
|
220
312
|
- **`retake`**: a sentence telling your team how to retake baselines, printed
|
|
221
313
|
when a run fails.
|
|
222
314
|
- **`fingerprint`**, **`network`**, and **`platform`** are described below.
|
|
315
|
+
- **`baselines`**: on a server source, the directory its baselines are written
|
|
316
|
+
in, relative to the config.
|
|
223
317
|
|
|
224
318
|
## Pairs
|
|
225
319
|
|
|
@@ -259,7 +353,8 @@ picture is committed.
|
|
|
259
353
|
element to be defined, the fonts to load, each element's ready promise, and
|
|
260
354
|
the page's boxes and scroll offsets to stay the same for three frames. It
|
|
261
355
|
emulates reduced motion and takes the shot with animations disabled. A font
|
|
262
|
-
that fails to load fails the entry.
|
|
356
|
+
file that fails to load fails the entry. A face with only `local()` sources has
|
|
357
|
+
no file to load, and the page draws the next family in its stack.
|
|
263
358
|
- **One page load for each width an entry is pictured at.** Shots at the same
|
|
264
359
|
width change the color scheme and the harness style on the same page, in the
|
|
265
360
|
order the config lists them. A shot at a new width loads the page again, so
|
|
@@ -275,11 +370,17 @@ picture is committed.
|
|
|
275
370
|
read the same, apart from where they are and each element's `id`, are one
|
|
276
371
|
group: a token change that moved one kind of element across 300 pictures is
|
|
277
372
|
one group of 300. No model is involved.
|
|
373
|
+
- **A page that answers an error is not pictured.** A status of 400 or above, or a
|
|
374
|
+
page that does not load, fails the entry, in `test` and in `update`. No baseline
|
|
375
|
+
is written.
|
|
278
376
|
- **Requests stay on the harness origin.** Every request to another host is
|
|
279
|
-
refused, so a baseline never records what another server returned
|
|
280
|
-
`network:
|
|
377
|
+
refused, so a baseline never records what another server returned, and the run
|
|
378
|
+
names each refused host. `network: { allow: [...] }` lets some hosts through, and
|
|
379
|
+
`network: 'open'` lets every host through.
|
|
281
380
|
- **Baselines are taken and compared on Linux.** On another platform `helix
|
|
282
|
-
visual test`
|
|
381
|
+
visual test` and `helix visual update` say so, compare nothing, and exit 0.
|
|
382
|
+
`platform: 'any'` compares wherever it runs, and baselines taken that way hold
|
|
383
|
+
only on that platform.
|
|
283
384
|
- **The environment is recorded.** `helix visual update` of every entry
|
|
284
385
|
writes `visual.fingerprint.json`: the platform, the operating system release, the
|
|
285
386
|
browser version, and a hash of the installed fonts. A run on an environment
|
|
@@ -321,7 +422,7 @@ Every `helix visual test` and `helix visual update` writes a report into the con
|
|
|
321
422
|
`report` directory: `index.html` to look at, and `run.json` for a program to
|
|
322
423
|
read. The page shows each shot that failed with its baseline, its capture, and
|
|
323
424
|
its difference side by side, and a swipe between the baseline and the capture.
|
|
324
|
-
It lists the shots that needed a second take, the exceptions with their
|
|
425
|
+
After an `update`, it shows each picture the run wrote. It lists the shots that needed a second take, the exceptions with their
|
|
325
426
|
measurements, and what was skipped. The directory is emptied at the start of
|
|
326
427
|
each run.
|
|
327
428
|
|
package/visual/driver.mjs
CHANGED
|
@@ -48,6 +48,7 @@ import { extname, resolve, sep } from 'node:path'
|
|
|
48
48
|
import { StringDecoder } from 'node:string_decoder'
|
|
49
49
|
import { pathToFileURL } from 'node:url'
|
|
50
50
|
import { boxesOf, readPage } from './checks.mjs'
|
|
51
|
+
import { allowed, unanswered, unloadable } from './words.mjs'
|
|
51
52
|
|
|
52
53
|
/** The `id` of the harness `<style>` the binary writes into every page. */
|
|
53
54
|
const STYLE_ID = 'helix-visual-harness'
|
|
@@ -110,6 +111,8 @@ const TYPES = {
|
|
|
110
111
|
|
|
111
112
|
/** The run's state, filled in by the requests in the order they come. */
|
|
112
113
|
const state = {
|
|
114
|
+
/** Hosts requests may reach besides the origins, with `network: { allow }`. */
|
|
115
|
+
allow: [],
|
|
113
116
|
browser: undefined,
|
|
114
117
|
config: undefined,
|
|
115
118
|
/**
|
|
@@ -236,19 +239,47 @@ const respond = (request, response) => {
|
|
|
236
239
|
*/
|
|
237
240
|
const upgrades = (page, prefix) =>
|
|
238
241
|
page.evaluate(
|
|
239
|
-
async ({ prefix, seconds }) => {
|
|
242
|
+
async ({ PLAIN_HOSTS, prefix, seconds }) => {
|
|
243
|
+
const hosts = globalThis[Symbol.for(PLAIN_HOSTS)]
|
|
240
244
|
const pending = [
|
|
241
245
|
...new Set([...document.querySelectorAll(':not(:defined)')].map((one) => one.localName)),
|
|
242
|
-
].filter((tag) => tag.startsWith(prefix))
|
|
246
|
+
].filter((tag) => tag.startsWith(prefix) && !hosts?.has(tag))
|
|
243
247
|
await Promise.race([
|
|
244
248
|
Promise.all(pending.map((tag) => customElements.whenDefined(tag))),
|
|
245
249
|
new Promise((done) => setTimeout(done, seconds * 1000)),
|
|
246
250
|
])
|
|
247
251
|
return pending.filter((tag) => customElements.get(tag) === undefined)
|
|
248
252
|
},
|
|
249
|
-
{ prefix: prefix ?? '', seconds: UPGRADES },
|
|
253
|
+
{ PLAIN_HOSTS, prefix: prefix ?? '', seconds: UPGRADES },
|
|
250
254
|
)
|
|
251
255
|
|
|
256
|
+
/** Where a page keeps the tags {@link notePlainHosts} saw. */
|
|
257
|
+
const PLAIN_HOSTS = 'fusion.dev/helix/visual/plain-hosts'
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Notes each tag a script gives a shadow root while nothing defines it.
|
|
261
|
+
*
|
|
262
|
+
* **A plain host is waiting for no definition.** Next.js puts its route
|
|
263
|
+
* announcer in `<next-route-announcer>` and its dev overlay in
|
|
264
|
+
* `<nextjs-portal>` this way, and no framework that does this ever registers
|
|
265
|
+
* the tag. An element waiting for a definition has no shadow root yet, or has
|
|
266
|
+
* one its HTML declared, which calls nothing, so a page whose bundle failed to
|
|
267
|
+
* load still fails. A definition's own constructor runs with the tag already
|
|
268
|
+
* registered, so it is never noted.
|
|
269
|
+
*/
|
|
270
|
+
const notePlainHosts = (context) =>
|
|
271
|
+
context.addInitScript((key) => {
|
|
272
|
+
const hosts = new Set()
|
|
273
|
+
globalThis[Symbol.for(key)] = hosts
|
|
274
|
+
const attach = Element.prototype.attachShadow
|
|
275
|
+
Element.prototype.attachShadow = function (...options) {
|
|
276
|
+
if (customElements.get(this.localName) === undefined) {
|
|
277
|
+
hosts.add(this.localName)
|
|
278
|
+
}
|
|
279
|
+
return attach.apply(this, options)
|
|
280
|
+
}
|
|
281
|
+
}, PLAIN_HOSTS)
|
|
282
|
+
|
|
252
283
|
/**
|
|
253
284
|
* **How long a page has to define its elements after it loads: twenty
|
|
254
285
|
* seconds**, a third of what one shot may take. The bound only stops a run
|
|
@@ -261,8 +292,9 @@ const upgrades = (page, prefix) =>
|
|
|
261
292
|
const UPGRADES = 20
|
|
262
293
|
|
|
263
294
|
/**
|
|
264
|
-
* Waits for the page to hold still: three frames, the fonts,
|
|
265
|
-
* own ready signal, then boxes and scroll offsets unchanged for
|
|
295
|
+
* Waits for the page to hold still: three frames, the fonts, every image,
|
|
296
|
+
* each element's own ready signal, then boxes and scroll offsets unchanged for
|
|
297
|
+
* three frames.
|
|
266
298
|
*
|
|
267
299
|
* **Three frames, because a framework may render in a task of its own** and
|
|
268
300
|
* an example that sets state in an effect needs the render after it.
|
|
@@ -271,7 +303,15 @@ const UPGRADES = 20
|
|
|
271
303
|
* library that publishes a promise for "rendered and styled" knows better
|
|
272
304
|
* than any frame count. Without it, Fusion's `pagination/footer` differed by
|
|
273
305
|
* 419 pixels on one run and 842 on the next from the same two files.
|
|
274
|
-
* Bounded, so an element that never resolves cannot hang the run
|
|
306
|
+
* Bounded, so an element that never resolves cannot hang the run, and the tags
|
|
307
|
+
* still waiting at the bound come back, for the run to name.
|
|
308
|
+
*
|
|
309
|
+
* **Every picture loaded, lazy ones included.** A whole-page picture is taken
|
|
310
|
+
* without scrolling, so an image marked `loading="lazy"` below the first
|
|
311
|
+
* screen was never asked for, and the site's home page was baselined with an
|
|
312
|
+
* empty app window for as long as it had one. Each is switched to eager, and
|
|
313
|
+
* the wait is for every image to load and decode, or fail, bounded at ten
|
|
314
|
+
* seconds so an image that never answers cannot hang the run.
|
|
275
315
|
*
|
|
276
316
|
* **Then geometry and scroll held for three frames**, which `animations:
|
|
277
317
|
* 'disabled'` does not cover. A programmatic smooth scroll is no animation:
|
|
@@ -286,12 +326,26 @@ const still = (page, readyProperty) =>
|
|
|
286
326
|
requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(done))),
|
|
287
327
|
)
|
|
288
328
|
await Promise.race([document.fonts.ready, new Promise((done) => setTimeout(done, 10000))])
|
|
329
|
+
const images = [...document.images]
|
|
330
|
+
for (const image of images) {
|
|
331
|
+
if (image.loading === 'lazy') {
|
|
332
|
+
image.loading = 'eager'
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
await Promise.race([
|
|
336
|
+
Promise.all(images.map((image) => image.decode().catch(() => {}))),
|
|
337
|
+
new Promise((done) => setTimeout(done, 10000)),
|
|
338
|
+
])
|
|
339
|
+
const late = new Set()
|
|
289
340
|
if (readyProperty) {
|
|
290
341
|
await Promise.race([
|
|
291
342
|
Promise.all(
|
|
292
343
|
[...document.querySelectorAll('*')]
|
|
293
|
-
.
|
|
294
|
-
.
|
|
344
|
+
.filter((one) => one[readyProperty] instanceof Promise)
|
|
345
|
+
.map((one) => {
|
|
346
|
+
late.add(one.localName)
|
|
347
|
+
return one[readyProperty].then(() => late.delete(one.localName))
|
|
348
|
+
}),
|
|
295
349
|
),
|
|
296
350
|
new Promise((done) => setTimeout(done, 5000)),
|
|
297
351
|
])
|
|
@@ -310,8 +364,62 @@ const still = (page, readyProperty) =>
|
|
|
310
364
|
held = now === last ? held + 1 : 0
|
|
311
365
|
last = now
|
|
312
366
|
}
|
|
367
|
+
return [...late]
|
|
313
368
|
}, readyProperty ?? null)
|
|
314
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Waits for what `settle.selector` or a route's `selector` names, which is how a
|
|
372
|
+
* page says it has drawn what no frame count can see, such as a map's tiles.
|
|
373
|
+
* It waits for the element to be in the document, since a host with
|
|
374
|
+
* `display: contents` is never visible. Giving up fails the entry, because a
|
|
375
|
+
* picture taken anyway is a baseline of the page before it was ready.
|
|
376
|
+
*/
|
|
377
|
+
const awaited = async (page, selector, seconds) => {
|
|
378
|
+
try {
|
|
379
|
+
await page
|
|
380
|
+
.locator(selector)
|
|
381
|
+
.first()
|
|
382
|
+
.waitFor({ state: 'attached', timeout: seconds * 1000 })
|
|
383
|
+
} catch (caught) {
|
|
384
|
+
if (caught?.name !== 'TimeoutError') {
|
|
385
|
+
throw caught
|
|
386
|
+
}
|
|
387
|
+
/* A page drawing WebGL in software can hold its main thread for minutes, and
|
|
388
|
+
the wait polls there: BlocNav's map marked itself ready at 4.8 seconds and
|
|
389
|
+
answered at 142. So the page is asked once more, and this waits for its
|
|
390
|
+
answer, within what `bounded` allows the entry. */
|
|
391
|
+
if ((await page.locator(selector).count()) > 0) {
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
throw new Error(
|
|
395
|
+
`the selector \`${selector}\` matched nothing after ${seconds} seconds, so nothing was pictured`,
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* What each mask painted on this page: `painted`, `no box` when it matches only
|
|
402
|
+
* elements with no area, such as a host with `display: contents`, or `nothing`.
|
|
403
|
+
* Playwright paints over a box, so the last two leave the picture as it was.
|
|
404
|
+
*/
|
|
405
|
+
const maskStates = (page) =>
|
|
406
|
+
Promise.all(
|
|
407
|
+
state.mask.map(async (selector) => [
|
|
408
|
+
selector,
|
|
409
|
+
await page.locator(selector).evaluateAll((elements) => {
|
|
410
|
+
if (elements.length === 0) {
|
|
411
|
+
return 'nothing'
|
|
412
|
+
}
|
|
413
|
+
return elements.some((one) => {
|
|
414
|
+
const box = one.getBoundingClientRect()
|
|
415
|
+
return box.width > 0 && box.height > 0
|
|
416
|
+
})
|
|
417
|
+
? 'painted'
|
|
418
|
+
: 'no box'
|
|
419
|
+
}),
|
|
420
|
+
]),
|
|
421
|
+
)
|
|
422
|
+
|
|
315
423
|
/**
|
|
316
424
|
* `promise`, or a refusal after `seconds`.
|
|
317
425
|
*
|
|
@@ -443,6 +551,46 @@ const restore = async (page, state) => {
|
|
|
443
551
|
}
|
|
444
552
|
}
|
|
445
553
|
|
|
554
|
+
/**
|
|
555
|
+
* The families of the faces a page failed to load, in the page.
|
|
556
|
+
*
|
|
557
|
+
* **A face with only `local()` sources has nothing to download.** `next/font`
|
|
558
|
+
* writes a fallback as `src: local("Arial")`, and Linux has no Arial, so its
|
|
559
|
+
* status is `error` on the platform baselines are taken on, and the page draws
|
|
560
|
+
* the next family in its stack, as it is written to. A sheet the page cannot
|
|
561
|
+
* read leaves a face judged by its status alone. A family with a file among its
|
|
562
|
+
* faces has something to download, so its failure stands.
|
|
563
|
+
*/
|
|
564
|
+
const unloadedFaces = () => {
|
|
565
|
+
const localOnly = new Set()
|
|
566
|
+
const downloaded = new Set()
|
|
567
|
+
const rulesOf = (sheet) => {
|
|
568
|
+
try {
|
|
569
|
+
return [...sheet.cssRules]
|
|
570
|
+
} catch {
|
|
571
|
+
return []
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const walk = (rules) => {
|
|
575
|
+
for (const rule of rules) {
|
|
576
|
+
if (rule instanceof CSSFontFaceRule) {
|
|
577
|
+
const family = rule.style.getPropertyValue('font-family').replace(/["']/g, '')
|
|
578
|
+
const faces = rule.style.getPropertyValue('src').includes('url(') ? downloaded : localOnly
|
|
579
|
+
faces.add(family)
|
|
580
|
+
} else if (rule.cssRules) {
|
|
581
|
+
walk([...rule.cssRules])
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
for (const sheet of document.styleSheets) {
|
|
586
|
+
walk(rulesOf(sheet))
|
|
587
|
+
}
|
|
588
|
+
return [...document.fonts]
|
|
589
|
+
.filter((face) => face.status === 'error')
|
|
590
|
+
.map((face) => face.family.replace(/["']/g, ''))
|
|
591
|
+
.filter((family) => !localOnly.has(family) || downloaded.has(family))
|
|
592
|
+
}
|
|
593
|
+
|
|
446
594
|
/** One entry, every shot of it from one navigation. */
|
|
447
595
|
const take = async (lane, item, { elements, readyProperty }, progress) => {
|
|
448
596
|
const { page } = lane
|
|
@@ -455,8 +603,14 @@ const take = async (lane, item, { elements, readyProperty }, progress) => {
|
|
|
455
603
|
let aria = null
|
|
456
604
|
/* The color schemes contrast has been read in. */
|
|
457
605
|
const read = new Set()
|
|
606
|
+
/* What each mask painted, the best of the shots, since one element may hide
|
|
607
|
+
at one width. */
|
|
608
|
+
const masks = new Map()
|
|
609
|
+
/* The tags still holding an unresolved ready promise at the bound. */
|
|
610
|
+
const unready = new Set()
|
|
458
611
|
lane.errors.length = 0
|
|
459
612
|
lane.messages.length = 0
|
|
613
|
+
lane.refused.clear()
|
|
460
614
|
/* The width the page was last loaded at. */
|
|
461
615
|
let loaded
|
|
462
616
|
try {
|
|
@@ -472,17 +626,25 @@ const take = async (lane, item, { elements, readyProperty }, progress) => {
|
|
|
472
626
|
const address =
|
|
473
627
|
item.url ??
|
|
474
628
|
`${state.origin}${item.path}?visual=${encodeURIComponent(item.key)}${index > 0 ? `&shot=${index}` : ''}`
|
|
475
|
-
await page.goto(address, { waitUntil: 'load' })
|
|
629
|
+
const answered = await page.goto(address, { waitUntil: 'load' })
|
|
630
|
+
const refused = unanswered(answered?.status(), item.status, address)
|
|
631
|
+
if (refused !== undefined) {
|
|
632
|
+
throw new Error(refused)
|
|
633
|
+
}
|
|
476
634
|
loaded = shot.width
|
|
477
635
|
const missing = await upgrades(page, elements)
|
|
478
636
|
if (missing.length > 0) {
|
|
479
637
|
throw new Error(
|
|
480
|
-
`nothing defines ${missing.map((tag) => `<${tag}>`).join(', ')}, after ${UPGRADES} seconds of waiting`,
|
|
638
|
+
`nothing defines ${missing.map((tag) => `<${tag}>`).join(', ')}, after ${UPGRADES} seconds of waiting. If a framework writes that tag and never defines it, set \`settle.elements\` to the prefix of the custom elements you do define`,
|
|
481
639
|
)
|
|
482
640
|
}
|
|
483
641
|
if (item.state) {
|
|
484
642
|
await restore(page, item.state)
|
|
485
643
|
}
|
|
644
|
+
if (item.selector) {
|
|
645
|
+
progress.stage = `waiting for ${item.selector}`
|
|
646
|
+
await awaited(page, item.selector, item.wait)
|
|
647
|
+
}
|
|
486
648
|
} else if (shot.harness !== null) {
|
|
487
649
|
await page.evaluate(
|
|
488
650
|
([id, rules]) => {
|
|
@@ -495,12 +657,12 @@ const take = async (lane, item, { elements, readyProperty }, progress) => {
|
|
|
495
657
|
)
|
|
496
658
|
}
|
|
497
659
|
progress.stage = `settling for ${shot.name}`
|
|
498
|
-
await still(page, readyProperty)
|
|
660
|
+
for (const tag of await still(page, readyProperty)) {
|
|
661
|
+
unready.add(tag)
|
|
662
|
+
}
|
|
499
663
|
/* A face the page asked for and could not load draws in the platform's
|
|
500
664
|
fallback, and a baseline taken that way defends the wrong face. */
|
|
501
|
-
const broken = await page.evaluate(
|
|
502
|
-
[...document.fonts].filter((face) => face.status === 'error').map((face) => face.family),
|
|
503
|
-
)
|
|
665
|
+
const broken = await page.evaluate(unloadedFaces)
|
|
504
666
|
if (broken.length > 0) {
|
|
505
667
|
throw new Error(`the font ${[...new Set(broken)].join(', ')} did not load`)
|
|
506
668
|
}
|
|
@@ -508,6 +670,12 @@ const take = async (lane, item, { elements, readyProperty }, progress) => {
|
|
|
508
670
|
frame and finishes finite ones, so a picture is of one definite moment:
|
|
509
671
|
Fusion's spinner moved 17 pixels between two takes without it. */
|
|
510
672
|
progress.stage = `capturing ${shot.name}`
|
|
673
|
+
for (const [selector, drew] of await maskStates(page)) {
|
|
674
|
+
const before = masks.get(selector)
|
|
675
|
+
if (before !== 'painted' && !(before === 'no box' && drew === 'nothing')) {
|
|
676
|
+
masks.set(selector, drew)
|
|
677
|
+
}
|
|
678
|
+
}
|
|
511
679
|
/* A masked element is painted over in one fixed color, so what changes
|
|
512
680
|
on its own, a version number, moves no pixel. */
|
|
513
681
|
const png = await page.screenshot({
|
|
@@ -583,10 +751,13 @@ const take = async (lane, item, { elements, readyProperty }, progress) => {
|
|
|
583
751
|
errors: item.errors ? [...lane.errors] : [],
|
|
584
752
|
findings,
|
|
585
753
|
key: item.key,
|
|
754
|
+
masks: [...masks].filter(([, drew]) => drew !== 'painted'),
|
|
586
755
|
op: 'item',
|
|
587
756
|
painted,
|
|
588
757
|
problems,
|
|
758
|
+
refused: [...lane.refused],
|
|
589
759
|
seconds,
|
|
760
|
+
unready: [...unready],
|
|
590
761
|
})
|
|
591
762
|
}
|
|
592
763
|
|
|
@@ -607,7 +778,7 @@ const PER_SHOT = 60
|
|
|
607
778
|
*/
|
|
608
779
|
const bounded = async (lane, item, settle) => {
|
|
609
780
|
const progress = { abandoned: false, stage: 'starting' }
|
|
610
|
-
const limit = PER_SHOT * item.shots.length
|
|
781
|
+
const limit = PER_SHOT * item.shots.length + (item.wait ?? 0)
|
|
611
782
|
let timer
|
|
612
783
|
const expired = new Promise((done) => {
|
|
613
784
|
timer = setTimeout(() => done(true), limit * 1000)
|
|
@@ -640,6 +811,13 @@ const listened = (lane, page) => {
|
|
|
640
811
|
}
|
|
641
812
|
})
|
|
642
813
|
page.on('pageerror', (caught) => lane.errors.push(caught.message))
|
|
814
|
+
/* What the browser itself blocked, a policy of the page's among it, beside
|
|
815
|
+
what the run refused. */
|
|
816
|
+
page.on('requestfailed', (request) => {
|
|
817
|
+
if (request.failure()?.errorText.includes('BLOCKED')) {
|
|
818
|
+
lane.refused.add(request.url())
|
|
819
|
+
}
|
|
820
|
+
})
|
|
643
821
|
return page
|
|
644
822
|
}
|
|
645
823
|
|
|
@@ -664,18 +842,27 @@ const lanes = async (count) => {
|
|
|
664
842
|
viewport: { height: 800, width: 1280 },
|
|
665
843
|
})
|
|
666
844
|
await askForState(context)
|
|
845
|
+
await notePlainHosts(context)
|
|
846
|
+
const lane = { errors: [], messages: [], refused: new Set() }
|
|
667
847
|
if (state.network === 'origin') {
|
|
668
848
|
await context.route('**/*', (route) => {
|
|
669
|
-
|
|
849
|
+
const asked = route.request().url()
|
|
850
|
+
let url
|
|
670
851
|
try {
|
|
671
|
-
|
|
852
|
+
url = new URL(asked)
|
|
672
853
|
} catch {
|
|
673
|
-
|
|
854
|
+
url = undefined
|
|
855
|
+
}
|
|
856
|
+
if (
|
|
857
|
+
url !== undefined &&
|
|
858
|
+
(state.origins.has(url.origin) || allowed(url.hostname, state.allow))
|
|
859
|
+
) {
|
|
860
|
+
return route.continue()
|
|
674
861
|
}
|
|
675
|
-
|
|
862
|
+
lane.refused.add(asked)
|
|
863
|
+
return route.abort()
|
|
676
864
|
})
|
|
677
865
|
}
|
|
678
|
-
const lane = { errors: [], messages: [] }
|
|
679
866
|
lane.page = listened(lane, await context.newPage())
|
|
680
867
|
state.lanes.push(lane)
|
|
681
868
|
}
|
|
@@ -714,7 +901,12 @@ const handlers = {
|
|
|
714
901
|
},
|
|
715
902
|
|
|
716
903
|
async config({ path }) {
|
|
717
|
-
|
|
904
|
+
let loaded
|
|
905
|
+
try {
|
|
906
|
+
loaded = await import(pathToFileURL(path).href)
|
|
907
|
+
} catch (caught) {
|
|
908
|
+
throw new Error(unloadable(path, caught, process.version))
|
|
909
|
+
}
|
|
718
910
|
const config = loaded.default ?? loaded.config
|
|
719
911
|
if (config === null || typeof config !== 'object') {
|
|
720
912
|
throw new Error(`${path} exports no config: export one as the default`)
|
|
@@ -835,7 +1027,17 @@ const handlers = {
|
|
|
835
1027
|
})
|
|
836
1028
|
},
|
|
837
1029
|
|
|
838
|
-
async start({
|
|
1030
|
+
async start({
|
|
1031
|
+
allow = [],
|
|
1032
|
+
hide = [],
|
|
1033
|
+
mask = [],
|
|
1034
|
+
network,
|
|
1035
|
+
origins = [],
|
|
1036
|
+
serve,
|
|
1037
|
+
style = null,
|
|
1038
|
+
workers,
|
|
1039
|
+
}) {
|
|
1040
|
+
state.allow = allow
|
|
839
1041
|
state.hide = hide
|
|
840
1042
|
state.mask = mask
|
|
841
1043
|
state.style = style ?? undefined
|
package/visual/index.d.ts
CHANGED
|
@@ -101,12 +101,27 @@ interface Served {
|
|
|
101
101
|
url: string
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/** A route with something to say about itself. */
|
|
105
|
+
export interface Route {
|
|
106
|
+
/** The name its baselines take. Taken from `path` when not given. */
|
|
107
|
+
name?: string
|
|
108
|
+
/** The route's path on the server. */
|
|
109
|
+
path: string
|
|
110
|
+
/** Waited for on this route, in place of `settle.selector`. */
|
|
111
|
+
selector?: string
|
|
112
|
+
/**
|
|
113
|
+
* The status the route answers on purpose, such as 404 for a not-found page. Any other answer
|
|
114
|
+
* fails it. When not given, any status of 400 or above fails it.
|
|
115
|
+
*/
|
|
116
|
+
status?: number
|
|
117
|
+
}
|
|
118
|
+
|
|
104
119
|
/** Pages of any server, by route or from its sitemap. */
|
|
105
120
|
export interface ServerSource extends Served {
|
|
106
121
|
/** Picks this source out of the others. */
|
|
107
122
|
kind: 'server'
|
|
108
|
-
/** Routes to picture, each a path or a
|
|
109
|
-
routes?: (string |
|
|
123
|
+
/** Routes to picture, each a path or a {@link Route}. `/` is named `index`. */
|
|
124
|
+
routes?: (string | Route)[]
|
|
110
125
|
/** A sitemap's path on the server, `/sitemap.xml`, whose every route is pictured too. */
|
|
111
126
|
sitemap?: string
|
|
112
127
|
}
|
|
@@ -350,11 +365,16 @@ export interface Config {
|
|
|
350
365
|
/**
|
|
351
366
|
* Selectors whose elements are painted over in one fixed color in every picture and left out of every
|
|
352
367
|
* accessibility snapshot, for what changes on its own and says nothing about the page, such as a version
|
|
353
|
-
* number or a date.
|
|
368
|
+
* number or a date. A mask paints over its elements' boxes and whatever is drawn over them, and a run
|
|
369
|
+
* names a mask that matches nothing on a page or only elements with no box.
|
|
354
370
|
*/
|
|
355
371
|
mask?: string[]
|
|
356
|
-
/**
|
|
357
|
-
|
|
372
|
+
/**
|
|
373
|
+
* `origin` refuses every request off the harness origin, and is the default. `open` lets them
|
|
374
|
+
* through. `{ allow }` refuses them but those to the hosts it lists, each a host or `*.` and a
|
|
375
|
+
* domain. A run names every request it refused, in its log and in `run.json`.
|
|
376
|
+
*/
|
|
377
|
+
network?: 'open' | 'origin' | { allow: string[] }
|
|
358
378
|
/** `linux` takes and compares baselines on Linux alone, and is the default. `any` compares wherever it runs. */
|
|
359
379
|
platform?: 'any' | 'linux'
|
|
360
380
|
/** Where each run writes its report and `run.json`, relative to the config. `.helix/visual` when not given. */
|
|
@@ -367,12 +387,25 @@ export interface Config {
|
|
|
367
387
|
settle?: {
|
|
368
388
|
/**
|
|
369
389
|
* Only custom elements whose tag starts with this are awaited, and one left undefined fails the
|
|
370
|
-
* entry. Set it for a framework that writes hyphenated tags
|
|
371
|
-
*
|
|
390
|
+
* entry. Set it for a framework that writes hyphenated tags and never defines them, such as
|
|
391
|
+
* Angular's component tags. Every custom element when not given, except a tag a script gave a
|
|
392
|
+
* shadow root while nothing defined it, which is a plain host, as Next.js's
|
|
393
|
+
* `next-route-announcer` is.
|
|
372
394
|
*/
|
|
373
395
|
elements?: string
|
|
374
|
-
/**
|
|
396
|
+
/**
|
|
397
|
+
* A property each element may carry holding a promise that resolves when it has rendered. A run
|
|
398
|
+
* waits 5 seconds, then names the tags still waiting and takes the picture.
|
|
399
|
+
*/
|
|
375
400
|
readyProperty?: string
|
|
401
|
+
/**
|
|
402
|
+
* A selector that matches once a page has drawn what it draws late, such as a map's tiles, for a
|
|
403
|
+
* page that sets an attribute or adds an element when it is done. Each entry waits for it after
|
|
404
|
+
* it loads, and fails when nothing matches in time.
|
|
405
|
+
*/
|
|
406
|
+
selector?: string
|
|
407
|
+
/** Seconds to wait for `selector`. 30 when not given. */
|
|
408
|
+
timeout?: number
|
|
376
409
|
}
|
|
377
410
|
/** How `--shard` packs entries into shards. */
|
|
378
411
|
shard?: {
|
package/visual/words.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The driver's sentences that depend only on what they are given, apart from
|
|
3
|
+
* the driver, which starts reading its standard input as soon as it is imported.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Why a config could not be imported, with what to do when the reason is that
|
|
8
|
+
* the runtime reads no TypeScript. Node strips types by default from 22.18 and
|
|
9
|
+
* 23.6, and an older one answers only with the file's extension.
|
|
10
|
+
*/
|
|
11
|
+
export const unloadable = (path, caught, runtime) => {
|
|
12
|
+
const said = caught instanceof Error ? caught.message : String(caught)
|
|
13
|
+
if (caught?.code !== 'ERR_UNKNOWN_FILE_EXTENSION' || !/\.[cm]?ts$/.test(path)) {
|
|
14
|
+
return said
|
|
15
|
+
}
|
|
16
|
+
return `${said}. Node ${runtime} reads no TypeScript. Use Node 22.18 or later, or write the config as visual.config.mjs.`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Why a page's answer is no page to picture, or undefined when it is one. */
|
|
20
|
+
export const unanswered = (status, expected, address) => {
|
|
21
|
+
if (status === undefined) {
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
if (expected !== undefined && expected !== null) {
|
|
25
|
+
return status === expected
|
|
26
|
+
? undefined
|
|
27
|
+
: `the page answered ${status} at ${address}, and its route names ${expected}, so nothing was pictured`
|
|
28
|
+
}
|
|
29
|
+
return status >= 400
|
|
30
|
+
? `the page answered ${status} at ${address}, so nothing was pictured. A route that answers ${status} on purpose names it, as \`{ path, status: ${status} }\``
|
|
31
|
+
: undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether the network setting lets a request to `hostname` through: an entry
|
|
36
|
+
* is a host, or `*.` and a domain, which matches the hosts under the domain.
|
|
37
|
+
*/
|
|
38
|
+
export const allowed = (hostname, allow) =>
|
|
39
|
+
allow.some((entry) =>
|
|
40
|
+
entry.startsWith('*.') ? hostname.endsWith(entry.slice(1)) : hostname === entry,
|
|
41
|
+
)
|