@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 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, looks up the installed packages, and serves
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), { stdio: 'inherit' })
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.187.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.187.0",
33
- "@fusion.dev/helix-cli-darwin-x64": "0.187.0",
34
- "@fusion.dev/helix-cli-linux-arm64": "0.187.0",
35
- "@fusion.dev/helix-cli-linux-x64": "0.187.0",
36
- "@fusion.dev/helix-cli-win32-x64": "0.187.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"
@@ -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
@@ -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