@crvouga/atlas 1.3.0 → 1.5.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 +12 -0
- package/dist/reports/src/evidence-detail.d.ts +23 -0
- package/dist/reports/src/evidence-detail.js +19 -1
- package/dist/reports/src/export-evidence.js +1 -1
- package/dist/reports/src/layers-route.d.ts +2 -1
- package/dist/reports/src/layers-route.js +8 -4
- package/dist/reports/src/test-catalog.d.ts +27 -0
- package/dist/reports/src/test-catalog.js +8 -2
- package/dist/schema/src/api.d.ts +22 -0
- package/dist/schema/src/api.js +10 -1
- package/dist/server/src/openapi.js +2 -2
- package/docs/reference/http-api.md +4 -4
- package/docs/reference/reports.md +64 -7
- package/docs/rich-evidence.md +23 -0
- package/docs/ui.md +22 -2
- package/llms-full.txt +49 -6
- package/package.json +1 -1
- package/packages/schema/schemas/check-detail.schema.json +24 -0
- package/packages/schema/schemas/report-run.schema.json +85 -0
- package/packages/schema/schemas/report-runs.schema.json +6 -0
- package/packages/server/openapi.json +119 -4
- package/packages/visualizer/src/evidence/artifacts.tsx +69 -18
- package/packages/visualizer/src/evidence/behavior-panel.tsx +291 -0
- package/packages/visualizer/src/evidence/behavior.test.ts +99 -0
- package/packages/visualizer/src/evidence/behavior.ts +268 -0
- package/packages/visualizer/src/evidence/check-detail.tsx +4 -0
- package/packages/visualizer/src/evidence/feature-page.tsx +82 -36
- package/packages/visualizer/src/evidence/gherkin.ts +31 -1
- package/packages/visualizer/src/evidence/spec-index.tsx +48 -15
- package/packages/visualizer/src/explorer/Explorer.tsx +9 -5
- package/packages/visualizer/src/explorer/RunArtifacts.tsx +246 -0
- package/packages/visualizer/src/explorer/RunsView.tsx +40 -9
- package/packages/visualizer/src/explorer/search.ts +5 -2
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Behavior panel
|
|
3
|
+
*
|
|
4
|
+
* How a product reader meets one behavior of a feature: Read (the promise and what is expected, with an outline's
|
|
5
|
+
* concrete example filled in), Watch (the matched recording beside what it should show) and Inspect (the technical
|
|
6
|
+
* record: run, environment, source, attempts). Also the coverage summary of the whole feature. A recording is always
|
|
7
|
+
* labelled as illustrative evidence with its own status, provenance and revision; it never stands for approval.
|
|
8
|
+
*/
|
|
9
|
+
import type { EvidenceHolder } from '@crvouga/atlas/reports/evidence-detail';
|
|
10
|
+
import { isVideoAttachment } from '@crvouga/atlas/reports/evidence-detail';
|
|
11
|
+
import { effectiveScope } from '@crvouga/atlas/reports/evidence-scope';
|
|
12
|
+
import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
|
|
13
|
+
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
14
|
+
import type { Attachment } from '@crvouga/atlas/schema';
|
|
15
|
+
import { Alert, Badge, Box, Group, List, Paper, Stack, Tabs, Text, Title } from '@mantine/core';
|
|
16
|
+
import { useMemo, type ReactNode } from 'react';
|
|
17
|
+
|
|
18
|
+
import { attachmentSource, useCheckDetail } from './artifacts';
|
|
19
|
+
import { COVERAGE, COVERAGE_ORDER, type Behavior, type CoverageCounts, type CoverageState, type Revision } from './behavior';
|
|
20
|
+
import { PlainSteps } from './check-detail';
|
|
21
|
+
import { useEvidence } from './evidence-context';
|
|
22
|
+
import { ScopeNotice } from './evidence-scope';
|
|
23
|
+
import { plural, statusWord, useNow } from './format';
|
|
24
|
+
import { exampleIndexFor, exampleRows, fillExample, type FeatureStep } from './gherkin';
|
|
25
|
+
import { Origin, StatusMark } from './marks';
|
|
26
|
+
|
|
27
|
+
/** The short, readable form of a spec fingerprint (`<length>:<hash>:<hash>`): its length and the start of its hash. */
|
|
28
|
+
export function shortRevision(fingerprint: string): string {
|
|
29
|
+
const [length, hash] = fingerprint.split(':');
|
|
30
|
+
return hash ? `${hash.slice(0, 8)} (${length} characters)` : fingerprint.slice(0, 12);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A behavior's coverage state, always in words. */
|
|
34
|
+
export function CoverageBadge({ state }: { state: CoverageState }) {
|
|
35
|
+
return (
|
|
36
|
+
<Badge data-coverage={state} color={COVERAGE[state].color} variant="light" title={COVERAGE[state].meaning}>
|
|
37
|
+
{COVERAGE[state].label}
|
|
38
|
+
</Badge>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The promise and the evidence side by side, each with its own revision. A mismatch is stated, not hidden; a missing
|
|
44
|
+
* fingerprint is stated as unknown rather than treated as a match.
|
|
45
|
+
*/
|
|
46
|
+
export function RevisionLine({ revision }: { revision: Revision }) {
|
|
47
|
+
return (
|
|
48
|
+
<Stack gap="xs" data-revision={revision.match}>
|
|
49
|
+
<Group gap="md" wrap="wrap" c="dimmed">
|
|
50
|
+
<Text span inherit>
|
|
51
|
+
Promise revision:{' '}
|
|
52
|
+
<Text span inherit fw={600}>
|
|
53
|
+
{revision.promise ? shortRevision(revision.promise) : 'not recorded'}
|
|
54
|
+
</Text>
|
|
55
|
+
</Text>
|
|
56
|
+
<Text span inherit>
|
|
57
|
+
Evidence revision:{' '}
|
|
58
|
+
<Text span inherit fw={600}>
|
|
59
|
+
{revision.evidence ? shortRevision(revision.evidence) : 'not recorded'}
|
|
60
|
+
</Text>
|
|
61
|
+
</Text>
|
|
62
|
+
</Group>
|
|
63
|
+
{revision.match === 'different' && (
|
|
64
|
+
<Alert color="orange" role="status">
|
|
65
|
+
This evidence was recorded against an earlier revision of the spec. It does not show that the current promise holds.
|
|
66
|
+
</Alert>
|
|
67
|
+
)}
|
|
68
|
+
{revision.match === 'unknown' && (
|
|
69
|
+
<Text component="div" c="dimmed">
|
|
70
|
+
The revisions cannot be compared, so this evidence is not tied to the current promise.
|
|
71
|
+
</Text>
|
|
72
|
+
)}
|
|
73
|
+
</Stack>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The `Then` steps (and the `And`/`But` after them): what should be true when the behavior has run. */
|
|
78
|
+
function expectedSteps(steps: readonly FeatureStep[]): FeatureStep[] {
|
|
79
|
+
const at = steps.findIndex((step) => step.keyword === 'Then');
|
|
80
|
+
return at < 0 ? [] : steps.slice(at);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function videosIn(holder: EvidenceHolder | undefined): Attachment[] {
|
|
84
|
+
if (!holder) return [];
|
|
85
|
+
const own = (holder.attachments ?? []).filter(isVideoAttachment);
|
|
86
|
+
return [
|
|
87
|
+
...own,
|
|
88
|
+
...(holder.steps ?? []).flatMap((step) => (step.attachments ?? []).filter(isVideoAttachment)),
|
|
89
|
+
...(holder.attempts ?? []).flatMap((attempt) => [
|
|
90
|
+
...(attempt.attachments ?? []).filter(isVideoAttachment),
|
|
91
|
+
...(attempt.steps ?? []).flatMap((step) => (step.attachments ?? []).filter(isVideoAttachment))
|
|
92
|
+
])
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The recording of one result, read only when this is on screen. */
|
|
97
|
+
function Player({ result }: { result: EvidenceResult }) {
|
|
98
|
+
const detail = useCheckDetail(result.layer, result.row);
|
|
99
|
+
const videos = useMemo(() => videosIn(detail.data?.check), [detail.data]);
|
|
100
|
+
const sources = videos.flatMap((attachment) => {
|
|
101
|
+
const { url } = attachmentSource(attachment, result.layer.id);
|
|
102
|
+
return url ? [{ attachment, url }] : [];
|
|
103
|
+
});
|
|
104
|
+
if (detail.isLoading) {
|
|
105
|
+
return (
|
|
106
|
+
<Text role="status" component="div" c="dimmed">
|
|
107
|
+
Loading the recording…
|
|
108
|
+
</Text>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (detail.isError) {
|
|
112
|
+
return (
|
|
113
|
+
<Alert role="alert" color="red">
|
|
114
|
+
The recording could not be read.
|
|
115
|
+
</Alert>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (!sources.length) {
|
|
119
|
+
return (
|
|
120
|
+
<Text component="div" c="dimmed">
|
|
121
|
+
This result names a recording, but no file can be played from here.
|
|
122
|
+
</Text>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return (
|
|
126
|
+
<Stack gap="sm">
|
|
127
|
+
{sources.slice(0, 1).map(({ attachment, url }) => (
|
|
128
|
+
<Stack key={url} gap="xs">
|
|
129
|
+
{/* What the recording does and does not show, as its producer declared it; undeclared says unknown. */}
|
|
130
|
+
<ScopeNotice
|
|
131
|
+
scope={effectiveScope(attachment.scope, detail.data?.check.scope, detail.data?.inheritedScope)}
|
|
132
|
+
name={attachment.name}
|
|
133
|
+
/>
|
|
134
|
+
<Box
|
|
135
|
+
component="video"
|
|
136
|
+
src={url}
|
|
137
|
+
controls
|
|
138
|
+
preload="metadata"
|
|
139
|
+
aria-label={`Recording: ${attachment.description ?? attachment.name}`}
|
|
140
|
+
maw="100%"
|
|
141
|
+
w="100%"
|
|
142
|
+
mah="70vh"
|
|
143
|
+
/>
|
|
144
|
+
</Stack>
|
|
145
|
+
))}
|
|
146
|
+
{sources.length > 1 && (
|
|
147
|
+
<Text component="div" c="dimmed">
|
|
148
|
+
{plural(sources.length - 1, 'more recording', 'more recordings')} in Inspect.
|
|
149
|
+
</Text>
|
|
150
|
+
)}
|
|
151
|
+
</Stack>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Read, Watch and Inspect for one behavior. `inspect` is the technical record, shown only under Inspect. */
|
|
156
|
+
export function BehaviorPanel({
|
|
157
|
+
behavior,
|
|
158
|
+
inspect,
|
|
159
|
+
start
|
|
160
|
+
}: {
|
|
161
|
+
behavior: Behavior;
|
|
162
|
+
inspect: ReactNode;
|
|
163
|
+
/** The tab to open on; by default Watch when there is something to watch, Inspect while a run is in progress (its progress lives there), else Read. */ start?:
|
|
164
|
+
'read' | 'watch' | 'inspect';
|
|
165
|
+
}) {
|
|
166
|
+
const { backends } = useEvidence();
|
|
167
|
+
const now = useNow();
|
|
168
|
+
const { scenario, example, demo, subject, revision, state } = behavior;
|
|
169
|
+
const at = example === null ? -1 : exampleIndexFor(scenario, example);
|
|
170
|
+
const entry = at < 0 ? null : exampleRows(scenario)[at]!;
|
|
171
|
+
const steps = useMemo(() => (entry ? fillExample(scenario.steps, entry) : scenario.steps), [scenario, entry]);
|
|
172
|
+
const expected = expectedSteps(steps);
|
|
173
|
+
const named = example !== null && entry === null;
|
|
174
|
+
return (
|
|
175
|
+
<Tabs
|
|
176
|
+
defaultValue={start ?? (demo ? 'watch' : state === 'running' || behavior.slot?.live ? 'inspect' : 'read')}
|
|
177
|
+
keepMounted={false}
|
|
178
|
+
aria-label={`${scenario.name}${example ? `: ${example}` : ''}`}
|
|
179
|
+
>
|
|
180
|
+
<Tabs.List mb="sm">
|
|
181
|
+
<Tabs.Tab value="read">Read</Tabs.Tab>
|
|
182
|
+
<Tabs.Tab value="watch">Watch</Tabs.Tab>
|
|
183
|
+
<Tabs.Tab value="inspect">Inspect</Tabs.Tab>
|
|
184
|
+
</Tabs.List>
|
|
185
|
+
<Tabs.Panel value="read">
|
|
186
|
+
<Stack gap="sm" miw={0}>
|
|
187
|
+
<Group gap="sm">
|
|
188
|
+
<CoverageBadge state={state} />
|
|
189
|
+
<Text component="span" c="dimmed">
|
|
190
|
+
{COVERAGE[state].meaning}
|
|
191
|
+
</Text>
|
|
192
|
+
</Group>
|
|
193
|
+
{example !== null && (
|
|
194
|
+
<Text component="div" c="dimmed">
|
|
195
|
+
Example: {example}
|
|
196
|
+
</Text>
|
|
197
|
+
)}
|
|
198
|
+
<PlainSteps steps={steps} examples={entry ? [] : scenario.examples} />
|
|
199
|
+
{named && (
|
|
200
|
+
<Text component="div" c="dimmed">
|
|
201
|
+
The recorded example name does not match one row of the examples, so the values are not filled in.
|
|
202
|
+
</Text>
|
|
203
|
+
)}
|
|
204
|
+
</Stack>
|
|
205
|
+
</Tabs.Panel>
|
|
206
|
+
<Tabs.Panel value="watch">
|
|
207
|
+
<Stack gap="sm" miw={0}>
|
|
208
|
+
<Group gap="sm">
|
|
209
|
+
<CoverageBadge state={state} />
|
|
210
|
+
{behavior.representative && <Badge variant="outline">Representative demo</Badge>}
|
|
211
|
+
</Group>
|
|
212
|
+
{expected.length > 0 && (
|
|
213
|
+
<Box component="section" aria-label="Expected outcome">
|
|
214
|
+
<Title order={4} mb="xs">
|
|
215
|
+
Expected
|
|
216
|
+
</Title>
|
|
217
|
+
<PlainSteps steps={expected} />
|
|
218
|
+
</Box>
|
|
219
|
+
)}
|
|
220
|
+
{demo ? (
|
|
221
|
+
<>
|
|
222
|
+
<Player result={demo} />
|
|
223
|
+
<Text component="div" c="dimmed">
|
|
224
|
+
Illustrative recording of one run. It shows what happened in that run and does not approve the behavior for production.
|
|
225
|
+
</Text>
|
|
226
|
+
</>
|
|
227
|
+
) : (
|
|
228
|
+
<Text component="div" c="dimmed">
|
|
229
|
+
Nothing to watch. {COVERAGE[state].meaning}
|
|
230
|
+
</Text>
|
|
231
|
+
)}
|
|
232
|
+
{subject && (
|
|
233
|
+
<Group gap="xs" wrap="wrap" align="center">
|
|
234
|
+
<StatusMark status={subject.row.status} size={14} />
|
|
235
|
+
<Text span inherit fw={600}>
|
|
236
|
+
{statusWord(subject.row.status)}
|
|
237
|
+
</Text>
|
|
238
|
+
<Text span inherit c="dimmed">
|
|
239
|
+
in {environmentLabel(subject.layer.environment)}
|
|
240
|
+
</Text>
|
|
241
|
+
<Origin layer={subject.layer} backends={backends} now={now} />
|
|
242
|
+
</Group>
|
|
243
|
+
)}
|
|
244
|
+
<RevisionLine revision={revision} />
|
|
245
|
+
</Stack>
|
|
246
|
+
</Tabs.Panel>
|
|
247
|
+
<Tabs.Panel value="inspect">
|
|
248
|
+
<Stack gap="sm" miw={0}>
|
|
249
|
+
<RevisionLine revision={revision} />
|
|
250
|
+
{inspect}
|
|
251
|
+
</Stack>
|
|
252
|
+
</Tabs.Panel>
|
|
253
|
+
</Tabs>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* What the feature's behaviors add up to, one count per state. A state with none is left out except
|
|
259
|
+
* `demonstrated`, which is always named so its absence is not mistaken for a pass.
|
|
260
|
+
*/
|
|
261
|
+
export function CoverageSummary({ counts, total }: { counts: CoverageCounts; total: number }) {
|
|
262
|
+
if (total === 0) return null;
|
|
263
|
+
return (
|
|
264
|
+
<Paper component="section" aria-label="Coverage" withBorder p="md" my="md">
|
|
265
|
+
<Stack gap="xs">
|
|
266
|
+
<Title order={2}>Coverage</Title>
|
|
267
|
+
<Text component="div">
|
|
268
|
+
{counts.demonstrated} of {plural(total, 'behavior', 'behaviors')} demonstrated with a recording of the current spec.
|
|
269
|
+
</Text>
|
|
270
|
+
<List listStyleType="none" spacing="xs">
|
|
271
|
+
{COVERAGE_ORDER.filter((state) => state === 'demonstrated' || counts[state] > 0).map((state) => (
|
|
272
|
+
<List.Item key={state} data-coverage={state}>
|
|
273
|
+
<Group gap="sm" wrap="wrap">
|
|
274
|
+
<CoverageBadge state={state} />
|
|
275
|
+
<Text span inherit fw={600}>
|
|
276
|
+
{counts[state]}
|
|
277
|
+
</Text>
|
|
278
|
+
<Text span inherit c="dimmed">
|
|
279
|
+
{COVERAGE[state].meaning}
|
|
280
|
+
</Text>
|
|
281
|
+
</Group>
|
|
282
|
+
</List.Item>
|
|
283
|
+
))}
|
|
284
|
+
</List>
|
|
285
|
+
<Text component="div" c="dimmed">
|
|
286
|
+
A recording shows one run. It is illustrative evidence and does not mean the behavior is live or ready for production.
|
|
287
|
+
</Text>
|
|
288
|
+
</Stack>
|
|
289
|
+
</Paper>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
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 { behaviorOf, compareRevisions, coverageCounts, isRepresentative, scenarioBehaviors, type RevisionSource } from './behavior';
|
|
7
|
+
import { exampleIndexFor, featureModel, fillExample, exampleRows } from './gherkin';
|
|
8
|
+
|
|
9
|
+
const FEATURE = featureModel(`Feature: Lists
|
|
10
|
+
@demo
|
|
11
|
+
Scenario: Add
|
|
12
|
+
When adding tea
|
|
13
|
+
|
|
14
|
+
@parked
|
|
15
|
+
Scenario: Archive
|
|
16
|
+
When archiving
|
|
17
|
+
|
|
18
|
+
Scenario Outline: Several
|
|
19
|
+
When adding "<item>" for <who>
|
|
20
|
+
Examples:
|
|
21
|
+
| item | who |
|
|
22
|
+
| Tea | Ann |
|
|
23
|
+
| Milk | Bo |
|
|
24
|
+
`)!;
|
|
25
|
+
const [ADD, ARCHIVE, SEVERAL] = FEATURE.rules[0]!.scenarios;
|
|
26
|
+
|
|
27
|
+
const header = (layer: string, environment: string | null = 'desktop-web'): LayerHeader => ({
|
|
28
|
+
id: layer, backend: 'b', producer: 'p', packageDir: null, recordedAtMs: 1, sha: null, branch: null, environment, runUrl: null, parameters: {},
|
|
29
|
+
recording: null, live: false, counts: { passed: 0, failed: 0, running: 0, 'not-run': 0 }, execution: layer
|
|
30
|
+
});
|
|
31
|
+
function result(layer: string, row: Partial<LayerRow>, environment: string | null = 'desktop-web'): EvidenceResult {
|
|
32
|
+
return {
|
|
33
|
+
key: layer, slot: 's', layer: header(layer, environment), parameters: {}, warning: null,
|
|
34
|
+
row: { kind: 'behavior', id: 'x', title: 'Lists: Add', status: 'passed', specPath: 'specs/a.feature', detail: '', measurements: [], testId: null, variant: {}, durationMs: null, ...row }
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const slot = (status: CheckSlot['status'], outcomes: EvidenceResult[], title = 'Lists: Add'): CheckSlot => ({
|
|
38
|
+
key: title, id: title, title, specPath: 'specs/a.feature', status, outcomes, diagnostics: [], latestAtMs: 1, live: false
|
|
39
|
+
});
|
|
40
|
+
const VIDEO = { attachments: 1, steps: 0, annotations: 0, attempts: 0, links: 0, tags: [], labels: {}, roles: ['video'], videos: [{ name: 'a.webm', owner: 'check' as const, recording: 'sha256:1', available: true }] };
|
|
41
|
+
const revisions = (current: string | null, recorded: Record<string, string> = {}): RevisionSource => ({ current, recorded: (id) => recorded[id] });
|
|
42
|
+
const stateOf = (s: CheckSlot | null, rev = revisions('f1', { l1: 'f1', l2: 'f0' }), scenario = ADD!) => behaviorOf(scenario, s, null, rev, 'k').state;
|
|
43
|
+
|
|
44
|
+
test('revisions compare only when both are known', () => {
|
|
45
|
+
expect(compareRevisions('a', 'a').match).toBe('same');
|
|
46
|
+
expect(compareRevisions('a', 'b').match).toBe('different');
|
|
47
|
+
expect(compareRevisions('a', undefined).match).toBe('unknown');
|
|
48
|
+
expect(compareRevisions(null, 'a').match).toBe('unknown');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('only a passing result with a playable recording of the current promise is demonstrated', () => {
|
|
52
|
+
expect(stateOf(slot('passed', [result('l1', { evidence: VIDEO })]))).toBe('demonstrated');
|
|
53
|
+
// The same recording of an older revision is not proof of the new promise.
|
|
54
|
+
expect(stateOf(slot('passed', [result('l2', { evidence: VIDEO })]))).toBe('outdated-recording');
|
|
55
|
+
// An unplayable reference is a gap, and a pass with nothing to watch says so.
|
|
56
|
+
expect(stateOf(slot('passed', [result('l1', { evidence: { ...VIDEO, videos: [{ ...VIDEO.videos[0]!, available: false }] } })]))).toBe('recording-missing');
|
|
57
|
+
expect(stateOf(slot('passed', [result('l1', { recording: { state: 'skipped', reason: 'too-large' } })]))).toBe('recording-missing');
|
|
58
|
+
expect(stateOf(slot('passed', [result('l1', {})]))).toBe('no-recording');
|
|
59
|
+
expect(stateOf(slot('passed', [result('l1', {}, 'api')]))).toBe('api-only');
|
|
60
|
+
// A video on the API does not turn it into API-only evidence: it can be watched.
|
|
61
|
+
expect(stateOf(slot('passed', [result('l1', { evidence: VIDEO }, 'api')]))).toBe('demonstrated');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('failed, running, skipped, parked and unexecuted stay distinct and never read as passes', () => {
|
|
65
|
+
expect(stateOf(slot('failed', [result('l1', { status: 'failed', evidence: VIDEO })]))).toBe('failed');
|
|
66
|
+
expect(stateOf(slot('running', [result('l1', { status: 'running' })]))).toBe('running');
|
|
67
|
+
expect(stateOf(slot('not-run', [result('l1', { status: 'not-run' })]))).toBe('skipped');
|
|
68
|
+
expect(stateOf(null, undefined, ARCHIVE)).toBe('parked');
|
|
69
|
+
expect(stateOf(null)).toBe('unexecuted');
|
|
70
|
+
// One environment passing with a clip does not hide another that failed.
|
|
71
|
+
expect(stateOf(slot('failed', [result('l1', { evidence: VIDEO }), result('l3', { status: 'failed' })]))).toBe('failed');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a demo tag marks the scenario as the representative one', () => {
|
|
75
|
+
expect(isRepresentative(['#Demo'])).toBe(true);
|
|
76
|
+
expect(isRepresentative(['smoke'])).toBe(false);
|
|
77
|
+
expect(behaviorOf(ADD!, null, null, revisions(null), 'k').representative).toBe(true);
|
|
78
|
+
expect(behaviorOf(ARCHIVE!, null, null, revisions(null), 'k').representative).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('an outline is one behavior per example, never the template as well', () => {
|
|
82
|
+
const tea = slot('passed', [result('l1', {})], 'Lists: Several — Tea');
|
|
83
|
+
const behaviors = scenarioBehaviors(SEVERAL!, [tea], 'Lists', revisions('f1'));
|
|
84
|
+
expect(behaviors.map((behavior) => [behavior.example, behavior.state])).toEqual([
|
|
85
|
+
['Tea', 'no-recording'],
|
|
86
|
+
['Milk, Bo', 'unexecuted']
|
|
87
|
+
]);
|
|
88
|
+
expect(coverageCounts(behaviors).unexecuted).toBe(1);
|
|
89
|
+
// With nothing recorded there are still only the two examples.
|
|
90
|
+
expect(scenarioBehaviors(SEVERAL!, [], 'Lists', revisions(null))).toHaveLength(2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a recorded example is matched to its row by cells or column pairs, and its values are filled in', () => {
|
|
94
|
+
expect(exampleIndexFor(SEVERAL!, 'Milk')).toBe(1);
|
|
95
|
+
expect(exampleIndexFor(SEVERAL!, 'item=Tea, who=Ann')).toBe(0);
|
|
96
|
+
expect(exampleIndexFor(SEVERAL!, 'Unrelated wording')).toBe(-1);
|
|
97
|
+
const filled = fillExample(SEVERAL!.steps, exampleRows(SEVERAL!)[1]!);
|
|
98
|
+
expect(filled[0]!.text).toBe('adding "Milk" for Bo');
|
|
99
|
+
});
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Behavior coverage
|
|
3
|
+
*
|
|
4
|
+
* Reads a feature the way a product reader does: each scenario (or each concrete example of an outline) is one
|
|
5
|
+
* behavior, and each behavior is in exactly one coverage state. The states keep apart what a green count would blur:
|
|
6
|
+
* a passing test with a playable recording of the current promise is `demonstrated`; a passing test with no
|
|
7
|
+
* recording, with a recording of an older revision of the spec, or only in API environments is not. Nothing here
|
|
8
|
+
* decides an outcome: statuses and winners come from the combined report, and a state is a reading of them.
|
|
9
|
+
* A missing fingerprint, recording or result is unknown or missing, never a pass.
|
|
10
|
+
*/
|
|
11
|
+
import type { CheckSlot } from '@crvouga/atlas/reports/evidence-view';
|
|
12
|
+
import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
|
|
13
|
+
|
|
14
|
+
import { exampleIndexFor, exampleRows, scenarioLabel, type FeatureScenario } from './gherkin';
|
|
15
|
+
|
|
16
|
+
/** Where a behavior stands, from the most reassuring to the least. */
|
|
17
|
+
export type CoverageState =
|
|
18
|
+
| 'demonstrated'
|
|
19
|
+
| 'outdated-recording'
|
|
20
|
+
| 'recording-missing'
|
|
21
|
+
| 'no-recording'
|
|
22
|
+
| 'api-only'
|
|
23
|
+
| 'failed'
|
|
24
|
+
| 'running'
|
|
25
|
+
| 'skipped'
|
|
26
|
+
| 'parked'
|
|
27
|
+
| 'unexecuted';
|
|
28
|
+
|
|
29
|
+
/** What each state is called and why, in the order a summary lists them. */
|
|
30
|
+
export const COVERAGE: Readonly<Record<CoverageState, { readonly label: string; readonly meaning: string; readonly color: string }>> = {
|
|
31
|
+
demonstrated: {
|
|
32
|
+
label: 'Demonstrated',
|
|
33
|
+
meaning: 'A passing result with a recording of the current promise.',
|
|
34
|
+
color: 'green'
|
|
35
|
+
},
|
|
36
|
+
'outdated-recording': {
|
|
37
|
+
label: 'Recording is older',
|
|
38
|
+
meaning: 'A passing result, but the recording is of an earlier revision of the spec.',
|
|
39
|
+
color: 'orange'
|
|
40
|
+
},
|
|
41
|
+
'recording-missing': {
|
|
42
|
+
label: 'Recording missing',
|
|
43
|
+
meaning: 'A passing result whose expected recording was not published or cannot be played.',
|
|
44
|
+
color: 'orange'
|
|
45
|
+
},
|
|
46
|
+
'no-recording': {
|
|
47
|
+
label: 'No recording',
|
|
48
|
+
meaning: 'A passing result with nothing to watch.',
|
|
49
|
+
color: 'gray'
|
|
50
|
+
},
|
|
51
|
+
'api-only': {
|
|
52
|
+
label: 'API only',
|
|
53
|
+
meaning: 'A passing result recorded only through the API, so there is no screen to watch.',
|
|
54
|
+
color: 'gray'
|
|
55
|
+
},
|
|
56
|
+
failed: {
|
|
57
|
+
label: 'Failed',
|
|
58
|
+
meaning: 'The latest result failed.',
|
|
59
|
+
color: 'red'
|
|
60
|
+
},
|
|
61
|
+
running: {
|
|
62
|
+
label: 'Running',
|
|
63
|
+
meaning: 'A run is checking it now.',
|
|
64
|
+
color: 'blue'
|
|
65
|
+
},
|
|
66
|
+
skipped: {
|
|
67
|
+
label: 'Skipped',
|
|
68
|
+
meaning: 'It was reported as skipped or not run.',
|
|
69
|
+
color: 'gray'
|
|
70
|
+
},
|
|
71
|
+
parked: {
|
|
72
|
+
label: 'Parked',
|
|
73
|
+
meaning: 'The scenario is tagged as parked or work in progress, and has no result.',
|
|
74
|
+
color: 'gray'
|
|
75
|
+
},
|
|
76
|
+
unexecuted: {
|
|
77
|
+
label: 'Not executed',
|
|
78
|
+
meaning: 'No test reported a result for it.',
|
|
79
|
+
color: 'gray'
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The order a coverage summary lists states in. */
|
|
84
|
+
export const COVERAGE_ORDER = Object.keys(COVERAGE) as CoverageState[];
|
|
85
|
+
|
|
86
|
+
/** How the spec the evidence was recorded against compares with the spec now. */
|
|
87
|
+
export type Revision = {
|
|
88
|
+
/** Fingerprint of the spec now (the promise). Null when the outline has none. */
|
|
89
|
+
readonly promise: string | null;
|
|
90
|
+
/** Fingerprint of the spec when the evidence was recorded. Null when the layer did not record one. */
|
|
91
|
+
readonly evidence: string | null;
|
|
92
|
+
/** `same`, `different`, or `unknown` when either side is missing. */
|
|
93
|
+
readonly match: 'same' | 'different' | 'unknown';
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Compares two fingerprints: unknown unless both are there. */
|
|
97
|
+
export function compareRevisions(promise: string | null | undefined, evidence: string | null | undefined): Revision {
|
|
98
|
+
const now = promise || null;
|
|
99
|
+
const then = evidence || null;
|
|
100
|
+
return {
|
|
101
|
+
promise: now,
|
|
102
|
+
evidence: then,
|
|
103
|
+
match: now === null || then === null ? 'unknown' : now === then ? 'same' : 'different'
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The tags that mark a scenario as deliberately not running, and the state each gives it. */
|
|
108
|
+
const TAG_STATE: Readonly<Record<string, CoverageState>> = {
|
|
109
|
+
parked: 'parked',
|
|
110
|
+
wip: 'parked',
|
|
111
|
+
pending: 'parked',
|
|
112
|
+
skip: 'skipped',
|
|
113
|
+
skipped: 'skipped',
|
|
114
|
+
ignore: 'skipped'
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** The tags that say a recording was picked to stand for a behavior. */
|
|
118
|
+
const REPRESENTATIVE = new Set(['demo', 'representative']);
|
|
119
|
+
|
|
120
|
+
/** Whether a recorded result or its scenario is tagged as the representative demo. */
|
|
121
|
+
export function isRepresentative(tags: readonly string[]): boolean {
|
|
122
|
+
return tags.some((tag) => REPRESENTATIVE.has(tag.replace(/^[#@]/, '').toLowerCase()));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A result's playable video references: those that name bytes. */
|
|
126
|
+
function playable(result: EvidenceResult): number {
|
|
127
|
+
return result.row.evidence?.videos?.filter((video) => video.available).length ?? 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Environments a reader cannot watch: a result recorded only through these has no screen. */
|
|
131
|
+
function isApi(environment: string | null): boolean {
|
|
132
|
+
return environment !== null && /^(api|http|rest|graphql|service|backend)(-|$)/.test(environment);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** What is known about one behavior. */
|
|
136
|
+
export type Behavior = {
|
|
137
|
+
/** Stable within the page: the scenario and its example. */
|
|
138
|
+
readonly key: string;
|
|
139
|
+
readonly scenario: FeatureScenario;
|
|
140
|
+
/** The example label of an outline's row (what the producer named it), or null for a plain scenario. */
|
|
141
|
+
readonly example: string | null;
|
|
142
|
+
/** The recorded check, or null when nothing reported on it. */
|
|
143
|
+
readonly slot: CheckSlot | null;
|
|
144
|
+
readonly state: CoverageState;
|
|
145
|
+
/** The result to watch: the newest of those with a playable recording, preferring one of the current revision. */
|
|
146
|
+
readonly demo: EvidenceResult | null;
|
|
147
|
+
/** The result the revision line and provenance describe: the demo, else the newest. */
|
|
148
|
+
readonly subject: EvidenceResult | null;
|
|
149
|
+
readonly revision: Revision;
|
|
150
|
+
/** Whether the scenario or the recorded result is tagged as the representative demo. */
|
|
151
|
+
readonly representative: boolean;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** What the page knows about the spec as it is now and as each layer recorded it. */
|
|
155
|
+
export type RevisionSource = {
|
|
156
|
+
/** The spec's fingerprint now. */
|
|
157
|
+
readonly current: string | null;
|
|
158
|
+
/** The fingerprint a layer recorded for this spec, if it did. */
|
|
159
|
+
readonly recorded: (layerId: string) => string | undefined;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The state of one behavior from its recorded check (or none). A failing, running or skipped result is that, whatever
|
|
164
|
+
* recordings exist. A pass is `demonstrated` only with a playable recording that is not known to be of another
|
|
165
|
+
* revision; a recording whose revision cannot be compared is still shown, with the revision stated as unknown.
|
|
166
|
+
*/
|
|
167
|
+
export function behaviorOf(
|
|
168
|
+
scenario: FeatureScenario,
|
|
169
|
+
slot: CheckSlot | null,
|
|
170
|
+
example: string | null,
|
|
171
|
+
revisions: RevisionSource,
|
|
172
|
+
key: string
|
|
173
|
+
): Behavior {
|
|
174
|
+
const outcomes = slot?.outcomes ?? [];
|
|
175
|
+
const revisionOf = (result: EvidenceResult | null) =>
|
|
176
|
+
compareRevisions(revisions.current, result ? revisions.recorded(result.layer.id) : undefined);
|
|
177
|
+
const withVideo = outcomes.filter((result) => result.row.status === 'passed' && playable(result) > 0);
|
|
178
|
+
const demo = withVideo.find((result) => revisionOf(result).match !== 'different') ?? withVideo[0] ?? null;
|
|
179
|
+
const subject = demo ?? outcomes[0] ?? null;
|
|
180
|
+
const revision = revisionOf(subject);
|
|
181
|
+
const representative =
|
|
182
|
+
isRepresentative(scenario.tags) ||
|
|
183
|
+
outcomes.some((result) => isRepresentative([...(result.row.evidence?.tags ?? []), ...(result.row.evidence?.artifactTags ?? [])]));
|
|
184
|
+
const base = {
|
|
185
|
+
key,
|
|
186
|
+
scenario,
|
|
187
|
+
example,
|
|
188
|
+
slot,
|
|
189
|
+
demo,
|
|
190
|
+
subject,
|
|
191
|
+
revision,
|
|
192
|
+
representative
|
|
193
|
+
};
|
|
194
|
+
if (!slot || outcomes.length === 0) {
|
|
195
|
+
const tagged = scenario.tags.map((tag) => TAG_STATE[tag.toLowerCase()]).find((state) => state !== undefined);
|
|
196
|
+
return { ...base, state: tagged ?? 'unexecuted' };
|
|
197
|
+
}
|
|
198
|
+
if (slot.status === 'failed') return { ...base, state: 'failed' };
|
|
199
|
+
if (slot.status === 'running') return { ...base, state: 'running' };
|
|
200
|
+
if (slot.status === 'not-run') return { ...base, state: 'skipped' };
|
|
201
|
+
if (demo)
|
|
202
|
+
return {
|
|
203
|
+
...base,
|
|
204
|
+
state: revision.match === 'different' ? 'outdated-recording' : 'demonstrated'
|
|
205
|
+
};
|
|
206
|
+
const passed = outcomes.filter((result) => result.row.status === 'passed');
|
|
207
|
+
// A recording was promised and is not here, or is referenced without bytes: that is a gap, not an API test.
|
|
208
|
+
if (
|
|
209
|
+
passed.some((result) => result.row.recording && !['uploaded', 'referenced', 'not-applicable'].includes(result.row.recording.state)) ||
|
|
210
|
+
passed.some((result) => (result.row.evidence?.videos?.length ?? 0) > 0)
|
|
211
|
+
) {
|
|
212
|
+
return { ...base, state: 'recording-missing' };
|
|
213
|
+
}
|
|
214
|
+
if (passed.length > 0 && passed.every((result) => isApi(result.layer.environment))) return { ...base, state: 'api-only' };
|
|
215
|
+
return { ...base, state: 'no-recording' };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Every behavior of a scenario. A plain scenario is one. An outline is one per example row, never the template as an
|
|
220
|
+
* extra: a recorded example is filed under the row it names; a row nothing reported on is `unexecuted` (or parked);
|
|
221
|
+
* a recorded example whose label names no row is kept as its own behavior and the unclaimed rows are counted against
|
|
222
|
+
* the recorded results that remain, so the total never exceeds the rows by inventing a template.
|
|
223
|
+
*/
|
|
224
|
+
export function scenarioBehaviors(
|
|
225
|
+
scenario: FeatureScenario,
|
|
226
|
+
slots: readonly CheckSlot[],
|
|
227
|
+
feature: string,
|
|
228
|
+
revisions: RevisionSource
|
|
229
|
+
): Behavior[] {
|
|
230
|
+
const rows = exampleRows(scenario);
|
|
231
|
+
if (rows.length === 0) {
|
|
232
|
+
// A scenario with no examples fills one slot per environment/parameter set, which the slot already groups.
|
|
233
|
+
const slot = slots[0] ?? null;
|
|
234
|
+
if (slots.length <= 1) return [behaviorOf(scenario, slot, null, revisions, scenario.name)];
|
|
235
|
+
return slots.map((each) => behaviorOf(scenario, each, null, revisions, each.key));
|
|
236
|
+
}
|
|
237
|
+
const claimed = new Map<number, CheckSlot[]>();
|
|
238
|
+
const loose: CheckSlot[] = [];
|
|
239
|
+
for (const slot of slots) {
|
|
240
|
+
const label = scenarioLabel(feature, slot.title).slice(scenario.name.length + 3);
|
|
241
|
+
const at = exampleIndexFor(scenario, label);
|
|
242
|
+
if (at < 0) loose.push(slot);
|
|
243
|
+
else claimed.set(at, [...(claimed.get(at) ?? []), slot]);
|
|
244
|
+
}
|
|
245
|
+
const behaviors: Behavior[] = [];
|
|
246
|
+
const unmatched = rows.map((_, at) => at).filter((at) => !claimed.has(at));
|
|
247
|
+
// Results whose wording matches no row stand in for the rows nothing claimed, in order, before any row is called unexecuted.
|
|
248
|
+
const spare = [...loose];
|
|
249
|
+
rows.forEach((entry, at) => {
|
|
250
|
+
const found = claimed.get(at) ?? (unmatched.includes(at) && spare.length ? [spare.shift()!] : []);
|
|
251
|
+
const label = found[0] ? scenarioLabel(feature, found[0].title).slice(scenario.name.length + 3) : entry.row.join(', ');
|
|
252
|
+
if (found.length === 0) behaviors.push(behaviorOf(scenario, null, label, revisions, `${scenario.name}:${at}`));
|
|
253
|
+
for (const slot of found) behaviors.push(behaviorOf(scenario, slot, label, revisions, slot.key));
|
|
254
|
+
});
|
|
255
|
+
for (const slot of spare)
|
|
256
|
+
behaviors.push(behaviorOf(scenario, slot, scenarioLabel(feature, slot.title).slice(scenario.name.length + 3), revisions, slot.key));
|
|
257
|
+
return behaviors;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Behaviors counted by state. Every state is present, so an absent one reads as zero, not as unknown. */
|
|
261
|
+
export type CoverageCounts = Readonly<Record<CoverageState, number>>;
|
|
262
|
+
|
|
263
|
+
/** Counts behaviors by coverage state; only `demonstrated` is a demonstrated pass. */
|
|
264
|
+
export function coverageCounts(behaviors: readonly Behavior[]): CoverageCounts {
|
|
265
|
+
const counts = Object.fromEntries(COVERAGE_ORDER.map((state) => [state, 0])) as Record<CoverageState, number>;
|
|
266
|
+
for (const behavior of behaviors) counts[behavior.state] += 1;
|
|
267
|
+
return counts;
|
|
268
|
+
}
|
|
@@ -61,12 +61,15 @@ export function ResultRow({
|
|
|
61
61
|
slot,
|
|
62
62
|
open,
|
|
63
63
|
onToggle,
|
|
64
|
+
badge,
|
|
64
65
|
children
|
|
65
66
|
}: {
|
|
66
67
|
label: string;
|
|
67
68
|
slot: CheckSlot | null;
|
|
68
69
|
open: boolean;
|
|
69
70
|
onToggle: () => void;
|
|
71
|
+
/** A short extra mark after the label, such as a coverage state. */
|
|
72
|
+
badge?: ReactNode;
|
|
70
73
|
children: ReactNode;
|
|
71
74
|
}) {
|
|
72
75
|
const now = useNow();
|
|
@@ -96,6 +99,7 @@ export function ResultRow({
|
|
|
96
99
|
</Flex>
|
|
97
100
|
)}
|
|
98
101
|
</Text>
|
|
102
|
+
{badge}
|
|
99
103
|
{slot?.live && (
|
|
100
104
|
<Text span inherit>
|
|
101
105
|
Running now
|