@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
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Watch playlist
|
|
3
|
+
*
|
|
4
|
+
* A watch session over a filtered set of recorded results: the player and the plain-language scenario beside an ordered
|
|
5
|
+
* list of every result in the set. A failed result stays in the list with its outcome, and a result with no recording,
|
|
6
|
+
* an API-only one or one whose file is missing says so in words instead of being left out or shown as demonstrated.
|
|
7
|
+
*
|
|
8
|
+
* Only the clip on screen is read: the list is made of the metadata the catalog already sent, the current result's
|
|
9
|
+
* evidence is read when it is selected, and its video is loaded with `preload="metadata"`, so opening the playlist of a
|
|
10
|
+
* large library downloads no recordings in bulk. There is one `<video>`; selecting another clip replaces it and keeps
|
|
11
|
+
* the position of the one left, so returning resumes there. A clip that will not load (or whose autoplay the browser
|
|
12
|
+
* blocked) stays in the list and offers Retry, Play and Next. Recordings are always labelled illustrative: playback
|
|
13
|
+
* length and speed are those of the recording, never production latency.
|
|
14
|
+
*/
|
|
15
|
+
import { attachmentIdentity, isVideoAttachment, type CheckDetail } from '@crvouga/atlas/reports/evidence-detail';
|
|
16
|
+
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
17
|
+
import { contentTypeOf } from '@crvouga/atlas/reports/media-types';
|
|
18
|
+
import { Alert, Anchor, Badge, Box, Button, Code, Flex, Grid, Group, List, Modal, NativeSelect, Stack, Switch, Text, Title } from '@mantine/core';
|
|
19
|
+
import { IconAlertTriangle, IconPlayerPlay, IconPlayerSkipBack, IconPlayerSkipForward } from '@tabler/icons-react';
|
|
20
|
+
import { useQuery } from '@tanstack/react-query';
|
|
21
|
+
import { Link } from '@tanstack/react-router';
|
|
22
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
23
|
+
|
|
24
|
+
import { reportJson } from '../evidence/api';
|
|
25
|
+
import { attachmentSource } from '../evidence/artifacts';
|
|
26
|
+
import { everyAttachment } from '../evidence/artifact-tags';
|
|
27
|
+
import { ScopeNotice } from '../evidence/evidence-scope';
|
|
28
|
+
import { duration, exactTime, plural, statusWord } from '../evidence/format';
|
|
29
|
+
import { StatusMark } from '../evidence/marks';
|
|
30
|
+
import { patched } from './navigate';
|
|
31
|
+
import { playableVideo, playlistCounts, RECORDING_STATE_TEXT, recordingState, stepFrom, type PlaylistEntry } from './playlist-entries';
|
|
32
|
+
|
|
33
|
+
/** Entries listed before "more"; the selected entry is always listed. */
|
|
34
|
+
const LISTED = 50;
|
|
35
|
+
/** Playback speeds offered. */
|
|
36
|
+
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
|
|
37
|
+
/** Step names are shown with their Gherkin keyword when they have one. */
|
|
38
|
+
const KEYWORDS = new Set(['given', 'when', 'then', 'and', 'but']);
|
|
39
|
+
|
|
40
|
+
/** The evidence of one entry's check, read when the entry is on screen. */
|
|
41
|
+
function useEntryDetail(entry: PlaylistEntry, enabled: boolean) {
|
|
42
|
+
return useQuery({
|
|
43
|
+
queryKey: ['evidence-detail', entry.backend ?? '', entry.layer, entry.kind ?? '', entry.check],
|
|
44
|
+
queryFn: ({ signal }) =>
|
|
45
|
+
reportJson<CheckDetail>(
|
|
46
|
+
`/evidence/detail/${encodeURIComponent(entry.backend!)}/${encodeURIComponent(entry.layer)}?check=${encodeURIComponent(entry.check)}${entry.kind ? `&kind=${encodeURIComponent(entry.kind)}` : ''}`,
|
|
47
|
+
signal
|
|
48
|
+
),
|
|
49
|
+
enabled: enabled && entry.backend !== null && /^[0-9a-f]{64}$/.test(entry.layer),
|
|
50
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
51
|
+
retry: false
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The scenario in words: the steps the producer recorded, with the Gherkin keyword where it gave one. */
|
|
56
|
+
function Scenario({ entry, detail }: { entry: PlaylistEntry; detail: CheckDetail | undefined }) {
|
|
57
|
+
const steps = detail?.check.steps ?? [];
|
|
58
|
+
return (
|
|
59
|
+
<Stack gap="xs" component="section" aria-label="Scenario">
|
|
60
|
+
<Title order={3}>{entry.title}</Title>
|
|
61
|
+
{steps.length > 0 && (
|
|
62
|
+
<List type="ordered" spacing="xs" aria-label="Steps">
|
|
63
|
+
{steps.map((step, index) => (
|
|
64
|
+
<List.Item key={`${step.id ?? step.name}:${index}`}>
|
|
65
|
+
{step.kind && KEYWORDS.has(step.kind.toLowerCase()) ? `${step.kind.charAt(0).toUpperCase()}${step.kind.slice(1).toLowerCase()} ` : ''}
|
|
66
|
+
{step.name}
|
|
67
|
+
{step.status && step.status !== 'passed' ? ` (${step.status})` : ''}
|
|
68
|
+
</List.Item>
|
|
69
|
+
))}
|
|
70
|
+
</List>
|
|
71
|
+
)}
|
|
72
|
+
{entry.status === 'failed' && detail?.check.detail && (
|
|
73
|
+
<Alert color="red" title="Why it failed" role="status">
|
|
74
|
+
<Text component="div" lineClamp={4}>
|
|
75
|
+
{detail.check.detail}
|
|
76
|
+
</Text>
|
|
77
|
+
</Alert>
|
|
78
|
+
)}
|
|
79
|
+
</Stack>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Who recorded the entry and what it carries, in one line each. */
|
|
84
|
+
function Facts({ entry }: { entry: PlaylistEntry }) {
|
|
85
|
+
return (
|
|
86
|
+
<Stack gap="xs" aria-label="Recording facts">
|
|
87
|
+
<Group gap="xs" wrap="wrap">
|
|
88
|
+
<StatusMark status={entry.status} size={14} />
|
|
89
|
+
<Text span inherit fw={600} data-outcome={entry.status}>
|
|
90
|
+
{statusWord(entry.status)}
|
|
91
|
+
</Text>
|
|
92
|
+
<Text span inherit c="dimmed">
|
|
93
|
+
{[environmentLabel(entry.environment), entry.producer, `recorded ${exactTime(entry.at)}`].join(' · ')}
|
|
94
|
+
</Text>
|
|
95
|
+
</Group>
|
|
96
|
+
{entry.tags.length > 0 && (
|
|
97
|
+
<Group gap="xs" aria-label="Tags">
|
|
98
|
+
{entry.tags.map((tag) => (
|
|
99
|
+
<Badge key={tag} variant="light" tt="none">
|
|
100
|
+
#{tag}
|
|
101
|
+
</Badge>
|
|
102
|
+
))}
|
|
103
|
+
</Group>
|
|
104
|
+
)}
|
|
105
|
+
{entry.specPath && (
|
|
106
|
+
<Text component="div" c="dimmed">
|
|
107
|
+
Spec <Code>{entry.specPath}</Code>
|
|
108
|
+
</Text>
|
|
109
|
+
)}
|
|
110
|
+
</Stack>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The one player. It is keyed by its entry, so a new clip is a fresh element and the previous one is gone. */
|
|
115
|
+
function Clip({
|
|
116
|
+
entry,
|
|
117
|
+
speed,
|
|
118
|
+
autoPlay,
|
|
119
|
+
positions,
|
|
120
|
+
onEnded,
|
|
121
|
+
onFailed,
|
|
122
|
+
onNext,
|
|
123
|
+
onClose
|
|
124
|
+
}: {
|
|
125
|
+
entry: PlaylistEntry;
|
|
126
|
+
speed: number;
|
|
127
|
+
autoPlay: boolean;
|
|
128
|
+
positions: Map<string, number>;
|
|
129
|
+
onEnded: () => PlaylistEntry | null;
|
|
130
|
+
onFailed: (id: string) => void;
|
|
131
|
+
onNext: (() => void) | null;
|
|
132
|
+
onClose: () => void;
|
|
133
|
+
}) {
|
|
134
|
+
const state = recordingState(entry);
|
|
135
|
+
const detail = useEntryDetail(entry, state === 'playable');
|
|
136
|
+
const video = useRef<HTMLVideoElement>(null);
|
|
137
|
+
const [failed, setFailed] = useState(false);
|
|
138
|
+
const [blocked, setBlocked] = useState(false);
|
|
139
|
+
const [attempt, setAttempt] = useState(0);
|
|
140
|
+
const chosen = playableVideo(entry);
|
|
141
|
+
const attachments = useMemo(() => (detail.data ? everyAttachment(detail.data.check) : []), [detail.data]);
|
|
142
|
+
const movie = useMemo(() => {
|
|
143
|
+
const videos = attachments.filter(isVideoAttachment);
|
|
144
|
+
return videos.find((item) => attachmentIdentity(item) === chosen?.recording) ?? videos[0];
|
|
145
|
+
}, [attachments, chosen]);
|
|
146
|
+
const captions = useMemo(
|
|
147
|
+
() => attachments.filter((item) => (item.contentType ?? contentTypeOf(item.name)).toLowerCase().startsWith('text/vtt')),
|
|
148
|
+
[attachments]
|
|
149
|
+
);
|
|
150
|
+
const url = movie ? attachmentSource(movie, entry.layer).url : null;
|
|
151
|
+
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (video.current) video.current.playbackRate = speed;
|
|
154
|
+
}, [speed]);
|
|
155
|
+
|
|
156
|
+
const play = () => {
|
|
157
|
+
const element = video.current;
|
|
158
|
+
if (!element) return;
|
|
159
|
+
setBlocked(false);
|
|
160
|
+
// `play()` rejects when the browser blocks it; a browser without a promise has nothing to catch.
|
|
161
|
+
const started = element.play() as Promise<void> | undefined;
|
|
162
|
+
started?.catch(() => setBlocked(true));
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const developer = (
|
|
166
|
+
<Anchor
|
|
167
|
+
renderRoot={(props) => (
|
|
168
|
+
<Link {...props} to="/" search={patched({ tab: undefined, openRun: undefined, artifact: undefined, test: entry.key, layer: entry.layer })} resetScroll={false} activeOptions={{ exact: true }} onClick={onClose} />
|
|
169
|
+
)}
|
|
170
|
+
>
|
|
171
|
+
Developer details
|
|
172
|
+
</Anchor>
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
let body;
|
|
176
|
+
if (state !== 'playable') {
|
|
177
|
+
body = (
|
|
178
|
+
<Alert color="orange" title={RECORDING_STATE_TEXT[state].label} icon={<IconAlertTriangle size={16} aria-hidden="true" />} role="status" data-recording-state={state}>
|
|
179
|
+
{RECORDING_STATE_TEXT[state].meaning}
|
|
180
|
+
</Alert>
|
|
181
|
+
);
|
|
182
|
+
} else if (detail.isPending) {
|
|
183
|
+
body = (
|
|
184
|
+
<Text role="status" component="div" c="dimmed">
|
|
185
|
+
Loading the recording…
|
|
186
|
+
</Text>
|
|
187
|
+
);
|
|
188
|
+
} else if (detail.isError || !url) {
|
|
189
|
+
body = (
|
|
190
|
+
<Alert role="alert" color="orange" title="This recording cannot be played" data-clip-failed>
|
|
191
|
+
<Stack gap="xs" align="flex-start">
|
|
192
|
+
<Text component="div">{detail.isError ? 'Its evidence could not be read.' : 'The result names a recording, but no file can be played from here.'}</Text>
|
|
193
|
+
<Group gap="xs">
|
|
194
|
+
{detail.isError && (
|
|
195
|
+
<Button type="button" variant="light" onClick={() => void detail.refetch()}>
|
|
196
|
+
Try again
|
|
197
|
+
</Button>
|
|
198
|
+
)}
|
|
199
|
+
{onNext && (
|
|
200
|
+
<Button type="button" variant="light" onClick={onNext}>
|
|
201
|
+
Next clip
|
|
202
|
+
</Button>
|
|
203
|
+
)}
|
|
204
|
+
</Group>
|
|
205
|
+
</Stack>
|
|
206
|
+
</Alert>
|
|
207
|
+
);
|
|
208
|
+
} else {
|
|
209
|
+
body = (
|
|
210
|
+
<Stack gap="xs">
|
|
211
|
+
<ScopeNotice scope={chosen?.scope} name={movie!.name} />
|
|
212
|
+
<Box
|
|
213
|
+
key={attempt}
|
|
214
|
+
component="video"
|
|
215
|
+
ref={video}
|
|
216
|
+
src={url}
|
|
217
|
+
controls
|
|
218
|
+
preload="metadata"
|
|
219
|
+
aria-label={`Recording: ${movie!.description ?? movie!.name}`}
|
|
220
|
+
maw="100%"
|
|
221
|
+
w="100%"
|
|
222
|
+
mah="70vh"
|
|
223
|
+
data-playing={entry.id}
|
|
224
|
+
onLoadedMetadata={() => {
|
|
225
|
+
const element = video.current!;
|
|
226
|
+
element.playbackRate = speed;
|
|
227
|
+
const saved = positions.get(entry.id);
|
|
228
|
+
if (saved !== undefined && saved < (element.duration || Number.POSITIVE_INFINITY)) element.currentTime = saved;
|
|
229
|
+
if (autoPlay) play();
|
|
230
|
+
}}
|
|
231
|
+
onTimeUpdate={() => positions.set(entry.id, video.current!.currentTime)}
|
|
232
|
+
onEnded={() => {
|
|
233
|
+
positions.delete(entry.id);
|
|
234
|
+
const next = onEnded();
|
|
235
|
+
if (next && next.id === entry.id && video.current) {
|
|
236
|
+
video.current.currentTime = 0;
|
|
237
|
+
play();
|
|
238
|
+
}
|
|
239
|
+
}}
|
|
240
|
+
onError={() => {
|
|
241
|
+
setFailed(true);
|
|
242
|
+
onFailed(entry.id);
|
|
243
|
+
}}
|
|
244
|
+
>
|
|
245
|
+
{captions.map((caption) => {
|
|
246
|
+
const source = attachmentSource(caption, entry.layer).url;
|
|
247
|
+
return source ? <track key={source} kind="captions" src={source} label={caption.description ?? caption.name} /> : null;
|
|
248
|
+
})}
|
|
249
|
+
</Box>
|
|
250
|
+
{movie!.durationMs ? (
|
|
251
|
+
<Text component="div" c="dimmed">
|
|
252
|
+
Length of the recording: {duration(movie!.durationMs)}
|
|
253
|
+
</Text>
|
|
254
|
+
) : null}
|
|
255
|
+
{captions.length > 0 && (
|
|
256
|
+
<Text component="div" c="dimmed">
|
|
257
|
+
Captions are offered in the player's captions control: {captions.map((caption) => caption.description ?? caption.name).join(', ')}.
|
|
258
|
+
</Text>
|
|
259
|
+
)}
|
|
260
|
+
{blocked && (
|
|
261
|
+
<Alert color="blue" role="status" title="Autoplay was blocked" data-autoplay-blocked>
|
|
262
|
+
<Stack gap="xs" align="flex-start">
|
|
263
|
+
<Text component="div">The browser did not start this clip by itself. Play it to continue.</Text>
|
|
264
|
+
<Button type="button" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={play}>
|
|
265
|
+
Play
|
|
266
|
+
</Button>
|
|
267
|
+
</Stack>
|
|
268
|
+
</Alert>
|
|
269
|
+
)}
|
|
270
|
+
{failed && (
|
|
271
|
+
<Alert color="orange" role="alert" title="This video cannot be played" data-clip-failed>
|
|
272
|
+
<Stack gap="xs" align="flex-start">
|
|
273
|
+
<Text component="div">The file could not be loaded, or this browser cannot play its format. It stays in the playlist.</Text>
|
|
274
|
+
<Group gap="xs">
|
|
275
|
+
<Button
|
|
276
|
+
type="button"
|
|
277
|
+
variant="light"
|
|
278
|
+
onClick={() => {
|
|
279
|
+
setFailed(false);
|
|
280
|
+
setAttempt(attempt + 1);
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
Retry
|
|
284
|
+
</Button>
|
|
285
|
+
{onNext && (
|
|
286
|
+
<Button type="button" variant="light" onClick={onNext}>
|
|
287
|
+
Next clip
|
|
288
|
+
</Button>
|
|
289
|
+
)}
|
|
290
|
+
</Group>
|
|
291
|
+
</Stack>
|
|
292
|
+
</Alert>
|
|
293
|
+
)}
|
|
294
|
+
</Stack>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
return (
|
|
298
|
+
<Stack gap="md" miw={0} aria-label={`Now showing ${entry.title}`} component="section" data-clip={entry.id}>
|
|
299
|
+
{body}
|
|
300
|
+
<Facts entry={entry} />
|
|
301
|
+
<Scenario entry={entry} detail={detail.data} />
|
|
302
|
+
<Text component="div" c="dimmed">
|
|
303
|
+
Illustrative recording of one run. Its length and speed are those of the recording, not production latency, and it does not approve the behavior for production.
|
|
304
|
+
</Text>
|
|
305
|
+
{developer}
|
|
306
|
+
</Stack>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The watch session for `entries`: opens as a modal over the explorer. `total` is how many results matched when the list
|
|
312
|
+
* holds only the newest of them, so the session says it is not the whole set. Closing it discards positions.
|
|
313
|
+
*/
|
|
314
|
+
export function WatchPlaylist({ entries, total, onClose }: { entries: readonly PlaylistEntry[]; total?: number; onClose: () => void }) {
|
|
315
|
+
const first = entries.find((entry) => recordingState(entry) === 'playable') ?? entries[0];
|
|
316
|
+
const [chosen, setChosen] = useState<string | null>(first?.id ?? null);
|
|
317
|
+
const [autoPlay, setAutoPlay] = useState(false);
|
|
318
|
+
const [speed, setSpeed] = useState<number>(1);
|
|
319
|
+
const [advance, setAdvance] = useState(true);
|
|
320
|
+
const [repeat, setRepeat] = useState(false);
|
|
321
|
+
const [failedIds, setFailedIds] = useState<ReadonlySet<string>>(new Set());
|
|
322
|
+
const [shown, setShown] = useState(LISTED);
|
|
323
|
+
const positions = useRef(new Map<string, number>());
|
|
324
|
+
const active = entries.find((entry) => entry.id === chosen) ?? first ?? null;
|
|
325
|
+
const counts = useMemo(() => playlistCounts(entries), [entries]);
|
|
326
|
+
const activeAt = active ? entries.indexOf(active) : -1;
|
|
327
|
+
const next = stepFrom(entries, active?.id ?? null, 1, repeat);
|
|
328
|
+
const previous = stepFrom(entries, active?.id ?? null, -1, repeat);
|
|
329
|
+
const select = (entry: PlaylistEntry, play: boolean) => {
|
|
330
|
+
setAutoPlay(play);
|
|
331
|
+
setChosen(entry.id);
|
|
332
|
+
};
|
|
333
|
+
const listed = entries.slice(0, Math.max(shown, activeAt + 1));
|
|
334
|
+
return (
|
|
335
|
+
<Modal opened onClose={onClose} title="Watch recordings" size="xl" centered>
|
|
336
|
+
<Stack gap="md">
|
|
337
|
+
<Text role="status" component="div" data-playlist-summary>
|
|
338
|
+
{plural(entries.length, 'result', 'results')}
|
|
339
|
+
{total !== undefined && total > entries.length ? ` (the newest of ${total.toLocaleString()} matching)` : ''}: {counts.playable} with a recording
|
|
340
|
+
{counts.unplayable > 0 ? `, ${counts.unplayable} whose recording is not available` : ''}
|
|
341
|
+
{counts['api-only'] > 0 ? `, ${counts['api-only']} API only` : ''}
|
|
342
|
+
{counts.none > 0 ? `, ${counts.none} with no recording` : ''}. {plural(counts.failed, 'failed result', 'failed results')}
|
|
343
|
+
{counts.failed > 0 ? ' kept in the playlist' : ''}.
|
|
344
|
+
</Text>
|
|
345
|
+
{entries.length === 0 && (
|
|
346
|
+
<Text component="div" c="dimmed">
|
|
347
|
+
No result is in this set, so there is nothing to watch.
|
|
348
|
+
</Text>
|
|
349
|
+
)}
|
|
350
|
+
{active && (
|
|
351
|
+
<Group role="group" aria-label="Playlist controls" align="flex-end" gap="md" wrap="wrap">
|
|
352
|
+
<Button type="button" variant="default" leftSection={<IconPlayerSkipBack size={16} aria-hidden="true" />} disabled={!previous} onClick={() => previous && select(previous, true)}>
|
|
353
|
+
Previous clip
|
|
354
|
+
</Button>
|
|
355
|
+
<Button type="button" variant="default" rightSection={<IconPlayerSkipForward size={16} aria-hidden="true" />} disabled={!next} onClick={() => next && select(next, true)}>
|
|
356
|
+
Next clip
|
|
357
|
+
</Button>
|
|
358
|
+
<NativeSelect
|
|
359
|
+
label="Playback speed"
|
|
360
|
+
value={String(speed)}
|
|
361
|
+
onChange={(event) => setSpeed(Number(event.currentTarget.value))}
|
|
362
|
+
data={SPEEDS.map((value) => ({ value: String(value), label: `${value}×` }))}
|
|
363
|
+
/>
|
|
364
|
+
<Switch label="Play the next clip when one ends" checked={advance} onChange={(event) => setAdvance(event.currentTarget.checked)} />
|
|
365
|
+
<Switch label="Repeat" description="Start over after the last clip, or replay a clip when not advancing" checked={repeat} onChange={(event) => setRepeat(event.currentTarget.checked)} />
|
|
366
|
+
</Group>
|
|
367
|
+
)}
|
|
368
|
+
{active && (
|
|
369
|
+
<Text c="dimmed" component="div" size="sm">
|
|
370
|
+
Previous and next skip results with nothing to play; select one in the list to see why.
|
|
371
|
+
</Text>
|
|
372
|
+
)}
|
|
373
|
+
<Grid gap="md">
|
|
374
|
+
<Grid.Col span={{ base: 12, md: 8 }}>
|
|
375
|
+
{active && (
|
|
376
|
+
<Clip
|
|
377
|
+
key={active.id}
|
|
378
|
+
entry={active}
|
|
379
|
+
speed={speed}
|
|
380
|
+
autoPlay={autoPlay}
|
|
381
|
+
positions={positions.current}
|
|
382
|
+
onEnded={() => {
|
|
383
|
+
const target = advance ? stepFrom(entries, active.id, 1, repeat) : repeat ? active : null;
|
|
384
|
+
if (target && target.id !== active.id) select(target, true);
|
|
385
|
+
return target;
|
|
386
|
+
}}
|
|
387
|
+
onFailed={(id) => setFailedIds((held) => new Set(held).add(id))}
|
|
388
|
+
onNext={next ? () => select(next, true) : null}
|
|
389
|
+
onClose={onClose}
|
|
390
|
+
/>
|
|
391
|
+
)}
|
|
392
|
+
</Grid.Col>
|
|
393
|
+
<Grid.Col span={{ base: 12, md: 4 }}>
|
|
394
|
+
<Stack gap="xs" component="section" aria-label="Playlist">
|
|
395
|
+
<Title order={3}>Playlist</Title>
|
|
396
|
+
<List spacing="xs" listStyleType="none">
|
|
397
|
+
{listed.map((entry, index) => {
|
|
398
|
+
const state = recordingState(entry);
|
|
399
|
+
const here = entry.id === active?.id;
|
|
400
|
+
return (
|
|
401
|
+
<List.Item key={entry.id} data-entry={entry.id} data-status={entry.status} data-recording-state={state}>
|
|
402
|
+
<Button
|
|
403
|
+
type="button"
|
|
404
|
+
variant={here ? 'light' : 'subtle'}
|
|
405
|
+
fullWidth
|
|
406
|
+
h="auto"
|
|
407
|
+
py="xs"
|
|
408
|
+
justify="flex-start"
|
|
409
|
+
aria-current={here ? 'true' : undefined}
|
|
410
|
+
aria-label={`${index + 1}. ${entry.title}, ${statusWord(entry.status)}, ${failedIds.has(entry.id) ? 'could not be loaded' : RECORDING_STATE_TEXT[state].label}`}
|
|
411
|
+
onClick={() => select(entry, state === 'playable')}
|
|
412
|
+
>
|
|
413
|
+
<Flex component="span" gap="xs" wrap="nowrap" align="flex-start">
|
|
414
|
+
<StatusMark status={entry.status} size={14} />
|
|
415
|
+
<Flex component="span" direction="column" align="flex-start">
|
|
416
|
+
<Text span inherit ta="left">
|
|
417
|
+
{entry.title}
|
|
418
|
+
</Text>
|
|
419
|
+
<Text span inherit c="dimmed" size="xs" ta="left">
|
|
420
|
+
{statusWord(entry.status)} · {environmentLabel(entry.environment)} ·{' '}
|
|
421
|
+
{failedIds.has(entry.id) ? 'Could not be loaded' : RECORDING_STATE_TEXT[state].label}
|
|
422
|
+
</Text>
|
|
423
|
+
</Flex>
|
|
424
|
+
</Flex>
|
|
425
|
+
</Button>
|
|
426
|
+
</List.Item>
|
|
427
|
+
);
|
|
428
|
+
})}
|
|
429
|
+
</List>
|
|
430
|
+
{entries.length > listed.length && (
|
|
431
|
+
<Button type="button" variant="subtle" onClick={() => setShown(listed.length + LISTED)}>
|
|
432
|
+
Show more ({entries.length - listed.length} more)
|
|
433
|
+
</Button>
|
|
434
|
+
)}
|
|
435
|
+
</Stack>
|
|
436
|
+
</Grid.Col>
|
|
437
|
+
</Grid>
|
|
438
|
+
</Stack>
|
|
439
|
+
</Modal>
|
|
440
|
+
);
|
|
441
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* `type`, `label`, `branch`, `outcome`, `since` and `until`, so a link to one tab's filters opens the same query on another.
|
|
11
11
|
*/
|
|
12
12
|
import { joinList, splitList, type ArtifactFilter } from '@crvouga/atlas/reports/artifact-query';
|
|
13
|
+
import type { ReliabilityScope } from '@crvouga/atlas/reports/reliability';
|
|
13
14
|
import { TEST_SORTS, type TestQuery } from '@crvouga/atlas/reports/test-catalog';
|
|
14
15
|
import { z } from 'zod/v4';
|
|
15
16
|
|
|
@@ -71,6 +72,18 @@ export const ExplorerSearchFields = {
|
|
|
71
72
|
source: text,
|
|
72
73
|
/** With `openRun` and `layer`: the one file of that layer (an `artifactRef`: `id:…`, `key:…` or `name:…`) the run's Artifacts select. */
|
|
73
74
|
artifact: text,
|
|
75
|
+
/** The scope of the open test's reliability figure: environments, producers, branches, `key=value` parameters. */
|
|
76
|
+
relEnv: text,
|
|
77
|
+
relProducer: text,
|
|
78
|
+
relBranch: text,
|
|
79
|
+
relParam: text,
|
|
80
|
+
/** The reliability window, as `since` and `until` (`YYYY-MM-DD`, local time). */
|
|
81
|
+
relSince: text,
|
|
82
|
+
relUntil: text,
|
|
83
|
+
/** `any`: count every spec revision. Otherwise only the current one is counted. */
|
|
84
|
+
relRevision: z.enum(['any']).optional().catch(undefined),
|
|
85
|
+
/** The reliability's independent executions (its denominator) are open. */
|
|
86
|
+
relOpen: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined),
|
|
74
87
|
/** Only the runs, and the results of an opened run, that have a playable video recording. */
|
|
75
88
|
video: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined)
|
|
76
89
|
};
|
|
@@ -102,6 +115,14 @@ export type ExplorerSearch = {
|
|
|
102
115
|
readonly layer?: string;
|
|
103
116
|
readonly source?: string;
|
|
104
117
|
readonly artifact?: string;
|
|
118
|
+
readonly relEnv?: string;
|
|
119
|
+
readonly relProducer?: string;
|
|
120
|
+
readonly relBranch?: string;
|
|
121
|
+
readonly relParam?: string;
|
|
122
|
+
readonly relSince?: string;
|
|
123
|
+
readonly relUntil?: string;
|
|
124
|
+
readonly relRevision?: 'any';
|
|
125
|
+
readonly relOpen?: true;
|
|
105
126
|
readonly video?: true;
|
|
106
127
|
};
|
|
107
128
|
|
|
@@ -153,6 +174,21 @@ export function narrowed(search: ExplorerSearch): boolean {
|
|
|
153
174
|
return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.scope || search.artifactTag || search.inRun || search.video);
|
|
154
175
|
}
|
|
155
176
|
|
|
177
|
+
/** The reliability scope an address asks for, in the shape `reliability` reads: days are the viewer's local days. */
|
|
178
|
+
export function reliabilityScopeOf(search: ExplorerSearch): ReliabilityScope {
|
|
179
|
+
const list = (value: string | undefined) => (values(value).length ? values(value) : undefined);
|
|
180
|
+
const parts = {
|
|
181
|
+
environment: list(search.relEnv),
|
|
182
|
+
producer: list(search.relProducer),
|
|
183
|
+
branch: list(search.relBranch),
|
|
184
|
+
parameter: list(search.relParam),
|
|
185
|
+
...(search.relRevision ? { revision: search.relRevision } : {}),
|
|
186
|
+
since: dayMs(search.relSince, false),
|
|
187
|
+
until: dayMs(search.relUntil, true)
|
|
188
|
+
};
|
|
189
|
+
return Object.fromEntries(Object.entries(parts).filter(([, value]) => value !== undefined)) as ReliabilityScope;
|
|
190
|
+
}
|
|
191
|
+
|
|
156
192
|
/** The filters of the Runs and Artifacts tabs, in the address. */
|
|
157
193
|
export const FILTER_FIELDS = ['q', 'artifactTag', 'inherited', 'type', 'label', 'env', 'producer', 'branch', 'outcome', 'domain', 'spec', 'since', 'until', 'video'] as const;
|
|
158
194
|
|