@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
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.1",
|
|
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.1"
|
|
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
|
-
}
|