@crvouga/atlas 1.17.0 → 1.19.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 (66) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/dist/reports/src/artifact-query.d.ts +14 -2
  3. package/dist/reports/src/artifact-query.js +13 -4
  4. package/dist/reports/src/evidence-detail.js +19 -6
  5. package/dist/reports/src/evidence-types.d.ts +8 -0
  6. package/dist/reports/src/recording-receipt.d.ts +23 -0
  7. package/dist/reports/src/recording-receipt.js +16 -0
  8. package/dist/reports/src/recording-verify.d.ts +74 -0
  9. package/dist/reports/src/recording-verify.js +142 -0
  10. package/dist/reports/src/report-cli.d.ts +2 -2
  11. package/dist/reports/src/report-cli.js +53 -3
  12. package/dist/reports/src/test-catalog.d.ts +7 -1
  13. package/dist/reports/src/test-catalog.js +10 -4
  14. package/dist/schema/src/api.d.ts +134 -0
  15. package/dist/schema/src/api.js +13 -2
  16. package/dist/schema/src/cli-results.d.ts +51 -1
  17. package/dist/schema/src/cli-results.js +25 -1
  18. package/dist/server/src/openapi.js +1 -0
  19. package/docs/agents.md +1 -0
  20. package/docs/reference/cli.md +11 -2
  21. package/docs/reference/http-api.md +2 -0
  22. package/docs/reference/reports.md +189 -9
  23. package/docs/reference/schema.md +19 -1
  24. package/docs/rich-evidence.md +33 -5
  25. package/docs/server.md +1 -1
  26. package/docs/ui.md +40 -1
  27. package/llms-full.txt +88 -9
  28. package/package.json +1 -1
  29. package/packages/schema/schemas/effective-delta.schema.json +26 -0
  30. package/packages/schema/schemas/effective-evidence.schema.json +26 -0
  31. package/packages/schema/schemas/evidence-page.schema.json +13 -0
  32. package/packages/schema/schemas/layer-rows.schema.json +13 -0
  33. package/packages/schema/schemas/report-run.schema.json +83 -0
  34. package/packages/schema/schemas/report-runs.schema.json +86 -0
  35. package/packages/schema/schemas/review-collection.schema.json +13 -0
  36. package/packages/schema/schemas/review-manifest.schema.json +13 -0
  37. package/packages/schema/schemas/test-catalog.schema.json +13 -0
  38. package/packages/schema/schemas/test-history.schema.json +26 -0
  39. package/packages/schema/schemas/verify-recordings-result.schema.json +97 -0
  40. package/packages/server/openapi.json +378 -0
  41. package/packages/visualizer/src/app/AppShell.tsx +2 -2
  42. package/packages/visualizer/src/app/router.tsx +25 -1
  43. package/packages/visualizer/src/components/Header.tsx +15 -4
  44. package/packages/visualizer/src/evidence/behavior-index.test.ts +119 -0
  45. package/packages/visualizer/src/evidence/behavior-index.ts +162 -0
  46. package/packages/visualizer/src/evidence/behavior-panel.tsx +36 -22
  47. package/packages/visualizer/src/evidence/behavior-return.test.ts +41 -0
  48. package/packages/visualizer/src/evidence/behavior-return.ts +59 -0
  49. package/packages/visualizer/src/evidence/behavior.ts +32 -1
  50. package/packages/visualizer/src/evidence/behaviors-page.tsx +465 -0
  51. package/packages/visualizer/src/evidence/feature-page.tsx +5 -26
  52. package/packages/visualizer/src/evidence/scoped-summary-view.tsx +110 -0
  53. package/packages/visualizer/src/evidence/scoped-summary.test.ts +62 -0
  54. package/packages/visualizer/src/evidence/scoped-summary.ts +118 -0
  55. package/packages/visualizer/src/evidence/spec-texts.ts +88 -0
  56. package/packages/visualizer/src/explorer/ArtifactsView.tsx +7 -6
  57. package/packages/visualizer/src/explorer/Explorer.tsx +1 -1
  58. package/packages/visualizer/src/explorer/RunsView.tsx +32 -7
  59. package/packages/visualizer/src/explorer/collections.tsx +5 -5
  60. package/packages/visualizer/src/explorer/filters.tsx +11 -3
  61. package/packages/visualizer/src/explorer/navigate.ts +17 -0
  62. package/packages/visualizer/src/explorer/playlist-entries.test.ts +10 -1
  63. package/packages/visualizer/src/explorer/playlist-entries.ts +10 -0
  64. package/packages/visualizer/src/explorer/playlist.tsx +43 -8
  65. package/packages/visualizer/src/explorer/search.ts +13 -3
  66. 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>
@@ -2,12 +2,12 @@
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
  *
9
9
  * The Runs and Artifacts tabs share the tests' `q`, `env`, `producer`, `domain` and `spec` and add `artifactTag`,
10
- * `type`, `label`, `branch`, `outcome`, `since` and `until`, so a link to one tab's filters opens the same query on another.
10
+ * `type`, `label`, `branch`, `outcome`, `since` and `until` (and the Runs tab alone `recordings`, the completeness of its recordings), so a link to one tab's filters opens the same query on another.
11
11
  */
12
12
  import { joinList, splitList, type ArtifactFilter } from '@crvouga/atlas/reports/artifact-query';
13
13
  import type { ReliabilityScope } from '@crvouga/atlas/reports/reliability';
@@ -55,6 +55,8 @@ export const ExplorerSearchFields = {
55
55
  branch: text,
56
56
  /** Outcomes of a run or recorded result (`passed`, `failed`…). */
57
57
  outcome: text,
58
+ /** Recording completeness of a run (`complete`, `incomplete`, `none-expected`, `unknown`): runs only, apart from the outcome. */
59
+ recordings: text,
58
60
  /** Only records from this day (`YYYY-MM-DD`, the viewer's local time). */
59
61
  since: text,
60
62
  /** Only records until the end of this day (`YYYY-MM-DD`, local time). */
@@ -107,6 +109,10 @@ export const ExplorerSearchFields = {
107
109
  relRevision: z.enum(['any']).optional().catch(undefined),
108
110
  /** The reliability's independent executions (its denominator) are open. */
109
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,
110
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. */
111
117
  inside: text,
112
118
  /** Only the runs, and the results of an opened run, that have a playable video recording. */
@@ -130,6 +136,7 @@ export type ExplorerSearch = {
130
136
  readonly label?: string;
131
137
  readonly branch?: string;
132
138
  readonly outcome?: string;
139
+ readonly recordings?: string;
133
140
  readonly since?: string;
134
141
  readonly until?: string;
135
142
  readonly inRun?: string;
@@ -163,6 +170,8 @@ export type ExplorerSearch = {
163
170
  readonly relOpen?: true;
164
171
  readonly inside?: string;
165
172
  readonly video?: true;
173
+ readonly watch?: true;
174
+ readonly watchClip?: string;
166
175
  };
167
176
 
168
177
  /** The values of a comma-separated filter, empty when it is not set. */
@@ -229,7 +238,7 @@ export function reliabilityScopeOf(search: ExplorerSearch): ReliabilityScope {
229
238
  }
230
239
 
231
240
  /** The filters of the Runs and Artifacts tabs, in the address. */
232
- export const FILTER_FIELDS = ['q', 'artifactTag', 'inherited', 'type', 'label', 'env', 'producer', 'branch', 'outcome', 'domain', 'spec', 'since', 'until', 'video'] as const;
241
+ export const FILTER_FIELDS = ['q', 'artifactTag', 'inherited', 'type', 'label', 'env', 'producer', 'branch', 'outcome', 'recordings', 'domain', 'spec', 'since', 'until', 'video'] as const;
233
242
 
234
243
  /** A patch that removes every filter of the Runs and Artifacts tabs. */
235
244
  export const NO_FILTERS = Object.fromEntries(FILTER_FIELDS.map((field) => [field, undefined])) as { [K in (typeof FILTER_FIELDS)[number]]?: undefined };
@@ -263,6 +272,7 @@ export function filterOf(search: ExplorerSearch): ArtifactFilter {
263
272
  producer: list(search.producer),
264
273
  branch: list(search.branch),
265
274
  outcome: list(search.outcome),
275
+ recordings: list(search.recordings),
266
276
  domain: list(search.domain),
267
277
  spec: list(search.spec),
268
278
  since,
@@ -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();