@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/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,17 @@ 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
+ The pieces can also be wired by hand. Tests then import from the adapter
59
+ themselves: `render` from `@describe-me/react` in browser mode, and `render`,
60
+ `fireEvent` and `cleanup` from `@describe-me/react/testing-library` in jsdom:
50
61
 
51
62
  ```ts
52
63
  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
+ }
@@ -0,0 +1,51 @@
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
+ /**
19
+ * Asset names per snapshot or style chunk. Both are content-addressed, so an
20
+ * entry never goes stale.
21
+ */
22
+ private readonly referencesByFile;
23
+ constructor(outDir: string, root: string);
24
+ /** Forget what the previous run found: files may have changed or appeared since. */
25
+ startRun(): void;
26
+ /**
27
+ * Replace every URL in a serialized snapshot that points at a project file
28
+ * with an asset URL. rrweb has made most of them absolute against `origin`:
29
+ * `src`, `href`, `srcset`, `xlink:href`, `style` attributes and, in a real
30
+ * browser, `url()` inside stylesheets. The two it leaves root-relative are
31
+ * handled on their own. A `#fragment` is kept, for SVG sprites.
32
+ */
33
+ rewrite(json: string, origin: string | undefined): string;
34
+ /** Root-relative paths that snapshots in this run pointed at but that do not exist. */
35
+ missing(): string[];
36
+ /**
37
+ * Drop the assets no kept snapshot or style chunk refers to. Paths are
38
+ * relative to the output directory, as the manifest stores them. Every kept
39
+ * file is scanned, not only the ones written now: snapshots of modules that
40
+ * were not re-run were rewritten by an earlier run.
41
+ */
42
+ collectGarbage(files: Iterable<string>): void;
43
+ private referencesIn;
44
+ /** The asset URL for a root-relative URL path, or null when it is not a project file. */
45
+ private assetUrl;
46
+ /** The stored name for a URL pathname, copying the file on first sight in this run. */
47
+ private nameFor;
48
+ private copyIntoStore;
49
+ /** Where Vite serves a URL pathname from: `/@fs/`, the project root, or its `public/` folder. */
50
+ private fileFor;
51
+ }
@@ -0,0 +1,233 @@
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
+ /**
98
+ * Asset names per snapshot or style chunk. Both are content-addressed, so an
99
+ * entry never goes stale.
100
+ */
101
+ referencesByFile = new Map();
102
+ constructor(outDir, root) {
103
+ this.outDir = outDir;
104
+ this.dir = join(outDir, 'assets');
105
+ this.root = resolve(root);
106
+ mkdirSync(this.dir, { recursive: true });
107
+ }
108
+ /** Forget what the previous run found: files may have changed or appeared since. */
109
+ startRun() {
110
+ this.names.clear();
111
+ this.notFound.clear();
112
+ }
113
+ /**
114
+ * Replace every URL in a serialized snapshot that points at a project file
115
+ * with an asset URL. rrweb has made most of them absolute against `origin`:
116
+ * `src`, `href`, `srcset`, `xlink:href`, `style` attributes and, in a real
117
+ * browser, `url()` inside stylesheets. The two it leaves root-relative are
118
+ * handled on their own. A `#fragment` is kept, for SVG sprites.
119
+ */
120
+ rewrite(json, origin) {
121
+ let rewritten = json;
122
+ // An opaque origin serializes as "null", which is not a URL prefix at all.
123
+ if (origin !== undefined && /^https?:\/\//.test(origin)) {
124
+ const onOrigin = new RegExp(`${escapeRegExp(origin)}(${URL_PATH})`, 'g');
125
+ rewritten = rewritten.replace(onOrigin, (url, urlPath) => {
126
+ return this.assetUrl(urlPath) ?? url;
127
+ });
128
+ }
129
+ rewritten = rewritten.replace(CSS_ROOT_RELATIVE_URL, (url, quote, urlPath) => {
130
+ const asset = this.assetUrl(urlPath);
131
+ return asset === null ? url : `url(${quote ?? ''}${asset}`;
132
+ });
133
+ return rewritten.replace(ROOT_RELATIVE_POSTER, (poster, urlPath) => {
134
+ const asset = this.assetUrl(urlPath);
135
+ return asset === null ? poster : `"poster":"${asset}`;
136
+ });
137
+ }
138
+ /** Root-relative paths that snapshots in this run pointed at but that do not exist. */
139
+ missing() {
140
+ return Array.from(this.notFound).sort();
141
+ }
142
+ /**
143
+ * Drop the assets no kept snapshot or style chunk refers to. Paths are
144
+ * relative to the output directory, as the manifest stores them. Every kept
145
+ * file is scanned, not only the ones written now: snapshots of modules that
146
+ * were not re-run were rewritten by an earlier run.
147
+ */
148
+ collectGarbage(files) {
149
+ const stored = readdirSync(this.dir);
150
+ if (stored.length === 0) {
151
+ return;
152
+ }
153
+ const keep = new Set();
154
+ for (const file of files) {
155
+ for (const name of this.referencesIn(file)) {
156
+ keep.add(name);
157
+ }
158
+ }
159
+ for (const file of stored) {
160
+ if (!keep.has(file)) {
161
+ rmSync(join(this.dir, file), { force: true });
162
+ }
163
+ }
164
+ }
165
+ referencesIn(file) {
166
+ const cached = this.referencesByFile.get(file);
167
+ if (cached) {
168
+ return cached;
169
+ }
170
+ const path = join(this.outDir, file);
171
+ if (!isFile(path)) {
172
+ return [];
173
+ }
174
+ const text = readFileSync(path, 'utf8');
175
+ const names = Array.from(text.matchAll(ASSET_REFERENCE), (match) => match[1]);
176
+ this.referencesByFile.set(file, names);
177
+ return names;
178
+ }
179
+ /** The asset URL for a root-relative URL path, or null when it is not a project file. */
180
+ assetUrl(urlPath) {
181
+ const { pathname, fragment } = splitUrlPath(urlPath);
182
+ const name = this.nameFor(pathname);
183
+ if (name === null) {
184
+ return null;
185
+ }
186
+ return `${ASSET_URL_PREFIX}${name}${fragment}`;
187
+ }
188
+ /** The stored name for a URL pathname, copying the file on first sight in this run. */
189
+ nameFor(pathname) {
190
+ const known = this.names.get(pathname);
191
+ if (known !== undefined) {
192
+ return known;
193
+ }
194
+ const name = this.copyIntoStore(decodePath(pathname));
195
+ this.names.set(pathname, name);
196
+ return name;
197
+ }
198
+ copyIntoStore(pathname) {
199
+ if (isServerPath(pathname)) {
200
+ return null;
201
+ }
202
+ const file = this.fileFor(pathname);
203
+ // Page links such as `/`, `/#` or `/about` resolve to nothing and are not
204
+ // assets either. Only a path that names a file is worth reporting.
205
+ if (!file) {
206
+ if (extname(pathname) !== '') {
207
+ this.notFound.add(pathname);
208
+ }
209
+ return null;
210
+ }
211
+ const hash = createHash('sha1').update(readFileSync(file)).digest('hex').slice(0, 16);
212
+ const extension = extname(file).toLowerCase();
213
+ const name = /^\.[a-z0-9]+$/.test(extension) ? `${hash}${extension}` : hash;
214
+ const target = join(this.dir, name);
215
+ if (!existsSync(target)) {
216
+ copyFileSync(file, target);
217
+ }
218
+ return name;
219
+ }
220
+ /** Where Vite serves a URL pathname from: `/@fs/`, the project root, or its `public/` folder. */
221
+ fileFor(pathname) {
222
+ if (pathname.startsWith('/@fs/')) {
223
+ const path = fsPathFromUrl(pathname);
224
+ return isFile(path) ? path : undefined;
225
+ }
226
+ const candidates = [
227
+ resolve(this.root, `.${pathname}`),
228
+ resolve(this.root, 'public', `.${pathname}`),
229
+ ];
230
+ // A decoded `%2F..` must not reach outside the project.
231
+ return candidates.find((path) => path.startsWith(this.root + sep) && isFile(path));
232
+ }
233
+ }
@@ -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('/');
@@ -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
+ }
@@ -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
  }
@@ -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;