@describe-me/vitest 0.4.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 +10 -7
- package/dist/asset-store.js +18 -15
- package/dist/compile-globs.d.ts +6 -0
- package/dist/compile-globs.js +20 -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 +8 -0
- package/dist/plugin.js +40 -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 +27 -2
- package/dist/reporter.js +72 -14
- 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
package/README.md
CHANGED
|
@@ -19,10 +19,13 @@ plugin detects where the tests run:
|
|
|
19
19
|
- **Browser mode** (`test.browser.enabled`): redirects `vitest-browser-react`,
|
|
20
20
|
records a frame after every `Locator` action and keyboard-level `userEvent`.
|
|
21
21
|
- **DOM environments** such as jsdom (anything else): redirects
|
|
22
|
-
`@testing-library/react`, records a frame after every
|
|
22
|
+
`@testing-library/react`, records a frame after every `fireEvent` and every
|
|
23
23
|
`@testing-library/user-event` call, turns on `test.css` so imported
|
|
24
24
|
stylesheets reach the snapshots, and sets `RTL_SKIP_AUTO_CLEANUP` so the
|
|
25
|
-
component is unmounted only after the closing frame.
|
|
25
|
+
component is unmounted only after the closing frame. The setup file sets
|
|
26
|
+
React's `IS_REACT_ACT_ENVIRONMENT` where Testing Library would, so act
|
|
27
|
+
warnings match a run without describe-me. It also works without user-event
|
|
28
|
+
installed.
|
|
26
29
|
|
|
27
30
|
```ts
|
|
28
31
|
import { describeMe } from '@describe-me/vitest/plugin'
|
|
@@ -44,9 +47,17 @@ export default defineConfig({
|
|
|
44
47
|
|
|
45
48
|
Pass `describeMe({ environment: 'browser' | 'dom' })` to override the detection.
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
A test that records no frame before the closing one (no recording `render`,
|
|
51
|
+
no interaction, no `step()`) costs no snapshot and is left out of the
|
|
52
|
+
manifest. `describeMe({ include, exclude })` chooses which test files are
|
|
53
|
+
recorded, with picomatch globs relative to the Vitest root. `exclude` wins.
|
|
54
|
+
Tests in other files still run, but record nothing. The reporter takes the
|
|
55
|
+
same two options, but on its own it only filters the manifest: without the
|
|
56
|
+
plugin the setup file does not know them.
|
|
57
|
+
|
|
58
|
+
The pieces can also be wired by hand. Tests then import from the adapter
|
|
59
|
+
themselves: `render` from `@describe-me/react` in browser mode, and `render`,
|
|
60
|
+
`fireEvent` and `cleanup` from `@describe-me/react/testing-library` in jsdom:
|
|
50
61
|
|
|
51
62
|
```ts
|
|
52
63
|
import DescribeMeReporter from '@describe-me/vitest/reporter'
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The directory of the adapter package the project resolves, e.g.
|
|
3
|
+
* `/repo/node_modules/@describe-me/react/`, with posix separators and a
|
|
4
|
+
* trailing slash the way Vite ids look, or null when it cannot be resolved.
|
|
5
|
+
* It is the real path, so a workspace link points at its source directory.
|
|
6
|
+
*/
|
|
7
|
+
export declare function adapterPackageRoot(root: string, packageName: string): string | null;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join, sep } from 'node:path';
|
|
4
|
+
/** The `name` in a directory's package.json, if it has a readable one. */
|
|
5
|
+
function packageNameIn(directory) {
|
|
6
|
+
const manifest = join(directory, 'package.json');
|
|
7
|
+
if (!existsSync(manifest)) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(manifest, 'utf8')).name;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The directory of the adapter package the project resolves, e.g.
|
|
19
|
+
* `/repo/node_modules/@describe-me/react/`, with posix separators and a
|
|
20
|
+
* trailing slash the way Vite ids look, or null when it cannot be resolved.
|
|
21
|
+
* It is the real path, so a workspace link points at its source directory.
|
|
22
|
+
*/
|
|
23
|
+
export function adapterPackageRoot(root, packageName) {
|
|
24
|
+
let entry;
|
|
25
|
+
// The package does not export `./package.json`, so resolve its entry and walk up.
|
|
26
|
+
try {
|
|
27
|
+
entry = createRequire(join(root, 'package.json')).resolve(packageName);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
let directory = dirname(entry);
|
|
33
|
+
while (directory !== dirname(directory)) {
|
|
34
|
+
if (packageNameIn(directory) === packageName) {
|
|
35
|
+
return `${directory.split(sep).join('/')}/`;
|
|
36
|
+
}
|
|
37
|
+
directory = dirname(directory);
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
package/dist/asset-store.d.ts
CHANGED
|
@@ -15,8 +15,11 @@ export declare class AssetStore {
|
|
|
15
15
|
/** Stored name per URL pathname in this run, or null when it is not a project file. */
|
|
16
16
|
private readonly names;
|
|
17
17
|
private readonly notFound;
|
|
18
|
-
/**
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Asset names per snapshot or style chunk. Both are content-addressed, so an
|
|
20
|
+
* entry never goes stale.
|
|
21
|
+
*/
|
|
22
|
+
private readonly referencesByFile;
|
|
20
23
|
constructor(outDir: string, root: string);
|
|
21
24
|
/** Forget what the previous run found: files may have changed or appeared since. */
|
|
22
25
|
startRun(): void;
|
|
@@ -31,12 +34,12 @@ export declare class AssetStore {
|
|
|
31
34
|
/** Root-relative paths that snapshots in this run pointed at but that do not exist. */
|
|
32
35
|
missing(): string[];
|
|
33
36
|
/**
|
|
34
|
-
* Drop the assets no kept snapshot refers to. Paths are
|
|
35
|
-
* output directory, as the manifest stores them. Every kept
|
|
36
|
-
* not only the ones written now: snapshots of modules that
|
|
37
|
-
* were rewritten by an earlier run.
|
|
37
|
+
* Drop the assets no kept snapshot or style chunk refers to. Paths are
|
|
38
|
+
* relative to the output directory, as the manifest stores them. Every kept
|
|
39
|
+
* file is scanned, not only the ones written now: snapshots of modules that
|
|
40
|
+
* were not re-run were rewritten by an earlier run.
|
|
38
41
|
*/
|
|
39
|
-
collectGarbage(
|
|
42
|
+
collectGarbage(files: Iterable<string>): void;
|
|
40
43
|
private referencesIn;
|
|
41
44
|
/** The asset URL for a root-relative URL path, or null when it is not a project file. */
|
|
42
45
|
private assetUrl;
|
package/dist/asset-store.js
CHANGED
|
@@ -94,8 +94,11 @@ export class AssetStore {
|
|
|
94
94
|
/** Stored name per URL pathname in this run, or null when it is not a project file. */
|
|
95
95
|
names = new Map();
|
|
96
96
|
notFound = new Set();
|
|
97
|
-
/**
|
|
98
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Asset names per snapshot or style chunk. Both are content-addressed, so an
|
|
99
|
+
* entry never goes stale.
|
|
100
|
+
*/
|
|
101
|
+
referencesByFile = new Map();
|
|
99
102
|
constructor(outDir, root) {
|
|
100
103
|
this.outDir = outDir;
|
|
101
104
|
this.dir = join(outDir, 'assets');
|
|
@@ -137,19 +140,19 @@ export class AssetStore {
|
|
|
137
140
|
return Array.from(this.notFound).sort();
|
|
138
141
|
}
|
|
139
142
|
/**
|
|
140
|
-
* Drop the assets no kept snapshot refers to. Paths are
|
|
141
|
-
* output directory, as the manifest stores them. Every kept
|
|
142
|
-
* not only the ones written now: snapshots of modules that
|
|
143
|
-
* were rewritten by an earlier run.
|
|
143
|
+
* Drop the assets no kept snapshot or style chunk refers to. Paths are
|
|
144
|
+
* relative to the output directory, as the manifest stores them. Every kept
|
|
145
|
+
* file is scanned, not only the ones written now: snapshots of modules that
|
|
146
|
+
* were not re-run were rewritten by an earlier run.
|
|
144
147
|
*/
|
|
145
|
-
collectGarbage(
|
|
148
|
+
collectGarbage(files) {
|
|
146
149
|
const stored = readdirSync(this.dir);
|
|
147
150
|
if (stored.length === 0) {
|
|
148
151
|
return;
|
|
149
152
|
}
|
|
150
153
|
const keep = new Set();
|
|
151
|
-
for (const
|
|
152
|
-
for (const name of this.referencesIn(
|
|
154
|
+
for (const file of files) {
|
|
155
|
+
for (const name of this.referencesIn(file)) {
|
|
153
156
|
keep.add(name);
|
|
154
157
|
}
|
|
155
158
|
}
|
|
@@ -159,18 +162,18 @@ export class AssetStore {
|
|
|
159
162
|
}
|
|
160
163
|
}
|
|
161
164
|
}
|
|
162
|
-
referencesIn(
|
|
163
|
-
const cached = this.
|
|
165
|
+
referencesIn(file) {
|
|
166
|
+
const cached = this.referencesByFile.get(file);
|
|
164
167
|
if (cached) {
|
|
165
168
|
return cached;
|
|
166
169
|
}
|
|
167
|
-
const path = join(this.outDir,
|
|
170
|
+
const path = join(this.outDir, file);
|
|
168
171
|
if (!isFile(path)) {
|
|
169
172
|
return [];
|
|
170
173
|
}
|
|
171
|
-
const
|
|
172
|
-
const names = Array.from(
|
|
173
|
-
this.
|
|
174
|
+
const text = readFileSync(path, 'utf8');
|
|
175
|
+
const names = Array.from(text.matchAll(ASSET_REFERENCE), (match) => match[1]);
|
|
176
|
+
this.referencesByFile.set(file, names);
|
|
174
177
|
return names;
|
|
175
178
|
}
|
|
176
179
|
/** The asset URL for a root-relative URL path, or null when it is not a project file. */
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CompiledGlob } from './runtime-options.js';
|
|
2
|
+
/**
|
|
3
|
+
* Plain `{ source, flags }` pairs, because the globs travel to the test
|
|
4
|
+
* runtime as JSON. Invalid input fails here, before any test runs.
|
|
5
|
+
*/
|
|
6
|
+
export declare function compileGlobs(patterns: string | string[] | undefined): CompiledGlob[];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { inspect } from 'node:util';
|
|
2
|
+
import picomatch from 'picomatch';
|
|
3
|
+
/**
|
|
4
|
+
* Plain `{ source, flags }` pairs, because the globs travel to the test
|
|
5
|
+
* runtime as JSON. Invalid input fails here, before any test runs.
|
|
6
|
+
*/
|
|
7
|
+
export function compileGlobs(patterns) {
|
|
8
|
+
if (patterns === undefined) {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
const list = Array.isArray(patterns) ? patterns : [patterns];
|
|
12
|
+
return list.map((pattern) => {
|
|
13
|
+
// picomatch throws its own unprefixed TypeError for an empty string.
|
|
14
|
+
if (typeof pattern !== 'string' || pattern === '') {
|
|
15
|
+
throw new Error(`describe-me: include and exclude expect non-empty glob strings, got ${inspect(pattern)}`);
|
|
16
|
+
}
|
|
17
|
+
const regExp = picomatch.makeRe(pattern, { dot: true, windows: false });
|
|
18
|
+
return { source: regExp.source, flags: regExp.flags };
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RuntimeOptions } from './runtime-options.js';
|
|
2
|
+
/**
|
|
3
|
+
* Takes a path relative to the root, with posix separators. Build it once and
|
|
4
|
+
* reuse the returned function, which memoizes per file name. The test runtime
|
|
5
|
+
* imports this file, so no Node imports.
|
|
6
|
+
*/
|
|
7
|
+
export declare function fileFilter(options: Pick<RuntimeOptions, 'include' | 'exclude'>): (fileName: string) => boolean;
|
|
@@ -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
|
@@ -27,6 +27,14 @@ 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[];
|
|
30
38
|
}
|
|
31
39
|
type VitePlugin = NonNullable<ViteUserConfig['plugins']>[number];
|
|
32
40
|
/**
|
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,30 @@ 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
|
+
});
|
|
83
116
|
// Vite concatenates arrays when merging, so only add `default` when the
|
|
84
117
|
// user has not chosen their own reporters; otherwise they would lose it.
|
|
85
118
|
const reporters = userConfig.test?.reporters ? [reporter] : ['default', reporter];
|
|
86
119
|
const config = environment === 'browser' ? browserConfig(renderModule) : domConfig(userConfig, options);
|
|
87
|
-
return { ...config, test: { ...config.test, reporters } };
|
|
120
|
+
return { ...config, test: { ...config.test, reporters, provide } };
|
|
88
121
|
},
|
|
89
122
|
async resolveId(source, importer) {
|
|
90
123
|
if (!enabled || source !== renderModule.original) {
|
|
91
124
|
return null;
|
|
92
125
|
}
|
|
93
126
|
const fromAdapter = importer !== undefined &&
|
|
94
|
-
(importer
|
|
95
|
-
ADAPTER_PATHS.some((path) => importer.includes(path)));
|
|
127
|
+
isAdapterModule(importer, adapterRoot, packageNameOf(renderModule.adapter));
|
|
96
128
|
if (fromAdapter) {
|
|
97
129
|
return null;
|
|
98
130
|
}
|
|
@@ -101,13 +133,14 @@ export function describeMe(options = {}) {
|
|
|
101
133
|
// recording nothing, and in a DOM environment it would also leave every
|
|
102
134
|
// test mounted, because Testing Library's auto-cleanup is switched off.
|
|
103
135
|
if (!resolved) {
|
|
104
|
-
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)}.`);
|
|
105
137
|
}
|
|
106
138
|
return resolved;
|
|
107
139
|
},
|
|
108
140
|
configResolved(config) {
|
|
109
141
|
root = config.root;
|
|
110
142
|
serving = config.command === 'serve';
|
|
143
|
+
adapterRoot = adapterPackageRoot(config.root, packageNameOf(renderModule.adapter));
|
|
111
144
|
},
|
|
112
145
|
// Names components after their export (see registerExports). `order: 'post'`
|
|
113
146
|
// 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,25 @@ 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;
|
|
11
22
|
private assets;
|
|
23
|
+
private styles;
|
|
12
24
|
private modules;
|
|
13
25
|
constructor(options?: DescribeMeReporterOptions);
|
|
14
26
|
onInit(vitest: Vitest): void;
|
|
@@ -16,12 +28,25 @@ export default class DescribeMeReporter implements Reporter {
|
|
|
16
28
|
/**
|
|
17
29
|
* A filtered run (`vitest run Button.test.tsx`) must not wipe the other
|
|
18
30
|
* modules from the manifest, so start from whatever the last run wrote.
|
|
19
|
-
* 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.
|
|
20
33
|
*/
|
|
21
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
|
+
*/
|
|
22
39
|
onTestModuleEnd(module: TestModule): void;
|
|
23
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;
|
|
24
45
|
private toManifestTest;
|
|
25
|
-
/**
|
|
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
|
+
*/
|
|
26
51
|
private writeSnapshot;
|
|
27
52
|
}
|
package/dist/reporter.js
CHANGED
|
@@ -1,31 +1,45 @@
|
|
|
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;
|
|
16
24
|
root = process.cwd();
|
|
17
25
|
outDir = '';
|
|
18
26
|
snapshots;
|
|
19
27
|
assets;
|
|
28
|
+
styles;
|
|
20
29
|
modules = new Map();
|
|
21
30
|
constructor(options = {}) {
|
|
22
31
|
this.outDirOption = options.outDir ?? '.describe-me';
|
|
32
|
+
this.isRecordedFile = fileFilter({
|
|
33
|
+
include: compileGlobs(options.include),
|
|
34
|
+
exclude: compileGlobs(options.exclude),
|
|
35
|
+
});
|
|
23
36
|
}
|
|
24
37
|
onInit(vitest) {
|
|
25
38
|
this.root = vitest.config.root;
|
|
26
39
|
this.outDir = resolve(this.root, this.outDirOption);
|
|
27
40
|
this.snapshots = new SnapshotStore(this.outDir);
|
|
28
41
|
this.assets = new AssetStore(this.outDir, this.root);
|
|
42
|
+
this.styles = new StyleStore(this.outDir);
|
|
29
43
|
this.seedFromPreviousRun();
|
|
30
44
|
}
|
|
31
45
|
onTestRunStart() {
|
|
@@ -34,7 +48,8 @@ export default class DescribeMeReporter {
|
|
|
34
48
|
/**
|
|
35
49
|
* A filtered run (`vitest run Button.test.tsx`) must not wipe the other
|
|
36
50
|
* modules from the manifest, so start from whatever the last run wrote.
|
|
37
|
-
* Modules whose file no longer
|
|
51
|
+
* Modules whose file is gone or no longer selected by `include` / `exclude`
|
|
52
|
+
* are dropped, and so are the empty tests that 0.4 still wrote.
|
|
38
53
|
*/
|
|
39
54
|
seedFromPreviousRun() {
|
|
40
55
|
const file = join(this.outDir, 'manifest.json');
|
|
@@ -44,8 +59,11 @@ export default class DescribeMeReporter {
|
|
|
44
59
|
try {
|
|
45
60
|
const previous = JSON.parse(readFileSync(file, 'utf8'));
|
|
46
61
|
for (const mod of previous.modules ?? []) {
|
|
47
|
-
|
|
48
|
-
|
|
62
|
+
const tests = mod.tests.filter((test) => isRecordedTest(test));
|
|
63
|
+
if (tests.length > 0 &&
|
|
64
|
+
this.isRecordedModule(mod.id) &&
|
|
65
|
+
existsSync(resolve(this.root, mod.id))) {
|
|
66
|
+
this.modules.set(mod.id, { ...mod, tests });
|
|
49
67
|
}
|
|
50
68
|
}
|
|
51
69
|
}
|
|
@@ -53,9 +71,28 @@ export default class DescribeMeReporter {
|
|
|
53
71
|
// corrupt or incompatible manifest: start fresh
|
|
54
72
|
}
|
|
55
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* A test skipped in this run keeps its previous entry, so a filtered run does
|
|
76
|
+
* not erase documentation.
|
|
77
|
+
*/
|
|
56
78
|
onTestModuleEnd(module) {
|
|
57
79
|
const id = relative(this.root, module.moduleId);
|
|
58
|
-
|
|
80
|
+
if (!this.isRecordedModule(id)) {
|
|
81
|
+
this.modules.delete(id);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const previous = this.modules.get(id);
|
|
85
|
+
const tests = [];
|
|
86
|
+
for (const tc of module.children.allTests()) {
|
|
87
|
+
const test = this.manifestTestOf(tc, previous);
|
|
88
|
+
if (test) {
|
|
89
|
+
tests.push(test);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (tests.length === 0) {
|
|
93
|
+
this.modules.delete(id);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
59
96
|
this.modules.set(id, { id, tests });
|
|
60
97
|
}
|
|
61
98
|
async onTestRunEnd() {
|
|
@@ -74,20 +111,35 @@ export default class DescribeMeReporter {
|
|
|
74
111
|
};
|
|
75
112
|
const snapshotFiles = manifest.modules.flatMap((mod) => mod.tests.flatMap((test) => test.frames.map((frame) => frame.snapshot)));
|
|
76
113
|
this.snapshots.collectGarbage(snapshotFiles);
|
|
77
|
-
this.
|
|
114
|
+
const styleFiles = this.styles.collectGarbage(snapshotFiles);
|
|
115
|
+
this.assets.collectGarbage([...snapshotFiles, ...styleFiles]);
|
|
78
116
|
writeFileSync(join(this.outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
79
117
|
printDiagnostics(manifest);
|
|
80
118
|
}
|
|
81
|
-
|
|
82
|
-
|
|
119
|
+
/** Module ids use the platform's separators. Globs expect posix ones. */
|
|
120
|
+
isRecordedModule(id) {
|
|
121
|
+
return this.isRecordedFile(id.split(sep).join('/'));
|
|
122
|
+
}
|
|
123
|
+
/** Decided before any snapshot is written, so a dropped test writes no file. */
|
|
124
|
+
manifestTestOf(tc, previous) {
|
|
125
|
+
const record = recordOf(tc);
|
|
126
|
+
if (record && isRecordedTest(record)) {
|
|
127
|
+
return this.toManifestTest(tc, record);
|
|
128
|
+
}
|
|
129
|
+
if (tc.result().state !== 'skipped') {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
return previous?.tests.find((test) => test.id === tc.id && test.fullName === tc.fullName && isRecordedTest(test));
|
|
133
|
+
}
|
|
134
|
+
toManifestTest(tc, record) {
|
|
83
135
|
const result = tc.result();
|
|
84
|
-
const frames =
|
|
136
|
+
const frames = record.frames.map((frame) => ({
|
|
85
137
|
id: frame.id,
|
|
86
138
|
kind: frame.kind,
|
|
87
139
|
label: frame.label,
|
|
88
140
|
at: frame.at,
|
|
89
141
|
meta: frame.meta,
|
|
90
|
-
snapshot: this.writeSnapshot(frame.snapshot, record
|
|
142
|
+
snapshot: this.writeSnapshot(frame.snapshot, record.origin),
|
|
91
143
|
}));
|
|
92
144
|
return {
|
|
93
145
|
id: tc.id,
|
|
@@ -97,12 +149,18 @@ export default class DescribeMeReporter {
|
|
|
97
149
|
state: result.state ?? 'pending',
|
|
98
150
|
duration: tc.diagnostic()?.duration,
|
|
99
151
|
errors: result.errors?.map((error) => ({ message: error.message, stack: error.stack })),
|
|
100
|
-
component: record
|
|
152
|
+
component: record.component,
|
|
101
153
|
frames,
|
|
102
154
|
};
|
|
103
155
|
}
|
|
104
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* Store one frame's DOM, with asset URLs pointing into the asset store and
|
|
158
|
+
* long stylesheets moved to the style store. Assets go first, so stored CSS
|
|
159
|
+
* carries asset URLs too.
|
|
160
|
+
*/
|
|
105
161
|
writeSnapshot(snapshot, origin) {
|
|
106
|
-
|
|
162
|
+
// A mixed install, where an older core created the global recorder, still hands over objects.
|
|
163
|
+
const json = typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot);
|
|
164
|
+
return this.snapshots.write(this.styles.extract(this.assets.rewrite(json, origin)));
|
|
107
165
|
}
|
|
108
166
|
}
|
|
@@ -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());
|
package/dist/setup.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Vitest setup file for browser mode: `setupFiles: ['@describe-me/vitest/setup']`.
|
|
3
3
|
* Patches Vitest's locators and userEvent so every interaction records a
|
|
4
|
-
* frame, and registers the recording hooks.
|
|
4
|
+
* frame, and registers the recording hooks. Which test files are recorded
|
|
5
|
+
* (`include` / `exclude`) comes from the plugin. For jsdom use `./setup-dom`.
|
|
5
6
|
*/
|
|
6
7
|
import { patchLocators } from './patch-locators.js';
|
|
7
8
|
import { patchUserEvent } from './patch-user-event.js';
|
|
9
|
+
import { readRuntimeOptions } from './read-runtime-options.js';
|
|
8
10
|
import { registerRecordingHooks } from './register-recording-hooks.js';
|
|
9
11
|
patchLocators();
|
|
10
12
|
patchUserEvent();
|
|
11
|
-
registerRecordingHooks();
|
|
13
|
+
registerRecordingHooks(readRuntimeOptions());
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split a stylesheet into chunks of about 4 KB, cut between top-level rules
|
|
3
|
+
* where their content decides, so a sheet that grows from test to test keeps
|
|
4
|
+
* most of its chunks. `chunks.join('')` is always the input, even for
|
|
5
|
+
* malformed CSS.
|
|
6
|
+
*/
|
|
7
|
+
export declare function splitStylesheet(css: string): string[];
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** A chunk is never closed below this length, so small rules do not end up in tiny files. */
|
|
2
|
+
const MIN_CHUNK = 1024;
|
|
3
|
+
/**
|
|
4
|
+
* A chunk is closed once it reaches this length. Only its last rule can take it
|
|
5
|
+
* past the limit, because a rule is never split.
|
|
6
|
+
*/
|
|
7
|
+
const MAX_CHUNK = 16384;
|
|
8
|
+
/**
|
|
9
|
+
* Past `MIN_CHUNK`, a rule of `n` characters ends its chunk with a probability
|
|
10
|
+
* of about `n / BOUNDARY_SPACING`, decided by a hash of its text. Chunks then
|
|
11
|
+
* average about 4 KB, and where they end depends on content, not on offsets.
|
|
12
|
+
*/
|
|
13
|
+
const BOUNDARY_SPACING = 3072;
|
|
14
|
+
/** Boundaries only need a cheap, stable hash, not sha1. */
|
|
15
|
+
function fnv1a32(text) {
|
|
16
|
+
let hash = 0x811c9dc5;
|
|
17
|
+
for (let i = 0; i < text.length; i++) {
|
|
18
|
+
hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
|
|
19
|
+
}
|
|
20
|
+
return hash >>> 0;
|
|
21
|
+
}
|
|
22
|
+
function isBoundary(piece) {
|
|
23
|
+
return fnv1a32(piece) % BOUNDARY_SPACING < piece.length;
|
|
24
|
+
}
|
|
25
|
+
function endOfComment(css, start) {
|
|
26
|
+
const close = css.indexOf('*/', start + 2);
|
|
27
|
+
return close === -1 ? css.length : close + 2;
|
|
28
|
+
}
|
|
29
|
+
function endOfString(css, start) {
|
|
30
|
+
const quote = css[start];
|
|
31
|
+
let i = start + 1;
|
|
32
|
+
while (i < css.length) {
|
|
33
|
+
if (css[i] === '\\') {
|
|
34
|
+
i += 2;
|
|
35
|
+
}
|
|
36
|
+
else if (css[i] === quote) {
|
|
37
|
+
return i + 1;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return css.length;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Cut a stylesheet after every top-level rule: after the `}` that closes a
|
|
47
|
+
* top-level block, or after a semicolon at depth 0 (`@import`). Strings,
|
|
48
|
+
* comments and escapes are skipped and nothing in parentheses counts, so
|
|
49
|
+
* `content: "}"` or an unquoted font URL with semicolons never ends a rule.
|
|
50
|
+
*/
|
|
51
|
+
function topLevelPieces(css) {
|
|
52
|
+
const pieces = [];
|
|
53
|
+
let start = 0;
|
|
54
|
+
let braces = 0;
|
|
55
|
+
let parens = 0;
|
|
56
|
+
let i = 0;
|
|
57
|
+
while (i < css.length) {
|
|
58
|
+
const char = css[i];
|
|
59
|
+
if (char === '\\') {
|
|
60
|
+
i += 2;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (char === '/' && css[i + 1] === '*') {
|
|
64
|
+
i = endOfComment(css, i);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (char === '"' || char === "'") {
|
|
68
|
+
i = endOfString(css, i);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (char === '(') {
|
|
72
|
+
parens++;
|
|
73
|
+
}
|
|
74
|
+
else if (char === ')') {
|
|
75
|
+
parens = Math.max(0, parens - 1);
|
|
76
|
+
}
|
|
77
|
+
else if (parens === 0 && char === '{') {
|
|
78
|
+
braces++;
|
|
79
|
+
}
|
|
80
|
+
else if (parens === 0 && char === '}') {
|
|
81
|
+
braces = Math.max(0, braces - 1);
|
|
82
|
+
}
|
|
83
|
+
i++;
|
|
84
|
+
if (parens === 0 && braces === 0 && (char === '}' || char === ';')) {
|
|
85
|
+
pieces.push(css.slice(start, i));
|
|
86
|
+
start = i;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (start < css.length) {
|
|
90
|
+
pieces.push(css.slice(start));
|
|
91
|
+
}
|
|
92
|
+
return pieces;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Split a stylesheet into chunks of about 4 KB, cut between top-level rules
|
|
96
|
+
* where their content decides, so a sheet that grows from test to test keeps
|
|
97
|
+
* most of its chunks. `chunks.join('')` is always the input, even for
|
|
98
|
+
* malformed CSS.
|
|
99
|
+
*/
|
|
100
|
+
export function splitStylesheet(css) {
|
|
101
|
+
const chunks = [];
|
|
102
|
+
let current = '';
|
|
103
|
+
for (const piece of topLevelPieces(css)) {
|
|
104
|
+
current += piece;
|
|
105
|
+
if (current.length >= MAX_CHUNK || (current.length >= MIN_CHUNK && isBoundary(piece))) {
|
|
106
|
+
chunks.push(current);
|
|
107
|
+
current = '';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (current !== '') {
|
|
111
|
+
chunks.push(current);
|
|
112
|
+
}
|
|
113
|
+
return chunks;
|
|
114
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-addressed store for the stylesheets rrweb copies into every capture,
|
|
3
|
+
* living in `<outDir>/styles`. A sheet is stored once, in chunks, so one that
|
|
4
|
+
* grows from test to test shares most of them.
|
|
5
|
+
*/
|
|
6
|
+
export declare class StyleStore {
|
|
7
|
+
private readonly outDir;
|
|
8
|
+
private readonly dir;
|
|
9
|
+
/** Snapshots are content-addressed, so a cached entry never goes stale. */
|
|
10
|
+
private readonly referencesBySnapshot;
|
|
11
|
+
constructor(outDir: string);
|
|
12
|
+
/**
|
|
13
|
+
* Replace each long `_cssText` in a serialized snapshot with a reference to
|
|
14
|
+
* its chunks, writing the ones not stored yet. Idempotent.
|
|
15
|
+
*/
|
|
16
|
+
extract(json: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Drop the chunks no kept snapshot refers to, and return the kept ones as
|
|
19
|
+
* sorted `styles/<hash>.css` paths for the asset store. Paths are relative to
|
|
20
|
+
* the output directory, as the manifest stores them.
|
|
21
|
+
*/
|
|
22
|
+
collectGarbage(snapshotFiles: Iterable<string>): string[];
|
|
23
|
+
private write;
|
|
24
|
+
private referencesIn;
|
|
25
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { STYLE_URL_PREFIX } from '@describe-me/core/types';
|
|
5
|
+
import { splitStylesheet } from './split-stylesheet.js';
|
|
6
|
+
/** Shorter sheets stay inline, where they cost less than a reference and a file of their own. */
|
|
7
|
+
const MIN_EXTRACTED_LENGTH = 256;
|
|
8
|
+
/**
|
|
9
|
+
* Inside a JSON string every `"` is escaped, so only a real `_cssText` key
|
|
10
|
+
* matches. Unrolled, because `(?:[^"\\]|\\.)*` is much slower on
|
|
11
|
+
* megabyte-long sheets.
|
|
12
|
+
*/
|
|
13
|
+
const CSS_TEXT_MEMBER = /"_cssText":"([^"\\]*(?:\\.[^"\\]*)*)"/g;
|
|
14
|
+
const STYLE_REFERENCE = new RegExp(`${STYLE_URL_PREFIX}([0-9a-f]{16}(?:\\+[0-9a-f]{16})*)`, 'g');
|
|
15
|
+
/**
|
|
16
|
+
* Content-addressed store for the stylesheets rrweb copies into every capture,
|
|
17
|
+
* living in `<outDir>/styles`. A sheet is stored once, in chunks, so one that
|
|
18
|
+
* grows from test to test shares most of them.
|
|
19
|
+
*/
|
|
20
|
+
export class StyleStore {
|
|
21
|
+
outDir;
|
|
22
|
+
dir;
|
|
23
|
+
/** Snapshots are content-addressed, so a cached entry never goes stale. */
|
|
24
|
+
referencesBySnapshot = new Map();
|
|
25
|
+
constructor(outDir) {
|
|
26
|
+
this.outDir = outDir;
|
|
27
|
+
this.dir = join(outDir, 'styles');
|
|
28
|
+
mkdirSync(this.dir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Replace each long `_cssText` in a serialized snapshot with a reference to
|
|
32
|
+
* its chunks, writing the ones not stored yet. Idempotent.
|
|
33
|
+
*/
|
|
34
|
+
extract(json) {
|
|
35
|
+
return json.replace(CSS_TEXT_MEMBER, (member, escaped) => {
|
|
36
|
+
const css = JSON.parse(`"${escaped}"`);
|
|
37
|
+
if (css.length < MIN_EXTRACTED_LENGTH || css.startsWith(STYLE_URL_PREFIX)) {
|
|
38
|
+
return member;
|
|
39
|
+
}
|
|
40
|
+
const hashes = splitStylesheet(css).map((chunk) => this.write(chunk));
|
|
41
|
+
return `"_cssText":"${STYLE_URL_PREFIX}${hashes.join('+')}"`;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Drop the chunks no kept snapshot refers to, and return the kept ones as
|
|
46
|
+
* sorted `styles/<hash>.css` paths for the asset store. Paths are relative to
|
|
47
|
+
* the output directory, as the manifest stores them.
|
|
48
|
+
*/
|
|
49
|
+
collectGarbage(snapshotFiles) {
|
|
50
|
+
const keep = new Set();
|
|
51
|
+
for (const snapshotFile of snapshotFiles) {
|
|
52
|
+
for (const hash of this.referencesIn(snapshotFile)) {
|
|
53
|
+
keep.add(`${hash}.css`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const kept = [];
|
|
57
|
+
for (const file of readdirSync(this.dir)) {
|
|
58
|
+
if (keep.has(file)) {
|
|
59
|
+
kept.push(`styles/${file}`);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
rmSync(join(this.dir, file), { force: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return kept.sort();
|
|
66
|
+
}
|
|
67
|
+
write(chunk) {
|
|
68
|
+
const hash = createHash('sha1').update(chunk).digest('hex').slice(0, 16);
|
|
69
|
+
const file = join(this.dir, `${hash}.css`);
|
|
70
|
+
// Not remembered in memory: in watch mode, GC can delete the file between runs.
|
|
71
|
+
if (!existsSync(file)) {
|
|
72
|
+
writeFileSync(file, chunk);
|
|
73
|
+
}
|
|
74
|
+
return hash;
|
|
75
|
+
}
|
|
76
|
+
referencesIn(snapshotFile) {
|
|
77
|
+
const cached = this.referencesBySnapshot.get(snapshotFile);
|
|
78
|
+
if (cached) {
|
|
79
|
+
return cached;
|
|
80
|
+
}
|
|
81
|
+
const path = join(this.outDir, snapshotFile);
|
|
82
|
+
if (!statSync(path, { throwIfNoEntry: false })?.isFile()) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
const json = readFileSync(path, 'utf8');
|
|
86
|
+
const hashes = Array.from(json.matchAll(STYLE_REFERENCE), (match) => match[1].split('+')).flat();
|
|
87
|
+
this.referencesBySnapshot.set(snapshotFile, hashes);
|
|
88
|
+
return hashes;
|
|
89
|
+
}
|
|
90
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@describe-me/vitest",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-next.0",
|
|
4
4
|
"description": "Vitest integration for describe-me: plugin, setup file and reporter",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vitest",
|
|
@@ -58,12 +58,13 @@
|
|
|
58
58
|
"./dist/setup-dom.js"
|
|
59
59
|
],
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"
|
|
61
|
+
"picomatch": "^4.0.3",
|
|
62
|
+
"@describe-me/core": "^0.5.0-next.0"
|
|
62
63
|
},
|
|
63
64
|
"peerDependencies": {
|
|
64
|
-
"vitest": "
|
|
65
|
-
"typescript": "
|
|
66
|
-
"@testing-library/user-event": "
|
|
65
|
+
"vitest": "^4.0.0 || ^5.0.0",
|
|
66
|
+
"typescript": "^5.0.0 || ^6.0.0",
|
|
67
|
+
"@testing-library/user-event": "^14.0.0"
|
|
67
68
|
},
|
|
68
69
|
"peerDependenciesMeta": {
|
|
69
70
|
"typescript": {
|
|
@@ -78,7 +79,8 @@
|
|
|
78
79
|
"@types/node": "^22",
|
|
79
80
|
"typescript": "^6",
|
|
80
81
|
"@testing-library/user-event": "^14.6.1",
|
|
81
|
-
"@testing-library/dom": "^10.4.0"
|
|
82
|
+
"@testing-library/dom": "^10.4.0",
|
|
83
|
+
"@types/picomatch": "^4.0.3"
|
|
82
84
|
},
|
|
83
85
|
"scripts": {
|
|
84
86
|
"build": "rimraf dist && tsc -p tsconfig.json"
|
package/dist/element-label.d.ts
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
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;
|
package/dist/element-label.js
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
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
|
-
}
|