@describe-me/core 0.5.0-next.1 → 0.5.0-next.3
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/manifest-diagnostics.d.ts +2 -1
- package/dist/manifest-diagnostics.js +4 -1
- 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 +43 -8
- package/dist/recorder.js +159 -46
- package/dist/step.js +3 -1
- package/dist/types.d.ts +61 -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
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type Manifest, type ManifestDiagnostics } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* List what the manifest could not document: anonymous components, named ones
|
|
4
|
-
* without props,
|
|
4
|
+
* without props, assets that will not load, font families that nothing loads
|
|
5
|
+
* and hosts that frames load stylesheets from. The reporter prints it after a
|
|
5
6
|
* run and the viewer shows it in the header, so both agree on what is missing.
|
|
6
7
|
*/
|
|
7
8
|
export declare function manifestDiagnostics(manifest: Manifest): ManifestDiagnostics;
|
|
@@ -5,7 +5,8 @@ function isHostElement(name) {
|
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
7
|
* List what the manifest could not document: anonymous components, named ones
|
|
8
|
-
* without props,
|
|
8
|
+
* without props, assets that will not load, font families that nothing loads
|
|
9
|
+
* and hosts that frames load stylesheets from. The reporter prints it after a
|
|
9
10
|
* run and the viewer shows it in the header, so both agree on what is missing.
|
|
10
11
|
*/
|
|
11
12
|
export function manifestDiagnostics(manifest) {
|
|
@@ -30,5 +31,7 @@ export function manifestDiagnostics(manifest) {
|
|
|
30
31
|
anonymous,
|
|
31
32
|
undocumented: Array.from(undocumented, ([name, tests]) => ({ name, tests })).sort((left, right) => left.name.localeCompare(right.name)),
|
|
32
33
|
assetsMissing: manifest.assetsMissing ?? [],
|
|
34
|
+
fontsMissing: manifest.fontsMissing ?? [],
|
|
35
|
+
remoteStylesheets: manifest.remoteStylesheets ?? [],
|
|
33
36
|
};
|
|
34
37
|
}
|
|
@@ -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,52 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type CaptureOptions, type ComponentInfo, type FrameKind, type RecorderOptions, type 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;
|
|
34
|
+
/**
|
|
35
|
+
* Name the component the test documents. The first name stays, unless it is
|
|
36
|
+
* `Anonymous` and `info` is not, or it has no `file` and `info` has one.
|
|
37
|
+
*/
|
|
19
38
|
setComponent(info: ComponentInfo): void;
|
|
20
39
|
capture(kind: FrameKind, label: string, meta?: Record<string, unknown>, options?: CaptureOptions): Promise<void>;
|
|
21
|
-
/**
|
|
22
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Take a deferred render frame now. Adapters call it before an interaction
|
|
42
|
+
* changes the page: an event, a `step()` body, another render, an unmount.
|
|
43
|
+
*/
|
|
44
|
+
beforeInteraction(): void;
|
|
45
|
+
/**
|
|
46
|
+
* Wait for the captures in flight, then take a deferred render frame after
|
|
47
|
+
* one settle. Resolves to whether it took a deferred render frame.
|
|
48
|
+
*/
|
|
49
|
+
flush(): Promise<boolean>;
|
|
23
50
|
end(): TestRecord;
|
|
24
51
|
/**
|
|
25
52
|
* Register work that must run after the test's frames are handed over, such
|
|
@@ -29,6 +56,14 @@ declare class Recorder {
|
|
|
29
56
|
onTeardown(callback: Teardown): void;
|
|
30
57
|
/** Run the registered teardowns. Called by the runner integration after `end()`. */
|
|
31
58
|
teardown(): void;
|
|
59
|
+
/**
|
|
60
|
+
* Hold the render frame back until `mode` allows it. `capture()` already
|
|
61
|
+
* took any earlier one.
|
|
62
|
+
*/
|
|
63
|
+
private defer;
|
|
64
|
+
private clearDeferred;
|
|
65
|
+
private settleThenFill;
|
|
66
|
+
private fill;
|
|
32
67
|
}
|
|
33
68
|
export declare const recorder: Recorder;
|
|
34
69
|
export {};
|
package/dist/recorder.js
CHANGED
|
@@ -1,36 +1,84 @@
|
|
|
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
|
+
import { ANONYMOUS_COMPONENT, } from './types.js';
|
|
8
|
+
/** Fails in the setup file rather than in the first test that renders. */
|
|
9
|
+
function assertValidSelector(selector) {
|
|
10
|
+
if (typeof document === 'undefined') {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
document.querySelector(selector);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error(`describe-me: renderFrame.pending is not a valid CSS selector: "${selector}"`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
5
20
|
/**
|
|
6
21
|
* Test-side recorder, running wherever the tests run: the browser-mode iframe
|
|
7
|
-
* or jsdom. `begin()` in beforeEach
|
|
8
|
-
* Framework adapters call `capture()`,
|
|
22
|
+
* or jsdom. `begin()` in beforeEach. `flush()`, the closing capture, `end()`
|
|
23
|
+
* then `teardown()` in afterEach. Framework adapters call `capture()`,
|
|
24
|
+
* `beforeInteraction()`, `setComponent()` and `onTeardown()`.
|
|
9
25
|
*/
|
|
10
26
|
class Recorder {
|
|
11
|
-
|
|
27
|
+
// One per capture, in call order, so a frame sits where it was asked for.
|
|
28
|
+
slots = [];
|
|
29
|
+
currentGeneration = 0;
|
|
30
|
+
inFlight = new Set();
|
|
31
|
+
// The render frame `renderFrame` holds back, until an interaction or `flush()` takes it.
|
|
32
|
+
deferred = null;
|
|
33
|
+
pending = null;
|
|
34
|
+
renderFrame = 'eager';
|
|
12
35
|
component;
|
|
13
36
|
startedAt = 0;
|
|
14
37
|
active = false;
|
|
15
38
|
// Once `cleanup()` or the setup file took the closing frame, a second one is skipped.
|
|
16
39
|
closed = false;
|
|
17
|
-
lastJson = '';
|
|
18
|
-
seq = 0;
|
|
19
40
|
teardowns = new Set();
|
|
20
41
|
begin() {
|
|
21
|
-
this.
|
|
42
|
+
this.currentGeneration++;
|
|
43
|
+
this.slots = [];
|
|
44
|
+
this.inFlight.clear();
|
|
45
|
+
this.clearDeferred();
|
|
22
46
|
this.component = undefined;
|
|
23
47
|
this.startedAt = realTimers.now();
|
|
24
48
|
this.active = true;
|
|
25
49
|
this.closed = false;
|
|
26
|
-
this.lastJson = '';
|
|
27
|
-
this.seq = 0;
|
|
28
50
|
}
|
|
29
51
|
get isActive() {
|
|
30
52
|
return this.active;
|
|
31
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Changes with every test. An interaction reads it when it begins and hands
|
|
56
|
+
* it to `capture()`.
|
|
57
|
+
*/
|
|
58
|
+
get generation() {
|
|
59
|
+
return this.currentGeneration;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Choose when the render frame is taken, from the next render capture on.
|
|
63
|
+
* The setup files call it with the plugin's options. An invalid `pending`
|
|
64
|
+
* selector throws.
|
|
65
|
+
*/
|
|
66
|
+
configure(options) {
|
|
67
|
+
const renderFrame = options.renderFrame ?? 'eager';
|
|
68
|
+
if (typeof renderFrame === 'object') {
|
|
69
|
+
assertValidSelector(renderFrame.pending);
|
|
70
|
+
}
|
|
71
|
+
this.renderFrame = renderFrame;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Name the component the test documents. The first name stays, unless it is
|
|
75
|
+
* `Anonymous` and `info` is not, or it has no `file` and `info` has one.
|
|
76
|
+
*/
|
|
32
77
|
setComponent(info) {
|
|
33
|
-
|
|
78
|
+
const current = this.component;
|
|
79
|
+
const addsFile = current?.file === undefined && info.file !== undefined;
|
|
80
|
+
const namesAnonymous = current?.name === ANONYMOUS_COMPONENT && info.name !== ANONYMOUS_COMPONENT;
|
|
81
|
+
if (!current || namesAnonymous || addsFile) {
|
|
34
82
|
this.component = info;
|
|
35
83
|
}
|
|
36
84
|
}
|
|
@@ -38,62 +86,85 @@ class Recorder {
|
|
|
38
86
|
if (!this.active) {
|
|
39
87
|
return;
|
|
40
88
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (kind === 'end' && (this.frames.length === 0 || this.closed)) {
|
|
89
|
+
const generation = options.generation ?? this.currentGeneration;
|
|
90
|
+
// The interaction began in a test that has ended.
|
|
91
|
+
if (generation !== this.currentGeneration) {
|
|
45
92
|
return;
|
|
46
93
|
}
|
|
47
|
-
|
|
48
|
-
|
|
94
|
+
// A call site that forgot `beforeInteraction()` still gets its render frame here.
|
|
95
|
+
this.beforeInteraction();
|
|
96
|
+
if (kind !== 'end') {
|
|
97
|
+
this.closed = false;
|
|
98
|
+
}
|
|
99
|
+
// Before any `await`, so a test that recorded nothing costs nothing.
|
|
100
|
+
if (kind === 'end' && (this.slots.length === 0 || this.closed)) {
|
|
101
|
+
return;
|
|
49
102
|
}
|
|
50
103
|
if (kind === 'end') {
|
|
51
104
|
this.closed = true;
|
|
52
|
-
// After `cleanup()` or `unmount()` there is nothing left to show.
|
|
53
|
-
if (!pageHasContent()) {
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
105
|
}
|
|
57
|
-
const
|
|
58
|
-
|
|
106
|
+
const slot = { kind, label, meta };
|
|
107
|
+
this.slots.push(slot);
|
|
108
|
+
if (kind === 'render' && this.renderFrame !== 'eager') {
|
|
109
|
+
this.defer(slot, this.renderFrame);
|
|
59
110
|
return;
|
|
60
111
|
}
|
|
61
|
-
//
|
|
62
|
-
if (
|
|
112
|
+
// Filled before `capture()` returns: Testing Library frames depend on it.
|
|
113
|
+
if (options.settle === false) {
|
|
114
|
+
this.fill(slot);
|
|
63
115
|
return;
|
|
64
116
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
117
|
+
const filling = this.settleThenFill(slot, generation);
|
|
118
|
+
this.inFlight.add(filling);
|
|
119
|
+
try {
|
|
120
|
+
await filling;
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
this.inFlight.delete(filling);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Take a deferred render frame now. Adapters call it before an interaction
|
|
128
|
+
* changes the page: an event, a `step()` body, another render, an unmount.
|
|
129
|
+
*/
|
|
130
|
+
beforeInteraction() {
|
|
131
|
+
const slot = this.deferred;
|
|
132
|
+
if (slot === null) {
|
|
70
133
|
return;
|
|
71
134
|
}
|
|
72
|
-
this.
|
|
73
|
-
this.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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()';
|
|
135
|
+
this.clearDeferred();
|
|
136
|
+
this.fill(slot);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Wait for the captures in flight, then take a deferred render frame after
|
|
140
|
+
* one settle. Resolves to whether it took a deferred render frame.
|
|
141
|
+
*/
|
|
142
|
+
async flush() {
|
|
143
|
+
while (this.inFlight.size > 0) {
|
|
144
|
+
await Promise.allSettled(this.inFlight);
|
|
89
145
|
}
|
|
146
|
+
const slot = this.deferred;
|
|
147
|
+
// Nothing to settle for, so empty and eager tests stay free.
|
|
148
|
+
if (slot === null) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
const generation = this.currentGeneration;
|
|
152
|
+
await settle();
|
|
153
|
+
// The test ended, or an interaction took the frame, while this settled.
|
|
154
|
+
if (generation !== this.currentGeneration || this.deferred !== slot) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
this.beforeInteraction();
|
|
158
|
+
return true;
|
|
90
159
|
}
|
|
91
160
|
end() {
|
|
92
161
|
this.active = false;
|
|
162
|
+
this.currentGeneration++;
|
|
163
|
+
this.clearDeferred();
|
|
93
164
|
// The reporter needs the origin rrweb resolved every URL against. Optional
|
|
94
165
|
// chaining, because a DOM environment is not guaranteed to define `location`.
|
|
95
166
|
return {
|
|
96
|
-
frames: this.
|
|
167
|
+
frames: compactFrames(this.slots),
|
|
97
168
|
component: this.component,
|
|
98
169
|
origin: globalThis.location?.origin,
|
|
99
170
|
};
|
|
@@ -112,6 +183,48 @@ class Recorder {
|
|
|
112
183
|
callback();
|
|
113
184
|
}
|
|
114
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Hold the render frame back until `mode` allows it. `capture()` already
|
|
188
|
+
* took any earlier one.
|
|
189
|
+
*/
|
|
190
|
+
defer(slot, mode) {
|
|
191
|
+
if (mode === 'lazy') {
|
|
192
|
+
this.deferred = slot;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (PendingRender.isReady(mode.pending)) {
|
|
196
|
+
this.fill(slot);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
this.deferred = slot;
|
|
200
|
+
// Without an observer the frame waits like in lazy mode.
|
|
201
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
202
|
+
this.pending = new PendingRender(mode.pending, mode.timeout, () => this.beforeInteraction());
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
clearDeferred() {
|
|
206
|
+
this.deferred = null;
|
|
207
|
+
this.pending?.dispose();
|
|
208
|
+
this.pending = null;
|
|
209
|
+
}
|
|
210
|
+
async settleThenFill(slot, generation) {
|
|
211
|
+
await settle();
|
|
212
|
+
if (generation === this.currentGeneration) {
|
|
213
|
+
this.fill(slot);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
fill(slot) {
|
|
217
|
+
// After `cleanup()` or `unmount()` there is nothing left to show.
|
|
218
|
+
if (slot.kind === 'end' && !pageHasContent()) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const snapshot = serializeDocument();
|
|
222
|
+
if (snapshot === null) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
slot.snapshot = snapshot;
|
|
226
|
+
slot.at = Math.round(realTimers.now() - this.startedAt);
|
|
227
|
+
}
|
|
115
228
|
}
|
|
116
229
|
const RECORDER_KEY = Symbol.for('describe-me.recorder');
|
|
117
230
|
/**
|
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 */
|
|
@@ -83,7 +105,14 @@ export interface ManifestFrame {
|
|
|
83
105
|
snapshot: string;
|
|
84
106
|
}
|
|
85
107
|
export interface ManifestTest {
|
|
108
|
+
/**
|
|
109
|
+
* Stable across runs and machines: the first 12 hex characters of a SHA-1 of
|
|
110
|
+
* the module path, the suite path, the name and, for a repeated name, its
|
|
111
|
+
* occurrence. Before 0.5 it was Vitest's id.
|
|
112
|
+
*/
|
|
86
113
|
id: string;
|
|
114
|
+
/** Vitest's `TestCase.id` in the run that recorded the test. Absent in manifests written before 0.5. */
|
|
115
|
+
vitestId?: string;
|
|
87
116
|
name: string;
|
|
88
117
|
/** suite names from outermost to innermost */
|
|
89
118
|
path: string[];
|
|
@@ -147,6 +176,32 @@ export interface Manifest {
|
|
|
147
176
|
* Absent when `previewHead` is not set.
|
|
148
177
|
*/
|
|
149
178
|
head?: string;
|
|
179
|
+
/**
|
|
180
|
+
* Font families that frames use but nothing loads, most tests first. Absent
|
|
181
|
+
* in manifests written before 0.5, which counts as empty.
|
|
182
|
+
*/
|
|
183
|
+
fontsMissing?: FontMissing[];
|
|
184
|
+
/**
|
|
185
|
+
* Hosts that frames load stylesheets from, most frames first. Absent in
|
|
186
|
+
* manifests written before 0.5, which counts as empty.
|
|
187
|
+
*/
|
|
188
|
+
remoteStylesheets?: RemoteStylesheet[];
|
|
189
|
+
}
|
|
190
|
+
/** A font family that the captured CSS uses but no `@font-face`, font stylesheet or preview head loads. */
|
|
191
|
+
export interface FontMissing {
|
|
192
|
+
/** The family as first spelled in manifest order. */
|
|
193
|
+
family: string;
|
|
194
|
+
/** Tests with at least one frame that misses it. */
|
|
195
|
+
tests: number;
|
|
196
|
+
/** `ManifestTest.id` of the first such test, in manifest order. */
|
|
197
|
+
testId: string;
|
|
198
|
+
}
|
|
199
|
+
/** A host that frames load stylesheets from, which the viewer fetches from the network. */
|
|
200
|
+
export interface RemoteStylesheet {
|
|
201
|
+
/** The URL's hostname. */
|
|
202
|
+
host: string;
|
|
203
|
+
/** Frames that link or import at least one stylesheet from it. */
|
|
204
|
+
frames: number;
|
|
150
205
|
}
|
|
151
206
|
/** The name adapters fall back to when a component has no usable name. */
|
|
152
207
|
export declare const ANONYMOUS_COMPONENT: "Anonymous";
|
|
@@ -164,4 +219,8 @@ export interface ManifestDiagnostics {
|
|
|
164
219
|
}[];
|
|
165
220
|
/** Asset paths that will not load in the viewer, root-relative or in the `/@fs/` form. */
|
|
166
221
|
assetsMissing: string[];
|
|
222
|
+
/** Font families that frames use but nothing loads. */
|
|
223
|
+
fontsMissing: FontMissing[];
|
|
224
|
+
/** Hosts that frames load stylesheets from. */
|
|
225
|
+
remoteStylesheets: RemoteStylesheet[];
|
|
167
226
|
}
|