@describe-me/vitest 0.2.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.
- package/README.md +28 -4
- package/dist/asset-store.d.ts +48 -0
- package/dist/asset-store.js +230 -0
- package/dist/collect-component-docs.d.ts +8 -3
- package/dist/collect-component-docs.js +37 -8
- package/dist/component-entries.js +5 -1
- package/dist/element-label.d.ts +6 -0
- package/dist/element-label.js +34 -0
- package/dist/extract-props.js +87 -15
- package/dist/patch-testing-library-user-event.d.ts +8 -0
- package/dist/patch-testing-library-user-event.js +61 -0
- package/dist/plugin.d.ts +24 -2
- package/dist/plugin.js +113 -23
- package/dist/print-diagnostics.d.ts +7 -0
- package/dist/print-diagnostics.js +31 -0
- package/dist/project-module-path.d.ts +7 -0
- package/dist/project-module-path.js +23 -0
- package/dist/register-exports.d.ts +28 -0
- package/dist/register-exports.js +118 -0
- package/dist/register-recording-hooks.d.ts +6 -0
- package/dist/register-recording-hooks.js +17 -0
- package/dist/reporter.d.ts +4 -0
- package/dist/reporter.js +20 -2
- package/dist/setup-dom.d.ts +1 -0
- package/dist/setup-dom.js +11 -0
- package/dist/setup.js +5 -13
- package/dist/snapshot-store.d.ts +2 -2
- package/dist/snapshot-store.js +2 -3
- package/dist/styled-components-browser-build.d.ts +15 -0
- package/dist/styled-components-browser-build.js +69 -0
- package/package.json +16 -5
package/README.md
CHANGED
|
@@ -13,35 +13,59 @@ pnpm add -D @describe-me/vitest @describe-me/react describe-me
|
|
|
13
13
|
## Usage
|
|
14
14
|
|
|
15
15
|
One line in the Vitest config registers the setup file and the reporter, and
|
|
16
|
-
redirects `
|
|
16
|
+
redirects the test library's `render` import to the recording adapter. The
|
|
17
|
+
plugin detects where the tests run:
|
|
18
|
+
|
|
19
|
+
- **Browser mode** (`test.browser.enabled`): redirects `vitest-browser-react`,
|
|
20
|
+
records a frame after every `Locator` action and keyboard-level `userEvent`.
|
|
21
|
+
- **DOM environments** such as jsdom (anything else): redirects
|
|
22
|
+
`@testing-library/react`, records a frame after every
|
|
23
|
+
`@testing-library/user-event` call, turns on `test.css` so imported
|
|
24
|
+
stylesheets reach the snapshots, and sets `RTL_SKIP_AUTO_CLEANUP` so the
|
|
25
|
+
component is unmounted only after the closing frame.
|
|
17
26
|
|
|
18
27
|
```ts
|
|
19
28
|
import { describeMe } from '@describe-me/vitest/plugin'
|
|
20
29
|
|
|
30
|
+
// browser mode
|
|
21
31
|
export default defineConfig({
|
|
22
32
|
plugins: [react(), describeMe()],
|
|
23
33
|
test: {
|
|
24
34
|
browser: { enabled: true, provider: playwright(), instances: [{ browser: 'chromium' }] },
|
|
25
35
|
},
|
|
26
36
|
})
|
|
37
|
+
|
|
38
|
+
// jsdom
|
|
39
|
+
export default defineConfig({
|
|
40
|
+
plugins: [react(), describeMe()],
|
|
41
|
+
test: { environment: 'jsdom' },
|
|
42
|
+
})
|
|
27
43
|
```
|
|
28
44
|
|
|
45
|
+
Pass `describeMe({ environment: 'browser' | 'dom' })` to override the detection.
|
|
46
|
+
|
|
29
47
|
The pieces can also be wired by hand, in which case tests import `render` from
|
|
30
|
-
`@describe-me/react`
|
|
48
|
+
the adapter themselves (`@describe-me/react` in browser mode,
|
|
49
|
+
`@describe-me/react/testing-library` in jsdom):
|
|
31
50
|
|
|
32
51
|
```ts
|
|
33
52
|
import DescribeMeReporter from '@describe-me/vitest/reporter'
|
|
34
53
|
|
|
35
54
|
export default defineConfig({
|
|
36
55
|
test: {
|
|
37
|
-
|
|
56
|
+
// browser mode: '@describe-me/vitest/setup'
|
|
57
|
+
setupFiles: ['@describe-me/vitest/setup-dom'],
|
|
38
58
|
reporters: ['default', new DescribeMeReporter()],
|
|
59
|
+
// jsdom only: keep imported CSS and leave unmounting to describe-me
|
|
60
|
+
css: true,
|
|
61
|
+
env: { RTL_SKIP_AUTO_CLEANUP: 'true' },
|
|
39
62
|
},
|
|
40
63
|
})
|
|
41
64
|
```
|
|
42
65
|
|
|
43
66
|
`typescript` is an optional peer: without it the docs are still generated, only
|
|
44
|
-
the props tables are left out.
|
|
67
|
+
the props tables are left out. `@testing-library/user-event` is an optional
|
|
68
|
+
peer too, needed only in DOM environments.
|
|
45
69
|
|
|
46
70
|
See the [root README](https://github.com/grzehub/describe-me#readme) for the
|
|
47
71
|
full picture.
|
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
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({
|
|
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
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
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;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Attributes that name an element better than its text, in order of preference. */
|
|
2
|
+
const NAME_ATTRIBUTES = ['aria-label', 'name', 'placeholder', 'title'];
|
|
3
|
+
function roleOf(element) {
|
|
4
|
+
const explicit = element.getAttribute('role');
|
|
5
|
+
if (explicit) {
|
|
6
|
+
return explicit;
|
|
7
|
+
}
|
|
8
|
+
const tag = element.tagName.toLowerCase();
|
|
9
|
+
if (tag === 'input') {
|
|
10
|
+
return element.getAttribute('type') ?? 'input';
|
|
11
|
+
}
|
|
12
|
+
return tag;
|
|
13
|
+
}
|
|
14
|
+
function nameOf(element) {
|
|
15
|
+
for (const attribute of NAME_ATTRIBUTES) {
|
|
16
|
+
const value = element.getAttribute(attribute);
|
|
17
|
+
if (value) {
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return (element.textContent ?? '').trim().slice(0, 40);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A readable target for a frame label, e.g. `button "show details"`.
|
|
25
|
+
* Testing Library hands interactions a real `Element` rather than a Vitest
|
|
26
|
+
* locator, so the label is read off the element instead of a selector string.
|
|
27
|
+
*/
|
|
28
|
+
export function elementLabel(target) {
|
|
29
|
+
if (typeof Element === 'undefined' || !(target instanceof Element)) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const name = nameOf(target);
|
|
33
|
+
return name ? `${roleOf(target)} "${name}"` : roleOf(target);
|
|
34
|
+
}
|
package/dist/extract-props.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
52
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
135
|
+
function declaredTypeNode(prop) {
|
|
86
136
|
for (const declaration of prop.declarations ?? []) {
|
|
87
137
|
if (ts.isPropertySignature(declaration) && declaration.type) {
|
|
88
|
-
return declaration.type
|
|
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
|
-
|
|
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,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrap Testing Library's userEvent in place so each call records a frame.
|
|
3
|
+
* It comes in two shapes, the direct API (`userEvent.click(el)`) and an
|
|
4
|
+
* instance from `userEvent.setup()`, and the first delegates to the second,
|
|
5
|
+
* so both are patched and `recordAction`'s depth guard keeps one gesture to
|
|
6
|
+
* one frame. Safe to call more than once.
|
|
7
|
+
*/
|
|
8
|
+
export declare function patchTestingLibraryUserEvent(): void;
|