@describe-me/core 0.3.0 → 0.5.0-next.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
@@ -25,8 +25,14 @@ import { step } from '@describe-me/core'
25
25
  await step('open the menu', () => screen.getByRole('button', { name: 'Menu' }).click())
26
26
  ```
27
27
 
28
+ `elementLabel()` names a DOM element the way frame labels do, e.g.
29
+ `button "Save"`, for adapters that record their own interactions.
30
+
28
31
  The manifest types live in `@describe-me/core/types`, for tools that want to
29
- read `.describe-me/manifest.json` themselves.
32
+ read `.describe-me/manifest.json` themselves. Stored snapshots point into
33
+ `assets/` and `styles/` through `ASSET_URL_PREFIX` and `STYLE_URL_PREFIX`
34
+ ([Output directory](https://github.com/grzehub/describe-me#output-directory) in
35
+ the root README).
30
36
 
31
37
  See the [root README](https://github.com/grzehub/describe-me#readme) for the
32
38
  full picture.
@@ -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
+ }
@@ -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
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export * from './types.js';
2
+ export { describeComponentType } from './describe-component-type.js';
3
+ export { elementLabel } from './element-label.js';
4
+ export { manifestDiagnostics } from './manifest-diagnostics.js';
2
5
  export { recorder } from './recorder.js';
3
6
  export { serializeValue } from './serialize-value.js';
4
7
  export { step } from './step.js';
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  export * from './types.js';
2
+ export { describeComponentType } from './describe-component-type.js';
3
+ export { elementLabel } from './element-label.js';
4
+ export { manifestDiagnostics } from './manifest-diagnostics.js';
2
5
  export { recorder } from './recorder.js';
3
6
  export { serializeValue } from './serialize-value.js';
4
7
  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
+ }
@@ -2,6 +2,8 @@
2
2
  * Constructable stylesheets (`document.adoptedStyleSheets`, used by Lit and
3
3
  * other web-component libraries) are not part of the DOM tree, so rrweb's
4
4
  * snapshot never sees them. For the duration of `run`, mirror their rules into
5
- * a temporary `<style>` element so they end up in the capture.
5
+ * a temporary `<style>` element so they end up in the capture. Synchronous,
6
+ * like rrweb's snapshot, so a `settle: false` capture records its frame before
7
+ * `recorder.capture()` returns.
6
8
  */
7
- export declare function materializeAdoptedStyles<T>(run: () => T | Promise<T>): Promise<T>;
9
+ export declare function materializeAdoptedStyles<T>(run: () => T): T;
@@ -2,9 +2,11 @@
2
2
  * Constructable stylesheets (`document.adoptedStyleSheets`, used by Lit and
3
3
  * other web-component libraries) are not part of the DOM tree, so rrweb's
4
4
  * snapshot never sees them. For the duration of `run`, mirror their rules into
5
- * a temporary `<style>` element so they end up in the capture.
5
+ * a temporary `<style>` element so they end up in the capture. Synchronous,
6
+ * like rrweb's snapshot, so a `settle: false` capture records its frame before
7
+ * `recorder.capture()` returns.
6
8
  */
7
- export async function materializeAdoptedStyles(run) {
9
+ export function materializeAdoptedStyles(run) {
8
10
  const sheets = document.adoptedStyleSheets ?? [];
9
11
  if (sheets.length === 0) {
10
12
  return run();
@@ -16,7 +18,7 @@ export async function materializeAdoptedStyles(run) {
16
18
  .join('\n');
17
19
  document.head.append(mirror);
18
20
  try {
19
- return await run();
21
+ return run();
20
22
  }
21
23
  finally {
22
24
  mirror.remove();
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Whether the page shows anything at all. False for what Testing Library
3
+ * leaves behind: an empty body after `cleanup()`, the bare container `div`
4
+ * after `unmount()`. Any text, any other element and any attribute count as
5
+ * content, so a styled box or an icon is never taken for an empty page.
6
+ */
7
+ export declare function pageHasContent(): boolean;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Whether the page shows anything at all. False for what Testing Library
3
+ * leaves behind: an empty body after `cleanup()`, the bare container `div`
4
+ * after `unmount()`. Any text, any other element and any attribute count as
5
+ * content, so a styled box or an icon is never taken for an empty page.
6
+ */
7
+ export function pageHasContent() {
8
+ const body = document.body;
9
+ if (!body) {
10
+ return false;
11
+ }
12
+ if ((body.textContent ?? '').trim() !== '') {
13
+ return true;
14
+ }
15
+ for (const element of body.querySelectorAll('*')) {
16
+ if (element.localName !== 'div' || element.attributes.length > 0) {
17
+ return true;
18
+ }
19
+ }
20
+ return false;
21
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `setTimeout` and `performance.now()` as they were before any test faked
3
+ * them, so `vi.useFakeTimers()` neither hangs a capture nor skews frame times.
4
+ */
5
+ export declare const realTimers: {
6
+ setTimeout: typeof setTimeout;
7
+ now: () => number;
8
+ };
@@ -0,0 +1,23 @@
1
+ /** The global `setTimeout`, unwrapped when a setup file faked timers before this module loaded. */
2
+ function originalSetTimeout() {
3
+ const current = globalThis.setTimeout;
4
+ return current.clock ? current.clock._setTimeout : current;
5
+ }
6
+ /** The global `performance`, unwrapped the same way. */
7
+ function originalPerformance() {
8
+ const current = globalThis.performance;
9
+ return current.clock ? current.clock._performance : current;
10
+ }
11
+ // Bound, because browsers throw "Illegal invocation" when `setTimeout` is
12
+ // called without its global as `this`.
13
+ const setTimeoutAtLoad = originalSetTimeout().bind(globalThis);
14
+ // Fake timers replace `performance` as a whole object, so this reference stays real.
15
+ const performanceAtLoad = originalPerformance();
16
+ /**
17
+ * `setTimeout` and `performance.now()` as they were before any test faked
18
+ * them, so `vi.useFakeTimers()` neither hangs a capture nor skews frame times.
19
+ */
20
+ export const realTimers = {
21
+ setTimeout: setTimeoutAtLoad,
22
+ now: () => performanceAtLoad.now(),
23
+ };
@@ -10,13 +10,16 @@ declare class Recorder {
10
10
  private component?;
11
11
  private startedAt;
12
12
  private active;
13
- private lastHash;
13
+ private closed;
14
+ private lastJson;
14
15
  private seq;
15
16
  private teardowns;
16
17
  begin(): void;
17
18
  get isActive(): boolean;
18
19
  setComponent(info: ComponentInfo): void;
19
20
  capture(kind: FrameKind, label: string, meta?: Record<string, unknown>, options?: CaptureOptions): Promise<void>;
21
+ /** The test went on after `cleanup()`, so the closing frame it took names the state before it. */
22
+ private reopen;
20
23
  end(): TestRecord;
21
24
  /**
22
25
  * Register work that must run after the test's frames are handed over, such
package/dist/recorder.js CHANGED
@@ -1,6 +1,6 @@
1
- import { snapshot, createMirror } from 'rrweb-snapshot';
2
- import { materializeAdoptedStyles } from './materialize-adopted-styles.js';
3
- import { quickHash } from './quick-hash.js';
1
+ import { pageHasContent } from './page-has-content.js';
2
+ import { realTimers } from './real-timers.js';
3
+ import { serializeDocument } from './serialize-document.js';
4
4
  import { settle } from './settle.js';
5
5
  /**
6
6
  * Test-side recorder, running wherever the tests run: the browser-mode iframe
@@ -12,15 +12,18 @@ class Recorder {
12
12
  component;
13
13
  startedAt = 0;
14
14
  active = false;
15
- lastHash = '';
15
+ // Once `cleanup()` or the setup file took the closing frame, a second one is skipped.
16
+ closed = false;
17
+ lastJson = '';
16
18
  seq = 0;
17
19
  teardowns = new Set();
18
20
  begin() {
19
21
  this.frames = [];
20
22
  this.component = undefined;
21
- this.startedAt = performance.now();
23
+ this.startedAt = realTimers.now();
22
24
  this.active = true;
23
- this.lastHash = '';
25
+ this.closed = false;
26
+ this.lastJson = '';
24
27
  this.seq = 0;
25
28
  }
26
29
  get isActive() {
@@ -35,39 +38,65 @@ class Recorder {
35
38
  if (!this.active) {
36
39
  return;
37
40
  }
41
+ if (kind !== 'end' && this.closed) {
42
+ this.reopen();
43
+ }
44
+ if (kind === 'end' && (this.frames.length === 0 || this.closed)) {
45
+ return;
46
+ }
38
47
  if (options.settle !== false) {
39
48
  await settle();
40
49
  }
41
- const node = await materializeAdoptedStyles(() => snapshot(document, { mirror: createMirror(), inlineStylesheet: true }));
42
- if (!node) {
50
+ if (kind === 'end') {
51
+ this.closed = true;
52
+ // After `cleanup()` or `unmount()` there is nothing left to show.
53
+ if (!pageHasContent()) {
54
+ return;
55
+ }
56
+ }
57
+ const json = serializeDocument();
58
+ if (json === null) {
43
59
  return;
44
60
  }
45
- // rrweb numbers nodes with a global counter, so ids differ between otherwise identical captures.
46
- const hash = quickHash(JSON.stringify(node, (key, value) => (key === 'id' || key === 'rootId' ? undefined : value)));
47
61
  // The closing frame is only interesting if something changed since the last one.
48
- if (kind === 'end' && hash === this.lastHash) {
62
+ if (kind === 'end' && json === this.lastJson) {
49
63
  return;
50
64
  }
51
65
  // A step whose body already produced this exact DOM (e.g. via an action) just names that frame.
52
- if (kind === 'step' && hash === this.lastHash && this.frames.length) {
66
+ if (kind === 'step' && json === this.lastJson && this.frames.length) {
53
67
  const last = this.frames[this.frames.length - 1];
54
68
  last.label = `${label} · ${last.label}`;
55
69
  last.kind = 'step';
56
70
  return;
57
71
  }
58
- this.lastHash = hash;
72
+ this.lastJson = json;
59
73
  this.frames.push({
60
74
  id: `f${this.seq++}`,
61
75
  kind,
62
76
  label,
63
- at: Math.round(performance.now() - this.startedAt),
77
+ at: Math.round(realTimers.now() - this.startedAt),
64
78
  meta,
65
- snapshot: node,
79
+ snapshot: json,
66
80
  });
67
81
  }
82
+ /** The test went on after `cleanup()`, so the closing frame it took names the state before it. */
83
+ reopen() {
84
+ this.closed = false;
85
+ const last = this.frames[this.frames.length - 1];
86
+ if (last?.kind === 'end') {
87
+ last.kind = 'step';
88
+ last.label = 'before cleanup()';
89
+ }
90
+ }
68
91
  end() {
69
92
  this.active = false;
70
- return { frames: this.frames, component: this.component };
93
+ // The reporter needs the origin rrweb resolved every URL against. Optional
94
+ // chaining, because a DOM environment is not guaranteed to define `location`.
95
+ return {
96
+ frames: this.frames,
97
+ component: this.component,
98
+ origin: globalThis.location?.origin,
99
+ };
71
100
  }
72
101
  /**
73
102
  * Register work that must run after the test's frames are handed over, such
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Serialize the current document with rrweb, as JSON text, or `null` when
3
+ * rrweb has nothing to serialize.
4
+ *
5
+ * rrweb numbers nodes from a module-level counter that only
6
+ * `cleanupSnapshot()` resets. Resetting it before every capture makes ids
7
+ * start at 1, drops `rootId` (added whenever the document's id is not 1), and
8
+ * makes identical DOM serialize to identical text, so the snapshot store
9
+ * writes it once. Gotcha: any other rrweb user in the same realm shares that
10
+ * counter; none is expected inside tests.
11
+ */
12
+ export declare function serializeDocument(): string | null;
@@ -0,0 +1,20 @@
1
+ import { cleanupSnapshot, createMirror, snapshot } from 'rrweb-snapshot';
2
+ import { materializeAdoptedStyles } from './materialize-adopted-styles.js';
3
+ /**
4
+ * Serialize the current document with rrweb, as JSON text, or `null` when
5
+ * rrweb has nothing to serialize.
6
+ *
7
+ * rrweb numbers nodes from a module-level counter that only
8
+ * `cleanupSnapshot()` resets. Resetting it before every capture makes ids
9
+ * start at 1, drops `rootId` (added whenever the document's id is not 1), and
10
+ * makes identical DOM serialize to identical text, so the snapshot store
11
+ * writes it once. Gotcha: any other rrweb user in the same realm shares that
12
+ * counter; none is expected inside tests.
13
+ */
14
+ export function serializeDocument() {
15
+ return materializeAdoptedStyles(() => {
16
+ cleanupSnapshot();
17
+ const node = snapshot(document, { mirror: createMirror(), inlineStylesheet: true });
18
+ return node ? JSON.stringify(node) : null;
19
+ });
20
+ }
@@ -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/settle.d.ts CHANGED
@@ -3,5 +3,6 @@
3
3
  * One macrotask is enough for React to commit batched updates. We deliberately
4
4
  * do not wait for requestAnimationFrame: the snapshot is DOM + stylesheets, not
5
5
  * layout, and a rAF costs a whole vsync (~16ms) per capture in headless Chromium.
6
+ * It uses real timers, so a test that leaves fake timers on still settles.
6
7
  */
7
8
  export declare function settle(): Promise<void>;
package/dist/settle.js CHANGED
@@ -1,9 +1,11 @@
1
+ import { realTimers } from './real-timers.js';
1
2
  /**
2
3
  * Let the framework flush pending updates before we look at the DOM.
3
4
  * One macrotask is enough for React to commit batched updates. We deliberately
4
5
  * do not wait for requestAnimationFrame: the snapshot is DOM + stylesheets, not
5
6
  * layout, and a rAF costs a whole vsync (~16ms) per capture in headless Chromium.
7
+ * It uses real timers, so a test that leaves fake timers on still settles.
6
8
  */
7
9
  export async function settle() {
8
- await new Promise((resolve) => setTimeout(resolve, 0));
10
+ await new Promise((resolve) => realTimers.setTimeout(resolve, 0));
9
11
  }
package/dist/types.d.ts CHANGED
@@ -3,18 +3,53 @@ 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;
6
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
+ /**
35
+ * Prefix of stylesheet references inside stored snapshots, e.g.
36
+ * `describe-me-style:3f2a9c1d0b7e4a55+9c1d…`. The reporter stores an rrweb
37
+ * `_cssText` of 256+ characters as chunks in `<outDir>/styles/<hash>.css`, and
38
+ * the viewer joins them back in order before replaying.
39
+ */
40
+ export declare const STYLE_URL_PREFIX: "describe-me-style:";
7
41
  /** Options for `recorder.capture()`. */
8
42
  export interface CaptureOptions {
9
43
  /**
10
44
  * Wait one macrotask for the framework to flush before looking at the DOM.
11
45
  * Default: true. Pass false when the caller knows the DOM is already
12
46
  * committed (e.g. right after Testing Library's `act()`-wrapped `rerender`):
13
- * the snapshot is then taken synchronously, before the caller moves on.
47
+ * the snapshot is then taken and the frame recorded before `capture()`
48
+ * returns, so the caller can move on without awaiting it.
14
49
  */
15
50
  settle?: boolean;
16
51
  }
17
- /** A frame as captured in the browser. `snapshot` is an rrweb serialized document. */
52
+ /** A frame as captured by the recorder. `snapshot` is the DOM, already serialized to JSON. */
18
53
  export interface Frame {
19
54
  id: string;
20
55
  kind: FrameKind;
@@ -22,12 +57,19 @@ export interface Frame {
22
57
  /** ms since the test began */
23
58
  at: number;
24
59
  meta?: Record<string, unknown>;
25
- snapshot: unknown;
60
+ /** rrweb serialized document, as JSON text */
61
+ snapshot: string;
26
62
  }
27
63
  /** What a single test hands over to the reporter via task.meta[META_KEY]. */
28
64
  export interface TestRecord {
29
65
  frames: Frame[];
30
66
  component?: ComponentInfo;
67
+ /**
68
+ * `location.origin` of the page the test ran in (`http://localhost:3000` in
69
+ * jsdom, the Vitest server in browser mode). rrweb makes every URL absolute
70
+ * against it, so the reporter uses it to find project assets.
71
+ */
72
+ origin?: string;
31
73
  }
32
74
  export declare const META_KEY: "describeMe";
33
75
  export type TestState = 'passed' | 'failed' | 'skipped' | 'pending';
@@ -93,4 +135,27 @@ export interface Manifest {
93
135
  modules: ManifestModule[];
94
136
  /** Keyed by component name, as reported by the framework adapter. */
95
137
  components: Record<string, ComponentDoc>;
138
+ /**
139
+ * Asset URLs the snapshots point at on the test page's origin that could not
140
+ * be found in the project, as root-relative paths (`/lib/images/logo.svg`).
141
+ * They will not load in the viewer. Absent in manifests written before 0.4.
142
+ */
143
+ assetsMissing?: string[];
144
+ }
145
+ /** The name adapters fall back to when a component has no usable name. */
146
+ export declare const ANONYMOUS_COMPONENT: "Anonymous";
147
+ /** What keeps a manifest from documenting everything its tests rendered. */
148
+ export interface ManifestDiagnostics {
149
+ /** Tests whose rendered component has no usable name. */
150
+ anonymous: {
151
+ testId: string;
152
+ fullName: string;
153
+ }[];
154
+ /** Named components without a props doc, with how many tests rendered each. */
155
+ undocumented: {
156
+ name: string;
157
+ tests: number;
158
+ }[];
159
+ /** Root-relative asset paths that will not load in the viewer. */
160
+ assetsMissing: string[];
96
161
  }
package/dist/types.js CHANGED
@@ -1,2 +1,25 @@
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:';
16
+ /**
17
+ * Prefix of stylesheet references inside stored snapshots, e.g.
18
+ * `describe-me-style:3f2a9c1d0b7e4a55+9c1d…`. The reporter stores an rrweb
19
+ * `_cssText` of 256+ characters as chunks in `<outDir>/styles/<hash>.css`, and
20
+ * the viewer joins them back in order before replaying.
21
+ */
22
+ export const STYLE_URL_PREFIX = 'describe-me-style:';
2
23
  export const META_KEY = 'describeMe';
24
+ /** The name adapters fall back to when a component has no usable name. */
25
+ 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.5.0-next.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": [
@@ -1,7 +0,0 @@
1
- /**
2
- * Inexpensive, non-cryptographic hash used to tell two captures apart.
3
- * `| 0` keeps the accumulator a 32-bit integer, so overflow wraps like a Java
4
- * int instead of drifting into floating point. A collision merely skips one
5
- * frame, which is why this is good enough here and SHA-1 is not needed.
6
- */
7
- export declare function quickHash(input: string): string;
@@ -1,19 +0,0 @@
1
- /**
2
- * Multiplier of the polynomial rolling hash, the same one `String.hashCode()`
3
- * uses in Java. Odd, so no bit is lost to a plain shift on every step; prime,
4
- * so common inputs spread evenly; and `x * 31` is just `(x << 5) - x`.
5
- */
6
- const MULTIPLIER = 31;
7
- /**
8
- * Inexpensive, non-cryptographic hash used to tell two captures apart.
9
- * `| 0` keeps the accumulator a 32-bit integer, so overflow wraps like a Java
10
- * int instead of drifting into floating point. A collision merely skips one
11
- * frame, which is why this is good enough here and SHA-1 is not needed.
12
- */
13
- export function quickHash(input) {
14
- let hash = 0;
15
- for (let i = 0; i < input.length; i++) {
16
- hash = (hash * MULTIPLIER + input.charCodeAt(i)) | 0;
17
- }
18
- return `${input.length}:${hash}`;
19
- }