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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Grzegorz Łotysz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# describe-me
|
|
2
|
+
|
|
3
|
+
The viewer and CLI of [describe-me](https://github.com/grzehub/describe-me):
|
|
4
|
+
living component documentation generated from the Vitest tests you already have.
|
|
5
|
+
`describe` blocks become the sidebar, each `it` is a story, each step is a frame
|
|
6
|
+
you can scrub through.
|
|
7
|
+
|
|
8
|
+
It reads the `.describe-me/` directory that `@describe-me/vitest`'s reporter
|
|
9
|
+
writes, and replays the snapshots in a sandboxed iframe.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pnpm add -D describe-me @describe-me/vitest @describe-me/react
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
describe-me dev # viewer on http://localhost:6006, live while vitest --watch runs
|
|
21
|
+
describe-me build --out docs # self-contained static site: viewer + __data/
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
| Flag | Default | Meaning |
|
|
25
|
+
| -------- | ------------------ | --------------------------------- |
|
|
26
|
+
| `--data` | `.describe-me` | The directory the reporter wrote. |
|
|
27
|
+
| `--out` | `describe-me-dist` | Output directory for `build`. |
|
|
28
|
+
| `--port` | `6006` | Port for `dev`. |
|
|
29
|
+
|
|
30
|
+
The static site uses relative URLs, so it works from a sub-path such as
|
|
31
|
+
GitHub Pages.
|
|
32
|
+
|
|
33
|
+
See the [root README](https://github.com/grzehub/describe-me#readme) for the
|
|
34
|
+
full picture.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist-cli/main.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { parseArgs } from './parse-args.js';
|
|
2
|
+
import { runBuild } from './run-build.js';
|
|
3
|
+
import { runDev } from './run-dev.js';
|
|
4
|
+
const USAGE = `describe-me — living component docs from your tests
|
|
5
|
+
|
|
6
|
+
describe-me dev [--data .describe-me] [--port 6006] viewer with live updates
|
|
7
|
+
describe-me build [--data .describe-me] [--out describe-me-dist] static site
|
|
8
|
+
`;
|
|
9
|
+
/** Entry point of the `describe-me` binary. */
|
|
10
|
+
async function main() {
|
|
11
|
+
const options = parseArgs(process.argv.slice(2));
|
|
12
|
+
if (options.command === 'help') {
|
|
13
|
+
process.stdout.write(USAGE);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (options.command === 'build') {
|
|
17
|
+
await runBuild(options.data, options.out);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
await runDev(options.data, options.port);
|
|
21
|
+
}
|
|
22
|
+
main().catch((error) => {
|
|
23
|
+
process.stderr.write(`describe-me: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type Command = 'dev' | 'build' | 'help';
|
|
2
|
+
export interface CliOptions {
|
|
3
|
+
command: Command;
|
|
4
|
+
/** Directory written by the reporter. */
|
|
5
|
+
data: string;
|
|
6
|
+
/** Output directory for `build`. */
|
|
7
|
+
out: string;
|
|
8
|
+
/** Port for `dev`. */
|
|
9
|
+
port: number;
|
|
10
|
+
}
|
|
11
|
+
/** Turn `describe-me <command> [--data dir] [--out dir] [--port n]` into options with defaults. */
|
|
12
|
+
export declare function parseArgs(argv: string[]): CliOptions;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { parseArgs as parseNodeArgs } from 'node:util';
|
|
2
|
+
function commandFrom(positional, help) {
|
|
3
|
+
if (help || positional === undefined) {
|
|
4
|
+
return 'help';
|
|
5
|
+
}
|
|
6
|
+
if (positional === 'build') {
|
|
7
|
+
return 'build';
|
|
8
|
+
}
|
|
9
|
+
return 'dev';
|
|
10
|
+
}
|
|
11
|
+
/** Turn `describe-me <command> [--data dir] [--out dir] [--port n]` into options with defaults. */
|
|
12
|
+
export function parseArgs(argv) {
|
|
13
|
+
const { values, positionals } = parseNodeArgs({
|
|
14
|
+
args: argv,
|
|
15
|
+
allowPositionals: true,
|
|
16
|
+
options: {
|
|
17
|
+
data: { type: 'string', default: '.describe-me' },
|
|
18
|
+
out: { type: 'string', default: 'describe-me-dist' },
|
|
19
|
+
port: { type: 'string', default: '6006' },
|
|
20
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
command: commandFrom(positionals[0], values.help),
|
|
25
|
+
data: values.data,
|
|
26
|
+
out: values.out,
|
|
27
|
+
port: Number(values.port),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { build } from 'vite';
|
|
3
|
+
import { viewerRoot } from './viewer-root.js';
|
|
4
|
+
/** Produce a self-contained static site: the viewer plus the data directory under `__data/`. */
|
|
5
|
+
export async function runBuild(data, out) {
|
|
6
|
+
process.env.DESCRIBE_ME_DIR = resolve(data);
|
|
7
|
+
const root = viewerRoot();
|
|
8
|
+
await build({
|
|
9
|
+
root,
|
|
10
|
+
configFile: resolve(root, 'vite.config.ts'),
|
|
11
|
+
build: { outDir: resolve(out), emptyOutDir: true },
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { createServer } from 'vite';
|
|
3
|
+
import { viewerRoot } from './viewer-root.js';
|
|
4
|
+
/** Start the viewer against a data directory, with live updates as the reporter rewrites it. */
|
|
5
|
+
export async function runDev(data, port) {
|
|
6
|
+
process.env.DESCRIBE_ME_DIR = resolve(data);
|
|
7
|
+
const root = viewerRoot();
|
|
8
|
+
const server = await createServer({
|
|
9
|
+
root,
|
|
10
|
+
configFile: resolve(root, 'vite.config.ts'),
|
|
11
|
+
server: { port },
|
|
12
|
+
});
|
|
13
|
+
await server.listen();
|
|
14
|
+
server.printUrls();
|
|
15
|
+
}
|
package/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>describe-me</title>
|
|
7
|
+
<link rel="stylesheet" href="/src/style.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<script type="module" src="/src/main.ts"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "describe-me",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Living component documentation generated from your Vitest tests: viewer and CLI",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"vitest",
|
|
7
|
+
"storybook",
|
|
8
|
+
"component documentation",
|
|
9
|
+
"visual testing",
|
|
10
|
+
"testing",
|
|
11
|
+
"cli"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Grzegorz Łotysz",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/grzehub/describe-me.git",
|
|
18
|
+
"directory": "packages/viewer"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://grzehub.github.io/describe-me/",
|
|
21
|
+
"bugs": "https://github.com/grzehub/describe-me/issues",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"describe-me": "./bin/describe-me.js"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"bin",
|
|
34
|
+
"dist-cli",
|
|
35
|
+
"index.html",
|
|
36
|
+
"src",
|
|
37
|
+
"vite.config.ts",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"sideEffects": false,
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"rrweb-snapshot": "^2.1.6",
|
|
44
|
+
"vite": "^8.3.0",
|
|
45
|
+
"@describe-me/core": "^0.1.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^22"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "rimraf dist-cli && tsc -p tsconfig.cli.json",
|
|
52
|
+
"dev": "node dist-cli/main.js dev --data ../../examples/react-basic/.describe-me"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/coverage.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ComponentDoc, ManifestTest, PropDoc } from '@describe-me/core/types'
|
|
2
|
+
|
|
3
|
+
/** One value of an enumerable prop, and whether the tests in scope produced it. */
|
|
4
|
+
export interface CoverageEntry {
|
|
5
|
+
label: string
|
|
6
|
+
covered: boolean
|
|
7
|
+
isDefault: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** What the props table shows for one prop of one component. */
|
|
11
|
+
export interface PropCoverage {
|
|
12
|
+
prop: PropDoc
|
|
13
|
+
entries: CoverageEntry[]
|
|
14
|
+
/** Tests that passed the prop at least once. Only meaningful without entries. */
|
|
15
|
+
passedIn: number
|
|
16
|
+
testsTotal: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The manifest keeps literal types as source text, so `'primary'` arrives quoted. */
|
|
20
|
+
function unquote(text: string): string {
|
|
21
|
+
for (const quote of ["'", '"']) {
|
|
22
|
+
if (text.length > 1 && text.startsWith(quote) && text.endsWith(quote)) {
|
|
23
|
+
return text.slice(1, -1)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return text
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The props of every `render` frame of one test; frames without props are ignored. */
|
|
31
|
+
function renderProps(test: ManifestTest): Record<string, unknown>[] {
|
|
32
|
+
const passed: Record<string, unknown>[] = []
|
|
33
|
+
for (const frame of test.frames) {
|
|
34
|
+
const props = frame.kind === 'render' ? frame.meta?.props : undefined
|
|
35
|
+
if (props && typeof props === 'object' && !Array.isArray(props)) {
|
|
36
|
+
passed.push(props as Record<string, unknown>)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return passed
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function wasPassed(passed: Record<string, unknown>[], name: string, label: string): boolean {
|
|
44
|
+
return passed.some((props) => name in props && String(props[name]) === label)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function entriesFor(prop: PropDoc, passed: Record<string, unknown>[]): CoverageEntry[] {
|
|
48
|
+
if (prop.kind !== 'literals' && prop.kind !== 'boolean') {
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Omitting the prop exercises its default, so it covers the matching value.
|
|
53
|
+
const omitted = passed.some((props) => !(prop.name in props))
|
|
54
|
+
|
|
55
|
+
return (prop.values ?? []).map((value) => {
|
|
56
|
+
const label = unquote(value)
|
|
57
|
+
const isDefault = prop.defaultValue !== undefined && unquote(prop.defaultValue) === label
|
|
58
|
+
const covered = wasPassed(passed, prop.name, label) || (isDefault && omitted)
|
|
59
|
+
|
|
60
|
+
return { label, covered, isDefault }
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** How well a set of tests exercises a component's props, prop by prop. */
|
|
65
|
+
export function computeCoverage(doc: ComponentDoc, tests: ManifestTest[]): PropCoverage[] {
|
|
66
|
+
const perTest = tests.map((test) => renderProps(test))
|
|
67
|
+
const passed = perTest.flat()
|
|
68
|
+
|
|
69
|
+
return doc.props.map((prop) => ({
|
|
70
|
+
prop,
|
|
71
|
+
entries: entriesFor(prop, passed),
|
|
72
|
+
passedIn: perTest.filter((frames) => frames.some((props) => prop.name in props)).length,
|
|
73
|
+
testsTotal: tests.length,
|
|
74
|
+
}))
|
|
75
|
+
}
|
package/src/data.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { rebuildIntoSandboxedIframe } from 'rrweb-snapshot'
|
|
2
|
+
import type { Manifest } from '@describe-me/core/types'
|
|
3
|
+
import { allTests, currentTest, state } from './state.js'
|
|
4
|
+
import { rerender } from './rerender.js'
|
|
5
|
+
|
|
6
|
+
/** rrweb's serialized document node; the package does not re-export the type. */
|
|
7
|
+
type SerializedNode = Parameters<typeof rebuildIntoSandboxedIframe>[0]
|
|
8
|
+
|
|
9
|
+
const snapshotCache = new Map<string, Promise<SerializedNode>>()
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fetch the manifest and repaint, but only if it differs from the one on
|
|
13
|
+
* screen. Polling an unchanged static site must not re-render or drop caches.
|
|
14
|
+
*/
|
|
15
|
+
export async function loadManifest(): Promise<void> {
|
|
16
|
+
const response = await fetch('__data/manifest.json', { cache: 'no-store' })
|
|
17
|
+
if (!response.ok) {
|
|
18
|
+
throw new Error(`manifest: ${response.status}`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const next = (await response.json()) as Manifest
|
|
22
|
+
if (state.manifest?.generatedAt === next.generatedAt) {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
state.manifest = next
|
|
27
|
+
snapshotCache.clear()
|
|
28
|
+
if (!currentTest()) {
|
|
29
|
+
const first = allTests(state.manifest)[0]
|
|
30
|
+
state.testId = first?.id ?? null
|
|
31
|
+
state.frame = 0
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const test = currentTest()
|
|
35
|
+
if (test) {
|
|
36
|
+
state.frame = Math.min(state.frame, Math.max(0, test.frames.length - 1))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
rerender()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Fetch one serialized DOM, memoized until the next manifest load. */
|
|
43
|
+
export function loadSnapshot(path: string): Promise<SerializedNode> {
|
|
44
|
+
let pending = snapshotCache.get(path)
|
|
45
|
+
if (!pending) {
|
|
46
|
+
pending = fetch(`__data/${path}`, { cache: 'no-store' }).then(
|
|
47
|
+
(response) => response.json() as Promise<SerializedNode>,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
snapshotCache.set(path, pending)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return pending
|
|
54
|
+
}
|
package/src/el.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Terse `createElement`: attributes, event listeners (by event name), children. */
|
|
2
|
+
export function el<K extends keyof HTMLElementTagNameMap>(
|
|
3
|
+
tag: K,
|
|
4
|
+
attrs: Record<string, string | boolean | ((e: Event) => void)> = {},
|
|
5
|
+
...children: (Node | string | null | undefined | false)[]
|
|
6
|
+
): HTMLElementTagNameMap[K] {
|
|
7
|
+
const node = document.createElement(tag)
|
|
8
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
9
|
+
if (typeof value === 'function') {
|
|
10
|
+
node.addEventListener(key, value)
|
|
11
|
+
} else if (value === true) {
|
|
12
|
+
node.setAttribute(key, '')
|
|
13
|
+
} else if (value !== false) {
|
|
14
|
+
node.setAttribute(key, value)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
for (const child of children) {
|
|
19
|
+
if (child) {
|
|
20
|
+
node.append(child)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return node
|
|
25
|
+
}
|
package/src/header.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { el } from './el.js'
|
|
2
|
+
import { allTests, state } from './state.js'
|
|
3
|
+
|
|
4
|
+
/** Wordmark, pass/fail counts and the time of the last run. */
|
|
5
|
+
export function renderHeader(): HTMLElement {
|
|
6
|
+
const manifest = state.manifest
|
|
7
|
+
const tests = manifest ? allTests(manifest) : []
|
|
8
|
+
const passed = tests.filter((test) => test.state === 'passed').length
|
|
9
|
+
const failed = tests.filter((test) => test.state === 'failed').length
|
|
10
|
+
const summary = el('span', { class: 'summary' })
|
|
11
|
+
summary.append(
|
|
12
|
+
el('b', {}, String(tests.length)),
|
|
13
|
+
' tests · ',
|
|
14
|
+
el('b', {}, String(passed)),
|
|
15
|
+
' passed',
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if (failed) {
|
|
19
|
+
summary.append(' · ', el('b', { class: 'fail' }, String(failed)), ' failed')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const wm = el('span', { class: 'wordmark' }, 'describe', el('span', { class: 'me' }, '-me'))
|
|
23
|
+
const when = manifest ? new Date(manifest.generatedAt).toLocaleTimeString() : '…'
|
|
24
|
+
return el(
|
|
25
|
+
'header',
|
|
26
|
+
{ class: 'header' },
|
|
27
|
+
wm,
|
|
28
|
+
summary,
|
|
29
|
+
el('span', { class: 'right live' }, `updated ${when}`),
|
|
30
|
+
)
|
|
31
|
+
}
|
package/src/inspector.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { el } from './el.js'
|
|
2
|
+
import { currentTest, state } from './state.js'
|
|
3
|
+
import { valueCell } from './value-cell.js'
|
|
4
|
+
|
|
5
|
+
/** Right-hand pane: test status, component props, current frame, errors. */
|
|
6
|
+
export function renderInspector(): HTMLElement {
|
|
7
|
+
const test = currentTest()
|
|
8
|
+
const aside = el('aside', { class: 'inspector' })
|
|
9
|
+
if (!test) {
|
|
10
|
+
return aside
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const frame = test.frames[state.frame]
|
|
14
|
+
const props =
|
|
15
|
+
(frame?.meta?.props as Record<string, unknown> | undefined) ?? test.component?.props ?? {}
|
|
16
|
+
|
|
17
|
+
const status = el('section', {}, el('h3', {}, 'test'))
|
|
18
|
+
status.append(
|
|
19
|
+
el(
|
|
20
|
+
'div',
|
|
21
|
+
{ class: 'status' },
|
|
22
|
+
el('span', { class: `dot ${test.state}` }),
|
|
23
|
+
test.state,
|
|
24
|
+
test.duration != null
|
|
25
|
+
? el('span', { class: 'hint' }, `${Math.round(test.duration)}ms`)
|
|
26
|
+
: null,
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
aside.append(status)
|
|
31
|
+
|
|
32
|
+
if (test.component) {
|
|
33
|
+
const table = el('table', { class: 'kv' })
|
|
34
|
+
for (const [key, value] of Object.entries(props)) {
|
|
35
|
+
table.append(el('tr', {}, el('td', {}, key), el('td', {}, valueCell(value))))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!Object.keys(props).length) {
|
|
39
|
+
table.append(el('tr', {}, el('td', {}, el('span', { class: 'hint' }, 'no props'))))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
aside.append(el('section', {}, el('h3', {}, `component · ${test.component.name}`), table))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (frame) {
|
|
46
|
+
aside.append(
|
|
47
|
+
el(
|
|
48
|
+
'section',
|
|
49
|
+
{},
|
|
50
|
+
el('h3', {}, `frame ${state.frame + 1} / ${test.frames.length}`),
|
|
51
|
+
el('div', {}, frame.label),
|
|
52
|
+
el('div', { class: 'hint' }, `${frame.kind} · ${frame.at}ms into the test`),
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (test.errors?.length) {
|
|
58
|
+
const sec = el('section', {}, el('h3', {}, 'errors'))
|
|
59
|
+
for (const error of test.errors) {
|
|
60
|
+
sec.append(el('pre', { class: 'err' }, error.message))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
aside.append(sec)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
aside.append(
|
|
67
|
+
el(
|
|
68
|
+
'section',
|
|
69
|
+
{},
|
|
70
|
+
el(
|
|
71
|
+
'div',
|
|
72
|
+
{ class: 'hint' },
|
|
73
|
+
el('kbd', {}, '←'),
|
|
74
|
+
' ',
|
|
75
|
+
el('kbd', {}, '→'),
|
|
76
|
+
' frames · ',
|
|
77
|
+
el('kbd', {}, '↑'),
|
|
78
|
+
' ',
|
|
79
|
+
el('kbd', {}, '↓'),
|
|
80
|
+
' tests',
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return aside
|
|
86
|
+
}
|
package/src/keyboard.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { rerender } from './rerender.js'
|
|
2
|
+
import { testsInScope } from './scope.js'
|
|
3
|
+
import { allTests, currentTest, select, state, writeHash } from './state.js'
|
|
4
|
+
|
|
5
|
+
/** In the overview, ↑ ↓ jump into the scope: the first or the last test it holds. */
|
|
6
|
+
function onSuiteKey(event: KeyboardEvent, key: string): void {
|
|
7
|
+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') {
|
|
8
|
+
return
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const tests = testsInScope(key)
|
|
12
|
+
const next = event.key === 'ArrowDown' ? tests[0] : tests[tests.length - 1]
|
|
13
|
+
if (next) {
|
|
14
|
+
select(next.id)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
event.preventDefault()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** ← → step through frames, ↑ ↓ through tests. */
|
|
21
|
+
export function onKey(event: KeyboardEvent): void {
|
|
22
|
+
if (!state.manifest) {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (state.suiteKey) {
|
|
27
|
+
onSuiteKey(event, state.suiteKey)
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const test = currentTest()
|
|
32
|
+
if (!test) {
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (event.key === 'ArrowRight' && state.frame < test.frames.length - 1) {
|
|
37
|
+
state.frame++
|
|
38
|
+
writeHash()
|
|
39
|
+
rerender()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (event.key === 'ArrowLeft' && state.frame > 0) {
|
|
43
|
+
state.frame--
|
|
44
|
+
writeHash()
|
|
45
|
+
rerender()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
49
|
+
const tests = allTests(state.manifest)
|
|
50
|
+
const index = tests.findIndex((candidate) => candidate.id === test.id)
|
|
51
|
+
const next = tests[index + (event.key === 'ArrowDown' ? 1 : -1)]
|
|
52
|
+
if (next) {
|
|
53
|
+
select(next.id)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
event.preventDefault()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { el } from './el.js'
|
|
2
|
+
import { rerender } from './rerender.js'
|
|
3
|
+
import { paintFrame } from './stage.js'
|
|
4
|
+
import { currentTest, state, writeHash } from './state.js'
|
|
5
|
+
|
|
6
|
+
/** Breadcrumbs, viewport buttons, the replay stage and the frame timeline. */
|
|
7
|
+
export function renderMain(): HTMLElement {
|
|
8
|
+
const test = currentTest()
|
|
9
|
+
const frame = test?.frames[state.frame]
|
|
10
|
+
|
|
11
|
+
const crumbs = el('div', { class: 'crumbs' })
|
|
12
|
+
if (test) {
|
|
13
|
+
for (const part of test.path) {
|
|
14
|
+
crumbs.append(el('span', {}, part), el('span', { class: 'sep' }, '›'))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
crumbs.append(el('span', { class: 'cur' }, test.name))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const tools = el('div', { class: 'tools' })
|
|
21
|
+
for (const width of ['auto', '768', '375'] as const) {
|
|
22
|
+
tools.append(
|
|
23
|
+
el(
|
|
24
|
+
'button',
|
|
25
|
+
{
|
|
26
|
+
class: state.width === width ? 'on' : '',
|
|
27
|
+
click: () => {
|
|
28
|
+
state.width = width
|
|
29
|
+
rerender()
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
width === 'auto' ? '100%' : `${width}px`,
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
crumbs.append(tools)
|
|
38
|
+
|
|
39
|
+
const stage = el('div', { class: 'stage' })
|
|
40
|
+
if (frame) {
|
|
41
|
+
void paintFrame(stage, frame)
|
|
42
|
+
} else {
|
|
43
|
+
stage.append(
|
|
44
|
+
el('div', { class: 'empty' }, test ? 'no frames recorded for this test' : 'select a test'),
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const timeline = el('div', { class: 'timeline' })
|
|
49
|
+
test?.frames.forEach((timelineFrame, i) => {
|
|
50
|
+
timeline.append(
|
|
51
|
+
el(
|
|
52
|
+
'button',
|
|
53
|
+
{
|
|
54
|
+
class: `frame ${timelineFrame.kind}${i === state.frame ? ' active' : ''}`,
|
|
55
|
+
click: () => {
|
|
56
|
+
state.frame = i
|
|
57
|
+
writeHash()
|
|
58
|
+
rerender()
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
el('span', { class: 'k' }, timelineFrame.kind),
|
|
62
|
+
el('span', {}, timelineFrame.label),
|
|
63
|
+
el('span', { class: 't' }, `${timelineFrame.at}ms`),
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
return el('main', { class: 'main' }, crumbs, stage, timeline)
|
|
69
|
+
}
|