@crvouga/atlas 1.4.0 → 1.6.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/docs/ui.md +32 -2
- package/llms-full.txt +32 -2
- package/package.json +1 -1
- package/packages/visualizer/README.md +28 -0
- package/packages/visualizer/src/evidence/artifacts.tsx +17 -8
- 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 +105 -25
- package/packages/visualizer/src/explorer/RunsView.tsx +173 -35
- 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 +16 -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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Alert, Button, Stack, Text } from '@mantine/core';
|
|
2
|
+
import { IconX } from '@tabler/icons-react';
|
|
3
|
+
/**
|
|
4
|
+
* @module Search guidance
|
|
5
|
+
*
|
|
6
|
+
* What every search box in the explorer says beside its results, from one place: the filters typed in the box shown as
|
|
7
|
+
* removable chips, the parts of the query that were not understood (never silently dropped), and whether meaning-based
|
|
8
|
+
* matching is working, still refining, or off, so a search that is words only says so.
|
|
9
|
+
*/
|
|
10
|
+
import { withoutToken, type FilterKey, type ParsedSearch } from '../find/query';
|
|
11
|
+
import { semanticNote, type SemanticStatus } from '../find/semantic-search';
|
|
12
|
+
|
|
13
|
+
const FILTER_LABEL: Record<FilterKey, string> = {
|
|
14
|
+
status: 'Status',
|
|
15
|
+
env: 'Environment',
|
|
16
|
+
producer: 'Producer',
|
|
17
|
+
domain: 'Domain',
|
|
18
|
+
spec: 'Spec',
|
|
19
|
+
tag: 'Tag',
|
|
20
|
+
branch: 'Branch',
|
|
21
|
+
video: 'Has video'
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** The label of a typed filter chip: `Status: failed`, or `Has video`. */
|
|
25
|
+
export function filterLabel(key: FilterKey, value: string): string {
|
|
26
|
+
return key === 'video' ? FILTER_LABEL.video : `${FILTER_LABEL[key]}: ${value}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The filters typed in the box, each a button that removes its text from the query. */
|
|
30
|
+
export function TypedFilters({ parsed, query, onChange }: { parsed: ParsedSearch; query: string; onChange: (next: string) => void }) {
|
|
31
|
+
return (
|
|
32
|
+
<>
|
|
33
|
+
{parsed.tokens.map((token) => {
|
|
34
|
+
const label = filterLabel(token.key, token.value);
|
|
35
|
+
return (
|
|
36
|
+
<Button key={`typed:${token.text}:${token.value}`} type="button" onClick={() => onChange(withoutToken(query, token))} aria-label={`Remove ${label}`} variant="light">
|
|
37
|
+
{label}{' '}
|
|
38
|
+
<Text aria-hidden="true" span inherit>
|
|
39
|
+
<IconX size={16} aria-hidden="true" />
|
|
40
|
+
</Text>
|
|
41
|
+
</Button>
|
|
42
|
+
);
|
|
43
|
+
})}
|
|
44
|
+
</>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The parts of a query that could not be used, with what to type instead, and the meaning-based search's state. */
|
|
49
|
+
export function SearchNotes({ issues, semantic }: { issues: ParsedSearch['issues']; semantic: SemanticStatus }) {
|
|
50
|
+
const note = semanticNote(semantic);
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
{issues.length > 0 && (
|
|
54
|
+
<Alert role="status" color="yellow" my="sm" title="Part of this search was not understood">
|
|
55
|
+
<Stack gap="xs">
|
|
56
|
+
{issues.map((issue) => (
|
|
57
|
+
<Text key={issue.token + issue.message} size="sm">
|
|
58
|
+
<Text span inherit fw={600}>
|
|
59
|
+
{issue.token}
|
|
60
|
+
</Text>{' '}
|
|
61
|
+
{issue.message}
|
|
62
|
+
</Text>
|
|
63
|
+
))}
|
|
64
|
+
</Stack>
|
|
65
|
+
</Alert>
|
|
66
|
+
)}
|
|
67
|
+
{note && (
|
|
68
|
+
<Text role="status" size="xs" c="dimmed">
|
|
69
|
+
{note}
|
|
70
|
+
</Text>
|
|
71
|
+
)}
|
|
72
|
+
</>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* `type`, `label`, `branch`, `outcome`, `since` and `until`, so a link to one tab's filters opens the same query on another.
|
|
11
11
|
*/
|
|
12
12
|
import { joinList, splitList, type ArtifactFilter } from '@crvouga/atlas/reports/artifact-query';
|
|
13
|
-
import { TEST_SORTS, type TestQuery
|
|
13
|
+
import { TEST_SORTS, type TestQuery } from '@crvouga/atlas/reports/test-catalog';
|
|
14
14
|
import { z } from 'zod/v4';
|
|
15
15
|
|
|
16
16
|
/** How the explorer groups its tests. `spec` is the default. */
|
|
@@ -18,6 +18,11 @@ export const TEST_GROUPS = ['spec', 'domain', 'environment', 'status', 'producer
|
|
|
18
18
|
/** One of {@link TEST_GROUPS}. */
|
|
19
19
|
export type TestGroup = (typeof TEST_GROUPS)[number];
|
|
20
20
|
|
|
21
|
+
/** The orders the explorer offers: `relevance` (best match first, only with words to match) and those of the catalog. */
|
|
22
|
+
export const EXPLORER_SORTS = ['relevance', ...TEST_SORTS] as const;
|
|
23
|
+
/** One of {@link EXPLORER_SORTS}. */
|
|
24
|
+
export type ExplorerSort = (typeof EXPLORER_SORTS)[number];
|
|
25
|
+
|
|
21
26
|
/** A search value as text: the router reads `123` as a number and `["a"]` as an array, both of which are meant as text here. */
|
|
22
27
|
const text = z
|
|
23
28
|
.preprocess((value) => (typeof value === 'number' || typeof value === 'boolean' ? String(value) : value !== null && typeof value === 'object' ? JSON.stringify(value) : value), z.string().min(1))
|
|
@@ -56,7 +61,7 @@ export const ExplorerSearchFields = {
|
|
|
56
61
|
/** Only the tests a CI run recorded (a `RunSummary.key`). Not `run`, which already names a recorded statechart run. */
|
|
57
62
|
inRun: text,
|
|
58
63
|
group: z.enum(TEST_GROUPS).optional().catch(undefined),
|
|
59
|
-
sort: z.enum(
|
|
64
|
+
sort: z.enum(EXPLORER_SORTS).optional().catch(undefined),
|
|
60
65
|
tab: z.enum(['runs', 'artifacts']).optional().catch(undefined),
|
|
61
66
|
/** The run opened in the Runs tab. */
|
|
62
67
|
openRun: text,
|
|
@@ -89,7 +94,7 @@ export type ExplorerSearch = {
|
|
|
89
94
|
readonly until?: string;
|
|
90
95
|
readonly inRun?: string;
|
|
91
96
|
readonly group?: TestGroup;
|
|
92
|
-
readonly sort?:
|
|
97
|
+
readonly sort?: ExplorerSort;
|
|
93
98
|
readonly tab?: 'runs' | 'artifacts';
|
|
94
99
|
readonly openRun?: string;
|
|
95
100
|
readonly layer?: string;
|
|
@@ -113,7 +118,12 @@ export function toggled(value: string | undefined, item: string): string | undef
|
|
|
113
118
|
return joined(list.includes(item) ? list.filter((entry) => entry !== item) : [...list, item]);
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
/** The
|
|
121
|
+
/** The order a list is in: what the address says, else best match when words are searched, else by spec. */
|
|
122
|
+
export function sortOf(search: ExplorerSearch): ExplorerSort {
|
|
123
|
+
return search.sort ?? (search.q ? 'relevance' : 'spec');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The catalog query an address asks for, without the run (it is matched separately) and without paging. The words in `q` are matched by the shared search, not by the catalog. */
|
|
117
127
|
export function queryOf(search: ExplorerSearch): TestQuery {
|
|
118
128
|
return {
|
|
119
129
|
...(search.q ? { q: search.q } : {}),
|
|
@@ -126,7 +136,7 @@ export function queryOf(search: ExplorerSearch): TestQuery {
|
|
|
126
136
|
...(values(search.scope).length ? { scope: values(search.scope) } : {}),
|
|
127
137
|
artifactTag: values(search.artifactTag),
|
|
128
138
|
...(search.inherited ? { inherited: true } : {}),
|
|
129
|
-
sort: search.sort
|
|
139
|
+
sort: search.sort === 'relevance' || !search.sort ? 'spec' : search.sort
|
|
130
140
|
};
|
|
131
141
|
}
|
|
132
142
|
|
|
@@ -137,7 +147,7 @@ export function explorerSearch(search: Record<string, unknown>): ExplorerSearch
|
|
|
137
147
|
|
|
138
148
|
/** True when any filter or search narrows the list. */
|
|
139
149
|
export function narrowed(search: ExplorerSearch): boolean {
|
|
140
|
-
return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.scope || search.artifactTag || search.inRun);
|
|
150
|
+
return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.scope || search.artifactTag || search.inRun || search.video);
|
|
141
151
|
}
|
|
142
152
|
|
|
143
153
|
/** The filters of the Runs and Artifacts tabs, in the address. */
|
|
@@ -1,18 +1,26 @@
|
|
|
1
|
-
import { Badge, Group, Kbd, List, Modal, Text } from '@mantine/core';
|
|
1
|
+
import { Badge, Button, Group, Kbd, List, Modal, Stack, Text } from '@mantine/core';
|
|
2
2
|
import { Spotlight, useSpotlight } from '@mantine/spotlight';
|
|
3
3
|
import { useQueryClient } from '@tanstack/react-query';
|
|
4
4
|
import { useNavigate, useSearch } from '@tanstack/react-router';
|
|
5
|
-
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
5
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
6
6
|
import { useAtlas } from '../app/atlas-context';
|
|
7
|
+
import { useTestCatalog } from '../explorer/catalog';
|
|
8
|
+
import { SearchNotes } from '../explorer/search-notes';
|
|
7
9
|
import { keys, specKey, useAdapter } from '../data/queries';
|
|
8
10
|
import { useEvidence } from '../evidence/evidence-context';
|
|
9
11
|
import { plural } from '../evidence/format';
|
|
10
12
|
import { StatusMark } from '../evidence/marks';
|
|
11
13
|
import { kindOfFile } from '../spec-kind';
|
|
14
|
+
import { describeHit } from './behaviors';
|
|
12
15
|
import { corpusFrom } from './corpus';
|
|
13
16
|
import { finderStore, isHelpOpen, setFinderOpen, setHelpOpen, toggleFinder, useFinderOpen, useHelpOpen } from './open';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
17
|
+
import { parseSearch, passesFilters } from './query';
|
|
18
|
+
import { FIND_GROUPS, indexCorpus, notationOf, rankIndex, splitQuery, type FindKind, type FindTarget, type Ranked } from './rank';
|
|
19
|
+
import { applySemantic } from './semantic';
|
|
20
|
+
import { useSemantic } from './semantic-search';
|
|
21
|
+
|
|
22
|
+
/** The finder lists at most this many results; it says so, and offers all of them, when there are more. */
|
|
23
|
+
export const FINDER_LIMIT = 40;
|
|
16
24
|
|
|
17
25
|
const KIND_WORD: Record<FindKind, string> = {
|
|
18
26
|
journey: 'Journey',
|
|
@@ -31,6 +39,7 @@ const SHORTCUTS: readonly {
|
|
|
31
39
|
{ keys: '/', does: 'Find a flow' },
|
|
32
40
|
{ keys: '↑ ↓', does: 'Move through the list' },
|
|
33
41
|
{ keys: 'Enter', does: 'Open' },
|
|
42
|
+
{ keys: 'Shift+Enter', does: 'See all results of the search' },
|
|
34
43
|
{ keys: '⌘Enter or Ctrl+Enter', does: 'Open the recording' },
|
|
35
44
|
{ keys: 'Esc', does: 'Close' },
|
|
36
45
|
{ keys: '?', does: 'Show these shortcuts' },
|
|
@@ -102,16 +111,6 @@ function useSpecTexts(files: readonly string[], enabled: boolean, open: boolean)
|
|
|
102
111
|
const failed = useMemo(() => files.filter((file) => failedFiles.has(file)).length, [files, failedFiles]);
|
|
103
112
|
return { texts, pending, total: files.length, read, failed };
|
|
104
113
|
}
|
|
105
|
-
/** The vector cache outlives a keystroke, so a live run does not embed the suite again. */
|
|
106
|
-
const vectorCache = new Map<string, Float32Array>();
|
|
107
|
-
function vectorsFor(docs: readonly FindDoc[]): ReadonlyMap<string, Float32Array> {
|
|
108
|
-
const vectors = new Map<string, Float32Array>();
|
|
109
|
-
for (const doc of docs) {
|
|
110
|
-
const vector = vectorCache.get(textHash(embedText(doc)));
|
|
111
|
-
if (vector) vectors.set(doc.id, vector);
|
|
112
|
-
}
|
|
113
|
-
return vectors;
|
|
114
|
-
}
|
|
115
114
|
export function Finder() {
|
|
116
115
|
const open = useFinderOpen();
|
|
117
116
|
const help = useHelpOpen();
|
|
@@ -125,49 +124,85 @@ export function Finder() {
|
|
|
125
124
|
useEffect(() => {
|
|
126
125
|
if (open) setWanted(true);
|
|
127
126
|
}, [open]);
|
|
128
|
-
const { texts, pending, total, read, failed } = useSpecTexts(readable, wanted, open);
|
|
127
|
+
const { texts, pending, total: specTotal, read, failed } = useSpecTexts(readable, wanted, open);
|
|
128
|
+
const catalog = useTestCatalog();
|
|
129
|
+
const tests = useMemo(() => (catalog.model ? new Map(catalog.model.tests.map((test) => [test.key, test] as const)) : undefined), [catalog.model]);
|
|
129
130
|
const docs = useMemo(
|
|
130
|
-
() => corpusFrom(view, evidence.files, evidence.titleOf, evidence.view, texts),
|
|
131
|
-
[view, evidence.files, evidence.titleOf, evidence.view, texts]
|
|
131
|
+
() => corpusFrom(view, evidence.files, evidence.titleOf, evidence.view, texts, tests),
|
|
132
|
+
[view, evidence.files, evidence.titleOf, evidence.view, texts, tests]
|
|
132
133
|
);
|
|
133
134
|
const index = useMemo(() => indexCorpus(docs), [docs]);
|
|
134
135
|
// What search has not covered yet, so an empty answer is not read as "no such behavior".
|
|
135
136
|
const unindexed = evidence.filesPending
|
|
136
137
|
? 'The spec list is still loading'
|
|
137
138
|
: pending
|
|
138
|
-
? `${
|
|
139
|
+
? `${specTotal - read} of ${specTotal} specs are still being read`
|
|
139
140
|
: !evidence.ready && !evidence.unavailable
|
|
140
141
|
? 'Recorded results are still loading'
|
|
141
142
|
: '';
|
|
142
143
|
const [query, setQuery] = useState('');
|
|
144
|
+
const [showAll, setShowAll] = useState(false);
|
|
145
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
143
146
|
const store = finderStore;
|
|
144
147
|
const spotlightState = useSpotlight(store);
|
|
145
148
|
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
} | null>(null);
|
|
151
|
-
const [vectors, setVectors] = useState<ReadonlyMap<string, Float32Array>>(() => new Map());
|
|
152
|
-
const queryVector = embedded !== null && embedded.query === query.trim() ? embedded.vector : null;
|
|
149
|
+
const parsed = useMemo(() => parseSearch(query), [query]);
|
|
150
|
+
// Every match, best first: the list below is the first FINDER_LIMIT of it, and says so when there are more.
|
|
151
|
+
const everything = useMemo(() => rankIndex(index, query, Number.POSITIVE_INFINITY), [index, query]);
|
|
152
|
+
const lexical = useMemo(() => (showAll ? everything : everything.slice(0, FINDER_LIMIT)), [everything, showAll]);
|
|
153
153
|
const searchable = useMemo(() => {
|
|
154
|
-
const notations = splitQuery(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
154
|
+
const notations = splitQuery(parsed.words).notations;
|
|
155
|
+
return docs.filter((doc) => (notations.size === 0 || notations.has(notationOf(doc))) && passesFilters(doc.facets, parsed.filters));
|
|
156
|
+
}, [docs, parsed]);
|
|
157
|
+
const lexicalDocs = useMemo(() => lexical.map((hit) => hit.doc), [lexical]);
|
|
158
|
+
const literal = parsed.phrases.length + parsed.identifiers.length > 0;
|
|
159
|
+
const semantic = useSemantic(open && parsed.words.length > 0 && !literal, parsed.words, searchable, lexicalDocs);
|
|
160
|
+
const shown = useMemo<Ranked[]>(
|
|
161
|
+
() => (semantic.queryVector ? applySemantic(searchable, lexical, semantic.queryVector, semantic.vectors, showAll ? Number.POSITIVE_INFINITY : FINDER_LIMIT) : lexical),
|
|
162
|
+
[semantic.queryVector, searchable, lexical, semantic.vectors, showAll]
|
|
161
163
|
);
|
|
162
164
|
const ordered = useMemo(() => FIND_GROUPS.flatMap((group) => shown.filter((hit) => hit.doc.group === group)), [shown]);
|
|
165
|
+
const total = everything.length;
|
|
166
|
+
const bounded = !showAll && total > FINDER_LIMIT;
|
|
167
|
+
const orderedRef = useRef(ordered);
|
|
168
|
+
orderedRef.current = ordered;
|
|
169
|
+
const selectedId = useRef<string | null>(null);
|
|
170
|
+
useEffect(
|
|
171
|
+
() =>
|
|
172
|
+
store.subscribe((state) => {
|
|
173
|
+
selectedId.current = orderedRef.current[Math.max(0, state.selected)]?.doc.id ?? null;
|
|
174
|
+
}),
|
|
175
|
+
[store]
|
|
176
|
+
);
|
|
163
177
|
|
|
164
178
|
// Rank results ourselves, then let Spotlight own keyboard selection and scrolling.
|
|
165
|
-
// Compound Spotlight does not initialize a selection when asynchronous results arrive.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
179
|
+
// Compound Spotlight does not initialize a selection when asynchronous results arrive. A new query selects its first
|
|
180
|
+
// result; results that change under the same query (specs read, meaning-based re-ranking) keep the selected result
|
|
181
|
+
// selected by identity, and say so when it is gone, so Enter always opens what is highlighted.
|
|
182
|
+
const lastQuery = useRef<string | null>(null);
|
|
183
|
+
useLayoutEffect(() => {
|
|
184
|
+
if (!open) {
|
|
185
|
+
lastQuery.current = null;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const first = ordered[0]?.doc.id ?? null;
|
|
189
|
+
if (lastQuery.current !== query) {
|
|
190
|
+
lastQuery.current = query;
|
|
191
|
+
selectedId.current = first;
|
|
192
|
+
setNotice(null);
|
|
193
|
+
setShowAll(false);
|
|
194
|
+
store.setState((state) => ({ ...state, selected: 0 }));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const kept = selectedId.current === null ? -1 : ordered.findIndex((hit) => hit.doc.id === selectedId.current);
|
|
198
|
+
if (kept >= 0) {
|
|
199
|
+
if (store.getState().selected !== kept) store.setState((state) => ({ ...state, selected: kept }));
|
|
200
|
+
} else {
|
|
201
|
+
if (selectedId.current !== null) setNotice('The results changed and the selected result is no longer listed, so the first result is selected.');
|
|
202
|
+
selectedId.current = first;
|
|
203
|
+
store.setState((state) => ({ ...state, selected: 0 }));
|
|
204
|
+
}
|
|
205
|
+
}, [open, query, ordered, store]);
|
|
171
206
|
useEffect(() => {
|
|
172
207
|
if (open) return;
|
|
173
208
|
setQuery('');
|
|
@@ -198,40 +233,6 @@ export function Finder() {
|
|
|
198
233
|
window.addEventListener('keydown', onKey);
|
|
199
234
|
return () => window.removeEventListener('keydown', onKey);
|
|
200
235
|
}, []);
|
|
201
|
-
useEffect(() => {
|
|
202
|
-
if (!open || embedFailed.current || !embeddingsAvailable() || query.trim().length === 0) return;
|
|
203
|
-
let cancelled = false;
|
|
204
|
-
const needle = query.trim();
|
|
205
|
-
embedder.current ??= createWorkerEmbedder();
|
|
206
|
-
const rankedFirst = new Set(lexical.map((hit) => hit.doc.id));
|
|
207
|
-
const ordered = [...lexical.map((hit) => hit.doc), ...docs.filter((doc) => !rankedFirst.has(doc.id))];
|
|
208
|
-
void (async () => {
|
|
209
|
-
try {
|
|
210
|
-
const embedded = await embedder.current!.embed([needle]);
|
|
211
|
-
const nextQuery = embedded[0];
|
|
212
|
-
if (cancelled || !nextQuery) return;
|
|
213
|
-
setEmbedded({ query: needle, vector: nextQuery });
|
|
214
|
-
for (let start = 0; start < ordered.length && !cancelled; start += 8) {
|
|
215
|
-
const batch = ordered.slice(start, start + 8);
|
|
216
|
-
const missing = batch.filter((doc) => !vectorCache.has(textHash(embedText(doc))));
|
|
217
|
-
if (missing.length > 0) {
|
|
218
|
-
const batchVectors = await embedder.current!.embed(missing.map((doc) => embedText(doc)));
|
|
219
|
-
if (cancelled) return;
|
|
220
|
-
missing.forEach((doc, at) => {
|
|
221
|
-
const vector = batchVectors[at];
|
|
222
|
-
if (vector) vectorCache.set(textHash(embedText(doc)), vector);
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
if (!cancelled) setVectors(vectorsFor(docs));
|
|
226
|
-
}
|
|
227
|
-
} catch {
|
|
228
|
-
embedFailed.current = true;
|
|
229
|
-
}
|
|
230
|
-
})();
|
|
231
|
-
return () => {
|
|
232
|
-
cancelled = true;
|
|
233
|
-
};
|
|
234
|
-
}, [open, query, docs, lexical]);
|
|
235
236
|
const openTarget = (target: FindTarget) => {
|
|
236
237
|
const run = typeof routeSearch.run === 'string' ? routeSearch.run : undefined;
|
|
237
238
|
if (target.to === '/chart/$chartId' && target.chartId) {
|
|
@@ -253,7 +254,22 @@ export function Finder() {
|
|
|
253
254
|
}
|
|
254
255
|
setFinderOpen(false);
|
|
255
256
|
};
|
|
257
|
+
// The full results of a search: the explorer's list when the project has one (it filters, counts and sorts everything),
|
|
258
|
+
// else every result in this list.
|
|
259
|
+
const seeAll = () => {
|
|
260
|
+
if (catalog.model && query.trim()) {
|
|
261
|
+
const run = typeof routeSearch.run === 'string' ? routeSearch.run : undefined;
|
|
262
|
+
void navigate({ to: '/', search: { run, q: query.trim() } });
|
|
263
|
+
setFinderOpen(false);
|
|
264
|
+
} else setShowAll(true);
|
|
265
|
+
};
|
|
256
266
|
const onCommandKey = (event: ReactKeyboardEvent) => {
|
|
267
|
+
if (event.key === 'Enter' && event.shiftKey && !event.metaKey && !event.ctrlKey) {
|
|
268
|
+
event.preventDefault();
|
|
269
|
+
event.stopPropagation();
|
|
270
|
+
seeAll();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
257
273
|
if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey)) return;
|
|
258
274
|
event.preventDefault();
|
|
259
275
|
event.stopPropagation();
|
|
@@ -311,9 +327,15 @@ export function Finder() {
|
|
|
311
327
|
aria-selected={ordered[Math.max(0, spotlightState.selected)]?.doc.id === hit.doc.id}
|
|
312
328
|
onClick={() => openTarget(hit.doc.target)}
|
|
313
329
|
label={hit.doc.title}
|
|
314
|
-
description={[hit.snippet !== hit.doc.subtitle ? hit.snippet : '', hit.doc.subtitle].filter(Boolean).join(' · ')}
|
|
330
|
+
description={[hit.snippet !== hit.doc.subtitle ? hit.snippet : '', hit.doc.subtitle, query.trim() ? describeHit(hit) : ''].filter(Boolean).join(' · ')}
|
|
315
331
|
leftSection={hit.doc.status ? <StatusMark status={hit.doc.status} size={16} /> : undefined}
|
|
316
|
-
rightSection={
|
|
332
|
+
rightSection={
|
|
333
|
+
<Group gap="xs" wrap="nowrap">
|
|
334
|
+
{hit.exact && <Badge color="blue">Exact</Badge>}
|
|
335
|
+
{hit.partial && <Badge color="gray" variant="outline">Partial</Badge>}
|
|
336
|
+
<Badge color="gray">{KIND_WORD[hit.doc.kind]}</Badge>
|
|
337
|
+
</Group>
|
|
338
|
+
}
|
|
317
339
|
/>
|
|
318
340
|
))}
|
|
319
341
|
</Spotlight.ActionsGroup>
|
|
@@ -321,13 +343,37 @@ export function Finder() {
|
|
|
321
343
|
})}
|
|
322
344
|
</Spotlight.ActionsList>
|
|
323
345
|
<Spotlight.Footer>
|
|
324
|
-
<Group gap="md" justify="space-between">
|
|
325
|
-
<
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
346
|
+
<Group gap="md" justify="space-between" align="flex-start">
|
|
347
|
+
<Stack gap={0}>
|
|
348
|
+
<Text role="status" size="xs" c="dimmed">
|
|
349
|
+
{query.trim() && total > 0
|
|
350
|
+
? bounded
|
|
351
|
+
? `Showing the first ${shown.length} of ${total} results, not all of them.`
|
|
352
|
+
: `${total} ${total === 1 ? 'result' : 'results'}.`
|
|
353
|
+
: '↑↓ move'}
|
|
354
|
+
</Text>
|
|
355
|
+
{(unindexed || failed > 0) && (
|
|
356
|
+
<Text role="status" size="xs" c="dimmed">
|
|
357
|
+
{unindexed ? `Searching what is read so far: ${unindexed}` : `${plural(failed, 'spec', 'specs')} could not be read and ${failed === 1 ? 'is' : 'are'} not searched`}
|
|
358
|
+
</Text>
|
|
359
|
+
)}
|
|
360
|
+
{notice && (
|
|
361
|
+
<Text role="status" size="xs" c="dimmed">
|
|
362
|
+
{notice}
|
|
363
|
+
</Text>
|
|
364
|
+
)}
|
|
365
|
+
<SearchNotes issues={parsed.issues} semantic={query.trim() ? semantic.status : 'idle'} />
|
|
366
|
+
</Stack>
|
|
367
|
+
<Group gap="xs" wrap="wrap" justify="flex-end">
|
|
368
|
+
{bounded && (
|
|
369
|
+
<Button size="compact-xs" variant="light" onClick={seeAll}>
|
|
370
|
+
See all {total} results
|
|
371
|
+
</Button>
|
|
372
|
+
)}
|
|
373
|
+
<Text size="xs" c="dimmed">
|
|
374
|
+
↵ open · ⇧↵ all results · esc close
|
|
375
|
+
</Text>
|
|
376
|
+
</Group>
|
|
331
377
|
</Group>
|
|
332
378
|
</Spotlight.Footer>
|
|
333
379
|
</Spotlight.Root>
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Searchable behaviors, results and runs
|
|
3
|
+
*
|
|
4
|
+
* The documents every search surface matches against, built from one place so they cannot disagree. A recorded
|
|
5
|
+
* behavior (`TestSummary`) is searched by the same fields wherever it appears: the finder's Runs group, the test list
|
|
6
|
+
* and the results of an opened run all call {@link testFields} and {@link testFacets}. They differ only in what the
|
|
7
|
+
* structured filters say about this particular appearance (a run's result has the status, environment and videos of
|
|
8
|
+
* that run, not of the latest combined evidence), so the same words always give the same eligible behaviors.
|
|
9
|
+
*/
|
|
10
|
+
import { videoCounts } from '@crvouga/atlas/reports/evidence-detail';
|
|
11
|
+
import type { RunResult, RunSummary, TestSummary } from '@crvouga/atlas/reports/test-catalog';
|
|
12
|
+
|
|
13
|
+
import type { DocFacets, ParsedSearch, SearchIssue, SearchFilters } from './query';
|
|
14
|
+
import { NO_FILTERS, SYNTAX_HELP } from './query';
|
|
15
|
+
import type { FieldName, FindDoc, FindField, FindStatus, Ranked } from './rank';
|
|
16
|
+
|
|
17
|
+
/** True when a behavior's current results reference at least one playable video recording. */
|
|
18
|
+
export function testHasVideo(test: Pick<TestSummary, 'outcomes'>): boolean {
|
|
19
|
+
return test.outcomes.some((outcome) => (videoCounts(outcome.evidence?.videos)?.recordings ?? 0) > 0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The id of the finder document of a behavior (`run:` followed by its `TestSummary.key`): the identity every surface shares. */
|
|
23
|
+
export function behaviorId(key: string): string {
|
|
24
|
+
return `run:${key}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What the filters read about a behavior: its current status, where it ran, its domain, spec, tags and branches, and its videos. */
|
|
28
|
+
export function testFacets(test: TestSummary): DocFacets {
|
|
29
|
+
return {
|
|
30
|
+
status: [test.status, ...(test.stats.flaky ? ['flaky'] : [])],
|
|
31
|
+
env: test.environments.length ? test.environments : ['(none)'],
|
|
32
|
+
producer: test.producers,
|
|
33
|
+
domain: [test.spec?.domain || '(no spec)'],
|
|
34
|
+
spec: test.specPath ? [test.specPath] : [],
|
|
35
|
+
tag: [...test.tags, ...(test.artifactTags ?? []), ...(test.inheritedArtifactTags ?? [])].map((tag) => tag.replace(/^#/, '')),
|
|
36
|
+
branch: test.stats.branches,
|
|
37
|
+
video: testHasVideo(test)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The first line of a failure, for finding a behavior by what went wrong. */
|
|
42
|
+
function failureLines(test: TestSummary): string[] {
|
|
43
|
+
return test.outcomes.filter((outcome) => outcome.status === 'failed' && outcome.detail).map((outcome) => outcome.detail.split('\n')[0]!);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The searchable texts of a behavior: title, path and identifier, spec and failures, and metadata. Every surface uses these. */
|
|
47
|
+
export function testFields(test: TestSummary): FindField[] {
|
|
48
|
+
const fields: FindField[] = [{ name: 'title', text: test.title }];
|
|
49
|
+
fields.push({ name: 'path', text: [test.specPath ?? '', test.id].filter(Boolean).join('\n') });
|
|
50
|
+
const description = [test.spec?.title ?? '', ...failureLines(test)].filter(Boolean).join('\n');
|
|
51
|
+
if (description) fields.push({ name: 'description', text: description });
|
|
52
|
+
const metadata = [
|
|
53
|
+
test.spec?.domain ?? '',
|
|
54
|
+
...test.tags,
|
|
55
|
+
...(test.artifactTags ?? []),
|
|
56
|
+
...Object.entries(test.labels).flatMap(([key, value]) => [key, value]),
|
|
57
|
+
...test.environments,
|
|
58
|
+
...test.producers
|
|
59
|
+
].filter(Boolean);
|
|
60
|
+
if (metadata.length) fields.push({ name: 'metadata', text: metadata.join('\n') });
|
|
61
|
+
return fields;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function findStatus(status: TestSummary['status']): FindStatus {
|
|
65
|
+
return status;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A behavior as a document. Its id is {@link behaviorId}. */
|
|
69
|
+
export function testDoc(test: TestSummary): FindDoc {
|
|
70
|
+
return {
|
|
71
|
+
id: behaviorId(test.key),
|
|
72
|
+
group: 'Runs',
|
|
73
|
+
kind: 'run',
|
|
74
|
+
title: test.title,
|
|
75
|
+
subtitle: test.specPath ?? '',
|
|
76
|
+
fields: testFields(test),
|
|
77
|
+
status: findStatus(test.status),
|
|
78
|
+
related: [],
|
|
79
|
+
target: { to: '/', search: { test: test.key } },
|
|
80
|
+
recording: null,
|
|
81
|
+
facets: testFacets(test)
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const documents = new WeakMap<readonly TestSummary[], ReadonlyMap<string, FindDoc>>();
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The documents of every behavior of a report by `TestSummary.key`, built once per list of tests. Every surface that
|
|
89
|
+
* searches behaviors (the test list, the results of a run) searches this same set, so the same words are judged against
|
|
90
|
+
* the same vocabulary and the same behaviors are eligible.
|
|
91
|
+
*/
|
|
92
|
+
export function behaviorDocs(tests: readonly TestSummary[]): ReadonlyMap<string, FindDoc> {
|
|
93
|
+
let found = documents.get(tests);
|
|
94
|
+
if (!found) {
|
|
95
|
+
found = new Map(tests.map((test) => [test.key, testDoc(test)] as const));
|
|
96
|
+
documents.set(tests, found);
|
|
97
|
+
}
|
|
98
|
+
return found;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The result of a behavior within one run as a document: the behavior's own fields (so it matches exactly when the
|
|
103
|
+
* behavior does), with the status, environment, producer and videos of this result for the filters. Without the
|
|
104
|
+
* behavior in the report any more, its title, spec and environment are all there is to match.
|
|
105
|
+
*/
|
|
106
|
+
export function resultDoc(result: RunResult, test: TestSummary | undefined): FindDoc {
|
|
107
|
+
const base: FindDoc = test
|
|
108
|
+
? testDoc(test)
|
|
109
|
+
: {
|
|
110
|
+
id: behaviorId(result.key),
|
|
111
|
+
group: 'Runs',
|
|
112
|
+
kind: 'run',
|
|
113
|
+
title: result.title,
|
|
114
|
+
subtitle: result.specPath ?? '',
|
|
115
|
+
fields: [
|
|
116
|
+
{ name: 'title', text: result.title },
|
|
117
|
+
{ name: 'path', text: [result.specPath ?? '', result.id].filter(Boolean).join('\n') },
|
|
118
|
+
{ name: 'metadata', text: [result.environment ?? '', result.producer].filter(Boolean).join('\n') }
|
|
119
|
+
],
|
|
120
|
+
status: findStatus(result.status),
|
|
121
|
+
related: [],
|
|
122
|
+
target: { to: '/', search: { test: result.key } },
|
|
123
|
+
recording: null
|
|
124
|
+
};
|
|
125
|
+
const facets = base.facets;
|
|
126
|
+
return {
|
|
127
|
+
...base,
|
|
128
|
+
status: findStatus(result.status),
|
|
129
|
+
facets: {
|
|
130
|
+
status: [result.status, ...(facets?.status.includes('flaky') ? ['flaky'] : [])],
|
|
131
|
+
env: [result.environment ?? '(none)'],
|
|
132
|
+
producer: [result.producer],
|
|
133
|
+
domain: facets?.domain ?? ['(no spec)'],
|
|
134
|
+
spec: result.specPath ? [result.specPath] : [],
|
|
135
|
+
tag: facets?.tag ?? [],
|
|
136
|
+
branch: facets?.branch ?? [],
|
|
137
|
+
video: (videoCounts(result.videos)?.recordings ?? 0) > 0
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** What a run is searched by: its label, commit and branch, environments, producers, and the tests that failed in it. */
|
|
143
|
+
export function runDoc(run: RunSummary, label: string): FindDoc {
|
|
144
|
+
const status = run.counts.failed > 0 ? 'failed' : run.counts.running > 0 ? 'running' : run.counts['not-run'] > 0 ? 'not-run' : 'passed';
|
|
145
|
+
return {
|
|
146
|
+
id: `runs:${run.key}`,
|
|
147
|
+
group: 'Runs',
|
|
148
|
+
kind: 'run',
|
|
149
|
+
title: label,
|
|
150
|
+
subtitle: [run.branch, run.sha?.slice(0, 7)].filter(Boolean).join(' @ '),
|
|
151
|
+
fields: [
|
|
152
|
+
{ name: 'title', text: label },
|
|
153
|
+
{ name: 'path', text: [run.key, run.branch ?? '', run.sha ?? '', run.runId ?? ''].filter(Boolean).join('\n') },
|
|
154
|
+
{ name: 'description', text: run.failing.flatMap((failure) => [failure.title, failure.detail.split('\n')[0] ?? '']).filter(Boolean).join('\n') },
|
|
155
|
+
{ name: 'metadata', text: [...run.environments, ...run.producers].join('\n') }
|
|
156
|
+
],
|
|
157
|
+
status,
|
|
158
|
+
related: [],
|
|
159
|
+
target: { to: '/', search: {} },
|
|
160
|
+
recording: null,
|
|
161
|
+
facets: {
|
|
162
|
+
status: [...(run.counts.failed > 0 ? ['failed'] : []), ...(run.counts.running > 0 ? ['running'] : []), ...(run.counts['not-run'] > 0 ? ['not-run'] : []), ...(run.counts.passed > 0 ? ['passed'] : [])],
|
|
163
|
+
env: run.environments.length ? run.environments : ['(none)'],
|
|
164
|
+
producer: run.producers,
|
|
165
|
+
domain: [],
|
|
166
|
+
spec: [],
|
|
167
|
+
tag: [],
|
|
168
|
+
branch: run.branch ? [run.branch] : [],
|
|
169
|
+
video: (run.videos?.recordings ?? 0) > 0
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A search for a list that has no domain, spec or tag (the runs): those filters are removed and said so, instead of
|
|
176
|
+
* silently emptying the list or pretending to filter.
|
|
177
|
+
*/
|
|
178
|
+
export function forRuns(parsed: ParsedSearch): ParsedSearch {
|
|
179
|
+
const issues: SearchIssue[] = [...parsed.issues];
|
|
180
|
+
const dropped = parsed.tokens.filter((token) => token.key === 'domain' || token.key === 'spec' || token.key === 'tag');
|
|
181
|
+
for (const token of dropped) issues.push({ token: token.text, message: `does not apply to runs, so it filters nothing here. Open a run to filter its tests by ${token.key}:. ${SYNTAX_HELP}` });
|
|
182
|
+
const filters: SearchFilters = { ...parsed.filters, domain: NO_FILTERS.domain, spec: NO_FILTERS.spec, tag: NO_FILTERS.tag };
|
|
183
|
+
return { ...parsed, filters, tokens: parsed.tokens.filter((token) => !dropped.includes(token)), issues };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const FIELD_WORD: Record<FieldName, string> = {
|
|
187
|
+
title: 'title',
|
|
188
|
+
steps: 'steps',
|
|
189
|
+
description: 'description',
|
|
190
|
+
metadata: 'tags and details',
|
|
191
|
+
path: 'path or identifier'
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Why a document is in the results, for a person: an exact phrase or identifier, the fields the words were found
|
|
196
|
+
* in, and whether a close spelling or only the meaning brought it. Empty for a hit that carries no reason (a browse).
|
|
197
|
+
*/
|
|
198
|
+
export function describeHit(hit: Pick<Ranked, 'fields' | 'exact' | 'literals' | 'fuzzy' | 'partial' | 'semantic' | 'related'>): string {
|
|
199
|
+
const parts: string[] = [];
|
|
200
|
+
if (hit.exact && hit.literals?.length) parts.push(`Exact: ${hit.literals.map((literal) => `“${literal}”`).join(', ')}`);
|
|
201
|
+
if (hit.semantic) parts.push('Similar meaning');
|
|
202
|
+
else if (hit.fields?.length) parts.push(`${hit.partial ? 'Some words in' : 'Matches'} ${hit.fields.map((field) => FIELD_WORD[field]).join(', ')}${hit.fuzzy ? ' (close spelling)' : ''}`);
|
|
203
|
+
if (hit.related && !parts.length) parts.push('Related to a match');
|
|
204
|
+
return parts.join(' · ');
|
|
205
|
+
}
|