@describe-me/react 0.5.3 → 0.6.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
@@ -1,54 +1,71 @@
1
1
  # @describe-me/react
2
2
 
3
- The React adapter for [describe-me](https://github.com/grzehub/describe-me):
4
- drop-in `render` functions that record a frame after mount and after every
5
- `rerender`, and read the component's name and props for the docs. This is the
6
- only React-specific code in the project.
3
+ **Your tests are your stories.** The React adapter of describe-me: living
4
+ component documentation generated from the Vitest tests you already have.
7
5
 
8
- | entry point | wraps | for |
9
- | ------------------------------------ | ------------------------ | ------------------------ |
10
- | `@describe-me/react` | `vitest-browser-react` | Vitest browser mode |
11
- | `@describe-me/react/testing-library` | `@testing-library/react` | jsdom and other DOM envs |
6
+ **[Read the docs](https://grzehub.github.io/describe-me/)** · open the viewer
7
+ of the [browser-mode example](https://grzehub.github.io/describe-me/examples/react-browser/)
8
+ or the [jsdom example](https://grzehub.github.io/describe-me/examples/react-jsdom/)
9
+ · [Limitations](https://grzehub.github.io/describe-me/limitations.html)
12
10
 
13
- Both test libraries are optional peers: install the one your tests use.
11
+ describe-me captures the DOM after each step of a test and shows the result as
12
+ a browsable catalog. `describe` blocks become the sidebar, each `it` is a
13
+ story, and each step is a frame you can scrub through. Stories cannot rot,
14
+ because they are tests. If one drifts from the component, CI goes red.
14
15
 
15
- ## Install
16
+ Status: prototype. React 18 and 19, Vitest 4 and 5 in browser mode or jsdom,
17
+ recorded frames and Live in the viewer.
18
+
19
+ ## Which package do I need
20
+
21
+ | Package | What it is | Install it |
22
+ | --------------------- | -------------------------------------------- | ----------------------- |
23
+ | `describe-me` | the viewer and the `describe-me` CLI | yes |
24
+ | `@describe-me/vitest` | the Vitest plugin, setup files and reporter | yes |
25
+ | `@describe-me/react` | `render` functions that record frames (this) | yes |
26
+ | `@describe-me/core` | the recorder and the manifest types | no, the others bring it |
27
+
28
+ ## Quick start
16
29
 
17
30
  ```sh
18
- pnpm add -D @describe-me/react
31
+ pnpm add -D describe-me @describe-me/vitest @describe-me/react
19
32
  ```
20
33
 
21
- All describe-me packages must be on the same version
22
- ([One version for all packages](https://grzehub.github.io/describe-me/installation.html#lockstep)).
34
+ ```ts
35
+ // vitest.config.ts
36
+ import { describeMe } from '@describe-me/vitest/plugin'
23
37
 
24
- ## Usage
38
+ export default defineConfig({
39
+ plugins: [react(), describeMe()],
40
+ test: { environment: 'jsdom' }, // or your browser-mode settings
41
+ })
42
+ ```
25
43
 
26
- Each entry point re-exports everything its test library offers, with the
27
- recording functions swapped in, so switching an existing test is one import.
28
- `@describe-me/react` swaps `render`. The Testing Library entry swaps `render`,
29
- `fireEvent` and `cleanup`: every `fireEvent` call records a frame, and
30
- `cleanup` takes the closing frame before it unmounts. When the render frame is
31
- taken follows the plugin's
32
- [`renderFrame`](https://grzehub.github.io/describe-me/configuration.html#render-frame)
33
- option.
44
+ Run your tests, then `describe-me dev` opens the viewer.
45
+ [Getting started](https://grzehub.github.io/describe-me/getting-started.html)
46
+ walks through both environments. Keep all describe-me packages on one version
47
+ ([why](https://grzehub.github.io/describe-me/installation.html#lockstep)).
34
48
 
35
- ```tsx
36
- import { render, step } from '@describe-me/react'
49
+ ## This package
37
50
 
38
- const screen = await render(<Counter initial={1} />)
39
- await step('increment', () => screen.getByRole('button', { name: 'add' }).click())
40
- ```
51
+ Drop-in `render` functions that record a frame after mount and after every
52
+ `rerender`, and read the component's name and props for the docs. It is the
53
+ only React-specific code in describe-me.
41
54
 
42
- ```tsx
43
- import { render } from '@describe-me/react/testing-library'
55
+ | Entry point | Wraps | For |
56
+ | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ------------------- |
57
+ | [`@describe-me/react`](https://grzehub.github.io/describe-me/api.html#entry-react) | `vitest-browser-react` | Vitest browser mode |
58
+ | [`@describe-me/react/testing-library`](https://grzehub.github.io/describe-me/api.html#entry-react-testing-library) | `@testing-library/react` | jsdom |
59
+ | [`@describe-me/react/live`](https://grzehub.github.io/describe-me/api.html#entry-react-live) | stands in for `vitest-browser-react` | the live preview |
60
+ | [`@describe-me/react/live-testing-library`](https://grzehub.github.io/describe-me/api.html#entry-react-live-testing-library) | `@testing-library/react` | the live preview |
44
61
 
45
- // Synchronous, exactly like the original.
46
- const { getByRole } = render(<Counter initial={1} />)
47
- ```
62
+ The first two entry points re-export their test library with the recording
63
+ functions swapped in. With the `describeMe()` plugin you never import them
64
+ yourself: it redirects your `render` imports, those in your own `test-utils`
65
+ included. It redirects them to the live entry points only in the live preview,
66
+ so tests never import those. Install the test library your tests use. Both are
67
+ optional peers.
48
68
 
49
- With `@describe-me/vitest`'s `describeMe()` plugin you do not even need that:
50
- the plugin redirects `vitest-browser-react` or `@testing-library/react` imports
51
- to the matching entry point, including imports made from your own
52
- `test-utils` wrapper, so tests record frames unchanged.
69
+ ## License
53
70
 
54
- See the [docs](https://grzehub.github.io/describe-me/) for the full picture.
71
+ MIT
@@ -0,0 +1,17 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { LiveMountOptions } from './mount-live.js';
3
+ /** What the live render returns while it has nothing to mount. */
4
+ export interface EmptyLiveResult {
5
+ container: Element | DocumentFragment;
6
+ baseElement: Element;
7
+ rerender(ui: ReactNode): unknown;
8
+ unmount(): void;
9
+ asFragment(): DocumentFragment;
10
+ debug(): void;
11
+ }
12
+ /**
13
+ * The result of a live render that had nothing to mount yet. Its `rerender`
14
+ * renders again into the same container, so the component mounts at the
15
+ * test's rerender.
16
+ */
17
+ export declare function emptyLiveResult(options: LiveMountOptions, render: (ui: ReactNode, options: LiveMountOptions) => unknown): EmptyLiveResult;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The result of a live render that had nothing to mount yet. Its `rerender`
3
+ * renders again into the same container, so the component mounts at the
4
+ * test's rerender.
5
+ */
6
+ export function emptyLiveResult(options, render) {
7
+ const baseElement = options.baseElement ?? document.body;
8
+ const container = options.container ?? baseElement.appendChild(document.createElement('div'));
9
+ const next = { ...options, baseElement, container };
10
+ return {
11
+ container,
12
+ baseElement,
13
+ rerender: (ui) => render(ui, next),
14
+ unmount: () => undefined,
15
+ asFragment: () => document.createDocumentFragment(),
16
+ debug: () => undefined,
17
+ };
18
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Whether a test rendered nothing yet: `null`, `undefined`, a boolean, `''`,
3
+ * or a fragment whose children are all empty. The live render then waits for
4
+ * the test's `rerender`.
5
+ */
6
+ export declare function isEmptyLiveUi(ui: unknown): boolean;
@@ -0,0 +1,18 @@
1
+ import { Fragment, isValidElement } from 'react';
2
+ /**
3
+ * Whether a test rendered nothing yet: `null`, `undefined`, a boolean, `''`,
4
+ * or a fragment whose children are all empty. The live render then waits for
5
+ * the test's `rerender`.
6
+ */
7
+ export function isEmptyLiveUi(ui) {
8
+ if (ui === null || ui === undefined || typeof ui === 'boolean' || ui === '') {
9
+ return true;
10
+ }
11
+ if (Array.isArray(ui)) {
12
+ return ui.every((child) => isEmptyLiveUi(child));
13
+ }
14
+ if (!isValidElement(ui) || ui.type !== Fragment) {
15
+ return false;
16
+ }
17
+ return isEmptyLiveUi(ui.props.children);
18
+ }
@@ -0,0 +1,2 @@
1
+ /** Stands in for `cleanup` on the live page, where the component stays mounted. */
2
+ export declare function liveCleanup(): void;
@@ -0,0 +1,2 @@
1
+ /** Stands in for `cleanup` on the live page, where the component stays mounted. */
2
+ export function liveCleanup() { }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Stands in for Testing Library's `renderHook` on the live page. A hook shows
3
+ * nothing, so it stops the test and the page reports `no-render`.
4
+ */
5
+ export declare function liveRenderHookTestingLibrary(): never;
@@ -0,0 +1,8 @@
1
+ import { liveStop } from '@describe-me/core/live';
2
+ /**
3
+ * Stands in for Testing Library's `renderHook` on the live page. A hook shows
4
+ * nothing, so it stops the test and the page reports `no-render`.
5
+ */
6
+ export function liveRenderHookTestingLibrary() {
7
+ throw liveStop();
8
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Stands in for `renderHook` from vitest-browser-react on the live page. A
3
+ * hook shows nothing, so it stops the test and the page reports `no-render`.
4
+ */
5
+ export declare function liveRenderHook(): Promise<never>;
@@ -0,0 +1,8 @@
1
+ import { liveStop } from '@describe-me/core/live';
2
+ /**
3
+ * Stands in for `renderHook` from vitest-browser-react on the live page. A
4
+ * hook shows nothing, so it stops the test and the page reports `no-render`.
5
+ */
6
+ export function liveRenderHook() {
7
+ return Promise.reject(liveStop());
8
+ }
@@ -0,0 +1,8 @@
1
+ import type { ReactNode } from 'react';
2
+ import { type LiveMountOptions } from './mount-live.js';
3
+ /**
4
+ * Stands in for Testing Library's `render` on the live page. It mounts the
5
+ * component and throws the stop signal, so the test ends at its first
6
+ * render. For an empty UI it waits for the test's `rerender`.
7
+ */
8
+ export declare function liveRenderTestingLibrary(ui: ReactNode, options?: LiveMountOptions): unknown;
@@ -0,0 +1,18 @@
1
+ import { within } from '@testing-library/react';
2
+ import { liveStop } from '@describe-me/core/live';
3
+ import { emptyLiveResult } from './empty-live-result.js';
4
+ import { isEmptyLiveUi } from './is-empty-live-ui.js';
5
+ import { mountLive } from './mount-live.js';
6
+ /**
7
+ * Stands in for Testing Library's `render` on the live page. It mounts the
8
+ * component and throws the stop signal, so the test ends at its first
9
+ * render. For an empty UI it waits for the test's `rerender`.
10
+ */
11
+ export function liveRenderTestingLibrary(ui, options = {}) {
12
+ if (isEmptyLiveUi(ui)) {
13
+ const result = emptyLiveResult(options, liveRenderTestingLibrary);
14
+ return { ...within(result.baseElement), ...result };
15
+ }
16
+ mountLive(ui, options);
17
+ throw liveStop();
18
+ }
@@ -0,0 +1,8 @@
1
+ import type { ReactNode } from 'react';
2
+ import { type LiveMountOptions } from './mount-live.js';
3
+ /**
4
+ * Stands in for `render` from vitest-browser-react on the live page. It
5
+ * mounts the component and rejects with the stop signal, so the test ends at
6
+ * its first render. For an empty UI it waits for the test's `rerender`.
7
+ */
8
+ export declare function liveRender(ui: ReactNode, options?: LiveMountOptions): Promise<unknown>;
@@ -0,0 +1,27 @@
1
+ import { liveInert, liveStop } from '@describe-me/core/live';
2
+ import { emptyLiveResult } from './empty-live-result.js';
3
+ import { isEmptyLiveUi } from './is-empty-live-ui.js';
4
+ import { mountLive } from './mount-live.js';
5
+ /** The rest of a vitest-browser-react screen, such as its locators, is inert. Its `then` too. */
6
+ function withInertRest(result) {
7
+ return new Proxy(result, {
8
+ get(target, key, receiver) {
9
+ if (key in target) {
10
+ return Reflect.get(target, key, receiver);
11
+ }
12
+ return Reflect.get(liveInert(), key);
13
+ },
14
+ });
15
+ }
16
+ /**
17
+ * Stands in for `render` from vitest-browser-react on the live page. It
18
+ * mounts the component and rejects with the stop signal, so the test ends at
19
+ * its first render. For an empty UI it waits for the test's `rerender`.
20
+ */
21
+ export function liveRender(ui, options = {}) {
22
+ if (isEmptyLiveUi(ui)) {
23
+ return Promise.resolve(withInertRest(emptyLiveResult(options, liveRender)));
24
+ }
25
+ mountLive(ui, options);
26
+ return Promise.reject(liveStop());
27
+ }
@@ -0,0 +1,4 @@
1
+ export * from '@testing-library/react';
2
+ export { liveCleanup as cleanup } from './live-cleanup.js';
3
+ export { liveRenderTestingLibrary as render } from './live-render-testing-library.js';
4
+ export { liveRenderHookTestingLibrary as renderHook } from './live-render-hook-testing-library.js';
@@ -0,0 +1,6 @@
1
+ // Stands in for @testing-library/react on the live preview page, with `render`,
2
+ // `renderHook` and `cleanup` swapped. Not for import in tests.
3
+ export * from '@testing-library/react';
4
+ export { liveCleanup as cleanup } from './live-cleanup.js';
5
+ export { liveRenderTestingLibrary as render } from './live-render-testing-library.js';
6
+ export { liveRenderHookTestingLibrary as renderHook } from './live-render-hook-testing-library.js';
package/dist/live.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { liveCleanup as cleanup } from './live-cleanup.js';
2
+ export { liveRender as render } from './live-render.js';
3
+ export { liveRenderHook as renderHook } from './live-render-hook.js';
package/dist/live.js ADDED
@@ -0,0 +1,4 @@
1
+ // Stands in for vitest-browser-react on the live preview page. Not for import in tests.
2
+ export { liveCleanup as cleanup } from './live-cleanup.js';
3
+ export { liveRender as render } from './live-render.js';
4
+ export { liveRenderHook as renderHook } from './live-render-hook.js';
@@ -0,0 +1,17 @@
1
+ import { type ComponentType, type ReactNode } from 'react';
2
+ import { type RootOptions } from 'react-dom/client';
3
+ /** The render options, of either flavour, that the live mount reads. */
4
+ export interface LiveMountOptions {
5
+ container?: Element | DocumentFragment;
6
+ baseElement?: Element;
7
+ wrapper?: ComponentType<{
8
+ children?: ReactNode;
9
+ }>;
10
+ createRootOptions?: RootOptions;
11
+ }
12
+ /**
13
+ * Mounts what a test rendered, in its wrapper, with `createRoot` as Testing
14
+ * Library does, and reports the page as mounted. A page that has a status
15
+ * already mounts nothing. Either way the caller then stops the test.
16
+ */
17
+ export declare function mountLive(ui: ReactNode, options?: LiveMountOptions): void;
@@ -0,0 +1,24 @@
1
+ import { createElement } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { liveStatus, reportLiveStatus } from '@describe-me/core/live';
4
+ import { nameLiveMocks } from './name-live-mocks.js';
5
+ /**
6
+ * Mounts what a test rendered, in its wrapper, with `createRoot` as Testing
7
+ * Library does, and reports the page as mounted. A page that has a status
8
+ * already mounts nothing. Either way the caller then stops the test.
9
+ */
10
+ export function mountLive(ui, options = {}) {
11
+ if (liveStatus() !== null) {
12
+ return;
13
+ }
14
+ nameLiveMocks(ui);
15
+ // Testing Library's own `beforeAll` turns it on, but the live component
16
+ // runs outside `act()`, where React would warn about every update.
17
+ const scope = globalThis;
18
+ scope.IS_REACT_ACT_ENVIRONMENT = false;
19
+ const baseElement = options.baseElement ?? document.body;
20
+ const container = options.container ?? baseElement.appendChild(document.createElement('div'));
21
+ const element = options.wrapper === undefined ? ui : createElement(options.wrapper, null, ui);
22
+ createRoot(container, options.createRootOptions).render(element);
23
+ reportLiveStatus('mounted');
24
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Names every unnamed mock in what a test rendered after the prop it is
3
+ * passed as, so a click shows as `onSelect("Rename")`. Walks element props,
4
+ * children and arrays, a few levels deep.
5
+ */
6
+ export declare function nameLiveMocks(node: unknown, depth?: number): void;
@@ -0,0 +1,42 @@
1
+ import { isValidElement } from 'react';
2
+ const MAX_DEPTH = 5;
3
+ /** The name Vitest gives a mock that was never named. */
4
+ const UNNAMED = 'vi.fn()';
5
+ /** Duck typed, so it needs nothing from Vitest. */
6
+ function isUnnamedMock(value) {
7
+ if (typeof value !== 'function') {
8
+ return false;
9
+ }
10
+ const mock = value;
11
+ return (mock._isMockFunction === true &&
12
+ typeof mock.getMockName === 'function' &&
13
+ typeof mock.mockName === 'function' &&
14
+ mock.getMockName() === UNNAMED);
15
+ }
16
+ /**
17
+ * Names every unnamed mock in what a test rendered after the prop it is
18
+ * passed as, so a click shows as `onSelect("Rename")`. Walks element props,
19
+ * children and arrays, a few levels deep.
20
+ */
21
+ export function nameLiveMocks(node, depth = 0) {
22
+ if (depth > MAX_DEPTH) {
23
+ return;
24
+ }
25
+ if (Array.isArray(node)) {
26
+ for (const item of node) {
27
+ nameLiveMocks(item, depth + 1);
28
+ }
29
+ return;
30
+ }
31
+ if (!isValidElement(node)) {
32
+ return;
33
+ }
34
+ for (const [prop, value] of Object.entries(node.props)) {
35
+ if (isUnnamedMock(value)) {
36
+ value.mockName(prop);
37
+ }
38
+ else {
39
+ nameLiveMocks(value, depth + 1);
40
+ }
41
+ }
42
+ }
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@describe-me/react",
3
- "version": "0.5.3",
4
- "description": "React adapter for describe-me: drop-in render functions that record frames",
3
+ "version": "0.6.0",
4
+ "description": "Living component documentation from your Vitest tests: the React adapter of describe-me",
5
5
  "keywords": [
6
6
  "vitest",
7
+ "react",
7
8
  "storybook",
9
+ "storybook alternative",
8
10
  "component documentation",
9
- "visual testing",
10
- "react",
11
+ "living documentation",
12
+ "jsdom",
11
13
  "testing"
12
14
  ],
13
15
  "license": "MIT",
@@ -35,6 +37,14 @@
35
37
  "./testing-library": {
36
38
  "types": "./dist/testing-library.d.ts",
37
39
  "default": "./dist/testing-library.js"
40
+ },
41
+ "./live": {
42
+ "types": "./dist/live.d.ts",
43
+ "default": "./dist/live.js"
44
+ },
45
+ "./live-testing-library": {
46
+ "types": "./dist/live-testing-library.d.ts",
47
+ "default": "./dist/live-testing-library.js"
38
48
  }
39
49
  },
40
50
  "files": [
@@ -46,13 +56,14 @@
46
56
  "./dist/render-testing-library.js"
47
57
  ],
48
58
  "dependencies": {
49
- "@describe-me/core": "^0.5.3"
59
+ "@describe-me/core": "^0.6.0"
50
60
  },
51
61
  "peerDependencies": {
52
62
  "@testing-library/react": "^16.0.0",
53
63
  "react": "^18.0.0 || ^19.0.0",
64
+ "react-dom": "^18.0.0 || ^19.0.0",
54
65
  "vitest-browser-react": "^2.0.0",
55
- "@describe-me/vitest": "^0.5.3"
66
+ "@describe-me/vitest": "^0.6.0"
56
67
  },
57
68
  "peerDependenciesMeta": {
58
69
  "@testing-library/react": {
@@ -71,7 +82,7 @@
71
82
  "react-dom": "^19.3.0",
72
83
  "vitest": "^5.0.1",
73
84
  "vitest-browser-react": "^2.3.0",
74
- "@describe-me/vitest": "^0.5.3"
85
+ "@describe-me/vitest": "^0.6.0"
75
86
  },
76
87
  "scripts": {
77
88
  "build": "rimraf dist && tsc -p tsconfig.json"