@crvouga/atlas 0.10.2 → 0.12.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 +12 -0
- package/README.md +7 -0
- package/dist/reports/src/catalog-health.d.ts +142 -0
- package/dist/reports/src/catalog-health.js +257 -0
- package/dist/reports/src/test-catalog.d.ts +8 -0
- package/dist/reports/src/test-catalog.js +5 -1
- package/dist/schema/src/api.d.ts +79 -0
- package/dist/schema/src/api.js +50 -0
- package/dist/server/src/app.js +9 -0
- package/dist/server/src/llms.js +1 -0
- package/dist/server/src/openapi.js +18 -2
- package/dist/server/src/settings-page.d.ts +4 -4
- package/dist/server/src/settings-page.js +17 -78
- package/docs/reference/http-api.md +34 -0
- package/docs/reference/reports.md +224 -0
- package/docs/reference/schema.md +10 -0
- package/docs/reference/server.md +4 -4
- package/docs/server.md +10 -0
- package/docs/ui.md +142 -0
- package/docs/workspaces.md +130 -0
- package/llms-full.txt +322 -0
- package/llms.txt +2 -0
- package/package.json +17 -10
- package/packages/schema/schemas/catalog-health.schema.json +400 -0
- package/packages/server/openapi.json +596 -0
- package/packages/visualizer/src/admin/SettingsPage.tsx +456 -0
- package/packages/visualizer/src/admin/main.tsx +11 -0
- package/packages/visualizer/src/app/AppShell.tsx +62 -38
- package/packages/visualizer/src/app/gazetteer.tsx +90 -83
- package/packages/visualizer/src/app/router.tsx +33 -15
- package/packages/visualizer/src/components/ActivityIndicator.tsx +4 -5
- package/packages/visualizer/src/components/DataIssues.tsx +36 -54
- package/packages/visualizer/src/components/Header.tsx +197 -125
- package/packages/visualizer/src/components/Legend.tsx +13 -41
- package/packages/visualizer/src/components/ProjectPicker.tsx +61 -26
- package/packages/visualizer/src/components/Screen.tsx +80 -71
- package/packages/visualizer/src/components/SpecLogo.tsx +15 -0
- package/packages/visualizer/src/components/Status.tsx +20 -34
- package/packages/visualizer/src/components/labels.tsx +8 -3
- package/packages/visualizer/src/components/spec-logos/APACHE-2.0.txt +206 -0
- package/packages/visualizer/src/components/spec-logos/CAMUNDA-MIT.txt +21 -0
- package/packages/visualizer/src/components/spec-logos/FILE-ICONS-ISC.txt +13 -0
- package/packages/visualizer/src/components/spec-logos/README.md +23 -0
- package/packages/visualizer/src/components/spec-logos/SIMPLE-ICONS-CC0.txt +30 -0
- package/packages/visualizer/src/components/spec-logos/alloy.svg +1 -0
- package/packages/visualizer/src/components/spec-logos/dmn.svg +3 -0
- package/packages/visualizer/src/components/spec-logos/gherkin.svg +1 -0
- package/packages/visualizer/src/components/spec-logos/openslo.svg +63 -0
- package/packages/visualizer/src/components/spec-logos/quint.svg +1 -0
- package/packages/visualizer/src/evidence/artifacts.tsx +263 -110
- package/packages/visualizer/src/evidence/chart-source.tsx +18 -0
- package/packages/visualizer/src/evidence/check-detail.tsx +283 -139
- package/packages/visualizer/src/evidence/decision-table.tsx +35 -26
- package/packages/visualizer/src/evidence/document-page.tsx +51 -29
- package/packages/visualizer/src/evidence/feature-page.tsx +121 -53
- package/packages/visualizer/src/evidence/marks.tsx +112 -79
- package/packages/visualizer/src/evidence/model-page.tsx +77 -46
- package/packages/visualizer/src/evidence/model-trace.tsx +55 -35
- package/packages/visualizer/src/evidence/overview.tsx +137 -69
- package/packages/visualizer/src/evidence/recording-page.tsx +70 -31
- package/packages/visualizer/src/evidence/recording.tsx +135 -68
- package/packages/visualizer/src/evidence/snapshot.tsx +99 -43
- package/packages/visualizer/src/evidence/source-text.tsx +33 -0
- package/packages/visualizer/src/evidence/sources-panel.tsx +138 -144
- package/packages/visualizer/src/evidence/spec-header.tsx +52 -36
- package/packages/visualizer/src/evidence/spec-index.tsx +61 -32
- package/packages/visualizer/src/evidence/spec-page.tsx +56 -25
- package/packages/visualizer/src/evidence/test-filter.tsx +38 -14
- package/packages/visualizer/src/explorer/Explorer.tsx +688 -261
- package/packages/visualizer/src/explorer/ExplorerIndex.tsx +136 -68
- package/packages/visualizer/src/explorer/RunsView.tsx +307 -94
- package/packages/visualizer/src/explorer/TestDetail.tsx +393 -118
- package/packages/visualizer/src/explorer/parts.tsx +35 -13
- package/packages/visualizer/src/find/Finder.tsx +106 -90
- package/packages/visualizer/src/find/open.ts +17 -20
- package/packages/visualizer/src/live/test-progress.tsx +60 -22
- package/packages/visualizer/src/looks/Stage.tsx +223 -110
- package/packages/visualizer/src/main.tsx +9 -11
- package/packages/visualizer/src/map/ChartMap.tsx +125 -69
- package/packages/visualizer/src/map/EventEdge.tsx +59 -37
- package/packages/visualizer/src/map/map.module.css +12 -546
- package/packages/visualizer/src/map/nodes.tsx +124 -70
- package/packages/visualizer/src/reachable.test.ts +3 -2
- package/packages/visualizer/src/spec-kind.ts +2 -2
- package/packages/visualizer/src/ui/grammars/ALLOY-MIT.txt +21 -0
- package/packages/visualizer/src/ui/grammars/QUINT-APACHE-2.0.txt +206 -0
- package/packages/visualizer/src/ui/grammars/README.md +14 -0
- package/packages/visualizer/src/ui/grammars/alloy.json +71 -0
- package/packages/visualizer/src/ui/grammars/quint.json +152 -0
- package/packages/visualizer/src/ui/highlight.ts +50 -0
- package/packages/visualizer/src/ui/theme.tsx +109 -0
- package/packages/visualizer/src/views/ChartPage.tsx +46 -26
- package/packages/visualizer/src/views/ChartTests.tsx +59 -22
- package/packages/visualizer/src/views/ProductPage.tsx +16 -15
- package/packages/visualizer/src/views/SidePanel.tsx +241 -131
- package/packages/visualizer/src/app/gazetteer.module.css +0 -107
- package/packages/visualizer/src/components/ActivityIndicator.module.css +0 -4
- package/packages/visualizer/src/components/DataIssues.module.css +0 -136
- package/packages/visualizer/src/components/Header.module.css +0 -114
- package/packages/visualizer/src/components/Legend.module.css +0 -84
- package/packages/visualizer/src/components/Screen.module.css +0 -312
- package/packages/visualizer/src/components/Status.module.css +0 -116
- package/packages/visualizer/src/components/icons.tsx +0 -156
- package/packages/visualizer/src/evidence/artifacts.module.css +0 -213
- package/packages/visualizer/src/evidence/evidence.module.css +0 -907
- package/packages/visualizer/src/evidence/recording.module.css +0 -460
- package/packages/visualizer/src/evidence/sources.module.css +0 -168
- package/packages/visualizer/src/evidence/spec-snapshot.tsx +0 -26
- package/packages/visualizer/src/explorer/explorer.module.css +0 -1238
- package/packages/visualizer/src/find/Finder.module.css +0 -168
- package/packages/visualizer/src/looks/stage.module.css +0 -300
- package/packages/visualizer/src/styles/global.css +0 -105
- package/packages/visualizer/src/styles/tokens.css +0 -80
- package/packages/visualizer/src/views/views.module.css +0 -546
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
import { SpecLogo } from '../components/SpecLogo';
|
|
2
|
+
import { IconX } from '@tabler/icons-react';
|
|
3
|
+
import {
|
|
4
|
+
Accordion,
|
|
5
|
+
Alert,
|
|
6
|
+
Anchor,
|
|
7
|
+
Badge,
|
|
8
|
+
Box,
|
|
9
|
+
Button,
|
|
10
|
+
Container,
|
|
11
|
+
Drawer,
|
|
12
|
+
Flex,
|
|
13
|
+
Group,
|
|
14
|
+
List,
|
|
15
|
+
NativeSelect,
|
|
16
|
+
Paper,
|
|
17
|
+
SimpleGrid,
|
|
18
|
+
Stack,
|
|
19
|
+
Tabs,
|
|
20
|
+
Text,
|
|
21
|
+
TextInput,
|
|
22
|
+
Title,
|
|
23
|
+
VisuallyHidden
|
|
24
|
+
} from '@mantine/core';
|
|
1
25
|
/**
|
|
2
26
|
* @module Test explorer
|
|
3
27
|
*
|
|
@@ -6,33 +30,54 @@
|
|
|
6
30
|
* 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
31
|
* numbers; beside it, the open test; in a second tab, the CI runs that produced the results.
|
|
8
32
|
*/
|
|
9
|
-
import { durationStats, formatMs, matchesQuery, testCatalog, TEST_SORTS, type CatalogModel, type TestQuery, type TestSort, type TestSummary } from '@crvouga/atlas/reports/test-catalog';
|
|
10
33
|
import type { CheckSlot } from '@crvouga/atlas/reports/evidence-view';
|
|
11
34
|
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
35
|
+
import {
|
|
36
|
+
durationStats,
|
|
37
|
+
formatMs,
|
|
38
|
+
matchesQuery,
|
|
39
|
+
TEST_SORTS,
|
|
40
|
+
testCatalog,
|
|
41
|
+
type CatalogModel,
|
|
42
|
+
type TestQuery,
|
|
43
|
+
type TestSort,
|
|
44
|
+
type TestSummary
|
|
45
|
+
} from '@crvouga/atlas/reports/test-catalog';
|
|
12
46
|
import { Link } from '@tanstack/react-router';
|
|
13
47
|
import { useDeferredValue, useEffect, useMemo, useState } from 'react';
|
|
14
|
-
|
|
15
48
|
import { useEvidence } from '../evidence/evidence-context';
|
|
16
49
|
import { ago, plural, useNow } from '../evidence/format';
|
|
17
50
|
import { Meter, StatusMark } from '../evidence/marks';
|
|
18
51
|
import { SPEC_KINDS } from '../spec-kind';
|
|
19
52
|
import { useRunDetail, useTestCatalog } from './catalog';
|
|
20
|
-
import styles from './explorer.module.css';
|
|
21
53
|
import { groupTests, tally, type TestGroupView } from './groups';
|
|
22
|
-
import { Badges, EnvironmentChips, projectTitle, runName } from './parts';
|
|
23
54
|
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
55
|
+
import { Badges, EnvironmentChips, projectTitle, runName } from './parts';
|
|
24
56
|
import { RunsView } from './RunsView';
|
|
25
|
-
import { narrowed, queryOf, toggled, values,
|
|
57
|
+
import { narrowed, queryOf, TEST_GROUPS, toggled, values, type ExplorerSearch, type TestGroup } from './search';
|
|
26
58
|
import { TestDetail } from './TestDetail';
|
|
27
|
-
|
|
28
59
|
/** Groups start closed when there are more of them than this, unless the list is short. */
|
|
29
60
|
const OPEN_GROUPS = 40;
|
|
30
61
|
const OPEN_TESTS = 300;
|
|
31
62
|
/** Rows a group shows before "Show more". */
|
|
32
63
|
const GROUP_ROWS = 50;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
64
|
+
const GROUP_LABEL: Record<TestGroup, string> = {
|
|
65
|
+
spec: 'Spec',
|
|
66
|
+
domain: 'Domain',
|
|
67
|
+
environment: 'Environment',
|
|
68
|
+
status: 'Status',
|
|
69
|
+
producer: 'Producer',
|
|
70
|
+
none: 'None'
|
|
71
|
+
};
|
|
72
|
+
const SORT_LABEL: Record<TestSort, string> = {
|
|
73
|
+
spec: 'Spec',
|
|
74
|
+
title: 'Title',
|
|
75
|
+
recent: 'Most recent',
|
|
76
|
+
slowest: 'Slowest',
|
|
77
|
+
failures: 'Most failures',
|
|
78
|
+
flaky: 'Flakiest',
|
|
79
|
+
runs: 'Most runs'
|
|
80
|
+
};
|
|
36
81
|
const STATUS_FILTERS = [
|
|
37
82
|
{ id: '', label: 'All' },
|
|
38
83
|
{ id: 'failed', label: 'Failed' },
|
|
@@ -41,27 +86,34 @@ const STATUS_FILTERS = [
|
|
|
41
86
|
{ id: 'not-run', label: 'Not run' },
|
|
42
87
|
{ id: 'running', label: 'Running' }
|
|
43
88
|
] as const;
|
|
44
|
-
|
|
45
89
|
function percent(rate: number | null): string {
|
|
46
90
|
return rate === null ? '—' : `${Math.round(rate * 100)}%`;
|
|
47
91
|
}
|
|
48
|
-
|
|
49
92
|
/** What the explorer lists for an address: the tests shown, and the scope the insight strip and filter counts describe. */
|
|
50
93
|
function useListing(model: CatalogModel | null, search: ExplorerSearch, q: string | undefined) {
|
|
51
94
|
const run = useRunDetail(search.inRun);
|
|
52
|
-
const inRun = useMemo(
|
|
95
|
+
const inRun = useMemo(
|
|
96
|
+
() => (search.inRun ? new Set(run.data?.results.map((result) => result.key) ?? []) : null),
|
|
97
|
+
[search.inRun, run.data]
|
|
98
|
+
);
|
|
53
99
|
return useMemo(() => {
|
|
54
100
|
if (!model) return null;
|
|
55
101
|
const query: TestQuery = { ...queryOf(search), ...(q ? { q } : { q: undefined }) };
|
|
56
|
-
const shown = testCatalog(model, { ...query, limit:
|
|
102
|
+
const shown = testCatalog(model, { ...query, limit: 20000 }).tests.filter((test) => !inRun || inRun.has(test.key));
|
|
57
103
|
// The strip and the status and environment counts describe what the other filters leave.
|
|
58
104
|
const scope = model.tests.filter((test) => matchesQuery(model, test, { ...query, status: [], environment: [] }, inRun));
|
|
59
105
|
const byEnvironment = scope.filter((test) => matchesQuery(model, test, { environment: query.environment }));
|
|
60
106
|
const byStatus = scope.filter((test) => matchesQuery(model, test, { status: query.status }));
|
|
61
|
-
return {
|
|
107
|
+
return {
|
|
108
|
+
shown,
|
|
109
|
+
scope,
|
|
110
|
+
byEnvironment,
|
|
111
|
+
byStatus,
|
|
112
|
+
runPending: !!search.inRun && run.isPending,
|
|
113
|
+
runMissing: !!search.inRun && run.isSuccess && run.data === null
|
|
114
|
+
};
|
|
62
115
|
}, [model, search, q, inRun, run.isPending, run.isSuccess, run.data]);
|
|
63
116
|
}
|
|
64
|
-
|
|
65
117
|
/** The project home: the published spec kinds, the insight strip, the toolbar, the grouped list and the open test. */
|
|
66
118
|
export function Explorer() {
|
|
67
119
|
const evidence = useEvidence();
|
|
@@ -78,63 +130,150 @@ export function Explorer() {
|
|
|
78
130
|
return map;
|
|
79
131
|
}, [evidence.view]);
|
|
80
132
|
const model = catalog.model;
|
|
81
|
-
const selected = search.test ? model?.byKey.get(search.test) ?? null : null;
|
|
133
|
+
const selected = search.test ? (model?.byKey.get(search.test) ?? null) : null;
|
|
82
134
|
const empty = model !== null && model.tests.length === 0;
|
|
83
135
|
const split = !!search.test && search.tab !== 'runs';
|
|
84
|
-
|
|
85
136
|
return (
|
|
86
|
-
<
|
|
87
|
-
<
|
|
88
|
-
<header
|
|
89
|
-
<
|
|
137
|
+
<Container size="xl" py="xl">
|
|
138
|
+
<Box aria-label="Test explorer" component="article" miw={0}>
|
|
139
|
+
<Box component="header" mb="lg">
|
|
140
|
+
<Title order={1} mb="sm">
|
|
141
|
+
{projectTitle()}
|
|
142
|
+
</Title>
|
|
90
143
|
{model && model.tests.length > 0 && (
|
|
91
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
144
|
+
<Tabs
|
|
145
|
+
value={search.tab ?? 'tests'}
|
|
146
|
+
onChange={(value) => go({ tab: value === 'runs' ? 'runs' : undefined, test: undefined, openRun: undefined })}
|
|
147
|
+
>
|
|
148
|
+
<Tabs.List aria-label="Explorer view">
|
|
149
|
+
<Tabs.Tab
|
|
150
|
+
value="tests"
|
|
151
|
+
rightSection={
|
|
152
|
+
<Text span c="dimmed">
|
|
153
|
+
{model.tests.length}
|
|
154
|
+
</Text>
|
|
155
|
+
}
|
|
156
|
+
>
|
|
157
|
+
Tests
|
|
158
|
+
</Tabs.Tab>
|
|
159
|
+
<Tabs.Tab
|
|
160
|
+
value="runs"
|
|
161
|
+
rightSection={
|
|
162
|
+
<Text span c="dimmed">
|
|
163
|
+
{model.summary.runs}
|
|
164
|
+
</Text>
|
|
165
|
+
}
|
|
166
|
+
>
|
|
167
|
+
Runs
|
|
168
|
+
</Tabs.Tab>
|
|
169
|
+
</Tabs.List>
|
|
170
|
+
</Tabs>
|
|
95
171
|
)}
|
|
96
|
-
</
|
|
97
|
-
{evidence.unavailable &&
|
|
98
|
-
|
|
172
|
+
</Box>
|
|
173
|
+
{evidence.unavailable && (
|
|
174
|
+
<Alert role="alert" color={'red'} my="sm">
|
|
175
|
+
{evidence.unavailable}
|
|
176
|
+
</Alert>
|
|
177
|
+
)}
|
|
178
|
+
{catalog.notice && (
|
|
179
|
+
<Alert role="status" color={'blue'} my="sm">
|
|
180
|
+
The server’s test history could not be read ({catalog.notice}). Showing what this page holds.
|
|
181
|
+
</Alert>
|
|
182
|
+
)}
|
|
99
183
|
{kinds.length > 0 && (
|
|
100
|
-
<
|
|
101
|
-
<
|
|
102
|
-
|
|
184
|
+
<Box aria-label="Published specs" component="section" mb="lg">
|
|
185
|
+
<Title order={2} mb="sm" c="dimmed">
|
|
186
|
+
Specs
|
|
187
|
+
</Title>
|
|
188
|
+
<List spacing="xs" listStyleType="none">
|
|
103
189
|
{kinds.map(({ kind, files }) => (
|
|
104
|
-
<
|
|
105
|
-
<Link to="/specs/$kind" params={{ kind: kind.id }} search={{}}
|
|
106
|
-
|
|
190
|
+
<List.Item key={kind.id}>
|
|
191
|
+
<Anchor renderRoot={(props) => <Link {...props} to="/specs/$kind" params={{ kind: kind.id }} search={{}} />}>
|
|
192
|
+
<Group component="span" display="inline-flex" gap="xs">
|
|
193
|
+
<SpecLogo kind={kind} />
|
|
194
|
+
<Text span inherit fw={600}>
|
|
195
|
+
{kind.label}
|
|
196
|
+
</Text>
|
|
197
|
+
<Text span inherit>
|
|
198
|
+
{files.length} {files.length === 1 ? kind.one : kind.many}
|
|
199
|
+
</Text>
|
|
200
|
+
</Group>
|
|
201
|
+
</Anchor>
|
|
202
|
+
</List.Item>
|
|
107
203
|
))}
|
|
108
|
-
</
|
|
109
|
-
</
|
|
204
|
+
</List>
|
|
205
|
+
</Box>
|
|
206
|
+
)}
|
|
207
|
+
{!model && !evidence.unavailable && (
|
|
208
|
+
<Text role="status" component="div" c="dimmed">
|
|
209
|
+
Loading tests…
|
|
210
|
+
</Text>
|
|
211
|
+
)}
|
|
212
|
+
{empty && !kinds.length && (
|
|
213
|
+
<Text component="div" c="dimmed">
|
|
214
|
+
No reports yet. Publish tests and specs to this project to populate its report.
|
|
215
|
+
</Text>
|
|
216
|
+
)}
|
|
217
|
+
{empty && kinds.length > 0 && (
|
|
218
|
+
<Text component="div" c="dimmed">
|
|
219
|
+
No test results recorded yet.
|
|
220
|
+
</Text>
|
|
110
221
|
)}
|
|
111
|
-
{
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
:
|
|
222
|
+
{model &&
|
|
223
|
+
!empty &&
|
|
224
|
+
listing &&
|
|
225
|
+
(search.tab === 'runs' ? (
|
|
226
|
+
<RunsView />
|
|
227
|
+
) : (
|
|
228
|
+
<>
|
|
117
229
|
<InsightStrip model={model} scope={listing.scope} search={search} />
|
|
118
|
-
<Toolbar
|
|
119
|
-
|
|
120
|
-
|
|
230
|
+
<Toolbar
|
|
231
|
+
model={model}
|
|
232
|
+
search={search}
|
|
233
|
+
listing={listing}
|
|
234
|
+
onSearch={(value) => go({ q: value || undefined }, { replace: true })}
|
|
235
|
+
/>
|
|
236
|
+
{listing.runPending && (
|
|
237
|
+
<Text role="status" component="div" c="dimmed">
|
|
238
|
+
Reading the tests of this run…
|
|
239
|
+
</Text>
|
|
240
|
+
)}
|
|
241
|
+
{listing.runMissing && (
|
|
242
|
+
<Alert role="status" color={'blue'} my="sm">
|
|
243
|
+
That run is not in the report any more.
|
|
244
|
+
</Alert>
|
|
245
|
+
)}
|
|
121
246
|
<TestList tests={listing.shown} search={search} total={model.tests.length} />
|
|
122
|
-
</>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
247
|
+
</>
|
|
248
|
+
))}
|
|
249
|
+
</Box>
|
|
250
|
+
<Drawer opened={split} onClose={() => go({ test: undefined })} title="Test detail" size="xl">
|
|
251
|
+
<Box aria-label="Test detail" component="aside">
|
|
252
|
+
{selected ? (
|
|
253
|
+
<TestDetail test={selected} slot={slots.get(selected.key)} />
|
|
254
|
+
) : model ? (
|
|
255
|
+
<Stack gap="sm">
|
|
256
|
+
<Alert color={'blue'} my="sm">
|
|
257
|
+
This test is not in the report any more. A source may be switched off, or its results were removed.
|
|
258
|
+
</Alert>
|
|
259
|
+
<Anchor
|
|
260
|
+
renderRoot={(props) => (
|
|
261
|
+
<Link {...props} to="/" search={patched({ test: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
262
|
+
)}
|
|
263
|
+
>
|
|
264
|
+
Close
|
|
265
|
+
</Anchor>
|
|
266
|
+
</Stack>
|
|
267
|
+
) : (
|
|
268
|
+
<Text role="status" component="div" c="dimmed">
|
|
269
|
+
Loading the test…
|
|
270
|
+
</Text>
|
|
271
|
+
)}
|
|
272
|
+
</Box>
|
|
273
|
+
</Drawer>
|
|
274
|
+
</Container>
|
|
135
275
|
);
|
|
136
276
|
}
|
|
137
|
-
|
|
138
277
|
function InsightStrip({ model, scope, search }: { model: CatalogModel; scope: readonly TestSummary[]; search: ExplorerSearch }) {
|
|
139
278
|
const now = useNow();
|
|
140
279
|
const go = useExplorerNavigate();
|
|
@@ -148,111 +287,219 @@ function InsightStrip({ model, scope, search }: { model: CatalogModel; scope: re
|
|
|
148
287
|
const last = scope.reduce((latest, test) => Math.max(latest, test.lastRecordedAtMs), 0);
|
|
149
288
|
const status = (id: string) => go({ status: statuses.length === 1 && statuses[0] === id ? undefined : id });
|
|
150
289
|
return (
|
|
151
|
-
<
|
|
152
|
-
<
|
|
153
|
-
<
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
</
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
290
|
+
<Box component="section" aria-label="Insights" mb="lg">
|
|
291
|
+
<SimpleGrid cols={{ base: 1, sm: 2, xl: 4 }} spacing="md">
|
|
292
|
+
<Paper withBorder>
|
|
293
|
+
<Stack gap="xs">
|
|
294
|
+
<Text c="dimmed">Tests</Text>
|
|
295
|
+
<Title order={2}>{scope.length.toLocaleString()}</Title>
|
|
296
|
+
<Meter counts={counts} />
|
|
297
|
+
<Group gap="xs">
|
|
298
|
+
<Button
|
|
299
|
+
color="red"
|
|
300
|
+
variant={statuses.includes('failed') ? 'light' : 'subtle'}
|
|
301
|
+
aria-pressed={statuses.includes('failed')}
|
|
302
|
+
disabled={!counts.failed}
|
|
303
|
+
onClick={() => status('failed')}
|
|
304
|
+
>
|
|
305
|
+
{counts.failed} failed
|
|
306
|
+
</Button>
|
|
307
|
+
<Button
|
|
308
|
+
color="green"
|
|
309
|
+
variant={statuses.includes('passed') ? 'light' : 'subtle'}
|
|
310
|
+
aria-pressed={statuses.includes('passed')}
|
|
311
|
+
onClick={() => status('passed')}
|
|
312
|
+
>
|
|
313
|
+
{counts.passed} passed
|
|
314
|
+
</Button>
|
|
315
|
+
{counts['not-run'] > 0 && (
|
|
316
|
+
<Button aria-pressed={statuses.includes('not-run')} onClick={() => status('not-run')}>
|
|
317
|
+
{counts['not-run']} not run
|
|
318
|
+
</Button>
|
|
319
|
+
)}
|
|
320
|
+
{counts.running > 0 && (
|
|
321
|
+
<Button aria-pressed={statuses.includes('running')} onClick={() => status('running')}>
|
|
322
|
+
{counts.running} running
|
|
323
|
+
</Button>
|
|
324
|
+
)}
|
|
325
|
+
</Group>
|
|
326
|
+
</Stack>
|
|
327
|
+
</Paper>
|
|
328
|
+
<Paper withBorder>
|
|
329
|
+
<Stack gap="xs">
|
|
330
|
+
<Text c="dimmed">Flaky</Text>
|
|
331
|
+
<Title order={2}>{flaky}</Title>
|
|
332
|
+
<Text size="xs" c="dimmed">
|
|
333
|
+
{flaky ? 'Passed and failed on one commit, or needed a retry' : 'None recorded'}
|
|
334
|
+
</Text>
|
|
335
|
+
<Button
|
|
336
|
+
color="orange"
|
|
337
|
+
variant={statuses.includes('flaky') ? 'light' : 'subtle'}
|
|
338
|
+
aria-pressed={statuses.includes('flaky')}
|
|
339
|
+
aria-label={`${flaky} flaky tests`}
|
|
340
|
+
onClick={() => status('flaky')}
|
|
341
|
+
>
|
|
342
|
+
View flaky tests
|
|
343
|
+
</Button>
|
|
344
|
+
</Stack>
|
|
345
|
+
</Paper>
|
|
346
|
+
<Paper withBorder>
|
|
347
|
+
<Stack gap="xs">
|
|
348
|
+
<Text c="dimmed">Duration</Text>
|
|
349
|
+
<Title order={2}>{durations ? formatMs(durations.avgMs) : '—'}</Title>
|
|
350
|
+
<Text size="xs" c="dimmed">
|
|
351
|
+
{durations
|
|
352
|
+
? `average · p95 ${formatMs(durations.p95Ms)} · ${averages.length.toLocaleString()} measured`
|
|
353
|
+
: 'No durations recorded. Producers can send durationMs with each check.'}
|
|
354
|
+
</Text>
|
|
355
|
+
<Anchor
|
|
356
|
+
aria-label={`${model.summary.runs} runs`}
|
|
357
|
+
renderRoot={(props) => (
|
|
358
|
+
<Link
|
|
359
|
+
{...props}
|
|
360
|
+
to="/"
|
|
361
|
+
search={patched({ tab: 'runs', test: undefined })}
|
|
362
|
+
resetScroll={false}
|
|
363
|
+
activeOptions={{ exact: true }}
|
|
364
|
+
/>
|
|
365
|
+
)}
|
|
366
|
+
>
|
|
367
|
+
{model.summary.runs.toLocaleString()} runs
|
|
368
|
+
</Anchor>
|
|
369
|
+
<Text size="xs" c="dimmed">
|
|
370
|
+
{last ? `Last recorded ${ago(last, now)}` : 'Nothing recorded yet'}
|
|
371
|
+
</Text>
|
|
372
|
+
</Stack>
|
|
373
|
+
</Paper>
|
|
374
|
+
<Paper withBorder>
|
|
375
|
+
<Stack gap="xs">
|
|
376
|
+
<Text c="dimmed">Environments</Text>
|
|
377
|
+
{environments.map((group) => (
|
|
378
|
+
<Button
|
|
379
|
+
key={group.key}
|
|
380
|
+
variant={envs.includes(group.key) ? 'light' : 'subtle'}
|
|
381
|
+
fullWidth
|
|
382
|
+
h="auto"
|
|
383
|
+
py="xs"
|
|
384
|
+
justify="flex-start"
|
|
385
|
+
aria-pressed={envs.includes(group.key)}
|
|
386
|
+
onClick={() => go({ env: toggled(search.env, group.key) })}
|
|
387
|
+
title={`Show only ${group.label}`}
|
|
388
|
+
>
|
|
389
|
+
<Stack align="flex-start" gap="xs">
|
|
390
|
+
<Text fw={500}>{group.label}</Text>
|
|
391
|
+
<Text size="xs" c="dimmed">
|
|
392
|
+
{plural(group.tests.length, 'test', 'tests')} · {group.counts.failed} failed · {group.counts.passed} passed
|
|
393
|
+
{group.counts['not-run'] ? ` · ${group.counts['not-run']} not run` : ''}
|
|
394
|
+
{group.avgMs !== null ? ` · avg ${formatMs(group.avgMs)}` : ''}
|
|
395
|
+
</Text>
|
|
396
|
+
</Stack>
|
|
397
|
+
</Button>
|
|
398
|
+
))}
|
|
399
|
+
</Stack>
|
|
400
|
+
</Paper>
|
|
401
|
+
</SimpleGrid>
|
|
402
|
+
<Box mt="md">
|
|
403
|
+
<MoreInsights scope={scope} />
|
|
404
|
+
</Box>
|
|
405
|
+
</Box>
|
|
206
406
|
);
|
|
207
407
|
}
|
|
208
|
-
|
|
209
408
|
/** The slowest, flakiest and failing tests of the scope, each one click from its detail. */
|
|
210
409
|
function MoreInsights({ scope }: { scope: readonly TestSummary[] }) {
|
|
211
|
-
const slowest = useMemo(
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
</li>
|
|
227
|
-
))}
|
|
228
|
-
</ol>
|
|
229
|
-
</div>
|
|
410
|
+
const slowest = useMemo(
|
|
411
|
+
() =>
|
|
412
|
+
scope
|
|
413
|
+
.filter((test) => test.stats.durations)
|
|
414
|
+
.sort((a, b) => b.stats.durations!.avgMs - a.stats.durations!.avgMs)
|
|
415
|
+
.slice(0, 5),
|
|
416
|
+
[scope]
|
|
417
|
+
);
|
|
418
|
+
const flakiest = useMemo(
|
|
419
|
+
() =>
|
|
420
|
+
scope
|
|
421
|
+
.filter((test) => test.stats.flaky)
|
|
422
|
+
.sort((a, b) => b.stats.flips - a.stats.flips)
|
|
423
|
+
.slice(0, 5),
|
|
424
|
+
[scope]
|
|
230
425
|
);
|
|
426
|
+
const failing = useMemo(
|
|
427
|
+
() =>
|
|
428
|
+
scope
|
|
429
|
+
.filter((test) => test.status === 'failed')
|
|
430
|
+
.sort((a, b) => b.lastRecordedAtMs - a.lastRecordedAtMs)
|
|
431
|
+
.slice(0, 5),
|
|
432
|
+
[scope]
|
|
433
|
+
);
|
|
434
|
+
if (!slowest.length && !flakiest.length && !failing.length) return null;
|
|
435
|
+
const list = (title: string, tests: readonly TestSummary[], value: (test: TestSummary) => string) =>
|
|
436
|
+
tests.length > 0 && (
|
|
437
|
+
<Stack gap="sm">
|
|
438
|
+
<Title order={3} mb="sm" c="dimmed">
|
|
439
|
+
{title}
|
|
440
|
+
</Title>
|
|
441
|
+
<List spacing="xs" listStyleType="none" type="ordered">
|
|
442
|
+
{tests.map((test) => (
|
|
443
|
+
<List.Item key={test.key}>
|
|
444
|
+
<Anchor
|
|
445
|
+
renderRoot={(props) => (
|
|
446
|
+
<Link {...props} to="/" search={patched({ test: test.key })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
447
|
+
)}
|
|
448
|
+
>
|
|
449
|
+
<StatusMark status={test.status} size={12} />
|
|
450
|
+
<Flex component="span" direction="column" gap="xs" miw={0}>
|
|
451
|
+
{test.title}
|
|
452
|
+
</Flex>
|
|
453
|
+
<Text span inherit>
|
|
454
|
+
{value(test)}
|
|
455
|
+
</Text>
|
|
456
|
+
</Anchor>
|
|
457
|
+
</List.Item>
|
|
458
|
+
))}
|
|
459
|
+
</List>
|
|
460
|
+
</Stack>
|
|
461
|
+
);
|
|
231
462
|
return (
|
|
232
|
-
<
|
|
233
|
-
<
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
463
|
+
<Accordion variant="separated" my="sm">
|
|
464
|
+
<Accordion.Item value="content">
|
|
465
|
+
<Accordion.Control>Slowest, flakiest and failing tests</Accordion.Control>
|
|
466
|
+
<Accordion.Panel>
|
|
467
|
+
<SimpleGrid cols={{ base: 1, md: 3 }}>
|
|
468
|
+
{list('Failing', failing, (test) => plural(test.stats.failed, 'failure', 'failures'))}
|
|
469
|
+
{list('Flakiest', flakiest, (test) => (test.stats.flips ? plural(test.stats.flips, 'flip', 'flips') : 'flaky'))}
|
|
470
|
+
{list('Slowest', slowest, (test) => formatMs(test.stats.durations?.avgMs))}
|
|
471
|
+
</SimpleGrid>
|
|
472
|
+
</Accordion.Panel>
|
|
473
|
+
</Accordion.Item>
|
|
474
|
+
</Accordion>
|
|
240
475
|
);
|
|
241
476
|
}
|
|
242
|
-
|
|
243
477
|
type Listing = NonNullable<ReturnType<typeof useListing>>;
|
|
244
|
-
|
|
245
|
-
|
|
478
|
+
function Toolbar({
|
|
479
|
+
model,
|
|
480
|
+
search,
|
|
481
|
+
listing,
|
|
482
|
+
onSearch
|
|
483
|
+
}: {
|
|
484
|
+
model: CatalogModel;
|
|
485
|
+
search: ExplorerSearch;
|
|
486
|
+
listing: Listing;
|
|
487
|
+
onSearch: (value: string) => void;
|
|
488
|
+
}) {
|
|
246
489
|
const go = useExplorerNavigate();
|
|
247
490
|
const [draft, setDraft] = useState(search.q ?? '');
|
|
248
491
|
// 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]);
|
|
492
|
+
useEffect(() => setDraft((current) => (current.trim() === (search.q ?? '').trim() ? current : (search.q ?? ''))), [search.q]);
|
|
250
493
|
const statuses = values(search.status);
|
|
251
494
|
const envs = values(search.env);
|
|
252
|
-
const statusCount = (id: string) =>
|
|
495
|
+
const statusCount = (id: string) =>
|
|
496
|
+
id === ''
|
|
497
|
+
? listing.byEnvironment.length
|
|
498
|
+
: listing.byEnvironment.filter((test) => (id === 'flaky' ? test.stats.flaky : test.status === id)).length;
|
|
253
499
|
const envCounts = useMemo(() => {
|
|
254
500
|
const counts = new Map<string, number>();
|
|
255
|
-
for (const test of listing.byStatus)
|
|
501
|
+
for (const test of listing.byStatus)
|
|
502
|
+
for (const env of test.environments.length ? test.environments : ['(none)']) counts.set(env, (counts.get(env) ?? 0) + 1);
|
|
256
503
|
return counts;
|
|
257
504
|
}, [listing.byStatus]);
|
|
258
505
|
const specTitle = (path: string) => model.facets.specs.find((spec) => spec.path === path)?.title ?? path;
|
|
@@ -262,81 +509,168 @@ function Toolbar({ model, search, listing, onSearch }: { model: CatalogModel; se
|
|
|
262
509
|
...(search.inRun ? [{ label: `Run: ${runName(search.inRun)}`, clear: { inRun: undefined } }] : [])
|
|
263
510
|
];
|
|
264
511
|
return (
|
|
265
|
-
<
|
|
266
|
-
<
|
|
267
|
-
<
|
|
512
|
+
<Box aria-label="Find tests" component="section" mb="xl">
|
|
513
|
+
<Flex gap="sm" align="flex-end" wrap="wrap" mb="md">
|
|
514
|
+
<TextInput
|
|
515
|
+
flex={1}
|
|
516
|
+
miw={{ base: '100%', sm: '15rem' }}
|
|
268
517
|
type="search"
|
|
269
|
-
className={styles.searchBox}
|
|
270
518
|
aria-label="Search tests"
|
|
271
519
|
placeholder="Search titles, specs, domains, tags, environments…"
|
|
272
520
|
value={draft}
|
|
273
|
-
onChange={(event) => {
|
|
521
|
+
onChange={(event) => {
|
|
522
|
+
setDraft(event.target.value);
|
|
523
|
+
onSearch(event.target.value.trim());
|
|
524
|
+
}}
|
|
274
525
|
/>
|
|
275
|
-
<
|
|
276
|
-
<span
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
{
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
526
|
+
<Box component="label">
|
|
527
|
+
<Text span inherit>
|
|
528
|
+
Group by
|
|
529
|
+
</Text>
|
|
530
|
+
<NativeSelect
|
|
531
|
+
aria-label="Group by"
|
|
532
|
+
value={search.group ?? 'spec'}
|
|
533
|
+
onChange={(event) => go({ group: event.target.value === 'spec' ? undefined : (event.target.value as TestGroup) })}
|
|
534
|
+
>
|
|
535
|
+
{TEST_GROUPS.map((group) => (
|
|
536
|
+
<option key={group} value={group}>
|
|
537
|
+
{GROUP_LABEL[group]}
|
|
538
|
+
</option>
|
|
539
|
+
))}
|
|
540
|
+
</NativeSelect>
|
|
541
|
+
</Box>
|
|
542
|
+
<Box component="label">
|
|
543
|
+
<Text span inherit>
|
|
544
|
+
Sort
|
|
545
|
+
</Text>
|
|
546
|
+
<NativeSelect
|
|
547
|
+
aria-label="Sort"
|
|
548
|
+
value={search.sort ?? 'spec'}
|
|
549
|
+
onChange={(event) => go({ sort: event.target.value === 'spec' ? undefined : (event.target.value as TestSort) })}
|
|
550
|
+
>
|
|
551
|
+
{TEST_SORTS.map((sort) => (
|
|
552
|
+
<option key={sort} value={sort}>
|
|
553
|
+
{SORT_LABEL[sort]}
|
|
554
|
+
</option>
|
|
555
|
+
))}
|
|
556
|
+
</NativeSelect>
|
|
557
|
+
</Box>
|
|
558
|
+
</Flex>
|
|
559
|
+
<Flex role="group" aria-label="Test status" gap="xs" align="center" wrap="wrap" mb="sm">
|
|
289
560
|
{STATUS_FILTERS.map((filter) => {
|
|
290
561
|
const count = statusCount(filter.id);
|
|
291
562
|
if (filter.id === 'running' && count === 0 && !statuses.includes('running')) return null;
|
|
292
563
|
const pressed = filter.id === '' ? statuses.length === 0 : statuses.length === 1 && statuses[0] === filter.id;
|
|
293
564
|
return (
|
|
294
|
-
<
|
|
295
|
-
{filter.
|
|
296
|
-
|
|
565
|
+
<Button
|
|
566
|
+
key={filter.id || 'all'}
|
|
567
|
+
type="button"
|
|
568
|
+
aria-pressed={pressed}
|
|
569
|
+
data-status={(count > 0 && filter.id) || undefined}
|
|
570
|
+
onClick={() => go({ status: filter.id || undefined })}
|
|
571
|
+
variant={pressed ? 'light' : 'subtle'}
|
|
572
|
+
>
|
|
573
|
+
{filter.label}{' '}
|
|
574
|
+
<Text span inherit>
|
|
575
|
+
{count.toLocaleString()}
|
|
576
|
+
</Text>
|
|
577
|
+
</Button>
|
|
297
578
|
);
|
|
298
579
|
})}
|
|
299
|
-
</
|
|
300
|
-
<
|
|
580
|
+
</Flex>
|
|
581
|
+
<Flex gap="xs" align="center" wrap="wrap">
|
|
301
582
|
{model.facets.environments.length > 0 && (
|
|
302
|
-
<
|
|
583
|
+
<Flex role="group" aria-label="Environments" gap="xs" align="center" wrap="wrap">
|
|
303
584
|
{model.facets.environments.map(({ value }) => (
|
|
304
|
-
<
|
|
305
|
-
{
|
|
306
|
-
|
|
585
|
+
<Button
|
|
586
|
+
key={value}
|
|
587
|
+
type="button"
|
|
588
|
+
aria-pressed={envs.includes(value)}
|
|
589
|
+
onClick={() => go({ env: toggled(search.env, value) })}
|
|
590
|
+
variant={envs.includes(value) ? 'light' : 'subtle'}
|
|
591
|
+
>
|
|
592
|
+
{environmentLabel(value)}{' '}
|
|
593
|
+
<Text span inherit>
|
|
594
|
+
{(envCounts.get(value) ?? 0).toLocaleString()}
|
|
595
|
+
</Text>
|
|
596
|
+
</Button>
|
|
307
597
|
))}
|
|
308
|
-
</
|
|
598
|
+
</Flex>
|
|
309
599
|
)}
|
|
310
600
|
{model.facets.producers.length > 1 && (
|
|
311
|
-
<
|
|
312
|
-
<
|
|
313
|
-
<
|
|
601
|
+
<Box component="label">
|
|
602
|
+
<VisuallyHidden>Producer</VisuallyHidden>
|
|
603
|
+
<NativeSelect
|
|
604
|
+
aria-label="Producer"
|
|
605
|
+
value={values(search.producer)[0] ?? ''}
|
|
606
|
+
onChange={(event) => go({ producer: event.target.value || undefined })}
|
|
607
|
+
>
|
|
314
608
|
<option value="">All producers</option>
|
|
315
|
-
{model.facets.producers.map(({ value, count }) =>
|
|
316
|
-
|
|
317
|
-
|
|
609
|
+
{model.facets.producers.map(({ value, count }) => (
|
|
610
|
+
<option key={value} value={value}>
|
|
611
|
+
{value} ({count})
|
|
612
|
+
</option>
|
|
613
|
+
))}
|
|
614
|
+
</NativeSelect>
|
|
615
|
+
</Box>
|
|
318
616
|
)}
|
|
319
617
|
{model.facets.domains.length > 1 && (
|
|
320
|
-
<
|
|
321
|
-
<
|
|
322
|
-
<
|
|
618
|
+
<Box component="label">
|
|
619
|
+
<VisuallyHidden>Domain</VisuallyHidden>
|
|
620
|
+
<NativeSelect
|
|
621
|
+
aria-label="Domain"
|
|
622
|
+
value={values(search.domain)[0] ?? ''}
|
|
623
|
+
onChange={(event) => go({ domain: event.target.value || undefined, spec: undefined })}
|
|
624
|
+
>
|
|
323
625
|
<option value="">All domains</option>
|
|
324
|
-
{model.facets.domains.map(({ value, count }) =>
|
|
325
|
-
|
|
326
|
-
|
|
626
|
+
{model.facets.domains.map(({ value, count }) => (
|
|
627
|
+
<option key={value} value={value}>
|
|
628
|
+
{value === '(no spec)' ? 'No spec' : value} ({count})
|
|
629
|
+
</option>
|
|
630
|
+
))}
|
|
631
|
+
</NativeSelect>
|
|
632
|
+
</Box>
|
|
327
633
|
)}
|
|
328
|
-
</
|
|
329
|
-
<
|
|
330
|
-
<span
|
|
634
|
+
</Flex>
|
|
635
|
+
<Flex role="status" gap="xs" align="center" wrap="wrap">
|
|
636
|
+
<Text span inherit>
|
|
637
|
+
{listing.shown.length === model.tests.length
|
|
638
|
+
? plural(model.tests.length, 'test', 'tests')
|
|
639
|
+
: `${listing.shown.length.toLocaleString()} of ${plural(model.tests.length, 'test', 'tests')}`}
|
|
640
|
+
</Text>
|
|
331
641
|
{chips.map((chip) => (
|
|
332
|
-
<
|
|
642
|
+
<Button key={chip.label} type="button" onClick={() => go(chip.clear)} aria-label={`Remove ${chip.label}`} variant="light">
|
|
643
|
+
{chip.label}{' '}
|
|
644
|
+
<Text aria-hidden="true" span inherit>
|
|
645
|
+
<IconX size={16} aria-hidden="true" />
|
|
646
|
+
</Text>
|
|
647
|
+
</Button>
|
|
333
648
|
))}
|
|
334
|
-
{narrowed(search) &&
|
|
335
|
-
|
|
336
|
-
|
|
649
|
+
{narrowed(search) && (
|
|
650
|
+
<Button
|
|
651
|
+
type="button"
|
|
652
|
+
onClick={() => {
|
|
653
|
+
setDraft('');
|
|
654
|
+
go({
|
|
655
|
+
q: undefined,
|
|
656
|
+
status: undefined,
|
|
657
|
+
env: undefined,
|
|
658
|
+
producer: undefined,
|
|
659
|
+
domain: undefined,
|
|
660
|
+
spec: undefined,
|
|
661
|
+
tag: undefined,
|
|
662
|
+
inRun: undefined
|
|
663
|
+
});
|
|
664
|
+
}}
|
|
665
|
+
variant="subtle"
|
|
666
|
+
>
|
|
667
|
+
Clear filters
|
|
668
|
+
</Button>
|
|
669
|
+
)}
|
|
670
|
+
</Flex>
|
|
671
|
+
</Box>
|
|
337
672
|
);
|
|
338
673
|
}
|
|
339
|
-
|
|
340
674
|
function TestList({ tests, search, total }: { tests: readonly TestSummary[]; search: ExplorerSearch; total: number }) {
|
|
341
675
|
const by = search.group ?? 'spec';
|
|
342
676
|
const groups = useMemo(() => groupTests(tests, by, (search.sort ?? 'spec') === 'spec'), [tests, by, search.sort]);
|
|
@@ -344,90 +678,183 @@ function TestList({ tests, search, total }: { tests: readonly TestSummary[]; sea
|
|
|
344
678
|
const defaultOpen = groups.length <= OPEN_GROUPS || tests.length <= OPEN_TESTS;
|
|
345
679
|
const isOpen = (group: TestGroupView) => by === 'none' || (overrides.get(`${by}|${group.key}`) ?? defaultOpen);
|
|
346
680
|
const setAll = (open: boolean) => setOverrides(new Map(groups.map((group) => [`${by}|${group.key}`, open])));
|
|
347
|
-
if (!tests.length)
|
|
681
|
+
if (!tests.length)
|
|
682
|
+
return (
|
|
683
|
+
<Text role="status" component="div" c="dimmed">
|
|
684
|
+
{total ? 'No test matches these filters.' : 'No tests recorded yet.'}
|
|
685
|
+
</Text>
|
|
686
|
+
);
|
|
348
687
|
let parent: string | null = null;
|
|
349
688
|
return (
|
|
350
|
-
<
|
|
351
|
-
<
|
|
352
|
-
<span
|
|
689
|
+
<Box aria-label="Tests" component="section" mb="lg">
|
|
690
|
+
<Flex gap="xs" align="center" wrap="wrap">
|
|
691
|
+
<Text span inherit>
|
|
692
|
+
{by === 'none'
|
|
693
|
+
? ''
|
|
694
|
+
: `${plural(groups.length, GROUP_LABEL[by].toLowerCase(), by === 'status' ? 'statuses' : `${GROUP_LABEL[by].toLowerCase()}s`)}`}
|
|
695
|
+
</Text>
|
|
353
696
|
{by !== 'none' && (
|
|
354
|
-
<span
|
|
355
|
-
<
|
|
356
|
-
|
|
357
|
-
|
|
697
|
+
<Flex component="span" display="inline-flex" gap="xs" align="center" wrap="wrap">
|
|
698
|
+
<Button type="button" onClick={() => setAll(true)} variant="subtle">
|
|
699
|
+
Expand all
|
|
700
|
+
</Button>
|
|
701
|
+
<Button type="button" onClick={() => setAll(false)} variant="subtle">
|
|
702
|
+
Collapse all
|
|
703
|
+
</Button>
|
|
704
|
+
</Flex>
|
|
358
705
|
)}
|
|
359
|
-
<
|
|
360
|
-
|
|
706
|
+
<Group aria-hidden="true" gap="md" ml="auto" visibleFrom="sm">
|
|
707
|
+
<Text span inherit>
|
|
708
|
+
Avg
|
|
709
|
+
</Text>
|
|
710
|
+
<Text span inherit>
|
|
711
|
+
Pass
|
|
712
|
+
</Text>
|
|
713
|
+
<Text span inherit>
|
|
714
|
+
Runs
|
|
715
|
+
</Text>
|
|
716
|
+
<Text span inherit>
|
|
717
|
+
Last
|
|
718
|
+
</Text>
|
|
719
|
+
</Group>
|
|
720
|
+
</Flex>
|
|
361
721
|
{groups.map((group) => {
|
|
362
722
|
const heading = by === 'spec' && group.parent !== parent ? group.parent : null;
|
|
363
723
|
parent = group.parent;
|
|
364
724
|
return (
|
|
365
|
-
<
|
|
366
|
-
{heading &&
|
|
367
|
-
|
|
368
|
-
|
|
725
|
+
<Stack key={group.key} gap="sm">
|
|
726
|
+
{heading && (
|
|
727
|
+
<Title order={3} mb="sm">
|
|
728
|
+
{heading === '(no spec)' ? 'No spec' : heading}
|
|
729
|
+
</Title>
|
|
730
|
+
)}
|
|
731
|
+
<TestGroupSection
|
|
732
|
+
group={group}
|
|
733
|
+
by={by}
|
|
734
|
+
open={isOpen(group)}
|
|
735
|
+
selected={search.test}
|
|
736
|
+
onToggle={() => setOverrides(new Map(overrides).set(`${by}|${group.key}`, !isOpen(group)))}
|
|
737
|
+
/>
|
|
738
|
+
</Stack>
|
|
369
739
|
);
|
|
370
740
|
})}
|
|
371
|
-
</
|
|
741
|
+
</Box>
|
|
372
742
|
);
|
|
373
743
|
}
|
|
374
|
-
|
|
375
|
-
|
|
744
|
+
function TestGroupSection({
|
|
745
|
+
group,
|
|
746
|
+
by,
|
|
747
|
+
open,
|
|
748
|
+
selected,
|
|
749
|
+
onToggle
|
|
750
|
+
}: {
|
|
751
|
+
group: TestGroupView;
|
|
752
|
+
by: TestGroup;
|
|
753
|
+
open: boolean;
|
|
754
|
+
selected: string | undefined;
|
|
755
|
+
onToggle: () => void;
|
|
756
|
+
}) {
|
|
376
757
|
const [shown, setShown] = useState(by === 'none' ? 100 : GROUP_ROWS);
|
|
377
758
|
const now = useNow();
|
|
378
|
-
|
|
379
|
-
<
|
|
380
|
-
{
|
|
381
|
-
<
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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>
|
|
759
|
+
const rows = (
|
|
760
|
+
<List spacing="xs" listStyleType="none">
|
|
761
|
+
{group.tests.slice(0, shown).map((test) => (
|
|
762
|
+
<TestRow key={test.key} test={test} selected={selected === test.key} now={now} />
|
|
763
|
+
))}
|
|
764
|
+
{group.tests.length > shown && (
|
|
765
|
+
<List.Item>
|
|
766
|
+
<Button variant="subtle" onClick={() => setShown(shown + (by === 'none' ? 100 : GROUP_ROWS))}>
|
|
767
|
+
Show more ({group.tests.length - shown} more)
|
|
768
|
+
</Button>
|
|
769
|
+
</List.Item>
|
|
395
770
|
)}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
771
|
+
</List>
|
|
772
|
+
);
|
|
773
|
+
return (
|
|
774
|
+
<Box component="section" aria-label={group.label} mb="md">
|
|
775
|
+
{by === 'none' ? (
|
|
776
|
+
rows
|
|
777
|
+
) : (
|
|
778
|
+
<Accordion variant="separated" value={open ? 'tests' : null} onChange={onToggle}>
|
|
779
|
+
<Accordion.Item value="tests">
|
|
780
|
+
<Accordion.Control>
|
|
781
|
+
<Group gap="md" justify="space-between">
|
|
782
|
+
<Stack gap="xs" miw={0}>
|
|
783
|
+
<Text fw={600}>{group.label}</Text>
|
|
784
|
+
{group.detail && (
|
|
785
|
+
<Text ff="monospace" size="xs" c="dimmed">
|
|
786
|
+
{group.detail}
|
|
787
|
+
</Text>
|
|
788
|
+
)}
|
|
789
|
+
</Stack>
|
|
790
|
+
<Group gap="xs">
|
|
791
|
+
{group.counts.failed > 0 && <Badge color="red">{group.counts.failed} failed</Badge>}
|
|
792
|
+
{group.counts['not-run'] > 0 && <Badge color="gray">{group.counts['not-run']} not run</Badge>}
|
|
793
|
+
<Text size="xs" c="dimmed">
|
|
794
|
+
{plural(group.tests.length, 'test', 'tests')}
|
|
795
|
+
{group.avgMs !== null ? ` · avg ${formatMs(group.avgMs)}` : ''}
|
|
796
|
+
</Text>
|
|
797
|
+
</Group>
|
|
798
|
+
</Group>
|
|
799
|
+
</Accordion.Control>
|
|
800
|
+
<Accordion.Panel>{open && rows}</Accordion.Panel>
|
|
801
|
+
</Accordion.Item>
|
|
802
|
+
</Accordion>
|
|
405
803
|
)}
|
|
406
|
-
</
|
|
804
|
+
</Box>
|
|
407
805
|
);
|
|
408
806
|
}
|
|
409
|
-
|
|
410
807
|
function TestRow({ test, selected, now }: { test: TestSummary; selected: boolean; now: number }) {
|
|
411
808
|
const failure = test.status === 'failed' ? test.outcomes.find((outcome) => outcome.status === 'failed')?.detail.split('\n')[0] : '';
|
|
412
809
|
return (
|
|
413
|
-
<
|
|
414
|
-
<
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
810
|
+
<List.Item data-status={test.status}>
|
|
811
|
+
<Anchor
|
|
812
|
+
underline="never"
|
|
813
|
+
aria-current={selected ? 'true' : undefined}
|
|
814
|
+
display="block"
|
|
815
|
+
bg={selected ? 'blue.0' : undefined}
|
|
816
|
+
p="sm"
|
|
817
|
+
c="text"
|
|
818
|
+
renderRoot={(props) => (
|
|
819
|
+
<Link
|
|
820
|
+
{...props}
|
|
821
|
+
to="/"
|
|
822
|
+
search={patched({ test: selected ? undefined : test.key })}
|
|
823
|
+
resetScroll={false}
|
|
824
|
+
activeOptions={{ exact: true }}
|
|
825
|
+
/>
|
|
826
|
+
)}
|
|
827
|
+
>
|
|
828
|
+
<Group gap="sm" wrap="nowrap" align="flex-start">
|
|
829
|
+
<StatusMark status={test.status} size={16} />
|
|
830
|
+
<Stack gap="xs" flex={1} miw={0}>
|
|
831
|
+
<Text fw={500}>{test.title}</Text>
|
|
832
|
+
<Group gap="xs">
|
|
833
|
+
<EnvironmentChips test={test} />
|
|
834
|
+
<Badges test={test} />
|
|
835
|
+
</Group>
|
|
836
|
+
{failure && (
|
|
837
|
+
<Text size="xs" c="red.7" lineClamp={2}>
|
|
838
|
+
{failure}
|
|
839
|
+
</Text>
|
|
840
|
+
)}
|
|
841
|
+
</Stack>
|
|
842
|
+
<Group gap="md" visibleFrom="sm">
|
|
843
|
+
<Text title="Average duration" size="xs" c="dimmed">
|
|
844
|
+
{formatMs(test.stats.durations?.avgMs)}
|
|
845
|
+
</Text>
|
|
846
|
+
<Text title="Pass rate" size="xs" c="dimmed">
|
|
847
|
+
{percent(test.stats.passRate)}
|
|
848
|
+
</Text>
|
|
849
|
+
<Text title="Runs" size="xs" c="dimmed">
|
|
850
|
+
{test.stats.runs || '—'}
|
|
851
|
+
</Text>
|
|
852
|
+
<Text title="Last recorded" size="xs" c="dimmed">
|
|
853
|
+
{test.live ? 'now' : test.lastRecordedAtMs ? ago(test.lastRecordedAtMs, now).replace(' ago', '') : '—'}
|
|
854
|
+
</Text>
|
|
855
|
+
</Group>
|
|
856
|
+
</Group>
|
|
857
|
+
</Anchor>
|
|
858
|
+
</List.Item>
|
|
432
859
|
);
|
|
433
860
|
}
|