@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
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { CheckSlot, SpecEvidence } from '@crvouga/atlas/reports/evidence-view';
|
|
2
2
|
import { Alert, Box, Container, List, Stack, Text, Title } from '@mantine/core';
|
|
3
3
|
import { useMemo } from 'react';
|
|
4
|
-
import type
|
|
4
|
+
import { outlinePath, type SpecKind } from '../spec-kind';
|
|
5
5
|
import { CheckDetail, PlainSteps, ResultRow, type CheckSelection, type SelectCheck } from './check-detail';
|
|
6
|
+
import { behaviorOf, coverageCounts, scenarioBehaviors, type Behavior, type RevisionSource } from './behavior';
|
|
7
|
+
import { BehaviorPanel, CoverageBadge, CoverageSummary } from './behavior-panel';
|
|
8
|
+
import { useEvidence } from './evidence-context';
|
|
6
9
|
import { featureModel, scenarioLabel, slotsByScenario, type FeatureModel, type FeatureScenario } from './gherkin';
|
|
7
10
|
import { Bounded } from './marks';
|
|
8
11
|
import { SpecHeader } from './spec-header';
|
|
@@ -23,7 +26,10 @@ function parse(text: string): {
|
|
|
23
26
|
try {
|
|
24
27
|
return { feature: featureModel(text), error: null };
|
|
25
28
|
} catch (error) {
|
|
26
|
-
return {
|
|
29
|
+
return {
|
|
30
|
+
feature: null,
|
|
31
|
+
error: error instanceof Error ? error.message : String(error)
|
|
32
|
+
};
|
|
27
33
|
}
|
|
28
34
|
}
|
|
29
35
|
/** A feature read as its rules and scenarios, each scenario with what was recorded when it ran. */
|
|
@@ -35,43 +41,81 @@ export function FeaturePage({ file, text, kind, entry, selection, select }: Prop
|
|
|
35
41
|
() => (feature ? slotsByScenario(feature, slots) : { byScenario: new Map<string, CheckSlot[]>(), leftOver: slots }),
|
|
36
42
|
[feature, slots]
|
|
37
43
|
);
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
44
|
+
const { specs, evidence } = useEvidence();
|
|
45
|
+
const specPath = outlinePath(file);
|
|
46
|
+
const revisions = useMemo<RevisionSource>(() => {
|
|
47
|
+
const recorded = new Map(evidence.map((layer) => [layer.layer.id, layer.fingerprints[specPath]]));
|
|
48
|
+
return {
|
|
49
|
+
current: specs.find((spec) => spec.path === specPath)?.fingerprint ?? null,
|
|
50
|
+
recorded: (id) => recorded.get(id)
|
|
51
|
+
};
|
|
52
|
+
}, [specs, evidence, specPath]);
|
|
53
|
+
const behaviors = useMemo(() => {
|
|
54
|
+
const byScenario = new Map<FeatureScenario, Behavior[]>();
|
|
55
|
+
for (const scenario of feature?.rules.flatMap((rule) => rule.scenarios.filter((each) => !each.background)) ?? []) {
|
|
56
|
+
byScenario.set(scenario, scenarioBehaviors(scenario, filed.byScenario.get(scenario.name) ?? [], feature!.name, revisions));
|
|
57
|
+
}
|
|
58
|
+
// A result no scenario claims is still a behavior that was checked, so it is counted.
|
|
59
|
+
const stray: FeatureScenario = {
|
|
60
|
+
keyword: 'Scenario',
|
|
61
|
+
name: '',
|
|
62
|
+
description: '',
|
|
63
|
+
background: false,
|
|
64
|
+
tags: [],
|
|
65
|
+
steps: [],
|
|
66
|
+
examples: []
|
|
67
|
+
};
|
|
68
|
+
const other = filed.leftOver.map((slot) => behaviorOf({ ...stray, name: slot.title }, slot, null, revisions, slot.key));
|
|
69
|
+
return { byScenario, all: [...[...byScenario.values()].flat(), ...other] };
|
|
70
|
+
}, [feature, filed, revisions]);
|
|
71
|
+
const counts = useMemo(() => coverageCounts(behaviors.all), [behaviors]);
|
|
72
|
+
const planned = behaviors.all.length;
|
|
41
73
|
const toggle = (id: string) => select({ check: selection.check === id ? null : id, layer: null });
|
|
42
|
-
const rows = (scenario: FeatureScenario) =>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const id = `scenario:${scenario.name}`;
|
|
74
|
+
const rows = (scenario: FeatureScenario) =>
|
|
75
|
+
(behaviors.byScenario.get(scenario) ?? []).map((behavior) => {
|
|
76
|
+
if (runningOnly && behavior.slot?.status !== 'running') return null;
|
|
77
|
+
const { slot } = behavior;
|
|
78
|
+
const written = <PlainSteps steps={scenario.steps} examples={scenario.examples} />;
|
|
79
|
+
const id = slot ? slot.id : `scenario:${scenario.name}${behavior.example ? `:${behavior.example}` : ''}`;
|
|
80
|
+
const label = slot
|
|
81
|
+
? scenarioLabel(feature!.name, slot.title)
|
|
82
|
+
: behavior.example
|
|
83
|
+
? `${scenario.name} — ${behavior.example}`
|
|
84
|
+
: scenario.name;
|
|
48
85
|
return (
|
|
49
|
-
<ResultRow
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
{
|
|
86
|
+
<ResultRow
|
|
87
|
+
key={id}
|
|
88
|
+
label={label}
|
|
89
|
+
slot={slot}
|
|
90
|
+
badge={<CoverageBadge state={behavior.state} />}
|
|
91
|
+
open={selection.check === id}
|
|
92
|
+
onToggle={() => toggle(id)}
|
|
93
|
+
>
|
|
94
|
+
<BehaviorPanel
|
|
95
|
+
behavior={behavior}
|
|
96
|
+
start={selection.check === id && selection.layer ? 'inspect' : undefined}
|
|
97
|
+
inspect={
|
|
98
|
+
slot ? (
|
|
99
|
+
<CheckDetail
|
|
100
|
+
slot={slot}
|
|
101
|
+
layerId={selection.check === id ? selection.layer : undefined}
|
|
102
|
+
select={(patch) => select({ check: id, ...patch })}
|
|
103
|
+
reads="steps"
|
|
104
|
+
written={written}
|
|
105
|
+
/>
|
|
106
|
+
) : (
|
|
107
|
+
<>
|
|
108
|
+
<Text component="div" c="dimmed">
|
|
109
|
+
No completed test report for this scenario yet.
|
|
110
|
+
</Text>
|
|
111
|
+
{written}
|
|
112
|
+
</>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
/>
|
|
54
116
|
</ResultRow>
|
|
55
117
|
);
|
|
56
|
-
}
|
|
57
|
-
return recorded.map((slot) => (
|
|
58
|
-
<ResultRow
|
|
59
|
-
key={slot.key}
|
|
60
|
-
label={scenarioLabel(feature!.name, slot.title)}
|
|
61
|
-
slot={slot}
|
|
62
|
-
open={selection.check === slot.id}
|
|
63
|
-
onToggle={() => toggle(slot.id)}
|
|
64
|
-
>
|
|
65
|
-
<CheckDetail
|
|
66
|
-
slot={slot}
|
|
67
|
-
layerId={selection.check === slot.id ? selection.layer : undefined}
|
|
68
|
-
select={(patch) => select({ check: slot.id, ...patch })}
|
|
69
|
-
reads="steps"
|
|
70
|
-
written={written}
|
|
71
|
-
/>
|
|
72
|
-
</ResultRow>
|
|
73
|
-
));
|
|
74
|
-
};
|
|
118
|
+
});
|
|
75
119
|
return (
|
|
76
120
|
<Container component="article" size="xl" w="100%" py="xl">
|
|
77
121
|
<SpecHeader
|
|
@@ -87,6 +131,7 @@ export function FeaturePage({ file, text, kind, entry, selection, select }: Prop
|
|
|
87
131
|
</Text>
|
|
88
132
|
)}
|
|
89
133
|
</SpecHeader>
|
|
134
|
+
<CoverageSummary counts={counts} total={behaviors.all.length} />
|
|
90
135
|
<SourceText text={text} file={file} withResults />
|
|
91
136
|
<Box component="section" id="spec-results" aria-label="Recorded results" tabIndex={-1}>
|
|
92
137
|
<TestFilter slots={slots} />
|
|
@@ -112,7 +157,8 @@ export function FeaturePage({ file, text, kind, entry, selection, select }: Prop
|
|
|
112
157
|
.map((scenario, at) => (
|
|
113
158
|
<Stack key={at} gap="sm" miw={0}>
|
|
114
159
|
<Text component="div" c="dimmed">
|
|
115
|
-
Before each scenario
|
|
160
|
+
Before each scenario
|
|
161
|
+
{scenario.name ? `: ${scenario.name}` : ''}
|
|
116
162
|
</Text>
|
|
117
163
|
<PlainSteps steps={scenario.steps} />
|
|
118
164
|
</Stack>
|
|
@@ -13,6 +13,8 @@ export type FeatureScenario = {
|
|
|
13
13
|
readonly name: string;
|
|
14
14
|
readonly description: string;
|
|
15
15
|
readonly background: boolean;
|
|
16
|
+
/** The scenario's own tags, without the `@` (a parked or skipped scenario says so here). */
|
|
17
|
+
readonly tags: readonly string[];
|
|
16
18
|
readonly steps: readonly FeatureStep[];
|
|
17
19
|
readonly examples: readonly FeatureExamples[];
|
|
18
20
|
};
|
|
@@ -50,13 +52,14 @@ export function featureModel(text: string): FeatureModel | null {
|
|
|
50
52
|
for (const entry of child.rule ? child.rule.children : [child]) {
|
|
51
53
|
const background = entry.background;
|
|
52
54
|
const scenario = entry.scenario;
|
|
53
|
-
if (background) group.scenarios.push({ keyword: background.keyword, name: background.name, description: prose(background.description), background: true, steps: steps(background.steps), examples: [] });
|
|
55
|
+
if (background) group.scenarios.push({ keyword: background.keyword, name: background.name, description: prose(background.description), background: true, tags: [], steps: steps(background.steps), examples: [] });
|
|
54
56
|
if (scenario) {
|
|
55
57
|
group.scenarios.push({
|
|
56
58
|
keyword: scenario.keyword,
|
|
57
59
|
name: scenario.name,
|
|
58
60
|
description: prose(scenario.description),
|
|
59
61
|
background: false,
|
|
62
|
+
tags: [...new Set([...feature.tags, ...(child.rule?.tags ?? []), ...scenario.tags].map((tag) => tag.name.replace(/^@/, '')))],
|
|
60
63
|
steps: steps(scenario.steps),
|
|
61
64
|
examples: scenario.examples.map((example) => ({ name: example.name, header: cells(example.tableHeader), rows: example.tableBody.map(cells) }))
|
|
62
65
|
});
|
|
@@ -96,3 +99,30 @@ export function slotsByScenario(feature: FeatureModel, slots: readonly CheckSlot
|
|
|
96
99
|
}
|
|
97
100
|
return { byScenario, leftOver };
|
|
98
101
|
}
|
|
102
|
+
|
|
103
|
+
/** The rows of an outline's examples, each with the names of its columns. */
|
|
104
|
+
export function exampleRows(scenario: Pick<FeatureScenario, 'examples'>): { readonly header: readonly string[]; readonly row: readonly string[] }[] {
|
|
105
|
+
return scenario.examples.flatMap((example) => example.rows.map((row) => ({ header: example.header, row })));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Whether a recorded example label (what follows `Scenario — `) names one row: by its cells, joined, or by `column=value` pairs. */
|
|
109
|
+
function namesRow(label: string, entry: { readonly header: readonly string[]; readonly row: readonly string[] }) {
|
|
110
|
+
const joined = [entry.row.join(', '), entry.row.join(' '), entry.row.join(' | '), entry.header.map((name, at) => `${name}=${entry.row[at] ?? ''}`).join(', ')];
|
|
111
|
+
return joined.includes(label) || entry.row.includes(label);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The index (in {@link exampleRows}) of the row a recorded label stands for, or -1 when the label names no single row (the producer worded it its own way, or two rows read alike). */
|
|
115
|
+
export function exampleIndexFor(scenario: Pick<FeatureScenario, 'examples'>, label: string): number {
|
|
116
|
+
const rows = exampleRows(scenario);
|
|
117
|
+
const found = rows.flatMap((entry, at) => (namesRow(label, entry) ? [at] : []));
|
|
118
|
+
return found.length === 1 ? found[0]! : -1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The steps of a scenario with one example's values put in for its `<column>` placeholders, so the concrete inputs read as written. */
|
|
122
|
+
export function fillExample(steps: readonly FeatureStep[], entry: { readonly header: readonly string[]; readonly row: readonly string[] }): FeatureStep[] {
|
|
123
|
+
const fill = (text: string) => text.replace(/<([^<>]+)>/g, (whole, name: string) => {
|
|
124
|
+
const at = entry.header.indexOf(name);
|
|
125
|
+
return at < 0 ? whole : (entry.row[at] ?? whole);
|
|
126
|
+
});
|
|
127
|
+
return steps.map((step) => ({ ...step, text: fill(step.text), docString: step.docString === null ? null : fill(step.docString), table: step.table.map((row) => row.map(fill)) }));
|
|
128
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { addSummaries, NO_EVIDENCE, type SpecSummary } from '@crvouga/atlas/reports/evidence-view';
|
|
2
|
-
import { Box, Button, Group, NavLink, Stack, Text, Title } from '@mantine/core';
|
|
2
|
+
import { Box, Button, Group, NavLink, SegmentedControl, Stack, Text, TextInput, Title } from '@mantine/core';
|
|
3
3
|
import { Link, useSearch } from '@tanstack/react-router';
|
|
4
|
-
import { useMemo } from 'react';
|
|
4
|
+
import { useMemo, useState } from 'react';
|
|
5
5
|
import { folderOf, outlinePath, specFile, type SpecKind } from '../spec-kind';
|
|
6
6
|
import { useUiStore, type ResultFilter } from '../state/ui-store';
|
|
7
7
|
import { useEvidence } from './evidence-context';
|
|
@@ -17,30 +17,42 @@ const FILTERS: readonly {
|
|
|
17
17
|
{ id: 'failed', label: 'Failing', keeps: (summary) => summary.counts.failed > 0 },
|
|
18
18
|
{ id: 'none', label: 'None', keeps: (summary) => summary.status === 'none' }
|
|
19
19
|
];
|
|
20
|
+
const NO_DOMAIN = 'No domain declared';
|
|
20
21
|
/** Every spec of one kind, grouped by folder, each with what the combined report holds for it. */
|
|
21
22
|
export function SpecIndex({ kind }: { kind: SpecKind }) {
|
|
22
23
|
const evidence = useEvidence();
|
|
23
24
|
const search = useSearch({ strict: false });
|
|
24
25
|
const filter = useUiStore((s) => s.resultFilter);
|
|
25
26
|
const setFilter = useUiStore((s) => s.setResultFilter);
|
|
27
|
+
const [query, setQuery] = useState('');
|
|
28
|
+
const [grouping, setGrouping] = useState<'folder' | 'domain'>('folder');
|
|
26
29
|
const open = typeof search.spec === 'string' ? specFile(search.spec) : '';
|
|
30
|
+
// The domain a spec declares in its metadata is used as given; a spec that declares none is not given one.
|
|
31
|
+
const domains = useMemo(() => new Map(evidence.specs.map((spec) => [spec.path, spec.domain])), [evidence.specs]);
|
|
27
32
|
const rows = useMemo(
|
|
28
33
|
() =>
|
|
29
34
|
evidence.files.filter(kind.accepts).map((file) => ({
|
|
30
35
|
file,
|
|
31
36
|
title: evidence.titleOf(file),
|
|
37
|
+
domain: domains.get(outlinePath(file)) || '',
|
|
32
38
|
summary: evidence.view.specs.get(outlinePath(file))?.summary ?? NO_EVIDENCE
|
|
33
39
|
})),
|
|
34
40
|
// `titleOf` reads the outline the view was built from.
|
|
35
|
-
[evidence.files, evidence.view, kind]
|
|
41
|
+
[evidence.files, evidence.view, kind, domains]
|
|
36
42
|
);
|
|
37
43
|
const keeps = FILTERS.find((entry) => entry.id === filter)!.keeps;
|
|
38
|
-
const
|
|
44
|
+
const needle = query.trim().toLowerCase();
|
|
45
|
+
const visible = rows.filter(
|
|
46
|
+
(row) => keeps(row.summary) && (!needle || `${row.title} ${row.file} ${row.domain}`.toLowerCase().includes(needle))
|
|
47
|
+
);
|
|
39
48
|
const groups = useMemo(() => {
|
|
40
|
-
const
|
|
41
|
-
for (const row of visible)
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
const byGroup = new Map<string, typeof visible>();
|
|
50
|
+
for (const row of visible) {
|
|
51
|
+
const group = grouping === 'domain' ? row.domain || NO_DOMAIN : folderOf(row.file);
|
|
52
|
+
byGroup.set(group, [...(byGroup.get(group) ?? []), row]);
|
|
53
|
+
}
|
|
54
|
+
return [...byGroup];
|
|
55
|
+
}, [visible, grouping]);
|
|
44
56
|
return (
|
|
45
57
|
<Box aria-label={`${kind.label} ${kind.many}`} component="nav" p="md">
|
|
46
58
|
<Group role="group" aria-label="Show" gap="xs" mb="md">
|
|
@@ -59,19 +71,40 @@ export function SpecIndex({ kind }: { kind: SpecKind }) {
|
|
|
59
71
|
</Button>
|
|
60
72
|
))}
|
|
61
73
|
</Group>
|
|
74
|
+
<Group gap="md" align="end" mb="md">
|
|
75
|
+
<TextInput
|
|
76
|
+
label={`Search ${kind.many}`}
|
|
77
|
+
type="search"
|
|
78
|
+
value={query}
|
|
79
|
+
onChange={(event) => setQuery(event.currentTarget.value)}
|
|
80
|
+
flex={1}
|
|
81
|
+
miw="12rem"
|
|
82
|
+
/>
|
|
83
|
+
<SegmentedControl
|
|
84
|
+
aria-label="Group by"
|
|
85
|
+
value={grouping}
|
|
86
|
+
onChange={(value) => setGrouping(value === 'domain' ? 'domain' : 'folder')}
|
|
87
|
+
data={[
|
|
88
|
+
{ label: 'Folder', value: 'folder' },
|
|
89
|
+
{ label: 'Domain', value: 'domain' }
|
|
90
|
+
]}
|
|
91
|
+
/>
|
|
92
|
+
</Group>
|
|
62
93
|
{evidence.filesPending && <Text component="div">Loading…</Text>}
|
|
63
94
|
{evidence.filesError && <Text component="div">The file list could not be loaded.</Text>}
|
|
64
95
|
{!evidence.filesPending && !evidence.filesError && visible.length === 0 && (
|
|
65
96
|
<Text component="div">
|
|
66
97
|
{rows.length === 0
|
|
67
98
|
? `No ${kind.many} in this spec set.`
|
|
68
|
-
:
|
|
69
|
-
? `No
|
|
70
|
-
: filter === '
|
|
71
|
-
? `No
|
|
72
|
-
: filter === '
|
|
73
|
-
? `No ${kind.many}
|
|
74
|
-
:
|
|
99
|
+
: needle
|
|
100
|
+
? `No ${kind.many} match "${query.trim()}".`
|
|
101
|
+
: filter === 'running'
|
|
102
|
+
? `No running ${kind.many}.`
|
|
103
|
+
: filter === 'failed'
|
|
104
|
+
? `No failing ${kind.many}.`
|
|
105
|
+
: filter === 'results'
|
|
106
|
+
? `No ${kind.many} with results yet.`
|
|
107
|
+
: `Every ${kind.one} has results.`}
|
|
75
108
|
</Text>
|
|
76
109
|
)}
|
|
77
110
|
{groups.map(([folder, entries]) => (
|
|
@@ -142,10 +142,10 @@ export function Explorer() {
|
|
|
142
142
|
<Title order={1} mb="sm">
|
|
143
143
|
{projectTitle()}
|
|
144
144
|
</Title>
|
|
145
|
-
{model && model.tests.length > 0 && (
|
|
145
|
+
{model && (model.tests.length > 0 || model.summary.runs > 0) && (
|
|
146
146
|
<Tabs
|
|
147
147
|
value={search.tab ?? 'tests'}
|
|
148
|
-
onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' ? value : undefined, test: undefined, openRun: undefined, layer: undefined })}
|
|
148
|
+
onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' ? value : undefined, test: undefined, openRun: undefined, layer: undefined, artifact: undefined })}
|
|
149
149
|
>
|
|
150
150
|
<Tabs.List aria-label="Explorer view">
|
|
151
151
|
<Tabs.Tab
|
|
@@ -212,7 +212,12 @@ export function Explorer() {
|
|
|
212
212
|
Loading tests…
|
|
213
213
|
</Text>
|
|
214
214
|
)}
|
|
215
|
-
{empty &&
|
|
215
|
+
{empty && model && model.summary.runs > 0 && search.tab !== 'runs' && (
|
|
216
|
+
<Text component="div" c="dimmed">
|
|
217
|
+
No test results recorded, but {model.summary.runs === 1 ? '1 run carries' : `${model.summary.runs} runs carry`} files. Open the Runs tab to see them.
|
|
218
|
+
</Text>
|
|
219
|
+
)}
|
|
220
|
+
{empty && !kinds.length && !(model && model.summary.runs > 0) && (
|
|
216
221
|
<Text component="div" c="dimmed">
|
|
217
222
|
No reports yet. Publish tests and specs to this project to populate its report.
|
|
218
223
|
</Text>
|
|
@@ -223,13 +228,12 @@ export function Explorer() {
|
|
|
223
228
|
</Text>
|
|
224
229
|
)}
|
|
225
230
|
{model &&
|
|
226
|
-
!empty &&
|
|
227
231
|
listing &&
|
|
228
232
|
(search.tab === 'runs' ? (
|
|
229
233
|
<RunsView />
|
|
230
234
|
) : search.tab === 'artifacts' ? (
|
|
231
235
|
<ArtifactsView />
|
|
232
|
-
) : (
|
|
236
|
+
) : empty ? null : (
|
|
233
237
|
<>
|
|
234
238
|
<InsightStrip model={model} scope={listing.scope} search={search} />
|
|
235
239
|
<Toolbar
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Run artifacts
|
|
3
|
+
*
|
|
4
|
+
* The files a run carries, without opening a test: for each layer of the run, who recorded it and how many checks it
|
|
5
|
+
* holds (a layer of none is evidence only and is never shown as a pass), then the files it carries as a whole (review
|
|
6
|
+
* movies, archives, summaries) played or shown in place; and the recordings its tests reference, listed once however
|
|
7
|
+
* many tests share the same bytes. Each run-level file has an address of its own (`openRun`, `layer`, `artifact`) that
|
|
8
|
+
* selects that exact file of that immutable layer, whatever runs arrive later. Files are shown exactly as everywhere
|
|
9
|
+
* else: by `ArtifactCard`, so sandboxing, content types and safe downloads are the same.
|
|
10
|
+
*/
|
|
11
|
+
import { artifactRef } from '@crvouga/atlas/reports/artifact-tags';
|
|
12
|
+
import { attachmentIdentity, type EvidenceHolder, type PartEvidence } from '@crvouga/atlas/reports/evidence-detail';
|
|
13
|
+
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
14
|
+
import type { RunDetail, RunLayer, RunResult } from '@crvouga/atlas/reports/test-catalog';
|
|
15
|
+
import type { Attachment } from '@crvouga/atlas/schema';
|
|
16
|
+
import { Alert, Anchor, Badge, Box, Button, Group, List, Stack, Text, Title } from '@mantine/core';
|
|
17
|
+
import { IconAlertTriangle, IconVideo } from '@tabler/icons-react';
|
|
18
|
+
import { useQueries } from '@tanstack/react-query';
|
|
19
|
+
import { Link } from '@tanstack/react-router';
|
|
20
|
+
import { useMemo } from 'react';
|
|
21
|
+
|
|
22
|
+
import { reportJson } from '../evidence/api';
|
|
23
|
+
import { ArtifactCard } from '../evidence/artifacts';
|
|
24
|
+
import { exactTime, plural } from '../evidence/format';
|
|
25
|
+
import { Bounded } from '../evidence/marks';
|
|
26
|
+
import { patched, useExplorerSearch } from './navigate';
|
|
27
|
+
|
|
28
|
+
/** Files shown per group before "more". */
|
|
29
|
+
const SHOWN = 12;
|
|
30
|
+
/** Tests named in a recording's sentence before "and N more". */
|
|
31
|
+
const TESTS_NAMED = 5;
|
|
32
|
+
|
|
33
|
+
/** What a layer carries as a whole, as `GET /evidence/detail/{backend}/{id}` answers without a check. */
|
|
34
|
+
type LayerFiles = { readonly layer: string; readonly run: EvidenceHolder; readonly parts?: readonly PartEvidence[] };
|
|
35
|
+
|
|
36
|
+
/** A group of files one holder of a layer carries: the layer itself or one of its parts. */
|
|
37
|
+
type Holding = { readonly owner: string; readonly attachments: readonly Attachment[] };
|
|
38
|
+
|
|
39
|
+
function holdings(files: LayerFiles): Holding[] {
|
|
40
|
+
return [
|
|
41
|
+
{ owner: 'the layer', attachments: files.run.attachments ?? [] },
|
|
42
|
+
...(files.parts ?? []).map((part) => ({ owner: `${part.title || part.id} (${part.kind})`, attachments: part.attachments ?? [] }))
|
|
43
|
+
].filter((holding) => holding.attachments.length > 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The tests that reference each recording, by what identifies its bytes. */
|
|
47
|
+
function recordingsOf(results: readonly RunResult[]) {
|
|
48
|
+
const byBytes = new Map<string, { name: string; tests: Map<string, RunResult>; references: number }>();
|
|
49
|
+
let unavailable = 0;
|
|
50
|
+
for (const result of results)
|
|
51
|
+
for (const video of result.videos ?? []) {
|
|
52
|
+
if (!video.available || video.recording === undefined) {
|
|
53
|
+
unavailable += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const held = byBytes.get(video.recording) ?? { name: video.name, tests: new Map<string, RunResult>(), references: 0 };
|
|
57
|
+
held.references += 1;
|
|
58
|
+
held.tests.set(result.key, held.tests.get(result.key) ?? result);
|
|
59
|
+
byBytes.set(video.recording, held);
|
|
60
|
+
}
|
|
61
|
+
return { byBytes, unavailable };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The tests of a recording as links that open each test pinned to this run's record of it. */
|
|
65
|
+
function TestLinks({ tests }: { tests: readonly RunResult[] }) {
|
|
66
|
+
return (
|
|
67
|
+
<>
|
|
68
|
+
{tests.slice(0, TESTS_NAMED).map((result, index) => (
|
|
69
|
+
<Text span inherit key={result.key}>
|
|
70
|
+
{index > 0 && ', '}
|
|
71
|
+
<Anchor renderRoot={(props) => <Link {...props} to="/" search={patched({ tab: undefined, openRun: undefined, artifact: undefined, test: result.key, layer: result.layer })} resetScroll={false} activeOptions={{ exact: true }} />}>
|
|
72
|
+
{result.title}
|
|
73
|
+
</Anchor>
|
|
74
|
+
</Text>
|
|
75
|
+
))}
|
|
76
|
+
{tests.length > TESTS_NAMED && ` and ${tests.length - TESTS_NAMED} more`}
|
|
77
|
+
</>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Where the run's layer is from, for a person: the producer, environment and when it was recorded. */
|
|
82
|
+
function provenance(layer: RunLayer): string {
|
|
83
|
+
return [layer.producer, layer.environment ? environmentLabel(layer.environment) : '', `recorded ${exactTime(layer.recordedAtMs)}`].filter(Boolean).join(' · ');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function LayerBlock({ runKey, layer, files, selected, shared }: { runKey: string; layer: RunLayer; files: { isPending: boolean; isError: boolean; refetch: () => unknown; data: LayerFiles | undefined }; selected: string | undefined; shared: (attachment: Attachment) => RunResult[] }) {
|
|
87
|
+
const groups = files.data ? holdings(files.data) : [];
|
|
88
|
+
return (
|
|
89
|
+
<Box component="section" aria-label={`Layer ${layer.producer}`} data-layer={layer.id} mb="lg">
|
|
90
|
+
<Title order={4}>{provenance(layer)}</Title>
|
|
91
|
+
<Text c="dimmed" data-layer-checks={layer.checks}>
|
|
92
|
+
{layer.checks > 0
|
|
93
|
+
? `${plural(layer.checks, 'check', 'checks')} recorded in this layer.`
|
|
94
|
+
: 'No checks in this layer: it carries files only, so it says nothing about whether anything passed.'}{' '}
|
|
95
|
+
{plural(layer.attachments, 'file', 'files')} of its own.
|
|
96
|
+
</Text>
|
|
97
|
+
{files.isPending && (
|
|
98
|
+
<Text role="status" component="div" c="dimmed">
|
|
99
|
+
Reading the files of this layer…
|
|
100
|
+
</Text>
|
|
101
|
+
)}
|
|
102
|
+
{files.isError && (
|
|
103
|
+
<Alert role="alert" color="red" my="sm" title="The files of this layer could not be read">
|
|
104
|
+
<Button variant="light" onClick={() => void files.refetch()}>
|
|
105
|
+
Try again
|
|
106
|
+
</Button>
|
|
107
|
+
</Alert>
|
|
108
|
+
)}
|
|
109
|
+
{groups.map((group) => (
|
|
110
|
+
<Stack key={group.owner} gap="xs" mt="sm">
|
|
111
|
+
{groups.length > 1 || group.owner !== 'the layer' ? <Text c="dimmed">From {group.owner}</Text> : null}
|
|
112
|
+
<List spacing="xs" listStyleType="none">
|
|
113
|
+
<Bounded items={group.attachments} size={SHOWN} more="More files">
|
|
114
|
+
{(attachment, index) => {
|
|
115
|
+
const tests = shared(attachment);
|
|
116
|
+
const ref = artifactRef(attachment);
|
|
117
|
+
return (
|
|
118
|
+
<ArtifactCard
|
|
119
|
+
key={`${ref}:${index}`}
|
|
120
|
+
attachment={attachment}
|
|
121
|
+
layer={layer.id}
|
|
122
|
+
selected={selected === `${layer.id}\u0000${ref}`}
|
|
123
|
+
link={
|
|
124
|
+
<Anchor
|
|
125
|
+
aria-label={`Link to ${attachment.name}`}
|
|
126
|
+
renderRoot={(props) => (
|
|
127
|
+
<Link {...props} to="/" search={patched({ tab: 'runs', openRun: runKey, layer: layer.id, artifact: ref, test: undefined, video: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
128
|
+
)}
|
|
129
|
+
>
|
|
130
|
+
Link to this file
|
|
131
|
+
</Anchor>
|
|
132
|
+
}
|
|
133
|
+
note={
|
|
134
|
+
tests.length > 0 ? (
|
|
135
|
+
<Text component="div" c="dimmed" data-shared>
|
|
136
|
+
The same bytes are also referenced by {plural(tests.length, 'test', 'tests')} (<TestLinks tests={tests} />
|
|
137
|
+
). That is one recording, not {tests.length + 1}.
|
|
138
|
+
</Text>
|
|
139
|
+
) : undefined
|
|
140
|
+
}
|
|
141
|
+
/>
|
|
142
|
+
);
|
|
143
|
+
}}
|
|
144
|
+
</Bounded>
|
|
145
|
+
</List>
|
|
146
|
+
</Stack>
|
|
147
|
+
))}
|
|
148
|
+
</Box>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The artifacts of one run: every layer's files, then the recordings its tests share. The selected file comes from the
|
|
154
|
+
* address (`layer` and `artifact`); one that cannot be found is said so, never silently replaced by another.
|
|
155
|
+
*/
|
|
156
|
+
export function RunArtifacts({ detail }: { detail: RunDetail }) {
|
|
157
|
+
const search = useExplorerSearch();
|
|
158
|
+
const { run, results } = detail;
|
|
159
|
+
const layers = detail.layers ?? [];
|
|
160
|
+
const carrying = layers.filter((layer) => layer.attachments > 0);
|
|
161
|
+
const queries = useQueries({
|
|
162
|
+
queries: carrying.map((layer) => ({
|
|
163
|
+
queryKey: ['run-files', layer.backend, layer.id],
|
|
164
|
+
queryFn: ({ signal }: { signal: AbortSignal }) => reportJson<LayerFiles>(`/evidence/detail/${encodeURIComponent(layer.backend)}/${encodeURIComponent(layer.id)}`, signal),
|
|
165
|
+
enabled: /^[0-9a-f]{64}$/.test(layer.id),
|
|
166
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
167
|
+
retry: false
|
|
168
|
+
}))
|
|
169
|
+
});
|
|
170
|
+
const { byBytes, unavailable } = useMemo(() => recordingsOf(results), [results]);
|
|
171
|
+
const loaded = queries.map((query) => query.data);
|
|
172
|
+
// What identifies the bytes of each run-level file, so a recording a test also references is one thing.
|
|
173
|
+
const runLevel = new Set<string>();
|
|
174
|
+
for (const files of loaded)
|
|
175
|
+
for (const group of files ? holdings(files) : [])
|
|
176
|
+
for (const attachment of group.attachments) {
|
|
177
|
+
const identity = attachmentIdentity(attachment);
|
|
178
|
+
if (identity) runLevel.add(identity);
|
|
179
|
+
}
|
|
180
|
+
const sharedWith = (attachment: Attachment): RunResult[] => {
|
|
181
|
+
const identity = attachmentIdentity(attachment);
|
|
182
|
+
return identity ? [...(byBytes.get(identity)?.tests.values() ?? [])] : [];
|
|
183
|
+
};
|
|
184
|
+
const inTests = [...byBytes.entries()].filter(([identity]) => !runLevel.has(identity));
|
|
185
|
+
const wanted = search.layer && search.artifact ? `${search.layer}\u0000${search.artifact}` : undefined;
|
|
186
|
+
const everyRead = queries.every((query) => !query.isPending);
|
|
187
|
+
const found = wanted
|
|
188
|
+
? loaded.some((files, index) => files && carrying[index]!.id === search.layer && holdings(files).some((group) => group.attachments.some((attachment) => artifactRef(attachment) === search.artifact)))
|
|
189
|
+
: true;
|
|
190
|
+
const wantedLayer = layers.find((layer) => layer.id === search.layer);
|
|
191
|
+
const nothing = !carrying.length && !inTests.length && !unavailable;
|
|
192
|
+
return (
|
|
193
|
+
<Box aria-label="Run artifacts" component="section" mb="lg" data-run-artifacts>
|
|
194
|
+
<Title order={3} mb="sm">
|
|
195
|
+
Artifacts
|
|
196
|
+
</Title>
|
|
197
|
+
{wanted && everyRead && !found && (
|
|
198
|
+
<Alert role="alert" color="orange" my="sm" icon={<IconAlertTriangle size={16} aria-hidden="true" />} title="That file is not in this run">
|
|
199
|
+
{wantedLayer
|
|
200
|
+
? 'The layer the link names is in this run, but it no longer lists that file. It is not shown as another file.'
|
|
201
|
+
: 'The link names a layer that is not part of this run (it may belong to another project or have been removed). It is not shown as another file.'}
|
|
202
|
+
</Alert>
|
|
203
|
+
)}
|
|
204
|
+
{nothing && (
|
|
205
|
+
<Text component="div" c="dimmed">
|
|
206
|
+
This run carries no files of its own and none of its tests references a recording.
|
|
207
|
+
</Text>
|
|
208
|
+
)}
|
|
209
|
+
{carrying.map((layer, index) => (
|
|
210
|
+
<LayerBlock key={layer.id} runKey={run.key} layer={layer} files={queries[index]!} selected={wanted} shared={sharedWith} />
|
|
211
|
+
))}
|
|
212
|
+
{(inTests.length > 0 || unavailable > 0) && (
|
|
213
|
+
<Box aria-label="Recordings in tests" component="section" data-test-recordings>
|
|
214
|
+
<Title order={4}>Recordings in tests</Title>
|
|
215
|
+
<Text c="dimmed">
|
|
216
|
+
A recording that several tests reference is listed once. Open a test to watch it.
|
|
217
|
+
</Text>
|
|
218
|
+
<List spacing="xs" listStyleType="none">
|
|
219
|
+
<Bounded items={inTests} size={SHOWN} more="More recordings">
|
|
220
|
+
{([identity, held]) => (
|
|
221
|
+
<List.Item key={identity} data-recording>
|
|
222
|
+
<Group gap="xs">
|
|
223
|
+
<IconVideo size={16} aria-hidden="true" />
|
|
224
|
+
<Text span inherit fw={600}>
|
|
225
|
+
{held.name}
|
|
226
|
+
</Text>
|
|
227
|
+
{held.tests.size > 1 && <Badge variant="light">shared by {held.tests.size} tests</Badge>}
|
|
228
|
+
</Group>
|
|
229
|
+
<Text component="div" c="dimmed">
|
|
230
|
+
{held.references > 1 ? `${plural(held.references, 'reference', 'references')} to the same recording, in ` : 'In '}
|
|
231
|
+
<TestLinks tests={[...held.tests.values()]} />
|
|
232
|
+
</Text>
|
|
233
|
+
</List.Item>
|
|
234
|
+
)}
|
|
235
|
+
</Bounded>
|
|
236
|
+
</List>
|
|
237
|
+
{unavailable > 0 && (
|
|
238
|
+
<Text component="div" c="dimmed" data-video-unavailable>
|
|
239
|
+
<IconAlertTriangle size={16} aria-hidden="true" /> {plural(unavailable, 'video reference', 'video references')} name no stored file, so they cannot be played.
|
|
240
|
+
</Text>
|
|
241
|
+
)}
|
|
242
|
+
</Box>
|
|
243
|
+
)}
|
|
244
|
+
</Box>
|
|
245
|
+
);
|
|
246
|
+
}
|