@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 CHANGED
@@ -1,6 +1,12 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
- ## 1.11.0 (2026-10-29)
3
+ ## 1.12.0 (2026-10-29)
4
+
5
+ ### Features
6
+
7
+ - seek recordings from scenario steps and share a moment ([6071cd9](https://github.com/crvouga/atlas/commit/6071cd90e8ece66deb384b037a25ee51381283ed))
8
+
9
+ ## 1.11.0 (2026-10-28)
4
10
 
5
11
  ### Features
6
12
 
package/docs/extending.md CHANGED
@@ -121,6 +121,13 @@ when you do not know, and the viewer says the cause is unknown. It is never infe
121
121
  failed before the behavior started comes from the failed step's `kind`: `hook`, `setup`, `fixture`, `before`,
122
122
  `beforeAll`, `beforeEach` or `given` mean preparation; `when`, `then`, `assertion` or `expect` mean the behavior.
123
123
 
124
+ ### Placing steps in a recording
125
+
126
+ A video may declare how its clock relates to the check's under the `atlas.media` extension (`checkStartMs`, `pauses`,
127
+ `marks`), so the viewer can seek it from a step and mark the step playing. Without it the video plays as ever and no step
128
+ is placed. The fields, the rules and the link to a moment are in
129
+ [Place steps in a recording](rich-evidence.md#place-steps-in-a-recording).
130
+
124
131
  ## Attach evidence
125
132
 
126
133
  ### From the command line
@@ -27,6 +27,7 @@ decided exactly as before ([the report contract](report-contract.md)). Evidence
27
27
  | `attempts` | check | Every try of a retried check, with its own status, detail, steps and attachments. A check that passed after failing is flaky. |
28
28
  | `tags`, `labels` | check, layer | Filters and groups (`smoke`, `flaky`, an owner, a browser). |
29
29
  | `tags` | attachment | Marks one recording or screenshot, for example `demo`: shown as a `#demo` chip, and searchable. See [Tagging artifacts and demos](#tagging-artifacts-and-demos). |
30
+ | `extensions['atlas.media']` | attachment (video) | Where the check's steps are in the recording, so a click on a step seeks the video. See [Place steps in a recording](#place-steps-in-a-recording). |
30
31
  | `scope` | attachment, attempt, check, part, layer | What a recording does and does not show: where it ran, whether data and dependencies were real, whether audio was captured. See [Evidence scope](#evidence-scope). |
31
32
  | `annotations`, `links` | check, step, attempt, layer | Notes, warnings and issue links next to the result. |
32
33
  | `specPath` | check | The spec a check belongs to (a `.feature`, a test file), relative to the repository. Checks are grouped by it. |
@@ -109,6 +110,44 @@ Every field is optional, and every value except the booleans is an open string.
109
110
  - **It decides nothing.** A scope never changes a status, a slot or a winner. A scope an older Atlas cannot read is
110
111
  dropped, and the attachment, check and layer stay.
111
112
 
113
+ ## Place steps in a recording
114
+
115
+ A step says when it started on the check's clock (`offsetMs`, after the check started). A video has its own clock, which
116
+ began when the recorder did, and the two are not assumed to agree: a recorder usually starts before the first step, and a
117
+ paced demo may have delays put into the video that the check never waited for. To let a reviewer click a step and land on
118
+ its moment in the video, say how the clocks relate in the `atlas.media` extension of the video attachment:
119
+
120
+ ```ts
121
+ {
122
+ name: 'checkout.webm', role: 'video', durationMs: 9000,
123
+ extensions: {
124
+ 'atlas.media': {
125
+ checkStartMs: 1200, // the check started 1.2 s into the video
126
+ pauses: [{ atMs: 900, durationMs: 2000, reason: 'title card' }], // 2 s put into the video before the step at +900 ms
127
+ marks: [{ step: 'pay', mediaMs: 4100 }] // a step whose moment in the video you know exactly
128
+ }
129
+ }
130
+ }
131
+ ```
132
+
133
+ Every field is optional and none of it decides a status, a slot or a winner. A step is placed at `checkStartMs` + its
134
+ `offsetMs` + the `durationMs` of every pause whose `atMs` is at or before the step; a mark names the moment outright and
135
+ wins. Anything else is unavailable and said so, never guessed.
136
+
137
+ - **Without timing, nothing is placed.** A video with no `atlas.media`, no `checkStartMs`, or a step with no `offsetMs` and
138
+ no mark, is never assumed to start with the check, and a step is never placed from the wording of its name. Selecting
139
+ such a step says why it cannot be shown, and moves nothing.
140
+ - **Outside the video is not clamped.** A moment before the start, or after `durationMs` (or the length the player reports),
141
+ is unavailable. A declaration with a field Atlas cannot read (a pause without a duration) is reported and places nothing.
142
+ - **Pacing is not latency.** A video with `pauses`, or with a `scope` of `pacing: 'paced'`, is labelled as paced: the time
143
+ between its steps includes deliberate pauses and is not application latency, and no measurement is changed by it.
144
+ - **Each try has its own clock.** Put the video and the `atlas.media` of a retry on its `attempts[]` entry, with that
145
+ try's `steps`. A try is aligned only by its own video, never by the check's or an earlier try's.
146
+ - **Link to a moment.** The viewer copies a link that names the run (`layer`), the exact recording (`clip`, its digest or
147
+ stored key) and the position (`clipAt`, milliseconds into the video). Opening it selects that recording of that run, even
148
+ when a newer run exists, and goes to the position; a recording the run does not hold is reported, never replaced.
149
+ - **Older layers.** A layer without `atlas.media` reads exactly as before; the video plays and its steps list as ever.
150
+
112
151
  ## Product latency
113
152
 
114
153
  A test's `durationMs` includes fixture setup, deliberate waits, demo pacing, retries and cleanup, so it says how long the
package/docs/ui.md CHANGED
@@ -150,6 +150,28 @@ over the same filtered set the page lists, in the same order (`explorer/playlist
150
150
  (paced, scripted, silent, synthetic) and the sentence that the length is the recording's, not production latency. Developer
151
151
  details opens the test pinned to that run's record and closes the playlist.
152
152
 
153
+ ## Steps and moments in a recording
154
+
155
+ Wherever a result's steps sit beside its recording (a result's Timeline under Inspect, the Watch tab of a behavior, an
156
+ opened watch playlist), `evidence/moments.tsx` joins them. Placement is from the recording's own declared timing
157
+ (`evidence/media-timing.ts`, the `atlas.media` extension; see [Place steps in a recording](rich-evidence.md#place-steps-in-a-recording)),
158
+ never from an assumption that the recording starts with the check.
159
+
160
+ - A step has a "Recording m:ss.s" button that seeks the selected recording; the step's test-clock offset stays visible beside it.
161
+ A step that cannot be placed (no timing, no origin, no offset, outside the recording, unreadable declaration) is "Not aligned"
162
+ and selecting it states the reason in a status alert. Nothing is guessed and the video is not moved.
163
+ - While playing, the step under the playhead gets `aria-current="step"` and "Playing now". It never moves focus, scrolls
164
+ the page or announces itself. Seeking only scrolls the player into view.
165
+ - A paced recording (pauses declared, or a `pacing: paced` scope) says that gaps between steps include deliberate pauses and are
166
+ not application latency.
167
+ - A failed step has "Jump to the failing moment" in the bar above the timeline, and the failure summary has the same action
168
+ (`requestFailureMoment`). If the recording is not on screen, or the step cannot be placed, it says so.
169
+ - "Copy link to this moment" names the run (`layer`), the exact recording (`clip`) and the position (`clipAt`, ms), as the
170
+ explorer address `/?test=…&layer=…&clip=…&clipAt=…` (the playlist links to the result it is playing). Opening it selects that
171
+ recording of that exact run and seeks once the player knows its length; a position past the end or a recording that the run
172
+ does not hold is reported and nothing else is played in its place. If the browser refuses to copy, the link is shown to copy.
173
+ - One provider serves one holder: the check, or one try. A try's steps are placed by that try's recording and timing only.
174
+
153
175
  ## Review collections
154
176
 
155
177
  The **Collections** tab (`explorer/collections.tsx`, `?tab=collections&collection=<id>&rev=<n>`) lists and opens review
package/llms-full.txt CHANGED
@@ -1014,6 +1014,7 @@ decided exactly as before ([the report contract](report-contract.md)). Evidence
1014
1014
  | `attempts` | check | Every try of a retried check, with its own status, detail, steps and attachments. A check that passed after failing is flaky. |
1015
1015
  | `tags`, `labels` | check, layer | Filters and groups (`smoke`, `flaky`, an owner, a browser). |
1016
1016
  | `tags` | attachment | Marks one recording or screenshot, for example `demo`: shown as a `#demo` chip, and searchable. See [Tagging artifacts and demos](#tagging-artifacts-and-demos). |
1017
+ | `extensions['atlas.media']` | attachment (video) | Where the check's steps are in the recording, so a click on a step seeks the video. See [Place steps in a recording](#place-steps-in-a-recording). |
1017
1018
  | `scope` | attachment, attempt, check, part, layer | What a recording does and does not show: where it ran, whether data and dependencies were real, whether audio was captured. See [Evidence scope](#evidence-scope). |
1018
1019
  | `annotations`, `links` | check, step, attempt, layer | Notes, warnings and issue links next to the result. |
1019
1020
  | `specPath` | check | The spec a check belongs to (a `.feature`, a test file), relative to the repository. Checks are grouped by it. |
@@ -1096,6 +1097,44 @@ Every field is optional, and every value except the booleans is an open string.
1096
1097
  - **It decides nothing.** A scope never changes a status, a slot or a winner. A scope an older Atlas cannot read is
1097
1098
  dropped, and the attachment, check and layer stay.
1098
1099
 
1100
+ ## Place steps in a recording
1101
+
1102
+ A step says when it started on the check's clock (`offsetMs`, after the check started). A video has its own clock, which
1103
+ began when the recorder did, and the two are not assumed to agree: a recorder usually starts before the first step, and a
1104
+ paced demo may have delays put into the video that the check never waited for. To let a reviewer click a step and land on
1105
+ its moment in the video, say how the clocks relate in the `atlas.media` extension of the video attachment:
1106
+
1107
+ ```ts
1108
+ {
1109
+ name: 'checkout.webm', role: 'video', durationMs: 9000,
1110
+ extensions: {
1111
+ 'atlas.media': {
1112
+ checkStartMs: 1200, // the check started 1.2 s into the video
1113
+ pauses: [{ atMs: 900, durationMs: 2000, reason: 'title card' }], // 2 s put into the video before the step at +900 ms
1114
+ marks: [{ step: 'pay', mediaMs: 4100 }] // a step whose moment in the video you know exactly
1115
+ }
1116
+ }
1117
+ }
1118
+ ```
1119
+
1120
+ Every field is optional and none of it decides a status, a slot or a winner. A step is placed at `checkStartMs` + its
1121
+ `offsetMs` + the `durationMs` of every pause whose `atMs` is at or before the step; a mark names the moment outright and
1122
+ wins. Anything else is unavailable and said so, never guessed.
1123
+
1124
+ - **Without timing, nothing is placed.** A video with no `atlas.media`, no `checkStartMs`, or a step with no `offsetMs` and
1125
+ no mark, is never assumed to start with the check, and a step is never placed from the wording of its name. Selecting
1126
+ such a step says why it cannot be shown, and moves nothing.
1127
+ - **Outside the video is not clamped.** A moment before the start, or after `durationMs` (or the length the player reports),
1128
+ is unavailable. A declaration with a field Atlas cannot read (a pause without a duration) is reported and places nothing.
1129
+ - **Pacing is not latency.** A video with `pauses`, or with a `scope` of `pacing: 'paced'`, is labelled as paced: the time
1130
+ between its steps includes deliberate pauses and is not application latency, and no measurement is changed by it.
1131
+ - **Each try has its own clock.** Put the video and the `atlas.media` of a retry on its `attempts[]` entry, with that
1132
+ try's `steps`. A try is aligned only by its own video, never by the check's or an earlier try's.
1133
+ - **Link to a moment.** The viewer copies a link that names the run (`layer`), the exact recording (`clip`, its digest or
1134
+ stored key) and the position (`clipAt`, milliseconds into the video). Opening it selects that recording of that run, even
1135
+ when a newer run exists, and goes to the position; a recording the run does not hold is reported, never replaced.
1136
+ - **Older layers.** A layer without `atlas.media` reads exactly as before; the video plays and its steps list as ever.
1137
+
1099
1138
  ## Product latency
1100
1139
 
1101
1140
  A test's `durationMs` includes fixture setup, deliberate waits, demo pacing, retries and cleanup, so it says how long the
@@ -3057,6 +3096,13 @@ when you do not know, and the viewer says the cause is unknown. It is never infe
3057
3096
  failed before the behavior started comes from the failed step's `kind`: `hook`, `setup`, `fixture`, `before`,
3058
3097
  `beforeAll`, `beforeEach` or `given` mean preparation; `when`, `then`, `assertion` or `expect` mean the behavior.
3059
3098
 
3099
+ ### Placing steps in a recording
3100
+
3101
+ A video may declare how its clock relates to the check's under the `atlas.media` extension (`checkStartMs`, `pauses`,
3102
+ `marks`), so the viewer can seek it from a step and mark the step playing. Without it the video plays as ever and no step
3103
+ is placed. The fields, the rules and the link to a moment are in
3104
+ [Place steps in a recording](rich-evidence.md#place-steps-in-a-recording).
3105
+
3060
3106
  ## Attach evidence
3061
3107
 
3062
3108
  ### From the command line
@@ -4139,6 +4185,28 @@ over the same filtered set the page lists, in the same order (`explorer/playlist
4139
4185
  (paced, scripted, silent, synthetic) and the sentence that the length is the recording's, not production latency. Developer
4140
4186
  details opens the test pinned to that run's record and closes the playlist.
4141
4187
 
4188
+ ## Steps and moments in a recording
4189
+
4190
+ Wherever a result's steps sit beside its recording (a result's Timeline under Inspect, the Watch tab of a behavior, an
4191
+ opened watch playlist), `evidence/moments.tsx` joins them. Placement is from the recording's own declared timing
4192
+ (`evidence/media-timing.ts`, the `atlas.media` extension; see [Place steps in a recording](rich-evidence.md#place-steps-in-a-recording)),
4193
+ never from an assumption that the recording starts with the check.
4194
+
4195
+ - A step has a "Recording m:ss.s" button that seeks the selected recording; the step's test-clock offset stays visible beside it.
4196
+ A step that cannot be placed (no timing, no origin, no offset, outside the recording, unreadable declaration) is "Not aligned"
4197
+ and selecting it states the reason in a status alert. Nothing is guessed and the video is not moved.
4198
+ - While playing, the step under the playhead gets `aria-current="step"` and "Playing now". It never moves focus, scrolls
4199
+ the page or announces itself. Seeking only scrolls the player into view.
4200
+ - A paced recording (pauses declared, or a `pacing: paced` scope) says that gaps between steps include deliberate pauses and are
4201
+ not application latency.
4202
+ - A failed step has "Jump to the failing moment" in the bar above the timeline, and the failure summary has the same action
4203
+ (`requestFailureMoment`). If the recording is not on screen, or the step cannot be placed, it says so.
4204
+ - "Copy link to this moment" names the run (`layer`), the exact recording (`clip`) and the position (`clipAt`, ms), as the
4205
+ explorer address `/?test=…&layer=…&clip=…&clipAt=…` (the playlist links to the result it is playing). Opening it selects that
4206
+ recording of that exact run and seeks once the player knows its length; a position past the end or a recording that the run
4207
+ does not hold is reported and nothing else is played in its place. If the browser refuses to copy, the link is shown to copy.
4208
+ - One provider serves one holder: the check, or one try. A try's steps are placed by that try's recording and timing only.
4209
+
4142
4210
  ## Review collections
4143
4211
 
4144
4212
  The **Collections** tab (`explorer/collections.tsx`, `?tab=collections&collection=<id>&rev=<n>`) lists and opens review
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crvouga/atlas",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "Atlas: specs of any kind, reported as layered evidence. Publish checks from any runner (JUnit, CTRF, a script), combine them across nodes, branches, environments and backends, gate CI on the result, and run statecharts against the real app.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,6 +30,9 @@ const SpecSearch = RootSearch.extend({
30
30
  spec: z.string().min(1).optional().catch(undefined),
31
31
  check: z.string().min(1).optional().catch(undefined),
32
32
  layer: z.string().min(1).optional().catch(undefined),
33
+ /** A shared moment: the exact recording (an `attachmentIdentity`) of that layer, and the position in it in milliseconds. */
34
+ clip: z.coerce.string().min(1).optional().catch(undefined),
35
+ clipAt: z.coerce.number().nonnegative().optional().catch(undefined),
33
36
  view: z.enum(['recording']).optional().catch(undefined),
34
37
  moment: z.coerce.number().int().positive().optional().catch(undefined),
35
38
  /** The release evidence is read against: its build, commit and platform, and the evidence filter (see `evidence/applicability.ts`). A build such as `90` is read from a typed link as a number: coerced back to its text. */
@@ -1,5 +1,5 @@
1
1
  import type { CheckDetail, EvidenceHolder } from '@crvouga/atlas/reports/evidence-detail';
2
- import { isVideoAttachment, roleOfType } from '@crvouga/atlas/reports/evidence-detail';
2
+ import { attachmentIdentity, isVideoAttachment, roleOfType } from '@crvouga/atlas/reports/evidence-detail';
3
3
  import { effectiveScope } from '@crvouga/atlas/reports/evidence-scope';
4
4
  import type { LayerHeader, LayerRow } from '@crvouga/atlas/reports/evidence-types';
5
5
  import { contentTypeOf } from '@crvouga/atlas/reports/media-types';
@@ -12,6 +12,8 @@ import { reportAssetUrl, reportJson, reportText } from './api';
12
12
  import { ScopeNotice, ScopeProvider, useScope } from './evidence-scope';
13
13
  import { duration, plural, safeUri } from './format';
14
14
  import { StatusMark } from './marks';
15
+ import { MomentBar, MomentPlayback, momentOwner, SeekStep, useIsPlaying, useMomentVideo, useRequestedMoment } from './moments';
16
+ import { videosIn } from './outcome';
15
17
  /**
16
18
  * How one kind of attachment is shown. The first renderer that matches wins, so a new kind of artifact (a
17
19
  * flame graph, a HAR, a coverage map) is one more entry, and an attachment nobody has a renderer for is still
@@ -38,6 +40,8 @@ const label = (attachment: Attachment) => attachment.description ?? attachment.n
38
40
  /** A video that plays inline; when the file cannot be loaded or this browser cannot play it, it says so and offers the file, instead of an empty player. */
39
41
  function VideoPlayer({ attachment, url }: { attachment: Attachment; url: string }) {
40
42
  const [failed, setFailed] = useState(false);
43
+ // Inside a result's playback the player is the one its steps seek and that marks the step playing.
44
+ const moments = useMomentVideo(attachment);
41
45
  if (failed)
42
46
  return (
43
47
  <Alert role="alert" color="orange" title="This video cannot be played">
@@ -50,7 +54,19 @@ function VideoPlayer({ attachment, url }: { attachment: Attachment; url: string
50
54
  </Text>
51
55
  </Alert>
52
56
  );
53
- return <Box src={url} controls preload="metadata" aria-label={label(attachment)} component="video" maw="100%" w="100%" onError={() => setFailed(true)} />;
57
+ return (
58
+ <Box
59
+ src={url}
60
+ controls
61
+ preload="metadata"
62
+ aria-label={label(attachment)}
63
+ component="video"
64
+ maw="100%"
65
+ w="100%"
66
+ onError={() => setFailed(true)}
67
+ {...moments}
68
+ />
69
+ );
54
70
  }
55
71
  function TextValue({ attachment, path }: { attachment: Attachment; path: string | null }) {
56
72
  const stored = useQuery({
@@ -261,37 +277,11 @@ export function Timeline({ steps, layer }: { steps: readonly Step[]; layer: stri
261
277
  }, [steps]);
262
278
  const render = (parent: string | null): ReactNode => (
263
279
  <List spacing="xs" listStyleType="none" type="ordered">
264
- {(tree.get(parent) ?? []).map((step, index) => {
265
- const status = STEP_STATUS[(step.status ?? '').toLowerCase()];
266
- return (
267
- <List.Item data-evidence-step key={`${step.id ?? step.name}:${index}`} data-status={status ?? step.status ?? 'none'}>
268
- <Flex gap="xs" align="center" wrap="wrap">
269
- <StatusMark status={status ?? 'none'} size={14} />
270
- <Text span inherit>
271
- {step.name}
272
- </Text>
273
- {step.kind && (
274
- <Text span inherit>
275
- {step.kind}
276
- </Text>
277
- )}
278
- {step.status && !status && (
279
- <Text span inherit>
280
- {step.status}
281
- </Text>
282
- )}
283
- <Box flex={1} />
284
- <Text span inherit c="dimmed">
285
- {[step.offsetMs !== undefined ? `+${duration(step.offsetMs)}` : '', duration(step.durationMs)].filter(Boolean).join(' · ')}
286
- </Text>
287
- </Flex>
288
- {step.detail && <Text component="div">{step.detail}</Text>}
289
- {step.annotations && <Notes notes={step.annotations} />}
290
- {step.attachments && <Attachments attachments={step.attachments} layer={layer} />}
291
- {step.id && tree.has(step.id) && render(step.id)}
292
- </List.Item>
293
- );
294
- })}
280
+ {(tree.get(parent) ?? []).map((step, index) => (
281
+ <TimelineStep key={`${step.id ?? step.name}:${index}`} step={step} layer={layer}>
282
+ {step.id && tree.has(step.id) && render(step.id)}
283
+ </TimelineStep>
284
+ ))}
295
285
  </List>
296
286
  );
297
287
  return (
@@ -299,10 +289,52 @@ export function Timeline({ steps, layer }: { steps: readonly Step[]; layer: stri
299
289
  <Title order={4} mb="sm">
300
290
  {plural(steps.length, 'step', 'steps')}
301
291
  </Title>
292
+ <MomentBar />
302
293
  {render(null)}
303
294
  </Box>
304
295
  );
305
296
  }
297
+ /** One step of the timeline: its outcome and times, a control to show it in the recording when there is one, and what it carries. */
298
+ function TimelineStep({ step, layer, children }: { step: Step; layer: string; children: ReactNode }) {
299
+ const status = STEP_STATUS[(step.status ?? '').toLowerCase()];
300
+ const playing = useIsPlaying(step);
301
+ return (
302
+ <List.Item
303
+ data-evidence-step
304
+ data-status={status ?? step.status ?? 'none'}
305
+ data-playing={playing || undefined}
306
+ aria-current={playing ? 'step' : undefined}
307
+ {...(playing ? { bg: 'blue.0' } : {})}
308
+ >
309
+ <Flex gap="xs" align="center" wrap="wrap">
310
+ <StatusMark status={status ?? 'none'} size={14} />
311
+ <Text span inherit>
312
+ {step.name}
313
+ </Text>
314
+ {step.kind && (
315
+ <Text span inherit>
316
+ {step.kind}
317
+ </Text>
318
+ )}
319
+ {step.status && !status && (
320
+ <Text span inherit>
321
+ {step.status}
322
+ </Text>
323
+ )}
324
+ {playing && <Badge variant="light">Playing now</Badge>}
325
+ <Box flex={1} />
326
+ <SeekStep step={step} />
327
+ <Text span inherit c="dimmed">
328
+ {[step.offsetMs !== undefined ? `+${duration(step.offsetMs)}` : '', duration(step.durationMs)].filter(Boolean).join(' · ')}
329
+ </Text>
330
+ </Flex>
331
+ {step.detail && <Text component="div">{step.detail}</Text>}
332
+ {step.annotations && <Notes notes={step.annotations} />}
333
+ {step.attachments && <Attachments attachments={step.attachments} layer={layer} />}
334
+ {children}
335
+ </List.Item>
336
+ );
337
+ }
306
338
  export function Notes({ notes }: { notes: readonly Annotation[] }) {
307
339
  if (!notes.length) return null;
308
340
  return (
@@ -333,6 +365,7 @@ export function Notes({ notes }: { notes: readonly Annotation[] }) {
333
365
  </List>
334
366
  );
335
367
  }
368
+ const NO_FILES: readonly Attachment[] = [];
336
369
  function Tries({ attempts, layer }: { attempts: readonly Attempt[]; layer: string }) {
337
370
  if (!attempts.length) return null;
338
371
  return (
@@ -355,7 +388,11 @@ function Tries({ attempts, layer }: { attempts: readonly Attempt[]; layer: strin
355
388
  </Flex>
356
389
  {attempt.detail && <Text component="div">{attempt.detail}</Text>}
357
390
  <ScopeProvider scope={attempt.scope}>
358
- {attempt.attachments && <Attachments attachments={attempt.attachments} layer={layer} />}
391
+ {/* A try is placed in its own recording with that recording's own timing, never in another try's. */}
392
+ <MomentPlayback steps={attempt.steps} attachments={attempt.attachments ?? NO_FILES} layer={layer}>
393
+ {attempt.steps && attempt.steps.length > 0 ? <Timeline steps={attempt.steps} layer={layer} /> : <MomentBar />}
394
+ {attempt.attachments && <Attachments attachments={attempt.attachments} layer={layer} />}
395
+ </MomentPlayback>
359
396
  </ScopeProvider>
360
397
  </List.Item>
361
398
  ))}
@@ -413,15 +450,17 @@ function Facts({ holder }: { holder: EvidenceHolder }) {
413
450
  </Stack>
414
451
  );
415
452
  }
416
- function Evidence({ holder, layer, steps }: { holder: EvidenceHolder; layer: string; steps?: boolean }) {
453
+ function Evidence({ holder, layer, steps = false, owner }: { holder: EvidenceHolder; layer: string; steps?: boolean; owner?: string }) {
417
454
  return (
418
455
  <ScopeProvider scope={holder.scope}>
419
- <Facts holder={holder} />
420
- {holder.annotations && <Notes notes={holder.annotations} />}
421
- {steps && holder.steps && holder.steps.length > 0 && <Timeline steps={holder.steps} layer={layer} />}
422
- {holder.attempts && holder.attempts.length > 1 && <Tries attempts={holder.attempts} layer={layer} />}
423
- {holder.attachments && <Attachments attachments={holder.attachments} layer={layer} />}
424
- {holder.extensions && <Raw extensions={holder.extensions} />}
456
+ <MomentPlayback steps={steps ? holder.steps : undefined} attachments={holder.attachments ?? NO_FILES} layer={layer} primary={steps} owner={owner}>
457
+ <Facts holder={holder} />
458
+ {holder.annotations && <Notes notes={holder.annotations} />}
459
+ {steps && holder.steps && holder.steps.length > 0 ? <Timeline steps={holder.steps} layer={layer} /> : <MomentBar />}
460
+ {holder.attempts && holder.attempts.length > 1 && <Tries attempts={holder.attempts} layer={layer} />}
461
+ {holder.attachments && <Attachments attachments={holder.attachments} layer={layer} />}
462
+ {holder.extensions && <Raw extensions={holder.extensions} />}
463
+ </MomentPlayback>
425
464
  </ScopeProvider>
426
465
  );
427
466
  }
@@ -444,6 +483,7 @@ export function useCheckDetail(layer: LayerHeader, row: LayerRow, enabled = true
444
483
  export function ResultEvidence({ layer, row }: { layer: LayerHeader; row: LayerRow }) {
445
484
  const wanted = row.evidence !== undefined || layer.evidence !== undefined;
446
485
  const detail = useCheckDetail(layer, row);
486
+ const requested = useRequestedMoment();
447
487
  if (!wanted) return null;
448
488
  if (detail.isLoading)
449
489
  return (
@@ -466,13 +506,19 @@ export function ResultEvidence({ layer, row }: { layer: LayerHeader; row: LayerR
466
506
  <ArtifactTagsProvider layer={layer.id} kind={row.kind} check={row.id} holders={[check, run]}>
467
507
  <Stack aria-label="Evidence" gap="sm" miw={0}>
468
508
  <TagFilterBar holders={[check, run]} />
509
+ {requested.clip !== undefined && requested.layer === layer.id && ![...videosIn(check), ...videosIn(run)].some((video) => attachmentIdentity(video) === requested.clip) && (
510
+ <Alert role="status" color="orange" title="The recording this link names is not in this result" data-moment-missing>
511
+ The link names a recording that this run did not record for this check. Nothing is played in its place, and no other run is
512
+ substituted.
513
+ </Alert>
514
+ )}
469
515
  {declared && (
470
516
  <Box aria-label="Scope of this result" component="section">
471
517
  <ScopeNotice scope={declared} name={row.title} />
472
518
  </Box>
473
519
  )}
474
520
  <ScopeProvider scope={inheritedScope}>
475
- <Evidence holder={check} layer={layer.id} steps />
521
+ <Evidence holder={check} layer={layer.id} steps owner={momentOwner(layer.id, row.kind, row.id)} />
476
522
  </ScopeProvider>
477
523
  {hasRun && (
478
524
  <Box aria-label="From the run" component="section" mb="lg">
@@ -6,22 +6,25 @@
6
6
  * record: run, environment, source, attempts). Also the coverage summary of the whole feature. A recording is always
7
7
  * labelled as illustrative evidence with its own status, provenance and revision; it never stands for approval.
8
8
  */
9
+ import { attachmentIdentity } from '@crvouga/atlas/reports/evidence-detail';
9
10
  import { effectiveScope } from '@crvouga/atlas/reports/evidence-scope';
10
11
  import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
11
12
  import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
12
13
  import { Alert, Badge, Box, Group, List, Paper, Stack, Tabs, Text, Title } from '@mantine/core';
13
14
  import { useMemo, type ReactNode } from 'react';
15
+ import type { Attachment } from '@crvouga/atlas/schema';
14
16
 
15
17
  import type { Assessment, ReleaseIdentity } from './applicability';
16
18
  import { ApplicabilitySummary } from './applicability-view';
17
- import { attachmentSource, useCheckDetail } from './artifacts';
19
+ import { attachmentSource, Timeline, useCheckDetail } from './artifacts';
18
20
  import { COVERAGE, COVERAGE_ORDER, type Behavior, type CoverageCounts, type CoverageState, type Revision } from './behavior';
19
21
  import { PlainSteps } from './check-detail';
20
22
  import { useEvidence } from './evidence-context';
21
- import { ScopeNotice } from './evidence-scope';
23
+ import { ScopeNotice, ScopeProvider } from './evidence-scope';
22
24
  import { plural, statusWord, useNow } from './format';
23
25
  import { exampleIndexFor, exampleRows, fillExample, type FeatureStep } from './gherkin';
24
26
  import { Origin, StatusMark } from './marks';
27
+ import { MomentPlayback, stepsOfRecording, useMomentVideo, useRequestedMoment } from './moments';
25
28
  import { outcomeHeadline, videosIn } from './outcome';
26
29
 
27
30
  /** The short, readable form of a spec fingerprint (`<length>:<hash>:<hash>`): its length and the start of its hash. */
@@ -80,14 +83,37 @@ function expectedSteps(steps: readonly FeatureStep[]): FeatureStep[] {
80
83
  return at < 0 ? [] : steps.slice(at);
81
84
  }
82
85
 
86
+ /** The recording itself: the one player the steps beside it seek, which marks the step playing. */
87
+ function WatchVideo({ attachment, url }: { attachment: Attachment; url: string }) {
88
+ const moments = useMomentVideo(attachment);
89
+ return (
90
+ <Box
91
+ component="video"
92
+ src={url}
93
+ controls
94
+ preload="metadata"
95
+ aria-label={`Recording: ${attachment.description ?? attachment.name}`}
96
+ maw="100%"
97
+ w="100%"
98
+ mah="70vh"
99
+ {...moments}
100
+ />
101
+ );
102
+ }
103
+
83
104
  /** The recording of one result, read only when this is on screen. */
84
105
  function Player({ result }: { result: EvidenceResult }) {
85
106
  const detail = useCheckDetail(result.layer, result.row);
86
107
  const videos = useMemo(() => videosIn(detail.data?.check), [detail.data]);
87
- const sources = videos.flatMap((attachment) => {
88
- const { url } = attachmentSource(attachment, result.layer.id);
89
- return url ? [{ attachment, url }] : [];
90
- });
108
+ const requested = useRequestedMoment();
109
+ const sources = useMemo(
110
+ () =>
111
+ videos.flatMap((attachment) => {
112
+ const { url } = attachmentSource(attachment, result.layer.id);
113
+ return url ? [{ attachment, url, list: [attachment] }] : [];
114
+ }),
115
+ [videos, result.layer.id]
116
+ );
91
117
  if (detail.isLoading) {
92
118
  return (
93
119
  <Text role="status" component="div" c="dimmed">
@@ -109,27 +135,20 @@ function Player({ result }: { result: EvidenceResult }) {
109
135
  </Text>
110
136
  );
111
137
  }
138
+ const shown = sources.find(({ attachment }) => attachmentIdentity(attachment) === requested.clip) ?? sources[0]!;
139
+ const { attachment, url } = shown;
140
+ const check = detail.data!.check;
141
+ const steps = stepsOfRecording(check, attachmentIdentity(attachment));
112
142
  return (
113
143
  <Stack gap="sm">
114
- {sources.slice(0, 1).map(({ attachment, url }) => (
115
- <Stack key={url} gap="xs">
116
- {/* What the recording does and does not show, as its producer declared it; undeclared says unknown. */}
117
- <ScopeNotice
118
- scope={effectiveScope(attachment.scope, detail.data?.check.scope, detail.data?.inheritedScope)}
119
- name={attachment.name}
120
- />
121
- <Box
122
- component="video"
123
- src={url}
124
- controls
125
- preload="metadata"
126
- aria-label={`Recording: ${attachment.description ?? attachment.name}`}
127
- maw="100%"
128
- w="100%"
129
- mah="70vh"
130
- />
131
- </Stack>
132
- ))}
144
+ {/* What the recording does and does not show, as its producer declared it; undeclared says unknown. */}
145
+ <ScopeProvider scope={effectiveScope(check.scope, detail.data?.inheritedScope)}>
146
+ <MomentPlayback steps={steps} attachments={shown.list} layer={result.layer.id}>
147
+ <ScopeNotice scope={effectiveScope(attachment.scope, check.scope, detail.data?.inheritedScope)} name={attachment.name} />
148
+ <WatchVideo attachment={attachment} url={url} />
149
+ {steps.length > 0 && <Timeline steps={steps} layer={result.layer.id} />}
150
+ </MomentPlayback>
151
+ </ScopeProvider>
133
152
  {sources.length > 1 && (
134
153
  <Text component="div" c="dimmed">
135
154
  {plural(sources.length - 1, 'more recording', 'more recordings')} in Inspect.
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { activeIndex, alignStep, clock, mediaTimingOf, pausedMs, UNALIGNED } from './media-timing';
4
+
5
+ const video = (media: unknown) => ({ extensions: { 'atlas.media': media } });
6
+
7
+ describe('what a recording declares about its clock', () => {
8
+ test('no declaration is no timing, never a timing that starts with the check', () => {
9
+ expect(mediaTimingOf({})).toBeUndefined();
10
+ expect(mediaTimingOf({ extensions: { 'com.example': {} } })).toBeUndefined();
11
+ expect(alignStep({ id: 'a', offsetMs: 0 }, undefined, 5000)).toEqual({ state: 'unavailable', reason: UNALIGNED.noTiming });
12
+ });
13
+
14
+ test('a declaration that cannot be read as a whole places nothing', () => {
15
+ for (const bad of [
16
+ 'text',
17
+ [],
18
+ { checkStartMs: '1000' },
19
+ { checkStartMs: 0, pauses: [{ atMs: 10 }] },
20
+ { checkStartMs: 0, pauses: 'long' },
21
+ { checkStartMs: 0, marks: [{ step: 'a' }] }
22
+ ]) {
23
+ const timing = mediaTimingOf(video(bad));
24
+ expect(timing?.problem).toBeDefined();
25
+ const aligned = alignStep({ id: 'a', offsetMs: 1 }, timing, undefined);
26
+ expect(aligned.state).toBe('unavailable');
27
+ }
28
+ });
29
+
30
+ test('a declaration without an origin places only the steps it marks', () => {
31
+ const timing = mediaTimingOf(video({ marks: [{ step: 'a', mediaMs: 700 }] }));
32
+ expect(alignStep({ id: 'a', offsetMs: 5 }, timing, undefined)).toEqual({ state: 'aligned', mediaMs: 700, source: 'mark', pausedMs: 0 });
33
+ expect(alignStep({ id: 'b', offsetMs: 5 }, timing, undefined)).toEqual({ state: 'unavailable', reason: UNALIGNED.noOrigin });
34
+ });
35
+ });
36
+
37
+ describe('placing a step in a recording', () => {
38
+ const timing = mediaTimingOf(video({ checkStartMs: 1200, pauses: [{ atMs: 2000, durationMs: 500 }, { atMs: 500, durationMs: 300, reason: 'title' }] }));
39
+
40
+ test('the recording starts before the check: the step is the origin plus its offset', () => {
41
+ expect(alignStep({ offsetMs: 0 }, timing, 20_000)).toMatchObject({ state: 'aligned', mediaMs: 1200, source: 'offset' });
42
+ expect(alignStep({ offsetMs: 100 }, timing, 20_000)).toMatchObject({ mediaMs: 1300 });
43
+ });
44
+
45
+ test('delays put into the recording move the steps after them and not the ones before', () => {
46
+ expect(alignStep({ offsetMs: 499 }, timing, undefined)).toMatchObject({ mediaMs: 1699, pausedMs: 0 });
47
+ expect(alignStep({ offsetMs: 500 }, timing, undefined)).toMatchObject({ mediaMs: 2000, pausedMs: 300 });
48
+ expect(alignStep({ offsetMs: 2000 }, timing, undefined)).toMatchObject({ mediaMs: 4000, pausedMs: 800 });
49
+ expect(pausedMs(timing)).toBe(800);
50
+ expect(timing!.pauses.map((pause) => pause.reason)).toEqual(['title', undefined]);
51
+ });
52
+
53
+ test('a mark states the moment outright and wins over the offset', () => {
54
+ const marked = mediaTimingOf(video({ checkStartMs: 0, marks: [{ step: 'a', mediaMs: 9 }] }));
55
+ expect(alignStep({ id: 'a', offsetMs: 5000 }, marked, undefined)).toMatchObject({ mediaMs: 9, source: 'mark' });
56
+ });
57
+
58
+ test('a step with no offset, before the start, or after the end is unavailable and is never clamped', () => {
59
+ expect(alignStep({ id: 'a' }, timing, undefined)).toEqual({ state: 'unavailable', reason: UNALIGNED.noOffset });
60
+ const early = mediaTimingOf(video({ checkStartMs: -500 }));
61
+ expect(alignStep({ offsetMs: 100 }, early, undefined)).toMatchObject({ state: 'unavailable', reason: expect.stringContaining('before the recording starts') });
62
+ expect(alignStep({ offsetMs: 100 }, early, undefined)).not.toHaveProperty('mediaMs');
63
+ expect(alignStep({ offsetMs: 9000 }, timing, 5000)).toMatchObject({ state: 'unavailable', reason: expect.stringContaining('after the recording ends') });
64
+ // A length that is not known never excludes a moment.
65
+ expect(alignStep({ offsetMs: 9000 }, timing, undefined).state).toBe('aligned');
66
+ });
67
+ });
68
+
69
+ describe('the step playing', () => {
70
+ const placed = [[0, 1000], [1, 3000], [2, 3000], [3, 6000]] as const;
71
+ test('is the latest step started, the innermost of several that started together, and none before the first', () => {
72
+ expect(activeIndex(placed, 500)).toBeNull();
73
+ expect(activeIndex(placed, 1000)).toBe(0);
74
+ expect(activeIndex(placed, 2999)).toBe(0);
75
+ expect(activeIndex(placed, 3000)).toBe(2);
76
+ expect(activeIndex(placed, 99_000)).toBe(3);
77
+ expect(activeIndex([], 10)).toBeNull();
78
+ });
79
+
80
+ test('a position reads as minutes, seconds and tenths', () => {
81
+ expect(clock(0)).toBe('0:00.0');
82
+ expect(clock(4250)).toBe('0:04.3');
83
+ expect(clock(61_000)).toBe('1:01.0');
84
+ });
85
+ });