@describe-me/core 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Grzegorz Łotysz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @describe-me/core
2
+
3
+ The framework-agnostic heart of [describe-me](https://github.com/grzehub/describe-me):
4
+ the recorder that captures the DOM, the `step()` helper, and the JSON manifest
5
+ types the viewer reads. Snapshots are serialized DOM via `rrweb-snapshot`, so
6
+ nothing here knows about React.
7
+
8
+ Most people never install this package directly — `@describe-me/react` and
9
+ `@describe-me/vitest` depend on it, and both re-export `step()`.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ pnpm add -D @describe-me/core
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ `step()` names a phase of a test. The DOM is captured after the body resolves,
20
+ and the label becomes the frame's caption in the viewer.
21
+
22
+ ```ts
23
+ import { step } from '@describe-me/core'
24
+
25
+ await step('open the menu', () => screen.getByRole('button', { name: 'Menu' }).click())
26
+ ```
27
+
28
+ The manifest types live in `@describe-me/core/types`, for tools that want to
29
+ read `.describe-me/manifest.json` themselves.
30
+
31
+ See the [root README](https://github.com/grzehub/describe-me#readme) for the
32
+ full picture.
@@ -0,0 +1,4 @@
1
+ export * from './types.js';
2
+ export { recorder } from './recorder.js';
3
+ export { serializeValue } from './serialize-value.js';
4
+ export { step } from './step.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from './types.js';
2
+ export { recorder } from './recorder.js';
3
+ export { serializeValue } from './serialize-value.js';
4
+ export { step } from './step.js';
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Constructable stylesheets (`document.adoptedStyleSheets`, used by Lit and
3
+ * other web-component libraries) are not part of the DOM tree, so rrweb's
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.
6
+ */
7
+ export declare function materializeAdoptedStyles<T>(run: () => T | Promise<T>): Promise<T>;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Constructable stylesheets (`document.adoptedStyleSheets`, used by Lit and
3
+ * other web-component libraries) are not part of the DOM tree, so rrweb's
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.
6
+ */
7
+ export async function materializeAdoptedStyles(run) {
8
+ const sheets = document.adoptedStyleSheets ?? [];
9
+ if (sheets.length === 0) {
10
+ return run();
11
+ }
12
+ const mirror = document.createElement('style');
13
+ mirror.setAttribute('data-describe-me', 'adopted-styles');
14
+ mirror.textContent = sheets
15
+ .flatMap((sheet) => Array.from(sheet.cssRules, (rule) => rule.cssText))
16
+ .join('\n');
17
+ document.head.append(mirror);
18
+ try {
19
+ return await run();
20
+ }
21
+ finally {
22
+ mirror.remove();
23
+ }
24
+ }
@@ -0,0 +1,7 @@
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;
@@ -0,0 +1,19 @@
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
+ }
@@ -0,0 +1,20 @@
1
+ import type { ComponentInfo, FrameKind, TestRecord } from './types.js';
2
+ /**
3
+ * Browser-side recorder. One instance per test iframe; `begin()` in beforeEach,
4
+ * `end()` in afterEach. Framework adapters call `capture()` and `setComponent()`.
5
+ */
6
+ declare class Recorder {
7
+ private frames;
8
+ private component?;
9
+ private startedAt;
10
+ private active;
11
+ private lastHash;
12
+ private seq;
13
+ begin(): void;
14
+ get isActive(): boolean;
15
+ setComponent(info: ComponentInfo): void;
16
+ capture(kind: FrameKind, label: string, meta?: Record<string, unknown>): Promise<void>;
17
+ end(): TestRecord;
18
+ }
19
+ export declare const recorder: Recorder;
20
+ export {};
@@ -0,0 +1,69 @@
1
+ import { snapshot, createMirror } from 'rrweb-snapshot';
2
+ import { materializeAdoptedStyles } from './materialize-adopted-styles.js';
3
+ import { quickHash } from './quick-hash.js';
4
+ import { settle } from './settle.js';
5
+ /**
6
+ * Browser-side recorder. One instance per test iframe; `begin()` in beforeEach,
7
+ * `end()` in afterEach. Framework adapters call `capture()` and `setComponent()`.
8
+ */
9
+ class Recorder {
10
+ frames = [];
11
+ component;
12
+ startedAt = 0;
13
+ active = false;
14
+ lastHash = '';
15
+ seq = 0;
16
+ begin() {
17
+ this.frames = [];
18
+ this.component = undefined;
19
+ this.startedAt = performance.now();
20
+ this.active = true;
21
+ this.lastHash = '';
22
+ this.seq = 0;
23
+ }
24
+ get isActive() {
25
+ return this.active;
26
+ }
27
+ setComponent(info) {
28
+ if (!this.component) {
29
+ this.component = info;
30
+ }
31
+ }
32
+ async capture(kind, label, meta) {
33
+ if (!this.active) {
34
+ return;
35
+ }
36
+ await settle();
37
+ const node = await materializeAdoptedStyles(() => snapshot(document, { mirror: createMirror(), inlineStylesheet: true }));
38
+ if (!node) {
39
+ return;
40
+ }
41
+ // rrweb numbers nodes with a global counter, so ids differ between otherwise identical captures.
42
+ const hash = quickHash(JSON.stringify(node, (key, value) => (key === 'id' || key === 'rootId' ? undefined : value)));
43
+ // The closing frame is only interesting if something changed since the last one.
44
+ if (kind === 'end' && hash === this.lastHash) {
45
+ return;
46
+ }
47
+ // A step whose body already produced this exact DOM (e.g. via an action) just names that frame.
48
+ if (kind === 'step' && hash === this.lastHash && this.frames.length) {
49
+ const last = this.frames[this.frames.length - 1];
50
+ last.label = `${label} · ${last.label}`;
51
+ last.kind = 'step';
52
+ return;
53
+ }
54
+ this.lastHash = hash;
55
+ this.frames.push({
56
+ id: `f${this.seq++}`,
57
+ kind,
58
+ label,
59
+ at: Math.round(performance.now() - this.startedAt),
60
+ meta,
61
+ snapshot: node,
62
+ });
63
+ }
64
+ end() {
65
+ this.active = false;
66
+ return { frames: this.frames, component: this.component };
67
+ }
68
+ }
69
+ export const recorder = new Recorder();
@@ -0,0 +1,2 @@
1
+ /** Turn arbitrary props into something JSON-safe and readable. */
2
+ export declare function serializeValue(value: unknown, depth?: number): unknown;
@@ -0,0 +1,41 @@
1
+ /** Turn arbitrary props into something JSON-safe and readable. */
2
+ export function serializeValue(value, depth = 0) {
3
+ if (depth > 3) {
4
+ return '…';
5
+ }
6
+ if (value === null || value === undefined) {
7
+ return value;
8
+ }
9
+ const kind = typeof value;
10
+ if (kind === 'string' || kind === 'number' || kind === 'boolean') {
11
+ return value;
12
+ }
13
+ if (kind === 'function') {
14
+ return `ƒ ${value.name || 'anonymous'}`;
15
+ }
16
+ if (kind === 'symbol') {
17
+ return String(value);
18
+ }
19
+ if (Array.isArray(value)) {
20
+ return value.map((item) => serializeValue(item, depth + 1));
21
+ }
22
+ if (kind === 'object') {
23
+ const record = value;
24
+ // React element
25
+ 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');
32
+ return `<${name} />`;
33
+ }
34
+ const out = {};
35
+ for (const [key, entry] of Object.entries(record)) {
36
+ out[key] = serializeValue(entry, depth + 1);
37
+ }
38
+ return out;
39
+ }
40
+ return String(value);
41
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Let the framework flush pending updates before we look at the DOM.
3
+ * One macrotask is enough for React to commit batched updates. We deliberately
4
+ * do not wait for requestAnimationFrame: the snapshot is DOM + stylesheets, not
5
+ * layout, and a rAF costs a whole vsync (~16ms) per capture in headless Chromium.
6
+ */
7
+ export declare function settle(): Promise<void>;
package/dist/settle.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Let the framework flush pending updates before we look at the DOM.
3
+ * One macrotask is enough for React to commit batched updates. We deliberately
4
+ * do not wait for requestAnimationFrame: the snapshot is DOM + stylesheets, not
5
+ * layout, and a rAF costs a whole vsync (~16ms) per capture in headless Chromium.
6
+ */
7
+ export async function settle() {
8
+ await new Promise((resolve) => setTimeout(resolve, 0));
9
+ }
package/dist/step.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Name a phase of the test. The DOM is captured after `fn` resolves. */
2
+ export declare function step<T>(label: string, fn: () => T | Promise<T>): Promise<T>;
package/dist/step.js ADDED
@@ -0,0 +1,7 @@
1
+ import { recorder } from './recorder.js';
2
+ /** Name a phase of the test. The DOM is captured after `fn` resolves. */
3
+ export async function step(label, fn) {
4
+ const result = await fn();
5
+ await recorder.capture('step', label);
6
+ return result;
7
+ }
@@ -0,0 +1,86 @@
1
+ /** Shared, JSON-serializable types. Safe to import from Node (no DOM). */
2
+ export type FrameKind = 'render' | 'action' | 'step' | 'end';
3
+ export interface ComponentInfo {
4
+ name: string;
5
+ props: Record<string, unknown>;
6
+ }
7
+ /** A frame as captured in the browser. `snapshot` is an rrweb serialized document. */
8
+ export interface Frame {
9
+ id: string;
10
+ kind: FrameKind;
11
+ label: string;
12
+ /** ms since the test began */
13
+ at: number;
14
+ meta?: Record<string, unknown>;
15
+ snapshot: unknown;
16
+ }
17
+ /** What a single test hands over to the reporter via task.meta[META_KEY]. */
18
+ export interface TestRecord {
19
+ frames: Frame[];
20
+ component?: ComponentInfo;
21
+ }
22
+ export declare const META_KEY: "describeMe";
23
+ export type TestState = 'passed' | 'failed' | 'skipped' | 'pending';
24
+ export interface ManifestFrame {
25
+ id: string;
26
+ kind: FrameKind;
27
+ label: string;
28
+ at: number;
29
+ meta?: Record<string, unknown>;
30
+ /** path relative to the manifest, e.g. "snapshots/ab12.json" */
31
+ snapshot: string;
32
+ }
33
+ export interface ManifestTest {
34
+ id: string;
35
+ name: string;
36
+ /** suite names from outermost to innermost */
37
+ path: string[];
38
+ fullName: string;
39
+ state: TestState;
40
+ duration?: number;
41
+ errors?: {
42
+ message: string;
43
+ stack?: string;
44
+ }[];
45
+ component?: ComponentInfo;
46
+ frames: ManifestFrame[];
47
+ }
48
+ export interface ManifestModule {
49
+ /** module path relative to project root */
50
+ id: string;
51
+ tests: ManifestTest[];
52
+ }
53
+ /** How a prop can be covered and, later, controlled. */
54
+ export type PropKind = 'literals' | 'boolean' | 'number' | 'string' | 'function' | 'node' | 'other';
55
+ /** One prop of a component, as read from its TypeScript type. */
56
+ export interface PropDoc {
57
+ name: string;
58
+ /** The type as TypeScript prints it, e.g. `'sm' | 'md' | 'lg'`. */
59
+ type: string;
60
+ required: boolean;
61
+ /** Default from the destructuring pattern, as source text, e.g. `'md'` or `false`. */
62
+ defaultValue?: string;
63
+ kind: PropKind;
64
+ /**
65
+ * For `literals`: every member as source text (`'sm'`, `3`), in declaration order.
66
+ * For `boolean`: `true` and `false`. Absent for other kinds.
67
+ */
68
+ values?: string[];
69
+ /** Leading JSDoc comment on the prop, if any. */
70
+ description?: string;
71
+ }
72
+ /** Everything the viewer needs to document a component beyond its tests. */
73
+ export interface ComponentDoc {
74
+ name: string;
75
+ /** Source file relative to the project root. */
76
+ file: string;
77
+ props: PropDoc[];
78
+ }
79
+ export interface Manifest {
80
+ version: 1;
81
+ generatedAt: string;
82
+ root: string;
83
+ modules: ManifestModule[];
84
+ /** Keyed by component name, as reported by the framework adapter. */
85
+ components: Record<string, ComponentDoc>;
86
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Shared, JSON-serializable types. Safe to import from Node (no DOM). */
2
+ export const META_KEY = 'describeMe';
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@describe-me/core",
3
+ "version": "0.1.0",
4
+ "description": "Recorder and manifest types for describe-me: living component docs from your tests",
5
+ "keywords": [
6
+ "vitest",
7
+ "storybook",
8
+ "component documentation",
9
+ "visual testing",
10
+ "testing"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Grzegorz Łotysz",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/grzehub/describe-me.git",
17
+ "directory": "packages/core"
18
+ },
19
+ "homepage": "https://grzehub.github.io/describe-me/",
20
+ "bugs": "https://github.com/grzehub/describe-me/issues",
21
+ "type": "module",
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "default": "./dist/index.js"
32
+ },
33
+ "./types": {
34
+ "types": "./dist/types.d.ts",
35
+ "default": "./dist/types.js"
36
+ }
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "README.md",
41
+ "LICENSE"
42
+ ],
43
+ "sideEffects": false,
44
+ "dependencies": {
45
+ "rrweb-snapshot": "^2.1.6"
46
+ },
47
+ "scripts": {
48
+ "build": "rimraf dist && tsc -p tsconfig.json"
49
+ }
50
+ }