@crvouga/atlas 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,12 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
- ## 1.9.0 (2026-10-29)
3
+ ## 1.10.0 (2026-10-29)
4
+
5
+ ### Features
6
+
7
+ - watch a filtered set of scenario recordings as a playlist ([00bac50](https://github.com/crvouga/atlas/commit/00bac503643601dfc54cb97ff1e16ce7ce74bd9f))
8
+
9
+ ## 1.9.0 (2026-10-28)
4
10
 
5
11
  ### Features
6
12
 
package/docs/ui.md CHANGED
@@ -120,6 +120,27 @@ environment) is unknown unless the producer declared it under the `atlas.failure
120
120
  wording. Records of one source whose outcome changed are listed oldest first, and a later pass is never called a fix.
121
121
  In the explorer the chosen source is the `source` search parameter (a layer id; unlike `layer` it does not pin a run).
122
122
 
123
+ ## Watch a filtered set as a playlist
124
+
125
+ The Artifacts tab and an opened run have a **Watch these N results** button. It opens a Mantine `Modal` (`explorer/playlist.tsx`)
126
+ over the same filtered set the page lists, in the same order (`explorer/playlist-entries.ts` builds the entries).
127
+
128
+ - Every result of the set is an entry. A failed one is never dropped, and one with no recording, an API-only one or one
129
+ whose file is missing says so in words (`Recording not available`, `API only`, `No recording`); none of these is shown as
130
+ demonstrated. A set longer than the page's limit says it is only the newest results.
131
+ - Only the selected clip is read: the list is the metadata the catalog already sent, the selected result's evidence is read
132
+ when it is selected, and the one `<video>` uses `preload="metadata"`. Opening the playlist downloads no recordings in bulk.
133
+ Posters and durations of unselected entries are not shown for that reason.
134
+ - One player: selecting another clip replaces it, and the position of the clip left is kept for the session so returning
135
+ resumes there. Previous/next and advancing on end move over the watchable entries; a result with nothing to play is
136
+ selected from the list to read why.
137
+ - A clip that fails to load, or whose autoplay the browser blocked, stays in the list and offers Retry, Play and Next clip.
138
+ Speed, "Play the next clip when one ends" and "Repeat" are labelled controls. Captions (`text/vtt` attachments) are offered
139
+ as `<track>` elements.
140
+ - The scenario (title, recorded steps, why it failed) sits beside the player with the recording's scope badges
141
+ (paced, scripted, silent, synthetic) and the sentence that the length is the recording's, not production latency. Developer
142
+ details opens the test pinned to that run's record and closes the playlist.
143
+
123
144
  ## Icons and notation marks
124
145
 
125
146
  Use named imports from `@tabler/icons-react` for all UI glyphs. The icon package supplies SVG paths;
@@ -188,7 +209,7 @@ The narrowly scoped exceptions are:
188
209
  | Area | Screens/interactions | Mantine composition |
189
210
  | --- | --- | --- |
190
211
  | Shared shell | Project switching, spec navigation, run selection, connection status, diagnostics, mobile index | AppShell, Burger, NavLink, Menu, Popover, NativeSelect, Drawer |
191
- | Project home | Tests/runs/artifacts tabs, a run's Artifacts (files of its layers, shared recordings, direct links), metrics, search, status/environment/domain/artifact-tag filters, run and artifact facets with saved views, grouping, sorting, show more | Container, Tabs, SimpleGrid, Paper, TextInput, MultiSelect, Popover, Button, Accordion |
212
+ | Project home | Tests/runs/artifacts tabs, a run's Artifacts (files of its layers, shared recordings, direct links), Watch playlist of a filtered set, metrics, search, status/environment/domain/artifact-tag filters, run and artifact facets with saved views, grouping, sorting, show more | Container, Tabs, SimpleGrid, Paper, TextInput, MultiSelect, Popover, Button, Accordion, Modal, Grid, Switch |
192
213
  | Test detail | Current outcomes, statistics, duration graph, product latency with budgets and baselines, history, artifacts, recording; the `layer` address parameter pins it to one run's result, labelled "This run" against "Latest combined evidence" | Drawer, Table, Badge, Alert, Stage |
193
214
  | Specs | All six existing kinds, overview/index with title search and folder or declared-domain grouping, Gherkin rules/scenarios/examples with a coverage summary and Read / Watch / Inspect per behavior, Quint declarations/traces, DMN tables, source text | SpecHeader, Accordion, Tabs, Table, CodeHighlightTabs, ResultRow |
194
215
  | Statecharts | Graph, screen/event/journey details, developer metadata, chart test results | Mantine cards/navigation and product compositions; graph exception above |
package/llms-full.txt CHANGED
@@ -4030,6 +4030,27 @@ environment) is unknown unless the producer declared it under the `atlas.failure
4030
4030
  wording. Records of one source whose outcome changed are listed oldest first, and a later pass is never called a fix.
4031
4031
  In the explorer the chosen source is the `source` search parameter (a layer id; unlike `layer` it does not pin a run).
4032
4032
 
4033
+ ## Watch a filtered set as a playlist
4034
+
4035
+ The Artifacts tab and an opened run have a **Watch these N results** button. It opens a Mantine `Modal` (`explorer/playlist.tsx`)
4036
+ over the same filtered set the page lists, in the same order (`explorer/playlist-entries.ts` builds the entries).
4037
+
4038
+ - Every result of the set is an entry. A failed one is never dropped, and one with no recording, an API-only one or one
4039
+ whose file is missing says so in words (`Recording not available`, `API only`, `No recording`); none of these is shown as
4040
+ demonstrated. A set longer than the page's limit says it is only the newest results.
4041
+ - Only the selected clip is read: the list is the metadata the catalog already sent, the selected result's evidence is read
4042
+ when it is selected, and the one `<video>` uses `preload="metadata"`. Opening the playlist downloads no recordings in bulk.
4043
+ Posters and durations of unselected entries are not shown for that reason.
4044
+ - One player: selecting another clip replaces it, and the position of the clip left is kept for the session so returning
4045
+ resumes there. Previous/next and advancing on end move over the watchable entries; a result with nothing to play is
4046
+ selected from the list to read why.
4047
+ - A clip that fails to load, or whose autoplay the browser blocked, stays in the list and offers Retry, Play and Next clip.
4048
+ Speed, "Play the next clip when one ends" and "Repeat" are labelled controls. Captions (`text/vtt` attachments) are offered
4049
+ as `<track>` elements.
4050
+ - The scenario (title, recorded steps, why it failed) sits beside the player with the recording's scope badges
4051
+ (paced, scripted, silent, synthetic) and the sentence that the length is the recording's, not production latency. Developer
4052
+ details opens the test pinned to that run's record and closes the playlist.
4053
+
4033
4054
  ## Icons and notation marks
4034
4055
 
4035
4056
  Use named imports from `@tabler/icons-react` for all UI glyphs. The icon package supplies SVG paths;
@@ -4098,7 +4119,7 @@ The narrowly scoped exceptions are:
4098
4119
  | Area | Screens/interactions | Mantine composition |
4099
4120
  | --- | --- | --- |
4100
4121
  | Shared shell | Project switching, spec navigation, run selection, connection status, diagnostics, mobile index | AppShell, Burger, NavLink, Menu, Popover, NativeSelect, Drawer |
4101
- | Project home | Tests/runs/artifacts tabs, a run's Artifacts (files of its layers, shared recordings, direct links), metrics, search, status/environment/domain/artifact-tag filters, run and artifact facets with saved views, grouping, sorting, show more | Container, Tabs, SimpleGrid, Paper, TextInput, MultiSelect, Popover, Button, Accordion |
4122
+ | Project home | Tests/runs/artifacts tabs, a run's Artifacts (files of its layers, shared recordings, direct links), Watch playlist of a filtered set, metrics, search, status/environment/domain/artifact-tag filters, run and artifact facets with saved views, grouping, sorting, show more | Container, Tabs, SimpleGrid, Paper, TextInput, MultiSelect, Popover, Button, Accordion, Modal, Grid, Switch |
4102
4123
  | Test detail | Current outcomes, statistics, duration graph, product latency with budgets and baselines, history, artifacts, recording; the `layer` address parameter pins it to one run's result, labelled "This run" against "Latest combined evidence" | Drawer, Table, Badge, Alert, Stage |
4103
4124
  | Specs | All six existing kinds, overview/index with title search and folder or declared-domain grouping, Gherkin rules/scenarios/examples with a coverage summary and Read / Watch / Inspect per behavior, Quint declarations/traces, DMN tables, source text | SpecHeader, Accordion, Tabs, Table, CodeHighlightTabs, ResultRow |
4104
4125
  | Statecharts | Graph, screen/event/journey details, developer metadata, chart test results | Mantine cards/navigation and product compositions; graph exception above |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crvouga/atlas",
3
- "version": "1.9.0",
3
+ "version": "1.10.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",
@@ -50,7 +50,7 @@ function shownTags(attachment: Attachment, context: TagsContext | null): Artifac
50
50
  }
51
51
 
52
52
  /** Every attachment a holder carries, on its steps and attempts too. */
53
- function everyAttachment(holder: EvidenceHolder): Attachment[] {
53
+ export function everyAttachment(holder: EvidenceHolder): Attachment[] {
54
54
  const out: Attachment[] = [...(holder.attachments ?? [])];
55
55
  for (const step of holder.steps ?? []) out.push(...(step.attachments ?? []));
56
56
  for (const attempt of holder.attempts ?? []) {
@@ -7,7 +7,7 @@
7
7
  * untagged. Each row opens that exact result in the test detail. An empty answer for a filter says nothing matches and
8
8
  * offers to clear it; it never says the project has nothing recorded.
9
9
  */
10
- import { IconVideo } from '@tabler/icons-react';
10
+ import { IconPlayerPlay, IconVideo } from '@tabler/icons-react';
11
11
  import { Alert, Anchor, Box, Button, Code, Stack, Table, Text } from '@mantine/core';
12
12
  import { videoCounts } from '@crvouga/atlas/reports/evidence-detail';
13
13
  import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
@@ -20,6 +20,8 @@ import { useArtifactList } from './catalog';
20
20
  import { FilterBar } from './filters';
21
21
  import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
22
22
  import { runName } from './parts';
23
+ import { entriesFromArtifacts } from './playlist-entries';
24
+ import { WatchPlaylist } from './playlist';
23
25
  import { filtered, filterOf, NO_FILTERS } from './search';
24
26
 
25
27
  const ARTIFACT_ROWS = 50;
@@ -33,6 +35,8 @@ export function ArtifactsView() {
33
35
  const answer = useArtifactList(filter);
34
36
  const now = useNow();
35
37
  const [shown, setShown] = useState(ARTIFACT_ROWS);
38
+ const [watching, setWatching] = useState(false);
39
+ const entries = useMemo(() => (answer.data && !answer.data.unsupported ? entriesFromArtifacts(answer.data.list.results) : []), [answer.data]);
36
40
  if (answer.isPending)
37
41
  return (
38
42
  <Text role="status" component="div" c="dimmed">
@@ -77,6 +81,12 @@ export function ArtifactsView() {
77
81
  </Button>
78
82
  </Stack>
79
83
  )}
84
+ {list.results.length > 0 && (
85
+ <Button type="button" variant="filled" my="sm" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => setWatching(true)}>
86
+ Watch these {plural(list.results.length, 'result', 'results')}
87
+ </Button>
88
+ )}
89
+ {watching && <WatchPlaylist entries={entries} total={list.total} onClose={() => setWatching(false)} />}
80
90
  {list.results.length > 0 && (
81
91
  <Table.ScrollContainer minWidth={700}>
82
92
  <Table data-artifacts>
@@ -1,4 +1,4 @@
1
- import { IconAlertTriangle, IconArrowLeft, IconVideo } from '@tabler/icons-react';
1
+ import { IconAlertTriangle, IconArrowLeft, IconPlayerPlay, IconVideo } from '@tabler/icons-react';
2
2
  import { Alert, Anchor, Badge, Box, Button, Code, Flex, Group, List, NativeSelect, Stack, Table, Text, TextInput, Title, VisuallyHidden } from '@mantine/core';
3
3
  /**
4
4
  * @module Runs view
@@ -25,6 +25,8 @@ import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidenc
25
25
  import { Meter, StatusMark } from '../evidence/marks';
26
26
  import { useRunDetail, useRunList, useTestCatalog } from './catalog';
27
27
  import { RunArtifacts } from './RunArtifacts';
28
+ import { entriesFromRun } from './playlist-entries';
29
+ import { WatchPlaylist } from './playlist';
28
30
  import { FilterBar } from './filters';
29
31
  import { SearchNotes, TypedFilters } from './search-notes';
30
32
  import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
@@ -469,6 +471,8 @@ function RunDetailView({ runKey }: { runKey: string }) {
469
471
  ),
470
472
  [narrowedResults, status, statusFilters, model]
471
473
  );
474
+ const [watching, setWatching] = useState(false);
475
+ const playlist = useMemo(() => entriesFromRun(filtered, detail.data?.layers), [filtered, detail.data]);
472
476
  const bySpec = useMemo(() => {
473
477
  const groups = new Map<string, RunResult[]>();
474
478
  for (const result of filtered.slice(0, shown)) {
@@ -636,6 +640,12 @@ function RunDetailView({ runKey }: { runKey: string }) {
636
640
  No result matches.
637
641
  </Text>
638
642
  )}
643
+ {filtered.length > 0 && (
644
+ <Button type="button" variant="filled" my="sm" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => setWatching(true)}>
645
+ Watch these {plural(filtered.length, 'result', 'results')}
646
+ </Button>
647
+ )}
648
+ {watching && <WatchPlaylist entries={playlist} onClose={() => setWatching(false)} />}
639
649
  {bySpec.map(([spec, list]) => (
640
650
  <Box key={spec} data-open aria-label={spec || 'No spec'} component="section" mb="lg">
641
651
  <Button data-static variant="default" fullWidth h="auto" py="sm" justify="flex-start">
@@ -0,0 +1,51 @@
1
+ import type { VideoRef } from '@crvouga/atlas/reports/evidence-types';
2
+ import { expect, test } from 'bun:test';
3
+
4
+ import { entriesFromRun, playlistCounts, recordingState, stepFrom, type PlaylistEntry } from './playlist-entries';
5
+
6
+ const video = (recording?: string): VideoRef => ({ name: 'a.webm', owner: 'check', available: recording !== undefined, ...(recording ? { recording } : {}) });
7
+ const entry = (id: string, extra: Partial<PlaylistEntry> = {}): PlaylistEntry => ({
8
+ id, title: id, key: id, specPath: null, status: 'passed', environment: 'desktop-web', producer: 'web', at: 1, layer: 'l', backend: 'local', check: id, kind: null, tags: [], videos: [], ...extra
9
+ });
10
+
11
+ test('a recording state never says more than what is stored', () => {
12
+ expect(recordingState(entry('a', { videos: [video('sha256:1')] }))).toBe('playable');
13
+ expect(recordingState(entry('a', { videos: [video()] }))).toBe('unplayable');
14
+ // Bytes exist, but nothing says which backend to read them from.
15
+ expect(recordingState(entry('a', { videos: [video('sha256:1')], backend: null }))).toBe('unplayable');
16
+ expect(recordingState(entry('a', { environment: 'api-rest' }))).toBe('api-only');
17
+ expect(recordingState(entry('a', { environment: null }))).toBe('none');
18
+ expect(recordingState(entry('a'))).toBe('none');
19
+ });
20
+
21
+ test('counts keep failures and gaps visible', () => {
22
+ const counts = playlistCounts([
23
+ entry('a', { videos: [video('sha256:1')] }),
24
+ entry('b', { status: 'failed', videos: [video('sha256:2')] }),
25
+ entry('c', { status: 'failed' }),
26
+ entry('d', { environment: 'api' })
27
+ ]);
28
+ expect(counts).toEqual({ playable: 2, unplayable: 0, 'api-only': 1, none: 1, failed: 2 });
29
+ });
30
+
31
+ test('previous and next walk the watchable entries, skip gaps, wrap only when asked and work from a gap', () => {
32
+ const play = { videos: [video('sha256:1')] };
33
+ const list = [entry('a', play), entry('gap'), entry('b', play), entry('c', play)];
34
+ expect(stepFrom(list, 'a', 1, false)?.id).toBe('b');
35
+ expect(stepFrom(list, 'c', 1, false)).toBeNull();
36
+ expect(stepFrom(list, 'c', 1, true)?.id).toBe('a');
37
+ expect(stepFrom(list, 'a', -1, false)).toBeNull();
38
+ expect(stepFrom(list, 'a', -1, true)?.id).toBe('c');
39
+ expect(stepFrom(list, 'gap', 1, false)?.id).toBe('b');
40
+ expect(stepFrom(list, 'gap', -1, false)?.id).toBe('a');
41
+ expect(stepFrom([list[0]!], 'a', 1, true)?.id).toBe('a');
42
+ expect(stepFrom([entry('gap')], 'gap', 1, true)).toBeNull();
43
+ });
44
+
45
+ test('a run result gets its backend from the layers of the run, and none when they are not known', () => {
46
+ const result = { key: 'k', id: 'c', title: 'T', specPath: null, environment: 'ios', producer: 'p', status: 'failed' as const, durationMs: null, at: 1, layer: 'abc', detail: '', videos: [{ ...video('sha256:1'), tags: ['demo'] }] };
47
+ const layers = [{ id: 'abc', backend: 'shared', producer: 'p', environment: 'ios', recordedAtMs: 1, checks: 1, attachments: 0 }];
48
+ expect(entriesFromRun([result], layers)[0]).toMatchObject({ backend: 'shared', check: 'c', status: 'failed', tags: ['demo'] });
49
+ expect(entriesFromRun([result], undefined)[0]!.backend).toBeNull();
50
+ expect(recordingState(entriesFromRun([result], undefined)[0]!)).toBe('unplayable');
51
+ });
@@ -0,0 +1,134 @@
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 { ArtifactResult, RunLayer, RunResult } from '@crvouga/atlas/reports/test-catalog';
13
+
14
+ /** One recorded result in a playlist: where to read its evidence, what to say about it, and its video references. */
15
+ export type PlaylistEntry = {
16
+ /** Identifies the entry for as long as the set is the same; selection follows it, never a position. */
17
+ readonly id: string;
18
+ readonly title: string;
19
+ /** The test's key, to open it. */
20
+ readonly key: string;
21
+ readonly specPath: string | null;
22
+ readonly status: EvidenceStatus;
23
+ readonly environment: string | null;
24
+ readonly producer: string;
25
+ /** When it was recorded, epoch ms. */
26
+ readonly at: number;
27
+ /** The layer that recorded it and the backend to read it from; null when the backend is not known (an older server). */
28
+ readonly layer: string;
29
+ readonly backend: string | null;
30
+ /** The check's id and, when known, the kind of its part: with the layer they name the evidence to read. */
31
+ readonly check: string;
32
+ readonly kind: string | null;
33
+ /** The tags its attachments carry. */
34
+ readonly tags: readonly string[];
35
+ /** Every video its evidence references. */
36
+ readonly videos: readonly VideoRef[];
37
+ };
38
+
39
+ /**
40
+ * What is known about an entry's recording: `playable` (a stored file can be tried), `unplayable` (referenced, but no
41
+ * bytes were published, or the backend to read them from is unknown), `api-only` (no recording and recorded through
42
+ * an API, so there is no screen) or `none`.
43
+ */
44
+ export type RecordingState = 'playable' | 'unplayable' | 'api-only' | 'none';
45
+
46
+ /** Environments a reader cannot watch: a result recorded only through these has no screen. */
47
+ const API_ENVIRONMENT = /^(api|http|rest|graphql|service|backend)(-|$)/;
48
+
49
+ /** The state of an entry's recording. Only `playable` means there is something to watch; none of the others is a pass with proof. */
50
+ export function recordingState(entry: Pick<PlaylistEntry, 'videos' | 'backend' | 'environment'>): RecordingState {
51
+ const counts = videoCounts(entry.videos);
52
+ if (counts && counts.recordings > 0) return entry.backend === null ? 'unplayable' : 'playable';
53
+ if (counts) return 'unplayable';
54
+ return entry.environment !== null && API_ENVIRONMENT.test(entry.environment) ? 'api-only' : 'none';
55
+ }
56
+
57
+ /** What each state says to a person. A recording is never described as demonstrating anything it does not. */
58
+ export const RECORDING_STATE_TEXT: Record<RecordingState, { readonly label: string; readonly meaning: string }> = {
59
+ playable: { label: 'Recording', meaning: 'A recording is stored for this result.' },
60
+ unplayable: { label: 'Recording not available', meaning: 'This result references a recording, but no stored file can be played, so it is not demonstrated.' },
61
+ '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.' },
62
+ none: { label: 'No recording', meaning: 'No recording was published for this result, so it is not demonstrated by one.' }
63
+ };
64
+
65
+ /** The first video of an entry that names bytes: the one that is played. */
66
+ export function playableVideo(entry: Pick<PlaylistEntry, 'videos'>): VideoRef | undefined {
67
+ return entry.videos.find((video) => video.available && video.recording !== undefined);
68
+ }
69
+
70
+ /** The recorded results of the Artifacts tab as playlist entries, in their order. */
71
+ export function entriesFromArtifacts(results: readonly ArtifactResult[]): PlaylistEntry[] {
72
+ return results.map((result) => ({
73
+ id: `${result.layer}|${result.key}|${result.kind}|${result.producer}|${result.environment ?? ''}`,
74
+ title: result.title,
75
+ key: result.key,
76
+ specPath: result.specPath,
77
+ status: result.status,
78
+ environment: result.environment,
79
+ producer: result.producer,
80
+ at: result.at,
81
+ layer: result.layer,
82
+ backend: result.backend,
83
+ check: result.id,
84
+ kind: result.kind,
85
+ tags: result.artifactTags,
86
+ videos: result.videos ?? []
87
+ }));
88
+ }
89
+
90
+ /** The results of one run as playlist entries, in their order; `layers` say which backend each result's layer was read from. */
91
+ export function entriesFromRun(results: readonly RunResult[], layers: readonly RunLayer[] | undefined): PlaylistEntry[] {
92
+ const backends = new Map((layers ?? []).map((layer) => [layer.id, layer.backend]));
93
+ return results.map((result) => ({
94
+ id: `${result.layer}|${result.key}||${result.producer}|${result.environment ?? ''}`,
95
+ title: result.title,
96
+ key: result.key,
97
+ specPath: result.specPath,
98
+ status: result.status,
99
+ environment: result.environment,
100
+ producer: result.producer,
101
+ at: result.at,
102
+ layer: result.layer,
103
+ backend: backends.get(result.layer) ?? null,
104
+ check: result.id,
105
+ kind: null,
106
+ tags: [...new Set((result.videos ?? []).flatMap((video) => video.tags ?? []))].sort(),
107
+ videos: result.videos ?? []
108
+ }));
109
+ }
110
+
111
+ /** How many entries are in each state, and how many failed (a failure is counted whether or not it was recorded). */
112
+ export function playlistCounts(entries: readonly PlaylistEntry[]): Record<RecordingState, number> & { readonly failed: number } {
113
+ const counts = { playable: 0, unplayable: 0, 'api-only': 0, none: 0, failed: 0 };
114
+ for (const entry of entries) {
115
+ counts[recordingState(entry)] += 1;
116
+ if (entry.status === 'failed') counts.failed += 1;
117
+ }
118
+ return counts;
119
+ }
120
+
121
+ /**
122
+ * The entry to go to from `from`, over the entries that can be watched. `step` is 1 for next, -1 for previous. Past the
123
+ * end it wraps to the other end when `wrap`, else it is null. From an entry that cannot be watched (selected from the
124
+ * list to read why), it is the nearest watchable one in that direction.
125
+ */
126
+ export function stepFrom(entries: readonly PlaylistEntry[], from: string | null, step: 1 | -1, wrap: boolean): PlaylistEntry | null {
127
+ const at = from === null ? -1 : entries.findIndex((entry) => entry.id === from);
128
+ const watchable = (entry: PlaylistEntry) => recordingState(entry) === 'playable';
129
+ const ordered = step === 1 ? entries.map((entry, index) => [entry, index] as const) : entries.map((entry, index) => [entry, index] as const).reverse();
130
+ const after = ordered.filter(([entry, index]) => watchable(entry) && (at < 0 ? true : step === 1 ? index > at : index < at));
131
+ if (after.length > 0) return after[0]![0];
132
+ if (!wrap) return null;
133
+ return ordered.find(([entry]) => watchable(entry))?.[0] ?? null;
134
+ }
@@ -0,0 +1,441 @@
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 { playableVideo, playlistCounts, RECORDING_STATE_TEXT, recordingState, stepFrom, type PlaylistEntry } from './playlist-entries';
32
+
33
+ /** Entries listed before "more"; the selected entry is always listed. */
34
+ const LISTED = 50;
35
+ /** Playback speeds offered. */
36
+ const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
37
+ /** Step names are shown with their Gherkin keyword when they have one. */
38
+ const KEYWORDS = new Set(['given', 'when', 'then', 'and', 'but']);
39
+
40
+ /** The evidence of one entry's check, read when the entry is on screen. */
41
+ function useEntryDetail(entry: PlaylistEntry, enabled: boolean) {
42
+ return useQuery({
43
+ queryKey: ['evidence-detail', entry.backend ?? '', entry.layer, entry.kind ?? '', entry.check],
44
+ queryFn: ({ signal }) =>
45
+ reportJson<CheckDetail>(
46
+ `/evidence/detail/${encodeURIComponent(entry.backend!)}/${encodeURIComponent(entry.layer)}?check=${encodeURIComponent(entry.check)}${entry.kind ? `&kind=${encodeURIComponent(entry.kind)}` : ''}`,
47
+ signal
48
+ ),
49
+ enabled: enabled && entry.backend !== null && /^[0-9a-f]{64}$/.test(entry.layer),
50
+ staleTime: Number.POSITIVE_INFINITY,
51
+ retry: false
52
+ });
53
+ }
54
+
55
+ /** The scenario in words: the steps the producer recorded, with the Gherkin keyword where it gave one. */
56
+ function Scenario({ entry, detail }: { entry: PlaylistEntry; detail: CheckDetail | undefined }) {
57
+ const steps = detail?.check.steps ?? [];
58
+ return (
59
+ <Stack gap="xs" component="section" aria-label="Scenario">
60
+ <Title order={3}>{entry.title}</Title>
61
+ {steps.length > 0 && (
62
+ <List type="ordered" spacing="xs" aria-label="Steps">
63
+ {steps.map((step, index) => (
64
+ <List.Item key={`${step.id ?? step.name}:${index}`}>
65
+ {step.kind && KEYWORDS.has(step.kind.toLowerCase()) ? `${step.kind.charAt(0).toUpperCase()}${step.kind.slice(1).toLowerCase()} ` : ''}
66
+ {step.name}
67
+ {step.status && step.status !== 'passed' ? ` (${step.status})` : ''}
68
+ </List.Item>
69
+ ))}
70
+ </List>
71
+ )}
72
+ {entry.status === 'failed' && detail?.check.detail && (
73
+ <Alert color="red" title="Why it failed" role="status">
74
+ <Text component="div" lineClamp={4}>
75
+ {detail.check.detail}
76
+ </Text>
77
+ </Alert>
78
+ )}
79
+ </Stack>
80
+ );
81
+ }
82
+
83
+ /** Who recorded the entry and what it carries, in one line each. */
84
+ function Facts({ entry }: { entry: PlaylistEntry }) {
85
+ return (
86
+ <Stack gap="xs" aria-label="Recording facts">
87
+ <Group gap="xs" wrap="wrap">
88
+ <StatusMark status={entry.status} size={14} />
89
+ <Text span inherit fw={600} data-outcome={entry.status}>
90
+ {statusWord(entry.status)}
91
+ </Text>
92
+ <Text span inherit c="dimmed">
93
+ {[environmentLabel(entry.environment), entry.producer, `recorded ${exactTime(entry.at)}`].join(' · ')}
94
+ </Text>
95
+ </Group>
96
+ {entry.tags.length > 0 && (
97
+ <Group gap="xs" aria-label="Tags">
98
+ {entry.tags.map((tag) => (
99
+ <Badge key={tag} variant="light" tt="none">
100
+ #{tag}
101
+ </Badge>
102
+ ))}
103
+ </Group>
104
+ )}
105
+ {entry.specPath && (
106
+ <Text component="div" c="dimmed">
107
+ Spec <Code>{entry.specPath}</Code>
108
+ </Text>
109
+ )}
110
+ </Stack>
111
+ );
112
+ }
113
+
114
+ /** The one player. It is keyed by its entry, so a new clip is a fresh element and the previous one is gone. */
115
+ function Clip({
116
+ entry,
117
+ speed,
118
+ autoPlay,
119
+ positions,
120
+ onEnded,
121
+ onFailed,
122
+ onNext,
123
+ onClose
124
+ }: {
125
+ entry: PlaylistEntry;
126
+ speed: number;
127
+ autoPlay: boolean;
128
+ positions: Map<string, number>;
129
+ onEnded: () => PlaylistEntry | null;
130
+ onFailed: (id: string) => void;
131
+ onNext: (() => void) | null;
132
+ onClose: () => void;
133
+ }) {
134
+ const state = recordingState(entry);
135
+ const detail = useEntryDetail(entry, state === 'playable');
136
+ const video = useRef<HTMLVideoElement>(null);
137
+ const [failed, setFailed] = useState(false);
138
+ const [blocked, setBlocked] = useState(false);
139
+ const [attempt, setAttempt] = useState(0);
140
+ const chosen = playableVideo(entry);
141
+ const attachments = useMemo(() => (detail.data ? everyAttachment(detail.data.check) : []), [detail.data]);
142
+ const movie = useMemo(() => {
143
+ const videos = attachments.filter(isVideoAttachment);
144
+ return videos.find((item) => attachmentIdentity(item) === chosen?.recording) ?? videos[0];
145
+ }, [attachments, chosen]);
146
+ const captions = useMemo(
147
+ () => attachments.filter((item) => (item.contentType ?? contentTypeOf(item.name)).toLowerCase().startsWith('text/vtt')),
148
+ [attachments]
149
+ );
150
+ const url = movie ? attachmentSource(movie, entry.layer).url : null;
151
+
152
+ useEffect(() => {
153
+ if (video.current) video.current.playbackRate = speed;
154
+ }, [speed]);
155
+
156
+ const play = () => {
157
+ const element = video.current;
158
+ if (!element) return;
159
+ setBlocked(false);
160
+ // `play()` rejects when the browser blocks it; a browser without a promise has nothing to catch.
161
+ const started = element.play() as Promise<void> | undefined;
162
+ started?.catch(() => setBlocked(true));
163
+ };
164
+
165
+ const developer = (
166
+ <Anchor
167
+ renderRoot={(props) => (
168
+ <Link {...props} to="/" search={patched({ tab: undefined, openRun: undefined, artifact: undefined, test: entry.key, layer: entry.layer })} resetScroll={false} activeOptions={{ exact: true }} onClick={onClose} />
169
+ )}
170
+ >
171
+ Developer details
172
+ </Anchor>
173
+ );
174
+
175
+ let body;
176
+ if (state !== 'playable') {
177
+ body = (
178
+ <Alert color="orange" title={RECORDING_STATE_TEXT[state].label} icon={<IconAlertTriangle size={16} aria-hidden="true" />} role="status" data-recording-state={state}>
179
+ {RECORDING_STATE_TEXT[state].meaning}
180
+ </Alert>
181
+ );
182
+ } else if (detail.isPending) {
183
+ body = (
184
+ <Text role="status" component="div" c="dimmed">
185
+ Loading the recording…
186
+ </Text>
187
+ );
188
+ } else if (detail.isError || !url) {
189
+ body = (
190
+ <Alert role="alert" color="orange" title="This recording cannot be played" data-clip-failed>
191
+ <Stack gap="xs" align="flex-start">
192
+ <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>
193
+ <Group gap="xs">
194
+ {detail.isError && (
195
+ <Button type="button" variant="light" onClick={() => void detail.refetch()}>
196
+ Try again
197
+ </Button>
198
+ )}
199
+ {onNext && (
200
+ <Button type="button" variant="light" onClick={onNext}>
201
+ Next clip
202
+ </Button>
203
+ )}
204
+ </Group>
205
+ </Stack>
206
+ </Alert>
207
+ );
208
+ } else {
209
+ body = (
210
+ <Stack gap="xs">
211
+ <ScopeNotice scope={chosen?.scope} name={movie!.name} />
212
+ <Box
213
+ key={attempt}
214
+ component="video"
215
+ ref={video}
216
+ src={url}
217
+ controls
218
+ preload="metadata"
219
+ aria-label={`Recording: ${movie!.description ?? movie!.name}`}
220
+ maw="100%"
221
+ w="100%"
222
+ mah="70vh"
223
+ data-playing={entry.id}
224
+ onLoadedMetadata={() => {
225
+ const element = video.current!;
226
+ element.playbackRate = speed;
227
+ const saved = positions.get(entry.id);
228
+ if (saved !== undefined && saved < (element.duration || Number.POSITIVE_INFINITY)) element.currentTime = saved;
229
+ if (autoPlay) play();
230
+ }}
231
+ onTimeUpdate={() => positions.set(entry.id, video.current!.currentTime)}
232
+ onEnded={() => {
233
+ positions.delete(entry.id);
234
+ const next = onEnded();
235
+ if (next && next.id === entry.id && video.current) {
236
+ video.current.currentTime = 0;
237
+ play();
238
+ }
239
+ }}
240
+ onError={() => {
241
+ setFailed(true);
242
+ onFailed(entry.id);
243
+ }}
244
+ >
245
+ {captions.map((caption) => {
246
+ const source = attachmentSource(caption, entry.layer).url;
247
+ return source ? <track key={source} kind="captions" src={source} label={caption.description ?? caption.name} /> : null;
248
+ })}
249
+ </Box>
250
+ {movie!.durationMs ? (
251
+ <Text component="div" c="dimmed">
252
+ Length of the recording: {duration(movie!.durationMs)}
253
+ </Text>
254
+ ) : null}
255
+ {captions.length > 0 && (
256
+ <Text component="div" c="dimmed">
257
+ Captions are offered in the player's captions control: {captions.map((caption) => caption.description ?? caption.name).join(', ')}.
258
+ </Text>
259
+ )}
260
+ {blocked && (
261
+ <Alert color="blue" role="status" title="Autoplay was blocked" data-autoplay-blocked>
262
+ <Stack gap="xs" align="flex-start">
263
+ <Text component="div">The browser did not start this clip by itself. Play it to continue.</Text>
264
+ <Button type="button" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={play}>
265
+ Play
266
+ </Button>
267
+ </Stack>
268
+ </Alert>
269
+ )}
270
+ {failed && (
271
+ <Alert color="orange" role="alert" title="This video cannot be played" data-clip-failed>
272
+ <Stack gap="xs" align="flex-start">
273
+ <Text component="div">The file could not be loaded, or this browser cannot play its format. It stays in the playlist.</Text>
274
+ <Group gap="xs">
275
+ <Button
276
+ type="button"
277
+ variant="light"
278
+ onClick={() => {
279
+ setFailed(false);
280
+ setAttempt(attempt + 1);
281
+ }}
282
+ >
283
+ Retry
284
+ </Button>
285
+ {onNext && (
286
+ <Button type="button" variant="light" onClick={onNext}>
287
+ Next clip
288
+ </Button>
289
+ )}
290
+ </Group>
291
+ </Stack>
292
+ </Alert>
293
+ )}
294
+ </Stack>
295
+ );
296
+ }
297
+ return (
298
+ <Stack gap="md" miw={0} aria-label={`Now showing ${entry.title}`} component="section" data-clip={entry.id}>
299
+ {body}
300
+ <Facts entry={entry} />
301
+ <Scenario entry={entry} detail={detail.data} />
302
+ <Text component="div" c="dimmed">
303
+ 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.
304
+ </Text>
305
+ {developer}
306
+ </Stack>
307
+ );
308
+ }
309
+
310
+ /**
311
+ * The watch session for `entries`: opens as a modal over the explorer. `total` is how many results matched when the list
312
+ * holds only the newest of them, so the session says it is not the whole set. Closing it discards positions.
313
+ */
314
+ export function WatchPlaylist({ entries, total, onClose }: { entries: readonly PlaylistEntry[]; total?: number; onClose: () => void }) {
315
+ const first = entries.find((entry) => recordingState(entry) === 'playable') ?? entries[0];
316
+ const [chosen, setChosen] = useState<string | null>(first?.id ?? null);
317
+ const [autoPlay, setAutoPlay] = useState(false);
318
+ const [speed, setSpeed] = useState<number>(1);
319
+ const [advance, setAdvance] = useState(true);
320
+ const [repeat, setRepeat] = useState(false);
321
+ const [failedIds, setFailedIds] = useState<ReadonlySet<string>>(new Set());
322
+ const [shown, setShown] = useState(LISTED);
323
+ const positions = useRef(new Map<string, number>());
324
+ const active = entries.find((entry) => entry.id === chosen) ?? first ?? null;
325
+ const counts = useMemo(() => playlistCounts(entries), [entries]);
326
+ const activeAt = active ? entries.indexOf(active) : -1;
327
+ const next = stepFrom(entries, active?.id ?? null, 1, repeat);
328
+ const previous = stepFrom(entries, active?.id ?? null, -1, repeat);
329
+ const select = (entry: PlaylistEntry, play: boolean) => {
330
+ setAutoPlay(play);
331
+ setChosen(entry.id);
332
+ };
333
+ const listed = entries.slice(0, Math.max(shown, activeAt + 1));
334
+ return (
335
+ <Modal opened onClose={onClose} title="Watch recordings" size="xl" centered>
336
+ <Stack gap="md">
337
+ <Text role="status" component="div" data-playlist-summary>
338
+ {plural(entries.length, 'result', 'results')}
339
+ {total !== undefined && total > entries.length ? ` (the newest of ${total.toLocaleString()} matching)` : ''}: {counts.playable} with a recording
340
+ {counts.unplayable > 0 ? `, ${counts.unplayable} whose recording is not available` : ''}
341
+ {counts['api-only'] > 0 ? `, ${counts['api-only']} API only` : ''}
342
+ {counts.none > 0 ? `, ${counts.none} with no recording` : ''}. {plural(counts.failed, 'failed result', 'failed results')}
343
+ {counts.failed > 0 ? ' kept in the playlist' : ''}.
344
+ </Text>
345
+ {entries.length === 0 && (
346
+ <Text component="div" c="dimmed">
347
+ No result is in this set, so there is nothing to watch.
348
+ </Text>
349
+ )}
350
+ {active && (
351
+ <Group role="group" aria-label="Playlist controls" align="flex-end" gap="md" wrap="wrap">
352
+ <Button type="button" variant="default" leftSection={<IconPlayerSkipBack size={16} aria-hidden="true" />} disabled={!previous} onClick={() => previous && select(previous, true)}>
353
+ Previous clip
354
+ </Button>
355
+ <Button type="button" variant="default" rightSection={<IconPlayerSkipForward size={16} aria-hidden="true" />} disabled={!next} onClick={() => next && select(next, true)}>
356
+ Next clip
357
+ </Button>
358
+ <NativeSelect
359
+ label="Playback speed"
360
+ value={String(speed)}
361
+ onChange={(event) => setSpeed(Number(event.currentTarget.value))}
362
+ data={SPEEDS.map((value) => ({ value: String(value), label: `${value}×` }))}
363
+ />
364
+ <Switch label="Play the next clip when one ends" checked={advance} onChange={(event) => setAdvance(event.currentTarget.checked)} />
365
+ <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)} />
366
+ </Group>
367
+ )}
368
+ {active && (
369
+ <Text c="dimmed" component="div" size="sm">
370
+ Previous and next skip results with nothing to play; select one in the list to see why.
371
+ </Text>
372
+ )}
373
+ <Grid gap="md">
374
+ <Grid.Col span={{ base: 12, md: 8 }}>
375
+ {active && (
376
+ <Clip
377
+ key={active.id}
378
+ entry={active}
379
+ speed={speed}
380
+ autoPlay={autoPlay}
381
+ positions={positions.current}
382
+ onEnded={() => {
383
+ const target = advance ? stepFrom(entries, active.id, 1, repeat) : repeat ? active : null;
384
+ if (target && target.id !== active.id) select(target, true);
385
+ return target;
386
+ }}
387
+ onFailed={(id) => setFailedIds((held) => new Set(held).add(id))}
388
+ onNext={next ? () => select(next, true) : null}
389
+ onClose={onClose}
390
+ />
391
+ )}
392
+ </Grid.Col>
393
+ <Grid.Col span={{ base: 12, md: 4 }}>
394
+ <Stack gap="xs" component="section" aria-label="Playlist">
395
+ <Title order={3}>Playlist</Title>
396
+ <List spacing="xs" listStyleType="none">
397
+ {listed.map((entry, index) => {
398
+ const state = recordingState(entry);
399
+ const here = entry.id === active?.id;
400
+ return (
401
+ <List.Item key={entry.id} data-entry={entry.id} data-status={entry.status} data-recording-state={state}>
402
+ <Button
403
+ type="button"
404
+ variant={here ? 'light' : 'subtle'}
405
+ fullWidth
406
+ h="auto"
407
+ py="xs"
408
+ justify="flex-start"
409
+ aria-current={here ? 'true' : undefined}
410
+ aria-label={`${index + 1}. ${entry.title}, ${statusWord(entry.status)}, ${failedIds.has(entry.id) ? 'could not be loaded' : RECORDING_STATE_TEXT[state].label}`}
411
+ onClick={() => select(entry, state === 'playable')}
412
+ >
413
+ <Flex component="span" gap="xs" wrap="nowrap" align="flex-start">
414
+ <StatusMark status={entry.status} size={14} />
415
+ <Flex component="span" direction="column" align="flex-start">
416
+ <Text span inherit ta="left">
417
+ {entry.title}
418
+ </Text>
419
+ <Text span inherit c="dimmed" size="xs" ta="left">
420
+ {statusWord(entry.status)} · {environmentLabel(entry.environment)} ·{' '}
421
+ {failedIds.has(entry.id) ? 'Could not be loaded' : RECORDING_STATE_TEXT[state].label}
422
+ </Text>
423
+ </Flex>
424
+ </Flex>
425
+ </Button>
426
+ </List.Item>
427
+ );
428
+ })}
429
+ </List>
430
+ {entries.length > listed.length && (
431
+ <Button type="button" variant="subtle" onClick={() => setShown(listed.length + LISTED)}>
432
+ Show more ({entries.length - listed.length} more)
433
+ </Button>
434
+ )}
435
+ </Stack>
436
+ </Grid.Col>
437
+ </Grid>
438
+ </Stack>
439
+ </Modal>
440
+ );
441
+ }