@describe-me/vitest 0.4.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.
Files changed (40) hide show
  1. package/README.md +22 -5
  2. package/dist/adapter-package-root.d.ts +7 -0
  3. package/dist/adapter-package-root.js +40 -0
  4. package/dist/asset-store.d.ts +62 -12
  5. package/dist/asset-store.js +226 -23
  6. package/dist/compile-globs.d.ts +6 -0
  7. package/dist/compile-globs.js +20 -0
  8. package/dist/css-asset-references.d.ts +5 -0
  9. package/dist/css-asset-references.js +17 -0
  10. package/dist/file-filter.d.ts +7 -0
  11. package/dist/file-filter.js +23 -0
  12. package/dist/is-recorded-test.d.ts +6 -0
  13. package/dist/is-recorded-test.js +3 -0
  14. package/dist/mirror-react-act-environment.d.ts +8 -0
  15. package/dist/mirror-react-act-environment.js +27 -0
  16. package/dist/patch-testing-library-user-event.d.ts +3 -2
  17. package/dist/patch-testing-library-user-event.js +11 -4
  18. package/dist/plugin.d.ts +14 -0
  19. package/dist/plugin.js +41 -7
  20. package/dist/read-runtime-options.d.ts +3 -0
  21. package/dist/read-runtime-options.js +6 -0
  22. package/dist/register-recording-hooks.d.ts +7 -4
  23. package/dist/register-recording-hooks.js +33 -9
  24. package/dist/reporter.d.ts +34 -2
  25. package/dist/reporter.js +83 -14
  26. package/dist/rewrite-css-references.d.ts +5 -0
  27. package/dist/rewrite-css-references.js +18 -0
  28. package/dist/rewrite-head-references.d.ts +8 -0
  29. package/dist/rewrite-head-references.js +49 -0
  30. package/dist/runtime-options.d.ts +22 -0
  31. package/dist/runtime-options.js +7 -0
  32. package/dist/setup-dom.js +9 -5
  33. package/dist/setup.js +4 -2
  34. package/dist/split-stylesheet.d.ts +7 -0
  35. package/dist/split-stylesheet.js +114 -0
  36. package/dist/style-store.d.ts +25 -0
  37. package/dist/style-store.js +90 -0
  38. package/package.json +8 -6
  39. package/dist/element-label.d.ts +0 -6
  40. package/dist/element-label.js +0 -34
package/README.md CHANGED
@@ -19,10 +19,13 @@ plugin detects where the tests run:
19
19
  - **Browser mode** (`test.browser.enabled`): redirects `vitest-browser-react`,
20
20
  records a frame after every `Locator` action and keyboard-level `userEvent`.
21
21
  - **DOM environments** such as jsdom (anything else): redirects
22
- `@testing-library/react`, records a frame after every
22
+ `@testing-library/react`, records a frame after every `fireEvent` and every
23
23
  `@testing-library/user-event` call, turns on `test.css` so imported
24
24
  stylesheets reach the snapshots, and sets `RTL_SKIP_AUTO_CLEANUP` so the
25
- component is unmounted only after the closing frame.
25
+ component is unmounted only after the closing frame. The setup file sets
26
+ React's `IS_REACT_ACT_ENVIRONMENT` where Testing Library would, so act
27
+ warnings match a run without describe-me. It also works without user-event
28
+ installed.
26
29
 
27
30
  ```ts
28
31
  import { describeMe } from '@describe-me/vitest/plugin'
@@ -44,9 +47,23 @@ export default defineConfig({
44
47
 
45
48
  Pass `describeMe({ environment: 'browser' | 'dom' })` to override the detection.
46
49
 
47
- The pieces can also be wired by hand, in which case tests import `render` from
48
- the adapter themselves (`@describe-me/react` in browser mode,
49
- `@describe-me/react/testing-library` in jsdom):
50
+ A test that records no frame before the closing one (no recording `render`,
51
+ no interaction, no `step()`) costs no snapshot and is left out of the
52
+ manifest. `describeMe({ include, exclude })` chooses which test files are
53
+ recorded, with picomatch globs relative to the Vitest root. `exclude` wins.
54
+ Tests in other files still run, but record nothing. The reporter takes the
55
+ same two options, but on its own it only filters the manifest: without the
56
+ plugin the setup file does not know them.
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
+ The pieces can also be wired by hand. Tests then import from the adapter
65
+ themselves: `render` from `@describe-me/react` in browser mode, and `render`,
66
+ `fireEvent` and `cleanup` from `@describe-me/react/testing-library` in jsdom:
50
67
 
51
68
  ```ts
52
69
  import DescribeMeReporter from '@describe-me/vitest/reporter'
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The directory of the adapter package the project resolves, e.g.
3
+ * `/repo/node_modules/@describe-me/react/`, with posix separators and a
4
+ * trailing slash the way Vite ids look, or null when it cannot be resolved.
5
+ * It is the real path, so a workspace link points at its source directory.
6
+ */
7
+ export declare function adapterPackageRoot(root: string, packageName: string): string | null;
@@ -0,0 +1,40 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join, sep } from 'node:path';
4
+ /** The `name` in a directory's package.json, if it has a readable one. */
5
+ function packageNameIn(directory) {
6
+ const manifest = join(directory, 'package.json');
7
+ if (!existsSync(manifest)) {
8
+ return undefined;
9
+ }
10
+ try {
11
+ return JSON.parse(readFileSync(manifest, 'utf8')).name;
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ /**
18
+ * The directory of the adapter package the project resolves, e.g.
19
+ * `/repo/node_modules/@describe-me/react/`, with posix separators and a
20
+ * trailing slash the way Vite ids look, or null when it cannot be resolved.
21
+ * It is the real path, so a workspace link points at its source directory.
22
+ */
23
+ export function adapterPackageRoot(root, packageName) {
24
+ let entry;
25
+ // The package does not export `./package.json`, so resolve its entry and walk up.
26
+ try {
27
+ entry = createRequire(join(root, 'package.json')).resolve(packageName);
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ let directory = dirname(entry);
33
+ while (directory !== dirname(directory)) {
34
+ if (packageNameIn(directory) === packageName) {
35
+ return `${directory.split(sep).join('/')}/`;
36
+ }
37
+ directory = dirname(directory);
38
+ }
39
+ return null;
40
+ }
@@ -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,8 +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;
18
- /** Asset names per snapshot file. Snapshots are content-addressed, so an entry never goes stale. */
19
- private readonly referencesBySnapshot;
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;
26
+ /**
27
+ * Asset names per snapshot or style chunk. Both are content-addressed, so an
28
+ * entry never goes stale.
29
+ */
30
+ private readonly referencesByFile;
31
+ /** Asset names per stored CSS asset, which is content-addressed too. */
32
+ private readonly referencesByCssAsset;
20
33
  constructor(outDir: string, root: string);
21
34
  /** Forget what the previous run found: files may have changed or appeared since. */
22
35
  startRun(): void;
@@ -28,21 +41,58 @@ export declare class AssetStore {
28
41
  * handled on their own. A `#fragment` is kept, for SVG sprites.
29
42
  */
30
43
  rewrite(json: string, origin: string | undefined): string;
31
- /** 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
+ */
32
55
  missing(): string[];
33
56
  /**
34
- * Drop the assets no kept snapshot refers to. Paths are relative to the
35
- * output directory, as the manifest stores them. Every kept file is scanned,
36
- * not only the ones written now: snapshots of modules that were not re-run
37
- * 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.
38
62
  */
39
- collectGarbage(snapshotFiles: 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;
40
67
  private referencesIn;
41
68
  /** The asset URL for a root-relative URL path, or null when it is not a project file. */
42
69
  private assetUrl;
43
70
  /** The stored name for a URL pathname, copying the file on first sight in this run. */
44
71
  private nameFor;
45
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;
46
96
  /** Where Vite serves a URL pathname from: `/@fs/`, the project root, or its `public/` folder. */
47
97
  private fileFor;
48
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,8 +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();
97
- /** Asset names per snapshot file. Snapshots are content-addressed, so an entry never goes stale. */
98
- referencesBySnapshot = new Map();
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();
162
+ /**
163
+ * Asset names per snapshot or style chunk. Both are content-addressed, so an
164
+ * entry never goes stale.
165
+ */
166
+ referencesByFile = new Map();
167
+ /** Asset names per stored CSS asset, which is content-addressed too. */
168
+ referencesByCssAsset = new Map();
99
169
  constructor(outDir, root) {
100
170
  this.outDir = outDir;
101
171
  this.dir = join(outDir, 'assets');
@@ -106,6 +176,7 @@ export class AssetStore {
106
176
  startRun() {
107
177
  this.names.clear();
108
178
  this.notFound.clear();
179
+ this.cssNames.clear();
109
180
  }
110
181
  /**
111
182
  * Replace every URL in a serialized snapshot that points at a project file
@@ -132,45 +203,96 @@ export class AssetStore {
132
203
  return asset === null ? poster : `"poster":"${asset}`;
133
204
  });
134
205
  }
135
- /** 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
+ */
136
225
  missing() {
137
226
  return Array.from(this.notFound).sort();
138
227
  }
139
228
  /**
140
- * Drop the assets no kept snapshot refers to. Paths are relative to the
141
- * output directory, as the manifest stores them. Every kept file is scanned,
142
- * not only the ones written now: snapshots of modules that were not re-run
143
- * 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.
144
234
  */
145
- collectGarbage(snapshotFiles) {
235
+ collectGarbage(files, inlineTexts = []) {
146
236
  const stored = readdirSync(this.dir);
147
237
  if (stored.length === 0) {
148
238
  return;
149
239
  }
150
240
  const keep = new Set();
151
- for (const snapshotFile of snapshotFiles) {
152
- for (const name of this.referencesIn(snapshotFile)) {
241
+ for (const file of files) {
242
+ for (const name of this.referencesIn(file)) {
243
+ keep.add(name);
244
+ }
245
+ }
246
+ for (const text of inlineTexts) {
247
+ for (const name of assetNamesIn(text)) {
153
248
  keep.add(name);
154
249
  }
155
250
  }
251
+ this.keepCssTargets(keep);
156
252
  for (const file of stored) {
157
253
  if (!keep.has(file)) {
158
254
  rmSync(join(this.dir, file), { force: true });
159
255
  }
160
256
  }
161
257
  }
162
- referencesIn(snapshotFile) {
163
- const cached = this.referencesBySnapshot.get(snapshotFile);
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
+ }
285
+ referencesIn(file) {
286
+ const cached = this.referencesByFile.get(file);
164
287
  if (cached) {
165
288
  return cached;
166
289
  }
167
- const path = join(this.outDir, snapshotFile);
290
+ const path = join(this.outDir, file);
168
291
  if (!isFile(path)) {
169
292
  return [];
170
293
  }
171
- const json = readFileSync(path, 'utf8');
172
- const names = Array.from(json.matchAll(ASSET_REFERENCE), (match) => match[1]);
173
- this.referencesBySnapshot.set(snapshotFile, names);
294
+ const names = assetNamesIn(readFileSync(path, 'utf8'));
295
+ this.referencesByFile.set(file, names);
174
296
  return names;
175
297
  }
176
298
  /** The asset URL for a root-relative URL path, or null when it is not a project file. */
@@ -205,8 +327,15 @@ export class AssetStore {
205
327
  }
206
328
  return null;
207
329
  }
208
- 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) {
209
334
  const extension = extname(file).toLowerCase();
335
+ if (extension === '.css') {
336
+ return this.storeCss(file);
337
+ }
338
+ const hash = contentHash(readFileSync(file));
210
339
  const name = /^\.[a-z0-9]+$/.test(extension) ? `${hash}${extension}` : hash;
211
340
  const target = join(this.dir, name);
212
341
  if (!existsSync(target)) {
@@ -214,6 +343,80 @@ export class AssetStore {
214
343
  }
215
344
  return name;
216
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
+ }
217
420
  /** Where Vite serves a URL pathname from: `/@fs/`, the project root, or its `public/` folder. */
218
421
  fileFor(pathname) {
219
422
  if (pathname.startsWith('/@fs/')) {
@@ -0,0 +1,6 @@
1
+ import type { CompiledGlob } from './runtime-options.js';
2
+ /**
3
+ * Plain `{ source, flags }` pairs, because the globs travel to the test
4
+ * runtime as JSON. Invalid input fails here, before any test runs.
5
+ */
6
+ export declare function compileGlobs(patterns: string | string[] | undefined): CompiledGlob[];
@@ -0,0 +1,20 @@
1
+ import { inspect } from 'node:util';
2
+ import picomatch from 'picomatch';
3
+ /**
4
+ * Plain `{ source, flags }` pairs, because the globs travel to the test
5
+ * runtime as JSON. Invalid input fails here, before any test runs.
6
+ */
7
+ export function compileGlobs(patterns) {
8
+ if (patterns === undefined) {
9
+ return [];
10
+ }
11
+ const list = Array.isArray(patterns) ? patterns : [patterns];
12
+ return list.map((pattern) => {
13
+ // picomatch throws its own unprefixed TypeError for an empty string.
14
+ if (typeof pattern !== 'string' || pattern === '') {
15
+ throw new Error(`describe-me: include and exclude expect non-empty glob strings, got ${inspect(pattern)}`);
16
+ }
17
+ const regExp = picomatch.makeRe(pattern, { dot: true, windows: false });
18
+ return { source: regExp.source, flags: regExp.flags };
19
+ });
20
+ }
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ import type { RuntimeOptions } from './runtime-options.js';
2
+ /**
3
+ * Takes a path relative to the root, with posix separators. Build it once and
4
+ * reuse the returned function, which memoizes per file name. The test runtime
5
+ * imports this file, so no Node imports.
6
+ */
7
+ export declare function fileFilter(options: Pick<RuntimeOptions, 'include' | 'exclude'>): (fileName: string) => boolean;
@@ -0,0 +1,23 @@
1
+ function toRegExps(globs) {
2
+ return globs.map((glob) => new RegExp(glob.source, glob.flags));
3
+ }
4
+ /**
5
+ * Takes a path relative to the root, with posix separators. Build it once and
6
+ * reuse the returned function, which memoizes per file name. The test runtime
7
+ * imports this file, so no Node imports.
8
+ */
9
+ export function fileFilter(options) {
10
+ const include = toRegExps(options.include);
11
+ const exclude = toRegExps(options.exclude);
12
+ const answers = new Map();
13
+ return (fileName) => {
14
+ const known = answers.get(fileName);
15
+ if (known !== undefined) {
16
+ return known;
17
+ }
18
+ const included = include.length === 0 || include.some((glob) => glob.test(fileName));
19
+ const answer = included && !exclude.some((glob) => glob.test(fileName));
20
+ answers.set(fileName, answer);
21
+ return answer;
22
+ };
23
+ }
@@ -0,0 +1,6 @@
1
+ import type { FrameKind } from '@describe-me/core/types';
2
+ export declare function isRecordedTest(test: {
3
+ frames: readonly {
4
+ kind: FrameKind;
5
+ }[];
6
+ }): boolean;
@@ -0,0 +1,3 @@
1
+ export function isRecordedTest(test) {
2
+ return test.frames.some((frame) => frame.kind !== 'end');
3
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Set React's `IS_REACT_ACT_ENVIRONMENT` flag for each test file, the way
3
+ * Testing Library does. `RTL_SKIP_AUTO_CLEANUP` skips its whole setup block,
4
+ * the flag included, which silences React's act warnings. The flag is set
5
+ * only under Testing Library's own condition (Vitest globals), so the
6
+ * warnings match a run without describe-me.
7
+ */
8
+ export declare function mirrorReactActEnvironment(): void;