@describe-me/core 0.4.0 → 0.5.0-next.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 +8 -1
- package/dist/css-references.d.ts +17 -0
- package/dist/css-references.js +129 -0
- package/dist/element-label.d.ts +6 -0
- package/dist/element-label.js +34 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/materialize-adopted-styles.d.ts +4 -2
- package/dist/materialize-adopted-styles.js +5 -3
- package/dist/page-has-content.d.ts +7 -0
- package/dist/page-has-content.js +21 -0
- package/dist/real-timers.d.ts +8 -0
- package/dist/real-timers.js +23 -0
- package/dist/recorder.d.ts +4 -1
- package/dist/recorder.js +38 -15
- package/dist/serialize-document.d.ts +12 -0
- package/dist/serialize-document.js +20 -0
- package/dist/settle.d.ts +1 -0
- package/dist/settle.js +3 -1
- package/dist/types.d.ts +21 -6
- package/dist/types.js +7 -0
- package/package.json +5 -1
- package/dist/quick-hash.d.ts +0 -7
- package/dist/quick-hash.js +0 -19
package/README.md
CHANGED
|
@@ -25,8 +25,15 @@ import { step } from '@describe-me/core'
|
|
|
25
25
|
await step('open the menu', () => screen.getByRole('button', { name: 'Menu' }).click())
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
`elementLabel()` names a DOM element the way frame labels do, e.g.
|
|
29
|
+
`button "Save"`, for adapters that record their own interactions.
|
|
30
|
+
|
|
28
31
|
The manifest types live in `@describe-me/core/types`, for tools that want to
|
|
29
|
-
read `.describe-me/manifest.json` themselves.
|
|
32
|
+
read `.describe-me/manifest.json` themselves. Stored snapshots point into
|
|
33
|
+
`assets/` and `styles/` through `ASSET_URL_PREFIX` and `STYLE_URL_PREFIX`
|
|
34
|
+
([Output directory](https://github.com/grzehub/describe-me#output-directory) in
|
|
35
|
+
the root README). `@describe-me/core/css-references` lists the `url()` and
|
|
36
|
+
`@import` references in CSS text.
|
|
30
37
|
|
|
31
38
|
See the [root README](https://github.com/grzehub/describe-me#readme) for the
|
|
32
39
|
full picture.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** A `url()` or `@import` target in CSS text. */
|
|
2
|
+
export interface CssReference {
|
|
3
|
+
/** The URL as written, without quotes or surrounding whitespace. */
|
|
4
|
+
url: string;
|
|
5
|
+
/** Offset of the URL's first character in the scanned text. */
|
|
6
|
+
start: number;
|
|
7
|
+
/** Offset just past the URL's last character. */
|
|
8
|
+
end: number;
|
|
9
|
+
/** `import` for an `@import` target, written as a string or as `url()`. */
|
|
10
|
+
kind: 'url' | 'import';
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Every `url()` and `@import` target in CSS text, in source order, remote,
|
|
14
|
+
* `data:` and `#fragment` URLs included. Comments and other strings are
|
|
15
|
+
* skipped, so `content: "url(x)"` yields nothing.
|
|
16
|
+
*/
|
|
17
|
+
export declare function cssReferences(cssText: string): CssReference[];
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Where a scan step can start: a comment, a string, an escape, `@import` or `url(`. */
|
|
2
|
+
const TOKEN = /\/\*|["'\\]|@import|url\(/gi;
|
|
3
|
+
const NAME_CHARACTER = /[\w\u0080-\uffff-]/;
|
|
4
|
+
const WHITESPACE = /\s/;
|
|
5
|
+
function isNameCharacter(char) {
|
|
6
|
+
return char !== undefined && NAME_CHARACTER.test(char);
|
|
7
|
+
}
|
|
8
|
+
function isQuote(char) {
|
|
9
|
+
return char === '"' || char === "'";
|
|
10
|
+
}
|
|
11
|
+
/** The offset of the quote that closes the string opening at `open`. A newline ends a broken string. */
|
|
12
|
+
function closingQuote(css, open) {
|
|
13
|
+
let index = open + 1;
|
|
14
|
+
while (index < css.length) {
|
|
15
|
+
const char = css[index];
|
|
16
|
+
if (char === '\\') {
|
|
17
|
+
index += 2;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (char === css[open] || char === '\n') {
|
|
21
|
+
return index;
|
|
22
|
+
}
|
|
23
|
+
index++;
|
|
24
|
+
}
|
|
25
|
+
return css.length;
|
|
26
|
+
}
|
|
27
|
+
/** The offset just past the comment opening at `open`. */
|
|
28
|
+
function commentEnd(css, open) {
|
|
29
|
+
const close = css.indexOf('*/', open + 2);
|
|
30
|
+
return close === -1 ? css.length : close + 2;
|
|
31
|
+
}
|
|
32
|
+
function skipWhitespaceAndComments(css, start) {
|
|
33
|
+
let index = start;
|
|
34
|
+
while (index < css.length) {
|
|
35
|
+
if (WHITESPACE.test(css[index])) {
|
|
36
|
+
index++;
|
|
37
|
+
}
|
|
38
|
+
else if (css.startsWith('/*', index)) {
|
|
39
|
+
index = commentEnd(css, index);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return index;
|
|
46
|
+
}
|
|
47
|
+
/** `url(` at `index` as a function of its own, not the tail of a longer name such as `my-url(`. */
|
|
48
|
+
function isUrlFunction(css, index) {
|
|
49
|
+
return css.slice(index, index + 4).toLowerCase() === 'url(' && !isNameCharacter(css[index - 1]);
|
|
50
|
+
}
|
|
51
|
+
function referenceOf(css, start, end, kind) {
|
|
52
|
+
if (end <= start) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return { url: css.slice(start, end), start, end, kind };
|
|
56
|
+
}
|
|
57
|
+
/** The quoted string opening at `open`, as a reference. */
|
|
58
|
+
function scanString(css, open, kind) {
|
|
59
|
+
const close = closingQuote(css, open);
|
|
60
|
+
return { reference: referenceOf(css, open + 1, close, kind), next: close + 1 };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The target of the `url(` starting at `index`. An unquoted URL runs until the
|
|
64
|
+
* first `)`, because a Google Fonts `css2` URL contains semicolons.
|
|
65
|
+
*/
|
|
66
|
+
function scanUrl(css, index, kind) {
|
|
67
|
+
let start = index + 'url('.length;
|
|
68
|
+
while (start < css.length && WHITESPACE.test(css[start])) {
|
|
69
|
+
start++;
|
|
70
|
+
}
|
|
71
|
+
if (isQuote(css[start])) {
|
|
72
|
+
return scanString(css, start, kind);
|
|
73
|
+
}
|
|
74
|
+
const close = css.indexOf(')', start);
|
|
75
|
+
if (close === -1) {
|
|
76
|
+
return { reference: null, next: start };
|
|
77
|
+
}
|
|
78
|
+
let end = close;
|
|
79
|
+
while (end > start && WHITESPACE.test(css[end - 1])) {
|
|
80
|
+
end--;
|
|
81
|
+
}
|
|
82
|
+
return { reference: referenceOf(css, start, end, kind), next: close + 1 };
|
|
83
|
+
}
|
|
84
|
+
/** The target of the `@import` starting at `index`: a string or a `url()`. */
|
|
85
|
+
function scanImport(css, index) {
|
|
86
|
+
const target = skipWhitespaceAndComments(css, index + '@import'.length);
|
|
87
|
+
if (isQuote(css[target])) {
|
|
88
|
+
return scanString(css, target, 'import');
|
|
89
|
+
}
|
|
90
|
+
if (isUrlFunction(css, target)) {
|
|
91
|
+
return scanUrl(css, target, 'import');
|
|
92
|
+
}
|
|
93
|
+
return { reference: null, next: target };
|
|
94
|
+
}
|
|
95
|
+
function scanToken(css, index, token) {
|
|
96
|
+
const skipped = (next) => ({ reference: null, next });
|
|
97
|
+
if (token === '/*') {
|
|
98
|
+
return skipped(commentEnd(css, index));
|
|
99
|
+
}
|
|
100
|
+
if (token === '\\') {
|
|
101
|
+
return skipped(index + 2);
|
|
102
|
+
}
|
|
103
|
+
if (isQuote(token)) {
|
|
104
|
+
return skipped(closingQuote(css, index) + 1);
|
|
105
|
+
}
|
|
106
|
+
if (token.startsWith('@')) {
|
|
107
|
+
return isNameCharacter(css[index + token.length]) ? skipped(index + 1) : scanImport(css, index);
|
|
108
|
+
}
|
|
109
|
+
return isUrlFunction(css, index) ? scanUrl(css, index, 'url') : skipped(index + 1);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Every `url()` and `@import` target in CSS text, in source order, remote,
|
|
113
|
+
* `data:` and `#fragment` URLs included. Comments and other strings are
|
|
114
|
+
* skipped, so `content: "url(x)"` yields nothing.
|
|
115
|
+
*/
|
|
116
|
+
export function cssReferences(cssText) {
|
|
117
|
+
const references = [];
|
|
118
|
+
const token = new RegExp(TOKEN);
|
|
119
|
+
let match = token.exec(cssText);
|
|
120
|
+
while (match !== null) {
|
|
121
|
+
const scanned = scanToken(cssText, match.index, match[0]);
|
|
122
|
+
if (scanned.reference) {
|
|
123
|
+
references.push(scanned.reference);
|
|
124
|
+
}
|
|
125
|
+
token.lastIndex = scanned.next;
|
|
126
|
+
match = token.exec(cssText);
|
|
127
|
+
}
|
|
128
|
+
return references;
|
|
129
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A readable target for a frame label, e.g. `button "show details"`.
|
|
3
|
+
* Testing Library hands interactions a real `Element` rather than a Vitest
|
|
4
|
+
* locator, so the label is read off the element instead of a selector string.
|
|
5
|
+
*/
|
|
6
|
+
export declare function elementLabel(target: unknown): string | undefined;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Attributes that name an element better than its text, in order of preference. */
|
|
2
|
+
const NAME_ATTRIBUTES = ['aria-label', 'name', 'placeholder', 'title'];
|
|
3
|
+
function roleOf(element) {
|
|
4
|
+
const explicit = element.getAttribute('role');
|
|
5
|
+
if (explicit) {
|
|
6
|
+
return explicit;
|
|
7
|
+
}
|
|
8
|
+
const tag = element.tagName.toLowerCase();
|
|
9
|
+
if (tag === 'input') {
|
|
10
|
+
return element.getAttribute('type') ?? 'input';
|
|
11
|
+
}
|
|
12
|
+
return tag;
|
|
13
|
+
}
|
|
14
|
+
function nameOf(element) {
|
|
15
|
+
for (const attribute of NAME_ATTRIBUTES) {
|
|
16
|
+
const value = element.getAttribute(attribute);
|
|
17
|
+
if (value) {
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return (element.textContent ?? '').trim().slice(0, 40);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A readable target for a frame label, e.g. `button "show details"`.
|
|
25
|
+
* Testing Library hands interactions a real `Element` rather than a Vitest
|
|
26
|
+
* locator, so the label is read off the element instead of a selector string.
|
|
27
|
+
*/
|
|
28
|
+
export function elementLabel(target) {
|
|
29
|
+
if (typeof Element === 'undefined' || !(target instanceof Element)) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const name = nameOf(target);
|
|
33
|
+
return name ? `${roleOf(target)} "${name}"` : roleOf(target);
|
|
34
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export * from './types.js';
|
|
2
|
+
export { cssReferences, type CssReference } from './css-references.js';
|
|
2
3
|
export { describeComponentType } from './describe-component-type.js';
|
|
4
|
+
export { elementLabel } from './element-label.js';
|
|
3
5
|
export { manifestDiagnostics } from './manifest-diagnostics.js';
|
|
4
6
|
export { recorder } from './recorder.js';
|
|
5
7
|
export { serializeValue } from './serialize-value.js';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export * from './types.js';
|
|
2
|
+
export { cssReferences } from './css-references.js';
|
|
2
3
|
export { describeComponentType } from './describe-component-type.js';
|
|
4
|
+
export { elementLabel } from './element-label.js';
|
|
3
5
|
export { manifestDiagnostics } from './manifest-diagnostics.js';
|
|
4
6
|
export { recorder } from './recorder.js';
|
|
5
7
|
export { serializeValue } from './serialize-value.js';
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Constructable stylesheets (`document.adoptedStyleSheets`, used by Lit and
|
|
3
3
|
* other web-component libraries) are not part of the DOM tree, so rrweb's
|
|
4
4
|
* snapshot never sees them. For the duration of `run`, mirror their rules into
|
|
5
|
-
* a temporary `<style>` element so they end up in the capture.
|
|
5
|
+
* a temporary `<style>` element so they end up in the capture. Synchronous,
|
|
6
|
+
* like rrweb's snapshot, so a `settle: false` capture records its frame before
|
|
7
|
+
* `recorder.capture()` returns.
|
|
6
8
|
*/
|
|
7
|
-
export declare function materializeAdoptedStyles<T>(run: () => T
|
|
9
|
+
export declare function materializeAdoptedStyles<T>(run: () => T): T;
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* Constructable stylesheets (`document.adoptedStyleSheets`, used by Lit and
|
|
3
3
|
* other web-component libraries) are not part of the DOM tree, so rrweb's
|
|
4
4
|
* snapshot never sees them. For the duration of `run`, mirror their rules into
|
|
5
|
-
* a temporary `<style>` element so they end up in the capture.
|
|
5
|
+
* a temporary `<style>` element so they end up in the capture. Synchronous,
|
|
6
|
+
* like rrweb's snapshot, so a `settle: false` capture records its frame before
|
|
7
|
+
* `recorder.capture()` returns.
|
|
6
8
|
*/
|
|
7
|
-
export
|
|
9
|
+
export function materializeAdoptedStyles(run) {
|
|
8
10
|
const sheets = document.adoptedStyleSheets ?? [];
|
|
9
11
|
if (sheets.length === 0) {
|
|
10
12
|
return run();
|
|
@@ -16,7 +18,7 @@ export async function materializeAdoptedStyles(run) {
|
|
|
16
18
|
.join('\n');
|
|
17
19
|
document.head.append(mirror);
|
|
18
20
|
try {
|
|
19
|
-
return
|
|
21
|
+
return run();
|
|
20
22
|
}
|
|
21
23
|
finally {
|
|
22
24
|
mirror.remove();
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the page shows anything at all. False for what Testing Library
|
|
3
|
+
* leaves behind: an empty body after `cleanup()`, the bare container `div`
|
|
4
|
+
* after `unmount()`. Any text, any other element and any attribute count as
|
|
5
|
+
* content, so a styled box or an icon is never taken for an empty page.
|
|
6
|
+
*/
|
|
7
|
+
export declare function pageHasContent(): boolean;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the page shows anything at all. False for what Testing Library
|
|
3
|
+
* leaves behind: an empty body after `cleanup()`, the bare container `div`
|
|
4
|
+
* after `unmount()`. Any text, any other element and any attribute count as
|
|
5
|
+
* content, so a styled box or an icon is never taken for an empty page.
|
|
6
|
+
*/
|
|
7
|
+
export function pageHasContent() {
|
|
8
|
+
const body = document.body;
|
|
9
|
+
if (!body) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
if ((body.textContent ?? '').trim() !== '') {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
for (const element of body.querySelectorAll('*')) {
|
|
16
|
+
if (element.localName !== 'div' || element.attributes.length > 0) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** The global `setTimeout`, unwrapped when a setup file faked timers before this module loaded. */
|
|
2
|
+
function originalSetTimeout() {
|
|
3
|
+
const current = globalThis.setTimeout;
|
|
4
|
+
return current.clock ? current.clock._setTimeout : current;
|
|
5
|
+
}
|
|
6
|
+
/** The global `performance`, unwrapped the same way. */
|
|
7
|
+
function originalPerformance() {
|
|
8
|
+
const current = globalThis.performance;
|
|
9
|
+
return current.clock ? current.clock._performance : current;
|
|
10
|
+
}
|
|
11
|
+
// Bound, because browsers throw "Illegal invocation" when `setTimeout` is
|
|
12
|
+
// called without its global as `this`.
|
|
13
|
+
const setTimeoutAtLoad = originalSetTimeout().bind(globalThis);
|
|
14
|
+
// Fake timers replace `performance` as a whole object, so this reference stays real.
|
|
15
|
+
const performanceAtLoad = originalPerformance();
|
|
16
|
+
/**
|
|
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.
|
|
19
|
+
*/
|
|
20
|
+
export const realTimers = {
|
|
21
|
+
setTimeout: setTimeoutAtLoad,
|
|
22
|
+
now: () => performanceAtLoad.now(),
|
|
23
|
+
};
|
package/dist/recorder.d.ts
CHANGED
|
@@ -10,13 +10,16 @@ declare class Recorder {
|
|
|
10
10
|
private component?;
|
|
11
11
|
private startedAt;
|
|
12
12
|
private active;
|
|
13
|
-
private
|
|
13
|
+
private closed;
|
|
14
|
+
private lastJson;
|
|
14
15
|
private seq;
|
|
15
16
|
private teardowns;
|
|
16
17
|
begin(): void;
|
|
17
18
|
get isActive(): boolean;
|
|
18
19
|
setComponent(info: ComponentInfo): void;
|
|
19
20
|
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;
|
|
20
23
|
end(): TestRecord;
|
|
21
24
|
/**
|
|
22
25
|
* Register work that must run after the test's frames are handed over, such
|
package/dist/recorder.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { pageHasContent } from './page-has-content.js';
|
|
2
|
+
import { realTimers } from './real-timers.js';
|
|
3
|
+
import { serializeDocument } from './serialize-document.js';
|
|
4
4
|
import { settle } from './settle.js';
|
|
5
5
|
/**
|
|
6
6
|
* Test-side recorder, running wherever the tests run: the browser-mode iframe
|
|
@@ -12,15 +12,18 @@ class Recorder {
|
|
|
12
12
|
component;
|
|
13
13
|
startedAt = 0;
|
|
14
14
|
active = false;
|
|
15
|
-
|
|
15
|
+
// Once `cleanup()` or the setup file took the closing frame, a second one is skipped.
|
|
16
|
+
closed = false;
|
|
17
|
+
lastJson = '';
|
|
16
18
|
seq = 0;
|
|
17
19
|
teardowns = new Set();
|
|
18
20
|
begin() {
|
|
19
21
|
this.frames = [];
|
|
20
22
|
this.component = undefined;
|
|
21
|
-
this.startedAt =
|
|
23
|
+
this.startedAt = realTimers.now();
|
|
22
24
|
this.active = true;
|
|
23
|
-
this.
|
|
25
|
+
this.closed = false;
|
|
26
|
+
this.lastJson = '';
|
|
24
27
|
this.seq = 0;
|
|
25
28
|
}
|
|
26
29
|
get isActive() {
|
|
@@ -35,36 +38,56 @@ class Recorder {
|
|
|
35
38
|
if (!this.active) {
|
|
36
39
|
return;
|
|
37
40
|
}
|
|
41
|
+
if (kind !== 'end' && this.closed) {
|
|
42
|
+
this.reopen();
|
|
43
|
+
}
|
|
44
|
+
if (kind === 'end' && (this.frames.length === 0 || this.closed)) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
38
47
|
if (options.settle !== false) {
|
|
39
48
|
await settle();
|
|
40
49
|
}
|
|
41
|
-
|
|
42
|
-
|
|
50
|
+
if (kind === 'end') {
|
|
51
|
+
this.closed = true;
|
|
52
|
+
// After `cleanup()` or `unmount()` there is nothing left to show.
|
|
53
|
+
if (!pageHasContent()) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const json = serializeDocument();
|
|
58
|
+
if (json === null) {
|
|
43
59
|
return;
|
|
44
60
|
}
|
|
45
|
-
// rrweb numbers nodes with a global counter, so ids differ between otherwise identical captures.
|
|
46
|
-
const hash = quickHash(JSON.stringify(node, (key, value) => (key === 'id' || key === 'rootId' ? undefined : value)));
|
|
47
61
|
// The closing frame is only interesting if something changed since the last one.
|
|
48
|
-
if (kind === 'end' &&
|
|
62
|
+
if (kind === 'end' && json === this.lastJson) {
|
|
49
63
|
return;
|
|
50
64
|
}
|
|
51
65
|
// A step whose body already produced this exact DOM (e.g. via an action) just names that frame.
|
|
52
|
-
if (kind === 'step' &&
|
|
66
|
+
if (kind === 'step' && json === this.lastJson && this.frames.length) {
|
|
53
67
|
const last = this.frames[this.frames.length - 1];
|
|
54
68
|
last.label = `${label} · ${last.label}`;
|
|
55
69
|
last.kind = 'step';
|
|
56
70
|
return;
|
|
57
71
|
}
|
|
58
|
-
this.
|
|
72
|
+
this.lastJson = json;
|
|
59
73
|
this.frames.push({
|
|
60
74
|
id: `f${this.seq++}`,
|
|
61
75
|
kind,
|
|
62
76
|
label,
|
|
63
|
-
at: Math.round(
|
|
77
|
+
at: Math.round(realTimers.now() - this.startedAt),
|
|
64
78
|
meta,
|
|
65
|
-
snapshot:
|
|
79
|
+
snapshot: json,
|
|
66
80
|
});
|
|
67
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()';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
68
91
|
end() {
|
|
69
92
|
this.active = false;
|
|
70
93
|
// The reporter needs the origin rrweb resolved every URL against. Optional
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize the current document with rrweb, as JSON text, or `null` when
|
|
3
|
+
* rrweb has nothing to serialize.
|
|
4
|
+
*
|
|
5
|
+
* rrweb numbers nodes from a module-level counter that only
|
|
6
|
+
* `cleanupSnapshot()` resets. Resetting it before every capture makes ids
|
|
7
|
+
* start at 1, drops `rootId` (added whenever the document's id is not 1), and
|
|
8
|
+
* makes identical DOM serialize to identical text, so the snapshot store
|
|
9
|
+
* writes it once. Gotcha: any other rrweb user in the same realm shares that
|
|
10
|
+
* counter; none is expected inside tests.
|
|
11
|
+
*/
|
|
12
|
+
export declare function serializeDocument(): string | null;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { cleanupSnapshot, createMirror, snapshot } from 'rrweb-snapshot';
|
|
2
|
+
import { materializeAdoptedStyles } from './materialize-adopted-styles.js';
|
|
3
|
+
/**
|
|
4
|
+
* Serialize the current document with rrweb, as JSON text, or `null` when
|
|
5
|
+
* rrweb has nothing to serialize.
|
|
6
|
+
*
|
|
7
|
+
* rrweb numbers nodes from a module-level counter that only
|
|
8
|
+
* `cleanupSnapshot()` resets. Resetting it before every capture makes ids
|
|
9
|
+
* start at 1, drops `rootId` (added whenever the document's id is not 1), and
|
|
10
|
+
* makes identical DOM serialize to identical text, so the snapshot store
|
|
11
|
+
* writes it once. Gotcha: any other rrweb user in the same realm shares that
|
|
12
|
+
* counter; none is expected inside tests.
|
|
13
|
+
*/
|
|
14
|
+
export function serializeDocument() {
|
|
15
|
+
return materializeAdoptedStyles(() => {
|
|
16
|
+
cleanupSnapshot();
|
|
17
|
+
const node = snapshot(document, { mirror: createMirror(), inlineStylesheet: true });
|
|
18
|
+
return node ? JSON.stringify(node) : null;
|
|
19
|
+
});
|
|
20
|
+
}
|
package/dist/settle.d.ts
CHANGED
|
@@ -3,5 +3,6 @@
|
|
|
3
3
|
* One macrotask is enough for React to commit batched updates. We deliberately
|
|
4
4
|
* do not wait for requestAnimationFrame: the snapshot is DOM + stylesheets, not
|
|
5
5
|
* layout, and a rAF costs a whole vsync (~16ms) per capture in headless Chromium.
|
|
6
|
+
* It uses real timers, so a test that leaves fake timers on still settles.
|
|
6
7
|
*/
|
|
7
8
|
export declare function settle(): Promise<void>;
|
package/dist/settle.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { realTimers } from './real-timers.js';
|
|
1
2
|
/**
|
|
2
3
|
* Let the framework flush pending updates before we look at the DOM.
|
|
3
4
|
* One macrotask is enough for React to commit batched updates. We deliberately
|
|
4
5
|
* do not wait for requestAnimationFrame: the snapshot is DOM + stylesheets, not
|
|
5
6
|
* layout, and a rAF costs a whole vsync (~16ms) per capture in headless Chromium.
|
|
7
|
+
* It uses real timers, so a test that leaves fake timers on still settles.
|
|
6
8
|
*/
|
|
7
9
|
export async function settle() {
|
|
8
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
10
|
+
await new Promise((resolve) => realTimers.setTimeout(resolve, 0));
|
|
9
11
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -31,17 +31,25 @@ export declare const EXPORT_REGISTRY_KEY: "describe-me.exports";
|
|
|
31
31
|
* `<outDir>/assets/` and the viewer resolves the prefix against `__data/assets/`.
|
|
32
32
|
*/
|
|
33
33
|
export declare const ASSET_URL_PREFIX: "describe-me-asset:";
|
|
34
|
+
/**
|
|
35
|
+
* Prefix of stylesheet references inside stored snapshots, e.g.
|
|
36
|
+
* `describe-me-style:3f2a9c1d0b7e4a55+9c1d…`. The reporter stores an rrweb
|
|
37
|
+
* `_cssText` of 256+ characters as chunks in `<outDir>/styles/<hash>.css`, and
|
|
38
|
+
* the viewer joins them back in order before replaying.
|
|
39
|
+
*/
|
|
40
|
+
export declare const STYLE_URL_PREFIX: "describe-me-style:";
|
|
34
41
|
/** Options for `recorder.capture()`. */
|
|
35
42
|
export interface CaptureOptions {
|
|
36
43
|
/**
|
|
37
44
|
* Wait one macrotask for the framework to flush before looking at the DOM.
|
|
38
45
|
* Default: true. Pass false when the caller knows the DOM is already
|
|
39
46
|
* committed (e.g. right after Testing Library's `act()`-wrapped `rerender`):
|
|
40
|
-
* the snapshot is then taken
|
|
47
|
+
* the snapshot is then taken and the frame recorded before `capture()`
|
|
48
|
+
* returns, so the caller can move on without awaiting it.
|
|
41
49
|
*/
|
|
42
50
|
settle?: boolean;
|
|
43
51
|
}
|
|
44
|
-
/** A frame as captured
|
|
52
|
+
/** A frame as captured by the recorder. `snapshot` is the DOM, already serialized to JSON. */
|
|
45
53
|
export interface Frame {
|
|
46
54
|
id: string;
|
|
47
55
|
kind: FrameKind;
|
|
@@ -49,7 +57,8 @@ export interface Frame {
|
|
|
49
57
|
/** ms since the test began */
|
|
50
58
|
at: number;
|
|
51
59
|
meta?: Record<string, unknown>;
|
|
52
|
-
|
|
60
|
+
/** rrweb serialized document, as JSON text */
|
|
61
|
+
snapshot: string;
|
|
53
62
|
}
|
|
54
63
|
/** What a single test hands over to the reporter via task.meta[META_KEY]. */
|
|
55
64
|
export interface TestRecord {
|
|
@@ -127,11 +136,17 @@ export interface Manifest {
|
|
|
127
136
|
/** Keyed by component name, as reported by the framework adapter. */
|
|
128
137
|
components: Record<string, ComponentDoc>;
|
|
129
138
|
/**
|
|
130
|
-
* Asset URLs the snapshots
|
|
131
|
-
* be found in the project, as root-relative paths
|
|
139
|
+
* Asset URLs the snapshots, the preview head or copied CSS files point at
|
|
140
|
+
* that could not be found in the project, as root-relative paths
|
|
141
|
+
* (`/lib/images/logo.svg`). Paths outside the root use the `/@fs/` form.
|
|
132
142
|
* They will not load in the viewer. Absent in manifests written before 0.4.
|
|
133
143
|
*/
|
|
134
144
|
assetsMissing?: string[];
|
|
145
|
+
/**
|
|
146
|
+
* The preview head HTML, with project files as `describe-me-asset:` URLs.
|
|
147
|
+
* Absent when `previewHead` is not set.
|
|
148
|
+
*/
|
|
149
|
+
head?: string;
|
|
135
150
|
}
|
|
136
151
|
/** The name adapters fall back to when a component has no usable name. */
|
|
137
152
|
export declare const ANONYMOUS_COMPONENT: "Anonymous";
|
|
@@ -147,6 +162,6 @@ export interface ManifestDiagnostics {
|
|
|
147
162
|
name: string;
|
|
148
163
|
tests: number;
|
|
149
164
|
}[];
|
|
150
|
-
/**
|
|
165
|
+
/** Asset paths that will not load in the viewer, root-relative or in the `/@fs/` form. */
|
|
151
166
|
assetsMissing: string[];
|
|
152
167
|
}
|
package/dist/types.js
CHANGED
|
@@ -13,6 +13,13 @@ export const EXPORT_REGISTRY_KEY = 'describe-me.exports';
|
|
|
13
13
|
* `<outDir>/assets/` and the viewer resolves the prefix against `__data/assets/`.
|
|
14
14
|
*/
|
|
15
15
|
export const ASSET_URL_PREFIX = 'describe-me-asset:';
|
|
16
|
+
/**
|
|
17
|
+
* Prefix of stylesheet references inside stored snapshots, e.g.
|
|
18
|
+
* `describe-me-style:3f2a9c1d0b7e4a55+9c1d…`. The reporter stores an rrweb
|
|
19
|
+
* `_cssText` of 256+ characters as chunks in `<outDir>/styles/<hash>.css`, and
|
|
20
|
+
* the viewer joins them back in order before replaying.
|
|
21
|
+
*/
|
|
22
|
+
export const STYLE_URL_PREFIX = 'describe-me-style:';
|
|
16
23
|
export const META_KEY = 'describeMe';
|
|
17
24
|
/** The name adapters fall back to when a component has no usable name. */
|
|
18
25
|
export const ANONYMOUS_COMPONENT = 'Anonymous';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@describe-me/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-next.1",
|
|
4
4
|
"description": "Recorder and manifest types for describe-me: living component docs from your tests",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vitest",
|
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
"./diagnostics": {
|
|
39
39
|
"types": "./dist/manifest-diagnostics.d.ts",
|
|
40
40
|
"default": "./dist/manifest-diagnostics.js"
|
|
41
|
+
},
|
|
42
|
+
"./css-references": {
|
|
43
|
+
"types": "./dist/css-references.d.ts",
|
|
44
|
+
"default": "./dist/css-references.js"
|
|
41
45
|
}
|
|
42
46
|
},
|
|
43
47
|
"files": [
|
package/dist/quick-hash.d.ts
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Inexpensive, non-cryptographic hash used to tell two captures apart.
|
|
3
|
-
* `| 0` keeps the accumulator a 32-bit integer, so overflow wraps like a Java
|
|
4
|
-
* int instead of drifting into floating point. A collision merely skips one
|
|
5
|
-
* frame, which is why this is good enough here and SHA-1 is not needed.
|
|
6
|
-
*/
|
|
7
|
-
export declare function quickHash(input: string): string;
|
package/dist/quick-hash.js
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Multiplier of the polynomial rolling hash, the same one `String.hashCode()`
|
|
3
|
-
* uses in Java. Odd, so no bit is lost to a plain shift on every step; prime,
|
|
4
|
-
* so common inputs spread evenly; and `x * 31` is just `(x << 5) - x`.
|
|
5
|
-
*/
|
|
6
|
-
const MULTIPLIER = 31;
|
|
7
|
-
/**
|
|
8
|
-
* Inexpensive, non-cryptographic hash used to tell two captures apart.
|
|
9
|
-
* `| 0` keeps the accumulator a 32-bit integer, so overflow wraps like a Java
|
|
10
|
-
* int instead of drifting into floating point. A collision merely skips one
|
|
11
|
-
* frame, which is why this is good enough here and SHA-1 is not needed.
|
|
12
|
-
*/
|
|
13
|
-
export function quickHash(input) {
|
|
14
|
-
let hash = 0;
|
|
15
|
-
for (let i = 0; i < input.length; i++) {
|
|
16
|
-
hash = (hash * MULTIPLIER + input.charCodeAt(i)) | 0;
|
|
17
|
-
}
|
|
18
|
-
return `${input.length}:${hash}`;
|
|
19
|
-
}
|