@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.
Files changed (40) hide show
  1. package/README.md +5 -0
  2. package/dist/font-audit.d.ts +33 -0
  3. package/dist/font-audit.js +296 -0
  4. package/dist/font-family-key.d.ts +5 -0
  5. package/dist/font-family-key.js +14 -0
  6. package/dist/font-family-list.d.ts +6 -0
  7. package/dist/font-family-list.js +66 -0
  8. package/dist/font-shorthand-families.d.ts +6 -0
  9. package/dist/font-shorthand-families.js +177 -0
  10. package/dist/font-stylesheet-families.d.ts +6 -0
  11. package/dist/font-stylesheet-families.js +54 -0
  12. package/dist/font-usage.d.ts +26 -0
  13. package/dist/font-usage.js +163 -0
  14. package/dist/head-stylesheets.d.ts +8 -0
  15. package/dist/head-stylesheets.js +51 -0
  16. package/dist/plugin.d.ts +7 -0
  17. package/dist/plugin.js +3 -1
  18. package/dist/print-diagnostics.d.ts +3 -2
  19. package/dist/print-diagnostics.js +13 -3
  20. package/dist/read-runtime-options.js +5 -1
  21. package/dist/record-action.js +18 -8
  22. package/dist/register-recording-hooks.d.ts +5 -4
  23. package/dist/register-recording-hooks.js +11 -5
  24. package/dist/reporter.d.ts +9 -0
  25. package/dist/reporter.js +50 -9
  26. package/dist/runtime-options.d.ts +3 -1
  27. package/dist/runtime-options.js +1 -1
  28. package/dist/setup-dom.js +3 -2
  29. package/dist/setup.js +2 -1
  30. package/dist/snapshot-styles.d.ts +17 -0
  31. package/dist/snapshot-styles.js +65 -0
  32. package/dist/stable-test-id.d.ts +5 -0
  33. package/dist/stable-test-id.js +12 -0
  34. package/dist/stable-test-ids.d.ts +5 -0
  35. package/dist/stable-test-ids.js +13 -0
  36. package/dist/system-font-family.d.ts +5 -0
  37. package/dist/system-font-family.js +78 -0
  38. package/dist/validate-render-frame.d.ts +6 -0
  39. package/dist/validate-render-frame.js +26 -0
  40. 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,8 @@
1
+ /**
2
+ * The CSS of preview head HTML: the body of each `<style>` and the `href` of
3
+ * each `<link rel="stylesheet">`. Comments and scripts are skipped.
4
+ */
5
+ export declare function headStylesheets(html: string): {
6
+ styles: string[];
7
+ links: string[];
8
+ };
@@ -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 `&amp;` 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('&amp;', '&'));
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
- // Compiled here, so invalid globs fail before any test runs. Vite
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. The viewer shows
5
- * the same list in its header.
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. The viewer shows
18
- * the same list in its header.
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) ?? { include: [], exclude: [] };
5
+ return (inject(RUNTIME_OPTIONS_KEY) ?? {
6
+ include: [],
7
+ exclude: [],
8
+ renderFrame: 'eager',
9
+ });
6
10
  }
@@ -1,22 +1,32 @@
1
1
  import { recorder } from '@describe-me/core';
2
2
  /**
3
- * Nesting depth of interactions currently in flight. `userEvent.click(el)`
4
- * delegates to `locator.click()`, and `copy()` delegates to `keyboard()`, so
5
- * without this guard one gesture would produce two frames.
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
- let depth = 0;
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
- depth++;
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
- depth--;
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 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.
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 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.
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.capture('end', 'end of test');
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
  ;
@@ -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.tests.filter((test) => isRecordedTest(test));
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
- for (const tc of module.children.allTests()) {
93
- const test = this.manifestTestOf(tc, previous);
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.split(sep).join('/'));
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 === tc.id && test.fullName === tc.fullName && isRecordedTest(test));
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: tc.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
- * nothing itself.
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' {
@@ -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
- * nothing itself.
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`) comes from the
7
- * plugin. Never imports `vitest/browser`, which only exists in browser mode.
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`) comes from the plugin. For jsdom use `./setup-dom`.
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';