@crvouga/atlas 1.5.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 +7 -1
- package/docs/ui.md +11 -1
- package/llms-full.txt +11 -1
- package/package.json +1 -1
- package/packages/visualizer/README.md +28 -0
- 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
|
@@ -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
|
+
}
|
|
@@ -5,6 +5,8 @@ import type { AtlasView, ItemStatus } from '../data/model';
|
|
|
5
5
|
import { worstOf } from '../data/model/status';
|
|
6
6
|
import { featureModel, scenarioLabel, slotsByScenario } from '../evidence/gherkin';
|
|
7
7
|
import { kindOfFile, outlinePath, specFile } from '../spec-kind';
|
|
8
|
+
import type { TestSummary } from '@crvouga/atlas/reports/test-catalog';
|
|
9
|
+
import { testFacets, testFields } from './behaviors';
|
|
8
10
|
import type { FindDoc, FindField, FindStatus, FindTarget } from './rank';
|
|
9
11
|
|
|
10
12
|
export type CorpusChart = { readonly id: string; readonly name: string };
|
|
@@ -67,6 +69,8 @@ export type CorpusSource = {
|
|
|
67
69
|
readonly texts: ReadonlyMap<string, string>;
|
|
68
70
|
/** The project home is the test explorer (no statecharts): a check without a published spec opens there. */
|
|
69
71
|
readonly explorer?: boolean;
|
|
72
|
+
/** The report's tests by key. A check that is one of them is searched by the same fields and filters as in the test list. */
|
|
73
|
+
readonly tests?: ReadonlyMap<string, TestSummary>;
|
|
70
74
|
};
|
|
71
75
|
|
|
72
76
|
function itemStatus(status: ItemStatus): FindStatus {
|
|
@@ -130,9 +134,17 @@ function actionsIn(text: string): string[] {
|
|
|
130
134
|
return [...new Set([...text.matchAll(ACTION)].map((match) => match[1]!))];
|
|
131
135
|
}
|
|
132
136
|
|
|
133
|
-
|
|
137
|
+
/** A check's fields: those the test list searches, else what the check itself says. */
|
|
138
|
+
function checkFields(slot: CorpusSlot, title: string, file: string, test: TestSummary | undefined): { fields: FindField[]; facets?: FindDoc['facets'] } {
|
|
139
|
+
if (!test) return { fields: fieldsOf(title, '', slot.detail, file) };
|
|
140
|
+
const fields = testFields(test);
|
|
141
|
+
return { fields: title === test.title ? fields : [{ name: 'title', text: title }, ...fields], facets: testFacets(test) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function runDoc(slot: CorpusSlot, title: string, kind: string | null, file: string, explorer = false, test?: TestSummary): FindDoc {
|
|
145
|
+
const { fields, facets } = checkFields(slot, title, file, test);
|
|
134
146
|
if (explorer) {
|
|
135
|
-
return { id: `run:${slot.key}`, group: 'Runs', kind: 'run', title, subtitle: file, fields
|
|
147
|
+
return { id: `run:${slot.key}`, group: 'Runs', kind: 'run', title, subtitle: file, fields, status: slot.status, related: [], target: { to: '/', search: { test: slot.key } }, recording: null, ...(facets ? { facets } : {}) };
|
|
136
148
|
}
|
|
137
149
|
const recording = timelineTarget(kind, file, [slot]);
|
|
138
150
|
return {
|
|
@@ -141,12 +153,13 @@ function runDoc(slot: CorpusSlot, title: string, kind: string | null, file: stri
|
|
|
141
153
|
kind: 'run',
|
|
142
154
|
title,
|
|
143
155
|
subtitle: file,
|
|
144
|
-
fields
|
|
156
|
+
fields,
|
|
145
157
|
status: slot.status,
|
|
146
158
|
related: [],
|
|
147
159
|
// Enter opens the recording when this check has a timeline.
|
|
148
160
|
target: recording ?? (kind ? specTarget(kind, file, slot.id) : { to: '/' as const, search: {} }),
|
|
149
|
-
recording
|
|
161
|
+
recording,
|
|
162
|
+
...(facets ? { facets } : {})
|
|
150
163
|
};
|
|
151
164
|
}
|
|
152
165
|
|
|
@@ -231,7 +244,7 @@ export function buildCorpus(source: CorpusSource): FindDoc[] {
|
|
|
231
244
|
const id = `run:${slot.key}`;
|
|
232
245
|
if (seenRuns.has(id)) return;
|
|
233
246
|
seenRuns.add(id);
|
|
234
|
-
docs.push(runDoc(slot, title, kind, file, explorer));
|
|
247
|
+
docs.push(runDoc(slot, title, kind, file, explorer, source.tests?.get(slot.key)));
|
|
235
248
|
};
|
|
236
249
|
|
|
237
250
|
for (const file of source.files) {
|
|
@@ -326,7 +339,7 @@ export function buildCorpus(source: CorpusSource): FindDoc[] {
|
|
|
326
339
|
}
|
|
327
340
|
|
|
328
341
|
/** The view and the evidence the page already holds, in the shape `buildCorpus` reads. */
|
|
329
|
-
export function corpusFrom(view: AtlasView | null, files: readonly string[], titleOf: (file: string) => string, evidence: EvidenceView, texts: ReadonlyMap<string, string>): FindDoc[] {
|
|
342
|
+
export function corpusFrom(view: AtlasView | null, files: readonly string[], titleOf: (file: string) => string, evidence: EvidenceView, texts: ReadonlyMap<string, string>, tests?: ReadonlyMap<string, TestSummary>): FindDoc[] {
|
|
330
343
|
const slots: CorpusSlot[] = [];
|
|
331
344
|
const take = (slot: CheckSlot) => {
|
|
332
345
|
slots.push({
|
|
@@ -370,6 +383,7 @@ export function corpusFrom(view: AtlasView | null, files: readonly string[], tit
|
|
|
370
383
|
files: files.map((path) => ({ path, title: titleOf(path) })),
|
|
371
384
|
slots,
|
|
372
385
|
texts,
|
|
373
|
-
explorer: !files.some(isStatechartFile)
|
|
386
|
+
explorer: !files.some(isStatechartFile),
|
|
387
|
+
...(tests ? { tests } : {})
|
|
374
388
|
});
|
|
375
389
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Searching a list of documents
|
|
3
|
+
*
|
|
4
|
+
* What the test list, the runs list and the results of an opened run call to narrow themselves by a typed query. It is
|
|
5
|
+
* the finder's engine (`rank.ts`) applied to a whole list: every document that is eligible for the words, phrases and
|
|
6
|
+
* identifiers of the query, best first, with the reason it matched; meaning-based additions when an embedder is
|
|
7
|
+
* available. The structured filters (`status:`, `has:video`…) are applied by the caller with `passesFilters`, so a
|
|
8
|
+
* count can leave one of them out; nothing here limits or pages the result.
|
|
9
|
+
*/
|
|
10
|
+
import { useMemo } from 'react';
|
|
11
|
+
|
|
12
|
+
import { applySemantic } from './semantic';
|
|
13
|
+
import { indexCorpus, matchText, type FindDoc, type FindIndex, type Ranked } from './rank';
|
|
14
|
+
import { parseSearch, passesFilters, type ParsedSearch } from './query';
|
|
15
|
+
import { useSemantic, type SemanticStatus } from './semantic-search';
|
|
16
|
+
|
|
17
|
+
/** The semantic part of a search to add to the words, when there is one. */
|
|
18
|
+
export type SemanticInput = {
|
|
19
|
+
readonly queryVector: Float32Array | null;
|
|
20
|
+
readonly vectors: ReadonlyMap<string, Float32Array>;
|
|
21
|
+
/** The documents a paraphrase may add (those that pass the filters). */
|
|
22
|
+
readonly docs: readonly FindDoc[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every document eligible for the text of a search (words, quoted phrases, identifiers), best first, by document id; null
|
|
27
|
+
* when the search has no text. A document missing a distinctive word is not eligible. With a semantic input, documents
|
|
28
|
+
* close in meaning are added (not when the search asks for an exact phrase or identifier).
|
|
29
|
+
*/
|
|
30
|
+
export function searchList(index: FindIndex, parsed: ParsedSearch, semantic?: SemanticInput): Map<string, Ranked> | null {
|
|
31
|
+
const lexical = matchText(index, parsed, { snippets: false });
|
|
32
|
+
if (lexical === null) return null;
|
|
33
|
+
const plain: Ranked[] = lexical.map(({ order: _order, misses: _misses, ...hit }) => hit);
|
|
34
|
+
const literal = parsed.phrases.length + parsed.identifiers.length > 0;
|
|
35
|
+
const list = semantic?.queryVector && !literal ? applySemantic(semantic.docs, plain, semantic.queryVector, semantic.vectors, Number.POSITIVE_INFINITY) : plain;
|
|
36
|
+
return new Map(list.map((hit) => [hit.doc.id, hit]));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What {@link useDocSearch} reads out. */
|
|
40
|
+
export type DocSearch = {
|
|
41
|
+
readonly parsed: ParsedSearch;
|
|
42
|
+
/** Eligible documents by id, best first; null when the query has no text to match (it may still have filters). */
|
|
43
|
+
readonly hits: ReadonlyMap<string, Ranked> | null;
|
|
44
|
+
/** How the meaning-based part stands: idle when there is nothing to match by meaning. */
|
|
45
|
+
readonly status: SemanticStatus;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parses `query` and searches `docs` with it: lexical results at once, meaning-based additions when they arrive. The
|
|
50
|
+
* index is only built when the query has text.
|
|
51
|
+
*/
|
|
52
|
+
export function useDocSearch(docs: readonly FindDoc[], query: string | undefined): DocSearch {
|
|
53
|
+
const parsed = useMemo(() => parseSearch(query ?? ''), [query]);
|
|
54
|
+
const hasText = parsed.words.length > 0 || parsed.phrases.length > 0 || parsed.identifiers.length > 0;
|
|
55
|
+
const index = useMemo(() => (hasText ? indexCorpus(docs) : null), [docs, hasText]);
|
|
56
|
+
const lexical = useMemo(() => (index ? searchList(index, parsed) : null), [index, parsed]);
|
|
57
|
+
const candidates = useMemo(() => (hasText ? docs.filter((doc) => passesFilters(doc.facets, parsed.filters)) : []), [docs, parsed, hasText]);
|
|
58
|
+
const lexicalDocs = useMemo(() => (lexical ? [...lexical.values()].map((hit) => hit.doc) : []), [lexical]);
|
|
59
|
+
const wantsMeaning = hasText && parsed.words.length > 0 && parsed.phrases.length + parsed.identifiers.length === 0;
|
|
60
|
+
const semantic = useSemantic(wantsMeaning, parsed.words, candidates, lexicalDocs);
|
|
61
|
+
const hits = useMemo(
|
|
62
|
+
() => (index ? searchList(index, parsed, { queryVector: semantic.queryVector, vectors: semantic.vectors, docs: candidates }) : null),
|
|
63
|
+
[index, parsed, semantic.queryVector, semantic.vectors, candidates]
|
|
64
|
+
);
|
|
65
|
+
return { parsed, hits, status: wantsMeaning ? semantic.status : 'idle' };
|
|
66
|
+
}
|