@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.
@@ -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
- /** The scenario in words: the steps the producer recorded, with the Gherkin keyword where it gave one. */
70
- function Scenario({ entry, detail }: { entry: PlaylistEntry; detail: CheckDetail | undefined }) {
71
- const steps = detail?.check.steps ?? [];
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
- <List.Item key={`${step.id ?? step.name}:${index}`}>
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
- <Box
317
+ <ClipVideo
258
318
  key={attempt}
259
- component="video"
260
- ref={video}
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
- </Box>
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
- <Stack gap="md" miw={0} aria-label={`Now showing ${entry.title}`} component="section" data-clip={entry.id}>
352
- {body}
353
- <Facts entry={entry} />
354
- <Scenario entry={entry} detail={detail.data} />
355
- <Text component="div" c="dimmed">
356
- 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.
357
- </Text>
358
- {developer}
359
- </Stack>
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;