@crvouga/atlas 1.10.0 → 1.11.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 +9 -1
- package/dist/reports/src/review-collection-store.d.ts +47 -0
- package/dist/reports/src/review-collection-store.js +214 -0
- package/dist/reports/src/review-collection.d.ts +88 -0
- package/dist/reports/src/review-collection.js +144 -0
- package/dist/reports/src/test-catalog.d.ts +2 -0
- package/dist/reports/src/test-catalog.js +1 -1
- package/dist/schema/src/api.d.ts +574 -0
- package/dist/schema/src/api.js +152 -1
- package/dist/server/src/app.js +4 -1
- package/dist/server/src/openapi.js +46 -1
- package/dist/server/src/review-collections.d.ts +39 -0
- package/dist/server/src/review-collections.js +179 -0
- package/docs/extending.md +1 -1
- package/docs/reference/http-api.md +122 -0
- package/docs/reference/reports.md +294 -0
- package/docs/reference/schema.md +131 -0
- package/docs/reference/server.md +44 -0
- package/docs/rich-evidence.md +38 -0
- package/docs/server.md +41 -0
- package/docs/ui.md +45 -0
- package/llms-full.txt +247 -1
- package/package.json +1 -1
- package/packages/schema/schemas/review-collection.schema.json +398 -0
- package/packages/schema/schemas/review-manifest.schema.json +475 -0
- package/packages/server/openapi.json +6729 -4084
- package/packages/visualizer/src/app/router.tsx +6 -1
- package/packages/visualizer/src/evidence/applicability-view.tsx +233 -0
- package/packages/visualizer/src/evidence/applicability.test.ts +131 -0
- package/packages/visualizer/src/evidence/applicability.ts +241 -0
- package/packages/visualizer/src/evidence/artifact-tags.tsx +2 -1
- package/packages/visualizer/src/evidence/behavior-panel.tsx +11 -1
- package/packages/visualizer/src/evidence/feature-page.tsx +25 -4
- package/packages/visualizer/src/explorer/ArtifactsView.tsx +19 -5
- package/packages/visualizer/src/explorer/Explorer.tsx +7 -2
- package/packages/visualizer/src/explorer/RecordingTextSearch.tsx +355 -0
- package/packages/visualizer/src/explorer/collection-model.test.ts +31 -0
- package/packages/visualizer/src/explorer/collection-model.ts +45 -0
- package/packages/visualizer/src/explorer/collections.tsx +513 -0
- package/packages/visualizer/src/explorer/playlist-entries.ts +29 -0
- package/packages/visualizer/src/explorer/playlist.tsx +75 -16
- package/packages/visualizer/src/explorer/recording-text.test.ts +149 -0
- package/packages/visualizer/src/explorer/recording-text.ts +490 -0
- package/packages/visualizer/src/explorer/search.ts +10 -2
|
@@ -31,7 +31,12 @@ const SpecSearch = RootSearch.extend({
|
|
|
31
31
|
check: z.string().min(1).optional().catch(undefined),
|
|
32
32
|
layer: z.string().min(1).optional().catch(undefined),
|
|
33
33
|
view: z.enum(['recording']).optional().catch(undefined),
|
|
34
|
-
moment: z.coerce.number().int().positive().optional().catch(undefined)
|
|
34
|
+
moment: z.coerce.number().int().positive().optional().catch(undefined),
|
|
35
|
+
/** The release evidence is read against: its build, commit and platform, and the evidence filter (see `evidence/applicability.ts`). A build such as `90` is read from a typed link as a number: coerced back to its text. */
|
|
36
|
+
release: z.coerce.string().min(1).optional().catch(undefined),
|
|
37
|
+
releaseSha: z.coerce.string().min(1).optional().catch(undefined),
|
|
38
|
+
releasePlatform: z.coerce.string().min(1).optional().catch(undefined),
|
|
39
|
+
evidence: z.enum(['stale']).optional().catch(undefined)
|
|
35
40
|
});
|
|
36
41
|
export type SpecSearch = z.infer<typeof SpecSearch>;
|
|
37
42
|
/** A fresh route tree per router, so a test can run its own viewer beside the page's. */
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Evidence applicability view
|
|
3
|
+
*
|
|
4
|
+
* Shows whether the evidence behind a behavior applies to it: a compact summary (verdict, when the checks ran, the
|
|
5
|
+
* promise, the release), the controls that name a release and filter to stale or unverified evidence, and the
|
|
6
|
+
* address state that keeps both shareable. A verdict is a reading of the evidence and never changes an outcome.
|
|
7
|
+
*/
|
|
8
|
+
import { Alert, Badge, Button, Group, List, Paper, SimpleGrid, Stack, Text, TextInput, Title } from '@mantine/core';
|
|
9
|
+
import { useNavigate, useSearch } from '@tanstack/react-router';
|
|
10
|
+
import { useCallback, useMemo } from 'react';
|
|
11
|
+
|
|
12
|
+
import { hasRelease, VERDICTS, type Assessment, type ReleaseIdentity, type ReleaseMatch, type Verdict } from './applicability';
|
|
13
|
+
import { exactTime, ago, plural, shortSha, useNow } from './format';
|
|
14
|
+
|
|
15
|
+
/** The release and the evidence filter the address holds, with the ways to change them. */
|
|
16
|
+
export type ApplicabilityState = {
|
|
17
|
+
readonly release: ReleaseIdentity;
|
|
18
|
+
/** True when only behaviors with stale or unverified evidence are listed. */
|
|
19
|
+
readonly onlyStale: boolean;
|
|
20
|
+
readonly setRelease: (next: ReleaseIdentity) => void;
|
|
21
|
+
readonly setOnlyStale: (on: boolean) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The release (`release` build, `releaseSha`, `releasePlatform`) and evidence filter (`evidence=stale`) of the spec
|
|
26
|
+
* address. They are held in the address, so a shared link shows the same reading. Must be used under the spec route.
|
|
27
|
+
*/
|
|
28
|
+
export function useApplicabilityState(): ApplicabilityState {
|
|
29
|
+
const search = useSearch({ from: '/specs/$kind' });
|
|
30
|
+
const navigate = useNavigate({ from: '/specs/$kind' });
|
|
31
|
+
const release = useMemo<ReleaseIdentity>(
|
|
32
|
+
() => ({
|
|
33
|
+
...(search.release ? { build: search.release } : {}),
|
|
34
|
+
...(search.releaseSha ? { sha: search.releaseSha } : {}),
|
|
35
|
+
...(search.releasePlatform ? { platform: search.releasePlatform } : {})
|
|
36
|
+
}),
|
|
37
|
+
[search.release, search.releaseSha, search.releasePlatform]
|
|
38
|
+
);
|
|
39
|
+
const setRelease = useCallback(
|
|
40
|
+
(next: ReleaseIdentity) =>
|
|
41
|
+
void navigate({
|
|
42
|
+
search: (prev) => ({
|
|
43
|
+
...prev,
|
|
44
|
+
release: next.build || undefined,
|
|
45
|
+
releaseSha: next.sha || undefined,
|
|
46
|
+
releasePlatform: next.platform || undefined
|
|
47
|
+
}),
|
|
48
|
+
replace: true
|
|
49
|
+
}),
|
|
50
|
+
[navigate]
|
|
51
|
+
);
|
|
52
|
+
const setOnlyStale = useCallback(
|
|
53
|
+
(on: boolean) => void navigate({ search: (prev) => ({ ...prev, evidence: on ? ('stale' as const) : undefined }), replace: true }),
|
|
54
|
+
[navigate]
|
|
55
|
+
);
|
|
56
|
+
return { release, onlyStale: search.evidence === 'stale', setRelease, setOnlyStale };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A verdict in words, with its reason on hover. */
|
|
60
|
+
export function VerdictBadge({ verdict }: { verdict: Verdict }) {
|
|
61
|
+
return (
|
|
62
|
+
<Badge data-applicability={verdict} color={VERDICTS[verdict].color} variant="light" title={VERDICTS[verdict].meaning}>
|
|
63
|
+
{VERDICTS[verdict].label}
|
|
64
|
+
</Badge>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const RELEASE_WORDS: Record<ReleaseMatch, string> = {
|
|
69
|
+
'not-selected': 'No release named',
|
|
70
|
+
exact: 'Exact match of the release',
|
|
71
|
+
asserted: 'Asserted to hold for the release',
|
|
72
|
+
other: 'Recorded against another build',
|
|
73
|
+
unknown: 'Not known to apply to the release'
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A compact reading of one behavior's evidence: the verdict; when the checks ran (the age of the evidence, whatever
|
|
78
|
+
* was uploaded or enriched since); what it ran against; and why. Stale evidence is stated first and prominently, and
|
|
79
|
+
* the recorded outcome is never described as changed.
|
|
80
|
+
*/
|
|
81
|
+
export function ApplicabilitySummary({ assessment, release }: { assessment: Assessment; release: ReleaseIdentity }) {
|
|
82
|
+
const now = useNow();
|
|
83
|
+
const { verdict, facts, reasons } = assessment;
|
|
84
|
+
if (verdict === 'none' || !facts) {
|
|
85
|
+
return (
|
|
86
|
+
<Text component="div" c="dimmed" data-applicability="none">
|
|
87
|
+
No completed result, so there is no evidence to apply.
|
|
88
|
+
</Text>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const named = hasRelease(release);
|
|
92
|
+
return (
|
|
93
|
+
<Stack gap="xs" component="section" aria-label="Applicability of this evidence" data-applicability={verdict} data-release={assessment.release}>
|
|
94
|
+
<Group gap="sm" wrap="wrap">
|
|
95
|
+
<VerdictBadge verdict={verdict} />
|
|
96
|
+
<Text span inherit c="dimmed">
|
|
97
|
+
{VERDICTS[verdict].meaning}
|
|
98
|
+
</Text>
|
|
99
|
+
</Group>
|
|
100
|
+
{assessment.release === 'other' && (
|
|
101
|
+
<Alert color="orange" role="status">
|
|
102
|
+
This recording is not proof of the named release. Its recorded outcome is unchanged.
|
|
103
|
+
</Alert>
|
|
104
|
+
)}
|
|
105
|
+
<Group gap="md" wrap="wrap" c="dimmed">
|
|
106
|
+
<Text span inherit>
|
|
107
|
+
Checks ran:{' '}
|
|
108
|
+
<Text span inherit fw={600}>
|
|
109
|
+
<time dateTime={new Date(facts.executedAtMs).toISOString()} title={exactTime(facts.executedAtMs)}>
|
|
110
|
+
{exactTime(facts.executedAtMs)} ({ago(facts.executedAtMs, now)})
|
|
111
|
+
</time>
|
|
112
|
+
</Text>
|
|
113
|
+
</Text>
|
|
114
|
+
<Text span inherit>
|
|
115
|
+
Build:{' '}
|
|
116
|
+
<Text span inherit fw={600}>
|
|
117
|
+
{facts.build ?? 'not recorded'}
|
|
118
|
+
</Text>
|
|
119
|
+
</Text>
|
|
120
|
+
<Text span inherit>
|
|
121
|
+
Commit:{' '}
|
|
122
|
+
<Text span inherit fw={600}>
|
|
123
|
+
{shortSha(facts.sha) ?? 'not recorded'}
|
|
124
|
+
{facts.provenance === 'modified' ? ' with local changes' : facts.provenance === 'unknown' ? ' (origin not captured)' : ''}
|
|
125
|
+
</Text>
|
|
126
|
+
</Text>
|
|
127
|
+
<Text span inherit>
|
|
128
|
+
Platform:{' '}
|
|
129
|
+
<Text span inherit fw={600}>
|
|
130
|
+
{facts.platform ?? 'not recorded'}
|
|
131
|
+
</Text>
|
|
132
|
+
</Text>
|
|
133
|
+
</Group>
|
|
134
|
+
{named && (
|
|
135
|
+
<Text component="div" c="dimmed">
|
|
136
|
+
Release {[release.build && `build ${release.build}`, release.sha && `commit ${release.sha.slice(0, 7)}`, release.platform].filter(Boolean).join(', ')}:{' '}
|
|
137
|
+
<Text span inherit fw={600}>
|
|
138
|
+
{RELEASE_WORDS[assessment.release]}
|
|
139
|
+
</Text>
|
|
140
|
+
</Text>
|
|
141
|
+
)}
|
|
142
|
+
{assessment.assertion && (
|
|
143
|
+
<Text component="div" c="dimmed" data-assertion={assessment.assertion.by ? 'policy' : 'unattributed'}>
|
|
144
|
+
Asserts it still holds for {assessment.assertion.builds.join(', ')}
|
|
145
|
+
{assessment.assertion.by ? ` (by ${assessment.assertion.by})` : ', with no policy stated'}.
|
|
146
|
+
</Text>
|
|
147
|
+
)}
|
|
148
|
+
{reasons.length > 0 && (
|
|
149
|
+
<List spacing={4}>
|
|
150
|
+
{reasons.map((reason) => (
|
|
151
|
+
<List.Item key={reason}>{reason}</List.Item>
|
|
152
|
+
))}
|
|
153
|
+
</List>
|
|
154
|
+
)}
|
|
155
|
+
<Text component="div" c="dimmed">
|
|
156
|
+
Its age counts from when the checks ran, not from when it was uploaded or annotated.
|
|
157
|
+
{assessment.others > 0 ? ` Best of ${plural(assessment.others + 1, 'completed result', 'completed results')}.` : ''}
|
|
158
|
+
</Text>
|
|
159
|
+
</Stack>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Names the release evidence is read against (build, commit and platform, each optional) and filters a feature to
|
|
165
|
+
* its behaviors with stale or unverified evidence. Both live in the address. A branch name or a passing run never
|
|
166
|
+
* names a release.
|
|
167
|
+
*/
|
|
168
|
+
export function ApplicabilityControls({
|
|
169
|
+
state,
|
|
170
|
+
stale,
|
|
171
|
+
total
|
|
172
|
+
}: {
|
|
173
|
+
state: ApplicabilityState;
|
|
174
|
+
/** How many behaviors have stale or unverified evidence. */
|
|
175
|
+
stale: number;
|
|
176
|
+
total: number;
|
|
177
|
+
}) {
|
|
178
|
+
const { release, setRelease, onlyStale, setOnlyStale } = state;
|
|
179
|
+
return (
|
|
180
|
+
<Paper component="section" aria-label="Evidence applicability" withBorder p="md" my="md">
|
|
181
|
+
<Stack gap="sm">
|
|
182
|
+
<Title order={2}>Evidence applicability</Title>
|
|
183
|
+
<Text component="div" c="dimmed">
|
|
184
|
+
Name the release you care about to see whether each recording was made against it. Without one, evidence is read against the current promise only.
|
|
185
|
+
</Text>
|
|
186
|
+
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
|
187
|
+
<TextInput
|
|
188
|
+
label="Release build"
|
|
189
|
+
description="Compared with each result's build label"
|
|
190
|
+
value={release.build ?? ''}
|
|
191
|
+
onChange={(event) => setRelease({ ...release, build: event.currentTarget.value })}
|
|
192
|
+
/>
|
|
193
|
+
<TextInput
|
|
194
|
+
label="Release commit"
|
|
195
|
+
description="Compared with the commit the checks ran at"
|
|
196
|
+
value={release.sha ?? ''}
|
|
197
|
+
onChange={(event) => setRelease({ ...release, sha: event.currentTarget.value })}
|
|
198
|
+
/>
|
|
199
|
+
<TextInput
|
|
200
|
+
label="Release platform"
|
|
201
|
+
description="For example ios, android or desktop-web"
|
|
202
|
+
value={release.platform ?? ''}
|
|
203
|
+
onChange={(event) => setRelease({ ...release, platform: event.currentTarget.value })}
|
|
204
|
+
/>
|
|
205
|
+
</SimpleGrid>
|
|
206
|
+
{release.platform && !hasRelease(release) && (
|
|
207
|
+
<Text role="status" component="div" c="dimmed">
|
|
208
|
+
A platform alone does not name a release. Add its build or commit.
|
|
209
|
+
</Text>
|
|
210
|
+
)}
|
|
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 && (
|
|
226
|
+
<Text role="status" component="div" c="dimmed">
|
|
227
|
+
Every behavior has evidence that applies.
|
|
228
|
+
</Text>
|
|
229
|
+
)}
|
|
230
|
+
</Stack>
|
|
231
|
+
</Paper>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { LayerHeader, LayerRow } from '@crvouga/atlas/reports/evidence-types';
|
|
2
|
+
import type { CheckSlot } from '@crvouga/atlas/reports/evidence-view';
|
|
3
|
+
import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
|
|
4
|
+
import { expect, test } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import { assessBehavior, assessResult, hasRelease, needsAttention } from './applicability';
|
|
7
|
+
import { behaviorOf, type RevisionSource } from './behavior';
|
|
8
|
+
import { featureModel } from './gherkin';
|
|
9
|
+
|
|
10
|
+
const FEATURE = featureModel('Feature: Lists\n Scenario: Add\n When adding tea\n')!;
|
|
11
|
+
const ADD = FEATURE.rules[0]!.scenarios[0]!;
|
|
12
|
+
|
|
13
|
+
type Extra = { layer?: Partial<LayerHeader>; labels?: Record<string, string>; layerLabels?: Record<string, string> };
|
|
14
|
+
function result(id: string, { layer = {}, labels, layerLabels }: Extra = {}, status: LayerRow['status'] = 'passed'): EvidenceResult {
|
|
15
|
+
const evidence = (record: Record<string, string> | undefined) =>
|
|
16
|
+
record ? { attachments: 0, steps: 0, annotations: 0, attempts: 0, links: 0, tags: [], labels: record, roles: [] } : undefined;
|
|
17
|
+
return {
|
|
18
|
+
key: id,
|
|
19
|
+
slot: 's',
|
|
20
|
+
layer: {
|
|
21
|
+
id, backend: 'b', producer: 'p', packageDir: null, recordedAtMs: 1_000, sha: null, branch: 'main', environment: 'desktop-web', runUrl: null, parameters: {},
|
|
22
|
+
recording: null, live: false, counts: { passed: 0, failed: 0, running: 0, 'not-run': 0 }, execution: id,
|
|
23
|
+
...(layerLabels ? { evidence: evidence(layerLabels) } : {}),
|
|
24
|
+
...layer
|
|
25
|
+
},
|
|
26
|
+
parameters: {},
|
|
27
|
+
warning: null,
|
|
28
|
+
row: { kind: 'behavior', id: 'x', title: 'Lists: Add', status, specPath: 'specs/a.feature', detail: '', measurements: [], testId: null, variant: {}, durationMs: null, ...(labels ? { evidence: evidence(labels) } : {}) }
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const revisions = (current: string | null, recorded: Record<string, string> = {}): RevisionSource => ({ current, recorded: (id) => recorded[id] });
|
|
32
|
+
const same = revisions('f1', { l1: 'f1', l2: 'f1', l3: 'f1' });
|
|
33
|
+
const slot = (outcomes: EvidenceResult[], status: CheckSlot['status'] = 'passed'): CheckSlot => ({
|
|
34
|
+
key: 'k', id: 'k', title: 'Lists: Add', specPath: 'specs/a.feature', status, outcomes, diagnostics: [], latestAtMs: 1, live: false
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('a changed promise makes the only recording stale, whatever the release', () => {
|
|
38
|
+
const old = assessResult(result('l1'), revisions('f2', { l1: 'f1' }), {});
|
|
39
|
+
expect(old.verdict).toBe('stale');
|
|
40
|
+
expect(old.promise).toBe('different');
|
|
41
|
+
expect(old.reasons[0]).toContain('not proof of the current promise');
|
|
42
|
+
expect(assessResult(result('l1'), revisions('f1', { l1: 'f1' }), {}).verdict).toBe('applies');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a missing revision is unknown, never a match', () => {
|
|
46
|
+
expect(assessResult(result('l1'), revisions('f1'), {}).verdict).toBe('unknown');
|
|
47
|
+
expect(assessResult(result('l1'), revisions(null, { l1: 'f1' }), {}).verdict).toBe('unknown');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('the age of evidence is when the checks ran, not when it was published or enriched', () => {
|
|
51
|
+
const facts = assessResult(result('l1', { layer: { recordedAtMs: 123_456 } }), same, {}).facts!;
|
|
52
|
+
expect(facts.executedAtMs).toBe(123_456);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a desktop recording says nothing about a phone release', () => {
|
|
56
|
+
const phone = { build: '90', platform: 'ios' };
|
|
57
|
+
const got = assessResult(result('l1', { labels: { build: '90' } }), same, phone);
|
|
58
|
+
expect(got.release).toBe('unknown');
|
|
59
|
+
expect(got.verdict).toBe('unknown');
|
|
60
|
+
expect(got.reasons.join(' ')).toContain('desktop-web');
|
|
61
|
+
// The same build on the release's platform is an exact match.
|
|
62
|
+
expect(assessResult(result('l1', { layer: { environment: 'ios' }, labels: { build: '90' } }), same, phone).release).toBe('exact');
|
|
63
|
+
// The platform label overrides the environment name.
|
|
64
|
+
expect(assessResult(result('l1', { labels: { build: '90', platform: 'ios' } }), same, phone).release).toBe('exact');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a mapped release marks evidence for another build, and a branch name or green check never supplies one', () => {
|
|
68
|
+
const release = { build: '91' };
|
|
69
|
+
const other = assessResult(result('l1', { labels: { build: '90' } }), same, release);
|
|
70
|
+
expect(other.release).toBe('other');
|
|
71
|
+
expect(other.verdict).toBe('stale');
|
|
72
|
+
expect(assessResult(result('l1', { labels: { build: '91' } }), same, release).verdict).toBe('applies');
|
|
73
|
+
// Evidence that does not say which build it ran against is unknown, even on the "release" branch with a passing result.
|
|
74
|
+
const unlabelled = assessResult(result('l1', { layer: { branch: 'release/91' } }), same, release);
|
|
75
|
+
expect(unlabelled.release).toBe('unknown');
|
|
76
|
+
expect(unlabelled.verdict).toBe('unknown');
|
|
77
|
+
// A commit is compared when one is named, abbreviated or not.
|
|
78
|
+
const sha = 'a'.repeat(40);
|
|
79
|
+
expect(assessResult(result('l1', { layer: { sha } }), same, { sha: 'aaaaaaa' }).release).toBe('exact');
|
|
80
|
+
expect(assessResult(result('l1', { layer: { sha } }), same, { sha: 'b'.repeat(40) }).release).toBe('other');
|
|
81
|
+
expect(assessResult(result('l1'), same, { sha }).release).toBe('unknown');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('local changes or incomplete provenance can never be an exact build match', () => {
|
|
85
|
+
const sha = 'a'.repeat(40);
|
|
86
|
+
const dirty = assessResult(result('l1', { layer: { sha, freshness: 'modified' }, labels: { build: '91' } }), same, { build: '91', sha });
|
|
87
|
+
expect(dirty.release).toBe('unknown');
|
|
88
|
+
expect(dirty.reasons.join(' ')).toContain('uncommitted changes');
|
|
89
|
+
const unknown = assessResult(result('l1', { layer: { sha, freshness: 'unknown' } }), same, { sha });
|
|
90
|
+
expect(unknown.release).toBe('unknown');
|
|
91
|
+
expect(unknown.reasons.join(' ')).toContain('not captured');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('an assertion of compatibility is accepted only with a stated policy and an unchanged promise', () => {
|
|
95
|
+
const release = { build: '91' };
|
|
96
|
+
const asserted = (labels: Record<string, string>, revs = same) => assessResult(result('l1', { labels: { build: '90', ...labels } }), revs, release);
|
|
97
|
+
const accepted = asserted({ 'applies-to': '91, 92', 'applies-to-by': 'release-policy-7' });
|
|
98
|
+
expect(accepted.release).toBe('asserted');
|
|
99
|
+
expect(accepted.verdict).toBe('applies');
|
|
100
|
+
expect(accepted.assertion).toEqual({ builds: ['91', '92'], by: 'release-policy-7' });
|
|
101
|
+
// No policy: not accepted, and not described as a match either.
|
|
102
|
+
expect(asserted({ 'applies-to': '91' }).release).toBe('unknown');
|
|
103
|
+
// A different promise wins over any assertion.
|
|
104
|
+
expect(asserted({ 'applies-to': '91', 'applies-to-by': 'p' }, revisions('f2', { l1: 'f1' })).verdict).toBe('stale');
|
|
105
|
+
// An assertion about other builds does not cover this one.
|
|
106
|
+
expect(asserted({ 'applies-to': '95', 'applies-to-by': 'p' }).release).toBe('other');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a platform alone names no release', () => {
|
|
110
|
+
expect(hasRelease({ platform: 'ios' })).toBe(false);
|
|
111
|
+
expect(assessResult(result('l1'), same, { platform: 'ios' }).release).toBe('not-selected');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a behavior is read by its best completed result and its status is untouched', () => {
|
|
115
|
+
const failing = result('l1', { labels: { build: '91' } }, 'failed');
|
|
116
|
+
const old = result('l2', { layer: { recordedAtMs: 5 }, labels: { build: '90' } });
|
|
117
|
+
const behavior = behaviorOf(ADD, slot([old, failing], 'failed'), null, same, 'k');
|
|
118
|
+
const got = assessBehavior(behavior, same, { build: '91' });
|
|
119
|
+
expect(got.verdict).toBe('applies');
|
|
120
|
+
expect(got.others).toBe(1);
|
|
121
|
+
expect(got.result).toBe(failing);
|
|
122
|
+
expect(behavior.state).toBe('failed');
|
|
123
|
+
// No completed result: no evidence, which is not a failure and is found with the stale ones.
|
|
124
|
+
const none = assessBehavior(behaviorOf(ADD, null, null, same, 'k'), same, {});
|
|
125
|
+
expect(none.verdict).toBe('none');
|
|
126
|
+
expect(needsAttention(none)).toBe(true);
|
|
127
|
+
expect(needsAttention(got)).toBe(false);
|
|
128
|
+
// A running result is not evidence.
|
|
129
|
+
const running = assessBehavior(behaviorOf(ADD, slot([result('l1', {}, 'running')]), null, same, 'k'), same, {});
|
|
130
|
+
expect(running.verdict).toBe('none');
|
|
131
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Evidence applicability
|
|
3
|
+
*
|
|
4
|
+
* Whether recorded evidence applies to a behavior as it is written now and, when a release is named, to that
|
|
5
|
+
* release. Four facts stay apart: the promise's revision (the spec now) against the revision the evidence recorded,
|
|
6
|
+
* when the checks ran (never when they were published or enriched), what the evidence ran against (build, commit,
|
|
7
|
+
* platform, and whether the checkout was clean), and the release a reader named. Nothing here decides an outcome:
|
|
8
|
+
* statuses and winners come from the combined report, and a verdict is a reading of them that never turns a pass
|
|
9
|
+
* into a failure. What cannot be compared is `unknown`, never `applies`. A branch name, a green check or a run's
|
|
10
|
+
* recency never supply deployment state.
|
|
11
|
+
*
|
|
12
|
+
* Producers state what they know with the open vocabulary of `labels` (see docs/extending.md): `build` (the build
|
|
13
|
+
* the app under test was), `platform` (the client family; the environment when absent), `applies-to` (a comma
|
|
14
|
+
* separated list of builds or commits the producer asserts the evidence is still good for) and `applies-to-by`
|
|
15
|
+
* (the policy, person or tool that makes the assertion). An assertion without `applies-to-by` is not accepted.
|
|
16
|
+
*/
|
|
17
|
+
import type { LayerHeader } from '@crvouga/atlas/reports/evidence-types';
|
|
18
|
+
import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
|
|
19
|
+
|
|
20
|
+
import { compareRevisions, type Behavior, type Revision, type RevisionSource } from './behavior';
|
|
21
|
+
|
|
22
|
+
/** The release a reader names: the build and/or commit that was deployed, on a platform. Every part is optional. */
|
|
23
|
+
export type ReleaseIdentity = {
|
|
24
|
+
/** The deployed build (`4821`, `2.4.1`), compared with the `build` label of the evidence. */
|
|
25
|
+
readonly build?: string;
|
|
26
|
+
/** The deployed commit, full or abbreviated, compared with the layer's commit. */
|
|
27
|
+
readonly sha?: string;
|
|
28
|
+
/** The platform of the release (`ios`, `android`, `desktop-web`), compared with the evidence's `platform` label or environment. */
|
|
29
|
+
readonly platform?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Whether a release is named: a build or a commit is needed to compare against, a platform alone selects nothing. */
|
|
33
|
+
export function hasRelease(release: ReleaseIdentity): boolean {
|
|
34
|
+
return Boolean(release.build?.trim() || release.sha?.trim());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where evidence stands against the release.
|
|
39
|
+
* `not-selected`: no release was named. `exact`: the build and/or commit match and the checkout was clean.
|
|
40
|
+
* `asserted`: a different build, with an explicit stated policy that the evidence still holds.
|
|
41
|
+
* `other`: a known different build or commit. `unknown`: it cannot be compared (another platform, no build recorded,
|
|
42
|
+
* a modified checkout, an assertion with no policy).
|
|
43
|
+
*/
|
|
44
|
+
export type ReleaseMatch = 'not-selected' | 'exact' | 'asserted' | 'other' | 'unknown';
|
|
45
|
+
|
|
46
|
+
/** What a reader should take from the evidence: it applies, it does not (stale), it cannot be told, or there is none. */
|
|
47
|
+
export type Verdict = 'applies' | 'stale' | 'unknown' | 'none';
|
|
48
|
+
|
|
49
|
+
/** What each verdict is called, and why. */
|
|
50
|
+
export const VERDICTS: Readonly<Record<Verdict, { readonly label: string; readonly meaning: string; readonly color: string }>> = {
|
|
51
|
+
applies: { label: 'Applies', meaning: 'The evidence was recorded against the current promise and, when a release is named, against that release.', color: 'green' },
|
|
52
|
+
stale: { label: 'Stale', meaning: 'The evidence is of an earlier promise or of another build. It is not proof of what is shown now. The recorded outcome is unchanged.', color: 'orange' },
|
|
53
|
+
unknown: { label: 'Applicability unknown', meaning: 'The evidence cannot be tied to the current promise or the named release. That is not a failure.', color: 'gray' },
|
|
54
|
+
none: { label: 'No evidence', meaning: 'Nothing was recorded for this behavior.', color: 'gray' }
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** What the evidence of one result says about what it ran against. Absent facts are null: unknown, never assumed. */
|
|
58
|
+
export type EvidenceFacts = {
|
|
59
|
+
/** When the checks ran, epoch milliseconds. The age of evidence counts from here, not from publication or enrichment. */
|
|
60
|
+
readonly executedAtMs: number;
|
|
61
|
+
readonly build: string | null;
|
|
62
|
+
readonly sha: string | null;
|
|
63
|
+
/** The `platform` label, else the layer's environment. */
|
|
64
|
+
readonly platform: string | null;
|
|
65
|
+
readonly environment: string | null;
|
|
66
|
+
/** `modified` or `unknown` when the checkout was not a clean commit, else null. */
|
|
67
|
+
readonly provenance: 'modified' | 'unknown' | null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** An assertion by the producer that evidence still holds for other builds, and who stands behind it. */
|
|
71
|
+
export type Assertion = {
|
|
72
|
+
readonly builds: readonly string[];
|
|
73
|
+
/** The stated policy or author; null when none was stated (the assertion is then not accepted). */
|
|
74
|
+
readonly by: string | null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** The reading of one result's evidence against the promise and the named release. */
|
|
78
|
+
export type Assessment = {
|
|
79
|
+
readonly verdict: Verdict;
|
|
80
|
+
readonly promise: Revision['match'];
|
|
81
|
+
readonly release: ReleaseMatch;
|
|
82
|
+
readonly facts: EvidenceFacts | null;
|
|
83
|
+
readonly assertion: Assertion | null;
|
|
84
|
+
/** Sentences saying why, in the order a reader needs them. Empty when there is nothing to add. */
|
|
85
|
+
readonly reasons: readonly string[];
|
|
86
|
+
/** The result this reading is of, or null when there is none. */
|
|
87
|
+
readonly result: EvidenceResult | null;
|
|
88
|
+
/** How many other completed results were read and set aside for this one. */
|
|
89
|
+
readonly others: number;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const norm = (value: string | null | undefined): string | null => {
|
|
93
|
+
const trimmed = value?.trim().toLowerCase();
|
|
94
|
+
return trimmed ? trimmed : null;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Whether two commits name the same one, allowing either to be abbreviated (at least seven characters). */
|
|
98
|
+
function sameCommit(a: string, b: string): boolean {
|
|
99
|
+
const [left, right] = [a.toLowerCase(), b.toLowerCase()];
|
|
100
|
+
const short = left.length <= right.length ? left : right;
|
|
101
|
+
const long = left.length <= right.length ? right : left;
|
|
102
|
+
return short.length >= 7 && long.startsWith(short);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The labels that apply to a result: the layer's, then the check's own. */
|
|
106
|
+
function labelsOf(result: EvidenceResult): Readonly<Record<string, string>> {
|
|
107
|
+
return { ...(result.layer.evidence?.labels ?? {}), ...(result.row.evidence?.labels ?? {}) };
|
|
108
|
+
}
|
|
109
|
+
|
|
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;
|
|
114
|
+
return {
|
|
115
|
+
executedAtMs: layer.recordedAtMs,
|
|
116
|
+
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
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
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);
|
|
127
|
+
const builds = (labels['applies-to'] ?? '')
|
|
128
|
+
.split(',')
|
|
129
|
+
.map((each) => each.trim())
|
|
130
|
+
.filter(Boolean);
|
|
131
|
+
return builds.length ? { builds, by: labels['applies-to-by']?.trim() || null } : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const RANK: Readonly<Record<Verdict, number>> = { applies: 0, unknown: 1, stale: 2, none: 3 };
|
|
135
|
+
|
|
136
|
+
/** How one result's evidence stands against the release. Never reads a branch or a status. */
|
|
137
|
+
function matchRelease(facts: EvidenceFacts, release: ReleaseIdentity, promise: Revision['match'], assertion: Assertion | null): { match: ReleaseMatch; reasons: string[] } {
|
|
138
|
+
if (!hasRelease(release)) return { match: 'not-selected', reasons: [] };
|
|
139
|
+
const wanted = { build: release.build?.trim() || null, sha: release.sha?.trim() || null, platform: norm(release.platform) };
|
|
140
|
+
const reasons: string[] = [];
|
|
141
|
+
const platform = norm(facts.platform);
|
|
142
|
+
if (wanted.platform && platform !== wanted.platform) {
|
|
143
|
+
reasons.push(
|
|
144
|
+
platform
|
|
145
|
+
? `Recorded on ${facts.platform}, the release is on ${release.platform}, so it is not known to apply.`
|
|
146
|
+
: `The platform it ran on was not recorded, so it is not known to apply to ${release.platform}.`
|
|
147
|
+
);
|
|
148
|
+
return { match: 'unknown', reasons };
|
|
149
|
+
}
|
|
150
|
+
// Each part of the release is compared where the evidence has an answer; a part it cannot answer is unknown.
|
|
151
|
+
const parts: ('same' | 'different' | 'unknown')[] = [];
|
|
152
|
+
if (wanted.build) {
|
|
153
|
+
if (!facts.build) {
|
|
154
|
+
parts.push('unknown');
|
|
155
|
+
reasons.push(`The build it ran against was not recorded, so it cannot be compared with build ${wanted.build}.`);
|
|
156
|
+
} else if (norm(facts.build) === norm(wanted.build)) parts.push('same');
|
|
157
|
+
else {
|
|
158
|
+
parts.push('different');
|
|
159
|
+
reasons.push(`Recorded against build ${facts.build}, not build ${wanted.build}.`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (wanted.sha) {
|
|
163
|
+
if (!facts.sha) {
|
|
164
|
+
parts.push('unknown');
|
|
165
|
+
reasons.push(`The commit it ran against was not recorded, so it cannot be compared with ${wanted.sha.slice(0, 7)}.`);
|
|
166
|
+
} else if (sameCommit(facts.sha, wanted.sha)) parts.push('same');
|
|
167
|
+
else {
|
|
168
|
+
parts.push('different');
|
|
169
|
+
reasons.push(`Recorded at commit ${facts.sha.slice(0, 7)}, not ${wanted.sha.slice(0, 7)}.`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (parts.includes('different')) {
|
|
173
|
+
const named = [wanted.build, wanted.sha].filter((each): each is string => each !== null);
|
|
174
|
+
const covered = assertion?.builds.some((each) => named.some((name) => norm(each) === norm(name) || sameCommit(each, name))) ?? false;
|
|
175
|
+
if (covered && promise === 'same') {
|
|
176
|
+
if (assertion!.by) {
|
|
177
|
+
return { match: 'asserted', reasons: [`${assertion!.by} asserts this evidence still holds for the release. That is a stated policy, not a match of builds.`] };
|
|
178
|
+
}
|
|
179
|
+
reasons.push('It is asserted to hold for this release, but no policy or author stands behind the assertion, so it is not accepted.');
|
|
180
|
+
return { match: 'unknown', reasons };
|
|
181
|
+
}
|
|
182
|
+
return { match: 'other', reasons };
|
|
183
|
+
}
|
|
184
|
+
if (parts.includes('unknown')) return { match: 'unknown', reasons };
|
|
185
|
+
if (facts.provenance) {
|
|
186
|
+
reasons.push(
|
|
187
|
+
facts.provenance === 'modified'
|
|
188
|
+
? 'It was recorded from a checkout with uncommitted changes, so it cannot be an exact match of the release.'
|
|
189
|
+
: 'Where it was recorded from was not captured, so it cannot be an exact match of the release.'
|
|
190
|
+
);
|
|
191
|
+
return { match: 'unknown', reasons };
|
|
192
|
+
}
|
|
193
|
+
return { match: 'exact', reasons };
|
|
194
|
+
}
|
|
195
|
+
|
|
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);
|
|
201
|
+
const { match, reasons } = matchRelease(facts, release, revision.match, assertion);
|
|
202
|
+
const promiseReason =
|
|
203
|
+
revision.match === 'different'
|
|
204
|
+
? 'The promise changed after this evidence was recorded. It is not proof of the current promise.'
|
|
205
|
+
: revision.match === 'unknown'
|
|
206
|
+
? 'The revision of the promise it was recorded against was not captured, so it is not tied to the current promise.'
|
|
207
|
+
: null;
|
|
208
|
+
const stale = revision.match === 'different' || match === 'other';
|
|
209
|
+
const unknown = revision.match === 'unknown' || match === 'unknown';
|
|
210
|
+
return {
|
|
211
|
+
verdict: stale ? 'stale' : unknown ? 'unknown' : 'applies',
|
|
212
|
+
promise: revision.match,
|
|
213
|
+
release: match,
|
|
214
|
+
facts,
|
|
215
|
+
assertion,
|
|
216
|
+
reasons: [...(promiseReason ? [promiseReason] : []), ...reasons],
|
|
217
|
+
result,
|
|
218
|
+
others: 0
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The reading of a behavior's evidence: every completed result is read and the one that best applies is kept
|
|
224
|
+
* (applies, then unknown, then stale; the newest execution among equals), so a recording on the release's platform is
|
|
225
|
+
* not hidden by a newer one elsewhere. A behavior with no completed result has `none`. The behavior's coverage state
|
|
226
|
+
* and its results' statuses are unchanged by this.
|
|
227
|
+
*/
|
|
228
|
+
export function assessBehavior(behavior: Behavior, revisions: RevisionSource, release: ReleaseIdentity): Assessment {
|
|
229
|
+
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 };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Whether a behavior belongs under "stale or unverified": anything but evidence that applies. */
|
|
239
|
+
export function needsAttention(assessment: Assessment): boolean {
|
|
240
|
+
return assessment.verdict !== 'applies';
|
|
241
|
+
}
|
|
@@ -60,7 +60,8 @@ export function everyAttachment(holder: EvidenceHolder): Attachment[] {
|
|
|
60
60
|
return out;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
/** The reason a server gave for refusing a change (its problem document's `detail`), or a sentence naming the status. */
|
|
64
|
+
export async function readProblem(response: Response): Promise<string> {
|
|
64
65
|
try {
|
|
65
66
|
const body = (await response.json()) as { detail?: string; error?: string };
|
|
66
67
|
return body.detail ?? body.error ?? `The server refused the change (${response.status}).`;
|