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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +16 -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 +51 -0
  5. package/dist/asset-store.js +233 -0
  6. package/dist/collect-component-docs.d.ts +8 -3
  7. package/dist/collect-component-docs.js +37 -8
  8. package/dist/compile-globs.d.ts +6 -0
  9. package/dist/compile-globs.js +20 -0
  10. package/dist/component-entries.js +5 -1
  11. package/dist/extract-props.js +87 -15
  12. package/dist/file-filter.d.ts +7 -0
  13. package/dist/file-filter.js +23 -0
  14. package/dist/is-recorded-test.d.ts +6 -0
  15. package/dist/is-recorded-test.js +3 -0
  16. package/dist/mirror-react-act-environment.d.ts +8 -0
  17. package/dist/mirror-react-act-environment.js +27 -0
  18. package/dist/patch-testing-library-user-event.d.ts +3 -2
  19. package/dist/patch-testing-library-user-event.js +11 -4
  20. package/dist/plugin.d.ts +22 -0
  21. package/dist/plugin.js +94 -10
  22. package/dist/print-diagnostics.d.ts +7 -0
  23. package/dist/print-diagnostics.js +31 -0
  24. package/dist/project-module-path.d.ts +7 -0
  25. package/dist/project-module-path.js +23 -0
  26. package/dist/read-runtime-options.d.ts +3 -0
  27. package/dist/read-runtime-options.js +6 -0
  28. package/dist/register-exports.d.ts +28 -0
  29. package/dist/register-exports.js +118 -0
  30. package/dist/register-recording-hooks.d.ts +7 -4
  31. package/dist/register-recording-hooks.js +33 -9
  32. package/dist/reporter.d.ts +30 -1
  33. package/dist/reporter.js +88 -12
  34. package/dist/runtime-options.d.ts +22 -0
  35. package/dist/runtime-options.js +7 -0
  36. package/dist/setup-dom.js +9 -5
  37. package/dist/setup.js +4 -2
  38. package/dist/snapshot-store.d.ts +2 -2
  39. package/dist/snapshot-store.js +2 -3
  40. package/dist/split-stylesheet.d.ts +7 -0
  41. package/dist/split-stylesheet.js +114 -0
  42. package/dist/style-store.d.ts +25 -0
  43. package/dist/style-store.js +90 -0
  44. package/dist/styled-components-browser-build.d.ts +15 -0
  45. package/dist/styled-components-browser-build.js +69 -0
  46. package/package.json +8 -6
  47. package/dist/element-label.d.ts +0 -6
  48. package/dist/element-label.js +0 -34
package/dist/reporter.js CHANGED
@@ -1,33 +1,55 @@
1
1
  /**
2
2
  * Node-side Vitest reporter. Collects frames from task.meta and writes
3
- * `.describe-me/manifest.json` plus one file per snapshot.
3
+ * `.describe-me/`: `manifest.json`, `snapshots/`, `styles/` and `assets/`.
4
4
  */
5
5
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
6
- import { join, relative, resolve } from 'node:path';
6
+ import { join, relative, resolve, sep } from 'node:path';
7
7
  import { META_KEY, } from '@describe-me/core/types';
8
+ import { AssetStore } from './asset-store.js';
8
9
  import { collectComponentDocsSafely } from './collect-component-docs-safely.js';
10
+ import { compileGlobs } from './compile-globs.js';
9
11
  import { componentEntries } from './component-entries.js';
12
+ import { fileFilter } from './file-filter.js';
13
+ import { isRecordedTest } from './is-recorded-test.js';
14
+ import { printDiagnostics } from './print-diagnostics.js';
10
15
  import { SnapshotStore } from './snapshot-store.js';
16
+ import { StyleStore } from './style-store.js';
11
17
  import { testPath } from './test-path.js';
18
+ function recordOf(tc) {
19
+ return tc.meta()[META_KEY];
20
+ }
12
21
  export default class DescribeMeReporter {
13
22
  outDirOption;
23
+ isRecordedFile;
14
24
  root = process.cwd();
15
25
  outDir = '';
16
26
  snapshots;
27
+ assets;
28
+ styles;
17
29
  modules = new Map();
18
30
  constructor(options = {}) {
19
31
  this.outDirOption = options.outDir ?? '.describe-me';
32
+ this.isRecordedFile = fileFilter({
33
+ include: compileGlobs(options.include),
34
+ exclude: compileGlobs(options.exclude),
35
+ });
20
36
  }
21
37
  onInit(vitest) {
22
38
  this.root = vitest.config.root;
23
39
  this.outDir = resolve(this.root, this.outDirOption);
24
40
  this.snapshots = new SnapshotStore(this.outDir);
41
+ this.assets = new AssetStore(this.outDir, this.root);
42
+ this.styles = new StyleStore(this.outDir);
25
43
  this.seedFromPreviousRun();
26
44
  }
45
+ onTestRunStart() {
46
+ this.assets.startRun();
47
+ }
27
48
  /**
28
49
  * A filtered run (`vitest run Button.test.tsx`) must not wipe the other
29
50
  * modules from the manifest, so start from whatever the last run wrote.
30
- * Modules whose file no longer exists are dropped.
51
+ * Modules whose file is gone or no longer selected by `include` / `exclude`
52
+ * are dropped, and so are the empty tests that 0.4 still wrote.
31
53
  */
32
54
  seedFromPreviousRun() {
33
55
  const file = join(this.outDir, 'manifest.json');
@@ -37,8 +59,11 @@ export default class DescribeMeReporter {
37
59
  try {
38
60
  const previous = JSON.parse(readFileSync(file, 'utf8'));
39
61
  for (const mod of previous.modules ?? []) {
40
- if (existsSync(resolve(this.root, mod.id))) {
41
- this.modules.set(mod.id, mod);
62
+ const tests = mod.tests.filter((test) => isRecordedTest(test));
63
+ if (tests.length > 0 &&
64
+ this.isRecordedModule(mod.id) &&
65
+ existsSync(resolve(this.root, mod.id))) {
66
+ this.modules.set(mod.id, { ...mod, tests });
42
67
  }
43
68
  }
44
69
  }
@@ -46,34 +71,75 @@ export default class DescribeMeReporter {
46
71
  // corrupt or incompatible manifest: start fresh
47
72
  }
48
73
  }
74
+ /**
75
+ * A test skipped in this run keeps its previous entry, so a filtered run does
76
+ * not erase documentation.
77
+ */
49
78
  onTestModuleEnd(module) {
50
79
  const id = relative(this.root, module.moduleId);
51
- const tests = Array.from(module.children.allTests()).map((tc) => this.toManifestTest(tc));
80
+ if (!this.isRecordedModule(id)) {
81
+ this.modules.delete(id);
82
+ return;
83
+ }
84
+ const previous = this.modules.get(id);
85
+ const tests = [];
86
+ for (const tc of module.children.allTests()) {
87
+ const test = this.manifestTestOf(tc, previous);
88
+ if (test) {
89
+ tests.push(test);
90
+ }
91
+ }
92
+ if (tests.length === 0) {
93
+ this.modules.delete(id);
94
+ return;
95
+ }
52
96
  this.modules.set(id, { id, tests });
53
97
  }
54
98
  async onTestRunEnd() {
55
99
  const modules = Array.from(this.modules.values()).sort((left, right) => left.id.localeCompare(right.id));
56
100
  const components = await collectComponentDocsSafely(this.root, componentEntries(modules, this.root));
101
+ // Only what this run found: a filtered run does not know which of the
102
+ // previous run's missing assets belonged to the modules it skipped.
103
+ const assetsMissing = this.assets.missing();
57
104
  const manifest = {
58
105
  version: 1,
59
106
  generatedAt: new Date().toISOString(),
60
107
  root: this.root,
61
108
  modules,
62
109
  components,
110
+ assetsMissing,
63
111
  };
64
- this.snapshots.collectGarbage(manifest.modules.flatMap((mod) => mod.tests.flatMap((test) => test.frames.map((frame) => frame.snapshot))));
112
+ const snapshotFiles = manifest.modules.flatMap((mod) => mod.tests.flatMap((test) => test.frames.map((frame) => frame.snapshot)));
113
+ this.snapshots.collectGarbage(snapshotFiles);
114
+ const styleFiles = this.styles.collectGarbage(snapshotFiles);
115
+ this.assets.collectGarbage([...snapshotFiles, ...styleFiles]);
65
116
  writeFileSync(join(this.outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
117
+ printDiagnostics(manifest);
118
+ }
119
+ /** Module ids use the platform's separators. Globs expect posix ones. */
120
+ isRecordedModule(id) {
121
+ return this.isRecordedFile(id.split(sep).join('/'));
122
+ }
123
+ /** Decided before any snapshot is written, so a dropped test writes no file. */
124
+ manifestTestOf(tc, previous) {
125
+ const record = recordOf(tc);
126
+ if (record && isRecordedTest(record)) {
127
+ return this.toManifestTest(tc, record);
128
+ }
129
+ if (tc.result().state !== 'skipped') {
130
+ return undefined;
131
+ }
132
+ return previous?.tests.find((test) => test.id === tc.id && test.fullName === tc.fullName && isRecordedTest(test));
66
133
  }
67
- toManifestTest(tc) {
68
- const record = tc.meta()[META_KEY];
134
+ toManifestTest(tc, record) {
69
135
  const result = tc.result();
70
- const frames = (record?.frames ?? []).map((frame) => ({
136
+ const frames = record.frames.map((frame) => ({
71
137
  id: frame.id,
72
138
  kind: frame.kind,
73
139
  label: frame.label,
74
140
  at: frame.at,
75
141
  meta: frame.meta,
76
- snapshot: this.snapshots.write(frame.snapshot),
142
+ snapshot: this.writeSnapshot(frame.snapshot, record.origin),
77
143
  }));
78
144
  return {
79
145
  id: tc.id,
@@ -83,8 +149,18 @@ export default class DescribeMeReporter {
83
149
  state: result.state ?? 'pending',
84
150
  duration: tc.diagnostic()?.duration,
85
151
  errors: result.errors?.map((error) => ({ message: error.message, stack: error.stack })),
86
- component: record?.component,
152
+ component: record.component,
87
153
  frames,
88
154
  };
89
155
  }
156
+ /**
157
+ * Store one frame's DOM, with asset URLs pointing into the asset store and
158
+ * long stylesheets moved to the style store. Assets go first, so stored CSS
159
+ * carries asset URLs too.
160
+ */
161
+ writeSnapshot(snapshot, origin) {
162
+ // A mixed install, where an older core created the global recorder, still hands over objects.
163
+ const json = typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot);
164
+ return this.snapshots.write(this.styles.extract(this.assets.rewrite(json, origin)));
165
+ }
90
166
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Travels from the plugin to the setup files through Vitest's `provide` /
3
+ * `inject`. Keep it plain JSON: the browser receives it serialized, so a
4
+ * RegExp would not survive. The test runtime imports this file, so it imports
5
+ * nothing itself.
6
+ */
7
+ export interface CompiledGlob {
8
+ source: string;
9
+ flags: string;
10
+ }
11
+ export interface RuntimeOptions {
12
+ /** Empty means every test file. */
13
+ include: CompiledGlob[];
14
+ /** Wins over `include`. */
15
+ exclude: CompiledGlob[];
16
+ }
17
+ export declare const RUNTIME_OPTIONS_KEY: "describe-me";
18
+ declare module 'vitest' {
19
+ interface ProvidedContext {
20
+ 'describe-me': RuntimeOptions;
21
+ }
22
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Travels from the plugin to the setup files through Vitest's `provide` /
3
+ * `inject`. Keep it plain JSON: the browser receives it serialized, so a
4
+ * RegExp would not survive. The test runtime imports this file, so it imports
5
+ * nothing itself.
6
+ */
7
+ export const RUNTIME_OPTIONS_KEY = 'describe-me';
package/dist/setup-dom.js CHANGED
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * Vitest setup file for DOM environments such as jsdom:
3
3
  * `setupFiles: ['@describe-me/vitest/setup-dom']`. Patches Testing Library's
4
- * userEvent so every interaction records a frame, and registers the
5
- * recording hooks. Never imports `vitest/browser`, which only exists in
6
- * browser mode.
4
+ * userEvent, when it is installed, so every interaction records a frame, sets
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.
7
8
  */
9
+ import { mirrorReactActEnvironment } from './mirror-react-act-environment.js';
8
10
  import { patchTestingLibraryUserEvent } from './patch-testing-library-user-event.js';
11
+ import { readRuntimeOptions } from './read-runtime-options.js';
9
12
  import { registerRecordingHooks } from './register-recording-hooks.js';
10
- patchTestingLibraryUserEvent();
11
- registerRecordingHooks();
13
+ await patchTestingLibraryUserEvent();
14
+ mirrorReactActEnvironment();
15
+ registerRecordingHooks(readRuntimeOptions());
package/dist/setup.js CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
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
- * frame, and registers the recording hooks. For jsdom use `./setup-dom`.
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
6
  */
6
7
  import { patchLocators } from './patch-locators.js';
7
8
  import { patchUserEvent } from './patch-user-event.js';
9
+ import { readRuntimeOptions } from './read-runtime-options.js';
8
10
  import { registerRecordingHooks } from './register-recording-hooks.js';
9
11
  patchLocators();
10
12
  patchUserEvent();
11
- registerRecordingHooks();
13
+ registerRecordingHooks(readRuntimeOptions());
@@ -5,8 +5,8 @@
5
5
  export declare class SnapshotStore {
6
6
  private readonly dir;
7
7
  constructor(outDir: string);
8
- /** Write one snapshot and return its path relative to the manifest. */
9
- write(snapshot: unknown): string;
8
+ /** Write one serialized snapshot and return its path relative to the manifest. */
9
+ write(json: string): string;
10
10
  /** Drop the files no manifest frame points at any more. */
11
11
  collectGarbage(referenced: Iterable<string>): void;
12
12
  }
@@ -11,9 +11,8 @@ export class SnapshotStore {
11
11
  this.dir = join(outDir, 'snapshots');
12
12
  mkdirSync(this.dir, { recursive: true });
13
13
  }
14
- /** Write one snapshot and return its path relative to the manifest. */
15
- write(snapshot) {
16
- const json = JSON.stringify(snapshot);
14
+ /** Write one serialized snapshot and return its path relative to the manifest. */
15
+ write(json) {
17
16
  const hash = createHash('sha1').update(json).digest('hex').slice(0, 16);
18
17
  const file = `${hash}.json`;
19
18
  const abs = join(this.dir, file);
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Split a stylesheet into chunks of about 4 KB, cut between top-level rules
3
+ * where their content decides, so a sheet that grows from test to test keeps
4
+ * most of its chunks. `chunks.join('')` is always the input, even for
5
+ * malformed CSS.
6
+ */
7
+ export declare function splitStylesheet(css: string): string[];
@@ -0,0 +1,114 @@
1
+ /** A chunk is never closed below this length, so small rules do not end up in tiny files. */
2
+ const MIN_CHUNK = 1024;
3
+ /**
4
+ * A chunk is closed once it reaches this length. Only its last rule can take it
5
+ * past the limit, because a rule is never split.
6
+ */
7
+ const MAX_CHUNK = 16384;
8
+ /**
9
+ * Past `MIN_CHUNK`, a rule of `n` characters ends its chunk with a probability
10
+ * of about `n / BOUNDARY_SPACING`, decided by a hash of its text. Chunks then
11
+ * average about 4 KB, and where they end depends on content, not on offsets.
12
+ */
13
+ const BOUNDARY_SPACING = 3072;
14
+ /** Boundaries only need a cheap, stable hash, not sha1. */
15
+ function fnv1a32(text) {
16
+ let hash = 0x811c9dc5;
17
+ for (let i = 0; i < text.length; i++) {
18
+ hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
19
+ }
20
+ return hash >>> 0;
21
+ }
22
+ function isBoundary(piece) {
23
+ return fnv1a32(piece) % BOUNDARY_SPACING < piece.length;
24
+ }
25
+ function endOfComment(css, start) {
26
+ const close = css.indexOf('*/', start + 2);
27
+ return close === -1 ? css.length : close + 2;
28
+ }
29
+ function endOfString(css, start) {
30
+ const quote = css[start];
31
+ let i = start + 1;
32
+ while (i < css.length) {
33
+ if (css[i] === '\\') {
34
+ i += 2;
35
+ }
36
+ else if (css[i] === quote) {
37
+ return i + 1;
38
+ }
39
+ else {
40
+ i++;
41
+ }
42
+ }
43
+ return css.length;
44
+ }
45
+ /**
46
+ * Cut a stylesheet after every top-level rule: after the `}` that closes a
47
+ * top-level block, or after a semicolon at depth 0 (`@import`). Strings,
48
+ * comments and escapes are skipped and nothing in parentheses counts, so
49
+ * `content: "}"` or an unquoted font URL with semicolons never ends a rule.
50
+ */
51
+ function topLevelPieces(css) {
52
+ const pieces = [];
53
+ let start = 0;
54
+ let braces = 0;
55
+ let parens = 0;
56
+ let i = 0;
57
+ while (i < css.length) {
58
+ const char = css[i];
59
+ if (char === '\\') {
60
+ i += 2;
61
+ continue;
62
+ }
63
+ if (char === '/' && css[i + 1] === '*') {
64
+ i = endOfComment(css, i);
65
+ continue;
66
+ }
67
+ if (char === '"' || char === "'") {
68
+ i = endOfString(css, i);
69
+ continue;
70
+ }
71
+ if (char === '(') {
72
+ parens++;
73
+ }
74
+ else if (char === ')') {
75
+ parens = Math.max(0, parens - 1);
76
+ }
77
+ else if (parens === 0 && char === '{') {
78
+ braces++;
79
+ }
80
+ else if (parens === 0 && char === '}') {
81
+ braces = Math.max(0, braces - 1);
82
+ }
83
+ i++;
84
+ if (parens === 0 && braces === 0 && (char === '}' || char === ';')) {
85
+ pieces.push(css.slice(start, i));
86
+ start = i;
87
+ }
88
+ }
89
+ if (start < css.length) {
90
+ pieces.push(css.slice(start));
91
+ }
92
+ return pieces;
93
+ }
94
+ /**
95
+ * Split a stylesheet into chunks of about 4 KB, cut between top-level rules
96
+ * where their content decides, so a sheet that grows from test to test keeps
97
+ * most of its chunks. `chunks.join('')` is always the input, even for
98
+ * malformed CSS.
99
+ */
100
+ export function splitStylesheet(css) {
101
+ const chunks = [];
102
+ let current = '';
103
+ for (const piece of topLevelPieces(css)) {
104
+ current += piece;
105
+ if (current.length >= MAX_CHUNK || (current.length >= MIN_CHUNK && isBoundary(piece))) {
106
+ chunks.push(current);
107
+ current = '';
108
+ }
109
+ }
110
+ if (current !== '') {
111
+ chunks.push(current);
112
+ }
113
+ return chunks;
114
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Content-addressed store for the stylesheets rrweb copies into every capture,
3
+ * living in `<outDir>/styles`. A sheet is stored once, in chunks, so one that
4
+ * grows from test to test shares most of them.
5
+ */
6
+ export declare class StyleStore {
7
+ private readonly outDir;
8
+ private readonly dir;
9
+ /** Snapshots are content-addressed, so a cached entry never goes stale. */
10
+ private readonly referencesBySnapshot;
11
+ constructor(outDir: string);
12
+ /**
13
+ * Replace each long `_cssText` in a serialized snapshot with a reference to
14
+ * its chunks, writing the ones not stored yet. Idempotent.
15
+ */
16
+ extract(json: string): string;
17
+ /**
18
+ * Drop the chunks no kept snapshot refers to, and return the kept ones as
19
+ * sorted `styles/<hash>.css` paths for the asset store. Paths are relative to
20
+ * the output directory, as the manifest stores them.
21
+ */
22
+ collectGarbage(snapshotFiles: Iterable<string>): string[];
23
+ private write;
24
+ private referencesIn;
25
+ }
@@ -0,0 +1,90 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { STYLE_URL_PREFIX } from '@describe-me/core/types';
5
+ import { splitStylesheet } from './split-stylesheet.js';
6
+ /** Shorter sheets stay inline, where they cost less than a reference and a file of their own. */
7
+ const MIN_EXTRACTED_LENGTH = 256;
8
+ /**
9
+ * Inside a JSON string every `"` is escaped, so only a real `_cssText` key
10
+ * matches. Unrolled, because `(?:[^"\\]|\\.)*` is much slower on
11
+ * megabyte-long sheets.
12
+ */
13
+ const CSS_TEXT_MEMBER = /"_cssText":"([^"\\]*(?:\\.[^"\\]*)*)"/g;
14
+ const STYLE_REFERENCE = new RegExp(`${STYLE_URL_PREFIX}([0-9a-f]{16}(?:\\+[0-9a-f]{16})*)`, 'g');
15
+ /**
16
+ * Content-addressed store for the stylesheets rrweb copies into every capture,
17
+ * living in `<outDir>/styles`. A sheet is stored once, in chunks, so one that
18
+ * grows from test to test shares most of them.
19
+ */
20
+ export class StyleStore {
21
+ outDir;
22
+ dir;
23
+ /** Snapshots are content-addressed, so a cached entry never goes stale. */
24
+ referencesBySnapshot = new Map();
25
+ constructor(outDir) {
26
+ this.outDir = outDir;
27
+ this.dir = join(outDir, 'styles');
28
+ mkdirSync(this.dir, { recursive: true });
29
+ }
30
+ /**
31
+ * Replace each long `_cssText` in a serialized snapshot with a reference to
32
+ * its chunks, writing the ones not stored yet. Idempotent.
33
+ */
34
+ extract(json) {
35
+ return json.replace(CSS_TEXT_MEMBER, (member, escaped) => {
36
+ const css = JSON.parse(`"${escaped}"`);
37
+ if (css.length < MIN_EXTRACTED_LENGTH || css.startsWith(STYLE_URL_PREFIX)) {
38
+ return member;
39
+ }
40
+ const hashes = splitStylesheet(css).map((chunk) => this.write(chunk));
41
+ return `"_cssText":"${STYLE_URL_PREFIX}${hashes.join('+')}"`;
42
+ });
43
+ }
44
+ /**
45
+ * Drop the chunks no kept snapshot refers to, and return the kept ones as
46
+ * sorted `styles/<hash>.css` paths for the asset store. Paths are relative to
47
+ * the output directory, as the manifest stores them.
48
+ */
49
+ collectGarbage(snapshotFiles) {
50
+ const keep = new Set();
51
+ for (const snapshotFile of snapshotFiles) {
52
+ for (const hash of this.referencesIn(snapshotFile)) {
53
+ keep.add(`${hash}.css`);
54
+ }
55
+ }
56
+ const kept = [];
57
+ for (const file of readdirSync(this.dir)) {
58
+ if (keep.has(file)) {
59
+ kept.push(`styles/${file}`);
60
+ }
61
+ else {
62
+ rmSync(join(this.dir, file), { force: true });
63
+ }
64
+ }
65
+ return kept.sort();
66
+ }
67
+ write(chunk) {
68
+ const hash = createHash('sha1').update(chunk).digest('hex').slice(0, 16);
69
+ const file = join(this.dir, `${hash}.css`);
70
+ // Not remembered in memory: in watch mode, GC can delete the file between runs.
71
+ if (!existsSync(file)) {
72
+ writeFileSync(file, chunk);
73
+ }
74
+ return hash;
75
+ }
76
+ referencesIn(snapshotFile) {
77
+ const cached = this.referencesBySnapshot.get(snapshotFile);
78
+ if (cached) {
79
+ return cached;
80
+ }
81
+ const path = join(this.outDir, snapshotFile);
82
+ if (!statSync(path, { throwIfNoEntry: false })?.isFile()) {
83
+ return [];
84
+ }
85
+ const json = readFileSync(path, 'utf8');
86
+ const hashes = Array.from(json.matchAll(STYLE_REFERENCE), (match) => match[1].split('+')).flat();
87
+ this.referencesBySnapshot.set(snapshotFile, hashes);
88
+ return hashes;
89
+ }
90
+ }
@@ -0,0 +1,15 @@
1
+ import type { ViteUserConfig } from 'vitest/config';
2
+ /**
3
+ * The Vite and Vitest config that makes a DOM environment load the browser
4
+ * build of styled-components, or undefined when the project does not use
5
+ * styled-components 5 or later.
6
+ *
7
+ * Vitest resolves the Node build, whose `createGlobalStyle` never inserts its
8
+ * CSS on the client, so global resets and fonts would be missing from every
9
+ * snapshot. The alias points `styled-components` at the browser ESM file. The
10
+ * optimizer is needed as well: jest-styled-components `require`s
11
+ * styled-components through Node, which ignores the alias, and would get a
12
+ * second instance whose styles its serializer cannot see. Bundling both
13
+ * together gives them one.
14
+ */
15
+ export declare function styledComponentsBrowserBuild(root: string): ViteUserConfig | undefined;
@@ -0,0 +1,69 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ function resolverFor(root) {
5
+ // createRequire wants an absolute file inside the directory; it does not have to exist.
6
+ const requireFromRoot = createRequire(join(resolve(root), 'package.json'));
7
+ return (id) => {
8
+ try {
9
+ return requireFromRoot.resolve(id);
10
+ }
11
+ catch {
12
+ return undefined;
13
+ }
14
+ };
15
+ }
16
+ function withoutDotSlash(path) {
17
+ return path.replace(/^\.\//, '');
18
+ }
19
+ /** The file the package's `browser` map swaps its `module` entry for, relative to the package. */
20
+ function browserModule(pkg) {
21
+ if (!pkg.module || !pkg.browser || typeof pkg.browser === 'string') {
22
+ return undefined;
23
+ }
24
+ const moduleEntry = withoutDotSlash(pkg.module);
25
+ for (const [from, to] of Object.entries(pkg.browser)) {
26
+ if (withoutDotSlash(from) === moduleEntry && typeof to === 'string') {
27
+ return to;
28
+ }
29
+ }
30
+ return undefined;
31
+ }
32
+ /**
33
+ * The Vite and Vitest config that makes a DOM environment load the browser
34
+ * build of styled-components, or undefined when the project does not use
35
+ * styled-components 5 or later.
36
+ *
37
+ * Vitest resolves the Node build, whose `createGlobalStyle` never inserts its
38
+ * CSS on the client, so global resets and fonts would be missing from every
39
+ * snapshot. The alias points `styled-components` at the browser ESM file. The
40
+ * optimizer is needed as well: jest-styled-components `require`s
41
+ * styled-components through Node, which ignores the alias, and would get a
42
+ * second instance whose styles its serializer cannot see. Bundling both
43
+ * together gives them one.
44
+ */
45
+ export function styledComponentsBrowserBuild(root) {
46
+ const resolveFromRoot = resolverFor(root);
47
+ const manifest = resolveFromRoot('styled-components/package.json');
48
+ if (!manifest) {
49
+ return undefined;
50
+ }
51
+ const pkg = JSON.parse(readFileSync(manifest, 'utf8'));
52
+ const major = Number.parseInt(pkg.version ?? '', 10);
53
+ const browserFile = browserModule(pkg);
54
+ if (Number.isNaN(major) || major < 5 || !browserFile) {
55
+ return undefined;
56
+ }
57
+ const replacement = join(dirname(manifest), browserFile);
58
+ if (!existsSync(replacement)) {
59
+ return undefined;
60
+ }
61
+ const include = ['styled-components'];
62
+ if (resolveFromRoot('jest-styled-components')) {
63
+ include.push('jest-styled-components');
64
+ }
65
+ return {
66
+ resolve: { alias: [{ find: /^styled-components$/, replacement }] },
67
+ test: { deps: { optimizer: { client: { enabled: true, include } } } },
68
+ };
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@describe-me/vitest",
3
- "version": "0.3.0",
3
+ "version": "0.5.0-next.0",
4
4
  "description": "Vitest integration for describe-me: plugin, setup file and reporter",
5
5
  "keywords": [
6
6
  "vitest",
@@ -58,12 +58,13 @@
58
58
  "./dist/setup-dom.js"
59
59
  ],
60
60
  "dependencies": {
61
- "@describe-me/core": "^0.3.0"
61
+ "picomatch": "^4.0.3",
62
+ "@describe-me/core": "^0.5.0-next.0"
62
63
  },
63
64
  "peerDependencies": {
64
- "vitest": ">=4",
65
- "typescript": ">=5.0",
66
- "@testing-library/user-event": ">=14"
65
+ "vitest": "^4.0.0 || ^5.0.0",
66
+ "typescript": "^5.0.0 || ^6.0.0",
67
+ "@testing-library/user-event": "^14.0.0"
67
68
  },
68
69
  "peerDependenciesMeta": {
69
70
  "typescript": {
@@ -78,7 +79,8 @@
78
79
  "@types/node": "^22",
79
80
  "typescript": "^6",
80
81
  "@testing-library/user-event": "^14.6.1",
81
- "@testing-library/dom": "^10.4.0"
82
+ "@testing-library/dom": "^10.4.0",
83
+ "@types/picomatch": "^4.0.3"
82
84
  },
83
85
  "scripts": {
84
86
  "build": "rimraf dist && tsc -p tsconfig.json"
@@ -1,6 +0,0 @@
1
- /**
2
- * A readable target for a frame label, e.g. `button "show details"`.
3
- * Testing Library hands interactions a real `Element` rather than a Vitest
4
- * locator, so the label is read off the element instead of a selector string.
5
- */
6
- export declare function elementLabel(target: unknown): string | undefined;