@describe-me/vitest 0.4.0 → 0.5.0-next.1
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 +22 -5
- package/dist/adapter-package-root.d.ts +7 -0
- package/dist/adapter-package-root.js +40 -0
- package/dist/asset-store.d.ts +62 -12
- package/dist/asset-store.js +226 -23
- package/dist/compile-globs.d.ts +6 -0
- package/dist/compile-globs.js +20 -0
- package/dist/css-asset-references.d.ts +5 -0
- package/dist/css-asset-references.js +17 -0
- 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 +14 -0
- package/dist/plugin.js +41 -7
- package/dist/read-runtime-options.d.ts +3 -0
- package/dist/read-runtime-options.js +6 -0
- package/dist/register-recording-hooks.d.ts +7 -4
- package/dist/register-recording-hooks.js +33 -9
- package/dist/reporter.d.ts +34 -2
- package/dist/reporter.js +83 -14
- package/dist/rewrite-css-references.d.ts +5 -0
- package/dist/rewrite-css-references.js +18 -0
- package/dist/rewrite-head-references.d.ts +8 -0
- package/dist/rewrite-head-references.js +49 -0
- 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/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/package.json +8 -6
- package/dist/element-label.d.ts +0 -6
- package/dist/element-label.js +0 -34
|
@@ -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
|
@@ -27,6 +27,20 @@ export interface DescribeMeOptions {
|
|
|
27
27
|
* Applied when styled-components is installed. Default: true.
|
|
28
28
|
*/
|
|
29
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[];
|
|
38
|
+
/**
|
|
39
|
+
* HTML the viewer adds to the start of the `<head>` of every replayed frame,
|
|
40
|
+
* like Storybook's `preview-head.html`. It never reaches the test page.
|
|
41
|
+
* Project files it links are copied into the output directory. Default: none.
|
|
42
|
+
*/
|
|
43
|
+
previewHead?: string;
|
|
30
44
|
}
|
|
31
45
|
type VitePlugin = NonNullable<ViteUserConfig['plugins']>[number];
|
|
32
46
|
/**
|
package/dist/plugin.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { adapterPackageRoot } from './adapter-package-root.js';
|
|
2
|
+
import { compileGlobs } from './compile-globs.js';
|
|
1
3
|
import { projectModulePath } from './project-module-path.js';
|
|
2
4
|
import { registerExports } from './register-exports.js';
|
|
3
5
|
import DescribeMeReporter from './reporter.js';
|
|
6
|
+
import { RUNTIME_OPTIONS_KEY } from './runtime-options.js';
|
|
4
7
|
import { styledComponentsBrowserBuild } from './styled-components-browser-build.js';
|
|
5
8
|
/** For each framework and environment: the render module tests import, and our adapter that wraps it. */
|
|
6
9
|
const RENDER_MODULES = {
|
|
@@ -9,8 +12,24 @@ const RENDER_MODULES = {
|
|
|
9
12
|
dom: { original: '@testing-library/react', adapter: '@describe-me/react/testing-library' },
|
|
10
13
|
},
|
|
11
14
|
};
|
|
12
|
-
/**
|
|
13
|
-
|
|
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
|
+
}
|
|
14
33
|
function detectEnvironment(userConfig) {
|
|
15
34
|
return userConfig.test?.browser?.enabled ? 'browser' : 'dom';
|
|
16
35
|
}
|
|
@@ -70,6 +89,9 @@ export function describeMe(options = {}) {
|
|
|
70
89
|
// build that shares the config must not carry the export registration.
|
|
71
90
|
let root = '';
|
|
72
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;
|
|
73
95
|
return {
|
|
74
96
|
name: 'describe-me',
|
|
75
97
|
enforce: 'pre',
|
|
@@ -79,20 +101,31 @@ export function describeMe(options = {}) {
|
|
|
79
101
|
}
|
|
80
102
|
const environment = options.environment ?? detectEnvironment(userConfig);
|
|
81
103
|
renderModule = RENDER_MODULES[framework][environment];
|
|
82
|
-
|
|
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
|
+
previewHead: options.previewHead,
|
|
116
|
+
});
|
|
83
117
|
// Vite concatenates arrays when merging, so only add `default` when the
|
|
84
118
|
// user has not chosen their own reporters; otherwise they would lose it.
|
|
85
119
|
const reporters = userConfig.test?.reporters ? [reporter] : ['default', reporter];
|
|
86
120
|
const config = environment === 'browser' ? browserConfig(renderModule) : domConfig(userConfig, options);
|
|
87
|
-
return { ...config, test: { ...config.test, reporters } };
|
|
121
|
+
return { ...config, test: { ...config.test, reporters, provide } };
|
|
88
122
|
},
|
|
89
123
|
async resolveId(source, importer) {
|
|
90
124
|
if (!enabled || source !== renderModule.original) {
|
|
91
125
|
return null;
|
|
92
126
|
}
|
|
93
127
|
const fromAdapter = importer !== undefined &&
|
|
94
|
-
(importer
|
|
95
|
-
ADAPTER_PATHS.some((path) => importer.includes(path)));
|
|
128
|
+
isAdapterModule(importer, adapterRoot, packageNameOf(renderModule.adapter));
|
|
96
129
|
if (fromAdapter) {
|
|
97
130
|
return null;
|
|
98
131
|
}
|
|
@@ -101,13 +134,14 @@ export function describeMe(options = {}) {
|
|
|
101
134
|
// recording nothing, and in a DOM environment it would also leave every
|
|
102
135
|
// test mounted, because Testing Library's auto-cleanup is switched off.
|
|
103
136
|
if (!resolved) {
|
|
104
|
-
throw new Error(`describe-me: cannot resolve ${renderModule.adapter} (the recording replacement for ${renderModule.original}). Install ${renderModule.adapter
|
|
137
|
+
throw new Error(`describe-me: cannot resolve ${renderModule.adapter} (the recording replacement for ${renderModule.original}). Install ${packageNameOf(renderModule.adapter)}.`);
|
|
105
138
|
}
|
|
106
139
|
return resolved;
|
|
107
140
|
},
|
|
108
141
|
configResolved(config) {
|
|
109
142
|
root = config.root;
|
|
110
143
|
serving = config.command === 'serve';
|
|
144
|
+
adapterRoot = adapterPackageRoot(config.root, packageNameOf(renderModule.adapter));
|
|
111
145
|
},
|
|
112
146
|
// Names components after their export (see registerExports). `order: 'post'`
|
|
113
147
|
// runs it after the TypeScript and JSX transforms, so it parses plain
|
|
@@ -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
|
+
}
|
|
@@ -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,13 +2,32 @@ 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[];
|
|
15
|
+
/**
|
|
16
|
+
* HTML the viewer adds to the start of the `<head>` of every replayed frame,
|
|
17
|
+
* like Storybook's `preview-head.html`. It never reaches the test page.
|
|
18
|
+
* Project files it links are copied into the output directory. Default: none.
|
|
19
|
+
*/
|
|
20
|
+
previewHead?: string;
|
|
5
21
|
}
|
|
6
22
|
export default class DescribeMeReporter implements Reporter {
|
|
7
23
|
private readonly outDirOption;
|
|
24
|
+
private readonly isRecordedFile;
|
|
25
|
+
private readonly previewHead;
|
|
8
26
|
private root;
|
|
9
27
|
private outDir;
|
|
10
28
|
private snapshots;
|
|
11
29
|
private assets;
|
|
30
|
+
private styles;
|
|
12
31
|
private modules;
|
|
13
32
|
constructor(options?: DescribeMeReporterOptions);
|
|
14
33
|
onInit(vitest: Vitest): void;
|
|
@@ -16,12 +35,25 @@ export default class DescribeMeReporter implements Reporter {
|
|
|
16
35
|
/**
|
|
17
36
|
* A filtered run (`vitest run Button.test.tsx`) must not wipe the other
|
|
18
37
|
* modules from the manifest, so start from whatever the last run wrote.
|
|
19
|
-
* Modules whose file no longer
|
|
38
|
+
* Modules whose file is gone or no longer selected by `include` / `exclude`
|
|
39
|
+
* are dropped, and so are the empty tests that 0.4 still wrote.
|
|
20
40
|
*/
|
|
21
41
|
private seedFromPreviousRun;
|
|
42
|
+
/**
|
|
43
|
+
* A test skipped in this run keeps its previous entry, so a filtered run does
|
|
44
|
+
* not erase documentation.
|
|
45
|
+
*/
|
|
22
46
|
onTestModuleEnd(module: TestModule): void;
|
|
23
47
|
onTestRunEnd(): Promise<void>;
|
|
48
|
+
/** Module ids use the platform's separators. Globs expect posix ones. */
|
|
49
|
+
private isRecordedModule;
|
|
50
|
+
/** Decided before any snapshot is written, so a dropped test writes no file. */
|
|
51
|
+
private manifestTestOf;
|
|
24
52
|
private toManifestTest;
|
|
25
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Store one frame's DOM, with asset URLs pointing into the asset store and
|
|
55
|
+
* long stylesheets moved to the style store. Assets go first, so stored CSS
|
|
56
|
+
* carries asset URLs too.
|
|
57
|
+
*/
|
|
26
58
|
private writeSnapshot;
|
|
27
59
|
}
|
package/dist/reporter.js
CHANGED
|
@@ -1,32 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Node-side Vitest reporter. Collects frames from task.meta and writes
|
|
3
|
-
* `.describe-me
|
|
3
|
+
* `.describe-me/`: `manifest.json`, `snapshots/`, `styles/` and `assets/`.
|
|
4
4
|
*/
|
|
5
5
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
-
import { join, relative, resolve } from 'node:path';
|
|
6
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
7
7
|
import { META_KEY, } from '@describe-me/core/types';
|
|
8
8
|
import { AssetStore } from './asset-store.js';
|
|
9
9
|
import { collectComponentDocsSafely } from './collect-component-docs-safely.js';
|
|
10
|
+
import { compileGlobs } from './compile-globs.js';
|
|
10
11
|
import { componentEntries } from './component-entries.js';
|
|
12
|
+
import { fileFilter } from './file-filter.js';
|
|
13
|
+
import { isRecordedTest } from './is-recorded-test.js';
|
|
11
14
|
import { printDiagnostics } from './print-diagnostics.js';
|
|
12
15
|
import { SnapshotStore } from './snapshot-store.js';
|
|
16
|
+
import { StyleStore } from './style-store.js';
|
|
13
17
|
import { testPath } from './test-path.js';
|
|
18
|
+
function recordOf(tc) {
|
|
19
|
+
return tc.meta()[META_KEY];
|
|
20
|
+
}
|
|
14
21
|
export default class DescribeMeReporter {
|
|
15
22
|
outDirOption;
|
|
23
|
+
isRecordedFile;
|
|
24
|
+
previewHead;
|
|
16
25
|
root = process.cwd();
|
|
17
26
|
outDir = '';
|
|
18
27
|
snapshots;
|
|
19
28
|
assets;
|
|
29
|
+
styles;
|
|
20
30
|
modules = new Map();
|
|
21
31
|
constructor(options = {}) {
|
|
22
32
|
this.outDirOption = options.outDir ?? '.describe-me';
|
|
33
|
+
this.isRecordedFile = fileFilter({
|
|
34
|
+
include: compileGlobs(options.include),
|
|
35
|
+
exclude: compileGlobs(options.exclude),
|
|
36
|
+
});
|
|
37
|
+
// A blank head, such as an empty preview-head.html, is the same as none.
|
|
38
|
+
this.previewHead = options.previewHead?.trim() ? options.previewHead : undefined;
|
|
23
39
|
}
|
|
24
40
|
onInit(vitest) {
|
|
25
41
|
this.root = vitest.config.root;
|
|
26
42
|
this.outDir = resolve(this.root, this.outDirOption);
|
|
27
43
|
this.snapshots = new SnapshotStore(this.outDir);
|
|
28
44
|
this.assets = new AssetStore(this.outDir, this.root);
|
|
45
|
+
this.styles = new StyleStore(this.outDir);
|
|
29
46
|
this.seedFromPreviousRun();
|
|
47
|
+
if (this.previewHead !== undefined && /<script\b/i.test(this.previewHead)) {
|
|
48
|
+
console.warn('describe-me: previewHead contains a <script>, which the viewer never runs.');
|
|
49
|
+
}
|
|
30
50
|
}
|
|
31
51
|
onTestRunStart() {
|
|
32
52
|
this.assets.startRun();
|
|
@@ -34,7 +54,8 @@ export default class DescribeMeReporter {
|
|
|
34
54
|
/**
|
|
35
55
|
* A filtered run (`vitest run Button.test.tsx`) must not wipe the other
|
|
36
56
|
* modules from the manifest, so start from whatever the last run wrote.
|
|
37
|
-
* Modules whose file no longer
|
|
57
|
+
* Modules whose file is gone or no longer selected by `include` / `exclude`
|
|
58
|
+
* are dropped, and so are the empty tests that 0.4 still wrote.
|
|
38
59
|
*/
|
|
39
60
|
seedFromPreviousRun() {
|
|
40
61
|
const file = join(this.outDir, 'manifest.json');
|
|
@@ -44,8 +65,11 @@ export default class DescribeMeReporter {
|
|
|
44
65
|
try {
|
|
45
66
|
const previous = JSON.parse(readFileSync(file, 'utf8'));
|
|
46
67
|
for (const mod of previous.modules ?? []) {
|
|
47
|
-
|
|
48
|
-
|
|
68
|
+
const tests = mod.tests.filter((test) => isRecordedTest(test));
|
|
69
|
+
if (tests.length > 0 &&
|
|
70
|
+
this.isRecordedModule(mod.id) &&
|
|
71
|
+
existsSync(resolve(this.root, mod.id))) {
|
|
72
|
+
this.modules.set(mod.id, { ...mod, tests });
|
|
49
73
|
}
|
|
50
74
|
}
|
|
51
75
|
}
|
|
@@ -53,14 +77,35 @@ export default class DescribeMeReporter {
|
|
|
53
77
|
// corrupt or incompatible manifest: start fresh
|
|
54
78
|
}
|
|
55
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* A test skipped in this run keeps its previous entry, so a filtered run does
|
|
82
|
+
* not erase documentation.
|
|
83
|
+
*/
|
|
56
84
|
onTestModuleEnd(module) {
|
|
57
85
|
const id = relative(this.root, module.moduleId);
|
|
58
|
-
|
|
86
|
+
if (!this.isRecordedModule(id)) {
|
|
87
|
+
this.modules.delete(id);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const previous = this.modules.get(id);
|
|
91
|
+
const tests = [];
|
|
92
|
+
for (const tc of module.children.allTests()) {
|
|
93
|
+
const test = this.manifestTestOf(tc, previous);
|
|
94
|
+
if (test) {
|
|
95
|
+
tests.push(test);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (tests.length === 0) {
|
|
99
|
+
this.modules.delete(id);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
59
102
|
this.modules.set(id, { id, tests });
|
|
60
103
|
}
|
|
61
104
|
async onTestRunEnd() {
|
|
62
105
|
const modules = Array.from(this.modules.values()).sort((left, right) => left.id.localeCompare(right.id));
|
|
63
106
|
const components = await collectComponentDocsSafely(this.root, componentEntries(modules, this.root));
|
|
107
|
+
// Before `missing()`, so the files the head links that do not exist are listed too.
|
|
108
|
+
const head = this.previewHead === undefined ? undefined : this.assets.rewriteHead(this.previewHead);
|
|
64
109
|
// Only what this run found: a filtered run does not know which of the
|
|
65
110
|
// previous run's missing assets belonged to the modules it skipped.
|
|
66
111
|
const assetsMissing = this.assets.missing();
|
|
@@ -72,22 +117,40 @@ export default class DescribeMeReporter {
|
|
|
72
117
|
components,
|
|
73
118
|
assetsMissing,
|
|
74
119
|
};
|
|
120
|
+
if (head !== undefined) {
|
|
121
|
+
manifest.head = head;
|
|
122
|
+
}
|
|
75
123
|
const snapshotFiles = manifest.modules.flatMap((mod) => mod.tests.flatMap((test) => test.frames.map((frame) => frame.snapshot)));
|
|
76
124
|
this.snapshots.collectGarbage(snapshotFiles);
|
|
77
|
-
this.
|
|
125
|
+
const styleFiles = this.styles.collectGarbage(snapshotFiles);
|
|
126
|
+
this.assets.collectGarbage([...snapshotFiles, ...styleFiles], head === undefined ? [] : [head]);
|
|
78
127
|
writeFileSync(join(this.outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
79
128
|
printDiagnostics(manifest);
|
|
80
129
|
}
|
|
81
|
-
|
|
82
|
-
|
|
130
|
+
/** Module ids use the platform's separators. Globs expect posix ones. */
|
|
131
|
+
isRecordedModule(id) {
|
|
132
|
+
return this.isRecordedFile(id.split(sep).join('/'));
|
|
133
|
+
}
|
|
134
|
+
/** Decided before any snapshot is written, so a dropped test writes no file. */
|
|
135
|
+
manifestTestOf(tc, previous) {
|
|
136
|
+
const record = recordOf(tc);
|
|
137
|
+
if (record && isRecordedTest(record)) {
|
|
138
|
+
return this.toManifestTest(tc, record);
|
|
139
|
+
}
|
|
140
|
+
if (tc.result().state !== 'skipped') {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
return previous?.tests.find((test) => test.id === tc.id && test.fullName === tc.fullName && isRecordedTest(test));
|
|
144
|
+
}
|
|
145
|
+
toManifestTest(tc, record) {
|
|
83
146
|
const result = tc.result();
|
|
84
|
-
const frames =
|
|
147
|
+
const frames = record.frames.map((frame) => ({
|
|
85
148
|
id: frame.id,
|
|
86
149
|
kind: frame.kind,
|
|
87
150
|
label: frame.label,
|
|
88
151
|
at: frame.at,
|
|
89
152
|
meta: frame.meta,
|
|
90
|
-
snapshot: this.writeSnapshot(frame.snapshot, record
|
|
153
|
+
snapshot: this.writeSnapshot(frame.snapshot, record.origin),
|
|
91
154
|
}));
|
|
92
155
|
return {
|
|
93
156
|
id: tc.id,
|
|
@@ -97,12 +160,18 @@ export default class DescribeMeReporter {
|
|
|
97
160
|
state: result.state ?? 'pending',
|
|
98
161
|
duration: tc.diagnostic()?.duration,
|
|
99
162
|
errors: result.errors?.map((error) => ({ message: error.message, stack: error.stack })),
|
|
100
|
-
component: record
|
|
163
|
+
component: record.component,
|
|
101
164
|
frames,
|
|
102
165
|
};
|
|
103
166
|
}
|
|
104
|
-
/**
|
|
167
|
+
/**
|
|
168
|
+
* Store one frame's DOM, with asset URLs pointing into the asset store and
|
|
169
|
+
* long stylesheets moved to the style store. Assets go first, so stored CSS
|
|
170
|
+
* carries asset URLs too.
|
|
171
|
+
*/
|
|
105
172
|
writeSnapshot(snapshot, origin) {
|
|
106
|
-
|
|
173
|
+
// A mixed install, where an older core created the global recorder, still hands over objects.
|
|
174
|
+
const json = typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot);
|
|
175
|
+
return this.snapshots.write(this.styles.extract(this.assets.rewrite(json, origin)));
|
|
107
176
|
}
|
|
108
177
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { cssReferences } from '@describe-me/core/css-references';
|
|
2
|
+
/**
|
|
3
|
+
* Replace each `url()` and `@import` target in CSS text for which `resolve`
|
|
4
|
+
* returns a string. Quotes and everything else stay as written.
|
|
5
|
+
*/
|
|
6
|
+
export function rewriteCssReferences(css, resolve) {
|
|
7
|
+
let rewritten = '';
|
|
8
|
+
let copied = 0;
|
|
9
|
+
for (const reference of cssReferences(css)) {
|
|
10
|
+
const replacement = resolve(reference.url);
|
|
11
|
+
if (replacement === null) {
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
rewritten += css.slice(copied, reference.start) + replacement;
|
|
15
|
+
copied = reference.end;
|
|
16
|
+
}
|
|
17
|
+
return rewritten + css.slice(copied);
|
|
18
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
type Resolve = (url: string) => string | null;
|
|
2
|
+
/**
|
|
3
|
+
* Replace the URLs in HTML meant for a document's `<head>`: `href` and `src`
|
|
4
|
+
* values, and the `url()` and `@import` targets of `<style>` elements and
|
|
5
|
+
* `style` attributes, wherever `resolve` returns a string.
|
|
6
|
+
*/
|
|
7
|
+
export declare function rewriteHeadReferences(html: string, resolve: Resolve): string;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { rewriteCssReferences } from './rewrite-css-references.js';
|
|
2
|
+
/**
|
|
3
|
+
* Comments and `<script>` elements, which stay as written, and `<style>`
|
|
4
|
+
* elements, whose CSS is rewritten. Tags between them are rewritten attribute
|
|
5
|
+
* by attribute. End tags and comment ends are matched as loosely as browsers
|
|
6
|
+
* read them: `</script foo>` and `--!>` count too.
|
|
7
|
+
*/
|
|
8
|
+
const RAW_TEXT = /<!--[\s\S]*?--!?>|<script\b[\s\S]*?<\/script\b[^>]*>|(<style\b[^>]*>)([\s\S]*?)(<\/style\b[^>]*>)/gi;
|
|
9
|
+
/** A start tag, split after its name. Quoted attribute values may contain `>`. */
|
|
10
|
+
const START_TAG = /(<[a-z][^\s/>]*)((?:[^>"']|"[^"]*"|'[^']*')*>)/gi;
|
|
11
|
+
/**
|
|
12
|
+
* One attribute: its whole name, so `data-src` is never `src`, and its value,
|
|
13
|
+
* double-quoted, single-quoted or bare.
|
|
14
|
+
*/
|
|
15
|
+
const ATTRIBUTE = /([^\s"'<>/=]+)(?:(\s*=\s*)("[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g;
|
|
16
|
+
/** Attributes whose value is a URL, and `style`, whose value is CSS. */
|
|
17
|
+
const REWRITTEN_ATTRIBUTES = new Set(['href', 'src', 'style']);
|
|
18
|
+
function rewriteAttribute(attribute, name, equals, value, resolve) {
|
|
19
|
+
const kind = name.toLowerCase();
|
|
20
|
+
if (equals === undefined || value === undefined || !REWRITTEN_ATTRIBUTES.has(kind)) {
|
|
21
|
+
return attribute;
|
|
22
|
+
}
|
|
23
|
+
const quote = value.startsWith('"') || value.startsWith("'") ? value[0] : '';
|
|
24
|
+
const text = quote === '' ? value : value.slice(1, -1);
|
|
25
|
+
const rewritten = kind === 'style' ? rewriteCssReferences(text, resolve) : (resolve(text) ?? text);
|
|
26
|
+
return `${name}${equals}${quote}${rewritten}${quote}`;
|
|
27
|
+
}
|
|
28
|
+
function rewriteTags(html, resolve) {
|
|
29
|
+
return html.replace(START_TAG, (_tag, name, attributes) => {
|
|
30
|
+
const rewritten = attributes.replace(ATTRIBUTE, (attribute, attributeName, equals, value) => rewriteAttribute(attribute, attributeName, equals, value, resolve));
|
|
31
|
+
return name + rewritten;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Replace the URLs in HTML meant for a document's `<head>`: `href` and `src`
|
|
36
|
+
* values, and the `url()` and `@import` targets of `<style>` elements and
|
|
37
|
+
* `style` attributes, wherever `resolve` returns a string.
|
|
38
|
+
*/
|
|
39
|
+
export function rewriteHeadReferences(html, resolve) {
|
|
40
|
+
let rewritten = '';
|
|
41
|
+
let copied = 0;
|
|
42
|
+
for (const match of html.matchAll(RAW_TEXT)) {
|
|
43
|
+
const [text, styleStart, css, styleEnd] = match;
|
|
44
|
+
const kept = styleStart === undefined ? text : styleStart + rewriteCssReferences(css, resolve) + styleEnd;
|
|
45
|
+
rewritten += rewriteTags(html.slice(copied, match.index), resolve) + kept;
|
|
46
|
+
copied = match.index + text.length;
|
|
47
|
+
}
|
|
48
|
+
return rewritten + rewriteTags(html.slice(copied), resolve);
|
|
49
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Travels from the plugin to the setup files through Vitest's `provide` /
|
|
3
|
+
* `inject`. Keep it plain JSON: the browser receives it serialized, so a
|
|
4
|
+
* RegExp would not survive. The test runtime imports this file, so it imports
|
|
5
|
+
* nothing itself.
|
|
6
|
+
*/
|
|
7
|
+
export interface CompiledGlob {
|
|
8
|
+
source: string;
|
|
9
|
+
flags: string;
|
|
10
|
+
}
|
|
11
|
+
export interface RuntimeOptions {
|
|
12
|
+
/** Empty means every test file. */
|
|
13
|
+
include: CompiledGlob[];
|
|
14
|
+
/** Wins over `include`. */
|
|
15
|
+
exclude: CompiledGlob[];
|
|
16
|
+
}
|
|
17
|
+
export declare const RUNTIME_OPTIONS_KEY: "describe-me";
|
|
18
|
+
declare module 'vitest' {
|
|
19
|
+
interface ProvidedContext {
|
|
20
|
+
'describe-me': RuntimeOptions;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Travels from the plugin to the setup files through Vitest's `provide` /
|
|
3
|
+
* `inject`. Keep it plain JSON: the browser receives it serialized, so a
|
|
4
|
+
* RegExp would not survive. The test runtime imports this file, so it imports
|
|
5
|
+
* nothing itself.
|
|
6
|
+
*/
|
|
7
|
+
export const RUNTIME_OPTIONS_KEY = 'describe-me';
|
package/dist/setup-dom.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Vitest setup file for DOM environments such as jsdom:
|
|
3
3
|
* `setupFiles: ['@describe-me/vitest/setup-dom']`. Patches Testing Library's
|
|
4
|
-
* userEvent so every interaction records a frame,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* userEvent, when it is installed, so every interaction records a frame, sets
|
|
5
|
+
* React's act flag where Testing Library would, and registers the recording
|
|
6
|
+
* hooks. Which test files are recorded (`include` / `exclude`) comes from the
|
|
7
|
+
* plugin. Never imports `vitest/browser`, which only exists in browser mode.
|
|
7
8
|
*/
|
|
9
|
+
import { mirrorReactActEnvironment } from './mirror-react-act-environment.js';
|
|
8
10
|
import { patchTestingLibraryUserEvent } from './patch-testing-library-user-event.js';
|
|
11
|
+
import { readRuntimeOptions } from './read-runtime-options.js';
|
|
9
12
|
import { registerRecordingHooks } from './register-recording-hooks.js';
|
|
10
|
-
patchTestingLibraryUserEvent();
|
|
11
|
-
|
|
13
|
+
await patchTestingLibraryUserEvent();
|
|
14
|
+
mirrorReactActEnvironment();
|
|
15
|
+
registerRecordingHooks(readRuntimeOptions());
|