@crvouga/atlas 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -1
- package/dist/reports/src/evidence-detail.js +6 -1
- package/dist/reports/src/evidence-types.d.ts +2 -0
- package/dist/reports/src/reliability.d.ts +181 -0
- package/dist/reports/src/reliability.js +294 -0
- package/dist/reports/src/report-index.js +1 -1
- package/dist/reports/src/test-catalog.d.ts +8 -2
- package/dist/reports/src/test-catalog.js +10 -5
- package/dist/schema/src/api.d.ts +226 -0
- package/dist/schema/src/api.js +51 -0
- package/dist/server/src/app.js +15 -0
- package/dist/server/src/llms.js +1 -0
- package/dist/server/src/openapi.js +25 -2
- package/docs/reference/http-api.md +48 -0
- package/docs/reference/reports.md +272 -1
- package/docs/reference/schema.md +11 -0
- package/docs/server.md +18 -0
- package/docs/ui.md +32 -1
- package/llms-full.txt +98 -1
- package/package.json +1 -1
- package/packages/schema/schemas/effective-delta.schema.json +26 -0
- package/packages/schema/schemas/effective-evidence.schema.json +26 -0
- package/packages/schema/schemas/evidence-page.schema.json +13 -0
- package/packages/schema/schemas/layer-rows.schema.json +13 -0
- package/packages/schema/schemas/test-catalog.schema.json +17 -0
- package/packages/schema/schemas/test-history.schema.json +34 -0
- package/packages/schema/schemas/test-reliability.schema.json +629 -0
- package/packages/server/openapi.json +1722 -568
- package/packages/visualizer/src/evidence/artifact-tags.tsx +1 -1
- package/packages/visualizer/src/explorer/ArtifactsView.tsx +11 -1
- package/packages/visualizer/src/explorer/Reliability.tsx +337 -0
- package/packages/visualizer/src/explorer/RunsView.tsx +11 -1
- package/packages/visualizer/src/explorer/TestDetail.tsx +3 -0
- package/packages/visualizer/src/explorer/catalog.tsx +1 -1
- package/packages/visualizer/src/explorer/playlist-entries.test.ts +51 -0
- package/packages/visualizer/src/explorer/playlist-entries.ts +134 -0
- package/packages/visualizer/src/explorer/playlist.tsx +441 -0
- package/packages/visualizer/src/explorer/search.ts +36 -0
|
@@ -50,7 +50,7 @@ function shownTags(attachment: Attachment, context: TagsContext | null): Artifac
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/** Every attachment a holder carries, on its steps and attempts too. */
|
|
53
|
-
function everyAttachment(holder: EvidenceHolder): Attachment[] {
|
|
53
|
+
export function everyAttachment(holder: EvidenceHolder): Attachment[] {
|
|
54
54
|
const out: Attachment[] = [...(holder.attachments ?? [])];
|
|
55
55
|
for (const step of holder.steps ?? []) out.push(...(step.attachments ?? []));
|
|
56
56
|
for (const attempt of holder.attempts ?? []) {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* untagged. Each row opens that exact result in the test detail. An empty answer for a filter says nothing matches and
|
|
8
8
|
* offers to clear it; it never says the project has nothing recorded.
|
|
9
9
|
*/
|
|
10
|
-
import { IconVideo } from '@tabler/icons-react';
|
|
10
|
+
import { IconPlayerPlay, IconVideo } from '@tabler/icons-react';
|
|
11
11
|
import { Alert, Anchor, Box, Button, Code, Stack, Table, Text } from '@mantine/core';
|
|
12
12
|
import { videoCounts } from '@crvouga/atlas/reports/evidence-detail';
|
|
13
13
|
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
@@ -20,6 +20,8 @@ import { useArtifactList } from './catalog';
|
|
|
20
20
|
import { FilterBar } from './filters';
|
|
21
21
|
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
22
22
|
import { runName } from './parts';
|
|
23
|
+
import { entriesFromArtifacts } from './playlist-entries';
|
|
24
|
+
import { WatchPlaylist } from './playlist';
|
|
23
25
|
import { filtered, filterOf, NO_FILTERS } from './search';
|
|
24
26
|
|
|
25
27
|
const ARTIFACT_ROWS = 50;
|
|
@@ -33,6 +35,8 @@ export function ArtifactsView() {
|
|
|
33
35
|
const answer = useArtifactList(filter);
|
|
34
36
|
const now = useNow();
|
|
35
37
|
const [shown, setShown] = useState(ARTIFACT_ROWS);
|
|
38
|
+
const [watching, setWatching] = useState(false);
|
|
39
|
+
const entries = useMemo(() => (answer.data && !answer.data.unsupported ? entriesFromArtifacts(answer.data.list.results) : []), [answer.data]);
|
|
36
40
|
if (answer.isPending)
|
|
37
41
|
return (
|
|
38
42
|
<Text role="status" component="div" c="dimmed">
|
|
@@ -77,6 +81,12 @@ export function ArtifactsView() {
|
|
|
77
81
|
</Button>
|
|
78
82
|
</Stack>
|
|
79
83
|
)}
|
|
84
|
+
{list.results.length > 0 && (
|
|
85
|
+
<Button type="button" variant="filled" my="sm" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => setWatching(true)}>
|
|
86
|
+
Watch these {plural(list.results.length, 'result', 'results')}
|
|
87
|
+
</Button>
|
|
88
|
+
)}
|
|
89
|
+
{watching && <WatchPlaylist entries={entries} total={list.total} onClose={() => setWatching(false)} />}
|
|
80
90
|
{list.results.length > 0 && (
|
|
81
91
|
<Table.ScrollContainer minWidth={700}>
|
|
82
92
|
<Table data-artifacts>
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Reliability of a test
|
|
3
|
+
*
|
|
4
|
+
* The open test's reliability, qualified: how many independent executions it rests on (not snapshots, not tries), for
|
|
5
|
+
* which environment, branch, spec revision and time window, how often it passed first time and in the end, and why the
|
|
6
|
+
* figure may not be what it looks like (a small sample, pooled environments, failed cases published again, bounded
|
|
7
|
+
* history). The scope is in the address and the executions behind the figure (its denominator) open in place, so a link
|
|
8
|
+
* shows the same figure and the same executions. It is worked out from the history the page already holds
|
|
9
|
+
* (`reliability` in the reports library), so it needs no further request.
|
|
10
|
+
*/
|
|
11
|
+
import { Accordion, Alert, Anchor, Badge, Box, Button, Checkbox, Code, CopyButton, Flex, List, MultiSelect, SimpleGrid, Stack, Table, Text, TextInput, Title, VisuallyHidden } from '@mantine/core';
|
|
12
|
+
import { IconLink } from '@tabler/icons-react';
|
|
13
|
+
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
14
|
+
import { reliability, type ReliabilityExecution } from '@crvouga/atlas/reports/reliability';
|
|
15
|
+
import type { TestResultEntry } from '@crvouga/atlas/reports/test-catalog';
|
|
16
|
+
import { Link } from '@tanstack/react-router';
|
|
17
|
+
import { useMemo } from 'react';
|
|
18
|
+
import { exactTime, plural, shortSha, STATUS_WORD } from '../evidence/format';
|
|
19
|
+
import { StatusMark } from '../evidence/marks';
|
|
20
|
+
import { joined, reliabilityScopeOf, values } from './search';
|
|
21
|
+
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
22
|
+
|
|
23
|
+
/** A recorded status as a mark and a word; one this viewer does not know is shown by its own name and as unknown, never as a pass. */
|
|
24
|
+
const known = (status: string) => (Object.hasOwn(STATUS_WORD, status) && status !== 'none' ? (status as keyof typeof STATUS_WORD) : 'none');
|
|
25
|
+
const word = (status: string) => (known(status) === 'none' ? status : STATUS_WORD[known(status)]);
|
|
26
|
+
const percent = (rate: number | null) => (rate === null ? 'no rate' : `${Math.round(rate * 1000) / 10}%`);
|
|
27
|
+
const outOf = (passed: number, executions: number) => `${passed} of ${plural(executions, 'execution', 'executions')} passed`;
|
|
28
|
+
const REASONS: Record<string, string> = {
|
|
29
|
+
'not-run': 'Did not run',
|
|
30
|
+
unfinished: 'Not finished',
|
|
31
|
+
'unknown-status': 'Status not understood',
|
|
32
|
+
'historical-revision': 'Earlier spec revision',
|
|
33
|
+
'revision-not-recorded': 'Spec revision not recorded'
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function Choice({ label, param, options, chosen }: { label: string; param: 'relEnv' | 'relProducer' | 'relBranch' | 'relParam'; options: readonly { value: string; label: string }[]; chosen: string[] }) {
|
|
37
|
+
const go = useExplorerNavigate();
|
|
38
|
+
if (options.length < 2 && !chosen.length) return null;
|
|
39
|
+
return (
|
|
40
|
+
<MultiSelect
|
|
41
|
+
label={label}
|
|
42
|
+
data={options as { value: string; label: string }[]}
|
|
43
|
+
value={chosen}
|
|
44
|
+
searchable
|
|
45
|
+
clearable
|
|
46
|
+
placeholder={chosen.length ? undefined : `Any ${label.toLowerCase()}`}
|
|
47
|
+
nothingFoundMessage="Nothing found"
|
|
48
|
+
onChange={(next) => go({ [param]: joined(next) } as never, { replace: true })}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function Denominator({ executions, layer }: { executions: readonly ReliabilityExecution[]; layer: string | undefined }) {
|
|
54
|
+
return (
|
|
55
|
+
<Table.ScrollContainer minWidth={760}>
|
|
56
|
+
<Table>
|
|
57
|
+
<Table.Caption>Independent executions of this test in the scope, newest first. Snapshots of one execution are one row.</Table.Caption>
|
|
58
|
+
<Table.Thead>
|
|
59
|
+
<Table.Tr>
|
|
60
|
+
<Table.Th scope="col">Recorded</Table.Th>
|
|
61
|
+
<Table.Th scope="col">Environment</Table.Th>
|
|
62
|
+
<Table.Th scope="col">Status</Table.Th>
|
|
63
|
+
<Table.Th scope="col">Tries</Table.Th>
|
|
64
|
+
<Table.Th scope="col">Snapshots</Table.Th>
|
|
65
|
+
<Table.Th scope="col">Branch</Table.Th>
|
|
66
|
+
<Table.Th scope="col">Commit</Table.Th>
|
|
67
|
+
<Table.Th scope="col">Counted</Table.Th>
|
|
68
|
+
<Table.Th scope="col">
|
|
69
|
+
<VisuallyHidden>Evidence</VisuallyHidden>
|
|
70
|
+
</Table.Th>
|
|
71
|
+
</Table.Tr>
|
|
72
|
+
</Table.Thead>
|
|
73
|
+
<Table.Tbody>
|
|
74
|
+
{executions.map((execution) => (
|
|
75
|
+
<Table.Tr key={`${execution.layer}|${execution.kind}|${execution.producer}|${execution.environment}|${JSON.stringify(execution.parameters)}`} data-status={execution.status} data-counted={execution.counted}>
|
|
76
|
+
<Table.Td title={exactTime(execution.at)}>{exactTime(execution.at)}</Table.Td>
|
|
77
|
+
<Table.Td>
|
|
78
|
+
{environmentLabel(execution.environment)}
|
|
79
|
+
{Object.entries(execution.parameters).map(([name, value]) => (
|
|
80
|
+
<Text key={name} span inherit c="dimmed">
|
|
81
|
+
{' '}
|
|
82
|
+
{name}: {value}
|
|
83
|
+
</Text>
|
|
84
|
+
))}
|
|
85
|
+
</Table.Td>
|
|
86
|
+
<Table.Td>
|
|
87
|
+
<Flex component="span" display="inline-flex" gap="xs" align="center" wrap="wrap">
|
|
88
|
+
<StatusMark status={known(execution.status)} size={12} />
|
|
89
|
+
{word(execution.status)}
|
|
90
|
+
</Flex>
|
|
91
|
+
</Table.Td>
|
|
92
|
+
<Table.Td>{execution.attempts ? execution.attempts.map(word).join(' → ') : 'Not recorded'}</Table.Td>
|
|
93
|
+
<Table.Td>
|
|
94
|
+
{execution.snapshots}
|
|
95
|
+
{execution.republished && (
|
|
96
|
+
<Text span inherit c="dimmed">
|
|
97
|
+
{' '}
|
|
98
|
+
(published again after a failure)
|
|
99
|
+
</Text>
|
|
100
|
+
)}
|
|
101
|
+
</Table.Td>
|
|
102
|
+
<Table.Td c="dimmed">{execution.branch ?? '—'}</Table.Td>
|
|
103
|
+
<Table.Td>
|
|
104
|
+
<Code>{shortSha(execution.sha) ?? '—'}</Code>
|
|
105
|
+
</Table.Td>
|
|
106
|
+
<Table.Td>{execution.counted ? 'Yes' : `No: ${REASONS[execution.excluded ?? ''] ?? execution.excluded}`}</Table.Td>
|
|
107
|
+
<Table.Td>
|
|
108
|
+
<Anchor
|
|
109
|
+
aria-label={`Open the ${word(execution.status)} result recorded ${exactTime(execution.at)} in ${environmentLabel(execution.environment)}`}
|
|
110
|
+
aria-current={execution.layer === layer ? 'true' : undefined}
|
|
111
|
+
renderRoot={(props) => <Link {...props} to="/" search={patched({ layer: execution.layer })} resetScroll={false} activeOptions={{ exact: true }} />}
|
|
112
|
+
>
|
|
113
|
+
Open this result
|
|
114
|
+
</Anchor>
|
|
115
|
+
</Table.Td>
|
|
116
|
+
</Table.Tr>
|
|
117
|
+
))}
|
|
118
|
+
</Table.Tbody>
|
|
119
|
+
</Table>
|
|
120
|
+
</Table.ScrollContainer>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The reliability section of an open test: scope choices, the qualified figures, why they may mislead, and the
|
|
126
|
+
* executions they count. `entries` is every recorded result of the test the page holds, newest first; while it is
|
|
127
|
+
* `undefined` the history is still being read.
|
|
128
|
+
*/
|
|
129
|
+
export function Reliability({ testKey, entries }: { testKey: string; entries: readonly TestResultEntry[] | undefined }) {
|
|
130
|
+
const search = useExplorerSearch();
|
|
131
|
+
const go = useExplorerNavigate();
|
|
132
|
+
const scope = useMemo(() => reliabilityScopeOf(search), [search]);
|
|
133
|
+
const document = useMemo(() => (entries ? reliability(testKey, entries, scope) : null), [testKey, entries, scope]);
|
|
134
|
+
const parameters = useMemo(() => {
|
|
135
|
+
const counts = new Map<string, number>();
|
|
136
|
+
for (const entry of entries ?? []) for (const [name, value] of Object.entries(entry.parameters)) counts.set(`${name}=${value}`, (counts.get(`${name}=${value}`) ?? 0) + 1);
|
|
137
|
+
return [...counts].map(([value, count]) => ({ value, label: `${value} (${count})` }));
|
|
138
|
+
}, [entries]);
|
|
139
|
+
if (!document)
|
|
140
|
+
return (
|
|
141
|
+
<Box aria-label="Reliability" component="section" mb="lg">
|
|
142
|
+
<Title order={3} mb="sm" c="dimmed">
|
|
143
|
+
Reliability
|
|
144
|
+
</Title>
|
|
145
|
+
<Text role="status" component="div" c="dimmed">
|
|
146
|
+
Reading the history…
|
|
147
|
+
</Text>
|
|
148
|
+
</Box>
|
|
149
|
+
);
|
|
150
|
+
const { eventual, firstAttempt } = document;
|
|
151
|
+
const label = (list: readonly { value: string; count: number }[], format: (value: string) => string = (value) => value) => list.map(({ value, count }) => ({ value, label: `${format(value)} (${count})` }));
|
|
152
|
+
const warnings = document.notes.filter((note) => note.severity === 'warning');
|
|
153
|
+
const infos = document.notes.filter((note) => note.severity === 'info');
|
|
154
|
+
const scoped = Object.keys(scope).length > 0;
|
|
155
|
+
return (
|
|
156
|
+
<Box aria-label="Reliability" component="section" mb="lg">
|
|
157
|
+
<Title order={3} mb="sm" c="dimmed">
|
|
158
|
+
Reliability
|
|
159
|
+
</Title>
|
|
160
|
+
<Text component="div" c="dimmed" mb="sm">
|
|
161
|
+
Counted over independent executions: the snapshots one run recorded for one environment are one execution, and a retry is a try of it, not another
|
|
162
|
+
execution. This describes the checks Atlas holds, not how often the product fails in production.
|
|
163
|
+
</Text>
|
|
164
|
+
<Stack gap="sm" mb="md">
|
|
165
|
+
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
|
|
166
|
+
<Choice label="Environment" param="relEnv" options={label(document.available.environments, environmentLabel)} chosen={values(search.relEnv)} />
|
|
167
|
+
<Choice label="Producer" param="relProducer" options={label(document.available.producers)} chosen={values(search.relProducer)} />
|
|
168
|
+
<Choice label="Branch" param="relBranch" options={label(document.available.branches)} chosen={values(search.relBranch)} />
|
|
169
|
+
<Choice label="Parameters" param="relParam" options={parameters} chosen={values(search.relParam)} />
|
|
170
|
+
<TextInput type="date" label="Executions from" value={search.relSince ?? ''} onChange={(event) => go({ relSince: event.target.value || undefined }, { replace: true })} />
|
|
171
|
+
<TextInput type="date" label="Executions until" value={search.relUntil ?? ''} onChange={(event) => go({ relUntil: event.target.value || undefined }, { replace: true })} />
|
|
172
|
+
</SimpleGrid>
|
|
173
|
+
{document.available.specRevisions.length > 0 && (
|
|
174
|
+
<Checkbox
|
|
175
|
+
label="Include earlier revisions of the spec"
|
|
176
|
+
description={document.currentRevision ? 'Off: only executions of the newest recorded spec revision count; earlier ones are shown as context.' : 'On: every revision is pooled, though the behavior may have changed between them.'}
|
|
177
|
+
checked={search.relRevision === 'any'}
|
|
178
|
+
onChange={(event) => go({ relRevision: event.currentTarget.checked ? 'any' : undefined }, { replace: true })}
|
|
179
|
+
/>
|
|
180
|
+
)}
|
|
181
|
+
</Stack>
|
|
182
|
+
|
|
183
|
+
<Flex gap="xs" align="center" wrap="wrap" mb="sm" role="status" aria-label="Reliability sample">
|
|
184
|
+
<Badge color={document.sample === 'sufficient' ? 'gray' : 'orange'} variant="light">
|
|
185
|
+
{document.sample === 'none' ? 'No independent executions' : document.sample === 'insufficient' ? 'Insufficient evidence' : 'Sample large enough to read as a rate'}
|
|
186
|
+
</Badge>
|
|
187
|
+
<Text span inherit fw={600}>
|
|
188
|
+
{plural(eventual.executions, 'independent execution', 'independent executions')}
|
|
189
|
+
</Text>
|
|
190
|
+
<Text span inherit c="dimmed">
|
|
191
|
+
{scoped ? 'in this scope' : 'in all recorded history'}
|
|
192
|
+
{document.sample === 'insufficient' ? `; at least ${document.minimumExecutions} are needed before a rate means much` : ''}
|
|
193
|
+
</Text>
|
|
194
|
+
</Flex>
|
|
195
|
+
|
|
196
|
+
<SimpleGrid component="dl" cols={{ base: 1, sm: 2, md: 3 }}>
|
|
197
|
+
<Stack gap="sm">
|
|
198
|
+
<Box component="dt">Eventual success</Box>
|
|
199
|
+
<Box component="dd" m={0}>
|
|
200
|
+
{eventual.executions ? `${outOf(eventual.passed, eventual.executions)} (${percent(eventual.rate)})` : 'No rate: nothing completed in this scope'}
|
|
201
|
+
</Box>
|
|
202
|
+
</Stack>
|
|
203
|
+
<Stack gap="sm">
|
|
204
|
+
<Box component="dt">First-try success</Box>
|
|
205
|
+
<Box component="dd" m={0}>
|
|
206
|
+
{firstAttempt.executions ? `${outOf(firstAttempt.passed, firstAttempt.executions)} first time (${percent(firstAttempt.rate)})` : 'Unknown: no tries recorded'}
|
|
207
|
+
{document.unrecordedAttempts > 0 && firstAttempt.executions > 0 && (
|
|
208
|
+
<Text span inherit c="dimmed">
|
|
209
|
+
{' '}
|
|
210
|
+
· {document.unrecordedAttempts} recorded no tries
|
|
211
|
+
</Text>
|
|
212
|
+
)}
|
|
213
|
+
</Box>
|
|
214
|
+
</Stack>
|
|
215
|
+
<Stack gap="sm">
|
|
216
|
+
<Box component="dt">Tries</Box>
|
|
217
|
+
<Box component="dd" m={0}>
|
|
218
|
+
{document.attempts.total
|
|
219
|
+
? `${document.attempts.total} recorded; ${plural(document.attempts.retried, 'execution', 'executions')} retried, ${document.attempts.recovered} failed first and passed after`
|
|
220
|
+
: 'None recorded'}
|
|
221
|
+
</Box>
|
|
222
|
+
</Stack>
|
|
223
|
+
<Stack gap="sm">
|
|
224
|
+
<Box component="dt">Interval (95%)</Box>
|
|
225
|
+
<Box component="dd" m={0} title={document.interval?.assumptions}>
|
|
226
|
+
{document.interval ? `${percent(document.interval.lower)} to ${percent(document.interval.upper)} (Wilson)` : '—'}
|
|
227
|
+
</Box>
|
|
228
|
+
</Stack>
|
|
229
|
+
<Stack gap="sm">
|
|
230
|
+
<Box component="dt">Recorded snapshots</Box>
|
|
231
|
+
<Box component="dd" m={0}>
|
|
232
|
+
{document.snapshots} behind the counted {plural(eventual.executions, 'execution', 'executions')}
|
|
233
|
+
</Box>
|
|
234
|
+
</Stack>
|
|
235
|
+
<Stack gap="sm">
|
|
236
|
+
<Box component="dt">Not counted</Box>
|
|
237
|
+
<Box component="dd" m={0}>
|
|
238
|
+
{document.outsideScope} outside the scope
|
|
239
|
+
{Object.entries(document.excluded)
|
|
240
|
+
.filter(([, count]) => count > 0)
|
|
241
|
+
.map(([reason, count]) => `, ${count} ${(REASONS[reason] ?? reason).toLowerCase()}`)
|
|
242
|
+
.join('')}
|
|
243
|
+
</Box>
|
|
244
|
+
</Stack>
|
|
245
|
+
</SimpleGrid>
|
|
246
|
+
|
|
247
|
+
{warnings.length > 0 && (
|
|
248
|
+
<Alert color="orange" title="Read this figure with care" mt="sm">
|
|
249
|
+
<List>
|
|
250
|
+
{warnings.map((note) => (
|
|
251
|
+
<List.Item key={note.code}>{note.message}</List.Item>
|
|
252
|
+
))}
|
|
253
|
+
</List>
|
|
254
|
+
</Alert>
|
|
255
|
+
)}
|
|
256
|
+
{infos.length > 0 && (
|
|
257
|
+
<List mt="sm" c="dimmed">
|
|
258
|
+
{infos.map((note) => (
|
|
259
|
+
<List.Item key={note.code}>{note.message}</List.Item>
|
|
260
|
+
))}
|
|
261
|
+
</List>
|
|
262
|
+
)}
|
|
263
|
+
{document.interval && (
|
|
264
|
+
<Text component="div" c="dimmed" mt="sm">
|
|
265
|
+
Interval assumes: {document.interval.assumptions}
|
|
266
|
+
</Text>
|
|
267
|
+
)}
|
|
268
|
+
{document.historical.executions > 0 && (
|
|
269
|
+
<Text component="div" c="dimmed" mt="sm">
|
|
270
|
+
Earlier spec revisions, shown as context and not counted: {outOf(document.historical.passed, document.historical.executions)}.
|
|
271
|
+
</Text>
|
|
272
|
+
)}
|
|
273
|
+
|
|
274
|
+
{document.cohorts.length > 1 && (
|
|
275
|
+
<Table.ScrollContainer minWidth={500} mt="sm">
|
|
276
|
+
<Table>
|
|
277
|
+
<Table.Caption>Each environment, producer and parameter set on its own</Table.Caption>
|
|
278
|
+
<Table.Thead>
|
|
279
|
+
<Table.Tr>
|
|
280
|
+
<Table.Th scope="col">Environment</Table.Th>
|
|
281
|
+
<Table.Th scope="col">Producer</Table.Th>
|
|
282
|
+
<Table.Th scope="col">Executions</Table.Th>
|
|
283
|
+
<Table.Th scope="col">Passed</Table.Th>
|
|
284
|
+
</Table.Tr>
|
|
285
|
+
</Table.Thead>
|
|
286
|
+
<Table.Tbody>
|
|
287
|
+
{document.cohorts.map((cohort) => (
|
|
288
|
+
<Table.Tr key={JSON.stringify([cohort.environment, cohort.producer, cohort.parameters])}>
|
|
289
|
+
<Table.Td>
|
|
290
|
+
{environmentLabel(cohort.environment)}
|
|
291
|
+
{Object.entries(cohort.parameters).map(([name, value]) => (
|
|
292
|
+
<Text key={name} span inherit c="dimmed">
|
|
293
|
+
{' '}
|
|
294
|
+
{name}: {value}
|
|
295
|
+
</Text>
|
|
296
|
+
))}
|
|
297
|
+
</Table.Td>
|
|
298
|
+
<Table.Td>{cohort.producer}</Table.Td>
|
|
299
|
+
<Table.Td>{cohort.executions}</Table.Td>
|
|
300
|
+
<Table.Td>
|
|
301
|
+
{cohort.passed} ({percent(cohort.rate)})
|
|
302
|
+
</Table.Td>
|
|
303
|
+
</Table.Tr>
|
|
304
|
+
))}
|
|
305
|
+
</Table.Tbody>
|
|
306
|
+
</Table>
|
|
307
|
+
</Table.ScrollContainer>
|
|
308
|
+
)}
|
|
309
|
+
|
|
310
|
+
<Accordion variant="separated" mt="sm" value={search.relOpen ? 'denominator' : null} onChange={(value) => go({ relOpen: value ? true : undefined }, { replace: true })}>
|
|
311
|
+
<Accordion.Item value="denominator">
|
|
312
|
+
<Accordion.Control>
|
|
313
|
+
Executions behind this figure: {document.executions.filter((execution) => execution.counted).length} counted
|
|
314
|
+
{document.executions.some((execution) => !execution.counted) ? `, ${document.executions.filter((execution) => !execution.counted).length} not counted` : ''}
|
|
315
|
+
</Accordion.Control>
|
|
316
|
+
<Accordion.Panel>
|
|
317
|
+
<Stack gap="sm">
|
|
318
|
+
<Flex gap="xs" align="center" wrap="wrap">
|
|
319
|
+
<CopyButton value={typeof window === 'undefined' ? '' : window.location.href}>
|
|
320
|
+
{({ copied, copy }) => (
|
|
321
|
+
<Button type="button" variant="default" onClick={copy} leftSection={<IconLink size={16} aria-hidden="true" />}>
|
|
322
|
+
{copied ? 'Link copied' : 'Copy link to this scope'}
|
|
323
|
+
</Button>
|
|
324
|
+
)}
|
|
325
|
+
</CopyButton>
|
|
326
|
+
<Text span inherit c="dimmed">
|
|
327
|
+
The link opens this scope and these executions.
|
|
328
|
+
</Text>
|
|
329
|
+
</Flex>
|
|
330
|
+
{document.executions.length > 0 ? <Denominator executions={document.executions} layer={search.layer} /> : <Text c="dimmed">No execution of this test matches the scope.</Text>}
|
|
331
|
+
</Stack>
|
|
332
|
+
</Accordion.Panel>
|
|
333
|
+
</Accordion.Item>
|
|
334
|
+
</Accordion>
|
|
335
|
+
</Box>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IconAlertTriangle, IconArrowLeft, IconVideo } from '@tabler/icons-react';
|
|
1
|
+
import { IconAlertTriangle, IconArrowLeft, IconPlayerPlay, IconVideo } from '@tabler/icons-react';
|
|
2
2
|
import { Alert, Anchor, Badge, Box, Button, Code, Flex, Group, List, NativeSelect, Stack, Table, Text, TextInput, Title, VisuallyHidden } from '@mantine/core';
|
|
3
3
|
/**
|
|
4
4
|
* @module Runs view
|
|
@@ -25,6 +25,8 @@ import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidenc
|
|
|
25
25
|
import { Meter, StatusMark } from '../evidence/marks';
|
|
26
26
|
import { useRunDetail, useRunList, useTestCatalog } from './catalog';
|
|
27
27
|
import { RunArtifacts } from './RunArtifacts';
|
|
28
|
+
import { entriesFromRun } from './playlist-entries';
|
|
29
|
+
import { WatchPlaylist } from './playlist';
|
|
28
30
|
import { FilterBar } from './filters';
|
|
29
31
|
import { SearchNotes, TypedFilters } from './search-notes';
|
|
30
32
|
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
@@ -469,6 +471,8 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
469
471
|
),
|
|
470
472
|
[narrowedResults, status, statusFilters, model]
|
|
471
473
|
);
|
|
474
|
+
const [watching, setWatching] = useState(false);
|
|
475
|
+
const playlist = useMemo(() => entriesFromRun(filtered, detail.data?.layers), [filtered, detail.data]);
|
|
472
476
|
const bySpec = useMemo(() => {
|
|
473
477
|
const groups = new Map<string, RunResult[]>();
|
|
474
478
|
for (const result of filtered.slice(0, shown)) {
|
|
@@ -636,6 +640,12 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
636
640
|
No result matches.
|
|
637
641
|
</Text>
|
|
638
642
|
)}
|
|
643
|
+
{filtered.length > 0 && (
|
|
644
|
+
<Button type="button" variant="filled" my="sm" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => setWatching(true)}>
|
|
645
|
+
Watch these {plural(filtered.length, 'result', 'results')}
|
|
646
|
+
</Button>
|
|
647
|
+
)}
|
|
648
|
+
{watching && <WatchPlaylist entries={playlist} onClose={() => setWatching(false)} />}
|
|
639
649
|
{bySpec.map(([spec, list]) => (
|
|
640
650
|
<Box key={spec} data-open aria-label={spec || 'No spec'} component="section" mb="lg">
|
|
641
651
|
<Button data-static variant="default" fullWidth h="auto" py="sm" justify="flex-start">
|
|
@@ -21,6 +21,7 @@ import { outcomeHeadline, selectedResult } from '../evidence/outcome';
|
|
|
21
21
|
import { useTestHistory } from './catalog';
|
|
22
22
|
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
23
23
|
import { Badges, runName } from './parts';
|
|
24
|
+
import { Reliability } from './Reliability';
|
|
24
25
|
import { ProductLatency } from './ProductLatency';
|
|
25
26
|
import { usePinnedResult, type PinnedResult } from './pinned';
|
|
26
27
|
const RecordingPage = lazy(() => import('../evidence/recording-page').then((module) => ({ default: module.RecordingPage })));
|
|
@@ -405,6 +406,8 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
405
406
|
{entries.length > 1 && <Trend entries={entries} />}
|
|
406
407
|
</Box>
|
|
407
408
|
|
|
409
|
+
{!history.isError && <Reliability testKey={test.key} entries={history.data === null ? [] : history.data?.history} />}
|
|
410
|
+
|
|
408
411
|
<ProductLatency entries={entries} />
|
|
409
412
|
|
|
410
413
|
{(test.tags.length > 0 || Object.keys(test.labels).length > 0) && (
|
|
@@ -180,7 +180,7 @@ function usePageHistory(layers: readonly LayerEvidence[] | null): PageHistory |
|
|
|
180
180
|
const key = `${entry.layer.backend}|${entry.layer.id}`;
|
|
181
181
|
if (current.rows.has(key)) continue;
|
|
182
182
|
current.rows.set(key, entry.rows);
|
|
183
|
-
current.history.add(entry.layer, entry.rows, [], entry.layer.backend);
|
|
183
|
+
current.history.add(entry.layer, entry.rows, [], entry.layer.backend, entry.fingerprints);
|
|
184
184
|
}
|
|
185
185
|
return { history: current.history, stamp: `page:${current.gen}:${current.history.rev}` };
|
|
186
186
|
}, [layers]);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { VideoRef } from '@crvouga/atlas/reports/evidence-types';
|
|
2
|
+
import { expect, test } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import { entriesFromRun, playlistCounts, recordingState, stepFrom, type PlaylistEntry } from './playlist-entries';
|
|
5
|
+
|
|
6
|
+
const video = (recording?: string): VideoRef => ({ name: 'a.webm', owner: 'check', available: recording !== undefined, ...(recording ? { recording } : {}) });
|
|
7
|
+
const entry = (id: string, extra: Partial<PlaylistEntry> = {}): PlaylistEntry => ({
|
|
8
|
+
id, title: id, key: id, specPath: null, status: 'passed', environment: 'desktop-web', producer: 'web', at: 1, layer: 'l', backend: 'local', check: id, kind: null, tags: [], videos: [], ...extra
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('a recording state never says more than what is stored', () => {
|
|
12
|
+
expect(recordingState(entry('a', { videos: [video('sha256:1')] }))).toBe('playable');
|
|
13
|
+
expect(recordingState(entry('a', { videos: [video()] }))).toBe('unplayable');
|
|
14
|
+
// Bytes exist, but nothing says which backend to read them from.
|
|
15
|
+
expect(recordingState(entry('a', { videos: [video('sha256:1')], backend: null }))).toBe('unplayable');
|
|
16
|
+
expect(recordingState(entry('a', { environment: 'api-rest' }))).toBe('api-only');
|
|
17
|
+
expect(recordingState(entry('a', { environment: null }))).toBe('none');
|
|
18
|
+
expect(recordingState(entry('a'))).toBe('none');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('counts keep failures and gaps visible', () => {
|
|
22
|
+
const counts = playlistCounts([
|
|
23
|
+
entry('a', { videos: [video('sha256:1')] }),
|
|
24
|
+
entry('b', { status: 'failed', videos: [video('sha256:2')] }),
|
|
25
|
+
entry('c', { status: 'failed' }),
|
|
26
|
+
entry('d', { environment: 'api' })
|
|
27
|
+
]);
|
|
28
|
+
expect(counts).toEqual({ playable: 2, unplayable: 0, 'api-only': 1, none: 1, failed: 2 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('previous and next walk the watchable entries, skip gaps, wrap only when asked and work from a gap', () => {
|
|
32
|
+
const play = { videos: [video('sha256:1')] };
|
|
33
|
+
const list = [entry('a', play), entry('gap'), entry('b', play), entry('c', play)];
|
|
34
|
+
expect(stepFrom(list, 'a', 1, false)?.id).toBe('b');
|
|
35
|
+
expect(stepFrom(list, 'c', 1, false)).toBeNull();
|
|
36
|
+
expect(stepFrom(list, 'c', 1, true)?.id).toBe('a');
|
|
37
|
+
expect(stepFrom(list, 'a', -1, false)).toBeNull();
|
|
38
|
+
expect(stepFrom(list, 'a', -1, true)?.id).toBe('c');
|
|
39
|
+
expect(stepFrom(list, 'gap', 1, false)?.id).toBe('b');
|
|
40
|
+
expect(stepFrom(list, 'gap', -1, false)?.id).toBe('a');
|
|
41
|
+
expect(stepFrom([list[0]!], 'a', 1, true)?.id).toBe('a');
|
|
42
|
+
expect(stepFrom([entry('gap')], 'gap', 1, true)).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a run result gets its backend from the layers of the run, and none when they are not known', () => {
|
|
46
|
+
const result = { key: 'k', id: 'c', title: 'T', specPath: null, environment: 'ios', producer: 'p', status: 'failed' as const, durationMs: null, at: 1, layer: 'abc', detail: '', videos: [{ ...video('sha256:1'), tags: ['demo'] }] };
|
|
47
|
+
const layers = [{ id: 'abc', backend: 'shared', producer: 'p', environment: 'ios', recordedAtMs: 1, checks: 1, attachments: 0 }];
|
|
48
|
+
expect(entriesFromRun([result], layers)[0]).toMatchObject({ backend: 'shared', check: 'c', status: 'failed', tags: ['demo'] });
|
|
49
|
+
expect(entriesFromRun([result], undefined)[0]!.backend).toBeNull();
|
|
50
|
+
expect(recordingState(entriesFromRun([result], undefined)[0]!)).toBe('unplayable');
|
|
51
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Recording playlist
|
|
3
|
+
*
|
|
4
|
+
* The order and the honesty of a watch session: a filtered set of recorded results becomes an ordered list of entries,
|
|
5
|
+
* each with what can be said about its recording before anything is downloaded. Every result of the set is an entry
|
|
6
|
+
* (a failed one is never dropped, and one without a recording says so) and the order is the order of the set it came
|
|
7
|
+
* from, so the same filters give the same playlist. Pure functions; the player is `playlist.tsx`.
|
|
8
|
+
*/
|
|
9
|
+
import { videoCounts } from '@crvouga/atlas/reports/evidence-detail';
|
|
10
|
+
import type { VideoRef } from '@crvouga/atlas/reports/evidence-types';
|
|
11
|
+
import type { EvidenceStatus } from '@crvouga/atlas/reports/explorer-types';
|
|
12
|
+
import type { ArtifactResult, RunLayer, RunResult } from '@crvouga/atlas/reports/test-catalog';
|
|
13
|
+
|
|
14
|
+
/** One recorded result in a playlist: where to read its evidence, what to say about it, and its video references. */
|
|
15
|
+
export type PlaylistEntry = {
|
|
16
|
+
/** Identifies the entry for as long as the set is the same; selection follows it, never a position. */
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly title: string;
|
|
19
|
+
/** The test's key, to open it. */
|
|
20
|
+
readonly key: string;
|
|
21
|
+
readonly specPath: string | null;
|
|
22
|
+
readonly status: EvidenceStatus;
|
|
23
|
+
readonly environment: string | null;
|
|
24
|
+
readonly producer: string;
|
|
25
|
+
/** When it was recorded, epoch ms. */
|
|
26
|
+
readonly at: number;
|
|
27
|
+
/** The layer that recorded it and the backend to read it from; null when the backend is not known (an older server). */
|
|
28
|
+
readonly layer: string;
|
|
29
|
+
readonly backend: string | null;
|
|
30
|
+
/** The check's id and, when known, the kind of its part: with the layer they name the evidence to read. */
|
|
31
|
+
readonly check: string;
|
|
32
|
+
readonly kind: string | null;
|
|
33
|
+
/** The tags its attachments carry. */
|
|
34
|
+
readonly tags: readonly string[];
|
|
35
|
+
/** Every video its evidence references. */
|
|
36
|
+
readonly videos: readonly VideoRef[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What is known about an entry's recording: `playable` (a stored file can be tried), `unplayable` (referenced, but no
|
|
41
|
+
* bytes were published, or the backend to read them from is unknown), `api-only` (no recording and recorded through
|
|
42
|
+
* an API, so there is no screen) or `none`.
|
|
43
|
+
*/
|
|
44
|
+
export type RecordingState = 'playable' | 'unplayable' | 'api-only' | 'none';
|
|
45
|
+
|
|
46
|
+
/** Environments a reader cannot watch: a result recorded only through these has no screen. */
|
|
47
|
+
const API_ENVIRONMENT = /^(api|http|rest|graphql|service|backend)(-|$)/;
|
|
48
|
+
|
|
49
|
+
/** The state of an entry's recording. Only `playable` means there is something to watch; none of the others is a pass with proof. */
|
|
50
|
+
export function recordingState(entry: Pick<PlaylistEntry, 'videos' | 'backend' | 'environment'>): RecordingState {
|
|
51
|
+
const counts = videoCounts(entry.videos);
|
|
52
|
+
if (counts && counts.recordings > 0) return entry.backend === null ? 'unplayable' : 'playable';
|
|
53
|
+
if (counts) return 'unplayable';
|
|
54
|
+
return entry.environment !== null && API_ENVIRONMENT.test(entry.environment) ? 'api-only' : 'none';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What each state says to a person. A recording is never described as demonstrating anything it does not. */
|
|
58
|
+
export const RECORDING_STATE_TEXT: Record<RecordingState, { readonly label: string; readonly meaning: string }> = {
|
|
59
|
+
playable: { label: 'Recording', meaning: 'A recording is stored for this result.' },
|
|
60
|
+
unplayable: { label: 'Recording not available', meaning: 'This result references a recording, but no stored file can be played, so it is not demonstrated.' },
|
|
61
|
+
'api-only': { label: 'API only', meaning: 'This result was recorded through an API, so there is no screen to watch. It is not demonstrated by a recording.' },
|
|
62
|
+
none: { label: 'No recording', meaning: 'No recording was published for this result, so it is not demonstrated by one.' }
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The first video of an entry that names bytes: the one that is played. */
|
|
66
|
+
export function playableVideo(entry: Pick<PlaylistEntry, 'videos'>): VideoRef | undefined {
|
|
67
|
+
return entry.videos.find((video) => video.available && video.recording !== undefined);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The recorded results of the Artifacts tab as playlist entries, in their order. */
|
|
71
|
+
export function entriesFromArtifacts(results: readonly ArtifactResult[]): PlaylistEntry[] {
|
|
72
|
+
return results.map((result) => ({
|
|
73
|
+
id: `${result.layer}|${result.key}|${result.kind}|${result.producer}|${result.environment ?? ''}`,
|
|
74
|
+
title: result.title,
|
|
75
|
+
key: result.key,
|
|
76
|
+
specPath: result.specPath,
|
|
77
|
+
status: result.status,
|
|
78
|
+
environment: result.environment,
|
|
79
|
+
producer: result.producer,
|
|
80
|
+
at: result.at,
|
|
81
|
+
layer: result.layer,
|
|
82
|
+
backend: result.backend,
|
|
83
|
+
check: result.id,
|
|
84
|
+
kind: result.kind,
|
|
85
|
+
tags: result.artifactTags,
|
|
86
|
+
videos: result.videos ?? []
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The results of one run as playlist entries, in their order; `layers` say which backend each result's layer was read from. */
|
|
91
|
+
export function entriesFromRun(results: readonly RunResult[], layers: readonly RunLayer[] | undefined): PlaylistEntry[] {
|
|
92
|
+
const backends = new Map((layers ?? []).map((layer) => [layer.id, layer.backend]));
|
|
93
|
+
return results.map((result) => ({
|
|
94
|
+
id: `${result.layer}|${result.key}||${result.producer}|${result.environment ?? ''}`,
|
|
95
|
+
title: result.title,
|
|
96
|
+
key: result.key,
|
|
97
|
+
specPath: result.specPath,
|
|
98
|
+
status: result.status,
|
|
99
|
+
environment: result.environment,
|
|
100
|
+
producer: result.producer,
|
|
101
|
+
at: result.at,
|
|
102
|
+
layer: result.layer,
|
|
103
|
+
backend: backends.get(result.layer) ?? null,
|
|
104
|
+
check: result.id,
|
|
105
|
+
kind: null,
|
|
106
|
+
tags: [...new Set((result.videos ?? []).flatMap((video) => video.tags ?? []))].sort(),
|
|
107
|
+
videos: result.videos ?? []
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** How many entries are in each state, and how many failed (a failure is counted whether or not it was recorded). */
|
|
112
|
+
export function playlistCounts(entries: readonly PlaylistEntry[]): Record<RecordingState, number> & { readonly failed: number } {
|
|
113
|
+
const counts = { playable: 0, unplayable: 0, 'api-only': 0, none: 0, failed: 0 };
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
counts[recordingState(entry)] += 1;
|
|
116
|
+
if (entry.status === 'failed') counts.failed += 1;
|
|
117
|
+
}
|
|
118
|
+
return counts;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The entry to go to from `from`, over the entries that can be watched. `step` is 1 for next, -1 for previous. Past the
|
|
123
|
+
* end it wraps to the other end when `wrap`, else it is null. From an entry that cannot be watched (selected from the
|
|
124
|
+
* list to read why), it is the nearest watchable one in that direction.
|
|
125
|
+
*/
|
|
126
|
+
export function stepFrom(entries: readonly PlaylistEntry[], from: string | null, step: 1 | -1, wrap: boolean): PlaylistEntry | null {
|
|
127
|
+
const at = from === null ? -1 : entries.findIndex((entry) => entry.id === from);
|
|
128
|
+
const watchable = (entry: PlaylistEntry) => recordingState(entry) === 'playable';
|
|
129
|
+
const ordered = step === 1 ? entries.map((entry, index) => [entry, index] as const) : entries.map((entry, index) => [entry, index] as const).reverse();
|
|
130
|
+
const after = ordered.filter(([entry, index]) => watchable(entry) && (at < 0 ? true : step === 1 ? index > at : index < at));
|
|
131
|
+
if (after.length > 0) return after[0]![0];
|
|
132
|
+
if (!wrap) return null;
|
|
133
|
+
return ordered.find(([entry]) => watchable(entry))?.[0] ?? null;
|
|
134
|
+
}
|