@crvouga/atlas 0.12.0 → 0.13.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,5 +1,11 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
+ ## 0.13.0 (2026-10-28)
4
+
5
+ ### Features
6
+
7
+ - keep the selected historical run when opening test evidence ([4ddad5f](https://github.com/crvouga/atlas/commit/4ddad5fd5405acf15366f0925bcca72b47be563f))
8
+
3
9
  ## 0.12.0 (2026-10-28)
4
10
 
5
11
  ### Features
package/docs/ui.md CHANGED
@@ -119,7 +119,7 @@ The narrowly scoped exceptions are:
119
119
  | --- | --- | --- |
120
120
  | Shared shell | Project switching, spec navigation, run selection, connection status, diagnostics, mobile index | AppShell, Burger, NavLink, Menu, Popover, NativeSelect, Drawer |
121
121
  | Project home | Tests/runs tabs, metrics, search, status/environment/domain filters, grouping, sorting, show more | Container, Tabs, SimpleGrid, Paper, TextInput, Button, Accordion |
122
- | Test detail | Current outcomes, statistics, duration graph, history, artifacts, recording | Drawer, Table, Badge, Alert, Stage |
122
+ | Test detail | Current outcomes, statistics, duration graph, 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 |
123
123
  | Specs | All six existing kinds, overview/index, Gherkin rules/scenarios/examples, Quint declarations/traces, DMN tables, source text | SpecHeader, Accordion, Table, CodeHighlightTabs, ResultRow |
124
124
  | Statecharts | Graph, screen/event/journey details, developer metadata, chart test results | Mantine cards/navigation and product compositions; graph exception above |
125
125
  | Evidence | Steps, retries, annotations, facts, unknown extensions, safe downloads and previews | List, Badge, Accordion, Code, Image, native media |
package/llms-full.txt CHANGED
@@ -3789,7 +3789,7 @@ The narrowly scoped exceptions are:
3789
3789
  | --- | --- | --- |
3790
3790
  | Shared shell | Project switching, spec navigation, run selection, connection status, diagnostics, mobile index | AppShell, Burger, NavLink, Menu, Popover, NativeSelect, Drawer |
3791
3791
  | Project home | Tests/runs tabs, metrics, search, status/environment/domain filters, grouping, sorting, show more | Container, Tabs, SimpleGrid, Paper, TextInput, Button, Accordion |
3792
- | Test detail | Current outcomes, statistics, duration graph, history, artifacts, recording | Drawer, Table, Badge, Alert, Stage |
3792
+ | Test detail | Current outcomes, statistics, duration graph, 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 |
3793
3793
  | Specs | All six existing kinds, overview/index, Gherkin rules/scenarios/examples, Quint declarations/traces, DMN tables, source text | SpecHeader, Accordion, Table, CodeHighlightTabs, ResultRow |
3794
3794
  | Statecharts | Graph, screen/event/journey details, developer metadata, chart test results | Mantine cards/navigation and product compositions; graph exception above |
3795
3795
  | Evidence | Steps, retries, annotations, facts, unknown extensions, safe downloads and previews | List, Badge, Accordion, Code, Image, native media |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crvouga/atlas",
3
- "version": "0.12.0",
3
+ "version": "0.13.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",
@@ -268,6 +268,10 @@ type Props = {
268
268
  reads: 'steps' | 'trace';
269
269
  /** The check as written, shown until a recording is read or when there is none. */
270
270
  written?: ReactNode;
271
+ /** An exact earlier result the caller found (for example on a server that only sends winners). It wins over `layerId`. */
272
+ pinned?: HistoryEntry;
273
+ /** Choosing a run in the history always names its layer, even when that result is a current one, so the choice stays pinned. */
274
+ pin?: boolean;
271
275
  };
272
276
  /** Everything recorded for one check: the chosen result, how it went, and every other run of it. */
273
277
  export function CheckDetail(props: Props) {
@@ -298,13 +302,13 @@ export function CheckDetail(props: Props) {
298
302
  </>
299
303
  );
300
304
  }
301
- function RecordedCheckDetail({ slot, layerId, select, reads, written }: Props) {
305
+ function RecordedCheckDetail({ slot, layerId, select, reads, written, pinned, pin }: Props) {
302
306
  const { evidence, backends } = useEvidence();
303
307
  const now = useNow();
304
308
  const [variant, setVariant] = useState(0);
305
309
  const history = useMemo(() => checkHistory(evidence, slot.specPath, slot.id), [evidence, slot.specPath, slot.id]);
306
310
  const winner = slot.outcomes[Math.min(variant, slot.outcomes.length - 1)]!;
307
- const earlier = layerId ? history.find((entry) => entry.layer.id === layerId) : undefined;
311
+ const earlier = pinned ?? (layerId ? history.find((entry) => entry.layer.id === layerId) : undefined);
308
312
  const chosen: HistoryEntry = earlier ?? { layer: winner.layer, row: winner.row };
309
313
  const superseded = !slot.outcomes.some((outcome) => outcome.layer.id === chosen.layer.id);
310
314
  const { paths, report } = useRecordedReport(chosen);
@@ -397,7 +401,7 @@ function RecordedCheckDetail({ slot, layerId, select, reads, written }: Props) {
397
401
  {moments > 0 && (
398
402
  <Button
399
403
  type="button"
400
- onClick={() => select({ view: 'recording', check: slot.id, layer: earlier ? chosen.layer.id : null })}
404
+ onClick={() => select({ view: 'recording', check: slot.id, layer: earlier || variant > 0 ? chosen.layer.id : null })}
401
405
  variant="filled"
402
406
  >
403
407
  Open recording{' '}
@@ -428,7 +432,9 @@ function RecordedCheckDetail({ slot, layerId, select, reads, written }: Props) {
428
432
  type="button"
429
433
  aria-current={current || undefined}
430
434
  onClick={() =>
431
- select({ layer: slot.outcomes.some((outcome) => outcome.layer.id === entry.layer.id) ? null : entry.layer.id })
435
+ select({
436
+ layer: !pin && slot.outcomes.some((outcome) => outcome.layer.id === entry.layer.id) ? null : entry.layer.id
437
+ })
432
438
  }
433
439
  variant="light"
434
440
  >
@@ -15,6 +15,8 @@ type Props = {
15
15
  /** The check whose recording is open, once the combined report holds it. */
16
16
  slot: CheckSlot | undefined;
17
17
  layerId: string | undefined;
18
+ /** The exact result to play, when the caller already found it (it wins over `layerId` and the combined winner). */
19
+ entry?: HistoryEntry | undefined;
18
20
  label: string;
19
21
  back: string;
20
22
  moment: number | null;
@@ -36,14 +38,15 @@ function needsStylesheet(report: RunReport | undefined): boolean {
36
38
  return visit(report.steps) || (report.artifacts ?? []).some((artifact) => artifact.media === 'html');
37
39
  }
38
40
  /** One recorded test, opened in place of its spec. */
39
- export function RecordingPage({ slot, layerId, label, back, moment, onMoment, onClose }: Props) {
41
+ export function RecordingPage({ slot, layerId, entry, label, back, moment, onMoment, onClose }: Props) {
40
42
  const progress = useTestProgress(slot ?? null);
41
43
  const { evidence, backends, pending, ready, unavailable } = useEvidence();
42
44
  const now = useNow();
43
45
  const history = useMemo(() => (slot ? checkHistory(evidence, slot.specPath, slot.id) : []), [evidence, slot]);
44
46
  const winner = slot?.outcomes[0];
45
47
  const chosen: HistoryEntry | null =
46
- (layerId ? history.find((entry) => entry.layer.id === layerId) : undefined) ??
48
+ entry ??
49
+ (layerId ? history.find((earlier) => earlier.layer.id === layerId) : undefined) ??
47
50
  (winner ? { layer: winner.layer, row: winner.row } : null);
48
51
  const { paths, report } = useRecordedReport(chosen);
49
52
  const wantsCss = needsStylesheet(report.data);
@@ -143,7 +143,7 @@ export function Explorer() {
143
143
  {model && model.tests.length > 0 && (
144
144
  <Tabs
145
145
  value={search.tab ?? 'tests'}
146
- onChange={(value) => go({ tab: value === 'runs' ? 'runs' : undefined, test: undefined, openRun: undefined })}
146
+ onChange={(value) => go({ tab: value === 'runs' ? 'runs' : undefined, test: undefined, openRun: undefined, layer: undefined })}
147
147
  >
148
148
  <Tabs.List aria-label="Explorer view">
149
149
  <Tabs.Tab
@@ -247,7 +247,7 @@ export function Explorer() {
247
247
  </>
248
248
  ))}
249
249
  </Box>
250
- <Drawer opened={split} onClose={() => go({ test: undefined })} title="Test detail" size="xl">
250
+ <Drawer opened={split} onClose={() => go({ test: undefined, layer: undefined })} title="Test detail" size="xl">
251
251
  <Box aria-label="Test detail" component="aside">
252
252
  {selected ? (
253
253
  <TestDetail test={selected} slot={slots.get(selected.key)} />
@@ -258,7 +258,7 @@ export function Explorer() {
258
258
  </Alert>
259
259
  <Anchor
260
260
  renderRoot={(props) => (
261
- <Link {...props} to="/" search={patched({ test: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
261
+ <Link {...props} to="/" search={patched({ test: undefined, layer: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
262
262
  )}
263
263
  >
264
264
  Close
@@ -358,7 +358,7 @@ function InsightStrip({ model, scope, search }: { model: CatalogModel; scope: re
358
358
  <Link
359
359
  {...props}
360
360
  to="/"
361
- search={patched({ tab: 'runs', test: undefined })}
361
+ search={patched({ tab: 'runs', test: undefined, layer: undefined })}
362
362
  resetScroll={false}
363
363
  activeOptions={{ exact: true }}
364
364
  />
@@ -443,7 +443,7 @@ function MoreInsights({ scope }: { scope: readonly TestSummary[] }) {
443
443
  <List.Item key={test.key}>
444
444
  <Anchor
445
445
  renderRoot={(props) => (
446
- <Link {...props} to="/" search={patched({ test: test.key })} resetScroll={false} activeOptions={{ exact: true }} />
446
+ <Link {...props} to="/" search={patched({ test: test.key, layer: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
447
447
  )}
448
448
  >
449
449
  <StatusMark status={test.status} size={12} />
@@ -819,7 +819,7 @@ function TestRow({ test, selected, now }: { test: TestSummary; selected: boolean
819
819
  <Link
820
820
  {...props}
821
821
  to="/"
822
- search={patched({ test: selected ? undefined : test.key })}
822
+ search={patched({ test: selected ? undefined : test.key, layer: undefined })}
823
823
  resetScroll={false}
824
824
  activeOptions={{ exact: true }}
825
825
  />
@@ -262,7 +262,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
262
262
  <Link
263
263
  {...props}
264
264
  to="/"
265
- search={patched({ tab: undefined, openRun: undefined, inRun: run.key, test: undefined })}
265
+ search={patched({ tab: undefined, openRun: undefined, inRun: run.key, test: undefined, layer: undefined })}
266
266
  resetScroll={false}
267
267
  activeOptions={{ exact: true }}
268
268
  />
@@ -328,7 +328,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
328
328
  <Link
329
329
  {...props}
330
330
  to="/"
331
- search={patched({ tab: undefined, openRun: undefined, test: result.key })}
331
+ search={patched({ tab: undefined, openRun: undefined, test: result.key, layer: result.layer })}
332
332
  resetScroll={false}
333
333
  activeOptions={{ exact: true }}
334
334
  />
@@ -1,5 +1,5 @@
1
1
  import { IconX, IconCircleFilled } from '@tabler/icons-react';
2
- import { Alert, Anchor, Box, Button, Code, Flex, SimpleGrid, Stack, Table, Text, Title, VisuallyHidden } from '@mantine/core';
2
+ import { Alert, Anchor, Badge, Box, Button, Code, Flex, SimpleGrid, Stack, Table, Text, Title, VisuallyHidden } from '@mantine/core';
3
3
  /**
4
4
  * @module Test detail
5
5
  *
@@ -10,27 +10,127 @@ import { Alert, Anchor, Box, Button, Code, Flex, SimpleGrid, Stack, Table, Text,
10
10
  */
11
11
  import type { CheckSlot } from '@crvouga/atlas/reports/evidence-view';
12
12
  import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
13
- import { formatMs, type TestResultEntry, type TestSummary } from '@crvouga/atlas/reports/test-catalog';
13
+ import { formatMs, runKey, type TestResultEntry, type TestSummary } from '@crvouga/atlas/reports/test-catalog';
14
14
  import { Link } from '@tanstack/react-router';
15
15
  import { lazy, Suspense, useState } from 'react';
16
- import { CheckDetail, type CheckSelection } from '../evidence/check-detail';
16
+ import { CheckDetail } from '../evidence/check-detail';
17
17
  import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
18
- import { StatusMark } from '../evidence/marks';
18
+ import { useEvidence } from '../evidence/evidence-context';
19
+ import { Origin, StatusMark } from '../evidence/marks';
19
20
  import { useTestHistory } from './catalog';
20
- import { patched } from './navigate';
21
- import { Badges } from './parts';
21
+ import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
22
+ import { Badges, runName } from './parts';
23
+ import { usePinnedResult, type PinnedResult } from './pinned';
22
24
  const RecordingPage = lazy(() => import('../evidence/recording-page').then((module) => ({ default: module.RecordingPage })));
23
25
  /** Results the history table shows before "Show more". */
24
26
  const HISTORY_ROWS = 20;
25
27
  function percent(rate: number | null): string {
26
28
  return rate === null ? '—' : `${Math.round(rate * 100)}%`;
27
29
  }
30
+ /** Switches the detail from one pinned result back to the latest combined evidence. */
31
+ function LatestLink({ children }: { children: string }) {
32
+ return (
33
+ <Anchor
34
+ renderRoot={(props) => (
35
+ <Link {...props} to="/" search={patched({ layer: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
36
+ )}
37
+ >
38
+ {children}
39
+ </Anchor>
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Says which evidence the detail shows: this one run's result (pinned by the address), or the latest combined evidence.
45
+ * The change from one to the other is always an explicit link, so a result never turns into another silently.
46
+ */
47
+ function Scope({ pinned, slot }: { pinned: PinnedResult; slot: CheckSlot | undefined }) {
48
+ const { backends } = useEvidence();
49
+ const now = useNow();
50
+ if (pinned.state === 'none')
51
+ return (
52
+ <Flex aria-label="Evidence scope" role="group" gap="xs" align="center" wrap="wrap" mb="lg">
53
+ <Badge variant="light">Latest combined evidence</Badge>
54
+ <Text span inherit c="dimmed">
55
+ The newest result of each environment, which can come from different runs. Open a run to see exactly what it recorded.
56
+ </Text>
57
+ </Flex>
58
+ );
59
+ if (pinned.state === 'loading')
60
+ return (
61
+ <Text role="status" component="div" c="dimmed" mb="lg">
62
+ Reading the result of the run you opened…
63
+ </Text>
64
+ );
65
+ if (pinned.state === 'missing')
66
+ return (
67
+ <Alert color="blue" title="That result is not available" mb="lg">
68
+ <Stack gap="xs">
69
+ <Text span inherit>
70
+ {pinned.reason} No other result is shown in its place.
71
+ </Text>
72
+ <LatestLink>Show latest combined evidence</LatestLink>
73
+ </Stack>
74
+ </Alert>
75
+ );
76
+ const { layer } = pinned.entry;
77
+ const newer = slot?.outcomes.find(
78
+ (outcome) =>
79
+ outcome.layer.id !== layer.id &&
80
+ outcome.layer.recordedAtMs > layer.recordedAtMs &&
81
+ outcome.layer.producer === layer.producer &&
82
+ outcome.layer.environment === layer.environment &&
83
+ JSON.stringify(outcome.parameters) === JSON.stringify(pinned.entry.row.variant)
84
+ );
85
+ const key = runKey(layer);
86
+ return (
87
+ <Alert color="blue" title={`This run: ${runName(key)}`} mb="lg">
88
+ <Stack gap="xs">
89
+ <Flex gap="xs" align="center" wrap="wrap">
90
+ <Text span inherit fw={600}>
91
+ {layer.producer}
92
+ </Text>
93
+ <Origin layer={layer} backends={backends} now={now} />
94
+ </Flex>
95
+ <Text span inherit>
96
+ The evidence below is what this run recorded. The current results, statistics and history are about the latest combined evidence
97
+ across runs.
98
+ </Text>
99
+ {newer && (
100
+ <Text span inherit role="status">
101
+ A newer result exists: {statusWord(newer.row.status)} {ago(newer.layer.recordedAtMs, now)}.
102
+ </Text>
103
+ )}
104
+ <Flex gap="md" align="center" wrap="wrap">
105
+ <LatestLink>Show latest combined evidence</LatestLink>
106
+ <Anchor
107
+ renderRoot={(props) => (
108
+ <Link
109
+ {...props}
110
+ to="/"
111
+ search={patched({ tab: 'runs', openRun: key, test: undefined, layer: undefined })}
112
+ resetScroll={false}
113
+ activeOptions={{ exact: true }}
114
+ />
115
+ )}
116
+ >
117
+ Back to run {runName(key)}
118
+ </Anchor>
119
+ </Flex>
120
+ </Stack>
121
+ </Alert>
122
+ );
123
+ }
124
+
28
125
  /** The open test. `slot` is the report's check for it, when the page holds one, for its evidence. */
29
126
  export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot | undefined }) {
30
127
  const now = useNow();
31
128
  const history = useTestHistory(test.key);
32
- const [selection, setSelection] = useState<CheckSelection>({});
129
+ const { layer } = useExplorerSearch();
130
+ const go = useExplorerNavigate();
131
+ const pinned = usePinnedResult(layer, test.key);
33
132
  const [recording, setRecording] = useState(false);
133
+ const [recordingLayer, setRecordingLayer] = useState<string | undefined>(undefined);
34
134
  const [moment, setMoment] = useState<number | null>(null);
35
135
  const [rows, setRows] = useState(HISTORY_ROWS);
36
136
  const entries = history.data?.history ?? [];
@@ -47,7 +147,8 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
47
147
  >
48
148
  <RecordingPage
49
149
  slot={slot}
50
- layerId={selection.layer}
150
+ layerId={recordingLayer ?? layer}
151
+ entry={pinned.state === 'ready' && (recordingLayer ?? layer) === pinned.entry.layer.id ? pinned.entry : undefined}
51
152
  label={test.title}
52
153
  back="Test detail"
53
154
  moment={moment}
@@ -63,7 +164,7 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
63
164
  <Anchor
64
165
  aria-label="Close the test"
65
166
  renderRoot={(props) => (
66
- <Link {...props} to="/" search={patched({ test: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
167
+ <Link {...props} to="/" search={patched({ test: undefined, layer: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
67
168
  )}
68
169
  >
69
170
  <IconX size={20} aria-hidden="true" />
@@ -123,6 +224,8 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
123
224
  </Code>
124
225
  </Box>
125
226
 
227
+ <Scope pinned={pinned} slot={slot} />
228
+
126
229
  <Box aria-label="Current results" component="section" mb="lg">
127
230
  <Title order={3} mb="sm" c="dimmed">
128
231
  Current result per environment
@@ -314,21 +417,26 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
314
417
 
315
418
  <Box aria-label="Evidence" component="section" mb="lg">
316
419
  <Title order={3} mb="sm" c="dimmed">
317
- What the current result carries
420
+ {layer ? 'What this run recorded' : 'What the current result carries'}
318
421
  </Title>
319
- {slot ? (
422
+ {slot && (!layer || pinned.state === 'ready') ? (
320
423
  <CheckDetail
321
424
  slot={slot}
322
- layerId={selection.layer}
425
+ layerId={layer}
426
+ {...(pinned.state === 'ready' ? { pinned: pinned.entry, pin: true } : {})}
323
427
  reads="steps"
324
428
  select={(patch) => {
325
- setSelection({ check: patch.check ?? slot.id, layer: patch.layer ?? undefined });
326
429
  if (patch.view === 'recording') {
430
+ setRecordingLayer(patch.layer ?? undefined);
327
431
  setRecording(true);
328
432
  setMoment(null);
329
- }
433
+ } else go({ layer: patch.layer ?? undefined });
330
434
  }}
331
435
  />
436
+ ) : layer ? (
437
+ <Text component="div" c="dimmed">
438
+ {pinned.state === 'missing' ? 'There is no evidence to show for that run.' : 'Reading the evidence of that run…'}
439
+ </Text>
332
440
  ) : (
333
441
  <Text component="div" c="dimmed">
334
442
  The evidence of this result is not loaded in this page.
@@ -362,6 +470,9 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
362
470
  <Table.Th scope="col">Branch</Table.Th>
363
471
  <Table.Th scope="col">Commit</Table.Th>
364
472
  <Table.Th scope="col">Detail</Table.Th>
473
+ <Table.Th scope="col">
474
+ <VisuallyHidden>Evidence</VisuallyHidden>
475
+ </Table.Th>
365
476
  </Table.Tr>
366
477
  </Table.Thead>
367
478
  <Table.Tbody>
@@ -396,6 +507,23 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
396
507
  <Table.Td title={entry.detail} c="dimmed">
397
508
  {entry.detail.split('\n')[0]}
398
509
  </Table.Td>
510
+ <Table.Td>
511
+ <Anchor
512
+ aria-label={`Open the ${statusWord(entry.status)} result recorded ${ago(entry.at, now)} in ${environmentLabel(entry.environment)}`}
513
+ aria-current={entry.layer === layer ? 'true' : undefined}
514
+ renderRoot={(props) => (
515
+ <Link
516
+ {...props}
517
+ to="/"
518
+ search={patched({ layer: entry.layer })}
519
+ resetScroll={false}
520
+ activeOptions={{ exact: true }}
521
+ />
522
+ )}
523
+ >
524
+ Open this result
525
+ </Anchor>
526
+ </Table.Td>
399
527
  </Table.Tr>
400
528
  ))}
401
529
  </Table.Tbody>
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @module Pinned result
3
+ *
4
+ * Opening a test from a run, or choosing an earlier run in its history, pins the detail to one exact layer: the
5
+ * `layer` parameter of the explorer's address. This finds that layer's own record of the open check, wherever the
6
+ * viewer holds it: in the evidence it already read, or, on a server that sends only the winners, from the layer
7
+ * itself. A layer that cannot be found is reported as missing, never replaced by another result.
8
+ */
9
+ import { checkHistory, type HistoryEntry } from '@crvouga/atlas/reports/evidence-view';
10
+ import type { EvidencePage, LayerHeader, LayerRows } from '@crvouga/atlas/reports/evidence-types';
11
+ import { isDiagnosticKind, slotKey } from '@crvouga/atlas/reports/layer-merge';
12
+ import { useQuery } from '@tanstack/react-query';
13
+ import { useMemo } from 'react';
14
+
15
+ import { reportJson } from '../evidence/api';
16
+ import { useEvidence } from '../evidence/evidence-context';
17
+
18
+ /** What the viewer knows of the pinned result. `none` when nothing is pinned. */
19
+ export type PinnedResult =
20
+ | { readonly state: 'none' }
21
+ | { readonly state: 'loading' }
22
+ | { readonly state: 'missing'; readonly reason: string }
23
+ | { readonly state: 'ready'; readonly entry: HistoryEntry };
24
+
25
+ /** The most layer-header pages read looking for a pinned layer on a server. */
26
+ const MAX_PAGES = 200;
27
+
28
+ /** A test key (`TestSummary.key`) as the spec path and check id it names, or null when it is not one. */
29
+ export function checkOfKey(key: string): { path: string | null; id: string } | null {
30
+ try {
31
+ const parsed: unknown = JSON.parse(key);
32
+ if (Array.isArray(parsed) && typeof parsed[0] === 'string' && typeof parsed[1] === 'string') return { path: parsed[0] || null, id: parsed[1] };
33
+ } catch {
34
+ /* not a check key */
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /** One layer's record of one check, preferring a verdict over a timing, as `checkHistory` does. */
40
+ function recordOf(layer: LayerHeader, rows: LayerRows, key: string): HistoryEntry | null {
41
+ const own = rows.rows.filter((row) => slotKey(row.specPath ?? '', row.id) === key);
42
+ const row = own.find((candidate) => !isDiagnosticKind(candidate.kind)) ?? own[0];
43
+ return row ? { layer, row } : null;
44
+ }
45
+
46
+ async function readLayer(layerId: string, key: string, signal: AbortSignal): Promise<HistoryEntry> {
47
+ let cursor: string | null = null;
48
+ let header: LayerHeader | undefined;
49
+ for (let page = 0; page < MAX_PAGES && !header; page++) {
50
+ const read: EvidencePage = await reportJson<EvidencePage>(`/evidence${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`, signal);
51
+ header = read.layers.find((layer) => layer.id === layerId);
52
+ cursor = read.cursor;
53
+ if (!cursor) break;
54
+ }
55
+ if (!header) throw new Error('That run is not in the report any more. Its source may be switched off or have expired.');
56
+ const rows = await reportJson<LayerRows>(`/evidence/rows/${encodeURIComponent(header.backend)}/${encodeURIComponent(header.id)}`, signal);
57
+ const entry = recordOf(header, rows, key);
58
+ if (!entry) throw new Error('That run no longer holds a result for this test.');
59
+ return entry;
60
+ }
61
+
62
+ /**
63
+ * The record of the check `testKey` made by the layer `layerId`.
64
+ *
65
+ * @param layerId - The pinned layer (the `layer` address parameter), or undefined for the latest combined evidence.
66
+ * @param testKey - The open test's key.
67
+ * @returns `none` without a layer, `loading` while it is being found, `ready` with its entry, or `missing` with why.
68
+ */
69
+ export function usePinnedResult(layerId: string | undefined, testKey: string): PinnedResult {
70
+ const { evidence, effective, ready, pending, unavailable } = useEvidence();
71
+ const check = useMemo(() => checkOfKey(testKey), [testKey]);
72
+ const local = useMemo(
73
+ () => (layerId && check ? checkHistory(evidence, check.path, check.id).find((entry) => entry.layer.id === layerId) : undefined),
74
+ [evidence, layerId, check]
75
+ );
76
+ const remote = useQuery({
77
+ queryKey: ['pinned-result', layerId ?? '', testKey],
78
+ queryFn: ({ signal }) => readLayer(layerId!, testKey, signal),
79
+ enabled: !!layerId && !local && effective && ready,
80
+ staleTime: 5 * 60_000,
81
+ retry: false
82
+ });
83
+ if (!layerId) return { state: 'none' };
84
+ if (local) return { state: 'ready', entry: local };
85
+ if (unavailable) return { state: 'missing', reason: unavailable };
86
+ if (!ready) return { state: 'loading' };
87
+ if (!effective) return pending > 0 ? { state: 'loading' } : { state: 'missing', reason: 'That run is not in the report any more. Its source may be switched off or have expired.' };
88
+ if (remote.data) return { state: 'ready', entry: remote.data };
89
+ if (remote.isError) return { state: 'missing', reason: remote.error.message };
90
+ return { state: 'loading' };
91
+ }
@@ -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, and the Runs tab with the run opened in it. Filters that
5
+ * the words searched, the filters, the grouping, the order, the Runs tab with the run opened in it, and the exact layer a test's detail is pinned to. Filters that
6
6
  * take several values are comma-separated (`status=failed,flaky`). Anything unreadable is dropped, never an error.
7
7
  */
8
8
  import { TEST_SORTS, type TestQuery, type TestSort } from '@crvouga/atlas/reports/test-catalog';
@@ -36,7 +36,9 @@ export const ExplorerSearchFields = {
36
36
  sort: z.enum(TEST_SORTS).optional().catch(undefined),
37
37
  tab: z.enum(['runs']).optional().catch(undefined),
38
38
  /** The run opened in the Runs tab. */
39
- openRun: text
39
+ openRun: text,
40
+ /** Pins the open test's detail to one exact layer (a run's record of it), instead of the latest combined evidence. */
41
+ layer: text
40
42
  };
41
43
 
42
44
  /** The explorer's search parameters as the route holds them. */
@@ -54,6 +56,7 @@ export type ExplorerSearch = {
54
56
  readonly sort?: TestSort;
55
57
  readonly tab?: 'runs';
56
58
  readonly openRun?: string;
59
+ readonly layer?: string;
57
60
  };
58
61
 
59
62
  /** The values of a comma-separated filter, empty when it is not set. */