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/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
+ }
@@ -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
+ }
@@ -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
+ }