@crvouga/atlas 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -1
- package/docs/extending.md +7 -0
- package/docs/rich-evidence.md +39 -0
- package/docs/ui.md +22 -0
- package/llms-full.txt +68 -0
- package/package.json +1 -1
- package/packages/visualizer/src/app/router.tsx +3 -0
- package/packages/visualizer/src/evidence/artifacts.tsx +88 -42
- package/packages/visualizer/src/evidence/behavior-panel.tsx +44 -25
- package/packages/visualizer/src/evidence/media-timing.test.ts +85 -0
- package/packages/visualizer/src/evidence/media-timing.ts +150 -0
- package/packages/visualizer/src/evidence/moments.tsx +457 -0
- package/packages/visualizer/src/evidence/outcome-summary.tsx +27 -0
- package/packages/visualizer/src/evidence/outcome.ts +7 -2
- package/packages/visualizer/src/explorer/TestDetail.tsx +2 -2
- package/packages/visualizer/src/explorer/playlist.tsx +89 -21
- package/packages/visualizer/src/explorer/search.ts +7 -1
|
@@ -35,7 +35,7 @@ function LatestLink({ children }: { children: string }) {
|
|
|
35
35
|
return (
|
|
36
36
|
<Anchor
|
|
37
37
|
renderRoot={(props) => (
|
|
38
|
-
<Link {...props} to="/" search={patched({ layer: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
38
|
+
<Link {...props} to="/" search={patched({ layer: undefined, clip: undefined, clipAt: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
39
39
|
)}
|
|
40
40
|
>
|
|
41
41
|
{children}
|
|
@@ -168,7 +168,7 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
168
168
|
<Anchor
|
|
169
169
|
aria-label="Close the test"
|
|
170
170
|
renderRoot={(props) => (
|
|
171
|
-
<Link {...props} to="/" search={patched({ test: undefined, layer: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
171
|
+
<Link {...props} to="/" search={patched({ test: undefined, layer: undefined, clip: undefined, clipAt: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
172
172
|
)}
|
|
173
173
|
>
|
|
174
174
|
<IconX size={20} aria-hidden="true" />
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { attachmentIdentity, isVideoAttachment, type CheckDetail } from '@crvouga/atlas/reports/evidence-detail';
|
|
16
16
|
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
17
17
|
import { contentTypeOf } from '@crvouga/atlas/reports/media-types';
|
|
18
|
+
import type { Attachment, Step } from '@crvouga/atlas/schema';
|
|
18
19
|
import { Alert, Anchor, Badge, Box, Button, Code, Flex, Grid, Group, List, Modal, NativeSelect, Stack, Switch, Text, Title } from '@mantine/core';
|
|
19
20
|
import { IconAlertTriangle, IconPlayerPlay, IconPlayerSkipBack, IconPlayerSkipForward } from '@tabler/icons-react';
|
|
20
21
|
import { useQuery } from '@tanstack/react-query';
|
|
@@ -25,6 +26,7 @@ import { reportJson } from '../evidence/api';
|
|
|
25
26
|
import { attachmentSource } from '../evidence/artifacts';
|
|
26
27
|
import { everyAttachment } from '../evidence/artifact-tags';
|
|
27
28
|
import { ScopeNotice } from '../evidence/evidence-scope';
|
|
29
|
+
import { MomentBar, MomentPlayback, SeekStep, stepsOfRecording, useIsPlaying, useMomentVideo } from '../evidence/moments';
|
|
28
30
|
import { duration, exactTime, plural, statusWord } from '../evidence/format';
|
|
29
31
|
import { StatusMark } from '../evidence/marks';
|
|
30
32
|
import { patched } from './navigate';
|
|
@@ -38,6 +40,8 @@ const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
|
|
|
38
40
|
/** Step names are shown with their Gherkin keyword when they have one. */
|
|
39
41
|
const KEYWORDS = new Set(['given', 'when', 'then', 'and', 'but']);
|
|
40
42
|
|
|
43
|
+
const NO_STEPS: readonly Step[] = [];
|
|
44
|
+
|
|
41
45
|
/** Where the evidence of an entry's check is read from, or null when the entry names no layer that can be read. */
|
|
42
46
|
export function entryDetailQuery(entry: PlaylistEntry) {
|
|
43
47
|
if (entry.backend === null || !/^[0-9a-f]{64}$/.test(entry.layer)) return null;
|
|
@@ -66,20 +70,33 @@ function useEntryDetail(entry: PlaylistEntry, enabled: boolean) {
|
|
|
66
70
|
});
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
/**
|
|
70
|
-
function
|
|
71
|
-
const
|
|
73
|
+
/** One step of the scenario: in words, with a control to show it in the recording and a mark while it is playing. */
|
|
74
|
+
function ScenarioStep({ step }: { step: Step }) {
|
|
75
|
+
const playing = useIsPlaying(step);
|
|
76
|
+
return (
|
|
77
|
+
<List.Item aria-current={playing ? 'step' : undefined} data-playing={playing || undefined} {...(playing ? { bg: 'blue.0' } : {})}>
|
|
78
|
+
<Group gap="xs" wrap="wrap">
|
|
79
|
+
<Text span inherit>
|
|
80
|
+
{step.kind && KEYWORDS.has(step.kind.toLowerCase()) ? `${step.kind.charAt(0).toUpperCase()}${step.kind.slice(1).toLowerCase()} ` : ''}
|
|
81
|
+
{step.name}
|
|
82
|
+
{step.status && step.status !== 'passed' ? ` (${step.status})` : ''}
|
|
83
|
+
</Text>
|
|
84
|
+
{playing && <Badge variant="light">Playing now</Badge>}
|
|
85
|
+
<SeekStep step={step} />
|
|
86
|
+
</Group>
|
|
87
|
+
</List.Item>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The scenario in words: the steps the producer recorded for the recording shown, with the Gherkin keyword where it gave one. */
|
|
92
|
+
function Scenario({ entry, detail, steps }: { entry: PlaylistEntry; detail: CheckDetail | undefined; steps: readonly Step[] }) {
|
|
72
93
|
return (
|
|
73
94
|
<Stack gap="xs" component="section" aria-label="Scenario">
|
|
74
95
|
<Title order={3}>{entry.title}</Title>
|
|
75
96
|
{steps.length > 0 && (
|
|
76
97
|
<List type="ordered" spacing="xs" aria-label="Steps">
|
|
77
98
|
{steps.map((step, index) => (
|
|
78
|
-
<
|
|
79
|
-
{step.kind && KEYWORDS.has(step.kind.toLowerCase()) ? `${step.kind.charAt(0).toUpperCase()}${step.kind.slice(1).toLowerCase()} ` : ''}
|
|
80
|
-
{step.name}
|
|
81
|
-
{step.status && step.status !== 'passed' ? ` (${step.status})` : ''}
|
|
82
|
-
</List.Item>
|
|
99
|
+
<ScenarioStep key={`${step.id ?? step.name}:${index}`} step={step} />
|
|
83
100
|
))}
|
|
84
101
|
</List>
|
|
85
102
|
)}
|
|
@@ -94,6 +111,46 @@ function Scenario({ entry, detail }: { entry: PlaylistEntry; detail: CheckDetail
|
|
|
94
111
|
);
|
|
95
112
|
}
|
|
96
113
|
|
|
114
|
+
/** The one `<video>`: joined to the steps beside it, with the player's own handlers kept. */
|
|
115
|
+
function ClipVideo({
|
|
116
|
+
movie,
|
|
117
|
+
videoRef,
|
|
118
|
+
onLoadedMetadata,
|
|
119
|
+
onTimeUpdate,
|
|
120
|
+
children,
|
|
121
|
+
...rest
|
|
122
|
+
}: {
|
|
123
|
+
movie: Attachment;
|
|
124
|
+
videoRef: React.RefObject<HTMLVideoElement | null>;
|
|
125
|
+
onLoadedMetadata: () => void;
|
|
126
|
+
onTimeUpdate: () => void;
|
|
127
|
+
children: React.ReactNode;
|
|
128
|
+
[prop: string]: unknown;
|
|
129
|
+
}) {
|
|
130
|
+
const moments = useMomentVideo(movie);
|
|
131
|
+
return (
|
|
132
|
+
<Box
|
|
133
|
+
component="video"
|
|
134
|
+
{...rest}
|
|
135
|
+
{...moments}
|
|
136
|
+
ref={(element: HTMLVideoElement | null) => {
|
|
137
|
+
(videoRef as { current: HTMLVideoElement | null }).current = element;
|
|
138
|
+
moments?.ref(element);
|
|
139
|
+
}}
|
|
140
|
+
onLoadedMetadata={(event: React.SyntheticEvent<HTMLVideoElement>) => {
|
|
141
|
+
moments?.onLoadedMetadata(event);
|
|
142
|
+
onLoadedMetadata();
|
|
143
|
+
}}
|
|
144
|
+
onTimeUpdate={(event: React.SyntheticEvent<HTMLVideoElement>) => {
|
|
145
|
+
moments?.onTimeUpdate(event);
|
|
146
|
+
onTimeUpdate();
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
149
|
+
{children}
|
|
150
|
+
</Box>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
97
154
|
/** Who recorded the entry and what it carries, in one line each. */
|
|
98
155
|
function Facts({ entry }: { entry: PlaylistEntry }) {
|
|
99
156
|
return (
|
|
@@ -192,6 +249,9 @@ function Clip({
|
|
|
192
249
|
[attachments]
|
|
193
250
|
);
|
|
194
251
|
const url = movie ? attachmentSource(movie, entry.layer).url : null;
|
|
252
|
+
// The steps are those of the check or the try that carries this recording, placed by this recording's own timing.
|
|
253
|
+
const placedIn = useMemo(() => (movie ? [movie] : []), [movie]);
|
|
254
|
+
const steps = useMemo(() => (detail.data ? stepsOfRecording(detail.data.check, movie ? attachmentIdentity(movie) : undefined) : NO_STEPS), [detail.data, movie]);
|
|
195
255
|
|
|
196
256
|
useEffect(() => {
|
|
197
257
|
if (video.current) video.current.playbackRate = speed;
|
|
@@ -254,10 +314,10 @@ function Clip({
|
|
|
254
314
|
<Stack gap="xs">
|
|
255
315
|
{focus && <FoundMoment focus={focus} seek={seek} />}
|
|
256
316
|
<ScopeNotice scope={chosen?.scope} name={movie!.name} />
|
|
257
|
-
<
|
|
317
|
+
<ClipVideo
|
|
258
318
|
key={attempt}
|
|
259
|
-
|
|
260
|
-
|
|
319
|
+
movie={movie!}
|
|
320
|
+
videoRef={video}
|
|
261
321
|
src={url}
|
|
262
322
|
controls
|
|
263
323
|
preload="metadata"
|
|
@@ -299,7 +359,7 @@ function Clip({
|
|
|
299
359
|
const source = attachmentSource(caption, entry.layer).url;
|
|
300
360
|
return source ? <track key={source} kind="captions" src={source} label={caption.description ?? caption.name} /> : null;
|
|
301
361
|
})}
|
|
302
|
-
</
|
|
362
|
+
</ClipVideo>
|
|
303
363
|
{movie!.durationMs ? (
|
|
304
364
|
<Text component="div" c="dimmed">
|
|
305
365
|
Length of the recording: {duration(movie!.durationMs)}
|
|
@@ -348,15 +408,23 @@ function Clip({
|
|
|
348
408
|
);
|
|
349
409
|
}
|
|
350
410
|
return (
|
|
351
|
-
<
|
|
352
|
-
{
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
411
|
+
<MomentPlayback
|
|
412
|
+
steps={steps}
|
|
413
|
+
attachments={placedIn}
|
|
414
|
+
layer={entry.layer}
|
|
415
|
+
linkSearch={{ test: entry.key, layer: entry.layer, tab: undefined, openRun: undefined, artifact: undefined }}
|
|
416
|
+
>
|
|
417
|
+
<Stack gap="md" miw={0} aria-label={`Now showing ${entry.title}`} component="section" data-clip={entry.id}>
|
|
418
|
+
{body}
|
|
419
|
+
<Facts entry={entry} />
|
|
420
|
+
<MomentBar />
|
|
421
|
+
<Scenario entry={entry} detail={detail.data} steps={steps} />
|
|
422
|
+
<Text component="div" c="dimmed">
|
|
423
|
+
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.
|
|
424
|
+
</Text>
|
|
425
|
+
{developer}
|
|
426
|
+
</Stack>
|
|
427
|
+
</MomentPlayback>
|
|
360
428
|
);
|
|
361
429
|
}
|
|
362
430
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @module Explorer address
|
|
3
3
|
*
|
|
4
4
|
* Everything the test explorer shows is in the address of the `/` route, so a link reproduces the view: the open test,
|
|
5
|
-
* the words searched, the filters, the grouping, the order, the Runs tab with the run opened in it, the exact layer a test's detail is pinned to, and the one file of a run's layer its Artifacts select. Filters that
|
|
5
|
+
* the words searched, the filters, the grouping, the order, the Runs tab with the run opened in it, the exact layer a test's detail is pinned to, the recording and position of a shared moment in it (`clip`, `clipAt`), and the one file of a run's layer its Artifacts select. Filters that
|
|
6
6
|
* take several values are comma-separated (`status=failed,flaky`); a comma or backslash inside a value is written `\,`
|
|
7
7
|
* or `\\`, and the router encodes `#` and spaces. Anything unreadable is dropped, never an error.
|
|
8
8
|
*
|
|
@@ -71,6 +71,10 @@ export const ExplorerSearchFields = {
|
|
|
71
71
|
openRun: text,
|
|
72
72
|
/** Pins the open test's detail to one exact layer (a run's record of it), instead of the latest combined evidence. With `openRun` and `artifact` it names the layer the file is in. */
|
|
73
73
|
layer: text,
|
|
74
|
+
/** With `layer`: the exact recording (an `attachmentIdentity`) of that run a shared moment names. It selects that recording; it never selects another run. */
|
|
75
|
+
clip: text,
|
|
76
|
+
/** With `clip`: the position in that recording, in milliseconds. */
|
|
77
|
+
clipAt: z.coerce.number().nonnegative().optional().catch(undefined),
|
|
74
78
|
/** Which current source (a layer id) of the open test the detail's outcome summary, media and failure describe, without pinning a run. Unknown or absent: the newest. */
|
|
75
79
|
source: text,
|
|
76
80
|
/** With `openRun` and `layer`: the one file of that layer (an `artifactRef`: `id:…`, `key:…` or `name:…`) the run's Artifacts select. */
|
|
@@ -120,6 +124,8 @@ export type ExplorerSearch = {
|
|
|
120
124
|
readonly rev?: number;
|
|
121
125
|
readonly openRun?: string;
|
|
122
126
|
readonly layer?: string;
|
|
127
|
+
readonly clip?: string;
|
|
128
|
+
readonly clipAt?: number;
|
|
123
129
|
readonly source?: string;
|
|
124
130
|
readonly artifact?: string;
|
|
125
131
|
readonly relEnv?: string;
|