@crvouga/atlas 1.9.0 → 1.11.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 +15 -1
- package/dist/reports/src/review-collection-store.d.ts +47 -0
- package/dist/reports/src/review-collection-store.js +214 -0
- package/dist/reports/src/review-collection.d.ts +88 -0
- package/dist/reports/src/review-collection.js +144 -0
- package/dist/reports/src/test-catalog.d.ts +2 -0
- package/dist/reports/src/test-catalog.js +1 -1
- package/dist/schema/src/api.d.ts +574 -0
- package/dist/schema/src/api.js +152 -1
- package/dist/server/src/app.js +4 -1
- package/dist/server/src/openapi.js +46 -1
- package/dist/server/src/review-collections.d.ts +39 -0
- package/dist/server/src/review-collections.js +179 -0
- package/docs/extending.md +1 -1
- package/docs/reference/http-api.md +122 -0
- package/docs/reference/reports.md +294 -0
- package/docs/reference/schema.md +131 -0
- package/docs/reference/server.md +44 -0
- package/docs/rich-evidence.md +38 -0
- package/docs/server.md +41 -0
- package/docs/ui.md +67 -1
- package/llms-full.txt +269 -2
- package/package.json +1 -1
- package/packages/schema/schemas/review-collection.schema.json +398 -0
- package/packages/schema/schemas/review-manifest.schema.json +475 -0
- package/packages/server/openapi.json +6729 -4084
- package/packages/visualizer/src/app/router.tsx +6 -1
- package/packages/visualizer/src/evidence/applicability-view.tsx +233 -0
- package/packages/visualizer/src/evidence/applicability.test.ts +131 -0
- package/packages/visualizer/src/evidence/applicability.ts +241 -0
- package/packages/visualizer/src/evidence/artifact-tags.tsx +3 -2
- package/packages/visualizer/src/evidence/behavior-panel.tsx +11 -1
- package/packages/visualizer/src/evidence/feature-page.tsx +25 -4
- package/packages/visualizer/src/explorer/ArtifactsView.tsx +26 -2
- package/packages/visualizer/src/explorer/Explorer.tsx +7 -2
- package/packages/visualizer/src/explorer/RecordingTextSearch.tsx +355 -0
- package/packages/visualizer/src/explorer/RunsView.tsx +11 -1
- package/packages/visualizer/src/explorer/collection-model.test.ts +31 -0
- package/packages/visualizer/src/explorer/collection-model.ts +45 -0
- package/packages/visualizer/src/explorer/collections.tsx +513 -0
- package/packages/visualizer/src/explorer/playlist-entries.test.ts +51 -0
- package/packages/visualizer/src/explorer/playlist-entries.ts +163 -0
- package/packages/visualizer/src/explorer/playlist.tsx +500 -0
- package/packages/visualizer/src/explorer/recording-text.test.ts +149 -0
- package/packages/visualizer/src/explorer/recording-text.ts +490 -0
- package/packages/visualizer/src/explorer/search.ts +10 -2
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Recording playlist
|
|
3
|
+
*
|
|
4
|
+
* The order and the honesty of a watch session: a filtered set of recorded results becomes an ordered list of entries,
|
|
5
|
+
* each with what can be said about its recording before anything is downloaded. Every result of the set is an entry
|
|
6
|
+
* (a failed one is never dropped, and one without a recording says so) and the order is the order of the set it came
|
|
7
|
+
* from, so the same filters give the same playlist. Pure functions; the player is `playlist.tsx`.
|
|
8
|
+
*/
|
|
9
|
+
import { videoCounts } from '@crvouga/atlas/reports/evidence-detail';
|
|
10
|
+
import type { VideoRef } from '@crvouga/atlas/reports/evidence-types';
|
|
11
|
+
import type { EvidenceStatus } from '@crvouga/atlas/reports/explorer-types';
|
|
12
|
+
import type { ReviewAvailability, ReviewCollection } from '@crvouga/atlas/reports/review-collection';
|
|
13
|
+
import type { ArtifactResult, RunLayer, RunResult } from '@crvouga/atlas/reports/test-catalog';
|
|
14
|
+
|
|
15
|
+
/** One recorded result in a playlist: where to read its evidence, what to say about it, and its video references. */
|
|
16
|
+
export type PlaylistEntry = {
|
|
17
|
+
/** Identifies the entry for as long as the set is the same; selection follows it, never a position. */
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly title: string;
|
|
20
|
+
/** The test's key, to open it. */
|
|
21
|
+
readonly key: string;
|
|
22
|
+
readonly specPath: string | null;
|
|
23
|
+
readonly status: EvidenceStatus;
|
|
24
|
+
readonly environment: string | null;
|
|
25
|
+
readonly producer: string;
|
|
26
|
+
/** When it was recorded, epoch ms. */
|
|
27
|
+
readonly at: number;
|
|
28
|
+
/** The layer that recorded it and the backend to read it from; null when the backend is not known (an older server). */
|
|
29
|
+
readonly layer: string;
|
|
30
|
+
readonly backend: string | null;
|
|
31
|
+
/** The check's id and, when known, the kind of its part: with the layer they name the evidence to read. */
|
|
32
|
+
readonly check: string;
|
|
33
|
+
readonly kind: string | null;
|
|
34
|
+
/** The tags its attachments carry. */
|
|
35
|
+
readonly tags: readonly string[];
|
|
36
|
+
/** Every video its evidence references. */
|
|
37
|
+
readonly videos: readonly VideoRef[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What is known about an entry's recording: `playable` (a stored file can be tried), `unplayable` (referenced, but no
|
|
42
|
+
* bytes were published, or the backend to read them from is unknown), `api-only` (no recording and recorded through
|
|
43
|
+
* an API, so there is no screen) or `none`.
|
|
44
|
+
*/
|
|
45
|
+
export type RecordingState = 'playable' | 'unplayable' | 'api-only' | 'none';
|
|
46
|
+
|
|
47
|
+
/** Environments a reader cannot watch: a result recorded only through these has no screen. */
|
|
48
|
+
const API_ENVIRONMENT = /^(api|http|rest|graphql|service|backend)(-|$)/;
|
|
49
|
+
|
|
50
|
+
/** The state of an entry's recording. Only `playable` means there is something to watch; none of the others is a pass with proof. */
|
|
51
|
+
export function recordingState(entry: Pick<PlaylistEntry, 'videos' | 'backend' | 'environment'>): RecordingState {
|
|
52
|
+
const counts = videoCounts(entry.videos);
|
|
53
|
+
if (counts && counts.recordings > 0) return entry.backend === null ? 'unplayable' : 'playable';
|
|
54
|
+
if (counts) return 'unplayable';
|
|
55
|
+
return entry.environment !== null && API_ENVIRONMENT.test(entry.environment) ? 'api-only' : 'none';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What each state says to a person. A recording is never described as demonstrating anything it does not. */
|
|
59
|
+
export const RECORDING_STATE_TEXT: Record<RecordingState, { readonly label: string; readonly meaning: string }> = {
|
|
60
|
+
playable: { label: 'Recording', meaning: 'A recording is stored for this result.' },
|
|
61
|
+
unplayable: { label: 'Recording not available', meaning: 'This result references a recording, but no stored file can be played, so it is not demonstrated.' },
|
|
62
|
+
'api-only': { label: 'API only', meaning: 'This result was recorded through an API, so there is no screen to watch. It is not demonstrated by a recording.' },
|
|
63
|
+
none: { label: 'No recording', meaning: 'No recording was published for this result, so it is not demonstrated by one.' }
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** The first video of an entry that names bytes: the one that is played. */
|
|
67
|
+
export function playableVideo(entry: Pick<PlaylistEntry, 'videos'>): VideoRef | undefined {
|
|
68
|
+
return entry.videos.find((video) => video.available && video.recording !== undefined);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The recorded results of the Artifacts tab as playlist entries, in their order. */
|
|
72
|
+
export function entriesFromArtifacts(results: readonly ArtifactResult[]): PlaylistEntry[] {
|
|
73
|
+
return results.map((result) => ({
|
|
74
|
+
id: `${result.layer}|${result.key}|${result.kind}|${result.producer}|${result.environment ?? ''}`,
|
|
75
|
+
title: result.title,
|
|
76
|
+
key: result.key,
|
|
77
|
+
specPath: result.specPath,
|
|
78
|
+
status: result.status,
|
|
79
|
+
environment: result.environment,
|
|
80
|
+
producer: result.producer,
|
|
81
|
+
at: result.at,
|
|
82
|
+
layer: result.layer,
|
|
83
|
+
backend: result.backend,
|
|
84
|
+
check: result.id,
|
|
85
|
+
kind: result.kind,
|
|
86
|
+
tags: result.artifactTags,
|
|
87
|
+
videos: result.videos ?? []
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The results of one run as playlist entries, in their order; `layers` say which backend each result's layer was read from. */
|
|
92
|
+
export function entriesFromRun(results: readonly RunResult[], layers: readonly RunLayer[] | undefined): PlaylistEntry[] {
|
|
93
|
+
const backends = new Map((layers ?? []).map((layer) => [layer.id, layer.backend]));
|
|
94
|
+
return results.map((result) => ({
|
|
95
|
+
id: `${result.layer}|${result.key}||${result.producer}|${result.environment ?? ''}`,
|
|
96
|
+
title: result.title,
|
|
97
|
+
key: result.key,
|
|
98
|
+
specPath: result.specPath,
|
|
99
|
+
status: result.status,
|
|
100
|
+
environment: result.environment,
|
|
101
|
+
producer: result.producer,
|
|
102
|
+
at: result.at,
|
|
103
|
+
layer: result.layer,
|
|
104
|
+
backend: backends.get(result.layer) ?? null,
|
|
105
|
+
check: result.id,
|
|
106
|
+
kind: null,
|
|
107
|
+
tags: [...new Set((result.videos ?? []).flatMap((video) => video.tags ?? []))].sort(),
|
|
108
|
+
videos: result.videos ?? []
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The entries of a review collection as playlist entries, in the collection's order (never re-sorted). Each is what the
|
|
114
|
+
* collection froze: its recorded status and time. An entry whose layer is gone has no backend to read, so it is said to
|
|
115
|
+
* have no recording that can be played rather than dropped. An entry that names one recording lists only that one.
|
|
116
|
+
*/
|
|
117
|
+
export function entriesFromCollection(collection: Pick<ReviewCollection, 'entries'>, availability: readonly ReviewAvailability[]): PlaylistEntry[] {
|
|
118
|
+
const gone = new Set(availability.filter((item) => item.state === 'unavailable').map((item) => item.entry));
|
|
119
|
+
return collection.entries.map((entry) => {
|
|
120
|
+
const videos = ((entry.snapshot.videos ?? []) as readonly VideoRef[]).filter((video) => entry.source.recording === undefined || video.recording === entry.source.recording);
|
|
121
|
+
return {
|
|
122
|
+
id: entry.id,
|
|
123
|
+
title: entry.snapshot.title,
|
|
124
|
+
key: entry.snapshot.testKey,
|
|
125
|
+
specPath: entry.snapshot.specPath,
|
|
126
|
+
status: entry.snapshot.status,
|
|
127
|
+
environment: entry.snapshot.environment,
|
|
128
|
+
producer: entry.snapshot.producer,
|
|
129
|
+
at: entry.snapshot.recordedAtMs,
|
|
130
|
+
layer: entry.source.layer,
|
|
131
|
+
backend: gone.has(entry.id) ? null : entry.snapshot.backend,
|
|
132
|
+
check: entry.source.check,
|
|
133
|
+
kind: entry.source.kind ?? null,
|
|
134
|
+
tags: [...new Set(videos.flatMap((video) => video.tags ?? []))].sort(),
|
|
135
|
+
videos
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** How many entries are in each state, and how many failed (a failure is counted whether or not it was recorded). */
|
|
141
|
+
export function playlistCounts(entries: readonly PlaylistEntry[]): Record<RecordingState, number> & { readonly failed: number } {
|
|
142
|
+
const counts = { playable: 0, unplayable: 0, 'api-only': 0, none: 0, failed: 0 };
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
counts[recordingState(entry)] += 1;
|
|
145
|
+
if (entry.status === 'failed') counts.failed += 1;
|
|
146
|
+
}
|
|
147
|
+
return counts;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The entry to go to from `from`, over the entries that can be watched. `step` is 1 for next, -1 for previous. Past the
|
|
152
|
+
* end it wraps to the other end when `wrap`, else it is null. From an entry that cannot be watched (selected from the
|
|
153
|
+
* list to read why), it is the nearest watchable one in that direction.
|
|
154
|
+
*/
|
|
155
|
+
export function stepFrom(entries: readonly PlaylistEntry[], from: string | null, step: 1 | -1, wrap: boolean): PlaylistEntry | null {
|
|
156
|
+
const at = from === null ? -1 : entries.findIndex((entry) => entry.id === from);
|
|
157
|
+
const watchable = (entry: PlaylistEntry) => recordingState(entry) === 'playable';
|
|
158
|
+
const ordered = step === 1 ? entries.map((entry, index) => [entry, index] as const) : entries.map((entry, index) => [entry, index] as const).reverse();
|
|
159
|
+
const after = ordered.filter(([entry, index]) => watchable(entry) && (at < 0 ? true : step === 1 ? index > at : index < at));
|
|
160
|
+
if (after.length > 0) return after[0]![0];
|
|
161
|
+
if (!wrap) return null;
|
|
162
|
+
return ordered.find(([entry]) => watchable(entry))?.[0] ?? null;
|
|
163
|
+
}
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Watch playlist
|
|
3
|
+
*
|
|
4
|
+
* A watch session over a filtered set of recorded results: the player and the plain-language scenario beside an ordered
|
|
5
|
+
* list of every result in the set. A failed result stays in the list with its outcome, and a result with no recording,
|
|
6
|
+
* an API-only one or one whose file is missing says so in words instead of being left out or shown as demonstrated.
|
|
7
|
+
*
|
|
8
|
+
* Only the clip on screen is read: the list is made of the metadata the catalog already sent, the current result's
|
|
9
|
+
* evidence is read when it is selected, and its video is loaded with `preload="metadata"`, so opening the playlist of a
|
|
10
|
+
* large library downloads no recordings in bulk. There is one `<video>`; selecting another clip replaces it and keeps
|
|
11
|
+
* the position of the one left, so returning resumes there. A clip that will not load (or whose autoplay the browser
|
|
12
|
+
* blocked) stays in the list and offers Retry, Play and Next. Recordings are always labelled illustrative: playback
|
|
13
|
+
* length and speed are those of the recording, never production latency.
|
|
14
|
+
*/
|
|
15
|
+
import { attachmentIdentity, isVideoAttachment, type CheckDetail } from '@crvouga/atlas/reports/evidence-detail';
|
|
16
|
+
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
17
|
+
import { contentTypeOf } from '@crvouga/atlas/reports/media-types';
|
|
18
|
+
import { Alert, Anchor, Badge, Box, Button, Code, Flex, Grid, Group, List, Modal, NativeSelect, Stack, Switch, Text, Title } from '@mantine/core';
|
|
19
|
+
import { IconAlertTriangle, IconPlayerPlay, IconPlayerSkipBack, IconPlayerSkipForward } from '@tabler/icons-react';
|
|
20
|
+
import { useQuery } from '@tanstack/react-query';
|
|
21
|
+
import { Link } from '@tanstack/react-router';
|
|
22
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
23
|
+
|
|
24
|
+
import { reportJson } from '../evidence/api';
|
|
25
|
+
import { attachmentSource } from '../evidence/artifacts';
|
|
26
|
+
import { everyAttachment } from '../evidence/artifact-tags';
|
|
27
|
+
import { ScopeNotice } from '../evidence/evidence-scope';
|
|
28
|
+
import { duration, exactTime, plural, statusWord } from '../evidence/format';
|
|
29
|
+
import { StatusMark } from '../evidence/marks';
|
|
30
|
+
import { patched } from './navigate';
|
|
31
|
+
import { clock, TEXT_KIND_WORD, type RecordingFocus } from './recording-text';
|
|
32
|
+
import { playableVideo, playlistCounts, RECORDING_STATE_TEXT, recordingState, stepFrom, type PlaylistEntry } from './playlist-entries';
|
|
33
|
+
|
|
34
|
+
/** Entries listed before "more"; the selected entry is always listed. */
|
|
35
|
+
const LISTED = 50;
|
|
36
|
+
/** Playback speeds offered. */
|
|
37
|
+
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
|
|
38
|
+
/** Step names are shown with their Gherkin keyword when they have one. */
|
|
39
|
+
const KEYWORDS = new Set(['given', 'when', 'then', 'and', 'but']);
|
|
40
|
+
|
|
41
|
+
/** Where the evidence of an entry's check is read from, or null when the entry names no layer that can be read. */
|
|
42
|
+
export function entryDetailQuery(entry: PlaylistEntry) {
|
|
43
|
+
if (entry.backend === null || !/^[0-9a-f]{64}$/.test(entry.layer)) return null;
|
|
44
|
+
const backend = entry.backend;
|
|
45
|
+
return {
|
|
46
|
+
queryKey: ['evidence-detail', backend, entry.layer, entry.kind ?? '', entry.check],
|
|
47
|
+
queryFn: ({ signal }: { signal?: AbortSignal }) =>
|
|
48
|
+
reportJson<CheckDetail>(
|
|
49
|
+
`/evidence/detail/${encodeURIComponent(backend)}/${encodeURIComponent(entry.layer)}?check=${encodeURIComponent(entry.check)}${entry.kind ? `&kind=${encodeURIComponent(entry.kind)}` : ''}`,
|
|
50
|
+
signal
|
|
51
|
+
),
|
|
52
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
53
|
+
retry: false
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The evidence of one entry's check, read when the entry is on screen. */
|
|
58
|
+
function useEntryDetail(entry: PlaylistEntry, enabled: boolean) {
|
|
59
|
+
const query = entryDetailQuery(entry);
|
|
60
|
+
return useQuery({
|
|
61
|
+
queryKey: query?.queryKey ?? ['evidence-detail', '', entry.layer, entry.kind ?? '', entry.check],
|
|
62
|
+
queryFn: query?.queryFn ?? (() => Promise.reject(new Error('no evidence to read'))),
|
|
63
|
+
enabled: enabled && query !== null,
|
|
64
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
65
|
+
retry: false
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The scenario in words: the steps the producer recorded, with the Gherkin keyword where it gave one. */
|
|
70
|
+
function Scenario({ entry, detail }: { entry: PlaylistEntry; detail: CheckDetail | undefined }) {
|
|
71
|
+
const steps = detail?.check.steps ?? [];
|
|
72
|
+
return (
|
|
73
|
+
<Stack gap="xs" component="section" aria-label="Scenario">
|
|
74
|
+
<Title order={3}>{entry.title}</Title>
|
|
75
|
+
{steps.length > 0 && (
|
|
76
|
+
<List type="ordered" spacing="xs" aria-label="Steps">
|
|
77
|
+
{steps.map((step, index) => (
|
|
78
|
+
<List.Item key={`${step.id ?? step.name}:${index}`}>
|
|
79
|
+
{step.kind && KEYWORDS.has(step.kind.toLowerCase()) ? `${step.kind.charAt(0).toUpperCase()}${step.kind.slice(1).toLowerCase()} ` : ''}
|
|
80
|
+
{step.name}
|
|
81
|
+
{step.status && step.status !== 'passed' ? ` (${step.status})` : ''}
|
|
82
|
+
</List.Item>
|
|
83
|
+
))}
|
|
84
|
+
</List>
|
|
85
|
+
)}
|
|
86
|
+
{entry.status === 'failed' && detail?.check.detail && (
|
|
87
|
+
<Alert color="red" title="Why it failed" role="status">
|
|
88
|
+
<Text component="div" lineClamp={4}>
|
|
89
|
+
{detail.check.detail}
|
|
90
|
+
</Text>
|
|
91
|
+
</Alert>
|
|
92
|
+
)}
|
|
93
|
+
</Stack>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Who recorded the entry and what it carries, in one line each. */
|
|
98
|
+
function Facts({ entry }: { entry: PlaylistEntry }) {
|
|
99
|
+
return (
|
|
100
|
+
<Stack gap="xs" aria-label="Recording facts">
|
|
101
|
+
<Group gap="xs" wrap="wrap">
|
|
102
|
+
<StatusMark status={entry.status} size={14} />
|
|
103
|
+
<Text span inherit fw={600} data-outcome={entry.status}>
|
|
104
|
+
{statusWord(entry.status)}
|
|
105
|
+
</Text>
|
|
106
|
+
<Text span inherit c="dimmed">
|
|
107
|
+
{[environmentLabel(entry.environment), entry.producer, `recorded ${exactTime(entry.at)}`].join(' · ')}
|
|
108
|
+
</Text>
|
|
109
|
+
</Group>
|
|
110
|
+
{entry.tags.length > 0 && (
|
|
111
|
+
<Group gap="xs" aria-label="Tags">
|
|
112
|
+
{entry.tags.map((tag) => (
|
|
113
|
+
<Badge key={tag} variant="light" tt="none">
|
|
114
|
+
#{tag}
|
|
115
|
+
</Badge>
|
|
116
|
+
))}
|
|
117
|
+
</Group>
|
|
118
|
+
)}
|
|
119
|
+
{entry.specPath && (
|
|
120
|
+
<Text component="div" c="dimmed">
|
|
121
|
+
Spec <Code>{entry.specPath}</Code>
|
|
122
|
+
</Text>
|
|
123
|
+
)}
|
|
124
|
+
</Stack>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Where a search result opened: which words, from what, in which attempt, and whether the player could go to the time. */
|
|
129
|
+
function FoundMoment({ focus, seek }: { focus: RecordingFocus; seek: 'applied' | 'unavailable' | null }) {
|
|
130
|
+
const timed = focus.startMs !== null && focus.endMs !== null;
|
|
131
|
+
return (
|
|
132
|
+
<Alert color="blue" variant="light" title="Found in this recording" role="status" data-found-moment>
|
|
133
|
+
<Stack gap="xs">
|
|
134
|
+
<Text component="div">
|
|
135
|
+
{TEXT_KIND_WORD[focus.kind]}
|
|
136
|
+
{focus.attemptLabel ? ` · ${focus.attemptLabel}` : ''}
|
|
137
|
+
{timed ? ` · ${clock(focus.startMs!)}–${clock(focus.endMs!)}` : ''}
|
|
138
|
+
</Text>
|
|
139
|
+
<Text component="div">Indexed text: {focus.text}</Text>
|
|
140
|
+
{focus.generated && (
|
|
141
|
+
<Text component="div" c="dimmed">
|
|
142
|
+
Generated text; it may contain errors.
|
|
143
|
+
</Text>
|
|
144
|
+
)}
|
|
145
|
+
{seek === 'unavailable' && (
|
|
146
|
+
<Text component="div" data-seek-unavailable>
|
|
147
|
+
Seeking is unavailable: {focus.startMs === null ? 'the text has no usable time' : 'the recording it belongs to or its time could not be reached'}, so the recording is opened at its start and not at a guessed moment.
|
|
148
|
+
</Text>
|
|
149
|
+
)}
|
|
150
|
+
</Stack>
|
|
151
|
+
</Alert>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The one player. It is keyed by its entry, so a new clip is a fresh element and the previous one is gone. */
|
|
156
|
+
function Clip({
|
|
157
|
+
entry,
|
|
158
|
+
speed,
|
|
159
|
+
autoPlay,
|
|
160
|
+
focus,
|
|
161
|
+
positions,
|
|
162
|
+
onEnded,
|
|
163
|
+
onFailed,
|
|
164
|
+
onNext,
|
|
165
|
+
onClose
|
|
166
|
+
}: {
|
|
167
|
+
entry: PlaylistEntry;
|
|
168
|
+
speed: number;
|
|
169
|
+
autoPlay: boolean;
|
|
170
|
+
/** A moment found by searching inside recordings: its video is selected and, when its time is usable, played from there. */
|
|
171
|
+
focus: RecordingFocus | null;
|
|
172
|
+
positions: Map<string, number>;
|
|
173
|
+
onEnded: () => PlaylistEntry | null;
|
|
174
|
+
onFailed: (id: string) => void;
|
|
175
|
+
onNext: (() => void) | null;
|
|
176
|
+
onClose: () => void;
|
|
177
|
+
}) {
|
|
178
|
+
const state = recordingState(entry);
|
|
179
|
+
const detail = useEntryDetail(entry, state === 'playable');
|
|
180
|
+
const video = useRef<HTMLVideoElement>(null);
|
|
181
|
+
const [failed, setFailed] = useState(false);
|
|
182
|
+
const [blocked, setBlocked] = useState(false);
|
|
183
|
+
const [attempt, setAttempt] = useState(0);
|
|
184
|
+
const [seek, setSeek] = useState<'applied' | 'unavailable' | null>(null);
|
|
185
|
+
const chosen = playableVideo(entry);
|
|
186
|
+
const attachments = useMemo(() => (detail.data ? everyAttachment(detail.data.check) : []), [detail.data]);
|
|
187
|
+
const videos = useMemo(() => attachments.filter(isVideoAttachment), [attachments]);
|
|
188
|
+
const found = focus?.recording ? videos.find((item) => attachmentIdentity(item) === focus.recording) : undefined;
|
|
189
|
+
const movie = useMemo(() => found ?? videos.find((item) => attachmentIdentity(item) === chosen?.recording) ?? videos[0], [found, videos, chosen]);
|
|
190
|
+
const captions = useMemo(
|
|
191
|
+
() => attachments.filter((item) => (item.contentType ?? contentTypeOf(item.name)).toLowerCase().startsWith('text/vtt')),
|
|
192
|
+
[attachments]
|
|
193
|
+
);
|
|
194
|
+
const url = movie ? attachmentSource(movie, entry.layer).url : null;
|
|
195
|
+
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (video.current) video.current.playbackRate = speed;
|
|
198
|
+
}, [speed]);
|
|
199
|
+
|
|
200
|
+
const play = () => {
|
|
201
|
+
const element = video.current;
|
|
202
|
+
if (!element) return;
|
|
203
|
+
setBlocked(false);
|
|
204
|
+
// `play()` rejects when the browser blocks it; a browser without a promise has nothing to catch.
|
|
205
|
+
const started = element.play() as Promise<void> | undefined;
|
|
206
|
+
started?.catch(() => setBlocked(true));
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const developer = (
|
|
210
|
+
<Anchor
|
|
211
|
+
renderRoot={(props) => (
|
|
212
|
+
<Link {...props} to="/" search={patched({ tab: undefined, openRun: undefined, artifact: undefined, test: entry.key, layer: entry.layer })} resetScroll={false} activeOptions={{ exact: true }} onClick={onClose} />
|
|
213
|
+
)}
|
|
214
|
+
>
|
|
215
|
+
Developer details
|
|
216
|
+
</Anchor>
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
let body;
|
|
220
|
+
if (state !== 'playable') {
|
|
221
|
+
body = (
|
|
222
|
+
<Alert color="orange" title={RECORDING_STATE_TEXT[state].label} icon={<IconAlertTriangle size={16} aria-hidden="true" />} role="status" data-recording-state={state}>
|
|
223
|
+
{RECORDING_STATE_TEXT[state].meaning}
|
|
224
|
+
</Alert>
|
|
225
|
+
);
|
|
226
|
+
} else if (detail.isPending) {
|
|
227
|
+
body = (
|
|
228
|
+
<Text role="status" component="div" c="dimmed">
|
|
229
|
+
Loading the recording…
|
|
230
|
+
</Text>
|
|
231
|
+
);
|
|
232
|
+
} else if (detail.isError || !url) {
|
|
233
|
+
body = (
|
|
234
|
+
<Alert role="alert" color="orange" title="This recording cannot be played" data-clip-failed>
|
|
235
|
+
<Stack gap="xs" align="flex-start">
|
|
236
|
+
<Text component="div">{detail.isError ? 'Its evidence could not be read.' : 'The result names a recording, but no file can be played from here.'}</Text>
|
|
237
|
+
<Group gap="xs">
|
|
238
|
+
{detail.isError && (
|
|
239
|
+
<Button type="button" variant="light" onClick={() => void detail.refetch()}>
|
|
240
|
+
Try again
|
|
241
|
+
</Button>
|
|
242
|
+
)}
|
|
243
|
+
{onNext && (
|
|
244
|
+
<Button type="button" variant="light" onClick={onNext}>
|
|
245
|
+
Next clip
|
|
246
|
+
</Button>
|
|
247
|
+
)}
|
|
248
|
+
</Group>
|
|
249
|
+
</Stack>
|
|
250
|
+
</Alert>
|
|
251
|
+
);
|
|
252
|
+
} else {
|
|
253
|
+
body = (
|
|
254
|
+
<Stack gap="xs">
|
|
255
|
+
{focus && <FoundMoment focus={focus} seek={seek} />}
|
|
256
|
+
<ScopeNotice scope={chosen?.scope} name={movie!.name} />
|
|
257
|
+
<Box
|
|
258
|
+
key={attempt}
|
|
259
|
+
component="video"
|
|
260
|
+
ref={video}
|
|
261
|
+
src={url}
|
|
262
|
+
controls
|
|
263
|
+
preload="metadata"
|
|
264
|
+
aria-label={`Recording: ${movie!.description ?? movie!.name}`}
|
|
265
|
+
maw="100%"
|
|
266
|
+
w="100%"
|
|
267
|
+
mah="70vh"
|
|
268
|
+
data-playing={entry.id}
|
|
269
|
+
onLoadedMetadata={() => {
|
|
270
|
+
const element = video.current!;
|
|
271
|
+
element.playbackRate = speed;
|
|
272
|
+
if (focus) {
|
|
273
|
+
// Seek only to a time the track gave and the recording can reach; otherwise open it at the start and say so.
|
|
274
|
+
const reachable = (found !== undefined || !focus.recording) && focus.startMs !== null && (!Number.isFinite(element.duration) || focus.startMs / 1000 < element.duration);
|
|
275
|
+
if (reachable) element.currentTime = focus.startMs! / 1000;
|
|
276
|
+
setSeek(reachable ? 'applied' : 'unavailable');
|
|
277
|
+
if (autoPlay) play();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const saved = positions.get(entry.id);
|
|
281
|
+
if (saved !== undefined && saved < (element.duration || Number.POSITIVE_INFINITY)) element.currentTime = saved;
|
|
282
|
+
if (autoPlay) play();
|
|
283
|
+
}}
|
|
284
|
+
onTimeUpdate={() => positions.set(entry.id, video.current!.currentTime)}
|
|
285
|
+
onEnded={() => {
|
|
286
|
+
positions.delete(entry.id);
|
|
287
|
+
const next = onEnded();
|
|
288
|
+
if (next && next.id === entry.id && video.current) {
|
|
289
|
+
video.current.currentTime = 0;
|
|
290
|
+
play();
|
|
291
|
+
}
|
|
292
|
+
}}
|
|
293
|
+
onError={() => {
|
|
294
|
+
setFailed(true);
|
|
295
|
+
onFailed(entry.id);
|
|
296
|
+
}}
|
|
297
|
+
>
|
|
298
|
+
{captions.map((caption) => {
|
|
299
|
+
const source = attachmentSource(caption, entry.layer).url;
|
|
300
|
+
return source ? <track key={source} kind="captions" src={source} label={caption.description ?? caption.name} /> : null;
|
|
301
|
+
})}
|
|
302
|
+
</Box>
|
|
303
|
+
{movie!.durationMs ? (
|
|
304
|
+
<Text component="div" c="dimmed">
|
|
305
|
+
Length of the recording: {duration(movie!.durationMs)}
|
|
306
|
+
</Text>
|
|
307
|
+
) : null}
|
|
308
|
+
{captions.length > 0 && (
|
|
309
|
+
<Text component="div" c="dimmed">
|
|
310
|
+
Captions are offered in the player's captions control: {captions.map((caption) => caption.description ?? caption.name).join(', ')}.
|
|
311
|
+
</Text>
|
|
312
|
+
)}
|
|
313
|
+
{blocked && (
|
|
314
|
+
<Alert color="blue" role="status" title="Autoplay was blocked" data-autoplay-blocked>
|
|
315
|
+
<Stack gap="xs" align="flex-start">
|
|
316
|
+
<Text component="div">The browser did not start this clip by itself. Play it to continue.</Text>
|
|
317
|
+
<Button type="button" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={play}>
|
|
318
|
+
Play
|
|
319
|
+
</Button>
|
|
320
|
+
</Stack>
|
|
321
|
+
</Alert>
|
|
322
|
+
)}
|
|
323
|
+
{failed && (
|
|
324
|
+
<Alert color="orange" role="alert" title="This video cannot be played" data-clip-failed>
|
|
325
|
+
<Stack gap="xs" align="flex-start">
|
|
326
|
+
<Text component="div">The file could not be loaded, or this browser cannot play its format. It stays in the playlist.</Text>
|
|
327
|
+
<Group gap="xs">
|
|
328
|
+
<Button
|
|
329
|
+
type="button"
|
|
330
|
+
variant="light"
|
|
331
|
+
onClick={() => {
|
|
332
|
+
setFailed(false);
|
|
333
|
+
setAttempt(attempt + 1);
|
|
334
|
+
}}
|
|
335
|
+
>
|
|
336
|
+
Retry
|
|
337
|
+
</Button>
|
|
338
|
+
{onNext && (
|
|
339
|
+
<Button type="button" variant="light" onClick={onNext}>
|
|
340
|
+
Next clip
|
|
341
|
+
</Button>
|
|
342
|
+
)}
|
|
343
|
+
</Group>
|
|
344
|
+
</Stack>
|
|
345
|
+
</Alert>
|
|
346
|
+
)}
|
|
347
|
+
</Stack>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return (
|
|
351
|
+
<Stack gap="md" miw={0} aria-label={`Now showing ${entry.title}`} component="section" data-clip={entry.id}>
|
|
352
|
+
{body}
|
|
353
|
+
<Facts entry={entry} />
|
|
354
|
+
<Scenario entry={entry} detail={detail.data} />
|
|
355
|
+
<Text component="div" c="dimmed">
|
|
356
|
+
Illustrative recording of one run. Its length and speed are those of the recording, not production latency, and it does not approve the behavior for production.
|
|
357
|
+
</Text>
|
|
358
|
+
{developer}
|
|
359
|
+
</Stack>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The watch session for `entries`: opens as a modal over the explorer. `total` is how many results matched when the list
|
|
365
|
+
* holds only the newest of them, so the session says it is not the whole set. `focus` opens it on one found moment: that
|
|
366
|
+
* result, that attempt's video and, when the time is usable, that time. `initial` is the entry to start on when there is no
|
|
367
|
+
* `focus`, and `title` the heading. Closing it discards positions.
|
|
368
|
+
*/
|
|
369
|
+
export function WatchPlaylist({ entries, total, focus, onClose, initial, title = 'Watch recordings' }: { entries: readonly PlaylistEntry[]; total?: number; focus?: RecordingFocus; onClose: () => void; initial?: string; title?: string }) {
|
|
370
|
+
const first = entries.find((entry) => entry.id === initial) ?? entries.find((entry) => recordingState(entry) === 'playable') ?? entries[0];
|
|
371
|
+
const [chosen, setChosen] = useState<string | null>(focus && entries.some((entry) => entry.id === focus.entryId) ? focus.entryId : (first?.id ?? null));
|
|
372
|
+
// Selecting anything else leaves the found moment behind: it describes one video and one time.
|
|
373
|
+
const [moment, setMoment] = useState<RecordingFocus | null>(focus ?? null);
|
|
374
|
+
const [autoPlay, setAutoPlay] = useState(false);
|
|
375
|
+
const [speed, setSpeed] = useState<number>(1);
|
|
376
|
+
const [advance, setAdvance] = useState(true);
|
|
377
|
+
const [repeat, setRepeat] = useState(false);
|
|
378
|
+
const [failedIds, setFailedIds] = useState<ReadonlySet<string>>(new Set());
|
|
379
|
+
const [shown, setShown] = useState(LISTED);
|
|
380
|
+
const positions = useRef(new Map<string, number>());
|
|
381
|
+
const active = entries.find((entry) => entry.id === chosen) ?? first ?? null;
|
|
382
|
+
const counts = useMemo(() => playlistCounts(entries), [entries]);
|
|
383
|
+
const activeAt = active ? entries.indexOf(active) : -1;
|
|
384
|
+
const next = stepFrom(entries, active?.id ?? null, 1, repeat);
|
|
385
|
+
const previous = stepFrom(entries, active?.id ?? null, -1, repeat);
|
|
386
|
+
const select = (entry: PlaylistEntry, play: boolean) => {
|
|
387
|
+
setMoment(null);
|
|
388
|
+
setAutoPlay(play);
|
|
389
|
+
setChosen(entry.id);
|
|
390
|
+
};
|
|
391
|
+
const listed = entries.slice(0, Math.max(shown, activeAt + 1));
|
|
392
|
+
return (
|
|
393
|
+
<Modal opened onClose={onClose} title={title} size="xl" centered>
|
|
394
|
+
<Stack gap="md">
|
|
395
|
+
<Text role="status" component="div" data-playlist-summary>
|
|
396
|
+
{plural(entries.length, 'result', 'results')}
|
|
397
|
+
{total !== undefined && total > entries.length ? ` (the newest of ${total.toLocaleString()} matching)` : ''}: {counts.playable} with a recording
|
|
398
|
+
{counts.unplayable > 0 ? `, ${counts.unplayable} whose recording is not available` : ''}
|
|
399
|
+
{counts['api-only'] > 0 ? `, ${counts['api-only']} API only` : ''}
|
|
400
|
+
{counts.none > 0 ? `, ${counts.none} with no recording` : ''}. {plural(counts.failed, 'failed result', 'failed results')}
|
|
401
|
+
{counts.failed > 0 ? ' kept in the playlist' : ''}.
|
|
402
|
+
</Text>
|
|
403
|
+
{entries.length === 0 && (
|
|
404
|
+
<Text component="div" c="dimmed">
|
|
405
|
+
No result is in this set, so there is nothing to watch.
|
|
406
|
+
</Text>
|
|
407
|
+
)}
|
|
408
|
+
{active && (
|
|
409
|
+
<Group role="group" aria-label="Playlist controls" align="flex-end" gap="md" wrap="wrap">
|
|
410
|
+
<Button type="button" variant="default" leftSection={<IconPlayerSkipBack size={16} aria-hidden="true" />} disabled={!previous} onClick={() => previous && select(previous, true)}>
|
|
411
|
+
Previous clip
|
|
412
|
+
</Button>
|
|
413
|
+
<Button type="button" variant="default" rightSection={<IconPlayerSkipForward size={16} aria-hidden="true" />} disabled={!next} onClick={() => next && select(next, true)}>
|
|
414
|
+
Next clip
|
|
415
|
+
</Button>
|
|
416
|
+
<NativeSelect
|
|
417
|
+
label="Playback speed"
|
|
418
|
+
value={String(speed)}
|
|
419
|
+
onChange={(event) => setSpeed(Number(event.currentTarget.value))}
|
|
420
|
+
data={SPEEDS.map((value) => ({ value: String(value), label: `${value}×` }))}
|
|
421
|
+
/>
|
|
422
|
+
<Switch label="Play the next clip when one ends" checked={advance} onChange={(event) => setAdvance(event.currentTarget.checked)} />
|
|
423
|
+
<Switch label="Repeat" description="Start over after the last clip, or replay a clip when not advancing" checked={repeat} onChange={(event) => setRepeat(event.currentTarget.checked)} />
|
|
424
|
+
</Group>
|
|
425
|
+
)}
|
|
426
|
+
{active && (
|
|
427
|
+
<Text c="dimmed" component="div" size="sm">
|
|
428
|
+
Previous and next skip results with nothing to play; select one in the list to see why.
|
|
429
|
+
</Text>
|
|
430
|
+
)}
|
|
431
|
+
<Grid gap="md">
|
|
432
|
+
<Grid.Col span={{ base: 12, md: 8 }}>
|
|
433
|
+
{active && (
|
|
434
|
+
<Clip
|
|
435
|
+
key={active.id}
|
|
436
|
+
entry={active}
|
|
437
|
+
speed={speed}
|
|
438
|
+
autoPlay={autoPlay}
|
|
439
|
+
focus={moment && moment.entryId === active.id ? moment : null}
|
|
440
|
+
positions={positions.current}
|
|
441
|
+
onEnded={() => {
|
|
442
|
+
const target = advance ? stepFrom(entries, active.id, 1, repeat) : repeat ? active : null;
|
|
443
|
+
if (target && target.id !== active.id) select(target, true);
|
|
444
|
+
return target;
|
|
445
|
+
}}
|
|
446
|
+
onFailed={(id) => setFailedIds((held) => new Set(held).add(id))}
|
|
447
|
+
onNext={next ? () => select(next, true) : null}
|
|
448
|
+
onClose={onClose}
|
|
449
|
+
/>
|
|
450
|
+
)}
|
|
451
|
+
</Grid.Col>
|
|
452
|
+
<Grid.Col span={{ base: 12, md: 4 }}>
|
|
453
|
+
<Stack gap="xs" component="section" aria-label="Playlist">
|
|
454
|
+
<Title order={3}>Playlist</Title>
|
|
455
|
+
<List spacing="xs" listStyleType="none">
|
|
456
|
+
{listed.map((entry, index) => {
|
|
457
|
+
const state = recordingState(entry);
|
|
458
|
+
const here = entry.id === active?.id;
|
|
459
|
+
return (
|
|
460
|
+
<List.Item key={entry.id} data-entry={entry.id} data-status={entry.status} data-recording-state={state}>
|
|
461
|
+
<Button
|
|
462
|
+
type="button"
|
|
463
|
+
variant={here ? 'light' : 'subtle'}
|
|
464
|
+
fullWidth
|
|
465
|
+
h="auto"
|
|
466
|
+
py="xs"
|
|
467
|
+
justify="flex-start"
|
|
468
|
+
aria-current={here ? 'true' : undefined}
|
|
469
|
+
aria-label={`${index + 1}. ${entry.title}, ${statusWord(entry.status)}, ${failedIds.has(entry.id) ? 'could not be loaded' : RECORDING_STATE_TEXT[state].label}`}
|
|
470
|
+
onClick={() => select(entry, state === 'playable')}
|
|
471
|
+
>
|
|
472
|
+
<Flex component="span" gap="xs" wrap="nowrap" align="flex-start">
|
|
473
|
+
<StatusMark status={entry.status} size={14} />
|
|
474
|
+
<Flex component="span" direction="column" align="flex-start">
|
|
475
|
+
<Text span inherit ta="left">
|
|
476
|
+
{entry.title}
|
|
477
|
+
</Text>
|
|
478
|
+
<Text span inherit c="dimmed" size="xs" ta="left">
|
|
479
|
+
{statusWord(entry.status)} · {environmentLabel(entry.environment)} ·{' '}
|
|
480
|
+
{failedIds.has(entry.id) ? 'Could not be loaded' : RECORDING_STATE_TEXT[state].label}
|
|
481
|
+
</Text>
|
|
482
|
+
</Flex>
|
|
483
|
+
</Flex>
|
|
484
|
+
</Button>
|
|
485
|
+
</List.Item>
|
|
486
|
+
);
|
|
487
|
+
})}
|
|
488
|
+
</List>
|
|
489
|
+
{entries.length > listed.length && (
|
|
490
|
+
<Button type="button" variant="subtle" onClick={() => setShown(listed.length + LISTED)}>
|
|
491
|
+
Show more ({entries.length - listed.length} more)
|
|
492
|
+
</Button>
|
|
493
|
+
)}
|
|
494
|
+
</Stack>
|
|
495
|
+
</Grid.Col>
|
|
496
|
+
</Grid>
|
|
497
|
+
</Stack>
|
|
498
|
+
</Modal>
|
|
499
|
+
);
|
|
500
|
+
}
|