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.
Files changed (39) hide show
  1. package/README.md +13 -3
  2. package/dist-cli/file-names-in.d.ts +2 -0
  3. package/dist-cli/file-names-in.js +11 -0
  4. package/dist-cli/remote-stylesheets.d.ts +7 -0
  5. package/dist-cli/remote-stylesheets.js +178 -0
  6. package/dist-cli/replace-asset-names.d.ts +2 -0
  7. package/dist-cli/replace-asset-names.js +13 -0
  8. package/dist-cli/replace-remote-urls.d.ts +6 -0
  9. package/dist-cli/replace-remote-urls.js +18 -0
  10. package/dist-cli/rewrite-stored-files.d.ts +27 -0
  11. package/dist-cli/rewrite-stored-files.js +174 -0
  12. package/dist-cli/store-vendored-file.d.ts +5 -2
  13. package/dist-cli/store-vendored-file.js +7 -4
  14. package/dist-cli/vendor-fonts.d.ts +5 -2
  15. package/dist-cli/vendor-fonts.js +77 -49
  16. package/dist-cli/vendored-name.d.ts +1 -1
  17. package/dist-cli/vendored-name.js +1 -1
  18. package/package.json +2 -2
  19. package/src/apply-viewport.ts +5 -1
  20. package/src/data.ts +32 -13
  21. package/src/diagnostics-menu.ts +17 -4
  22. package/src/fonts-settled.ts +62 -13
  23. package/src/keyboard.ts +27 -7
  24. package/src/load-style.ts +38 -9
  25. package/src/min-frame-height.ts +2 -0
  26. package/src/natural-height.ts +53 -13
  27. package/src/parse-viewport-size.ts +4 -3
  28. package/src/regions.ts +5 -2
  29. package/src/render-all.ts +7 -1
  30. package/src/render-tree.ts +5 -0
  31. package/src/restore-styles.ts +10 -4
  32. package/src/sidebar-order.ts +31 -0
  33. package/src/stage-note.ts +32 -0
  34. package/src/stage.ts +6 -2
  35. package/src/state.ts +8 -4
  36. package/src/style.css +31 -2
  37. package/src/test-view.ts +7 -4
  38. package/src/version-banner.ts +35 -0
  39. package/src/viewport-controls.ts +23 -6
@@ -1,18 +1,24 @@
1
- import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
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', prefix: ASSET_URL_PREFIX },
14
- { dir: 'styles', extension: '.css', syntax: 'css', prefix: ASSET_URL_PREFIX },
15
- { dir: 'assets', extension: '.css', syntax: 'css', prefix: '' },
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, prefix }) => {
48
+ return TEXT_DIRECTORIES.flatMap(({ dir, extension, syntax }) => {
43
49
  const abs = join(dataDir, dir);
44
- if (!existsSync(abs)) {
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 files = textFilesIn(dataDir).filter((file) => {
78
- return needsVendoring(readFileSync(file.path, 'utf8'), file.syntax);
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 { files, head: head !== null && needsVendoring(head, 'html'), plans };
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
- /** Each stored name written in place of every URL it replaces, left to right. */
108
- function rewrite(text, file, names) {
109
- const parts = [];
110
- let last = 0;
111
- for (const match of findRemoteUrls(text, file.syntax)) {
112
- const name = names.get(match.url);
113
- if (name !== undefined) {
114
- parts.push(text.slice(last, match.start), `${file.prefix}${name}`);
115
- last = match.end;
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
- function rewriteHead(dataDir, names) {
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
- const head = headOf(manifest);
124
- if (manifest === null || head === null) {
143
+ if (manifest === null) {
125
144
  return;
126
145
  }
127
- const rewritten = rewrite(head, { syntax: 'html', prefix: ASSET_URL_PREFIX }, names);
128
- if (rewritten !== head) {
129
- manifest.head = rewritten;
130
- writeFileSync(join(dataDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
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
- function rewriteTexts(dataDir, found, names) {
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 (found.head) {
142
- rewriteHead(dataDir, names);
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. Every file
187
- * is stored before any text changes, so a crash leaves only valid references.
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
- rewriteTexts(dataDir, found, names);
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 in `assets/`: the sha1-16 of its bytes plus its extension. */
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 in `assets/`: the sha1-16 of its bytes plus its extension. */
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.0",
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.0"
45
+ "@describe-me/core": "^0.5.2"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
@@ -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 = state.viewport.height ?? naturalHeight(iframe) ?? iframe.offsetHeight
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
- let pending = snapshotCache.get(path)
41
- if (!pending) {
42
- pending = fetch(`__data/${path}`, { cache: 'no-store' })
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
  }
@@ -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 docs, assets that will not
36
- * load, font families that nothing loads and hosts that frames load
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(
@@ -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('error', () => resolve(), { once: true })
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`. Never rejects. Call it right after the
55
- * rebuild, before any `await`, so no stylesheet's `load` event is missed.
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<void> {
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 next or previous test in the list. From outside it, ↓ enters at the top and ↑ at the bottom. */
24
- function neighbour(tests: ManifestTest[], id: string, step: 1 | -1): ManifestTest | undefined {
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 < 0) {
27
- return step === 1 ? tests[0] : tests[tests.length - 1]
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[index + step]
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(visibleTests(), test.id, event.key === 'ArrowDown' ? 1 : -1)
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<string>>()
19
+ const sheets = new Map<string, Promise<LoadedStyle>>()
11
20
 
12
- async function fetchChunk(hash: string): Promise<string | null> {
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
- // Default caching: the name is a content hash, so a cached copy is always right.
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, and is fetched
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<string> {
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
- if (texts.includes(null)) {
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)
@@ -0,0 +1,2 @@
1
+ /** The shortest stage frame in CSS pixels, for auto height and for a fixed `h` alike. */
2
+ export const MIN_FRAME_HEIGHT = 120