@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/dist/reports/src/explorer-types.d.ts +19 -0
  3. package/dist/reports/src/latency.d.ts +204 -0
  4. package/dist/reports/src/latency.js +219 -0
  5. package/dist/reports/src/reports-explorer-route.d.ts +19 -6
  6. package/dist/reports/src/reports-route.d.ts +19 -6
  7. package/dist/reports/src/test-catalog.d.ts +3 -1
  8. package/dist/reports/src/test-catalog.js +4 -2
  9. package/dist/schema/src/api.d.ts +151 -0
  10. package/dist/schema/src/api.js +4 -3
  11. package/dist/schema/src/layer.d.ts +90 -0
  12. package/dist/schema/src/layer.js +20 -1
  13. package/docs/extending.md +18 -1
  14. package/docs/reference/reports.md +347 -0
  15. package/docs/reference/schema.md +28 -0
  16. package/docs/rich-evidence.md +43 -0
  17. package/docs/ui.md +24 -1
  18. package/llms-full.txt +85 -2
  19. package/package.json +1 -1
  20. package/packages/schema/schemas/check-detail.schema.json +56 -1
  21. package/packages/schema/schemas/effective-delta.schema.json +60 -2
  22. package/packages/schema/schemas/effective-evidence.schema.json +60 -2
  23. package/packages/schema/schemas/layer-rows.schema.json +60 -2
  24. package/packages/schema/schemas/layer.schema.json +112 -2
  25. package/packages/schema/schemas/publish-checks.schema.json +56 -1
  26. package/packages/schema/schemas/test-catalog.schema.json +85 -0
  27. package/packages/schema/schemas/test-history.schema.json +170 -0
  28. package/packages/server/openapi.json +547 -8
  29. package/packages/visualizer/src/evidence/behavior-panel.tsx +10 -16
  30. package/packages/visualizer/src/evidence/check-detail.tsx +59 -15
  31. package/packages/visualizer/src/evidence/outcome-summary.tsx +208 -0
  32. package/packages/visualizer/src/evidence/outcome.test.ts +129 -0
  33. package/packages/visualizer/src/evidence/outcome.ts +309 -0
  34. package/packages/visualizer/src/explorer/ProductLatency.tsx +214 -0
  35. package/packages/visualizer/src/explorer/TestDetail.tsx +32 -3
  36. 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 winner = slot.outcomes[Math.min(variant, slot.outcomes.length - 1)]!;
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, index) => (
334
- <Button key={outcome.key} type="button" aria-pressed={index === variant} onClick={() => setVariant(index)} variant="light">
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
- {outcomeName(outcome)}
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 || variant > 0 ? chosen.layer.id : null })}
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
+ });