@describe-me/react 0.5.0-next.2 → 0.5.0-next.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/describe-rendered.d.ts +9 -0
- package/dist/describe-rendered.js +50 -0
- package/dist/first-registered-fiber.d.ts +7 -0
- package/dist/first-registered-fiber.js +55 -0
- package/dist/registered-element.d.ts +7 -0
- package/dist/registered-element.js +48 -0
- package/dist/render-testing-library.js +6 -3
- package/dist/render.js +15 -5
- package/dist/root-fiber.d.ts +17 -0
- package/dist/root-fiber.js +16 -0
- package/dist/subject-fibers.d.ts +8 -0
- package/dist/subject-fibers.js +51 -0
- package/package.json +2 -2
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
import { type ComponentInfo } from '@describe-me/core';
|
|
3
|
+
/**
|
|
4
|
+
* Describe what a test rendered: `ui` when the plugin registered its
|
|
5
|
+
* component, otherwise the first registered component inside it, with the
|
|
6
|
+
* props that component received. Falls back to `ui` when nothing is found or
|
|
7
|
+
* React's internals look different. Synchronous and bounded.
|
|
8
|
+
*/
|
|
9
|
+
export declare function describeRendered(ui: ReactElement, container: Element | DocumentFragment, hasWrapper: boolean): ComponentInfo;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describeComponentType, EXPORT_REGISTRY_KEY, serializeValue, } from '@describe-me/core';
|
|
2
|
+
import { describeElement } from './describe-element.js';
|
|
3
|
+
import { firstRegisteredFiber } from './first-registered-fiber.js';
|
|
4
|
+
import { registeredElement } from './registered-element.js';
|
|
5
|
+
import { rootFiber } from './root-fiber.js';
|
|
6
|
+
import { subjectFibers } from './subject-fibers.js';
|
|
7
|
+
/**
|
|
8
|
+
* Describe what a test rendered: `ui` when the plugin registered its
|
|
9
|
+
* component, otherwise the first registered component inside it, with the
|
|
10
|
+
* props that component received. Falls back to `ui` when nothing is found or
|
|
11
|
+
* React's internals look different. Synchronous and bounded.
|
|
12
|
+
*/
|
|
13
|
+
export function describeRendered(ui, container, hasWrapper) {
|
|
14
|
+
try {
|
|
15
|
+
return lookUp(ui, container, hasWrapper) ?? describeElement(ui);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return describeElement(ui);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** The component to name the test after, or null to keep `ui`. */
|
|
22
|
+
function lookUp(ui, container, hasWrapper) {
|
|
23
|
+
// Without a registry, as with `registerExports: false`, nothing can be found.
|
|
24
|
+
if (describeComponentType(ui.type).file !== undefined || !hasRegistry()) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const element = registeredElement(ui);
|
|
28
|
+
if (element) {
|
|
29
|
+
return describeElement(element);
|
|
30
|
+
}
|
|
31
|
+
const root = rootFiber(container);
|
|
32
|
+
if (root === null) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const subjects = subjectFibers(root, ui);
|
|
36
|
+
// With a wrapper, the root also holds the wrapper's own components.
|
|
37
|
+
const starts = subjects.length === 0 && !hasWrapper ? [root] : subjects;
|
|
38
|
+
const fiber = firstRegisteredFiber(starts);
|
|
39
|
+
return fiber === null ? null : describeFiber(fiber);
|
|
40
|
+
}
|
|
41
|
+
function hasRegistry() {
|
|
42
|
+
const scope = globalThis;
|
|
43
|
+
return scope[Symbol.for(EXPORT_REGISTRY_KEY)] !== undefined;
|
|
44
|
+
}
|
|
45
|
+
function describeFiber(fiber) {
|
|
46
|
+
const byElementType = describeComponentType(fiber.elementType);
|
|
47
|
+
const identity = byElementType.file === undefined ? describeComponentType(fiber.type) : byElementType;
|
|
48
|
+
const props = serializeValue(fiber.memoizedProps ?? {});
|
|
49
|
+
return { ...identity, props };
|
|
50
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FiberLike } from './root-fiber.js';
|
|
2
|
+
/**
|
|
3
|
+
* The first fiber whose component the plugin registered, depth first through
|
|
4
|
+
* each start fiber and its descendants, or null. The siblings of a start
|
|
5
|
+
* fiber and the subtrees of `Suspense` fallbacks are not searched.
|
|
6
|
+
*/
|
|
7
|
+
export declare function firstRegisteredFiber(starts: FiberLike[]): FiberLike | null;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { isValidElement } from 'react';
|
|
2
|
+
import { describeComponentType } from '@describe-me/core';
|
|
3
|
+
const MAX_VISITED = 5000;
|
|
4
|
+
/**
|
|
5
|
+
* The first fiber whose component the plugin registered, depth first through
|
|
6
|
+
* each start fiber and its descendants, or null. The siblings of a start
|
|
7
|
+
* fiber and the subtrees of `Suspense` fallbacks are not searched.
|
|
8
|
+
*/
|
|
9
|
+
export function firstRegisteredFiber(starts) {
|
|
10
|
+
// React mounts a fallback inside a Fragment fiber whose `memoizedProps` is the fallback itself.
|
|
11
|
+
const fallbacks = new Set();
|
|
12
|
+
let visited = 0;
|
|
13
|
+
for (const start of starts) {
|
|
14
|
+
const pending = [start];
|
|
15
|
+
while (pending.length > 0) {
|
|
16
|
+
if (visited >= MAX_VISITED) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
visited++;
|
|
20
|
+
const fiber = pending.pop();
|
|
21
|
+
if (fiber !== start && fiber.sibling) {
|
|
22
|
+
pending.push(fiber.sibling);
|
|
23
|
+
}
|
|
24
|
+
if (fallbacks.has(fiber.memoizedProps)) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (isRegistered(fiber)) {
|
|
28
|
+
return fiber;
|
|
29
|
+
}
|
|
30
|
+
rememberFallback(fiber.memoizedProps, fallbacks);
|
|
31
|
+
if (fiber.child) {
|
|
32
|
+
pending.push(fiber.child);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
function isRegistered(fiber) {
|
|
39
|
+
return (describeComponentType(fiber.elementType).file !== undefined ||
|
|
40
|
+
describeComponentType(fiber.type).file !== undefined);
|
|
41
|
+
}
|
|
42
|
+
/** Text fibers hold a string and the HostRoot holds null, so the shape is checked first. */
|
|
43
|
+
function rememberFallback(props, fallbacks) {
|
|
44
|
+
if (typeof props !== 'object' || props === null) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const { fallback } = props;
|
|
48
|
+
if (typeof fallback !== 'object' || fallback === null) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
fallbacks.add(fallback);
|
|
52
|
+
if (isValidElement(fallback)) {
|
|
53
|
+
fallbacks.add(fallback.props);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ReactElement } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* The first element inside `ui` whose component the plugin registered, or
|
|
4
|
+
* null. Depth first through `children`, then the other element props. A
|
|
5
|
+
* `fallback` is skipped, because `Suspense` shows it only while waiting.
|
|
6
|
+
*/
|
|
7
|
+
export declare function registeredElement(ui: ReactElement): ReactElement | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isValidElement } from 'react';
|
|
2
|
+
import { describeComponentType } from '@describe-me/core';
|
|
3
|
+
const MAX_DEPTH = 6;
|
|
4
|
+
const MAX_ELEMENTS = 200;
|
|
5
|
+
/**
|
|
6
|
+
* The first element inside `ui` whose component the plugin registered, or
|
|
7
|
+
* null. Depth first through `children`, then the other element props. A
|
|
8
|
+
* `fallback` is skipped, because `Suspense` shows it only while waiting.
|
|
9
|
+
*/
|
|
10
|
+
export function registeredElement(ui) {
|
|
11
|
+
return searchInside(ui, 1, { visited: 0 });
|
|
12
|
+
}
|
|
13
|
+
function searchInside(element, depth, budget) {
|
|
14
|
+
for (const held of heldElements(element)) {
|
|
15
|
+
if (budget.visited >= MAX_ELEMENTS) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
budget.visited++;
|
|
19
|
+
if (describeComponentType(held.type).file !== undefined) {
|
|
20
|
+
return held;
|
|
21
|
+
}
|
|
22
|
+
const found = depth < MAX_DEPTH ? searchInside(held, depth + 1, budget) : null;
|
|
23
|
+
if (found) {
|
|
24
|
+
return found;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
/** The elements in `children` first, then those in every other prop but `fallback`. */
|
|
30
|
+
function heldElements(element) {
|
|
31
|
+
const props = (element.props ?? {});
|
|
32
|
+
const held = elementsIn(props.children);
|
|
33
|
+
for (const [key, value] of Object.entries(props)) {
|
|
34
|
+
if (key !== 'children' && key !== 'fallback') {
|
|
35
|
+
held.push(...elementsIn(value));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return held;
|
|
39
|
+
}
|
|
40
|
+
function elementsIn(value) {
|
|
41
|
+
if (isValidElement(value)) {
|
|
42
|
+
return [value];
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(value)) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
return value.flat(Infinity).filter((item) => isValidElement(item));
|
|
48
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { cleanup as unmountAll, render as baseRender } from '@testing-library/react';
|
|
2
2
|
import { isValidElement } from 'react';
|
|
3
3
|
import { recorder } from '@describe-me/core';
|
|
4
|
-
import {
|
|
4
|
+
import { describeRendered } from './describe-rendered.js';
|
|
5
5
|
import { labelFor } from './label-for.js';
|
|
6
6
|
// On import rather than in `render()`, so a file that only calls `renderHook`
|
|
7
7
|
// is unmounted after each test too.
|
|
@@ -26,8 +26,10 @@ recorder.onTeardown(unmountAll);
|
|
|
26
26
|
export function render(ui, options) {
|
|
27
27
|
recorder.beforeInteraction();
|
|
28
28
|
const result = baseRender(ui, options);
|
|
29
|
+
// Testing Library keeps the wrapper for `rerender`.
|
|
30
|
+
const hasWrapper = Boolean(options?.wrapper);
|
|
29
31
|
if (recorder.isActive && isValidElement(ui)) {
|
|
30
|
-
const info =
|
|
32
|
+
const info = describeRendered(ui, result.container, hasWrapper);
|
|
31
33
|
recorder.setComponent(info);
|
|
32
34
|
void recorder.capture('render', labelFor(info), { props: info.props }, { settle: false });
|
|
33
35
|
}
|
|
@@ -36,7 +38,8 @@ export function render(ui, options) {
|
|
|
36
38
|
recorder.beforeInteraction();
|
|
37
39
|
originalRerender(next);
|
|
38
40
|
if (recorder.isActive && isValidElement(next)) {
|
|
39
|
-
const info =
|
|
41
|
+
const info = describeRendered(next, result.container, hasWrapper);
|
|
42
|
+
recorder.setComponent(info);
|
|
40
43
|
void recorder.capture('render', `rerender ${labelFor(info)}`, { props: info.props }, { settle: false });
|
|
41
44
|
}
|
|
42
45
|
};
|
package/dist/render.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { render as baseRender } from 'vitest-browser-react';
|
|
2
2
|
import { isValidElement } from 'react';
|
|
3
3
|
import { recorder } from '@describe-me/core';
|
|
4
|
-
import {
|
|
4
|
+
import { describeRendered } from './describe-rendered.js';
|
|
5
5
|
import { labelFor } from './label-for.js';
|
|
6
6
|
/**
|
|
7
7
|
* Drop-in for `render` from vitest-browser-react. Records a frame after mount
|
|
@@ -12,8 +12,10 @@ export async function render(ui, options) {
|
|
|
12
12
|
const generation = recorder.generation;
|
|
13
13
|
recorder.beforeInteraction();
|
|
14
14
|
const screen = await baseRender(ui, options);
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// vitest-browser-react keeps the wrapper for `rerender`.
|
|
16
|
+
const hasWrapper = Boolean(options?.wrapper);
|
|
17
|
+
if (isRecording(generation) && isValidElement(ui)) {
|
|
18
|
+
const info = describeRendered(ui, screen.container, hasWrapper);
|
|
17
19
|
recorder.setComponent(info);
|
|
18
20
|
await recorder.capture('render', labelFor(info), { props: info.props }, { generation });
|
|
19
21
|
}
|
|
@@ -22,8 +24,9 @@ export async function render(ui, options) {
|
|
|
22
24
|
const rerenderGeneration = recorder.generation;
|
|
23
25
|
recorder.beforeInteraction();
|
|
24
26
|
const result = await originalRerender(next);
|
|
25
|
-
if (isValidElement(next)) {
|
|
26
|
-
const info =
|
|
27
|
+
if (isRecording(rerenderGeneration) && isValidElement(next)) {
|
|
28
|
+
const info = describeRendered(next, screen.container, hasWrapper);
|
|
29
|
+
recorder.setComponent(info);
|
|
27
30
|
await recorder.capture('render', `rerender ${labelFor(info)}`, { props: info.props }, { generation: rerenderGeneration });
|
|
28
31
|
}
|
|
29
32
|
return result;
|
|
@@ -36,3 +39,10 @@ export async function render(ui, options) {
|
|
|
36
39
|
};
|
|
37
40
|
return screen;
|
|
38
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Whether the test that began at `generation` is still being recorded. A
|
|
44
|
+
* render the test did not await can finish after it ended.
|
|
45
|
+
*/
|
|
46
|
+
function isRecording(generation) {
|
|
47
|
+
return recorder.isActive && recorder.generation === generation;
|
|
48
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fields of a React fiber the naming lookup reads. Structural, because
|
|
3
|
+
* React does not export its fiber type.
|
|
4
|
+
*/
|
|
5
|
+
export interface FiberLike {
|
|
6
|
+
elementType?: unknown;
|
|
7
|
+
type?: unknown;
|
|
8
|
+
memoizedProps?: unknown;
|
|
9
|
+
child?: FiberLike | null;
|
|
10
|
+
sibling?: FiberLike | null;
|
|
11
|
+
stateNode?: unknown;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The current HostRoot fiber of the React root mounted into `container`, or
|
|
15
|
+
* null when there is none.
|
|
16
|
+
*/
|
|
17
|
+
export declare function rootFiber(container: Element | DocumentFragment): FiberLike | null;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// React appends a random suffix, one per copy of React on the page.
|
|
2
|
+
const CONTAINER_KEY_PREFIX = '__reactContainer$';
|
|
3
|
+
/**
|
|
4
|
+
* The current HostRoot fiber of the React root mounted into `container`, or
|
|
5
|
+
* null when there is none.
|
|
6
|
+
*/
|
|
7
|
+
export function rootFiber(container) {
|
|
8
|
+
const key = Object.keys(container).find((name) => name.startsWith(CONTAINER_KEY_PREFIX));
|
|
9
|
+
if (key === undefined) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const stored = container[key];
|
|
13
|
+
// The fiber on the container can be the stale alternate. Its FiberRoot knows the current one.
|
|
14
|
+
const fiberRoot = stored?.stateNode;
|
|
15
|
+
return fiberRoot?.current ?? null;
|
|
16
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ReactElement } from 'react';
|
|
2
|
+
import type { FiberLike } from './root-fiber.js';
|
|
3
|
+
/**
|
|
4
|
+
* The fibers React mounted for `ui` itself, where the naming lookup starts,
|
|
5
|
+
* so a wrapper's own components are never searched. Empty when none can be
|
|
6
|
+
* told apart.
|
|
7
|
+
*/
|
|
8
|
+
export declare function subjectFibers(root: FiberLike, ui: ReactElement): FiberLike[];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Fragment, isValidElement } from 'react';
|
|
2
|
+
const MAX_VISITED = 5000;
|
|
3
|
+
/**
|
|
4
|
+
* The fibers React mounted for `ui` itself, where the naming lookup starts,
|
|
5
|
+
* so a wrapper's own components are never searched. Empty when none can be
|
|
6
|
+
* told apart.
|
|
7
|
+
*/
|
|
8
|
+
export function subjectFibers(root, ui) {
|
|
9
|
+
const subjectProps = propsOfSubject(ui);
|
|
10
|
+
const byProps = fibersMatching(root, (fiber) => subjectProps.has(fiber.memoizedProps));
|
|
11
|
+
// Every `div` or fragment would share a host or symbol type, so only component types count.
|
|
12
|
+
if (byProps.length > 0 || !isComponentType(ui.type)) {
|
|
13
|
+
return byProps;
|
|
14
|
+
}
|
|
15
|
+
return fibersMatching(root, (fiber) => fiber.elementType === ui.type);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The props objects the fibers of `ui` hold. A fragment's own fiber holds its
|
|
19
|
+
* children or does not exist, so its direct element children stand in for it.
|
|
20
|
+
*/
|
|
21
|
+
function propsOfSubject(ui) {
|
|
22
|
+
if (ui.type !== Fragment) {
|
|
23
|
+
return new Set([ui.props]);
|
|
24
|
+
}
|
|
25
|
+
const { children } = ui.props;
|
|
26
|
+
const items = Array.isArray(children) ? children.flat(Infinity) : [children];
|
|
27
|
+
return new Set(items.filter((item) => isValidElement(item)).map((element) => element.props));
|
|
28
|
+
}
|
|
29
|
+
/** Depth first from `root`, without descending into a fiber that matches. */
|
|
30
|
+
function fibersMatching(root, matches) {
|
|
31
|
+
const found = [];
|
|
32
|
+
const pending = [root];
|
|
33
|
+
for (let visited = 0; visited < MAX_VISITED && pending.length > 0; visited++) {
|
|
34
|
+
const fiber = pending.pop();
|
|
35
|
+
if (fiber.sibling) {
|
|
36
|
+
pending.push(fiber.sibling);
|
|
37
|
+
}
|
|
38
|
+
if (matches(fiber)) {
|
|
39
|
+
found.push(fiber);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (fiber.child) {
|
|
43
|
+
pending.push(fiber.child);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return found;
|
|
47
|
+
}
|
|
48
|
+
/** Components, `memo`, `forwardRef`, `lazy` and contexts. */
|
|
49
|
+
function isComponentType(type) {
|
|
50
|
+
return typeof type === 'function' || (typeof type === 'object' && type !== null);
|
|
51
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@describe-me/react",
|
|
3
|
-
"version": "0.5.0-next.
|
|
3
|
+
"version": "0.5.0-next.3",
|
|
4
4
|
"description": "React adapter for describe-me: drop-in render functions that record frames",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vitest",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"./dist/render-testing-library.js"
|
|
47
47
|
],
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@describe-me/core": "^0.5.0-next.
|
|
49
|
+
"@describe-me/core": "^0.5.0-next.3"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"@testing-library/react": "^16.0.0",
|