@describe-me/vitest 0.3.0 → 0.4.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.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Content-addressed store for the project files snapshots point at (images,
3
+ * fonts, `url()` targets of stylesheets), living in `<outDir>/assets`.
4
+ *
5
+ * 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
+ * resolve against itself. `rewrite()` copies each file it finds in the project
8
+ * and swaps its URL for `ASSET_URL_PREFIX` plus the stored name, which the
9
+ * viewer resolves against its own data directory.
10
+ */
11
+ export declare class AssetStore {
12
+ private readonly outDir;
13
+ private readonly dir;
14
+ private readonly root;
15
+ /** Stored name per URL pathname in this run, or null when it is not a project file. */
16
+ private readonly names;
17
+ private readonly notFound;
18
+ /** Asset names per snapshot file. Snapshots are content-addressed, so an entry never goes stale. */
19
+ private readonly referencesBySnapshot;
20
+ constructor(outDir: string, root: string);
21
+ /** Forget what the previous run found: files may have changed or appeared since. */
22
+ startRun(): void;
23
+ /**
24
+ * Replace every URL in a serialized snapshot that points at a project file
25
+ * with an asset URL. rrweb has made most of them absolute against `origin`:
26
+ * `src`, `href`, `srcset`, `xlink:href`, `style` attributes and, in a real
27
+ * browser, `url()` inside stylesheets. The two it leaves root-relative are
28
+ * handled on their own. A `#fragment` is kept, for SVG sprites.
29
+ */
30
+ rewrite(json: string, origin: string | undefined): string;
31
+ /** Root-relative paths that snapshots in this run pointed at but that do not exist. */
32
+ missing(): string[];
33
+ /**
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.
38
+ */
39
+ collectGarbage(snapshotFiles: Iterable<string>): void;
40
+ private referencesIn;
41
+ /** The asset URL for a root-relative URL path, or null when it is not a project file. */
42
+ private assetUrl;
43
+ /** The stored name for a URL pathname, copying the file on first sight in this run. */
44
+ private nameFor;
45
+ private copyIntoStore;
46
+ /** Where Vite serves a URL pathname from: `/@fs/`, the project root, or its `public/` folder. */
47
+ private fileFor;
48
+ }
@@ -0,0 +1,230 @@
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';
4
+ import { ASSET_URL_PREFIX } from '@describe-me/core/types';
5
+ /** A URL path inside serialized JSON runs until a quote, whitespace, `)`, `\`, `<` or `>`. */
6
+ const URL_PATH = String.raw `/[^"'\s)\\<>]*`;
7
+ /** A root-relative path: one slash, not the two of a protocol-relative URL. */
8
+ const ROOT_RELATIVE_PATH = String.raw `/(?!/)[^"'\s)\\<>]*`;
9
+ /**
10
+ * `url(/…)` in CSS text, with the opening quote as it looks inside JSON (`\"`,
11
+ * `'` or none). rrweb makes stylesheet URLs absolute through the sheet's
12
+ * `ownerNode`, which jsdom does not implement, so in jsdom they stay as Vite
13
+ * wrote them.
14
+ */
15
+ const CSS_ROOT_RELATIVE_URL = new RegExp(String.raw `url\((\\"|')?(${ROOT_RELATIVE_PATH})`, 'g');
16
+ /** rrweb makes `src` and `href` absolute, but leaves a video's `poster` as authored. */
17
+ const ROOT_RELATIVE_POSTER = new RegExp(`"poster":"(${ROOT_RELATIVE_PATH})`, 'g');
18
+ /** A stored asset's name: a content hash and, when the file has one, its extension. */
19
+ const ASSET_REFERENCE = new RegExp(`${escapeRegExp(ASSET_URL_PREFIX)}([0-9a-f]{16}(?:\\.[a-z0-9]+)?)`, 'g');
20
+ /**
21
+ * Dev-server paths that are never project files: Vite's client and module ids,
22
+ * the pre-bundled dependencies, and the pages Vitest serves in browser mode.
23
+ */
24
+ const SERVER_PREFIXES = [
25
+ '/@vite/',
26
+ '/@id/',
27
+ '/@react-refresh',
28
+ '/node_modules/.vite/',
29
+ '/__vitest__/',
30
+ '/__vitest_browser__/',
31
+ '/__vitest_test__/',
32
+ ];
33
+ /** Scripts, not assets: they never run in the viewer, so their URLs are left as they are. */
34
+ const MODULE_EXTENSIONS = new Set([
35
+ '.js',
36
+ '.mjs',
37
+ '.cjs',
38
+ '.jsx',
39
+ '.ts',
40
+ '.mts',
41
+ '.cts',
42
+ '.tsx',
43
+ '.vue',
44
+ '.svelte',
45
+ ]);
46
+ function escapeRegExp(text) {
47
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
48
+ }
49
+ function isFile(path) {
50
+ return existsSync(path) && statSync(path).isFile();
51
+ }
52
+ function decodePath(pathname) {
53
+ try {
54
+ return decodeURIComponent(pathname);
55
+ }
56
+ catch {
57
+ return pathname;
58
+ }
59
+ }
60
+ /** Split `/logo.svg?v=2#icon` into its pathname and its fragment; the query is dropped. */
61
+ function splitUrlPath(urlPath) {
62
+ const hashAt = urlPath.indexOf('#');
63
+ const beforeHash = hashAt === -1 ? urlPath : urlPath.slice(0, hashAt);
64
+ const fragment = hashAt === -1 ? '' : urlPath.slice(hashAt);
65
+ const queryAt = beforeHash.indexOf('?');
66
+ const pathname = queryAt === -1 ? beforeHash : beforeHash.slice(0, queryAt);
67
+ return { pathname, fragment };
68
+ }
69
+ function isServerPath(pathname) {
70
+ if (SERVER_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
71
+ return true;
72
+ }
73
+ return MODULE_EXTENSIONS.has(extname(pathname).toLowerCase());
74
+ }
75
+ /** Vite serves files outside the root as `/@fs/<absolute path>`; a Windows path keeps its drive letter. */
76
+ function fsPathFromUrl(pathname) {
77
+ const path = pathname.slice('/@fs/'.length);
78
+ return /^[A-Za-z]:/.test(path) ? path : `/${path}`;
79
+ }
80
+ /**
81
+ * Content-addressed store for the project files snapshots point at (images,
82
+ * fonts, `url()` targets of stylesheets), living in `<outDir>/assets`.
83
+ *
84
+ * 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
86
+ * resolve against itself. `rewrite()` copies each file it finds in the project
87
+ * and swaps its URL for `ASSET_URL_PREFIX` plus the stored name, which the
88
+ * viewer resolves against its own data directory.
89
+ */
90
+ export class AssetStore {
91
+ outDir;
92
+ dir;
93
+ root;
94
+ /** Stored name per URL pathname in this run, or null when it is not a project file. */
95
+ names = new Map();
96
+ notFound = new Set();
97
+ /** Asset names per snapshot file. Snapshots are content-addressed, so an entry never goes stale. */
98
+ referencesBySnapshot = new Map();
99
+ constructor(outDir, root) {
100
+ this.outDir = outDir;
101
+ this.dir = join(outDir, 'assets');
102
+ this.root = resolve(root);
103
+ mkdirSync(this.dir, { recursive: true });
104
+ }
105
+ /** Forget what the previous run found: files may have changed or appeared since. */
106
+ startRun() {
107
+ this.names.clear();
108
+ this.notFound.clear();
109
+ }
110
+ /**
111
+ * Replace every URL in a serialized snapshot that points at a project file
112
+ * with an asset URL. rrweb has made most of them absolute against `origin`:
113
+ * `src`, `href`, `srcset`, `xlink:href`, `style` attributes and, in a real
114
+ * browser, `url()` inside stylesheets. The two it leaves root-relative are
115
+ * handled on their own. A `#fragment` is kept, for SVG sprites.
116
+ */
117
+ rewrite(json, origin) {
118
+ let rewritten = json;
119
+ // An opaque origin serializes as "null", which is not a URL prefix at all.
120
+ if (origin !== undefined && /^https?:\/\//.test(origin)) {
121
+ const onOrigin = new RegExp(`${escapeRegExp(origin)}(${URL_PATH})`, 'g');
122
+ rewritten = rewritten.replace(onOrigin, (url, urlPath) => {
123
+ return this.assetUrl(urlPath) ?? url;
124
+ });
125
+ }
126
+ rewritten = rewritten.replace(CSS_ROOT_RELATIVE_URL, (url, quote, urlPath) => {
127
+ const asset = this.assetUrl(urlPath);
128
+ return asset === null ? url : `url(${quote ?? ''}${asset}`;
129
+ });
130
+ return rewritten.replace(ROOT_RELATIVE_POSTER, (poster, urlPath) => {
131
+ const asset = this.assetUrl(urlPath);
132
+ return asset === null ? poster : `"poster":"${asset}`;
133
+ });
134
+ }
135
+ /** Root-relative paths that snapshots in this run pointed at but that do not exist. */
136
+ missing() {
137
+ return Array.from(this.notFound).sort();
138
+ }
139
+ /**
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.
144
+ */
145
+ collectGarbage(snapshotFiles) {
146
+ const stored = readdirSync(this.dir);
147
+ if (stored.length === 0) {
148
+ return;
149
+ }
150
+ const keep = new Set();
151
+ for (const snapshotFile of snapshotFiles) {
152
+ for (const name of this.referencesIn(snapshotFile)) {
153
+ keep.add(name);
154
+ }
155
+ }
156
+ for (const file of stored) {
157
+ if (!keep.has(file)) {
158
+ rmSync(join(this.dir, file), { force: true });
159
+ }
160
+ }
161
+ }
162
+ referencesIn(snapshotFile) {
163
+ const cached = this.referencesBySnapshot.get(snapshotFile);
164
+ if (cached) {
165
+ return cached;
166
+ }
167
+ const path = join(this.outDir, snapshotFile);
168
+ if (!isFile(path)) {
169
+ return [];
170
+ }
171
+ const json = readFileSync(path, 'utf8');
172
+ const names = Array.from(json.matchAll(ASSET_REFERENCE), (match) => match[1]);
173
+ this.referencesBySnapshot.set(snapshotFile, names);
174
+ return names;
175
+ }
176
+ /** The asset URL for a root-relative URL path, or null when it is not a project file. */
177
+ assetUrl(urlPath) {
178
+ const { pathname, fragment } = splitUrlPath(urlPath);
179
+ const name = this.nameFor(pathname);
180
+ if (name === null) {
181
+ return null;
182
+ }
183
+ return `${ASSET_URL_PREFIX}${name}${fragment}`;
184
+ }
185
+ /** The stored name for a URL pathname, copying the file on first sight in this run. */
186
+ nameFor(pathname) {
187
+ const known = this.names.get(pathname);
188
+ if (known !== undefined) {
189
+ return known;
190
+ }
191
+ const name = this.copyIntoStore(decodePath(pathname));
192
+ this.names.set(pathname, name);
193
+ return name;
194
+ }
195
+ copyIntoStore(pathname) {
196
+ if (isServerPath(pathname)) {
197
+ return null;
198
+ }
199
+ const file = this.fileFor(pathname);
200
+ // Page links such as `/`, `/#` or `/about` resolve to nothing and are not
201
+ // assets either. Only a path that names a file is worth reporting.
202
+ if (!file) {
203
+ if (extname(pathname) !== '') {
204
+ this.notFound.add(pathname);
205
+ }
206
+ return null;
207
+ }
208
+ const hash = createHash('sha1').update(readFileSync(file)).digest('hex').slice(0, 16);
209
+ const extension = extname(file).toLowerCase();
210
+ const name = /^\.[a-z0-9]+$/.test(extension) ? `${hash}${extension}` : hash;
211
+ const target = join(this.dir, name);
212
+ if (!existsSync(target)) {
213
+ copyFileSync(file, target);
214
+ }
215
+ return name;
216
+ }
217
+ /** Where Vite serves a URL pathname from: `/@fs/`, the project root, or its `public/` folder. */
218
+ fileFor(pathname) {
219
+ if (pathname.startsWith('/@fs/')) {
220
+ const path = fsPathFromUrl(pathname);
221
+ return isFile(path) ? path : undefined;
222
+ }
223
+ const candidates = [
224
+ resolve(this.root, `.${pathname}`),
225
+ resolve(this.root, 'public', `.${pathname}`),
226
+ ];
227
+ // A decoded `%2F..` must not reach outside the project.
228
+ return candidates.find((path) => path.startsWith(this.root + sep) && isFile(path));
229
+ }
230
+ }
@@ -3,10 +3,15 @@ import type { ComponentDoc } from '@describe-me/core/types';
3
3
  export interface ComponentEntry {
4
4
  testFile: string;
5
5
  componentName: string;
6
+ /**
7
+ * The module that defines the component, relative to the root, when the
8
+ * plugin registered its exports. Otherwise the test's imports are searched.
9
+ */
10
+ componentFile?: string;
6
11
  }
7
12
  /**
8
- * Document every component the tests rendered: find its source file through the
9
- * test's imports, then read its props from TypeScript. One program covers all of
10
- * them; a component that cannot be found or read is left out of the result.
13
+ * Document every component the tests rendered: find its source file, then read
14
+ * its props from TypeScript. One program covers all of them; a component that
15
+ * cannot be found or read is left out of the result.
11
16
  */
12
17
  export declare function collectComponentDocs(root: string, entries: Iterable<ComponentEntry>): Record<string, ComponentDoc>;
@@ -1,16 +1,16 @@
1
- import { relative, sep } from 'node:path';
1
+ import { relative, resolve, sep } from 'node:path';
2
2
  import ts from 'typescript';
3
3
  import { extractProps } from './extract-props.js';
4
4
  import { loadCompilerOptions } from './load-compiler-options.js';
5
5
  import { resolveComponentFile } from './resolve-component-file.js';
6
6
  /**
7
- * Document every component the tests rendered: find its source file through the
8
- * test's imports, then read its props from TypeScript. One program covers all of
9
- * them; a component that cannot be found or read is left out of the result.
7
+ * Document every component the tests rendered: find its source file, then read
8
+ * its props from TypeScript. One program covers all of them; a component that
9
+ * cannot be found or read is left out of the result.
10
10
  */
11
11
  export function collectComponentDocs(root, entries) {
12
12
  const options = loadCompilerOptions(root);
13
- const resolved = resolveAll(entries, options);
13
+ const resolved = resolveAll(root, Array.from(entries), options);
14
14
  if (resolved.size === 0) {
15
15
  return {};
16
16
  }
@@ -28,9 +28,13 @@ export function collectComponentDocs(root, entries) {
28
28
  }
29
29
  return docs;
30
30
  }
31
- /** One source file per component name; the same component is looked up once. */
32
- function resolveAll(entries, options) {
33
- const resolved = new Map();
31
+ /**
32
+ * One source file per component name. Files the plugin registered come first,
33
+ * because the test's imports are only a guess; among them the first one seen
34
+ * for a name wins. The remaining names are looked up through the imports.
35
+ */
36
+ function resolveAll(root, entries, options) {
37
+ const resolved = registeredComponents(root, entries);
34
38
  for (const entry of entries) {
35
39
  if (resolved.has(entry.componentName)) {
36
40
  continue;
@@ -42,6 +46,31 @@ function resolveAll(entries, options) {
42
46
  }
43
47
  return resolved;
44
48
  }
49
+ /**
50
+ * The components whose file the adapter reported. Docs are keyed by name, so
51
+ * two components exported under the same name from different files cannot
52
+ * both be documented: the first stays, and the clash is reported once.
53
+ */
54
+ function registeredComponents(root, entries) {
55
+ const resolved = new Map();
56
+ const clashes = new Set();
57
+ for (const { componentName, componentFile } of entries) {
58
+ if (componentFile === undefined) {
59
+ continue;
60
+ }
61
+ const file = resolve(root, componentFile);
62
+ const known = resolved.get(componentName);
63
+ if (!known) {
64
+ resolved.set(componentName, { file, exportName: componentName });
65
+ continue;
66
+ }
67
+ if (known.file !== file && !clashes.has(componentName)) {
68
+ clashes.add(componentName);
69
+ console.warn(`[describe-me] two components are named ${componentName}: ${toPosix(relative(root, known.file))} and ${componentFile}. Only the first is documented.`);
70
+ }
71
+ }
72
+ return resolved;
73
+ }
45
74
  /** The manifest is read in a browser, where paths are posix whatever wrote them. */
46
75
  function toPosix(path) {
47
76
  return path.split(sep).join('/');
@@ -5,7 +5,11 @@ export function componentEntries(modules, root) {
5
5
  for (const mod of modules) {
6
6
  for (const test of mod.tests) {
7
7
  if (test.component) {
8
- entries.push({ testFile: resolve(root, mod.id), componentName: test.component.name });
8
+ entries.push({
9
+ testFile: resolve(root, mod.id),
10
+ componentName: test.component.name,
11
+ componentFile: test.component.file,
12
+ });
9
13
  }
10
14
  }
11
15
  }
@@ -1,6 +1,8 @@
1
1
  import ts from 'typescript';
2
2
  /** Type names that mean "renderable children" once TypeScript has printed them. */
3
3
  const NODE_TYPE_NAMES = ['ReactNode', 'ReactElement', 'Element'];
4
+ /** Calls that wrap a component function without changing its props. */
5
+ const COMPONENT_WRAPPERS = ['forwardRef', 'memo'];
4
6
  /**
5
7
  * Read a component's props from its TypeScript type: one PropDoc per own prop,
6
8
  * with the literal choices and the default from the destructuring pattern.
@@ -33,23 +35,46 @@ export function extractProps(program, file, exportName) {
33
35
  .filter(isDeclaredInProject)
34
36
  .map((prop) => toPropDoc(checker, prop, declaration, defaults));
35
37
  }
36
- /** The exported symbol behind `exportName`, with `export { X } from` aliases followed. */
38
+ /**
39
+ * The exported symbol behind `exportName`, with `export { X } from` aliases
40
+ * followed. A default export also answers to its local name, which is the name
41
+ * the plugin registers it under: `export default function Button` is `Button`.
42
+ */
37
43
  function exportedSymbol(checker, sourceFile, exportName) {
38
44
  const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
39
45
  if (!moduleSymbol) {
40
46
  return undefined;
41
47
  }
42
- const exported = checker.getExportsOfModule(moduleSymbol).find((sym) => sym.name === exportName);
43
- if (exported && exported.flags & ts.SymbolFlags.Alias) {
44
- return checker.getAliasedSymbol(exported);
48
+ const exports = checker.getExportsOfModule(moduleSymbol);
49
+ const exported = exports.find((sym) => sym.name === exportName) ??
50
+ exports.find((sym) => {
51
+ return sym.name === 'default' && localNameOf(followAlias(checker, sym)) === exportName;
52
+ });
53
+ return exported && followAlias(checker, exported);
54
+ }
55
+ function followAlias(checker, symbol) {
56
+ if (symbol.flags & ts.SymbolFlags.Alias) {
57
+ return checker.getAliasedSymbol(symbol);
45
58
  }
46
- return exported;
59
+ return symbol;
60
+ }
61
+ /** The name a symbol is declared under, which differs from its export name for `default`. */
62
+ function localNameOf(symbol) {
63
+ const declaration = symbol.declarations?.[0];
64
+ const name = declaration && ts.getNameOfDeclaration(declaration);
65
+ return name && ts.isIdentifier(name) ? name.text : undefined;
47
66
  }
48
- /** The first call signature of the export, or null when it is not callable (not a component). */
67
+ /**
68
+ * The call signature props are read from, or null when the export is not
69
+ * callable (not a component). A plain signature beats a generic one:
70
+ * styled-components 6 puts a generic `as`-aware signature first, whose props
71
+ * TypeScript cannot list until `as` is known, and inherits a plain one.
72
+ */
49
73
  function componentSignature(checker, component, declaration) {
50
74
  const type = checker.getTypeOfSymbolAtLocation(component, declaration);
51
- const [signature] = type.getCallSignatures();
52
- return signature ?? null;
75
+ const signatures = type.getCallSignatures();
76
+ const plain = signatures.find((signature) => !signature.typeParameters?.length);
77
+ return plain ?? signatures[0] ?? null;
53
78
  }
54
79
  /**
55
80
  * Drop props inherited from a library type. `Omit<ButtonHTMLAttributes<…>, …>`
@@ -61,14 +86,12 @@ function isDeclaredInProject(prop) {
61
86
  });
62
87
  }
63
88
  function toPropDoc(checker, prop, location, defaults) {
64
- // An optional prop carries `| undefined`, which says nothing the `required`
65
- // flag does not already say and makes every printed type noisier.
66
- const type = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(prop, location));
89
+ const type = propType(checker, prop, location);
67
90
  const description = ts.displayPartsToString(prop.getDocumentationComment(checker));
68
91
  const shape = classify(checker, type);
69
92
  return {
70
93
  name: prop.name,
71
- type: declaredTypeText(prop) ??
94
+ type: declaredTypeNode(prop)?.getText() ??
72
95
  checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation),
73
96
  required: !(prop.flags & ts.SymbolFlags.Optional),
74
97
  defaultValue: defaults.get(prop.name),
@@ -77,15 +100,42 @@ function toPropDoc(checker, prop, location, defaults) {
77
100
  description: description || undefined,
78
101
  };
79
102
  }
103
+ /**
104
+ * The type a prop is classified by. An optional prop carries `| undefined`,
105
+ * which says nothing the `required` flag does not already say and makes every
106
+ * printed type noisier, so it is dropped.
107
+ *
108
+ * A generic component (`<T extends ElementType>(props: Props<T>)`) leaves prop
109
+ * types that still depend on `T`, such as `Props<T>['variant']`, which cannot
110
+ * be classified. The type the prop was declared with (`'primary' | …`) usually
111
+ * does not depend on `T`, and is used instead. One that does, such as
112
+ * `component?: T`, stays generic and is classified as `other`.
113
+ */
114
+ function propType(checker, prop, location) {
115
+ const atLocation = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(prop, location));
116
+ const node = declaredTypeNode(prop);
117
+ if (!isGeneric(atLocation) || !node) {
118
+ return atLocation;
119
+ }
120
+ const declared = checker.getTypeFromTypeNode(node);
121
+ return isGeneric(declared) ? atLocation : checker.getNonNullableType(declared);
122
+ }
123
+ /** Whether a type still depends on a type parameter: `T`, `Props<T>['size']`, `T & {}`. */
124
+ function isGeneric(type) {
125
+ if (type.flags & ts.TypeFlags.Instantiable) {
126
+ return true;
127
+ }
128
+ return type.isUnionOrIntersection() && type.types.some(isGeneric);
129
+ }
80
130
  /**
81
131
  * The type as the author wrote it (`ReactNode`), when the prop has an
82
132
  * annotation. The checker would otherwise print the expanded alias, which for
83
133
  * ReactNode is a union longer than the table column.
84
134
  */
85
- function declaredTypeText(prop) {
135
+ function declaredTypeNode(prop) {
86
136
  for (const declaration of prop.declarations ?? []) {
87
137
  if (ts.isPropertySignature(declaration) && declaration.type) {
88
- return declaration.type.getText();
138
+ return declaration.type;
89
139
  }
90
140
  }
91
141
  return undefined;
@@ -160,9 +210,31 @@ function functionOf(declaration) {
160
210
  }
161
211
  return undefined;
162
212
  }
213
+ /**
214
+ * The function an expression evaluates to, seen through the wrappers a
215
+ * component is commonly written with: `forwardRef(fn)`, `React.memo(fn)`,
216
+ * `memo(forwardRef<A, B>(fn))`, and `as` / `satisfies` / parentheses.
217
+ */
163
218
  function asFunction(expression) {
164
219
  if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) {
165
220
  return expression;
166
221
  }
167
- return undefined;
222
+ if (ts.isParenthesizedExpression(expression) ||
223
+ ts.isAsExpression(expression) ||
224
+ ts.isSatisfiesExpression(expression) ||
225
+ ts.isTypeAssertionExpression(expression) ||
226
+ ts.isNonNullExpression(expression)) {
227
+ return asFunction(expression.expression);
228
+ }
229
+ const [wrapped] = isComponentWrapperCall(expression) ? expression.arguments : [];
230
+ return wrapped ? asFunction(wrapped) : undefined;
231
+ }
232
+ /** `forwardRef(…)` or `memo(…)`, called bare or on a namespace such as `React`. */
233
+ function isComponentWrapperCall(expression) {
234
+ if (!ts.isCallExpression(expression)) {
235
+ return false;
236
+ }
237
+ const callee = expression.expression;
238
+ const name = ts.isPropertyAccessExpression(callee) ? callee.name : callee;
239
+ return ts.isIdentifier(name) && COMPONENT_WRAPPERS.includes(name.text);
168
240
  }
package/dist/plugin.d.ts CHANGED
@@ -13,6 +13,20 @@ export interface DescribeMeOptions {
13
13
  environment?: DescribeMeEnvironment;
14
14
  /** Output directory, relative to the Vitest root. Default: `.describe-me`. */
15
15
  outDir?: string;
16
+ /**
17
+ * Register the top-level exports of project modules, so a component is named
18
+ * after its export (`Button`) even when it is an anonymous `forwardRef`,
19
+ * `memo` or styled component, and its props are read from the right file.
20
+ * Default: true.
21
+ */
22
+ registerExports?: boolean;
23
+ /**
24
+ * DOM environments only. Load the browser build of styled-components (v5+)
25
+ * instead of the Node one, whose `createGlobalStyle` never inserts its CSS on
26
+ * the client: global resets and fonts would be missing from every snapshot.
27
+ * Applied when styled-components is installed. Default: true.
28
+ */
29
+ styledComponentsBrowserBuild?: boolean;
16
30
  }
17
31
  type VitePlugin = NonNullable<ViteUserConfig['plugins']>[number];
18
32
  /**
package/dist/plugin.js CHANGED
@@ -1,4 +1,7 @@
1
+ import { projectModulePath } from './project-module-path.js';
2
+ import { registerExports } from './register-exports.js';
1
3
  import DescribeMeReporter from './reporter.js';
4
+ import { styledComponentsBrowserBuild } from './styled-components-browser-build.js';
2
5
  /** For each framework and environment: the render module tests import, and our adapter that wraps it. */
3
6
  const RENDER_MODULES = {
4
7
  react: {
@@ -26,7 +29,8 @@ function browserConfig(renderModule) {
26
29
  },
27
30
  };
28
31
  }
29
- function domConfig(userTest) {
32
+ function domConfig(userConfig, options) {
33
+ const userTest = userConfig.test;
30
34
  const test = {
31
35
  setupFiles: ['@describe-me/vitest/setup-dom'],
32
36
  // Testing Library would otherwise unmount in its own afterEach, before the
@@ -41,7 +45,14 @@ function domConfig(userTest) {
41
45
  else if (userTest.css === false) {
42
46
  console.warn('describe-me: `test.css` is false, so imported stylesheets are stubbed and will be missing from the snapshots.');
43
47
  }
44
- return { test };
48
+ const styledComponents = options.styledComponentsBrowserBuild === false
49
+ ? undefined
50
+ : styledComponentsBrowserBuild(userConfig.root ?? process.cwd());
51
+ if (!styledComponents) {
52
+ return { test };
53
+ }
54
+ console.info('describe-me: using the browser build of styled-components, for createGlobalStyle');
55
+ return { resolve: styledComponents.resolve, test: { ...test, ...styledComponents.test } };
45
56
  }
46
57
  /**
47
58
  * One-line integration: `plugins: [describeMe()]`.
@@ -55,6 +66,10 @@ function domConfig(userTest) {
55
66
  export function describeMe(options = {}) {
56
67
  const { enabled = true, framework = 'react', outDir } = options;
57
68
  let renderModule = RENDER_MODULES[framework].browser;
69
+ // Known once Vite has resolved its config. Vitest always serves; a production
70
+ // build that shares the config must not carry the export registration.
71
+ let root = '';
72
+ let serving = false;
58
73
  return {
59
74
  name: 'describe-me',
60
75
  enforce: 'pre',
@@ -68,7 +83,7 @@ export function describeMe(options = {}) {
68
83
  // Vite concatenates arrays when merging, so only add `default` when the
69
84
  // user has not chosen their own reporters; otherwise they would lose it.
70
85
  const reporters = userConfig.test?.reporters ? [reporter] : ['default', reporter];
71
- const config = environment === 'browser' ? browserConfig(renderModule) : domConfig(userConfig.test);
86
+ const config = environment === 'browser' ? browserConfig(renderModule) : domConfig(userConfig, options);
72
87
  return { ...config, test: { ...config.test, reporters } };
73
88
  },
74
89
  async resolveId(source, importer) {
@@ -90,5 +105,41 @@ export function describeMe(options = {}) {
90
105
  }
91
106
  return resolved;
92
107
  },
108
+ configResolved(config) {
109
+ root = config.root;
110
+ serving = config.command === 'serve';
111
+ },
112
+ // Names components after their export (see registerExports). `order: 'post'`
113
+ // runs it after the TypeScript and JSX transforms, so it parses plain
114
+ // JavaScript, while the plugin as a whole stays `enforce: 'pre'` for the
115
+ // render redirect above.
116
+ transform: {
117
+ order: 'post',
118
+ handler(code, id) {
119
+ if (!enabled || options.registerExports === false || !serving) {
120
+ return null;
121
+ }
122
+ const file = projectModulePath(id, root);
123
+ // Without the word `export` there is nothing to register: skip the parse.
124
+ if (file === null || !code.includes('export')) {
125
+ return null;
126
+ }
127
+ let snippet;
128
+ // Parse errors are for the user's own tooling to report, and an older
129
+ // Vite may lack `this.parse`. This transform must never be the reason a
130
+ // module fails, so it steps aside instead.
131
+ try {
132
+ snippet = registerExports(this.parse(code), file);
133
+ }
134
+ catch {
135
+ return null;
136
+ }
137
+ if (snippet === null) {
138
+ return null;
139
+ }
140
+ // Appending moves no code, so the source maps of earlier transforms stay valid.
141
+ return { code: code + snippet, map: null };
142
+ },
143
+ },
93
144
  };
94
145
  }
@@ -0,0 +1,7 @@
1
+ import type { Manifest } from '@describe-me/core/types';
2
+ /**
3
+ * Warn, one line per kind, about what the manifest could not document. Prints
4
+ * nothing when everything is named, documented and loadable. The viewer shows
5
+ * the same list in its header.
6
+ */
7
+ export declare function printDiagnostics(manifest: Manifest): void;
@@ -0,0 +1,31 @@
1
+ import { manifestDiagnostics } from '@describe-me/core/diagnostics';
2
+ /** How many names or paths a warning line spells out before summing up the rest. */
3
+ const SHOWN = 5;
4
+ function listed(items) {
5
+ const shown = items.slice(0, SHOWN).join(', ');
6
+ const rest = items.length - SHOWN;
7
+ if (rest > 0) {
8
+ return `${shown} (+${rest} more)`;
9
+ }
10
+ return shown;
11
+ }
12
+ function plural(count, one, many) {
13
+ return `${count} ${count === 1 ? one : many}`;
14
+ }
15
+ /**
16
+ * Warn, one line per kind, about what the manifest could not document. Prints
17
+ * nothing when everything is named, documented and loadable. The viewer shows
18
+ * the same list in its header.
19
+ */
20
+ export function printDiagnostics(manifest) {
21
+ const { anonymous, undocumented, assetsMissing } = manifestDiagnostics(manifest);
22
+ if (anonymous.length > 0) {
23
+ console.warn(`describe-me: ${plural(anonymous.length, 'test renders', 'tests render')} an anonymous component; export it from a project module or give it a displayName`);
24
+ }
25
+ if (undocumented.length > 0) {
26
+ console.warn(`describe-me: ${plural(undocumented.length, 'component has', 'components have')} no props docs: ${listed(undocumented.map((component) => component.name))}`);
27
+ }
28
+ if (assetsMissing.length > 0) {
29
+ console.warn(`describe-me: ${plural(assetsMissing.length, 'asset was', 'assets were')} not found in the project and will not load in the viewer: ${listed(assetsMissing)}`);
30
+ }
31
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The path of a Vite module id relative to the project root, with posix
3
+ * separators, or null when the module is not the project's own source: a
4
+ * virtual module (`\0…`), a request with a query (`?raw`, `?worker`), anything
5
+ * outside the root or in node_modules, a non-script file, or a test file.
6
+ */
7
+ export declare function projectModulePath(id: string, root: string): string | null;
@@ -0,0 +1,23 @@
1
+ import { isAbsolute, relative, sep } from 'node:path';
2
+ /** Modules that can export a component: JavaScript and TypeScript, with or without JSX. */
3
+ const SCRIPT_MODULE = /\.[cm]?[jt]sx?$/;
4
+ const TEST_MODULE = /\.(test|spec)\.[cm]?[jt]sx?$/;
5
+ /**
6
+ * The path of a Vite module id relative to the project root, with posix
7
+ * separators, or null when the module is not the project's own source: a
8
+ * virtual module (`\0…`), a request with a query (`?raw`, `?worker`), anything
9
+ * outside the root or in node_modules, a non-script file, or a test file.
10
+ */
11
+ export function projectModulePath(id, root) {
12
+ if (root === '' || id.startsWith('\0') || id.includes('?')) {
13
+ return null;
14
+ }
15
+ if (id.includes('/node_modules/') || !SCRIPT_MODULE.test(id) || TEST_MODULE.test(id)) {
16
+ return null;
17
+ }
18
+ const path = relative(root, id);
19
+ if (path === '' || path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)) {
20
+ return null;
21
+ }
22
+ return path.split(sep).join('/');
23
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The part of an ESTree node every node has. Rollup (Vite 6 and 7) and
3
+ * Rolldown (Vite 8) both parse to ESTree, so structural types fit either
4
+ * without depending on their AST packages.
5
+ */
6
+ export interface EstreeNode {
7
+ type: string;
8
+ }
9
+ /** A parsed module, as `this.parse(code)` returns it in a Vite plugin. */
10
+ export interface EstreeProgram {
11
+ body: readonly EstreeNode[];
12
+ }
13
+ /**
14
+ * The code to append to a project module so that each value it exports is
15
+ * registered under its export name, or null when it exports nothing to
16
+ * register. A component is then named `Button` even when it is an anonymous
17
+ * `forwardRef` or a styled component.
18
+ *
19
+ * The appended code imports nothing: a user's module cannot always resolve
20
+ * `@describe-me/core`, and a bundled adapter may carry its own copy of it. It
21
+ * writes to a `WeakMap` on `globalThis` instead, and the first registration of
22
+ * a value wins, so the defining module beats a barrel that re-exports it.
23
+ *
24
+ * Only local bindings are referenced. Re-exports with `from` are skipped (the
25
+ * defining module registers the value itself), and so are anonymous default
26
+ * exports, which have no binding to point at.
27
+ */
28
+ export declare function registerExports(program: EstreeProgram, file: string): string | null;
@@ -0,0 +1,118 @@
1
+ import { EXPORT_REGISTRY_KEY } from '@describe-me/core/types';
2
+ /**
3
+ * The code to append to a project module so that each value it exports is
4
+ * registered under its export name, or null when it exports nothing to
5
+ * register. A component is then named `Button` even when it is an anonymous
6
+ * `forwardRef` or a styled component.
7
+ *
8
+ * The appended code imports nothing: a user's module cannot always resolve
9
+ * `@describe-me/core`, and a bundled adapter may carry its own copy of it. It
10
+ * writes to a `WeakMap` on `globalThis` instead, and the first registration of
11
+ * a value wins, so the defining module beats a barrel that re-exports it.
12
+ *
13
+ * Only local bindings are referenced. Re-exports with `from` are skipped (the
14
+ * defining module registers the value itself), and so are anonymous default
15
+ * exports, which have no binding to point at.
16
+ */
17
+ export function registerExports(program, file) {
18
+ const registrations = program.body.flatMap(registrationsOf);
19
+ if (registrations.length === 0) {
20
+ return null;
21
+ }
22
+ return snippetFor(registrations, file);
23
+ }
24
+ function registrationsOf(node) {
25
+ if (node.type === 'ExportNamedDeclaration') {
26
+ return namedExports(node);
27
+ }
28
+ if (node.type === 'ExportDefaultDeclaration') {
29
+ return defaultExport(node);
30
+ }
31
+ return [];
32
+ }
33
+ /** `export const A = …`, `export function A`, `export class A`, `export { a as A }`. */
34
+ function namedExports(node) {
35
+ if (node.source) {
36
+ return [];
37
+ }
38
+ if (node.declaration) {
39
+ return declaredNames(node.declaration).map((name) => ({ name, local: name }));
40
+ }
41
+ return (node.specifiers ?? []).flatMap(specifierExport);
42
+ }
43
+ /** Identifiers only: a destructuring pattern would need its own walk for little gain. */
44
+ function declaredNames(declaration) {
45
+ let ids = [];
46
+ if (declaration.type === 'VariableDeclaration') {
47
+ ids = declaration.declarations.map((declarator) => declarator.id);
48
+ }
49
+ else if (isFunctionOrClass(declaration)) {
50
+ ids = [declaration.id];
51
+ }
52
+ return ids.map(identifierName).filter((name) => name !== null);
53
+ }
54
+ function specifierExport(specifier) {
55
+ const local = identifierName(specifier.local);
56
+ const exported = exportedName(specifier.exported);
57
+ if (local === null || exported === null) {
58
+ return [];
59
+ }
60
+ // `export { Button as default }` is a default export: its local name says more.
61
+ const name = exported === 'default' ? local : exported;
62
+ return [{ name, local }];
63
+ }
64
+ /**
65
+ * `export default Button` and `export default function Button`, under the local
66
+ * name. A function or class expression (`export default (function A() {})`)
67
+ * binds nothing in the module scope, so it is left out like any anonymous one.
68
+ */
69
+ function defaultExport(node) {
70
+ const { declaration } = node;
71
+ const local = identifierName(isFunctionOrClass(declaration) ? declaration.id : declaration);
72
+ if (local === null) {
73
+ return [];
74
+ }
75
+ return [{ name: local, local }];
76
+ }
77
+ function isFunctionOrClass(node) {
78
+ return node.type === 'FunctionDeclaration' || node.type === 'ClassDeclaration';
79
+ }
80
+ function identifierName(node) {
81
+ if (node?.type !== 'Identifier') {
82
+ return null;
83
+ }
84
+ return node.name;
85
+ }
86
+ /** `export { a as A }` or, since ES2022, `export { a as 'A' }`. */
87
+ function exportedName(node) {
88
+ if (node.type === 'Literal') {
89
+ const { value } = node;
90
+ return typeof value === 'string' ? value : null;
91
+ }
92
+ return identifierName(node);
93
+ }
94
+ /**
95
+ * Appended after the module's last line, so no existing code moves and the
96
+ * source maps from earlier transforms stay valid. Starts on a new line and
97
+ * with a semicolon, in case the module ends in a comment or an unterminated
98
+ * expression.
99
+ */
100
+ function snippetFor(registrations, file) {
101
+ const entries = registrations
102
+ .map(({ name, local }) => `[${JSON.stringify(name)}, ${local}]`)
103
+ .join(', ');
104
+ return [
105
+ '',
106
+ '// describe-me: name components after their export (registerExports option)',
107
+ ';((key, file, entries) => {',
108
+ ' const registry = globalThis[key] || (globalThis[key] = new WeakMap())',
109
+ ' for (const [name, value] of entries) {',
110
+ " const isObject = typeof value === 'function' || (typeof value === 'object' && value !== null)",
111
+ ' if (isObject && !registry.has(value)) {',
112
+ ' registry.set(value, { name, file })',
113
+ ' }',
114
+ ' }',
115
+ `})(Symbol.for(${JSON.stringify(EXPORT_REGISTRY_KEY)}), ${JSON.stringify(file)}, [${entries}])`,
116
+ '',
117
+ ].join('\n');
118
+ }
@@ -8,9 +8,11 @@ export default class DescribeMeReporter implements Reporter {
8
8
  private root;
9
9
  private outDir;
10
10
  private snapshots;
11
+ private assets;
11
12
  private modules;
12
13
  constructor(options?: DescribeMeReporterOptions);
13
14
  onInit(vitest: Vitest): void;
15
+ onTestRunStart(): void;
14
16
  /**
15
17
  * A filtered run (`vitest run Button.test.tsx`) must not wipe the other
16
18
  * modules from the manifest, so start from whatever the last run wrote.
@@ -20,4 +22,6 @@ export default class DescribeMeReporter implements Reporter {
20
22
  onTestModuleEnd(module: TestModule): void;
21
23
  onTestRunEnd(): Promise<void>;
22
24
  private toManifestTest;
25
+ /** Store one frame's DOM, with its project asset URLs pointing into the asset store. */
26
+ private writeSnapshot;
23
27
  }
package/dist/reporter.js CHANGED
@@ -5,8 +5,10 @@
5
5
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
6
6
  import { join, relative, resolve } 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';
9
10
  import { componentEntries } from './component-entries.js';
11
+ import { printDiagnostics } from './print-diagnostics.js';
10
12
  import { SnapshotStore } from './snapshot-store.js';
11
13
  import { testPath } from './test-path.js';
12
14
  export default class DescribeMeReporter {
@@ -14,6 +16,7 @@ export default class DescribeMeReporter {
14
16
  root = process.cwd();
15
17
  outDir = '';
16
18
  snapshots;
19
+ assets;
17
20
  modules = new Map();
18
21
  constructor(options = {}) {
19
22
  this.outDirOption = options.outDir ?? '.describe-me';
@@ -22,8 +25,12 @@ export default class DescribeMeReporter {
22
25
  this.root = vitest.config.root;
23
26
  this.outDir = resolve(this.root, this.outDirOption);
24
27
  this.snapshots = new SnapshotStore(this.outDir);
28
+ this.assets = new AssetStore(this.outDir, this.root);
25
29
  this.seedFromPreviousRun();
26
30
  }
31
+ onTestRunStart() {
32
+ this.assets.startRun();
33
+ }
27
34
  /**
28
35
  * A filtered run (`vitest run Button.test.tsx`) must not wipe the other
29
36
  * modules from the manifest, so start from whatever the last run wrote.
@@ -54,15 +61,22 @@ export default class DescribeMeReporter {
54
61
  async onTestRunEnd() {
55
62
  const modules = Array.from(this.modules.values()).sort((left, right) => left.id.localeCompare(right.id));
56
63
  const components = await collectComponentDocsSafely(this.root, componentEntries(modules, this.root));
64
+ // Only what this run found: a filtered run does not know which of the
65
+ // previous run's missing assets belonged to the modules it skipped.
66
+ const assetsMissing = this.assets.missing();
57
67
  const manifest = {
58
68
  version: 1,
59
69
  generatedAt: new Date().toISOString(),
60
70
  root: this.root,
61
71
  modules,
62
72
  components,
73
+ assetsMissing,
63
74
  };
64
- this.snapshots.collectGarbage(manifest.modules.flatMap((mod) => mod.tests.flatMap((test) => test.frames.map((frame) => frame.snapshot))));
75
+ const snapshotFiles = manifest.modules.flatMap((mod) => mod.tests.flatMap((test) => test.frames.map((frame) => frame.snapshot)));
76
+ this.snapshots.collectGarbage(snapshotFiles);
77
+ this.assets.collectGarbage(snapshotFiles);
65
78
  writeFileSync(join(this.outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
79
+ printDiagnostics(manifest);
66
80
  }
67
81
  toManifestTest(tc) {
68
82
  const record = tc.meta()[META_KEY];
@@ -73,7 +87,7 @@ export default class DescribeMeReporter {
73
87
  label: frame.label,
74
88
  at: frame.at,
75
89
  meta: frame.meta,
76
- snapshot: this.snapshots.write(frame.snapshot),
90
+ snapshot: this.writeSnapshot(frame.snapshot, record?.origin),
77
91
  }));
78
92
  return {
79
93
  id: tc.id,
@@ -87,4 +101,8 @@ export default class DescribeMeReporter {
87
101
  frames,
88
102
  };
89
103
  }
104
+ /** Store one frame's DOM, with its project asset URLs pointing into the asset store. */
105
+ writeSnapshot(snapshot, origin) {
106
+ return this.snapshots.write(this.assets.rewrite(JSON.stringify(snapshot), origin));
107
+ }
90
108
  }
@@ -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,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.4.0",
4
4
  "description": "Vitest integration for describe-me: plugin, setup file and reporter",
5
5
  "keywords": [
6
6
  "vitest",
@@ -58,7 +58,7 @@
58
58
  "./dist/setup-dom.js"
59
59
  ],
60
60
  "dependencies": {
61
- "@describe-me/core": "^0.3.0"
61
+ "@describe-me/core": "^0.4.0"
62
62
  },
63
63
  "peerDependencies": {
64
64
  "vitest": ">=4",