@crvouga/atlas 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -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/schema/src/common.d.ts +2 -2
- package/dist/schema/src/manifest.d.ts +4 -4
- package/dist/schema/src/spec.d.ts +6 -6
- 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 +8 -3
- package/packages/schema/schemas/check-detail.schema.json +15 -15
- package/packages/schema/schemas/checks-result.schema.json +2 -2
- package/packages/schema/schemas/command-error.schema.json +4 -4
- package/packages/schema/schemas/doctor-result.schema.json +4 -4
- package/packages/schema/schemas/effective-delta.schema.json +69 -95
- package/packages/schema/schemas/effective-evidence.schema.json +63 -89
- package/packages/schema/schemas/evidence-page.schema.json +41 -59
- package/packages/schema/schemas/init-plan.schema.json +58 -90
- package/packages/schema/schemas/layer-rows.schema.json +17 -29
- package/packages/schema/schemas/layer.schema.json +54 -70
- package/packages/schema/schemas/live-run-start.schema.json +1 -1
- package/packages/schema/schemas/live-run.schema.json +1 -1
- package/packages/schema/schemas/merge-result.schema.json +6 -6
- package/packages/schema/schemas/outline.schema.json +7 -11
- package/packages/schema/schemas/path-record.schema.json +12 -28
- package/packages/schema/schemas/problem.schema.json +9 -9
- package/packages/schema/schemas/publish-checks.schema.json +27 -43
- package/packages/schema/schemas/publish-result.schema.json +16 -24
- package/packages/schema/schemas/ready.schema.json +12 -12
- package/packages/schema/schemas/report-event.schema.json +22 -28
- package/packages/schema/schemas/report-run.schema.json +225 -0
- package/packages/schema/schemas/report-runs.schema.json +163 -0
- package/packages/schema/schemas/report-status.schema.json +30 -54
- package/packages/schema/schemas/run-info.schema.json +9 -21
- package/packages/schema/schemas/run-summary.schema.json +6 -14
- package/packages/schema/schemas/server-config.schema.json +21 -21
- package/packages/schema/schemas/server-editable-settings.schema.json +14 -14
- package/packages/schema/schemas/server-info.schema.json +15 -19
- package/packages/schema/schemas/server-settings-snapshot.schema.json +28 -28
- package/packages/schema/schemas/spec-set.schema.json +16 -24
- package/packages/schema/schemas/state-node.schema.json +16 -16
- package/packages/schema/schemas/state-record.schema.json +5 -9
- package/packages/schema/schemas/store-listing.schema.json +3 -7
- package/packages/schema/schemas/store-write-result.schema.json +2 -2
- package/packages/schema/schemas/stream-result.schema.json +13 -17
- package/packages/schema/schemas/test-catalog.schema.json +1135 -0
- package/packages/schema/schemas/test-history.schema.json +632 -0
- package/packages/schema/schemas/timeline-entry.schema.json +2 -2
- package/packages/schema/schemas/transition-record.schema.json +9 -21
- package/packages/server/openapi.json +4417 -1382
- 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
|
@@ -12,7 +12,9 @@ import { AtlasProvider, useAtlas } from './atlas-context';
|
|
|
12
12
|
import { Gazetteer } from './gazetteer';
|
|
13
13
|
import { useEvidence } from '../evidence/evidence-context';
|
|
14
14
|
import { isStatechartFile } from '@crvouga/atlas/schema';
|
|
15
|
-
import {
|
|
15
|
+
import { Explorer } from '../explorer/Explorer';
|
|
16
|
+
import { ProjectIndex } from '../explorer/ExplorerIndex';
|
|
17
|
+
import { TestCatalogProvider } from '../explorer/catalog';
|
|
16
18
|
|
|
17
19
|
// The finder brings every spec parser with it. It loads as soon as the page has drawn, long before anyone presses ⌘K.
|
|
18
20
|
const loadFinder = () => import('../find/Finder').then((m) => ({ default: m.Finder }));
|
|
@@ -23,14 +25,14 @@ function Index() {
|
|
|
23
25
|
const evidence = useEvidence();
|
|
24
26
|
const { pathname } = useLocation();
|
|
25
27
|
const kind = kindOfAddress(pathname);
|
|
26
|
-
return kind ? <SpecIndex kind={kind} /> : pathname.startsWith('/specs/') ? <nav aria-label="Specs" /> : evidence.ready && !evidence.files.some(isStatechartFile) ? <
|
|
28
|
+
return kind ? <SpecIndex kind={kind} /> : pathname.startsWith('/specs/') ? <nav aria-label="Specs" /> : evidence.ready && !evidence.files.some(isStatechartFile) ? <ProjectIndex /> : <Gazetteer />;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
function Body() {
|
|
30
32
|
const evidence = useEvidence();
|
|
31
33
|
const { view, spec } = useAtlas();
|
|
32
34
|
const { pathname } = useLocation();
|
|
33
|
-
if (pathname === '/' && evidence.ready && !evidence.filesPending && !evidence.files.some(isStatechartFile)) return <
|
|
35
|
+
if (pathname === '/' && evidence.ready && !evidence.filesPending && !evidence.files.some(isStatechartFile)) return <Explorer />;
|
|
34
36
|
if (!pathname.startsWith('/specs/') && !view && spec.error) {
|
|
35
37
|
return (
|
|
36
38
|
<div className={styles.message} role="alert">
|
|
@@ -51,6 +53,7 @@ export function AppShell() {
|
|
|
51
53
|
<LiveRunsProvider enabled={!run}>
|
|
52
54
|
<AtlasProvider run={run}>
|
|
53
55
|
<EvidenceProvider>
|
|
56
|
+
<TestCatalogProvider>
|
|
54
57
|
<div className={styles.app}>
|
|
55
58
|
<Header />
|
|
56
59
|
<div className={styles.frame}>
|
|
@@ -64,6 +67,7 @@ export function AppShell() {
|
|
|
64
67
|
<Finder />
|
|
65
68
|
</Suspense>
|
|
66
69
|
</div>
|
|
70
|
+
</TestCatalogProvider>
|
|
67
71
|
</EvidenceProvider>
|
|
68
72
|
</AtlasProvider>
|
|
69
73
|
</LiveRunsProvider>
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createBrowserHistory, createMemoryHistory, createRootRoute, createRoute, createRouter, lazyRouteComponent, type RouterHistory } from '@tanstack/react-router';
|
|
2
2
|
import { z } from 'zod/v4';
|
|
3
3
|
|
|
4
4
|
import { SpecPage } from '../evidence/spec-page';
|
|
5
5
|
import { ProductPage } from '../views/ProductPage';
|
|
6
6
|
import { AppShell } from './AppShell';
|
|
7
|
+
import { ExplorerSearchFields } from '../explorer/search';
|
|
7
8
|
|
|
8
9
|
const RootSearch = z.object({ run: z.string().min(1).optional().catch(undefined) });
|
|
9
10
|
|
|
@@ -17,6 +18,9 @@ const ChartSearch = RootSearch.extend({
|
|
|
17
18
|
|
|
18
19
|
export type ChartSearch = z.infer<typeof ChartSearch>;
|
|
19
20
|
|
|
21
|
+
/** The project home: the test explorer's filters, grouping, order, open test and open run (see `explorer/search.ts`). */
|
|
22
|
+
const HomeSearch = RootSearch.extend(ExplorerSearchFields);
|
|
23
|
+
|
|
20
24
|
/** A spec document, the check opened in it, one layer's record of that check, and a recorded moment. */
|
|
21
25
|
const SpecSearch = RootSearch.extend({
|
|
22
26
|
spec: z.string().min(1).optional().catch(undefined),
|
|
@@ -37,6 +41,7 @@ function routeTree() {
|
|
|
37
41
|
const productRoute = createRoute({
|
|
38
42
|
getParentRoute: () => rootRoute,
|
|
39
43
|
path: '/',
|
|
44
|
+
validateSearch: (search) => HomeSearch.parse(search),
|
|
40
45
|
component: ProductPage
|
|
41
46
|
});
|
|
42
47
|
const chartRoute = createRoute({
|
|
@@ -59,8 +64,26 @@ export function createAtlasRouter(history: RouterHistory) {
|
|
|
59
64
|
return createRouter({ routeTree: routeTree(), history, defaultPreload: false, scrollRestoration: true });
|
|
60
65
|
}
|
|
61
66
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Addresses in the hash, so a built site deep-links without server rewrites. The page's own query (`?project=geviti`)
|
|
69
|
+
* selects the project and is not part of the address: TanStack's hash history appends it to the hash, which turned
|
|
70
|
+
* `#/?group=domain` into a `group` of `domain?project=geviti`. This one reads the hash alone and keeps the query in links.
|
|
71
|
+
*/
|
|
72
|
+
export function createProjectHashHistory(win: Window = window): RouterHistory {
|
|
73
|
+
return createBrowserHistory({
|
|
74
|
+
window: win,
|
|
75
|
+
parseLocation: () => {
|
|
76
|
+
const hash = win.location.hash;
|
|
77
|
+
const second = hash.indexOf('#', 1);
|
|
78
|
+
const at = second < 0 ? hash.length : second;
|
|
79
|
+
const parsed = createMemoryHistory({ initialEntries: [`${hash.slice(1, at) || '/'}${hash.slice(at)}`] }).location;
|
|
80
|
+
return win.history.state ? { ...parsed, state: win.history.state } : parsed;
|
|
81
|
+
},
|
|
82
|
+
createHref: (href) => `${win.location.pathname}${win.location.search}#${href}`
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const router = createAtlasRouter(createProjectHashHistory());
|
|
64
87
|
|
|
65
88
|
declare module '@tanstack/react-router' {
|
|
66
89
|
interface Register {
|
|
@@ -124,10 +124,10 @@ export function Header() {
|
|
|
124
124
|
Atlas
|
|
125
125
|
</Link>
|
|
126
126
|
<div className={styles.breadcrumbs}>
|
|
127
|
-
<HeaderMenu label="Spec type" working={tabs.some(tab => tab.running)} caption={kind?.label ?? (hasCharts ? 'Statecharts' : 'Specs')}>
|
|
127
|
+
<HeaderMenu label="Spec type" working={tabs.some(tab => tab.running)} caption={kind?.label ?? (hasCharts ? 'Statecharts' : evidence.ready && !tabs.length ? 'Tests' : 'Specs')}>
|
|
128
128
|
<nav className={styles.tabs} aria-label="Spec type" onClick={event => event.currentTarget.closest('details')?.removeAttribute('open')}>
|
|
129
129
|
<Link to="/" search={(prev) => runOnly(prev)} aria-current={pathname.startsWith('/specs/') ? undefined : 'page'}>
|
|
130
|
-
{hasCharts || !evidence.ready ? 'Statecharts' : '
|
|
130
|
+
{hasCharts || !evidence.ready ? 'Statecharts' : 'Tests'}
|
|
131
131
|
</Link>
|
|
132
132
|
{tabs.map((tab) => (
|
|
133
133
|
<Link key={tab.kind.id} to="/specs/$kind" params={{ kind: tab.kind.id }} search={{}} aria-current={tab.kind === kind ? 'page' : undefined}>
|
|
@@ -51,6 +51,12 @@ export type EvidenceState = {
|
|
|
51
51
|
readonly titleOf: (file: string) => string;
|
|
52
52
|
readonly excluded: SourceSelection;
|
|
53
53
|
readonly setExcluded: (excluded: SourceSelection) => void;
|
|
54
|
+
/** True when the server answers with the effective report (winners only), so history lives on the server. */
|
|
55
|
+
readonly effective: boolean;
|
|
56
|
+
/** The effective report's version when read from a server; null otherwise. Changes exactly when the report does. */
|
|
57
|
+
readonly version: string | null;
|
|
58
|
+
/** The specs the results are filed under: the server's outline, or the spec files found. */
|
|
59
|
+
readonly specs: readonly SpecEntry[];
|
|
54
60
|
};
|
|
55
61
|
|
|
56
62
|
const EvidenceContext = createContext<EvidenceState | null>(null);
|
|
@@ -220,7 +226,10 @@ export function EvidenceProvider({ children }: { children: ReactNode }) {
|
|
|
220
226
|
return title && title !== name ? title : name.replace(/\.[^.]+$/, '').replace(/\.openslo$/, '');
|
|
221
227
|
},
|
|
222
228
|
excluded,
|
|
223
|
-
setExcluded
|
|
229
|
+
setExcluded,
|
|
230
|
+
effective,
|
|
231
|
+
version: effective ? overview.data?.version ?? null : null,
|
|
232
|
+
specs: outline.specs
|
|
224
233
|
};
|
|
225
234
|
return <EvidenceContext.Provider value={state}>{children}</EvidenceContext.Provider>;
|
|
226
235
|
}
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Test explorer
|
|
3
|
+
*
|
|
4
|
+
* The home of a project without statecharts: every test the report holds, found by searching and filtering, grouped
|
|
5
|
+
* by spec (or domain, environment, status, producer), with what was recorded about each at a glance: where it ran and
|
|
6
|
+
* how it went there, how long it takes, how often it passes, how many runs it was in. Above the list, the report in
|
|
7
|
+
* numbers; beside it, the open test; in a second tab, the CI runs that produced the results.
|
|
8
|
+
*/
|
|
9
|
+
import { durationStats, formatMs, matchesQuery, testCatalog, TEST_SORTS, type CatalogModel, type TestQuery, type TestSort, type TestSummary } from '@crvouga/atlas/reports/test-catalog';
|
|
10
|
+
import type { CheckSlot } from '@crvouga/atlas/reports/evidence-view';
|
|
11
|
+
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
12
|
+
import { Link } from '@tanstack/react-router';
|
|
13
|
+
import { useDeferredValue, useEffect, useMemo, useState } from 'react';
|
|
14
|
+
|
|
15
|
+
import { useEvidence } from '../evidence/evidence-context';
|
|
16
|
+
import { ago, plural, useNow } from '../evidence/format';
|
|
17
|
+
import { Meter, StatusMark } from '../evidence/marks';
|
|
18
|
+
import { SPEC_KINDS } from '../spec-kind';
|
|
19
|
+
import { useRunDetail, useTestCatalog } from './catalog';
|
|
20
|
+
import styles from './explorer.module.css';
|
|
21
|
+
import { groupTests, tally, type TestGroupView } from './groups';
|
|
22
|
+
import { Badges, EnvironmentChips, projectTitle, runName } from './parts';
|
|
23
|
+
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
24
|
+
import { RunsView } from './RunsView';
|
|
25
|
+
import { narrowed, queryOf, toggled, values, TEST_GROUPS, type ExplorerSearch, type TestGroup } from './search';
|
|
26
|
+
import { TestDetail } from './TestDetail';
|
|
27
|
+
|
|
28
|
+
/** Groups start closed when there are more of them than this, unless the list is short. */
|
|
29
|
+
const OPEN_GROUPS = 40;
|
|
30
|
+
const OPEN_TESTS = 300;
|
|
31
|
+
/** Rows a group shows before "Show more". */
|
|
32
|
+
const GROUP_ROWS = 50;
|
|
33
|
+
|
|
34
|
+
const GROUP_LABEL: Record<TestGroup, string> = { spec: 'Spec', domain: 'Domain', environment: 'Environment', status: 'Status', producer: 'Producer', none: 'None' };
|
|
35
|
+
const SORT_LABEL: Record<TestSort, string> = { spec: 'Spec', title: 'Title', recent: 'Most recent', slowest: 'Slowest', failures: 'Most failures', flaky: 'Flakiest', runs: 'Most runs' };
|
|
36
|
+
const STATUS_FILTERS = [
|
|
37
|
+
{ id: '', label: 'All' },
|
|
38
|
+
{ id: 'failed', label: 'Failed' },
|
|
39
|
+
{ id: 'flaky', label: 'Flaky' },
|
|
40
|
+
{ id: 'passed', label: 'Passed' },
|
|
41
|
+
{ id: 'not-run', label: 'Not run' },
|
|
42
|
+
{ id: 'running', label: 'Running' }
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
function percent(rate: number | null): string {
|
|
46
|
+
return rate === null ? '—' : `${Math.round(rate * 100)}%`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What the explorer lists for an address: the tests shown, and the scope the insight strip and filter counts describe. */
|
|
50
|
+
function useListing(model: CatalogModel | null, search: ExplorerSearch, q: string | undefined) {
|
|
51
|
+
const run = useRunDetail(search.inRun);
|
|
52
|
+
const inRun = useMemo(() => (search.inRun ? new Set(run.data?.results.map((result) => result.key) ?? []) : null), [search.inRun, run.data]);
|
|
53
|
+
return useMemo(() => {
|
|
54
|
+
if (!model) return null;
|
|
55
|
+
const query: TestQuery = { ...queryOf(search), ...(q ? { q } : { q: undefined }) };
|
|
56
|
+
const shown = testCatalog(model, { ...query, limit: 20_000 }).tests.filter((test) => !inRun || inRun.has(test.key));
|
|
57
|
+
// The strip and the status and environment counts describe what the other filters leave.
|
|
58
|
+
const scope = model.tests.filter((test) => matchesQuery(model, test, { ...query, status: [], environment: [] }, inRun));
|
|
59
|
+
const byEnvironment = scope.filter((test) => matchesQuery(model, test, { environment: query.environment }));
|
|
60
|
+
const byStatus = scope.filter((test) => matchesQuery(model, test, { status: query.status }));
|
|
61
|
+
return { shown, scope, byEnvironment, byStatus, runPending: !!search.inRun && run.isPending, runMissing: !!search.inRun && run.isSuccess && run.data === null };
|
|
62
|
+
}, [model, search, q, inRun, run.isPending, run.isSuccess, run.data]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The project home: the published spec kinds, the insight strip, the toolbar, the grouped list and the open test. */
|
|
66
|
+
export function Explorer() {
|
|
67
|
+
const evidence = useEvidence();
|
|
68
|
+
const catalog = useTestCatalog();
|
|
69
|
+
const search = useExplorerSearch();
|
|
70
|
+
const go = useExplorerNavigate();
|
|
71
|
+
const q = useDeferredValue(search.q);
|
|
72
|
+
const listing = useListing(catalog.model, search, q);
|
|
73
|
+
const kinds = SPEC_KINDS.map((kind) => ({ kind, files: evidence.files.filter(kind.accepts) })).filter((entry) => entry.files.length);
|
|
74
|
+
const slots = useMemo(() => {
|
|
75
|
+
const map = new Map<string, CheckSlot>();
|
|
76
|
+
for (const entry of evidence.view.specs.values()) for (const slot of entry.slots) map.set(slot.key, slot);
|
|
77
|
+
for (const slot of evidence.view.unassigned) map.set(slot.key, slot);
|
|
78
|
+
return map;
|
|
79
|
+
}, [evidence.view]);
|
|
80
|
+
const model = catalog.model;
|
|
81
|
+
const selected = search.test ? model?.byKey.get(search.test) ?? null : null;
|
|
82
|
+
const empty = model !== null && model.tests.length === 0;
|
|
83
|
+
const split = !!search.test && search.tab !== 'runs';
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div className={styles.explorer} data-split={split || undefined}>
|
|
87
|
+
<article className={styles.main} aria-label="Test explorer">
|
|
88
|
+
<header className={styles.head}>
|
|
89
|
+
<h1 className={styles.title}>{projectTitle()}</h1>
|
|
90
|
+
{model && model.tests.length > 0 && (
|
|
91
|
+
<div className={styles.tabs} role="tablist" aria-label="Explorer view">
|
|
92
|
+
<Link role="tab" aria-selected={search.tab !== 'runs'} to="/" search={patched({ tab: undefined, openRun: undefined })} resetScroll={false} activeOptions={{ exact: true }}>Tests <span>{model.tests.length}</span></Link>
|
|
93
|
+
<Link role="tab" aria-selected={search.tab === 'runs'} to="/" search={patched({ tab: 'runs', test: undefined })} resetScroll={false} activeOptions={{ exact: true }}>Runs <span>{model.summary.runs}</span></Link>
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
</header>
|
|
97
|
+
{evidence.unavailable && <p role="alert" className={styles.notice}>{evidence.unavailable}</p>}
|
|
98
|
+
{catalog.notice && <p role="status" className={styles.notice}>The server’s test history could not be read ({catalog.notice}). Showing what this page holds.</p>}
|
|
99
|
+
{kinds.length > 0 && (
|
|
100
|
+
<section className={styles.kinds} aria-label="Published specs">
|
|
101
|
+
<h2 className={styles.label}>Specs</h2>
|
|
102
|
+
<ul>
|
|
103
|
+
{kinds.map(({ kind, files }) => (
|
|
104
|
+
<li key={kind.id}>
|
|
105
|
+
<Link to="/specs/$kind" params={{ kind: kind.id }} search={{}}><strong>{kind.label}</strong> <span>{files.length} {files.length === 1 ? kind.one : kind.many}</span></Link>
|
|
106
|
+
</li>
|
|
107
|
+
))}
|
|
108
|
+
</ul>
|
|
109
|
+
</section>
|
|
110
|
+
)}
|
|
111
|
+
{!model && !evidence.unavailable && <p className={styles.quiet} role="status">Loading tests…</p>}
|
|
112
|
+
{empty && !kinds.length && <p className={styles.quiet}>No reports yet. Publish tests and specs to this project to populate its report.</p>}
|
|
113
|
+
{empty && kinds.length > 0 && <p className={styles.quiet}>No test results recorded yet.</p>}
|
|
114
|
+
{model && !empty && listing && (search.tab === 'runs'
|
|
115
|
+
? <RunsView />
|
|
116
|
+
: <>
|
|
117
|
+
<InsightStrip model={model} scope={listing.scope} search={search} />
|
|
118
|
+
<Toolbar model={model} search={search} listing={listing} onSearch={(value) => go({ q: value || undefined }, { replace: true })} />
|
|
119
|
+
{listing.runPending && <p className={styles.quiet} role="status">Reading the tests of this run…</p>}
|
|
120
|
+
{listing.runMissing && <p className={styles.notice} role="status">That run is not in the report any more.</p>}
|
|
121
|
+
<TestList tests={listing.shown} search={search} total={model.tests.length} />
|
|
122
|
+
</>)}
|
|
123
|
+
</article>
|
|
124
|
+
{split && (
|
|
125
|
+
<aside className={styles.side} aria-label="Test detail">
|
|
126
|
+
{selected ? <TestDetail test={selected} slot={slots.get(selected.key)} /> : model ? (
|
|
127
|
+
<div className={styles.detailEmpty}>
|
|
128
|
+
<p className={styles.notice}>This test is not in the report any more. A source may be switched off, or its results were removed.</p>
|
|
129
|
+
<Link to="/" search={patched({ test: undefined })} resetScroll={false} activeOptions={{ exact: true }}>Close</Link>
|
|
130
|
+
</div>
|
|
131
|
+
) : <p className={styles.quiet} role="status">Loading the test…</p>}
|
|
132
|
+
</aside>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function InsightStrip({ model, scope, search }: { model: CatalogModel; scope: readonly TestSummary[]; search: ExplorerSearch }) {
|
|
139
|
+
const now = useNow();
|
|
140
|
+
const go = useExplorerNavigate();
|
|
141
|
+
const { counts } = tally(scope);
|
|
142
|
+
const flaky = scope.filter((test) => test.stats.flaky).length;
|
|
143
|
+
const averages = scope.flatMap((test) => (test.stats.durations ? [test.stats.durations.avgMs] : []));
|
|
144
|
+
const durations = durationStats(averages);
|
|
145
|
+
const environments = useMemo(() => groupTests(scope, 'environment', false).sort((a, b) => b.tests.length - a.tests.length), [scope]);
|
|
146
|
+
const statuses = values(search.status);
|
|
147
|
+
const envs = values(search.env);
|
|
148
|
+
const last = scope.reduce((latest, test) => Math.max(latest, test.lastRecordedAtMs), 0);
|
|
149
|
+
const status = (id: string) => go({ status: statuses.length === 1 && statuses[0] === id ? undefined : id });
|
|
150
|
+
return (
|
|
151
|
+
<section className={styles.insights} aria-label="Insights">
|
|
152
|
+
<div className={styles.tile}>
|
|
153
|
+
<span className={styles.tileLabel}>Tests</span>
|
|
154
|
+
<strong className={styles.tileValue}>{scope.length.toLocaleString()}</strong>
|
|
155
|
+
<Meter counts={counts} />
|
|
156
|
+
<span className={styles.tileCounts}>
|
|
157
|
+
<button type="button" aria-pressed={statuses.includes('failed')} data-status="failed" disabled={!counts.failed} onClick={() => status('failed')}>{counts.failed} failed</button>
|
|
158
|
+
<button type="button" aria-pressed={statuses.includes('passed')} onClick={() => status('passed')}>{counts.passed} passed</button>
|
|
159
|
+
{counts['not-run'] > 0 && <button type="button" aria-pressed={statuses.includes('not-run')} onClick={() => status('not-run')}>{counts['not-run']} not run</button>}
|
|
160
|
+
{counts.running > 0 && <button type="button" aria-pressed={statuses.includes('running')} data-status="running" onClick={() => status('running')}>{counts.running} running</button>}
|
|
161
|
+
</span>
|
|
162
|
+
</div>
|
|
163
|
+
<button type="button" className={styles.tile} aria-pressed={statuses.includes('flaky')} aria-label={`${flaky} flaky tests`} onClick={() => status('flaky')}>
|
|
164
|
+
<span className={styles.tileLabel}>Flaky</span>
|
|
165
|
+
<strong className={styles.tileValue} data-warn={flaky > 0 || undefined}>{flaky}</strong>
|
|
166
|
+
<span className={styles.tileNote}>{flaky ? 'Passed and failed on one commit, or needed a retry' : 'None recorded'}</span>
|
|
167
|
+
</button>
|
|
168
|
+
<div className={styles.tile}>
|
|
169
|
+
<span className={styles.tileLabel}>Duration</span>
|
|
170
|
+
{durations ? (
|
|
171
|
+
<>
|
|
172
|
+
<strong className={styles.tileValue}>{formatMs(durations.avgMs)}</strong>
|
|
173
|
+
<span className={styles.tileNote}>average · p95 {formatMs(durations.p95Ms)} · {averages.length.toLocaleString()} measured</span>
|
|
174
|
+
</>
|
|
175
|
+
) : (
|
|
176
|
+
<>
|
|
177
|
+
<strong className={styles.tileValue} data-none>—</strong>
|
|
178
|
+
<span className={styles.tileNote}>No durations recorded. Producers can send <code>durationMs</code> with each check.</span>
|
|
179
|
+
</>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
<div className={styles.tile} data-wide>
|
|
183
|
+
<span className={styles.tileLabel}>Environments</span>
|
|
184
|
+
<ul className={styles.envList}>
|
|
185
|
+
{environments.map((group) => (
|
|
186
|
+
<li key={group.key}>
|
|
187
|
+
<button type="button" aria-pressed={envs.includes(group.key)} onClick={() => go({ env: toggled(search.env, group.key) })} title={`Show only ${group.label}`}>
|
|
188
|
+
<span className={styles.envName}>{group.label}</span>
|
|
189
|
+
<span>{plural(group.tests.length, 'test', 'tests')}</span>
|
|
190
|
+
{group.counts.failed > 0 && <span data-status="failed">{group.counts.failed} failed</span>}
|
|
191
|
+
<span>{group.counts.passed} passed</span>
|
|
192
|
+
{group.counts['not-run'] > 0 && <span>{group.counts['not-run']} not run</span>}
|
|
193
|
+
{group.avgMs !== null && <span>avg {formatMs(group.avgMs)}</span>}
|
|
194
|
+
</button>
|
|
195
|
+
</li>
|
|
196
|
+
))}
|
|
197
|
+
</ul>
|
|
198
|
+
</div>
|
|
199
|
+
<Link className={styles.tile} to="/" search={patched({ tab: 'runs', test: undefined })} resetScroll={false} activeOptions={{ exact: true }} aria-label={`${model.summary.runs} runs`}>
|
|
200
|
+
<span className={styles.tileLabel}>Runs</span>
|
|
201
|
+
<strong className={styles.tileValue}>{model.summary.runs.toLocaleString()}</strong>
|
|
202
|
+
<span className={styles.tileNote}>{last ? `Last recorded ${ago(last, now)}` : 'Nothing recorded yet'}</span>
|
|
203
|
+
</Link>
|
|
204
|
+
<MoreInsights scope={scope} />
|
|
205
|
+
</section>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The slowest, flakiest and failing tests of the scope, each one click from its detail. */
|
|
210
|
+
function MoreInsights({ scope }: { scope: readonly TestSummary[] }) {
|
|
211
|
+
const slowest = useMemo(() => scope.filter((test) => test.stats.durations).sort((a, b) => b.stats.durations!.avgMs - a.stats.durations!.avgMs).slice(0, 5), [scope]);
|
|
212
|
+
const flakiest = useMemo(() => scope.filter((test) => test.stats.flaky).sort((a, b) => b.stats.flips - a.stats.flips).slice(0, 5), [scope]);
|
|
213
|
+
const failing = useMemo(() => scope.filter((test) => test.status === 'failed').sort((a, b) => b.lastRecordedAtMs - a.lastRecordedAtMs).slice(0, 5), [scope]);
|
|
214
|
+
if (!slowest.length && !flakiest.length && !failing.length) return null;
|
|
215
|
+
const list = (title: string, tests: readonly TestSummary[], value: (test: TestSummary) => string) => tests.length > 0 && (
|
|
216
|
+
<div>
|
|
217
|
+
<h3 className={styles.label}>{title}</h3>
|
|
218
|
+
<ol>
|
|
219
|
+
{tests.map((test) => (
|
|
220
|
+
<li key={test.key}>
|
|
221
|
+
<Link to="/" search={patched({ test: test.key })} resetScroll={false} activeOptions={{ exact: true }}>
|
|
222
|
+
<StatusMark status={test.status} size={12} />
|
|
223
|
+
<span className={styles.insightName}>{test.title}</span>
|
|
224
|
+
<span className={styles.insightValue}>{value(test)}</span>
|
|
225
|
+
</Link>
|
|
226
|
+
</li>
|
|
227
|
+
))}
|
|
228
|
+
</ol>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
return (
|
|
232
|
+
<details className={styles.more}>
|
|
233
|
+
<summary>Slowest, flakiest and failing tests</summary>
|
|
234
|
+
<div className={styles.moreLists}>
|
|
235
|
+
{list('Failing', failing, (test) => plural(test.stats.failed, 'failure', 'failures'))}
|
|
236
|
+
{list('Flakiest', flakiest, (test) => (test.stats.flips ? plural(test.stats.flips, 'flip', 'flips') : 'flaky'))}
|
|
237
|
+
{list('Slowest', slowest, (test) => formatMs(test.stats.durations?.avgMs))}
|
|
238
|
+
</div>
|
|
239
|
+
</details>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
type Listing = NonNullable<ReturnType<typeof useListing>>;
|
|
244
|
+
|
|
245
|
+
function Toolbar({ model, search, listing, onSearch }: { model: CatalogModel; search: ExplorerSearch; listing: Listing; onSearch: (value: string) => void }) {
|
|
246
|
+
const go = useExplorerNavigate();
|
|
247
|
+
const [draft, setDraft] = useState(search.q ?? '');
|
|
248
|
+
// The address may change from elsewhere (a link, the index, back): follow it.
|
|
249
|
+
useEffect(() => setDraft((current) => (current.trim() === (search.q ?? '').trim() ? current : search.q ?? '')), [search.q]);
|
|
250
|
+
const statuses = values(search.status);
|
|
251
|
+
const envs = values(search.env);
|
|
252
|
+
const statusCount = (id: string) => (id === '' ? listing.byEnvironment.length : listing.byEnvironment.filter((test) => (id === 'flaky' ? test.stats.flaky : test.status === id)).length);
|
|
253
|
+
const envCounts = useMemo(() => {
|
|
254
|
+
const counts = new Map<string, number>();
|
|
255
|
+
for (const test of listing.byStatus) for (const env of test.environments.length ? test.environments : ['(none)']) counts.set(env, (counts.get(env) ?? 0) + 1);
|
|
256
|
+
return counts;
|
|
257
|
+
}, [listing.byStatus]);
|
|
258
|
+
const specTitle = (path: string) => model.facets.specs.find((spec) => spec.path === path)?.title ?? path;
|
|
259
|
+
const chips = [
|
|
260
|
+
...values(search.spec).map((path) => ({ label: `Spec: ${specTitle(path)}`, clear: { spec: toggled(search.spec, path) } })),
|
|
261
|
+
...values(search.tag).map((tag) => ({ label: `Tag: ${tag}`, clear: { tag: toggled(search.tag, tag) } })),
|
|
262
|
+
...(search.inRun ? [{ label: `Run: ${runName(search.inRun)}`, clear: { inRun: undefined } }] : [])
|
|
263
|
+
];
|
|
264
|
+
return (
|
|
265
|
+
<section className={styles.toolbar} aria-label="Find tests">
|
|
266
|
+
<div className={styles.searchRow}>
|
|
267
|
+
<input
|
|
268
|
+
type="search"
|
|
269
|
+
className={styles.searchBox}
|
|
270
|
+
aria-label="Search tests"
|
|
271
|
+
placeholder="Search titles, specs, domains, tags, environments…"
|
|
272
|
+
value={draft}
|
|
273
|
+
onChange={(event) => { setDraft(event.target.value); onSearch(event.target.value.trim()); }}
|
|
274
|
+
/>
|
|
275
|
+
<label className={styles.select}>
|
|
276
|
+
<span>Group by</span>
|
|
277
|
+
<select aria-label="Group by" value={search.group ?? 'spec'} onChange={(event) => go({ group: event.target.value === 'spec' ? undefined : (event.target.value as TestGroup) })}>
|
|
278
|
+
{TEST_GROUPS.map((group) => <option key={group} value={group}>{GROUP_LABEL[group]}</option>)}
|
|
279
|
+
</select>
|
|
280
|
+
</label>
|
|
281
|
+
<label className={styles.select}>
|
|
282
|
+
<span>Sort</span>
|
|
283
|
+
<select aria-label="Sort" value={search.sort ?? 'spec'} onChange={(event) => go({ sort: event.target.value === 'spec' ? undefined : (event.target.value as TestSort) })}>
|
|
284
|
+
{TEST_SORTS.map((sort) => <option key={sort} value={sort}>{SORT_LABEL[sort]}</option>)}
|
|
285
|
+
</select>
|
|
286
|
+
</label>
|
|
287
|
+
</div>
|
|
288
|
+
<div className={styles.segments} role="group" aria-label="Test status">
|
|
289
|
+
{STATUS_FILTERS.map((filter) => {
|
|
290
|
+
const count = statusCount(filter.id);
|
|
291
|
+
if (filter.id === 'running' && count === 0 && !statuses.includes('running')) return null;
|
|
292
|
+
const pressed = filter.id === '' ? statuses.length === 0 : statuses.length === 1 && statuses[0] === filter.id;
|
|
293
|
+
return (
|
|
294
|
+
<button key={filter.id || 'all'} type="button" aria-pressed={pressed} data-status={(count > 0 && filter.id) || undefined} onClick={() => go({ status: filter.id || undefined })}>
|
|
295
|
+
{filter.label} <span>{count.toLocaleString()}</span>
|
|
296
|
+
</button>
|
|
297
|
+
);
|
|
298
|
+
})}
|
|
299
|
+
</div>
|
|
300
|
+
<div className={styles.filterRow}>
|
|
301
|
+
{model.facets.environments.length > 0 && (
|
|
302
|
+
<div className={styles.chips} role="group" aria-label="Environments">
|
|
303
|
+
{model.facets.environments.map(({ value }) => (
|
|
304
|
+
<button key={value} type="button" aria-pressed={envs.includes(value)} onClick={() => go({ env: toggled(search.env, value) })}>
|
|
305
|
+
{environmentLabel(value)} <span>{(envCounts.get(value) ?? 0).toLocaleString()}</span>
|
|
306
|
+
</button>
|
|
307
|
+
))}
|
|
308
|
+
</div>
|
|
309
|
+
)}
|
|
310
|
+
{model.facets.producers.length > 1 && (
|
|
311
|
+
<label className={styles.select}>
|
|
312
|
+
<span className="sr-only">Producer</span>
|
|
313
|
+
<select aria-label="Producer" value={values(search.producer)[0] ?? ''} onChange={(event) => go({ producer: event.target.value || undefined })}>
|
|
314
|
+
<option value="">All producers</option>
|
|
315
|
+
{model.facets.producers.map(({ value, count }) => <option key={value} value={value}>{value} ({count})</option>)}
|
|
316
|
+
</select>
|
|
317
|
+
</label>
|
|
318
|
+
)}
|
|
319
|
+
{model.facets.domains.length > 1 && (
|
|
320
|
+
<label className={styles.select}>
|
|
321
|
+
<span className="sr-only">Domain</span>
|
|
322
|
+
<select aria-label="Domain" value={values(search.domain)[0] ?? ''} onChange={(event) => go({ domain: event.target.value || undefined, spec: undefined })}>
|
|
323
|
+
<option value="">All domains</option>
|
|
324
|
+
{model.facets.domains.map(({ value, count }) => <option key={value} value={value}>{value === '(no spec)' ? 'No spec' : value} ({count})</option>)}
|
|
325
|
+
</select>
|
|
326
|
+
</label>
|
|
327
|
+
)}
|
|
328
|
+
</div>
|
|
329
|
+
<p className={styles.showing} role="status">
|
|
330
|
+
<span>{listing.shown.length === model.tests.length ? plural(model.tests.length, 'test', 'tests') : `${listing.shown.length.toLocaleString()} of ${plural(model.tests.length, 'test', 'tests')}`}</span>
|
|
331
|
+
{chips.map((chip) => (
|
|
332
|
+
<button key={chip.label} type="button" className={styles.activeChip} onClick={() => go(chip.clear)} aria-label={`Remove ${chip.label}`}>{chip.label} <span aria-hidden="true">×</span></button>
|
|
333
|
+
))}
|
|
334
|
+
{narrowed(search) && <button type="button" className={styles.link} onClick={() => { setDraft(''); go({ q: undefined, status: undefined, env: undefined, producer: undefined, domain: undefined, spec: undefined, tag: undefined, inRun: undefined }); }}>Clear filters</button>}
|
|
335
|
+
</p>
|
|
336
|
+
</section>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function TestList({ tests, search, total }: { tests: readonly TestSummary[]; search: ExplorerSearch; total: number }) {
|
|
341
|
+
const by = search.group ?? 'spec';
|
|
342
|
+
const groups = useMemo(() => groupTests(tests, by, (search.sort ?? 'spec') === 'spec'), [tests, by, search.sort]);
|
|
343
|
+
const [overrides, setOverrides] = useState<ReadonlyMap<string, boolean>>(new Map());
|
|
344
|
+
const defaultOpen = groups.length <= OPEN_GROUPS || tests.length <= OPEN_TESTS;
|
|
345
|
+
const isOpen = (group: TestGroupView) => by === 'none' || (overrides.get(`${by}|${group.key}`) ?? defaultOpen);
|
|
346
|
+
const setAll = (open: boolean) => setOverrides(new Map(groups.map((group) => [`${by}|${group.key}`, open])));
|
|
347
|
+
if (!tests.length) return <p className={styles.quiet} role="status">{total ? 'No test matches these filters.' : 'No tests recorded yet.'}</p>;
|
|
348
|
+
let parent: string | null = null;
|
|
349
|
+
return (
|
|
350
|
+
<section className={styles.list} aria-label="Tests">
|
|
351
|
+
<div className={styles.listHead}>
|
|
352
|
+
<span>{by === 'none' ? '' : `${plural(groups.length, GROUP_LABEL[by].toLowerCase(), by === 'status' ? 'statuses' : `${GROUP_LABEL[by].toLowerCase()}s`)}`}</span>
|
|
353
|
+
{by !== 'none' && (
|
|
354
|
+
<span className={styles.listTools}>
|
|
355
|
+
<button type="button" className={styles.link} onClick={() => setAll(true)}>Expand all</button>
|
|
356
|
+
<button type="button" className={styles.link} onClick={() => setAll(false)}>Collapse all</button>
|
|
357
|
+
</span>
|
|
358
|
+
)}
|
|
359
|
+
<span className={styles.columns} aria-hidden="true"><span>Avg</span><span>Pass</span><span>Runs</span><span>Last</span></span>
|
|
360
|
+
</div>
|
|
361
|
+
{groups.map((group) => {
|
|
362
|
+
const heading = by === 'spec' && group.parent !== parent ? group.parent : null;
|
|
363
|
+
parent = group.parent;
|
|
364
|
+
return (
|
|
365
|
+
<div key={group.key} className={styles.groupWrap}>
|
|
366
|
+
{heading && <h3 className={styles.domainHeading}>{heading === '(no spec)' ? 'No spec' : heading}</h3>}
|
|
367
|
+
<TestGroupSection group={group} by={by} open={isOpen(group)} selected={search.test} onToggle={() => setOverrides(new Map(overrides).set(`${by}|${group.key}`, !isOpen(group)))} />
|
|
368
|
+
</div>
|
|
369
|
+
);
|
|
370
|
+
})}
|
|
371
|
+
</section>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function TestGroupSection({ group, by, open, selected, onToggle }: { group: TestGroupView; by: TestGroup; open: boolean; selected: string | undefined; onToggle: () => void }) {
|
|
376
|
+
const [shown, setShown] = useState(by === 'none' ? 100 : GROUP_ROWS);
|
|
377
|
+
const now = useNow();
|
|
378
|
+
return (
|
|
379
|
+
<section className={styles.group} data-open={open || undefined} aria-label={group.label}>
|
|
380
|
+
{by !== 'none' && (
|
|
381
|
+
<button type="button" className={styles.groupHead} aria-expanded={open} onClick={onToggle}>
|
|
382
|
+
<span className={styles.chevron} aria-hidden="true" />
|
|
383
|
+
<span className={styles.groupName}>
|
|
384
|
+
<span>{group.label}</span>
|
|
385
|
+
{group.detail && <code>{group.detail}</code>}
|
|
386
|
+
</span>
|
|
387
|
+
<span className={styles.groupMeter}><Meter counts={group.counts} /></span>
|
|
388
|
+
<span className={styles.groupTally}>
|
|
389
|
+
{group.counts.failed > 0 && <strong>{group.counts.failed} failed</strong>}
|
|
390
|
+
{group.counts['not-run'] > 0 && <span>{group.counts['not-run']} not run</span>}
|
|
391
|
+
<span>{plural(group.tests.length, 'test', 'tests')}</span>
|
|
392
|
+
{group.avgMs !== null && <span>avg {formatMs(group.avgMs)}</span>}
|
|
393
|
+
</span>
|
|
394
|
+
</button>
|
|
395
|
+
)}
|
|
396
|
+
{open && (
|
|
397
|
+
<ul className={styles.rows}>
|
|
398
|
+
{group.tests.slice(0, shown).map((test) => <TestRow key={test.key} test={test} selected={selected === test.key} now={now} />)}
|
|
399
|
+
{group.tests.length > shown && (
|
|
400
|
+
<li className={styles.moreRow}>
|
|
401
|
+
<button type="button" className={styles.link} onClick={() => setShown(shown + (by === 'none' ? 100 : GROUP_ROWS))}>Show more ({group.tests.length - shown} more)</button>
|
|
402
|
+
</li>
|
|
403
|
+
)}
|
|
404
|
+
</ul>
|
|
405
|
+
)}
|
|
406
|
+
</section>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function TestRow({ test, selected, now }: { test: TestSummary; selected: boolean; now: number }) {
|
|
411
|
+
const failure = test.status === 'failed' ? test.outcomes.find((outcome) => outcome.status === 'failed')?.detail.split('\n')[0] : '';
|
|
412
|
+
return (
|
|
413
|
+
<li className={styles.row} data-status={test.status}>
|
|
414
|
+
<Link to="/" search={patched({ test: selected ? undefined : test.key })} resetScroll={false} activeOptions={{ exact: true }} aria-current={selected ? 'true' : undefined} className={styles.rowLink}>
|
|
415
|
+
<StatusMark status={test.status} size={14} />
|
|
416
|
+
<span className={styles.rowMain}>
|
|
417
|
+
<span className={styles.rowTitle}>{test.title}</span>
|
|
418
|
+
<span className={styles.rowMeta}>
|
|
419
|
+
<EnvironmentChips test={test} />
|
|
420
|
+
<Badges test={test} />
|
|
421
|
+
</span>
|
|
422
|
+
{failure && <span className={styles.rowError}>{failure}</span>}
|
|
423
|
+
</span>
|
|
424
|
+
<span className={styles.columns}>
|
|
425
|
+
<span title="Average duration">{formatMs(test.stats.durations?.avgMs)}</span>
|
|
426
|
+
<span title="Pass rate" data-low={test.stats.passRate !== null && test.stats.passRate < 1 || undefined}>{percent(test.stats.passRate)}</span>
|
|
427
|
+
<span title="Runs">{test.stats.runs || '—'}</span>
|
|
428
|
+
<span title="Last recorded">{test.live ? 'now' : test.lastRecordedAtMs ? ago(test.lastRecordedAtMs, now).replace(' ago', '') : '—'}</span>
|
|
429
|
+
</span>
|
|
430
|
+
</Link>
|
|
431
|
+
</li>
|
|
432
|
+
);
|
|
433
|
+
}
|