@describe-me/core 0.5.4 → 0.6.1

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
@@ -15,7 +15,7 @@ story, and each step is a frame you can scrub through. Stories cannot rot,
15
15
  because they are tests. If one drifts from the component, CI goes red.
16
16
 
17
17
  Status: prototype. React 18 and 19, Vitest 4 and 5 in browser mode or jsdom,
18
- static snapshots.
18
+ recorded frames and Live in the viewer.
19
19
 
20
20
  ## Which package do I need
21
21
 
@@ -53,12 +53,13 @@ The framework-agnostic heart of describe-me. Snapshots are serialized DOM via
53
53
  `rrweb-snapshot`, so nothing here knows about React. You need it directly only
54
54
  to write an adapter for another framework or to read the output yourself.
55
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 |
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 |
62
63
 
63
64
  [How it works](https://grzehub.github.io/describe-me/how-it-works.html) explains
64
65
  the recorder and the output directory.
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@describe-me/core",
3
- "version": "0.5.4",
3
+ "version": "0.6.1",
4
4
  "description": "Living component documentation from your Vitest tests: the recorder and manifest types of describe-me",
5
5
  "keywords": [
6
6
  "vitest",
@@ -45,6 +45,10 @@
45
45
  "./css-references": {
46
46
  "types": "./dist/css-references.d.ts",
47
47
  "default": "./dist/css-references.js"
48
+ },
49
+ "./live": {
50
+ "types": "./dist/live.d.ts",
51
+ "default": "./dist/live.js"
48
52
  }
49
53
  },
50
54
  "files": [