describe-me 0.1.0
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/LICENSE +21 -0
- package/README.md +34 -0
- package/bin/describe-me.js +4 -0
- package/dist-cli/main.d.ts +1 -0
- package/dist-cli/main.js +25 -0
- package/dist-cli/parse-args.d.ts +12 -0
- package/dist-cli/parse-args.js +29 -0
- package/dist-cli/run-build.d.ts +2 -0
- package/dist-cli/run-build.js +13 -0
- package/dist-cli/run-dev.d.ts +2 -0
- package/dist-cli/run-dev.js +15 -0
- package/dist-cli/viewer-root.d.ts +2 -0
- package/dist-cli/viewer-root.js +5 -0
- package/index.html +13 -0
- package/package.json +54 -0
- package/src/coverage.ts +75 -0
- package/src/data.ts +54 -0
- package/src/el.ts +25 -0
- package/src/header.ts +31 -0
- package/src/inspector.ts +86 -0
- package/src/keyboard.ts +58 -0
- package/src/main-panel.ts +69 -0
- package/src/main.ts +41 -0
- package/src/overview-inspector.ts +101 -0
- package/src/overview.ts +92 -0
- package/src/props-table.ts +72 -0
- package/src/render-all.ts +17 -0
- package/src/rerender.ts +15 -0
- package/src/scope-components.ts +14 -0
- package/src/scope.ts +17 -0
- package/src/sidebar.ts +62 -0
- package/src/stage.ts +28 -0
- package/src/state.ts +71 -0
- package/src/states-gallery.ts +191 -0
- package/src/style.css +527 -0
- package/src/suite-key.ts +19 -0
- package/src/tree.ts +27 -0
- package/src/value-cell.ts +22 -0
- package/vite.config.ts +85 -0
package/src/main.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
import { loadManifest } from './data.js'
|
|
3
|
+
import { el } from './el.js'
|
|
4
|
+
import { renderHeader } from './header.js'
|
|
5
|
+
import { onKey } from './keyboard.js'
|
|
6
|
+
import { renderAll } from './render-all.js'
|
|
7
|
+
import { setRerender } from './rerender.js'
|
|
8
|
+
import { readHash } from './state.js'
|
|
9
|
+
|
|
10
|
+
setRerender(renderAll)
|
|
11
|
+
|
|
12
|
+
readHash()
|
|
13
|
+
document.addEventListener('keydown', onKey)
|
|
14
|
+
window.addEventListener('hashchange', () => {
|
|
15
|
+
readHash()
|
|
16
|
+
renderAll()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
loadManifest().catch((err) => {
|
|
20
|
+
document
|
|
21
|
+
.getElementById('app')!
|
|
22
|
+
.replaceChildren(
|
|
23
|
+
renderHeader(),
|
|
24
|
+
el(
|
|
25
|
+
'div',
|
|
26
|
+
{ class: 'stage' },
|
|
27
|
+
el('div', { class: 'empty' }, `no manifest yet — run vitest first (${String(err)})`),
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/** A failed refresh is not worth breaking the page over; the next one may succeed. */
|
|
33
|
+
const refresh = () => loadManifest().catch(() => undefined)
|
|
34
|
+
|
|
35
|
+
if (import.meta.hot) {
|
|
36
|
+
// Dev: the Vite plugin pushes an event whenever the reporter rewrites the manifest.
|
|
37
|
+
import.meta.hot.on('describe-me:update', () => void refresh())
|
|
38
|
+
} else {
|
|
39
|
+
// Static site: data only changes on redeploy, so a slow poll is plenty.
|
|
40
|
+
setInterval(() => void refresh(), 60_000)
|
|
41
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ManifestTest } from '@describe-me/core/types'
|
|
2
|
+
import { computeCoverage } from './coverage.js'
|
|
3
|
+
import { el } from './el.js'
|
|
4
|
+
import { componentNamesInScope } from './scope-components.js'
|
|
5
|
+
import { testsInScope } from './scope.js'
|
|
6
|
+
import { state } from './state.js'
|
|
7
|
+
|
|
8
|
+
interface CoverageTally {
|
|
9
|
+
total: number
|
|
10
|
+
covered: number
|
|
11
|
+
missing: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function tally(tests: ManifestTest[]): CoverageTally {
|
|
15
|
+
const docs = state.manifest?.components ?? {}
|
|
16
|
+
const result: CoverageTally = { total: 0, covered: 0, missing: [] }
|
|
17
|
+
|
|
18
|
+
for (const name of componentNamesInScope(tests)) {
|
|
19
|
+
const doc = docs[name]
|
|
20
|
+
if (!doc) {
|
|
21
|
+
continue
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const own = tests.filter((test) => test.component?.name === name)
|
|
25
|
+
for (const coverage of computeCoverage(doc, own)) {
|
|
26
|
+
for (const entry of coverage.entries) {
|
|
27
|
+
result.total++
|
|
28
|
+
if (entry.covered) {
|
|
29
|
+
result.covered++
|
|
30
|
+
} else {
|
|
31
|
+
result.missing.push(`${coverage.prop.name}: ${entry.label}`)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return result
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderCoverage(tests: ManifestTest[]): HTMLElement {
|
|
41
|
+
const section = el('section', {}, el('h3', {}, 'coverage'))
|
|
42
|
+
const counts = tally(tests)
|
|
43
|
+
if (!counts.total) {
|
|
44
|
+
section.append(el('div', { class: 'hint' }, 'no enumerable props here'))
|
|
45
|
+
|
|
46
|
+
return section
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
section.append(el('div', {}, `${counts.covered} of ${counts.total} values covered`))
|
|
50
|
+
for (const label of counts.missing) {
|
|
51
|
+
section.append(el('div', { class: 'missing-line' }, label))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return section
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function renderTests(tests: ManifestTest[]): HTMLElement {
|
|
58
|
+
const failed = tests.filter((test) => test.state === 'failed').length
|
|
59
|
+
const section = el('section', {}, el('h3', {}, 'tests'))
|
|
60
|
+
section.append(el('div', {}, `${tests.length} in scope`))
|
|
61
|
+
section.append(
|
|
62
|
+
el(
|
|
63
|
+
'div',
|
|
64
|
+
{ class: failed ? 'missing-line' : 'hint' },
|
|
65
|
+
failed ? `${failed} failed` : 'none failed',
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return section
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderComponents(tests: ManifestTest[]): HTMLElement {
|
|
73
|
+
const docs = state.manifest?.components ?? {}
|
|
74
|
+
const section = el('section', {}, el('h3', {}, 'components'))
|
|
75
|
+
for (const name of componentNamesInScope(tests)) {
|
|
76
|
+
section.append(
|
|
77
|
+
el(
|
|
78
|
+
'div',
|
|
79
|
+
{ class: 'component-line' },
|
|
80
|
+
el('span', {}, name),
|
|
81
|
+
el('span', { class: 'hint' }, docs[name]?.file ?? 'unknown file'),
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return section
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The right column while a suite or module is selected: what the tests do and do not cover. */
|
|
90
|
+
export function renderOverviewInspector(): HTMLElement {
|
|
91
|
+
const aside = el('aside', { class: 'inspector' })
|
|
92
|
+
const key = state.suiteKey
|
|
93
|
+
if (!key) {
|
|
94
|
+
return aside
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const tests = testsInScope(key)
|
|
98
|
+
aside.append(renderCoverage(tests), renderTests(tests), renderComponents(tests))
|
|
99
|
+
|
|
100
|
+
return aside
|
|
101
|
+
}
|
package/src/overview.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { ComponentDoc, ManifestTest } from '@describe-me/core/types'
|
|
2
|
+
import { computeCoverage } from './coverage.js'
|
|
3
|
+
import { el } from './el.js'
|
|
4
|
+
import { renderPropsTable } from './props-table.js'
|
|
5
|
+
import { componentNamesInScope } from './scope-components.js'
|
|
6
|
+
import { renderStatesGallery } from './states-gallery.js'
|
|
7
|
+
import { testsInScope } from './scope.js'
|
|
8
|
+
import { state } from './state.js'
|
|
9
|
+
import { parseSuiteKey } from './suite-key.js'
|
|
10
|
+
|
|
11
|
+
function titleOf(key: string): string {
|
|
12
|
+
const { moduleId, path } = parseSuiteKey(key)
|
|
13
|
+
|
|
14
|
+
return path[path.length - 1] ?? moduleId
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function renderHead(key: string, tests: ManifestTest[], docs: Record<string, ComponentDoc>) {
|
|
18
|
+
const files = componentNamesInScope(tests)
|
|
19
|
+
.map((name) => docs[name]?.file)
|
|
20
|
+
.filter((file): file is string => Boolean(file))
|
|
21
|
+
|
|
22
|
+
const frames = tests.reduce((sum, test) => sum + test.frames.length, 0)
|
|
23
|
+
const parts = [...new Set(files), `${tests.length} tests`, `${frames} frames`]
|
|
24
|
+
|
|
25
|
+
return el(
|
|
26
|
+
'div',
|
|
27
|
+
{ class: 'overview-head' },
|
|
28
|
+
el('h1', {}, titleOf(key)),
|
|
29
|
+
el('div', { class: 'overview-meta' }, parts.join(' · ')),
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function renderComponent(
|
|
34
|
+
name: string,
|
|
35
|
+
doc: ComponentDoc | undefined,
|
|
36
|
+
tests: ManifestTest[],
|
|
37
|
+
showName: boolean,
|
|
38
|
+
): HTMLElement {
|
|
39
|
+
const section = el('section', { class: 'overview-section' })
|
|
40
|
+
if (showName) {
|
|
41
|
+
section.append(el('h2', {}, name))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
section.append(el('h3', {}, 'props'))
|
|
45
|
+
if (doc) {
|
|
46
|
+
section.append(renderPropsTable(computeCoverage(doc, tests)))
|
|
47
|
+
} else {
|
|
48
|
+
const hint = 'no type information (rerun tests with the latest reporter)'
|
|
49
|
+
section.append(renderPropsTable([]), el('div', { class: 'hint' }, hint))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
section.append(
|
|
53
|
+
el(
|
|
54
|
+
'h3',
|
|
55
|
+
{},
|
|
56
|
+
'states',
|
|
57
|
+
el('span', { class: 'head-hint' }, '(last frame of every test in scope)'),
|
|
58
|
+
),
|
|
59
|
+
renderStatesGallery(tests),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return section
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The main column while a suite or module is selected: what its tests document. */
|
|
66
|
+
export function renderOverview(): HTMLElement {
|
|
67
|
+
const main = el('main', { class: 'overview' })
|
|
68
|
+
const key = state.suiteKey
|
|
69
|
+
if (!key) {
|
|
70
|
+
return main
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const tests = testsInScope(key)
|
|
74
|
+
const docs = state.manifest?.components ?? {}
|
|
75
|
+
main.append(renderHead(key, tests, docs))
|
|
76
|
+
|
|
77
|
+
// A single component named like the suite would only repeat the page title.
|
|
78
|
+
const names = componentNamesInScope(tests)
|
|
79
|
+
const title = titleOf(key)
|
|
80
|
+
|
|
81
|
+
for (const name of names) {
|
|
82
|
+
const own = tests.filter((test) => test.component?.name === name)
|
|
83
|
+
const showName = names.length > 1 || name !== title
|
|
84
|
+
main.append(renderComponent(name, docs[name], own, showName))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!tests.length) {
|
|
88
|
+
main.append(el('div', { class: 'empty' }, 'no tests here'))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return main
|
|
92
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { CoverageEntry, PropCoverage } from './coverage.js'
|
|
2
|
+
import { el } from './el.js'
|
|
3
|
+
|
|
4
|
+
function chip(entry: CoverageEntry): HTMLElement {
|
|
5
|
+
const mark = entry.covered ? '✓' : '✗'
|
|
6
|
+
const tone = entry.covered ? 'covered' : 'missing'
|
|
7
|
+
const node = el('span', { class: `chip ${tone}` }, `${entry.label} ${mark}`)
|
|
8
|
+
if (entry.isDefault) {
|
|
9
|
+
node.append(el('span', { class: 'chip-default' }, ' (default)'))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return node
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function nameCell(coverage: PropCoverage): HTMLElement {
|
|
16
|
+
return el(
|
|
17
|
+
'td',
|
|
18
|
+
{ class: 'prop-name', title: coverage.prop.description ?? false },
|
|
19
|
+
coverage.prop.name,
|
|
20
|
+
coverage.prop.required ? el('span', { class: 'req' }, '*') : null,
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function coveredCell(coverage: PropCoverage): HTMLElement {
|
|
25
|
+
const cell = el('td', { class: 'prop-covered' })
|
|
26
|
+
if (coverage.entries.length) {
|
|
27
|
+
for (const entry of coverage.entries) {
|
|
28
|
+
cell.append(chip(entry))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return cell
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (coverage.passedIn > 0) {
|
|
35
|
+
const text = `passed in ${coverage.passedIn} of ${coverage.testsTotal} tests`
|
|
36
|
+
cell.append(el('span', { class: 'hint' }, text))
|
|
37
|
+
|
|
38
|
+
return cell
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
cell.append(
|
|
42
|
+
el('span', { class: coverage.prop.required ? 'chip missing' : 'hint' }, 'never passed'),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return cell
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The props of one component as a table: name, type, and which values the tests covered. */
|
|
49
|
+
export function renderPropsTable(coverage: PropCoverage[]): HTMLElement {
|
|
50
|
+
const table = el('table', { class: 'kv props' })
|
|
51
|
+
table.append(el('tr', {}, el('th', {}, 'prop'), el('th', {}, 'type'), el('th', {}, 'covered')))
|
|
52
|
+
|
|
53
|
+
for (const item of coverage) {
|
|
54
|
+
table.append(
|
|
55
|
+
el(
|
|
56
|
+
'tr',
|
|
57
|
+
{},
|
|
58
|
+
nameCell(item),
|
|
59
|
+
el('td', { class: 'prop-type' }, item.prop.type),
|
|
60
|
+
coveredCell(item),
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!coverage.length) {
|
|
66
|
+
table.append(
|
|
67
|
+
el('tr', {}, el('td', { colspan: '3' }, el('span', { class: 'hint' }, 'no props'))),
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return table
|
|
72
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { el } from './el.js'
|
|
2
|
+
import { renderHeader } from './header.js'
|
|
3
|
+
import { renderInspector } from './inspector.js'
|
|
4
|
+
import { renderMain } from './main-panel.js'
|
|
5
|
+
import { renderOverview } from './overview.js'
|
|
6
|
+
import { renderOverviewInspector } from './overview-inspector.js'
|
|
7
|
+
import { renderSidebar } from './sidebar.js'
|
|
8
|
+
import { state } from './state.js'
|
|
9
|
+
|
|
10
|
+
/** Repaint the whole app from `state`: the overview when a suite is selected, else one test. */
|
|
11
|
+
export function renderAll(): void {
|
|
12
|
+
const app = document.getElementById('app')!
|
|
13
|
+
const main = state.suiteKey ? renderOverview() : renderMain()
|
|
14
|
+
const aside = state.suiteKey ? renderOverviewInspector() : renderInspector()
|
|
15
|
+
|
|
16
|
+
app.replaceChildren(renderHeader(), el('div', { class: 'body' }, renderSidebar(), main, aside))
|
|
17
|
+
}
|
package/src/rerender.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Indirection so that state and data modules can trigger a repaint without
|
|
3
|
+
* importing the panels that read them (which would be a cycle).
|
|
4
|
+
*/
|
|
5
|
+
let paint: () => void = () => {}
|
|
6
|
+
|
|
7
|
+
/** Install the repaint function. Called once from the bootstrap. */
|
|
8
|
+
export function setRerender(fn: () => void): void {
|
|
9
|
+
paint = fn
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Repaint the whole app. */
|
|
13
|
+
export function rerender(): void {
|
|
14
|
+
paint()
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ManifestTest } from '@describe-me/core/types'
|
|
2
|
+
|
|
3
|
+
/** Distinct component names recorded by a set of tests, in the order they appear. */
|
|
4
|
+
export function componentNamesInScope(tests: ManifestTest[]): string[] {
|
|
5
|
+
const names: string[] = []
|
|
6
|
+
for (const test of tests) {
|
|
7
|
+
const name = test.component?.name
|
|
8
|
+
if (name && !names.includes(name)) {
|
|
9
|
+
names.push(name)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return names
|
|
14
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ManifestTest } from '@describe-me/core/types'
|
|
2
|
+
import { parseSuiteKey } from './suite-key.js'
|
|
3
|
+
import { state } from './state.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The tests a suite key selects: every test of that module whose suite path
|
|
7
|
+
* starts with the key's path, so a module-level key takes the whole module.
|
|
8
|
+
*/
|
|
9
|
+
export function testsInScope(key: string): ManifestTest[] {
|
|
10
|
+
const { moduleId, path } = parseSuiteKey(key)
|
|
11
|
+
const mod = state.manifest?.modules.find((candidate) => candidate.id === moduleId)
|
|
12
|
+
if (!mod) {
|
|
13
|
+
return []
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return mod.tests.filter((test) => path.every((part, index) => test.path[index] === part))
|
|
17
|
+
}
|
package/src/sidebar.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { el } from './el.js'
|
|
2
|
+
import { select, selectSuite, state } from './state.js'
|
|
3
|
+
import { suiteKey } from './suite-key.js'
|
|
4
|
+
import { buildTree, type SuiteNode } from './tree.js'
|
|
5
|
+
|
|
6
|
+
function scopeButton(label: string, key: string, kind: 'module' | 'suite'): HTMLElement {
|
|
7
|
+
return el(
|
|
8
|
+
'button',
|
|
9
|
+
{
|
|
10
|
+
class: `${kind}-button${key === state.suiteKey ? ' active' : ''}`,
|
|
11
|
+
click: () => selectSuite(key),
|
|
12
|
+
title: `overview of ${label}`,
|
|
13
|
+
},
|
|
14
|
+
label,
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function renderSuite(node: SuiteNode, moduleId: string, path: string[]): HTMLElement {
|
|
19
|
+
const wrap = el('div', { class: 'suite' })
|
|
20
|
+
if (node.name) {
|
|
21
|
+
wrap.append(scopeButton(node.name, suiteKey(moduleId, path), 'suite'))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
for (const test of node.tests) {
|
|
25
|
+
wrap.append(
|
|
26
|
+
el(
|
|
27
|
+
'button',
|
|
28
|
+
{
|
|
29
|
+
class: `test${!state.suiteKey && test.id === state.testId ? ' active' : ''}`,
|
|
30
|
+
click: () => select(test.id),
|
|
31
|
+
title: test.fullName,
|
|
32
|
+
},
|
|
33
|
+
el('span', { class: `dot ${test.state}` }),
|
|
34
|
+
el('span', {}, test.name),
|
|
35
|
+
el('span', { class: 'n' }, String(test.frames.length)),
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const child of node.suites.values()) {
|
|
41
|
+
wrap.append(renderSuite(child, moduleId, [...path, child.name]))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return wrap
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One block per test module, each holding its suite tree. */
|
|
48
|
+
export function renderSidebar(): HTMLElement {
|
|
49
|
+
const aside = el('aside', { class: 'sidebar' })
|
|
50
|
+
for (const mod of state.manifest?.modules ?? []) {
|
|
51
|
+
aside.append(
|
|
52
|
+
el(
|
|
53
|
+
'div',
|
|
54
|
+
{ class: 'module' },
|
|
55
|
+
scopeButton(mod.id, suiteKey(mod.id, []), 'module'),
|
|
56
|
+
renderSuite(buildTree(mod), mod.id, []),
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return aside
|
|
62
|
+
}
|
package/src/stage.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createCache, createMirror, rebuildIntoSandboxedIframe } from 'rrweb-snapshot'
|
|
2
|
+
import type { ManifestFrame } from '@describe-me/core/types'
|
|
3
|
+
import { loadSnapshot } from './data.js'
|
|
4
|
+
import { state } from './state.js'
|
|
5
|
+
|
|
6
|
+
let paintToken = 0
|
|
7
|
+
|
|
8
|
+
/** Replay a snapshot into a fresh sandboxed iframe inside `stage`. */
|
|
9
|
+
export async function paintFrame(stage: HTMLElement, frame: ManifestFrame): Promise<void> {
|
|
10
|
+
const token = ++paintToken
|
|
11
|
+
const node = await loadSnapshot(frame.snapshot)
|
|
12
|
+
if (token !== paintToken) {
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
stage.replaceChildren()
|
|
17
|
+
const { iframe } = rebuildIntoSandboxedIframe(node, {
|
|
18
|
+
root: stage,
|
|
19
|
+
iframeAttributes: { title: 'snapshot' },
|
|
20
|
+
cache: createCache(),
|
|
21
|
+
mirror: createMirror(),
|
|
22
|
+
hackCss: true,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
if (state.width !== 'auto') {
|
|
26
|
+
iframe.style.width = `${state.width}px`
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Manifest, ManifestTest } from '@describe-me/core/types'
|
|
2
|
+
import { rerender } from './rerender.js'
|
|
3
|
+
|
|
4
|
+
export interface State {
|
|
5
|
+
manifest: Manifest | null
|
|
6
|
+
testId: string | null
|
|
7
|
+
/** A sidebar suite or module selection; when set, the overview replaces the frame view. */
|
|
8
|
+
suiteKey: string | null
|
|
9
|
+
frame: number
|
|
10
|
+
width: 'auto' | '768' | '375'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const state: State = {
|
|
14
|
+
manifest: null,
|
|
15
|
+
testId: null,
|
|
16
|
+
suiteKey: null,
|
|
17
|
+
frame: 0,
|
|
18
|
+
width: 'auto',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readHash(): void {
|
|
22
|
+
const params = new URLSearchParams(location.hash.slice(1))
|
|
23
|
+
state.testId = params.get('test')
|
|
24
|
+
state.suiteKey = params.get('suite')
|
|
25
|
+
state.frame = Number(params.get('frame') ?? 0) || 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function writeHash(): void {
|
|
29
|
+
const params = new URLSearchParams()
|
|
30
|
+
if (state.suiteKey) {
|
|
31
|
+
params.set('suite', state.suiteKey)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (state.testId) {
|
|
35
|
+
params.set('test', state.testId)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (state.frame) {
|
|
39
|
+
params.set('frame', String(state.frame))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
history.replaceState(null, '', `#${params.toString()}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function allTests(manifest: Manifest): ManifestTest[] {
|
|
46
|
+
return manifest.modules.flatMap((mod) => mod.tests)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function currentTest(): ManifestTest | null {
|
|
50
|
+
if (!state.manifest) {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return allTests(state.manifest).find((test) => test.id === state.testId) ?? null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Show one test. Leaving the overview is the whole point, so the suite is cleared. */
|
|
58
|
+
export function select(testId: string, frame = 0): void {
|
|
59
|
+
state.testId = testId
|
|
60
|
+
state.suiteKey = null
|
|
61
|
+
state.frame = frame
|
|
62
|
+
writeHash()
|
|
63
|
+
rerender()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Show the overview of a suite or module. The selected test is kept, so going back works. */
|
|
67
|
+
export function selectSuite(key: string): void {
|
|
68
|
+
state.suiteKey = key
|
|
69
|
+
writeHash()
|
|
70
|
+
rerender()
|
|
71
|
+
}
|