@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +7 -0
  3. package/dist/reports/src/catalog-health.d.ts +142 -0
  4. package/dist/reports/src/catalog-health.js +257 -0
  5. package/dist/reports/src/test-catalog.d.ts +8 -0
  6. package/dist/reports/src/test-catalog.js +5 -1
  7. package/dist/schema/src/api.d.ts +79 -0
  8. package/dist/schema/src/api.js +50 -0
  9. package/dist/server/src/app.js +9 -0
  10. package/dist/server/src/llms.js +1 -0
  11. package/dist/server/src/openapi.js +18 -2
  12. package/dist/server/src/settings-page.d.ts +4 -4
  13. package/dist/server/src/settings-page.js +17 -78
  14. package/docs/reference/http-api.md +34 -0
  15. package/docs/reference/reports.md +224 -0
  16. package/docs/reference/schema.md +10 -0
  17. package/docs/reference/server.md +4 -4
  18. package/docs/server.md +10 -0
  19. package/docs/ui.md +142 -0
  20. package/docs/workspaces.md +130 -0
  21. package/llms-full.txt +322 -0
  22. package/llms.txt +2 -0
  23. package/package.json +17 -10
  24. package/packages/schema/schemas/catalog-health.schema.json +400 -0
  25. package/packages/server/openapi.json +596 -0
  26. package/packages/visualizer/src/admin/SettingsPage.tsx +456 -0
  27. package/packages/visualizer/src/admin/main.tsx +11 -0
  28. package/packages/visualizer/src/app/AppShell.tsx +62 -38
  29. package/packages/visualizer/src/app/gazetteer.tsx +90 -83
  30. package/packages/visualizer/src/app/router.tsx +33 -15
  31. package/packages/visualizer/src/components/ActivityIndicator.tsx +4 -5
  32. package/packages/visualizer/src/components/DataIssues.tsx +36 -54
  33. package/packages/visualizer/src/components/Header.tsx +197 -125
  34. package/packages/visualizer/src/components/Legend.tsx +13 -41
  35. package/packages/visualizer/src/components/ProjectPicker.tsx +61 -26
  36. package/packages/visualizer/src/components/Screen.tsx +80 -71
  37. package/packages/visualizer/src/components/SpecLogo.tsx +15 -0
  38. package/packages/visualizer/src/components/Status.tsx +20 -34
  39. package/packages/visualizer/src/components/labels.tsx +8 -3
  40. package/packages/visualizer/src/components/spec-logos/APACHE-2.0.txt +206 -0
  41. package/packages/visualizer/src/components/spec-logos/CAMUNDA-MIT.txt +21 -0
  42. package/packages/visualizer/src/components/spec-logos/FILE-ICONS-ISC.txt +13 -0
  43. package/packages/visualizer/src/components/spec-logos/README.md +23 -0
  44. package/packages/visualizer/src/components/spec-logos/SIMPLE-ICONS-CC0.txt +30 -0
  45. package/packages/visualizer/src/components/spec-logos/alloy.svg +1 -0
  46. package/packages/visualizer/src/components/spec-logos/dmn.svg +3 -0
  47. package/packages/visualizer/src/components/spec-logos/gherkin.svg +1 -0
  48. package/packages/visualizer/src/components/spec-logos/openslo.svg +63 -0
  49. package/packages/visualizer/src/components/spec-logos/quint.svg +1 -0
  50. package/packages/visualizer/src/evidence/artifacts.tsx +263 -110
  51. package/packages/visualizer/src/evidence/chart-source.tsx +18 -0
  52. package/packages/visualizer/src/evidence/check-detail.tsx +283 -139
  53. package/packages/visualizer/src/evidence/decision-table.tsx +35 -26
  54. package/packages/visualizer/src/evidence/document-page.tsx +51 -29
  55. package/packages/visualizer/src/evidence/feature-page.tsx +121 -53
  56. package/packages/visualizer/src/evidence/marks.tsx +112 -79
  57. package/packages/visualizer/src/evidence/model-page.tsx +77 -46
  58. package/packages/visualizer/src/evidence/model-trace.tsx +55 -35
  59. package/packages/visualizer/src/evidence/overview.tsx +137 -69
  60. package/packages/visualizer/src/evidence/recording-page.tsx +70 -31
  61. package/packages/visualizer/src/evidence/recording.tsx +135 -68
  62. package/packages/visualizer/src/evidence/snapshot.tsx +99 -43
  63. package/packages/visualizer/src/evidence/source-text.tsx +33 -0
  64. package/packages/visualizer/src/evidence/sources-panel.tsx +138 -144
  65. package/packages/visualizer/src/evidence/spec-header.tsx +52 -36
  66. package/packages/visualizer/src/evidence/spec-index.tsx +61 -32
  67. package/packages/visualizer/src/evidence/spec-page.tsx +56 -25
  68. package/packages/visualizer/src/evidence/test-filter.tsx +38 -14
  69. package/packages/visualizer/src/explorer/Explorer.tsx +688 -261
  70. package/packages/visualizer/src/explorer/ExplorerIndex.tsx +136 -68
  71. package/packages/visualizer/src/explorer/RunsView.tsx +307 -94
  72. package/packages/visualizer/src/explorer/TestDetail.tsx +393 -118
  73. package/packages/visualizer/src/explorer/parts.tsx +35 -13
  74. package/packages/visualizer/src/find/Finder.tsx +106 -90
  75. package/packages/visualizer/src/find/open.ts +17 -20
  76. package/packages/visualizer/src/live/test-progress.tsx +60 -22
  77. package/packages/visualizer/src/looks/Stage.tsx +223 -110
  78. package/packages/visualizer/src/main.tsx +9 -11
  79. package/packages/visualizer/src/map/ChartMap.tsx +125 -69
  80. package/packages/visualizer/src/map/EventEdge.tsx +59 -37
  81. package/packages/visualizer/src/map/map.module.css +12 -546
  82. package/packages/visualizer/src/map/nodes.tsx +124 -70
  83. package/packages/visualizer/src/reachable.test.ts +3 -2
  84. package/packages/visualizer/src/spec-kind.ts +2 -2
  85. package/packages/visualizer/src/ui/grammars/ALLOY-MIT.txt +21 -0
  86. package/packages/visualizer/src/ui/grammars/QUINT-APACHE-2.0.txt +206 -0
  87. package/packages/visualizer/src/ui/grammars/README.md +14 -0
  88. package/packages/visualizer/src/ui/grammars/alloy.json +71 -0
  89. package/packages/visualizer/src/ui/grammars/quint.json +152 -0
  90. package/packages/visualizer/src/ui/highlight.ts +50 -0
  91. package/packages/visualizer/src/ui/theme.tsx +109 -0
  92. package/packages/visualizer/src/views/ChartPage.tsx +46 -26
  93. package/packages/visualizer/src/views/ChartTests.tsx +59 -22
  94. package/packages/visualizer/src/views/ProductPage.tsx +16 -15
  95. package/packages/visualizer/src/views/SidePanel.tsx +241 -131
  96. package/packages/visualizer/src/app/gazetteer.module.css +0 -107
  97. package/packages/visualizer/src/components/ActivityIndicator.module.css +0 -4
  98. package/packages/visualizer/src/components/DataIssues.module.css +0 -136
  99. package/packages/visualizer/src/components/Header.module.css +0 -114
  100. package/packages/visualizer/src/components/Legend.module.css +0 -84
  101. package/packages/visualizer/src/components/Screen.module.css +0 -312
  102. package/packages/visualizer/src/components/Status.module.css +0 -116
  103. package/packages/visualizer/src/components/icons.tsx +0 -156
  104. package/packages/visualizer/src/evidence/artifacts.module.css +0 -213
  105. package/packages/visualizer/src/evidence/evidence.module.css +0 -907
  106. package/packages/visualizer/src/evidence/recording.module.css +0 -460
  107. package/packages/visualizer/src/evidence/sources.module.css +0 -168
  108. package/packages/visualizer/src/evidence/spec-snapshot.tsx +0 -26
  109. package/packages/visualizer/src/explorer/explorer.module.css +0 -1238
  110. package/packages/visualizer/src/find/Finder.module.css +0 -168
  111. package/packages/visualizer/src/looks/stage.module.css +0 -300
  112. package/packages/visualizer/src/styles/global.css +0 -105
  113. package/packages/visualizer/src/styles/tokens.css +0 -80
  114. 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, TEST_GROUPS, type ExplorerSearch, type TestGroup } from './search';
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
- 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' };
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(() => (search.inRun ? new Set(run.data?.results.map((result) => result.key) ?? []) : null), [search.inRun, run.data]);
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: 20_000 }).tests.filter((test) => !inRun || inRun.has(test.key));
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 { shown, scope, byEnvironment, byStatus, runPending: !!search.inRun && run.isPending, runMissing: !!search.inRun && run.isSuccess && run.data === null };
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
- <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>
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
- <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>
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
- </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>}
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
- <section className={styles.kinds} aria-label="Published specs">
101
- <h2 className={styles.label}>Specs</h2>
102
- <ul>
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
- <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>
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
- </ul>
109
- </section>
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
- {!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
- : <>
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 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>}
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
- </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>
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
- <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>
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(() => 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>
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
- <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>
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
- function Toolbar({ model, search, listing, onSearch }: { model: CatalogModel; search: ExplorerSearch; listing: Listing; onSearch: (value: string) => void }) {
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) => (id === '' ? listing.byEnvironment.length : listing.byEnvironment.filter((test) => (id === 'flaky' ? test.stats.flaky : test.status === id)).length);
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) for (const env of test.environments.length ? test.environments : ['(none)']) counts.set(env, (counts.get(env) ?? 0) + 1);
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
- <section className={styles.toolbar} aria-label="Find tests">
266
- <div className={styles.searchRow}>
267
- <input
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) => { setDraft(event.target.value); onSearch(event.target.value.trim()); }}
521
+ onChange={(event) => {
522
+ setDraft(event.target.value);
523
+ onSearch(event.target.value.trim());
524
+ }}
274
525
  />
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">
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
- <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>
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
- </div>
300
- <div className={styles.filterRow}>
580
+ </Flex>
581
+ <Flex gap="xs" align="center" wrap="wrap">
301
582
  {model.facets.environments.length > 0 && (
302
- <div className={styles.chips} role="group" aria-label="Environments">
583
+ <Flex role="group" aria-label="Environments" gap="xs" align="center" wrap="wrap">
303
584
  {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>
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
- </div>
598
+ </Flex>
309
599
  )}
310
600
  {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 })}>
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 }) => <option key={value} value={value}>{value} ({count})</option>)}
316
- </select>
317
- </label>
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
- <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 })}>
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 }) => <option key={value} value={value}>{value === '(no spec)' ? 'No spec' : value} ({count})</option>)}
325
- </select>
326
- </label>
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
- </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>
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
- <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>
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) && <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>
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) return <p className={styles.quiet} role="status">{total ? 'No test matches these filters.' : 'No tests recorded yet.'}</p>;
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
- <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>
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 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>
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
- <span className={styles.columns} aria-hidden="true"><span>Avg</span><span>Pass</span><span>Runs</span><span>Last</span></span>
360
- </div>
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
- <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>
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
- </section>
741
+ </Box>
372
742
  );
373
743
  }
374
-
375
- function TestGroupSection({ group, by, open, selected, onToggle }: { group: TestGroupView; by: TestGroup; open: boolean; selected: string | undefined; onToggle: () => void }) {
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
- 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>
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
- {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>
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
- </section>
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
- <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>
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
  }