@crvouga/atlas 1.15.0 → 1.17.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 (36) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/dist/reports/src/catalog-health.d.ts +15 -2
  3. package/dist/reports/src/catalog-health.js +23 -2
  4. package/dist/reports/src/test-catalog.d.ts +13 -0
  5. package/dist/reports/src/test-catalog.js +21 -5
  6. package/dist/schema/src/api.d.ts +35 -0
  7. package/dist/schema/src/api.js +14 -3
  8. package/dist/schema/src/server-config.d.ts +3 -0
  9. package/dist/schema/src/server-config.js +5 -0
  10. package/dist/server/src/app.d.ts +2 -0
  11. package/dist/server/src/app.js +6 -2
  12. package/dist/server/src/config.js +4 -0
  13. package/dist/server/src/main.js +1 -0
  14. package/dist/server/src/openapi.js +2 -2
  15. package/docs/extending.md +1 -1
  16. package/docs/reference/http-api.md +4 -4
  17. package/docs/reference/reports.md +41 -2
  18. package/docs/reference/server.md +2 -0
  19. package/docs/server.md +11 -2
  20. package/docs/ui.md +28 -1
  21. package/llms-full.txt +44 -8
  22. package/package.json +1 -1
  23. package/packages/schema/schemas/catalog-health.schema.json +20 -0
  24. package/packages/schema/schemas/server-config.schema.json +17 -0
  25. package/packages/schema/schemas/test-catalog.schema.json +26 -0
  26. package/packages/schema/schemas/test-history.schema.json +40 -0
  27. package/packages/server/openapi.json +90 -4
  28. package/packages/visualizer/src/evidence/applicability-view.tsx +46 -20
  29. package/packages/visualizer/src/evidence/applicability.test.ts +68 -1
  30. package/packages/visualizer/src/evidence/applicability.ts +121 -26
  31. package/packages/visualizer/src/explorer/Explorer.tsx +105 -10
  32. package/packages/visualizer/src/explorer/HealthView.tsx +402 -0
  33. package/packages/visualizer/src/explorer/TestDetail.tsx +14 -1
  34. package/packages/visualizer/src/explorer/applicability.ts +43 -0
  35. package/packages/visualizer/src/explorer/catalog.tsx +33 -0
  36. package/packages/visualizer/src/explorer/search.ts +20 -7
@@ -111,6 +111,26 @@ export function ApplicabilitySummary({ assessment, release }: { assessment: Asse
111
111
  </time>
112
112
  </Text>
113
113
  </Text>
114
+ {facts.publishedAtMs !== null && (
115
+ <Text span inherit data-published>
116
+ Published:{' '}
117
+ <Text span inherit fw={600}>
118
+ <time dateTime={new Date(facts.publishedAtMs).toISOString()} title={exactTime(facts.publishedAtMs)}>
119
+ {exactTime(facts.publishedAtMs)} ({ago(facts.publishedAtMs, now)})
120
+ </time>
121
+ </Text>
122
+ </Text>
123
+ )}
124
+ {facts.enrichedAtMs !== null && (
125
+ <Text span inherit data-enriched>
126
+ Enriched:{' '}
127
+ <Text span inherit fw={600}>
128
+ <time dateTime={new Date(facts.enrichedAtMs).toISOString()} title={exactTime(facts.enrichedAtMs)}>
129
+ {exactTime(facts.enrichedAtMs)} ({ago(facts.enrichedAtMs, now)})
130
+ </time>
131
+ </Text>
132
+ </Text>
133
+ )}
114
134
  <Text span inherit>
115
135
  Build:{' '}
116
136
  <Text span inherit fw={600}>
@@ -167,13 +187,17 @@ export function ApplicabilitySummary({ assessment, release }: { assessment: Asse
167
187
  */
168
188
  export function ApplicabilityControls({
169
189
  state,
170
- stale,
171
- total
190
+ stale = 0,
191
+ total = 0,
192
+ filter = true
172
193
  }: {
173
194
  state: ApplicabilityState;
174
- /** How many behaviors have stale or unverified evidence. */
175
- stale: number;
176
- total: number;
195
+ /** How many behaviors (or tests) have stale or unverified evidence. Only shown with the filter. */
196
+ stale?: number;
197
+ /** How many behaviors (or tests) there are. Only shown with the filter. */
198
+ total?: number;
199
+ /** Whether to offer the stale-or-unverified filter here; a caller that offers it elsewhere sets this false. */
200
+ filter?: boolean;
177
201
  }) {
178
202
  const { release, setRelease, onlyStale, setOnlyStale } = state;
179
203
  return (
@@ -208,21 +232,23 @@ export function ApplicabilityControls({
208
232
  A platform alone does not name a release. Add its build or commit.
209
233
  </Text>
210
234
  )}
211
- <Group role="group" aria-label="Evidence filter" gap="sm">
212
- <Button type="button" aria-pressed={!onlyStale} onClick={() => setOnlyStale(false)} variant={onlyStale ? 'subtle' : 'light'}>
213
- All evidence{' '}
214
- <Text span inherit>
215
- {total}
216
- </Text>
217
- </Button>
218
- <Button type="button" aria-pressed={onlyStale} onClick={() => setOnlyStale(true)} variant={onlyStale ? 'light' : 'subtle'}>
219
- Stale or unverified{' '}
220
- <Text span inherit>
221
- {stale}
222
- </Text>
223
- </Button>
224
- </Group>
225
- {onlyStale && stale === 0 && (
235
+ {filter && (
236
+ <Group role="group" aria-label="Evidence filter" gap="sm">
237
+ <Button type="button" aria-pressed={!onlyStale} onClick={() => setOnlyStale(false)} variant={onlyStale ? 'subtle' : 'light'}>
238
+ All evidence{' '}
239
+ <Text span inherit>
240
+ {total}
241
+ </Text>
242
+ </Button>
243
+ <Button type="button" aria-pressed={onlyStale} onClick={() => setOnlyStale(true)} variant={onlyStale ? 'light' : 'subtle'}>
244
+ Stale or unverified{' '}
245
+ <Text span inherit>
246
+ {stale}
247
+ </Text>
248
+ </Button>
249
+ </Group>
250
+ )}
251
+ {filter && onlyStale && stale === 0 && (
226
252
  <Text role="status" component="div" c="dimmed">
227
253
  Every behavior has evidence that applies.
228
254
  </Text>
@@ -3,7 +3,9 @@ import type { CheckSlot } from '@crvouga/atlas/reports/evidence-view';
3
3
  import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
4
4
  import { expect, test } from 'bun:test';
5
5
 
6
- import { assessBehavior, assessResult, hasRelease, needsAttention } from './applicability';
6
+ import type { TestResultEntry, TestSummary } from '@crvouga/atlas/reports/test-catalog';
7
+
8
+ import { assessBehavior, assessResult, assessTest, attentionRank, hasRelease, needsAttention } from './applicability';
7
9
  import { behaviorOf, type RevisionSource } from './behavior';
8
10
  import { featureModel } from './gherkin';
9
11
 
@@ -129,3 +131,68 @@ test('a behavior is read by its best completed result and its status is untouche
129
131
  const running = assessBehavior(behaviorOf(ADD, slot([result('l1', {}, 'running')]), null, same, 'k'), same, {});
130
132
  expect(running.verdict).toBe('none');
131
133
  });
134
+
135
+ type Outcome = Partial<TestResultEntry>;
136
+ function testOf(outcomes: Outcome[], fingerprint: string | null = 'f1'): TestSummary {
137
+ return {
138
+ key: 'k', id: 'k', title: 'Lists: Add', specPath: 'specs/a.feature',
139
+ spec: { path: 'specs/a.feature', title: 'Lists', domain: 'lists', kind: 'gherkin', ...(fingerprint ? { fingerprint } : {}) },
140
+ outcomes: outcomes.map((outcome, index) => ({
141
+ at: 1_000, status: 'passed', durationMs: null, environment: 'desktop-web', producer: 'p', kind: 'behavior', parameters: {}, branch: 'main', sha: null, runUrl: null,
142
+ run: 'r', layer: `l${index + 1}`, backend: 'b', detail: '', specRevision: 'f1', ...outcome
143
+ }))
144
+ } as unknown as TestSummary;
145
+ }
146
+
147
+ test('a test whose promise changed after its only result is stale, and the same test read from the catalog agrees with the page', () => {
148
+ const stale = assessTest(testOf([{ specRevision: 'f0' }]), {});
149
+ expect(stale.verdict).toBe('stale');
150
+ expect(stale.reasons[0]).toContain('not proof of the current promise');
151
+ expect(assessTest(testOf([{ specRevision: 'f1' }]), {}).verdict).toBe('applies');
152
+ // The same facts, read from the report, give the same verdict.
153
+ expect(assessResult(result('l1'), revisions('f1', { l1: 'f0' }), {}).verdict).toBe(stale.verdict);
154
+ });
155
+
156
+ test('a test with no recorded revision or no promise fingerprint is unknown, never applying', () => {
157
+ expect(assessTest(testOf([{ specRevision: undefined }]), {}).verdict).toBe('unknown');
158
+ expect(assessTest(testOf([{ specRevision: 'f1' }], null), {}).verdict).toBe('unknown');
159
+ });
160
+
161
+ test('a test with no completed result has none, and its failure is not read at all as stale', () => {
162
+ expect(assessTest(testOf([{ status: 'running' }, { status: 'not-run' }]), {}).verdict).toBe('none');
163
+ expect(assessTest(testOf([]), { build: '9' }).release).toBe('unknown');
164
+ expect(assessTest(testOf([{ status: 'failed' }]), {}).verdict).toBe('applies');
165
+ });
166
+
167
+ test('a test is read against the release from its provenance: build, platform, modified checkout and asserted compatibility', () => {
168
+ const release = { build: '91' };
169
+ expect(assessTest(testOf([{ provenance: { build: '90' } }]), release).release).toBe('other');
170
+ expect(assessTest(testOf([{ provenance: { build: '91' } }]), release).release).toBe('exact');
171
+ expect(assessTest(testOf([{ provenance: { build: '91' } }]), { ...release, platform: 'ios' }).release).toBe('unknown');
172
+ expect(assessTest(testOf([{ provenance: { build: '91' }, freshness: 'modified' }]), release).release).toBe('unknown');
173
+ const asserted = (by: Record<string, string>) => assessTest(testOf([{ provenance: { build: '90', 'applies-to': '91', ...by } }]), release);
174
+ expect(asserted({ 'applies-to-by': 'policy-7' }).release).toBe('asserted');
175
+ expect(asserted({}).release).toBe('unknown');
176
+ // A branch never supplies deployment state.
177
+ expect(assessTest(testOf([{ branch: 'release/91' }]), release).release).toBe('unknown');
178
+ });
179
+
180
+ test('the best of a test\'s current results is kept, and stale evidence sorts before evidence that applies', () => {
181
+ const both = assessTest(testOf([{ specRevision: 'f0', at: 5_000 }, { specRevision: 'f1', at: 1_000 }]), {});
182
+ expect(both.verdict).toBe('applies');
183
+ expect(both.layer).toBe('l2');
184
+ expect(both.others).toBe(1);
185
+ const ranks = [assessTest(testOf([{}]), {}), assessTest(testOf([{ specRevision: 'f0' }]), {}), assessTest(testOf([{ specRevision: undefined }]), {}), assessTest(testOf([]), {})].map(attentionRank);
186
+ expect(ranks).toEqual([3, 0, 1, 2]);
187
+ });
188
+
189
+ test('when evidence was published or enriched is carried apart from when the checks ran, and a time that cannot be read is no time', () => {
190
+ const facts = assessTest(testOf([{ at: 1_000, provenance: { 'published-at': '1700000000000', 'enriched-at': '2026-10-01T00:00:00Z' } }]), {}).facts!;
191
+ expect(facts.executedAtMs).toBe(1_000);
192
+ expect(facts.publishedAtMs).toBe(1_700_000_000_000);
193
+ expect(facts.enrichedAtMs).toBe(Date.parse('2026-10-01T00:00:00Z'));
194
+ const unreadable = assessTest(testOf([{ provenance: { 'published-at': 'yesterday-ish', 'enriched-at': '0' } }]), {}).facts!;
195
+ expect([unreadable.publishedAtMs, unreadable.enrichedAtMs]).toEqual([null, null]);
196
+ // Read from the report, labels at the layer or the check say the same.
197
+ expect(assessResult(result('l1', { layerLabels: { 'published-at': '1700000000000' } }), same, {}).facts!.publishedAtMs).toBe(1_700_000_000_000);
198
+ });
@@ -13,9 +13,16 @@
13
13
  * the app under test was), `platform` (the client family; the environment when absent), `applies-to` (a comma
14
14
  * separated list of builds or commits the producer asserts the evidence is still good for) and `applies-to-by`
15
15
  * (the policy, person or tool that makes the assertion). An assertion without `applies-to-by` is not accepted.
16
+ * `published-at` and `enriched-at` say when the evidence was published or enriched (epoch milliseconds or an ISO date).
17
+ * Layers carry no publication time of their own, because a layer's id is the digest of its content and a time of
18
+ * upload in it would make a retried publish a different layer; a producer that knows states it as a label. It is shown
19
+ * apart from when the checks ran and is never the age of the evidence.
20
+ *
21
+ * The same reading serves a feature page (from the report's results, {@link assessBehavior}) and the test explorer (from
22
+ * the catalog's current results, {@link assessTest}).
16
23
  */
17
- import type { LayerHeader } from '@crvouga/atlas/reports/evidence-types';
18
24
  import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
25
+ import type { TestResultEntry, TestSummary } from '@crvouga/atlas/reports/test-catalog';
19
26
 
20
27
  import { compareRevisions, type Behavior, type Revision, type RevisionSource } from './behavior';
21
28
 
@@ -58,6 +65,10 @@ export const VERDICTS: Readonly<Record<Verdict, { readonly label: string; readon
58
65
  export type EvidenceFacts = {
59
66
  /** When the checks ran, epoch milliseconds. The age of evidence counts from here, not from publication or enrichment. */
60
67
  readonly executedAtMs: number;
68
+ /** When the evidence was published, from the `published-at` label, or null when the producer did not say. Never the age of the evidence. */
69
+ readonly publishedAtMs: number | null;
70
+ /** When the evidence was enriched (annotated, captioned, re-indexed), from the `enriched-at` label, or null. Never the age of the evidence. */
71
+ readonly enrichedAtMs: number | null;
61
72
  readonly build: string | null;
62
73
  readonly sha: string | null;
63
74
  /** The `platform` label, else the layer's environment. */
@@ -83,8 +94,10 @@ export type Assessment = {
83
94
  readonly assertion: Assertion | null;
84
95
  /** Sentences saying why, in the order a reader needs them. Empty when there is nothing to add. */
85
96
  readonly reasons: readonly string[];
86
- /** The result this reading is of, or null when there is none. */
97
+ /** The result this reading is of, or null when there is none (or it was read from a test catalog, which holds its layer id instead). */
87
98
  readonly result: EvidenceResult | null;
99
+ /** The id of the layer that recorded the result this reading is of, or null when there is none. */
100
+ readonly layer: string | null;
88
101
  /** How many other completed results were read and set aside for this one. */
89
102
  readonly others: number;
90
103
  };
@@ -107,23 +120,38 @@ function labelsOf(result: EvidenceResult): Readonly<Record<string, string>> {
107
120
  return { ...(result.layer.evidence?.labels ?? {}), ...(result.row.evidence?.labels ?? {}) };
108
121
  }
109
122
 
110
- /** What a result's layer and labels say it ran against. */
111
- export function evidenceFacts(result: EvidenceResult): EvidenceFacts {
112
- const labels = labelsOf(result);
113
- const layer: LayerHeader = result.layer;
123
+ /** A time a label states: epoch milliseconds or an ISO date. Anything else is no time. */
124
+ function labelTime(value: string | undefined): number | null {
125
+ const text = value?.trim();
126
+ if (!text) return null;
127
+ const at = /^\d{9,}$/.test(text) ? Number(text) : /^\d{4}-\d{2}-\d{2}/.test(text) ? Date.parse(text) : Number.NaN;
128
+ return Number.isFinite(at) && at > 0 ? at : null;
129
+ }
130
+
131
+ /** What one result recorded about itself, whether read from the report or from the test catalog. */
132
+ type Recorded = {
133
+ readonly labels: Readonly<Record<string, string>>;
134
+ readonly executedAtMs: number;
135
+ readonly sha: string | null;
136
+ readonly environment: string | null;
137
+ readonly freshness: 'modified' | 'unknown' | null;
138
+ };
139
+
140
+ function factsFrom(recorded: Recorded): EvidenceFacts {
141
+ const { labels } = recorded;
114
142
  return {
115
- executedAtMs: layer.recordedAtMs,
143
+ executedAtMs: recorded.executedAtMs,
144
+ publishedAtMs: labelTime(labels['published-at']),
145
+ enrichedAtMs: labelTime(labels['enriched-at']),
116
146
  build: labels.build?.trim() || null,
117
- sha: layer.sha,
118
- platform: labels.platform?.trim() || layer.environment,
119
- environment: layer.environment,
120
- provenance: layer.freshness ?? null
147
+ sha: recorded.sha,
148
+ platform: labels.platform?.trim() || recorded.environment,
149
+ environment: recorded.environment,
150
+ provenance: recorded.freshness
121
151
  };
122
152
  }
123
153
 
124
- /** The assertion a result's labels make, or null when it makes none. */
125
- export function assertionOf(result: EvidenceResult): Assertion | null {
126
- const labels = labelsOf(result);
154
+ function assertionFrom(labels: Readonly<Record<string, string>>): Assertion | null {
127
155
  const builds = (labels['applies-to'] ?? '')
128
156
  .split(',')
129
157
  .map((each) => each.trim())
@@ -131,6 +159,32 @@ export function assertionOf(result: EvidenceResult): Assertion | null {
131
159
  return builds.length ? { builds, by: labels['applies-to-by']?.trim() || null } : null;
132
160
  }
133
161
 
162
+ const ofResult = (result: EvidenceResult): Recorded => ({
163
+ labels: labelsOf(result),
164
+ executedAtMs: result.layer.recordedAtMs,
165
+ sha: result.layer.sha,
166
+ environment: result.layer.environment,
167
+ freshness: result.layer.freshness ?? null
168
+ });
169
+
170
+ const ofOutcome = (outcome: TestResultEntry): Recorded => ({
171
+ labels: outcome.provenance ?? {},
172
+ executedAtMs: outcome.at,
173
+ sha: outcome.sha,
174
+ environment: outcome.environment,
175
+ freshness: outcome.freshness ?? null
176
+ });
177
+
178
+ /** What a result's layer and labels say it ran against. */
179
+ export function evidenceFacts(result: EvidenceResult): EvidenceFacts {
180
+ return factsFrom(ofResult(result));
181
+ }
182
+
183
+ /** The assertion a result's labels make, or null when it makes none. */
184
+ export function assertionOf(result: EvidenceResult): Assertion | null {
185
+ return assertionFrom(labelsOf(result));
186
+ }
187
+
134
188
  const RANK: Readonly<Record<Verdict, number>> = { applies: 0, unknown: 1, stale: 2, none: 3 };
135
189
 
136
190
  /** How one result's evidence stands against the release. Never reads a branch or a status. */
@@ -193,11 +247,10 @@ function matchRelease(facts: EvidenceFacts, release: ReleaseIdentity, promise: R
193
247
  return { match: 'exact', reasons };
194
248
  }
195
249
 
196
- /** One result against the promise and the release. */
197
- export function assessResult(result: EvidenceResult, revisions: RevisionSource, release: ReleaseIdentity): Assessment {
198
- const facts = evidenceFacts(result);
199
- const revision = compareRevisions(revisions.current, revisions.recorded(result.layer.id));
200
- const assertion = assertionOf(result);
250
+ /** One recorded result against the promise (current and recorded fingerprints) and the release. */
251
+ function read(recorded: Recorded, revision: Revision, release: ReleaseIdentity, source: { result: EvidenceResult | null; layer: string }): Assessment {
252
+ const facts = factsFrom(recorded);
253
+ const assertion = assertionFrom(recorded.labels);
201
254
  const { match, reasons } = matchRelease(facts, release, revision.match, assertion);
202
255
  const promiseReason =
203
256
  revision.match === 'different'
@@ -214,11 +267,36 @@ export function assessResult(result: EvidenceResult, revisions: RevisionSource,
214
267
  facts,
215
268
  assertion,
216
269
  reasons: [...(promiseReason ? [promiseReason] : []), ...reasons],
217
- result,
270
+ result: source.result,
271
+ layer: source.layer,
218
272
  others: 0
219
273
  };
220
274
  }
221
275
 
276
+ /** One result against the promise and the release. */
277
+ export function assessResult(result: EvidenceResult, revisions: RevisionSource, release: ReleaseIdentity): Assessment {
278
+ const revision = compareRevisions(revisions.current, revisions.recorded(result.layer.id));
279
+ return read(ofResult(result), revision, release, { result, layer: result.layer.id });
280
+ }
281
+
282
+ const NOTHING = (release: ReleaseIdentity): Assessment => ({
283
+ verdict: 'none',
284
+ promise: 'unknown',
285
+ release: hasRelease(release) ? 'unknown' : 'not-selected',
286
+ facts: null,
287
+ assertion: null,
288
+ reasons: [],
289
+ result: null,
290
+ layer: null,
291
+ others: 0
292
+ });
293
+
294
+ /** The reading that best applies (applies, then unknown, then stale; the newest execution among equals), with how many were set aside. */
295
+ function best(readings: Assessment[]): Assessment {
296
+ readings.sort((a, b) => RANK[a.verdict] - RANK[b.verdict] || b.facts!.executedAtMs - a.facts!.executedAtMs);
297
+ return { ...readings[0]!, others: readings.length - 1 };
298
+ }
299
+
222
300
  /**
223
301
  * The reading of a behavior's evidence: every completed result is read and the one that best applies is kept
224
302
  * (applies, then unknown, then stale; the newest execution among equals), so a recording on the release's platform is
@@ -227,12 +305,29 @@ export function assessResult(result: EvidenceResult, revisions: RevisionSource,
227
305
  */
228
306
  export function assessBehavior(behavior: Behavior, revisions: RevisionSource, release: ReleaseIdentity): Assessment {
229
307
  const completed = (behavior.slot?.outcomes ?? []).filter((result) => result.row.status === 'passed' || result.row.status === 'failed');
230
- if (completed.length === 0) {
231
- return { verdict: 'none', promise: 'unknown', release: hasRelease(release) ? 'unknown' : 'not-selected', facts: null, assertion: null, reasons: [], result: null, others: 0 };
232
- }
233
- const readings = completed.map((result) => assessResult(result, revisions, release));
234
- readings.sort((a, b) => RANK[a.verdict] - RANK[b.verdict] || b.facts!.executedAtMs - a.facts!.executedAtMs);
235
- return { ...readings[0]!, others: readings.length - 1 };
308
+ if (completed.length === 0) return NOTHING(release);
309
+ return best(completed.map((result) => assessResult(result, revisions, release)));
310
+ }
311
+
312
+ /**
313
+ * The reading of a test of the catalog from its current results: the spec's fingerprint now (`test.spec.fingerprint`) against
314
+ * the one each result recorded (`specRevision`), and the build, commit, platform and provenance each states. The same
315
+ * rules as {@link assessBehavior}; a result without a recorded revision, or a test whose spec has no fingerprint, is unknown.
316
+ * Only completed results (passed or failed) are read; a test with none has `none`. Statuses are unchanged by this.
317
+ */
318
+ export function assessTest(test: TestSummary, release: ReleaseIdentity): Assessment {
319
+ const completed = test.outcomes.filter((outcome) => outcome.status === 'passed' || outcome.status === 'failed');
320
+ if (completed.length === 0) return NOTHING(release);
321
+ const current = test.spec?.fingerprint ?? null;
322
+ return best(completed.map((outcome) => read(ofOutcome(outcome), compareRevisions(current, outcome.specRevision), release, { result: null, layer: outcome.layer })));
323
+ }
324
+
325
+ /**
326
+ * The order the explorer lists tests in when sorted by evidence: what needs a look first (stale, then unknown, then
327
+ * without evidence), evidence that applies last. A rank for sorting only; it is never shown as a status.
328
+ */
329
+ export function attentionRank(assessment: Assessment): number {
330
+ return { stale: 0, unknown: 1, none: 2, applies: 3 }[assessment.verdict];
236
331
  }
237
332
 
238
333
  /** Whether a behavior belongs under "stale or unverified": anything but evidence that applies. */