@crvouga/atlas 1.16.0 → 1.18.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 (51) hide show
  1. package/CHANGELOG.md +13 -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 +7 -4
  5. package/dist/reports/src/evidence-types.d.ts +4 -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 +20 -1
  13. package/dist/reports/src/test-catalog.js +31 -9
  14. package/dist/schema/src/api.d.ts +113 -0
  15. package/dist/schema/src/api.js +17 -3
  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/extending.md +1 -1
  21. package/docs/reference/cli.md +11 -2
  22. package/docs/reference/http-api.md +2 -0
  23. package/docs/reference/reports.md +202 -9
  24. package/docs/reference/schema.md +19 -1
  25. package/docs/rich-evidence.md +32 -5
  26. package/docs/server.md +1 -1
  27. package/docs/ui.md +8 -0
  28. package/llms-full.txt +56 -9
  29. package/package.json +1 -1
  30. package/packages/schema/schemas/effective-delta.schema.json +8 -0
  31. package/packages/schema/schemas/effective-evidence.schema.json +8 -0
  32. package/packages/schema/schemas/evidence-page.schema.json +4 -0
  33. package/packages/schema/schemas/layer-rows.schema.json +4 -0
  34. package/packages/schema/schemas/report-run.schema.json +74 -0
  35. package/packages/schema/schemas/report-runs.schema.json +86 -0
  36. package/packages/schema/schemas/review-collection.schema.json +4 -0
  37. package/packages/schema/schemas/review-manifest.schema.json +4 -0
  38. package/packages/schema/schemas/test-catalog.schema.json +30 -0
  39. package/packages/schema/schemas/test-history.schema.json +48 -0
  40. package/packages/schema/schemas/verify-recordings-result.schema.json +97 -0
  41. package/packages/server/openapi.json +318 -0
  42. package/packages/visualizer/src/evidence/applicability-view.tsx +46 -20
  43. package/packages/visualizer/src/evidence/applicability.test.ts +68 -1
  44. package/packages/visualizer/src/evidence/applicability.ts +121 -26
  45. package/packages/visualizer/src/explorer/ArtifactsView.tsx +3 -2
  46. package/packages/visualizer/src/explorer/Explorer.tsx +96 -8
  47. package/packages/visualizer/src/explorer/RunsView.tsx +28 -3
  48. package/packages/visualizer/src/explorer/TestDetail.tsx +14 -1
  49. package/packages/visualizer/src/explorer/applicability.ts +43 -0
  50. package/packages/visualizer/src/explorer/filters.tsx +11 -3
  51. package/packages/visualizer/src/explorer/search.ts +20 -6
@@ -93,15 +93,39 @@ function ownerText(video: VideoRef): string {
93
93
  return 'the test';
94
94
  }
95
95
  const UNAVAILABLE = 'Listed in the report, but its file was never stored, so it cannot be played.';
96
- /** The unavailable references of a video summary and why they cannot be watched. */
96
+ const MISSING = 'The run says it stored this file, but its bundle no longer holds it, so it cannot be played.';
97
+ /** The unavailable references of a video summary and why they cannot be watched: never stored, or stored and then gone. */
97
98
  function Unavailable({ videos }: { videos: VideoCounts }) {
98
99
  if (!videos.unavailable) return null;
100
+ const missing = Math.min(videos.missing ?? 0, videos.unavailable);
99
101
  return (
100
102
  <Text component="div" size="xs" c="dimmed" data-video-unavailable>
101
- <IconAlertTriangle size={16} aria-hidden="true" /> {plural(videos.unavailable, 'video', 'videos')} unavailable. {UNAVAILABLE}
103
+ <IconAlertTriangle size={16} aria-hidden="true" /> {plural(videos.unavailable, 'video', 'videos')} unavailable.{' '}
104
+ {missing > 0 && <Text span inherit data-video-missing>{missing === videos.unavailable ? MISSING : `${plural(missing, 'video', 'videos')}: ${MISSING}`}{' '}</Text>}
105
+ {missing < videos.unavailable && UNAVAILABLE}
102
106
  </Text>
103
107
  );
104
108
  }
109
+ /** Words for a run's recording completeness, apart from how its tests went. */
110
+ const COMPLETENESS_WORDS: Record<string, string> = { complete: 'complete', incomplete: 'incomplete', 'none-expected': 'none expected', unknown: 'unknown' };
111
+ /** A run's recording completeness in its row: how many expected recordings were published, said in words and independent of its outcomes. Nothing when its layers declared no expectations (unknown is not shown as complete). */
112
+ function RunRecordings({ run }: { run: RunSummary }) {
113
+ const recordings = run.recordings;
114
+ if (!recordings) return null;
115
+ const word = COMPLETENESS_WORDS[recordings.state] ?? 'unknown';
116
+ const text = recordings.expected > 0 ? `Recordings ${recordings.published} of ${recordings.expected}: ${word}` : `Recordings: ${word}`;
117
+ return (
118
+ <Badge
119
+ size="sm"
120
+ variant="light"
121
+ color={recordings.state === 'incomplete' ? 'orange' : 'gray'}
122
+ data-recordings={recordings.state}
123
+ title={recordings.state === 'incomplete' ? 'Some expected recordings were not published. The test outcomes are unaffected.' : undefined}
124
+ >
125
+ {text}
126
+ </Badge>
127
+ );
128
+ }
105
129
  /** A run's videos in its row: a link to the recordings, how many references they come from, and what cannot be played. */
106
130
  function RunVideos({ run }: { run: RunSummary }) {
107
131
  const videos = run.videos;
@@ -210,7 +234,7 @@ const RUN_SORTS = [
210
234
  { id: 'relevance', label: 'Best match' }
211
235
  ] as const;
212
236
  /** What a filter is called when a page says a server could not apply it. */
213
- const FILTER_WORDS: Partial<Record<FilterName, string>> = { artifactTag: 'tag', type: 'media type', label: 'label', domain: 'domain', spec: 'spec' };
237
+ const FILTER_WORDS: Partial<Record<FilterName, string>> = { artifactTag: 'tag', type: 'media type', label: 'label', domain: 'domain', spec: 'spec', recordings: 'recording completeness' };
214
238
  /** Says which filters the list is not narrowed by, because the server that answered ignored them. */
215
239
  export function NotApplied({ names, noun }: { names: readonly FilterName[]; noun: string }) {
216
240
  if (!names.length) return null;
@@ -383,6 +407,7 @@ function RunTable() {
383
407
  <Table.Td>{formatMs(run.spanMs)}</Table.Td>
384
408
  <Table.Td>
385
409
  <RunVideos run={run} />
410
+ <RunRecordings run={run} />
386
411
  </Table.Td>
387
412
  <Table.Td data-tags>{run.artifactTags?.map((tag) => `#${tag}`).join(' ') || '—'}</Table.Td>
388
413
  <Table.Td>
@@ -13,12 +13,15 @@ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
13
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 { assessTest } from '../evidence/applicability';
17
+ import { ApplicabilitySummary } from '../evidence/applicability-view';
16
18
  import { CheckDetail } from '../evidence/check-detail';
17
19
  import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
18
20
  import { useEvidence } from '../evidence/evidence-context';
19
21
  import { Origin, StatusMark } from '../evidence/marks';
20
22
  import { outcomeHeadline, selectedResult } from '../evidence/outcome';
21
23
  import { useTestHistory } from './catalog';
24
+ import { releaseOf } from './applicability';
22
25
  import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
23
26
  import { Badges, runName } from './parts';
24
27
  import { Reliability } from './Reliability';
@@ -129,7 +132,10 @@ function Scope({ pinned, slot }: { pinned: PinnedResult; slot: CheckSlot | undef
129
132
  export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot | undefined }) {
130
133
  const now = useNow();
131
134
  const history = useTestHistory(test.key);
132
- const { layer, source } = useExplorerSearch();
135
+ const search = useExplorerSearch();
136
+ const { layer, source } = search;
137
+ const release = releaseOf(search);
138
+ const applicability = assessTest(test, release);
133
139
  const go = useExplorerNavigate();
134
140
  const pinned = usePinnedResult(layer, test.key);
135
141
  const [recording, setRecording] = useState(false);
@@ -238,6 +244,13 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
238
244
 
239
245
  <Scope pinned={pinned} slot={slot} />
240
246
 
247
+ <Box aria-label="Evidence applicability" component="section" mb="lg">
248
+ <Title order={3} mb="sm" c="dimmed">
249
+ Does this evidence apply?
250
+ </Title>
251
+ <ApplicabilitySummary assessment={applicability} release={release} />
252
+ </Box>
253
+
241
254
  <Box aria-label="Current results" component="section" mb="lg">
242
255
  <Title order={3} mb="sm" c="dimmed">
243
256
  Current result per environment
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @module Explorer evidence applicability
3
+ *
4
+ * The explorer's part of evidence applicability: the release a reader names and the stale-or-unverified filter, held in
5
+ * the explorer's address (`release`, `releaseSha`, `releasePlatform`, `evidence=stale`, the same names a spec page uses),
6
+ * and the reading of every test of the catalog against them. The reading is the one a feature page makes
7
+ * (`evidence/applicability.ts`); it never changes a test's status.
8
+ */
9
+ import { useMemo } from 'react';
10
+ import type { TestSummary } from '@crvouga/atlas/reports/test-catalog';
11
+
12
+ import { assessTest, type Assessment, type ReleaseIdentity } from '../evidence/applicability';
13
+ import type { ApplicabilityState } from '../evidence/applicability-view';
14
+ import { useExplorerNavigate, useExplorerSearch } from './navigate';
15
+ import type { ExplorerSearch } from './search';
16
+
17
+ /** The release an explorer address names; every part is optional. */
18
+ export function releaseOf(search: ExplorerSearch): ReleaseIdentity {
19
+ return {
20
+ ...(search.release ? { build: search.release } : {}),
21
+ ...(search.releaseSha ? { sha: search.releaseSha } : {}),
22
+ ...(search.releasePlatform ? { platform: search.releasePlatform } : {})
23
+ };
24
+ }
25
+
26
+ /** Each test's evidence read against the promise and the release, by test key. */
27
+ export function assessTests(tests: readonly TestSummary[], release: ReleaseIdentity): ReadonlyMap<string, Assessment> {
28
+ return new Map(tests.map((test) => [test.key, assessTest(test, release)] as const));
29
+ }
30
+
31
+ /** The release and the evidence filter of the explorer's address, with the ways to change them. */
32
+ export function useExplorerApplicability(): ApplicabilityState {
33
+ const search = useExplorerSearch();
34
+ const go = useExplorerNavigate();
35
+ const { release: build, releaseSha: sha, releasePlatform: platform } = search;
36
+ const release = useMemo(() => releaseOf({ release: build, releaseSha: sha, releasePlatform: platform }), [build, sha, platform]);
37
+ return {
38
+ release,
39
+ onlyStale: search.evidence === 'stale',
40
+ setRelease: (next) => go({ release: next.build || undefined, releaseSha: next.sha || undefined, releasePlatform: next.platform || undefined }, { replace: true }),
41
+ setOnlyStale: (on) => go({ evidence: on ? 'stale' : undefined }, { replace: true })
42
+ };
43
+ }
@@ -2,7 +2,7 @@
2
2
  * @module Run and artifact filters
3
3
  *
4
4
  * The filter bar of the Runs and Artifacts tabs: words, artifact tag, media type, label, environment, branch, outcome,
5
- * feature domain and a time range, each offering the values the report has with counts in the scope of the list. What is
5
+ * feature domain and a time range (and, for runs, the completeness of their recordings, apart from their outcome), each offering the values the report has with counts in the scope of the list. What is
6
6
  * chosen shows as removable chips and is the address, so copying it or reloading restores the same view; named views keep
7
7
  * a query to open again. Choices combine with AND across the controls and OR within one.
8
8
  */
@@ -18,7 +18,7 @@ import { patched, useExplorerNavigate } from './navigate';
18
18
  import { filtered, joined, NO_FILTERS, toggled, values, type ExplorerSearch } from './search';
19
19
  import { filtersOf, isView, readViews, writeViews, type SavedView } from './views';
20
20
 
21
- type Param = 'artifactTag' | 'type' | 'label' | 'env' | 'branch' | 'outcome' | 'domain' | 'producer';
21
+ type Param = 'artifactTag' | 'type' | 'label' | 'env' | 'branch' | 'outcome' | 'recordings' | 'domain' | 'producer';
22
22
 
23
23
  const NOUN: Record<Param, string> = {
24
24
  artifactTag: 'Tag',
@@ -27,13 +27,18 @@ const NOUN: Record<Param, string> = {
27
27
  env: 'Environment',
28
28
  branch: 'Branch',
29
29
  outcome: 'Outcome',
30
+ recordings: 'Recordings',
30
31
  domain: 'Domain',
31
32
  producer: 'Producer'
32
33
  };
33
34
 
35
+ /** How the completeness of a run's recordings reads. */
36
+ const COMPLETENESS: Record<string, string> = { complete: 'Complete', incomplete: 'Incomplete', 'none-expected': 'None expected', unknown: 'Unknown' };
37
+
34
38
  /** How a chosen value reads: tags with their hash, environments by name. */
35
39
  function shown(param: Param, value: string): string {
36
40
  if (param === 'artifactTag') return `#${value}`;
41
+ if (param === 'recordings') return COMPLETENESS[value] ?? value;
37
42
  if (param === 'env') return environmentLabel(value);
38
43
  if (param === 'domain' && value === '(no spec)') return 'No spec';
39
44
  return value;
@@ -178,7 +183,7 @@ export function FilterBar({
178
183
  const [draft, setDraft] = useState(search.q ?? '');
179
184
  // The address may change from elsewhere (a link, a saved view, back): follow it.
180
185
  useEffect(() => setDraft((current) => (current.trim() === (search.q ?? '').trim() ? current : (search.q ?? ''))), [search.q]);
181
- const params: Param[] = ['artifactTag', 'type', 'label', 'env', 'branch', 'outcome', ...(tab === 'artifacts' ? (['domain'] as const) : []), 'producer'];
186
+ const params: Param[] = ['artifactTag', 'type', 'label', 'env', 'branch', 'outcome', ...(tab === 'runs' ? (['recordings'] as const) : []), ...(tab === 'artifacts' ? (['domain'] as const) : []), 'producer'];
182
187
  const chips = [
183
188
  ...params.flatMap((param) =>
184
189
  values(search[param]).map((value) => ({ label: `${NOUN[param]}: ${shown(param, value)}`, clear: { [param]: toggled(search[param], value) } as never }))
@@ -233,6 +238,9 @@ export function FilterBar({
233
238
  <Facet param="env" facets={facets.environments} search={search} />
234
239
  <Facet param="branch" facets={facets.branches} search={search} />
235
240
  <Facet param="outcome" facets={facets.outcomes} search={search} />
241
+ {tab === 'runs' && (facets.recordings || values(search.recordings).length > 0) && (
242
+ <Facet param="recordings" facets={facets.recordings ?? []} search={search} />
243
+ )}
236
244
  {tab === 'artifacts' && facets.domains && <Facet param="domain" facets={facets.domains} search={search} />}
237
245
  {facets.producers.length > 1 && <Facet param="producer" facets={facets.producers} search={search} />}
238
246
  <TextInput type="date" label="From" value={search.since ?? ''} onChange={(event) => go({ since: event.target.value || undefined })} />
@@ -7,7 +7,7 @@
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';
@@ -19,8 +19,8 @@ export const TEST_GROUPS = ['spec', 'domain', 'environment', 'status', 'producer
19
19
  /** One of {@link TEST_GROUPS}. */
20
20
  export type TestGroup = (typeof TEST_GROUPS)[number];
21
21
 
22
- /** The orders the explorer offers: `relevance` (best match first, only with words to match) and those of the catalog. */
23
- export const EXPLORER_SORTS = ['relevance', ...TEST_SORTS] as const;
22
+ /** The orders the explorer offers: `relevance` (best match first, only with words to match), `evidence` (tests whose evidence is stale or unverified first, worked out in the viewer) and those of the catalog. */
23
+ export const EXPLORER_SORTS = ['relevance', 'evidence', ...TEST_SORTS] as const;
24
24
  /** One of {@link EXPLORER_SORTS}. */
25
25
  export type ExplorerSort = (typeof EXPLORER_SORTS)[number];
26
26
 
@@ -55,12 +55,20 @@ 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). */
61
63
  until: text,
62
64
  /** Only the tests a CI run recorded (a `RunSummary.key`). Not `run`, which already names a recorded statechart run. */
63
65
  inRun: text,
66
+ /** `stale`: only tests whose evidence is stale or unverified (not that applies to the current promise and, when one is named, the release). Never a test status. */
67
+ evidence: z.enum(['stale']).optional().catch(undefined),
68
+ /** The release evidence is read against: its build, commit and platform (the names a spec page uses). A build such as `90` is read from a typed link as a number: coerced back to its text. */
69
+ release: text,
70
+ releaseSha: text,
71
+ releasePlatform: text,
64
72
  group: z.enum(TEST_GROUPS).optional().catch(undefined),
65
73
  sort: z.enum(EXPLORER_SORTS).optional().catch(undefined),
66
74
  tab: z.enum(['runs', 'artifacts', 'collections', 'compare', 'health']).optional().catch(undefined),
@@ -124,9 +132,14 @@ export type ExplorerSearch = {
124
132
  readonly label?: string;
125
133
  readonly branch?: string;
126
134
  readonly outcome?: string;
135
+ readonly recordings?: string;
127
136
  readonly since?: string;
128
137
  readonly until?: string;
129
138
  readonly inRun?: string;
139
+ readonly evidence?: 'stale';
140
+ readonly release?: string;
141
+ readonly releaseSha?: string;
142
+ readonly releasePlatform?: string;
130
143
  readonly group?: TestGroup;
131
144
  readonly sort?: ExplorerSort;
132
145
  readonly tab?: 'runs' | 'artifacts' | 'collections' | 'compare' | 'health';
@@ -189,7 +202,7 @@ export function queryOf(search: ExplorerSearch): TestQuery {
189
202
  ...(values(search.scope).length ? { scope: values(search.scope) } : {}),
190
203
  artifactTag: values(search.artifactTag),
191
204
  ...(search.inherited ? { inherited: true } : {}),
192
- sort: search.sort === 'relevance' || !search.sort ? 'spec' : search.sort
205
+ sort: search.sort === 'relevance' || search.sort === 'evidence' || !search.sort ? 'spec' : search.sort
193
206
  };
194
207
  }
195
208
 
@@ -200,7 +213,7 @@ export function explorerSearch(search: Record<string, unknown>): ExplorerSearch
200
213
 
201
214
  /** True when any filter or search narrows the list. */
202
215
  export function narrowed(search: ExplorerSearch): boolean {
203
- return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.scope || search.artifactTag || search.inRun || search.video);
216
+ return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.scope || search.artifactTag || search.inRun || search.video || search.evidence);
204
217
  }
205
218
 
206
219
  /** The reliability scope an address asks for, in the shape `reliability` reads: days are the viewer's local days. */
@@ -219,7 +232,7 @@ export function reliabilityScopeOf(search: ExplorerSearch): ReliabilityScope {
219
232
  }
220
233
 
221
234
  /** The filters of the Runs and Artifacts tabs, in the address. */
222
- export const FILTER_FIELDS = ['q', 'artifactTag', 'inherited', 'type', 'label', 'env', 'producer', 'branch', 'outcome', 'domain', 'spec', 'since', 'until', 'video'] as const;
235
+ export const FILTER_FIELDS = ['q', 'artifactTag', 'inherited', 'type', 'label', 'env', 'producer', 'branch', 'outcome', 'recordings', 'domain', 'spec', 'since', 'until', 'video'] as const;
223
236
 
224
237
  /** A patch that removes every filter of the Runs and Artifacts tabs. */
225
238
  export const NO_FILTERS = Object.fromEntries(FILTER_FIELDS.map((field) => [field, undefined])) as { [K in (typeof FILTER_FIELDS)[number]]?: undefined };
@@ -253,6 +266,7 @@ export function filterOf(search: ExplorerSearch): ArtifactFilter {
253
266
  producer: list(search.producer),
254
267
  branch: list(search.branch),
255
268
  outcome: list(search.outcome),
269
+ recordings: list(search.recordings),
256
270
  domain: list(search.domain),
257
271
  spec: list(search.spec),
258
272
  since,