@crvouga/atlas 1.6.0 → 1.8.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 +13 -1
- package/dist/reports/src/explorer-types.d.ts +19 -0
- package/dist/reports/src/latency.d.ts +204 -0
- package/dist/reports/src/latency.js +219 -0
- package/dist/reports/src/reports-explorer-route.d.ts +19 -6
- package/dist/reports/src/reports-route.d.ts +19 -6
- package/dist/reports/src/test-catalog.d.ts +3 -1
- package/dist/reports/src/test-catalog.js +4 -2
- package/dist/schema/src/api.d.ts +151 -0
- package/dist/schema/src/api.js +4 -3
- package/dist/schema/src/layer.d.ts +90 -0
- package/dist/schema/src/layer.js +20 -1
- package/docs/extending.md +18 -1
- package/docs/reference/reports.md +347 -0
- package/docs/reference/schema.md +28 -0
- package/docs/rich-evidence.md +43 -0
- package/docs/ui.md +24 -1
- package/llms-full.txt +85 -2
- package/package.json +1 -1
- package/packages/schema/schemas/check-detail.schema.json +56 -1
- package/packages/schema/schemas/effective-delta.schema.json +60 -2
- package/packages/schema/schemas/effective-evidence.schema.json +60 -2
- package/packages/schema/schemas/layer-rows.schema.json +60 -2
- package/packages/schema/schemas/layer.schema.json +112 -2
- package/packages/schema/schemas/publish-checks.schema.json +56 -1
- package/packages/schema/schemas/test-catalog.schema.json +85 -0
- package/packages/schema/schemas/test-history.schema.json +170 -0
- package/packages/server/openapi.json +547 -8
- package/packages/visualizer/src/evidence/behavior-panel.tsx +10 -16
- package/packages/visualizer/src/evidence/check-detail.tsx +59 -15
- package/packages/visualizer/src/evidence/outcome-summary.tsx +208 -0
- package/packages/visualizer/src/evidence/outcome.test.ts +129 -0
- package/packages/visualizer/src/evidence/outcome.ts +309 -0
- package/packages/visualizer/src/explorer/ProductLatency.tsx +214 -0
- package/packages/visualizer/src/explorer/TestDetail.tsx +32 -3
- package/packages/visualizer/src/explorer/search.ts +3 -0
|
@@ -6,12 +6,9 @@
|
|
|
6
6
|
* record: run, environment, source, attempts). Also the coverage summary of the whole feature. A recording is always
|
|
7
7
|
* labelled as illustrative evidence with its own status, provenance and revision; it never stands for approval.
|
|
8
8
|
*/
|
|
9
|
-
import type { EvidenceHolder } from '@crvouga/atlas/reports/evidence-detail';
|
|
10
|
-
import { isVideoAttachment } from '@crvouga/atlas/reports/evidence-detail';
|
|
11
9
|
import { effectiveScope } from '@crvouga/atlas/reports/evidence-scope';
|
|
12
10
|
import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
|
|
13
11
|
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
14
|
-
import type { Attachment } from '@crvouga/atlas/schema';
|
|
15
12
|
import { Alert, Badge, Box, Group, List, Paper, Stack, Tabs, Text, Title } from '@mantine/core';
|
|
16
13
|
import { useMemo, type ReactNode } from 'react';
|
|
17
14
|
|
|
@@ -23,6 +20,7 @@ import { ScopeNotice } from './evidence-scope';
|
|
|
23
20
|
import { plural, statusWord, useNow } from './format';
|
|
24
21
|
import { exampleIndexFor, exampleRows, fillExample, type FeatureStep } from './gherkin';
|
|
25
22
|
import { Origin, StatusMark } from './marks';
|
|
23
|
+
import { outcomeHeadline, videosIn } from './outcome';
|
|
26
24
|
|
|
27
25
|
/** The short, readable form of a spec fingerprint (`<length>:<hash>:<hash>`): its length and the start of its hash. */
|
|
28
26
|
export function shortRevision(fingerprint: string): string {
|
|
@@ -80,19 +78,6 @@ function expectedSteps(steps: readonly FeatureStep[]): FeatureStep[] {
|
|
|
80
78
|
return at < 0 ? [] : steps.slice(at);
|
|
81
79
|
}
|
|
82
80
|
|
|
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
81
|
/** The recording of one result, read only when this is on screen. */
|
|
97
82
|
function Player({ result }: { result: EvidenceResult }) {
|
|
98
83
|
const detail = useCheckDetail(result.layer, result.row);
|
|
@@ -171,6 +156,13 @@ export function BehaviorPanel({
|
|
|
171
156
|
const steps = useMemo(() => (entry ? fillExample(scenario.steps, entry) : scenario.steps), [scenario, entry]);
|
|
172
157
|
const expected = expectedSteps(steps);
|
|
173
158
|
const named = example !== null && entry === null;
|
|
159
|
+
// The coverage state is the check's combined status; this says what the recording or result shown here recorded, apart from it.
|
|
160
|
+
const outcome =
|
|
161
|
+
subject && behavior.slot ? (
|
|
162
|
+
<Text component="div" fw={600} data-outcome-headline>
|
|
163
|
+
{outcomeHeadline({ layer: subject.layer, row: subject.row }, behavior.slot).text}
|
|
164
|
+
</Text>
|
|
165
|
+
) : null;
|
|
174
166
|
return (
|
|
175
167
|
<Tabs
|
|
176
168
|
defaultValue={start ?? (demo ? 'watch' : state === 'running' || behavior.slot?.live ? 'inspect' : 'read')}
|
|
@@ -190,6 +182,7 @@ export function BehaviorPanel({
|
|
|
190
182
|
{COVERAGE[state].meaning}
|
|
191
183
|
</Text>
|
|
192
184
|
</Group>
|
|
185
|
+
{outcome}
|
|
193
186
|
{example !== null && (
|
|
194
187
|
<Text component="div" c="dimmed">
|
|
195
188
|
Example: {example}
|
|
@@ -209,6 +202,7 @@ export function BehaviorPanel({
|
|
|
209
202
|
<CoverageBadge state={state} />
|
|
210
203
|
{behavior.representative && <Badge variant="outline">Representative demo</Badge>}
|
|
211
204
|
</Group>
|
|
205
|
+
{outcome}
|
|
212
206
|
{expected.length > 0 && (
|
|
213
207
|
<Box component="section" aria-label="Expected outcome">
|
|
214
208
|
<Title order={4} mb="xs">
|
|
@@ -2,6 +2,7 @@ import { recordingPaths } from '@crvouga/atlas/reports/evidence-types';
|
|
|
2
2
|
import { checkHistory, type CheckSlot, type HistoryEntry } from '@crvouga/atlas/reports/evidence-view';
|
|
3
3
|
import { flattenFrames } from '@crvouga/atlas/reports/frames';
|
|
4
4
|
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
5
|
+
import { boundaryOf, isProductLatency } from '@crvouga/atlas/reports/latency';
|
|
5
6
|
import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
|
|
6
7
|
import type { ReportStep, RunReport } from '@crvouga/atlas/reports/report-types';
|
|
7
8
|
import { Accordion, Alert, Anchor, Box, Button, Code, Flex, Group, List, Stack, Table, Text, Title } from '@mantine/core';
|
|
@@ -9,12 +10,14 @@ import { useQuery } from '@tanstack/react-query';
|
|
|
9
10
|
import { useMemo, useState, type ReactNode } from 'react';
|
|
10
11
|
import { TestProgress, useTestProgress } from '../live/test-progress';
|
|
11
12
|
import { reportJson } from './api';
|
|
12
|
-
import { ResultEvidence } from './artifacts';
|
|
13
|
+
import { ResultEvidence, useCheckDetail } from './artifacts';
|
|
13
14
|
import { useEvidence } from './evidence-context';
|
|
14
15
|
import { ago, duration, plural, statusWord, useNow } from './format';
|
|
15
16
|
import type { FeatureExamples, FeatureStep } from './gherkin';
|
|
16
17
|
import { Bounded, Origin, StatusMark } from './marks';
|
|
17
18
|
import { ModelTrace } from './model-trace';
|
|
19
|
+
import { OutcomeSummary } from './outcome-summary';
|
|
20
|
+
import { failedReportStep, failedStep, recordOf, sourceLabels, type ResultRecord } from './outcome';
|
|
18
21
|
import { recordedModelStates } from './quint';
|
|
19
22
|
/** What the address says is open: a check, and optionally one particular layer's record of it. */
|
|
20
23
|
export type CheckSelection = {
|
|
@@ -49,13 +52,6 @@ function environmentMarks(slot: CheckSlot) {
|
|
|
49
52
|
if (seen.size < 2) return [];
|
|
50
53
|
return [...seen].map(([id, status]) => ({ id, status }));
|
|
51
54
|
}
|
|
52
|
-
function outcomeName(outcome: EvidenceResult) {
|
|
53
|
-
const environment = outcome.layer.environment ? environmentLabel(outcome.layer.environment) : '';
|
|
54
|
-
const parameters = Object.entries(outcome.parameters)
|
|
55
|
-
.map(([name, value]) => `${name}: ${value}`)
|
|
56
|
-
.join(', ');
|
|
57
|
-
return [environment, parameters].filter(Boolean).join(' · ') || outcome.layer.producer;
|
|
58
|
-
}
|
|
59
55
|
export function ResultRow({
|
|
60
56
|
label,
|
|
61
57
|
slot,
|
|
@@ -93,7 +89,7 @@ export function ResultRow({
|
|
|
93
89
|
<Flex component="span" display="inline-flex" gap="xs" align="center" wrap="wrap">
|
|
94
90
|
{marks.map((mark) => (
|
|
95
91
|
<Text key={mark.id || 'unspecified'} data-status={mark.status} span inherit>
|
|
96
|
-
{environmentLabel(mark.id || null)}
|
|
92
|
+
{environmentLabel(mark.id || null)} {statusWord(mark.status).toLowerCase()}
|
|
97
93
|
</Text>
|
|
98
94
|
))}
|
|
99
95
|
</Flex>
|
|
@@ -276,6 +272,14 @@ type Props = {
|
|
|
276
272
|
pinned?: HistoryEntry;
|
|
277
273
|
/** Choosing a run in the history always names its layer, even when that result is a current one, so the choice stays pinned. */
|
|
278
274
|
pin?: boolean;
|
|
275
|
+
/** The layer id of the current source the address selected (without pinning a run). Used with `onSource`; otherwise the choice is local. */
|
|
276
|
+
source?: string;
|
|
277
|
+
/** Called with the layer id when a source is chosen (null for the newest), so the page can keep the choice in its address. */
|
|
278
|
+
onSource?: (layerId: string | null) => void;
|
|
279
|
+
/** Whether the outcome summary states its own heading. False when the page already shows it. */
|
|
280
|
+
headline?: boolean;
|
|
281
|
+
/** Every record of this check the page knows (a node's history of it), when the layers held here are only the winners. */
|
|
282
|
+
records?: readonly ResultRecord[];
|
|
279
283
|
};
|
|
280
284
|
/** Everything recorded for one check: the chosen result, how it went, and every other run of it. */
|
|
281
285
|
export function CheckDetail(props: Props) {
|
|
@@ -306,16 +310,28 @@ export function CheckDetail(props: Props) {
|
|
|
306
310
|
</>
|
|
307
311
|
);
|
|
308
312
|
}
|
|
309
|
-
function RecordedCheckDetail({ slot, layerId, select, reads, written, pinned, pin }: Props) {
|
|
313
|
+
function RecordedCheckDetail({ slot, layerId, select, reads, written, pinned, pin, source, onSource, headline = true, records }: Props) {
|
|
310
314
|
const { evidence, backends, ready, pending } = useEvidence();
|
|
311
315
|
const now = useNow();
|
|
312
316
|
const [variant, setVariant] = useState(0);
|
|
313
317
|
const history = useMemo(() => checkHistory(evidence, slot.specPath, slot.id), [evidence, slot.specPath, slot.id]);
|
|
314
|
-
const
|
|
318
|
+
const sourceAt = onSource && source ? slot.outcomes.findIndex((outcome) => outcome.layer.id === source) : -1;
|
|
319
|
+
const index = Math.min(onSource ? Math.max(sourceAt, 0) : variant, slot.outcomes.length - 1);
|
|
320
|
+
const winner = slot.outcomes[index]!;
|
|
315
321
|
const earlier = pinned ?? (layerId ? history.find((entry) => entry.layer.id === layerId) : undefined);
|
|
316
322
|
const chosen: HistoryEntry = earlier ?? { layer: winner.layer, row: winner.row };
|
|
317
323
|
const superseded = !slot.outcomes.some((outcome) => outcome.layer.id === chosen.layer.id);
|
|
318
324
|
const { paths, report } = useRecordedReport(chosen);
|
|
325
|
+
const recorded = useCheckDetail(chosen.layer, chosen.row);
|
|
326
|
+
const point = useMemo(
|
|
327
|
+
() => (chosen.row.status === 'failed' ? (failedStep(recorded.data?.check.steps) ?? failedReportStep(report.data)) : undefined),
|
|
328
|
+
[chosen.row.status, recorded.data, report.data]
|
|
329
|
+
);
|
|
330
|
+
const labels = sourceLabels(slot.outcomes, now);
|
|
331
|
+
const choose = (at: number) => {
|
|
332
|
+
setVariant(at);
|
|
333
|
+
onSource?.(at === 0 ? null : slot.outcomes[at]!.layer.id);
|
|
334
|
+
};
|
|
319
335
|
const moments = report.data ? flattenFrames(report.data.steps).length : 0;
|
|
320
336
|
const timings = superseded ? [] : slot.diagnostics.filter((entry) => entry.layer.id === chosen.layer.id || slot.diagnostics.length === 1);
|
|
321
337
|
const note = superseded ? 'An earlier result. A newer run replaced it in the combined report.' : qualification(winner.warning);
|
|
@@ -328,12 +344,32 @@ function RecordedCheckDetail({ slot, layerId, select, reads, written, pinned, pi
|
|
|
328
344
|
: '';
|
|
329
345
|
return (
|
|
330
346
|
<Stack gap="sm" miw={0}>
|
|
347
|
+
<OutcomeSummary
|
|
348
|
+
slot={slot}
|
|
349
|
+
selected={chosen}
|
|
350
|
+
history={records && records.length > history.length ? records : history.map(recordOf)}
|
|
351
|
+
point={point}
|
|
352
|
+
detail={recorded.data}
|
|
353
|
+
now={now}
|
|
354
|
+
showHeadline={headline}
|
|
355
|
+
choose={(layer) => {
|
|
356
|
+
const at = slot.outcomes.findIndex((outcome) => outcome.layer.id === layer);
|
|
357
|
+
if (at >= 0) choose(at);
|
|
358
|
+
else select({ layer });
|
|
359
|
+
}}
|
|
360
|
+
/>
|
|
331
361
|
{slot.outcomes.length > 1 && !earlier && (
|
|
332
362
|
<Flex role="group" aria-label="Environments" gap="xs" align="center" wrap="wrap">
|
|
333
|
-
{slot.outcomes.map((outcome,
|
|
334
|
-
<Button
|
|
363
|
+
{slot.outcomes.map((outcome, at) => (
|
|
364
|
+
<Button
|
|
365
|
+
key={outcome.key}
|
|
366
|
+
type="button"
|
|
367
|
+
aria-pressed={at === index}
|
|
368
|
+
onClick={() => choose(at)}
|
|
369
|
+
variant="light"
|
|
370
|
+
>
|
|
335
371
|
<StatusMark status={outcome.row.status} size={12} />
|
|
336
|
-
{
|
|
372
|
+
{labels[at]!.text}
|
|
337
373
|
</Button>
|
|
338
374
|
))}
|
|
339
375
|
</Flex>
|
|
@@ -400,6 +436,14 @@ function RecordedCheckDetail({ slot, layerId, select, reads, written, pinned, pi
|
|
|
400
436
|
against an allowance of {measurement.limit} {measurement.unit}
|
|
401
437
|
</Text>
|
|
402
438
|
)}
|
|
439
|
+
{isProductLatency(measurement) && (
|
|
440
|
+
<Text span inherit c="dimmed">
|
|
441
|
+
{' '}
|
|
442
|
+
· product latency, {boundaryOf(measurement).label}
|
|
443
|
+
{boundaryOf(measurement).state === 'partial' && ` (partial: not ${boundaryOf(measurement).intended})`}
|
|
444
|
+
{measurement.outcome !== undefined && measurement.outcome !== 'observed' && ` · ${measurement.outcome}: waited at least this long`}
|
|
445
|
+
</Text>
|
|
446
|
+
)}
|
|
403
447
|
</Text>
|
|
404
448
|
);
|
|
405
449
|
})
|
|
@@ -410,7 +454,7 @@ function RecordedCheckDetail({ slot, layerId, select, reads, written, pinned, pi
|
|
|
410
454
|
{moments > 0 && (
|
|
411
455
|
<Button
|
|
412
456
|
type="button"
|
|
413
|
-
onClick={() => select({ view: 'recording', check: slot.id, layer: earlier ||
|
|
457
|
+
onClick={() => select({ view: 'recording', check: slot.id, layer: earlier || index > 0 ? chosen.layer.id : null })}
|
|
414
458
|
variant="filled"
|
|
415
459
|
>
|
|
416
460
|
Open recording{' '}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Outcome summary
|
|
3
|
+
*
|
|
4
|
+
* What the selected result says, stated apart from the other current sources, and the failure explained before its
|
|
5
|
+
* raw output: the heading names the selected source's outcome and what the other scoped sources recorded (each one
|
|
6
|
+
* click away), whether the behavior was reached, the recorded expected and observed values, the closest video and
|
|
7
|
+
* step, the chronology of the source, and who is to blame, which stays unknown unless the producer declared it. The
|
|
8
|
+
* original diagnostics remain below, untouched.
|
|
9
|
+
*/
|
|
10
|
+
import type { CheckSlot, HistoryEntry } from '@crvouga/atlas/reports/evidence-view';
|
|
11
|
+
import type { CheckDetail } from '@crvouga/atlas/reports/evidence-detail';
|
|
12
|
+
import { effectiveScope, scopeBadges } from '@crvouga/atlas/reports/evidence-scope';
|
|
13
|
+
import { Badge, Box, Button, Flex, List, Paper, Stack, Text, Title } from '@mantine/core';
|
|
14
|
+
|
|
15
|
+
import { ago, duration, shortSha, statusWord } from './format';
|
|
16
|
+
import { StatusMark } from './marks';
|
|
17
|
+
import {
|
|
18
|
+
chronology,
|
|
19
|
+
failureNote,
|
|
20
|
+
outcomeHeadline,
|
|
21
|
+
passedAfterFailure,
|
|
22
|
+
recordOf,
|
|
23
|
+
type ResultRecord,
|
|
24
|
+
reachOf,
|
|
25
|
+
sourceLabels,
|
|
26
|
+
videosIn,
|
|
27
|
+
type FailurePoint
|
|
28
|
+
} from './outcome';
|
|
29
|
+
|
|
30
|
+
const BLAME: Record<string, string> = {
|
|
31
|
+
product: 'the product',
|
|
32
|
+
'test-harness': 'the test harness',
|
|
33
|
+
environment: 'the environment'
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The outcome of the selected result in words, the other current sources beside it, and, for a failure, where and
|
|
38
|
+
* how it failed. `point` is the failed step found in the recording (undefined when none was recorded); `detail` is the
|
|
39
|
+
* check's full record, for its videos and scope. `choose` selects one of the other current sources; `showHeadline`
|
|
40
|
+
* is false when the page already shows the heading.
|
|
41
|
+
*/
|
|
42
|
+
export function OutcomeSummary({
|
|
43
|
+
slot,
|
|
44
|
+
selected,
|
|
45
|
+
history,
|
|
46
|
+
point,
|
|
47
|
+
detail,
|
|
48
|
+
now,
|
|
49
|
+
choose,
|
|
50
|
+
showHeadline = true
|
|
51
|
+
}: {
|
|
52
|
+
slot: CheckSlot;
|
|
53
|
+
selected: HistoryEntry;
|
|
54
|
+
history: readonly ResultRecord[];
|
|
55
|
+
point: FailurePoint | undefined;
|
|
56
|
+
detail: CheckDetail | undefined;
|
|
57
|
+
now: number;
|
|
58
|
+
choose: (layerId: string) => void;
|
|
59
|
+
showHeadline?: boolean;
|
|
60
|
+
}) {
|
|
61
|
+
const headline = outcomeHeadline(selected, slot);
|
|
62
|
+
const failed = selected.row.status === 'failed';
|
|
63
|
+
const reach = failed ? reachOf(point) : undefined;
|
|
64
|
+
const note = point?.note ?? failureNote(detail?.check.extensions);
|
|
65
|
+
const every = sourceLabels(slot.outcomes, now);
|
|
66
|
+
const labels = headline.others.map((outcome) => every[slot.outcomes.indexOf(outcome)]!);
|
|
67
|
+
const video = point?.videos[0] ?? videosIn(detail?.check)[0];
|
|
68
|
+
const scope = video ? effectiveScope(video.scope, detail?.check.scope, detail?.inheritedScope) : undefined;
|
|
69
|
+
const timeline = chronology(recordOf(selected), history);
|
|
70
|
+
return (
|
|
71
|
+
<Paper component="section" aria-label="Outcome summary" data-outcome={selected.row.status} withBorder p="md">
|
|
72
|
+
<Stack gap="sm" miw={0}>
|
|
73
|
+
{showHeadline && (
|
|
74
|
+
<Title order={3} data-outcome-headline>
|
|
75
|
+
{headline.text}
|
|
76
|
+
</Title>
|
|
77
|
+
)}
|
|
78
|
+
{headline.others.length > 0 && (
|
|
79
|
+
<Stack gap="xs" component="section" aria-label="Other current sources">
|
|
80
|
+
<Text component="div" c="dimmed">
|
|
81
|
+
Other current sources of this check
|
|
82
|
+
</Text>
|
|
83
|
+
<List listStyleType="none" spacing="xs">
|
|
84
|
+
{headline.others.map((outcome, index) => (
|
|
85
|
+
<List.Item key={outcome.key}>
|
|
86
|
+
<Button type="button" variant="light" onClick={() => choose(outcome.layer.id)} aria-label={`Open ${labels[index]!.text}`}>
|
|
87
|
+
<StatusMark status={outcome.row.status} size={12} />
|
|
88
|
+
<Text span inherit>
|
|
89
|
+
{labels[index]!.text}
|
|
90
|
+
</Text>
|
|
91
|
+
</Button>
|
|
92
|
+
</List.Item>
|
|
93
|
+
))}
|
|
94
|
+
</List>
|
|
95
|
+
</Stack>
|
|
96
|
+
)}
|
|
97
|
+
{reach && (
|
|
98
|
+
<Flex gap="xs" align="center" wrap="wrap" data-reach={reach.state}>
|
|
99
|
+
<Badge variant={reach.state === 'setup-failed' ? 'filled' : 'light'} color={reach.state === 'unknown' ? 'gray' : 'red'}>
|
|
100
|
+
{reach.state === 'setup-failed'
|
|
101
|
+
? 'Behavior not verified'
|
|
102
|
+
: reach.state === 'expectation-failed'
|
|
103
|
+
? 'Expectation not met'
|
|
104
|
+
: 'Failure point unknown'}
|
|
105
|
+
</Badge>
|
|
106
|
+
<Text span inherit>
|
|
107
|
+
{reach.text}
|
|
108
|
+
</Text>
|
|
109
|
+
</Flex>
|
|
110
|
+
)}
|
|
111
|
+
{failed && (note?.expected !== undefined || note?.actual !== undefined) && (
|
|
112
|
+
<Box component="dl" aria-label="Expected and observed" m={0}>
|
|
113
|
+
{note?.expected !== undefined && (
|
|
114
|
+
<>
|
|
115
|
+
<Text component="dt" fw={600}>
|
|
116
|
+
Expected
|
|
117
|
+
</Text>
|
|
118
|
+
<Text component="dd" m={0}>
|
|
119
|
+
{note.expected}
|
|
120
|
+
</Text>
|
|
121
|
+
</>
|
|
122
|
+
)}
|
|
123
|
+
{note?.actual !== undefined && (
|
|
124
|
+
<>
|
|
125
|
+
<Text component="dt" fw={600}>
|
|
126
|
+
Observed
|
|
127
|
+
</Text>
|
|
128
|
+
<Text component="dd" m={0}>
|
|
129
|
+
{note.actual}
|
|
130
|
+
</Text>
|
|
131
|
+
</>
|
|
132
|
+
)}
|
|
133
|
+
</Box>
|
|
134
|
+
)}
|
|
135
|
+
{failed && (point || video) && (
|
|
136
|
+
<Stack gap="xs" component="section" aria-label="Closest evidence">
|
|
137
|
+
{point && (
|
|
138
|
+
<Text component="div">
|
|
139
|
+
Closest step:{' '}
|
|
140
|
+
<Text span inherit fw={600}>
|
|
141
|
+
{point.name}
|
|
142
|
+
</Text>
|
|
143
|
+
{point.offsetMs !== undefined && (
|
|
144
|
+
<Text span inherit c="dimmed">
|
|
145
|
+
{' '}
|
|
146
|
+
at {duration(point.offsetMs) || '0 ms'} into the check
|
|
147
|
+
</Text>
|
|
148
|
+
)}
|
|
149
|
+
</Text>
|
|
150
|
+
)}
|
|
151
|
+
{video && (
|
|
152
|
+
<Text component="div">
|
|
153
|
+
Closest video:{' '}
|
|
154
|
+
<Text span inherit fw={600}>
|
|
155
|
+
{video.description ?? video.name}
|
|
156
|
+
</Text>
|
|
157
|
+
<Text span inherit c="dimmed">
|
|
158
|
+
{' '}
|
|
159
|
+
{point?.videos.length ? 'attached to that step' : 'recorded for the whole check'}
|
|
160
|
+
{' · '}
|
|
161
|
+
{scopeBadges(scope)
|
|
162
|
+
.map((badge) => badge.text)
|
|
163
|
+
.join(', ')}
|
|
164
|
+
</Text>
|
|
165
|
+
</Text>
|
|
166
|
+
)}
|
|
167
|
+
</Stack>
|
|
168
|
+
)}
|
|
169
|
+
{failed && (
|
|
170
|
+
<Text component="div" c="dimmed" data-attribution={note?.attribution ?? 'unknown'}>
|
|
171
|
+
Cause:{' '}
|
|
172
|
+
{note?.attribution
|
|
173
|
+
? `${BLAME[note.attribution]}, as declared by the producer`
|
|
174
|
+
: 'unknown. Nothing recorded says whether the product, the test harness or the environment is at fault.'}
|
|
175
|
+
</Text>
|
|
176
|
+
)}
|
|
177
|
+
{timeline.length > 0 && (
|
|
178
|
+
<Stack gap="xs" component="section" aria-label="Chronology of this source">
|
|
179
|
+
<Text component="div" c="dimmed">
|
|
180
|
+
Records of this source, oldest first
|
|
181
|
+
</Text>
|
|
182
|
+
<List listStyleType="none" spacing="xs" type="ordered">
|
|
183
|
+
{timeline.map(({ record, selected: current }) => (
|
|
184
|
+
<List.Item key={`${record.layer}|${record.kind}`} aria-current={current || undefined} data-status={record.status}>
|
|
185
|
+
<Flex gap="xs" align="center" wrap="wrap">
|
|
186
|
+
<StatusMark status={record.status} size={12} />
|
|
187
|
+
<Text span inherit fw={current ? 600 : undefined}>
|
|
188
|
+
{statusWord(record.status)}
|
|
189
|
+
</Text>
|
|
190
|
+
<Text span inherit c="dimmed">
|
|
191
|
+
{ago(record.at, now)} · {shortSha(record.sha) ?? 'revision not recorded'}
|
|
192
|
+
{current ? ' · selected' : ''}
|
|
193
|
+
</Text>
|
|
194
|
+
</Flex>
|
|
195
|
+
</List.Item>
|
|
196
|
+
))}
|
|
197
|
+
</List>
|
|
198
|
+
{passedAfterFailure(timeline) && (
|
|
199
|
+
<Text component="div" role="status">
|
|
200
|
+
The latest run passed after an earlier failure. Nothing recorded shows that the failure was fixed or why it passed.
|
|
201
|
+
</Text>
|
|
202
|
+
)}
|
|
203
|
+
</Stack>
|
|
204
|
+
)}
|
|
205
|
+
</Stack>
|
|
206
|
+
</Paper>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
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 {
|
|
7
|
+
chronology,
|
|
8
|
+
failedStep,
|
|
9
|
+
failureNote,
|
|
10
|
+
outcomeHeadline,
|
|
11
|
+
passedAfterFailure,
|
|
12
|
+
phaseOf,
|
|
13
|
+
reachOf,
|
|
14
|
+
recordOf,
|
|
15
|
+
selectedResult,
|
|
16
|
+
sourceLabels,
|
|
17
|
+
type ResultRecord
|
|
18
|
+
} from './outcome';
|
|
19
|
+
|
|
20
|
+
const header = (id: string, at: number, extra: Partial<LayerHeader> = {}): LayerHeader => ({
|
|
21
|
+
id, backend: 'b', producer: 'p', packageDir: null, recordedAtMs: at, sha: null, branch: null, environment: 'desktop-web', runUrl: null,
|
|
22
|
+
parameters: {}, recording: null, live: false, counts: { passed: 0, failed: 0, running: 0, 'not-run': 0 }, execution: id, ...extra
|
|
23
|
+
});
|
|
24
|
+
const row = (status: LayerRow['status']): LayerRow => ({
|
|
25
|
+
kind: 'checks', id: 'x', title: 'X', status, specPath: null, detail: '', measurements: [], testId: null, variant: {}, durationMs: null
|
|
26
|
+
});
|
|
27
|
+
const result = (id: string, status: LayerRow['status'], extra: Partial<LayerHeader> = {}): EvidenceResult => ({
|
|
28
|
+
key: id, slot: 's', layer: header(id, 1, extra), parameters: {}, warning: null, row: row(status)
|
|
29
|
+
});
|
|
30
|
+
const slot = (status: CheckSlot['status'], outcomes: EvidenceResult[]): CheckSlot => ({
|
|
31
|
+
key: 's', id: 'x', title: 'X', specPath: null, status, outcomes, diagnostics: [], latestAtMs: 1, live: false
|
|
32
|
+
});
|
|
33
|
+
const entry = (r: EvidenceResult) => ({ layer: r.layer, row: r.row });
|
|
34
|
+
|
|
35
|
+
test('the heading names the selected outcome before the other sources, and never reads it as the combined status', () => {
|
|
36
|
+
const passed = result('a', 'passed');
|
|
37
|
+
const failed = result('b', 'failed', { environment: 'api' });
|
|
38
|
+
const both = slot('failed', [passed, failed]);
|
|
39
|
+
expect(outcomeHeadline(entry(passed), both).text).toBe('Selected result passed; another scoped source failed');
|
|
40
|
+
expect(outcomeHeadline(entry(failed), both).text).toBe('Selected result failed; the other scoped source: 1 passed');
|
|
41
|
+
expect(outcomeHeadline(entry(passed), slot('passed', [passed])).text).toBe('Selected result passed');
|
|
42
|
+
expect(outcomeHeadline(entry(result('c', 'running')), slot('running', [result('c', 'running')])).text).toBe('Selected result is still running');
|
|
43
|
+
expect(outcomeHeadline(entry(passed), slot('failed', [passed, failed, result('d', 'failed')])).text).toContain('2 other scoped sources failed');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('an earlier result says so and names what the combined report now shows', () => {
|
|
47
|
+
const current = result('new', 'passed');
|
|
48
|
+
const old = result('old', 'failed');
|
|
49
|
+
const headline = outcomeHeadline(entry(old), slot('passed', [current]));
|
|
50
|
+
expect(headline.current).toBe(false);
|
|
51
|
+
expect(headline.text).toBe('This earlier result failed; the combined report now shows passed across its current source');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('the selected result is the pinned run, else the named source, else the newest', () => {
|
|
55
|
+
const a = result('a', 'passed');
|
|
56
|
+
const b = result('b', 'failed');
|
|
57
|
+
const both = slot('failed', [a, b]);
|
|
58
|
+
expect(selectedResult(both)?.layer.id).toBe('a');
|
|
59
|
+
expect(selectedResult(both, undefined, 'b')?.layer.id).toBe('b');
|
|
60
|
+
expect(selectedResult(both, undefined, 'unknown')?.layer.id).toBe('a');
|
|
61
|
+
expect(selectedResult(both, entry(b), 'a')?.layer.id).toBe('b');
|
|
62
|
+
expect(selectedResult(slot('passed', []))).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('sources that share a name are told apart by status, revision and their place among them', () => {
|
|
66
|
+
const labels = sourceLabels(
|
|
67
|
+
[result('a', 'passed', { sha: 'b'.repeat(40), producer: 'one' }), result('b', 'failed', { sha: 'a'.repeat(40), producer: 'two' }), result('c', 'passed', { environment: 'api' })],
|
|
68
|
+
1_000
|
|
69
|
+
);
|
|
70
|
+
expect(labels.map((label) => label.text)).toEqual([
|
|
71
|
+
'Desktop web · Passed · just now · bbbbbbb · result 1 of 2 from one',
|
|
72
|
+
'Desktop web · Failed · just now · aaaaaaa · result 2 of 2 from two',
|
|
73
|
+
'Api · Passed · just now · revision not recorded'
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('the phase comes from the recorded kind, never from the wording', () => {
|
|
78
|
+
expect(phaseOf('hook')).toBe('setup');
|
|
79
|
+
expect(phaseOf('Given')).toBe('setup');
|
|
80
|
+
expect(phaseOf('then')).toBe('behavior');
|
|
81
|
+
expect(phaseOf('action')).toBe('unspecified');
|
|
82
|
+
expect(phaseOf(undefined)).toBe('unspecified');
|
|
83
|
+
const point = failedStep([{ name: 'setup failed to connect', status: 'failed', offsetMs: 0 }]);
|
|
84
|
+
expect(point?.phase).toBe('unspecified');
|
|
85
|
+
expect(reachOf(point).state).toBe('unknown');
|
|
86
|
+
expect(reachOf(undefined).state).toBe('unknown');
|
|
87
|
+
expect(reachOf(failedStep([{ name: 'Seed', kind: 'hook', status: 'failed' }])).state).toBe('setup-failed');
|
|
88
|
+
expect(reachOf(failedStep([{ name: 'Check', kind: 'assertion', status: 'failed' }])).state).toBe('expectation-failed');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('the failed step is the first innermost one in time order', () => {
|
|
92
|
+
const point = failedStep([
|
|
93
|
+
{ id: 'late', name: 'Late', status: 'failed', offsetMs: 900 },
|
|
94
|
+
{ id: 'group', name: 'Group', status: 'failed', offsetMs: 100 },
|
|
95
|
+
{ id: 'leaf', parentId: 'group', name: 'Leaf', status: 'failed', offsetMs: 150, detail: 'nope' },
|
|
96
|
+
{ name: 'Fine', status: 'passed', offsetMs: 0 }
|
|
97
|
+
]);
|
|
98
|
+
expect(point?.name).toBe('Leaf');
|
|
99
|
+
expect(point?.detail).toBe('nope');
|
|
100
|
+
expect(failedStep([{ name: 'ok', status: 'passed' }])).toBeUndefined();
|
|
101
|
+
expect(failedStep(undefined)).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('only a producer-declared failure is read, and only a known attribution counts', () => {
|
|
105
|
+
expect(failureNote({ 'atlas.failure': { expected: 200, actual: { status: 500 }, attribution: 'environment' } })).toEqual({
|
|
106
|
+
expected: '200',
|
|
107
|
+
actual: '{"status":500}',
|
|
108
|
+
attribution: 'environment'
|
|
109
|
+
});
|
|
110
|
+
expect(failureNote({ 'atlas.failure': { attribution: 'the intern' } })).toBeUndefined();
|
|
111
|
+
expect(failureNote({ other: {} })).toBeUndefined();
|
|
112
|
+
expect(failureNote(undefined)).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a chronology is only told for one source whose outcome changed, oldest first, without declaring a cause', () => {
|
|
116
|
+
const record = (layer: string, status: ResultRecord['status'], at: number, env: string | null = 'desktop-web'): ResultRecord => ({
|
|
117
|
+
...recordOf(entry(result(layer, status, { environment: env, recordedAtMs: at }))),
|
|
118
|
+
at
|
|
119
|
+
});
|
|
120
|
+
const early = record('1', 'failed', 100);
|
|
121
|
+
const late = record('2', 'passed', 200);
|
|
122
|
+
const timeline = chronology(late, [late, early, record('3', 'passed', 150, 'api')]);
|
|
123
|
+
expect(timeline.map((each) => each.record.layer)).toEqual(['1', '2']);
|
|
124
|
+
expect(timeline[1]!.selected).toBe(true);
|
|
125
|
+
expect(passedAfterFailure(timeline)).toBe(true);
|
|
126
|
+
expect(passedAfterFailure(chronology(early, [early, late]).slice().reverse())).toBe(false);
|
|
127
|
+
expect(chronology(late, [late])).toEqual([]);
|
|
128
|
+
expect(chronology(late, [late, record('4', 'passed', 50)])).toEqual([]);
|
|
129
|
+
});
|