@describe-me/vitest 0.5.0-next.0 → 0.5.0-next.2

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,17 @@ 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
+
64
+ `describeMe({ renderFrame })` chooses when the render frame is taken:
65
+ `'eager'` (default) right after mount, `'lazy'` right before the next
66
+ interaction, or `{ pending: '<selector>' }` once a loader is gone. See
67
+ [Render frame timing](https://github.com/grzehub/describe-me#render-frame-timing).
68
+
58
69
  The pieces can also be wired by hand. Tests then import from the adapter
59
70
  themselves: `render` from `@describe-me/react` in browser mode, and `render`,
60
71
  `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
@@ -1,4 +1,5 @@
1
1
  import type { ViteUserConfig } from 'vitest/config';
2
+ import type { RenderFrameMode } from '@describe-me/core/types';
2
3
  /** Where the tests run: Vitest browser mode, or a simulated DOM such as jsdom. */
3
4
  export type DescribeMeEnvironment = 'browser' | 'dom';
4
5
  export interface DescribeMeOptions {
@@ -35,6 +36,18 @@ export interface DescribeMeOptions {
35
36
  include?: string | string[];
36
37
  /** Test files never to record, as globs like `include`. Wins over `include`. Default: none. */
37
38
  exclude?: string | string[];
39
+ /**
40
+ * HTML the viewer adds to the start of the `<head>` of every replayed frame,
41
+ * like Storybook's `preview-head.html`. It never reaches the test page.
42
+ * Project files it links are copied into the output directory. Default: none.
43
+ */
44
+ previewHead?: string;
45
+ /**
46
+ * When the render frame is taken: `'eager'` right after mount, `'lazy'`
47
+ * before the next interaction, `{ pending }` once nothing matches the
48
+ * selector. Default: `'eager'`.
49
+ */
50
+ renderFrame?: RenderFrameMode;
38
51
  }
39
52
  type VitePlugin = NonNullable<ViteUserConfig['plugins']>[number];
40
53
  /**
package/dist/plugin.js CHANGED
@@ -5,6 +5,7 @@ import { registerExports } from './register-exports.js';
5
5
  import DescribeMeReporter from './reporter.js';
6
6
  import { RUNTIME_OPTIONS_KEY } from './runtime-options.js';
7
7
  import { styledComponentsBrowserBuild } from './styled-components-browser-build.js';
8
+ import { validateRenderFrame } from './validate-render-frame.js';
8
9
  /** For each framework and environment: the render module tests import, and our adapter that wraps it. */
9
10
  const RENDER_MODULES = {
10
11
  react: {
@@ -101,17 +102,19 @@ export function describeMe(options = {}) {
101
102
  }
102
103
  const environment = options.environment ?? detectEnvironment(userConfig);
103
104
  renderModule = RENDER_MODULES[framework][environment];
104
- // Compiled here, so invalid globs fail before any test runs. Vite
105
+ // Checked here, so invalid options fail before any test runs. Vite
105
106
  // deep-merges `provide`, so the user's own keys survive.
106
107
  const runtimeOptions = {
107
108
  include: compileGlobs(options.include),
108
109
  exclude: compileGlobs(options.exclude),
110
+ renderFrame: validateRenderFrame(options.renderFrame),
109
111
  };
110
112
  const provide = { [RUNTIME_OPTIONS_KEY]: runtimeOptions };
111
113
  const reporter = new DescribeMeReporter({
112
114
  outDir,
113
115
  include: options.include,
114
116
  exclude: options.exclude,
117
+ previewHead: options.previewHead,
115
118
  });
116
119
  // Vite concatenates arrays when merging, so only add `default` when the
117
120
  // user has not chosen their own reporters; otherwise they would lose it.
@@ -2,5 +2,9 @@ import { inject } from 'vitest';
2
2
  import { RUNTIME_OPTIONS_KEY } from './runtime-options.js';
3
3
  /** Without the plugin nothing is provided, so a hand-wired setup keeps recording every test. */
4
4
  export function readRuntimeOptions() {
5
- return inject(RUNTIME_OPTIONS_KEY) ?? { include: [], exclude: [] };
5
+ return (inject(RUNTIME_OPTIONS_KEY) ?? {
6
+ include: [],
7
+ exclude: [],
8
+ renderFrame: 'eager',
9
+ });
6
10
  }
@@ -1,22 +1,32 @@
1
1
  import { recorder } from '@describe-me/core';
2
2
  /**
3
- * Nesting depth of interactions currently in flight. `userEvent.click(el)`
4
- * delegates to `locator.click()`, and `copy()` delegates to `keyboard()`, so
5
- * without this guard one gesture would produce two frames.
3
+ * Nesting depth of the interactions in flight, and the test they started in.
4
+ * `userEvent.click(el)` delegates to `locator.click()`, and `copy()` delegates
5
+ * to `keyboard()`, so without this guard one gesture would produce two
6
+ * frames. Keyed to the recorder's generation, so an interaction that an ended
7
+ * test left running cannot swallow the next test's frame.
6
8
  */
7
- let depth = 0;
9
+ const nesting = { generation: -1, depth: 0 };
8
10
  /** Run one user interaction and record a frame for it, but only for the outermost call. */
9
11
  export async function recordAction(label, run) {
10
- depth++;
12
+ const generation = recorder.generation;
13
+ if (generation !== nesting.generation) {
14
+ nesting.generation = generation;
15
+ nesting.depth = 0;
16
+ }
17
+ recorder.beforeInteraction();
18
+ nesting.depth++;
11
19
  let result;
12
20
  try {
13
21
  result = await run();
14
22
  }
15
23
  finally {
16
- depth--;
24
+ if (generation === nesting.generation) {
25
+ nesting.depth--;
26
+ }
17
27
  }
18
- if (depth === 0) {
19
- await recorder.capture('action', label);
28
+ if (generation === nesting.generation && nesting.depth === 0) {
29
+ await recorder.capture('action', label, undefined, { generation });
20
30
  }
21
31
  return result;
22
32
  }
@@ -1,9 +1,10 @@
1
1
  import type { RuntimeOptions } from './runtime-options.js';
2
2
  /**
3
3
  * The per-test lifecycle shared by every environment: begin a recording, take
4
- * the closing frame, hand the frames to the reporter via task.meta, and only
5
- * then let adapters unmount. Teardown runs for unrecorded tests too, because
6
- * Testing Library's auto-cleanup is off in jsdom. Concurrent tests would share
7
- * the one recorder, so they are not recorded.
4
+ * a deferred render frame or else the closing frame, hand the frames to the
5
+ * reporter via task.meta, and only then let adapters unmount. Teardown runs
6
+ * for unrecorded tests too, because Testing Library's auto-cleanup is off in
7
+ * jsdom. Concurrent tests would share the one recorder, so they are not
8
+ * recorded.
8
9
  */
9
10
  export declare function registerRecordingHooks(options: RuntimeOptions): void;
@@ -3,14 +3,16 @@ import { META_KEY, recorder } from '@describe-me/core';
3
3
  import { fileFilter } from './file-filter.js';
4
4
  /**
5
5
  * The per-test lifecycle shared by every environment: begin a recording, take
6
- * the closing frame, hand the frames to the reporter via task.meta, and only
7
- * then let adapters unmount. Teardown runs for unrecorded tests too, because
8
- * Testing Library's auto-cleanup is off in jsdom. Concurrent tests would share
9
- * the one recorder, so they are not recorded.
6
+ * a deferred render frame or else the closing frame, hand the frames to the
7
+ * reporter via task.meta, and only then let adapters unmount. Teardown runs
8
+ * for unrecorded tests too, because Testing Library's auto-cleanup is off in
9
+ * jsdom. Concurrent tests would share the one recorder, so they are not
10
+ * recorded.
10
11
  */
11
12
  export function registerRecordingHooks(options) {
12
13
  const isRecordedFile = fileFilter(options);
13
14
  let warnedConcurrent = false;
15
+ recorder.configure({ renderFrame: options.renderFrame });
14
16
  beforeEach((context) => {
15
17
  if (context.task.concurrent) {
16
18
  if (!warnedConcurrent) {
@@ -26,7 +28,11 @@ export function registerRecordingHooks(options) {
26
28
  afterEach(async (context) => {
27
29
  try {
28
30
  if (recorder.isActive) {
29
- await recorder.capture('end', 'end of test');
31
+ const tookRender = await recorder.flush();
32
+ // A render frame taken this late already shows how the test ended.
33
+ if (!tookRender) {
34
+ await recorder.capture('end', 'end of test');
35
+ }
30
36
  const record = recorder.end();
31
37
  if (record.frames.length > 0) {
32
38
  ;
@@ -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
+ }
@@ -2,8 +2,9 @@
2
2
  * Travels from the plugin to the setup files through Vitest's `provide` /
3
3
  * `inject`. Keep it plain JSON: the browser receives it serialized, so a
4
4
  * RegExp would not survive. The test runtime imports this file, so it imports
5
- * nothing itself.
5
+ * only types.
6
6
  */
7
+ import type { RenderFrameMode } from '@describe-me/core/types';
7
8
  export interface CompiledGlob {
8
9
  source: string;
9
10
  flags: string;
@@ -13,6 +14,7 @@ export interface RuntimeOptions {
13
14
  include: CompiledGlob[];
14
15
  /** Wins over `include`. */
15
16
  exclude: CompiledGlob[];
17
+ renderFrame: RenderFrameMode;
16
18
  }
17
19
  export declare const RUNTIME_OPTIONS_KEY: "describe-me";
18
20
  declare module 'vitest' {
@@ -2,6 +2,6 @@
2
2
  * Travels from the plugin to the setup files through Vitest's `provide` /
3
3
  * `inject`. Keep it plain JSON: the browser receives it serialized, so a
4
4
  * RegExp would not survive. The test runtime imports this file, so it imports
5
- * nothing itself.
5
+ * only types.
6
6
  */
7
7
  export const RUNTIME_OPTIONS_KEY = 'describe-me';
package/dist/setup-dom.js CHANGED
@@ -3,8 +3,9 @@
3
3
  * `setupFiles: ['@describe-me/vitest/setup-dom']`. Patches Testing Library's
4
4
  * userEvent, when it is installed, so every interaction records a frame, sets
5
5
  * React's act flag where Testing Library would, and registers the recording
6
- * hooks. Which test files are recorded (`include` / `exclude`) comes from the
7
- * plugin. Never imports `vitest/browser`, which only exists in browser mode.
6
+ * hooks. Which test files are recorded (`include` / `exclude`) and when the
7
+ * render frame is taken (`renderFrame`) come from the plugin. Never imports
8
+ * `vitest/browser`, which only exists in browser mode.
8
9
  */
9
10
  import { mirrorReactActEnvironment } from './mirror-react-act-environment.js';
10
11
  import { patchTestingLibraryUserEvent } from './patch-testing-library-user-event.js';
package/dist/setup.js CHANGED
@@ -2,7 +2,8 @@
2
2
  * Vitest setup file for browser mode: `setupFiles: ['@describe-me/vitest/setup']`.
3
3
  * Patches Vitest's locators and userEvent so every interaction records a
4
4
  * frame, and registers the recording hooks. Which test files are recorded
5
- * (`include` / `exclude`) comes from the plugin. For jsdom use `./setup-dom`.
5
+ * (`include` / `exclude`) and when the render frame is taken (`renderFrame`)
6
+ * come from the plugin. For jsdom use `./setup-dom`.
6
7
  */
7
8
  import { patchLocators } from './patch-locators.js';
8
9
  import { patchUserEvent } from './patch-user-event.js';
@@ -0,0 +1,6 @@
1
+ import type { RenderFrameMode } from '@describe-me/core/types';
2
+ /**
3
+ * The plugin's `renderFrame`, checked before any test runs. Selector syntax is
4
+ * left to `recorder.configure()` in the test runtime, which has a DOM to parse it.
5
+ */
6
+ export declare function validateRenderFrame(value: unknown): RenderFrameMode;
@@ -0,0 +1,26 @@
1
+ import { inspect } from 'node:util';
2
+ const ACCEPTED = "'eager', 'lazy' or { pending: string, timeout?: number } with a non-empty selector and a timeout above 0 ms";
3
+ function isPendingMode(value) {
4
+ if (typeof value !== 'object' || value === null) {
5
+ return false;
6
+ }
7
+ const { pending, timeout, ...rest } = value;
8
+ if (Object.keys(rest).length > 0 || typeof pending !== 'string' || pending === '') {
9
+ return false;
10
+ }
11
+ return (timeout === undefined ||
12
+ (typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0));
13
+ }
14
+ /**
15
+ * The plugin's `renderFrame`, checked before any test runs. Selector syntax is
16
+ * left to `recorder.configure()` in the test runtime, which has a DOM to parse it.
17
+ */
18
+ export function validateRenderFrame(value) {
19
+ if (value === undefined) {
20
+ return 'eager';
21
+ }
22
+ if (value === 'eager' || value === 'lazy' || isPendingMode(value)) {
23
+ return value;
24
+ }
25
+ throw new Error(`describe-me: renderFrame expects ${ACCEPTED}, got ${inspect(value)}`);
26
+ }
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.2",
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.2"
63
63
  },
64
64
  "peerDependencies": {
65
65
  "vitest": "^4.0.0 || ^5.0.0",