@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
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# Changelog: @crvouga/atlas
|
|
2
2
|
|
|
3
|
-
## 1.
|
|
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
|
package/docs/rich-evidence.md
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
{
|
|
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
|
-
<
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
{
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
<ScopeNotice
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
});
|