@crvouga/atlas 0.10.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +7 -0
- package/dist/server/src/settings-page.d.ts +4 -4
- package/dist/server/src/settings-page.js +17 -78
- package/docs/reference/server.md +4 -4
- package/docs/ui.md +142 -0
- package/docs/workspaces.md +130 -0
- package/llms-full.txt +278 -0
- package/llms.txt +2 -0
- package/package.json +17 -10
- package/packages/visualizer/src/admin/SettingsPage.tsx +456 -0
- package/packages/visualizer/src/admin/main.tsx +11 -0
- package/packages/visualizer/src/app/AppShell.tsx +62 -38
- package/packages/visualizer/src/app/gazetteer.tsx +90 -83
- package/packages/visualizer/src/app/router.tsx +33 -15
- package/packages/visualizer/src/components/ActivityIndicator.tsx +4 -5
- package/packages/visualizer/src/components/DataIssues.tsx +36 -54
- package/packages/visualizer/src/components/Header.tsx +197 -125
- package/packages/visualizer/src/components/Legend.tsx +13 -41
- package/packages/visualizer/src/components/ProjectPicker.tsx +61 -26
- package/packages/visualizer/src/components/Screen.tsx +80 -71
- package/packages/visualizer/src/components/SpecLogo.tsx +15 -0
- package/packages/visualizer/src/components/Status.tsx +20 -34
- package/packages/visualizer/src/components/labels.tsx +8 -3
- package/packages/visualizer/src/components/spec-logos/APACHE-2.0.txt +206 -0
- package/packages/visualizer/src/components/spec-logos/CAMUNDA-MIT.txt +21 -0
- package/packages/visualizer/src/components/spec-logos/FILE-ICONS-ISC.txt +13 -0
- package/packages/visualizer/src/components/spec-logos/README.md +23 -0
- package/packages/visualizer/src/components/spec-logos/SIMPLE-ICONS-CC0.txt +30 -0
- package/packages/visualizer/src/components/spec-logos/alloy.svg +1 -0
- package/packages/visualizer/src/components/spec-logos/dmn.svg +3 -0
- package/packages/visualizer/src/components/spec-logos/gherkin.svg +1 -0
- package/packages/visualizer/src/components/spec-logos/openslo.svg +63 -0
- package/packages/visualizer/src/components/spec-logos/quint.svg +1 -0
- package/packages/visualizer/src/evidence/artifacts.tsx +263 -110
- package/packages/visualizer/src/evidence/chart-source.tsx +18 -0
- package/packages/visualizer/src/evidence/check-detail.tsx +283 -139
- package/packages/visualizer/src/evidence/decision-table.tsx +35 -26
- package/packages/visualizer/src/evidence/document-page.tsx +51 -29
- package/packages/visualizer/src/evidence/feature-page.tsx +121 -53
- package/packages/visualizer/src/evidence/marks.tsx +112 -79
- package/packages/visualizer/src/evidence/model-page.tsx +77 -46
- package/packages/visualizer/src/evidence/model-trace.tsx +55 -35
- package/packages/visualizer/src/evidence/overview.tsx +137 -69
- package/packages/visualizer/src/evidence/recording-page.tsx +70 -31
- package/packages/visualizer/src/evidence/recording.tsx +135 -68
- package/packages/visualizer/src/evidence/snapshot.tsx +99 -43
- package/packages/visualizer/src/evidence/source-text.tsx +33 -0
- package/packages/visualizer/src/evidence/sources-panel.tsx +138 -144
- package/packages/visualizer/src/evidence/spec-header.tsx +52 -36
- package/packages/visualizer/src/evidence/spec-index.tsx +61 -32
- package/packages/visualizer/src/evidence/spec-page.tsx +56 -25
- package/packages/visualizer/src/evidence/test-filter.tsx +38 -14
- package/packages/visualizer/src/explorer/Explorer.tsx +688 -261
- package/packages/visualizer/src/explorer/ExplorerIndex.tsx +136 -68
- package/packages/visualizer/src/explorer/RunsView.tsx +307 -94
- package/packages/visualizer/src/explorer/TestDetail.tsx +393 -118
- package/packages/visualizer/src/explorer/parts.tsx +35 -13
- package/packages/visualizer/src/find/Finder.tsx +106 -90
- package/packages/visualizer/src/find/open.ts +17 -20
- package/packages/visualizer/src/live/test-progress.tsx +60 -22
- package/packages/visualizer/src/looks/Stage.tsx +223 -110
- package/packages/visualizer/src/main.tsx +9 -11
- package/packages/visualizer/src/map/ChartMap.tsx +125 -69
- package/packages/visualizer/src/map/EventEdge.tsx +59 -37
- package/packages/visualizer/src/map/map.module.css +12 -546
- package/packages/visualizer/src/map/nodes.tsx +124 -70
- package/packages/visualizer/src/reachable.test.ts +3 -2
- package/packages/visualizer/src/spec-kind.ts +2 -2
- package/packages/visualizer/src/ui/grammars/ALLOY-MIT.txt +21 -0
- package/packages/visualizer/src/ui/grammars/QUINT-APACHE-2.0.txt +206 -0
- package/packages/visualizer/src/ui/grammars/README.md +14 -0
- package/packages/visualizer/src/ui/grammars/alloy.json +71 -0
- package/packages/visualizer/src/ui/grammars/quint.json +152 -0
- package/packages/visualizer/src/ui/highlight.ts +50 -0
- package/packages/visualizer/src/ui/theme.tsx +109 -0
- package/packages/visualizer/src/views/ChartPage.tsx +46 -26
- package/packages/visualizer/src/views/ChartTests.tsx +59 -22
- package/packages/visualizer/src/views/ProductPage.tsx +16 -15
- package/packages/visualizer/src/views/SidePanel.tsx +241 -131
- package/packages/visualizer/src/app/gazetteer.module.css +0 -107
- package/packages/visualizer/src/components/ActivityIndicator.module.css +0 -4
- package/packages/visualizer/src/components/DataIssues.module.css +0 -136
- package/packages/visualizer/src/components/Header.module.css +0 -114
- package/packages/visualizer/src/components/Legend.module.css +0 -84
- package/packages/visualizer/src/components/Screen.module.css +0 -312
- package/packages/visualizer/src/components/Status.module.css +0 -116
- package/packages/visualizer/src/components/icons.tsx +0 -156
- package/packages/visualizer/src/evidence/artifacts.module.css +0 -213
- package/packages/visualizer/src/evidence/evidence.module.css +0 -907
- package/packages/visualizer/src/evidence/recording.module.css +0 -460
- package/packages/visualizer/src/evidence/sources.module.css +0 -168
- package/packages/visualizer/src/evidence/spec-snapshot.tsx +0 -26
- package/packages/visualizer/src/explorer/explorer.module.css +0 -1238
- package/packages/visualizer/src/find/Finder.module.css +0 -168
- package/packages/visualizer/src/looks/stage.module.css +0 -300
- package/packages/visualizer/src/styles/global.css +0 -105
- package/packages/visualizer/src/styles/tokens.css +0 -80
- package/packages/visualizer/src/views/views.module.css +0 -546
|
@@ -1,10 +1,7 @@
|
|
|
1
|
+
import { Alert, Box, Table, Text, Title } from '@mantine/core';
|
|
1
2
|
import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
|
2
|
-
|
|
3
|
-
import styles from './evidence.module.css';
|
|
4
|
-
|
|
5
3
|
type Xml = Record<string, unknown>;
|
|
6
4
|
const array = (value: unknown): Xml[] => (Array.isArray(value) ? value : value ? [value] : []) as Xml[];
|
|
7
|
-
|
|
8
5
|
export function decisionTables(text: string) {
|
|
9
6
|
if (XMLValidator.validate(text) !== true) throw new Error('Invalid decision-table XML');
|
|
10
7
|
const doc = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, parseTagValue: false }).parse(text) as Xml;
|
|
@@ -21,47 +18,59 @@ export function decisionTables(text: string) {
|
|
|
21
18
|
id: String(table['@_id'] ?? 'Decision table'),
|
|
22
19
|
policy: String(table['@_hitPolicy'] ?? 'UNIQUE'),
|
|
23
20
|
inputs: array(table.input).length,
|
|
24
|
-
columns: [...array(table.input), ...array(table.output)].map((column) =>
|
|
21
|
+
columns: [...array(table.input), ...array(table.output)].map((column) =>
|
|
22
|
+
String(column['@_label'] ?? column['@_name'] ?? (column.inputExpression as Xml | undefined)?.text ?? 'Condition')
|
|
23
|
+
),
|
|
25
24
|
rows: array(table.rule).map((rule) => [...array(rule.inputEntry), ...array(rule.outputEntry)].map((entry) => String(entry.text ?? '')))
|
|
26
25
|
}));
|
|
27
26
|
}
|
|
28
|
-
|
|
29
27
|
/** Each decision as its conditions and the outcomes they lead to, side by side. */
|
|
30
28
|
export function DecisionTables({ text }: { text: string }) {
|
|
31
29
|
let tables: ReturnType<typeof decisionTables>;
|
|
32
30
|
try {
|
|
33
31
|
tables = decisionTables(text);
|
|
34
32
|
} catch (error) {
|
|
35
|
-
return
|
|
33
|
+
return (
|
|
34
|
+
<Alert role="alert" color={'red'} my="sm">
|
|
35
|
+
This decision table could not be read: {error instanceof Error ? error.message : String(error)}
|
|
36
|
+
</Alert>
|
|
37
|
+
);
|
|
36
38
|
}
|
|
37
39
|
return (
|
|
38
40
|
<>
|
|
39
41
|
{tables.map((table) => (
|
|
40
|
-
<
|
|
41
|
-
<
|
|
42
|
-
{table.id}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
<Box key={table.id} component="section" mb="lg">
|
|
43
|
+
<Title order={2} mb="sm">
|
|
44
|
+
{table.id}{' '}
|
|
45
|
+
<Text span inherit c="dimmed">
|
|
46
|
+
Hit policy {table.policy.toLowerCase()}
|
|
47
|
+
</Text>
|
|
48
|
+
</Title>
|
|
49
|
+
<Table.ScrollContainer minWidth={600}>
|
|
50
|
+
<Table>
|
|
51
|
+
<Table.Thead>
|
|
52
|
+
<Table.Tr>
|
|
48
53
|
{table.columns.map((column, index) => (
|
|
49
|
-
<
|
|
54
|
+
<Table.Th key={index} scope="col" data-output={index >= table.inputs || undefined}>
|
|
55
|
+
{column}
|
|
56
|
+
</Table.Th>
|
|
50
57
|
))}
|
|
51
|
-
</
|
|
52
|
-
</
|
|
53
|
-
<
|
|
58
|
+
</Table.Tr>
|
|
59
|
+
</Table.Thead>
|
|
60
|
+
<Table.Tbody>
|
|
54
61
|
{table.rows.map((row, index) => (
|
|
55
|
-
<
|
|
62
|
+
<Table.Tr key={index}>
|
|
56
63
|
{row.map((cell, at) => (
|
|
57
|
-
<
|
|
64
|
+
<Table.Td key={at} data-output={at >= table.inputs || undefined}>
|
|
65
|
+
{cell || '—'}
|
|
66
|
+
</Table.Td>
|
|
58
67
|
))}
|
|
59
|
-
</
|
|
68
|
+
</Table.Tr>
|
|
60
69
|
))}
|
|
61
|
-
</
|
|
62
|
-
</
|
|
63
|
-
</
|
|
64
|
-
</
|
|
70
|
+
</Table.Tbody>
|
|
71
|
+
</Table>
|
|
72
|
+
</Table.ScrollContainer>
|
|
73
|
+
</Box>
|
|
65
74
|
))}
|
|
66
75
|
</>
|
|
67
76
|
);
|
|
@@ -1,44 +1,66 @@
|
|
|
1
1
|
import type { SpecEvidence } from '@crvouga/atlas/reports/evidence-view';
|
|
2
|
-
|
|
2
|
+
import { Box, Container, List, Title } from '@mantine/core';
|
|
3
3
|
import type { SpecKind } from '../spec-kind';
|
|
4
4
|
import { CheckDetail, ResultRow, type CheckSelection, type SelectCheck } from './check-detail';
|
|
5
5
|
import { DecisionTables } from './decision-table';
|
|
6
|
-
import styles from './evidence.module.css';
|
|
7
6
|
import { Bounded } from './marks';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
import { SpecHeader } from './spec-header';
|
|
8
|
+
import { SourceText } from './source-text';
|
|
10
9
|
import { TestFilter, useRunningFilter } from './test-filter';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
type Props = {
|
|
11
|
+
file: string;
|
|
12
|
+
text: string;
|
|
13
|
+
kind: SpecKind;
|
|
14
|
+
entry: SpecEvidence | undefined;
|
|
15
|
+
selection: CheckSelection;
|
|
16
|
+
select: SelectCheck;
|
|
17
|
+
};
|
|
14
18
|
/** A decision table, structure or objective: what it says, and each check recorded against it. */
|
|
15
19
|
export function DocumentPage({ file, text, kind, entry, selection, select }: Props) {
|
|
16
20
|
const slots = entry?.slots ?? [];
|
|
17
21
|
const runningOnly = useRunningFilter();
|
|
18
|
-
const visible = slots.filter(slot => !runningOnly || slot.status === 'running');
|
|
22
|
+
const visible = slots.filter((slot) => !runningOnly || slot.status === 'running');
|
|
19
23
|
const toggle = (id: string) => select({ check: selection.check === id ? null : id, layer: null });
|
|
20
|
-
const name = file
|
|
24
|
+
const name = file
|
|
25
|
+
.split('/')
|
|
26
|
+
.at(-1)!
|
|
27
|
+
.replace(/\.[^.]+$/, '')
|
|
28
|
+
.replace(/\.openslo$/, '');
|
|
21
29
|
return (
|
|
22
|
-
<article
|
|
30
|
+
<Container component="article" size="xl" w="100%" py="xl">
|
|
23
31
|
<SpecHeader title={name} file={file} kind={kind} entry={entry} />
|
|
24
|
-
<
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
32
|
+
<SourceText text={text} file={file} withResults />
|
|
33
|
+
<Box component="section" id="spec-results" aria-label="Recorded results" tabIndex={-1}>
|
|
34
|
+
<TestFilter slots={slots} />
|
|
35
|
+
{visible.length > 0 && (
|
|
36
|
+
<Box component="section" mb="lg">
|
|
37
|
+
<Title order={2} mb="sm">
|
|
38
|
+
Checks
|
|
39
|
+
</Title>
|
|
40
|
+
<List spacing="xs" listStyleType="none">
|
|
41
|
+
<Bounded items={visible} size={40} more="More checks">
|
|
42
|
+
{(slot) => (
|
|
43
|
+
<ResultRow
|
|
44
|
+
key={slot.key}
|
|
45
|
+
label={slot.title}
|
|
46
|
+
slot={slot}
|
|
47
|
+
open={selection.check === slot.id}
|
|
48
|
+
onToggle={() => toggle(slot.id)}
|
|
49
|
+
>
|
|
50
|
+
<CheckDetail
|
|
51
|
+
slot={slot}
|
|
52
|
+
layerId={selection.check === slot.id ? selection.layer : undefined}
|
|
53
|
+
select={(patch) => select({ check: slot.id, ...patch })}
|
|
54
|
+
reads="steps"
|
|
55
|
+
/>
|
|
56
|
+
</ResultRow>
|
|
57
|
+
)}
|
|
58
|
+
</Bounded>
|
|
59
|
+
</List>
|
|
60
|
+
</Box>
|
|
61
|
+
)}
|
|
62
|
+
{kind.id === 'dmn' && <DecisionTables text={text} />}
|
|
63
|
+
</Box>
|
|
64
|
+
</Container>
|
|
43
65
|
);
|
|
44
66
|
}
|
|
@@ -1,92 +1,160 @@
|
|
|
1
1
|
import type { CheckSlot, SpecEvidence } from '@crvouga/atlas/reports/evidence-view';
|
|
2
|
+
import { Alert, Box, Container, List, Stack, Text, Title } from '@mantine/core';
|
|
2
3
|
import { useMemo } from 'react';
|
|
3
|
-
|
|
4
4
|
import type { SpecKind } from '../spec-kind';
|
|
5
5
|
import { CheckDetail, PlainSteps, ResultRow, type CheckSelection, type SelectCheck } from './check-detail';
|
|
6
|
-
import styles from './evidence.module.css';
|
|
7
6
|
import { featureModel, scenarioLabel, slotsByScenario, type FeatureModel, type FeatureScenario } from './gherkin';
|
|
8
7
|
import { Bounded } from './marks';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
8
|
+
import { SpecHeader } from './spec-header';
|
|
9
|
+
import { SourceText } from './source-text';
|
|
11
10
|
import { TestFilter, useRunningFilter } from './test-filter';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
type Props = {
|
|
12
|
+
file: string;
|
|
13
|
+
text: string;
|
|
14
|
+
kind: SpecKind;
|
|
15
|
+
entry: SpecEvidence | undefined;
|
|
16
|
+
selection: CheckSelection;
|
|
17
|
+
select: SelectCheck;
|
|
18
|
+
};
|
|
19
|
+
function parse(text: string): {
|
|
20
|
+
feature: FeatureModel | null;
|
|
21
|
+
error: string | null;
|
|
22
|
+
} {
|
|
16
23
|
try {
|
|
17
24
|
return { feature: featureModel(text), error: null };
|
|
18
25
|
} catch (error) {
|
|
19
26
|
return { feature: null, error: error instanceof Error ? error.message : String(error) };
|
|
20
27
|
}
|
|
21
28
|
}
|
|
22
|
-
|
|
23
29
|
/** A feature read as its rules and scenarios, each scenario with what was recorded when it ran. */
|
|
24
30
|
export function FeaturePage({ file, text, kind, entry, selection, select }: Props) {
|
|
25
31
|
const { feature, error } = useMemo(() => parse(text), [text]);
|
|
26
32
|
const slots = entry?.slots ?? [];
|
|
27
33
|
const runningOnly = useRunningFilter();
|
|
28
|
-
const filed = useMemo(
|
|
34
|
+
const filed = useMemo(
|
|
35
|
+
() => (feature ? slotsByScenario(feature, slots) : { byScenario: new Map<string, CheckSlot[]>(), leftOver: slots }),
|
|
36
|
+
[feature, slots]
|
|
37
|
+
);
|
|
29
38
|
const scenarios = feature?.rules.flatMap((rule) => rule.scenarios.filter((scenario) => !scenario.background)) ?? [];
|
|
30
|
-
const planned =
|
|
39
|
+
const planned =
|
|
40
|
+
scenarios.reduce((count, scenario) => count + Math.max(1, filed.byScenario.get(scenario.name)?.length ?? 0), 0) + filed.leftOver.length;
|
|
31
41
|
const toggle = (id: string) => select({ check: selection.check === id ? null : id, layer: null });
|
|
32
|
-
|
|
33
42
|
const rows = (scenario: FeatureScenario) => {
|
|
34
43
|
const written = <PlainSteps steps={scenario.steps} examples={scenario.examples} />;
|
|
35
|
-
const recorded = (filed.byScenario.get(scenario.name) ?? []).filter(slot => !runningOnly || slot.status === 'running');
|
|
44
|
+
const recorded = (filed.byScenario.get(scenario.name) ?? []).filter((slot) => !runningOnly || slot.status === 'running');
|
|
36
45
|
if (!recorded.length) {
|
|
37
46
|
if (runningOnly) return null;
|
|
38
47
|
const id = `scenario:${scenario.name}`;
|
|
39
48
|
return (
|
|
40
49
|
<ResultRow key={id} label={scenario.name} slot={null} open={selection.check === id} onToggle={() => toggle(id)}>
|
|
41
|
-
<
|
|
50
|
+
<Text component="div" c="dimmed">
|
|
51
|
+
No completed test report for this scenario yet.
|
|
52
|
+
</Text>
|
|
42
53
|
{written}
|
|
43
54
|
</ResultRow>
|
|
44
55
|
);
|
|
45
56
|
}
|
|
46
57
|
return recorded.map((slot) => (
|
|
47
|
-
<ResultRow
|
|
48
|
-
|
|
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
|
+
/>
|
|
49
72
|
</ResultRow>
|
|
50
73
|
));
|
|
51
74
|
};
|
|
52
|
-
|
|
53
75
|
return (
|
|
54
|
-
<article
|
|
55
|
-
<SpecHeader
|
|
56
|
-
{feature?.
|
|
76
|
+
<Container component="article" size="xl" w="100%" py="xl">
|
|
77
|
+
<SpecHeader
|
|
78
|
+
title={feature?.name || file.split('/').at(-1)!}
|
|
79
|
+
file={file}
|
|
80
|
+
kind={kind}
|
|
81
|
+
entry={entry}
|
|
82
|
+
planned={feature ? planned : undefined}
|
|
83
|
+
>
|
|
84
|
+
{feature?.description && (
|
|
85
|
+
<Text component="div" c="dimmed">
|
|
86
|
+
{feature.description}
|
|
87
|
+
</Text>
|
|
88
|
+
)}
|
|
57
89
|
</SpecHeader>
|
|
58
|
-
<
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
<
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
<SourceText text={text} file={file} withResults />
|
|
91
|
+
<Box component="section" id="spec-results" aria-label="Recorded results" tabIndex={-1}>
|
|
92
|
+
<TestFilter slots={slots} />
|
|
93
|
+
{error && (
|
|
94
|
+
<Alert role="alert" color={'red'} my="sm">
|
|
95
|
+
This feature could not be parsed: {error}
|
|
96
|
+
</Alert>
|
|
97
|
+
)}
|
|
98
|
+
{feature?.rules.map((rule, index) => (
|
|
99
|
+
<Box key={index} component="section" mb="lg">
|
|
100
|
+
{rule.name !== null && (
|
|
101
|
+
<Title order={2} mb="sm">
|
|
102
|
+
{rule.name}
|
|
103
|
+
</Title>
|
|
104
|
+
)}
|
|
105
|
+
{rule.description && (
|
|
106
|
+
<Text component="div" c="dimmed">
|
|
107
|
+
{rule.description}
|
|
108
|
+
</Text>
|
|
109
|
+
)}
|
|
110
|
+
{rule.scenarios
|
|
111
|
+
.filter((scenario) => scenario.background)
|
|
112
|
+
.map((scenario, at) => (
|
|
113
|
+
<Stack key={at} gap="sm" miw={0}>
|
|
114
|
+
<Text component="div" c="dimmed">
|
|
115
|
+
Before each scenario{scenario.name ? `: ${scenario.name}` : ''}
|
|
116
|
+
</Text>
|
|
117
|
+
<PlainSteps steps={scenario.steps} />
|
|
118
|
+
</Stack>
|
|
119
|
+
))}
|
|
120
|
+
<List spacing="xs" listStyleType="none">
|
|
121
|
+
{rule.scenarios.filter((scenario) => !scenario.background).map(rows)}
|
|
122
|
+
</List>
|
|
123
|
+
</Box>
|
|
124
|
+
))}
|
|
125
|
+
{filed.leftOver.some((slot) => !runningOnly || slot.status === 'running') && (
|
|
126
|
+
<Box component="section" mb="lg">
|
|
127
|
+
<Title order={2} mb="sm">
|
|
128
|
+
{feature ? 'Other recorded results' : 'Recorded results'}
|
|
129
|
+
</Title>
|
|
130
|
+
{feature && (
|
|
131
|
+
<Text component="div" c="dimmed">
|
|
132
|
+
Recorded against this file under a name no scenario has now.
|
|
133
|
+
</Text>
|
|
134
|
+
)}
|
|
135
|
+
<List spacing="xs" listStyleType="none">
|
|
136
|
+
<Bounded items={filed.leftOver.filter((slot) => !runningOnly || slot.status === 'running')} size={40} more="More results">
|
|
137
|
+
{(slot) => (
|
|
138
|
+
<ResultRow
|
|
139
|
+
key={slot.key}
|
|
140
|
+
label={feature ? scenarioLabel(feature.name, slot.title) : slot.title}
|
|
141
|
+
slot={slot}
|
|
142
|
+
open={selection.check === slot.id}
|
|
143
|
+
onToggle={() => toggle(slot.id)}
|
|
144
|
+
>
|
|
145
|
+
<CheckDetail
|
|
146
|
+
slot={slot}
|
|
147
|
+
layerId={selection.check === slot.id ? selection.layer : undefined}
|
|
148
|
+
select={(patch) => select({ check: slot.id, ...patch })}
|
|
149
|
+
reads="steps"
|
|
150
|
+
/>
|
|
151
|
+
</ResultRow>
|
|
152
|
+
)}
|
|
153
|
+
</Bounded>
|
|
154
|
+
</List>
|
|
155
|
+
</Box>
|
|
156
|
+
)}
|
|
157
|
+
</Box>
|
|
158
|
+
</Container>
|
|
91
159
|
);
|
|
92
160
|
}
|
|
@@ -1,119 +1,152 @@
|
|
|
1
|
+
import { IconCheck, IconX, IconCircle } from '@tabler/icons-react';
|
|
1
2
|
import type { BackendInfo, LayerHeader, StatusCounts } from '@crvouga/atlas/reports/evidence-types';
|
|
2
3
|
import type { SpecStatus, SpecSummary } from '@crvouga/atlas/reports/evidence-view';
|
|
3
4
|
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
5
|
+
import { Badge, Box, Button, Flex, List, Loader, Progress, Text, ThemeIcon } from '@mantine/core';
|
|
4
6
|
import { useState, type ReactNode } from 'react';
|
|
5
|
-
|
|
6
|
-
import styles from './evidence.module.css';
|
|
7
|
+
import { statusColor } from '../ui/theme';
|
|
7
8
|
import { ago, exactTime, plural, shortSha, statusWord } from './format';
|
|
8
|
-
|
|
9
|
-
/** The outcome as a small mark. Its word is always available to a screen reader. */
|
|
9
|
+
/** A named outcome mark; color is always accompanied by a distinct symbol and accessible text. */
|
|
10
10
|
export function StatusMark({ status, size = 16 }: { status: SpecStatus; size?: number }) {
|
|
11
|
-
|
|
12
|
-
if (status === 'passed') {
|
|
13
|
-
return (
|
|
14
|
-
<svg {...common}>
|
|
15
|
-
<circle cx="8" cy="8" r="7" fill="currentColor" />
|
|
16
|
-
<path d="M4.7 8.3l2.2 2.2 4.4-4.7" fill="none" stroke="#fff" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
|
|
17
|
-
</svg>
|
|
18
|
-
);
|
|
19
|
-
}
|
|
20
|
-
if (status === 'failed') {
|
|
21
|
-
return (
|
|
22
|
-
<svg {...common}>
|
|
23
|
-
<circle cx="8" cy="8" r="7" fill="currentColor" />
|
|
24
|
-
<path d="M5.4 5.4l5.2 5.2M10.6 5.4l-5.2 5.2" fill="none" stroke="#fff" strokeWidth="1.7" strokeLinecap="round" />
|
|
25
|
-
</svg>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
if (status === 'running') {
|
|
29
|
-
return (
|
|
30
|
-
<svg {...common}>
|
|
31
|
-
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
|
|
32
|
-
<path d="M8 2a6 6 0 0 1 6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
33
|
-
</svg>
|
|
34
|
-
);
|
|
35
|
-
}
|
|
11
|
+
if (status === 'running') return <Loader size={size} role="img" aria-label={statusWord(status)} />;
|
|
36
12
|
return (
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
13
|
+
<ThemeIcon
|
|
14
|
+
size={size}
|
|
15
|
+
radius="xl"
|
|
16
|
+
variant={status === 'none' ? 'outline' : 'light'}
|
|
17
|
+
color={statusColor(status)}
|
|
18
|
+
role="img"
|
|
19
|
+
aria-label={statusWord(status)}
|
|
20
|
+
>
|
|
21
|
+
{status === 'passed' ? (
|
|
22
|
+
<IconCheck size={size} aria-hidden="true" />
|
|
23
|
+
) : status === 'failed' ? (
|
|
24
|
+
<IconX size={size} aria-hidden="true" />
|
|
25
|
+
) : (
|
|
26
|
+
<IconCircle size={size} aria-hidden="true" />
|
|
27
|
+
)}
|
|
28
|
+
</ThemeIcon>
|
|
40
29
|
);
|
|
41
30
|
}
|
|
42
|
-
|
|
43
|
-
/** What was recorded for a spec, short enough for an index row. */
|
|
31
|
+
/** An index row's outcome, with the exact recorded count. */
|
|
44
32
|
export function Tally({ summary }: { summary: SpecSummary }) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
33
|
+
const label =
|
|
34
|
+
summary.status === 'none'
|
|
35
|
+
? 'No results'
|
|
36
|
+
: summary.counts.failed
|
|
37
|
+
? `${summary.counts.failed} failed`
|
|
38
|
+
: summary.counts.running
|
|
39
|
+
? 'Running'
|
|
40
|
+
: !summary.counts.passed
|
|
41
|
+
? 'Not run'
|
|
42
|
+
: summary.counts.passed === summary.total
|
|
43
|
+
? `${summary.total} passed`
|
|
44
|
+
: `${summary.counts.passed}/${summary.total}`;
|
|
45
|
+
return (
|
|
46
|
+
<Badge color={statusColor(summary.status)} size="sm">
|
|
47
|
+
{label}
|
|
48
|
+
</Badge>
|
|
49
|
+
);
|
|
50
50
|
}
|
|
51
|
-
|
|
52
51
|
const SEGMENTS = ['failed', 'running', 'passed', 'not-run'] as const;
|
|
53
|
-
|
|
54
|
-
/** Proportions of the recorded outcomes. With nothing recorded it is an empty track. */
|
|
52
|
+
/** Recorded outcome proportions. Missing evidence remains an unfilled gray segment. */
|
|
55
53
|
export function Meter({ counts, missing = 0 }: { counts: StatusCounts; missing?: number }) {
|
|
56
54
|
const total = SEGMENTS.reduce((sum, status) => sum + counts[status], 0) + missing;
|
|
57
55
|
return (
|
|
58
|
-
<
|
|
59
|
-
{total > 0 &&
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
<Progress.Root size="sm" w="100%" miw="4rem" aria-hidden="true">
|
|
57
|
+
{total > 0 &&
|
|
58
|
+
SEGMENTS.map(
|
|
59
|
+
(status) =>
|
|
60
|
+
counts[status] > 0 && <Progress.Section key={status} value={(counts[status] / total) * 100} color={statusColor(status)} />
|
|
61
|
+
)}
|
|
62
|
+
{total > 0 && missing > 0 && <Progress.Section value={(missing / total) * 100} color="gray.3" />}
|
|
63
|
+
</Progress.Root>
|
|
62
64
|
);
|
|
63
65
|
}
|
|
64
|
-
|
|
65
66
|
export function CountLine({ summary, noun, nouns }: { summary: SpecSummary; noun: string; nouns: string }) {
|
|
66
|
-
if (summary.status === 'none') return <span>No {nouns} recorded yet</
|
|
67
|
-
const parts = [
|
|
68
|
-
summary.counts.failed ? <strong key="failed" className={styles.failedText}>{summary.counts.failed} failed</strong> : null,
|
|
69
|
-
summary.counts.passed ? <span key="passed">{summary.counts.passed} passed</span> : null,
|
|
70
|
-
summary.counts.running ? <span key="running">{summary.counts.running} running</span> : null,
|
|
71
|
-
summary.counts['not-run'] ? <span key="not-run">{summary.counts['not-run']} not run</span> : null
|
|
72
|
-
].filter(Boolean);
|
|
67
|
+
if (summary.status === 'none') return <Text span>No {nouns} recorded yet</Text>;
|
|
73
68
|
return (
|
|
74
|
-
<span
|
|
75
|
-
<span>{plural(summary.total, noun, nouns)}</
|
|
76
|
-
{
|
|
77
|
-
|
|
69
|
+
<Flex component="span" display="inline-flex" wrap="wrap" gap="xs">
|
|
70
|
+
<Text span>{plural(summary.total, noun, nouns)}</Text>
|
|
71
|
+
{SEGMENTS.map(
|
|
72
|
+
(status) =>
|
|
73
|
+
summary.counts[status] > 0 && (
|
|
74
|
+
<Text span key={status} c={statusColor(status)}>
|
|
75
|
+
{summary.counts[status]} {status === 'not-run' ? 'not run' : status}
|
|
76
|
+
</Text>
|
|
77
|
+
)
|
|
78
|
+
)}
|
|
79
|
+
</Flex>
|
|
78
80
|
);
|
|
79
81
|
}
|
|
80
|
-
|
|
81
82
|
export function backendLabel(backends: readonly BackendInfo[], id: string) {
|
|
82
83
|
return backends.find((backend) => backend.id === id)?.label ?? id;
|
|
83
84
|
}
|
|
84
|
-
|
|
85
|
-
/** Where and when a result was recorded. A modified checkout is stated, not raised as an alarm. */
|
|
85
|
+
/** A result's provenance; incomplete origin data is stated explicitly. */
|
|
86
86
|
export function Origin({ layer, backends, now }: { layer: LayerHeader; backends: readonly BackendInfo[]; now: number }) {
|
|
87
87
|
const sha = shortSha(layer.sha);
|
|
88
88
|
return (
|
|
89
|
-
<span
|
|
90
|
-
{layer.live && <
|
|
91
|
-
<span
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
{
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
89
|
+
<Flex component="span" display="inline-flex" wrap="wrap" gap="xs" align="center" c="dimmed" fz="xs">
|
|
90
|
+
{layer.live && <Badge size="sm">Running now</Badge>}
|
|
91
|
+
<Text span inherit>
|
|
92
|
+
{backendLabel(backends, layer.backend)}
|
|
93
|
+
</Text>
|
|
94
|
+
{layer.branch && (
|
|
95
|
+
<Text span inherit>
|
|
96
|
+
{layer.branch}
|
|
97
|
+
</Text>
|
|
98
|
+
)}
|
|
99
|
+
{layer.environment && (
|
|
100
|
+
<Text span inherit>
|
|
101
|
+
{environmentLabel(layer.environment)}
|
|
102
|
+
</Text>
|
|
103
|
+
)}
|
|
104
|
+
{sha && <Box component="code">{sha}</Box>}
|
|
105
|
+
{layer.freshness === 'modified' && (
|
|
106
|
+
<Text span inherit>
|
|
107
|
+
with local changes
|
|
108
|
+
</Text>
|
|
109
|
+
)}
|
|
110
|
+
{layer.freshness === 'unknown' && (
|
|
111
|
+
<Text span inherit>
|
|
112
|
+
origin not captured
|
|
113
|
+
</Text>
|
|
114
|
+
)}
|
|
115
|
+
{!layer.live && (
|
|
116
|
+
<time dateTime={new Date(layer.recordedAtMs).toISOString()} title={exactTime(layer.recordedAtMs)}>
|
|
117
|
+
{ago(layer.recordedAtMs, now)}
|
|
118
|
+
</time>
|
|
119
|
+
)}
|
|
98
120
|
{Object.entries(layer.parameters).map(([name, value]) => (
|
|
99
|
-
<span key={name}
|
|
121
|
+
<Text span inherit key={name}>
|
|
122
|
+
{name}: {value}
|
|
123
|
+
</Text>
|
|
100
124
|
))}
|
|
101
|
-
</
|
|
125
|
+
</Flex>
|
|
102
126
|
);
|
|
103
127
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
128
|
+
/** Bound long report lists without losing access to earlier evidence. */
|
|
129
|
+
export function Bounded<T>({
|
|
130
|
+
items,
|
|
131
|
+
size,
|
|
132
|
+
more,
|
|
133
|
+
children
|
|
134
|
+
}: {
|
|
135
|
+
items: readonly T[];
|
|
136
|
+
size: number;
|
|
137
|
+
more: string;
|
|
138
|
+
children: (item: T, index: number) => ReactNode;
|
|
139
|
+
}) {
|
|
107
140
|
const [shown, setShown] = useState(size);
|
|
108
141
|
return (
|
|
109
142
|
<>
|
|
110
143
|
{items.slice(0, shown).map(children)}
|
|
111
144
|
{items.length > shown && (
|
|
112
|
-
<
|
|
113
|
-
<
|
|
145
|
+
<List.Item>
|
|
146
|
+
<Button variant="subtle" onClick={() => setShown(shown + size)}>
|
|
114
147
|
{more} ({items.length - shown} more)
|
|
115
|
-
</
|
|
116
|
-
</
|
|
148
|
+
</Button>
|
|
149
|
+
</List.Item>
|
|
117
150
|
)}
|
|
118
151
|
</>
|
|
119
152
|
);
|