@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 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:
@@ -1,12 +1,16 @@
1
1
  /**
2
- * Content-addressed store for the project files snapshots point at (images,
3
- * fonts, `url()` targets of stylesheets), living in `<outDir>/assets`.
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; a few stay root-relative, which the viewer would
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
- /** Root-relative paths that snapshots in this run pointed at but that do not exist. */
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 refers to. Paths are
38
- * relative to the output directory, as the manifest stores them. Every kept
39
- * file is scanned, not only the ones written now: snapshots of modules that
40
- * were not re-run were rewritten by an earlier run.
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
  }
@@ -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 point at (images,
82
- * fonts, `url()` targets of stylesheets), living in `<outDir>/assets`.
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; a few stay root-relative, which the viewer would
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
- /** Root-relative paths that snapshots in this run pointed at but that do not exist. */
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 refers to. Paths are
144
- * relative to the output directory, as the manifest stores them. Every kept
145
- * file is scanned, not only the ones written now: snapshots of modules that
146
- * were not re-run were rewritten by an earlier run.
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 text = readFileSync(path, 'utf8');
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
- const hash = createHash('sha1').update(readFileSync(file)).digest('hex').slice(0, 16);
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,5 @@
1
+ /**
2
+ * The stored asset names a stored CSS asset refers to. The asset store writes
3
+ * them as bare sibling names, so garbage collection can follow them.
4
+ */
5
+ export declare function cssAssetReferences(css: string): string[];
@@ -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.
@@ -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,5 @@
1
+ /**
2
+ * Replace each `url()` and `@import` target in CSS text for which `resolve`
3
+ * returns a string. Quotes and everything else stay as written.
4
+ */
5
+ export declare function rewriteCssReferences(css: string, resolve: (url: string) => string | null): string;
@@ -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.0",
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.0"
62
+ "@describe-me/core": "^0.5.0-next.1"
63
63
  },
64
64
  "peerDependencies": {
65
65
  "vitest": "^4.0.0 || ^5.0.0",