@describe-me/vitest 0.2.0 → 0.4.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.
@@ -0,0 +1,61 @@
1
+ import userEvent from '@testing-library/user-event';
2
+ import { describeArg } from './describe-arg.js';
3
+ import { elementLabel } from './element-label.js';
4
+ import { recordAction } from './record-action.js';
5
+ /** Every interaction of user-event 14 that can change what the page shows. */
6
+ const METHODS = [
7
+ 'click',
8
+ 'dblClick',
9
+ 'tripleClick',
10
+ 'hover',
11
+ 'unhover',
12
+ 'type',
13
+ 'clear',
14
+ 'keyboard',
15
+ 'tab',
16
+ 'paste',
17
+ 'copy',
18
+ 'cut',
19
+ 'pointer',
20
+ 'selectOptions',
21
+ 'deselectOptions',
22
+ 'upload',
23
+ ];
24
+ const PATCHED = Symbol.for('describe-me.patched');
25
+ function labelFor(method, args) {
26
+ const parts = args.map((arg) => elementLabel(arg) ?? describeArg(arg)).filter(Boolean);
27
+ return `${method}(${parts.join(', ')})`;
28
+ }
29
+ function patchMethods(target) {
30
+ for (const method of METHODS) {
31
+ const original = target[method];
32
+ if (typeof original !== 'function' || original[PATCHED]) {
33
+ continue;
34
+ }
35
+ const wrapped = (...args) => recordAction(labelFor(method, args), async () => original.apply(target, args));
36
+ wrapped[PATCHED] = true;
37
+ target[method] = wrapped;
38
+ }
39
+ }
40
+ /**
41
+ * Wrap Testing Library's userEvent in place so each call records a frame.
42
+ * It comes in two shapes, the direct API (`userEvent.click(el)`) and an
43
+ * instance from `userEvent.setup()`, and the first delegates to the second,
44
+ * so both are patched and `recordAction`'s depth guard keeps one gesture to
45
+ * one frame. Safe to call more than once.
46
+ */
47
+ export function patchTestingLibraryUserEvent() {
48
+ const direct = userEvent;
49
+ patchMethods(direct);
50
+ const originalSetup = direct.setup;
51
+ if (typeof originalSetup !== 'function' || originalSetup[PATCHED]) {
52
+ return;
53
+ }
54
+ const wrappedSetup = (...args) => {
55
+ const instance = originalSetup.apply(direct, args);
56
+ patchMethods(instance);
57
+ return instance;
58
+ };
59
+ wrappedSetup[PATCHED] = true;
60
+ direct.setup = wrappedSetup;
61
+ }
package/dist/plugin.d.ts CHANGED
@@ -1,11 +1,32 @@
1
1
  import type { ViteUserConfig } from 'vitest/config';
2
+ /** Where the tests run: Vitest browser mode, or a simulated DOM such as jsdom. */
3
+ export type DescribeMeEnvironment = 'browser' | 'dom';
2
4
  export interface DescribeMeOptions {
3
5
  /** Set to false to run the same tests without recording, e.g. for benchmarks. Default: true. */
4
6
  enabled?: boolean;
5
7
  /** Which framework adapter replaces the test-library `render`. Default: 'react'. */
6
8
  framework?: 'react';
9
+ /**
10
+ * Which environment the tests run in. Default: detected, `browser` when
11
+ * `test.browser.enabled` is set and `dom` otherwise.
12
+ */
13
+ environment?: DescribeMeEnvironment;
7
14
  /** Output directory, relative to the Vitest root. Default: `.describe-me`. */
8
15
  outDir?: string;
16
+ /**
17
+ * Register the top-level exports of project modules, so a component is named
18
+ * after its export (`Button`) even when it is an anonymous `forwardRef`,
19
+ * `memo` or styled component, and its props are read from the right file.
20
+ * Default: true.
21
+ */
22
+ registerExports?: boolean;
23
+ /**
24
+ * DOM environments only. Load the browser build of styled-components (v5+)
25
+ * instead of the Node one, whose `createGlobalStyle` never inserts its CSS on
26
+ * the client: global resets and fonts would be missing from every snapshot.
27
+ * Applied when styled-components is installed. Default: true.
28
+ */
29
+ styledComponentsBrowserBuild?: boolean;
9
30
  }
10
31
  type VitePlugin = NonNullable<ViteUserConfig['plugins']>[number];
11
32
  /**
@@ -13,8 +34,9 @@ type VitePlugin = NonNullable<ViteUserConfig['plugins']>[number];
13
34
  *
14
35
  * Registers the setup file and the reporter, and redirects the framework's
15
36
  * `render` import to our adapter so existing tests record frames unchanged.
16
- * The adapter itself still needs the real module, so imports coming from
17
- * inside it are left alone.
37
+ * Works in browser mode and in DOM environments such as jsdom; the adapter
38
+ * itself still needs the real module, so imports coming from inside it are
39
+ * left alone.
18
40
  */
19
41
  export declare function describeMe(options?: DescribeMeOptions): VitePlugin;
20
42
  export {};
package/dist/plugin.js CHANGED
@@ -1,19 +1,75 @@
1
+ import { projectModulePath } from './project-module-path.js';
2
+ import { registerExports } from './register-exports.js';
1
3
  import DescribeMeReporter from './reporter.js';
2
- /** For each framework: the render module tests import, and our adapter that wraps it. */
4
+ import { styledComponentsBrowserBuild } from './styled-components-browser-build.js';
5
+ /** For each framework and environment: the render module tests import, and our adapter that wraps it. */
3
6
  const RENDER_MODULES = {
4
- react: { original: 'vitest-browser-react', adapter: '@describe-me/react' },
7
+ react: {
8
+ browser: { original: 'vitest-browser-react', adapter: '@describe-me/react' },
9
+ dom: { original: '@testing-library/react', adapter: '@describe-me/react/testing-library' },
10
+ },
5
11
  };
12
+ /** Paths an adapter's own files live under, published or in this workspace. */
13
+ const ADAPTER_PATHS = ['/@describe-me/react/', '/packages/react/'];
14
+ function detectEnvironment(userConfig) {
15
+ return userConfig.test?.browser?.enabled ? 'browser' : 'dom';
16
+ }
17
+ function browserConfig(renderModule) {
18
+ return {
19
+ // Vite's dependency scanner runs before our `resolveId` redirect, so it
20
+ // never sees the adapter. Discovering it mid-run makes Vite re-optimize
21
+ // and reload, and the test ends up with two copies of the bundled deps
22
+ // ("Vitest failed to find the runner"). Declaring the adapter up front
23
+ // keeps a single optimization pass.
24
+ optimizeDeps: {
25
+ include: [renderModule.adapter, renderModule.original],
26
+ },
27
+ test: {
28
+ setupFiles: ['@describe-me/vitest/setup'],
29
+ },
30
+ };
31
+ }
32
+ function domConfig(userConfig, options) {
33
+ const userTest = userConfig.test;
34
+ const test = {
35
+ setupFiles: ['@describe-me/vitest/setup-dom'],
36
+ // Testing Library would otherwise unmount in its own afterEach, before the
37
+ // closing frame is taken. The adapter unmounts through the recorder instead.
38
+ env: { RTL_SKIP_AUTO_CLEANUP: 'true' },
39
+ };
40
+ // Vitest stubs CSS imports by default, which would leave every imported
41
+ // stylesheet out of the snapshots. An explicit choice by the user wins.
42
+ if (userTest?.css === undefined) {
43
+ test.css = true;
44
+ }
45
+ else if (userTest.css === false) {
46
+ console.warn('describe-me: `test.css` is false, so imported stylesheets are stubbed and will be missing from the snapshots.');
47
+ }
48
+ const styledComponents = options.styledComponentsBrowserBuild === false
49
+ ? undefined
50
+ : styledComponentsBrowserBuild(userConfig.root ?? process.cwd());
51
+ if (!styledComponents) {
52
+ return { test };
53
+ }
54
+ console.info('describe-me: using the browser build of styled-components, for createGlobalStyle');
55
+ return { resolve: styledComponents.resolve, test: { ...test, ...styledComponents.test } };
56
+ }
6
57
  /**
7
58
  * One-line integration: `plugins: [describeMe()]`.
8
59
  *
9
60
  * Registers the setup file and the reporter, and redirects the framework's
10
61
  * `render` import to our adapter so existing tests record frames unchanged.
11
- * The adapter itself still needs the real module, so imports coming from
12
- * inside it are left alone.
62
+ * Works in browser mode and in DOM environments such as jsdom; the adapter
63
+ * itself still needs the real module, so imports coming from inside it are
64
+ * left alone.
13
65
  */
14
66
  export function describeMe(options = {}) {
15
67
  const { enabled = true, framework = 'react', outDir } = options;
16
- const { original, adapter } = RENDER_MODULES[framework];
68
+ let renderModule = RENDER_MODULES[framework].browser;
69
+ // Known once Vite has resolved its config. Vitest always serves; a production
70
+ // build that shares the config must not carry the export registration.
71
+ let root = '';
72
+ let serving = false;
17
73
  return {
18
74
  name: 'describe-me',
19
75
  enforce: 'pre',
@@ -21,35 +77,69 @@ export function describeMe(options = {}) {
21
77
  if (!enabled) {
22
78
  return {};
23
79
  }
80
+ const environment = options.environment ?? detectEnvironment(userConfig);
81
+ renderModule = RENDER_MODULES[framework][environment];
24
82
  const reporter = new DescribeMeReporter({ outDir });
25
83
  // Vite concatenates arrays when merging, so only add `default` when the
26
84
  // user has not chosen their own reporters; otherwise they would lose it.
27
85
  const reporters = userConfig.test?.reporters ? [reporter] : ['default', reporter];
28
- return {
29
- // Vite's dependency scanner runs before our `resolveId` redirect, so it
30
- // never sees the adapter. Discovering it mid-run makes Vite re-optimize
31
- // and reload, and the test ends up with two copies of the bundled deps
32
- // ("Vitest failed to find the runner"). Declaring the adapter up front
33
- // keeps a single optimization pass.
34
- optimizeDeps: {
35
- include: [adapter, original],
36
- },
37
- test: {
38
- setupFiles: ['@describe-me/vitest/setup'],
39
- reporters,
40
- },
41
- };
86
+ const config = environment === 'browser' ? browserConfig(renderModule) : domConfig(userConfig, options);
87
+ return { ...config, test: { ...config.test, reporters } };
42
88
  },
43
- resolveId(source, importer) {
44
- if (!enabled || source !== original) {
89
+ async resolveId(source, importer) {
90
+ if (!enabled || source !== renderModule.original) {
45
91
  return null;
46
92
  }
47
93
  const fromAdapter = importer !== undefined &&
48
- (importer.includes(adapter) || importer.includes('/packages/react/'));
94
+ (importer.includes(renderModule.adapter) ||
95
+ ADAPTER_PATHS.some((path) => importer.includes(path)));
49
96
  if (fromAdapter) {
50
97
  return null;
51
98
  }
52
- return this.resolve(adapter, importer, { skipSelf: true });
99
+ const resolved = await this.resolve(renderModule.adapter, importer, { skipSelf: true });
100
+ // Falling back to the original module would look like success while
101
+ // recording nothing, and in a DOM environment it would also leave every
102
+ // test mounted, because Testing Library's auto-cleanup is switched off.
103
+ if (!resolved) {
104
+ throw new Error(`describe-me: cannot resolve ${renderModule.adapter} (the recording replacement for ${renderModule.original}). Install ${renderModule.adapter.split('/').slice(0, 2).join('/')}.`);
105
+ }
106
+ return resolved;
107
+ },
108
+ configResolved(config) {
109
+ root = config.root;
110
+ serving = config.command === 'serve';
111
+ },
112
+ // Names components after their export (see registerExports). `order: 'post'`
113
+ // runs it after the TypeScript and JSX transforms, so it parses plain
114
+ // JavaScript, while the plugin as a whole stays `enforce: 'pre'` for the
115
+ // render redirect above.
116
+ transform: {
117
+ order: 'post',
118
+ handler(code, id) {
119
+ if (!enabled || options.registerExports === false || !serving) {
120
+ return null;
121
+ }
122
+ const file = projectModulePath(id, root);
123
+ // Without the word `export` there is nothing to register: skip the parse.
124
+ if (file === null || !code.includes('export')) {
125
+ return null;
126
+ }
127
+ let snippet;
128
+ // Parse errors are for the user's own tooling to report, and an older
129
+ // Vite may lack `this.parse`. This transform must never be the reason a
130
+ // module fails, so it steps aside instead.
131
+ try {
132
+ snippet = registerExports(this.parse(code), file);
133
+ }
134
+ catch {
135
+ return null;
136
+ }
137
+ if (snippet === null) {
138
+ return null;
139
+ }
140
+ // Appending moves no code, so the source maps of earlier transforms stay valid.
141
+ return { code: code + snippet, map: null };
142
+ },
53
143
  },
54
144
  };
55
145
  }
@@ -0,0 +1,7 @@
1
+ import type { Manifest } from '@describe-me/core/types';
2
+ /**
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.
6
+ */
7
+ export declare function printDiagnostics(manifest: Manifest): void;
@@ -0,0 +1,31 @@
1
+ import { manifestDiagnostics } from '@describe-me/core/diagnostics';
2
+ /** How many names or paths a warning line spells out before summing up the rest. */
3
+ const SHOWN = 5;
4
+ function listed(items) {
5
+ const shown = items.slice(0, SHOWN).join(', ');
6
+ const rest = items.length - SHOWN;
7
+ if (rest > 0) {
8
+ return `${shown} (+${rest} more)`;
9
+ }
10
+ return shown;
11
+ }
12
+ function plural(count, one, many) {
13
+ return `${count} ${count === 1 ? one : many}`;
14
+ }
15
+ /**
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.
19
+ */
20
+ export function printDiagnostics(manifest) {
21
+ const { anonymous, undocumented, assetsMissing } = manifestDiagnostics(manifest);
22
+ if (anonymous.length > 0) {
23
+ 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
+ if (undocumented.length > 0) {
26
+ console.warn(`describe-me: ${plural(undocumented.length, 'component has', 'components have')} no props docs: ${listed(undocumented.map((component) => component.name))}`);
27
+ }
28
+ if (assetsMissing.length > 0) {
29
+ 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
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The path of a Vite module id relative to the project root, with posix
3
+ * separators, or null when the module is not the project's own source: a
4
+ * virtual module (`\0…`), a request with a query (`?raw`, `?worker`), anything
5
+ * outside the root or in node_modules, a non-script file, or a test file.
6
+ */
7
+ export declare function projectModulePath(id: string, root: string): string | null;
@@ -0,0 +1,23 @@
1
+ import { isAbsolute, relative, sep } from 'node:path';
2
+ /** Modules that can export a component: JavaScript and TypeScript, with or without JSX. */
3
+ const SCRIPT_MODULE = /\.[cm]?[jt]sx?$/;
4
+ const TEST_MODULE = /\.(test|spec)\.[cm]?[jt]sx?$/;
5
+ /**
6
+ * The path of a Vite module id relative to the project root, with posix
7
+ * separators, or null when the module is not the project's own source: a
8
+ * virtual module (`\0…`), a request with a query (`?raw`, `?worker`), anything
9
+ * outside the root or in node_modules, a non-script file, or a test file.
10
+ */
11
+ export function projectModulePath(id, root) {
12
+ if (root === '' || id.startsWith('\0') || id.includes('?')) {
13
+ return null;
14
+ }
15
+ if (id.includes('/node_modules/') || !SCRIPT_MODULE.test(id) || TEST_MODULE.test(id)) {
16
+ return null;
17
+ }
18
+ const path = relative(root, id);
19
+ if (path === '' || path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)) {
20
+ return null;
21
+ }
22
+ return path.split(sep).join('/');
23
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The part of an ESTree node every node has. Rollup (Vite 6 and 7) and
3
+ * Rolldown (Vite 8) both parse to ESTree, so structural types fit either
4
+ * without depending on their AST packages.
5
+ */
6
+ export interface EstreeNode {
7
+ type: string;
8
+ }
9
+ /** A parsed module, as `this.parse(code)` returns it in a Vite plugin. */
10
+ export interface EstreeProgram {
11
+ body: readonly EstreeNode[];
12
+ }
13
+ /**
14
+ * The code to append to a project module so that each value it exports is
15
+ * registered under its export name, or null when it exports nothing to
16
+ * register. A component is then named `Button` even when it is an anonymous
17
+ * `forwardRef` or a styled component.
18
+ *
19
+ * The appended code imports nothing: a user's module cannot always resolve
20
+ * `@describe-me/core`, and a bundled adapter may carry its own copy of it. It
21
+ * writes to a `WeakMap` on `globalThis` instead, and the first registration of
22
+ * a value wins, so the defining module beats a barrel that re-exports it.
23
+ *
24
+ * Only local bindings are referenced. Re-exports with `from` are skipped (the
25
+ * defining module registers the value itself), and so are anonymous default
26
+ * exports, which have no binding to point at.
27
+ */
28
+ export declare function registerExports(program: EstreeProgram, file: string): string | null;
@@ -0,0 +1,118 @@
1
+ import { EXPORT_REGISTRY_KEY } from '@describe-me/core/types';
2
+ /**
3
+ * The code to append to a project module so that each value it exports is
4
+ * registered under its export name, or null when it exports nothing to
5
+ * register. A component is then named `Button` even when it is an anonymous
6
+ * `forwardRef` or a styled component.
7
+ *
8
+ * The appended code imports nothing: a user's module cannot always resolve
9
+ * `@describe-me/core`, and a bundled adapter may carry its own copy of it. It
10
+ * writes to a `WeakMap` on `globalThis` instead, and the first registration of
11
+ * a value wins, so the defining module beats a barrel that re-exports it.
12
+ *
13
+ * Only local bindings are referenced. Re-exports with `from` are skipped (the
14
+ * defining module registers the value itself), and so are anonymous default
15
+ * exports, which have no binding to point at.
16
+ */
17
+ export function registerExports(program, file) {
18
+ const registrations = program.body.flatMap(registrationsOf);
19
+ if (registrations.length === 0) {
20
+ return null;
21
+ }
22
+ return snippetFor(registrations, file);
23
+ }
24
+ function registrationsOf(node) {
25
+ if (node.type === 'ExportNamedDeclaration') {
26
+ return namedExports(node);
27
+ }
28
+ if (node.type === 'ExportDefaultDeclaration') {
29
+ return defaultExport(node);
30
+ }
31
+ return [];
32
+ }
33
+ /** `export const A = …`, `export function A`, `export class A`, `export { a as A }`. */
34
+ function namedExports(node) {
35
+ if (node.source) {
36
+ return [];
37
+ }
38
+ if (node.declaration) {
39
+ return declaredNames(node.declaration).map((name) => ({ name, local: name }));
40
+ }
41
+ return (node.specifiers ?? []).flatMap(specifierExport);
42
+ }
43
+ /** Identifiers only: a destructuring pattern would need its own walk for little gain. */
44
+ function declaredNames(declaration) {
45
+ let ids = [];
46
+ if (declaration.type === 'VariableDeclaration') {
47
+ ids = declaration.declarations.map((declarator) => declarator.id);
48
+ }
49
+ else if (isFunctionOrClass(declaration)) {
50
+ ids = [declaration.id];
51
+ }
52
+ return ids.map(identifierName).filter((name) => name !== null);
53
+ }
54
+ function specifierExport(specifier) {
55
+ const local = identifierName(specifier.local);
56
+ const exported = exportedName(specifier.exported);
57
+ if (local === null || exported === null) {
58
+ return [];
59
+ }
60
+ // `export { Button as default }` is a default export: its local name says more.
61
+ const name = exported === 'default' ? local : exported;
62
+ return [{ name, local }];
63
+ }
64
+ /**
65
+ * `export default Button` and `export default function Button`, under the local
66
+ * name. A function or class expression (`export default (function A() {})`)
67
+ * binds nothing in the module scope, so it is left out like any anonymous one.
68
+ */
69
+ function defaultExport(node) {
70
+ const { declaration } = node;
71
+ const local = identifierName(isFunctionOrClass(declaration) ? declaration.id : declaration);
72
+ if (local === null) {
73
+ return [];
74
+ }
75
+ return [{ name: local, local }];
76
+ }
77
+ function isFunctionOrClass(node) {
78
+ return node.type === 'FunctionDeclaration' || node.type === 'ClassDeclaration';
79
+ }
80
+ function identifierName(node) {
81
+ if (node?.type !== 'Identifier') {
82
+ return null;
83
+ }
84
+ return node.name;
85
+ }
86
+ /** `export { a as A }` or, since ES2022, `export { a as 'A' }`. */
87
+ function exportedName(node) {
88
+ if (node.type === 'Literal') {
89
+ const { value } = node;
90
+ return typeof value === 'string' ? value : null;
91
+ }
92
+ return identifierName(node);
93
+ }
94
+ /**
95
+ * Appended after the module's last line, so no existing code moves and the
96
+ * source maps from earlier transforms stay valid. Starts on a new line and
97
+ * with a semicolon, in case the module ends in a comment or an unterminated
98
+ * expression.
99
+ */
100
+ function snippetFor(registrations, file) {
101
+ const entries = registrations
102
+ .map(({ name, local }) => `[${JSON.stringify(name)}, ${local}]`)
103
+ .join(', ');
104
+ return [
105
+ '',
106
+ '// describe-me: name components after their export (registerExports option)',
107
+ ';((key, file, entries) => {',
108
+ ' const registry = globalThis[key] || (globalThis[key] = new WeakMap())',
109
+ ' for (const [name, value] of entries) {',
110
+ " const isObject = typeof value === 'function' || (typeof value === 'object' && value !== null)",
111
+ ' if (isObject && !registry.has(value)) {',
112
+ ' registry.set(value, { name, file })',
113
+ ' }',
114
+ ' }',
115
+ `})(Symbol.for(${JSON.stringify(EXPORT_REGISTRY_KEY)}), ${JSON.stringify(file)}, [${entries}])`,
116
+ '',
117
+ ].join('\n');
118
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The per-test lifecycle shared by every environment: begin a recording
3
+ * before each test, then take the closing frame, hand the frames to the
4
+ * reporter via task.meta, and only then let adapters unmount.
5
+ */
6
+ export declare function registerRecordingHooks(): void;
@@ -0,0 +1,17 @@
1
+ import { afterEach, beforeEach } from 'vitest';
2
+ import { META_KEY, recorder } from '@describe-me/core';
3
+ /**
4
+ * The per-test lifecycle shared by every environment: begin a recording
5
+ * before each test, then take the closing frame, hand the frames to the
6
+ * reporter via task.meta, and only then let adapters unmount.
7
+ */
8
+ export function registerRecordingHooks() {
9
+ beforeEach(() => {
10
+ recorder.begin();
11
+ });
12
+ afterEach(async (context) => {
13
+ await recorder.capture('end', 'end of test');
14
+ context.task.meta[META_KEY] = recorder.end();
15
+ recorder.teardown();
16
+ });
17
+ }
@@ -8,9 +8,11 @@ export default class DescribeMeReporter implements Reporter {
8
8
  private root;
9
9
  private outDir;
10
10
  private snapshots;
11
+ private assets;
11
12
  private modules;
12
13
  constructor(options?: DescribeMeReporterOptions);
13
14
  onInit(vitest: Vitest): void;
15
+ onTestRunStart(): void;
14
16
  /**
15
17
  * A filtered run (`vitest run Button.test.tsx`) must not wipe the other
16
18
  * modules from the manifest, so start from whatever the last run wrote.
@@ -20,4 +22,6 @@ export default class DescribeMeReporter implements Reporter {
20
22
  onTestModuleEnd(module: TestModule): void;
21
23
  onTestRunEnd(): Promise<void>;
22
24
  private toManifestTest;
25
+ /** Store one frame's DOM, with its project asset URLs pointing into the asset store. */
26
+ private writeSnapshot;
23
27
  }
package/dist/reporter.js CHANGED
@@ -5,8 +5,10 @@
5
5
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
6
6
  import { join, relative, resolve } 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';
9
10
  import { componentEntries } from './component-entries.js';
11
+ import { printDiagnostics } from './print-diagnostics.js';
10
12
  import { SnapshotStore } from './snapshot-store.js';
11
13
  import { testPath } from './test-path.js';
12
14
  export default class DescribeMeReporter {
@@ -14,6 +16,7 @@ export default class DescribeMeReporter {
14
16
  root = process.cwd();
15
17
  outDir = '';
16
18
  snapshots;
19
+ assets;
17
20
  modules = new Map();
18
21
  constructor(options = {}) {
19
22
  this.outDirOption = options.outDir ?? '.describe-me';
@@ -22,8 +25,12 @@ export default class DescribeMeReporter {
22
25
  this.root = vitest.config.root;
23
26
  this.outDir = resolve(this.root, this.outDirOption);
24
27
  this.snapshots = new SnapshotStore(this.outDir);
28
+ this.assets = new AssetStore(this.outDir, this.root);
25
29
  this.seedFromPreviousRun();
26
30
  }
31
+ onTestRunStart() {
32
+ this.assets.startRun();
33
+ }
27
34
  /**
28
35
  * A filtered run (`vitest run Button.test.tsx`) must not wipe the other
29
36
  * modules from the manifest, so start from whatever the last run wrote.
@@ -54,15 +61,22 @@ export default class DescribeMeReporter {
54
61
  async onTestRunEnd() {
55
62
  const modules = Array.from(this.modules.values()).sort((left, right) => left.id.localeCompare(right.id));
56
63
  const components = await collectComponentDocsSafely(this.root, componentEntries(modules, this.root));
64
+ // Only what this run found: a filtered run does not know which of the
65
+ // previous run's missing assets belonged to the modules it skipped.
66
+ const assetsMissing = this.assets.missing();
57
67
  const manifest = {
58
68
  version: 1,
59
69
  generatedAt: new Date().toISOString(),
60
70
  root: this.root,
61
71
  modules,
62
72
  components,
73
+ assetsMissing,
63
74
  };
64
- this.snapshots.collectGarbage(manifest.modules.flatMap((mod) => mod.tests.flatMap((test) => test.frames.map((frame) => frame.snapshot))));
75
+ const snapshotFiles = manifest.modules.flatMap((mod) => mod.tests.flatMap((test) => test.frames.map((frame) => frame.snapshot)));
76
+ this.snapshots.collectGarbage(snapshotFiles);
77
+ this.assets.collectGarbage(snapshotFiles);
65
78
  writeFileSync(join(this.outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
79
+ printDiagnostics(manifest);
66
80
  }
67
81
  toManifestTest(tc) {
68
82
  const record = tc.meta()[META_KEY];
@@ -73,7 +87,7 @@ export default class DescribeMeReporter {
73
87
  label: frame.label,
74
88
  at: frame.at,
75
89
  meta: frame.meta,
76
- snapshot: this.snapshots.write(frame.snapshot),
90
+ snapshot: this.writeSnapshot(frame.snapshot, record?.origin),
77
91
  }));
78
92
  return {
79
93
  id: tc.id,
@@ -87,4 +101,8 @@ export default class DescribeMeReporter {
87
101
  frames,
88
102
  };
89
103
  }
104
+ /** Store one frame's DOM, with its project asset URLs pointing into the asset store. */
105
+ writeSnapshot(snapshot, origin) {
106
+ return this.snapshots.write(this.assets.rewrite(JSON.stringify(snapshot), origin));
107
+ }
90
108
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Vitest setup file for DOM environments such as jsdom:
3
+ * `setupFiles: ['@describe-me/vitest/setup-dom']`. Patches Testing Library's
4
+ * userEvent so every interaction records a frame, and registers the
5
+ * recording hooks. Never imports `vitest/browser`, which only exists in
6
+ * browser mode.
7
+ */
8
+ import { patchTestingLibraryUserEvent } from './patch-testing-library-user-event.js';
9
+ import { registerRecordingHooks } from './register-recording-hooks.js';
10
+ patchTestingLibraryUserEvent();
11
+ registerRecordingHooks();
package/dist/setup.js CHANGED
@@ -1,19 +1,11 @@
1
1
  /**
2
- * Vitest setup file: `setupFiles: ['@describe-me/vitest/setup']`.
3
- * Runs in the browser. Patches Vitest's locators and userEvent so every
4
- * interaction records a frame, begins a recording before each test, and hands
5
- * the frames to the reporter via task.meta.
2
+ * Vitest setup file for browser mode: `setupFiles: ['@describe-me/vitest/setup']`.
3
+ * Patches Vitest's locators and userEvent so every interaction records a
4
+ * frame, and registers the recording hooks. For jsdom use `./setup-dom`.
6
5
  */
7
- import { afterEach, beforeEach } from 'vitest';
8
- import { META_KEY, recorder } from '@describe-me/core';
9
6
  import { patchLocators } from './patch-locators.js';
10
7
  import { patchUserEvent } from './patch-user-event.js';
8
+ import { registerRecordingHooks } from './register-recording-hooks.js';
11
9
  patchLocators();
12
10
  patchUserEvent();
13
- beforeEach(() => {
14
- recorder.begin();
15
- });
16
- afterEach(async (context) => {
17
- await recorder.capture('end', 'end of test');
18
- context.task.meta[META_KEY] = recorder.end();
19
- });
11
+ registerRecordingHooks();