@fusion.dev/helix-cli 0.187.0 → 0.189.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 +5 -3
- package/bin.mjs +16 -1
- package/package.json +29 -9
- package/visual/README.md +211 -0
- package/visual/driver.mjs +660 -0
- package/visual/index.d.ts +251 -0
- package/visual/index.mjs +10 -0
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @fusion.dev/helix-cli
|
|
2
2
|
|
|
3
3
|
The Helix command line. Derives themes, scaffolds a project, checks a project's
|
|
4
|
-
code against the Helix it installed,
|
|
5
|
-
all of it to an agent.
|
|
4
|
+
code against the Helix it installed, pictures its pages against baselines kept
|
|
5
|
+
in git, looks up the installed packages, and serves all of it to an agent.
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
npm install --save-dev @fusion.dev/helix-cli
|
|
@@ -22,11 +22,13 @@ helix mcp init --client claude
|
|
|
22
22
|
|
|
23
23
|
| Verb | What it does |
|
|
24
24
|
|---|---|
|
|
25
|
+
| `component` | Checks the components you build on Helix's base classes: registration under your prefix, imports, slots, parts, states, hooks, words, reflection, attribute names, lifecycle, and listeners. Reads the folders named by `tiers` in `helix.json`. `--only <rule>` runs some of them |
|
|
25
26
|
| `theme` | `init` a starter intent, `build` it to CSS, `audit` every contrast pair, `check` in CI |
|
|
26
27
|
| `new` | Scaffolds a project from a Helix template |
|
|
27
|
-
| `diagnostics` | Receives the runs `initDiagnostics()` sends from your app and prints each new finding. `--out <path>` writes the CI file, `--port` picks the port, 7417 by default, and `--config <path>` names the settings served to the page |
|
|
28
|
+
| `diagnostics` | Receives the runs `initDiagnostics()` sends from your app and prints each new finding. `--once <url>` opens a page headless instead, diagnoses it, and exits. `--out <path>` writes the CI file, `--port` picks the port, 7417 by default, and `--config <path>` names the settings served to the page |
|
|
28
29
|
| `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 |
|
|
29
30
|
| `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` |
|
|
31
|
+
| `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 |
|
|
30
32
|
| `mcp` | Serves the same lookups, the scan, and the theme derivation to an agent over stdio, as an MCP server. `mcp init --client claude\|cursor\|vscode` writes the client's configuration |
|
|
31
33
|
|
|
32
34
|
`--json` is on every verb, in a shape a script can read without parsing
|
package/bin.mjs
CHANGED
|
@@ -145,8 +145,23 @@ const onPath = () => {
|
|
|
145
145
|
return undefined
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* What `helix visual` needs to start its capture driver: the runtime that ran
|
|
150
|
+
* this shim, and the driver beside it. Set only where the caller has not set
|
|
151
|
+
* them, so `HELIX_VISUAL_RUNTIME=node bun run helix visual test` still means
|
|
152
|
+
* node.
|
|
153
|
+
*/
|
|
154
|
+
const DRIVER_ENV = {
|
|
155
|
+
...process.env,
|
|
156
|
+
HELIX_VISUAL_DRIVER: process.env.HELIX_VISUAL_DRIVER ?? join(HERE, 'visual', 'driver.mjs'),
|
|
157
|
+
HELIX_VISUAL_RUNTIME: process.env.HELIX_VISUAL_RUNTIME ?? process.execPath,
|
|
158
|
+
}
|
|
159
|
+
|
|
148
160
|
const run = (binary) => {
|
|
149
|
-
const { status, error } = spawnSync(binary, process.argv.slice(2), {
|
|
161
|
+
const { status, error } = spawnSync(binary, process.argv.slice(2), {
|
|
162
|
+
env: DRIVER_ENV,
|
|
163
|
+
stdio: 'inherit',
|
|
164
|
+
})
|
|
150
165
|
/* A status means it ran. Bun sets `error` on any exit but zero, to a
|
|
151
166
|
`SystemError` reading "null", so asking `error` alone reported every failed
|
|
152
167
|
check as a binary that would not start, and exited 70 for its 1. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fusion.dev/helix-cli",
|
|
3
|
-
"description": "The Helix command line, for a workspace that reaches it through npm.",
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "The Helix command line, for a workspace that reaches it through npm, and the visual tests it runs.",
|
|
4
|
+
"version": "0.189.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ross Sheppard <ross.sheppard@fusion.dev>",
|
|
7
7
|
"repository": {
|
|
@@ -10,8 +10,17 @@
|
|
|
10
10
|
"url": "git+https://github.com/project-fusion/fusion.git"
|
|
11
11
|
},
|
|
12
12
|
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
"./bin.mjs": "./bin.mjs",
|
|
15
|
+
"./package.json": "./package.json",
|
|
16
|
+
"./visual": {
|
|
17
|
+
"types": "./visual/index.d.ts",
|
|
18
|
+
"default": "./visual/index.mjs"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
13
21
|
"files": [
|
|
14
|
-
"bin.mjs"
|
|
22
|
+
"bin.mjs",
|
|
23
|
+
"visual"
|
|
15
24
|
],
|
|
16
25
|
"bin": {
|
|
17
26
|
"helix": "./bin.mjs"
|
|
@@ -26,14 +35,25 @@
|
|
|
26
35
|
"cli",
|
|
27
36
|
"design-system",
|
|
28
37
|
"scaffold",
|
|
29
|
-
"theme"
|
|
38
|
+
"theme",
|
|
39
|
+
"playwright",
|
|
40
|
+
"screenshot",
|
|
41
|
+
"visual-regression"
|
|
30
42
|
],
|
|
31
43
|
"optionalDependencies": {
|
|
32
|
-
"@fusion.dev/helix-cli-darwin-arm64": "0.
|
|
33
|
-
"@fusion.dev/helix-cli-darwin-x64": "0.
|
|
34
|
-
"@fusion.dev/helix-cli-linux-arm64": "0.
|
|
35
|
-
"@fusion.dev/helix-cli-linux-x64": "0.
|
|
36
|
-
"@fusion.dev/helix-cli-win32-x64": "0.
|
|
44
|
+
"@fusion.dev/helix-cli-darwin-arm64": "0.189.0",
|
|
45
|
+
"@fusion.dev/helix-cli-darwin-x64": "0.189.0",
|
|
46
|
+
"@fusion.dev/helix-cli-linux-arm64": "0.189.0",
|
|
47
|
+
"@fusion.dev/helix-cli-linux-x64": "0.189.0",
|
|
48
|
+
"@fusion.dev/helix-cli-win32-x64": "0.189.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"playwright": ">=1.50"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"playwright": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
37
57
|
},
|
|
38
58
|
"publishConfig": {
|
|
39
59
|
"access": "restricted"
|
package/visual/README.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Visual tests
|
|
2
|
+
|
|
3
|
+
`helix visual` takes pictures of your pages and components in a real browser,
|
|
4
|
+
compares each one to a baseline kept in your git repository, and fails the run
|
|
5
|
+
when a picture changes. It ships in `@fusion.dev/helix-cli`, and it pictures
|
|
6
|
+
any web project, with Helix or without it.
|
|
7
|
+
|
|
8
|
+
It pictures HTML files, any server's pages, Storybook stories, and entries from
|
|
9
|
+
a JSON catalog, and it has a GitHub Action. What comes next is in the roadmap
|
|
10
|
+
below.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install --save-dev @fusion.dev/helix-cli playwright
|
|
14
|
+
npx playwright install --with-deps chromium
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Capture runs in Playwright, which you install beside the command line. The
|
|
18
|
+
rest of `helix` needs no browser, so Playwright is an optional peer, and
|
|
19
|
+
`helix visual test` names it when it is missing.
|
|
20
|
+
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
helix visual test compare every shot, and fail on a difference
|
|
25
|
+
helix visual test button only the entries named button
|
|
26
|
+
helix visual update write only the baselines the comparison rejects
|
|
27
|
+
helix visual test --shard 0 --of 4 the first of four shards, for CI
|
|
28
|
+
helix visual report open the report of the last run
|
|
29
|
+
helix visual compare a.png b.png compare two PNG files, with no browser
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- **`helix visual test`** pictures every entry and compares each shot with its
|
|
33
|
+
baseline. It exits 1 when a shot differs, changes size, has no baseline, or
|
|
34
|
+
cannot be taken, and writes an image of each difference beside its baseline,
|
|
35
|
+
named `<stem><suffix>.diff.png`. Add `*.diff.png` to your `.gitignore`.
|
|
36
|
+
- **`helix visual update`** pictures every entry and writes a baseline only where the
|
|
37
|
+
comparison would fail. Review the files it writes before you commit them.
|
|
38
|
+
- **`helix visual report`** opens the report the last run wrote. `--markdown` prints
|
|
39
|
+
its summary as Markdown instead, for a CI job's summary page.
|
|
40
|
+
- **`helix visual compare`** compares two PNG files. `--diff` writes the image of the
|
|
41
|
+
difference, and `--threshold` sets how far apart two colors may be and still
|
|
42
|
+
count as the same pixel, from 0 to 1.
|
|
43
|
+
|
|
44
|
+
Each run ends with one line that says what it compared and what it skipped.
|
|
45
|
+
`--config` names the config file, `--workers` sets how many pages capture at
|
|
46
|
+
once, and `--shard` with `--of` takes one shard of a run split across jobs.
|
|
47
|
+
|
|
48
|
+
`helix --json visual …` prints one JSON document on stdout and moves the
|
|
49
|
+
sentences to stderr: `test` and `update` print the run's record, the same as
|
|
50
|
+
the report's `run.json`, `report` prints the last one, and `compare` prints
|
|
51
|
+
whether the two pictures match and how many pixels differ.
|
|
52
|
+
|
|
53
|
+
## The config
|
|
54
|
+
|
|
55
|
+
`visual.config.ts`, in the directory you run `helix visual` from:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { defineConfig } from '@fusion.dev/helix-cli/visual'
|
|
59
|
+
|
|
60
|
+
export default defineConfig({
|
|
61
|
+
serve: { '/theme.css': 'dist/theme.css' },
|
|
62
|
+
shots: [
|
|
63
|
+
{ name: 'wide', scheme: 'light', width: 1280 },
|
|
64
|
+
{ name: 'phone', scheme: 'light', width: 390 },
|
|
65
|
+
{ name: 'dark', scheme: 'dark', width: 1280 },
|
|
66
|
+
],
|
|
67
|
+
sources: [
|
|
68
|
+
{
|
|
69
|
+
glob: '{name}/example.html',
|
|
70
|
+
kind: 'html',
|
|
71
|
+
name: 'components',
|
|
72
|
+
root: 'src',
|
|
73
|
+
styles: ['/theme.css'],
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
})
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- **`sources`**: where the entries come from, four kinds:
|
|
80
|
+
- `html` reads every file its `glob` matches under `root`, described next.
|
|
81
|
+
- `server` pictures a server's `routes`, and every route its `sitemap` lists.
|
|
82
|
+
`/` is named `index`, and a route given as `{ name, path }` takes its name.
|
|
83
|
+
- `storybook` pictures every story in a running Storybook's `index.json`.
|
|
84
|
+
`include` and `exclude` filter them by tag.
|
|
85
|
+
- `catalog` pictures every `{ id, url }` in a JSON file, each `url` relative to
|
|
86
|
+
the server.
|
|
87
|
+
|
|
88
|
+
The three on a server take its `url`, a `start` command that runs only when
|
|
89
|
+
nothing answers there and is stopped when the run ends, and `baselines`, the
|
|
90
|
+
directory their baselines are written in. An `html` source reads every file
|
|
91
|
+
its `glob` matches under `root`. `{name}` is the path segment that names the
|
|
92
|
+
entry, `*` matches within a segment, and `**` matches any number of
|
|
93
|
+
directories. Each file is pictured inside a document that links `styles`,
|
|
94
|
+
loads `scripts` with `defer`, and sets the color scheme, the background, the
|
|
95
|
+
text color, the width, and a 24-pixel inset on `body`. `harness: false`
|
|
96
|
+
pictures the file as it is. `files` answers a relative request from a page
|
|
97
|
+
with another file beside it, such as `{ 'styles.css': '{stem}.css' }`.
|
|
98
|
+
- **`shots`**: the pictures taken of each entry. A wide width, a phone width,
|
|
99
|
+
and dark when you give none. Each baseline is written beside its entry as
|
|
100
|
+
`<stem>.<name>.png`, and `suffix` changes `.<name>`.
|
|
101
|
+
- **`serve`**: URL paths served from disk, relative to the config. A path that
|
|
102
|
+
ends in `/` serves a directory.
|
|
103
|
+
- **`settle.readyProperty`**: a property your elements carry holding a promise
|
|
104
|
+
that resolves once they have rendered. `helix visual` awaits it before each shot.
|
|
105
|
+
- **`diff`**: how a shot is compared, for the whole config or for one source.
|
|
106
|
+
`mode: 'pixel'` counts the pixels past `threshold`, 0.1 unless you set it,
|
|
107
|
+
and fails above `pixels`, 0 unless you set it. `mode: 'perceptual'` scores
|
|
108
|
+
structural similarity and fails below `ssim`, 0.99 unless you set it, for
|
|
109
|
+
photographs and gradients whose pixels move a shade on every render.
|
|
110
|
+
- **`exceptions`**: each names an `entry`, optionally a `shot`, and a `reason`.
|
|
111
|
+
`unstable: true` skips the shot and names it in every run. `pixels` lets the
|
|
112
|
+
shot differ by that many, and the run fails once the shot stops differing, so
|
|
113
|
+
an exception that is no longer needed is removed.
|
|
114
|
+
- **`inspect`**: `run` is called with the Playwright page after each entry's
|
|
115
|
+
first shot, and `report` once at the end with everything `run` returned.
|
|
116
|
+
`report` returns `{ failed: true }` to fail the run.
|
|
117
|
+
- **`shard`**: `cost` maps entry names to measured seconds, so shards are packed
|
|
118
|
+
by time. Entries with no measurement weigh `perShot` seconds a shot.
|
|
119
|
+
- **`timings`**: a file to write each entry name's seconds to, which is how you
|
|
120
|
+
measure `shard.cost`.
|
|
121
|
+
- **`mask`**: selectors whose elements are painted over in one fixed color in
|
|
122
|
+
every picture, for what changes on its own, such as a version number.
|
|
123
|
+
- **`style`**: a stylesheet applied while each picture is taken, to hold still
|
|
124
|
+
what `mask` cannot, such as a masked element whose width follows its text.
|
|
125
|
+
- **`report`**: where each run writes its report, `.helix/visual` unless you
|
|
126
|
+
set it. Add `.helix/visual/` to your `.gitignore`.
|
|
127
|
+
- **`retake`**: a sentence telling your team how to retake baselines, printed
|
|
128
|
+
when a run fails.
|
|
129
|
+
- **`fingerprint`**, **`network`**, and **`platform`** are described below.
|
|
130
|
+
|
|
131
|
+
## What every run does
|
|
132
|
+
|
|
133
|
+
- **Capture holds still.** Before each shot `helix visual` waits for every custom
|
|
134
|
+
element to be defined, the fonts to load, each element's ready promise, and
|
|
135
|
+
the page's boxes and scroll offsets to stay the same for three frames. It
|
|
136
|
+
emulates reduced motion and takes the shot with animations disabled. A font
|
|
137
|
+
that fails to load fails the entry.
|
|
138
|
+
- **One page load for all the shots of an entry.** The viewport, the color
|
|
139
|
+
scheme, and the harness style change between shots on the same page.
|
|
140
|
+
- **A difference is taken again from a fresh page load.** A shot that matches
|
|
141
|
+
on the second take passes, and the run names it.
|
|
142
|
+
- **Requests stay on the harness origin.** Every request to another host is
|
|
143
|
+
refused, so a baseline never records what another server returned.
|
|
144
|
+
`network: 'open'` turns that off.
|
|
145
|
+
- **Baselines are taken and compared on Linux.** On another platform `helix
|
|
146
|
+
visual test` says so and exits 0. `platform: 'any'` compares wherever it runs.
|
|
147
|
+
- **The environment is recorded.** `helix visual update` of every entry
|
|
148
|
+
writes `visual.fingerprint.json`: the platform, the operating system release, the
|
|
149
|
+
browser version, and a hash of the installed fonts. A run on an environment
|
|
150
|
+
that differs stops and names what differs, because every picture would
|
|
151
|
+
differ for that reason alone.
|
|
152
|
+
|
|
153
|
+
## The report
|
|
154
|
+
|
|
155
|
+
Every `helix visual test` and `helix visual update` writes a report into the config's
|
|
156
|
+
`report` directory: `index.html` to look at, and `run.json` for a program to
|
|
157
|
+
read. The page shows each shot that failed with its baseline, its capture, and
|
|
158
|
+
its difference side by side, and a swipe between the baseline and the capture.
|
|
159
|
+
It lists the shots that needed a second take, the exceptions with their
|
|
160
|
+
measurements, and what was skipped. The directory is emptied at the start of
|
|
161
|
+
each run.
|
|
162
|
+
|
|
163
|
+
## The GitHub Action
|
|
164
|
+
|
|
165
|
+
The Action lives in the Fusion repository, in `helix/visual/action`.
|
|
166
|
+
|
|
167
|
+
```yaml
|
|
168
|
+
- uses: project-fusion/fusion/helix/visual/action@main
|
|
169
|
+
with:
|
|
170
|
+
mode: check
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
- **`mode: check`** installs Chromium and its system packages from the cache,
|
|
174
|
+
runs `helix visual test`, writes the summary to the job's summary page, and
|
|
175
|
+
on a failure uploads the report and the diff images.
|
|
176
|
+
- **`mode: retake`** runs `helix visual update` and opens a pull request with
|
|
177
|
+
what it wrote, on a branch named for the ref. Give it `token` from a GitHub
|
|
178
|
+
App or a person: a pull request opened with `GITHUB_TOKEN` starts no checks.
|
|
179
|
+
Run it on `workflow_dispatch` only, with `concurrency` set to queue and never
|
|
180
|
+
cancel, so a retake is asked for and never cut in half.
|
|
181
|
+
|
|
182
|
+
`args` passes arguments to `helix visual`, such as `--config` or `--shard`.
|
|
183
|
+
`helix` and `playwright` name the commands that run each,
|
|
184
|
+
`npx --no-install helix` and `npx --no-install playwright` unless you set them.
|
|
185
|
+
|
|
186
|
+
## How it works
|
|
187
|
+
|
|
188
|
+
- **Local-first.** `helix visual` runs on your machine and in your CI. It needs no
|
|
189
|
+
hosted service, and no picture leaves your machine unless you turn that on.
|
|
190
|
+
- **Baselines in git.** Each baseline is a file in your repository. A retake
|
|
191
|
+
writes only the pictures the comparison rejects, and the pull request that
|
|
192
|
+
carries them is where you review the change.
|
|
193
|
+
- **Three shots by default.** A wide width, a phone width, and the dark color
|
|
194
|
+
scheme, each compared to its own baseline.
|
|
195
|
+
- **The environment is part of the baseline.** The browser version, the
|
|
196
|
+
operating system image, and the installed fonts are recorded beside the
|
|
197
|
+
baselines, and a run on a different environment stops and names what differs.
|
|
198
|
+
- **A Rust core and a Playwright driver.** The `helix` binary reads the config,
|
|
199
|
+
finds what to picture, shards the work, decodes and compares the images, and
|
|
200
|
+
writes the report. Capture runs in a small Node driver that runs Playwright,
|
|
201
|
+
and the driver ships in `@fusion.dev/helix-cli`.
|
|
202
|
+
|
|
203
|
+
## Roadmap
|
|
204
|
+
|
|
205
|
+
| Phase | What it adds |
|
|
206
|
+
|---|---|
|
|
207
|
+
| 1. Core | `helix visual test` and `helix visual update` on plain HTML sources. The config file, the environment fingerprint, the pixel diff, exceptions with their measurements, deterministic shards, and capture that waits for the page to hold still, refuses requests to other origins, and takes all three shots from one page load |
|
|
208
|
+
| 2. Ship | Server and Storybook sources, a perceptual diff, the HTML report behind `helix visual report`, the GitHub Action with its `check` and `retake` modes, documentation, and the first public `0.x` |
|
|
209
|
+
| 3. Checks | Differential checks with no baseline, such as server rendering against client rendering. Page checks on the render that was pictured: unhighlighted code blocks, clipped text, overlapping elements, and contrast. Accessibility snapshots stored beside each picture, and each changed region traced to the elements under it |
|
|
210
|
+
| 4. Triage | A model that groups and explains changes, only on a failed comparison and only when you configure one. Off by default, text-only first, local or hosted models, and a cost cap per run |
|
|
211
|
+
| GitHub App | Opens the baselines pull request for a retake so your required checks run on it, without a token you create and store. Planned after phase 2 |
|
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capture driver: the Node half of `helix visual`, which runs Playwright.
|
|
3
|
+
*
|
|
4
|
+
* **The binary starts this once per run and sends it one request per line**
|
|
5
|
+
* on standard input; every answer is one line of JSON on standard output.
|
|
6
|
+
* Standard error is the run's own. `console.log` is sent there, and the binary
|
|
7
|
+
* passes any other line on standard output that is not an answer through to
|
|
8
|
+
* standard error, so a config's `inspect` cannot corrupt the protocol.
|
|
9
|
+
*
|
|
10
|
+
* - `config` imports the config file and answers with its values. Functions
|
|
11
|
+
* stay here, where they run.
|
|
12
|
+
* - `fetch` and `wait` ask a server for a page, or wait for it to answer, so
|
|
13
|
+
* the binary needs no HTTP of its own.
|
|
14
|
+
* - `start` serves the harness origin and launches Chromium.
|
|
15
|
+
* - `capture` pictures entries, answering `shot` for each picture as it is
|
|
16
|
+
* taken, `item` when an entry is done, and `captured` at the end.
|
|
17
|
+
* - `finish` hands the config's `inspect.report` every run it collected.
|
|
18
|
+
* - `diagnose` opens pages and runs the installed diagnostics in each, for
|
|
19
|
+
* `helix diagnostics --once`.
|
|
20
|
+
* - `close` closes the browser and the server and exits.
|
|
21
|
+
*
|
|
22
|
+
* Any request that fails answers `error`, with the reason.
|
|
23
|
+
*
|
|
24
|
+
* **One navigation per entry for every shot.** The first shot loads the page;
|
|
25
|
+
* each later one resizes the viewport, switches the emulated scheme, rewrites
|
|
26
|
+
* the harness style and settles again. Measured on October 8, 2026 over 153
|
|
27
|
+
* pictures of 51 examples, that took 242.6 ms a picture against 287.3 ms for a
|
|
28
|
+
* navigation each, and every picture was pixel for pixel the same.
|
|
29
|
+
*
|
|
30
|
+
* **Node and Playwright, decided on October 8, 2026.** Chromium's work is about
|
|
31
|
+
* 94% of the time of a picture, so a capture written in Rust over the browser
|
|
32
|
+
* protocol is the same speed. It would also lose what Playwright already
|
|
33
|
+
* tests: auto-waiting, disabled animations, request routing, media emulation,
|
|
34
|
+
* `ariaSnapshot()`, Firefox and WebKit, and `playwright install --with-deps`.
|
|
35
|
+
*/
|
|
36
|
+
import { existsSync, readFileSync, readSync, statSync, writeSync } from 'node:fs'
|
|
37
|
+
import { createServer } from 'node:http'
|
|
38
|
+
import { extname, resolve, sep } from 'node:path'
|
|
39
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
40
|
+
import { pathToFileURL } from 'node:url'
|
|
41
|
+
|
|
42
|
+
/** The `id` of the harness `<style>` the binary writes into every page. */
|
|
43
|
+
const STYLE_ID = 'helix-visual-harness'
|
|
44
|
+
|
|
45
|
+
/** Where `diagnose` serves the diagnostics on a page's own origin. */
|
|
46
|
+
const DIAGNOSTICS = '__helix-diagnostics'
|
|
47
|
+
|
|
48
|
+
/* **Read and written synchronously, on the descriptors themselves.** Bun
|
|
49
|
+
1.1.43, the runtime Fusion's CI pins, hands a script nothing from a piped
|
|
50
|
+
standard input until the pipe closes, so a stream reader would wait for a
|
|
51
|
+
request the binary is waiting to have answered. `readSync` on descriptor 0
|
|
52
|
+
returns each line as it arrives, in Bun and in Node. A request is read only
|
|
53
|
+
when the last one is answered, which is the one time this has nothing else
|
|
54
|
+
to do. Answers are written with `writeSync` for the same reason: the next
|
|
55
|
+
thing after an answer is that blocking read, and an answer still queued
|
|
56
|
+
behind it would never reach the binary. */
|
|
57
|
+
for (const name of ['debug', 'info', 'log']) {
|
|
58
|
+
console[name] = console.error
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Blocks for `milliseconds`, for a descriptor that asked to be retried. */
|
|
62
|
+
const pause = (milliseconds) =>
|
|
63
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds)
|
|
64
|
+
|
|
65
|
+
/** One answer, written whole. */
|
|
66
|
+
const say = (message) => {
|
|
67
|
+
const bytes = Buffer.from(`${JSON.stringify(message)}\n`)
|
|
68
|
+
for (let at = 0; at < bytes.length; ) {
|
|
69
|
+
try {
|
|
70
|
+
at += writeSync(1, bytes, at)
|
|
71
|
+
} catch (caught) {
|
|
72
|
+
if (caught?.code !== 'EAGAIN') {
|
|
73
|
+
throw caught
|
|
74
|
+
}
|
|
75
|
+
pause(2)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const TYPES = {
|
|
81
|
+
'.css': 'text/css',
|
|
82
|
+
'.gif': 'image/gif',
|
|
83
|
+
'.html': 'text/html',
|
|
84
|
+
'.jpeg': 'image/jpeg',
|
|
85
|
+
'.jpg': 'image/jpeg',
|
|
86
|
+
'.js': 'text/javascript',
|
|
87
|
+
'.json': 'application/json',
|
|
88
|
+
'.mjs': 'text/javascript',
|
|
89
|
+
'.mp4': 'video/mp4',
|
|
90
|
+
'.otf': 'font/otf',
|
|
91
|
+
'.png': 'image/png',
|
|
92
|
+
'.svg': 'image/svg+xml',
|
|
93
|
+
'.ttf': 'font/ttf',
|
|
94
|
+
'.webm': 'video/webm',
|
|
95
|
+
'.webp': 'image/webp',
|
|
96
|
+
'.woff': 'font/woff',
|
|
97
|
+
'.woff2': 'font/woff2',
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The run's state, filled in by the requests in the order they come. */
|
|
101
|
+
const state = {
|
|
102
|
+
browser: undefined,
|
|
103
|
+
config: undefined,
|
|
104
|
+
/**
|
|
105
|
+
* Each document by the key of the request that asked for it. Two takes of
|
|
106
|
+
* one entry can load at once on two pages, each styled for its own shot, so
|
|
107
|
+
* the document is named in the query and the path is the entry's.
|
|
108
|
+
*/
|
|
109
|
+
documents: new Map(),
|
|
110
|
+
lanes: [],
|
|
111
|
+
/** Selectors painted over in every picture. */
|
|
112
|
+
mask: [],
|
|
113
|
+
network: 'origin',
|
|
114
|
+
origin: undefined,
|
|
115
|
+
/** The origins requests may reach: the harness's and each source's server. */
|
|
116
|
+
origins: new Set(),
|
|
117
|
+
/** `/source/name/` to the directory relative requests resolve in, and its renames. */
|
|
118
|
+
pages: new Map(),
|
|
119
|
+
/** What `inspect.run` returned for each entry, for `inspect.report`. */
|
|
120
|
+
runs: [],
|
|
121
|
+
serve: [],
|
|
122
|
+
server: undefined,
|
|
123
|
+
/** A stylesheet applied while each picture is taken. */
|
|
124
|
+
style: undefined,
|
|
125
|
+
workers: 1,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `relative` resolved inside `directory`, or undefined when it escapes it.
|
|
130
|
+
*
|
|
131
|
+
* The directory is resolved too, because a mount named with a trailing slash
|
|
132
|
+
* arrives as `fonts/`, and `fonts/` plus a separator is a prefix no file has.
|
|
133
|
+
*/
|
|
134
|
+
const inside = (directory, relative) => {
|
|
135
|
+
const root = resolve(directory)
|
|
136
|
+
const file = resolve(root, relative)
|
|
137
|
+
return file === root || file.startsWith(root + sep) ? file : undefined
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What the server answers for `path`: a file, a document, or nothing. */
|
|
141
|
+
const lookup = (path, document) => {
|
|
142
|
+
for (const { directory, file, url } of state.serve) {
|
|
143
|
+
if (directory && path.startsWith(url)) {
|
|
144
|
+
return { file: inside(file, path.slice(url.length)) }
|
|
145
|
+
}
|
|
146
|
+
if (!directory && path === url) {
|
|
147
|
+
return { file }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/* The page whose path is the longest prefix of this one, so a relative
|
|
151
|
+
request from a page resolves inside that page's directory. */
|
|
152
|
+
for (let at = path.lastIndexOf('/'); at >= 0; at = path.lastIndexOf('/', at - 1)) {
|
|
153
|
+
const page = state.pages.get(path.slice(0, at + 1))
|
|
154
|
+
if (page !== undefined) {
|
|
155
|
+
const rest = path.slice(at + 1)
|
|
156
|
+
if (rest === '') {
|
|
157
|
+
return { html: state.documents.get(document) }
|
|
158
|
+
}
|
|
159
|
+
return { file: inside(page.dir, page.files[rest] ?? rest) }
|
|
160
|
+
}
|
|
161
|
+
if (at === 0) {
|
|
162
|
+
break
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return {}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const respond = (request, response) => {
|
|
169
|
+
let path
|
|
170
|
+
let document
|
|
171
|
+
try {
|
|
172
|
+
const url = new URL(request.url ?? '/', 'http://visual')
|
|
173
|
+
path = decodeURIComponent(url.pathname)
|
|
174
|
+
document = url.searchParams.get('visual')
|
|
175
|
+
} catch {
|
|
176
|
+
response.writeHead(400).end()
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
const found = lookup(path, document)
|
|
180
|
+
if (found.html !== undefined) {
|
|
181
|
+
response.writeHead(200, { 'content-type': 'text/html' })
|
|
182
|
+
response.end(found.html)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
let body
|
|
186
|
+
try {
|
|
187
|
+
if (found.file === undefined || !statSync(found.file).isFile()) {
|
|
188
|
+
throw new Error('not a file')
|
|
189
|
+
}
|
|
190
|
+
body = readFileSync(found.file)
|
|
191
|
+
} catch {
|
|
192
|
+
response.writeHead(404).end()
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
response.writeHead(200, {
|
|
196
|
+
'content-type': TYPES[extname(found.file).toLowerCase()] ?? 'application/octet-stream',
|
|
197
|
+
})
|
|
198
|
+
response.end(body)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Waits for every custom element on the page to be defined, and names the
|
|
203
|
+
* tags nothing defines.
|
|
204
|
+
*
|
|
205
|
+
* **Bounded, because `whenDefined` on a tag nothing registers never settles.**
|
|
206
|
+
* Unbounded, a page naming an element the bundle does not define hangs the
|
|
207
|
+
* run with no output. This gives up after five seconds and names the tags.
|
|
208
|
+
*/
|
|
209
|
+
const upgrades = (page, prefix) =>
|
|
210
|
+
page.evaluate(async (prefix) => {
|
|
211
|
+
const pending = [
|
|
212
|
+
...new Set([...document.querySelectorAll(':not(:defined)')].map((one) => one.localName)),
|
|
213
|
+
].filter((tag) => tag.startsWith(prefix))
|
|
214
|
+
await Promise.race([
|
|
215
|
+
Promise.all(pending.map((tag) => customElements.whenDefined(tag))),
|
|
216
|
+
new Promise((done) => setTimeout(done, 5000)),
|
|
217
|
+
])
|
|
218
|
+
return pending.filter((tag) => customElements.get(tag) === undefined)
|
|
219
|
+
}, prefix ?? '')
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Waits for the page to hold still: three frames, the fonts, each element's
|
|
223
|
+
* own ready signal, then boxes and scroll offsets unchanged for three frames.
|
|
224
|
+
*
|
|
225
|
+
* **Three frames, because a framework may render in a task of its own** and
|
|
226
|
+
* an example that sets state in an effect needs the render after it.
|
|
227
|
+
*
|
|
228
|
+
* **Each element's ready signal, when the config names one.** A component
|
|
229
|
+
* library that publishes a promise for "rendered and styled" knows better
|
|
230
|
+
* than any frame count. Without it, Fusion's `pagination/footer` differed by
|
|
231
|
+
* 419 pixels on one run and 842 on the next from the same two files.
|
|
232
|
+
* Bounded, so an element that never resolves cannot hang the run.
|
|
233
|
+
*
|
|
234
|
+
* **Then geometry and scroll held for three frames**, which `animations:
|
|
235
|
+
* 'disabled'` does not cover. A programmatic smooth scroll is no animation:
|
|
236
|
+
* `carousel/controlled` reported `scrollLeft` of 135, 184 and 239 on three
|
|
237
|
+
* loads. A control that measures itself settles a frame or two late, and
|
|
238
|
+
* `pagination/footer` measured 272, 273 and 275 wide. Bounded at 120 frames,
|
|
239
|
+
* so a page that never stops moving reports a difference and does not hang.
|
|
240
|
+
*/
|
|
241
|
+
const still = (page, readyProperty) =>
|
|
242
|
+
page.evaluate(async (readyProperty) => {
|
|
243
|
+
await new Promise((done) =>
|
|
244
|
+
requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(done))),
|
|
245
|
+
)
|
|
246
|
+
await Promise.race([document.fonts.ready, new Promise((done) => setTimeout(done, 10000))])
|
|
247
|
+
if (readyProperty) {
|
|
248
|
+
await Promise.race([
|
|
249
|
+
Promise.all(
|
|
250
|
+
[...document.querySelectorAll('*')]
|
|
251
|
+
.map((one) => one[readyProperty])
|
|
252
|
+
.filter((one) => one instanceof Promise),
|
|
253
|
+
),
|
|
254
|
+
new Promise((done) => setTimeout(done, 5000)),
|
|
255
|
+
])
|
|
256
|
+
}
|
|
257
|
+
const shape = () =>
|
|
258
|
+
[...document.querySelectorAll('*')]
|
|
259
|
+
.map((one) => {
|
|
260
|
+
const box = one.getBoundingClientRect()
|
|
261
|
+
return `${Math.round(box.x)},${Math.round(box.y)},${Math.round(box.width)},${Math.round(box.height)},${one.scrollLeft},${one.scrollTop}`
|
|
262
|
+
})
|
|
263
|
+
.join('|')
|
|
264
|
+
let last = shape()
|
|
265
|
+
for (let held = 0, spent = 0; held < 3 && spent < 120; spent += 1) {
|
|
266
|
+
await new Promise((done) => requestAnimationFrame(done))
|
|
267
|
+
const now = shape()
|
|
268
|
+
held = now === last ? held + 1 : 0
|
|
269
|
+
last = now
|
|
270
|
+
}
|
|
271
|
+
}, readyProperty ?? null)
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* `promise`, or a refusal after `seconds`.
|
|
275
|
+
*
|
|
276
|
+
* **A timer of its own, because an abort signal is not one everywhere.** A
|
|
277
|
+
* server that accepts a connection and never answers leaves a fetch waiting,
|
|
278
|
+
* and a runtime that does not honor `AbortSignal.timeout` on it waits with it.
|
|
279
|
+
*/
|
|
280
|
+
const within = (seconds, promise, what) => {
|
|
281
|
+
let timer
|
|
282
|
+
const expired = new Promise((_, refuse) => {
|
|
283
|
+
timer = setTimeout(
|
|
284
|
+
() => refuse(new Error(`${what} did not answer within ${seconds} seconds`)),
|
|
285
|
+
seconds * 1000,
|
|
286
|
+
)
|
|
287
|
+
})
|
|
288
|
+
return Promise.race([promise, expired]).finally(() => clearTimeout(timer))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** One entry, every shot of it from one navigation. */
|
|
292
|
+
const take = async (page, item, { elements, readyProperty }, progress) => {
|
|
293
|
+
const started = performance.now()
|
|
294
|
+
let error = null
|
|
295
|
+
try {
|
|
296
|
+
for (const [index, shot] of item.shots.entries()) {
|
|
297
|
+
progress.stage = index === 0 ? 'loading the page' : `settling for ${shot.name}`
|
|
298
|
+
await page.setViewportSize({ height: shot.height, width: shot.width })
|
|
299
|
+
await page.emulateMedia({ colorScheme: shot.scheme })
|
|
300
|
+
if (index === 0) {
|
|
301
|
+
/* An entry on a server has its own address. An HTML entry is served
|
|
302
|
+
here, its document named in the query. */
|
|
303
|
+
const address =
|
|
304
|
+
item.url ?? `${state.origin}${item.path}?visual=${encodeURIComponent(item.key)}`
|
|
305
|
+
await page.goto(address, { waitUntil: 'load' })
|
|
306
|
+
const missing = await upgrades(page, elements)
|
|
307
|
+
if (missing.length > 0) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`nothing defines ${missing.map((tag) => `<${tag}>`).join(', ')}, after five seconds of waiting`,
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
} else if (shot.harness !== null) {
|
|
313
|
+
await page.evaluate(
|
|
314
|
+
([id, rules]) => {
|
|
315
|
+
const style = document.getElementById(id)
|
|
316
|
+
if (style !== null) {
|
|
317
|
+
style.textContent = rules
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
[STYLE_ID, shot.harness],
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
progress.stage = `settling for ${shot.name}`
|
|
324
|
+
await still(page, readyProperty)
|
|
325
|
+
/* A face the page asked for and could not load draws in the platform's
|
|
326
|
+
fallback, and a baseline taken that way defends the wrong face. */
|
|
327
|
+
const broken = await page.evaluate(() =>
|
|
328
|
+
[...document.fonts].filter((face) => face.status === 'error').map((face) => face.family),
|
|
329
|
+
)
|
|
330
|
+
if (broken.length > 0) {
|
|
331
|
+
throw new Error(`the font ${[...new Set(broken)].join(', ')} did not load`)
|
|
332
|
+
}
|
|
333
|
+
/* `animations: 'disabled'` cancels infinite animations to their first
|
|
334
|
+
frame and finishes finite ones, so a picture is of one definite moment:
|
|
335
|
+
Fusion's spinner moved 17 pixels between two takes without it. */
|
|
336
|
+
progress.stage = `capturing ${shot.name}`
|
|
337
|
+
/* A masked element is painted over in one fixed color, so what changes
|
|
338
|
+
on its own, a version number, moves no pixel. */
|
|
339
|
+
const png = await page.screenshot({
|
|
340
|
+
animations: 'disabled',
|
|
341
|
+
fullPage: true,
|
|
342
|
+
mask: state.mask.map((selector) => page.locator(selector)),
|
|
343
|
+
maskColor: '#ff00ff',
|
|
344
|
+
style: state.style,
|
|
345
|
+
})
|
|
346
|
+
if (progress.abandoned) {
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
say({ key: item.key, op: 'shot', png: png.toString('base64'), shot: shot.name })
|
|
350
|
+
/* After the first picture, on the page that was pictured, so what the
|
|
351
|
+
inspection reads cannot move a pixel of that picture. */
|
|
352
|
+
if (index === 0 && item.inspect) {
|
|
353
|
+
progress.stage = 'inspecting the page'
|
|
354
|
+
state.runs.push({
|
|
355
|
+
entry: item.entry,
|
|
356
|
+
result: await state.config.inspect.run({ entry: item.entry, page }),
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
} catch (caught) {
|
|
361
|
+
error = caught instanceof Error ? caught.message.split('\n')[0] : String(caught)
|
|
362
|
+
}
|
|
363
|
+
if (progress.abandoned) {
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
const seconds = (performance.now() - started) / 1000
|
|
367
|
+
console.error(
|
|
368
|
+
`helix: ${item.key}: ${error === null ? `${item.shots.length} ${item.shots.length === 1 ? 'shot' : 'shots'}` : 'failed'} in ${seconds.toFixed(1)} s`,
|
|
369
|
+
)
|
|
370
|
+
say({ error, key: item.key, op: 'item', seconds })
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Seconds an entry may take for each of its shots before it is given up on.
|
|
375
|
+
*
|
|
376
|
+
* **Every wait in a picture is bounded, and so is the whole of one.** A page
|
|
377
|
+
* that stops drawing frames leaves an `evaluate` waiting on a frame that
|
|
378
|
+
* never comes, and an unbounded run is a job that hangs until CI kills it and
|
|
379
|
+
* says nothing about which page did it.
|
|
380
|
+
*/
|
|
381
|
+
const PER_SHOT = 60
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* One entry on the lane's page, given up on after its time with the stage it
|
|
385
|
+
* reached named. The page it was stuck on is closed and the lane gets a new
|
|
386
|
+
* one in the same context, so the entries after it start clean.
|
|
387
|
+
*/
|
|
388
|
+
const bounded = async (lane, item, settle) => {
|
|
389
|
+
const progress = { abandoned: false, stage: 'starting' }
|
|
390
|
+
const limit = PER_SHOT * item.shots.length
|
|
391
|
+
let timer
|
|
392
|
+
const expired = new Promise((done) => {
|
|
393
|
+
timer = setTimeout(() => done(true), limit * 1000)
|
|
394
|
+
})
|
|
395
|
+
const finished = await Promise.race([take(lane.page, item, settle, progress), expired])
|
|
396
|
+
clearTimeout(timer)
|
|
397
|
+
if (finished !== true) {
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
progress.abandoned = true
|
|
401
|
+
const message = `did not finish in ${limit} seconds, stuck ${progress.stage}`
|
|
402
|
+
console.error(`helix: ${item.key}: ${message}`)
|
|
403
|
+
say({ error: message, key: item.key, op: 'item', seconds: limit })
|
|
404
|
+
const context = lane.page.context()
|
|
405
|
+
await lane.page.close().catch(() => {})
|
|
406
|
+
lane.page = await context.newPage()
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* A page per worker, each in a context of its own, made when first needed and
|
|
411
|
+
* kept for every capture after.
|
|
412
|
+
*
|
|
413
|
+
* **Reduced motion on the context**, because `animations: 'disabled'` reaches
|
|
414
|
+
* only what the browser animates: a WebGL field driven by its own
|
|
415
|
+
* `requestAnimationFrame` differed by 26,883 pixels between two loads.
|
|
416
|
+
* Measured across 181 of Fusion's families, no other picture changed under it.
|
|
417
|
+
*
|
|
418
|
+
* **Every request off the harness origin is refused**, unless the config opens
|
|
419
|
+
* the network. A baseline that loaded media from another host recorded
|
|
420
|
+
* whatever that host returned, and a refusal by the resolver arrives in
|
|
421
|
+
* whatever time the resolver takes.
|
|
422
|
+
*/
|
|
423
|
+
const lanes = async (count) => {
|
|
424
|
+
while (state.lanes.length < count) {
|
|
425
|
+
const context = await state.browser.newContext({
|
|
426
|
+
reducedMotion: 'reduce',
|
|
427
|
+
viewport: { height: 800, width: 1280 },
|
|
428
|
+
})
|
|
429
|
+
if (state.network === 'origin') {
|
|
430
|
+
await context.route('**/*', (route) => {
|
|
431
|
+
let origin
|
|
432
|
+
try {
|
|
433
|
+
origin = new URL(route.request().url()).origin
|
|
434
|
+
} catch {
|
|
435
|
+
origin = undefined
|
|
436
|
+
}
|
|
437
|
+
return state.origins.has(origin) ? route.continue() : route.abort()
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
state.lanes.push({ page: await context.newPage() })
|
|
441
|
+
}
|
|
442
|
+
return state.lanes.slice(0, count)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const handlers = {
|
|
446
|
+
async capture({ elements, items, readyProperty }) {
|
|
447
|
+
for (const item of items.filter((one) => one.path !== undefined)) {
|
|
448
|
+
state.documents.set(item.key, item.html)
|
|
449
|
+
state.pages.set(item.path, { dir: item.dir, files: item.files ?? {} })
|
|
450
|
+
}
|
|
451
|
+
/* **Worker `k` takes items `k`, `k + n`, and so on**, rather than the next
|
|
452
|
+
free one off a queue, so which entries shared a page is a function of
|
|
453
|
+
the worker count and a finding can be reproduced by running again. */
|
|
454
|
+
const taken = await lanes(Math.min(state.workers, items.length))
|
|
455
|
+
await Promise.all(
|
|
456
|
+
taken.map(async (lane, k) => {
|
|
457
|
+
for (let at = k; at < items.length; at += taken.length) {
|
|
458
|
+
console.error(`helix: ${items[at].key}: picturing`)
|
|
459
|
+
await bounded(lane, items[at], { elements, readyProperty })
|
|
460
|
+
}
|
|
461
|
+
}),
|
|
462
|
+
)
|
|
463
|
+
say({ op: 'captured' })
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
async close() {
|
|
467
|
+
await state.browser?.close().catch(() => {})
|
|
468
|
+
state.server?.close()
|
|
469
|
+
process.exit(0)
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
async config({ path }) {
|
|
473
|
+
const loaded = await import(pathToFileURL(path).href)
|
|
474
|
+
const config = loaded.default ?? loaded.config
|
|
475
|
+
if (config === null || typeof config !== 'object') {
|
|
476
|
+
throw new Error(`${path} exports no config: export one as the default`)
|
|
477
|
+
}
|
|
478
|
+
if (config.inspect !== undefined && typeof config.inspect.run !== 'function') {
|
|
479
|
+
throw new Error(
|
|
480
|
+
'`inspect.run` is a function, called with the page after each entry is pictured',
|
|
481
|
+
)
|
|
482
|
+
}
|
|
483
|
+
state.config = config
|
|
484
|
+
const { inspect, ...values } = config
|
|
485
|
+
say({
|
|
486
|
+
config: { ...JSON.parse(JSON.stringify(values)), inspect: inspect !== undefined },
|
|
487
|
+
op: 'config',
|
|
488
|
+
})
|
|
489
|
+
},
|
|
490
|
+
|
|
491
|
+
/* **Each page's own diagnostics, with nothing added to the app.** The
|
|
492
|
+
installed `@fusion.dev/helix-diagnostics` is served on each page's own
|
|
493
|
+
origin, under a path no app uses, so a policy that allows only `'self'`
|
|
494
|
+
scripts allows it, and the page imports it once it holds still and runs
|
|
495
|
+
one `diagnose()` against the installed manifests. The runtime events the
|
|
496
|
+
library logged as the page loaded are in the run, which is the half a
|
|
497
|
+
read of the code cannot see. */
|
|
498
|
+
async diagnose({ dist, file, readyProperty, tiers, urls }) {
|
|
499
|
+
const { chromium } = await playwright()
|
|
500
|
+
state.browser ??= await chromium.launch()
|
|
501
|
+
const context = await state.browser.newContext({ reducedMotion: 'reduce' })
|
|
502
|
+
await context.route(`**/${DIAGNOSTICS}/**`, async (route) => {
|
|
503
|
+
const name = new URL(route.request().url()).pathname.split(`/${DIAGNOSTICS}/`)[1] ?? ''
|
|
504
|
+
const path = inside(dist, name)
|
|
505
|
+
if (path === undefined || !existsSync(path) || !statSync(path).isFile()) {
|
|
506
|
+
await route.fulfill({ status: 404 })
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
await route.fulfill({ body: readFileSync(path), contentType: 'text/javascript' })
|
|
510
|
+
})
|
|
511
|
+
const runs = []
|
|
512
|
+
for (const url of urls) {
|
|
513
|
+
const page = await context.newPage()
|
|
514
|
+
try {
|
|
515
|
+
const answered = await page.goto(url, { waitUntil: 'load' })
|
|
516
|
+
/* A page that is not there is no clean page. */
|
|
517
|
+
if (answered !== null && !answered.ok()) {
|
|
518
|
+
throw new Error(`answered ${answered.status()}`)
|
|
519
|
+
}
|
|
520
|
+
await still(page, readyProperty)
|
|
521
|
+
runs.push(
|
|
522
|
+
await page.evaluate(
|
|
523
|
+
async ({ entry, file, tiers }) => {
|
|
524
|
+
const { diagnose } = await import(entry)
|
|
525
|
+
return diagnose({ environment: 'development', file: file ?? undefined, tiers })
|
|
526
|
+
},
|
|
527
|
+
{ entry: `/${DIAGNOSTICS}/index.js`, file, tiers },
|
|
528
|
+
),
|
|
529
|
+
)
|
|
530
|
+
} catch (caught) {
|
|
531
|
+
runs.push({ error: caught instanceof Error ? caught.message : String(caught), url })
|
|
532
|
+
} finally {
|
|
533
|
+
await page.close().catch(() => {})
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
await context.close().catch(() => {})
|
|
537
|
+
say({ op: 'diagnosed', runs })
|
|
538
|
+
},
|
|
539
|
+
|
|
540
|
+
async fetch({ url }) {
|
|
541
|
+
const answer = await within(30, fetch(url, { redirect: 'follow' }), url)
|
|
542
|
+
say({
|
|
543
|
+
ok: answer.ok,
|
|
544
|
+
op: 'fetched',
|
|
545
|
+
status: answer.status,
|
|
546
|
+
text: await within(30, answer.text(), url),
|
|
547
|
+
})
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
async finish({ update }) {
|
|
551
|
+
const reported = await state.config.inspect?.report?.(state.runs, { update })
|
|
552
|
+
say({ failed: Boolean(reported?.failed), op: 'finished' })
|
|
553
|
+
},
|
|
554
|
+
|
|
555
|
+
async start({ mask = [], network, origins = [], serve, style = null, workers }) {
|
|
556
|
+
state.mask = mask
|
|
557
|
+
state.style = style ?? undefined
|
|
558
|
+
state.network = network
|
|
559
|
+
state.workers = workers
|
|
560
|
+
state.serve = Object.entries(serve)
|
|
561
|
+
.map(([url, file]) => ({ directory: url.endsWith('/'), file, url }))
|
|
562
|
+
.sort((a, b) => b.url.length - a.url.length)
|
|
563
|
+
state.server = createServer(respond)
|
|
564
|
+
await new Promise((done) => state.server.listen(0, '127.0.0.1', done))
|
|
565
|
+
state.origin = `http://127.0.0.1:${state.server.address().port}`
|
|
566
|
+
state.origins = new Set([state.origin, ...origins])
|
|
567
|
+
const { chromium } = await playwright()
|
|
568
|
+
state.browser = await chromium.launch()
|
|
569
|
+
say({ browser: `chromium ${state.browser.version()}`, op: 'started' })
|
|
570
|
+
},
|
|
571
|
+
|
|
572
|
+
/* Polls until anything answers, a refusal of the connection being the one
|
|
573
|
+
thing that means nothing is listening yet. */
|
|
574
|
+
async wait({ seconds, url }) {
|
|
575
|
+
const deadline = Date.now() + seconds * 1000
|
|
576
|
+
for (;;) {
|
|
577
|
+
try {
|
|
578
|
+
await within(5, fetch(url, { redirect: 'manual' }), url)
|
|
579
|
+
say({ op: 'waited', up: true })
|
|
580
|
+
return
|
|
581
|
+
} catch {
|
|
582
|
+
if (Date.now() > deadline) {
|
|
583
|
+
say({ op: 'waited', up: false })
|
|
584
|
+
return
|
|
585
|
+
}
|
|
586
|
+
await new Promise((done) => setTimeout(done, 500))
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
},
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Playwright, or a failure that says how to install it.
|
|
594
|
+
*
|
|
595
|
+
* **An optional peer of `@fusion.dev/helix-cli`**, because a project that only
|
|
596
|
+
* scans or derives a theme never pictures a page and should not install a
|
|
597
|
+
* browser to do it. The first `helix visual test` is where it is needed, so
|
|
598
|
+
* that is where the instruction is.
|
|
599
|
+
*/
|
|
600
|
+
const playwright = async () => {
|
|
601
|
+
try {
|
|
602
|
+
return await import('playwright')
|
|
603
|
+
} catch (error) {
|
|
604
|
+
if (error?.code !== 'ERR_MODULE_NOT_FOUND' || !String(error.message).includes("'playwright'")) {
|
|
605
|
+
throw error
|
|
606
|
+
}
|
|
607
|
+
throw new Error(
|
|
608
|
+
'`helix visual` pictures pages with Playwright, which is not installed. Install it with `npm install --save-dev playwright`, then its browser with `npx playwright install chromium`.',
|
|
609
|
+
)
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const input = Buffer.alloc(1 << 16)
|
|
614
|
+
const decoder = new StringDecoder('utf8')
|
|
615
|
+
let pending = ''
|
|
616
|
+
|
|
617
|
+
/** The next request line, or undefined once the binary has closed the pipe. */
|
|
618
|
+
const request = () => {
|
|
619
|
+
for (;;) {
|
|
620
|
+
const at = pending.indexOf('\n')
|
|
621
|
+
if (at >= 0) {
|
|
622
|
+
const line = pending.slice(0, at)
|
|
623
|
+
pending = pending.slice(at + 1)
|
|
624
|
+
return line
|
|
625
|
+
}
|
|
626
|
+
let read
|
|
627
|
+
try {
|
|
628
|
+
read = readSync(0, input, 0, input.length, null)
|
|
629
|
+
} catch (caught) {
|
|
630
|
+
if (caught?.code === 'EAGAIN') {
|
|
631
|
+
pause(5)
|
|
632
|
+
continue
|
|
633
|
+
}
|
|
634
|
+
if (caught?.code === 'EOF') {
|
|
635
|
+
return undefined
|
|
636
|
+
}
|
|
637
|
+
throw caught
|
|
638
|
+
}
|
|
639
|
+
if (read === 0) {
|
|
640
|
+
return undefined
|
|
641
|
+
}
|
|
642
|
+
pending += decoder.write(input.subarray(0, read))
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/* One request at a time, each answered before the next is read. */
|
|
647
|
+
for (let line = request(); line !== undefined; line = request()) {
|
|
648
|
+
try {
|
|
649
|
+
const asked = JSON.parse(line)
|
|
650
|
+
const handler = handlers[asked.op]
|
|
651
|
+
if (handler === undefined) {
|
|
652
|
+
throw new Error(`no request is called \`${asked.op}\``)
|
|
653
|
+
}
|
|
654
|
+
await handler(asked)
|
|
655
|
+
} catch (caught) {
|
|
656
|
+
say({ message: caught instanceof Error ? caught.message : String(caught), op: 'error' })
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
/* The binary went away, so nothing is waiting for the browser. */
|
|
660
|
+
await handlers.close()
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type { Page } from 'playwright'
|
|
2
|
+
|
|
3
|
+
/** A color scheme a shot is taken in, declared on the page and emulated both. */
|
|
4
|
+
export type Scheme = 'dark' | 'light'
|
|
5
|
+
|
|
6
|
+
/** One picture of every entry. */
|
|
7
|
+
export interface Shot {
|
|
8
|
+
/** The viewport's height in pixels. The picture is the full page either way. 800 when not given. */
|
|
9
|
+
height?: number
|
|
10
|
+
/** Names the shot in the report, in exceptions and, unless `suffix` is given, in the baseline's file name. */
|
|
11
|
+
name: string
|
|
12
|
+
/** The color scheme the page is told it has. */
|
|
13
|
+
scheme: Scheme
|
|
14
|
+
/** What the baseline's file name carries after its stem. `.<name>` when not given, so `preview.phone.png`. */
|
|
15
|
+
suffix?: string
|
|
16
|
+
/** The viewport's width in pixels, set on `body` by the harness too. */
|
|
17
|
+
width: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One color for each scheme. */
|
|
21
|
+
export interface Pair {
|
|
22
|
+
/** The color in the dark scheme. */
|
|
23
|
+
dark: string
|
|
24
|
+
/** The color in the light scheme. */
|
|
25
|
+
light: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The ground, the ink and the inset the harness sets on `body`. */
|
|
29
|
+
export interface Harness {
|
|
30
|
+
/** The page's background. `#fff` and `#000` when not given. */
|
|
31
|
+
ground?: Pair
|
|
32
|
+
/** The page's text color. `#000` and `#fff` when not given. */
|
|
33
|
+
ink?: Pair
|
|
34
|
+
/** Pixels between the page's edge and its content. 24 when not given. */
|
|
35
|
+
padding?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** How a shot is compared with its baseline. */
|
|
39
|
+
export interface Diff {
|
|
40
|
+
/**
|
|
41
|
+
* `pixel` counts the pixels past `threshold`, and is the default. `perceptual` scores structural
|
|
42
|
+
* similarity, for photographs and gradients whose pixels move a shade on every render.
|
|
43
|
+
*/
|
|
44
|
+
mode?: 'perceptual' | 'pixel'
|
|
45
|
+
/** Pixels a shot may differ by and still pass, in the pixel mode. 0 when not given. */
|
|
46
|
+
pixels?: number
|
|
47
|
+
/** The lowest structural similarity a shot may score and pass, in the perceptual mode, from 0 to 1. 0.99 when not given. */
|
|
48
|
+
ssim?: number
|
|
49
|
+
/** How far apart two colors may be and still be one pixel, from 0 to 1. 0.1 when not given. */
|
|
50
|
+
threshold?: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** HTML files from a glob, each pictured inside a document the harness writes. */
|
|
54
|
+
export interface HtmlSource {
|
|
55
|
+
/** The diff for this source's shots, in place of the config's. */
|
|
56
|
+
diff?: Diff
|
|
57
|
+
/**
|
|
58
|
+
* Relative requests from a page answered by another file in its directory,
|
|
59
|
+
* with `{stem}` for the page's own file name without its extension:
|
|
60
|
+
* `{ 'styles.css': '{stem}.css' }`.
|
|
61
|
+
*/
|
|
62
|
+
files?: Record<string, string>
|
|
63
|
+
/**
|
|
64
|
+
* Which files under `root` are entries. `*` matches within a path segment,
|
|
65
|
+
* `**` matches any number of directories, and `{name}` is the one segment
|
|
66
|
+
* that names the entry: `{name}/.examples/preview.html`.
|
|
67
|
+
*/
|
|
68
|
+
glob: string
|
|
69
|
+
/** The harness's colors and inset, or `false` to picture the file as it is. */
|
|
70
|
+
harness?: boolean | Harness
|
|
71
|
+
/** Picks this source out of the others. */
|
|
72
|
+
kind: 'html'
|
|
73
|
+
/** The first half of each entry's id, `<name>/<entry>`. One word. */
|
|
74
|
+
name: string
|
|
75
|
+
/** Where the glob starts, relative to the config. */
|
|
76
|
+
root: string
|
|
77
|
+
/** URLs of scripts the page loads, each with `defer`, after the harness style. */
|
|
78
|
+
scripts?: string[]
|
|
79
|
+
/** URLs of stylesheets the page links, before the harness style. */
|
|
80
|
+
styles?: string[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** What every source on a server shares. */
|
|
84
|
+
interface Served {
|
|
85
|
+
/** The directory each entry's baselines are written in, relative to the config. */
|
|
86
|
+
baselines: string
|
|
87
|
+
/** The directory `start` runs in, relative to the config. */
|
|
88
|
+
cwd?: string
|
|
89
|
+
/** The diff for this source's shots, in place of the config's. */
|
|
90
|
+
diff?: Diff
|
|
91
|
+
/** The first half of each entry's id, `<name>/<entry>`. One word. */
|
|
92
|
+
name: string
|
|
93
|
+
/**
|
|
94
|
+
* A shell command that starts the server, run only when nothing answers at `url`, and stopped
|
|
95
|
+
* when the run ends. Left out, the server has to be running already.
|
|
96
|
+
*/
|
|
97
|
+
start?: string
|
|
98
|
+
/** Seconds to wait for the server to answer after `start`. 120 when not given. */
|
|
99
|
+
timeout?: number
|
|
100
|
+
/** The server's address, `http://localhost:3000`. */
|
|
101
|
+
url: string
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Pages of any server, by route or from its sitemap. */
|
|
105
|
+
export interface ServerSource extends Served {
|
|
106
|
+
/** Picks this source out of the others. */
|
|
107
|
+
kind: 'server'
|
|
108
|
+
/** Routes to picture, each a path or a path and the name its baselines take. `/` is named `index`. */
|
|
109
|
+
routes?: (string | { name: string; path: string })[]
|
|
110
|
+
/** A sitemap's path on the server, `/sitemap.xml`, whose every route is pictured too. */
|
|
111
|
+
sitemap?: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Stories from a running Storybook, read from its `index.json`. */
|
|
115
|
+
export interface StorybookSource extends Served {
|
|
116
|
+
/** Stories carrying one of these tags are left out. */
|
|
117
|
+
exclude?: string[]
|
|
118
|
+
/** Only stories carrying one of these tags, when any are given. */
|
|
119
|
+
include?: string[]
|
|
120
|
+
/** Picks this source out of the others. */
|
|
121
|
+
kind: 'storybook'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Entries listed in a JSON file: an array of `{ id, url }`, each `url` relative to the server. */
|
|
125
|
+
export interface CatalogSource extends Served {
|
|
126
|
+
/** The catalog file, relative to the config. */
|
|
127
|
+
catalog: string
|
|
128
|
+
/** Picks this source out of the others. */
|
|
129
|
+
kind: 'catalog'
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Any source of entries. */
|
|
133
|
+
export type Source = CatalogSource | HtmlSource | ServerSource | StorybookSource
|
|
134
|
+
|
|
135
|
+
/** An entry as `inspect` sees it. */
|
|
136
|
+
export interface Entry {
|
|
137
|
+
/** The HTML file, absolute, for an entry from an HTML source. */
|
|
138
|
+
file?: string
|
|
139
|
+
/** `<source>/<name>`, unique across the run. */
|
|
140
|
+
id: string
|
|
141
|
+
/** What the glob named it. */
|
|
142
|
+
name: string
|
|
143
|
+
/** The name of the source it came from. */
|
|
144
|
+
source: string
|
|
145
|
+
/** The page's address, for an entry on a server. */
|
|
146
|
+
url?: string
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A run of `inspect.run`, collected for `inspect.report`. */
|
|
150
|
+
export interface Inspected<Result = unknown> {
|
|
151
|
+
/** The entry that was inspected. */
|
|
152
|
+
entry: Entry
|
|
153
|
+
/** What `run` returned. */
|
|
154
|
+
result: Result
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Checks of your own on each page, after its first picture. */
|
|
158
|
+
export interface Inspect<Result = unknown> {
|
|
159
|
+
/**
|
|
160
|
+
* Called once at the end of the run with every result. Return `{ failed: true }` to fail the run.
|
|
161
|
+
*/
|
|
162
|
+
report?: (
|
|
163
|
+
runs: Inspected<Result>[],
|
|
164
|
+
context: {
|
|
165
|
+
/** Whether the run is `helix visual update`. */
|
|
166
|
+
update: boolean
|
|
167
|
+
},
|
|
168
|
+
) => Promise<{ failed?: boolean } | undefined> | { failed?: boolean } | undefined
|
|
169
|
+
/** Called on the page that was pictured, after its first shot. What it returns is kept for `report`. */
|
|
170
|
+
run: (context: {
|
|
171
|
+
/** The entry on the page. */
|
|
172
|
+
entry: Entry
|
|
173
|
+
/** The Playwright page. */
|
|
174
|
+
page: Page
|
|
175
|
+
}) => Promise<Result> | Result
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** One exception to the comparison, with the reason it exists. */
|
|
179
|
+
export interface Exception {
|
|
180
|
+
/** An entry's id, `styled/aura`, or its name in every source, `aura`. */
|
|
181
|
+
entry: string
|
|
182
|
+
/** What was measured to justify it, said beside it in every run. */
|
|
183
|
+
measured?: string
|
|
184
|
+
/** How many pixels the shot may differ by. An exception whose shot stops differing fails the run. */
|
|
185
|
+
pixels?: number
|
|
186
|
+
/** Why the exception exists. */
|
|
187
|
+
reason: string
|
|
188
|
+
/** One shot by name, or every shot of the entry when not given. */
|
|
189
|
+
shot?: string
|
|
190
|
+
/** A surface that cannot hold still: never pictured, and named in every run as skipped. */
|
|
191
|
+
unstable?: boolean
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A `visual.config.ts`. */
|
|
195
|
+
export interface Config {
|
|
196
|
+
/** How a shot is compared with its baseline, unless its source sets its own. */
|
|
197
|
+
diff?: Diff
|
|
198
|
+
/** Exceptions to the comparison, each with its reason. */
|
|
199
|
+
exceptions?: Exception[]
|
|
200
|
+
/** Where the environment fingerprint is kept, relative to the config. `visual.fingerprint.json` when not given. */
|
|
201
|
+
fingerprint?: string
|
|
202
|
+
/** Checks of your own on each page, after its first picture. */
|
|
203
|
+
inspect?: Inspect
|
|
204
|
+
/**
|
|
205
|
+
* Selectors whose elements are painted over in one fixed color in every picture, for what changes on its
|
|
206
|
+
* own and says nothing about the page, such as a version number or a date.
|
|
207
|
+
*/
|
|
208
|
+
mask?: string[]
|
|
209
|
+
/** `origin` refuses every request off the harness origin, and is the default. `open` lets them through. */
|
|
210
|
+
network?: 'open' | 'origin'
|
|
211
|
+
/** `linux` takes and compares baselines on Linux alone, and is the default. `any` compares wherever it runs. */
|
|
212
|
+
platform?: 'any' | 'linux'
|
|
213
|
+
/** Where each run writes its report and `run.json`, relative to the config. `.helix/visual` when not given. */
|
|
214
|
+
report?: string
|
|
215
|
+
/** How your project retakes its baselines, said when a run fails. */
|
|
216
|
+
retake?: string
|
|
217
|
+
/** URL paths the harness serves from disk, relative to the config. A path ending in `/` serves a directory. */
|
|
218
|
+
serve?: Record<string, string>
|
|
219
|
+
/** What the harness waits for before each picture. */
|
|
220
|
+
settle?: {
|
|
221
|
+
/**
|
|
222
|
+
* Only custom elements whose tag starts with this are awaited, and one left undefined fails the
|
|
223
|
+
* entry. Set it for a framework that writes hyphenated tags of its own, such as Next.js's
|
|
224
|
+
* `next-route-announcer`. Every custom element when not given.
|
|
225
|
+
*/
|
|
226
|
+
elements?: string
|
|
227
|
+
/** A property each element may carry holding a promise that resolves when it has rendered. */
|
|
228
|
+
readyProperty?: string
|
|
229
|
+
}
|
|
230
|
+
/** How `--shard` packs entries into shards. */
|
|
231
|
+
shard?: {
|
|
232
|
+
/** Measured seconds by entry name. */
|
|
233
|
+
cost?: Record<string, number>
|
|
234
|
+
/** Seconds a shot is taken to cost where `cost` names nothing. 1 when not given. */
|
|
235
|
+
perShot?: number
|
|
236
|
+
}
|
|
237
|
+
/** The pictures taken of every entry. A wide width, a phone width and dark when not given. */
|
|
238
|
+
shots?: Shot[]
|
|
239
|
+
/** Where entries come from. */
|
|
240
|
+
sources: Source[]
|
|
241
|
+
/**
|
|
242
|
+
* A stylesheet applied while each picture is taken and removed after, to hold still what `mask` alone
|
|
243
|
+
* cannot, such as a masked element whose width follows its text.
|
|
244
|
+
*/
|
|
245
|
+
style?: string
|
|
246
|
+
/** A file to write the seconds each entry name took, relative to the config. */
|
|
247
|
+
timings?: string
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Returns the config unchanged, typed. */
|
|
251
|
+
export declare const defineConfig: (config: Config) => Config
|
package/visual/index.mjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@fusion.dev/helix-cli/visual`: what a `visual.config.ts` imports.
|
|
3
|
+
*
|
|
4
|
+
* The config is plain data and two optional functions, so this returns what it
|
|
5
|
+
* is given. It exists for the types: `defineConfig` checks a config against
|
|
6
|
+
* `index.d.ts` beside it where it is written.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The config, unchanged, typed. */
|
|
10
|
+
export const defineConfig = (config) => config
|