@crvouga/atlas 1.5.0 → 1.7.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/docs/extending.md +17 -0
- package/docs/ui.md +25 -1
- package/llms-full.txt +42 -1
- package/package.json +1 -1
- package/packages/visualizer/README.md +28 -0
- package/packages/visualizer/src/evidence/behavior-panel.tsx +10 -16
- package/packages/visualizer/src/evidence/check-detail.tsx +50 -15
- package/packages/visualizer/src/evidence/outcome-summary.tsx +208 -0
- package/packages/visualizer/src/evidence/outcome.test.ts +129 -0
- package/packages/visualizer/src/evidence/outcome.ts +309 -0
- package/packages/visualizer/src/explorer/Explorer.tsx +105 -25
- package/packages/visualizer/src/explorer/RunsView.tsx +173 -35
- package/packages/visualizer/src/explorer/TestDetail.tsx +23 -1
- package/packages/visualizer/src/explorer/catalog.tsx +9 -5
- package/packages/visualizer/src/explorer/search-notes.tsx +74 -0
- package/packages/visualizer/src/explorer/search.ts +19 -6
- package/packages/visualizer/src/find/Finder.tsx +127 -81
- package/packages/visualizer/src/find/behaviors.ts +205 -0
- package/packages/visualizer/src/find/corpus.ts +21 -7
- package/packages/visualizer/src/find/doc-search.ts +66 -0
- package/packages/visualizer/src/find/query.ts +247 -0
- package/packages/visualizer/src/find/rank.ts +195 -47
- package/packages/visualizer/src/find/search.test.ts +322 -0
- package/packages/visualizer/src/find/semantic-search.ts +153 -0
- package/packages/visualizer/src/find/semantic.ts +2 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SpecLogo } from '../components/SpecLogo';
|
|
2
|
-
import { IconX } from '@tabler/icons-react';
|
|
2
|
+
import { IconVideo, IconX } from '@tabler/icons-react';
|
|
3
3
|
import {
|
|
4
4
|
Accordion,
|
|
5
5
|
Alert,
|
|
@@ -37,11 +37,9 @@ import {
|
|
|
37
37
|
durationStats,
|
|
38
38
|
formatMs,
|
|
39
39
|
matchesQuery,
|
|
40
|
-
TEST_SORTS,
|
|
41
40
|
testCatalog,
|
|
42
41
|
type CatalogModel,
|
|
43
42
|
type TestQuery,
|
|
44
|
-
type TestSort,
|
|
45
43
|
type TestSummary
|
|
46
44
|
} from '@crvouga/atlas/reports/test-catalog';
|
|
47
45
|
import { Link } from '@tanstack/react-router';
|
|
@@ -50,13 +48,18 @@ import { useEvidence } from '../evidence/evidence-context';
|
|
|
50
48
|
import { ago, plural, useNow } from '../evidence/format';
|
|
51
49
|
import { Meter, StatusMark } from '../evidence/marks';
|
|
52
50
|
import { SPEC_KINDS } from '../spec-kind';
|
|
51
|
+
import { behaviorDocs, behaviorId, describeHit, testHasVideo } from '../find/behaviors';
|
|
52
|
+
import { useDocSearch } from '../find/doc-search';
|
|
53
|
+
import { hasFilters, passesFilters, withoutToken, type FilterKey, type ParsedSearch } from '../find/query';
|
|
54
|
+
import type { FindDoc, Ranked } from '../find/rank';
|
|
53
55
|
import { useRunDetail, useTestCatalog } from './catalog';
|
|
54
56
|
import { groupTests, tally, type TestGroupView } from './groups';
|
|
55
57
|
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
56
58
|
import { Badges, EnvironmentChips, projectTitle, runName } from './parts';
|
|
57
59
|
import { ArtifactsView } from './ArtifactsView';
|
|
58
60
|
import { RunsView } from './RunsView';
|
|
59
|
-
import {
|
|
61
|
+
import { SearchNotes, TypedFilters } from './search-notes';
|
|
62
|
+
import { EXPLORER_SORTS, joined, narrowed, queryOf, sortOf, TEST_GROUPS, toggled, values, type ExplorerSearch, type ExplorerSort, type TestGroup } from './search';
|
|
60
63
|
import { TestDetail } from './TestDetail';
|
|
61
64
|
/** Groups start closed when there are more of them than this, unless the list is short. */
|
|
62
65
|
const OPEN_GROUPS = 40;
|
|
@@ -71,7 +74,8 @@ const GROUP_LABEL: Record<TestGroup, string> = {
|
|
|
71
74
|
producer: 'Producer',
|
|
72
75
|
none: 'None'
|
|
73
76
|
};
|
|
74
|
-
const SORT_LABEL: Record<
|
|
77
|
+
const SORT_LABEL: Record<ExplorerSort, string> = {
|
|
78
|
+
relevance: 'Best match',
|
|
75
79
|
spec: 'Spec',
|
|
76
80
|
title: 'Title',
|
|
77
81
|
recent: 'Most recent',
|
|
@@ -91,30 +95,59 @@ const STATUS_FILTERS = [
|
|
|
91
95
|
function percent(rate: number | null): string {
|
|
92
96
|
return rate === null ? '—' : `${Math.round(rate * 100)}%`;
|
|
93
97
|
}
|
|
94
|
-
|
|
98
|
+
const NO_STRUCTURE: ReadonlySet<FilterKey> = new Set();
|
|
99
|
+
const WITHOUT_STATUS_AND_ENVIRONMENT: ReadonlySet<FilterKey> = new Set<FilterKey>(['status', 'env']);
|
|
100
|
+
const WITHOUT_STATUS: ReadonlySet<FilterKey> = new Set<FilterKey>(['status']);
|
|
101
|
+
const WITHOUT_ENVIRONMENT: ReadonlySet<FilterKey> = new Set<FilterKey>(['env']);
|
|
102
|
+
const WITHOUT_VIDEO: ReadonlySet<FilterKey> = new Set<FilterKey>(['video']);
|
|
103
|
+
/**
|
|
104
|
+
* What the explorer lists for an address: the tests shown, and the scope the insight strip and filter counts describe.
|
|
105
|
+
* The words, quoted phrases and filters typed in the search box are read by the shared search (`find/`), over every
|
|
106
|
+
* test, before anything is counted or ordered; nothing is cut to a page here.
|
|
107
|
+
*/
|
|
95
108
|
function useListing(model: CatalogModel | null, search: ExplorerSearch, q: string | undefined) {
|
|
96
109
|
const run = useRunDetail(search.inRun);
|
|
97
110
|
const inRun = useMemo(
|
|
98
111
|
() => (search.inRun ? new Set(run.data?.results.map((result) => result.key) ?? []) : null),
|
|
99
112
|
[search.inRun, run.data]
|
|
100
113
|
);
|
|
101
|
-
|
|
114
|
+
const docs = useMemo(() => ((q || search.video) && model ? behaviorDocs(model.tests) : new Map<string, FindDoc>()), [q, search.video, model?.tests]);
|
|
115
|
+
const list = useMemo(() => [...docs.values()], [docs]);
|
|
116
|
+
const found = useDocSearch(list, q);
|
|
117
|
+
const sort = sortOf(search);
|
|
118
|
+
const listing = useMemo(() => {
|
|
102
119
|
if (!model) return null;
|
|
103
|
-
const query: TestQuery = { ...queryOf(search),
|
|
104
|
-
|
|
120
|
+
const query: TestQuery = { ...queryOf(search), q: undefined };
|
|
121
|
+
// `has:video` typed in the box and the Has video button are the same filter.
|
|
122
|
+
const operator = { ...found.parsed.filters, video: found.parsed.filters.video || search.video === true };
|
|
123
|
+
const structured = hasFilters(operator);
|
|
124
|
+
const hits = found.hits;
|
|
125
|
+
const passes = (test: TestSummary, omit: ReadonlySet<FilterKey>) =>
|
|
126
|
+
(!hits || hits.has(behaviorId(test.key))) && (!structured || passesFilters(docs.get(test.key)?.facets, operator, omit));
|
|
127
|
+
let shown = testCatalog(model, { ...query, limit: 20000 }).tests.filter((test) => (!inRun || inRun.has(test.key)) && passes(test, NO_STRUCTURE));
|
|
128
|
+
if (sort === 'relevance' && hits) {
|
|
129
|
+
const order = new Map([...hits.keys()].map((id, at) => [id, at] as const));
|
|
130
|
+
shown = [...shown].sort((a, b) => (order.get(behaviorId(a.key)) ?? 0) - (order.get(behaviorId(b.key)) ?? 0));
|
|
131
|
+
}
|
|
105
132
|
// The strip and the status and environment counts describe what the other filters leave.
|
|
106
|
-
const scope = model.tests.filter((test) => matchesQuery(model, test, { ...query, status: [], environment: [] }, inRun));
|
|
107
|
-
const byEnvironment = scope.filter((test) => matchesQuery(model, test, { environment: query.environment }));
|
|
108
|
-
const byStatus = scope.filter((test) => matchesQuery(model, test, { status: query.status }));
|
|
133
|
+
const scope = model.tests.filter((test) => matchesQuery(model, test, { ...query, status: [], environment: [] }, inRun) && passes(test, WITHOUT_STATUS_AND_ENVIRONMENT));
|
|
134
|
+
const byEnvironment = scope.filter((test) => matchesQuery(model, test, { environment: query.environment }) && passes(test, WITHOUT_STATUS));
|
|
135
|
+
const byStatus = scope.filter((test) => matchesQuery(model, test, { status: query.status }) && passes(test, WITHOUT_ENVIRONMENT));
|
|
136
|
+
const videoScope = model.tests.filter((test) => matchesQuery(model, test, query, inRun) && passes(test, WITHOUT_VIDEO));
|
|
109
137
|
return {
|
|
110
138
|
shown,
|
|
139
|
+
withVideo: videoScope.filter((test) => docs.get(test.key)?.facets?.video ?? testHasVideo(test)).length,
|
|
111
140
|
scope,
|
|
112
141
|
byEnvironment,
|
|
113
142
|
byStatus,
|
|
143
|
+
hits: hits as ReadonlyMap<string, Ranked> | null,
|
|
144
|
+
parsed: found.parsed,
|
|
145
|
+
semantic: found.status,
|
|
114
146
|
runPending: !!search.inRun && run.isPending,
|
|
115
147
|
runMissing: !!search.inRun && run.isSuccess && run.data === null
|
|
116
148
|
};
|
|
117
|
-
}, [model, search,
|
|
149
|
+
}, [model, search, found, docs, sort, inRun, run.isPending, run.isSuccess, run.data]);
|
|
150
|
+
return listing;
|
|
118
151
|
}
|
|
119
152
|
/** The project home: the published spec kinds, the insight strip, the toolbar, the grouped list and the open test. */
|
|
120
153
|
export function Explorer() {
|
|
@@ -252,7 +285,7 @@ export function Explorer() {
|
|
|
252
285
|
That run is not in the report any more.
|
|
253
286
|
</Alert>
|
|
254
287
|
)}
|
|
255
|
-
<TestList tests={listing.shown} search={search} total={model.tests.length} />
|
|
288
|
+
<TestList tests={listing.shown} search={search} total={model.tests.length} hits={listing.hits} sort={search.sort ?? (listing.hits ? 'relevance' : 'spec')} />
|
|
256
289
|
</>
|
|
257
290
|
))}
|
|
258
291
|
</Box>
|
|
@@ -496,6 +529,9 @@ function Toolbar({
|
|
|
496
529
|
onSearch: (value: string) => void;
|
|
497
530
|
}) {
|
|
498
531
|
const go = useExplorerNavigate();
|
|
532
|
+
// Best match is the order of a search with words; without words the list is in spec order.
|
|
533
|
+
const defaultSort: ExplorerSort = listing.hits ? 'relevance' : 'spec';
|
|
534
|
+
const sort: ExplorerSort = search.sort ?? defaultSort;
|
|
499
535
|
const [draft, setDraft] = useState(search.q ?? '');
|
|
500
536
|
// The address may change from elsewhere (a link, the index, back): follow it.
|
|
501
537
|
useEffect(() => setDraft((current) => (current.trim() === (search.q ?? '').trim() ? current : (search.q ?? ''))), [search.q]);
|
|
@@ -527,7 +563,7 @@ function Toolbar({
|
|
|
527
563
|
miw={{ base: '100%', sm: '15rem' }}
|
|
528
564
|
type="search"
|
|
529
565
|
aria-label="Search tests"
|
|
530
|
-
placeholder=
|
|
566
|
+
placeholder='Search titles, specs, tags… or filter: status:failed has:video "exact phrase"'
|
|
531
567
|
value={draft}
|
|
532
568
|
onChange={(event) => {
|
|
533
569
|
setDraft(event.target.value);
|
|
@@ -556,12 +592,12 @@ function Toolbar({
|
|
|
556
592
|
</Text>
|
|
557
593
|
<NativeSelect
|
|
558
594
|
aria-label="Sort"
|
|
559
|
-
value={
|
|
560
|
-
onChange={(event) => go({ sort: event.target.value ===
|
|
595
|
+
value={sort}
|
|
596
|
+
onChange={(event) => go({ sort: event.target.value === defaultSort ? undefined : (event.target.value as ExplorerSort) })}
|
|
561
597
|
>
|
|
562
|
-
{
|
|
563
|
-
<option key={
|
|
564
|
-
{SORT_LABEL[
|
|
598
|
+
{EXPLORER_SORTS.filter((option) => option !== 'relevance' || listing.hits !== null || sort === 'relevance').map((option) => (
|
|
599
|
+
<option key={option} value={option}>
|
|
600
|
+
{SORT_LABEL[option]}
|
|
565
601
|
</option>
|
|
566
602
|
))}
|
|
567
603
|
</NativeSelect>
|
|
@@ -608,6 +644,26 @@ function Toolbar({
|
|
|
608
644
|
))}
|
|
609
645
|
</Flex>
|
|
610
646
|
)}
|
|
647
|
+
<Button
|
|
648
|
+
type="button"
|
|
649
|
+
aria-pressed={search.video === true || listing.parsed.filters.video}
|
|
650
|
+
aria-label={`Has video: ${plural(listing.withVideo, 'test', 'tests')}`}
|
|
651
|
+
onClick={() => {
|
|
652
|
+
if (listing.parsed.filters.video) {
|
|
653
|
+
const token = listing.parsed.tokens.find((item) => item.key === 'video')!;
|
|
654
|
+
const next = withoutToken(draft, token);
|
|
655
|
+
setDraft(next);
|
|
656
|
+
onSearch(next);
|
|
657
|
+
} else go({ video: search.video ? undefined : true });
|
|
658
|
+
}}
|
|
659
|
+
variant={search.video === true || listing.parsed.filters.video ? 'light' : 'subtle'}
|
|
660
|
+
leftSection={<IconVideo size={16} aria-hidden="true" />}
|
|
661
|
+
>
|
|
662
|
+
Has video{' '}
|
|
663
|
+
<Text span inherit>
|
|
664
|
+
{listing.withVideo.toLocaleString()}
|
|
665
|
+
</Text>
|
|
666
|
+
</Button>
|
|
611
667
|
{model.facets.producers.length > 1 && (
|
|
612
668
|
<Box component="label">
|
|
613
669
|
<VisuallyHidden>Producer</VisuallyHidden>
|
|
@@ -683,6 +739,14 @@ function Toolbar({
|
|
|
683
739
|
? plural(model.tests.length, 'test', 'tests')
|
|
684
740
|
: `${listing.shown.length.toLocaleString()} of ${plural(model.tests.length, 'test', 'tests')}`}
|
|
685
741
|
</Text>
|
|
742
|
+
<TypedFilters
|
|
743
|
+
parsed={listing.parsed}
|
|
744
|
+
query={draft}
|
|
745
|
+
onChange={(next) => {
|
|
746
|
+
setDraft(next);
|
|
747
|
+
onSearch(next);
|
|
748
|
+
}}
|
|
749
|
+
/>
|
|
686
750
|
{chips.map((chip) => (
|
|
687
751
|
<Button key={chip.label} type="button" onClick={() => go(chip.clear)} aria-label={`Remove ${chip.label}`} variant="light">
|
|
688
752
|
{chip.label}{' '}
|
|
@@ -707,7 +771,8 @@ function Toolbar({
|
|
|
707
771
|
scope: undefined,
|
|
708
772
|
artifactTag: undefined,
|
|
709
773
|
inherited: undefined,
|
|
710
|
-
inRun: undefined
|
|
774
|
+
inRun: undefined,
|
|
775
|
+
video: undefined
|
|
711
776
|
});
|
|
712
777
|
}}
|
|
713
778
|
variant="subtle"
|
|
@@ -716,12 +781,13 @@ function Toolbar({
|
|
|
716
781
|
</Button>
|
|
717
782
|
)}
|
|
718
783
|
</Flex>
|
|
784
|
+
<SearchNotes issues={listing.parsed.issues} semantic={listing.semantic} />
|
|
719
785
|
</Box>
|
|
720
786
|
);
|
|
721
787
|
}
|
|
722
|
-
function TestList({ tests, search, total }: { tests: readonly TestSummary[]; search: ExplorerSearch; total: number }) {
|
|
788
|
+
function TestList({ tests, search, total, hits, sort }: { tests: readonly TestSummary[]; search: ExplorerSearch; total: number; hits: ReadonlyMap<string, Ranked> | null; sort: ExplorerSort }) {
|
|
723
789
|
const by = search.group ?? 'spec';
|
|
724
|
-
const groups = useMemo(() => groupTests(tests, by,
|
|
790
|
+
const groups = useMemo(() => groupTests(tests, by, sort === 'spec'), [tests, by, sort]);
|
|
725
791
|
const [overrides, setOverrides] = useState<ReadonlyMap<string, boolean>>(new Map());
|
|
726
792
|
const defaultOpen = groups.length <= OPEN_GROUPS || tests.length <= OPEN_TESTS;
|
|
727
793
|
const isOpen = (group: TestGroupView) => by === 'none' || (overrides.get(`${by}|${group.key}`) ?? defaultOpen);
|
|
@@ -781,6 +847,7 @@ function TestList({ tests, search, total }: { tests: readonly TestSummary[]; sea
|
|
|
781
847
|
by={by}
|
|
782
848
|
open={isOpen(group)}
|
|
783
849
|
selected={search.test}
|
|
850
|
+
hits={hits}
|
|
784
851
|
onToggle={() => setOverrides(new Map(overrides).set(`${by}|${group.key}`, !isOpen(group)))}
|
|
785
852
|
/>
|
|
786
853
|
</Stack>
|
|
@@ -794,12 +861,14 @@ function TestGroupSection({
|
|
|
794
861
|
by,
|
|
795
862
|
open,
|
|
796
863
|
selected,
|
|
864
|
+
hits,
|
|
797
865
|
onToggle
|
|
798
866
|
}: {
|
|
799
867
|
group: TestGroupView;
|
|
800
868
|
by: TestGroup;
|
|
801
869
|
open: boolean;
|
|
802
870
|
selected: string | undefined;
|
|
871
|
+
hits: ReadonlyMap<string, Ranked> | null;
|
|
803
872
|
onToggle: () => void;
|
|
804
873
|
}) {
|
|
805
874
|
const [shown, setShown] = useState(by === 'none' ? 100 : GROUP_ROWS);
|
|
@@ -807,7 +876,7 @@ function TestGroupSection({
|
|
|
807
876
|
const rows = (
|
|
808
877
|
<List spacing="xs" listStyleType="none">
|
|
809
878
|
{group.tests.slice(0, shown).map((test) => (
|
|
810
|
-
<TestRow key={test.key} test={test} selected={selected === test.key} now={now} />
|
|
879
|
+
<TestRow key={test.key} test={test} selected={selected === test.key} now={now} hit={hits?.get(behaviorId(test.key))} />
|
|
811
880
|
))}
|
|
812
881
|
{group.tests.length > shown && (
|
|
813
882
|
<List.Item>
|
|
@@ -852,7 +921,8 @@ function TestGroupSection({
|
|
|
852
921
|
</Box>
|
|
853
922
|
);
|
|
854
923
|
}
|
|
855
|
-
function TestRow({ test, selected, now }: { test: TestSummary; selected: boolean; now: number }) {
|
|
924
|
+
function TestRow({ test, selected, now, hit }: { test: TestSummary; selected: boolean; now: number; hit: Ranked | undefined }) {
|
|
925
|
+
const why = hit ? describeHit(hit) : '';
|
|
856
926
|
const failure = test.status === 'failed' ? test.outcomes.find((outcome) => outcome.status === 'failed')?.detail.split('\n')[0] : '';
|
|
857
927
|
return (
|
|
858
928
|
<List.Item data-status={test.status}>
|
|
@@ -886,6 +956,16 @@ function TestRow({ test, selected, now }: { test: TestSummary; selected: boolean
|
|
|
886
956
|
{failure}
|
|
887
957
|
</Text>
|
|
888
958
|
)}
|
|
959
|
+
{why && (
|
|
960
|
+
<Text size="xs" c="dimmed" data-why>
|
|
961
|
+
{hit?.exact && (
|
|
962
|
+
<Badge component="span" color="blue" mr="xs">
|
|
963
|
+
Exact
|
|
964
|
+
</Badge>
|
|
965
|
+
)}
|
|
966
|
+
{why}
|
|
967
|
+
</Text>
|
|
968
|
+
)}
|
|
889
969
|
</Stack>
|
|
890
970
|
<Group gap="md" visibleFrom="sm">
|
|
891
971
|
<Text title="Average duration" size="xs" c="dimmed">
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { IconAlertTriangle, IconArrowLeft, IconVideo } from '@tabler/icons-react';
|
|
2
|
-
import { Alert, Anchor, Box, Button, Code, Flex, Group, List, Stack, Table, Text, TextInput, Title, VisuallyHidden } from '@mantine/core';
|
|
2
|
+
import { Alert, Anchor, Badge, Box, Button, Code, Flex, Group, List, NativeSelect, Stack, Table, Text, TextInput, Title, VisuallyHidden } from '@mantine/core';
|
|
3
3
|
/**
|
|
4
4
|
* @module Runs view
|
|
5
5
|
*
|
|
@@ -16,16 +16,22 @@ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
|
16
16
|
import type { FilterName } from '@crvouga/atlas/reports/artifact-query';
|
|
17
17
|
import { formatMs, type RunResult, type RunSummary } from '@crvouga/atlas/reports/test-catalog';
|
|
18
18
|
import { Link } from '@tanstack/react-router';
|
|
19
|
-
import { useMemo, useState } from 'react';
|
|
19
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
20
|
+
import { behaviorDocs, behaviorId, describeHit, forRuns, resultDoc, runDoc } from '../find/behaviors';
|
|
21
|
+
import { useDocSearch } from '../find/doc-search';
|
|
22
|
+
import { hasFilters, passesFilters, type ParsedSearch, type SearchFilters } from '../find/query';
|
|
23
|
+
import type { FindDoc, Ranked } from '../find/rank';
|
|
20
24
|
import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
|
|
21
25
|
import { Meter, StatusMark } from '../evidence/marks';
|
|
22
|
-
import { useRunDetail, useRunList } from './catalog';
|
|
26
|
+
import { useRunDetail, useRunList, useTestCatalog } from './catalog';
|
|
23
27
|
import { RunArtifacts } from './RunArtifacts';
|
|
24
28
|
import { FilterBar } from './filters';
|
|
29
|
+
import { SearchNotes, TypedFilters } from './search-notes';
|
|
25
30
|
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
26
31
|
import { runName } from './parts';
|
|
27
32
|
import { filtered, filterOf, NO_FILTERS } from './search';
|
|
28
33
|
const RUN_ROWS = 50;
|
|
34
|
+
const NO_RESULTS: readonly RunResult[] = [];
|
|
29
35
|
const RESULT_ROWS = 200;
|
|
30
36
|
/** The Runs tab: the run list, or the run opened in it. */
|
|
31
37
|
export function RunsView() {
|
|
@@ -151,6 +157,56 @@ function HasVideo({ count, pressed, noun, onToggle }: { count: number; pressed:
|
|
|
151
157
|
</Button>
|
|
152
158
|
);
|
|
153
159
|
}
|
|
160
|
+
/** The search box of the Runs tab and of an opened run: one query in the address, so a link reproduces it. */
|
|
161
|
+
function SearchBox({ label, placeholder, parsed, onSearch }: { label: string; placeholder: string; parsed: ParsedSearch; onSearch: (value: string) => void }) {
|
|
162
|
+
const search = useExplorerSearch();
|
|
163
|
+
const [draft, setDraft] = useState(search.q ?? '');
|
|
164
|
+
// The address may change from elsewhere (a link, back, a chip): follow it.
|
|
165
|
+
useEffect(() => setDraft((current) => (current.trim() === (search.q ?? '').trim() ? current : (search.q ?? ''))), [search.q]);
|
|
166
|
+
return (
|
|
167
|
+
<Flex gap="xs" align="center" wrap="wrap">
|
|
168
|
+
<TextInput
|
|
169
|
+
type="search"
|
|
170
|
+
aria-label={label}
|
|
171
|
+
placeholder={placeholder}
|
|
172
|
+
value={draft}
|
|
173
|
+
miw={{ base: '100%', sm: '20rem' }}
|
|
174
|
+
onChange={(event) => {
|
|
175
|
+
setDraft(event.target.value);
|
|
176
|
+
onSearch(event.target.value.trim());
|
|
177
|
+
}}
|
|
178
|
+
/>
|
|
179
|
+
<TypedFilters
|
|
180
|
+
parsed={parsed}
|
|
181
|
+
query={draft}
|
|
182
|
+
onChange={(next) => {
|
|
183
|
+
setDraft(next);
|
|
184
|
+
onSearch(next);
|
|
185
|
+
}}
|
|
186
|
+
/>
|
|
187
|
+
</Flex>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
/** Why a result is in the list, beside it: exact, or the fields the words were found in. */
|
|
191
|
+
function WhyMatched({ hit }: { hit: Ranked | undefined }) {
|
|
192
|
+
const why = hit ? describeHit(hit) : '';
|
|
193
|
+
if (!hit || !why) return null;
|
|
194
|
+
return (
|
|
195
|
+
<Text size="xs" c="dimmed" data-why>
|
|
196
|
+
{hit.exact && (
|
|
197
|
+
<Badge component="span" color="blue" mr="xs">
|
|
198
|
+
Exact
|
|
199
|
+
</Badge>
|
|
200
|
+
)}
|
|
201
|
+
{why}
|
|
202
|
+
</Text>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const RUN_SORTS = [
|
|
206
|
+
{ id: 'recent', label: 'Newest' },
|
|
207
|
+
{ id: 'failures', label: 'Most failures' },
|
|
208
|
+
{ id: 'relevance', label: 'Best match' }
|
|
209
|
+
] as const;
|
|
154
210
|
/** What a filter is called when a page says a server could not apply it. */
|
|
155
211
|
const FILTER_WORDS: Partial<Record<FilterName, string>> = { artifactTag: 'tag', type: 'media type', label: 'label', domain: 'domain', spec: 'spec' };
|
|
156
212
|
/** Says which filters the list is not narrowed by, because the server that answered ignored them. */
|
|
@@ -167,11 +223,32 @@ function RunTable() {
|
|
|
167
223
|
const search = useExplorerSearch();
|
|
168
224
|
const go = useExplorerNavigate();
|
|
169
225
|
const video = search.video === true;
|
|
170
|
-
const
|
|
226
|
+
const searching = !!search.q;
|
|
227
|
+
// The structured filters of the address narrow the runs on the server; the words are matched here by the shared search,
|
|
228
|
+
// over every run, so the Runs tab judges "savde answer" as the finder and the test list do.
|
|
229
|
+
const filter = useMemo(() => {
|
|
230
|
+
const { q: _words, ...rest } = filterOf(search);
|
|
231
|
+
return rest;
|
|
232
|
+
}, [search]);
|
|
171
233
|
const narrow = filtered(search);
|
|
172
|
-
const runs = useRunList(true, filter);
|
|
234
|
+
const runs = useRunList(true, filter, searching);
|
|
173
235
|
const now = useNow();
|
|
174
236
|
const [shown, setShown] = useState(RUN_ROWS);
|
|
237
|
+
const docs = useMemo(() => (runs.data?.runs ?? []).map((run) => runDoc(run, runLabel(run))), [runs.data]);
|
|
238
|
+
const found = useDocSearch(docs, search.q);
|
|
239
|
+
const parsed = useMemo(() => forRuns(found.parsed), [found.parsed]);
|
|
240
|
+
const sortValue = search.sort === 'failures' ? 'failures' : (search.sort === 'relevance' || !search.sort) && found.hits ? 'relevance' : 'recent';
|
|
241
|
+
const visible = useMemo(() => {
|
|
242
|
+
const runsOf = runs.data?.runs ?? [];
|
|
243
|
+
const order = found.hits ? new Map([...found.hits.keys()].map((id, at) => [id, at] as const)) : null;
|
|
244
|
+
const kept = runsOf.filter((run, at) => {
|
|
245
|
+
const doc = docs[at]!;
|
|
246
|
+
return (!found.hits || found.hits.has(doc.id)) && passesFilters(doc.facets, parsed.filters);
|
|
247
|
+
});
|
|
248
|
+
if (sortValue === 'failures') return [...kept].sort((a, b) => b.counts.failed - a.counts.failed || b.endedAtMs - a.endedAtMs);
|
|
249
|
+
if (sortValue === 'relevance' && order) return [...kept].sort((a, b) => (order.get(`runs:${a.key}`) ?? 0) - (order.get(`runs:${b.key}`) ?? 0));
|
|
250
|
+
return kept;
|
|
251
|
+
}, [runs.data, docs, found.hits, parsed.filters, sortValue]);
|
|
175
252
|
if (runs.isPending)
|
|
176
253
|
return (
|
|
177
254
|
<Text role="status" component="div" c="dimmed">
|
|
@@ -184,24 +261,60 @@ function RunTable() {
|
|
|
184
261
|
The runs could not be read. {runs.error.message}
|
|
185
262
|
</Alert>
|
|
186
263
|
);
|
|
187
|
-
const
|
|
264
|
+
const everything = runs.data.runs;
|
|
265
|
+
const list = visible;
|
|
188
266
|
const withVideo = runs.data.withVideo;
|
|
189
267
|
const noun = narrow ? 'matching' : '';
|
|
268
|
+
const hasVideoNow = video || parsed.filters.video;
|
|
190
269
|
return (
|
|
191
270
|
<Box aria-label="Runs" component="section" mb="lg">
|
|
192
|
-
{(narrow ||
|
|
271
|
+
{(narrow || everything.length > 0) && (
|
|
272
|
+
<FilterBar
|
|
273
|
+
facets={runs.data.facets}
|
|
274
|
+
search={search}
|
|
275
|
+
tab="runs"
|
|
276
|
+
extra={
|
|
277
|
+
withVideo !== undefined || video ? (
|
|
278
|
+
<HasVideo count={withVideo ?? list.length} pressed={hasVideoNow} noun={['run', 'runs']} onToggle={() => go({ video: video ? undefined : true })} />
|
|
279
|
+
) : undefined
|
|
280
|
+
}
|
|
281
|
+
/>
|
|
282
|
+
)}
|
|
193
283
|
<NotApplied names={runs.data.notApplied} noun="runs" />
|
|
194
|
-
{
|
|
284
|
+
{(parsed.tokens.length > 0 || parsed.issues.length > 0 || found.status !== 'idle') && (
|
|
285
|
+
<>
|
|
286
|
+
<Flex gap="xs" align="center" wrap="wrap">
|
|
287
|
+
<TypedFilters parsed={parsed} query={search.q ?? ''} onChange={(next) => go({ q: next || undefined }, { replace: true })} />
|
|
288
|
+
</Flex>
|
|
289
|
+
<SearchNotes issues={parsed.issues} semantic={found.status} />
|
|
290
|
+
</>
|
|
291
|
+
)}
|
|
292
|
+
{!everything.length && !narrow && (
|
|
195
293
|
<Text component="div" c="dimmed">
|
|
196
294
|
No runs recorded yet.
|
|
197
295
|
</Text>
|
|
198
296
|
)}
|
|
199
|
-
{
|
|
200
|
-
<
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
297
|
+
{everything.length > 0 && (
|
|
298
|
+
<Flex gap="xs" align="center" wrap="wrap" role="status">
|
|
299
|
+
<Text span inherit>
|
|
300
|
+
{runs.data.total > everything.length
|
|
301
|
+
? `The newest ${everything.length} of ${plural(runs.data.total, 'run', 'runs')} ${noun}`.trim()
|
|
302
|
+
: searching || list.length !== everything.length
|
|
303
|
+
? `${list.length.toLocaleString()} of ${plural(everything.length, 'run', 'runs')} ${noun}`.trim()
|
|
304
|
+
: `${plural(list.length, 'run', 'runs')} ${noun}`.trim()}
|
|
305
|
+
</Text>
|
|
306
|
+
<NativeSelect
|
|
307
|
+
aria-label="Sort runs"
|
|
308
|
+
value={sortValue}
|
|
309
|
+
onChange={(event) => go({ sort: event.target.value === (found.hits ? 'relevance' : 'recent') ? undefined : (event.target.value as 'recent' | 'failures' | 'relevance') })}
|
|
310
|
+
>
|
|
311
|
+
{RUN_SORTS.filter((option) => option.id !== 'relevance' || found.hits !== null).map((option) => (
|
|
312
|
+
<option key={option.id} value={option.id}>
|
|
313
|
+
{option.label}
|
|
314
|
+
</option>
|
|
315
|
+
))}
|
|
316
|
+
</NativeSelect>
|
|
317
|
+
</Flex>
|
|
205
318
|
)}
|
|
206
319
|
{!list.length && narrow && (
|
|
207
320
|
<Stack gap="xs" align="flex-start" role="status" data-no-match>
|
|
@@ -249,6 +362,7 @@ function RunTable() {
|
|
|
249
362
|
{run.title}
|
|
250
363
|
</Text>
|
|
251
364
|
)}
|
|
365
|
+
<WhyMatched hit={found.hits?.get(`runs:${run.key}`)} />
|
|
252
366
|
</Table.Td>
|
|
253
367
|
<Table.Td title={run.branch ?? ''} c="dimmed">
|
|
254
368
|
{run.branch ?? '—'}
|
|
@@ -322,20 +436,39 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
322
436
|
const detail = useRunDetail(runKey);
|
|
323
437
|
const now = useNow();
|
|
324
438
|
const [status, setStatus] = useState('');
|
|
325
|
-
const [words, setWords] = useState('');
|
|
326
439
|
const [shown, setShown] = useState(RESULT_ROWS);
|
|
327
|
-
const
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
);
|
|
338
|
-
}, [
|
|
440
|
+
const catalog = useTestCatalog();
|
|
441
|
+
const model = catalog.model;
|
|
442
|
+
const results = detail.data?.results ?? NO_RESULTS;
|
|
443
|
+
// The words are judged against every behavior of the report, as in the test list, so the same words make the same
|
|
444
|
+
// behaviors eligible; the run's results are then the ones of those behaviors it recorded.
|
|
445
|
+
const docs = useMemo<readonly FindDoc[]>(() => {
|
|
446
|
+
if (!search.q) return [];
|
|
447
|
+
const base = model ? behaviorDocs(model.tests) : new Map<string, FindDoc>();
|
|
448
|
+
const extra = new Map<string, FindDoc>();
|
|
449
|
+
for (const result of results) if (!base.has(result.key) && !extra.has(result.key)) extra.set(result.key, resultDoc(result, undefined));
|
|
450
|
+
return [...base.values(), ...extra.values()];
|
|
451
|
+
}, [search.q, model, results]);
|
|
452
|
+
const found = useDocSearch(docs, search.q);
|
|
453
|
+
const resultDocs = useMemo(() => results.map((result) => resultDoc(result, model?.byKey.get(result.key))), [results, model]);
|
|
454
|
+
const narrowedResults = useMemo(
|
|
455
|
+
() =>
|
|
456
|
+
results.filter(
|
|
457
|
+
(result, at) =>
|
|
458
|
+
(!found.hits || found.hits.has(behaviorId(result.key))) &&
|
|
459
|
+
(!video || (videoCounts(result.videos)?.recordings ?? 0) > 0) &&
|
|
460
|
+
passesFilters(resultDocs[at]!.facets, { ...found.parsed.filters, status: [] })
|
|
461
|
+
),
|
|
462
|
+
[results, resultDocs, found, video]
|
|
463
|
+
);
|
|
464
|
+
const statusFilters = found.parsed.filters.status;
|
|
465
|
+
const filtered = useMemo(
|
|
466
|
+
() =>
|
|
467
|
+
narrowedResults.filter(
|
|
468
|
+
(result) => (!status || result.status === status) && (!statusFilters.length || statusFilters.some((wanted) => (wanted === 'flaky' ? model?.byKey.get(result.key)?.stats.flaky === true : result.status === wanted)))
|
|
469
|
+
),
|
|
470
|
+
[narrowedResults, status, statusFilters, model]
|
|
471
|
+
);
|
|
339
472
|
const bySpec = useMemo(() => {
|
|
340
473
|
const groups = new Map<string, RunResult[]>();
|
|
341
474
|
for (const result of filtered.slice(0, shown)) {
|
|
@@ -458,14 +591,19 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
458
591
|
)}
|
|
459
592
|
</Flex>
|
|
460
593
|
<Flex gap="xs" align="center" wrap="wrap">
|
|
461
|
-
<
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
onChange={(event) => setWords(event.target.value)}
|
|
594
|
+
<SearchBox
|
|
595
|
+
label="Search this run"
|
|
596
|
+
placeholder='Search this run… or filter: status:failed has:video "exact phrase"'
|
|
597
|
+
parsed={found.parsed}
|
|
598
|
+
onSearch={(value) => go({ q: value || undefined }, { replace: true })}
|
|
467
599
|
/>
|
|
468
600
|
</Flex>
|
|
601
|
+
<SearchNotes issues={found.parsed.issues} semantic={found.status} />
|
|
602
|
+
{(search.q || found.parsed.tokens.length > 0) && (
|
|
603
|
+
<Text role="status" size="sm" c="dimmed">
|
|
604
|
+
{filtered.length.toLocaleString()} of {plural(results.length, 'result', 'results')} match
|
|
605
|
+
</Text>
|
|
606
|
+
)}
|
|
469
607
|
<Flex role="group" aria-label="Result status" gap="xs" align="center" wrap="wrap">
|
|
470
608
|
{RESULT_FILTERS.map((filter) => (
|
|
471
609
|
<Button
|
|
@@ -478,7 +616,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
478
616
|
>
|
|
479
617
|
{filter.label}{' '}
|
|
480
618
|
<Text span inherit>
|
|
481
|
-
{filter.id ?
|
|
619
|
+
{filter.id ? narrowedResults.filter((result) => result.status === filter.id).length : narrowedResults.length}
|
|
482
620
|
</Text>
|
|
483
621
|
</Button>
|
|
484
622
|
))}
|
|
@@ -487,7 +625,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
487
625
|
<Flex gap="xs" align="center" wrap="wrap">
|
|
488
626
|
<HasVideo
|
|
489
627
|
count={results.filter((result) => (videoCounts(result.videos)?.recordings ?? 0) > 0).length}
|
|
490
|
-
pressed={video}
|
|
628
|
+
pressed={video || found.parsed.filters.video}
|
|
491
629
|
noun={['result', 'results']}
|
|
492
630
|
onToggle={() => go({ video: video ? undefined : true })}
|
|
493
631
|
/>
|
|
@@ -17,6 +17,7 @@ import { CheckDetail } from '../evidence/check-detail';
|
|
|
17
17
|
import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
|
|
18
18
|
import { useEvidence } from '../evidence/evidence-context';
|
|
19
19
|
import { Origin, StatusMark } from '../evidence/marks';
|
|
20
|
+
import { outcomeHeadline, selectedResult } from '../evidence/outcome';
|
|
20
21
|
import { useTestHistory } from './catalog';
|
|
21
22
|
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
22
23
|
import { Badges, runName } from './parts';
|
|
@@ -126,7 +127,7 @@ function Scope({ pinned, slot }: { pinned: PinnedResult; slot: CheckSlot | undef
|
|
|
126
127
|
export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot | undefined }) {
|
|
127
128
|
const now = useNow();
|
|
128
129
|
const history = useTestHistory(test.key);
|
|
129
|
-
const { layer } = useExplorerSearch();
|
|
130
|
+
const { layer, source } = useExplorerSearch();
|
|
130
131
|
const go = useExplorerNavigate();
|
|
131
132
|
const pinned = usePinnedResult(layer, test.key);
|
|
132
133
|
const [recording, setRecording] = useState(false);
|
|
@@ -134,6 +135,7 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
134
135
|
const [moment, setMoment] = useState<number | null>(null);
|
|
135
136
|
const [rows, setRows] = useState(HISTORY_ROWS);
|
|
136
137
|
const entries = history.data?.history ?? [];
|
|
138
|
+
const selected = slot && (!layer || pinned.state === 'ready') ? selectedResult(slot, pinned.state === 'ready' ? pinned.entry : undefined, source) : undefined;
|
|
137
139
|
const stats = test.stats;
|
|
138
140
|
const d = stats.durations;
|
|
139
141
|
if (recording && slot) {
|
|
@@ -211,6 +213,9 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
211
213
|
<Text data-status={test.status} span inherit fw={600}>
|
|
212
214
|
{statusWord(test.status)}
|
|
213
215
|
</Text>
|
|
216
|
+
<Text span inherit c="dimmed">
|
|
217
|
+
worst current result of {plural(test.outcomes.length, 'source', 'sources')}
|
|
218
|
+
</Text>
|
|
214
219
|
{stats.flaky && (
|
|
215
220
|
<Text title={stats.flakyReason} span inherit>
|
|
216
221
|
Flaky: {stats.flakyReason}
|
|
@@ -218,6 +223,11 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
218
223
|
)}
|
|
219
224
|
<Badges test={test} />
|
|
220
225
|
</Flex>
|
|
226
|
+
{selected && slot && (
|
|
227
|
+
<Text component="p" role="status" fw={600} data-outcome-headline>
|
|
228
|
+
{outcomeHeadline(selected, slot).text}
|
|
229
|
+
</Text>
|
|
230
|
+
)}
|
|
221
231
|
{test.spec && <Code c="dimmed">{test.spec!.path}</Code>}
|
|
222
232
|
<Code title="Check id" c="dimmed">
|
|
223
233
|
{test.id}
|
|
@@ -425,6 +435,18 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
425
435
|
layerId={layer}
|
|
426
436
|
{...(pinned.state === 'ready' ? { pinned: pinned.entry, pin: true } : {})}
|
|
427
437
|
reads="steps"
|
|
438
|
+
headline={false}
|
|
439
|
+
records={entries.map((entry) => ({
|
|
440
|
+
layer: entry.layer,
|
|
441
|
+
kind: entry.kind,
|
|
442
|
+
status: entry.status,
|
|
443
|
+
at: entry.at,
|
|
444
|
+
sha: entry.sha,
|
|
445
|
+
producer: entry.producer,
|
|
446
|
+
environment: entry.environment,
|
|
447
|
+
parameters: entry.parameters
|
|
448
|
+
}))}
|
|
449
|
+
{...(layer ? {} : { source, onSource: (next: string | null) => go({ source: next ?? undefined }) })}
|
|
428
450
|
select={(patch) => {
|
|
429
451
|
if (patch.view === 'recording') {
|
|
430
452
|
setRecordingLayer(patch.layer ?? undefined);
|