@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.
- package/README.md +16 -5
- package/dist/adapter-package-root.d.ts +7 -0
- package/dist/adapter-package-root.js +40 -0
- package/dist/asset-store.d.ts +51 -0
- package/dist/asset-store.js +233 -0
- package/dist/collect-component-docs.d.ts +8 -3
- package/dist/collect-component-docs.js +37 -8
- package/dist/compile-globs.d.ts +6 -0
- package/dist/compile-globs.js +20 -0
- package/dist/component-entries.js +5 -1
- package/dist/extract-props.js +87 -15
- package/dist/file-filter.d.ts +7 -0
- package/dist/file-filter.js +23 -0
- package/dist/is-recorded-test.d.ts +6 -0
- package/dist/is-recorded-test.js +3 -0
- package/dist/mirror-react-act-environment.d.ts +8 -0
- package/dist/mirror-react-act-environment.js +27 -0
- package/dist/patch-testing-library-user-event.d.ts +3 -2
- package/dist/patch-testing-library-user-event.js +11 -4
- package/dist/plugin.d.ts +22 -0
- package/dist/plugin.js +94 -10
- 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/read-runtime-options.d.ts +3 -0
- package/dist/read-runtime-options.js +6 -0
- package/dist/register-exports.d.ts +28 -0
- package/dist/register-exports.js +118 -0
- package/dist/register-recording-hooks.d.ts +7 -4
- package/dist/register-recording-hooks.js +33 -9
- package/dist/reporter.d.ts +30 -1
- package/dist/reporter.js +88 -12
- package/dist/runtime-options.d.ts +22 -0
- package/dist/runtime-options.js +7 -0
- package/dist/setup-dom.js +9 -5
- package/dist/setup.js +4 -2
- package/dist/snapshot-store.d.ts +2 -2
- package/dist/snapshot-store.js +2 -3
- package/dist/split-stylesheet.d.ts +7 -0
- package/dist/split-stylesheet.js +114 -0
- package/dist/style-store.d.ts +25 -0
- package/dist/style-store.js +90 -0
- package/dist/styled-components-browser-build.d.ts +15 -0
- package/dist/styled-components-browser-build.js +69 -0
- package/package.json +8 -6
- package/dist/element-label.d.ts +0 -6
- package/dist/element-label.js +0 -34
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
function toRegExps(globs) {
|
|
2
|
+
return globs.map((glob) => new RegExp(glob.source, glob.flags));
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Takes a path relative to the root, with posix separators. Build it once and
|
|
6
|
+
* reuse the returned function, which memoizes per file name. The test runtime
|
|
7
|
+
* imports this file, so no Node imports.
|
|
8
|
+
*/
|
|
9
|
+
export function fileFilter(options) {
|
|
10
|
+
const include = toRegExps(options.include);
|
|
11
|
+
const exclude = toRegExps(options.exclude);
|
|
12
|
+
const answers = new Map();
|
|
13
|
+
return (fileName) => {
|
|
14
|
+
const known = answers.get(fileName);
|
|
15
|
+
if (known !== undefined) {
|
|
16
|
+
return known;
|
|
17
|
+
}
|
|
18
|
+
const included = include.length === 0 || include.some((glob) => glob.test(fileName));
|
|
19
|
+
const answer = included && !exclude.some((glob) => glob.test(fileName));
|
|
20
|
+
answers.set(fileName, answer);
|
|
21
|
+
return answer;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Set React's `IS_REACT_ACT_ENVIRONMENT` flag for each test file, the way
|
|
3
|
+
* Testing Library does. `RTL_SKIP_AUTO_CLEANUP` skips its whole setup block,
|
|
4
|
+
* the flag included, which silences React's act warnings. The flag is set
|
|
5
|
+
* only under Testing Library's own condition (Vitest globals), so the
|
|
6
|
+
* warnings match a run without describe-me.
|
|
7
|
+
*/
|
|
8
|
+
export declare function mirrorReactActEnvironment(): void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { afterAll, beforeAll } from 'vitest';
|
|
2
|
+
/** Testing Library's own test: its auto-cleanup block runs unless the variable is set. */
|
|
3
|
+
function testingLibrarySkippedItsSetup() {
|
|
4
|
+
return typeof process !== 'undefined' && Boolean(process.env?.RTL_SKIP_AUTO_CLEANUP);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Set React's `IS_REACT_ACT_ENVIRONMENT` flag for each test file, the way
|
|
8
|
+
* Testing Library does. `RTL_SKIP_AUTO_CLEANUP` skips its whole setup block,
|
|
9
|
+
* the flag included, which silences React's act warnings. The flag is set
|
|
10
|
+
* only under Testing Library's own condition (Vitest globals), so the
|
|
11
|
+
* warnings match a run without describe-me.
|
|
12
|
+
*/
|
|
13
|
+
export function mirrorReactActEnvironment() {
|
|
14
|
+
const host = globalThis;
|
|
15
|
+
const hasGlobalHooks = typeof host.beforeAll === 'function' && typeof host.afterAll === 'function';
|
|
16
|
+
if (!testingLibrarySkippedItsSetup() || !hasGlobalHooks) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
let previous;
|
|
20
|
+
beforeAll(() => {
|
|
21
|
+
previous = host.IS_REACT_ACT_ENVIRONMENT;
|
|
22
|
+
host.IS_REACT_ACT_ENVIRONMENT = true;
|
|
23
|
+
});
|
|
24
|
+
afterAll(() => {
|
|
25
|
+
host.IS_REACT_ACT_ENVIRONMENT = previous;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* It comes in two shapes, the direct API (`userEvent.click(el)`) and an
|
|
4
4
|
* instance from `userEvent.setup()`, and the first delegates to the second,
|
|
5
5
|
* so both are patched and `recordAction`'s depth guard keeps one gesture to
|
|
6
|
-
* one frame.
|
|
6
|
+
* one frame. When the optional peer is not installed, there is nothing to
|
|
7
|
+
* patch. Safe to call more than once.
|
|
7
8
|
*/
|
|
8
|
-
export declare function patchTestingLibraryUserEvent(): void
|
|
9
|
+
export declare function patchTestingLibraryUserEvent(): Promise<void>;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { elementLabel } from '@describe-me/core';
|
|
2
2
|
import { describeArg } from './describe-arg.js';
|
|
3
|
-
import { elementLabel } from './element-label.js';
|
|
4
3
|
import { recordAction } from './record-action.js';
|
|
5
4
|
/** Every interaction of user-event 14 that can change what the page shows. */
|
|
6
5
|
const METHODS = [
|
|
@@ -42,9 +41,17 @@ function patchMethods(target) {
|
|
|
42
41
|
* It comes in two shapes, the direct API (`userEvent.click(el)`) and an
|
|
43
42
|
* instance from `userEvent.setup()`, and the first delegates to the second,
|
|
44
43
|
* so both are patched and `recordAction`'s depth guard keeps one gesture to
|
|
45
|
-
* one frame.
|
|
44
|
+
* one frame. When the optional peer is not installed, there is nothing to
|
|
45
|
+
* patch. Safe to call more than once.
|
|
46
46
|
*/
|
|
47
|
-
export function patchTestingLibraryUserEvent() {
|
|
47
|
+
export async function patchTestingLibraryUserEvent() {
|
|
48
|
+
let userEvent;
|
|
49
|
+
try {
|
|
50
|
+
userEvent = (await import('@testing-library/user-event')).default;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
48
55
|
const direct = userEvent;
|
|
49
56
|
patchMethods(direct);
|
|
50
57
|
const originalSetup = direct.setup;
|
package/dist/plugin.d.ts
CHANGED
|
@@ -13,6 +13,28 @@ 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;
|
|
30
|
+
/**
|
|
31
|
+
* Test files to record, as globs relative to the Vitest root, matched with
|
|
32
|
+
* picomatch, dotfiles included. Tests in other files still run, but record
|
|
33
|
+
* nothing and leave the manifest. Default: every test file.
|
|
34
|
+
*/
|
|
35
|
+
include?: string | string[];
|
|
36
|
+
/** Test files never to record, as globs like `include`. Wins over `include`. Default: none. */
|
|
37
|
+
exclude?: string | string[];
|
|
16
38
|
}
|
|
17
39
|
type VitePlugin = NonNullable<ViteUserConfig['plugins']>[number];
|
|
18
40
|
/**
|
package/dist/plugin.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import { adapterPackageRoot } from './adapter-package-root.js';
|
|
2
|
+
import { compileGlobs } from './compile-globs.js';
|
|
3
|
+
import { projectModulePath } from './project-module-path.js';
|
|
4
|
+
import { registerExports } from './register-exports.js';
|
|
1
5
|
import DescribeMeReporter from './reporter.js';
|
|
6
|
+
import { RUNTIME_OPTIONS_KEY } from './runtime-options.js';
|
|
7
|
+
import { styledComponentsBrowserBuild } from './styled-components-browser-build.js';
|
|
2
8
|
/** For each framework and environment: the render module tests import, and our adapter that wraps it. */
|
|
3
9
|
const RENDER_MODULES = {
|
|
4
10
|
react: {
|
|
@@ -6,8 +12,24 @@ const RENDER_MODULES = {
|
|
|
6
12
|
dom: { original: '@testing-library/react', adapter: '@describe-me/react/testing-library' },
|
|
7
13
|
},
|
|
8
14
|
};
|
|
9
|
-
/**
|
|
10
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The package an adapter entry point belongs to, e.g.
|
|
17
|
+
* `@describe-me/react/testing-library` → `@describe-me/react`.
|
|
18
|
+
*/
|
|
19
|
+
function packageNameOf(specifier) {
|
|
20
|
+
return specifier.split('/').slice(0, 2).join('/');
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Whether a module is one of the adapter's own files, whose imports of the
|
|
24
|
+
* original module must stay unredirected. `/node_modules/<package>/` is the
|
|
25
|
+
* fallback for when the project root could not resolve the package.
|
|
26
|
+
*/
|
|
27
|
+
function isAdapterModule(id, adapterRoot, adapterPackage) {
|
|
28
|
+
if (adapterRoot !== null && id.startsWith(adapterRoot)) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
return id.includes(`/node_modules/${adapterPackage}/`);
|
|
32
|
+
}
|
|
11
33
|
function detectEnvironment(userConfig) {
|
|
12
34
|
return userConfig.test?.browser?.enabled ? 'browser' : 'dom';
|
|
13
35
|
}
|
|
@@ -26,7 +48,8 @@ function browserConfig(renderModule) {
|
|
|
26
48
|
},
|
|
27
49
|
};
|
|
28
50
|
}
|
|
29
|
-
function domConfig(
|
|
51
|
+
function domConfig(userConfig, options) {
|
|
52
|
+
const userTest = userConfig.test;
|
|
30
53
|
const test = {
|
|
31
54
|
setupFiles: ['@describe-me/vitest/setup-dom'],
|
|
32
55
|
// Testing Library would otherwise unmount in its own afterEach, before the
|
|
@@ -41,7 +64,14 @@ function domConfig(userTest) {
|
|
|
41
64
|
else if (userTest.css === false) {
|
|
42
65
|
console.warn('describe-me: `test.css` is false, so imported stylesheets are stubbed and will be missing from the snapshots.');
|
|
43
66
|
}
|
|
44
|
-
|
|
67
|
+
const styledComponents = options.styledComponentsBrowserBuild === false
|
|
68
|
+
? undefined
|
|
69
|
+
: styledComponentsBrowserBuild(userConfig.root ?? process.cwd());
|
|
70
|
+
if (!styledComponents) {
|
|
71
|
+
return { test };
|
|
72
|
+
}
|
|
73
|
+
console.info('describe-me: using the browser build of styled-components, for createGlobalStyle');
|
|
74
|
+
return { resolve: styledComponents.resolve, test: { ...test, ...styledComponents.test } };
|
|
45
75
|
}
|
|
46
76
|
/**
|
|
47
77
|
* One-line integration: `plugins: [describeMe()]`.
|
|
@@ -55,6 +85,13 @@ function domConfig(userTest) {
|
|
|
55
85
|
export function describeMe(options = {}) {
|
|
56
86
|
const { enabled = true, framework = 'react', outDir } = options;
|
|
57
87
|
let renderModule = RENDER_MODULES[framework].browser;
|
|
88
|
+
// Known once Vite has resolved its config. Vitest always serves; a production
|
|
89
|
+
// build that shares the config must not carry the export registration.
|
|
90
|
+
let root = '';
|
|
91
|
+
let serving = false;
|
|
92
|
+
// Where the installed adapter lives, so a user path that merely looks like
|
|
93
|
+
// it (`packages/react/` in a monorepo) is still redirected.
|
|
94
|
+
let adapterRoot = null;
|
|
58
95
|
return {
|
|
59
96
|
name: 'describe-me',
|
|
60
97
|
enforce: 'pre',
|
|
@@ -64,20 +101,30 @@ export function describeMe(options = {}) {
|
|
|
64
101
|
}
|
|
65
102
|
const environment = options.environment ?? detectEnvironment(userConfig);
|
|
66
103
|
renderModule = RENDER_MODULES[framework][environment];
|
|
67
|
-
|
|
104
|
+
// Compiled here, so invalid globs fail before any test runs. Vite
|
|
105
|
+
// deep-merges `provide`, so the user's own keys survive.
|
|
106
|
+
const runtimeOptions = {
|
|
107
|
+
include: compileGlobs(options.include),
|
|
108
|
+
exclude: compileGlobs(options.exclude),
|
|
109
|
+
};
|
|
110
|
+
const provide = { [RUNTIME_OPTIONS_KEY]: runtimeOptions };
|
|
111
|
+
const reporter = new DescribeMeReporter({
|
|
112
|
+
outDir,
|
|
113
|
+
include: options.include,
|
|
114
|
+
exclude: options.exclude,
|
|
115
|
+
});
|
|
68
116
|
// Vite concatenates arrays when merging, so only add `default` when the
|
|
69
117
|
// user has not chosen their own reporters; otherwise they would lose it.
|
|
70
118
|
const reporters = userConfig.test?.reporters ? [reporter] : ['default', reporter];
|
|
71
|
-
const config = environment === 'browser' ? browserConfig(renderModule) : domConfig(userConfig
|
|
72
|
-
return { ...config, test: { ...config.test, reporters } };
|
|
119
|
+
const config = environment === 'browser' ? browserConfig(renderModule) : domConfig(userConfig, options);
|
|
120
|
+
return { ...config, test: { ...config.test, reporters, provide } };
|
|
73
121
|
},
|
|
74
122
|
async resolveId(source, importer) {
|
|
75
123
|
if (!enabled || source !== renderModule.original) {
|
|
76
124
|
return null;
|
|
77
125
|
}
|
|
78
126
|
const fromAdapter = importer !== undefined &&
|
|
79
|
-
(importer
|
|
80
|
-
ADAPTER_PATHS.some((path) => importer.includes(path)));
|
|
127
|
+
isAdapterModule(importer, adapterRoot, packageNameOf(renderModule.adapter));
|
|
81
128
|
if (fromAdapter) {
|
|
82
129
|
return null;
|
|
83
130
|
}
|
|
@@ -86,9 +133,46 @@ export function describeMe(options = {}) {
|
|
|
86
133
|
// recording nothing, and in a DOM environment it would also leave every
|
|
87
134
|
// test mounted, because Testing Library's auto-cleanup is switched off.
|
|
88
135
|
if (!resolved) {
|
|
89
|
-
throw new Error(`describe-me: cannot resolve ${renderModule.adapter} (the recording replacement for ${renderModule.original}). Install ${renderModule.adapter
|
|
136
|
+
throw new Error(`describe-me: cannot resolve ${renderModule.adapter} (the recording replacement for ${renderModule.original}). Install ${packageNameOf(renderModule.adapter)}.`);
|
|
90
137
|
}
|
|
91
138
|
return resolved;
|
|
92
139
|
},
|
|
140
|
+
configResolved(config) {
|
|
141
|
+
root = config.root;
|
|
142
|
+
serving = config.command === 'serve';
|
|
143
|
+
adapterRoot = adapterPackageRoot(config.root, packageNameOf(renderModule.adapter));
|
|
144
|
+
},
|
|
145
|
+
// Names components after their export (see registerExports). `order: 'post'`
|
|
146
|
+
// runs it after the TypeScript and JSX transforms, so it parses plain
|
|
147
|
+
// JavaScript, while the plugin as a whole stays `enforce: 'pre'` for the
|
|
148
|
+
// render redirect above.
|
|
149
|
+
transform: {
|
|
150
|
+
order: 'post',
|
|
151
|
+
handler(code, id) {
|
|
152
|
+
if (!enabled || options.registerExports === false || !serving) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const file = projectModulePath(id, root);
|
|
156
|
+
// Without the word `export` there is nothing to register: skip the parse.
|
|
157
|
+
if (file === null || !code.includes('export')) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
let snippet;
|
|
161
|
+
// Parse errors are for the user's own tooling to report, and an older
|
|
162
|
+
// Vite may lack `this.parse`. This transform must never be the reason a
|
|
163
|
+
// module fails, so it steps aside instead.
|
|
164
|
+
try {
|
|
165
|
+
snippet = registerExports(this.parse(code), file);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
if (snippet === null) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
// Appending moves no code, so the source maps of earlier transforms stay valid.
|
|
174
|
+
return { code: code + snippet, map: null };
|
|
175
|
+
},
|
|
176
|
+
},
|
|
93
177
|
};
|
|
94
178
|
}
|
|
@@ -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,6 @@
|
|
|
1
|
+
import { inject } from 'vitest';
|
|
2
|
+
import { RUNTIME_OPTIONS_KEY } from './runtime-options.js';
|
|
3
|
+
/** Without the plugin nothing is provided, so a hand-wired setup keeps recording every test. */
|
|
4
|
+
export function readRuntimeOptions() {
|
|
5
|
+
return inject(RUNTIME_OPTIONS_KEY) ?? { include: [], exclude: [] };
|
|
6
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import type { RuntimeOptions } from './runtime-options.js';
|
|
1
2
|
/**
|
|
2
|
-
* The per-test lifecycle shared by every environment: begin a recording
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* The per-test lifecycle shared by every environment: begin a recording, take
|
|
4
|
+
* the closing frame, hand the frames to the reporter via task.meta, and only
|
|
5
|
+
* then let adapters unmount. Teardown runs for unrecorded tests too, because
|
|
6
|
+
* Testing Library's auto-cleanup is off in jsdom. Concurrent tests would share
|
|
7
|
+
* the one recorder, so they are not recorded.
|
|
5
8
|
*/
|
|
6
|
-
export declare function registerRecordingHooks(): void;
|
|
9
|
+
export declare function registerRecordingHooks(options: RuntimeOptions): void;
|
|
@@ -1,17 +1,41 @@
|
|
|
1
1
|
import { afterEach, beforeEach } from 'vitest';
|
|
2
2
|
import { META_KEY, recorder } from '@describe-me/core';
|
|
3
|
+
import { fileFilter } from './file-filter.js';
|
|
3
4
|
/**
|
|
4
|
-
* The per-test lifecycle shared by every environment: begin a recording
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* The per-test lifecycle shared by every environment: begin a recording, take
|
|
6
|
+
* the closing frame, hand the frames to the reporter via task.meta, and only
|
|
7
|
+
* then let adapters unmount. Teardown runs for unrecorded tests too, because
|
|
8
|
+
* Testing Library's auto-cleanup is off in jsdom. Concurrent tests would share
|
|
9
|
+
* the one recorder, so they are not recorded.
|
|
7
10
|
*/
|
|
8
|
-
export function registerRecordingHooks() {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
export function registerRecordingHooks(options) {
|
|
12
|
+
const isRecordedFile = fileFilter(options);
|
|
13
|
+
let warnedConcurrent = false;
|
|
14
|
+
beforeEach((context) => {
|
|
15
|
+
if (context.task.concurrent) {
|
|
16
|
+
if (!warnedConcurrent) {
|
|
17
|
+
warnedConcurrent = true;
|
|
18
|
+
console.warn(`describe-me: concurrent tests are not recorded (${context.task.file.name})`);
|
|
19
|
+
}
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (isRecordedFile(context.task.file.name)) {
|
|
23
|
+
recorder.begin();
|
|
24
|
+
}
|
|
11
25
|
});
|
|
12
26
|
afterEach(async (context) => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
27
|
+
try {
|
|
28
|
+
if (recorder.isActive) {
|
|
29
|
+
await recorder.capture('end', 'end of test');
|
|
30
|
+
const record = recorder.end();
|
|
31
|
+
if (record.frames.length > 0) {
|
|
32
|
+
;
|
|
33
|
+
context.task.meta[META_KEY] = record;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
recorder.teardown();
|
|
39
|
+
}
|
|
16
40
|
});
|
|
17
41
|
}
|
package/dist/reporter.d.ts
CHANGED
|
@@ -2,22 +2,51 @@ import type { Reporter, TestModule, Vitest } from 'vitest/node';
|
|
|
2
2
|
export interface DescribeMeReporterOptions {
|
|
3
3
|
/** Output directory, relative to the Vitest root. Default: `.describe-me` */
|
|
4
4
|
outDir?: string;
|
|
5
|
+
/**
|
|
6
|
+
* Which test files appear in the manifest: globs relative to the Vitest
|
|
7
|
+
* root, matched with picomatch, dotfiles included. Default: every test file.
|
|
8
|
+
*/
|
|
9
|
+
include?: string | string[];
|
|
10
|
+
/**
|
|
11
|
+
* Test files to leave out of the manifest, as globs like `include`. Wins over
|
|
12
|
+
* `include`. Default: none.
|
|
13
|
+
*/
|
|
14
|
+
exclude?: string | string[];
|
|
5
15
|
}
|
|
6
16
|
export default class DescribeMeReporter implements Reporter {
|
|
7
17
|
private readonly outDirOption;
|
|
18
|
+
private readonly isRecordedFile;
|
|
8
19
|
private root;
|
|
9
20
|
private outDir;
|
|
10
21
|
private snapshots;
|
|
22
|
+
private assets;
|
|
23
|
+
private styles;
|
|
11
24
|
private modules;
|
|
12
25
|
constructor(options?: DescribeMeReporterOptions);
|
|
13
26
|
onInit(vitest: Vitest): void;
|
|
27
|
+
onTestRunStart(): void;
|
|
14
28
|
/**
|
|
15
29
|
* A filtered run (`vitest run Button.test.tsx`) must not wipe the other
|
|
16
30
|
* modules from the manifest, so start from whatever the last run wrote.
|
|
17
|
-
* Modules whose file no longer
|
|
31
|
+
* Modules whose file is gone or no longer selected by `include` / `exclude`
|
|
32
|
+
* are dropped, and so are the empty tests that 0.4 still wrote.
|
|
18
33
|
*/
|
|
19
34
|
private seedFromPreviousRun;
|
|
35
|
+
/**
|
|
36
|
+
* A test skipped in this run keeps its previous entry, so a filtered run does
|
|
37
|
+
* not erase documentation.
|
|
38
|
+
*/
|
|
20
39
|
onTestModuleEnd(module: TestModule): void;
|
|
21
40
|
onTestRunEnd(): Promise<void>;
|
|
41
|
+
/** Module ids use the platform's separators. Globs expect posix ones. */
|
|
42
|
+
private isRecordedModule;
|
|
43
|
+
/** Decided before any snapshot is written, so a dropped test writes no file. */
|
|
44
|
+
private manifestTestOf;
|
|
22
45
|
private toManifestTest;
|
|
46
|
+
/**
|
|
47
|
+
* Store one frame's DOM, with asset URLs pointing into the asset store and
|
|
48
|
+
* long stylesheets moved to the style store. Assets go first, so stored CSS
|
|
49
|
+
* carries asset URLs too.
|
|
50
|
+
*/
|
|
51
|
+
private writeSnapshot;
|
|
23
52
|
}
|