@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
@@ -7,7 +7,8 @@
7
7
  *
8
8
  * Only the clip on screen is read: the list is made of the metadata the catalog already sent, the current result's
9
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
10
+ * large library downloads no recordings in bulk. A row shows a poster (lazily loaded) and a length only when the catalog
11
+ * already carried them for its clip; none is read to find out. There is one `<video>`; selecting another clip replaces it and keeps
11
12
  * the position of the one left, so returning resumes there. A clip that will not load (or whose autoplay the browser
12
13
  * blocked) stays in the list and offers Retry, Play and Next. Recordings are always labelled illustrative: playback
13
14
  * length and speed are those of the recording, never production latency.
@@ -16,7 +17,7 @@ import { attachmentIdentity, isVideoAttachment, type CheckDetail } from '@crvoug
16
17
  import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
17
18
  import { contentTypeOf } from '@crvouga/atlas/reports/media-types';
18
19
  import type { Attachment, Step } from '@crvouga/atlas/schema';
19
- import { Alert, Anchor, Badge, Box, Button, Code, Flex, Grid, Group, List, Modal, NativeSelect, Stack, Switch, Text, Title } from '@mantine/core';
20
+ import { Alert, Anchor, Badge, Box, Button, Code, Flex, Grid, Group, Image, List, Modal, NativeSelect, Stack, Switch, Text, Title } from '@mantine/core';
20
21
  import { IconAlertTriangle, IconPlayerPlay, IconPlayerSkipBack, IconPlayerSkipForward } from '@tabler/icons-react';
21
22
  import { useQuery } from '@tanstack/react-query';
22
23
  import { Link } from '@tanstack/react-router';
@@ -31,7 +32,7 @@ import { duration, exactTime, plural, statusWord } from '../evidence/format';
31
32
  import { StatusMark } from '../evidence/marks';
32
33
  import { patched } from './navigate';
33
34
  import { clock, TEXT_KIND_WORD, type RecordingFocus } from './recording-text';
34
- import { playableVideo, playlistCounts, RECORDING_STATE_TEXT, recordingState, stepFrom, type PlaylistEntry } from './playlist-entries';
35
+ import { clipHints, playableVideo, playlistCounts, RECORDING_STATE_TEXT, recordingState, stepFrom, type PlaylistEntry } from './playlist-entries';
35
36
 
36
37
  /** Entries listed before "more"; the selected entry is always listed. */
37
38
  const LISTED = 50;
@@ -70,6 +71,14 @@ export function useEntryDetail(entry: PlaylistEntry, enabled: boolean) {
70
71
  });
71
72
  }
72
73
 
74
+ /** The poster the catalog named for a clip, loaded only when its row is near the screen (`loading="lazy"`); a poster that will not load is left out. */
75
+ function Poster({ entry, bundleKey }: { entry: PlaylistEntry; bundleKey: string }) {
76
+ const [broken, setBroken] = useState(false);
77
+ const url = attachmentSource({ name: 'poster', bundleKey }, entry.layer).url;
78
+ if (!url || broken) return null;
79
+ return <Image src={url} alt="" loading="lazy" decoding="async" w={80} fit="contain" radius="sm" data-poster onError={() => setBroken(true)} />;
80
+ }
81
+
73
82
  /** One step of the scenario: in words, with a control to show it in the recording and a mark while it is playing. */
74
83
  function ScenarioStep({ step }: { step: Step }) {
75
84
  const playing = useIsPlaying(step);
@@ -269,7 +278,7 @@ function Clip({
269
278
  const developer = (
270
279
  <Anchor
271
280
  renderRoot={(props) => (
272
- <Link {...props} to="/" search={patched({ tab: undefined, openRun: undefined, artifact: undefined, test: entry.key, layer: entry.layer })} resetScroll={false} activeOptions={{ exact: true }} onClick={onClose} />
281
+ <Link {...props} to="/" search={patched({ tab: undefined, openRun: undefined, artifact: undefined, watch: undefined, watchClip: undefined, test: entry.key, layer: entry.layer })} resetScroll={false} activeOptions={{ exact: true }} onClick={onClose} />
273
282
  )}
274
283
  >
275
284
  Developer details
@@ -412,7 +421,7 @@ function Clip({
412
421
  steps={steps}
413
422
  attachments={placedIn}
414
423
  layer={entry.layer}
415
- linkSearch={{ test: entry.key, layer: entry.layer, tab: undefined, openRun: undefined, artifact: undefined }}
424
+ linkSearch={{ test: entry.key, layer: entry.layer, tab: undefined, openRun: undefined, artifact: undefined, watch: undefined, watchClip: undefined }}
416
425
  >
417
426
  <Stack gap="md" miw={0} aria-label={`Now showing ${entry.title}`} component="section" data-clip={entry.id}>
418
427
  {body}
@@ -432,9 +441,26 @@ function Clip({
432
441
  * The watch session for `entries`: opens as a modal over the explorer. `total` is how many results matched when the list
433
442
  * holds only the newest of them, so the session says it is not the whole set. `focus` opens it on one found moment: that
434
443
  * result, that attempt's video and, when the time is usable, that time. `initial` is the entry to start on when there is no
435
- * `focus`, and `title` the heading. Closing it discards positions.
444
+ * `focus`, and `title` the heading. `onSelect` is told the id of the entry on screen when the session opens and each time it
445
+ * changes, so the address can name it. Closing it discards positions.
436
446
  */
437
- export function WatchPlaylist({ entries, total, focus, onClose, initial, title = 'Watch recordings' }: { entries: readonly PlaylistEntry[]; total?: number; focus?: RecordingFocus; onClose: () => void; initial?: string; title?: string }) {
447
+ export function WatchPlaylist({
448
+ entries,
449
+ total,
450
+ focus,
451
+ onClose,
452
+ onSelect,
453
+ initial,
454
+ title = 'Watch recordings'
455
+ }: {
456
+ entries: readonly PlaylistEntry[];
457
+ total?: number;
458
+ focus?: RecordingFocus;
459
+ onClose: () => void;
460
+ onSelect?: (id: string) => void;
461
+ initial?: string;
462
+ title?: string;
463
+ }) {
438
464
  const first = entries.find((entry) => entry.id === initial) ?? entries.find((entry) => recordingState(entry) === 'playable') ?? entries[0];
439
465
  const [chosen, setChosen] = useState<string | null>(focus && entries.some((entry) => entry.id === focus.entryId) ? focus.entryId : (first?.id ?? null));
440
466
  // Selecting anything else leaves the found moment behind: it describes one video and one time.
@@ -449,6 +475,12 @@ export function WatchPlaylist({ entries, total, focus, onClose, initial, title =
449
475
  const active = entries.find((entry) => entry.id === chosen) ?? first ?? null;
450
476
  const counts = useMemo(() => playlistCounts(entries), [entries]);
451
477
  const activeAt = active ? entries.indexOf(active) : -1;
478
+ const activeId = active?.id;
479
+ const selected = useRef(onSelect);
480
+ selected.current = onSelect;
481
+ useEffect(() => {
482
+ if (activeId !== undefined && !focus) selected.current?.(activeId);
483
+ }, [activeId, focus]);
452
484
  const next = stepFrom(entries, active?.id ?? null, 1, repeat);
453
485
  const previous = stepFrom(entries, active?.id ?? null, -1, repeat);
454
486
  const select = (entry: PlaylistEntry, play: boolean) => {
@@ -524,6 +556,7 @@ export function WatchPlaylist({ entries, total, focus, onClose, initial, title =
524
556
  {listed.map((entry, index) => {
525
557
  const state = recordingState(entry);
526
558
  const here = entry.id === active?.id;
559
+ const hints = clipHints(entry);
527
560
  return (
528
561
  <List.Item key={entry.id} data-entry={entry.id} data-status={entry.status} data-recording-state={state}>
529
562
  <Button
@@ -534,11 +567,12 @@ export function WatchPlaylist({ entries, total, focus, onClose, initial, title =
534
567
  py="xs"
535
568
  justify="flex-start"
536
569
  aria-current={here ? 'true' : undefined}
537
- aria-label={`${index + 1}. ${entry.title}, ${statusWord(entry.status)}, ${failedIds.has(entry.id) ? 'could not be loaded' : RECORDING_STATE_TEXT[state].label}`}
570
+ aria-label={`${index + 1}. ${entry.title}, ${statusWord(entry.status)}, ${failedIds.has(entry.id) ? 'could not be loaded' : RECORDING_STATE_TEXT[state].label}${hints.durationMs ? `, ${duration(hints.durationMs)} long` : ''}`}
538
571
  onClick={() => select(entry, state === 'playable')}
539
572
  >
540
573
  <Flex component="span" gap="xs" wrap="nowrap" align="flex-start">
541
574
  <StatusMark status={entry.status} size={14} />
575
+ {hints.poster && <Poster entry={entry} bundleKey={hints.poster} />}
542
576
  <Flex component="span" direction="column" align="flex-start">
543
577
  <Text span inherit ta="left">
544
578
  {entry.title}
@@ -546,6 +580,7 @@ export function WatchPlaylist({ entries, total, focus, onClose, initial, title =
546
580
  <Text span inherit c="dimmed" size="xs" ta="left">
547
581
  {statusWord(entry.status)} · {environmentLabel(entry.environment)} ·{' '}
548
582
  {failedIds.has(entry.id) ? 'Could not be loaded' : RECORDING_STATE_TEXT[state].label}
583
+ {hints.durationMs ? ` · ${duration(hints.durationMs)} recording` : ''}
549
584
  </Text>
550
585
  </Flex>
551
586
  </Flex>
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @module Recording verification of a run
3
+ *
4
+ * What the server's read-back of stored videos says about one run, for the Runs tab: how many of its stored videos were
5
+ * read back, how many passed, and which problems the others have. It is evidence about storage, kept apart from the run's
6
+ * test outcomes: it never changes how a run is counted, and "never read back" is `null`, not "verified".
7
+ */
8
+ import type { RecordingVerification } from '@crvouga/atlas/schema';
9
+
10
+ /** The words for each way a stored recording can be wrong; an unknown problem is still a gap and is named as it came. */
11
+ const PROBLEMS: Readonly<Record<string, string>> = {
12
+ 'blob-missing': 'file gone from storage',
13
+ 'not-in-bundle': 'file missing from the run’s bundle',
14
+ unreadable: 'storage could not be read',
15
+ 'size-mismatch': 'size differs from what was published',
16
+ 'checksum-mismatch': 'content differs from what was published',
17
+ undecodable: 'not the video format it claims to be'
18
+ };
19
+
20
+ /** What was read back of one run's stored recordings. */
21
+ export type RunVerification = {
22
+ /** Videos read back: those that name a stored file. */
23
+ readonly checked: number;
24
+ readonly ok: number;
25
+ /** Each problem in words, with how many videos have it, most common first. */
26
+ readonly problems: readonly string[];
27
+ readonly gaps: number;
28
+ /** When the server last read the run's layers back, epoch milliseconds (the oldest of them). */
29
+ readonly atMs: number;
30
+ };
31
+
32
+ /**
33
+ * What the read-back found for the run with this key, or `null` when none of its layers was read back (the page then says
34
+ * nothing about its stored files). A run is as unavailable as its worst layer: gaps add up across its layers.
35
+ */
36
+ export function verificationOfRun(document: RecordingVerification | null | undefined, runKey: string): RunVerification | null {
37
+ const layers = document?.layers.filter((layer) => layer.run === runKey) ?? [];
38
+ if (!layers.length) return null;
39
+ const counts = new Map<string, number>();
40
+ let checked = 0, ok = 0, gaps = 0, atMs = Infinity;
41
+ for (const layer of layers) {
42
+ checked += layer.checked;
43
+ ok += layer.ok;
44
+ atMs = Math.min(atMs, layer.verifiedAtMs);
45
+ for (const gap of layer.gaps) {
46
+ gaps += 1;
47
+ const word = PROBLEMS[gap.problem] ?? gap.problem;
48
+ counts.set(word, (counts.get(word) ?? 0) + 1);
49
+ }
50
+ }
51
+ const problems = [...counts].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).map(([word, count]) => `${count} ${word}`);
52
+ return { checked, ok, problems, gaps, atMs };
53
+ }
54
+
55
+ /** Whether a run's stored videos were read back and none of them can be played: its Watch action would lead nowhere. */
56
+ export function allStoredVideosGone(verification: RunVerification | null, playable: number): boolean {
57
+ return verification !== null && verification.gaps > 0 && verification.ok === 0 && verification.checked >= playable;
58
+ }
@@ -2,7 +2,7 @@
2
2
  * @module Explorer address
3
3
  *
4
4
  * Everything the test explorer shows is in the address of the `/` route, so a link reproduces the view: the open test,
5
- * the words searched, the filters, the grouping, the order, the Runs tab with the run opened in it, the Compare tab with its two pinned runs (`base`, `cand`) and the behavior opened (`pair`), the Catalog health tab with the root mappings it tries (`root`), the exact layer a test's detail is pinned to, the recording and position of a shared moment in it (`clip`, `clipAt`), and the one file of a run's layer its Artifacts select. Filters that
5
+ * the words searched, the filters, the grouping, the order, the Runs tab with the run opened in it, the Compare tab with its two pinned runs (`base`, `cand`) and the behavior opened (`pair`), the Catalog health tab with the root mappings it tries (`root`), the exact layer a test's detail is pinned to, the recording and position of a shared moment in it (`clip`, `clipAt`), the watch playlist that is open over the list or run and the clip it is on (`watch`, `watchClip`), and the one file of a run's layer its Artifacts select. Filters that
6
6
  * take several values are comma-separated (`status=failed,flaky`); a comma or backslash inside a value is written `\,`
7
7
  * or `\\`, and the router encodes `#` and spaces. Anything unreadable is dropped, never an error.
8
8
  *
@@ -109,6 +109,10 @@ export const ExplorerSearchFields = {
109
109
  relRevision: z.enum(['any']).optional().catch(undefined),
110
110
  /** The reliability's independent executions (its denominator) are open. */
111
111
  relOpen: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined),
112
+ /** The watch playlist is open over the list, opened run or collection the rest of the address names. A link with it restores the playlist; without it the page is as it was before. */
113
+ watch: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined),
114
+ /** With `watch`: the entry of the playlist that is playing (its id, which names the result and the layer that recorded it). Unknown or absent: the first one that can be watched. */
115
+ watchClip: text,
112
116
  /** Words to find inside the recordings of the Artifacts tab (what was said or shown, from the text producers supplied). Not a filter of the list. */
113
117
  inside: text,
114
118
  /** Only the runs, and the results of an opened run, that have a playable video recording. */
@@ -166,6 +170,8 @@ export type ExplorerSearch = {
166
170
  readonly relOpen?: true;
167
171
  readonly inside?: string;
168
172
  readonly video?: true;
173
+ readonly watch?: true;
174
+ readonly watchClip?: string;
169
175
  };
170
176
 
171
177
  /** The values of a comma-separated filter, empty when it is not set. */
@@ -1,14 +1,13 @@
1
1
  import { Badge, Button, Group, Kbd, List, Modal, Stack, Text } from '@mantine/core';
2
2
  import { Spotlight, useSpotlight } from '@mantine/spotlight';
3
- import { useQueryClient } from '@tanstack/react-query';
4
3
  import { useNavigate, useSearch } from '@tanstack/react-router';
5
4
  import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
6
5
  import { useAtlas } from '../app/atlas-context';
7
6
  import { useTestCatalog } from '../explorer/catalog';
8
7
  import { SearchNotes } from '../explorer/search-notes';
9
- import { keys, specKey, useAdapter } from '../data/queries';
10
8
  import { useEvidence } from '../evidence/evidence-context';
11
9
  import { plural } from '../evidence/format';
10
+ import { useSpecTexts } from '../evidence/spec-texts';
12
11
  import { StatusMark } from '../evidence/marks';
13
12
  import { kindOfFile } from '../spec-kind';
14
13
  import { describeHit } from './behaviors';
@@ -51,66 +50,6 @@ function isTypingTarget(target: EventTarget | null) {
51
50
  const tag = target.tagName;
52
51
  return target.isContentEditable || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'BUTTON' || tag === 'A';
53
52
  }
54
- /**
55
- * Reads the text of every spec for search, a few at a time, once `enabled`. Each file lands as it arrives, so a search
56
- * 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.
57
- */
58
- function useSpecTexts(files: readonly string[], enabled: boolean, open: boolean) {
59
- const adapter = useAdapter();
60
- const client = useQueryClient();
61
- const source = specKey(adapter);
62
- const cache = useRef(new Map<string, string>());
63
- const unreadable = useRef(new Set<string>());
64
- const [texts, setTexts] = useState<ReadonlyMap<string, string>>(() => new Map());
65
- const [pending, setPending] = useState(false);
66
- const [failedFiles, setFailedFiles] = useState<ReadonlySet<string>>(() => new Set());
67
- const key = files.join('\n');
68
- // Opening the finder is another chance for the files that could not be read.
69
- useEffect(() => {
70
- if (!open || unreadable.current.size === 0) return;
71
- for (const file of unreadable.current) cache.current.delete(file);
72
- unreadable.current.clear();
73
- setFailedFiles(new Set());
74
- }, [open]);
75
- useEffect(() => {
76
- if (!enabled) return;
77
- const wanted = key.length === 0 ? [] : key.split('\n');
78
- let cancelled = false;
79
- const missing = wanted.filter((file) => !cache.current.has(file));
80
- if (missing.length === 0) {
81
- setPending(false);
82
- return;
83
- }
84
- setPending(true);
85
- let cursor = 0;
86
- const pull = async () => {
87
- for (;;) {
88
- const file = missing[cursor++];
89
- if (file === undefined || cancelled) return;
90
- let text = '';
91
- try {
92
- text = await client.fetchQuery({ queryKey: keys.specFile(source, file), queryFn: () => adapter.specFile(file), retry: false });
93
- } catch {
94
- text = '';
95
- unreadable.current.add(file);
96
- }
97
- if (cancelled) return;
98
- cache.current.set(file, text);
99
- setTexts(new Map(cache.current));
100
- if (unreadable.current.has(file)) setFailedFiles(new Set(unreadable.current));
101
- }
102
- };
103
- void Promise.all([pull(), pull(), pull(), pull()]).then(() => {
104
- if (!cancelled) setPending(false);
105
- });
106
- return () => {
107
- cancelled = true;
108
- };
109
- }, [adapter, client, enabled, key, source]);
110
- const read = useMemo(() => files.filter((file) => texts.has(file)).length, [files, texts]);
111
- const failed = useMemo(() => files.filter((file) => failedFiles.has(file)).length, [files, failedFiles]);
112
- return { texts, pending, total: files.length, read, failed };
113
- }
114
53
  export function Finder() {
115
54
  const open = useFinderOpen();
116
55
  const help = useHelpOpen();