@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 +6 -0
- package/dist/compact-frames.d.ts +19 -0
- package/dist/compact-frames.js +38 -0
- package/dist/pending-render.d.ts +19 -0
- package/dist/pending-render.js +51 -0
- package/dist/real-timers.d.ts +4 -2
- package/dist/real-timers.js +10 -2
- package/dist/recorder.d.ts +39 -8
- package/dist/recorder.js +150 -45
- package/dist/step.js +3 -1
- package/dist/types.d.ts +24 -2
- package/package.json +1 -1
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
|
+
}
|
package/dist/real-timers.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `setTimeout` and `performance.now()` as they were before any
|
|
3
|
-
* them, so `vi.useFakeTimers()` neither hangs a capture nor skews
|
|
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
|
};
|
package/dist/real-timers.js
CHANGED
|
@@ -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
|
|
18
|
-
* them, so `vi.useFakeTimers()` neither hangs a capture nor skews
|
|
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
|
};
|
package/dist/recorder.d.ts
CHANGED
|
@@ -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
|
|
6
|
-
* Framework adapters call `capture()`,
|
|
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
|
|
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
|
-
/**
|
|
22
|
-
|
|
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
|
|
8
|
-
* Framework adapters call `capture()`,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
58
|
-
|
|
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
|
-
//
|
|
62
|
-
if (
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
73
|
-
this.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
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 */
|