@describe-me/core 0.3.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,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.js CHANGED
@@ -67,7 +67,13 @@ class Recorder {
67
67
  }
68
68
  end() {
69
69
  this.active = false;
70
- return { frames: this.frames, component: this.component };
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
+ };
71
77
  }
72
78
  /**
73
79
  * Register work that must run after the test's frames are handed over, such
@@ -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 type = record.type;
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,7 +3,34 @@ 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;
6
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:";
7
34
  /** Options for `recorder.capture()`. */
8
35
  export interface CaptureOptions {
9
36
  /**
@@ -28,6 +55,12 @@ export interface Frame {
28
55
  export interface TestRecord {
29
56
  frames: Frame[];
30
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;
31
64
  }
32
65
  export declare const META_KEY: "describeMe";
33
66
  export type TestState = 'passed' | 'failed' | 'skipped' | 'pending';
@@ -93,4 +126,27 @@ export interface Manifest {
93
126
  modules: ManifestModule[];
94
127
  /** Keyed by component name, as reported by the framework adapter. */
95
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[];
96
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.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": [