@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 +73 -0
- package/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/archive.d.ts +40 -0
- package/dist/archive.js +96 -0
- package/dist/archive.js.map +1 -0
- package/dist/capture.d.ts +33 -0
- package/dist/capture.js +84 -0
- package/dist/capture.js.map +1 -0
- package/dist/collector.d.ts +7 -0
- package/dist/collector.js +65 -0
- package/dist/collector.js.map +1 -0
- package/dist/contract.d.ts +36 -0
- package/dist/contract.js +2 -0
- package/dist/contract.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/operator.d.ts +17 -0
- package/dist/operator.js +19 -0
- package/dist/operator.js.map +1 -0
- package/dist/shape.d.ts +18 -0
- package/dist/shape.js +365 -0
- package/dist/shape.js.map +1 -0
- package/dist/value.d.ts +44 -0
- package/dist/value.js +37 -0
- package/dist/value.js.map +1 -0
- package/mark.svg +30 -0
- package/package.json +55 -0
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
|
package/dist/archive.js
ADDED
|
@@ -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":"AAAA,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AACjF,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAEjD,MAAM,CAAC,MAAM,cAAc,GAAG,kBAAkB,CAAC;AAEjD;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,SAAiB,EACjB,QAAyB;IAEzB,MAAM,KAAK,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC5C,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,EAAE,GAAG,kBAAkB,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,GAAG,cAAc,EAAE,CAAC,CAAC;IAC5F,MAAM,SAAS,GAAG,GAAG,IAAI,IAAI,OAAO,CAAC,GAAG,IAAI,MAAM,CAAC,UAAU,EAAE,MAAM,CAAC;IACtE,MAAM,SAAS,CAAC,SAAS,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC7E,IAAI,CAAC;QACH,MAAM,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;IAC9B,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAK,KAA+B,CAAC,IAAI,KAAK,QAAQ;YAAE,MAAM,KAAK,CAAC;QACpE,MAAM,IAAI,KAAK,CACb,GAAG,QAAQ,CAAC,OAAO,CAAC,EAAE,6BAA6B,IAAI,6BAA6B;YAClF,sFAAsF;YACtF,iFAAiF;YACjF,2DAA2D,EAC7D,EAAE,KAAK,EAAE,KAAK,EAAE,CACjB,CAAC;IACJ,CAAC;YAAS,CAAC;QACT,MAAM,EAAE,CAAC,SAAS,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IACvC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,SAAiB;IACnD,IAAI,OAA0B,CAAC;IAC/B,IAAI,CAAC;QACH,OAAO,GAAG,MAAM,OAAO,CAAC,SAAS,CAAC,CAAC;IACrC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAK,KAA+B,CAAC,IAAI,KAAK,QAAQ;YAAE,OAAO;QAC/D,MAAM,KAAK,CAAC;IACd,CAAC;IACD,MAAM,OAAO,CAAC,GAAG,CACf,OAAO;SACJ,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC;SACjD,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,EAAE,CAAC,IAAI,CAAC,SAAS,EAAE,KAAK,CAAC,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAC/D,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,IAAY;IAC5C,IAAI,KAAc,CAAC;IACnB,IAAI,CAAC;QACH,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,KAAK,CAAC,gCAAgC,IAAI,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;IAC5E,CAAC;IAED,OAAO,mBAAmB,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;AAC1C,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,SAAiB;IAClD,IAAI,OAA0B,CAAC;IAC/B,IAAI,CAAC;QACH,OAAO,GAAG,MAAM,OAAO,CAAC,SAAS,CAAC,CAAC;IACrC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,KAAK,CAAC,iCAAiC,SAAS,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;IAClF,CAAC;IACD,OAAO,OAAO;SACX,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC;SACjD,IAAI,EAAE;SACN,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC,CAAC;AAC5C,CAAC","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
|
package/dist/capture.js
ADDED
|
@@ -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":"AAAA,OAAO,EACL,WAAW,EACX,SAAS,GASV,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,eAAe,EACf,OAAO,EACP,aAAa,EACb,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,yBAAyB,CAAC;AAkCjC,2EAA2E;AAC3E,MAAM,CAAC,KAAK,UAAU,OAAO,CAC3B,IAAa,EACb,OAA2B;IAE3B,MAAM,OAAO,GACX,OAAO,OAAO,CAAC,OAAO,KAAK,QAAQ;QACjC,CAAC,CAAC,EAAE,EAAE,EAAE,OAAO,CAAC,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE;QAC1C,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC;IACtB,MAAM,KAAK,GAAG,IAAI,CAAC,aAAa,CAAC;IACjC,MAAM,KAAK,GAAG,gBAAgB,CAC5B,KAAK,EACL,aAAa,CAAC,KAAK,EAAE,OAAO,CAAC,QAAQ,EAAE,OAAO,CAAC,QAAQ,CAAC,CACzD,CAAC;IACF,MAAM,cAAc,GAAG;QACrB,OAAO;QACP,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,GAAG,CAAC,OAAO,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,CAAC;QAChE,GAAG,CAAC,OAAO,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAAE,CAAC;QACzE,KAAK;KACN,CAAC;IACF,MAAM,QAAQ,GAAG,eAAe,CAAC,IAAI,EAAE,cAAc,CAAC,CAAC;IACvD,MAAM,IAAI,GAAG,YAAY,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IAC1C,MAAM,SAAS,GAAG,MAAM,cAAc,CAAC,IAAI,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC;IACtE,MAAM,MAAM,GAAG,MAAM,CAAC,WAAW,CAC/B,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,QAAQ,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC,CAC3E,CAAC;IACF,MAAM,GAAG,GAAG,OAAO,CAAC,IAAI,EAAE;QACxB,GAAG,cAAc;QACjB,MAAM,EAAE,OAAO,CAAC,MAAM,IAAI,QAAQ,CAAC,KAAK,CAAC;QACzC,MAAM;QACN,GAAG,CAAC,OAAO,CAAC,YAAY,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,OAAO,CAAC,YAAY,EAAE,CAAC;QACrF,GAAG,CAAC,OAAO,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAAE,CAAC;QACzE,GAAG,CAAC,OAAO,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,CAAC;KAC7E,CAAC,CAAC;IAEH,MAAM,QAAQ,GAAmB;QAC/B,GAAG,QAAQ;QACX,MAAM;QACN,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,SAAS;KACV,CAAC;IAEF,OAAO;QACL,eAAe,EAAE,CAAC;QAClB,OAAO;QACP,QAAQ,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE;QACxC,QAAQ,EAAE,SAAS,CACjB,GAAG,EACH,OAAO,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,OAAO,CAAC,UAAU,EAAE,CAC3E;KACF,CAAC;AACJ,CAAC;AAED,SAAS,QAAQ,CAAC,QAAkB;IAClC,OAAO,QAAQ,CAAC,WAAW,EAAE,SAAS,CAAC,SAAS,IAAI,aAAa,CAAC;AACpE,CAAC;AAED,SAAS,YAAY,CAAC,IAAa,EAAE,QAAwB;IAC3D,MAAM,KAAK,GAAG,IAAI,GAAG,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC;IAC9C,MAAM,IAAI,GAAG,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC;IACxC,MAAM,IAAI,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAEzD,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,QAAQ,CAAC,iDAAiD,CAAC,EAAE,CAAC;QACrF,YAAY,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,IAAI,CAAC,CAAC;IACpE,CAAC;IAED,IAAI,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CACb,WAAW,QAAQ,CAAC,OAAO,CAAC,EAAE,sDAAsD,CACrF,CAAC;IACJ,CAAC;IAED,OAAO,CAAC,GAAG,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC;AAC3B,CAAC;AAED,SAAS,YAAY,CAAC,KAAkB,EAAE,KAAa,EAAE,IAAY;IACnE,IAAI,KAAK,KAAK,EAAE,IAAI,4BAA4B,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO;IAC9F,IAAI,CAAC;QACH,KAAK,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC;IAC7C,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,KAAK,CAAC,sDAAsD,KAAK,EAAE,CAAC,CAAC;IACjF,CAAC;AACH,CAAC;AAED,KAAK,UAAU,cAAc,CAC3B,IAAuB,EACvB,OAA8C;IAE9C,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC7C,MAAM,IAAI,KAAK,CACb,+DAA+D,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CACjF,CAAC;IACJ,CAAC;IAED,MAAM,MAAM,GAAmC,EAAE,CAAC;IAClD,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACvB,MAAM,QAAQ,GAAG,MAAM,OAAQ,CAAC,GAAG,CAAC,CAAC;QACrC,IAAI,QAAQ,KAAK,IAAI;YAAE,MAAM,IAAI,KAAK,CAAC,uCAAuC,GAAG,EAAE,CAAC,CAAC;QACrF,MAAM,CAAC,GAAG,CAAC,GAAG;YACZ,WAAW,EAAE,QAAQ,CAAC,WAAW;YACjC,KAAK,EAAE,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC;YACrD,MAAM,EAAE,WAAW,CAAC,QAAQ,CAAC,KAAK,CAAC;SACpC,CAAC;IACJ,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC","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
|