@describe-me/core 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.
- package/dist/describe-component-type.d.ts +14 -0
- package/dist/describe-component-type.js +72 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/manifest-diagnostics.d.ts +7 -0
- package/dist/manifest-diagnostics.js +34 -0
- package/dist/recorder.d.ts +15 -4
- package/dist/recorder.js +29 -5
- package/dist/serialize-value.js +2 -6
- package/dist/types.d.ts +66 -0
- package/dist/types.js +16 -0
- package/package.json +5 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ComponentInfo } from './types.js';
|
|
2
|
+
/** How a component type is shown and where it was defined, when that is known. */
|
|
3
|
+
type ComponentIdentity = Pick<ComponentInfo, 'name' | 'file'>;
|
|
4
|
+
/**
|
|
5
|
+
* Name a component type (an element's `type`) the way its author would.
|
|
6
|
+
*
|
|
7
|
+
* The export it was registered under wins, because an anonymous `forwardRef`
|
|
8
|
+
* or a styled component has no name of its own. Then an explicit
|
|
9
|
+
* `displayName`, then the function's name, then the same again for the
|
|
10
|
+
* component a `memo` or `forwardRef` wraps. Shapes are detected structurally,
|
|
11
|
+
* so no framework is imported. `Anonymous` when nothing names it.
|
|
12
|
+
*/
|
|
13
|
+
export declare function describeComponentType(type: unknown): ComponentIdentity;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { ANONYMOUS_COMPONENT, EXPORT_REGISTRY_KEY, } from './types.js';
|
|
2
|
+
/** `memo(forwardRef(memo(…)))` is as deep as real code goes; the bound only guards against cycles. */
|
|
3
|
+
const MAX_UNWRAP_DEPTH = 5;
|
|
4
|
+
/**
|
|
5
|
+
* Display names styled-components gives when none was set: `styled.p`,
|
|
6
|
+
* `Styled(Button)`. They say which element is styled, not which component it is.
|
|
7
|
+
*/
|
|
8
|
+
const STYLED_DEFAULT_NAMES = [/^styled\./, /^Styled\(/];
|
|
9
|
+
/**
|
|
10
|
+
* Name a component type (an element's `type`) the way its author would.
|
|
11
|
+
*
|
|
12
|
+
* The export it was registered under wins, because an anonymous `forwardRef`
|
|
13
|
+
* or a styled component has no name of its own. Then an explicit
|
|
14
|
+
* `displayName`, then the function's name, then the same again for the
|
|
15
|
+
* component a `memo` or `forwardRef` wraps. Shapes are detected structurally,
|
|
16
|
+
* so no framework is imported. `Anonymous` when nothing names it.
|
|
17
|
+
*/
|
|
18
|
+
export function describeComponentType(type) {
|
|
19
|
+
if (typeof type === 'string') {
|
|
20
|
+
return { name: type };
|
|
21
|
+
}
|
|
22
|
+
return identify(type, 0) ?? { name: ANONYMOUS_COMPONENT };
|
|
23
|
+
}
|
|
24
|
+
function identify(type, depth) {
|
|
25
|
+
if (!isComponentLike(type)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const registered = registeredExport(type);
|
|
29
|
+
if (registered) {
|
|
30
|
+
return { name: registered.name, file: registered.file };
|
|
31
|
+
}
|
|
32
|
+
const { displayName, name } = type;
|
|
33
|
+
if (isNonEmptyString(displayName) && !isStyledDefault(displayName)) {
|
|
34
|
+
return { name: displayName };
|
|
35
|
+
}
|
|
36
|
+
if (typeof type === 'function' && isNonEmptyString(name)) {
|
|
37
|
+
return { name };
|
|
38
|
+
}
|
|
39
|
+
// An unregistered styled component: its render function is named after the
|
|
40
|
+
// library's internals (`forwardRefRender`), so `styled.p` is the best name left.
|
|
41
|
+
if (isNonEmptyString(displayName)) {
|
|
42
|
+
return { name: displayName };
|
|
43
|
+
}
|
|
44
|
+
if (depth >= MAX_UNWRAP_DEPTH) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return identify(wrappedType(type), depth + 1);
|
|
48
|
+
}
|
|
49
|
+
/** The component inside `memo(…)` (`.type`) or `forwardRef(…)` (`.render`). */
|
|
50
|
+
function wrappedType(type) {
|
|
51
|
+
const { type: memoized, render } = type;
|
|
52
|
+
return memoized ?? render;
|
|
53
|
+
}
|
|
54
|
+
/** What the plugin's appended code registered for this value, if anything. */
|
|
55
|
+
function registeredExport(type) {
|
|
56
|
+
const registry = globalThis[Symbol.for(EXPORT_REGISTRY_KEY)];
|
|
57
|
+
// Duck-typed rather than `instanceof`: the map may come from another realm.
|
|
58
|
+
if (typeof registry?.get !== 'function') {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
return registry.get(type);
|
|
62
|
+
}
|
|
63
|
+
/** Functions and non-null objects: everything a registry can hold and a component can be. */
|
|
64
|
+
function isComponentLike(value) {
|
|
65
|
+
return typeof value === 'function' || (typeof value === 'object' && value !== null);
|
|
66
|
+
}
|
|
67
|
+
function isNonEmptyString(value) {
|
|
68
|
+
return typeof value === 'string' && value !== '';
|
|
69
|
+
}
|
|
70
|
+
function isStyledDefault(displayName) {
|
|
71
|
+
return STYLED_DEFAULT_NAMES.some((pattern) => pattern.test(displayName));
|
|
72
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export * from './types.js';
|
|
2
|
+
export { describeComponentType } from './describe-component-type.js';
|
|
3
|
+
export { manifestDiagnostics } from './manifest-diagnostics.js';
|
|
2
4
|
export { recorder } from './recorder.js';
|
|
3
5
|
export { serializeValue } from './serialize-value.js';
|
|
4
6
|
export { step } from './step.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export * from './types.js';
|
|
2
|
+
export { describeComponentType } from './describe-component-type.js';
|
|
3
|
+
export { manifestDiagnostics } from './manifest-diagnostics.js';
|
|
2
4
|
export { recorder } from './recorder.js';
|
|
3
5
|
export { serializeValue } from './serialize-value.js';
|
|
4
6
|
export { step } from './step.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type Manifest, type ManifestDiagnostics } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* List what the manifest could not document: anonymous components, named ones
|
|
4
|
+
* without props, and assets that will not load. The reporter prints it after a
|
|
5
|
+
* run and the viewer shows it in the header, so both agree on what is missing.
|
|
6
|
+
*/
|
|
7
|
+
export declare function manifestDiagnostics(manifest: Manifest): ManifestDiagnostics;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ANONYMOUS_COMPONENT } from './types.js';
|
|
2
|
+
/** Host elements such as `div` are rendered as-is and have no props doc to miss. */
|
|
3
|
+
function isHostElement(name) {
|
|
4
|
+
return /^[a-z]/.test(name);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* List what the manifest could not document: anonymous components, named ones
|
|
8
|
+
* without props, and assets that will not load. The reporter prints it after a
|
|
9
|
+
* run and the viewer shows it in the header, so both agree on what is missing.
|
|
10
|
+
*/
|
|
11
|
+
export function manifestDiagnostics(manifest) {
|
|
12
|
+
const anonymous = [];
|
|
13
|
+
const undocumented = new Map();
|
|
14
|
+
for (const mod of manifest.modules) {
|
|
15
|
+
for (const test of mod.tests) {
|
|
16
|
+
const name = test.component?.name;
|
|
17
|
+
if (!name || isHostElement(name)) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (name === ANONYMOUS_COMPONENT) {
|
|
21
|
+
anonymous.push({ testId: test.id, fullName: test.fullName });
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (!manifest.components[name]) {
|
|
25
|
+
undocumented.set(name, (undocumented.get(name) ?? 0) + 1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
anonymous,
|
|
31
|
+
undocumented: Array.from(undocumented, ([name, tests]) => ({ name, tests })).sort((left, right) => left.name.localeCompare(right.name)),
|
|
32
|
+
assetsMissing: manifest.assetsMissing ?? [],
|
|
33
|
+
};
|
|
34
|
+
}
|
package/dist/recorder.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import type { ComponentInfo, FrameKind, TestRecord } from './types.js';
|
|
1
|
+
import type { CaptureOptions, ComponentInfo, FrameKind, TestRecord } from './types.js';
|
|
2
|
+
type Teardown = () => void;
|
|
2
3
|
/**
|
|
3
|
-
*
|
|
4
|
-
* `
|
|
4
|
+
* Test-side recorder, running wherever the tests run: the browser-mode iframe
|
|
5
|
+
* or jsdom. `begin()` in beforeEach, `end()` then `teardown()` in afterEach.
|
|
6
|
+
* Framework adapters call `capture()`, `setComponent()` and `onTeardown()`.
|
|
5
7
|
*/
|
|
6
8
|
declare class Recorder {
|
|
7
9
|
private frames;
|
|
@@ -10,11 +12,20 @@ declare class Recorder {
|
|
|
10
12
|
private active;
|
|
11
13
|
private lastHash;
|
|
12
14
|
private seq;
|
|
15
|
+
private teardowns;
|
|
13
16
|
begin(): void;
|
|
14
17
|
get isActive(): boolean;
|
|
15
18
|
setComponent(info: ComponentInfo): void;
|
|
16
|
-
capture(kind: FrameKind, label: string, meta?: Record<string, unknown
|
|
19
|
+
capture(kind: FrameKind, label: string, meta?: Record<string, unknown>, options?: CaptureOptions): Promise<void>;
|
|
17
20
|
end(): TestRecord;
|
|
21
|
+
/**
|
|
22
|
+
* Register work that must run after the test's frames are handed over, such
|
|
23
|
+
* as unmounting. Adapters call this once at import; registering the same
|
|
24
|
+
* callback again is a no-op.
|
|
25
|
+
*/
|
|
26
|
+
onTeardown(callback: Teardown): void;
|
|
27
|
+
/** Run the registered teardowns. Called by the runner integration after `end()`. */
|
|
28
|
+
teardown(): void;
|
|
18
29
|
}
|
|
19
30
|
export declare const recorder: Recorder;
|
|
20
31
|
export {};
|
package/dist/recorder.js
CHANGED
|
@@ -3,8 +3,9 @@ import { materializeAdoptedStyles } from './materialize-adopted-styles.js';
|
|
|
3
3
|
import { quickHash } from './quick-hash.js';
|
|
4
4
|
import { settle } from './settle.js';
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
6
|
+
* Test-side recorder, running wherever the tests run: the browser-mode iframe
|
|
7
|
+
* or jsdom. `begin()` in beforeEach, `end()` then `teardown()` in afterEach.
|
|
8
|
+
* Framework adapters call `capture()`, `setComponent()` and `onTeardown()`.
|
|
8
9
|
*/
|
|
9
10
|
class Recorder {
|
|
10
11
|
frames = [];
|
|
@@ -13,6 +14,7 @@ class Recorder {
|
|
|
13
14
|
active = false;
|
|
14
15
|
lastHash = '';
|
|
15
16
|
seq = 0;
|
|
17
|
+
teardowns = new Set();
|
|
16
18
|
begin() {
|
|
17
19
|
this.frames = [];
|
|
18
20
|
this.component = undefined;
|
|
@@ -29,11 +31,13 @@ class Recorder {
|
|
|
29
31
|
this.component = info;
|
|
30
32
|
}
|
|
31
33
|
}
|
|
32
|
-
async capture(kind, label, meta) {
|
|
34
|
+
async capture(kind, label, meta, options = {}) {
|
|
33
35
|
if (!this.active) {
|
|
34
36
|
return;
|
|
35
37
|
}
|
|
36
|
-
|
|
38
|
+
if (options.settle !== false) {
|
|
39
|
+
await settle();
|
|
40
|
+
}
|
|
37
41
|
const node = await materializeAdoptedStyles(() => snapshot(document, { mirror: createMirror(), inlineStylesheet: true }));
|
|
38
42
|
if (!node) {
|
|
39
43
|
return;
|
|
@@ -63,7 +67,27 @@ class Recorder {
|
|
|
63
67
|
}
|
|
64
68
|
end() {
|
|
65
69
|
this.active = false;
|
|
66
|
-
|
|
70
|
+
// The reporter needs the origin rrweb resolved every URL against. Optional
|
|
71
|
+
// chaining, because a DOM environment is not guaranteed to define `location`.
|
|
72
|
+
return {
|
|
73
|
+
frames: this.frames,
|
|
74
|
+
component: this.component,
|
|
75
|
+
origin: globalThis.location?.origin,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Register work that must run after the test's frames are handed over, such
|
|
80
|
+
* as unmounting. Adapters call this once at import; registering the same
|
|
81
|
+
* callback again is a no-op.
|
|
82
|
+
*/
|
|
83
|
+
onTeardown(callback) {
|
|
84
|
+
this.teardowns.add(callback);
|
|
85
|
+
}
|
|
86
|
+
/** Run the registered teardowns. Called by the runner integration after `end()`. */
|
|
87
|
+
teardown() {
|
|
88
|
+
for (const callback of this.teardowns) {
|
|
89
|
+
callback();
|
|
90
|
+
}
|
|
67
91
|
}
|
|
68
92
|
}
|
|
69
93
|
const RECORDER_KEY = Symbol.for('describe-me.recorder');
|
package/dist/serialize-value.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { describeComponentType } from './describe-component-type.js';
|
|
1
2
|
/** Turn arbitrary props into something JSON-safe and readable. */
|
|
2
3
|
export function serializeValue(value, depth = 0) {
|
|
3
4
|
if (depth > 3) {
|
|
@@ -23,12 +24,7 @@ export function serializeValue(value, depth = 0) {
|
|
|
23
24
|
const record = value;
|
|
24
25
|
// React element
|
|
25
26
|
if ('$$typeof' in record && 'type' in record) {
|
|
26
|
-
const
|
|
27
|
-
const name = typeof type === 'string'
|
|
28
|
-
? type
|
|
29
|
-
: (type?.displayName ??
|
|
30
|
-
type?.name ??
|
|
31
|
-
'Anonymous');
|
|
27
|
+
const { name } = describeComponentType(record.type);
|
|
32
28
|
return `<${name} />`;
|
|
33
29
|
}
|
|
34
30
|
const out = {};
|
package/dist/types.d.ts
CHANGED
|
@@ -3,6 +3,43 @@ export type FrameKind = 'render' | 'action' | 'step' | 'end';
|
|
|
3
3
|
export interface ComponentInfo {
|
|
4
4
|
name: string;
|
|
5
5
|
props: Record<string, unknown>;
|
|
6
|
+
/**
|
|
7
|
+
* Source file that defines the component, relative to the project root, when
|
|
8
|
+
* the plugin registered its module's exports. Lets the reporter read props
|
|
9
|
+
* without guessing the file from the test's imports.
|
|
10
|
+
*/
|
|
11
|
+
file?: string;
|
|
12
|
+
}
|
|
13
|
+
/** What the plugin registers for every top-level export of a project module. */
|
|
14
|
+
export interface RegisteredExport {
|
|
15
|
+
/** The export name in the defining module, e.g. `Button`. */
|
|
16
|
+
name: string;
|
|
17
|
+
/** The defining module, relative to the project root, posix separators. */
|
|
18
|
+
file: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* `Symbol.for()` key of the registry on `globalThis`: a
|
|
22
|
+
* `WeakMap<object, RegisteredExport>` filled by code the plugin appends to
|
|
23
|
+
* project modules. A global rather than an import, because a user's module
|
|
24
|
+
* cannot always resolve `@describe-me/core` (pnpm) and a bundled adapter may
|
|
25
|
+
* carry its own copy of core.
|
|
26
|
+
*/
|
|
27
|
+
export declare const EXPORT_REGISTRY_KEY: "describe-me.exports";
|
|
28
|
+
/**
|
|
29
|
+
* Prefix of project asset URLs inside stored snapshots, e.g.
|
|
30
|
+
* `describe-me-asset:3f2a9c1d.svg`. The reporter copies the file to
|
|
31
|
+
* `<outDir>/assets/` and the viewer resolves the prefix against `__data/assets/`.
|
|
32
|
+
*/
|
|
33
|
+
export declare const ASSET_URL_PREFIX: "describe-me-asset:";
|
|
34
|
+
/** Options for `recorder.capture()`. */
|
|
35
|
+
export interface CaptureOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Wait one macrotask for the framework to flush before looking at the DOM.
|
|
38
|
+
* Default: true. Pass false when the caller knows the DOM is already
|
|
39
|
+
* committed (e.g. right after Testing Library's `act()`-wrapped `rerender`):
|
|
40
|
+
* the snapshot is then taken synchronously, before the caller moves on.
|
|
41
|
+
*/
|
|
42
|
+
settle?: boolean;
|
|
6
43
|
}
|
|
7
44
|
/** A frame as captured in the browser. `snapshot` is an rrweb serialized document. */
|
|
8
45
|
export interface Frame {
|
|
@@ -18,6 +55,12 @@ export interface Frame {
|
|
|
18
55
|
export interface TestRecord {
|
|
19
56
|
frames: Frame[];
|
|
20
57
|
component?: ComponentInfo;
|
|
58
|
+
/**
|
|
59
|
+
* `location.origin` of the page the test ran in (`http://localhost:3000` in
|
|
60
|
+
* jsdom, the Vitest server in browser mode). rrweb makes every URL absolute
|
|
61
|
+
* against it, so the reporter uses it to find project assets.
|
|
62
|
+
*/
|
|
63
|
+
origin?: string;
|
|
21
64
|
}
|
|
22
65
|
export declare const META_KEY: "describeMe";
|
|
23
66
|
export type TestState = 'passed' | 'failed' | 'skipped' | 'pending';
|
|
@@ -83,4 +126,27 @@ export interface Manifest {
|
|
|
83
126
|
modules: ManifestModule[];
|
|
84
127
|
/** Keyed by component name, as reported by the framework adapter. */
|
|
85
128
|
components: Record<string, ComponentDoc>;
|
|
129
|
+
/**
|
|
130
|
+
* Asset URLs the snapshots point at on the test page's origin that could not
|
|
131
|
+
* be found in the project, as root-relative paths (`/lib/images/logo.svg`).
|
|
132
|
+
* They will not load in the viewer. Absent in manifests written before 0.4.
|
|
133
|
+
*/
|
|
134
|
+
assetsMissing?: string[];
|
|
135
|
+
}
|
|
136
|
+
/** The name adapters fall back to when a component has no usable name. */
|
|
137
|
+
export declare const ANONYMOUS_COMPONENT: "Anonymous";
|
|
138
|
+
/** What keeps a manifest from documenting everything its tests rendered. */
|
|
139
|
+
export interface ManifestDiagnostics {
|
|
140
|
+
/** Tests whose rendered component has no usable name. */
|
|
141
|
+
anonymous: {
|
|
142
|
+
testId: string;
|
|
143
|
+
fullName: string;
|
|
144
|
+
}[];
|
|
145
|
+
/** Named components without a props doc, with how many tests rendered each. */
|
|
146
|
+
undocumented: {
|
|
147
|
+
name: string;
|
|
148
|
+
tests: number;
|
|
149
|
+
}[];
|
|
150
|
+
/** Root-relative asset paths that will not load in the viewer. */
|
|
151
|
+
assetsMissing: string[];
|
|
86
152
|
}
|
package/dist/types.js
CHANGED
|
@@ -1,2 +1,18 @@
|
|
|
1
1
|
/** Shared, JSON-serializable types. Safe to import from Node (no DOM). */
|
|
2
|
+
/**
|
|
3
|
+
* `Symbol.for()` key of the registry on `globalThis`: a
|
|
4
|
+
* `WeakMap<object, RegisteredExport>` filled by code the plugin appends to
|
|
5
|
+
* project modules. A global rather than an import, because a user's module
|
|
6
|
+
* cannot always resolve `@describe-me/core` (pnpm) and a bundled adapter may
|
|
7
|
+
* carry its own copy of core.
|
|
8
|
+
*/
|
|
9
|
+
export const EXPORT_REGISTRY_KEY = 'describe-me.exports';
|
|
10
|
+
/**
|
|
11
|
+
* Prefix of project asset URLs inside stored snapshots, e.g.
|
|
12
|
+
* `describe-me-asset:3f2a9c1d.svg`. The reporter copies the file to
|
|
13
|
+
* `<outDir>/assets/` and the viewer resolves the prefix against `__data/assets/`.
|
|
14
|
+
*/
|
|
15
|
+
export const ASSET_URL_PREFIX = 'describe-me-asset:';
|
|
2
16
|
export const META_KEY = 'describeMe';
|
|
17
|
+
/** The name adapters fall back to when a component has no usable name. */
|
|
18
|
+
export const ANONYMOUS_COMPONENT = 'Anonymous';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@describe-me/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Recorder and manifest types for describe-me: living component docs from your tests",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vitest",
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
"./types": {
|
|
35
35
|
"types": "./dist/types.d.ts",
|
|
36
36
|
"default": "./dist/types.js"
|
|
37
|
+
},
|
|
38
|
+
"./diagnostics": {
|
|
39
|
+
"types": "./dist/manifest-diagnostics.d.ts",
|
|
40
|
+
"default": "./dist/manifest-diagnostics.js"
|
|
37
41
|
}
|
|
38
42
|
},
|
|
39
43
|
"files": [
|