describe-me 0.5.0 → 0.5.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 +13 -3
- package/dist-cli/file-names-in.d.ts +2 -0
- package/dist-cli/file-names-in.js +11 -0
- package/dist-cli/remote-stylesheets.d.ts +7 -0
- package/dist-cli/remote-stylesheets.js +178 -0
- package/dist-cli/replace-asset-names.d.ts +2 -0
- package/dist-cli/replace-asset-names.js +13 -0
- package/dist-cli/replace-remote-urls.d.ts +6 -0
- package/dist-cli/replace-remote-urls.js +18 -0
- package/dist-cli/rewrite-stored-files.d.ts +27 -0
- package/dist-cli/rewrite-stored-files.js +174 -0
- package/dist-cli/store-vendored-file.d.ts +5 -2
- package/dist-cli/store-vendored-file.js +7 -4
- package/dist-cli/vendor-fonts.d.ts +5 -2
- package/dist-cli/vendor-fonts.js +77 -49
- package/dist-cli/vendored-name.d.ts +1 -1
- package/dist-cli/vendored-name.js +1 -1
- package/package.json +2 -2
- package/src/apply-viewport.ts +5 -1
- package/src/data.ts +32 -13
- package/src/diagnostics-menu.ts +17 -4
- package/src/fonts-settled.ts +62 -13
- package/src/keyboard.ts +27 -7
- package/src/load-style.ts +38 -9
- package/src/min-frame-height.ts +2 -0
- package/src/natural-height.ts +53 -13
- package/src/parse-viewport-size.ts +4 -3
- package/src/regions.ts +5 -2
- package/src/render-all.ts +7 -1
- package/src/render-tree.ts +5 -0
- package/src/restore-styles.ts +10 -4
- package/src/sidebar-order.ts +31 -0
- package/src/stage-note.ts +32 -0
- package/src/stage.ts +6 -2
- package/src/state.ts +8 -4
- package/src/style.css +31 -2
- package/src/test-view.ts +7 -4
- package/src/version-banner.ts +35 -0
- package/src/viewport-controls.ts +23 -6
package/dist-cli/vendor-fonts.js
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
4
|
import { ASSET_URL_PREFIX } from '@describe-me/core/types';
|
|
4
5
|
import { CHROME_USER_AGENT } from './chrome-user-agent.js';
|
|
6
|
+
import { fileNamesIn } from './file-names-in.js';
|
|
5
7
|
import { findRemoteUrls } from './find-remote-urls.js';
|
|
6
8
|
import { FontCache } from './font-cache.js';
|
|
7
9
|
import { fontProvider } from './font-provider.js';
|
|
8
10
|
import { mapWithConcurrency } from './map-with-concurrency.js';
|
|
11
|
+
import { remoteStylesheets } from './remote-stylesheets.js';
|
|
12
|
+
import { replaceAssetNames } from './replace-asset-names.js';
|
|
13
|
+
import { replaceRemoteUrls } from './replace-remote-urls.js';
|
|
14
|
+
import { rewriteStoredFiles } from './rewrite-stored-files.js';
|
|
9
15
|
import { storeVendoredFile } from './store-vendored-file.js';
|
|
10
16
|
import { vendorFontFile } from './vendor-font-file.js';
|
|
11
17
|
import { vendorStylesheet } from './vendor-stylesheet.js';
|
|
12
18
|
const TEXT_DIRECTORIES = [
|
|
13
|
-
{ dir: 'snapshots', extension: '.json', syntax: 'json'
|
|
14
|
-
{ dir: 'styles', extension: '.css', syntax: 'css'
|
|
15
|
-
{ dir: 'assets', extension: '.css', syntax: 'css'
|
|
19
|
+
{ dir: 'snapshots', extension: '.json', syntax: 'json' },
|
|
20
|
+
{ dir: 'styles', extension: '.css', syntax: 'css' },
|
|
21
|
+
{ dir: 'assets', extension: '.css', syntax: 'css' },
|
|
16
22
|
];
|
|
17
23
|
/** The test server of the run, gone by now. The reporter lists what it could not copy. */
|
|
18
24
|
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
@@ -39,16 +45,9 @@ function planFor(url) {
|
|
|
39
45
|
return { action: 'remote', host: parsed.hostname, reason: NOT_ALLOWED };
|
|
40
46
|
}
|
|
41
47
|
function textFilesIn(dataDir) {
|
|
42
|
-
return TEXT_DIRECTORIES.flatMap(({ dir, extension, syntax
|
|
48
|
+
return TEXT_DIRECTORIES.flatMap(({ dir, extension, syntax }) => {
|
|
43
49
|
const abs = join(dataDir, dir);
|
|
44
|
-
|
|
45
|
-
return [];
|
|
46
|
-
}
|
|
47
|
-
return readdirSync(abs, { withFileTypes: true })
|
|
48
|
-
.filter((entry) => entry.isFile() && entry.name.endsWith(extension))
|
|
49
|
-
.map((entry) => entry.name)
|
|
50
|
-
.sort()
|
|
51
|
-
.map((name) => ({ path: join(abs, name), syntax, prefix }));
|
|
50
|
+
return fileNamesIn(abs, extension).map((name) => ({ path: join(abs, name), syntax }));
|
|
52
51
|
});
|
|
53
52
|
}
|
|
54
53
|
function readManifest(dataDir) {
|
|
@@ -74,11 +73,16 @@ function scan(dataDir) {
|
|
|
74
73
|
}
|
|
75
74
|
return vendorable;
|
|
76
75
|
};
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
const kept = textFilesIn(dataDir)
|
|
77
|
+
.filter((file) => needsVendoring(readFileSync(file.path, 'utf8'), file.syntax))
|
|
78
|
+
.map((file) => file.path);
|
|
80
79
|
const head = headOf(readManifest(dataDir));
|
|
81
|
-
return {
|
|
80
|
+
return {
|
|
81
|
+
kept: new Set(kept),
|
|
82
|
+
head: head !== null && needsVendoring(head, 'html'),
|
|
83
|
+
plans,
|
|
84
|
+
cssAssets: fileNamesIn(join(dataDir, 'assets'), '.css'),
|
|
85
|
+
};
|
|
82
86
|
}
|
|
83
87
|
/** The failure of a nested file names that file, so the reason is not lost. */
|
|
84
88
|
function failureOf(url, result) {
|
|
@@ -104,43 +108,55 @@ function store(target, files) {
|
|
|
104
108
|
}
|
|
105
109
|
}
|
|
106
110
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
111
|
+
function nextHead(manifest, found, names, rewritten) {
|
|
112
|
+
const head = headOf(manifest);
|
|
113
|
+
if (head === null || (!found.head && rewritten.assetRenames.size === 0)) {
|
|
114
|
+
return manifest.head;
|
|
115
|
+
}
|
|
116
|
+
const vendored = replaceRemoteUrls(head, 'html', ASSET_URL_PREFIX, names);
|
|
117
|
+
return replaceAssetNames(vendored, rewritten.assetRenames);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The hosts that frames still load stylesheets from, counted again only when a
|
|
121
|
+
* snapshot, chunk or CSS asset changed. An empty or absent list stays as it is.
|
|
122
|
+
*/
|
|
123
|
+
function nextRemoteStylesheets(dataDir, manifest, rewritten) {
|
|
124
|
+
const listed = manifest.remoteStylesheets;
|
|
125
|
+
if (!rewritten.changed || !Array.isArray(listed) || listed.length === 0) {
|
|
126
|
+
return listed;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
return remoteStylesheets(dataDir, manifest);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
133
|
+
console.warn(`describe-me: remote stylesheet hosts were not counted again (${message}). The issues menu keeps the list from the test run.`);
|
|
134
|
+
return listed;
|
|
117
135
|
}
|
|
118
|
-
parts.push(text.slice(last));
|
|
119
|
-
return parts.join('');
|
|
120
136
|
}
|
|
121
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Write the head and the remote stylesheet hosts in one go, and only when one
|
|
139
|
+
* of them changed. Every other field keeps its value and its place.
|
|
140
|
+
*/
|
|
141
|
+
function updateManifest(dataDir, found, names, rewritten) {
|
|
122
142
|
const manifest = readManifest(dataDir);
|
|
123
|
-
|
|
124
|
-
if (manifest === null || head === null) {
|
|
143
|
+
if (manifest === null) {
|
|
125
144
|
return;
|
|
126
145
|
}
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
146
|
+
const head = nextHead(manifest, found, names, rewritten);
|
|
147
|
+
const hosts = nextRemoteStylesheets(dataDir, manifest, rewritten);
|
|
148
|
+
const headChanged = head !== manifest.head;
|
|
149
|
+
const hostsChanged = !isDeepStrictEqual(hosts, manifest.remoteStylesheets);
|
|
150
|
+
if (!headChanged && !hostsChanged) {
|
|
151
|
+
return;
|
|
131
152
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
for (const file of found.files) {
|
|
135
|
-
const text = readFileSync(file.path, 'utf8');
|
|
136
|
-
const rewritten = rewrite(text, file, names);
|
|
137
|
-
if (rewritten !== text) {
|
|
138
|
-
writeFileSync(file.path, rewritten);
|
|
139
|
-
}
|
|
153
|
+
if (headChanged) {
|
|
154
|
+
manifest.head = head;
|
|
140
155
|
}
|
|
141
|
-
if (
|
|
142
|
-
|
|
156
|
+
if (hostsChanged) {
|
|
157
|
+
manifest.remoteStylesheets = hosts;
|
|
143
158
|
}
|
|
159
|
+
writeFileSync(join(dataDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
144
160
|
}
|
|
145
161
|
/** Code-unit order, the same on every machine. */
|
|
146
162
|
function byText(left, right) {
|
|
@@ -183,8 +199,11 @@ function contextFor(options) {
|
|
|
183
199
|
/**
|
|
184
200
|
* Download the web fonts that a data directory loads from known font hosts
|
|
185
201
|
* into its `assets/`, and point the manifest head, snapshots, style chunks and
|
|
186
|
-
* CSS assets at the copies. Meant for the copy inside a built site.
|
|
187
|
-
*
|
|
202
|
+
* CSS assets at the copies. Meant for the copy inside a built site. A style
|
|
203
|
+
* chunk or CSS asset whose text changes moves to the hash of its new text, and
|
|
204
|
+
* the manifest's `remoteStylesheets` is counted again. Every file is stored
|
|
205
|
+
* before any text that names it changes, and replaced files are removed last,
|
|
206
|
+
* so a crash leaves only valid references.
|
|
188
207
|
*/
|
|
189
208
|
export async function vendorFonts(dataDir, options) {
|
|
190
209
|
const found = scan(dataDir);
|
|
@@ -213,7 +232,16 @@ export async function vendorFonts(dataDir, options) {
|
|
|
213
232
|
vendoredStylesheets.push(url);
|
|
214
233
|
}
|
|
215
234
|
});
|
|
216
|
-
|
|
235
|
+
const rewritten = rewriteStoredFiles(dataDir, {
|
|
236
|
+
names,
|
|
237
|
+
cssAssets: found.cssAssets,
|
|
238
|
+
kept: found.kept,
|
|
239
|
+
vendored: new Set(target.files.keys()),
|
|
240
|
+
});
|
|
241
|
+
updateManifest(dataDir, found, names, rewritten);
|
|
242
|
+
for (const path of rewritten.replaced) {
|
|
243
|
+
rmSync(path, { force: true });
|
|
244
|
+
}
|
|
217
245
|
const fonts = [...target.files.values()].filter((file) => file.extension !== '.css');
|
|
218
246
|
return {
|
|
219
247
|
stylesheets: stylesheetsPerHost(vendoredStylesheets),
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
/** The name a vendored file gets
|
|
1
|
+
/** The name a vendored or rewritten file gets: the sha1-16 of its bytes plus its extension. */
|
|
2
2
|
export declare function vendoredName(bytes: Uint8Array, extension: string): string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
/** The name a vendored file gets
|
|
2
|
+
/** The name a vendored or rewritten file gets: the sha1-16 of its bytes plus its extension. */
|
|
3
3
|
export function vendoredName(bytes, extension) {
|
|
4
4
|
return `${createHash('sha1').update(bytes).digest('hex').slice(0, 16)}${extension}`;
|
|
5
5
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "describe-me",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Living component documentation generated from your Vitest tests: viewer and CLI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vitest",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"sideEffects": false,
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"rrweb-snapshot": "^2.1.6",
|
|
45
|
-
"@describe-me/core": "^0.5.
|
|
45
|
+
"@describe-me/core": "^0.5.2"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
package/src/apply-viewport.ts
CHANGED
|
@@ -21,10 +21,14 @@ export function applyViewport(
|
|
|
21
21
|
|
|
22
22
|
const width = state.viewport.width ?? available
|
|
23
23
|
const scale = Math.min(1, available / width)
|
|
24
|
+
const visibleHeight =
|
|
25
|
+
stage.clientHeight - parseFloat(padding.paddingTop) - parseFloat(padding.paddingBottom)
|
|
24
26
|
|
|
25
27
|
iframe.style.width = `${width}px`
|
|
26
28
|
|
|
27
|
-
const height =
|
|
29
|
+
const height =
|
|
30
|
+
state.viewport.height ?? naturalHeight(iframe, visibleHeight / scale) ?? iframe.offsetHeight
|
|
31
|
+
|
|
28
32
|
iframe.style.height = `${height}px`
|
|
29
33
|
|
|
30
34
|
if (scale < 1) {
|
package/src/data.ts
CHANGED
|
@@ -34,26 +34,45 @@ export async function loadManifest(): Promise<void> {
|
|
|
34
34
|
* Fetch one serialized DOM, memoized until the next manifest load. The promise
|
|
35
35
|
* resolves with asset URLs made absolute and stored stylesheets put back, so
|
|
36
36
|
* the stage and the gallery thumbnails always rebuild complete CSS and measure
|
|
37
|
-
* styled boxes.
|
|
37
|
+
* styled boxes. A snapshot that failed or came back with missing styles is not
|
|
38
|
+
* kept, so its next paint loads it again.
|
|
38
39
|
*/
|
|
39
40
|
export function loadSnapshot(path: string): Promise<SerializedNode> {
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
.then((response) => response.text())
|
|
44
|
-
.then((json) => parseSnapshot(json))
|
|
45
|
-
.then(async (node) => {
|
|
46
|
-
await restoreStyles(node)
|
|
47
|
-
|
|
48
|
-
return node
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
snapshotCache.set(path, pending)
|
|
41
|
+
const memo = snapshotCache.get(path)
|
|
42
|
+
if (memo) {
|
|
43
|
+
return memo
|
|
52
44
|
}
|
|
53
45
|
|
|
46
|
+
// A new manifest may have replaced the entry by the time this one settles.
|
|
47
|
+
const forget = (): void => {
|
|
48
|
+
if (snapshotCache.get(path) === pending) {
|
|
49
|
+
snapshotCache.delete(path)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const pending = fetchSnapshot(path).then(async (node) => {
|
|
54
|
+
if (!(await restoreStyles(node))) {
|
|
55
|
+
forget()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return node
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
pending.catch(forget)
|
|
62
|
+
snapshotCache.set(path, pending)
|
|
63
|
+
|
|
54
64
|
return pending
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
async function fetchSnapshot(path: string): Promise<SerializedNode> {
|
|
68
|
+
const response = await fetch(`__data/${path}`, { cache: 'no-store' })
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
throw new Error(`snapshot ${path}: ${response.status}`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return parseSnapshot(await response.text())
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
function parseSnapshot(json: string): SerializedNode {
|
|
58
77
|
return JSON.parse(resolveAssetUrls(json)) as SerializedNode
|
|
59
78
|
}
|
package/src/diagnostics-menu.ts
CHANGED
|
@@ -32,15 +32,16 @@ function pick(testId: string): void {
|
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* A header chip that opens the list of what this manifest could not document:
|
|
35
|
-
* anonymous components, components without props
|
|
36
|
-
* load, font families that nothing loads and hosts
|
|
37
|
-
* stylesheets from. Absent when there is nothing to report.
|
|
35
|
+
* problems in the test setup, anonymous components, components without props
|
|
36
|
+
* docs, assets that will not load, font families that nothing loads and hosts
|
|
37
|
+
* that frames load stylesheets from. Absent when there is nothing to report.
|
|
38
38
|
*/
|
|
39
39
|
export function renderDiagnosticsMenu(manifest: Manifest): HTMLElement | null {
|
|
40
|
-
const { anonymous, undocumented, assetsMissing, fontsMissing, remoteStylesheets } =
|
|
40
|
+
const { setupWarnings, anonymous, undocumented, assetsMissing, fontsMissing, remoteStylesheets } =
|
|
41
41
|
manifestDiagnostics(manifest)
|
|
42
42
|
|
|
43
43
|
const count =
|
|
44
|
+
setupWarnings.length +
|
|
44
45
|
anonymous.length +
|
|
45
46
|
undocumented.length +
|
|
46
47
|
assetsMissing.length +
|
|
@@ -53,6 +54,18 @@ export function renderDiagnosticsMenu(manifest: Manifest): HTMLElement | null {
|
|
|
53
54
|
|
|
54
55
|
const sections: HTMLElement[] = []
|
|
55
56
|
|
|
57
|
+
if (setupWarnings.length > 0) {
|
|
58
|
+
const items = setupWarnings.map((warning) => el('li', {}, warning))
|
|
59
|
+
|
|
60
|
+
sections.push(
|
|
61
|
+
section(
|
|
62
|
+
'Setup',
|
|
63
|
+
'The plugin found a problem in the test setup that can make tests fail.',
|
|
64
|
+
items,
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
if (anonymous.length > 0) {
|
|
57
70
|
const items = anonymous.map((test) =>
|
|
58
71
|
el(
|
package/src/fonts-settled.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/** What failed to load in a replayed frame, each list deduplicated and in document order. */
|
|
2
|
+
export interface FontFailures {
|
|
3
|
+
families: string[]
|
|
4
|
+
stylesheets: string[]
|
|
5
|
+
}
|
|
6
|
+
|
|
1
7
|
/**
|
|
2
8
|
* Wait until the next frame has been styled and laid out. A frame callback
|
|
3
9
|
* runs before that, the timeout after it, when the fonts that freshly loaded
|
|
@@ -9,22 +15,62 @@ function nextFrame(): Promise<void> {
|
|
|
9
15
|
})
|
|
10
16
|
}
|
|
11
17
|
|
|
18
|
+
/**
|
|
19
|
+
* A failed link by its host when it comes from elsewhere, by its file name
|
|
20
|
+
* otherwise. Parsed URLs are compared, never substrings of them.
|
|
21
|
+
*/
|
|
22
|
+
function stylesheetName(link: HTMLLinkElement): string | null {
|
|
23
|
+
let url: URL
|
|
24
|
+
try {
|
|
25
|
+
url = new URL(link.href)
|
|
26
|
+
} catch {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (url.origin !== location.origin) {
|
|
31
|
+
return url.host
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return url.pathname.split('/').pop() || url.pathname
|
|
35
|
+
}
|
|
36
|
+
|
|
12
37
|
/** A link that already has its sheet counts as loaded, a failed one as settled. */
|
|
13
|
-
function stylesheetSettled(link: HTMLLinkElement): Promise<void> {
|
|
38
|
+
function stylesheetSettled(link: HTMLLinkElement, failed: Set<HTMLLinkElement>): Promise<void> {
|
|
14
39
|
if (link.sheet) {
|
|
15
40
|
return Promise.resolve()
|
|
16
41
|
}
|
|
17
42
|
|
|
18
43
|
return new Promise((resolve) => {
|
|
19
44
|
link.addEventListener('load', () => resolve(), { once: true })
|
|
20
|
-
link.addEventListener(
|
|
45
|
+
link.addEventListener(
|
|
46
|
+
'error',
|
|
47
|
+
() => {
|
|
48
|
+
failed.add(link)
|
|
49
|
+
resolve()
|
|
50
|
+
},
|
|
51
|
+
{ once: true },
|
|
52
|
+
)
|
|
21
53
|
})
|
|
22
54
|
}
|
|
23
55
|
|
|
24
|
-
function stylesheetsSettled(doc: Document): Promise<unknown> {
|
|
56
|
+
function stylesheetsSettled(doc: Document, failed: Set<HTMLLinkElement>): Promise<unknown> {
|
|
25
57
|
const links = Array.from(doc.querySelectorAll<HTMLLinkElement>('link[rel~=stylesheet]'))
|
|
26
58
|
|
|
27
|
-
return Promise.all(links.map((link) => stylesheetSettled(link)))
|
|
59
|
+
return Promise.all(links.map((link) => stylesheetSettled(link, failed)))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function failures(doc: Document, failed: Set<HTMLLinkElement>): FontFailures {
|
|
63
|
+
const links = Array.from(doc.querySelectorAll<HTMLLinkElement>('link[rel~=stylesheet]'))
|
|
64
|
+
const stylesheets = links
|
|
65
|
+
.filter((link) => failed.has(link))
|
|
66
|
+
.map((link) => stylesheetName(link))
|
|
67
|
+
.filter((name) => name !== null)
|
|
68
|
+
|
|
69
|
+
const families = Array.from(doc.fonts)
|
|
70
|
+
.filter((face) => face.status === 'error')
|
|
71
|
+
.map((face) => face.family.replace(/^(["'])(.*)\1$/, '$2'))
|
|
72
|
+
|
|
73
|
+
return { families: [...new Set(families)], stylesheets: [...new Set(stylesheets)] }
|
|
28
74
|
}
|
|
29
75
|
|
|
30
76
|
function loadingFacesSettled(doc: Document): Promise<unknown> {
|
|
@@ -40,8 +86,8 @@ async function facesSettled(doc: Document): Promise<void> {
|
|
|
40
86
|
await loadingFacesSettled(doc)
|
|
41
87
|
}
|
|
42
88
|
|
|
43
|
-
async function settle(doc: Document): Promise<void> {
|
|
44
|
-
await stylesheetsSettled(doc)
|
|
89
|
+
async function settle(doc: Document, failed: Set<HTMLLinkElement>): Promise<void> {
|
|
90
|
+
await stylesheetsSettled(doc, failed)
|
|
45
91
|
await nextFrame()
|
|
46
92
|
// rrweb reopens the document and never closes it, so `fonts.ready` may never
|
|
47
93
|
// resolve. It may only end the wait early, and only from here on: raced any
|
|
@@ -51,23 +97,26 @@ async function settle(doc: Document): Promise<void> {
|
|
|
51
97
|
|
|
52
98
|
/**
|
|
53
99
|
* Resolve once the stylesheets and web fonts of a replayed frame have loaded
|
|
54
|
-
* or failed, or after `timeoutMs
|
|
55
|
-
* rebuild, before any `await`, so no stylesheet's
|
|
100
|
+
* or failed, or after `timeoutMs`, with what failed so far. Never rejects.
|
|
101
|
+
* Call it right after the rebuild, before any `await`, so no stylesheet's
|
|
102
|
+
* `load` or `error` event is missed.
|
|
56
103
|
*/
|
|
57
|
-
export function fontsSettled(iframe: HTMLIFrameElement, timeoutMs = 3000): Promise<
|
|
104
|
+
export function fontsSettled(iframe: HTMLIFrameElement, timeoutMs = 3000): Promise<FontFailures> {
|
|
58
105
|
const doc = iframe.contentDocument
|
|
59
106
|
if (!doc) {
|
|
60
|
-
return Promise.resolve()
|
|
107
|
+
return Promise.resolve({ families: [], stylesheets: [] })
|
|
61
108
|
}
|
|
62
109
|
|
|
110
|
+
const failed = new Set<HTMLLinkElement>()
|
|
111
|
+
|
|
63
112
|
return new Promise((resolve) => {
|
|
64
|
-
const timer = setTimeout(resolve, timeoutMs)
|
|
113
|
+
const timer = setTimeout(() => resolve(failures(doc, failed)), timeoutMs)
|
|
65
114
|
|
|
66
|
-
settle(doc)
|
|
115
|
+
settle(doc, failed)
|
|
67
116
|
.catch(() => undefined)
|
|
68
117
|
.finally(() => {
|
|
69
118
|
clearTimeout(timer)
|
|
70
|
-
resolve()
|
|
119
|
+
resolve(failures(doc, failed))
|
|
71
120
|
})
|
|
72
121
|
})
|
|
73
122
|
}
|
package/src/keyboard.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ManifestTest } from '@describe-me/core/types'
|
|
|
2
2
|
import { regions } from './regions.js'
|
|
3
3
|
import { rerender } from './rerender.js'
|
|
4
4
|
import { testsInScope } from './scope.js'
|
|
5
|
+
import { sidebarOrder } from './sidebar-order.js'
|
|
5
6
|
import { currentTest, select, state, writeHash } from './state.js'
|
|
6
7
|
import { visibleTests } from './visible-tests.js'
|
|
7
8
|
|
|
@@ -20,14 +21,33 @@ function onSuiteKey(event: KeyboardEvent, key: string): void {
|
|
|
20
21
|
event.preventDefault()
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
/** The
|
|
24
|
-
function
|
|
24
|
+
/** The first test after `id` in sidebar order, going by `step`, that the sidebar shows. */
|
|
25
|
+
function nearestShown(shown: ManifestTest[], id: string, step: 1 | -1): ManifestTest | undefined {
|
|
26
|
+
const shownIds = new Set(shown.map((test) => test.id))
|
|
27
|
+
const order = sidebarOrder()
|
|
28
|
+
const start = order.findIndex((candidate) => candidate.id === id)
|
|
29
|
+
const ahead = step === 1 ? order.slice(start + 1) : order.slice(0, Math.max(0, start)).reverse()
|
|
30
|
+
|
|
31
|
+
return ahead.find((candidate) => shownIds.has(candidate.id))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The next or previous test the sidebar shows. From a test hidden in a
|
|
36
|
+
* collapsed suite, that is the nearest shown row in sidebar order. From a test
|
|
37
|
+
* the search does not match, ↓ enters at the first match and ↑ at the last.
|
|
38
|
+
*/
|
|
39
|
+
function neighbour(id: string, step: 1 | -1): ManifestTest | undefined {
|
|
40
|
+
const tests = visibleTests()
|
|
25
41
|
const index = tests.findIndex((candidate) => candidate.id === id)
|
|
26
|
-
if (index
|
|
27
|
-
return
|
|
42
|
+
if (index >= 0) {
|
|
43
|
+
return tests[index + step]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (state.query.trim() === '') {
|
|
47
|
+
return nearestShown(tests, id, step)
|
|
28
48
|
}
|
|
29
49
|
|
|
30
|
-
return tests[
|
|
50
|
+
return step === 1 ? tests[0] : tests[tests.length - 1]
|
|
31
51
|
}
|
|
32
52
|
|
|
33
53
|
/** Arrow keys move the caret or the value in a field, so shortcuts stay out of the way there. */
|
|
@@ -81,9 +101,9 @@ export function onKey(event: KeyboardEvent): void {
|
|
|
81
101
|
}
|
|
82
102
|
|
|
83
103
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
84
|
-
const next = neighbour(
|
|
104
|
+
const next = neighbour(test.id, event.key === 'ArrowDown' ? 1 : -1)
|
|
85
105
|
if (next) {
|
|
86
|
-
select(next.id)
|
|
106
|
+
select(next.id, 0, 'replace')
|
|
87
107
|
}
|
|
88
108
|
|
|
89
109
|
event.preventDefault()
|
package/src/load-style.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { resolveAssetUrls } from './resolve-asset-urls.js'
|
|
2
2
|
|
|
3
|
+
/** How long a failed chunk waits before its one retry. */
|
|
4
|
+
const RETRY_DELAY_MS = 500
|
|
5
|
+
|
|
6
|
+
/** A stylesheet put back together, and whether every chunk of it arrived. */
|
|
7
|
+
export interface LoadedStyle {
|
|
8
|
+
css: string
|
|
9
|
+
complete: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
3
12
|
/**
|
|
4
13
|
* Chunk texts by hash, kept as promises so parallel thumbnails share one
|
|
5
14
|
* request. Never cleared, because a hash always names the same text.
|
|
@@ -7,12 +16,17 @@ import { resolveAssetUrls } from './resolve-asset-urls.js'
|
|
|
7
16
|
const chunks = new Map<string, Promise<string | null>>()
|
|
8
17
|
|
|
9
18
|
/** Joined sheets by reference, so every snapshot using a sheet shares one string. */
|
|
10
|
-
const sheets = new Map<string, Promise<
|
|
19
|
+
const sheets = new Map<string, Promise<LoadedStyle>>()
|
|
11
20
|
|
|
12
|
-
|
|
21
|
+
function delay(ms: number): Promise<void> {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
setTimeout(resolve, ms)
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function fetchOnce(hash: string, init?: RequestInit): Promise<string | null> {
|
|
13
28
|
try {
|
|
14
|
-
|
|
15
|
-
const response = await fetch(`__data/styles/${hash}.css`)
|
|
29
|
+
const response = await fetch(`__data/styles/${hash}.css`, init)
|
|
16
30
|
|
|
17
31
|
return response.ok ? resolveAssetUrls(await response.text()) : null
|
|
18
32
|
} catch {
|
|
@@ -20,6 +34,20 @@ async function fetchChunk(hash: string): Promise<string | null> {
|
|
|
20
34
|
}
|
|
21
35
|
}
|
|
22
36
|
|
|
37
|
+
async function fetchChunk(hash: string): Promise<string | null> {
|
|
38
|
+
// Default caching: the name is a content hash, so a cached copy is always right.
|
|
39
|
+
const first = await fetchOnce(hash)
|
|
40
|
+
if (first !== null) {
|
|
41
|
+
return first
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A chunk may be missing for a moment while a site redeploys. The retry
|
|
45
|
+
// bypasses the HTTP cache, which may have kept the 404.
|
|
46
|
+
await delay(RETRY_DELAY_MS)
|
|
47
|
+
|
|
48
|
+
return fetchOnce(hash, { cache: 'reload' })
|
|
49
|
+
}
|
|
50
|
+
|
|
23
51
|
function loadChunk(hash: string): Promise<string | null> {
|
|
24
52
|
const memo = chunks.get(hash)
|
|
25
53
|
if (memo) {
|
|
@@ -42,10 +70,10 @@ function loadChunk(hash: string): Promise<string | null> {
|
|
|
42
70
|
|
|
43
71
|
/**
|
|
44
72
|
* The stylesheet behind a reference, the text after `STYLE_URL_PREFIX`. A chunk
|
|
45
|
-
* that fails to load leaves a gap rather than failing the frame,
|
|
46
|
-
* again next time.
|
|
73
|
+
* that fails to load leaves a gap rather than failing the frame, marks the
|
|
74
|
+
* sheet incomplete and is fetched again next time.
|
|
47
75
|
*/
|
|
48
|
-
export function loadStyle(reference: string): Promise<
|
|
76
|
+
export function loadStyle(reference: string): Promise<LoadedStyle> {
|
|
49
77
|
const memo = sheets.get(reference)
|
|
50
78
|
if (memo) {
|
|
51
79
|
return memo
|
|
@@ -53,11 +81,12 @@ export function loadStyle(reference: string): Promise<string> {
|
|
|
53
81
|
|
|
54
82
|
const loads = reference.split('+').map((hash) => loadChunk(hash))
|
|
55
83
|
const pending = Promise.all(loads).then((texts) => {
|
|
56
|
-
|
|
84
|
+
const complete = !texts.includes(null)
|
|
85
|
+
if (!complete) {
|
|
57
86
|
sheets.delete(reference)
|
|
58
87
|
}
|
|
59
88
|
|
|
60
|
-
return texts.map((text) => text ?? '').join('')
|
|
89
|
+
return { css: texts.map((text) => text ?? '').join(''), complete }
|
|
61
90
|
})
|
|
62
91
|
|
|
63
92
|
sheets.set(reference, pending)
|