@crvouga/atlas 1.18.0 → 1.20.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/dist/reports/src/evidence-detail.js +13 -3
  3. package/dist/reports/src/evidence-types.d.ts +4 -0
  4. package/dist/schema/src/api.d.ts +105 -0
  5. package/dist/schema/src/api.js +35 -0
  6. package/dist/server/src/app.js +3 -1
  7. package/dist/server/src/openapi.d.ts +1 -1
  8. package/dist/server/src/openapi.js +24 -2
  9. package/dist/server/src/recording-verification.d.ts +34 -0
  10. package/dist/server/src/recording-verification.js +118 -0
  11. package/docs/reference/http-api.md +52 -0
  12. package/docs/reference/reports.md +4 -0
  13. package/docs/reference/schema.md +41 -0
  14. package/docs/reference/server.md +39 -0
  15. package/docs/rich-evidence.md +13 -0
  16. package/docs/server.md +12 -0
  17. package/docs/ui.md +40 -1
  18. package/llms-full.txt +117 -1
  19. package/package.json +1 -1
  20. package/packages/schema/schemas/effective-delta.schema.json +18 -0
  21. package/packages/schema/schemas/effective-evidence.schema.json +18 -0
  22. package/packages/schema/schemas/evidence-page.schema.json +9 -0
  23. package/packages/schema/schemas/layer-rows.schema.json +9 -0
  24. package/packages/schema/schemas/report-run.schema.json +9 -0
  25. package/packages/schema/schemas/review-collection.schema.json +9 -0
  26. package/packages/schema/schemas/review-manifest.schema.json +9 -0
  27. package/packages/schema/schemas/test-catalog.schema.json +9 -0
  28. package/packages/schema/schemas/test-history.schema.json +18 -0
  29. package/packages/server/openapi.json +653 -1
  30. package/packages/visualizer/src/app/AppShell.tsx +2 -2
  31. package/packages/visualizer/src/app/router.tsx +25 -1
  32. package/packages/visualizer/src/components/Header.tsx +15 -4
  33. package/packages/visualizer/src/evidence/behavior-index.test.ts +119 -0
  34. package/packages/visualizer/src/evidence/behavior-index.ts +162 -0
  35. package/packages/visualizer/src/evidence/behavior-panel.tsx +36 -22
  36. package/packages/visualizer/src/evidence/behavior-return.test.ts +41 -0
  37. package/packages/visualizer/src/evidence/behavior-return.ts +59 -0
  38. package/packages/visualizer/src/evidence/behavior.ts +32 -1
  39. package/packages/visualizer/src/evidence/behaviors-page.tsx +465 -0
  40. package/packages/visualizer/src/evidence/feature-page.tsx +5 -26
  41. package/packages/visualizer/src/evidence/scoped-summary-view.tsx +110 -0
  42. package/packages/visualizer/src/evidence/scoped-summary.test.ts +62 -0
  43. package/packages/visualizer/src/evidence/scoped-summary.ts +118 -0
  44. package/packages/visualizer/src/evidence/spec-texts.ts +88 -0
  45. package/packages/visualizer/src/explorer/ArtifactsView.tsx +4 -4
  46. package/packages/visualizer/src/explorer/Explorer.tsx +1 -1
  47. package/packages/visualizer/src/explorer/RunsView.tsx +36 -10
  48. package/packages/visualizer/src/explorer/catalog.tsx +24 -1
  49. package/packages/visualizer/src/explorer/collections.tsx +5 -5
  50. package/packages/visualizer/src/explorer/navigate.ts +17 -0
  51. package/packages/visualizer/src/explorer/playlist-entries.test.ts +10 -1
  52. package/packages/visualizer/src/explorer/playlist-entries.ts +10 -0
  53. package/packages/visualizer/src/explorer/playlist.tsx +43 -8
  54. package/packages/visualizer/src/explorer/recording-verification.ts +58 -0
  55. package/packages/visualizer/src/explorer/search.ts +7 -1
  56. package/packages/visualizer/src/find/Finder.tsx +1 -62
@@ -0,0 +1,62 @@
1
+ import type { TestResultEntry } from '@crvouga/atlas/reports/test-catalog';
2
+ import { expect, test } from 'bun:test';
3
+
4
+ import { entriesInScope, scopedFigures } from './scoped-summary';
5
+
6
+ const entry = (i: number, over: Partial<TestResultEntry> = {}): TestResultEntry => ({
7
+ at: 1_000 + i, status: 'passed', durationMs: 9_000, environment: 'desktop-web', producer: 'web', kind: 'checks', parameters: {}, branch: 'main',
8
+ sha: 'a'.repeat(40), runUrl: null, run: `run:r${i}`, layer: `l${i}`, backend: 'one', detail: '', ...over
9
+ });
10
+ const KEY = '["specs/a.feature","a"]';
11
+ const latency = (value: number, over: Record<string, unknown> = {}) => ({ name: 'save', kind: 'product-latency', unit: 'ms', value, from: 'tap', to: 'saved', ...over });
12
+
13
+ test('no history leaves reliability and product latency unknown, never good', () => {
14
+ const figures = scopedFigures(KEY, [], { environment: 'desktop-web' });
15
+ expect(figures.reliability.tone).toBe('unknown');
16
+ expect(figures.reliability.headline).toContain('unknown');
17
+ expect(figures.latency).toHaveLength(1);
18
+ expect(figures.latency[0]!.headline).toContain('unknown');
19
+ // The duration of the test is not a stand-in for the product's response time.
20
+ expect(figures.latency[0]!.qualifiers.join(' ')).toContain('not product latency');
21
+ });
22
+
23
+ test('a rate always names its independent executions and a small sample is insufficient evidence', () => {
24
+ const figures = scopedFigures(KEY, [entry(0), entry(1), entry(2, { status: 'failed' })], { environment: 'desktop-web' });
25
+ expect(figures.reliability.headline).toBe('2 of 3 independent executions passed in the end (66.7%).');
26
+ expect(figures.reliability.tone).toBe('weak');
27
+ expect(figures.reliability.qualifiers.join(' ')).toContain('Insufficient evidence');
28
+ expect(figures.reliability.qualifiers.join(' ')).toContain('First-try success is unknown');
29
+ });
30
+
31
+ test('the scope is the shown result\'s environment: another environment is not pooled into it', () => {
32
+ const history = [entry(0), entry(1, { environment: 'ios', status: 'failed' }), entry(2, { environment: 'ios', status: 'failed' })];
33
+ expect(entriesInScope(history, { environment: 'desktop-web' })).toHaveLength(1);
34
+ expect(entriesInScope(history, { environment: null })).toHaveLength(3);
35
+ const scoped = scopedFigures(KEY, history, { environment: 'desktop-web' });
36
+ expect(scoped.reliability.headline).toContain('1 of 1 independent execution passed');
37
+ expect(scoped.reliability.qualifiers[0]).toContain('desktop-web');
38
+ // With no environment named the executions are pooled, and the figure says so.
39
+ const pooled = scopedFigures(KEY, history, { environment: null });
40
+ expect(pooled.reliability.qualifiers.join(' ')).toMatch(/environments are pooled/i);
41
+ });
42
+
43
+ test('product latency is read only from named measurements, with withheld percentiles explained', () => {
44
+ const history = [entry(0, { latency: [latency(300)] }), entry(1, { latency: [latency(500, { limit: 400 })] }), entry(2)];
45
+ const [line] = scopedFigures(KEY, history, { environment: 'desktop-web' }).latency;
46
+ expect(line!.headline).toContain('save');
47
+ expect(line!.headline).toContain('no percentile can be stated');
48
+ expect(line!.tone).toBe('weak');
49
+ const text = line!.qualifiers.join(' ');
50
+ expect(text).toContain('2 observations');
51
+ expect(text).toContain('1 observation over it');
52
+ expect(text).toContain('p50 needs 5 observations');
53
+ });
54
+
55
+ test('enough observations state p50 and p95 with the budget and its breaches', () => {
56
+ const history = Array.from({ length: 20 }, (_, i) => entry(i, { latency: [latency(100 + i * 10, { limit: 250 })] }));
57
+ const [line] = scopedFigures(KEY, history, { environment: 'desktop-web' }).latency;
58
+ expect(line!.headline).toContain('p50');
59
+ expect(line!.headline).toContain('p95');
60
+ expect(line!.tone).toBe('figure');
61
+ expect(line!.qualifiers.join(' ')).toContain('20 observations');
62
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @module Scoped behavior summary
3
+ *
4
+ * The short account that sits beside a behavior's written promise and recording: what the selected result says, how
5
+ * reliable the check has been, and how quickly the product responded. It does not compute anything of its own. The
6
+ * outcome is {@link outcomeHeadline}; reliability is `reliability` and product latency is `latencyReview` from the
7
+ * reports library, read over the history of the same check in the same environment as the recording shown. This module
8
+ * only chooses the scope and puts the qualified figures into short sentences, keeping every qualification that makes a
9
+ * figure honest: how many independent executions it rests on, that a small sample is not established, which notes warn
10
+ * against reading it as representative, and that absent evidence is unknown, never good.
11
+ */
12
+ import { formatLatency, latencyReview, type LatencyReview } from '@crvouga/atlas/reports/latency';
13
+ import { reliability, scopeText, type ReliabilityDocument, type ReliabilityScope } from '@crvouga/atlas/reports/reliability';
14
+ import type { TestResultEntry } from '@crvouga/atlas/reports/test-catalog';
15
+
16
+ import { plural } from './format';
17
+
18
+ /** One line of the summary: the figure in words, and what qualifies it. */
19
+ export type SummaryLine = {
20
+ readonly headline: string;
21
+ /** What a reader must keep in mind: sample size, scope, warnings. Never empty for a figure. */
22
+ readonly qualifiers: readonly string[];
23
+ /** `figure`: a number supported by evidence. `unknown`: there is no evidence to state. `weak`: a figure that its own qualifiers say not to rely on. */
24
+ readonly tone: 'figure' | 'weak' | 'unknown';
25
+ };
26
+
27
+ /** The scope the summary describes: the environment of the shown result, so environments are never pooled silently. */
28
+ export type SummaryScope = {
29
+ /** The environment of the result shown; null when it recorded none (then every environment is pooled and the summary says so). */
30
+ readonly environment: string | null;
31
+ };
32
+
33
+ const percent = (rate: number | null) => (rate === null ? 'no rate' : `${Math.round(rate * 1000) / 10}%`);
34
+
35
+ /** The reliability scope for a summary scope. */
36
+ export function reliabilityScope(scope: SummaryScope): ReliabilityScope {
37
+ return scope.environment === null ? {} : { environment: [scope.environment] };
38
+ }
39
+
40
+ /** The recorded results of the summary's scope: those of its environment, or all when it names none. */
41
+ export function entriesInScope(entries: readonly TestResultEntry[], scope: SummaryScope): readonly TestResultEntry[] {
42
+ return scope.environment === null ? entries : entries.filter((entry) => entry.environment === scope.environment);
43
+ }
44
+
45
+ /**
46
+ * Reliability in one sentence and its qualifiers. A count of independent executions always comes with the figure;
47
+ * fewer than the minimum is "Insufficient evidence"; none is unknown. The library's warning notes are carried over.
48
+ */
49
+ export function reliabilityLine(document: ReliabilityDocument): SummaryLine {
50
+ const { eventual, firstAttempt } = document;
51
+ const where = scopeText(document.scope);
52
+ if (document.sample === 'none') {
53
+ return {
54
+ headline: 'Reliability is unknown: no independent execution is recorded in this scope.',
55
+ qualifiers: [`Scope: ${where}.`],
56
+ tone: 'unknown'
57
+ };
58
+ }
59
+ const qualifiers = [`Scope: ${where}.`];
60
+ if (document.sample === 'insufficient') {
61
+ qualifiers.push(`Insufficient evidence: at least ${document.minimumExecutions} independent executions are needed before a rate means much.`);
62
+ }
63
+ qualifiers.push(
64
+ firstAttempt.executions
65
+ ? `${firstAttempt.passed} of ${plural(firstAttempt.executions, 'execution', 'executions')} passed first time.`
66
+ : 'First-try success is unknown: no tries were recorded.'
67
+ );
68
+ for (const note of document.notes) if (note.severity === 'warning') qualifiers.push(note.message);
69
+ return {
70
+ headline: `${eventual.passed} of ${plural(eventual.executions, 'independent execution', 'independent executions')} passed in the end (${percent(eventual.rate)}).`,
71
+ qualifiers,
72
+ tone: document.sample === 'insufficient' || document.notes.some((note) => note.severity === 'warning') ? 'weak' : 'figure'
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Product latency: one line per measurement a producer named as product latency. The test's own duration is never read
78
+ * here. Percentiles the evidence cannot support are said to be withheld with their reason; with no measurement the
79
+ * answer is unknown, not fast.
80
+ */
81
+ export function latencyLines(review: LatencyReview): readonly SummaryLine[] {
82
+ if (review.metrics.length === 0) {
83
+ return [
84
+ {
85
+ headline: 'Product latency is unknown: no product-latency measurement is recorded.',
86
+ qualifiers: ['The duration of the test is not product latency and is not shown here.'],
87
+ tone: 'unknown'
88
+ }
89
+ ];
90
+ }
91
+ return review.metrics.flatMap(({ metric, groups }) =>
92
+ groups.map<SummaryLine>((summary) => {
93
+ const figures = [
94
+ summary.p50 === undefined ? undefined : `p50 ${formatLatency(summary.p50, metric.unit)}`,
95
+ summary.p95 === undefined ? undefined : `p95 ${formatLatency(summary.p95, metric.unit)}`
96
+ ].filter((figure): figure is string => figure !== undefined);
97
+ const qualifiers = [`${plural(summary.samples, 'observation', 'observations')}${summary.lowerBound ? ', including some that did not complete (lower bounds)' : ''}.`];
98
+ if (summary.budget !== undefined) {
99
+ qualifiers.push(`Budget ${formatLatency(summary.budget, metric.unit)}: ${plural(summary.breaches, 'observation', 'observations')} over it.`);
100
+ }
101
+ qualifiers.push(...summary.withheld, ...summary.gaps);
102
+ return {
103
+ headline: `${metric.name}${metric.boundary.label ? ` (${metric.boundary.label})` : ''}: ${figures.length ? figures.join(', ') : 'no percentile can be stated'}.`,
104
+ qualifiers,
105
+ tone: figures.length > 0 && summary.gaps.length === 0 ? 'figure' : 'weak'
106
+ };
107
+ })
108
+ );
109
+ }
110
+
111
+ /** Reliability and product latency of one check in a scope, worked out from its recorded history. */
112
+ export function scopedFigures(key: string, history: readonly TestResultEntry[], scope: SummaryScope) {
113
+ const entries = entriesInScope(history, scope);
114
+ return {
115
+ reliability: reliabilityLine(reliability(key, history, reliabilityScope(scope))),
116
+ latency: latencyLines(latencyReview(entries))
117
+ };
118
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @module Reading spec texts for search and indexes
3
+ *
4
+ * The finder and the project-wide behavior index both need the text of many specs, not only the one on screen. They
5
+ * read it here: a few files at a time, each landing as it arrives so a search can use what is read so far, through the
6
+ * same query cache the spec pages use (so a file is never fetched twice and a page opened afterwards finds it ready).
7
+ */
8
+ import { useQueryClient } from '@tanstack/react-query';
9
+ import { useEffect, useMemo, useRef, useState } from 'react';
10
+
11
+ import { keys, specKey, useAdapter } from '../data/queries';
12
+
13
+ /** How many spec files are read at once. */
14
+ const READERS = 4;
15
+
16
+ /**
17
+ * Reads the text of every spec in `files`, a few at a time, once `enabled`. Each file lands as it arrives, so a search
18
+ * can use what is read so far; `read` of `total` say how far it is, and `failed` how many could not be read at all
19
+ * (`texts` holds an empty text for them). Files the query cache already holds are available at once, without a request.
20
+ * Setting `open` (the finder opening) gives the files that could not be read another chance.
21
+ */
22
+ export function useSpecTexts(files: readonly string[], enabled: boolean, open: boolean) {
23
+ const adapter = useAdapter();
24
+ const client = useQueryClient();
25
+ const source = specKey(adapter);
26
+ const cache = useRef(new Map<string, string>());
27
+ const unreadable = useRef(new Set<string>());
28
+ const [texts, setTexts] = useState<ReadonlyMap<string, string>>(() => new Map());
29
+ const [pending, setPending] = useState(false);
30
+ const [failedFiles, setFailedFiles] = useState<ReadonlySet<string>>(() => new Set());
31
+ const key = files.join('\n');
32
+ // Opening the finder is another chance for the files that could not be read.
33
+ useEffect(() => {
34
+ if (!open || unreadable.current.size === 0) return;
35
+ for (const file of unreadable.current) cache.current.delete(file);
36
+ unreadable.current.clear();
37
+ setFailedFiles(new Set());
38
+ }, [open]);
39
+ useEffect(() => {
40
+ if (!enabled) return;
41
+ const wanted = key.length === 0 ? [] : key.split('\n');
42
+ let cancelled = false;
43
+ // What the pages already read is not asked for again.
44
+ let seeded = false;
45
+ for (const file of wanted) {
46
+ if (cache.current.has(file)) continue;
47
+ const held = client.getQueryData<string>(keys.specFile(source, file));
48
+ if (typeof held === 'string') {
49
+ cache.current.set(file, held);
50
+ seeded = true;
51
+ }
52
+ }
53
+ if (seeded) setTexts(new Map(cache.current));
54
+ const missing = wanted.filter((file) => !cache.current.has(file));
55
+ if (missing.length === 0) {
56
+ setPending(false);
57
+ return;
58
+ }
59
+ setPending(true);
60
+ let cursor = 0;
61
+ const pull = async () => {
62
+ for (;;) {
63
+ const file = missing[cursor++];
64
+ if (file === undefined || cancelled) return;
65
+ let text = '';
66
+ try {
67
+ text = await client.fetchQuery({ queryKey: keys.specFile(source, file), queryFn: () => adapter.specFile(file), retry: false });
68
+ } catch {
69
+ text = '';
70
+ unreadable.current.add(file);
71
+ }
72
+ if (cancelled) return;
73
+ cache.current.set(file, text);
74
+ setTexts(new Map(cache.current));
75
+ if (unreadable.current.has(file)) setFailedFiles(new Set(unreadable.current));
76
+ }
77
+ };
78
+ void Promise.all(Array.from({ length: READERS }, pull)).then(() => {
79
+ if (!cancelled) setPending(false);
80
+ });
81
+ return () => {
82
+ cancelled = true;
83
+ };
84
+ }, [adapter, client, enabled, key, source]);
85
+ const read = useMemo(() => files.filter((file) => texts.has(file)).length, [files, texts]);
86
+ const failed = useMemo(() => files.filter((file) => failedFiles.has(file)).length, [files, failedFiles]);
87
+ return { texts, pending, total: files.length, read, failed };
88
+ }
@@ -20,7 +20,7 @@ import { StatusMark } from '../evidence/marks';
20
20
  import { useArtifactList } from './catalog';
21
21
  import { FreezeCollection } from './collections';
22
22
  import { FilterBar } from './filters';
23
- import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
23
+ import { patched, useExplorerNavigate, useExplorerSearch, useWatchAddress } from './navigate';
24
24
  import { runName } from './parts';
25
25
  import { entriesFromArtifacts } from './playlist-entries';
26
26
  import { WatchPlaylist } from './playlist';
@@ -40,7 +40,7 @@ export function ArtifactsView() {
40
40
  const answer = useArtifactList(filter);
41
41
  const now = useNow();
42
42
  const [shown, setShown] = useState(ARTIFACT_ROWS);
43
- const [watching, setWatching] = useState(false);
43
+ const watch = useWatchAddress();
44
44
  const [freezing, setFreezing] = useState(false);
45
45
  const entries = useMemo(() => (answer.data && !answer.data.unsupported ? entriesFromArtifacts(answer.data.list.results) : []), [answer.data]);
46
46
  if (answer.isPending)
@@ -89,7 +89,7 @@ export function ArtifactsView() {
89
89
  )}
90
90
  {list.results.length > 0 && (
91
91
  <Group my="sm" gap="sm">
92
- <Button type="button" variant="filled" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => setWatching(true)}>
92
+ <Button type="button" variant="filled" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => watch.open()}>
93
93
  Watch these {plural(list.results.length, 'result', 'results')}
94
94
  </Button>
95
95
  {!staticEvidence && (
@@ -99,7 +99,7 @@ export function ArtifactsView() {
99
99
  )}
100
100
  </Group>
101
101
  )}
102
- {watching && <WatchPlaylist entries={entries} total={list.total} onClose={() => setWatching(false)} />}
102
+ {watch.watching && <WatchPlaylist entries={entries} total={list.total} initial={watch.clip} onSelect={watch.select} onClose={watch.close} />}
103
103
  {freezing && <FreezeCollection results={list.results} total={list.total} filterQuery={filterParams(filter).toString()} onClose={() => setFreezing(false)} />}
104
104
  {list.results.length > 0 && <RecordingTextSearch entries={entries} total={list.total} />}
105
105
  {list.results.length > 0 && (
@@ -198,7 +198,7 @@ export function Explorer() {
198
198
  {model && (model.tests.length > 0 || model.summary.runs > 0) && (
199
199
  <Tabs
200
200
  value={search.tab ?? 'tests'}
201
- onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' || value === 'collections' || value === 'compare' || value === 'health' ? value : undefined, test: undefined, openRun: undefined, layer: undefined, artifact: undefined, collection: undefined, rev: undefined, pair: undefined })}
201
+ onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' || value === 'collections' || value === 'compare' || value === 'health' ? value : undefined, test: undefined, openRun: undefined, layer: undefined, artifact: undefined, collection: undefined, rev: undefined, pair: undefined, watch: undefined, watchClip: undefined })}
202
202
  >
203
203
  <Tabs.List aria-label="Explorer view">
204
204
  <Tabs.Tab
@@ -1,4 +1,4 @@
1
- import { IconAlertTriangle, IconArrowLeft, IconPlayerPlay, IconVideo } from '@tabler/icons-react';
1
+ import { IconAlertTriangle, IconArrowLeft, IconCheck, 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
@@ -23,13 +23,14 @@ import { hasFilters, passesFilters, type ParsedSearch, type SearchFilters } from
23
23
  import type { FindDoc, Ranked } from '../find/rank';
24
24
  import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
25
25
  import { Meter, StatusMark } from '../evidence/marks';
26
- import { useRunDetail, useRunList, useTestCatalog } from './catalog';
26
+ import { useRecordingVerification, useRunDetail, useRunList, useTestCatalog } from './catalog';
27
+ import { allStoredVideosGone, verificationOfRun, type RunVerification } from './recording-verification';
27
28
  import { RunArtifacts } from './RunArtifacts';
28
29
  import { entriesFromRun } from './playlist-entries';
29
30
  import { WatchPlaylist } from './playlist';
30
31
  import { FilterBar } from './filters';
31
32
  import { SearchNotes, TypedFilters } from './search-notes';
32
- import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
33
+ import { patched, useExplorerNavigate, useExplorerSearch, useWatchAddress } from './navigate';
33
34
  import { runName } from './parts';
34
35
  import { filtered, filterOf, NO_FILTERS } from './search';
35
36
  const RUN_ROWS = 50;
@@ -126,9 +127,30 @@ function RunRecordings({ run }: { run: RunSummary }) {
126
127
  </Badge>
127
128
  );
128
129
  }
130
+ /**
131
+ * What the server's read-back says about a run's stored videos, apart from its test outcomes: some are gone or damaged
132
+ * (an orange mark that names the problems), or all were read back and are as published. Nothing when they were never
133
+ * read back: an unverified run is not shown as verified.
134
+ */
135
+ function StoredVideos({ verification }: { verification: RunVerification | null }) {
136
+ if (!verification) return null;
137
+ const at = `Read back from storage ${exactTime(verification.atMs)}. This checks the stored files, not playback in a browser. The test outcomes are unaffected.`;
138
+ if (verification.gaps === 0)
139
+ return (
140
+ <Text component="div" size="xs" c="dimmed" title={at} data-stored-videos="verified">
141
+ <IconCheck size={16} aria-hidden="true" /> {verification.checked > 0 ? `${plural(verification.ok, 'stored video', 'stored videos')} verified` : 'No stored videos to verify'}
142
+ </Text>
143
+ );
144
+ return (
145
+ <Text component="div" size="xs" c="orange.9" title={at} data-stored-videos="unavailable">
146
+ <IconAlertTriangle size={16} aria-hidden="true" /> Recording unavailable: {verification.gaps} of {plural(verification.checked, 'stored video', 'stored videos')} failed the read-back ({verification.problems.join(', ')}).
147
+ </Text>
148
+ );
149
+ }
129
150
  /** A run's videos in its row: a link to the recordings, how many references they come from, and what cannot be played. */
130
- function RunVideos({ run }: { run: RunSummary }) {
151
+ function RunVideos({ run, verification }: { run: RunSummary; verification: RunVerification | null }) {
131
152
  const videos = run.videos;
153
+ const gone = videos !== undefined && allStoredVideosGone(verification, videos.recordings);
132
154
  const files = run.attachments ? (
133
155
  <Anchor
134
156
  aria-label={`Open the ${plural(run.attachments, 'file', 'files')} of run ${runName(run.key)}`}
@@ -151,9 +173,9 @@ function RunVideos({ run }: { run: RunSummary }) {
151
173
  </Stack>
152
174
  );
153
175
  return (
154
- <Stack gap={0} data-video={videos.recordings > 0 ? 'available' : 'unavailable'}>
176
+ <Stack gap={0} data-video={videos.recordings > 0 && !gone ? 'available' : 'unavailable'}>
155
177
  {files}
156
- {videos.recordings > 0 && (
178
+ {videos.recordings > 0 && !gone && (
157
179
  <Anchor
158
180
  aria-label={`Watch ${plural(videos.recordings, 'recording', 'recordings')} in run ${runName(run.key)}`}
159
181
  renderRoot={(props) => (
@@ -169,6 +191,7 @@ function RunVideos({ run }: { run: RunSummary }) {
169
191
  </Text>
170
192
  )}
171
193
  <Unavailable videos={videos} />
194
+ <StoredVideos verification={verification} />
172
195
  </Stack>
173
196
  );
174
197
  }
@@ -258,6 +281,7 @@ function RunTable() {
258
281
  }, [search]);
259
282
  const narrow = filtered(search);
260
283
  const runs = useRunList(true, filter, searching);
284
+ const stored = useRecordingVerification();
261
285
  const now = useNow();
262
286
  const [shown, setShown] = useState(RUN_ROWS);
263
287
  const docs = useMemo(() => (runs.data?.runs ?? []).map((run) => runDoc(run, runLabel(run))), [runs.data]);
@@ -406,7 +430,7 @@ function RunTable() {
406
430
  </Table.Td>
407
431
  <Table.Td>{formatMs(run.spanMs)}</Table.Td>
408
432
  <Table.Td>
409
- <RunVideos run={run} />
433
+ <RunVideos run={run} verification={verificationOfRun(stored.data, run.key)} />
410
434
  <RunRecordings run={run} />
411
435
  </Table.Td>
412
436
  <Table.Td data-tags>{run.artifactTags?.map((tag) => `#${tag}`).join(' ') || '—'}</Table.Td>
@@ -461,6 +485,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
461
485
  const go = useExplorerNavigate();
462
486
  const video = search.video === true;
463
487
  const detail = useRunDetail(runKey);
488
+ const stored = useRecordingVerification();
464
489
  const now = useNow();
465
490
  const [status, setStatus] = useState('');
466
491
  const [shown, setShown] = useState(RESULT_ROWS);
@@ -496,7 +521,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
496
521
  ),
497
522
  [narrowedResults, status, statusFilters, model]
498
523
  );
499
- const [watching, setWatching] = useState(false);
524
+ const watch = useWatchAddress();
500
525
  const playlist = useMemo(() => entriesFromRun(filtered, detail.data?.layers), [filtered, detail.data]);
501
526
  const bySpec = useMemo(() => {
502
527
  const groups = new Map<string, RunResult[]>();
@@ -598,6 +623,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
598
623
  <Unavailable videos={run.videos} />
599
624
  </Text>
600
625
  )}
626
+ <StoredVideos verification={verificationOfRun(stored.data, run.key)} />
601
627
  <RunArtifacts detail={detail.data} />
602
628
  <Flex gap="xs" align="center" wrap="wrap">
603
629
  <Anchor
@@ -676,11 +702,11 @@ function RunDetailView({ runKey }: { runKey: string }) {
676
702
  </Text>
677
703
  )}
678
704
  {filtered.length > 0 && (
679
- <Button type="button" variant="filled" my="sm" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => setWatching(true)}>
705
+ <Button type="button" variant="filled" my="sm" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => watch.open()}>
680
706
  Watch these {plural(filtered.length, 'result', 'results')}
681
707
  </Button>
682
708
  )}
683
- {watching && <WatchPlaylist entries={playlist} onClose={() => setWatching(false)} />}
709
+ {watch.watching && <WatchPlaylist entries={playlist} initial={watch.clip} onSelect={watch.select} onClose={watch.close} />}
684
710
  {bySpec.map(([spec, list]) => (
685
711
  <Box key={spec} data-open aria-label={spec || 'No spec'} component="section" mb="lg">
686
712
  <Button data-static variant="default" fullWidth h="auto" py="sm" justify="flex-start">
@@ -44,7 +44,7 @@ import {
44
44
  type TestHistoryDocument,
45
45
  type TestSummary
46
46
  } from '@crvouga/atlas/reports/test-catalog';
47
- import { isStatechartFile } from '@crvouga/atlas/schema';
47
+ import { isStatechartFile, type RecordingVerification } from '@crvouga/atlas/schema';
48
48
  import { keepPreviousData, useQuery } from '@tanstack/react-query';
49
49
  import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
50
50
 
@@ -373,6 +373,29 @@ export function useRunDetail(key: string | undefined) {
373
373
  });
374
374
  }
375
375
 
376
+ /**
377
+ * What the server last read back of the stored videos, per layer (`GET /recordings/verification`; see
378
+ * `recording-verification.ts`). The question is only asked of a server, never reads a blob, and an older server (no route)
379
+ * or an unanswered request means nothing is known: `null`, which is never shown as verified.
380
+ */
381
+ export function useRecordingVerification() {
382
+ const catalog = useTestCatalog();
383
+ return useQuery({
384
+ queryKey: ['recording-verification', catalog.version],
385
+ queryFn: async ({ signal }): Promise<RecordingVerification | null> => {
386
+ try {
387
+ return await reportJson<RecordingVerification>('/recordings/verification', signal);
388
+ } catch (error) {
389
+ if (signal.aborted) throw error;
390
+ return null;
391
+ }
392
+ },
393
+ enabled: catalog.source === 'server',
394
+ staleTime: 30_000,
395
+ retry: false
396
+ });
397
+ }
398
+
376
399
  /** What the Catalog health tab reads: the diagnostics, or that this server is too old to give them. */
377
400
  export type HealthAnswer = { readonly unsupported: false; readonly health: CatalogHealth } | { readonly unsupported: true };
378
401
 
@@ -22,7 +22,7 @@ import { reportAssetUrl, reportJson, ReportsError } from '../evidence/api';
22
22
  import { exactTime, plural, shortSha, statusWord } from '../evidence/format';
23
23
  import { StatusMark } from '../evidence/marks';
24
24
  import { formatTimecode, parseTagsField, parseTimecode, sourceSpread } from './collection-model';
25
- import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
25
+ import { patched, useExplorerNavigate, useExplorerSearch, useWatchAddress } from './navigate';
26
26
  import { runName } from './parts';
27
27
  import { WatchPlaylist } from './playlist';
28
28
  import { entriesFromCollection, RECORDING_STATE_TEXT, recordingState } from './playlist-entries';
@@ -155,7 +155,7 @@ function CollectionPage({ id, revision }: { id: string; revision: number | undef
155
155
  const go = useExplorerNavigate();
156
156
  const client = useQueryClient();
157
157
  const [token, setToken] = usePublishToken();
158
- const [watching, setWatching] = useState<string | null | false>(false);
158
+ const watch = useWatchAddress();
159
159
  const detail = useQuery({
160
160
  queryKey: ['review-collection', id, revision ?? 'newest'],
161
161
  queryFn: ({ signal }) => reportJson<ReviewCollectionDetail>(`/review-collections/detail?id=${encodeURIComponent(id)}${revision ? `&revision=${revision}` : ''}`, signal),
@@ -235,7 +235,7 @@ function CollectionPage({ id, revision }: { id: string; revision: number | undef
235
235
  </Text>
236
236
  </Alert>
237
237
  <Group gap="sm" align="flex-end" wrap="wrap">
238
- <Button type="button" variant="filled" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => setWatching(null)} disabled={collection.entries.length === 0}>
238
+ <Button type="button" variant="filled" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => watch.open()} disabled={collection.entries.length === 0}>
239
239
  Watch these {plural(collection.entries.length, 'entry', 'entries')}
240
240
  </Button>
241
241
  <Button component="a" href={exportHref} download leftSection={<IconDownload size={16} aria-hidden="true" />}>
@@ -244,7 +244,7 @@ function CollectionPage({ id, revision }: { id: string; revision: number | undef
244
244
  <TextInput label="Share link" description="Opening it needs the same read access as this report." value={shareLink} readOnly onFocus={(event) => event.currentTarget.select()} miw={280} />
245
245
  </Group>
246
246
  <TokenField token={token} onChange={setToken} />
247
- {watching !== false && <WatchPlaylist title={collection.title} entries={playlist} initial={watching ?? undefined} onClose={() => setWatching(false)} />}
247
+ {watch.watching && <WatchPlaylist title={collection.title} entries={playlist} initial={watch.clip} onSelect={watch.select} onClose={watch.close} />}
248
248
  {notesOf(undefined).length > 0 && (
249
249
  <Stack gap="xs" component="section" aria-label="Notes on the collection">
250
250
  <Title order={3}>Notes on the collection</Title>
@@ -309,7 +309,7 @@ function CollectionPage({ id, revision }: { id: string; revision: number | undef
309
309
  {RECORDING_STATE_TEXT[state].label}
310
310
  </Text>
311
311
  {state === 'playable' && (
312
- <Button type="button" size="compact-sm" variant="light" leftSection={<IconPlayerPlay size={14} aria-hidden="true" />} onClick={() => setWatching(entry.id)}>
312
+ <Button type="button" size="compact-sm" variant="light" leftSection={<IconPlayerPlay size={14} aria-hidden="true" />} onClick={() => watch.open(entry.id)}>
313
313
  Watch entry {index + 1}
314
314
  </Button>
315
315
  )}
@@ -34,3 +34,20 @@ export function useExplorerNavigate() {
34
34
  export function patched(patch: ExplorerPatch) {
35
35
  return ((prev: Record<string, unknown>) => ({ ...prev, ...patch })) as never;
36
36
  }
37
+
38
+ /**
39
+ * The open watch playlist as the address holds it, so a link restores it: `watching` is true while the address says it is
40
+ * open and `clip` is the entry it is on. `open` pushes a history entry (Back closes it), `select` and `close` replace the
41
+ * current one, so stepping through clips does not fill the history. `open(clip)` starts on that entry.
42
+ */
43
+ export function useWatchAddress() {
44
+ const search = useExplorerSearch();
45
+ const go = useExplorerNavigate();
46
+ return {
47
+ watching: search.watch === true,
48
+ clip: search.watchClip,
49
+ open: (clip?: string) => go({ watch: true, watchClip: clip }),
50
+ select: (clip: string) => go({ watchClip: clip }, { replace: true }),
51
+ close: () => go({ watch: undefined, watchClip: undefined }, { replace: true })
52
+ };
53
+ }
@@ -1,7 +1,7 @@
1
1
  import type { VideoRef } from '@crvouga/atlas/reports/evidence-types';
2
2
  import { expect, test } from 'bun:test';
3
3
 
4
- import { entriesFromRun, playlistCounts, recordingState, stepFrom, type PlaylistEntry } from './playlist-entries';
4
+ import { clipHints, entriesFromRun, playlistCounts, recordingState, stepFrom, type PlaylistEntry } from './playlist-entries';
5
5
 
6
6
  const video = (recording?: string): VideoRef => ({ name: 'a.webm', owner: 'check', available: recording !== undefined, ...(recording ? { recording } : {}) });
7
7
  const entry = (id: string, extra: Partial<PlaylistEntry> = {}): PlaylistEntry => ({
@@ -49,3 +49,12 @@ test('a run result gets its backend from the layers of the run, and none when th
49
49
  expect(entriesFromRun([result], undefined)[0]!.backend).toBeNull();
50
50
  expect(recordingState(entriesFromRun([result], undefined)[0]!)).toBe('unplayable');
51
51
  });
52
+
53
+ test('clip hints are the poster and length the catalog already named for the clip that would play, and nothing otherwise', () => {
54
+ const hinted: VideoRef = { ...video('sha256:1'), poster: 'attachments/p', durationMs: 4_000 };
55
+ expect(clipHints(entry('a', { videos: [hinted] }))).toEqual({ poster: 'attachments/p', durationMs: 4_000 });
56
+ expect(clipHints(entry('a', { videos: [video('sha256:1')] }))).toEqual({});
57
+ // A reference that names no bytes is not the clip that would play.
58
+ expect(clipHints(entry('a', { videos: [{ ...video(), durationMs: 9 }] }))).toEqual({});
59
+ expect(clipHints(entry('a'))).toEqual({});
60
+ });
@@ -68,6 +68,16 @@ export function playableVideo(entry: Pick<PlaylistEntry, 'videos'>): VideoRef |
68
68
  return entry.videos.find((video) => video.available && video.recording !== undefined);
69
69
  }
70
70
 
71
+ /**
72
+ * What the catalog already says about the clip an entry would play, before anything is read or downloaded: the bundle key
73
+ * of its poster image and its declared length. Each is absent when the producer declared none (or the server predates the
74
+ * hints); they are hints to recognise a clip, never a reading of the recording and never a latency.
75
+ */
76
+ export function clipHints(entry: Pick<PlaylistEntry, 'videos'>): { readonly poster?: string; readonly durationMs?: number } {
77
+ const video = playableVideo(entry);
78
+ return { ...(video?.poster ? { poster: video.poster } : {}), ...(video?.durationMs ? { durationMs: video.durationMs } : {}) };
79
+ }
80
+
71
81
  /** The recorded results of the Artifacts tab as playlist entries, in their order. */
72
82
  export function entriesFromArtifacts(results: readonly ArtifactResult[]): PlaylistEntry[] {
73
83
  return results.map((result) => ({