@crvouga/atlas 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -1
- package/dist/critics/src/contract.d.ts +113 -0
- package/dist/critics/src/contract.js +48 -0
- package/dist/critics/src/credentials.d.ts +72 -0
- package/dist/critics/src/credentials.js +136 -0
- package/dist/critics/src/critique.d.ts +128 -0
- package/dist/critics/src/critique.js +163 -0
- package/dist/critics/src/index.d.ts +21 -0
- package/dist/critics/src/index.js +21 -0
- package/dist/critics/src/judging.d.ts +153 -0
- package/dist/critics/src/judging.js +145 -0
- package/dist/critics/src/publish.d.ts +23 -0
- package/dist/critics/src/publish.js +21 -0
- package/dist/critics/src/runner.d.ts +104 -0
- package/dist/critics/src/runner.js +202 -0
- package/dist/critics/src/scope.d.ts +8 -0
- package/dist/critics/src/scope.js +26 -0
- package/dist/reports/src/compose.d.ts +2 -2
- package/dist/reports/src/compose.js +6 -5
- package/dist/reports/src/evidence-view.d.ts +2 -1
- package/dist/reports/src/evidence-view.js +5 -2
- package/dist/reports/src/layer-merge.d.ts +11 -3
- package/dist/reports/src/layer-merge.js +14 -3
- package/dist/reports/src/report-status.d.ts +1 -1
- package/dist/reports/src/report-status.js +6 -4
- package/dist/schema/src/api.d.ts +1 -0
- package/dist/schema/src/api.js +1 -1
- package/dist/schema/src/critique.d.ts +144 -0
- package/dist/schema/src/critique.js +96 -0
- package/dist/schema/src/index.d.ts +2 -1
- package/dist/schema/src/index.js +2 -1
- package/dist/schema/src/server-config.d.ts +7 -1
- package/dist/schema/src/server-config.js +6 -3
- package/dist/server/src/auth.d.ts +1 -1
- package/dist/server/src/auth.js +2 -2
- package/dist/server/src/config.js +2 -2
- package/dist/server/src/openapi.d.ts +3 -3
- package/dist/server/src/openapi.js +7 -4
- package/dist/server/src/store-api.js +39 -1
- package/docs/agents.md +1 -0
- package/docs/critics.md +190 -0
- package/docs/extending.md +8 -0
- package/docs/reference/critics.md +840 -0
- package/docs/reference/http-api.md +6 -2
- package/docs/reference/reports.md +20 -7
- package/docs/reference/schema.md +119 -2
- package/docs/reference/server.md +3 -3
- package/docs/report-contract.md +4 -0
- package/docs/rich-evidence.md +39 -0
- package/docs/server.md +6 -4
- package/docs/ui.md +22 -0
- package/llms-full.txt +279 -6
- package/llms.txt +2 -0
- package/package.json +7 -2
- package/packages/critics/README.md +18 -0
- package/packages/schema/schemas/critique.schema.json +254 -0
- package/packages/schema/schemas/server-config.schema.json +2 -1
- package/packages/schema/schemas/server-info.schema.json +1 -0
- package/packages/server/openapi.json +7 -6
- 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
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Recording moments
|
|
3
|
+
*
|
|
4
|
+
* Connects a check's steps to its recordings. Inside a {@link MomentPlayback}, a step offers to show itself in the
|
|
5
|
+
* selected recording (its place comes from the recording's own timing, `media-timing.ts`, never from an assumption that
|
|
6
|
+
* the check and the recording started together), playback marks the step that is playing without moving focus, the
|
|
7
|
+
* failing step can be jumped to, and the current moment can be copied as a link that names the run, the recording and
|
|
8
|
+
* the position. A step that cannot be placed says why and nothing is guessed. One provider serves one holder (a check
|
|
9
|
+
* or one of its retries): a retry's steps are placed in that retry's own recording with that recording's own timing.
|
|
10
|
+
*/
|
|
11
|
+
import { attachmentIdentity, isVideoAttachment, type EvidenceHolder } from '@crvouga/atlas/reports/evidence-detail';
|
|
12
|
+
import { effectiveScope } from '@crvouga/atlas/reports/evidence-scope';
|
|
13
|
+
import type { Attachment, Step } from '@crvouga/atlas/schema';
|
|
14
|
+
import { Alert, Button, Group, NativeSelect, Stack, Text, TextInput } from '@mantine/core';
|
|
15
|
+
import { IconAlertTriangle, IconLink, IconPlayerPlay } from '@tabler/icons-react';
|
|
16
|
+
import { useRouter } from '@tanstack/react-router';
|
|
17
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, type SyntheticEvent } from 'react';
|
|
18
|
+
|
|
19
|
+
import { useScope } from './evidence-scope';
|
|
20
|
+
import { duration } from './format';
|
|
21
|
+
import { activeIndex, alignStep, clock, mediaTimingOf, pausedMs, type Alignment, type MediaTiming } from './media-timing';
|
|
22
|
+
import { failedStepOf } from './outcome';
|
|
23
|
+
|
|
24
|
+
/** One recording whose clock the steps can be placed on. */
|
|
25
|
+
export type MomentVideo = { readonly id: string; readonly attachment: Attachment; readonly timing: MediaTiming | undefined };
|
|
26
|
+
|
|
27
|
+
/** One step and where it is in the selected recording. */
|
|
28
|
+
export type Moment = { readonly index: number; readonly step: Step; readonly alignment: Alignment };
|
|
29
|
+
|
|
30
|
+
/** What asking for a moment came to. */
|
|
31
|
+
export type MomentOutcome = { readonly seeked: boolean; readonly text: string };
|
|
32
|
+
|
|
33
|
+
type Notice = { readonly text: string };
|
|
34
|
+
|
|
35
|
+
type Playback = {
|
|
36
|
+
readonly videos: readonly MomentVideo[];
|
|
37
|
+
readonly selected: MomentVideo | null;
|
|
38
|
+
readonly select: (id: string) => void;
|
|
39
|
+
readonly momentOf: (step: Step) => Moment | undefined;
|
|
40
|
+
/** The index (in the holder's steps) of the step that is playing. */
|
|
41
|
+
readonly active: number | null;
|
|
42
|
+
readonly notice: Notice | null;
|
|
43
|
+
readonly seek: (index: number) => MomentOutcome;
|
|
44
|
+
readonly seekFailure: () => MomentOutcome;
|
|
45
|
+
readonly failed: Step | undefined;
|
|
46
|
+
/** How many steps the holder recorded. */
|
|
47
|
+
readonly stepCount: number;
|
|
48
|
+
readonly currentMs: () => number | undefined;
|
|
49
|
+
readonly linkTo: (atMs: number) => string | null;
|
|
50
|
+
register: (id: string, element: HTMLVideoElement | null) => void;
|
|
51
|
+
loaded: (id: string, element: HTMLVideoElement) => void;
|
|
52
|
+
progress: (id: string, element: HTMLVideoElement) => void;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const Around = createContext<Playback | null>(null);
|
|
56
|
+
|
|
57
|
+
/** The playback of the holder around this point, null outside one. */
|
|
58
|
+
export function usePlayback(): Playback | null {
|
|
59
|
+
return useContext(Around);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The window event by which a part of the page asks the check's player for a moment (see {@link requestFailureMoment}). */
|
|
63
|
+
const REQUEST = 'atlas:moment-request';
|
|
64
|
+
type Request = { kind: 'failure'; owner: string; handled?: MomentOutcome };
|
|
65
|
+
|
|
66
|
+
/** Names the one result of a check, so a request reaches the player of that result and not another open beside it. */
|
|
67
|
+
export function momentOwner(layer: string, kind: string, check: string): string {
|
|
68
|
+
return `${layer}|${kind}|${check}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Asks the player of the result named by `owner` (see {@link momentOwner}) to jump to the step where it failed. Returns
|
|
73
|
+
* what came of it, or null when no player of that result is on screen (nothing is read or played on its behalf).
|
|
74
|
+
*/
|
|
75
|
+
export function requestFailureMoment(owner: string): MomentOutcome | null {
|
|
76
|
+
const detail: Request = { kind: 'failure', owner };
|
|
77
|
+
window.dispatchEvent(new CustomEvent(REQUEST, { detail }));
|
|
78
|
+
return detail.handled ?? null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The moment an address names: the recording (an `attachmentIdentity`), a position in it, and the run's layer. */
|
|
82
|
+
export type RequestedMoment = { readonly clip: string | undefined; readonly atMs: number | undefined; readonly layer: string | undefined };
|
|
83
|
+
|
|
84
|
+
type RouterLike = {
|
|
85
|
+
state: { location: { pathname: string; search: unknown } };
|
|
86
|
+
buildLocation: (options: { to: string; search: unknown }) => { href: string };
|
|
87
|
+
history: { createHref: (href: string) => string };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function text(value: unknown): string | undefined {
|
|
91
|
+
return typeof value === 'string' && value !== '' ? value : typeof value === 'number' ? String(value) : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The moment the address names, read when it is called; all undefined when the address names none or there is no router. */
|
|
95
|
+
export function useRequestedMoment(): RequestedMoment {
|
|
96
|
+
const router = useRouter({ warn: false }) as unknown as RouterLike | undefined;
|
|
97
|
+
const search = (router?.state.location.search ?? {}) as Record<string, unknown>;
|
|
98
|
+
const at = Number(search.clipAt);
|
|
99
|
+
return { clip: text(search.clip), atMs: search.clipAt !== undefined && Number.isFinite(at) && at >= 0 ? at : undefined, layer: text(search.layer) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The recordings among `attachments` that name bytes, each once, with the timing they declare. */
|
|
103
|
+
function momentVideos(attachments: readonly Attachment[]): MomentVideo[] {
|
|
104
|
+
const seen = new Set<string>();
|
|
105
|
+
return attachments.flatMap((attachment) => {
|
|
106
|
+
const id = isVideoAttachment(attachment) ? attachmentIdentity(attachment) : undefined;
|
|
107
|
+
if (id === undefined || seen.has(id)) return [];
|
|
108
|
+
seen.add(id);
|
|
109
|
+
return [{ id, attachment, timing: mediaTimingOf(attachment) }];
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const NONE: readonly Step[] = [];
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The steps that belong to the recording with this identity in a check's evidence: those of the check when it (or one of
|
|
117
|
+
* its steps) carries the recording, else those of the retry that does. A retry's recording is never read with another
|
|
118
|
+
* try's steps.
|
|
119
|
+
*/
|
|
120
|
+
export function stepsOfRecording(check: EvidenceHolder, identity: string | undefined): readonly Step[] {
|
|
121
|
+
const carries = (attachments: readonly Attachment[] | undefined, steps: readonly Step[] | undefined) =>
|
|
122
|
+
identity !== undefined &&
|
|
123
|
+
[...(attachments ?? []), ...(steps ?? []).flatMap((step) => step.attachments ?? [])].some((item) => attachmentIdentity(item) === identity);
|
|
124
|
+
if (carries(check.attachments, check.steps)) return check.steps ?? NONE;
|
|
125
|
+
const attempt = (check.attempts ?? []).find((each) => carries(each.attachments, each.steps));
|
|
126
|
+
return (attempt ? attempt.steps : check.steps) ?? NONE;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Places the steps of one holder in its recordings. `attachments` are the holder's own files (the videos among them are
|
|
131
|
+
* the recordings); `steps` are its timeline. `primary` marks the check itself, which answers
|
|
132
|
+
* {@link requestFailureMoment}. `linkSearch` is merged into the current address to make a link to a moment (by default
|
|
133
|
+
* the exact `layer`). When the address names one of the recordings (`clip`, `clipAt`), that recording is selected and the
|
|
134
|
+
* player goes to the position once it can.
|
|
135
|
+
*/
|
|
136
|
+
export function MomentPlayback({
|
|
137
|
+
steps = NONE,
|
|
138
|
+
attachments,
|
|
139
|
+
layer,
|
|
140
|
+
primary = false,
|
|
141
|
+
owner,
|
|
142
|
+
linkSearch,
|
|
143
|
+
children
|
|
144
|
+
}: {
|
|
145
|
+
steps?: readonly Step[];
|
|
146
|
+
attachments: readonly Attachment[];
|
|
147
|
+
layer: string;
|
|
148
|
+
primary?: boolean;
|
|
149
|
+
owner?: string;
|
|
150
|
+
linkSearch?: Record<string, unknown>;
|
|
151
|
+
children: ReactNode;
|
|
152
|
+
}) {
|
|
153
|
+
const router = useRouter({ warn: false }) as unknown as RouterLike | undefined;
|
|
154
|
+
const requested = useRequestedMoment();
|
|
155
|
+
const videos = useMemo(() => momentVideos(attachments), [attachments]);
|
|
156
|
+
const [chosen, setChosen] = useState<string | null>(() =>
|
|
157
|
+
requested.clip !== undefined && videos.some((video) => video.id === requested.clip)
|
|
158
|
+
? requested.clip
|
|
159
|
+
: ((videos.find((video) => video.timing && !video.timing.problem) ?? videos[0])?.id ?? null)
|
|
160
|
+
);
|
|
161
|
+
const selected = videos.find((video) => video.id === chosen) ?? videos[0] ?? null;
|
|
162
|
+
const elements = useRef(new Map<string, HTMLVideoElement>());
|
|
163
|
+
const pending = useRef<{ id: string; atMs: number } | null>(
|
|
164
|
+
requested.clip !== undefined && requested.atMs !== undefined && videos.some((video) => video.id === requested.clip)
|
|
165
|
+
? { id: requested.clip, atMs: requested.atMs }
|
|
166
|
+
: null
|
|
167
|
+
);
|
|
168
|
+
const [lengths, setLengths] = useState<Readonly<Record<string, number>>>({});
|
|
169
|
+
const [active, setActive] = useState<number | null>(null);
|
|
170
|
+
const [notice, setNotice] = useState<Notice | null>(null);
|
|
171
|
+
|
|
172
|
+
const lengthMs = selected ? (lengths[selected.id] ?? selected.attachment.durationMs) : undefined;
|
|
173
|
+
const moments = useMemo<Moment[]>(
|
|
174
|
+
() => steps.map((step, index) => ({ index, step, alignment: alignStep(step, selected?.timing, lengthMs) })),
|
|
175
|
+
[steps, selected, lengthMs]
|
|
176
|
+
);
|
|
177
|
+
const byStep = useMemo(() => new Map(moments.map((moment) => [moment.step, moment])), [moments]);
|
|
178
|
+
const placed = useMemo(
|
|
179
|
+
() => moments.flatMap((moment) => (moment.alignment.state === 'aligned' ? [[moment.index, moment.alignment.mediaMs] as const] : [])),
|
|
180
|
+
[moments]
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const goTo = useCallback(
|
|
184
|
+
(element: HTMLVideoElement | undefined, atMs: number): MomentOutcome => {
|
|
185
|
+
if (!element) return { seeked: false, text: 'The recording is not loaded, so it cannot be moved.' };
|
|
186
|
+
const length = Number.isFinite(element.duration) && element.duration > 0 ? element.duration * 1000 : undefined;
|
|
187
|
+
if (length !== undefined && atMs > length + 1) {
|
|
188
|
+
const text = `${clock(atMs)} is after the end of the recording, which is ${clock(length)} long. Nothing was moved.`;
|
|
189
|
+
setNotice({ text });
|
|
190
|
+
return { seeked: false, text };
|
|
191
|
+
}
|
|
192
|
+
element.currentTime = atMs / 1000;
|
|
193
|
+
setActive(activeIndex(placed, atMs));
|
|
194
|
+
setNotice(null);
|
|
195
|
+
// The page scrolls to the player; focus stays where it is.
|
|
196
|
+
element.scrollIntoView?.({ block: 'nearest' });
|
|
197
|
+
return { seeked: true, text: `Moved the recording to ${clock(atMs)}.` };
|
|
198
|
+
},
|
|
199
|
+
[placed]
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const seek = useCallback(
|
|
203
|
+
(index: number): MomentOutcome => {
|
|
204
|
+
const moment = moments[index];
|
|
205
|
+
if (!moment || !selected) return { seeked: false, text: 'This result has no recording to place the step in.' };
|
|
206
|
+
if (moment.alignment.state === 'unavailable') {
|
|
207
|
+
const text = `“${moment.step.name}” cannot be shown in the recording. ${moment.alignment.reason}`;
|
|
208
|
+
setNotice({ text });
|
|
209
|
+
return { seeked: false, text };
|
|
210
|
+
}
|
|
211
|
+
return goTo(elements.current.get(selected.id), moment.alignment.mediaMs);
|
|
212
|
+
},
|
|
213
|
+
[moments, selected, goTo]
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const failed = useMemo(() => failedStepOf(steps), [steps]);
|
|
217
|
+
const seekFailure = useCallback((): MomentOutcome => {
|
|
218
|
+
if (!failed) return { seeked: false, text: 'No failed step was recorded, so there is no failing moment to show.' };
|
|
219
|
+
return seek(steps.indexOf(failed));
|
|
220
|
+
}, [failed, steps, seek]);
|
|
221
|
+
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (!primary || owner === undefined) return;
|
|
224
|
+
const answer = (event: Event) => {
|
|
225
|
+
const request = (event as CustomEvent<Request>).detail;
|
|
226
|
+
if (request?.kind === 'failure' && request.owner === owner && request.handled === undefined) request.handled = seekFailure();
|
|
227
|
+
};
|
|
228
|
+
window.addEventListener(REQUEST, answer);
|
|
229
|
+
return () => window.removeEventListener(REQUEST, answer);
|
|
230
|
+
}, [primary, owner, seekFailure]);
|
|
231
|
+
|
|
232
|
+
const progress = useCallback(
|
|
233
|
+
(id: string, element: HTMLVideoElement) => {
|
|
234
|
+
if (id === selected?.id) setActive(activeIndex(placed, element.currentTime * 1000));
|
|
235
|
+
},
|
|
236
|
+
[selected, placed]
|
|
237
|
+
);
|
|
238
|
+
const register = useCallback((id: string, element: HTMLVideoElement | null) => {
|
|
239
|
+
if (element) elements.current.set(id, element);
|
|
240
|
+
else elements.current.delete(id);
|
|
241
|
+
}, []);
|
|
242
|
+
const loaded = useCallback(
|
|
243
|
+
(id: string, element: HTMLVideoElement) => {
|
|
244
|
+
if (Number.isFinite(element.duration) && element.duration > 0) setLengths((held) => ({ ...held, [id]: element.duration * 1000 }));
|
|
245
|
+
const wanted = pending.current;
|
|
246
|
+
if (wanted && wanted.id === id) {
|
|
247
|
+
pending.current = null;
|
|
248
|
+
goTo(element, wanted.atMs);
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
[goTo]
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const linkTo = useCallback(
|
|
255
|
+
(atMs: number): string | null => {
|
|
256
|
+
if (!router || !selected) return null;
|
|
257
|
+
try {
|
|
258
|
+
const built = router.buildLocation({
|
|
259
|
+
to: router.state.location.pathname,
|
|
260
|
+
search: (prev: Record<string, unknown>) => ({ ...prev, ...(linkSearch ?? { layer }), clip: selected.id, clipAt: Math.round(atMs) })
|
|
261
|
+
});
|
|
262
|
+
return new URL(router.history.createHref(built.href), window.location.href).href;
|
|
263
|
+
} catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
[router, selected, linkSearch, layer]
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const value = useMemo<Playback>(
|
|
271
|
+
() => ({
|
|
272
|
+
videos,
|
|
273
|
+
selected,
|
|
274
|
+
select: (id) => {
|
|
275
|
+
setChosen(id);
|
|
276
|
+
setActive(null);
|
|
277
|
+
setNotice(null);
|
|
278
|
+
},
|
|
279
|
+
momentOf: (step) => byStep.get(step),
|
|
280
|
+
active,
|
|
281
|
+
notice,
|
|
282
|
+
seek,
|
|
283
|
+
seekFailure,
|
|
284
|
+
failed,
|
|
285
|
+
stepCount: steps.length,
|
|
286
|
+
currentMs: () => {
|
|
287
|
+
const element = selected ? elements.current.get(selected.id) : undefined;
|
|
288
|
+
return element ? element.currentTime * 1000 : undefined;
|
|
289
|
+
},
|
|
290
|
+
linkTo,
|
|
291
|
+
register,
|
|
292
|
+
loaded,
|
|
293
|
+
progress
|
|
294
|
+
}),
|
|
295
|
+
[videos, selected, byStep, active, notice, seek, seekFailure, failed, steps, linkTo, register, loaded, progress]
|
|
296
|
+
);
|
|
297
|
+
return <Around.Provider value={value}>{children}</Around.Provider>;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The props that join a `<video>` of `attachment` to the playback around it; undefined when it is not one of its recordings. */
|
|
301
|
+
export function useMomentVideo(attachment: Attachment):
|
|
302
|
+
| {
|
|
303
|
+
ref: (element: HTMLVideoElement | null) => void;
|
|
304
|
+
onLoadedMetadata: (event: SyntheticEvent<HTMLVideoElement>) => void;
|
|
305
|
+
onTimeUpdate: (event: SyntheticEvent<HTMLVideoElement>) => void;
|
|
306
|
+
onSeeked: (event: SyntheticEvent<HTMLVideoElement>) => void;
|
|
307
|
+
}
|
|
308
|
+
| undefined {
|
|
309
|
+
const playback = usePlayback();
|
|
310
|
+
const id = isVideoAttachment(attachment) ? attachmentIdentity(attachment) : undefined;
|
|
311
|
+
const member = playback !== null && id !== undefined && playback.videos.some((video) => video.id === id);
|
|
312
|
+
const register = playback?.register;
|
|
313
|
+
const ref = useCallback(
|
|
314
|
+
(element: HTMLVideoElement | null) => {
|
|
315
|
+
if (id !== undefined) register?.(id, element);
|
|
316
|
+
},
|
|
317
|
+
[register, id]
|
|
318
|
+
);
|
|
319
|
+
if (!playback || !member || id === undefined) return undefined;
|
|
320
|
+
return {
|
|
321
|
+
ref,
|
|
322
|
+
onLoadedMetadata: (event) => playback.loaded(id, event.currentTarget),
|
|
323
|
+
onTimeUpdate: (event) => playback.progress(id, event.currentTarget),
|
|
324
|
+
onSeeked: (event) => playback.progress(id, event.currentTarget)
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const label = (attachment: Attachment) => attachment.description ?? attachment.name;
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* A step's control in a timeline: shows the step in the selected recording at its recorded moment, or says that it
|
|
332
|
+
* cannot be placed. Renders nothing outside a playback with a recording.
|
|
333
|
+
*/
|
|
334
|
+
export function SeekStep({ step }: { step: Step }) {
|
|
335
|
+
const playback = usePlayback();
|
|
336
|
+
const moment = playback?.momentOf(step);
|
|
337
|
+
if (!playback || !moment || !playback.selected) return null;
|
|
338
|
+
const aligned = moment.alignment.state === 'aligned';
|
|
339
|
+
const at = moment.alignment.state === 'aligned' ? clock(moment.alignment.mediaMs) : '';
|
|
340
|
+
return (
|
|
341
|
+
<Button
|
|
342
|
+
type="button"
|
|
343
|
+
size="compact-xs"
|
|
344
|
+
variant="subtle"
|
|
345
|
+
data-seek={aligned ? 'aligned' : 'unavailable'}
|
|
346
|
+
leftSection={<IconPlayerPlay size={14} aria-hidden="true" />}
|
|
347
|
+
aria-label={aligned ? `Show “${step.name}” in the recording at ${at}` : `Show “${step.name}” in the recording (alignment unavailable)`}
|
|
348
|
+
onClick={() => playback.seek(moment.index)}
|
|
349
|
+
>
|
|
350
|
+
{aligned ? `Recording ${at}` : 'Not aligned'}
|
|
351
|
+
</Button>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Whether the step is the one playing, so its row can say so. */
|
|
356
|
+
export function useIsPlaying(step: Step): boolean {
|
|
357
|
+
const playback = usePlayback();
|
|
358
|
+
const moment = playback?.momentOf(step);
|
|
359
|
+
return moment !== undefined && playback?.active === moment.index;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* What the playback says above a timeline: which recording the steps are placed in and how, the pacing of a paced
|
|
364
|
+
* recording (never application latency), the jump to the failing moment, the link to the current moment, and why a
|
|
365
|
+
* step could not be shown. Renders nothing outside a playback with a recording.
|
|
366
|
+
*/
|
|
367
|
+
export function MomentBar() {
|
|
368
|
+
const playback = usePlayback();
|
|
369
|
+
const scope = useScope();
|
|
370
|
+
const [copied, setCopied] = useState<'copied' | string | null>(null);
|
|
371
|
+
const [jump, setJump] = useState<string | null>(null);
|
|
372
|
+
if (!playback || !playback.selected) return null;
|
|
373
|
+
const { selected, videos, notice, failed } = playback;
|
|
374
|
+
const scoped = effectiveScope(selected.attachment.scope, scope);
|
|
375
|
+
const timing = selected.timing;
|
|
376
|
+
const paused = pausedMs(timing);
|
|
377
|
+
const reasons = [...new Set((timing?.pauses ?? []).flatMap((pause) => (pause.reason ? [pause.reason] : [])))];
|
|
378
|
+
const paced = scoped?.pacing === 'paced' || (timing?.pauses.length ?? 0) > 0;
|
|
379
|
+
const copy = async () => {
|
|
380
|
+
const at = playback.currentMs() ?? 0;
|
|
381
|
+
const link = playback.linkTo(at);
|
|
382
|
+
if (!link) {
|
|
383
|
+
setCopied('A link to this moment could not be made from here.');
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
await navigator.clipboard.writeText(link);
|
|
388
|
+
setCopied('copied');
|
|
389
|
+
} catch {
|
|
390
|
+
setCopied(link);
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
return (
|
|
394
|
+
<Stack gap="xs" component="section" aria-label="Recording moments" data-moments>
|
|
395
|
+
{videos.length > 1 && (
|
|
396
|
+
<NativeSelect
|
|
397
|
+
label="Recording the steps are shown in"
|
|
398
|
+
value={selected.id}
|
|
399
|
+
onChange={(event) => playback.select(event.currentTarget.value)}
|
|
400
|
+
data={videos.map((video) => ({ value: video.id, label: label(video.attachment) }))}
|
|
401
|
+
/>
|
|
402
|
+
)}
|
|
403
|
+
{playback.stepCount > 0 && (
|
|
404
|
+
<Text component="div" c="dimmed" data-alignment={timing && !timing.problem ? 'recorded' : 'unavailable'}>
|
|
405
|
+
{timing && !timing.problem
|
|
406
|
+
? timing.checkStartMs !== undefined
|
|
407
|
+
? `Steps are placed in ${label(selected.attachment)} from its recorded timing: the check starts ${clock(timing.checkStartMs)} into the recording.`
|
|
408
|
+
: `Steps are placed in ${label(selected.attachment)} only where its producer recorded the moment.`
|
|
409
|
+
: `Steps cannot be placed in ${label(selected.attachment)}: ${timing?.problem ?? 'it carries no timing metadata.'} Atlas does not guess where a step is.`}
|
|
410
|
+
</Text>
|
|
411
|
+
)}
|
|
412
|
+
{paced && playback.stepCount > 0 && (
|
|
413
|
+
<Text component="div" c="dimmed" data-pacing>
|
|
414
|
+
Paced recording
|
|
415
|
+
{timing && timing.pauses.length > 0
|
|
416
|
+
? `: ${timing.pauses.length === 1 ? 'a pause' : `${timing.pauses.length} pauses`} totalling ${duration(paused)} ${timing.pauses.length === 1 ? 'was' : 'were'} put into the recording${reasons.length ? ` (${reasons.join(', ')})` : ''}. `
|
|
417
|
+
: ': it was slowed for a viewer. '}
|
|
418
|
+
The time between steps includes deliberate pauses; it is pacing, not application latency.
|
|
419
|
+
</Text>
|
|
420
|
+
)}
|
|
421
|
+
<Group gap="xs" wrap="wrap">
|
|
422
|
+
{failed && (
|
|
423
|
+
<Button
|
|
424
|
+
type="button"
|
|
425
|
+
variant="light"
|
|
426
|
+
color="red"
|
|
427
|
+
leftSection={<IconAlertTriangle size={16} aria-hidden="true" />}
|
|
428
|
+
onClick={() => setJump(playback.seekFailure().text)}
|
|
429
|
+
>
|
|
430
|
+
Jump to the failing moment
|
|
431
|
+
</Button>
|
|
432
|
+
)}
|
|
433
|
+
<Button type="button" variant="default" leftSection={<IconLink size={16} aria-hidden="true" />} onClick={() => void copy()}>
|
|
434
|
+
Copy link to this moment
|
|
435
|
+
</Button>
|
|
436
|
+
</Group>
|
|
437
|
+
{copied === 'copied' && (
|
|
438
|
+
<Text component="div" role="status" c="dimmed">
|
|
439
|
+
Link copied. It names this run, this recording and this position, and opens that run even when a newer one exists.
|
|
440
|
+
</Text>
|
|
441
|
+
)}
|
|
442
|
+
{copied !== null && copied !== 'copied' && (
|
|
443
|
+
<TextInput label="Link to this moment" description="Copy it from here." readOnly value={copied} onFocus={(event) => event.currentTarget.select()} />
|
|
444
|
+
)}
|
|
445
|
+
{jump && !notice && (
|
|
446
|
+
<Text component="div" role="status" c="dimmed">
|
|
447
|
+
{jump}
|
|
448
|
+
</Text>
|
|
449
|
+
)}
|
|
450
|
+
{notice && (
|
|
451
|
+
<Alert color="orange" role="status" data-moment-notice>
|
|
452
|
+
{notice.text}
|
|
453
|
+
</Alert>
|
|
454
|
+
)}
|
|
455
|
+
</Stack>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
@@ -11,9 +11,12 @@ import type { CheckSlot, HistoryEntry } from '@crvouga/atlas/reports/evidence-vi
|
|
|
11
11
|
import type { CheckDetail } from '@crvouga/atlas/reports/evidence-detail';
|
|
12
12
|
import { effectiveScope, scopeBadges } from '@crvouga/atlas/reports/evidence-scope';
|
|
13
13
|
import { Badge, Box, Button, Flex, List, Paper, Stack, Text, Title } from '@mantine/core';
|
|
14
|
+
import { IconAlertTriangle } from '@tabler/icons-react';
|
|
15
|
+
import { useState } from 'react';
|
|
14
16
|
|
|
15
17
|
import { ago, duration, shortSha, statusWord } from './format';
|
|
16
18
|
import { StatusMark } from './marks';
|
|
19
|
+
import { momentOwner, requestFailureMoment } from './moments';
|
|
17
20
|
import {
|
|
18
21
|
chronology,
|
|
19
22
|
failureNote,
|
|
@@ -67,6 +70,7 @@ export function OutcomeSummary({
|
|
|
67
70
|
const video = point?.videos[0] ?? videosIn(detail?.check)[0];
|
|
68
71
|
const scope = video ? effectiveScope(video.scope, detail?.check.scope, detail?.inheritedScope) : undefined;
|
|
69
72
|
const timeline = chronology(recordOf(selected), history);
|
|
73
|
+
const [jumped, setJumped] = useState<string | null>(null);
|
|
70
74
|
return (
|
|
71
75
|
<Paper component="section" aria-label="Outcome summary" data-outcome={selected.row.status} withBorder p="md">
|
|
72
76
|
<Stack gap="sm" miw={0}>
|
|
@@ -164,6 +168,29 @@ export function OutcomeSummary({
|
|
|
164
168
|
</Text>
|
|
165
169
|
</Text>
|
|
166
170
|
)}
|
|
171
|
+
{video && point && (
|
|
172
|
+
<Flex gap="xs" align="center" wrap="wrap">
|
|
173
|
+
<Button
|
|
174
|
+
type="button"
|
|
175
|
+
variant="light"
|
|
176
|
+
color="red"
|
|
177
|
+
leftSection={<IconAlertTriangle size={16} aria-hidden="true" />}
|
|
178
|
+
onClick={() =>
|
|
179
|
+
setJumped(
|
|
180
|
+
requestFailureMoment(momentOwner(selected.layer.id, selected.row.kind, selected.row.id))?.text ??
|
|
181
|
+
'The recording of this result is not on screen yet. It is in the evidence below.'
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
>
|
|
185
|
+
Jump to the failing moment in the recording
|
|
186
|
+
</Button>
|
|
187
|
+
{jumped && (
|
|
188
|
+
<Text span inherit role="status" c="dimmed" data-jump-result>
|
|
189
|
+
{jumped}
|
|
190
|
+
</Text>
|
|
191
|
+
)}
|
|
192
|
+
</Flex>
|
|
193
|
+
)}
|
|
167
194
|
</Stack>
|
|
168
195
|
)}
|
|
169
196
|
{failed && (
|
|
@@ -92,12 +92,17 @@ export function failureNote(extensions: Readonly<Record<string, unknown>> | unde
|
|
|
92
92
|
* the action). Steps are ordered by their start offset, else by their position. Undefined when no step failed or none
|
|
93
93
|
* was recorded.
|
|
94
94
|
*/
|
|
95
|
-
export function
|
|
95
|
+
export function failedStepOf(steps: readonly Step[] | undefined): Step | undefined {
|
|
96
96
|
if (!steps?.length) return undefined;
|
|
97
97
|
const failedParents = new Set(steps.filter((step) => step.status === 'failed' && step.parentId).map((step) => step.parentId));
|
|
98
98
|
const order = steps.map((step, index) => ({ step, index }));
|
|
99
99
|
order.sort((a, b) => (a.step.offsetMs ?? 0) - (b.step.offsetMs ?? 0) || a.index - b.index);
|
|
100
|
-
|
|
100
|
+
return order.find(({ step }) => step.status === 'failed' && !(step.id !== undefined && failedParents.has(step.id)))?.step;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** {@link failedStepOf} described as the point a failure happened at: its kind and phase, message, offset and own videos. */
|
|
104
|
+
export function failedStep(steps: readonly Step[] | undefined): FailurePoint | undefined {
|
|
105
|
+
const found = failedStepOf(steps);
|
|
101
106
|
if (!found) return undefined;
|
|
102
107
|
return {
|
|
103
108
|
name: found.name,
|
|
@@ -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" />
|