@describe-me/vitest 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,35 +13,59 @@ pnpm add -D @describe-me/vitest @describe-me/react describe-me
13
13
  ## Usage
14
14
 
15
15
  One line in the Vitest config registers the setup file and the reporter, and
16
- redirects `vitest-browser-react` imports to the recording adapter:
16
+ redirects the test library's `render` import to the recording adapter. The
17
+ plugin detects where the tests run:
18
+
19
+ - **Browser mode** (`test.browser.enabled`): redirects `vitest-browser-react`,
20
+ records a frame after every `Locator` action and keyboard-level `userEvent`.
21
+ - **DOM environments** such as jsdom (anything else): redirects
22
+ `@testing-library/react`, records a frame after every
23
+ `@testing-library/user-event` call, turns on `test.css` so imported
24
+ stylesheets reach the snapshots, and sets `RTL_SKIP_AUTO_CLEANUP` so the
25
+ component is unmounted only after the closing frame.
17
26
 
18
27
  ```ts
19
28
  import { describeMe } from '@describe-me/vitest/plugin'
20
29
 
30
+ // browser mode
21
31
  export default defineConfig({
22
32
  plugins: [react(), describeMe()],
23
33
  test: {
24
34
  browser: { enabled: true, provider: playwright(), instances: [{ browser: 'chromium' }] },
25
35
  },
26
36
  })
37
+
38
+ // jsdom
39
+ export default defineConfig({
40
+ plugins: [react(), describeMe()],
41
+ test: { environment: 'jsdom' },
42
+ })
27
43
  ```
28
44
 
45
+ Pass `describeMe({ environment: 'browser' | 'dom' })` to override the detection.
46
+
29
47
  The pieces can also be wired by hand, in which case tests import `render` from
30
- `@describe-me/react` themselves:
48
+ the adapter themselves (`@describe-me/react` in browser mode,
49
+ `@describe-me/react/testing-library` in jsdom):
31
50
 
32
51
  ```ts
33
52
  import DescribeMeReporter from '@describe-me/vitest/reporter'
34
53
 
35
54
  export default defineConfig({
36
55
  test: {
37
- setupFiles: ['@describe-me/vitest/setup'],
56
+ // browser mode: '@describe-me/vitest/setup'
57
+ setupFiles: ['@describe-me/vitest/setup-dom'],
38
58
  reporters: ['default', new DescribeMeReporter()],
59
+ // jsdom only: keep imported CSS and leave unmounting to describe-me
60
+ css: true,
61
+ env: { RTL_SKIP_AUTO_CLEANUP: 'true' },
39
62
  },
40
63
  })
41
64
  ```
42
65
 
43
66
  `typescript` is an optional peer: without it the docs are still generated, only
44
- the props tables are left out.
67
+ the props tables are left out. `@testing-library/user-event` is an optional
68
+ peer too, needed only in DOM environments.
45
69
 
46
70
  See the [root README](https://github.com/grzehub/describe-me#readme) for the
47
71
  full picture.
@@ -0,0 +1,6 @@
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;
@@ -0,0 +1,34 @@
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
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Wrap Testing Library's userEvent in place so each call records a frame.
3
+ * It comes in two shapes, the direct API (`userEvent.click(el)`) and an
4
+ * instance from `userEvent.setup()`, and the first delegates to the second,
5
+ * so both are patched and `recordAction`'s depth guard keeps one gesture to
6
+ * one frame. Safe to call more than once.
7
+ */
8
+ export declare function patchTestingLibraryUserEvent(): void;
@@ -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,9 +1,16 @@
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;
9
16
  }
@@ -13,8 +20,9 @@ type VitePlugin = NonNullable<ViteUserConfig['plugins']>[number];
13
20
  *
14
21
  * Registers the setup file and the reporter, and redirects the framework's
15
22
  * `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.
23
+ * Works in browser mode and in DOM environments such as jsdom; the adapter
24
+ * itself still needs the real module, so imports coming from inside it are
25
+ * left alone.
18
26
  */
19
27
  export declare function describeMe(options?: DescribeMeOptions): VitePlugin;
20
28
  export {};
package/dist/plugin.js CHANGED
@@ -1,19 +1,60 @@
1
1
  import DescribeMeReporter from './reporter.js';
2
- /** For each framework: the render module tests import, and our adapter that wraps it. */
2
+ /** For each framework and environment: the render module tests import, and our adapter that wraps it. */
3
3
  const RENDER_MODULES = {
4
- react: { original: 'vitest-browser-react', adapter: '@describe-me/react' },
4
+ react: {
5
+ browser: { original: 'vitest-browser-react', adapter: '@describe-me/react' },
6
+ dom: { original: '@testing-library/react', adapter: '@describe-me/react/testing-library' },
7
+ },
5
8
  };
9
+ /** Paths an adapter's own files live under, published or in this workspace. */
10
+ const ADAPTER_PATHS = ['/@describe-me/react/', '/packages/react/'];
11
+ function detectEnvironment(userConfig) {
12
+ return userConfig.test?.browser?.enabled ? 'browser' : 'dom';
13
+ }
14
+ function browserConfig(renderModule) {
15
+ return {
16
+ // Vite's dependency scanner runs before our `resolveId` redirect, so it
17
+ // never sees the adapter. Discovering it mid-run makes Vite re-optimize
18
+ // and reload, and the test ends up with two copies of the bundled deps
19
+ // ("Vitest failed to find the runner"). Declaring the adapter up front
20
+ // keeps a single optimization pass.
21
+ optimizeDeps: {
22
+ include: [renderModule.adapter, renderModule.original],
23
+ },
24
+ test: {
25
+ setupFiles: ['@describe-me/vitest/setup'],
26
+ },
27
+ };
28
+ }
29
+ function domConfig(userTest) {
30
+ const test = {
31
+ setupFiles: ['@describe-me/vitest/setup-dom'],
32
+ // Testing Library would otherwise unmount in its own afterEach, before the
33
+ // closing frame is taken. The adapter unmounts through the recorder instead.
34
+ env: { RTL_SKIP_AUTO_CLEANUP: 'true' },
35
+ };
36
+ // Vitest stubs CSS imports by default, which would leave every imported
37
+ // stylesheet out of the snapshots. An explicit choice by the user wins.
38
+ if (userTest?.css === undefined) {
39
+ test.css = true;
40
+ }
41
+ else if (userTest.css === false) {
42
+ console.warn('describe-me: `test.css` is false, so imported stylesheets are stubbed and will be missing from the snapshots.');
43
+ }
44
+ return { test };
45
+ }
6
46
  /**
7
47
  * One-line integration: `plugins: [describeMe()]`.
8
48
  *
9
49
  * Registers the setup file and the reporter, and redirects the framework's
10
50
  * `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.
51
+ * Works in browser mode and in DOM environments such as jsdom; the adapter
52
+ * itself still needs the real module, so imports coming from inside it are
53
+ * left alone.
13
54
  */
14
55
  export function describeMe(options = {}) {
15
56
  const { enabled = true, framework = 'react', outDir } = options;
16
- const { original, adapter } = RENDER_MODULES[framework];
57
+ let renderModule = RENDER_MODULES[framework].browser;
17
58
  return {
18
59
  name: 'describe-me',
19
60
  enforce: 'pre',
@@ -21,35 +62,33 @@ export function describeMe(options = {}) {
21
62
  if (!enabled) {
22
63
  return {};
23
64
  }
65
+ const environment = options.environment ?? detectEnvironment(userConfig);
66
+ renderModule = RENDER_MODULES[framework][environment];
24
67
  const reporter = new DescribeMeReporter({ outDir });
25
68
  // Vite concatenates arrays when merging, so only add `default` when the
26
69
  // user has not chosen their own reporters; otherwise they would lose it.
27
70
  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
- };
71
+ const config = environment === 'browser' ? browserConfig(renderModule) : domConfig(userConfig.test);
72
+ return { ...config, test: { ...config.test, reporters } };
42
73
  },
43
- resolveId(source, importer) {
44
- if (!enabled || source !== original) {
74
+ async resolveId(source, importer) {
75
+ if (!enabled || source !== renderModule.original) {
45
76
  return null;
46
77
  }
47
78
  const fromAdapter = importer !== undefined &&
48
- (importer.includes(adapter) || importer.includes('/packages/react/'));
79
+ (importer.includes(renderModule.adapter) ||
80
+ ADAPTER_PATHS.some((path) => importer.includes(path)));
49
81
  if (fromAdapter) {
50
82
  return null;
51
83
  }
52
- return this.resolve(adapter, importer, { skipSelf: true });
84
+ const resolved = await this.resolve(renderModule.adapter, importer, { skipSelf: true });
85
+ // Falling back to the original module would look like success while
86
+ // recording nothing, and in a DOM environment it would also leave every
87
+ // test mounted, because Testing Library's auto-cleanup is switched off.
88
+ if (!resolved) {
89
+ throw new Error(`describe-me: cannot resolve ${renderModule.adapter} (the recording replacement for ${renderModule.original}). Install ${renderModule.adapter.split('/').slice(0, 2).join('/')}.`);
90
+ }
91
+ return resolved;
53
92
  },
54
93
  };
55
94
  }
@@ -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
+ }
@@ -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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@describe-me/vitest",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Vitest integration for describe-me: plugin, setup file and reporter",
5
5
  "keywords": [
6
6
  "vitest",
@@ -35,6 +35,10 @@
35
35
  "types": "./dist/setup.d.ts",
36
36
  "default": "./dist/setup.js"
37
37
  },
38
+ "./setup-dom": {
39
+ "types": "./dist/setup-dom.d.ts",
40
+ "default": "./dist/setup-dom.js"
41
+ },
38
42
  "./reporter": {
39
43
  "types": "./dist/reporter.d.ts",
40
44
  "default": "./dist/reporter.js"
@@ -50,24 +54,31 @@
50
54
  "LICENSE"
51
55
  ],
52
56
  "sideEffects": [
53
- "./dist/setup.js"
57
+ "./dist/setup.js",
58
+ "./dist/setup-dom.js"
54
59
  ],
55
60
  "dependencies": {
56
- "@describe-me/core": "^0.2.0"
61
+ "@describe-me/core": "^0.3.0"
57
62
  },
58
63
  "peerDependencies": {
59
64
  "vitest": ">=4",
60
- "typescript": ">=5.0"
65
+ "typescript": ">=5.0",
66
+ "@testing-library/user-event": ">=14"
61
67
  },
62
68
  "peerDependenciesMeta": {
63
69
  "typescript": {
64
70
  "optional": true
71
+ },
72
+ "@testing-library/user-event": {
73
+ "optional": true
65
74
  }
66
75
  },
67
76
  "devDependencies": {
68
77
  "vitest": "^5.0.1",
69
78
  "@types/node": "^22",
70
- "typescript": "^6"
79
+ "typescript": "^6",
80
+ "@testing-library/user-event": "^14.6.1",
81
+ "@testing-library/dom": "^10.4.0"
71
82
  },
72
83
  "scripts": {
73
84
  "build": "rimraf dist && tsc -p tsconfig.json"