@variance-authority/unit-test 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,73 @@
1
+ # @variance-authority/unit-test
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5c34e6d: Say which input moved, not just which tag did
8
+
9
+ `partingOf(baseline, candidate)` climbs from a set of deltas to the component
10
+ boundary that owns them and names the input that carried the decision:
11
+ `Cart chose differently — useState #0 moved`, with `Summary` reported as having
12
+ been handed a different `expanded` and hanging under that line as a
13
+ manifestation rather than as a second finding. `explainParting` renders that
14
+ as lines. Two runs of one page that differ in a `<p>` where the other has a
15
+ `<span>` previously produced a structural delta and nothing to say about it;
16
+ the tag is now the symptom and the hook cell is the report.
17
+
18
+ `holdingOf` from `@variance-authority/react` supplies the evidence — props,
19
+ context values and hook cells as digests, per boundary, including
20
+ `useSyncExternalStore` snapshots so a store that moved outside React is
21
+ distinguished from a component that decided differently on its own. Values are
22
+ never carried, only digests, and a holding reaches no hash: `renderHash`,
23
+ `structureHash`, `styleHash`, band-exact component digests and
24
+ `componentInstances` are all unmoved by it, the same bargain `styleProvenance`
25
+ makes. `collect` in `@variance-authority/dom` takes `holdingOf` as a
26
+ caller-supplied reader, opt in separately from `wiringOf`, and `capture` in
27
+ `@variance-authority/unit-test` now accepts `provenanceOf`, `wiringOf` and
28
+ `holdingOf` so a unit test can read one.
29
+
30
+ A boundary whose own input could not be read is reported as `unread` and never
31
+ as nondeterminism: `undetermined` is reserved for a component whose every input
32
+ was read and agreed. A wrapper that roots a component boundary no longer
33
+ collapses, because collapsing it discarded the holding — the cost is that a run
34
+ reading holdings keeps wrappers a run without them removes, which is why both
35
+ sides of a comparison must be read the same way.
36
+
37
+ A component that ran no hooks is read as having run none, rather than as
38
+ unreadable. React writes `_debugHookTypes = null` on every fiber and fills it on
39
+ the first hook call, so the property's absence is the only silence — and
40
+ collapsing the two reported the one shape most worth calling nondeterministic, a
41
+ component with no props and no hooks that renders differently twice, as
42
+ something nothing could be said about.
43
+
44
+ `PartedBoundary.moved` names the properties the owned deltas named — `color`,
45
+ `padding-top`, `width` — and `explainParting` spends them on the delta line.
46
+ That is the last joint of the chain the rungs climb: a hook cell moved, a prop
47
+ carried it down, and this is what the prop turned into on the page. A count and
48
+ a band stop one link short of what somebody chasing a visual regression is
49
+ trying to name.
50
+
51
+ `Parting.slice` answers the question asked before which input moved: is this
52
+ worth opening. Three facts are read independently — did the component tree
53
+ move, did any input move, did the output move — and the combinations collapse
54
+ to six sentences. `refactor` is the one that pays for the rest: a component
55
+ tree that moved while the page did not is the receipt a refactor never gets,
56
+ since a pixel differ can say the screenshots match and nothing about what was
57
+ rewritten underneath. `flake` is `settled`'s opposite number and is refused on
58
+ silence — a run that read no boundary reports `unread`, never `flake`.
59
+
60
+ `explainParting` leads with that line, and stops enumerating manifestations
61
+ past three: one input at a fork can put a boundary on every component beneath
62
+ it, and nine lines carrying one decision bury the one line worth reading.
63
+
64
+ The whole layer is documented in `docs/parting.md`: the six slices, the seven
65
+ rungs, what a holding carries and what it deliberately does not.
66
+
67
+ ### Patch Changes
68
+
69
+ - Updated dependencies [f800342]
70
+ - Updated dependencies [e8fee66]
71
+ - Updated dependencies [5c34e6d]
72
+ - @variance-authority/dom@0.1.0
73
+ - @variance-authority/core@0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Machine Garden
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,233 @@
1
+ <p align="center"><img src="./mark.svg" alt="Variance Authority mark" width="72"></p>
2
+
3
+ # @variance-authority/unit-test
4
+
5
+ > Capture a mounted DOM subject in a browserless Jest or Vitest process, then render and observe it later.
6
+
7
+ Capture a mounted DOM subject in a browserless Jest or Vitest process, then let
8
+ `variance run` render and observe that artifact in a later process. The package
9
+ does not export `test`, `expect`, a reporter, or a browser.
10
+
11
+ A subject is the thing captured for comparison: a mounted DOM subtree here, or
12
+ — for `snapshotValue` below — a plain value. Each one is identified by a
13
+ `SubjectRef`: an id plus a `kind` recording what produced it (`'story'`,
14
+ `'route'`, `'fixture'`, or `'value'`), not what it looks like.
15
+
16
+ It needs a live DOM and a directory it can write artifacts to. External resources
17
+ arrive as immutable bytes: capture closes over the document by resolving every
18
+ resource it references into bytes the artifact carries, and refuses to write a
19
+ capture it cannot close this way.
20
+
21
+ Browserless describes this half. The capture carries markup, CSS and resources —
22
+ not pixels — so the later `variance run` opens a browser to paint it and refuses
23
+ the run if it cannot.
24
+
25
+ Install the package and the DOM environment used by the test runner:
26
+
27
+ ```bash
28
+ npm install --save-dev @variance-authority/unit-test jsdom
29
+ ```
30
+
31
+ The render half is a separate process and a separate install, in the job that
32
+ paints:
33
+
34
+ ```bash
35
+ npm install --save-dev @variance-authority/cli
36
+ npx playwright install chromium
37
+ ```
38
+
39
+ Vitest needs `// @vitest-environment jsdom` (or an equivalent project setting).
40
+ Jest needs its normal `jsdom` test environment. This package does not configure
41
+ either runner.
42
+
43
+ ## Capture in the existing unit test
44
+
45
+ ```ts
46
+ // @vitest-environment jsdom
47
+ import { test, expect } from 'vitest';
48
+ import { capture, writeCapture } from '@variance-authority/unit-test';
49
+
50
+ test('save button', async () => {
51
+ const button = document.createElement('button');
52
+ button.textContent = 'Save';
53
+ document.body.append(button);
54
+
55
+ const artifact = await capture(button, {
56
+ subject: 'button/save',
57
+ viewport: {
58
+ width: 320,
59
+ height: 200,
60
+ deviceScaleFactor: 1,
61
+ colorScheme: 'light',
62
+ },
63
+ });
64
+
65
+ await writeCapture('.variance/captures', artifact);
66
+ expect(button.textContent).toBe('Save');
67
+ });
68
+ ```
69
+
70
+ This process acquires markup, applicable CSS, semantic evidence, and resources.
71
+ It does not produce a screenshot or visual verdict.
72
+
73
+ `capture(root, options)` takes:
74
+
75
+ | option | default | what it decides |
76
+ |---|---|---|
77
+ | `subject` | required | `'button/save'`, or a full `SubjectRef`. A bare string becomes `kind: 'fixture'` |
78
+ | `viewport` | required | width, height, scale, colour scheme. Media queries are resolved against it here, in the unit process, because the later renderer never sees this DOM |
79
+ | `engine` | the DOM's own user agent | what painted, as it lands in the identity |
80
+ | `fonts` | none | the font stack this capture is asserted to have, as `family/weight/style/hash` |
81
+ | `features` | none | environment facts folded into the capture and into media-condition resolution |
82
+ | `sourceRoot` | none | the root component paths are made relative to, so `file:line` survives the move to another machine |
83
+ | `resolveResource` | none | `(url) => bytes`. **Required the moment the subtree references anything**: a subtree with resources and no resolver throws at capture, naming every URL it would have had to guess at, and a resolver returning `null` for one of them throws naming that one |
84
+ | `provenanceOf` | none | `(node) => owners`. Attaches the component chain that rendered each node, so a difference can be named by component rather than by path |
85
+ | `wiringOf` | none | `(node) => wiring`. Attaches the framework wiring a node carries — hook count, keys, boundaries — as part of the compared identity |
86
+ | `holdingOf` | none | `(node) => holding`. Attaches what each component boundary was handed and what it retained: props, contexts, and hook cells as digests. Evidence beside the snapshot, not part of any hash |
87
+
88
+ `resolveResource` is the whole of the requirement above. A capture is rendered in
89
+ another process — possibly on another machine, possibly hours later — so a
90
+ document that carries digests and no bytes is a document that paints holes over
91
+ there and cannot say why.
92
+
93
+ `writeCapture(directory, artifact)` writes one versioned file per subject;
94
+ `readCapture` and `captureFiles` are the read half, and `CAPTURE_SUFFIX` is what
95
+ they match on.
96
+
97
+ ## Clear the directory once per run
98
+
99
+ ```ts
100
+ // vitest.config.js → test: { globalSetup: ['variance/reset.mjs'] }
101
+ import { resetCaptures } from '@variance-authority/unit-test';
102
+
103
+ export async function setup() {
104
+ await resetCaptures('.variance/captures');
105
+ }
106
+ ```
107
+
108
+ A capture is addressed by its subject id, so two tests that both call themselves
109
+ `button/save` address one file. `writeCapture` refuses to be the second write
110
+ rather than overwriting, because the run that reads this directory afterwards
111
+ would otherwise be missing a subject and say nothing about it.
112
+
113
+ That refusal is only correct over a directory holding one run, and nothing in a
114
+ test runner makes a directory hold one run by itself. `resetCaptures` is what
115
+ does. Call it from a once-per-run hook — `globalSetup` in Vitest, a setup project
116
+ in Playwright — never from a test file, where it would race the other test files
117
+ and delete their captures. It removes capture files and leaves everything else in
118
+ the directory alone, and a directory that does not exist yet is not an error.
119
+
120
+ Without it the second run of an unchanged suite fails, and the failure names a
121
+ subject id collision to somebody who has only ever had one subject.
122
+
123
+ ## Snapshot a value
124
+
125
+ Not every public surface is a page. An API response, a generated OpenAPI
126
+ document, a GraphQL schema, a route table and a build manifest are all things a
127
+ change can break, and none of them needs a DOM.
128
+
129
+ ```ts
130
+ import { test } from 'vitest';
131
+ import { snapshotValue } from '@variance-authority/unit-test';
132
+
133
+ test('the health endpoint', async () => {
134
+ const response = await fetch('http://localhost:3000/health');
135
+
136
+ await snapshotValue(await response.json(), {
137
+ subject: 'api/health',
138
+ directory: '.variance/captures',
139
+ drop: ['/uptimeSeconds'],
140
+ });
141
+ });
142
+ ```
143
+
144
+ `snapshotValue(value, options)` takes:
145
+
146
+ | option | default | what it decides |
147
+ |---|---|---|
148
+ | `subject` | required | `'api/health'`, or a full `SubjectRef`. A bare string becomes `kind: 'value'` |
149
+ | `directory` | required | where the capture is written; one fresh directory per run, as above |
150
+ | `dialect` | `'json'` | how the text is read later. A reader for a dialect it does not know still compares the value |
151
+ | `drop` | none | JSON Pointers whose value is volatile. Recorded as present, never compared |
152
+ | `replace` | none | JSON Pointers whose value becomes a token you choose, so the shape stays readable |
153
+ | `arrayKey` | none | for an array of records, the member that identifies a row: `{ '/rows': 'id' }` |
154
+ | `generator` | none | what emitted the value, when something did |
155
+
156
+ Set `arrayKey` for any array of records. Without it, an array is compared by
157
+ index, so a row inserted at the top reports every row after it as changed
158
+ instead of reporting the one insertion.
159
+
160
+ The value is serialized here, in the unit process, into canonical text: keys
161
+ sorted, numbers written portably, `undefined` members omitted. That text is what
162
+ is addressed and what a later run compares, so two processes that built the same
163
+ response in a different order produce the same record.
164
+
165
+ Like `capture`, `snapshotValue` writes and does not compare — the baseline is
166
+ not in this process, and the verdict belongs to `variance run`. It throws only
167
+ on a value that cannot be one: a function, a `Date`, a `bigint` or a non-finite
168
+ number, naming the JSON Pointer where it was found. Dropping it silently would
169
+ put a key in the baseline that a later run reads as removed.
170
+
171
+ `captureCollector` accepts document captures for the normal `variance run` path.
172
+ If its directory contains a value capture, collection
173
+ reports that the material is a value capture while the run compares rendered
174
+ documents; it does not turn that refusal into an unchanged result. The
175
+ value-writing API is therefore usable independently, but the CLI/raster path
176
+ does not compare value artifacts.
177
+
178
+ ## Render later
179
+
180
+ Point the normal CLI collector setting at a module exporting the artifact
181
+ collector:
182
+
183
+ ```ts
184
+ import { captureCollector } from '@variance-authority/unit-test';
185
+
186
+ export default captureCollector({ directory: '.variance/captures' });
187
+ ```
188
+
189
+ `directory` is the only option, and duplicate subject ids inside it are an error
190
+ rather than a last-write-wins: two tests writing `button/save` is a name
191
+ collision, and picking one silently makes half the suite invisible.
192
+
193
+ Name that module as the collector, and set `profile` to the observation tier
194
+ the capture was taken in — `"jsdom"` here, or `"chromium"` for a
195
+ browser-based collector:
196
+
197
+ ```json
198
+ {
199
+ "project": "design-system",
200
+ "profile": "jsdom",
201
+ "viewport": { "width": 320, "height": 200 },
202
+ "retention": "durable",
203
+ "subjects": { "kind": "collector", "collector": "variance/collector.mjs" },
204
+ "baselines": { "kind": "directory", "root": "baselines" },
205
+ "report": "out/report.json"
206
+ }
207
+ ```
208
+
209
+ `retention` is `"durable"` (compare against a stored baseline — `baselines` is
210
+ then required) or `"ephemeral"` (nothing is stored, and `baselines` must be
211
+ absent). `subjects.kind` says where the run gets its subject list:
212
+ `"collector"` (a module like the one above), `"storybook"`, or `"list"`.
213
+ `baselines.kind` says where the stored baseline lives: `"directory"`, `"lfs"`,
214
+ or `"remote"`.
215
+
216
+ ```bash
217
+ npx variance run
218
+ ```
219
+
220
+ `variance run` is the command `@variance-authority/cli` installs as `variance`.
221
+ It reads the document captures this package wrote, renders each one with the
222
+ configured renderer, compares it against the configured baseline, and writes
223
+ the report.
224
+
225
+ `profile: "jsdom"` says what observed the subject, not what paints it: the
226
+ capture has no layout engine behind it, so every changed region in the report
227
+ is reported unattributed rather than joined to a guessed node — the render
228
+ itself is still Chromium.
229
+
230
+ A run that finds nothing to review exits 0. The first run against baselines
231
+ that don't exist yet has nothing to compare against, so it exits 1 for review
232
+ rather than a pass. Use a fresh capture directory per run; the runner still
233
+ owns test selection and retry lifecycle.
@@ -0,0 +1,40 @@
1
+ import type { CaptureArtifact } from '@variance-authority/core';
2
+ export declare const CAPTURE_SUFFIX = ".va-capture.json";
3
+ /**
4
+ * Atomically write one capture, and refuse to be the second write for a subject.
5
+ *
6
+ * The filename is the subject id, so two tests capturing `button/save` address
7
+ * one file. `rename` would have taken the second one — silently, successfully,
8
+ * and with half the suite then invisible to the run that reads this directory.
9
+ * `link` is the same atomic publish with the opposite answer to an occupied
10
+ * name: it fails with `EEXIST` rather than overwriting, so a collision costs one
11
+ * failing test instead of a report that is quietly missing a subject.
12
+ *
13
+ * *What it costs.* A capture directory reused across runs fails on the second
14
+ * run, and the second run is the ordinary one: edit a component, re-run the
15
+ * tests, compare. So the run boundary this guard assumes has to be given to the
16
+ * writer rather than described to them — {@link resetCaptures}, once, from
17
+ * wherever the runner starts a run. Without it the guard is right about the
18
+ * directory and wrong about the cause, and it says so to somebody who changed
19
+ * one colour and has no second subject anywhere.
20
+ */
21
+ export declare function writeCapture(directory: string, artifact: CaptureArtifact): Promise<string>;
22
+ /**
23
+ * Empty a capture directory, once, at the start of a run.
24
+ *
25
+ * The other half of {@link writeCapture}'s refusal to overwrite. That guard is
26
+ * only correct over a directory that holds one run, and nothing in a test runner
27
+ * makes a directory hold one run by itself — so this is the boundary, called
28
+ * from wherever the runner has a once-per-run hook (`globalSetup` in Vitest, a
29
+ * global setup project in Playwright). Called from a test file it would race the
30
+ * other test files and delete their captures.
31
+ *
32
+ * It removes capture files and nothing else. The directory is named by the
33
+ * adopter and may be one they keep other things in; a helper that took
34
+ * `rm -rf` to a configured path would eventually take it to the wrong one.
35
+ * A directory that is not there yet is not an error — that is the first run.
36
+ */
37
+ export declare function resetCaptures(directory: string): Promise<void>;
38
+ export declare function readCapture(path: string): Promise<CaptureArtifact>;
39
+ export declare function captureFiles(directory: string): Promise<readonly string[]>;
40
+ //# sourceMappingURL=archive.d.ts.map
@@ -0,0 +1,96 @@
1
+ import { link, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { captureArtifactFrom } from './shape.js';
4
+ export const CAPTURE_SUFFIX = '.va-capture.json';
5
+ /**
6
+ * Atomically write one capture, and refuse to be the second write for a subject.
7
+ *
8
+ * The filename is the subject id, so two tests capturing `button/save` address
9
+ * one file. `rename` would have taken the second one — silently, successfully,
10
+ * and with half the suite then invisible to the run that reads this directory.
11
+ * `link` is the same atomic publish with the opposite answer to an occupied
12
+ * name: it fails with `EEXIST` rather than overwriting, so a collision costs one
13
+ * failing test instead of a report that is quietly missing a subject.
14
+ *
15
+ * *What it costs.* A capture directory reused across runs fails on the second
16
+ * run, and the second run is the ordinary one: edit a component, re-run the
17
+ * tests, compare. So the run boundary this guard assumes has to be given to the
18
+ * writer rather than described to them — {@link resetCaptures}, once, from
19
+ * wherever the runner starts a run. Without it the guard is right about the
20
+ * directory and wrong about the cause, and it says so to somebody who changed
21
+ * one colour and has no second subject anywhere.
22
+ */
23
+ export async function writeCapture(directory, artifact) {
24
+ await mkdir(directory, { recursive: true });
25
+ const path = join(directory, `${encodeURIComponent(artifact.subject.id)}${CAPTURE_SUFFIX}`);
26
+ const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
27
+ await writeFile(temporary, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
28
+ try {
29
+ await link(temporary, path);
30
+ }
31
+ catch (error) {
32
+ if (error.code !== 'EEXIST')
33
+ throw error;
34
+ throw new Error(`${artifact.subject.id} already has a capture at ${path}. Two subjects with one id ` +
35
+ 'is a name collision, and taking the later write makes the earlier subject invisible ' +
36
+ 'to the run. Give them distinct ids — or, if this is a second run over the same ' +
37
+ 'directory, call `resetCaptures` once when the run starts.', { cause: error });
38
+ }
39
+ finally {
40
+ await rm(temporary, { force: true });
41
+ }
42
+ return path;
43
+ }
44
+ /**
45
+ * Empty a capture directory, once, at the start of a run.
46
+ *
47
+ * The other half of {@link writeCapture}'s refusal to overwrite. That guard is
48
+ * only correct over a directory that holds one run, and nothing in a test runner
49
+ * makes a directory hold one run by itself — so this is the boundary, called
50
+ * from wherever the runner has a once-per-run hook (`globalSetup` in Vitest, a
51
+ * global setup project in Playwright). Called from a test file it would race the
52
+ * other test files and delete their captures.
53
+ *
54
+ * It removes capture files and nothing else. The directory is named by the
55
+ * adopter and may be one they keep other things in; a helper that took
56
+ * `rm -rf` to a configured path would eventually take it to the wrong one.
57
+ * A directory that is not there yet is not an error — that is the first run.
58
+ */
59
+ export async function resetCaptures(directory) {
60
+ let entries;
61
+ try {
62
+ entries = await readdir(directory);
63
+ }
64
+ catch (error) {
65
+ if (error.code === 'ENOENT')
66
+ return;
67
+ throw error;
68
+ }
69
+ await Promise.all(entries
70
+ .filter((entry) => entry.endsWith(CAPTURE_SUFFIX))
71
+ .map((entry) => rm(join(directory, entry), { force: true })));
72
+ }
73
+ export async function readCapture(path) {
74
+ let value;
75
+ try {
76
+ value = JSON.parse(await readFile(path, 'utf8'));
77
+ }
78
+ catch (error) {
79
+ throw new Error(`cannot read capture artifact ${path}`, { cause: error });
80
+ }
81
+ return captureArtifactFrom(value, path);
82
+ }
83
+ export async function captureFiles(directory) {
84
+ let entries;
85
+ try {
86
+ entries = await readdir(directory);
87
+ }
88
+ catch (error) {
89
+ throw new Error(`cannot read capture directory ${directory}`, { cause: error });
90
+ }
91
+ return entries
92
+ .filter((entry) => entry.endsWith(CAPTURE_SUFFIX))
93
+ .sort()
94
+ .map((entry) => join(directory, entry));
95
+ }
96
+ //# sourceMappingURL=archive.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"archive.js","sourceRoot":"","sources":["../src/archive.ts"],"names":[],"mappings":"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","sourcesContent":["import { link, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport type { CaptureArtifact } from '@variance-authority/core';\nimport { captureArtifactFrom } from './shape.js';\n\nexport const CAPTURE_SUFFIX = '.va-capture.json';\n\n/**\n * Atomically write one capture, and refuse to be the second write for a subject.\n *\n * The filename is the subject id, so two tests capturing `button/save` address\n * one file. `rename` would have taken the second one — silently, successfully,\n * and with half the suite then invisible to the run that reads this directory.\n * `link` is the same atomic publish with the opposite answer to an occupied\n * name: it fails with `EEXIST` rather than overwriting, so a collision costs one\n * failing test instead of a report that is quietly missing a subject.\n *\n * *What it costs.* A capture directory reused across runs fails on the second\n * run, and the second run is the ordinary one: edit a component, re-run the\n * tests, compare. So the run boundary this guard assumes has to be given to the\n * writer rather than described to them — {@link resetCaptures}, once, from\n * wherever the runner starts a run. Without it the guard is right about the\n * directory and wrong about the cause, and it says so to somebody who changed\n * one colour and has no second subject anywhere.\n */\nexport async function writeCapture(\n directory: string,\n artifact: CaptureArtifact,\n): Promise<string> {\n await mkdir(directory, { recursive: true });\n const path = join(directory, `${encodeURIComponent(artifact.subject.id)}${CAPTURE_SUFFIX}`);\n const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;\n await writeFile(temporary, `${JSON.stringify(artifact, null, 2)}\\n`, 'utf8');\n try {\n await link(temporary, path);\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;\n throw new Error(\n `${artifact.subject.id} already has a capture at ${path}. Two subjects with one id ` +\n 'is a name collision, and taking the later write makes the earlier subject invisible ' +\n 'to the run. Give them distinct ids — or, if this is a second run over the same ' +\n 'directory, call `resetCaptures` once when the run starts.',\n { cause: error },\n );\n } finally {\n await rm(temporary, { force: true });\n }\n return path;\n}\n\n/**\n * Empty a capture directory, once, at the start of a run.\n *\n * The other half of {@link writeCapture}'s refusal to overwrite. That guard is\n * only correct over a directory that holds one run, and nothing in a test runner\n * makes a directory hold one run by itself — so this is the boundary, called\n * from wherever the runner has a once-per-run hook (`globalSetup` in Vitest, a\n * global setup project in Playwright). Called from a test file it would race the\n * other test files and delete their captures.\n *\n * It removes capture files and nothing else. The directory is named by the\n * adopter and may be one they keep other things in; a helper that took\n * `rm -rf` to a configured path would eventually take it to the wrong one.\n * A directory that is not there yet is not an error — that is the first run.\n */\nexport async function resetCaptures(directory: string): Promise<void> {\n let entries: readonly string[];\n try {\n entries = await readdir(directory);\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;\n throw error;\n }\n await Promise.all(\n entries\n .filter((entry) => entry.endsWith(CAPTURE_SUFFIX))\n .map((entry) => rm(join(directory, entry), { force: true })),\n );\n}\n\nexport async function readCapture(path: string): Promise<CaptureArtifact> {\n let value: unknown;\n try {\n value = JSON.parse(await readFile(path, 'utf8'));\n } catch (error) {\n throw new Error(`cannot read capture artifact ${path}`, { cause: error });\n }\n\n return captureArtifactFrom(value, path);\n}\n\nexport async function captureFiles(directory: string): Promise<readonly string[]> {\n let entries: readonly string[];\n try {\n entries = await readdir(directory);\n } catch (error) {\n throw new Error(`cannot read capture directory ${directory}`, { cause: error });\n }\n return entries\n .filter((entry) => entry.endsWith(CAPTURE_SUFFIX))\n .sort()\n .map((entry) => join(directory, entry));\n}\n"]}
@@ -0,0 +1,33 @@
1
+ import { type CaptureArtifact, type Holding, type Provenance, type Wiring, type SubjectRef, type Viewport } from '@variance-authority/core';
2
+ export interface ResolvedResource {
3
+ readonly contentType: string;
4
+ readonly bytes: Uint8Array;
5
+ }
6
+ export interface UnitCaptureOptions {
7
+ readonly subject: string | SubjectRef;
8
+ readonly viewport: Viewport;
9
+ readonly engine?: string;
10
+ readonly fonts?: readonly string[];
11
+ readonly features?: Readonly<Record<string, string>>;
12
+ readonly sourceRoot?: string;
13
+ readonly resolveResource?: (url: string) => Promise<ResolvedResource | null>;
14
+ /**
15
+ * Framework readers, passed straight through to `collect`.
16
+ *
17
+ * Three callbacks rather than one adapter object, mirroring `CollectOptions`
18
+ * exactly, because they are opted into separately and cost differently: an
19
+ * owner chain is a name, and a holding is a digest of every hook cell and
20
+ * every prop in the tree. A caller reading a subject twice to ask whether it
21
+ * is stable wants all three; a caller archiving a fixture for a pixel diff
22
+ * wants none, and neither should be inferred from the other.
23
+ *
24
+ * Absent by default. This package does not import React, and a capture taken
25
+ * without them carries no component names — not empty ones.
26
+ */
27
+ readonly provenanceOf?: (element: Element) => Provenance | undefined;
28
+ readonly wiringOf?: (element: Element) => Wiring | undefined;
29
+ readonly holdingOf?: (element: Element) => Holding | undefined;
30
+ }
31
+ /** Capture a mounted DOM tree without importing or launching a browser. */
32
+ export declare function capture(root: Element, options: UnitCaptureOptions): Promise<CaptureArtifact>;
33
+ //# sourceMappingURL=capture.d.ts.map
@@ -0,0 +1,84 @@
1
+ import { digestBytes, normalize, } from '@variance-authority/core';
2
+ import { acquireDocument, collect, conditionsFor, indexStyleSheets, referencedAssets, } from '@variance-authority/dom';
3
+ /** Capture a mounted DOM tree without importing or launching a browser. */
4
+ export async function capture(root, options) {
5
+ const subject = typeof options.subject === 'string'
6
+ ? { id: options.subject, kind: 'fixture' }
7
+ : options.subject;
8
+ const owner = root.ownerDocument;
9
+ const index = indexStyleSheets(owner, conditionsFor(owner, options.viewport, options.features));
10
+ const acquireOptions = {
11
+ subject,
12
+ viewport: options.viewport,
13
+ ...(options.fonts === undefined ? {} : { fonts: options.fonts }),
14
+ ...(options.features === undefined ? {} : { features: options.features }),
15
+ index,
16
+ };
17
+ const acquired = acquireDocument(root, acquireOptions);
18
+ const urls = resourceUrls(root, acquired);
19
+ const resources = await closeResources(urls, options.resolveResource);
20
+ const assets = Object.fromEntries(Object.entries(resources).map(([url, resource]) => [url, resource.digest]));
21
+ const raw = collect(root, {
22
+ ...acquireOptions,
23
+ engine: options.engine ?? engineOf(owner),
24
+ assets,
25
+ ...(options.provenanceOf === undefined ? {} : { provenanceOf: options.provenanceOf }),
26
+ ...(options.wiringOf === undefined ? {} : { wiringOf: options.wiringOf }),
27
+ ...(options.holdingOf === undefined ? {} : { holdingOf: options.holdingOf }),
28
+ });
29
+ const document = {
30
+ ...acquired,
31
+ assets,
32
+ baseUrl: owner.baseURI,
33
+ resources,
34
+ };
35
+ return {
36
+ artifactVersion: 1,
37
+ subject,
38
+ material: { kind: 'document', document },
39
+ snapshot: normalize(raw, options.sourceRoot === undefined ? {} : { sourceRoot: options.sourceRoot }),
40
+ };
41
+ }
42
+ function engineOf(document) {
43
+ return document.defaultView?.navigator.userAgent ?? 'dom/unknown';
44
+ }
45
+ function resourceUrls(root, document) {
46
+ const found = new Set(referencedAssets(root));
47
+ const base = root.ownerDocument.baseURI;
48
+ const text = [document.html, ...document.css].join('\n');
49
+ for (const match of text.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^\s)]*))\s*\)/g)) {
50
+ addReference(found, match[1] ?? match[2] ?? match[3] ?? '', base);
51
+ }
52
+ if (/\bblob:/i.test(text)) {
53
+ throw new Error(`capture ${document.subject.id} is not portable: blob URLs have process-local bytes`);
54
+ }
55
+ return [...found].sort();
56
+ }
57
+ function addReference(found, value, base) {
58
+ if (value === '' || /^(data|about|javascript):/i.test(value) || value.startsWith('#'))
59
+ return;
60
+ try {
61
+ found.add(new URL(value, base).toString());
62
+ }
63
+ catch {
64
+ throw new Error(`capture contains an unresolved resource reference: ${value}`);
65
+ }
66
+ }
67
+ async function closeResources(urls, resolve) {
68
+ if (urls.length > 0 && resolve === undefined) {
69
+ throw new Error(`capture is not resource-closed; supply resolveResource for: ${urls.join(', ')}`);
70
+ }
71
+ const closed = {};
72
+ for (const url of urls) {
73
+ const response = await resolve(url);
74
+ if (response === null)
75
+ throw new Error(`capture could not archive resource: ${url}`);
76
+ closed[url] = {
77
+ contentType: response.contentType,
78
+ bytes: Buffer.from(response.bytes).toString('base64'),
79
+ digest: digestBytes(response.bytes),
80
+ };
81
+ }
82
+ return closed;
83
+ }
84
+ //# sourceMappingURL=capture.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capture.js","sourceRoot":"","sources":["../src/capture.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n digestBytes,\n normalize,\n type CaptureArtifact,\n type Holding,\n type Provenance,\n type Wiring,\n type RenderDocument,\n type RenderResource,\n type SubjectRef,\n type Viewport,\n} from '@variance-authority/core';\nimport {\n acquireDocument,\n collect,\n conditionsFor,\n indexStyleSheets,\n referencedAssets,\n} from '@variance-authority/dom';\n\nexport interface ResolvedResource {\n readonly contentType: string;\n readonly bytes: Uint8Array;\n}\n\nexport interface UnitCaptureOptions {\n readonly subject: string | SubjectRef;\n readonly viewport: Viewport;\n readonly engine?: string;\n readonly fonts?: readonly string[];\n readonly features?: Readonly<Record<string, string>>;\n readonly sourceRoot?: string;\n readonly resolveResource?: (url: string) => Promise<ResolvedResource | null>;\n\n /**\n * Framework readers, passed straight through to `collect`.\n *\n * Three callbacks rather than one adapter object, mirroring `CollectOptions`\n * exactly, because they are opted into separately and cost differently: an\n * owner chain is a name, and a holding is a digest of every hook cell and\n * every prop in the tree. A caller reading a subject twice to ask whether it\n * is stable wants all three; a caller archiving a fixture for a pixel diff\n * wants none, and neither should be inferred from the other.\n *\n * Absent by default. This package does not import React, and a capture taken\n * without them carries no component names — not empty ones.\n */\n readonly provenanceOf?: (element: Element) => Provenance | undefined;\n readonly wiringOf?: (element: Element) => Wiring | undefined;\n readonly holdingOf?: (element: Element) => Holding | undefined;\n}\n\n/** Capture a mounted DOM tree without importing or launching a browser. */\nexport async function capture(\n root: Element,\n options: UnitCaptureOptions,\n): Promise<CaptureArtifact> {\n const subject: SubjectRef =\n typeof options.subject === 'string'\n ? { id: options.subject, kind: 'fixture' }\n : options.subject;\n const owner = root.ownerDocument;\n const index = indexStyleSheets(\n owner,\n conditionsFor(owner, options.viewport, options.features),\n );\n const acquireOptions = {\n subject,\n viewport: options.viewport,\n ...(options.fonts === undefined ? {} : { fonts: options.fonts }),\n ...(options.features === undefined ? {} : { features: options.features }),\n index,\n };\n const acquired = acquireDocument(root, acquireOptions);\n const urls = resourceUrls(root, acquired);\n const resources = await closeResources(urls, options.resolveResource);\n const assets = Object.fromEntries(\n Object.entries(resources).map(([url, resource]) => [url, resource.digest]),\n );\n const raw = collect(root, {\n ...acquireOptions,\n engine: options.engine ?? engineOf(owner),\n assets,\n ...(options.provenanceOf === undefined ? {} : { provenanceOf: options.provenanceOf }),\n ...(options.wiringOf === undefined ? {} : { wiringOf: options.wiringOf }),\n ...(options.holdingOf === undefined ? {} : { holdingOf: options.holdingOf }),\n });\n\n const document: RenderDocument = {\n ...acquired,\n assets,\n baseUrl: owner.baseURI,\n resources,\n };\n\n return {\n artifactVersion: 1,\n subject,\n material: { kind: 'document', document },\n snapshot: normalize(\n raw,\n options.sourceRoot === undefined ? {} : { sourceRoot: options.sourceRoot },\n ),\n };\n}\n\nfunction engineOf(document: Document): string {\n return document.defaultView?.navigator.userAgent ?? 'dom/unknown';\n}\n\nfunction resourceUrls(root: Element, document: RenderDocument): readonly string[] {\n const found = new Set(referencedAssets(root));\n const base = root.ownerDocument.baseURI;\n const text = [document.html, ...document.css].join('\\n');\n\n for (const match of text.matchAll(/url\\(\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s)]*))\\s*\\)/g)) {\n addReference(found, match[1] ?? match[2] ?? match[3] ?? '', base);\n }\n\n if (/\\bblob:/i.test(text)) {\n throw new Error(\n `capture ${document.subject.id} is not portable: blob URLs have process-local bytes`,\n );\n }\n\n return [...found].sort();\n}\n\nfunction addReference(found: Set<string>, value: string, base: string): void {\n if (value === '' || /^(data|about|javascript):/i.test(value) || value.startsWith('#')) return;\n try {\n found.add(new URL(value, base).toString());\n } catch {\n throw new Error(`capture contains an unresolved resource reference: ${value}`);\n }\n}\n\nasync function closeResources(\n urls: readonly string[],\n resolve: UnitCaptureOptions['resolveResource'],\n): Promise<Readonly<Record<string, RenderResource>>> {\n if (urls.length > 0 && resolve === undefined) {\n throw new Error(\n `capture is not resource-closed; supply resolveResource for: ${urls.join(', ')}`,\n );\n }\n\n const closed: Record<string, RenderResource> = {};\n for (const url of urls) {\n const response = await resolve!(url);\n if (response === null) throw new Error(`capture could not archive resource: ${url}`);\n closed[url] = {\n contentType: response.contentType,\n bytes: Buffer.from(response.bytes).toString('base64'),\n digest: digestBytes(response.bytes),\n };\n }\n return closed;\n}\n"]}
@@ -0,0 +1,7 @@
1
+ import type { SubjectSource } from './contract.js';
2
+ export interface CaptureCollectorOptions {
3
+ readonly directory: string;
4
+ }
5
+ /** Read browserless unit-test captures in a later `variance run` process. */
6
+ export declare function captureCollector(options: CaptureCollectorOptions): SubjectSource;
7
+ //# sourceMappingURL=collector.d.ts.map