@describe-me/vitest 0.5.0-next.1 → 0.5.0-next.3
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 +5 -0
- package/dist/font-audit.d.ts +33 -0
- package/dist/font-audit.js +296 -0
- package/dist/font-family-key.d.ts +5 -0
- package/dist/font-family-key.js +14 -0
- package/dist/font-family-list.d.ts +6 -0
- package/dist/font-family-list.js +66 -0
- package/dist/font-shorthand-families.d.ts +6 -0
- package/dist/font-shorthand-families.js +177 -0
- package/dist/font-stylesheet-families.d.ts +6 -0
- package/dist/font-stylesheet-families.js +54 -0
- package/dist/font-usage.d.ts +26 -0
- package/dist/font-usage.js +163 -0
- package/dist/head-stylesheets.d.ts +8 -0
- package/dist/head-stylesheets.js +51 -0
- package/dist/plugin.d.ts +7 -0
- package/dist/plugin.js +3 -1
- package/dist/print-diagnostics.d.ts +3 -2
- package/dist/print-diagnostics.js +13 -3
- package/dist/read-runtime-options.js +5 -1
- package/dist/record-action.js +18 -8
- package/dist/register-recording-hooks.d.ts +5 -4
- package/dist/register-recording-hooks.js +11 -5
- package/dist/reporter.d.ts +9 -0
- package/dist/reporter.js +50 -9
- package/dist/runtime-options.d.ts +3 -1
- package/dist/runtime-options.js +1 -1
- package/dist/setup-dom.js +3 -2
- package/dist/setup.js +2 -1
- package/dist/snapshot-styles.d.ts +17 -0
- package/dist/snapshot-styles.js +65 -0
- package/dist/stable-test-id.d.ts +5 -0
- package/dist/stable-test-id.js +12 -0
- package/dist/stable-test-ids.d.ts +5 -0
- package/dist/stable-test-ids.js +13 -0
- package/dist/system-font-family.d.ts +5 -0
- package/dist/system-font-family.js +78 -0
- package/dist/validate-render-frame.d.ts +6 -0
- package/dist/validate-render-frame.js +26 -0
- package/package.json +2 -2
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** A `var()` in the place of a family. */
|
|
2
|
+
export interface FontVarReference {
|
|
3
|
+
/** The custom property, such as `--font-body`. */
|
|
4
|
+
name: string;
|
|
5
|
+
/** The text after its first comma, when there is one. */
|
|
6
|
+
fallback?: string;
|
|
7
|
+
}
|
|
8
|
+
/** What a stylesheet or a `style` attribute says about fonts. */
|
|
9
|
+
export interface FontUsage {
|
|
10
|
+
/** The first family of each `font-family` and `font` declaration, unless it is a `var()`. */
|
|
11
|
+
families: string[];
|
|
12
|
+
/** Each `var()` in the place of a first family. */
|
|
13
|
+
varRefs: FontVarReference[];
|
|
14
|
+
/** Every custom property definition, in source order. */
|
|
15
|
+
variables: Map<string, string[]>;
|
|
16
|
+
/** The families of `@font-face` rules. */
|
|
17
|
+
declared: string[];
|
|
18
|
+
/** `@import` targets as written. */
|
|
19
|
+
imports: string[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The fonts that CSS text uses, defines as custom properties, declares with
|
|
23
|
+
* `@font-face` and imports. Reads a stylesheet or the bare text of a `style`
|
|
24
|
+
* attribute. Only `font-family`, `font` and custom properties count.
|
|
25
|
+
*/
|
|
26
|
+
export declare function fontUsage(css: string): FontUsage;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { cssReferences } from '@describe-me/core/css-references';
|
|
2
|
+
import { fontFamilyList } from './font-family-list.js';
|
|
3
|
+
import { fontShorthandFamilies } from './font-shorthand-families.js';
|
|
4
|
+
const IMPORTANT = /!\s*important\s*$/i;
|
|
5
|
+
function commentEnd(css, open) {
|
|
6
|
+
const close = css.indexOf('*/', open + 2);
|
|
7
|
+
return close === -1 ? css.length : close + 2;
|
|
8
|
+
}
|
|
9
|
+
function stringEnd(css, open) {
|
|
10
|
+
let index = open + 1;
|
|
11
|
+
while (index < css.length && css[index] !== css[open]) {
|
|
12
|
+
index += css[index] === '\\' ? 2 : 1;
|
|
13
|
+
}
|
|
14
|
+
return index + 1;
|
|
15
|
+
}
|
|
16
|
+
function piecesOf(css) {
|
|
17
|
+
const pieces = [];
|
|
18
|
+
let text = '';
|
|
19
|
+
let copied = 0;
|
|
20
|
+
let parens = 0;
|
|
21
|
+
let index = 0;
|
|
22
|
+
while (index < css.length) {
|
|
23
|
+
const char = css[index];
|
|
24
|
+
if (char === '/' && css[index + 1] === '*') {
|
|
25
|
+
text += `${css.slice(copied, index)} `;
|
|
26
|
+
index = commentEnd(css, index);
|
|
27
|
+
copied = index;
|
|
28
|
+
}
|
|
29
|
+
else if (char === '\\') {
|
|
30
|
+
index += 2;
|
|
31
|
+
}
|
|
32
|
+
else if (char === '"' || char === "'") {
|
|
33
|
+
index = stringEnd(css, index);
|
|
34
|
+
}
|
|
35
|
+
else if (char === '(') {
|
|
36
|
+
parens++;
|
|
37
|
+
index++;
|
|
38
|
+
}
|
|
39
|
+
else if (char === ')') {
|
|
40
|
+
parens = Math.max(0, parens - 1);
|
|
41
|
+
index++;
|
|
42
|
+
}
|
|
43
|
+
else if (parens === 0 && (char === '{' || char === '}' || char === ';')) {
|
|
44
|
+
pieces.push({ text: text + css.slice(copied, index), end: char });
|
|
45
|
+
text = '';
|
|
46
|
+
index++;
|
|
47
|
+
copied = index;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
index++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
pieces.push({ text: text + css.slice(copied), end: '' });
|
|
54
|
+
return pieces;
|
|
55
|
+
}
|
|
56
|
+
/** `var(--x)` or `var(--x, fallback)` as a whole, or null. */
|
|
57
|
+
function varReference(text) {
|
|
58
|
+
if (text.slice(0, 4).toLowerCase() !== 'var(' || !text.endsWith(')')) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const inner = text.slice(4, -1);
|
|
62
|
+
let depth = 0;
|
|
63
|
+
let comma = -1;
|
|
64
|
+
for (let i = 0; i < inner.length && comma === -1; i++) {
|
|
65
|
+
if (inner[i] === '(') {
|
|
66
|
+
depth++;
|
|
67
|
+
}
|
|
68
|
+
else if (inner[i] === ')') {
|
|
69
|
+
depth--;
|
|
70
|
+
}
|
|
71
|
+
else if (inner[i] === ',' && depth === 0) {
|
|
72
|
+
comma = i;
|
|
73
|
+
}
|
|
74
|
+
// A `)` that closes `var(` before the end: `var(--a) var(--b)` is no single reference.
|
|
75
|
+
if (depth < 0) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const name = (comma === -1 ? inner : inner.slice(0, comma)).trim();
|
|
80
|
+
if (!name.startsWith('--')) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const fallback = comma === -1 ? '' : inner.slice(comma + 1).trim();
|
|
84
|
+
return fallback === '' ? { name } : { name, fallback };
|
|
85
|
+
}
|
|
86
|
+
function addFamily(usage, family) {
|
|
87
|
+
if (family === undefined) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const reference = varReference(family);
|
|
91
|
+
if (reference) {
|
|
92
|
+
usage.varRefs.push(reference);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
usage.families.push(family);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function addFontShorthand(usage, value) {
|
|
99
|
+
const reference = varReference(value);
|
|
100
|
+
if (reference) {
|
|
101
|
+
usage.varRefs.push(reference);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
addFamily(usage, fontShorthandFamilies(value)?.[0]);
|
|
105
|
+
}
|
|
106
|
+
function addDeclaration(usage, text, inFontFace) {
|
|
107
|
+
const colon = text.indexOf(':');
|
|
108
|
+
if (colon === -1) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const property = text.slice(0, colon).trim();
|
|
112
|
+
const name = property.toLowerCase();
|
|
113
|
+
const value = text
|
|
114
|
+
.slice(colon + 1)
|
|
115
|
+
.trim()
|
|
116
|
+
.replace(IMPORTANT, '')
|
|
117
|
+
.trim();
|
|
118
|
+
if (inFontFace) {
|
|
119
|
+
if (name === 'font-family') {
|
|
120
|
+
usage.declared.push(...fontFamilyList(value).slice(0, 1));
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (property.startsWith('--')) {
|
|
125
|
+
usage.variables.set(property, [...(usage.variables.get(property) ?? []), value]);
|
|
126
|
+
}
|
|
127
|
+
else if (name === 'font-family') {
|
|
128
|
+
addFamily(usage, fontFamilyList(value)[0]);
|
|
129
|
+
}
|
|
130
|
+
else if (name === 'font') {
|
|
131
|
+
addFontShorthand(usage, value);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The fonts that CSS text uses, defines as custom properties, declares with
|
|
136
|
+
* `@font-face` and imports. Reads a stylesheet or the bare text of a `style`
|
|
137
|
+
* attribute. Only `font-family`, `font` and custom properties count.
|
|
138
|
+
*/
|
|
139
|
+
export function fontUsage(css) {
|
|
140
|
+
const usage = {
|
|
141
|
+
families: [],
|
|
142
|
+
varRefs: [],
|
|
143
|
+
variables: new Map(),
|
|
144
|
+
declared: [],
|
|
145
|
+
imports: cssReferences(css)
|
|
146
|
+
.filter((reference) => reference.kind === 'import')
|
|
147
|
+
.map((reference) => reference.url),
|
|
148
|
+
};
|
|
149
|
+
// One entry per open block: whether it is, or sits inside, an `@font-face`.
|
|
150
|
+
const fontFace = [];
|
|
151
|
+
for (const piece of piecesOf(css)) {
|
|
152
|
+
const inFontFace = fontFace.at(-1) ?? false;
|
|
153
|
+
if (piece.end === '{') {
|
|
154
|
+
fontFace.push(inFontFace || piece.text.trim().toLowerCase() === '@font-face');
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
addDeclaration(usage, piece.text, inFontFace);
|
|
158
|
+
if (piece.end === '}') {
|
|
159
|
+
fontFace.pop();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return usage;
|
|
163
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The same patterns as RAW_TEXT, START_TAG and ATTRIBUTE in rewrite-head-references.ts.
|
|
2
|
+
const RAW_TEXT = /<!--[\s\S]*?--!?>|<script\b[\s\S]*?<\/script\b[^>]*>|(<style\b[^>]*>)([\s\S]*?)(<\/style\b[^>]*>)/gi;
|
|
3
|
+
const START_TAG = /(<[a-z][^\s/>]*)((?:[^>"']|"[^"]*"|'[^']*')*>)/gi;
|
|
4
|
+
const ATTRIBUTE = /([^\s"'<>/=]+)(?:(\s*=\s*)("[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g;
|
|
5
|
+
/** The attributes of a start tag by lowercase name, unquoted and with `&` decoded. */
|
|
6
|
+
function attributesOf(text) {
|
|
7
|
+
const attributes = new Map();
|
|
8
|
+
for (const [, name, , value] of text.matchAll(ATTRIBUTE)) {
|
|
9
|
+
if (value === undefined) {
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
const quoted = value.startsWith('"') || value.startsWith("'");
|
|
13
|
+
const unquoted = quoted ? value.slice(1, -1) : value;
|
|
14
|
+
attributes.set(name.toLowerCase(), unquoted.replaceAll('&', '&'));
|
|
15
|
+
}
|
|
16
|
+
return attributes;
|
|
17
|
+
}
|
|
18
|
+
function stylesheetLinksIn(html) {
|
|
19
|
+
const links = [];
|
|
20
|
+
for (const [, name, text] of html.matchAll(START_TAG)) {
|
|
21
|
+
if (name.slice(1).toLowerCase() !== 'link') {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const attributes = attributesOf(text);
|
|
25
|
+
const rel = (attributes.get('rel') ?? '').toLowerCase().split(/\s+/);
|
|
26
|
+
const href = attributes.get('href');
|
|
27
|
+
if (rel.includes('stylesheet') && href !== undefined) {
|
|
28
|
+
links.push(href);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return links;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The CSS of preview head HTML: the body of each `<style>` and the `href` of
|
|
35
|
+
* each `<link rel="stylesheet">`. Comments and scripts are skipped.
|
|
36
|
+
*/
|
|
37
|
+
export function headStylesheets(html) {
|
|
38
|
+
const styles = [];
|
|
39
|
+
const links = [];
|
|
40
|
+
let scanned = 0;
|
|
41
|
+
for (const match of html.matchAll(RAW_TEXT)) {
|
|
42
|
+
const [text, styleStart, css] = match;
|
|
43
|
+
links.push(...stylesheetLinksIn(html.slice(scanned, match.index)));
|
|
44
|
+
if (styleStart !== undefined) {
|
|
45
|
+
styles.push(css);
|
|
46
|
+
}
|
|
47
|
+
scanned = match.index + text.length;
|
|
48
|
+
}
|
|
49
|
+
links.push(...stylesheetLinksIn(html.slice(scanned)));
|
|
50
|
+
return { styles, links };
|
|
51
|
+
}
|
package/dist/plugin.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ViteUserConfig } from 'vitest/config';
|
|
2
|
+
import type { RenderFrameMode } from '@describe-me/core/types';
|
|
2
3
|
/** Where the tests run: Vitest browser mode, or a simulated DOM such as jsdom. */
|
|
3
4
|
export type DescribeMeEnvironment = 'browser' | 'dom';
|
|
4
5
|
export interface DescribeMeOptions {
|
|
@@ -41,6 +42,12 @@ export interface DescribeMeOptions {
|
|
|
41
42
|
* Project files it links are copied into the output directory. Default: none.
|
|
42
43
|
*/
|
|
43
44
|
previewHead?: string;
|
|
45
|
+
/**
|
|
46
|
+
* When the render frame is taken: `'eager'` right after mount, `'lazy'`
|
|
47
|
+
* before the next interaction, `{ pending }` once nothing matches the
|
|
48
|
+
* selector. Default: `'eager'`.
|
|
49
|
+
*/
|
|
50
|
+
renderFrame?: RenderFrameMode;
|
|
44
51
|
}
|
|
45
52
|
type VitePlugin = NonNullable<ViteUserConfig['plugins']>[number];
|
|
46
53
|
/**
|
package/dist/plugin.js
CHANGED
|
@@ -5,6 +5,7 @@ import { registerExports } from './register-exports.js';
|
|
|
5
5
|
import DescribeMeReporter from './reporter.js';
|
|
6
6
|
import { RUNTIME_OPTIONS_KEY } from './runtime-options.js';
|
|
7
7
|
import { styledComponentsBrowserBuild } from './styled-components-browser-build.js';
|
|
8
|
+
import { validateRenderFrame } from './validate-render-frame.js';
|
|
8
9
|
/** For each framework and environment: the render module tests import, and our adapter that wraps it. */
|
|
9
10
|
const RENDER_MODULES = {
|
|
10
11
|
react: {
|
|
@@ -101,11 +102,12 @@ export function describeMe(options = {}) {
|
|
|
101
102
|
}
|
|
102
103
|
const environment = options.environment ?? detectEnvironment(userConfig);
|
|
103
104
|
renderModule = RENDER_MODULES[framework][environment];
|
|
104
|
-
//
|
|
105
|
+
// Checked here, so invalid options fail before any test runs. Vite
|
|
105
106
|
// deep-merges `provide`, so the user's own keys survive.
|
|
106
107
|
const runtimeOptions = {
|
|
107
108
|
include: compileGlobs(options.include),
|
|
108
109
|
exclude: compileGlobs(options.exclude),
|
|
110
|
+
renderFrame: validateRenderFrame(options.renderFrame),
|
|
109
111
|
};
|
|
110
112
|
const provide = { [RUNTIME_OPTIONS_KEY]: runtimeOptions };
|
|
111
113
|
const reporter = new DescribeMeReporter({
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Manifest } from '@describe-me/core/types';
|
|
2
2
|
/**
|
|
3
3
|
* Warn, one line per kind, about what the manifest could not document. Prints
|
|
4
|
-
* nothing when everything is named, documented and loadable
|
|
5
|
-
*
|
|
4
|
+
* nothing when everything is named, documented and loadable, every font is
|
|
5
|
+
* loaded and no stylesheet comes from a remote host. The viewer shows the same
|
|
6
|
+
* list in its header.
|
|
6
7
|
*/
|
|
7
8
|
export declare function printDiagnostics(manifest: Manifest): void;
|
|
@@ -14,11 +14,12 @@ function plural(count, one, many) {
|
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
16
|
* Warn, one line per kind, about what the manifest could not document. Prints
|
|
17
|
-
* nothing when everything is named, documented and loadable
|
|
18
|
-
*
|
|
17
|
+
* nothing when everything is named, documented and loadable, every font is
|
|
18
|
+
* loaded and no stylesheet comes from a remote host. The viewer shows the same
|
|
19
|
+
* list in its header.
|
|
19
20
|
*/
|
|
20
21
|
export function printDiagnostics(manifest) {
|
|
21
|
-
const { anonymous, undocumented, assetsMissing } = manifestDiagnostics(manifest);
|
|
22
|
+
const { anonymous, undocumented, assetsMissing, fontsMissing, remoteStylesheets } = manifestDiagnostics(manifest);
|
|
22
23
|
if (anonymous.length > 0) {
|
|
23
24
|
console.warn(`describe-me: ${plural(anonymous.length, 'test renders', 'tests render')} an anonymous component; export it from a project module or give it a displayName`);
|
|
24
25
|
}
|
|
@@ -28,4 +29,13 @@ export function printDiagnostics(manifest) {
|
|
|
28
29
|
if (assetsMissing.length > 0) {
|
|
29
30
|
console.warn(`describe-me: ${plural(assetsMissing.length, 'asset was', 'assets were')} not found in the project and will not load in the viewer: ${listed(assetsMissing)}`);
|
|
30
31
|
}
|
|
32
|
+
if (fontsMissing.length > 0) {
|
|
33
|
+
const families = fontsMissing.map((font) => `${font.family} (${plural(font.tests, 'test', 'tests')})`);
|
|
34
|
+
const pronoun = fontsMissing.length === 1 ? 'it' : 'them';
|
|
35
|
+
console.warn(`describe-me: ${plural(fontsMissing.length, 'font family is', 'font families are')} used but never loaded: ${listed(families)} — add ${pronoun} to previewHead`);
|
|
36
|
+
}
|
|
37
|
+
if (remoteStylesheets.length > 0) {
|
|
38
|
+
const hosts = remoteStylesheets.map((stylesheet) => `${stylesheet.host} (${plural(stylesheet.frames, 'frame', 'frames')})`);
|
|
39
|
+
console.warn(`describe-me: frames load stylesheets from ${plural(remoteStylesheets.length, 'remote host', 'remote hosts')}, which the viewer fetches from the network: ${listed(hosts)}`);
|
|
40
|
+
}
|
|
31
41
|
}
|
|
@@ -2,5 +2,9 @@ import { inject } from 'vitest';
|
|
|
2
2
|
import { RUNTIME_OPTIONS_KEY } from './runtime-options.js';
|
|
3
3
|
/** Without the plugin nothing is provided, so a hand-wired setup keeps recording every test. */
|
|
4
4
|
export function readRuntimeOptions() {
|
|
5
|
-
return inject(RUNTIME_OPTIONS_KEY) ?? {
|
|
5
|
+
return (inject(RUNTIME_OPTIONS_KEY) ?? {
|
|
6
|
+
include: [],
|
|
7
|
+
exclude: [],
|
|
8
|
+
renderFrame: 'eager',
|
|
9
|
+
});
|
|
6
10
|
}
|
package/dist/record-action.js
CHANGED
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
import { recorder } from '@describe-me/core';
|
|
2
2
|
/**
|
|
3
|
-
* Nesting depth of interactions
|
|
4
|
-
* delegates to `locator.click()`, and `copy()` delegates
|
|
5
|
-
* without this guard one gesture would produce two
|
|
3
|
+
* Nesting depth of the interactions in flight, and the test they started in.
|
|
4
|
+
* `userEvent.click(el)` delegates to `locator.click()`, and `copy()` delegates
|
|
5
|
+
* to `keyboard()`, so without this guard one gesture would produce two
|
|
6
|
+
* frames. Keyed to the recorder's generation, so an interaction that an ended
|
|
7
|
+
* test left running cannot swallow the next test's frame.
|
|
6
8
|
*/
|
|
7
|
-
|
|
9
|
+
const nesting = { generation: -1, depth: 0 };
|
|
8
10
|
/** Run one user interaction and record a frame for it, but only for the outermost call. */
|
|
9
11
|
export async function recordAction(label, run) {
|
|
10
|
-
|
|
12
|
+
const generation = recorder.generation;
|
|
13
|
+
if (generation !== nesting.generation) {
|
|
14
|
+
nesting.generation = generation;
|
|
15
|
+
nesting.depth = 0;
|
|
16
|
+
}
|
|
17
|
+
recorder.beforeInteraction();
|
|
18
|
+
nesting.depth++;
|
|
11
19
|
let result;
|
|
12
20
|
try {
|
|
13
21
|
result = await run();
|
|
14
22
|
}
|
|
15
23
|
finally {
|
|
16
|
-
|
|
24
|
+
if (generation === nesting.generation) {
|
|
25
|
+
nesting.depth--;
|
|
26
|
+
}
|
|
17
27
|
}
|
|
18
|
-
if (depth === 0) {
|
|
19
|
-
await recorder.capture('action', label);
|
|
28
|
+
if (generation === nesting.generation && nesting.depth === 0) {
|
|
29
|
+
await recorder.capture('action', label, undefined, { generation });
|
|
20
30
|
}
|
|
21
31
|
return result;
|
|
22
32
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { RuntimeOptions } from './runtime-options.js';
|
|
2
2
|
/**
|
|
3
3
|
* The per-test lifecycle shared by every environment: begin a recording, take
|
|
4
|
-
* the closing frame, hand the frames to the
|
|
5
|
-
* then let adapters unmount. Teardown runs
|
|
6
|
-
* Testing Library's auto-cleanup is off in
|
|
7
|
-
* the one recorder, so they are not
|
|
4
|
+
* a deferred render frame or else the closing frame, hand the frames to the
|
|
5
|
+
* reporter via task.meta, and only then let adapters unmount. Teardown runs
|
|
6
|
+
* for unrecorded tests too, because Testing Library's auto-cleanup is off in
|
|
7
|
+
* jsdom. Concurrent tests would share the one recorder, so they are not
|
|
8
|
+
* recorded.
|
|
8
9
|
*/
|
|
9
10
|
export declare function registerRecordingHooks(options: RuntimeOptions): void;
|
|
@@ -3,14 +3,16 @@ import { META_KEY, recorder } from '@describe-me/core';
|
|
|
3
3
|
import { fileFilter } from './file-filter.js';
|
|
4
4
|
/**
|
|
5
5
|
* The per-test lifecycle shared by every environment: begin a recording, take
|
|
6
|
-
* the closing frame, hand the frames to the
|
|
7
|
-
* then let adapters unmount. Teardown runs
|
|
8
|
-
* Testing Library's auto-cleanup is off in
|
|
9
|
-
* the one recorder, so they are not
|
|
6
|
+
* a deferred render frame or else the closing frame, hand the frames to the
|
|
7
|
+
* reporter via task.meta, and only then let adapters unmount. Teardown runs
|
|
8
|
+
* for unrecorded tests too, because Testing Library's auto-cleanup is off in
|
|
9
|
+
* jsdom. Concurrent tests would share the one recorder, so they are not
|
|
10
|
+
* recorded.
|
|
10
11
|
*/
|
|
11
12
|
export function registerRecordingHooks(options) {
|
|
12
13
|
const isRecordedFile = fileFilter(options);
|
|
13
14
|
let warnedConcurrent = false;
|
|
15
|
+
recorder.configure({ renderFrame: options.renderFrame });
|
|
14
16
|
beforeEach((context) => {
|
|
15
17
|
if (context.task.concurrent) {
|
|
16
18
|
if (!warnedConcurrent) {
|
|
@@ -26,7 +28,11 @@ export function registerRecordingHooks(options) {
|
|
|
26
28
|
afterEach(async (context) => {
|
|
27
29
|
try {
|
|
28
30
|
if (recorder.isActive) {
|
|
29
|
-
await recorder.
|
|
31
|
+
const tookRender = await recorder.flush();
|
|
32
|
+
// A render frame taken this late already shows how the test ended.
|
|
33
|
+
if (!tookRender) {
|
|
34
|
+
await recorder.capture('end', 'end of test');
|
|
35
|
+
}
|
|
30
36
|
const record = recorder.end();
|
|
31
37
|
if (record.frames.length > 0) {
|
|
32
38
|
;
|
package/dist/reporter.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ export default class DescribeMeReporter implements Reporter {
|
|
|
28
28
|
private snapshots;
|
|
29
29
|
private assets;
|
|
30
30
|
private styles;
|
|
31
|
+
private fonts;
|
|
31
32
|
private modules;
|
|
32
33
|
constructor(options?: DescribeMeReporterOptions);
|
|
33
34
|
onInit(vitest: Vitest): void;
|
|
@@ -39,12 +40,20 @@ export default class DescribeMeReporter implements Reporter {
|
|
|
39
40
|
* are dropped, and so are the empty tests that 0.4 still wrote.
|
|
40
41
|
*/
|
|
41
42
|
private seedFromPreviousRun;
|
|
43
|
+
/**
|
|
44
|
+
* Tests written before 0.5 carry Vitest's id. They get their stable id, and
|
|
45
|
+
* the old one moves to `vitestId`. Occurrences are counted over the module's
|
|
46
|
+
* full list, as the run that wrote it saw them.
|
|
47
|
+
*/
|
|
48
|
+
private withStableIds;
|
|
42
49
|
/**
|
|
43
50
|
* A test skipped in this run keeps its previous entry, so a filtered run does
|
|
44
51
|
* not erase documentation.
|
|
45
52
|
*/
|
|
46
53
|
onTestModuleEnd(module: TestModule): void;
|
|
47
54
|
onTestRunEnd(): Promise<void>;
|
|
55
|
+
/** A diagnostic must never break the manifest, so a failing audit writes empty lists. */
|
|
56
|
+
private auditFonts;
|
|
48
57
|
/** Module ids use the platform's separators. Globs expect posix ones. */
|
|
49
58
|
private isRecordedModule;
|
|
50
59
|
/** Decided before any snapshot is written, so a dropped test writes no file. */
|
package/dist/reporter.js
CHANGED
|
@@ -10,11 +10,16 @@ import { collectComponentDocsSafely } from './collect-component-docs-safely.js';
|
|
|
10
10
|
import { compileGlobs } from './compile-globs.js';
|
|
11
11
|
import { componentEntries } from './component-entries.js';
|
|
12
12
|
import { fileFilter } from './file-filter.js';
|
|
13
|
+
import { FontAudit } from './font-audit.js';
|
|
13
14
|
import { isRecordedTest } from './is-recorded-test.js';
|
|
14
15
|
import { printDiagnostics } from './print-diagnostics.js';
|
|
15
16
|
import { SnapshotStore } from './snapshot-store.js';
|
|
17
|
+
import { stableTestIds } from './stable-test-ids.js';
|
|
16
18
|
import { StyleStore } from './style-store.js';
|
|
17
19
|
import { testPath } from './test-path.js';
|
|
20
|
+
function toPosix(moduleId) {
|
|
21
|
+
return moduleId.split(sep).join('/');
|
|
22
|
+
}
|
|
18
23
|
function recordOf(tc) {
|
|
19
24
|
return tc.meta()[META_KEY];
|
|
20
25
|
}
|
|
@@ -27,6 +32,7 @@ export default class DescribeMeReporter {
|
|
|
27
32
|
snapshots;
|
|
28
33
|
assets;
|
|
29
34
|
styles;
|
|
35
|
+
fonts;
|
|
30
36
|
modules = new Map();
|
|
31
37
|
constructor(options = {}) {
|
|
32
38
|
this.outDirOption = options.outDir ?? '.describe-me';
|
|
@@ -43,6 +49,7 @@ export default class DescribeMeReporter {
|
|
|
43
49
|
this.snapshots = new SnapshotStore(this.outDir);
|
|
44
50
|
this.assets = new AssetStore(this.outDir, this.root);
|
|
45
51
|
this.styles = new StyleStore(this.outDir);
|
|
52
|
+
this.fonts = new FontAudit(this.outDir);
|
|
46
53
|
this.seedFromPreviousRun();
|
|
47
54
|
if (this.previewHead !== undefined && /<script\b/i.test(this.previewHead)) {
|
|
48
55
|
console.warn('describe-me: previewHead contains a <script>, which the viewer never runs.');
|
|
@@ -65,7 +72,7 @@ export default class DescribeMeReporter {
|
|
|
65
72
|
try {
|
|
66
73
|
const previous = JSON.parse(readFileSync(file, 'utf8'));
|
|
67
74
|
for (const mod of previous.modules ?? []) {
|
|
68
|
-
const tests = mod.
|
|
75
|
+
const tests = this.withStableIds(mod).filter((test) => isRecordedTest(test));
|
|
69
76
|
if (tests.length > 0 &&
|
|
70
77
|
this.isRecordedModule(mod.id) &&
|
|
71
78
|
existsSync(resolve(this.root, mod.id))) {
|
|
@@ -77,6 +84,20 @@ export default class DescribeMeReporter {
|
|
|
77
84
|
// corrupt or incompatible manifest: start fresh
|
|
78
85
|
}
|
|
79
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Tests written before 0.5 carry Vitest's id. They get their stable id, and
|
|
89
|
+
* the old one moves to `vitestId`. Occurrences are counted over the module's
|
|
90
|
+
* full list, as the run that wrote it saw them.
|
|
91
|
+
*/
|
|
92
|
+
withStableIds(mod) {
|
|
93
|
+
const ids = stableTestIds(toPosix(mod.id), mod.tests);
|
|
94
|
+
return mod.tests.map((test, i) => {
|
|
95
|
+
if (test.vitestId !== undefined) {
|
|
96
|
+
return test;
|
|
97
|
+
}
|
|
98
|
+
return { ...test, id: ids[i], vitestId: test.id };
|
|
99
|
+
});
|
|
100
|
+
}
|
|
80
101
|
/**
|
|
81
102
|
* A test skipped in this run keeps its previous entry, so a filtered run does
|
|
82
103
|
* not erase documentation.
|
|
@@ -89,8 +110,12 @@ export default class DescribeMeReporter {
|
|
|
89
110
|
}
|
|
90
111
|
const previous = this.modules.get(id);
|
|
91
112
|
const tests = [];
|
|
92
|
-
|
|
93
|
-
|
|
113
|
+
// Over every test, skipped ones included, so an id does not depend on
|
|
114
|
+
// which tests recorded frames or on `-t` and `.only`.
|
|
115
|
+
const cases = Array.from(module.children.allTests());
|
|
116
|
+
const stableIds = stableTestIds(toPosix(id), cases.map((tc) => ({ path: testPath(tc), name: tc.name })));
|
|
117
|
+
for (const [i, tc] of cases.entries()) {
|
|
118
|
+
const test = this.manifestTestOf(tc, stableIds[i], previous);
|
|
94
119
|
if (test) {
|
|
95
120
|
tests.push(test);
|
|
96
121
|
}
|
|
@@ -124,25 +149,40 @@ export default class DescribeMeReporter {
|
|
|
124
149
|
this.snapshots.collectGarbage(snapshotFiles);
|
|
125
150
|
const styleFiles = this.styles.collectGarbage(snapshotFiles);
|
|
126
151
|
this.assets.collectGarbage([...snapshotFiles, ...styleFiles], head === undefined ? [] : [head]);
|
|
152
|
+
this.auditFonts(manifest);
|
|
127
153
|
writeFileSync(join(this.outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
128
154
|
printDiagnostics(manifest);
|
|
129
155
|
}
|
|
156
|
+
/** A diagnostic must never break the manifest, so a failing audit writes empty lists. */
|
|
157
|
+
auditFonts(manifest) {
|
|
158
|
+
try {
|
|
159
|
+
const { fontsMissing, remoteStylesheets } = this.fonts.audit(manifest);
|
|
160
|
+
manifest.fontsMissing = fontsMissing;
|
|
161
|
+
manifest.remoteStylesheets = remoteStylesheets;
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
165
|
+
console.warn(`describe-me: the font audit failed: ${message}`);
|
|
166
|
+
manifest.fontsMissing = [];
|
|
167
|
+
manifest.remoteStylesheets = [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
130
170
|
/** Module ids use the platform's separators. Globs expect posix ones. */
|
|
131
171
|
isRecordedModule(id) {
|
|
132
|
-
return this.isRecordedFile(id
|
|
172
|
+
return this.isRecordedFile(toPosix(id));
|
|
133
173
|
}
|
|
134
174
|
/** Decided before any snapshot is written, so a dropped test writes no file. */
|
|
135
|
-
manifestTestOf(tc, previous) {
|
|
175
|
+
manifestTestOf(tc, stableId, previous) {
|
|
136
176
|
const record = recordOf(tc);
|
|
137
177
|
if (record && isRecordedTest(record)) {
|
|
138
|
-
return this.toManifestTest(tc, record);
|
|
178
|
+
return this.toManifestTest(tc, stableId, record);
|
|
139
179
|
}
|
|
140
180
|
if (tc.result().state !== 'skipped') {
|
|
141
181
|
return undefined;
|
|
142
182
|
}
|
|
143
|
-
return previous?.tests.find((test) => test.id ===
|
|
183
|
+
return previous?.tests.find((test) => test.id === stableId && isRecordedTest(test));
|
|
144
184
|
}
|
|
145
|
-
toManifestTest(tc, record) {
|
|
185
|
+
toManifestTest(tc, stableId, record) {
|
|
146
186
|
const result = tc.result();
|
|
147
187
|
const frames = record.frames.map((frame) => ({
|
|
148
188
|
id: frame.id,
|
|
@@ -153,7 +193,8 @@ export default class DescribeMeReporter {
|
|
|
153
193
|
snapshot: this.writeSnapshot(frame.snapshot, record.origin),
|
|
154
194
|
}));
|
|
155
195
|
return {
|
|
156
|
-
id:
|
|
196
|
+
id: stableId,
|
|
197
|
+
vitestId: tc.id,
|
|
157
198
|
name: tc.name,
|
|
158
199
|
path: testPath(tc),
|
|
159
200
|
fullName: tc.fullName,
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* Travels from the plugin to the setup files through Vitest's `provide` /
|
|
3
3
|
* `inject`. Keep it plain JSON: the browser receives it serialized, so a
|
|
4
4
|
* RegExp would not survive. The test runtime imports this file, so it imports
|
|
5
|
-
*
|
|
5
|
+
* only types.
|
|
6
6
|
*/
|
|
7
|
+
import type { RenderFrameMode } from '@describe-me/core/types';
|
|
7
8
|
export interface CompiledGlob {
|
|
8
9
|
source: string;
|
|
9
10
|
flags: string;
|
|
@@ -13,6 +14,7 @@ export interface RuntimeOptions {
|
|
|
13
14
|
include: CompiledGlob[];
|
|
14
15
|
/** Wins over `include`. */
|
|
15
16
|
exclude: CompiledGlob[];
|
|
17
|
+
renderFrame: RenderFrameMode;
|
|
16
18
|
}
|
|
17
19
|
export declare const RUNTIME_OPTIONS_KEY: "describe-me";
|
|
18
20
|
declare module 'vitest' {
|
package/dist/runtime-options.js
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
* Travels from the plugin to the setup files through Vitest's `provide` /
|
|
3
3
|
* `inject`. Keep it plain JSON: the browser receives it serialized, so a
|
|
4
4
|
* RegExp would not survive. The test runtime imports this file, so it imports
|
|
5
|
-
*
|
|
5
|
+
* only types.
|
|
6
6
|
*/
|
|
7
7
|
export const RUNTIME_OPTIONS_KEY = 'describe-me';
|
package/dist/setup-dom.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* `setupFiles: ['@describe-me/vitest/setup-dom']`. Patches Testing Library's
|
|
4
4
|
* userEvent, when it is installed, so every interaction records a frame, sets
|
|
5
5
|
* React's act flag where Testing Library would, and registers the recording
|
|
6
|
-
* hooks. Which test files are recorded (`include` / `exclude`)
|
|
7
|
-
*
|
|
6
|
+
* hooks. Which test files are recorded (`include` / `exclude`) and when the
|
|
7
|
+
* render frame is taken (`renderFrame`) come from the plugin. Never imports
|
|
8
|
+
* `vitest/browser`, which only exists in browser mode.
|
|
8
9
|
*/
|
|
9
10
|
import { mirrorReactActEnvironment } from './mirror-react-act-environment.js';
|
|
10
11
|
import { patchTestingLibraryUserEvent } from './patch-testing-library-user-event.js';
|
package/dist/setup.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
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
4
|
* frame, and registers the recording hooks. Which test files are recorded
|
|
5
|
-
* (`include` / `exclude`)
|
|
5
|
+
* (`include` / `exclude`) and when the render frame is taken (`renderFrame`)
|
|
6
|
+
* come from the plugin. For jsdom use `./setup-dom`.
|
|
6
7
|
*/
|
|
7
8
|
import { patchLocators } from './patch-locators.js';
|
|
8
9
|
import { patchUserEvent } from './patch-user-event.js';
|