@crvouga/atlas 1.15.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -1
- package/dist/reports/src/catalog-health.d.ts +15 -2
- package/dist/reports/src/catalog-health.js +23 -2
- package/dist/reports/src/test-catalog.d.ts +13 -0
- package/dist/reports/src/test-catalog.js +21 -5
- package/dist/schema/src/api.d.ts +35 -0
- package/dist/schema/src/api.js +14 -3
- package/dist/schema/src/server-config.d.ts +3 -0
- package/dist/schema/src/server-config.js +5 -0
- package/dist/server/src/app.d.ts +2 -0
- package/dist/server/src/app.js +6 -2
- package/dist/server/src/config.js +4 -0
- package/dist/server/src/main.js +1 -0
- package/dist/server/src/openapi.js +2 -2
- package/docs/extending.md +1 -1
- package/docs/reference/http-api.md +4 -4
- package/docs/reference/reports.md +41 -2
- package/docs/reference/server.md +2 -0
- package/docs/server.md +11 -2
- package/docs/ui.md +28 -1
- package/llms-full.txt +44 -8
- package/package.json +1 -1
- package/packages/schema/schemas/catalog-health.schema.json +20 -0
- package/packages/schema/schemas/server-config.schema.json +17 -0
- package/packages/schema/schemas/test-catalog.schema.json +26 -0
- package/packages/schema/schemas/test-history.schema.json +40 -0
- package/packages/server/openapi.json +90 -4
- package/packages/visualizer/src/evidence/applicability-view.tsx +46 -20
- package/packages/visualizer/src/evidence/applicability.test.ts +68 -1
- package/packages/visualizer/src/evidence/applicability.ts +121 -26
- package/packages/visualizer/src/explorer/Explorer.tsx +105 -10
- package/packages/visualizer/src/explorer/HealthView.tsx +402 -0
- package/packages/visualizer/src/explorer/TestDetail.tsx +14 -1
- package/packages/visualizer/src/explorer/applicability.ts +43 -0
- package/packages/visualizer/src/explorer/catalog.tsx +33 -0
- package/packages/visualizer/src/explorer/search.ts +20 -7
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SpecLogo } from '../components/SpecLogo';
|
|
2
|
-
import { IconVideo, IconX } from '@tabler/icons-react';
|
|
2
|
+
import { IconAlertTriangle, IconVideo, IconX } from '@tabler/icons-react';
|
|
3
3
|
import {
|
|
4
4
|
Accordion,
|
|
5
5
|
Alert,
|
|
@@ -44,6 +44,8 @@ import {
|
|
|
44
44
|
} from '@crvouga/atlas/reports/test-catalog';
|
|
45
45
|
import { Link } from '@tanstack/react-router';
|
|
46
46
|
import { useDeferredValue, useEffect, useMemo, useState } from 'react';
|
|
47
|
+
import { attentionRank, hasRelease, needsAttention, type Assessment, type ReleaseIdentity } from '../evidence/applicability';
|
|
48
|
+
import { ApplicabilityControls, VerdictBadge } from '../evidence/applicability-view';
|
|
47
49
|
import { staticEvidence } from '../evidence/api';
|
|
48
50
|
import { useEvidence } from '../evidence/evidence-context';
|
|
49
51
|
import { ago, plural, useNow } from '../evidence/format';
|
|
@@ -53,12 +55,14 @@ import { behaviorDocs, behaviorId, describeHit, testHasVideo } from '../find/beh
|
|
|
53
55
|
import { useDocSearch } from '../find/doc-search';
|
|
54
56
|
import { hasFilters, passesFilters, withoutToken, type FilterKey, type ParsedSearch } from '../find/query';
|
|
55
57
|
import type { FindDoc, Ranked } from '../find/rank';
|
|
58
|
+
import { assessTests, releaseOf, useExplorerApplicability } from './applicability';
|
|
56
59
|
import { useRunDetail, useTestCatalog } from './catalog';
|
|
57
60
|
import { groupTests, tally, type TestGroupView } from './groups';
|
|
58
61
|
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
59
62
|
import { Badges, EnvironmentChips, projectTitle, runName } from './parts';
|
|
60
63
|
import { ArtifactsView } from './ArtifactsView';
|
|
61
64
|
import { CompareView } from './CompareView';
|
|
65
|
+
import { HealthView } from './HealthView';
|
|
62
66
|
import { CollectionsView } from './collections';
|
|
63
67
|
import { RunsView } from './RunsView';
|
|
64
68
|
import { SearchNotes, TypedFilters } from './search-notes';
|
|
@@ -79,6 +83,7 @@ const GROUP_LABEL: Record<TestGroup, string> = {
|
|
|
79
83
|
};
|
|
80
84
|
const SORT_LABEL: Record<ExplorerSort, string> = {
|
|
81
85
|
relevance: 'Best match',
|
|
86
|
+
evidence: 'Stale evidence first',
|
|
82
87
|
spec: 'Spec',
|
|
83
88
|
title: 'Title',
|
|
84
89
|
recent: 'Most recent',
|
|
@@ -118,8 +123,12 @@ function useListing(model: CatalogModel | null, search: ExplorerSearch, q: strin
|
|
|
118
123
|
const list = useMemo(() => [...docs.values()], [docs]);
|
|
119
124
|
const found = useDocSearch(list, q);
|
|
120
125
|
const sort = sortOf(search);
|
|
126
|
+
const { release: releaseBuild, releaseSha, releasePlatform } = search;
|
|
127
|
+
const release = useMemo(() => releaseOf({ release: releaseBuild, releaseSha, releasePlatform }), [releaseBuild, releaseSha, releasePlatform]);
|
|
128
|
+
// Every test's evidence read against its promise and the named release: a reading, never a status.
|
|
129
|
+
const assessments = useMemo(() => (model ? assessTests(model.tests, release) : null), [model?.tests, release]);
|
|
121
130
|
const listing = useMemo(() => {
|
|
122
|
-
if (!model) return null;
|
|
131
|
+
if (!model || !assessments) return null;
|
|
123
132
|
const query: TestQuery = { ...queryOf(search), q: undefined };
|
|
124
133
|
// `has:video` typed in the box and the Has video button are the same filter.
|
|
125
134
|
const operator = { ...found.parsed.filters, video: found.parsed.filters.video || search.video === true };
|
|
@@ -128,17 +137,25 @@ function useListing(model: CatalogModel | null, search: ExplorerSearch, q: strin
|
|
|
128
137
|
const passes = (test: TestSummary, omit: ReadonlySet<FilterKey>) =>
|
|
129
138
|
(!hits || hits.has(behaviorId(test.key))) && (!structured || passesFilters(docs.get(test.key)?.facets, operator, omit));
|
|
130
139
|
let shown = testCatalog(model, { ...query, limit: 20000 }).tests.filter((test) => (!inRun || inRun.has(test.key)) && passes(test, NO_STRUCTURE));
|
|
140
|
+
const attention = (test: TestSummary) => needsAttention(assessments.get(test.key)!);
|
|
141
|
+
if (search.evidence === 'stale') shown = shown.filter(attention);
|
|
131
142
|
if (sort === 'relevance' && hits) {
|
|
132
143
|
const order = new Map([...hits.keys()].map((id, at) => [id, at] as const));
|
|
133
144
|
shown = [...shown].sort((a, b) => (order.get(behaviorId(a.key)) ?? 0) - (order.get(behaviorId(b.key)) ?? 0));
|
|
134
145
|
}
|
|
146
|
+
// Stale evidence first; the order of the spec list is kept among equals.
|
|
147
|
+
if (sort === 'evidence') shown = [...shown].sort((a, b) => attentionRank(assessments.get(a.key)!) - attentionRank(assessments.get(b.key)!));
|
|
135
148
|
// The strip and the status and environment counts describe what the other filters leave.
|
|
136
149
|
const scope = model.tests.filter((test) => matchesQuery(model, test, { ...query, status: [], environment: [] }, inRun) && passes(test, WITHOUT_STATUS_AND_ENVIRONMENT));
|
|
137
150
|
const byEnvironment = scope.filter((test) => matchesQuery(model, test, { environment: query.environment }) && passes(test, WITHOUT_STATUS));
|
|
138
151
|
const byStatus = scope.filter((test) => matchesQuery(model, test, { status: query.status }) && passes(test, WITHOUT_ENVIRONMENT));
|
|
139
152
|
const videoScope = model.tests.filter((test) => matchesQuery(model, test, query, inRun) && passes(test, WITHOUT_VIDEO));
|
|
153
|
+
const evidenceScope = model.tests.filter((test) => matchesQuery(model, test, query, inRun) && passes(test, NO_STRUCTURE));
|
|
140
154
|
return {
|
|
141
155
|
shown,
|
|
156
|
+
assessments,
|
|
157
|
+
release,
|
|
158
|
+
stale: evidenceScope.filter(attention).length,
|
|
142
159
|
withVideo: videoScope.filter((test) => docs.get(test.key)?.facets?.video ?? testHasVideo(test)).length,
|
|
143
160
|
scope,
|
|
144
161
|
byEnvironment,
|
|
@@ -149,7 +166,7 @@ function useListing(model: CatalogModel | null, search: ExplorerSearch, q: strin
|
|
|
149
166
|
runPending: !!search.inRun && run.isPending,
|
|
150
167
|
runMissing: !!search.inRun && run.isSuccess && run.data === null
|
|
151
168
|
};
|
|
152
|
-
}, [model, search, found, docs, sort, inRun, run.isPending, run.isSuccess, run.data]);
|
|
169
|
+
}, [model, search, found, docs, sort, inRun, assessments, release, run.isPending, run.isSuccess, run.data]);
|
|
153
170
|
return listing;
|
|
154
171
|
}
|
|
155
172
|
/** The project home: the published spec kinds, the insight strip, the toolbar, the grouped list and the open test. */
|
|
@@ -170,7 +187,7 @@ export function Explorer() {
|
|
|
170
187
|
const model = catalog.model;
|
|
171
188
|
const selected = search.test ? (model?.byKey.get(search.test) ?? null) : null;
|
|
172
189
|
const empty = model !== null && model.tests.length === 0;
|
|
173
|
-
const split = !!search.test && search.tab !== 'runs' && search.tab !== 'artifacts' && search.tab !== 'collections' && search.tab !== 'compare';
|
|
190
|
+
const split = !!search.test && search.tab !== 'runs' && search.tab !== 'artifacts' && search.tab !== 'collections' && search.tab !== 'compare' && search.tab !== 'health';
|
|
174
191
|
return (
|
|
175
192
|
<Container size="xl" py="xl">
|
|
176
193
|
<Box aria-label="Test explorer" component="article" miw={0}>
|
|
@@ -181,7 +198,7 @@ export function Explorer() {
|
|
|
181
198
|
{model && (model.tests.length > 0 || model.summary.runs > 0) && (
|
|
182
199
|
<Tabs
|
|
183
200
|
value={search.tab ?? 'tests'}
|
|
184
|
-
onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' || value === 'collections' || value === 'compare' ? value : undefined, test: undefined, openRun: undefined, layer: undefined, artifact: undefined, collection: undefined, rev: undefined, pair: undefined })}
|
|
201
|
+
onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' || value === 'collections' || value === 'compare' || value === 'health' ? value : undefined, test: undefined, openRun: undefined, layer: undefined, artifact: undefined, collection: undefined, rev: undefined, pair: undefined })}
|
|
185
202
|
>
|
|
186
203
|
<Tabs.List aria-label="Explorer view">
|
|
187
204
|
<Tabs.Tab
|
|
@@ -206,6 +223,7 @@ export function Explorer() {
|
|
|
206
223
|
</Tabs.Tab>
|
|
207
224
|
<Tabs.Tab value="artifacts">Artifacts</Tabs.Tab>
|
|
208
225
|
<Tabs.Tab value="compare">Compare</Tabs.Tab>
|
|
226
|
+
<Tabs.Tab value="health">Catalog health</Tabs.Tab>
|
|
209
227
|
{!staticEvidence && <Tabs.Tab value="collections">Collections</Tabs.Tab>}
|
|
210
228
|
</Tabs.List>
|
|
211
229
|
</Tabs>
|
|
@@ -243,6 +261,9 @@ export function Explorer() {
|
|
|
243
261
|
</List.Item>
|
|
244
262
|
))}
|
|
245
263
|
</List>
|
|
264
|
+
<Anchor mt="xs" display="inline-block" size="sm" renderRoot={(props) => <Link {...props} to="/" search={patched({ tab: 'health', test: undefined })} resetScroll={false} activeOptions={{ exact: true }} />}>
|
|
265
|
+
Why do these counts differ from the Tests?
|
|
266
|
+
</Anchor>
|
|
246
267
|
</Box>
|
|
247
268
|
)}
|
|
248
269
|
{!model && !evidence.unavailable && (
|
|
@@ -273,6 +294,8 @@ export function Explorer() {
|
|
|
273
294
|
<ArtifactsView />
|
|
274
295
|
) : search.tab === 'compare' ? (
|
|
275
296
|
<CompareView />
|
|
297
|
+
) : search.tab === 'health' ? (
|
|
298
|
+
<HealthView />
|
|
276
299
|
) : search.tab === 'collections' ? (
|
|
277
300
|
<CollectionsView />
|
|
278
301
|
) : empty ? null : (
|
|
@@ -294,7 +317,7 @@ export function Explorer() {
|
|
|
294
317
|
That run is not in the report any more.
|
|
295
318
|
</Alert>
|
|
296
319
|
)}
|
|
297
|
-
<TestList tests={listing.shown} search={search} total={model.tests.length} hits={listing.hits} sort={search.sort ?? (listing.hits ? 'relevance' : 'spec')} />
|
|
320
|
+
<TestList tests={listing.shown} search={search} total={model.tests.length} hits={listing.hits} sort={search.sort ?? (listing.hits ? 'relevance' : 'spec')} assessments={listing.assessments} release={listing.release} />
|
|
298
321
|
</>
|
|
299
322
|
))}
|
|
300
323
|
</Box>
|
|
@@ -673,6 +696,19 @@ function Toolbar({
|
|
|
673
696
|
{listing.withVideo.toLocaleString()}
|
|
674
697
|
</Text>
|
|
675
698
|
</Button>
|
|
699
|
+
<Button
|
|
700
|
+
type="button"
|
|
701
|
+
aria-pressed={search.evidence === 'stale'}
|
|
702
|
+
aria-label={`Stale or unverified evidence: ${plural(listing.stale, 'test', 'tests')}`}
|
|
703
|
+
onClick={() => go({ evidence: search.evidence ? undefined : 'stale' })}
|
|
704
|
+
variant={search.evidence === 'stale' ? 'light' : 'subtle'}
|
|
705
|
+
leftSection={<IconAlertTriangle size={16} aria-hidden="true" />}
|
|
706
|
+
>
|
|
707
|
+
Stale or unverified{' '}
|
|
708
|
+
<Text span inherit>
|
|
709
|
+
{listing.stale.toLocaleString()}
|
|
710
|
+
</Text>
|
|
711
|
+
</Button>
|
|
676
712
|
{model.facets.producers.length > 1 && (
|
|
677
713
|
<Box component="label">
|
|
678
714
|
<VisuallyHidden>Producer</VisuallyHidden>
|
|
@@ -781,7 +817,8 @@ function Toolbar({
|
|
|
781
817
|
artifactTag: undefined,
|
|
782
818
|
inherited: undefined,
|
|
783
819
|
inRun: undefined,
|
|
784
|
-
video: undefined
|
|
820
|
+
video: undefined,
|
|
821
|
+
evidence: undefined
|
|
785
822
|
});
|
|
786
823
|
}}
|
|
787
824
|
variant="subtle"
|
|
@@ -791,10 +828,47 @@ function Toolbar({
|
|
|
791
828
|
)}
|
|
792
829
|
</Flex>
|
|
793
830
|
<SearchNotes issues={listing.parsed.issues} semantic={listing.semantic} />
|
|
831
|
+
<ReleaseReading />
|
|
794
832
|
</Box>
|
|
795
833
|
);
|
|
796
834
|
}
|
|
797
|
-
|
|
835
|
+
/**
|
|
836
|
+
* Names the release the evidence of the tests is read against (build, commit and platform). Folded away until a release is
|
|
837
|
+
* named, because most readers only ask whether evidence is stale.
|
|
838
|
+
*/
|
|
839
|
+
function ReleaseReading() {
|
|
840
|
+
const applicability = useExplorerApplicability();
|
|
841
|
+
const named = hasRelease(applicability.release) || !!applicability.release.platform;
|
|
842
|
+
return (
|
|
843
|
+
<Accordion variant="separated" defaultValue={named ? 'release' : null} mt="sm">
|
|
844
|
+
<Accordion.Item value="release">
|
|
845
|
+
<Accordion.Control>Release the evidence is read against</Accordion.Control>
|
|
846
|
+
<Accordion.Panel>
|
|
847
|
+
<ApplicabilityControls state={applicability} filter={false} />
|
|
848
|
+
</Accordion.Panel>
|
|
849
|
+
</Accordion.Item>
|
|
850
|
+
</Accordion>
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
function TestList({
|
|
854
|
+
tests,
|
|
855
|
+
search,
|
|
856
|
+
total,
|
|
857
|
+
hits,
|
|
858
|
+
sort,
|
|
859
|
+
assessments,
|
|
860
|
+
release
|
|
861
|
+
}: {
|
|
862
|
+
tests: readonly TestSummary[];
|
|
863
|
+
search: ExplorerSearch;
|
|
864
|
+
total: number;
|
|
865
|
+
hits: ReadonlyMap<string, Ranked> | null;
|
|
866
|
+
sort: ExplorerSort;
|
|
867
|
+
assessments: ReadonlyMap<string, Assessment>;
|
|
868
|
+
release: ReleaseIdentity;
|
|
869
|
+
}) {
|
|
870
|
+
// A reading is shown on every row only when the reader asked about evidence; otherwise only evidence known to be stale.
|
|
871
|
+
const asked = search.evidence === 'stale' || sort === 'evidence' || hasRelease(release);
|
|
798
872
|
const by = search.group ?? 'spec';
|
|
799
873
|
const groups = useMemo(() => groupTests(tests, by, sort === 'spec'), [tests, by, sort]);
|
|
800
874
|
const [overrides, setOverrides] = useState<ReadonlyMap<string, boolean>>(new Map());
|
|
@@ -857,6 +931,8 @@ function TestList({ tests, search, total, hits, sort }: { tests: readonly TestSu
|
|
|
857
931
|
open={isOpen(group)}
|
|
858
932
|
selected={search.test}
|
|
859
933
|
hits={hits}
|
|
934
|
+
assessments={assessments}
|
|
935
|
+
asked={asked}
|
|
860
936
|
onToggle={() => setOverrides(new Map(overrides).set(`${by}|${group.key}`, !isOpen(group)))}
|
|
861
937
|
/>
|
|
862
938
|
</Stack>
|
|
@@ -871,6 +947,8 @@ function TestGroupSection({
|
|
|
871
947
|
open,
|
|
872
948
|
selected,
|
|
873
949
|
hits,
|
|
950
|
+
assessments,
|
|
951
|
+
asked,
|
|
874
952
|
onToggle
|
|
875
953
|
}: {
|
|
876
954
|
group: TestGroupView;
|
|
@@ -878,6 +956,8 @@ function TestGroupSection({
|
|
|
878
956
|
open: boolean;
|
|
879
957
|
selected: string | undefined;
|
|
880
958
|
hits: ReadonlyMap<string, Ranked> | null;
|
|
959
|
+
assessments: ReadonlyMap<string, Assessment>;
|
|
960
|
+
asked: boolean;
|
|
881
961
|
onToggle: () => void;
|
|
882
962
|
}) {
|
|
883
963
|
const [shown, setShown] = useState(by === 'none' ? 100 : GROUP_ROWS);
|
|
@@ -885,7 +965,7 @@ function TestGroupSection({
|
|
|
885
965
|
const rows = (
|
|
886
966
|
<List spacing="xs" listStyleType="none">
|
|
887
967
|
{group.tests.slice(0, shown).map((test) => (
|
|
888
|
-
<TestRow key={test.key} test={test} selected={selected === test.key} now={now} hit={hits?.get(behaviorId(test.key))} />
|
|
968
|
+
<TestRow key={test.key} test={test} selected={selected === test.key} now={now} hit={hits?.get(behaviorId(test.key))} assessment={assessments.get(test.key)} asked={asked} />
|
|
889
969
|
))}
|
|
890
970
|
{group.tests.length > shown && (
|
|
891
971
|
<List.Item>
|
|
@@ -930,7 +1010,21 @@ function TestGroupSection({
|
|
|
930
1010
|
</Box>
|
|
931
1011
|
);
|
|
932
1012
|
}
|
|
933
|
-
function TestRow({
|
|
1013
|
+
function TestRow({
|
|
1014
|
+
test,
|
|
1015
|
+
selected,
|
|
1016
|
+
now,
|
|
1017
|
+
hit,
|
|
1018
|
+
assessment,
|
|
1019
|
+
asked
|
|
1020
|
+
}: {
|
|
1021
|
+
test: TestSummary;
|
|
1022
|
+
selected: boolean;
|
|
1023
|
+
now: number;
|
|
1024
|
+
hit: Ranked | undefined;
|
|
1025
|
+
assessment: Assessment | undefined;
|
|
1026
|
+
asked: boolean;
|
|
1027
|
+
}) {
|
|
934
1028
|
const why = hit ? describeHit(hit) : '';
|
|
935
1029
|
const failure = test.status === 'failed' ? test.outcomes.find((outcome) => outcome.status === 'failed')?.detail.split('\n')[0] : '';
|
|
936
1030
|
return (
|
|
@@ -959,6 +1053,7 @@ function TestRow({ test, selected, now, hit }: { test: TestSummary; selected: bo
|
|
|
959
1053
|
<Group gap="xs">
|
|
960
1054
|
<EnvironmentChips test={test} />
|
|
961
1055
|
<Badges test={test} />
|
|
1056
|
+
{assessment && (assessment.verdict === 'stale' || (asked && assessment.verdict !== 'applies')) && <VerdictBadge verdict={assessment.verdict} />}
|
|
962
1057
|
</Group>
|
|
963
1058
|
{failure && (
|
|
964
1059
|
<Text size="xs" c="red.7" lineClamp={2}>
|