@crvouga/atlas 1.16.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.
- package/CHANGELOG.md +7 -1
- package/dist/reports/src/test-catalog.d.ts +13 -0
- package/dist/reports/src/test-catalog.js +21 -5
- package/dist/schema/src/api.d.ts +29 -0
- package/dist/schema/src/api.js +6 -1
- package/docs/extending.md +1 -1
- package/docs/reference/reports.md +17 -0
- package/docs/ui.md +8 -0
- package/llms-full.txt +9 -1
- package/package.json +1 -1
- package/packages/schema/schemas/test-catalog.schema.json +26 -0
- package/packages/schema/schemas/test-history.schema.json +40 -0
- package/packages/server/openapi.json +66 -0
- package/packages/visualizer/src/evidence/applicability-view.tsx +46 -20
- package/packages/visualizer/src/evidence/applicability.test.ts +68 -1
- package/packages/visualizer/src/evidence/applicability.ts +121 -26
- package/packages/visualizer/src/explorer/Explorer.tsx +96 -8
- package/packages/visualizer/src/explorer/TestDetail.tsx +14 -1
- package/packages/visualizer/src/explorer/applicability.ts +43 -0
- package/packages/visualizer/src/explorer/search.ts +14 -4
|
@@ -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 {
|
|
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
|
-
/**
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
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:
|
|
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:
|
|
118
|
-
platform: labels.platform?.trim() ||
|
|
119
|
-
environment:
|
|
120
|
-
provenance:
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
const facts =
|
|
199
|
-
const
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SpecLogo } from '../components/SpecLogo';
|
|
2
|
-
import { IconVideo, IconX } from '@tabler/icons-react';
|
|
2
|
+
import { IconAlertTriangle, IconVideo, IconX } from '@tabler/icons-react';
|
|
3
3
|
import {
|
|
4
4
|
Accordion,
|
|
5
5
|
Alert,
|
|
@@ -44,6 +44,8 @@ import {
|
|
|
44
44
|
} from '@crvouga/atlas/reports/test-catalog';
|
|
45
45
|
import { Link } from '@tanstack/react-router';
|
|
46
46
|
import { useDeferredValue, useEffect, useMemo, useState } from 'react';
|
|
47
|
+
import { attentionRank, hasRelease, needsAttention, type Assessment, type ReleaseIdentity } from '../evidence/applicability';
|
|
48
|
+
import { ApplicabilityControls, VerdictBadge } from '../evidence/applicability-view';
|
|
47
49
|
import { staticEvidence } from '../evidence/api';
|
|
48
50
|
import { useEvidence } from '../evidence/evidence-context';
|
|
49
51
|
import { ago, plural, useNow } from '../evidence/format';
|
|
@@ -53,6 +55,7 @@ import { behaviorDocs, behaviorId, describeHit, testHasVideo } from '../find/beh
|
|
|
53
55
|
import { useDocSearch } from '../find/doc-search';
|
|
54
56
|
import { hasFilters, passesFilters, withoutToken, type FilterKey, type ParsedSearch } from '../find/query';
|
|
55
57
|
import type { FindDoc, Ranked } from '../find/rank';
|
|
58
|
+
import { assessTests, releaseOf, useExplorerApplicability } from './applicability';
|
|
56
59
|
import { useRunDetail, useTestCatalog } from './catalog';
|
|
57
60
|
import { groupTests, tally, type TestGroupView } from './groups';
|
|
58
61
|
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
@@ -80,6 +83,7 @@ const GROUP_LABEL: Record<TestGroup, string> = {
|
|
|
80
83
|
};
|
|
81
84
|
const SORT_LABEL: Record<ExplorerSort, string> = {
|
|
82
85
|
relevance: 'Best match',
|
|
86
|
+
evidence: 'Stale evidence first',
|
|
83
87
|
spec: 'Spec',
|
|
84
88
|
title: 'Title',
|
|
85
89
|
recent: 'Most recent',
|
|
@@ -119,8 +123,12 @@ function useListing(model: CatalogModel | null, search: ExplorerSearch, q: strin
|
|
|
119
123
|
const list = useMemo(() => [...docs.values()], [docs]);
|
|
120
124
|
const found = useDocSearch(list, q);
|
|
121
125
|
const sort = sortOf(search);
|
|
126
|
+
const { release: releaseBuild, releaseSha, releasePlatform } = search;
|
|
127
|
+
const release = useMemo(() => releaseOf({ release: releaseBuild, releaseSha, releasePlatform }), [releaseBuild, releaseSha, releasePlatform]);
|
|
128
|
+
// Every test's evidence read against its promise and the named release: a reading, never a status.
|
|
129
|
+
const assessments = useMemo(() => (model ? assessTests(model.tests, release) : null), [model?.tests, release]);
|
|
122
130
|
const listing = useMemo(() => {
|
|
123
|
-
if (!model) return null;
|
|
131
|
+
if (!model || !assessments) return null;
|
|
124
132
|
const query: TestQuery = { ...queryOf(search), q: undefined };
|
|
125
133
|
// `has:video` typed in the box and the Has video button are the same filter.
|
|
126
134
|
const operator = { ...found.parsed.filters, video: found.parsed.filters.video || search.video === true };
|
|
@@ -129,17 +137,25 @@ function useListing(model: CatalogModel | null, search: ExplorerSearch, q: strin
|
|
|
129
137
|
const passes = (test: TestSummary, omit: ReadonlySet<FilterKey>) =>
|
|
130
138
|
(!hits || hits.has(behaviorId(test.key))) && (!structured || passesFilters(docs.get(test.key)?.facets, operator, omit));
|
|
131
139
|
let shown = testCatalog(model, { ...query, limit: 20000 }).tests.filter((test) => (!inRun || inRun.has(test.key)) && passes(test, NO_STRUCTURE));
|
|
140
|
+
const attention = (test: TestSummary) => needsAttention(assessments.get(test.key)!);
|
|
141
|
+
if (search.evidence === 'stale') shown = shown.filter(attention);
|
|
132
142
|
if (sort === 'relevance' && hits) {
|
|
133
143
|
const order = new Map([...hits.keys()].map((id, at) => [id, at] as const));
|
|
134
144
|
shown = [...shown].sort((a, b) => (order.get(behaviorId(a.key)) ?? 0) - (order.get(behaviorId(b.key)) ?? 0));
|
|
135
145
|
}
|
|
146
|
+
// Stale evidence first; the order of the spec list is kept among equals.
|
|
147
|
+
if (sort === 'evidence') shown = [...shown].sort((a, b) => attentionRank(assessments.get(a.key)!) - attentionRank(assessments.get(b.key)!));
|
|
136
148
|
// The strip and the status and environment counts describe what the other filters leave.
|
|
137
149
|
const scope = model.tests.filter((test) => matchesQuery(model, test, { ...query, status: [], environment: [] }, inRun) && passes(test, WITHOUT_STATUS_AND_ENVIRONMENT));
|
|
138
150
|
const byEnvironment = scope.filter((test) => matchesQuery(model, test, { environment: query.environment }) && passes(test, WITHOUT_STATUS));
|
|
139
151
|
const byStatus = scope.filter((test) => matchesQuery(model, test, { status: query.status }) && passes(test, WITHOUT_ENVIRONMENT));
|
|
140
152
|
const videoScope = model.tests.filter((test) => matchesQuery(model, test, query, inRun) && passes(test, WITHOUT_VIDEO));
|
|
153
|
+
const evidenceScope = model.tests.filter((test) => matchesQuery(model, test, query, inRun) && passes(test, NO_STRUCTURE));
|
|
141
154
|
return {
|
|
142
155
|
shown,
|
|
156
|
+
assessments,
|
|
157
|
+
release,
|
|
158
|
+
stale: evidenceScope.filter(attention).length,
|
|
143
159
|
withVideo: videoScope.filter((test) => docs.get(test.key)?.facets?.video ?? testHasVideo(test)).length,
|
|
144
160
|
scope,
|
|
145
161
|
byEnvironment,
|
|
@@ -150,7 +166,7 @@ function useListing(model: CatalogModel | null, search: ExplorerSearch, q: strin
|
|
|
150
166
|
runPending: !!search.inRun && run.isPending,
|
|
151
167
|
runMissing: !!search.inRun && run.isSuccess && run.data === null
|
|
152
168
|
};
|
|
153
|
-
}, [model, search, found, docs, sort, inRun, run.isPending, run.isSuccess, run.data]);
|
|
169
|
+
}, [model, search, found, docs, sort, inRun, assessments, release, run.isPending, run.isSuccess, run.data]);
|
|
154
170
|
return listing;
|
|
155
171
|
}
|
|
156
172
|
/** The project home: the published spec kinds, the insight strip, the toolbar, the grouped list and the open test. */
|
|
@@ -301,7 +317,7 @@ export function Explorer() {
|
|
|
301
317
|
That run is not in the report any more.
|
|
302
318
|
</Alert>
|
|
303
319
|
)}
|
|
304
|
-
<TestList tests={listing.shown} search={search} total={model.tests.length} hits={listing.hits} sort={search.sort ?? (listing.hits ? 'relevance' : 'spec')} />
|
|
320
|
+
<TestList tests={listing.shown} search={search} total={model.tests.length} hits={listing.hits} sort={search.sort ?? (listing.hits ? 'relevance' : 'spec')} assessments={listing.assessments} release={listing.release} />
|
|
305
321
|
</>
|
|
306
322
|
))}
|
|
307
323
|
</Box>
|
|
@@ -680,6 +696,19 @@ function Toolbar({
|
|
|
680
696
|
{listing.withVideo.toLocaleString()}
|
|
681
697
|
</Text>
|
|
682
698
|
</Button>
|
|
699
|
+
<Button
|
|
700
|
+
type="button"
|
|
701
|
+
aria-pressed={search.evidence === 'stale'}
|
|
702
|
+
aria-label={`Stale or unverified evidence: ${plural(listing.stale, 'test', 'tests')}`}
|
|
703
|
+
onClick={() => go({ evidence: search.evidence ? undefined : 'stale' })}
|
|
704
|
+
variant={search.evidence === 'stale' ? 'light' : 'subtle'}
|
|
705
|
+
leftSection={<IconAlertTriangle size={16} aria-hidden="true" />}
|
|
706
|
+
>
|
|
707
|
+
Stale or unverified{' '}
|
|
708
|
+
<Text span inherit>
|
|
709
|
+
{listing.stale.toLocaleString()}
|
|
710
|
+
</Text>
|
|
711
|
+
</Button>
|
|
683
712
|
{model.facets.producers.length > 1 && (
|
|
684
713
|
<Box component="label">
|
|
685
714
|
<VisuallyHidden>Producer</VisuallyHidden>
|
|
@@ -788,7 +817,8 @@ function Toolbar({
|
|
|
788
817
|
artifactTag: undefined,
|
|
789
818
|
inherited: undefined,
|
|
790
819
|
inRun: undefined,
|
|
791
|
-
video: undefined
|
|
820
|
+
video: undefined,
|
|
821
|
+
evidence: undefined
|
|
792
822
|
});
|
|
793
823
|
}}
|
|
794
824
|
variant="subtle"
|
|
@@ -798,10 +828,47 @@ function Toolbar({
|
|
|
798
828
|
)}
|
|
799
829
|
</Flex>
|
|
800
830
|
<SearchNotes issues={listing.parsed.issues} semantic={listing.semantic} />
|
|
831
|
+
<ReleaseReading />
|
|
801
832
|
</Box>
|
|
802
833
|
);
|
|
803
834
|
}
|
|
804
|
-
|
|
835
|
+
/**
|
|
836
|
+
* Names the release the evidence of the tests is read against (build, commit and platform). Folded away until a release is
|
|
837
|
+
* named, because most readers only ask whether evidence is stale.
|
|
838
|
+
*/
|
|
839
|
+
function ReleaseReading() {
|
|
840
|
+
const applicability = useExplorerApplicability();
|
|
841
|
+
const named = hasRelease(applicability.release) || !!applicability.release.platform;
|
|
842
|
+
return (
|
|
843
|
+
<Accordion variant="separated" defaultValue={named ? 'release' : null} mt="sm">
|
|
844
|
+
<Accordion.Item value="release">
|
|
845
|
+
<Accordion.Control>Release the evidence is read against</Accordion.Control>
|
|
846
|
+
<Accordion.Panel>
|
|
847
|
+
<ApplicabilityControls state={applicability} filter={false} />
|
|
848
|
+
</Accordion.Panel>
|
|
849
|
+
</Accordion.Item>
|
|
850
|
+
</Accordion>
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
function TestList({
|
|
854
|
+
tests,
|
|
855
|
+
search,
|
|
856
|
+
total,
|
|
857
|
+
hits,
|
|
858
|
+
sort,
|
|
859
|
+
assessments,
|
|
860
|
+
release
|
|
861
|
+
}: {
|
|
862
|
+
tests: readonly TestSummary[];
|
|
863
|
+
search: ExplorerSearch;
|
|
864
|
+
total: number;
|
|
865
|
+
hits: ReadonlyMap<string, Ranked> | null;
|
|
866
|
+
sort: ExplorerSort;
|
|
867
|
+
assessments: ReadonlyMap<string, Assessment>;
|
|
868
|
+
release: ReleaseIdentity;
|
|
869
|
+
}) {
|
|
870
|
+
// A reading is shown on every row only when the reader asked about evidence; otherwise only evidence known to be stale.
|
|
871
|
+
const asked = search.evidence === 'stale' || sort === 'evidence' || hasRelease(release);
|
|
805
872
|
const by = search.group ?? 'spec';
|
|
806
873
|
const groups = useMemo(() => groupTests(tests, by, sort === 'spec'), [tests, by, sort]);
|
|
807
874
|
const [overrides, setOverrides] = useState<ReadonlyMap<string, boolean>>(new Map());
|
|
@@ -864,6 +931,8 @@ function TestList({ tests, search, total, hits, sort }: { tests: readonly TestSu
|
|
|
864
931
|
open={isOpen(group)}
|
|
865
932
|
selected={search.test}
|
|
866
933
|
hits={hits}
|
|
934
|
+
assessments={assessments}
|
|
935
|
+
asked={asked}
|
|
867
936
|
onToggle={() => setOverrides(new Map(overrides).set(`${by}|${group.key}`, !isOpen(group)))}
|
|
868
937
|
/>
|
|
869
938
|
</Stack>
|
|
@@ -878,6 +947,8 @@ function TestGroupSection({
|
|
|
878
947
|
open,
|
|
879
948
|
selected,
|
|
880
949
|
hits,
|
|
950
|
+
assessments,
|
|
951
|
+
asked,
|
|
881
952
|
onToggle
|
|
882
953
|
}: {
|
|
883
954
|
group: TestGroupView;
|
|
@@ -885,6 +956,8 @@ function TestGroupSection({
|
|
|
885
956
|
open: boolean;
|
|
886
957
|
selected: string | undefined;
|
|
887
958
|
hits: ReadonlyMap<string, Ranked> | null;
|
|
959
|
+
assessments: ReadonlyMap<string, Assessment>;
|
|
960
|
+
asked: boolean;
|
|
888
961
|
onToggle: () => void;
|
|
889
962
|
}) {
|
|
890
963
|
const [shown, setShown] = useState(by === 'none' ? 100 : GROUP_ROWS);
|
|
@@ -892,7 +965,7 @@ function TestGroupSection({
|
|
|
892
965
|
const rows = (
|
|
893
966
|
<List spacing="xs" listStyleType="none">
|
|
894
967
|
{group.tests.slice(0, shown).map((test) => (
|
|
895
|
-
<TestRow key={test.key} test={test} selected={selected === test.key} now={now} hit={hits?.get(behaviorId(test.key))} />
|
|
968
|
+
<TestRow key={test.key} test={test} selected={selected === test.key} now={now} hit={hits?.get(behaviorId(test.key))} assessment={assessments.get(test.key)} asked={asked} />
|
|
896
969
|
))}
|
|
897
970
|
{group.tests.length > shown && (
|
|
898
971
|
<List.Item>
|
|
@@ -937,7 +1010,21 @@ function TestGroupSection({
|
|
|
937
1010
|
</Box>
|
|
938
1011
|
);
|
|
939
1012
|
}
|
|
940
|
-
function TestRow({
|
|
1013
|
+
function TestRow({
|
|
1014
|
+
test,
|
|
1015
|
+
selected,
|
|
1016
|
+
now,
|
|
1017
|
+
hit,
|
|
1018
|
+
assessment,
|
|
1019
|
+
asked
|
|
1020
|
+
}: {
|
|
1021
|
+
test: TestSummary;
|
|
1022
|
+
selected: boolean;
|
|
1023
|
+
now: number;
|
|
1024
|
+
hit: Ranked | undefined;
|
|
1025
|
+
assessment: Assessment | undefined;
|
|
1026
|
+
asked: boolean;
|
|
1027
|
+
}) {
|
|
941
1028
|
const why = hit ? describeHit(hit) : '';
|
|
942
1029
|
const failure = test.status === 'failed' ? test.outcomes.find((outcome) => outcome.status === 'failed')?.detail.split('\n')[0] : '';
|
|
943
1030
|
return (
|
|
@@ -966,6 +1053,7 @@ function TestRow({ test, selected, now, hit }: { test: TestSummary; selected: bo
|
|
|
966
1053
|
<Group gap="xs">
|
|
967
1054
|
<EnvironmentChips test={test} />
|
|
968
1055
|
<Badges test={test} />
|
|
1056
|
+
{assessment && (assessment.verdict === 'stale' || (asked && assessment.verdict !== 'applies')) && <VerdictBadge verdict={assessment.verdict} />}
|
|
969
1057
|
</Group>
|
|
970
1058
|
{failure && (
|
|
971
1059
|
<Text size="xs" c="red.7" lineClamp={2}>
|
|
@@ -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
|
|
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
|
+
}
|