@crvouga/atlas 0.9.0 → 0.10.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/CHANGELOG.md +6 -0
- package/dist/playwright-reporter/src/evidence.d.ts +122 -0
- package/dist/playwright-reporter/src/evidence.js +327 -0
- package/dist/playwright-reporter/src/index.d.ts +114 -0
- package/dist/playwright-reporter/src/index.js +312 -0
- package/dist/reports/src/compose.js +7 -14
- package/dist/reports/src/recorded-specs.d.ts +33 -0
- package/dist/reports/src/recorded-specs.js +42 -0
- package/dist/reports/src/report-index.d.ts +6 -0
- package/dist/reports/src/report-index.js +14 -2
- package/dist/reports/src/test-catalog.d.ts +386 -0
- package/dist/reports/src/test-catalog.js +655 -0
- package/dist/schema/src/api.d.ts +585 -0
- package/dist/schema/src/api.js +105 -0
- package/dist/server/src/app.js +82 -4
- package/dist/server/src/llms.js +14 -0
- package/dist/server/src/openapi.d.ts +1 -1
- package/dist/server/src/openapi.js +60 -3
- package/docs/agents.md +1 -0
- package/docs/integrating.md +1 -0
- package/docs/reference/http-api.md +134 -2
- package/docs/reference/playwright-reporter.md +332 -0
- package/docs/reference/reports.md +632 -0
- package/docs/reference/schema.md +57 -0
- package/docs/rich-evidence.md +198 -0
- package/docs/server.md +41 -0
- package/llms-full.txt +378 -2
- package/llms.txt +2 -0
- package/package.json +6 -1
- package/packages/schema/schemas/report-run.schema.json +251 -0
- package/packages/schema/schemas/report-runs.schema.json +177 -0
- package/packages/schema/schemas/test-catalog.schema.json +1177 -0
- package/packages/schema/schemas/test-history.schema.json +696 -0
- package/packages/server/openapi.json +8676 -5351
- package/packages/visualizer/src/app/AppShell.tsx +7 -3
- package/packages/visualizer/src/app/router.tsx +26 -3
- package/packages/visualizer/src/components/Header.tsx +2 -2
- package/packages/visualizer/src/evidence/evidence-context.tsx +10 -1
- package/packages/visualizer/src/explorer/Explorer.tsx +433 -0
- package/packages/visualizer/src/explorer/ExplorerIndex.tsx +120 -0
- package/packages/visualizer/src/explorer/RunsView.tsx +164 -0
- package/packages/visualizer/src/explorer/TestDetail.tsx +214 -0
- package/packages/visualizer/src/explorer/catalog.tsx +274 -0
- package/packages/visualizer/src/explorer/explorer.module.css +1238 -0
- package/packages/visualizer/src/explorer/groups.test.ts +87 -0
- package/packages/visualizer/src/explorer/groups.ts +126 -0
- package/packages/visualizer/src/explorer/navigate.ts +36 -0
- package/packages/visualizer/src/explorer/parts.tsx +59 -0
- package/packages/visualizer/src/explorer/search.ts +97 -0
- package/packages/visualizer/src/find/Finder.tsx +2 -0
- package/packages/visualizer/src/find/corpus.ts +13 -6
- package/packages/visualizer/src/find/rank.ts +2 -0
- package/packages/visualizer/src/evidence/project-home.tsx +0 -45
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Project index
|
|
3
|
+
*
|
|
4
|
+
* The left index of a project without statecharts: its published spec kinds (when it has spec files), then every
|
|
5
|
+
* domain and spec its tests were recorded against, with a tally each. Choosing a domain or a spec filters the explorer.
|
|
6
|
+
*/
|
|
7
|
+
import type { StatusCounts } from '@crvouga/atlas/reports/evidence-types';
|
|
8
|
+
import { Link } from '@tanstack/react-router';
|
|
9
|
+
import { useMemo, useState } from 'react';
|
|
10
|
+
|
|
11
|
+
import evidenceStyles from '../evidence/evidence.module.css';
|
|
12
|
+
import { useEvidence } from '../evidence/evidence-context';
|
|
13
|
+
import { StatusMark } from '../evidence/marks';
|
|
14
|
+
import { SPEC_KINDS } from '../spec-kind';
|
|
15
|
+
import { useTestCatalog } from './catalog';
|
|
16
|
+
import styles from './explorer.module.css';
|
|
17
|
+
import { patched, useExplorerSearch } from './navigate';
|
|
18
|
+
import { values } from './search';
|
|
19
|
+
|
|
20
|
+
/** Domains start open when the index has at most this many specs. */
|
|
21
|
+
const OPEN_SPECS = 80;
|
|
22
|
+
|
|
23
|
+
function statusOf(counts: StatusCounts) {
|
|
24
|
+
return counts.failed ? 'failed' : counts.running ? 'running' : counts.passed ? 'passed' : 'not-run';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A tally for an index row: failures in red, otherwise how many tests, quietly. */
|
|
28
|
+
function Tally({ counts, tests }: { counts: StatusCounts; tests: number }) {
|
|
29
|
+
if (counts.failed) return <span className={evidenceStyles.tally} data-status="failed">{counts.failed} failed</span>;
|
|
30
|
+
if (counts.running) return <span className={evidenceStyles.tally} data-status="running">Running</span>;
|
|
31
|
+
return <span className={evidenceStyles.tally} data-status={counts.passed === tests ? 'passed' : 'not-run'}>{counts.passed === tests ? `${tests} passed` : `${counts.passed}/${tests}`}</span>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function add(a: StatusCounts, b: StatusCounts): StatusCounts {
|
|
35
|
+
return { passed: a.passed + b.passed, failed: a.failed + b.failed, running: a.running + b.running, 'not-run': a['not-run'] + b['not-run'] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The index beside the explorer. */
|
|
39
|
+
export function ProjectIndex() {
|
|
40
|
+
const { files } = useEvidence();
|
|
41
|
+
const { model } = useTestCatalog();
|
|
42
|
+
const search = useExplorerSearch();
|
|
43
|
+
const kinds = SPEC_KINDS.filter((kind) => files.some(kind.accepts));
|
|
44
|
+
const domains = useMemo(() => {
|
|
45
|
+
const byDomain = new Map<string, { specs: NonNullable<typeof model>['facets']['specs'][number][]; counts: StatusCounts; tests: number }>();
|
|
46
|
+
for (const spec of model?.facets.specs ?? []) {
|
|
47
|
+
const domain = spec.domain || '(no domain)';
|
|
48
|
+
const entry = byDomain.get(domain) ?? { specs: [], counts: { passed: 0, failed: 0, running: 0, 'not-run': 0 }, tests: 0 };
|
|
49
|
+
entry.specs.push(spec);
|
|
50
|
+
entry.counts = add(entry.counts, spec.counts);
|
|
51
|
+
entry.tests += spec.tests;
|
|
52
|
+
byDomain.set(domain, entry);
|
|
53
|
+
}
|
|
54
|
+
return [...byDomain];
|
|
55
|
+
}, [model]);
|
|
56
|
+
const specCount = model?.facets.specs.length ?? 0;
|
|
57
|
+
const chosenSpecs = values(search.spec);
|
|
58
|
+
const chosenDomains = values(search.domain);
|
|
59
|
+
const [toggled, setToggled] = useState<ReadonlyMap<string, boolean>>(new Map());
|
|
60
|
+
const isOpen = (domain: string, specs: readonly { path: string }[]) => toggled.get(domain) ?? (specCount <= OPEN_SPECS || chosenDomains.includes(domain) || specs.some((spec) => chosenSpecs.includes(spec.path)));
|
|
61
|
+
const everything = !search.spec && !search.domain;
|
|
62
|
+
return (
|
|
63
|
+
<nav className={evidenceStyles.index} aria-label="Project specs">
|
|
64
|
+
{kinds.length > 0 && (
|
|
65
|
+
<>
|
|
66
|
+
<h2 className={styles.indexHeading}>Specs</h2>
|
|
67
|
+
<ul>
|
|
68
|
+
{kinds.map((kind) => (
|
|
69
|
+
<li key={kind.id}>
|
|
70
|
+
<Link className={evidenceStyles.indexRow} to="/specs/$kind" params={{ kind: kind.id }} search={{}}>{kind.label}</Link>
|
|
71
|
+
</li>
|
|
72
|
+
))}
|
|
73
|
+
</ul>
|
|
74
|
+
</>
|
|
75
|
+
)}
|
|
76
|
+
{domains.length > 0 && (
|
|
77
|
+
<section aria-label="Tests by spec">
|
|
78
|
+
<h2 className={styles.indexHeading}>Tests</h2>
|
|
79
|
+
<ul>
|
|
80
|
+
<li>
|
|
81
|
+
<Link className={evidenceStyles.indexRow} to="/" search={patched({ spec: undefined, domain: undefined, tab: undefined })} resetScroll={false} activeOptions={{ exact: true }} aria-current={everything ? 'page' : undefined}>
|
|
82
|
+
<span className={evidenceStyles.indexName}>All tests</span>
|
|
83
|
+
{model && <Tally counts={model.summary.counts} tests={model.summary.tests} />}
|
|
84
|
+
</Link>
|
|
85
|
+
</li>
|
|
86
|
+
</ul>
|
|
87
|
+
{domains.map(([domain, entry]) => {
|
|
88
|
+
const open = isOpen(domain, entry.specs);
|
|
89
|
+
return (
|
|
90
|
+
<div key={domain} className={styles.indexDomain}>
|
|
91
|
+
<div className={styles.indexDomainHead}>
|
|
92
|
+
<button type="button" className={styles.indexToggle} aria-expanded={open} aria-label={`${open ? 'Collapse' : 'Expand'} ${domain}`} onClick={() => setToggled(new Map(toggled).set(domain, !open))}>
|
|
93
|
+
<span className={styles.chevron} data-open={open || undefined} aria-hidden="true" />
|
|
94
|
+
</button>
|
|
95
|
+
<Link className={evidenceStyles.indexRow} to="/" search={patched({ domain, spec: undefined, tab: undefined })} resetScroll={false} activeOptions={{ exact: true }} aria-current={chosenDomains.length === 1 && chosenDomains[0] === domain && !search.spec ? 'page' : undefined}>
|
|
96
|
+
<span className={evidenceStyles.indexName}><strong>{domain}</strong></span>
|
|
97
|
+
<Tally counts={entry.counts} tests={entry.tests} />
|
|
98
|
+
</Link>
|
|
99
|
+
</div>
|
|
100
|
+
{open && (
|
|
101
|
+
<ul className={styles.indexSpecs}>
|
|
102
|
+
{entry.specs.map((spec) => (
|
|
103
|
+
<li key={spec.path}>
|
|
104
|
+
<Link className={evidenceStyles.indexRow} to="/" search={patched({ spec: spec.path, domain: undefined, tab: undefined })} resetScroll={false} activeOptions={{ exact: true }} title={spec.path} aria-current={chosenSpecs.length === 1 && chosenSpecs[0] === spec.path ? 'page' : undefined}>
|
|
105
|
+
<StatusMark status={statusOf(spec.counts)} size={14} />
|
|
106
|
+
<span className={evidenceStyles.indexName}>{spec.title}</span>
|
|
107
|
+
<Tally counts={spec.counts} tests={spec.tests} />
|
|
108
|
+
</Link>
|
|
109
|
+
</li>
|
|
110
|
+
))}
|
|
111
|
+
</ul>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
})}
|
|
116
|
+
</section>
|
|
117
|
+
)}
|
|
118
|
+
</nav>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Runs view
|
|
3
|
+
*
|
|
4
|
+
* The CI runs that produced the report, newest first: when, on which branch and commit, which environments, how many
|
|
5
|
+
* tests and how they went, how long it took, and a link to the run. Opening one lists every test it ran, filterable,
|
|
6
|
+
* with a way to show exactly those tests in the explorer.
|
|
7
|
+
*/
|
|
8
|
+
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
9
|
+
import { formatMs, type RunResult, type RunSummary } from '@crvouga/atlas/reports/test-catalog';
|
|
10
|
+
import { Link } from '@tanstack/react-router';
|
|
11
|
+
import { useMemo, useState } from 'react';
|
|
12
|
+
|
|
13
|
+
import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
|
|
14
|
+
import { Meter, StatusMark } from '../evidence/marks';
|
|
15
|
+
import { useRunDetail, useRunList } from './catalog';
|
|
16
|
+
import styles from './explorer.module.css';
|
|
17
|
+
import { patched, useExplorerSearch } from './navigate';
|
|
18
|
+
import { runName } from './parts';
|
|
19
|
+
|
|
20
|
+
const RUN_ROWS = 50;
|
|
21
|
+
const RESULT_ROWS = 200;
|
|
22
|
+
|
|
23
|
+
/** The Runs tab: the run list, or the run opened in it. */
|
|
24
|
+
export function RunsView() {
|
|
25
|
+
const search = useExplorerSearch();
|
|
26
|
+
return search.openRun ? <RunDetailView runKey={search.openRun} /> : <RunTable />;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function Counts({ run }: { run: RunSummary }) {
|
|
30
|
+
return (
|
|
31
|
+
<span className={styles.runCounts}>
|
|
32
|
+
{run.counts.failed > 0 && <strong>{run.counts.failed} failed</strong>}
|
|
33
|
+
<span>{run.counts.passed} passed</span>
|
|
34
|
+
{run.counts['not-run'] > 0 && <span>{run.counts['not-run']} not run</span>}
|
|
35
|
+
{run.counts.running > 0 && <span data-status="running">{run.counts.running} running</span>}
|
|
36
|
+
</span>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function RunTable() {
|
|
41
|
+
const runs = useRunList();
|
|
42
|
+
const now = useNow();
|
|
43
|
+
const [shown, setShown] = useState(RUN_ROWS);
|
|
44
|
+
if (runs.isPending) return <p className={styles.quiet} role="status">Reading the runs…</p>;
|
|
45
|
+
if (runs.isError) return <p className={styles.notice} role="alert">The runs could not be read. {runs.error.message}</p>;
|
|
46
|
+
const list = runs.data.runs;
|
|
47
|
+
if (!list.length) return <p className={styles.quiet}>No runs recorded yet.</p>;
|
|
48
|
+
return (
|
|
49
|
+
<section className={styles.runs} aria-label="Runs">
|
|
50
|
+
<p className={styles.showing}>{runs.data.total > list.length ? `The newest ${list.length} of ${plural(runs.data.total, 'run', 'runs')}` : plural(list.length, 'run', 'runs')}</p>
|
|
51
|
+
<div className={styles.tableScroll}>
|
|
52
|
+
<table className={styles.table} data-runs>
|
|
53
|
+
<thead>
|
|
54
|
+
<tr><th scope="col">Ended</th><th scope="col">Branch</th><th scope="col">Commit</th><th scope="col">Environments</th><th scope="col">Tests</th><th scope="col">Outcomes</th><th scope="col">Span</th><th scope="col"><span className="sr-only">CI run</span></th></tr>
|
|
55
|
+
</thead>
|
|
56
|
+
<tbody>
|
|
57
|
+
{list.slice(0, shown).map((run) => (
|
|
58
|
+
<tr key={run.key} data-status={run.counts.failed ? 'failed' : 'passed'}>
|
|
59
|
+
<td title={exactTime(run.endedAtMs)}><Link to="/" search={patched({ openRun: run.key })} resetScroll={false} activeOptions={{ exact: true }} aria-label={`Open run ${runName(run.key)}`}>{ago(run.endedAtMs, now)}</Link></td>
|
|
60
|
+
<td className={styles.ellipsis} title={run.branch ?? ''}>{run.branch ?? '—'}</td>
|
|
61
|
+
<td><code>{shortSha(run.sha) ?? '—'}</code></td>
|
|
62
|
+
<td>{run.environments.map((env) => environmentLabel(env)).join(', ') || '—'}</td>
|
|
63
|
+
<td className={styles.num}>{run.tests}</td>
|
|
64
|
+
<td><span className={styles.runMeter}><Meter counts={run.counts} /></span><Counts run={run} /></td>
|
|
65
|
+
<td className={styles.num}>{formatMs(run.spanMs)}</td>
|
|
66
|
+
<td>{run.runUrl ? <a href={run.runUrl} target="_blank" rel="noreferrer">CI run</a> : null}</td>
|
|
67
|
+
</tr>
|
|
68
|
+
))}
|
|
69
|
+
</tbody>
|
|
70
|
+
</table>
|
|
71
|
+
</div>
|
|
72
|
+
{list.length > shown && <button type="button" className={styles.link} onClick={() => setShown(shown + RUN_ROWS)}>Show more ({list.length - shown} more)</button>}
|
|
73
|
+
</section>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const RESULT_FILTERS = [{ id: '', label: 'All' }, { id: 'failed', label: 'Failed' }, { id: 'passed', label: 'Passed' }, { id: 'not-run', label: 'Not run' }] as const;
|
|
78
|
+
|
|
79
|
+
function RunDetailView({ runKey }: { runKey: string }) {
|
|
80
|
+
const detail = useRunDetail(runKey);
|
|
81
|
+
const now = useNow();
|
|
82
|
+
const [status, setStatus] = useState('');
|
|
83
|
+
const [words, setWords] = useState('');
|
|
84
|
+
const [shown, setShown] = useState(RESULT_ROWS);
|
|
85
|
+
const results = detail.data?.results ?? [];
|
|
86
|
+
const filtered = useMemo(() => {
|
|
87
|
+
const terms = words.toLowerCase().split(/\s+/).filter(Boolean);
|
|
88
|
+
return results.filter((result) => (!status || result.status === status) && terms.every((term) => `${result.title}\n${result.specPath ?? ''}\n${result.environment ?? ''}\n${result.producer}`.toLowerCase().includes(term)));
|
|
89
|
+
}, [results, status, words]);
|
|
90
|
+
const bySpec = useMemo(() => {
|
|
91
|
+
const groups = new Map<string, RunResult[]>();
|
|
92
|
+
for (const result of filtered.slice(0, shown)) {
|
|
93
|
+
const key = result.specPath ?? '';
|
|
94
|
+
const group = groups.get(key);
|
|
95
|
+
if (group) group.push(result);
|
|
96
|
+
else groups.set(key, [result]);
|
|
97
|
+
}
|
|
98
|
+
return [...groups];
|
|
99
|
+
}, [filtered, shown]);
|
|
100
|
+
const back = <Link to="/" search={patched({ openRun: undefined })} resetScroll={false} activeOptions={{ exact: true }} className={styles.back}><span aria-hidden="true">←</span> All runs</Link>;
|
|
101
|
+
if (detail.isPending) return <>{back}<p className={styles.quiet} role="status">Reading the run…</p></>;
|
|
102
|
+
if (detail.isError) return <>{back}<p className={styles.notice} role="alert">The run could not be read. {detail.error.message}</p></>;
|
|
103
|
+
if (!detail.data) return <>{back}<p className={styles.notice}>That run is not in the report any more.</p></>;
|
|
104
|
+
const { run } = detail.data;
|
|
105
|
+
return (
|
|
106
|
+
<section className={styles.runs} aria-label={`Run ${runName(run.key)}`}>
|
|
107
|
+
{back}
|
|
108
|
+
<h2 className={styles.runTitle}>Run {runName(run.key)}</h2>
|
|
109
|
+
<p className={styles.runMeta}>
|
|
110
|
+
{run.branch && <span>{run.branch}</span>}
|
|
111
|
+
{run.sha && <code>{shortSha(run.sha)}</code>}
|
|
112
|
+
<span title={exactTime(run.endedAtMs)}>ended {ago(run.endedAtMs, now)}</span>
|
|
113
|
+
<span>took {formatMs(run.spanMs)}</span>
|
|
114
|
+
{run.environments.length > 0 && <span>{run.environments.map((env) => environmentLabel(env)).join(', ')}</span>}
|
|
115
|
+
<span>{run.producers.join(', ')}</span>
|
|
116
|
+
{run.totalDurationMs !== null && <span>{formatMs(run.totalDurationMs)} of test time</span>}
|
|
117
|
+
</p>
|
|
118
|
+
<div className={styles.runSummary}>
|
|
119
|
+
<span className={styles.runMeter}><Meter counts={run.counts} /></span>
|
|
120
|
+
<span>{plural(run.tests, 'test', 'tests')}</span>
|
|
121
|
+
<Counts run={run} />
|
|
122
|
+
</div>
|
|
123
|
+
<p className={styles.actionsLine}>
|
|
124
|
+
<Link className={styles.primary} to="/" search={patched({ tab: undefined, openRun: undefined, inRun: run.key, test: undefined })} resetScroll={false} activeOptions={{ exact: true }}>Show these tests in the explorer</Link>
|
|
125
|
+
{run.runUrl && <a href={run.runUrl} target="_blank" rel="noreferrer">Open the CI run</a>}
|
|
126
|
+
</p>
|
|
127
|
+
<div className={styles.searchRow}>
|
|
128
|
+
<input type="search" className={styles.searchBox} aria-label="Search this run" placeholder="Search this run…" value={words} onChange={(event) => setWords(event.target.value)} />
|
|
129
|
+
</div>
|
|
130
|
+
<div className={styles.segments} role="group" aria-label="Result status">
|
|
131
|
+
{RESULT_FILTERS.map((filter) => (
|
|
132
|
+
<button key={filter.id || 'all'} type="button" aria-pressed={status === filter.id} data-status={filter.id || undefined} onClick={() => setStatus(filter.id)}>
|
|
133
|
+
{filter.label} <span>{filter.id ? results.filter((result) => result.status === filter.id).length : results.length}</span>
|
|
134
|
+
</button>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
{!filtered.length && <p className={styles.quiet}>No result matches.</p>}
|
|
138
|
+
{bySpec.map(([spec, list]) => (
|
|
139
|
+
<section key={spec} className={styles.group} data-open aria-label={spec || 'No spec'}>
|
|
140
|
+
<div className={styles.groupHead} data-static>
|
|
141
|
+
<span className={styles.groupName}><code>{spec || 'No spec'}</code></span>
|
|
142
|
+
<span className={styles.groupTally}>{plural(list.length, 'result', 'results')}</span>
|
|
143
|
+
</div>
|
|
144
|
+
<ul className={styles.rows}>
|
|
145
|
+
{list.map((result) => (
|
|
146
|
+
<li key={`${result.key}|${result.environment}|${result.producer}|${result.layer}`} className={styles.row} data-status={result.status}>
|
|
147
|
+
<Link to="/" search={patched({ tab: undefined, openRun: undefined, test: result.key })} resetScroll={false} activeOptions={{ exact: true }} className={styles.rowLink}>
|
|
148
|
+
<StatusMark status={result.status} size={14} />
|
|
149
|
+
<span className={styles.rowMain}>
|
|
150
|
+
<span className={styles.rowTitle}>{result.title}</span>
|
|
151
|
+
<span className={styles.rowMeta}><span>{environmentLabel(result.environment)}</span><span>{result.producer}</span><span>{statusWord(result.status)}</span></span>
|
|
152
|
+
{result.status === 'failed' && result.detail && <span className={styles.rowError}>{result.detail.split('\n')[0]}</span>}
|
|
153
|
+
</span>
|
|
154
|
+
<span className={styles.columns} data-one><span>{formatMs(result.durationMs)}</span></span>
|
|
155
|
+
</Link>
|
|
156
|
+
</li>
|
|
157
|
+
))}
|
|
158
|
+
</ul>
|
|
159
|
+
</section>
|
|
160
|
+
))}
|
|
161
|
+
{filtered.length > shown && <button type="button" className={styles.link} onClick={() => setShown(shown + RESULT_ROWS)}>Show more ({filtered.length - shown} more)</button>}
|
|
162
|
+
</section>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Test detail
|
|
3
|
+
*
|
|
4
|
+
* One test, opened beside the list: what it is (spec, domain, id), its current result in every environment, how it has
|
|
5
|
+
* gone (pass rate, flips, durations, a duration trend and a strip of past outcomes), every recorded result, its tags and
|
|
6
|
+
* labels, and what its current result carries (steps, screenshots, video, traces, the recording) through the same
|
|
7
|
+
* evidence views as a spec page.
|
|
8
|
+
*/
|
|
9
|
+
import type { CheckSlot } from '@crvouga/atlas/reports/evidence-view';
|
|
10
|
+
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
11
|
+
import { formatMs, type TestResultEntry, type TestSummary } from '@crvouga/atlas/reports/test-catalog';
|
|
12
|
+
import { Link } from '@tanstack/react-router';
|
|
13
|
+
import { lazy, Suspense, useState } from 'react';
|
|
14
|
+
|
|
15
|
+
import { CheckDetail, type CheckSelection } from '../evidence/check-detail';
|
|
16
|
+
import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
|
|
17
|
+
import { StatusMark } from '../evidence/marks';
|
|
18
|
+
import { useTestHistory } from './catalog';
|
|
19
|
+
import styles from './explorer.module.css';
|
|
20
|
+
import { Badges } from './parts';
|
|
21
|
+
import { patched } from './navigate';
|
|
22
|
+
|
|
23
|
+
const RecordingPage = lazy(() => import('../evidence/recording-page').then((module) => ({ default: module.RecordingPage })));
|
|
24
|
+
|
|
25
|
+
/** Results the history table shows before "Show more". */
|
|
26
|
+
const HISTORY_ROWS = 20;
|
|
27
|
+
|
|
28
|
+
function percent(rate: number | null): string {
|
|
29
|
+
return rate === null ? '—' : `${Math.round(rate * 100)}%`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The open test. `slot` is the report's check for it, when the page holds one, for its evidence. */
|
|
33
|
+
export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot | undefined }) {
|
|
34
|
+
const now = useNow();
|
|
35
|
+
const history = useTestHistory(test.key);
|
|
36
|
+
const [selection, setSelection] = useState<CheckSelection>({});
|
|
37
|
+
const [recording, setRecording] = useState(false);
|
|
38
|
+
const [moment, setMoment] = useState<number | null>(null);
|
|
39
|
+
const [rows, setRows] = useState(HISTORY_ROWS);
|
|
40
|
+
const entries = history.data?.history ?? [];
|
|
41
|
+
const stats = test.stats;
|
|
42
|
+
const d = stats.durations;
|
|
43
|
+
|
|
44
|
+
if (recording && slot) {
|
|
45
|
+
return (
|
|
46
|
+
<Suspense fallback={<p className={styles.quiet} role="status">Loading the recording…</p>}>
|
|
47
|
+
<RecordingPage slot={slot} layerId={selection.layer} label={test.title} back="Test detail" moment={moment} onMoment={setMoment} onClose={() => setRecording(false)} />
|
|
48
|
+
</Suspense>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<article className={styles.detail} aria-label={test.title}>
|
|
54
|
+
<div className={styles.detailTop}>
|
|
55
|
+
<Link to="/" search={patched({ test: undefined })} resetScroll={false} activeOptions={{ exact: true }} className={styles.close} aria-label="Close the test">×</Link>
|
|
56
|
+
</div>
|
|
57
|
+
<header className={styles.detailHead}>
|
|
58
|
+
{test.spec && (
|
|
59
|
+
<p className={styles.detailSpec}>
|
|
60
|
+
{test.spec.domain && <Link to="/" search={patched({ domain: test.spec.domain, spec: undefined })} resetScroll={false} activeOptions={{ exact: true }}>{test.spec.domain}</Link>}
|
|
61
|
+
<Link to="/" search={patched({ spec: test.spec.path, domain: undefined })} resetScroll={false} activeOptions={{ exact: true }}>{test.spec.title}</Link>
|
|
62
|
+
</p>
|
|
63
|
+
)}
|
|
64
|
+
<h2 className={styles.detailTitle}>{test.title}</h2>
|
|
65
|
+
<p className={styles.detailStatus}>
|
|
66
|
+
<StatusMark status={test.status} />
|
|
67
|
+
<strong data-status={test.status}>{statusWord(test.status)}</strong>
|
|
68
|
+
{stats.flaky && <span className={styles.flaky} title={stats.flakyReason}>Flaky: {stats.flakyReason}</span>}
|
|
69
|
+
<Badges test={test} />
|
|
70
|
+
</p>
|
|
71
|
+
{test.spec && <code className={styles.mono}>{test.spec.path}</code>}
|
|
72
|
+
<code className={styles.mono} title="Check id">{test.id}</code>
|
|
73
|
+
</header>
|
|
74
|
+
|
|
75
|
+
<section className={styles.detailBlock} aria-label="Current results">
|
|
76
|
+
<h3 className={styles.label}>Current result per environment</h3>
|
|
77
|
+
{test.outcomes.length === 0 ? <p className={styles.quiet}>No completed result recorded yet.</p> : (
|
|
78
|
+
<div className={styles.tableScroll}>
|
|
79
|
+
<table className={styles.table}>
|
|
80
|
+
<thead><tr><th scope="col">Environment</th><th scope="col">Producer</th><th scope="col">Status</th><th scope="col">Duration</th><th scope="col">Branch</th><th scope="col">Commit</th><th scope="col">Recorded</th><th scope="col"><span className="sr-only">Run</span></th></tr></thead>
|
|
81
|
+
<tbody>
|
|
82
|
+
{test.outcomes.map((outcome) => (
|
|
83
|
+
<tr key={`${outcome.layer}|${outcome.producer}|${outcome.environment}|${JSON.stringify(outcome.parameters)}`} data-status={outcome.status}>
|
|
84
|
+
<td>{environmentLabel(outcome.environment)}{Object.entries(outcome.parameters).map(([name, value]) => <span key={name} className={styles.param}> {name}: {value}</span>)}</td>
|
|
85
|
+
<td>{outcome.producer}</td>
|
|
86
|
+
<td><span className={styles.cellStatus}><StatusMark status={outcome.status} size={12} />{statusWord(outcome.status)}</span></td>
|
|
87
|
+
<td className={styles.num}>{formatMs(outcome.durationMs)}</td>
|
|
88
|
+
<td className={styles.ellipsis} title={outcome.branch ?? ''}>{outcome.branch ?? '—'}</td>
|
|
89
|
+
<td><code>{shortSha(outcome.sha) ?? '—'}</code></td>
|
|
90
|
+
<td title={exactTime(outcome.at)}>{ago(outcome.at, now)}</td>
|
|
91
|
+
<td>{outcome.runUrl ? <a href={outcome.runUrl} target="_blank" rel="noreferrer">CI run</a> : null}</td>
|
|
92
|
+
</tr>
|
|
93
|
+
))}
|
|
94
|
+
</tbody>
|
|
95
|
+
</table>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
</section>
|
|
99
|
+
|
|
100
|
+
<section className={styles.detailBlock} aria-label="Statistics">
|
|
101
|
+
<h3 className={styles.label}>How it has gone</h3>
|
|
102
|
+
<dl className={styles.stats}>
|
|
103
|
+
<div><dt>Pass rate</dt><dd>{percent(stats.passRate)}</dd></div>
|
|
104
|
+
<div><dt>Results</dt><dd>{stats.results} <span>({stats.passed} passed{stats.failed ? `, ${stats.failed} failed` : ''})</span></dd></div>
|
|
105
|
+
<div><dt>Runs</dt><dd>{stats.runs}</dd></div>
|
|
106
|
+
<div><dt>Flips</dt><dd>{stats.flips}</dd></div>
|
|
107
|
+
<div><dt>Average</dt><dd>{formatMs(d?.avgMs)}</dd></div>
|
|
108
|
+
<div><dt>p50</dt><dd>{formatMs(d?.p50Ms)}</dd></div>
|
|
109
|
+
<div><dt>p95</dt><dd>{formatMs(d?.p95Ms)}</dd></div>
|
|
110
|
+
<div><dt>Min – max</dt><dd>{d ? `${formatMs(d.minMs)} – ${formatMs(d.maxMs)}` : '—'}</dd></div>
|
|
111
|
+
<div><dt>Last</dt><dd>{formatMs(d?.lastMs)}</dd></div>
|
|
112
|
+
<div><dt>Last passed</dt><dd>{stats.lastPassedAtMs ? ago(stats.lastPassedAtMs, now) : '—'}</dd></div>
|
|
113
|
+
<div><dt>Last failed</dt><dd>{stats.lastFailedAtMs ? ago(stats.lastFailedAtMs, now) : '—'}</dd></div>
|
|
114
|
+
<div><dt>First seen</dt><dd>{stats.firstSeenMs ? ago(stats.firstSeenMs, now) : '—'}</dd></div>
|
|
115
|
+
</dl>
|
|
116
|
+
{stats.branches.length > 0 && <p className={styles.quiet}>Branches: {stats.branches.join(', ')}</p>}
|
|
117
|
+
{!d && <p className={styles.quiet}>No duration recorded. Producers can send <code>durationMs</code> with each check.</p>}
|
|
118
|
+
{entries.length > 1 && <Trend entries={entries} />}
|
|
119
|
+
</section>
|
|
120
|
+
|
|
121
|
+
{(test.tags.length > 0 || Object.keys(test.labels).length > 0) && (
|
|
122
|
+
<section className={styles.detailBlock} aria-label="Tags and labels">
|
|
123
|
+
<h3 className={styles.label}>Tags and labels</h3>
|
|
124
|
+
<p className={styles.tags}>
|
|
125
|
+
{test.tags.map((tag) => <Link key={tag} to="/" search={patched({ tag })} resetScroll={false} activeOptions={{ exact: true }}>{tag}</Link>)}
|
|
126
|
+
{Object.entries(test.labels).map(([name, value]) => <span key={name}>{name}: {value}</span>)}
|
|
127
|
+
</p>
|
|
128
|
+
</section>
|
|
129
|
+
)}
|
|
130
|
+
|
|
131
|
+
<section className={styles.detailBlock} aria-label="Evidence">
|
|
132
|
+
<h3 className={styles.label}>What the current result carries</h3>
|
|
133
|
+
{slot ? (
|
|
134
|
+
<CheckDetail
|
|
135
|
+
slot={slot}
|
|
136
|
+
layerId={selection.layer}
|
|
137
|
+
reads="steps"
|
|
138
|
+
select={(patch) => {
|
|
139
|
+
setSelection({ check: patch.check ?? slot.id, layer: patch.layer ?? undefined });
|
|
140
|
+
if (patch.view === 'recording') { setRecording(true); setMoment(null); }
|
|
141
|
+
}}
|
|
142
|
+
/>
|
|
143
|
+
) : <p className={styles.quiet}>The evidence of this result is not loaded in this page.</p>}
|
|
144
|
+
</section>
|
|
145
|
+
|
|
146
|
+
<section className={styles.detailBlock} aria-label="History">
|
|
147
|
+
<h3 className={styles.label}>History{entries.length ? ` · ${plural(entries.length, 'result', 'results')}` : ''}</h3>
|
|
148
|
+
{history.isPending && <p className={styles.quiet} role="status">Reading the history…</p>}
|
|
149
|
+
{history.isError && <p className={styles.notice} role="alert">The history could not be read. {history.error.message}</p>}
|
|
150
|
+
{entries.length > 0 && (
|
|
151
|
+
<div className={styles.tableScroll}>
|
|
152
|
+
<table className={styles.table}>
|
|
153
|
+
<thead><tr><th scope="col">Recorded</th><th scope="col">Environment</th><th scope="col">Status</th><th scope="col">Duration</th><th scope="col">Branch</th><th scope="col">Commit</th><th scope="col">Detail</th></tr></thead>
|
|
154
|
+
<tbody>
|
|
155
|
+
{entries.slice(0, rows).map((entry) => (
|
|
156
|
+
<tr key={`${entry.layer}|${entry.kind}|${entry.producer}|${entry.environment}|${JSON.stringify(entry.parameters)}`} data-status={entry.status}>
|
|
157
|
+
<td title={exactTime(entry.at)}>{entry.runUrl ? <a href={entry.runUrl} target="_blank" rel="noreferrer">{ago(entry.at, now)}</a> : ago(entry.at, now)}</td>
|
|
158
|
+
<td>{environmentLabel(entry.environment)}</td>
|
|
159
|
+
<td><span className={styles.cellStatus}><StatusMark status={entry.status} size={12} />{statusWord(entry.status)}</span></td>
|
|
160
|
+
<td className={styles.num}>{formatMs(entry.durationMs)}</td>
|
|
161
|
+
<td className={styles.ellipsis} title={entry.branch ?? ''}>{entry.branch ?? '—'}</td>
|
|
162
|
+
<td><code>{shortSha(entry.sha) ?? '—'}</code></td>
|
|
163
|
+
<td className={styles.detailCell} title={entry.detail}>{entry.detail.split('\n')[0]}</td>
|
|
164
|
+
</tr>
|
|
165
|
+
))}
|
|
166
|
+
</tbody>
|
|
167
|
+
</table>
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
{entries.length > rows && <button type="button" className={styles.link} onClick={() => setRows(rows + HISTORY_ROWS)}>Show more ({entries.length - rows} more)</button>}
|
|
171
|
+
</section>
|
|
172
|
+
</article>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The duration of each completed result over time, and a strip of every outcome, oldest on the left. */
|
|
177
|
+
function Trend({ entries }: { entries: readonly TestResultEntry[] }) {
|
|
178
|
+
const oldest = [...entries].reverse();
|
|
179
|
+
const timed = oldest.filter((entry) => entry.durationMs !== null && (entry.status === 'passed' || entry.status === 'failed'));
|
|
180
|
+
const width = 320, height = 56, pad = 4;
|
|
181
|
+
const max = Math.max(1, ...timed.map((entry) => entry.durationMs!));
|
|
182
|
+
const x = (index: number) => (timed.length === 1 ? width / 2 : pad + (index * (width - 2 * pad)) / (timed.length - 1));
|
|
183
|
+
const y = (ms: number) => height - pad - (ms / max) * (height - 2 * pad);
|
|
184
|
+
const points = timed.map((entry, index) => `${x(index).toFixed(1)},${y(entry.durationMs!).toFixed(1)}`).join(' ');
|
|
185
|
+
return (
|
|
186
|
+
<div className={styles.trend}>
|
|
187
|
+
{timed.length > 1 && (
|
|
188
|
+
<figure className={styles.spark}>
|
|
189
|
+
<figcaption>Duration over the last {plural(timed.length, 'result', 'results')} · max {formatMs(max)}</figcaption>
|
|
190
|
+
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`Durations from ${formatMs(timed[0]!.durationMs)} to ${formatMs(timed.at(-1)!.durationMs)}`} preserveAspectRatio="none">
|
|
191
|
+
<line x1={pad} x2={width - pad} y1={height - pad} y2={height - pad} className={styles.sparkBase} />
|
|
192
|
+
<polyline points={points} className={styles.sparkLine} />
|
|
193
|
+
{timed.map((entry, index) => (
|
|
194
|
+
<circle key={index} cx={x(index)} cy={y(entry.durationMs!)} r={entry.status === 'failed' ? 3 : index === timed.length - 1 ? 2.5 : 0} data-status={entry.status}>
|
|
195
|
+
<title>{`${exactTime(entry.at)} · ${environmentLabel(entry.environment)} · ${statusWord(entry.status)} · ${formatMs(entry.durationMs)}`}</title>
|
|
196
|
+
</circle>
|
|
197
|
+
))}
|
|
198
|
+
{/* Wide invisible targets, so a point is easy to hover. */}
|
|
199
|
+
{timed.map((entry, index) => (
|
|
200
|
+
<rect key={`hit${index}`} x={x(index) - (width / timed.length) / 2} y={0} width={width / timed.length} height={height} className={styles.sparkHit}>
|
|
201
|
+
<title>{`${exactTime(entry.at)} · ${environmentLabel(entry.environment)} · ${statusWord(entry.status)} · ${formatMs(entry.durationMs)}`}</title>
|
|
202
|
+
</rect>
|
|
203
|
+
))}
|
|
204
|
+
</svg>
|
|
205
|
+
</figure>
|
|
206
|
+
)}
|
|
207
|
+
<div className={styles.strip} role="img" aria-label={`Outcomes, oldest first: ${oldest.map((entry) => statusWord(entry.status)).join(', ')}`}>
|
|
208
|
+
{oldest.map((entry, index) => (
|
|
209
|
+
<span key={index} data-status={entry.status} title={`${exactTime(entry.at)} · ${environmentLabel(entry.environment)} · ${statusWord(entry.status)}${entry.durationMs !== null ? ` · ${formatMs(entry.durationMs)}` : ''}`} />
|
|
210
|
+
))}
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|