describe-me 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 +3 -0
- package/package.json +8 -5
- package/src/data.ts +16 -13
- package/src/fonts-settled.ts +73 -0
- package/src/load-style.ts +66 -0
- package/src/preview-head.ts +21 -0
- package/src/replay-cache.ts +4 -0
- package/src/replay-snapshot.ts +33 -0
- package/src/resolve-asset-urls.ts +9 -0
- package/src/restore-styles.ts +44 -0
- package/src/serialized-node.ts +4 -0
- package/src/stage.ts +10 -8
- package/src/states-gallery.ts +10 -8
- package/src/without-empty-tests.ts +12 -0
package/README.md
CHANGED
|
@@ -14,6 +14,9 @@ writes, and replays the snapshots in a sandboxed iframe.
|
|
|
14
14
|
pnpm add -D describe-me @describe-me/vitest @describe-me/react
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
`vite` is a peer dependency (Vitest requires it anyway): pnpm and npm install
|
|
18
|
+
it automatically; with yarn, add `vite` to your `devDependencies`.
|
|
19
|
+
|
|
17
20
|
## Usage
|
|
18
21
|
|
|
19
22
|
```sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "describe-me",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-next.1",
|
|
4
4
|
"description": "Living component documentation generated from your Vitest tests: viewer and CLI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vitest",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"bugs": "https://github.com/grzehub/describe-me/issues",
|
|
22
22
|
"type": "module",
|
|
23
23
|
"engines": {
|
|
24
|
-
"node": "^20.
|
|
24
|
+
"node": "^20.16.0 || >=22.4.0"
|
|
25
25
|
},
|
|
26
26
|
"publishConfig": {
|
|
27
27
|
"access": "public",
|
|
@@ -42,11 +42,14 @@
|
|
|
42
42
|
"sideEffects": false,
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"rrweb-snapshot": "^2.1.6",
|
|
45
|
-
"
|
|
46
|
-
|
|
45
|
+
"@describe-me/core": "^0.5.0-next.1"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
|
47
49
|
},
|
|
48
50
|
"devDependencies": {
|
|
49
|
-
"@types/node": "^22"
|
|
51
|
+
"@types/node": "^22",
|
|
52
|
+
"vite": "^8.3.0"
|
|
50
53
|
},
|
|
51
54
|
"scripts": {
|
|
52
55
|
"build": "rimraf dist-cli && tsc -p tsconfig.cli.json",
|
package/src/data.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import { ASSET_URL_PREFIX, type Manifest } from '@describe-me/core/types'
|
|
1
|
+
import type { Manifest } from '@describe-me/core/types'
|
|
3
2
|
import { allTests, currentTest, state } from './state.js'
|
|
4
3
|
import { rerender } from './rerender.js'
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
type SerializedNode
|
|
4
|
+
import { resolveAssetUrls } from './resolve-asset-urls.js'
|
|
5
|
+
import { restoreStyles } from './restore-styles.js'
|
|
6
|
+
import type { SerializedNode } from './serialized-node.js'
|
|
7
|
+
import { withoutEmptyTests } from './without-empty-tests.js'
|
|
8
8
|
|
|
9
9
|
const snapshotCache = new Map<string, Promise<SerializedNode>>()
|
|
10
10
|
|
|
@@ -18,7 +18,7 @@ export async function loadManifest(): Promise<void> {
|
|
|
18
18
|
throw new Error(`manifest: ${response.status}`)
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const next = (await response.json()) as Manifest
|
|
21
|
+
const next = withoutEmptyTests((await response.json()) as Manifest)
|
|
22
22
|
if (state.manifest?.generatedAt === next.generatedAt) {
|
|
23
23
|
return
|
|
24
24
|
}
|
|
@@ -40,10 +40,10 @@ export async function loadManifest(): Promise<void> {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* Fetch one serialized DOM, memoized until the next manifest load.
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
43
|
+
* Fetch one serialized DOM, memoized until the next manifest load. The promise
|
|
44
|
+
* resolves with asset URLs made absolute and stored stylesheets put back, so
|
|
45
|
+
* the stage and the gallery thumbnails always rebuild complete CSS and measure
|
|
46
|
+
* styled boxes.
|
|
47
47
|
*/
|
|
48
48
|
export function loadSnapshot(path: string): Promise<SerializedNode> {
|
|
49
49
|
let pending = snapshotCache.get(path)
|
|
@@ -51,6 +51,11 @@ export function loadSnapshot(path: string): Promise<SerializedNode> {
|
|
|
51
51
|
pending = fetch(`__data/${path}`, { cache: 'no-store' })
|
|
52
52
|
.then((response) => response.text())
|
|
53
53
|
.then((json) => parseSnapshot(json))
|
|
54
|
+
.then(async (node) => {
|
|
55
|
+
await restoreStyles(node)
|
|
56
|
+
|
|
57
|
+
return node
|
|
58
|
+
})
|
|
54
59
|
|
|
55
60
|
snapshotCache.set(path, pending)
|
|
56
61
|
}
|
|
@@ -59,7 +64,5 @@ export function loadSnapshot(path: string): Promise<SerializedNode> {
|
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
function parseSnapshot(json: string): SerializedNode {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return JSON.parse(json.replaceAll(ASSET_URL_PREFIX, assetsUrl)) as SerializedNode
|
|
67
|
+
return JSON.parse(resolveAssetUrls(json)) as SerializedNode
|
|
65
68
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wait until the next frame has been styled and laid out. A frame callback
|
|
3
|
+
* runs before that, the timeout after it, when the fonts that freshly loaded
|
|
4
|
+
* rules need have started loading.
|
|
5
|
+
*/
|
|
6
|
+
function nextFrame(): Promise<void> {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
requestAnimationFrame(() => setTimeout(resolve, 0))
|
|
9
|
+
})
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A link that already has its sheet counts as loaded, a failed one as settled. */
|
|
13
|
+
function stylesheetSettled(link: HTMLLinkElement): Promise<void> {
|
|
14
|
+
if (link.sheet) {
|
|
15
|
+
return Promise.resolve()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
link.addEventListener('load', () => resolve(), { once: true })
|
|
20
|
+
link.addEventListener('error', () => resolve(), { once: true })
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stylesheetsSettled(doc: Document): Promise<unknown> {
|
|
25
|
+
const links = Array.from(doc.querySelectorAll<HTMLLinkElement>('link[rel~=stylesheet]'))
|
|
26
|
+
|
|
27
|
+
return Promise.all(links.map((link) => stylesheetSettled(link)))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadingFacesSettled(doc: Document): Promise<unknown> {
|
|
31
|
+
const loading = Array.from(doc.fonts).filter((face) => face.status === 'loading')
|
|
32
|
+
|
|
33
|
+
return Promise.allSettled(loading.map((face) => face.loaded))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function facesSettled(doc: Document): Promise<void> {
|
|
37
|
+
await loadingFacesSettled(doc)
|
|
38
|
+
// A second pass for the faces that started while the first ones loaded.
|
|
39
|
+
await nextFrame()
|
|
40
|
+
await loadingFacesSettled(doc)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function settle(doc: Document): Promise<void> {
|
|
44
|
+
await stylesheetsSettled(doc)
|
|
45
|
+
await nextFrame()
|
|
46
|
+
// rrweb reopens the document and never closes it, so `fonts.ready` may never
|
|
47
|
+
// resolve. It may only end the wait early, and only from here on: raced any
|
|
48
|
+
// sooner, it may have resolved before a single font started loading.
|
|
49
|
+
await Promise.race([facesSettled(doc), doc.fonts.ready])
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 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.
|
|
56
|
+
*/
|
|
57
|
+
export function fontsSettled(iframe: HTMLIFrameElement, timeoutMs = 3000): Promise<void> {
|
|
58
|
+
const doc = iframe.contentDocument
|
|
59
|
+
if (!doc) {
|
|
60
|
+
return Promise.resolve()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const timer = setTimeout(resolve, timeoutMs)
|
|
65
|
+
|
|
66
|
+
settle(doc)
|
|
67
|
+
.catch(() => undefined)
|
|
68
|
+
.finally(() => {
|
|
69
|
+
clearTimeout(timer)
|
|
70
|
+
resolve()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { resolveAssetUrls } from './resolve-asset-urls.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Chunk texts by hash, kept as promises so parallel thumbnails share one
|
|
5
|
+
* request. Never cleared, because a hash always names the same text.
|
|
6
|
+
*/
|
|
7
|
+
const chunks = new Map<string, Promise<string | null>>()
|
|
8
|
+
|
|
9
|
+
/** Joined sheets by reference, so every snapshot using a sheet shares one string. */
|
|
10
|
+
const sheets = new Map<string, Promise<string>>()
|
|
11
|
+
|
|
12
|
+
async function fetchChunk(hash: string): Promise<string | null> {
|
|
13
|
+
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`)
|
|
16
|
+
|
|
17
|
+
return response.ok ? resolveAssetUrls(await response.text()) : null
|
|
18
|
+
} catch {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function loadChunk(hash: string): Promise<string | null> {
|
|
24
|
+
const memo = chunks.get(hash)
|
|
25
|
+
if (memo) {
|
|
26
|
+
return memo
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const pending = fetchChunk(hash).then((css) => {
|
|
30
|
+
if (css === null) {
|
|
31
|
+
console.warn(`describe-me: style chunk styles/${hash}.css did not load`)
|
|
32
|
+
chunks.delete(hash)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return css
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
chunks.set(hash, pending)
|
|
39
|
+
|
|
40
|
+
return pending
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 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.
|
|
47
|
+
*/
|
|
48
|
+
export function loadStyle(reference: string): Promise<string> {
|
|
49
|
+
const memo = sheets.get(reference)
|
|
50
|
+
if (memo) {
|
|
51
|
+
return memo
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const loads = reference.split('+').map((hash) => loadChunk(hash))
|
|
55
|
+
const pending = Promise.all(loads).then((texts) => {
|
|
56
|
+
if (texts.includes(null)) {
|
|
57
|
+
sheets.delete(reference)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return texts.map((text) => text ?? '').join('')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
sheets.set(reference, pending)
|
|
64
|
+
|
|
65
|
+
return pending
|
|
66
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { resolveAssetUrls } from './resolve-asset-urls.js'
|
|
2
|
+
import { state } from './state.js'
|
|
3
|
+
|
|
4
|
+
let memo: { generatedAt: string; html: string } | null = null
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The manifest's preview head, with asset URLs made absolute, ready to go into
|
|
8
|
+
* every replayed frame. Empty when the manifest has none.
|
|
9
|
+
*/
|
|
10
|
+
export function previewHeadHtml(): string {
|
|
11
|
+
const manifest = state.manifest
|
|
12
|
+
if (!manifest) {
|
|
13
|
+
return ''
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (memo?.generatedAt !== manifest.generatedAt) {
|
|
17
|
+
memo = { generatedAt: manifest.generatedAt, html: resolveAssetUrls(manifest.head ?? '') }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return memo.html
|
|
21
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createMirror, rebuildIntoSandboxedIframe } from 'rrweb-snapshot'
|
|
2
|
+
import { previewHeadHtml } from './preview-head.js'
|
|
3
|
+
import { replayCache } from './replay-cache.js'
|
|
4
|
+
import type { SerializedNode } from './serialized-node.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Rebuild a snapshot into a fresh sandboxed iframe inside `root`, with the
|
|
8
|
+
* preview head at the start of its `<head>`.
|
|
9
|
+
*/
|
|
10
|
+
export function replaySnapshot(
|
|
11
|
+
root: HTMLElement,
|
|
12
|
+
node: SerializedNode,
|
|
13
|
+
title: string,
|
|
14
|
+
): HTMLIFrameElement {
|
|
15
|
+
const { iframe } = rebuildIntoSandboxedIframe(node, {
|
|
16
|
+
root,
|
|
17
|
+
iframeAttributes: { title },
|
|
18
|
+
cache: replayCache,
|
|
19
|
+
mirror: createMirror(),
|
|
20
|
+
hackCss: true,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const html = previewHeadHtml()
|
|
24
|
+
const head = iframe.contentDocument?.head
|
|
25
|
+
|
|
26
|
+
// First, so the captured styles win ties, as in Storybook. The sandbox
|
|
27
|
+
// (`allow-same-origin` only) never runs the head's scripts.
|
|
28
|
+
if (html !== '' && head) {
|
|
29
|
+
head.insertAdjacentHTML('afterbegin', html)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return iframe
|
|
33
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ASSET_URL_PREFIX } from '@describe-me/core/types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Make stored asset URLs absolute against the page, because a snapshot replays
|
|
5
|
+
* in a sandboxed iframe that has no base URL of its own.
|
|
6
|
+
*/
|
|
7
|
+
export function resolveAssetUrls(text: string): string {
|
|
8
|
+
return text.replaceAll(ASSET_URL_PREFIX, new URL('__data/assets/', location.href).href)
|
|
9
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { STYLE_URL_PREFIX } from '@describe-me/core/types'
|
|
2
|
+
import { loadStyle } from './load-style.js'
|
|
3
|
+
import type { SerializedNode } from './serialized-node.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What the walk reads. `SerializedNode` is `any` here: rrweb-snapshot's types
|
|
7
|
+
* import `@rrweb/types`, which it does not install.
|
|
8
|
+
*/
|
|
9
|
+
interface WalkedNode {
|
|
10
|
+
attributes?: Record<string, unknown>
|
|
11
|
+
childNodes?: WalkedNode[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface StoredSheet {
|
|
15
|
+
attributes: Record<string, unknown>
|
|
16
|
+
reference: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function storedSheets(node: WalkedNode, found: StoredSheet[] = []): StoredSheet[] {
|
|
20
|
+
const cssText = node.attributes?._cssText
|
|
21
|
+
if (node.attributes && typeof cssText === 'string' && cssText.startsWith(STYLE_URL_PREFIX)) {
|
|
22
|
+
found.push({ attributes: node.attributes, reference: cssText.slice(STYLE_URL_PREFIX.length) })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const child of node.childNodes ?? []) {
|
|
26
|
+
storedSheets(child, found)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return found
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Put every stylesheet a snapshot keeps in `styles/` back in place, before
|
|
34
|
+
* rrweb rebuilds it. Snapshots without references pass through unchanged.
|
|
35
|
+
*/
|
|
36
|
+
export async function restoreStyles(node: SerializedNode): Promise<void> {
|
|
37
|
+
const stored = storedSheets(node)
|
|
38
|
+
|
|
39
|
+
await Promise.all(
|
|
40
|
+
stored.map(async ({ attributes, reference }) => {
|
|
41
|
+
attributes._cssText = await loadStyle(reference)
|
|
42
|
+
}),
|
|
43
|
+
)
|
|
44
|
+
}
|
package/src/stage.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { createCache, createMirror, rebuildIntoSandboxedIframe } from 'rrweb-snapshot'
|
|
2
1
|
import type { ManifestFrame } from '@describe-me/core/types'
|
|
3
2
|
import { loadSnapshot } from './data.js'
|
|
4
3
|
import { fitStage } from './fit-stage.js'
|
|
4
|
+
import { fontsSettled } from './fonts-settled.js'
|
|
5
|
+
import { replaySnapshot } from './replay-snapshot.js'
|
|
5
6
|
import { state } from './state.js'
|
|
6
7
|
|
|
7
8
|
let paintToken = 0
|
|
@@ -15,13 +16,8 @@ export async function paintFrame(stage: HTMLElement, frame: ManifestFrame): Prom
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
stage.replaceChildren()
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
iframeAttributes: { title: 'snapshot' },
|
|
21
|
-
cache: createCache(),
|
|
22
|
-
mirror: createMirror(),
|
|
23
|
-
hackCss: true,
|
|
24
|
-
})
|
|
19
|
+
const iframe = replaySnapshot(stage, node, 'snapshot')
|
|
20
|
+
const fonts = fontsSettled(iframe)
|
|
25
21
|
|
|
26
22
|
if (state.width !== 'auto') {
|
|
27
23
|
iframe.style.width = `${state.width}px`
|
|
@@ -33,4 +29,10 @@ export async function paintFrame(stage: HTMLElement, frame: ManifestFrame): Prom
|
|
|
33
29
|
fitStage(iframe)
|
|
34
30
|
}
|
|
35
31
|
})
|
|
32
|
+
|
|
33
|
+
// Web fonts arrive later and change the text's size.
|
|
34
|
+
await fonts
|
|
35
|
+
if (token === paintToken) {
|
|
36
|
+
fitStage(iframe)
|
|
37
|
+
}
|
|
36
38
|
}
|
package/src/states-gallery.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { createCache, createMirror, rebuildIntoSandboxedIframe } from 'rrweb-snapshot'
|
|
2
1
|
import type { ManifestFrame, ManifestTest } from '@describe-me/core/types'
|
|
3
2
|
import { contentBox } from './content-box.js'
|
|
4
3
|
import { loadSnapshot } from './data.js'
|
|
5
4
|
import { el } from './el.js'
|
|
5
|
+
import { fontsSettled } from './fonts-settled.js'
|
|
6
|
+
import { replaySnapshot } from './replay-snapshot.js'
|
|
6
7
|
import { select } from './state.js'
|
|
7
8
|
|
|
8
9
|
/** Bumped on every gallery render, so snapshots that arrive late are dropped. */
|
|
@@ -14,16 +15,17 @@ async function paintThumb(thumb: HTMLElement, frame: ManifestFrame, token: numbe
|
|
|
14
15
|
return
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
iframeAttributes: { title: frame.label },
|
|
20
|
-
cache: createCache(),
|
|
21
|
-
mirror: createMirror(),
|
|
22
|
-
hackCss: true,
|
|
23
|
-
})
|
|
18
|
+
const iframe = replaySnapshot(thumb, node, frame.label)
|
|
19
|
+
const fonts = fontsSettled(iframe)
|
|
24
20
|
|
|
25
21
|
// Styles are in place after a frame; only then do the boxes have their final size.
|
|
26
22
|
requestAnimationFrame(() => fitThumb(thumb, iframe))
|
|
23
|
+
|
|
24
|
+
// Web fonts arrive later and change the text's size.
|
|
25
|
+
await fonts
|
|
26
|
+
if (token === galleryToken && thumb.isConnected) {
|
|
27
|
+
fitThumb(thumb, iframe)
|
|
28
|
+
}
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
const THUMB_PADDING = 16
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Manifest } from '@describe-me/core/types'
|
|
2
|
+
|
|
3
|
+
export function withoutEmptyTests(manifest: Manifest): Manifest {
|
|
4
|
+
const modules = manifest.modules
|
|
5
|
+
.map((mod) => ({
|
|
6
|
+
...mod,
|
|
7
|
+
tests: mod.tests.filter((test) => test.frames.some((frame) => frame.kind !== 'end')),
|
|
8
|
+
}))
|
|
9
|
+
.filter((mod) => mod.tests.length > 0)
|
|
10
|
+
|
|
11
|
+
return { ...manifest, modules }
|
|
12
|
+
}
|