@describe-me/core 0.5.3 → 0.6.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
@@ -1,47 +1,69 @@
1
1
  # @describe-me/core
2
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.
3
+ **Your tests are your stories.** The recorder and the manifest types of
4
+ describe-me: living component documentation generated from the Vitest tests
5
+ you already have.
7
6
 
8
- Most people never install this package directly — `@describe-me/react` and
9
- `@describe-me/vitest` depend on it, and both re-export `step()`.
7
+ **[Read the docs](https://grzehub.github.io/describe-me/)** · open the viewer
8
+ of the [browser-mode example](https://grzehub.github.io/describe-me/examples/react-browser/)
9
+ or the [jsdom example](https://grzehub.github.io/describe-me/examples/react-jsdom/)
10
+ · [Limitations](https://grzehub.github.io/describe-me/limitations.html)
10
11
 
11
- ## Install
12
+ describe-me captures the DOM after each step of a test and shows the result as
13
+ a browsable catalog. `describe` blocks become the sidebar, each `it` is a
14
+ story, and each step is a frame you can scrub through. Stories cannot rot,
15
+ because they are tests. If one drifts from the component, CI goes red.
12
16
 
13
- ```sh
14
- pnpm add -D @describe-me/core
15
- ```
17
+ Status: prototype. React 18 and 19, Vitest 4 and 5 in browser mode or jsdom,
18
+ recorded frames and Live in the viewer.
16
19
 
17
- All describe-me packages must be on the same version
18
- ([One version for all packages](https://grzehub.github.io/describe-me/installation.html#lockstep)).
20
+ ## Which package do I need
19
21
 
20
- ## Usage
22
+ | Package | What it is | Install it |
23
+ | --------------------- | ------------------------------------------- | ----------------------- |
24
+ | `describe-me` | the viewer and the `describe-me` CLI | yes |
25
+ | `@describe-me/vitest` | the Vitest plugin, setup files and reporter | yes |
26
+ | `@describe-me/react` | `render` functions that record frames | yes |
27
+ | `@describe-me/core` | the recorder and the manifest types (this) | no, the others bring it |
21
28
 
22
- `step()` names a phase of a test. The DOM is captured after the body resolves,
23
- and the label becomes the frame's caption in the viewer.
29
+ ## Quick start
30
+
31
+ ```sh
32
+ pnpm add -D describe-me @describe-me/vitest @describe-me/react
33
+ ```
24
34
 
25
35
  ```ts
26
- import { step } from '@describe-me/core'
36
+ // vitest.config.ts
37
+ import { describeMe } from '@describe-me/vitest/plugin'
27
38
 
28
- await step('open the menu', () => screen.getByRole('button', { name: 'Menu' }).click())
39
+ export default defineConfig({
40
+ plugins: [react(), describeMe()],
41
+ test: { environment: 'jsdom' }, // or your browser-mode settings
42
+ })
29
43
  ```
30
44
 
31
- `elementLabel()` names a DOM element the way frame labels do, e.g.
32
- `button "Save"`, for adapters that record their own interactions.
45
+ Run your tests, then `describe-me dev` opens the viewer.
46
+ [Getting started](https://grzehub.github.io/describe-me/getting-started.html)
47
+ walks through both environments. Keep all describe-me packages on one version
48
+ ([why](https://grzehub.github.io/describe-me/installation.html#lockstep)).
49
+
50
+ ## This package
51
+
52
+ The framework-agnostic heart of describe-me. Snapshots are serialized DOM via
53
+ `rrweb-snapshot`, so nothing here knows about React. You need it directly only
54
+ to write an adapter for another framework or to read the output yourself.
55
+
56
+ | Entry point | What it holds |
57
+ | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
58
+ | [`@describe-me/core`](https://grzehub.github.io/describe-me/api.html#entry-core) | `step()`, `recorder` and `elementLabel()`, for adapters |
59
+ | [`@describe-me/core/types`](https://grzehub.github.io/describe-me/api.html#entry-core-types) | the types of `.describe-me/manifest.json` |
60
+ | [`@describe-me/core/diagnostics`](https://grzehub.github.io/describe-me/api.html#entry-core-diagnostics) | what a manifest could not document |
61
+ | [`@describe-me/core/css-references`](https://grzehub.github.io/describe-me/api.html#entry-core-css-references) | the `url()` and `@import` references in CSS text |
62
+ | [`@describe-me/core/live`](https://grzehub.github.io/describe-me/api.html#entry-core-live) | the live page's state and its messages to the viewer, for the live preview |
33
63
 
34
- Such adapters call `recorder.beforeInteraction()` before they change the page,
35
- so a deferred render frame is taken first. As the capture's `generation`, they
36
- pass `recorder.generation`, read when the interaction began, so a capture that
37
- outlives its test is dropped. `recorder.flush()` waits for the captures in
38
- flight and takes a deferred render frame.
64
+ [How it works](https://grzehub.github.io/describe-me/how-it-works.html) explains
65
+ the recorder and the output directory.
39
66
 
40
- The manifest types live in `@describe-me/core/types`, for tools that want to
41
- read `.describe-me/manifest.json` themselves. Stored snapshots point into
42
- `assets/` and `styles/` through `ASSET_URL_PREFIX` and `STYLE_URL_PREFIX`
43
- ([The output directory](https://grzehub.github.io/describe-me/how-it-works.html#storage) in
44
- the docs). `@describe-me/core/css-references` lists the `url()` and
45
- `@import` references in CSS text.
67
+ ## License
46
68
 
47
- See the [docs](https://grzehub.github.io/describe-me/) for the full picture.
69
+ MIT
@@ -0,0 +1,2 @@
1
+ /** Whether a thrown value is the live page's stop signal rather than a failure. */
2
+ export declare function isLiveStop(value: unknown): boolean;
@@ -0,0 +1,5 @@
1
+ import { liveState } from './live-state.js';
2
+ /** Whether a thrown value is the live page's stop signal rather than a failure. */
3
+ export function isLiveStop(value) {
4
+ return value === liveState().stop;
5
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A value that takes any property access, call or `new` and gives back an
3
+ * inert value again. Test code after the first render never runs, but it must
4
+ * import, and hooks may call it. `await` ends on it, because it has no `then`.
5
+ */
6
+ export declare function liveInert(): unknown;
@@ -0,0 +1,34 @@
1
+ let inert;
2
+ function createInert() {
3
+ // A plain function, so the value can be called and constructed.
4
+ const value = new Proxy(function inertTarget() { }, {
5
+ get(_target, key) {
6
+ if (key === 'then') {
7
+ return undefined;
8
+ }
9
+ if (key === Symbol.toPrimitive) {
10
+ return () => '';
11
+ }
12
+ if (key === Symbol.iterator) {
13
+ return () => [][Symbol.iterator]();
14
+ }
15
+ return value;
16
+ },
17
+ apply() {
18
+ return value;
19
+ },
20
+ construct() {
21
+ return value;
22
+ },
23
+ });
24
+ return value;
25
+ }
26
+ /**
27
+ * A value that takes any property access, call or `new` and gives back an
28
+ * inert value again. Test code after the first render never runs, but it must
29
+ * import, and hooks may call it. `await` ends on it, because it has no `then`.
30
+ */
31
+ export function liveInert() {
32
+ inert ??= createInert();
33
+ return inert;
34
+ }
@@ -0,0 +1,20 @@
1
+ import type { LiveStatus } from './types.js';
2
+ /** What the height observer keeps between animation frames. */
3
+ interface LiveHeightState {
4
+ observing: boolean;
5
+ queued: boolean;
6
+ reported: number | null;
7
+ }
8
+ /** Everything one live page shares, whichever copy of core reads it. */
9
+ export interface LiveState {
10
+ status: LiveStatus | null;
11
+ stop: Error;
12
+ height: LiveHeightState;
13
+ }
14
+ /**
15
+ * The state of the live page. Vite can serve two copies of core, one bundled
16
+ * with an adapter and one from source, so the state lives on `globalThis`,
17
+ * like the recorder.
18
+ */
19
+ export declare function liveState(): LiveState;
20
+ export {};
@@ -0,0 +1,20 @@
1
+ const LIVE_KEY = Symbol.for('describe-me.live');
2
+ function createStop() {
3
+ const stop = new Error('describe-me: the live preview stops the test at its first render');
4
+ stop.name = 'LiveStop';
5
+ return stop;
6
+ }
7
+ /**
8
+ * The state of the live page. Vite can serve two copies of core, one bundled
9
+ * with an adapter and one from source, so the state lives on `globalThis`,
10
+ * like the recorder.
11
+ */
12
+ export function liveState() {
13
+ const scope = globalThis;
14
+ scope[LIVE_KEY] ??= {
15
+ status: null,
16
+ stop: createStop(),
17
+ height: { observing: false, queued: false, reported: null },
18
+ };
19
+ return scope[LIVE_KEY];
20
+ }
@@ -0,0 +1,3 @@
1
+ import type { LiveStatus } from './types.js';
2
+ /** The status the live page reported so far, or null before the first one. */
3
+ export declare function liveStatus(): LiveStatus | null;
@@ -0,0 +1,5 @@
1
+ import { liveState } from './live-state.js';
2
+ /** The status the live page reported so far, or null before the first one. */
3
+ export function liveStatus() {
4
+ return liveState().status;
5
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The page's one stop signal. The live render throws it to end the test at
3
+ * its first render, and the runner tells it apart from a failure.
4
+ */
5
+ export declare function liveStop(): Error;
@@ -0,0 +1,8 @@
1
+ import { liveState } from './live-state.js';
2
+ /**
3
+ * The page's one stop signal. The live render throws it to end the test at
4
+ * its first render, and the runner tells it apart from a failure.
5
+ */
6
+ export function liveStop() {
7
+ return liveState().stop;
8
+ }
package/dist/live.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { isLiveStop } from './is-live-stop.js';
2
+ export { liveInert } from './live-inert.js';
3
+ export { liveStatus } from './live-status.js';
4
+ export { liveStop } from './live-stop.js';
5
+ export { observeLiveHeight } from './observe-live-height.js';
6
+ export { postLiveMessage } from './post-live-message.js';
7
+ export { reportLiveStatus } from './report-live-status.js';
package/dist/live.js ADDED
@@ -0,0 +1,8 @@
1
+ // For the live preview page, not for tests: its status, messages, stop signal and height.
2
+ export { isLiveStop } from './is-live-stop.js';
3
+ export { liveInert } from './live-inert.js';
4
+ export { liveStatus } from './live-status.js';
5
+ export { liveStop } from './live-stop.js';
6
+ export { observeLiveHeight } from './observe-live-height.js';
7
+ export { postLiveMessage } from './post-live-message.js';
8
+ export { reportLiveStatus } from './report-live-status.js';
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Reports the height of the page's content now and whenever it changes, so
3
+ * the viewer can fit its frame. `scrollHeight` would never drop below the
4
+ * height of the frame itself. Runs once per page.
5
+ */
6
+ export declare function observeLiveHeight(): void;
@@ -0,0 +1,83 @@
1
+ import { liveState } from './live-state.js';
2
+ import { postLiveMessage } from './post-live-message.js';
3
+ /** Room under the lowest box, as the viewer leaves under a recorded frame. */
4
+ const BOTTOM_MARGIN = 20;
5
+ const SKIPPED_TAGS = new Set(['SCRIPT', 'NOSCRIPT', 'STYLE', 'LINK', 'META', 'TITLE', 'TEMPLATE']);
6
+ function hasOwnText(element) {
7
+ return Array.from(element.childNodes).some((node) => node.nodeType === Node.TEXT_NODE && (node.textContent ?? '').trim() !== '');
8
+ }
9
+ /**
10
+ * Whether an element draws something itself. Layout wrappers can stretch to
11
+ * the height of the frame, and would keep the height from ever shrinking.
12
+ */
13
+ function paints(element) {
14
+ if (element.children.length === 0 || hasOwnText(element)) {
15
+ return true;
16
+ }
17
+ const style = getComputedStyle(element);
18
+ const hasBackground = style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent';
19
+ const hasBorder = parseFloat(style.borderTopWidth) > 0 || parseFloat(style.borderBottomWidth) > 0;
20
+ return hasBackground || hasBorder || style.boxShadow !== 'none';
21
+ }
22
+ function* elementsIn(root) {
23
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
24
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
25
+ yield node;
26
+ }
27
+ }
28
+ /** The bottom of the lowest painted element in page pixels, plus the margin, or 0 for none. */
29
+ function contentHeight() {
30
+ let bottom = 0;
31
+ for (const element of elementsIn(document.body)) {
32
+ if (SKIPPED_TAGS.has(element.tagName) || !paints(element)) {
33
+ continue;
34
+ }
35
+ const rect = element.getBoundingClientRect();
36
+ if (rect.width > 0 && rect.height > 0) {
37
+ bottom = Math.max(bottom, rect.bottom + window.scrollY);
38
+ }
39
+ }
40
+ return bottom > 0 ? Math.ceil(bottom + BOTTOM_MARGIN) : 0;
41
+ }
42
+ function postHeight() {
43
+ const height = liveState().height;
44
+ const measured = contentHeight();
45
+ if (measured === height.reported) {
46
+ return;
47
+ }
48
+ height.reported = measured;
49
+ postLiveMessage({ type: 'size', height: measured });
50
+ }
51
+ function schedule() {
52
+ const height = liveState().height;
53
+ if (height.queued) {
54
+ return;
55
+ }
56
+ height.queued = true;
57
+ requestAnimationFrame(() => {
58
+ height.queued = false;
59
+ postHeight();
60
+ });
61
+ }
62
+ /**
63
+ * Reports the height of the page's content now and whenever it changes, so
64
+ * the viewer can fit its frame. `scrollHeight` would never drop below the
65
+ * height of the frame itself. Runs once per page.
66
+ */
67
+ export function observeLiveHeight() {
68
+ const height = liveState().height;
69
+ if (height.observing) {
70
+ return;
71
+ }
72
+ height.observing = true;
73
+ new ResizeObserver(schedule).observe(document.body);
74
+ new MutationObserver(schedule).observe(document.body, {
75
+ subtree: true,
76
+ childList: true,
77
+ attributes: true,
78
+ characterData: true,
79
+ });
80
+ window.addEventListener('resize', schedule);
81
+ void document.fonts.ready.then(schedule);
82
+ postHeight();
83
+ }
@@ -0,0 +1,7 @@
1
+ import { type LiveReport } from './types.js';
2
+ /**
3
+ * Posts a report to the viewer that frames the live page. It goes only to the
4
+ * origin the page URL names, so another site that frames the page learns
5
+ * nothing.
6
+ */
7
+ export declare function postLiveMessage(report: LiveReport): void;
@@ -0,0 +1,32 @@
1
+ import { LIVE_MESSAGE_SOURCE } from './types.js';
2
+ /** The viewer's origin from the page URL, when it is exactly the origin of an http(s) URL. */
3
+ function viewerOrigin() {
4
+ const origin = new URLSearchParams(window.location.search).get('origin');
5
+ if (origin === null) {
6
+ return null;
7
+ }
8
+ try {
9
+ const url = new URL(origin);
10
+ const web = url.protocol === 'http:' || url.protocol === 'https:';
11
+ return web && url.origin === origin ? origin : null;
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ /**
18
+ * Posts a report to the viewer that frames the live page. It goes only to the
19
+ * origin the page URL names, so another site that frames the page learns
20
+ * nothing.
21
+ */
22
+ export function postLiveMessage(report) {
23
+ if (window.parent === window) {
24
+ return;
25
+ }
26
+ const origin = viewerOrigin();
27
+ if (origin === null) {
28
+ return;
29
+ }
30
+ const message = { source: LIVE_MESSAGE_SOURCE, ...report };
31
+ window.parent.postMessage(message, origin);
32
+ }
@@ -0,0 +1,6 @@
1
+ import type { LiveStatus } from './types.js';
2
+ /**
3
+ * Reports how the live page ended up, on `<html data-describe-me-live>` and to
4
+ * the viewer. The first status of a page is final, so later calls do nothing.
5
+ */
6
+ export declare function reportLiveStatus(status: LiveStatus, detail?: string): void;
@@ -0,0 +1,28 @@
1
+ import { liveState } from './live-state.js';
2
+ import { observeLiveHeight } from './observe-live-height.js';
3
+ import { postLiveMessage } from './post-live-message.js';
4
+ /** Writes the status on the page itself, so the page is never blank and the viewer can fit it. */
5
+ function showStatus(status, detail) {
6
+ const note = document.createElement('p');
7
+ note.textContent = detail === undefined ? status : `${status}: ${detail}`;
8
+ note.style.whiteSpace = 'pre-wrap';
9
+ document.body.append(note);
10
+ }
11
+ /**
12
+ * Reports how the live page ended up, on `<html data-describe-me-live>` and to
13
+ * the viewer. The first status of a page is final, so later calls do nothing.
14
+ */
15
+ export function reportLiveStatus(status, detail) {
16
+ const state = liveState();
17
+ if (state.status !== null) {
18
+ return;
19
+ }
20
+ state.status = status;
21
+ document.documentElement.dataset.describeMeLive = status;
22
+ const report = detail === undefined ? { type: 'status', status } : { type: 'status', status, detail };
23
+ postLiveMessage(report);
24
+ if (status !== 'mounted') {
25
+ showStatus(status, detail);
26
+ }
27
+ observeLiveHeight();
28
+ }
package/dist/types.d.ts CHANGED
@@ -178,6 +178,11 @@ export interface Manifest {
178
178
  generator?: ManifestGenerator;
179
179
  generatedAt: string;
180
180
  root: string;
181
+ /**
182
+ * The Vitest config file of the run, relative to `root`, with `/` separators.
183
+ * Absent when the run had no config file, and in manifests written before 0.6.
184
+ */
185
+ configFile?: string;
181
186
  modules: ManifestModule[];
182
187
  /** Keyed by component name, as reported by the framework adapter. */
183
188
  components: Record<string, ComponentDoc>;
@@ -249,3 +254,32 @@ export interface ManifestDiagnostics {
249
254
  /** Problems in the project's setup that can make tests fail. */
250
255
  setupWarnings: string[];
251
256
  }
257
+ /** How the live preview page ended up. */
258
+ export type LiveStatus = 'mounted' | 'no-render' | 'not-found' | 'import-failed' | 'setup-failed' | 'failed';
259
+ /** The `source` of every message the live preview page posts. */
260
+ export declare const LIVE_MESSAGE_SOURCE: "describe-me-live";
261
+ /** The version of the contract between the viewer, the plugin's live mode and the live page. */
262
+ export declare const LIVE_PROTOCOL: 1;
263
+ /** What the live page reports, without the `source` field. */
264
+ export type LiveReport = {
265
+ type: 'status';
266
+ status: LiveStatus;
267
+ detail?: string;
268
+ } | {
269
+ type: 'size';
270
+ height: number;
271
+ } | {
272
+ type: 'action';
273
+ name: string;
274
+ args: string[];
275
+ } | {
276
+ type: 'error';
277
+ message: string;
278
+ } | {
279
+ type: 'notice';
280
+ message: string;
281
+ };
282
+ /** A message the live page posts to the viewer around it. */
283
+ export type LiveMessage = LiveReport & {
284
+ source: typeof LIVE_MESSAGE_SOURCE;
285
+ };
package/dist/types.js CHANGED
@@ -23,3 +23,7 @@ export const STYLE_URL_PREFIX = 'describe-me-style:';
23
23
  export const META_KEY = 'describeMe';
24
24
  /** The name adapters fall back to when a component has no usable name. */
25
25
  export const ANONYMOUS_COMPONENT = 'Anonymous';
26
+ /** The `source` of every message the live preview page posts. */
27
+ export const LIVE_MESSAGE_SOURCE = 'describe-me-live';
28
+ /** The version of the contract between the viewer, the plugin's live mode and the live page. */
29
+ export const LIVE_PROTOCOL = 1;
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@describe-me/core",
3
- "version": "0.5.3",
4
- "description": "Recorder and manifest types for describe-me: living component docs from your tests",
3
+ "version": "0.6.0",
4
+ "description": "Living component documentation from your Vitest tests: the recorder and manifest types of describe-me",
5
5
  "keywords": [
6
6
  "vitest",
7
+ "react",
7
8
  "storybook",
9
+ "storybook alternative",
8
10
  "component documentation",
9
- "visual testing",
11
+ "living documentation",
12
+ "jsdom",
10
13
  "testing"
11
14
  ],
12
15
  "license": "MIT",
@@ -42,6 +45,10 @@
42
45
  "./css-references": {
43
46
  "types": "./dist/css-references.d.ts",
44
47
  "default": "./dist/css-references.js"
48
+ },
49
+ "./live": {
50
+ "types": "./dist/live.d.ts",
51
+ "default": "./dist/live.js"
45
52
  }
46
53
  },
47
54
  "files": [