@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
package/dist/reporter.js
CHANGED
|
@@ -1,33 +1,55 @@
|
|
|
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
|
+
import { AssetStore } from './asset-store.js';
|
|
8
9
|
import { collectComponentDocsSafely } from './collect-component-docs-safely.js';
|
|
10
|
+
import { compileGlobs } from './compile-globs.js';
|
|
9
11
|
import { componentEntries } from './component-entries.js';
|
|
12
|
+
import { fileFilter } from './file-filter.js';
|
|
13
|
+
import { isRecordedTest } from './is-recorded-test.js';
|
|
14
|
+
import { printDiagnostics } from './print-diagnostics.js';
|
|
10
15
|
import { SnapshotStore } from './snapshot-store.js';
|
|
16
|
+
import { StyleStore } from './style-store.js';
|
|
11
17
|
import { testPath } from './test-path.js';
|
|
18
|
+
function recordOf(tc) {
|
|
19
|
+
return tc.meta()[META_KEY];
|
|
20
|
+
}
|
|
12
21
|
export default class DescribeMeReporter {
|
|
13
22
|
outDirOption;
|
|
23
|
+
isRecordedFile;
|
|
14
24
|
root = process.cwd();
|
|
15
25
|
outDir = '';
|
|
16
26
|
snapshots;
|
|
27
|
+
assets;
|
|
28
|
+
styles;
|
|
17
29
|
modules = new Map();
|
|
18
30
|
constructor(options = {}) {
|
|
19
31
|
this.outDirOption = options.outDir ?? '.describe-me';
|
|
32
|
+
this.isRecordedFile = fileFilter({
|
|
33
|
+
include: compileGlobs(options.include),
|
|
34
|
+
exclude: compileGlobs(options.exclude),
|
|
35
|
+
});
|
|
20
36
|
}
|
|
21
37
|
onInit(vitest) {
|
|
22
38
|
this.root = vitest.config.root;
|
|
23
39
|
this.outDir = resolve(this.root, this.outDirOption);
|
|
24
40
|
this.snapshots = new SnapshotStore(this.outDir);
|
|
41
|
+
this.assets = new AssetStore(this.outDir, this.root);
|
|
42
|
+
this.styles = new StyleStore(this.outDir);
|
|
25
43
|
this.seedFromPreviousRun();
|
|
26
44
|
}
|
|
45
|
+
onTestRunStart() {
|
|
46
|
+
this.assets.startRun();
|
|
47
|
+
}
|
|
27
48
|
/**
|
|
28
49
|
* A filtered run (`vitest run Button.test.tsx`) must not wipe the other
|
|
29
50
|
* modules from the manifest, so start from whatever the last run wrote.
|
|
30
|
-
* 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.
|
|
31
53
|
*/
|
|
32
54
|
seedFromPreviousRun() {
|
|
33
55
|
const file = join(this.outDir, 'manifest.json');
|
|
@@ -37,8 +59,11 @@ export default class DescribeMeReporter {
|
|
|
37
59
|
try {
|
|
38
60
|
const previous = JSON.parse(readFileSync(file, 'utf8'));
|
|
39
61
|
for (const mod of previous.modules ?? []) {
|
|
40
|
-
|
|
41
|
-
|
|
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 });
|
|
42
67
|
}
|
|
43
68
|
}
|
|
44
69
|
}
|
|
@@ -46,34 +71,75 @@ export default class DescribeMeReporter {
|
|
|
46
71
|
// corrupt or incompatible manifest: start fresh
|
|
47
72
|
}
|
|
48
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* A test skipped in this run keeps its previous entry, so a filtered run does
|
|
76
|
+
* not erase documentation.
|
|
77
|
+
*/
|
|
49
78
|
onTestModuleEnd(module) {
|
|
50
79
|
const id = relative(this.root, module.moduleId);
|
|
51
|
-
|
|
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
|
+
}
|
|
52
96
|
this.modules.set(id, { id, tests });
|
|
53
97
|
}
|
|
54
98
|
async onTestRunEnd() {
|
|
55
99
|
const modules = Array.from(this.modules.values()).sort((left, right) => left.id.localeCompare(right.id));
|
|
56
100
|
const components = await collectComponentDocsSafely(this.root, componentEntries(modules, this.root));
|
|
101
|
+
// Only what this run found: a filtered run does not know which of the
|
|
102
|
+
// previous run's missing assets belonged to the modules it skipped.
|
|
103
|
+
const assetsMissing = this.assets.missing();
|
|
57
104
|
const manifest = {
|
|
58
105
|
version: 1,
|
|
59
106
|
generatedAt: new Date().toISOString(),
|
|
60
107
|
root: this.root,
|
|
61
108
|
modules,
|
|
62
109
|
components,
|
|
110
|
+
assetsMissing,
|
|
63
111
|
};
|
|
64
|
-
|
|
112
|
+
const snapshotFiles = manifest.modules.flatMap((mod) => mod.tests.flatMap((test) => test.frames.map((frame) => frame.snapshot)));
|
|
113
|
+
this.snapshots.collectGarbage(snapshotFiles);
|
|
114
|
+
const styleFiles = this.styles.collectGarbage(snapshotFiles);
|
|
115
|
+
this.assets.collectGarbage([...snapshotFiles, ...styleFiles]);
|
|
65
116
|
writeFileSync(join(this.outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
117
|
+
printDiagnostics(manifest);
|
|
118
|
+
}
|
|
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));
|
|
66
133
|
}
|
|
67
|
-
toManifestTest(tc) {
|
|
68
|
-
const record = tc.meta()[META_KEY];
|
|
134
|
+
toManifestTest(tc, record) {
|
|
69
135
|
const result = tc.result();
|
|
70
|
-
const frames =
|
|
136
|
+
const frames = record.frames.map((frame) => ({
|
|
71
137
|
id: frame.id,
|
|
72
138
|
kind: frame.kind,
|
|
73
139
|
label: frame.label,
|
|
74
140
|
at: frame.at,
|
|
75
141
|
meta: frame.meta,
|
|
76
|
-
snapshot: this.
|
|
142
|
+
snapshot: this.writeSnapshot(frame.snapshot, record.origin),
|
|
77
143
|
}));
|
|
78
144
|
return {
|
|
79
145
|
id: tc.id,
|
|
@@ -83,8 +149,18 @@ export default class DescribeMeReporter {
|
|
|
83
149
|
state: result.state ?? 'pending',
|
|
84
150
|
duration: tc.diagnostic()?.duration,
|
|
85
151
|
errors: result.errors?.map((error) => ({ message: error.message, stack: error.stack })),
|
|
86
|
-
component: record
|
|
152
|
+
component: record.component,
|
|
87
153
|
frames,
|
|
88
154
|
};
|
|
89
155
|
}
|
|
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
|
+
*/
|
|
161
|
+
writeSnapshot(snapshot, origin) {
|
|
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)));
|
|
165
|
+
}
|
|
90
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());
|
package/dist/snapshot-store.d.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
export declare class SnapshotStore {
|
|
6
6
|
private readonly dir;
|
|
7
7
|
constructor(outDir: string);
|
|
8
|
-
/** Write one snapshot and return its path relative to the manifest. */
|
|
9
|
-
write(
|
|
8
|
+
/** Write one serialized snapshot and return its path relative to the manifest. */
|
|
9
|
+
write(json: string): string;
|
|
10
10
|
/** Drop the files no manifest frame points at any more. */
|
|
11
11
|
collectGarbage(referenced: Iterable<string>): void;
|
|
12
12
|
}
|
package/dist/snapshot-store.js
CHANGED
|
@@ -11,9 +11,8 @@ export class SnapshotStore {
|
|
|
11
11
|
this.dir = join(outDir, 'snapshots');
|
|
12
12
|
mkdirSync(this.dir, { recursive: true });
|
|
13
13
|
}
|
|
14
|
-
/** Write one snapshot and return its path relative to the manifest. */
|
|
15
|
-
write(
|
|
16
|
-
const json = JSON.stringify(snapshot);
|
|
14
|
+
/** Write one serialized snapshot and return its path relative to the manifest. */
|
|
15
|
+
write(json) {
|
|
17
16
|
const hash = createHash('sha1').update(json).digest('hex').slice(0, 16);
|
|
18
17
|
const file = `${hash}.json`;
|
|
19
18
|
const abs = join(this.dir, file);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ViteUserConfig } from 'vitest/config';
|
|
2
|
+
/**
|
|
3
|
+
* The Vite and Vitest config that makes a DOM environment load the browser
|
|
4
|
+
* build of styled-components, or undefined when the project does not use
|
|
5
|
+
* styled-components 5 or later.
|
|
6
|
+
*
|
|
7
|
+
* Vitest resolves the Node build, whose `createGlobalStyle` never inserts its
|
|
8
|
+
* CSS on the client, so global resets and fonts would be missing from every
|
|
9
|
+
* snapshot. The alias points `styled-components` at the browser ESM file. The
|
|
10
|
+
* optimizer is needed as well: jest-styled-components `require`s
|
|
11
|
+
* styled-components through Node, which ignores the alias, and would get a
|
|
12
|
+
* second instance whose styles its serializer cannot see. Bundling both
|
|
13
|
+
* together gives them one.
|
|
14
|
+
*/
|
|
15
|
+
export declare function styledComponentsBrowserBuild(root: string): ViteUserConfig | undefined;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
function resolverFor(root) {
|
|
5
|
+
// createRequire wants an absolute file inside the directory; it does not have to exist.
|
|
6
|
+
const requireFromRoot = createRequire(join(resolve(root), 'package.json'));
|
|
7
|
+
return (id) => {
|
|
8
|
+
try {
|
|
9
|
+
return requireFromRoot.resolve(id);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function withoutDotSlash(path) {
|
|
17
|
+
return path.replace(/^\.\//, '');
|
|
18
|
+
}
|
|
19
|
+
/** The file the package's `browser` map swaps its `module` entry for, relative to the package. */
|
|
20
|
+
function browserModule(pkg) {
|
|
21
|
+
if (!pkg.module || !pkg.browser || typeof pkg.browser === 'string') {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const moduleEntry = withoutDotSlash(pkg.module);
|
|
25
|
+
for (const [from, to] of Object.entries(pkg.browser)) {
|
|
26
|
+
if (withoutDotSlash(from) === moduleEntry && typeof to === 'string') {
|
|
27
|
+
return to;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The Vite and Vitest config that makes a DOM environment load the browser
|
|
34
|
+
* build of styled-components, or undefined when the project does not use
|
|
35
|
+
* styled-components 5 or later.
|
|
36
|
+
*
|
|
37
|
+
* Vitest resolves the Node build, whose `createGlobalStyle` never inserts its
|
|
38
|
+
* CSS on the client, so global resets and fonts would be missing from every
|
|
39
|
+
* snapshot. The alias points `styled-components` at the browser ESM file. The
|
|
40
|
+
* optimizer is needed as well: jest-styled-components `require`s
|
|
41
|
+
* styled-components through Node, which ignores the alias, and would get a
|
|
42
|
+
* second instance whose styles its serializer cannot see. Bundling both
|
|
43
|
+
* together gives them one.
|
|
44
|
+
*/
|
|
45
|
+
export function styledComponentsBrowserBuild(root) {
|
|
46
|
+
const resolveFromRoot = resolverFor(root);
|
|
47
|
+
const manifest = resolveFromRoot('styled-components/package.json');
|
|
48
|
+
if (!manifest) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const pkg = JSON.parse(readFileSync(manifest, 'utf8'));
|
|
52
|
+
const major = Number.parseInt(pkg.version ?? '', 10);
|
|
53
|
+
const browserFile = browserModule(pkg);
|
|
54
|
+
if (Number.isNaN(major) || major < 5 || !browserFile) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const replacement = join(dirname(manifest), browserFile);
|
|
58
|
+
if (!existsSync(replacement)) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const include = ['styled-components'];
|
|
62
|
+
if (resolveFromRoot('jest-styled-components')) {
|
|
63
|
+
include.push('jest-styled-components');
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
resolve: { alias: [{ find: /^styled-components$/, replacement }] },
|
|
67
|
+
test: { deps: { optimizer: { client: { enabled: true, include } } } },
|
|
68
|
+
};
|
|
69
|
+
}
|
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;
|