@describe-me/vitest 0.5.0-next.0 → 0.5.0-next.1
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 +6 -0
- package/dist/asset-store.d.ts +57 -10
- package/dist/asset-store.js +215 -15
- package/dist/css-asset-references.d.ts +5 -0
- package/dist/css-asset-references.js +17 -0
- package/dist/plugin.d.ts +6 -0
- package/dist/plugin.js +1 -0
- package/dist/reporter.d.ts +7 -0
- package/dist/reporter.js +12 -1
- package/dist/rewrite-css-references.d.ts +5 -0
- package/dist/rewrite-css-references.js +18 -0
- package/dist/rewrite-head-references.d.ts +8 -0
- package/dist/rewrite-head-references.js +49 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -55,6 +55,12 @@ Tests in other files still run, but record nothing. The reporter takes the
|
|
|
55
55
|
same two options, but on its own it only filters the manifest: without the
|
|
56
56
|
plugin the setup file does not know them.
|
|
57
57
|
|
|
58
|
+
`describeMe({ previewHead })` takes HTML that the viewer adds to the start of
|
|
59
|
+
every frame's `<head>`, like Storybook's `preview-head.html`: typically the
|
|
60
|
+
font links that the app shell loads and tests never do. It never reaches the
|
|
61
|
+
test page, and the local files it links are copied into the output directory
|
|
62
|
+
([Fonts](https://github.com/grzehub/describe-me#fonts) in the root README).
|
|
63
|
+
|
|
58
64
|
The pieces can also be wired by hand. Tests then import from the adapter
|
|
59
65
|
themselves: `render` from `@describe-me/react` in browser mode, and `render`,
|
|
60
66
|
`fireEvent` and `cleanup` from `@describe-me/react/testing-library` in jsdom:
|
package/dist/asset-store.d.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Content-addressed store for the project files snapshots
|
|
3
|
-
* fonts, `url()` targets of stylesheets), living in
|
|
2
|
+
* Content-addressed store for the project files that snapshots and the preview
|
|
3
|
+
* head point at (images, fonts, `url()` targets of stylesheets), living in
|
|
4
|
+
* `<outDir>/assets`.
|
|
4
5
|
*
|
|
5
6
|
* rrweb makes most URLs absolute against the test page, whose server is gone
|
|
6
|
-
* once the tests finish
|
|
7
|
+
* once the tests finish. A few stay root-relative, which the viewer would
|
|
7
8
|
* resolve against itself. `rewrite()` copies each file it finds in the project
|
|
8
9
|
* and swaps its URL for `ASSET_URL_PREFIX` plus the stored name, which the
|
|
9
|
-
* viewer resolves against its own data directory.
|
|
10
|
+
* viewer resolves against its own data directory. `rewriteHead()` does the
|
|
11
|
+
* same for the preview head. A CSS file is stored with its own `url()` and
|
|
12
|
+
* `@import` targets copied too and written as bare sibling names, so it keeps
|
|
13
|
+
* working from `assets/`.
|
|
10
14
|
*/
|
|
11
15
|
export declare class AssetStore {
|
|
12
16
|
private readonly outDir;
|
|
@@ -15,11 +19,17 @@ export declare class AssetStore {
|
|
|
15
19
|
/** Stored name per URL pathname in this run, or null when it is not a project file. */
|
|
16
20
|
private readonly names;
|
|
17
21
|
private readonly notFound;
|
|
22
|
+
/** Stored name per absolute CSS file path in this run. */
|
|
23
|
+
private readonly cssNames;
|
|
24
|
+
/** CSS files being copied right now, so an `@import` cycle ends. */
|
|
25
|
+
private readonly copying;
|
|
18
26
|
/**
|
|
19
27
|
* Asset names per snapshot or style chunk. Both are content-addressed, so an
|
|
20
28
|
* entry never goes stale.
|
|
21
29
|
*/
|
|
22
30
|
private readonly referencesByFile;
|
|
31
|
+
/** Asset names per stored CSS asset, which is content-addressed too. */
|
|
32
|
+
private readonly referencesByCssAsset;
|
|
23
33
|
constructor(outDir: string, root: string);
|
|
24
34
|
/** Forget what the previous run found: files may have changed or appeared since. */
|
|
25
35
|
startRun(): void;
|
|
@@ -31,21 +41,58 @@ export declare class AssetStore {
|
|
|
31
41
|
* handled on their own. A `#fragment` is kept, for SVG sprites.
|
|
32
42
|
*/
|
|
33
43
|
rewrite(json: string, origin: string | undefined): string;
|
|
34
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Replace every URL in the preview head that points at a project file with
|
|
46
|
+
* an asset URL. A relative URL counts from the project root, the page the
|
|
47
|
+
* head is written for.
|
|
48
|
+
*/
|
|
49
|
+
rewriteHead(html: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* Paths that snapshots, the preview head or copied CSS files in this run
|
|
52
|
+
* pointed at but that do not exist: root-relative inside the project root,
|
|
53
|
+
* `/@fs/<absolute path>` outside it.
|
|
54
|
+
*/
|
|
35
55
|
missing(): string[];
|
|
36
56
|
/**
|
|
37
|
-
* Drop the assets no kept snapshot or style chunk
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
57
|
+
* Drop the assets that no kept snapshot or style chunk, no inline text such
|
|
58
|
+
* as the preview head, and no kept CSS asset refers to. Paths are relative
|
|
59
|
+
* to the output directory, as the manifest stores them. Every kept file is
|
|
60
|
+
* scanned, not only the ones written now: snapshots of modules that were not
|
|
61
|
+
* re-run were rewritten by an earlier run.
|
|
41
62
|
*/
|
|
42
|
-
collectGarbage(files: Iterable<string>): void;
|
|
63
|
+
collectGarbage(files: Iterable<string>, inlineTexts?: Iterable<string>): void;
|
|
64
|
+
/** Add what each kept CSS asset refers to, and what those refer to in turn. */
|
|
65
|
+
private keepCssTargets;
|
|
66
|
+
private cssAssetReferencesOf;
|
|
43
67
|
private referencesIn;
|
|
44
68
|
/** The asset URL for a root-relative URL path, or null when it is not a project file. */
|
|
45
69
|
private assetUrl;
|
|
46
70
|
/** The stored name for a URL pathname, copying the file on first sight in this run. */
|
|
47
71
|
private nameFor;
|
|
48
72
|
private copyIntoStore;
|
|
73
|
+
/** Store a project file under its content hash, and return its stored name. */
|
|
74
|
+
private store;
|
|
75
|
+
/**
|
|
76
|
+
* Store a CSS file with its `url()` and `@import` targets stored too. They
|
|
77
|
+
* are written as bare sibling names, which the browser resolves against the
|
|
78
|
+
* stylesheet's own URL, so the viewer has nothing to substitute. The name is
|
|
79
|
+
* the hash of the rewritten text.
|
|
80
|
+
*/
|
|
81
|
+
private storeCss;
|
|
82
|
+
/**
|
|
83
|
+
* The stored name, with its fragment, for a target of the CSS file
|
|
84
|
+
* `cssFile`, or null to leave the target as written: a remote URL, a
|
|
85
|
+
* file type outside the allowlist, a missing file or a file being copied.
|
|
86
|
+
*/
|
|
87
|
+
private siblingName;
|
|
88
|
+
/**
|
|
89
|
+
* The file behind a target path of a CSS file, reported when missing. A
|
|
90
|
+
* root-relative path is looked up like a snapshot URL, a relative one next
|
|
91
|
+
* to the CSS file on disk.
|
|
92
|
+
*/
|
|
93
|
+
private cssTargetFile;
|
|
94
|
+
/** How Vite serves a file: root-relative inside the project root, `/@fs/<absolute path>` outside it. */
|
|
95
|
+
private servedPath;
|
|
49
96
|
/** Where Vite serves a URL pathname from: `/@fs/`, the project root, or its `public/` folder. */
|
|
50
97
|
private fileFor;
|
|
51
98
|
}
|
package/dist/asset-store.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, } from 'node:fs';
|
|
3
|
-
import { extname, join, resolve, sep } from 'node:path';
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { dirname, extname, join, resolve, sep } from 'node:path';
|
|
4
4
|
import { ASSET_URL_PREFIX } from '@describe-me/core/types';
|
|
5
|
+
import { cssAssetReferences } from './css-asset-references.js';
|
|
6
|
+
import { rewriteCssReferences } from './rewrite-css-references.js';
|
|
7
|
+
import { rewriteHeadReferences } from './rewrite-head-references.js';
|
|
5
8
|
/** A URL path inside serialized JSON runs until a quote, whitespace, `)`, `\`, `<` or `>`. */
|
|
6
9
|
const URL_PATH = String.raw `/[^"'\s)\\<>]*`;
|
|
7
10
|
/** A root-relative path: one slash, not the two of a protocol-relative URL. */
|
|
@@ -43,12 +46,66 @@ const MODULE_EXTENSIONS = new Set([
|
|
|
43
46
|
'.vue',
|
|
44
47
|
'.svelte',
|
|
45
48
|
]);
|
|
49
|
+
/** A URL with a scheme (`https:`, `data:`, `mailto:`, `describe-me-asset:`) is never a project path. */
|
|
50
|
+
const URL_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
51
|
+
/** The preview head's URLs resolve as if the page were served from the project root. */
|
|
52
|
+
const PROJECT_ROOT_URL = 'http://project-root.invalid/';
|
|
53
|
+
/**
|
|
54
|
+
* What a copied CSS file may pull into the store: stylesheets, fonts, images
|
|
55
|
+
* and cursors. Any other target stays as written, so a stylesheet cannot
|
|
56
|
+
* publish source files or secrets.
|
|
57
|
+
*/
|
|
58
|
+
const CSS_TARGET_EXTENSIONS = new Set([
|
|
59
|
+
'.css',
|
|
60
|
+
'.woff2',
|
|
61
|
+
'.woff',
|
|
62
|
+
'.ttf',
|
|
63
|
+
'.otf',
|
|
64
|
+
'.eot',
|
|
65
|
+
'.svg',
|
|
66
|
+
'.png',
|
|
67
|
+
'.jpg',
|
|
68
|
+
'.jpeg',
|
|
69
|
+
'.gif',
|
|
70
|
+
'.webp',
|
|
71
|
+
'.avif',
|
|
72
|
+
'.ico',
|
|
73
|
+
'.bmp',
|
|
74
|
+
'.cur',
|
|
75
|
+
]);
|
|
46
76
|
function escapeRegExp(text) {
|
|
47
77
|
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
48
78
|
}
|
|
49
79
|
function isFile(path) {
|
|
50
80
|
return existsSync(path) && statSync(path).isFile();
|
|
51
81
|
}
|
|
82
|
+
function contentHash(content) {
|
|
83
|
+
return createHash('sha1').update(content).digest('hex').slice(0, 16);
|
|
84
|
+
}
|
|
85
|
+
function assetNamesIn(text) {
|
|
86
|
+
return Array.from(text.matchAll(ASSET_REFERENCE), (match) => match[1]);
|
|
87
|
+
}
|
|
88
|
+
/** Empty values, `#fragment`s, protocol-relative and scheme URLs never name a project file. */
|
|
89
|
+
function isExternalUrl(url) {
|
|
90
|
+
return url === '' || url.startsWith('#') || url.startsWith('//') || URL_SCHEME.test(url);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A preview head URL as a root-relative path, or null when it cannot be read
|
|
94
|
+
* or leaves the project root, as `\\host\x` does.
|
|
95
|
+
*/
|
|
96
|
+
function rootRelativePath(url) {
|
|
97
|
+
let resolved;
|
|
98
|
+
try {
|
|
99
|
+
resolved = new URL(url, PROJECT_ROOT_URL);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (!resolved.href.startsWith(PROJECT_ROOT_URL)) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return resolved.pathname + resolved.search + resolved.hash;
|
|
108
|
+
}
|
|
52
109
|
function decodePath(pathname) {
|
|
53
110
|
try {
|
|
54
111
|
return decodeURIComponent(pathname);
|
|
@@ -78,14 +135,18 @@ function fsPathFromUrl(pathname) {
|
|
|
78
135
|
return /^[A-Za-z]:/.test(path) ? path : `/${path}`;
|
|
79
136
|
}
|
|
80
137
|
/**
|
|
81
|
-
* Content-addressed store for the project files snapshots
|
|
82
|
-
* fonts, `url()` targets of stylesheets), living in
|
|
138
|
+
* Content-addressed store for the project files that snapshots and the preview
|
|
139
|
+
* head point at (images, fonts, `url()` targets of stylesheets), living in
|
|
140
|
+
* `<outDir>/assets`.
|
|
83
141
|
*
|
|
84
142
|
* rrweb makes most URLs absolute against the test page, whose server is gone
|
|
85
|
-
* once the tests finish
|
|
143
|
+
* once the tests finish. A few stay root-relative, which the viewer would
|
|
86
144
|
* resolve against itself. `rewrite()` copies each file it finds in the project
|
|
87
145
|
* and swaps its URL for `ASSET_URL_PREFIX` plus the stored name, which the
|
|
88
|
-
* viewer resolves against its own data directory.
|
|
146
|
+
* viewer resolves against its own data directory. `rewriteHead()` does the
|
|
147
|
+
* same for the preview head. A CSS file is stored with its own `url()` and
|
|
148
|
+
* `@import` targets copied too and written as bare sibling names, so it keeps
|
|
149
|
+
* working from `assets/`.
|
|
89
150
|
*/
|
|
90
151
|
export class AssetStore {
|
|
91
152
|
outDir;
|
|
@@ -94,11 +155,17 @@ export class AssetStore {
|
|
|
94
155
|
/** Stored name per URL pathname in this run, or null when it is not a project file. */
|
|
95
156
|
names = new Map();
|
|
96
157
|
notFound = new Set();
|
|
158
|
+
/** Stored name per absolute CSS file path in this run. */
|
|
159
|
+
cssNames = new Map();
|
|
160
|
+
/** CSS files being copied right now, so an `@import` cycle ends. */
|
|
161
|
+
copying = new Set();
|
|
97
162
|
/**
|
|
98
163
|
* Asset names per snapshot or style chunk. Both are content-addressed, so an
|
|
99
164
|
* entry never goes stale.
|
|
100
165
|
*/
|
|
101
166
|
referencesByFile = new Map();
|
|
167
|
+
/** Asset names per stored CSS asset, which is content-addressed too. */
|
|
168
|
+
referencesByCssAsset = new Map();
|
|
102
169
|
constructor(outDir, root) {
|
|
103
170
|
this.outDir = outDir;
|
|
104
171
|
this.dir = join(outDir, 'assets');
|
|
@@ -109,6 +176,7 @@ export class AssetStore {
|
|
|
109
176
|
startRun() {
|
|
110
177
|
this.names.clear();
|
|
111
178
|
this.notFound.clear();
|
|
179
|
+
this.cssNames.clear();
|
|
112
180
|
}
|
|
113
181
|
/**
|
|
114
182
|
* Replace every URL in a serialized snapshot that points at a project file
|
|
@@ -135,17 +203,36 @@ export class AssetStore {
|
|
|
135
203
|
return asset === null ? poster : `"poster":"${asset}`;
|
|
136
204
|
});
|
|
137
205
|
}
|
|
138
|
-
/**
|
|
206
|
+
/**
|
|
207
|
+
* Replace every URL in the preview head that points at a project file with
|
|
208
|
+
* an asset URL. A relative URL counts from the project root, the page the
|
|
209
|
+
* head is written for.
|
|
210
|
+
*/
|
|
211
|
+
rewriteHead(html) {
|
|
212
|
+
return rewriteHeadReferences(html, (url) => {
|
|
213
|
+
if (isExternalUrl(url)) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
const path = rootRelativePath(url);
|
|
217
|
+
return path === null ? null : this.assetUrl(path);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Paths that snapshots, the preview head or copied CSS files in this run
|
|
222
|
+
* pointed at but that do not exist: root-relative inside the project root,
|
|
223
|
+
* `/@fs/<absolute path>` outside it.
|
|
224
|
+
*/
|
|
139
225
|
missing() {
|
|
140
226
|
return Array.from(this.notFound).sort();
|
|
141
227
|
}
|
|
142
228
|
/**
|
|
143
|
-
* Drop the assets no kept snapshot or style chunk
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
229
|
+
* Drop the assets that no kept snapshot or style chunk, no inline text such
|
|
230
|
+
* as the preview head, and no kept CSS asset refers to. Paths are relative
|
|
231
|
+
* to the output directory, as the manifest stores them. Every kept file is
|
|
232
|
+
* scanned, not only the ones written now: snapshots of modules that were not
|
|
233
|
+
* re-run were rewritten by an earlier run.
|
|
147
234
|
*/
|
|
148
|
-
collectGarbage(files) {
|
|
235
|
+
collectGarbage(files, inlineTexts = []) {
|
|
149
236
|
const stored = readdirSync(this.dir);
|
|
150
237
|
if (stored.length === 0) {
|
|
151
238
|
return;
|
|
@@ -156,12 +243,45 @@ export class AssetStore {
|
|
|
156
243
|
keep.add(name);
|
|
157
244
|
}
|
|
158
245
|
}
|
|
246
|
+
for (const text of inlineTexts) {
|
|
247
|
+
for (const name of assetNamesIn(text)) {
|
|
248
|
+
keep.add(name);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
this.keepCssTargets(keep);
|
|
159
252
|
for (const file of stored) {
|
|
160
253
|
if (!keep.has(file)) {
|
|
161
254
|
rmSync(join(this.dir, file), { force: true });
|
|
162
255
|
}
|
|
163
256
|
}
|
|
164
257
|
}
|
|
258
|
+
/** Add what each kept CSS asset refers to, and what those refer to in turn. */
|
|
259
|
+
keepCssTargets(keep) {
|
|
260
|
+
const pending = Array.from(keep).filter((name) => name.endsWith('.css'));
|
|
261
|
+
let name = pending.pop();
|
|
262
|
+
while (name !== undefined) {
|
|
263
|
+
for (const target of this.cssAssetReferencesOf(name)) {
|
|
264
|
+
if (!keep.has(target)) {
|
|
265
|
+
keep.add(target);
|
|
266
|
+
pending.push(target);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
name = pending.pop();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
cssAssetReferencesOf(name) {
|
|
273
|
+
const cached = this.referencesByCssAsset.get(name);
|
|
274
|
+
if (cached) {
|
|
275
|
+
return cached;
|
|
276
|
+
}
|
|
277
|
+
const path = join(this.dir, name);
|
|
278
|
+
if (!name.endsWith('.css') || !isFile(path)) {
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
const names = cssAssetReferences(readFileSync(path, 'utf8'));
|
|
282
|
+
this.referencesByCssAsset.set(name, names);
|
|
283
|
+
return names;
|
|
284
|
+
}
|
|
165
285
|
referencesIn(file) {
|
|
166
286
|
const cached = this.referencesByFile.get(file);
|
|
167
287
|
if (cached) {
|
|
@@ -171,8 +291,7 @@ export class AssetStore {
|
|
|
171
291
|
if (!isFile(path)) {
|
|
172
292
|
return [];
|
|
173
293
|
}
|
|
174
|
-
const
|
|
175
|
-
const names = Array.from(text.matchAll(ASSET_REFERENCE), (match) => match[1]);
|
|
294
|
+
const names = assetNamesIn(readFileSync(path, 'utf8'));
|
|
176
295
|
this.referencesByFile.set(file, names);
|
|
177
296
|
return names;
|
|
178
297
|
}
|
|
@@ -208,8 +327,15 @@ export class AssetStore {
|
|
|
208
327
|
}
|
|
209
328
|
return null;
|
|
210
329
|
}
|
|
211
|
-
|
|
330
|
+
return this.store(file);
|
|
331
|
+
}
|
|
332
|
+
/** Store a project file under its content hash, and return its stored name. */
|
|
333
|
+
store(file) {
|
|
212
334
|
const extension = extname(file).toLowerCase();
|
|
335
|
+
if (extension === '.css') {
|
|
336
|
+
return this.storeCss(file);
|
|
337
|
+
}
|
|
338
|
+
const hash = contentHash(readFileSync(file));
|
|
213
339
|
const name = /^\.[a-z0-9]+$/.test(extension) ? `${hash}${extension}` : hash;
|
|
214
340
|
const target = join(this.dir, name);
|
|
215
341
|
if (!existsSync(target)) {
|
|
@@ -217,6 +343,80 @@ export class AssetStore {
|
|
|
217
343
|
}
|
|
218
344
|
return name;
|
|
219
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* Store a CSS file with its `url()` and `@import` targets stored too. They
|
|
348
|
+
* are written as bare sibling names, which the browser resolves against the
|
|
349
|
+
* stylesheet's own URL, so the viewer has nothing to substitute. The name is
|
|
350
|
+
* the hash of the rewritten text.
|
|
351
|
+
*/
|
|
352
|
+
storeCss(file) {
|
|
353
|
+
const known = this.cssNames.get(file);
|
|
354
|
+
if (known !== undefined) {
|
|
355
|
+
return known;
|
|
356
|
+
}
|
|
357
|
+
let css;
|
|
358
|
+
this.copying.add(file);
|
|
359
|
+
try {
|
|
360
|
+
css = rewriteCssReferences(readFileSync(file, 'utf8'), (url) => this.siblingName(url, file));
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
this.copying.delete(file);
|
|
364
|
+
}
|
|
365
|
+
const name = `${contentHash(css)}.css`;
|
|
366
|
+
const target = join(this.dir, name);
|
|
367
|
+
if (!existsSync(target)) {
|
|
368
|
+
writeFileSync(target, css);
|
|
369
|
+
}
|
|
370
|
+
this.cssNames.set(file, name);
|
|
371
|
+
return name;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* The stored name, with its fragment, for a target of the CSS file
|
|
375
|
+
* `cssFile`, or null to leave the target as written: a remote URL, a
|
|
376
|
+
* file type outside the allowlist, a missing file or a file being copied.
|
|
377
|
+
*/
|
|
378
|
+
siblingName(url, cssFile) {
|
|
379
|
+
if (isExternalUrl(url)) {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
const { pathname, fragment } = splitUrlPath(url);
|
|
383
|
+
const path = decodePath(pathname);
|
|
384
|
+
if (!CSS_TARGET_EXTENSIONS.has(extname(path).toLowerCase())) {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
const file = this.cssTargetFile(path, cssFile);
|
|
388
|
+
if (!file || this.copying.has(file)) {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
return `${this.store(file)}${fragment}`;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* The file behind a target path of a CSS file, reported when missing. A
|
|
395
|
+
* root-relative path is looked up like a snapshot URL, a relative one next
|
|
396
|
+
* to the CSS file on disk.
|
|
397
|
+
*/
|
|
398
|
+
cssTargetFile(path, cssFile) {
|
|
399
|
+
if (path.startsWith('/')) {
|
|
400
|
+
const file = this.fileFor(path);
|
|
401
|
+
if (!file) {
|
|
402
|
+
this.notFound.add(path);
|
|
403
|
+
}
|
|
404
|
+
return file;
|
|
405
|
+
}
|
|
406
|
+
const file = resolve(dirname(cssFile), path);
|
|
407
|
+
if (!isFile(file)) {
|
|
408
|
+
this.notFound.add(this.servedPath(file));
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
return file;
|
|
412
|
+
}
|
|
413
|
+
/** How Vite serves a file: root-relative inside the project root, `/@fs/<absolute path>` outside it. */
|
|
414
|
+
servedPath(file) {
|
|
415
|
+
const path = file.startsWith(this.root + sep)
|
|
416
|
+
? file.slice(this.root.length)
|
|
417
|
+
: `/@fs/${file.replace(/^[\\/]/, '')}`;
|
|
418
|
+
return path.split(sep).join('/');
|
|
419
|
+
}
|
|
220
420
|
/** Where Vite serves a URL pathname from: `/@fs/`, the project root, or its `public/` folder. */
|
|
221
421
|
fileFor(pathname) {
|
|
222
422
|
if (pathname.startsWith('/@fs/')) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { cssReferences } from '@describe-me/core/css-references';
|
|
2
|
+
/** A stored asset's name: a content hash and, when the file has one, its extension. */
|
|
3
|
+
const ASSET_NAME = /^[0-9a-f]{16}(\.[a-z0-9]+)?$/;
|
|
4
|
+
/**
|
|
5
|
+
* The stored asset names a stored CSS asset refers to. The asset store writes
|
|
6
|
+
* them as bare sibling names, so garbage collection can follow them.
|
|
7
|
+
*/
|
|
8
|
+
export function cssAssetReferences(css) {
|
|
9
|
+
const names = [];
|
|
10
|
+
for (const { url } of cssReferences(css)) {
|
|
11
|
+
const name = url.split(/[?#]/)[0];
|
|
12
|
+
if (ASSET_NAME.test(name)) {
|
|
13
|
+
names.push(name);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return names;
|
|
17
|
+
}
|
package/dist/plugin.d.ts
CHANGED
|
@@ -35,6 +35,12 @@ export interface DescribeMeOptions {
|
|
|
35
35
|
include?: string | string[];
|
|
36
36
|
/** Test files never to record, as globs like `include`. Wins over `include`. Default: none. */
|
|
37
37
|
exclude?: string | string[];
|
|
38
|
+
/**
|
|
39
|
+
* HTML the viewer adds to the start of the `<head>` of every replayed frame,
|
|
40
|
+
* like Storybook's `preview-head.html`. It never reaches the test page.
|
|
41
|
+
* Project files it links are copied into the output directory. Default: none.
|
|
42
|
+
*/
|
|
43
|
+
previewHead?: string;
|
|
38
44
|
}
|
|
39
45
|
type VitePlugin = NonNullable<ViteUserConfig['plugins']>[number];
|
|
40
46
|
/**
|
package/dist/plugin.js
CHANGED
|
@@ -112,6 +112,7 @@ export function describeMe(options = {}) {
|
|
|
112
112
|
outDir,
|
|
113
113
|
include: options.include,
|
|
114
114
|
exclude: options.exclude,
|
|
115
|
+
previewHead: options.previewHead,
|
|
115
116
|
});
|
|
116
117
|
// Vite concatenates arrays when merging, so only add `default` when the
|
|
117
118
|
// user has not chosen their own reporters; otherwise they would lose it.
|
package/dist/reporter.d.ts
CHANGED
|
@@ -12,10 +12,17 @@ export interface DescribeMeReporterOptions {
|
|
|
12
12
|
* `include`. Default: none.
|
|
13
13
|
*/
|
|
14
14
|
exclude?: string | string[];
|
|
15
|
+
/**
|
|
16
|
+
* HTML the viewer adds to the start of the `<head>` of every replayed frame,
|
|
17
|
+
* like Storybook's `preview-head.html`. It never reaches the test page.
|
|
18
|
+
* Project files it links are copied into the output directory. Default: none.
|
|
19
|
+
*/
|
|
20
|
+
previewHead?: string;
|
|
15
21
|
}
|
|
16
22
|
export default class DescribeMeReporter implements Reporter {
|
|
17
23
|
private readonly outDirOption;
|
|
18
24
|
private readonly isRecordedFile;
|
|
25
|
+
private readonly previewHead;
|
|
19
26
|
private root;
|
|
20
27
|
private outDir;
|
|
21
28
|
private snapshots;
|
package/dist/reporter.js
CHANGED
|
@@ -21,6 +21,7 @@ function recordOf(tc) {
|
|
|
21
21
|
export default class DescribeMeReporter {
|
|
22
22
|
outDirOption;
|
|
23
23
|
isRecordedFile;
|
|
24
|
+
previewHead;
|
|
24
25
|
root = process.cwd();
|
|
25
26
|
outDir = '';
|
|
26
27
|
snapshots;
|
|
@@ -33,6 +34,8 @@ export default class DescribeMeReporter {
|
|
|
33
34
|
include: compileGlobs(options.include),
|
|
34
35
|
exclude: compileGlobs(options.exclude),
|
|
35
36
|
});
|
|
37
|
+
// A blank head, such as an empty preview-head.html, is the same as none.
|
|
38
|
+
this.previewHead = options.previewHead?.trim() ? options.previewHead : undefined;
|
|
36
39
|
}
|
|
37
40
|
onInit(vitest) {
|
|
38
41
|
this.root = vitest.config.root;
|
|
@@ -41,6 +44,9 @@ export default class DescribeMeReporter {
|
|
|
41
44
|
this.assets = new AssetStore(this.outDir, this.root);
|
|
42
45
|
this.styles = new StyleStore(this.outDir);
|
|
43
46
|
this.seedFromPreviousRun();
|
|
47
|
+
if (this.previewHead !== undefined && /<script\b/i.test(this.previewHead)) {
|
|
48
|
+
console.warn('describe-me: previewHead contains a <script>, which the viewer never runs.');
|
|
49
|
+
}
|
|
44
50
|
}
|
|
45
51
|
onTestRunStart() {
|
|
46
52
|
this.assets.startRun();
|
|
@@ -98,6 +104,8 @@ export default class DescribeMeReporter {
|
|
|
98
104
|
async onTestRunEnd() {
|
|
99
105
|
const modules = Array.from(this.modules.values()).sort((left, right) => left.id.localeCompare(right.id));
|
|
100
106
|
const components = await collectComponentDocsSafely(this.root, componentEntries(modules, this.root));
|
|
107
|
+
// Before `missing()`, so the files the head links that do not exist are listed too.
|
|
108
|
+
const head = this.previewHead === undefined ? undefined : this.assets.rewriteHead(this.previewHead);
|
|
101
109
|
// Only what this run found: a filtered run does not know which of the
|
|
102
110
|
// previous run's missing assets belonged to the modules it skipped.
|
|
103
111
|
const assetsMissing = this.assets.missing();
|
|
@@ -109,10 +117,13 @@ export default class DescribeMeReporter {
|
|
|
109
117
|
components,
|
|
110
118
|
assetsMissing,
|
|
111
119
|
};
|
|
120
|
+
if (head !== undefined) {
|
|
121
|
+
manifest.head = head;
|
|
122
|
+
}
|
|
112
123
|
const snapshotFiles = manifest.modules.flatMap((mod) => mod.tests.flatMap((test) => test.frames.map((frame) => frame.snapshot)));
|
|
113
124
|
this.snapshots.collectGarbage(snapshotFiles);
|
|
114
125
|
const styleFiles = this.styles.collectGarbage(snapshotFiles);
|
|
115
|
-
this.assets.collectGarbage([...snapshotFiles, ...styleFiles]);
|
|
126
|
+
this.assets.collectGarbage([...snapshotFiles, ...styleFiles], head === undefined ? [] : [head]);
|
|
116
127
|
writeFileSync(join(this.outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
117
128
|
printDiagnostics(manifest);
|
|
118
129
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { cssReferences } from '@describe-me/core/css-references';
|
|
2
|
+
/**
|
|
3
|
+
* Replace each `url()` and `@import` target in CSS text for which `resolve`
|
|
4
|
+
* returns a string. Quotes and everything else stay as written.
|
|
5
|
+
*/
|
|
6
|
+
export function rewriteCssReferences(css, resolve) {
|
|
7
|
+
let rewritten = '';
|
|
8
|
+
let copied = 0;
|
|
9
|
+
for (const reference of cssReferences(css)) {
|
|
10
|
+
const replacement = resolve(reference.url);
|
|
11
|
+
if (replacement === null) {
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
rewritten += css.slice(copied, reference.start) + replacement;
|
|
15
|
+
copied = reference.end;
|
|
16
|
+
}
|
|
17
|
+
return rewritten + css.slice(copied);
|
|
18
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
type Resolve = (url: string) => string | null;
|
|
2
|
+
/**
|
|
3
|
+
* Replace the URLs in HTML meant for a document's `<head>`: `href` and `src`
|
|
4
|
+
* values, and the `url()` and `@import` targets of `<style>` elements and
|
|
5
|
+
* `style` attributes, wherever `resolve` returns a string.
|
|
6
|
+
*/
|
|
7
|
+
export declare function rewriteHeadReferences(html: string, resolve: Resolve): string;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { rewriteCssReferences } from './rewrite-css-references.js';
|
|
2
|
+
/**
|
|
3
|
+
* Comments and `<script>` elements, which stay as written, and `<style>`
|
|
4
|
+
* elements, whose CSS is rewritten. Tags between them are rewritten attribute
|
|
5
|
+
* by attribute. End tags and comment ends are matched as loosely as browsers
|
|
6
|
+
* read them: `</script foo>` and `--!>` count too.
|
|
7
|
+
*/
|
|
8
|
+
const RAW_TEXT = /<!--[\s\S]*?--!?>|<script\b[\s\S]*?<\/script\b[^>]*>|(<style\b[^>]*>)([\s\S]*?)(<\/style\b[^>]*>)/gi;
|
|
9
|
+
/** A start tag, split after its name. Quoted attribute values may contain `>`. */
|
|
10
|
+
const START_TAG = /(<[a-z][^\s/>]*)((?:[^>"']|"[^"]*"|'[^']*')*>)/gi;
|
|
11
|
+
/**
|
|
12
|
+
* One attribute: its whole name, so `data-src` is never `src`, and its value,
|
|
13
|
+
* double-quoted, single-quoted or bare.
|
|
14
|
+
*/
|
|
15
|
+
const ATTRIBUTE = /([^\s"'<>/=]+)(?:(\s*=\s*)("[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g;
|
|
16
|
+
/** Attributes whose value is a URL, and `style`, whose value is CSS. */
|
|
17
|
+
const REWRITTEN_ATTRIBUTES = new Set(['href', 'src', 'style']);
|
|
18
|
+
function rewriteAttribute(attribute, name, equals, value, resolve) {
|
|
19
|
+
const kind = name.toLowerCase();
|
|
20
|
+
if (equals === undefined || value === undefined || !REWRITTEN_ATTRIBUTES.has(kind)) {
|
|
21
|
+
return attribute;
|
|
22
|
+
}
|
|
23
|
+
const quote = value.startsWith('"') || value.startsWith("'") ? value[0] : '';
|
|
24
|
+
const text = quote === '' ? value : value.slice(1, -1);
|
|
25
|
+
const rewritten = kind === 'style' ? rewriteCssReferences(text, resolve) : (resolve(text) ?? text);
|
|
26
|
+
return `${name}${equals}${quote}${rewritten}${quote}`;
|
|
27
|
+
}
|
|
28
|
+
function rewriteTags(html, resolve) {
|
|
29
|
+
return html.replace(START_TAG, (_tag, name, attributes) => {
|
|
30
|
+
const rewritten = attributes.replace(ATTRIBUTE, (attribute, attributeName, equals, value) => rewriteAttribute(attribute, attributeName, equals, value, resolve));
|
|
31
|
+
return name + rewritten;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Replace the URLs in HTML meant for a document's `<head>`: `href` and `src`
|
|
36
|
+
* values, and the `url()` and `@import` targets of `<style>` elements and
|
|
37
|
+
* `style` attributes, wherever `resolve` returns a string.
|
|
38
|
+
*/
|
|
39
|
+
export function rewriteHeadReferences(html, resolve) {
|
|
40
|
+
let rewritten = '';
|
|
41
|
+
let copied = 0;
|
|
42
|
+
for (const match of html.matchAll(RAW_TEXT)) {
|
|
43
|
+
const [text, styleStart, css, styleEnd] = match;
|
|
44
|
+
const kept = styleStart === undefined ? text : styleStart + rewriteCssReferences(css, resolve) + styleEnd;
|
|
45
|
+
rewritten += rewriteTags(html.slice(copied, match.index), resolve) + kept;
|
|
46
|
+
copied = match.index + text.length;
|
|
47
|
+
}
|
|
48
|
+
return rewritten + rewriteTags(html.slice(copied), resolve);
|
|
49
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@describe-me/vitest",
|
|
3
|
-
"version": "0.5.0-next.
|
|
3
|
+
"version": "0.5.0-next.1",
|
|
4
4
|
"description": "Vitest integration for describe-me: plugin, setup file and reporter",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vitest",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
],
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"picomatch": "^4.0.3",
|
|
62
|
-
"@describe-me/core": "^0.5.0-next.
|
|
62
|
+
"@describe-me/core": "^0.5.0-next.1"
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
65
65
|
"vitest": "^4.0.0 || ^5.0.0",
|