@describe-me/core 0.5.0-next.1 → 0.5.0-next.2

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
@@ -28,6 +28,12 @@ await step('open the menu', () => screen.getByRole('button', { name: 'Menu' }).c
28
28
  `elementLabel()` names a DOM element the way frame labels do, e.g.
29
29
  `button "Save"`, for adapters that record their own interactions.
30
30
 
31
+ Such adapters call `recorder.beforeInteraction()` before they change the page,
32
+ so a deferred render frame is taken first. As the capture's `generation`, they
33
+ pass `recorder.generation`, read when the interaction began, so a capture that
34
+ outlives its test is dropped. `recorder.flush()` waits for the captures in
35
+ flight and takes a deferred render frame.
36
+
31
37
  The manifest types live in `@describe-me/core/types`, for tools that want to
32
38
  read `.describe-me/manifest.json` themselves. Stored snapshots point into
33
39
  `assets/` and `styles/` through `ASSET_URL_PREFIX` and `STYLE_URL_PREFIX`
@@ -0,0 +1,19 @@
1
+ import type { Frame, FrameKind } from './types.js';
2
+ /**
3
+ * The place of one `capture()` call among the test's frames, reserved when it
4
+ * is called. Unfilled while `snapshot` is undefined.
5
+ */
6
+ export interface FrameSlot {
7
+ kind: FrameKind;
8
+ label: string;
9
+ meta?: Record<string, unknown>;
10
+ at?: number;
11
+ snapshot?: string;
12
+ }
13
+ /**
14
+ * The frames a test hands over, in the order their captures were called.
15
+ * Unfilled slots are left out. A closing frame equal to the frame before it
16
+ * is dropped, and a step equal to the frame before it names that frame
17
+ * instead. The slots are left unchanged.
18
+ */
19
+ export declare function compactFrames(slots: readonly FrameSlot[]): Frame[];
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The frames a test hands over, in the order their captures were called.
3
+ * Unfilled slots are left out. A closing frame equal to the frame before it
4
+ * is dropped, and a step equal to the frame before it names that frame
5
+ * instead. The slots are left unchanged.
6
+ */
7
+ export function compactFrames(slots) {
8
+ const kept = [];
9
+ for (const slot of slots) {
10
+ const last = kept.at(-1);
11
+ // The test went on after `cleanup()`, so its closing frame names the state before it.
12
+ if (slot.kind !== 'end' && last?.kind === 'end') {
13
+ last.kind = 'step';
14
+ last.label = 'before cleanup()';
15
+ }
16
+ if (slot.snapshot === undefined) {
17
+ continue;
18
+ }
19
+ if (slot.kind === 'end' && last?.snapshot === slot.snapshot) {
20
+ continue;
21
+ }
22
+ // A step whose body already produced this exact DOM (e.g. via an action) just names that frame.
23
+ if (slot.kind === 'step' && last?.snapshot === slot.snapshot) {
24
+ last.label = `${slot.label} · ${last.label}`;
25
+ last.kind = 'step';
26
+ continue;
27
+ }
28
+ kept.push({
29
+ id: `f${kept.length}`,
30
+ kind: slot.kind,
31
+ label: slot.label,
32
+ at: slot.at ?? 0,
33
+ meta: slot.meta,
34
+ snapshot: slot.snapshot,
35
+ });
36
+ }
37
+ return kept;
38
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Watches the page while a render frame waits for its loader to go, and calls
3
+ * `onReady` once nothing matches `selector` and the page shows content. After
4
+ * `timeout` ms it stops watching and leaves the frame deferred, so the next
5
+ * interaction or the end of the test takes it.
6
+ */
7
+ export declare class PendingRender {
8
+ private observer;
9
+ private timer;
10
+ constructor(selector: string, timeout: number | undefined, onReady: () => void);
11
+ /**
12
+ * Whether the frame can be taken: nothing matches `selector` and the page
13
+ * shows content. The selector goes first, because it is cheap while the
14
+ * loader is shown.
15
+ */
16
+ static isReady(selector: string): boolean;
17
+ /** Stop watching. Safe to call twice. */
18
+ dispose(): void;
19
+ }
@@ -0,0 +1,51 @@
1
+ import { pageHasContent } from './page-has-content.js';
2
+ import { realTimers } from './real-timers.js';
3
+ /**
4
+ * How long to watch for the loader to go, in ms of real time. After that the
5
+ * frame waits as in `'lazy'`.
6
+ */
7
+ const DEFAULT_TIMEOUT = 2000;
8
+ const WATCHED = {
9
+ childList: true,
10
+ subtree: true,
11
+ attributes: true,
12
+ characterData: true,
13
+ };
14
+ /**
15
+ * Watches the page while a render frame waits for its loader to go, and calls
16
+ * `onReady` once nothing matches `selector` and the page shows content. After
17
+ * `timeout` ms it stops watching and leaves the frame deferred, so the next
18
+ * interaction or the end of the test takes it.
19
+ */
20
+ export class PendingRender {
21
+ observer;
22
+ timer;
23
+ constructor(selector, timeout, onReady) {
24
+ this.observer = new MutationObserver(() => {
25
+ if (!PendingRender.isReady(selector)) {
26
+ return;
27
+ }
28
+ this.dispose();
29
+ onReady();
30
+ });
31
+ this.observer.observe(document, WATCHED);
32
+ this.timer = realTimers.setTimeout(() => this.dispose(), timeout ?? DEFAULT_TIMEOUT);
33
+ }
34
+ /**
35
+ * Whether the frame can be taken: nothing matches `selector` and the page
36
+ * shows content. The selector goes first, because it is cheap while the
37
+ * loader is shown.
38
+ */
39
+ static isReady(selector) {
40
+ return document.querySelector(selector) === null && pageHasContent();
41
+ }
42
+ /** Stop watching. Safe to call twice. */
43
+ dispose() {
44
+ this.observer?.disconnect();
45
+ this.observer = null;
46
+ if (this.timer !== null) {
47
+ realTimers.clearTimeout(this.timer);
48
+ this.timer = null;
49
+ }
50
+ }
51
+ }
@@ -1,8 +1,10 @@
1
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.
2
+ * `setTimeout`, `clearTimeout` and `performance.now()` as they were before any
3
+ * test faked them, so `vi.useFakeTimers()` neither hangs a capture nor skews
4
+ * frame times.
4
5
  */
5
6
  export declare const realTimers: {
6
7
  setTimeout: typeof setTimeout;
8
+ clearTimeout: typeof clearTimeout;
7
9
  now: () => number;
8
10
  };
@@ -3,6 +3,11 @@ function originalSetTimeout() {
3
3
  const current = globalThis.setTimeout;
4
4
  return current.clock ? current.clock._setTimeout : current;
5
5
  }
6
+ /** The global `clearTimeout`, unwrapped the same way. A faked one would not clear a real timer. */
7
+ function originalClearTimeout() {
8
+ const current = globalThis.clearTimeout;
9
+ return current.clock ? current.clock._clearTimeout : current;
10
+ }
6
11
  /** The global `performance`, unwrapped the same way. */
7
12
  function originalPerformance() {
8
13
  const current = globalThis.performance;
@@ -11,13 +16,16 @@ function originalPerformance() {
11
16
  // Bound, because browsers throw "Illegal invocation" when `setTimeout` is
12
17
  // called without its global as `this`.
13
18
  const setTimeoutAtLoad = originalSetTimeout().bind(globalThis);
19
+ const clearTimeoutAtLoad = originalClearTimeout().bind(globalThis);
14
20
  // Fake timers replace `performance` as a whole object, so this reference stays real.
15
21
  const performanceAtLoad = originalPerformance();
16
22
  /**
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.
23
+ * `setTimeout`, `clearTimeout` and `performance.now()` as they were before any
24
+ * test faked them, so `vi.useFakeTimers()` neither hangs a capture nor skews
25
+ * frame times.
19
26
  */
20
27
  export const realTimers = {
21
28
  setTimeout: setTimeoutAtLoad,
29
+ clearTimeout: clearTimeoutAtLoad,
22
30
  now: () => performanceAtLoad.now(),
23
31
  };
@@ -1,25 +1,48 @@
1
- import type { CaptureOptions, ComponentInfo, FrameKind, TestRecord } from './types.js';
1
+ import type { CaptureOptions, ComponentInfo, FrameKind, RecorderOptions, TestRecord } from './types.js';
2
2
  type Teardown = () => void;
3
3
  /**
4
4
  * Test-side recorder, running wherever the tests run: the browser-mode iframe
5
- * or jsdom. `begin()` in beforeEach, `end()` then `teardown()` in afterEach.
6
- * Framework adapters call `capture()`, `setComponent()` and `onTeardown()`.
5
+ * or jsdom. `begin()` in beforeEach. `flush()`, the closing capture, `end()`
6
+ * then `teardown()` in afterEach. Framework adapters call `capture()`,
7
+ * `beforeInteraction()`, `setComponent()` and `onTeardown()`.
7
8
  */
8
9
  declare class Recorder {
9
- private frames;
10
+ private slots;
11
+ private currentGeneration;
12
+ private inFlight;
13
+ private deferred;
14
+ private pending;
15
+ private renderFrame;
10
16
  private component?;
11
17
  private startedAt;
12
18
  private active;
13
19
  private closed;
14
- private lastJson;
15
- private seq;
16
20
  private teardowns;
17
21
  begin(): void;
18
22
  get isActive(): boolean;
23
+ /**
24
+ * Changes with every test. An interaction reads it when it begins and hands
25
+ * it to `capture()`.
26
+ */
27
+ get generation(): number;
28
+ /**
29
+ * Choose when the render frame is taken, from the next render capture on.
30
+ * The setup files call it with the plugin's options. An invalid `pending`
31
+ * selector throws.
32
+ */
33
+ configure(options: RecorderOptions): void;
19
34
  setComponent(info: ComponentInfo): void;
20
35
  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;
36
+ /**
37
+ * Take a deferred render frame now. Adapters call it before an interaction
38
+ * changes the page: an event, a `step()` body, another render, an unmount.
39
+ */
40
+ beforeInteraction(): void;
41
+ /**
42
+ * Wait for the captures in flight, then take a deferred render frame after
43
+ * one settle. Resolves to whether it took a deferred render frame.
44
+ */
45
+ flush(): Promise<boolean>;
23
46
  end(): TestRecord;
24
47
  /**
25
48
  * Register work that must run after the test's frames are handed over, such
@@ -29,6 +52,14 @@ declare class Recorder {
29
52
  onTeardown(callback: Teardown): void;
30
53
  /** Run the registered teardowns. Called by the runner integration after `end()`. */
31
54
  teardown(): void;
55
+ /**
56
+ * Hold the render frame back until `mode` allows it. `capture()` already
57
+ * took any earlier one.
58
+ */
59
+ private defer;
60
+ private clearDeferred;
61
+ private settleThenFill;
62
+ private fill;
32
63
  }
33
64
  export declare const recorder: Recorder;
34
65
  export {};
package/dist/recorder.js CHANGED
@@ -1,34 +1,74 @@
1
+ import { compactFrames } from './compact-frames.js';
1
2
  import { pageHasContent } from './page-has-content.js';
3
+ import { PendingRender } from './pending-render.js';
2
4
  import { realTimers } from './real-timers.js';
3
5
  import { serializeDocument } from './serialize-document.js';
4
6
  import { settle } from './settle.js';
7
+ /** Fails in the setup file rather than in the first test that renders. */
8
+ function assertValidSelector(selector) {
9
+ if (typeof document === 'undefined') {
10
+ return;
11
+ }
12
+ try {
13
+ document.querySelector(selector);
14
+ }
15
+ catch {
16
+ throw new Error(`describe-me: renderFrame.pending is not a valid CSS selector: "${selector}"`);
17
+ }
18
+ }
5
19
  /**
6
20
  * Test-side recorder, running wherever the tests run: the browser-mode iframe
7
- * or jsdom. `begin()` in beforeEach, `end()` then `teardown()` in afterEach.
8
- * Framework adapters call `capture()`, `setComponent()` and `onTeardown()`.
21
+ * or jsdom. `begin()` in beforeEach. `flush()`, the closing capture, `end()`
22
+ * then `teardown()` in afterEach. Framework adapters call `capture()`,
23
+ * `beforeInteraction()`, `setComponent()` and `onTeardown()`.
9
24
  */
10
25
  class Recorder {
11
- frames = [];
26
+ // One per capture, in call order, so a frame sits where it was asked for.
27
+ slots = [];
28
+ currentGeneration = 0;
29
+ inFlight = new Set();
30
+ // The render frame `renderFrame` holds back, until an interaction or `flush()` takes it.
31
+ deferred = null;
32
+ pending = null;
33
+ renderFrame = 'eager';
12
34
  component;
13
35
  startedAt = 0;
14
36
  active = false;
15
37
  // Once `cleanup()` or the setup file took the closing frame, a second one is skipped.
16
38
  closed = false;
17
- lastJson = '';
18
- seq = 0;
19
39
  teardowns = new Set();
20
40
  begin() {
21
- this.frames = [];
41
+ this.currentGeneration++;
42
+ this.slots = [];
43
+ this.inFlight.clear();
44
+ this.clearDeferred();
22
45
  this.component = undefined;
23
46
  this.startedAt = realTimers.now();
24
47
  this.active = true;
25
48
  this.closed = false;
26
- this.lastJson = '';
27
- this.seq = 0;
28
49
  }
29
50
  get isActive() {
30
51
  return this.active;
31
52
  }
53
+ /**
54
+ * Changes with every test. An interaction reads it when it begins and hands
55
+ * it to `capture()`.
56
+ */
57
+ get generation() {
58
+ return this.currentGeneration;
59
+ }
60
+ /**
61
+ * Choose when the render frame is taken, from the next render capture on.
62
+ * The setup files call it with the plugin's options. An invalid `pending`
63
+ * selector throws.
64
+ */
65
+ configure(options) {
66
+ const renderFrame = options.renderFrame ?? 'eager';
67
+ if (typeof renderFrame === 'object') {
68
+ assertValidSelector(renderFrame.pending);
69
+ }
70
+ this.renderFrame = renderFrame;
71
+ }
32
72
  setComponent(info) {
33
73
  if (!this.component) {
34
74
  this.component = info;
@@ -38,62 +78,85 @@ class Recorder {
38
78
  if (!this.active) {
39
79
  return;
40
80
  }
41
- if (kind !== 'end' && this.closed) {
42
- this.reopen();
43
- }
44
- if (kind === 'end' && (this.frames.length === 0 || this.closed)) {
81
+ const generation = options.generation ?? this.currentGeneration;
82
+ // The interaction began in a test that has ended.
83
+ if (generation !== this.currentGeneration) {
45
84
  return;
46
85
  }
47
- if (options.settle !== false) {
48
- await settle();
86
+ // A call site that forgot `beforeInteraction()` still gets its render frame here.
87
+ this.beforeInteraction();
88
+ if (kind !== 'end') {
89
+ this.closed = false;
90
+ }
91
+ // Before any `await`, so a test that recorded nothing costs nothing.
92
+ if (kind === 'end' && (this.slots.length === 0 || this.closed)) {
93
+ return;
49
94
  }
50
95
  if (kind === 'end') {
51
96
  this.closed = true;
52
- // After `cleanup()` or `unmount()` there is nothing left to show.
53
- if (!pageHasContent()) {
54
- return;
55
- }
56
97
  }
57
- const json = serializeDocument();
58
- if (json === null) {
98
+ const slot = { kind, label, meta };
99
+ this.slots.push(slot);
100
+ if (kind === 'render' && this.renderFrame !== 'eager') {
101
+ this.defer(slot, this.renderFrame);
59
102
  return;
60
103
  }
61
- // The closing frame is only interesting if something changed since the last one.
62
- if (kind === 'end' && json === this.lastJson) {
104
+ // Filled before `capture()` returns: Testing Library frames depend on it.
105
+ if (options.settle === false) {
106
+ this.fill(slot);
63
107
  return;
64
108
  }
65
- // A step whose body already produced this exact DOM (e.g. via an action) just names that frame.
66
- if (kind === 'step' && json === this.lastJson && this.frames.length) {
67
- const last = this.frames[this.frames.length - 1];
68
- last.label = `${label} · ${last.label}`;
69
- last.kind = 'step';
109
+ const filling = this.settleThenFill(slot, generation);
110
+ this.inFlight.add(filling);
111
+ try {
112
+ await filling;
113
+ }
114
+ finally {
115
+ this.inFlight.delete(filling);
116
+ }
117
+ }
118
+ /**
119
+ * Take a deferred render frame now. Adapters call it before an interaction
120
+ * changes the page: an event, a `step()` body, another render, an unmount.
121
+ */
122
+ beforeInteraction() {
123
+ const slot = this.deferred;
124
+ if (slot === null) {
70
125
  return;
71
126
  }
72
- this.lastJson = json;
73
- this.frames.push({
74
- id: `f${this.seq++}`,
75
- kind,
76
- label,
77
- at: Math.round(realTimers.now() - this.startedAt),
78
- meta,
79
- snapshot: json,
80
- });
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()';
127
+ this.clearDeferred();
128
+ this.fill(slot);
129
+ }
130
+ /**
131
+ * Wait for the captures in flight, then take a deferred render frame after
132
+ * one settle. Resolves to whether it took a deferred render frame.
133
+ */
134
+ async flush() {
135
+ while (this.inFlight.size > 0) {
136
+ await Promise.allSettled(this.inFlight);
137
+ }
138
+ const slot = this.deferred;
139
+ // Nothing to settle for, so empty and eager tests stay free.
140
+ if (slot === null) {
141
+ return false;
89
142
  }
143
+ const generation = this.currentGeneration;
144
+ await settle();
145
+ // The test ended, or an interaction took the frame, while this settled.
146
+ if (generation !== this.currentGeneration || this.deferred !== slot) {
147
+ return false;
148
+ }
149
+ this.beforeInteraction();
150
+ return true;
90
151
  }
91
152
  end() {
92
153
  this.active = false;
154
+ this.currentGeneration++;
155
+ this.clearDeferred();
93
156
  // The reporter needs the origin rrweb resolved every URL against. Optional
94
157
  // chaining, because a DOM environment is not guaranteed to define `location`.
95
158
  return {
96
- frames: this.frames,
159
+ frames: compactFrames(this.slots),
97
160
  component: this.component,
98
161
  origin: globalThis.location?.origin,
99
162
  };
@@ -112,6 +175,48 @@ class Recorder {
112
175
  callback();
113
176
  }
114
177
  }
178
+ /**
179
+ * Hold the render frame back until `mode` allows it. `capture()` already
180
+ * took any earlier one.
181
+ */
182
+ defer(slot, mode) {
183
+ if (mode === 'lazy') {
184
+ this.deferred = slot;
185
+ return;
186
+ }
187
+ if (PendingRender.isReady(mode.pending)) {
188
+ this.fill(slot);
189
+ return;
190
+ }
191
+ this.deferred = slot;
192
+ // Without an observer the frame waits like in lazy mode.
193
+ if (typeof MutationObserver !== 'undefined') {
194
+ this.pending = new PendingRender(mode.pending, mode.timeout, () => this.beforeInteraction());
195
+ }
196
+ }
197
+ clearDeferred() {
198
+ this.deferred = null;
199
+ this.pending?.dispose();
200
+ this.pending = null;
201
+ }
202
+ async settleThenFill(slot, generation) {
203
+ await settle();
204
+ if (generation === this.currentGeneration) {
205
+ this.fill(slot);
206
+ }
207
+ }
208
+ fill(slot) {
209
+ // After `cleanup()` or `unmount()` there is nothing left to show.
210
+ if (slot.kind === 'end' && !pageHasContent()) {
211
+ return;
212
+ }
213
+ const snapshot = serializeDocument();
214
+ if (snapshot === null) {
215
+ return;
216
+ }
217
+ slot.snapshot = snapshot;
218
+ slot.at = Math.round(realTimers.now() - this.startedAt);
219
+ }
115
220
  }
116
221
  const RECORDER_KEY = Symbol.for('describe-me.recorder');
117
222
  /**
package/dist/step.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { recorder } from './recorder.js';
2
2
  /** Name a phase of the test. The DOM is captured after `fn` resolves. */
3
3
  export async function step(label, fn) {
4
+ const generation = recorder.generation;
5
+ recorder.beforeInteraction();
4
6
  const result = await fn();
5
- await recorder.capture('step', label);
7
+ await recorder.capture('step', label, undefined, { generation });
6
8
  return result;
7
9
  }
package/dist/types.d.ts CHANGED
@@ -38,6 +38,22 @@ export declare const ASSET_URL_PREFIX: "describe-me-asset:";
38
38
  * the viewer joins them back in order before replaying.
39
39
  */
40
40
  export declare const STYLE_URL_PREFIX: "describe-me-style:";
41
+ /**
42
+ * When the render frame is taken. `'eager'`: right after mount. `'lazy'`:
43
+ * right before the test's next interaction, or at the end of the test.
44
+ * `{ pending, timeout }`: as soon as nothing matches the CSS selector
45
+ * `pending` and the page shows content, falling back to `'lazy'` after
46
+ * `timeout` ms (default 2000) or at an earlier interaction.
47
+ */
48
+ export type RenderFrameMode = 'eager' | 'lazy' | {
49
+ pending: string;
50
+ timeout?: number;
51
+ };
52
+ /** Options for `recorder.configure()`. */
53
+ export interface RecorderOptions {
54
+ /** When the render frame is taken. Default: `'eager'`. */
55
+ renderFrame?: RenderFrameMode;
56
+ }
41
57
  /** Options for `recorder.capture()`. */
42
58
  export interface CaptureOptions {
43
59
  /**
@@ -45,16 +61,22 @@ export interface CaptureOptions {
45
61
  * Default: true. Pass false when the caller knows the DOM is already
46
62
  * committed (e.g. right after Testing Library's `act()`-wrapped `rerender`):
47
63
  * the snapshot is then taken and the frame recorded before `capture()`
48
- * returns, so the caller can move on without awaiting it.
64
+ * returns, so the caller can move on without awaiting it. Render frames
65
+ * follow `renderFrame` (see `RecorderOptions`), which may defer them.
49
66
  */
50
67
  settle?: boolean;
68
+ /**
69
+ * `recorder.generation` as read when the interaction began. A capture from
70
+ * an earlier test is dropped. Default: the current generation.
71
+ */
72
+ generation?: number;
51
73
  }
52
74
  /** A frame as captured by the recorder. `snapshot` is the DOM, already serialized to JSON. */
53
75
  export interface Frame {
54
76
  id: string;
55
77
  kind: FrameKind;
56
78
  label: string;
57
- /** ms since the test began */
79
+ /** when the snapshot was taken, in ms since the test began */
58
80
  at: number;
59
81
  meta?: Record<string, unknown>;
60
82
  /** rrweb serialized document, as JSON text */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@describe-me/core",
3
- "version": "0.5.0-next.1",
3
+ "version": "0.5.0-next.2",
4
4
  "description": "Recorder and manifest types for describe-me: living component docs from your tests",
5
5
  "keywords": [
6
6
  "vitest",