@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.
Files changed (27) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/docs/ui.md +32 -2
  3. package/llms-full.txt +32 -2
  4. package/package.json +1 -1
  5. package/packages/visualizer/README.md +28 -0
  6. package/packages/visualizer/src/evidence/artifacts.tsx +17 -8
  7. package/packages/visualizer/src/evidence/behavior-panel.tsx +291 -0
  8. package/packages/visualizer/src/evidence/behavior.test.ts +99 -0
  9. package/packages/visualizer/src/evidence/behavior.ts +268 -0
  10. package/packages/visualizer/src/evidence/check-detail.tsx +4 -0
  11. package/packages/visualizer/src/evidence/feature-page.tsx +82 -36
  12. package/packages/visualizer/src/evidence/gherkin.ts +31 -1
  13. package/packages/visualizer/src/evidence/spec-index.tsx +48 -15
  14. package/packages/visualizer/src/explorer/Explorer.tsx +105 -25
  15. package/packages/visualizer/src/explorer/RunsView.tsx +173 -35
  16. package/packages/visualizer/src/explorer/catalog.tsx +9 -5
  17. package/packages/visualizer/src/explorer/search-notes.tsx +74 -0
  18. package/packages/visualizer/src/explorer/search.ts +16 -6
  19. package/packages/visualizer/src/find/Finder.tsx +127 -81
  20. package/packages/visualizer/src/find/behaviors.ts +205 -0
  21. package/packages/visualizer/src/find/corpus.ts +21 -7
  22. package/packages/visualizer/src/find/doc-search.ts +66 -0
  23. package/packages/visualizer/src/find/query.ts +247 -0
  24. package/packages/visualizer/src/find/rank.ts +195 -47
  25. package/packages/visualizer/src/find/search.test.ts +322 -0
  26. package/packages/visualizer/src/find/semantic-search.ts +153 -0
  27. 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, type TestSort } from '@crvouga/atlas/reports/test-catalog';
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(TEST_SORTS).optional().catch(undefined),
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?: TestSort;
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 catalog query an address asks for, without the run (it is matched separately) and without paging. */
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 ?? 'spec'
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 { FIND_GROUPS, indexCorpus, notationOf, rankIndex, splitQuery, type FindDoc, type FindKind, type FindTarget } from './rank';
15
- import { applySemantic, createWorkerEmbedder, embeddingsAvailable, embedText, textHash, type Embedder } from './semantic';
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
- ? `${total - read} of ${total} specs are still being read`
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 lexical = useMemo(() => rankIndex(index, query), [index, query]);
147
- const [embedded, setEmbedded] = useState<{
148
- readonly query: string;
149
- readonly vector: Float32Array;
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(query).notations;
155
- if (notations.size === 0) return docs;
156
- return docs.filter((doc) => notations.has(notationOf(doc)));
157
- }, [docs, query]);
158
- const shown = useMemo(
159
- () => (queryVector ? applySemantic(searchable, lexical, queryVector, vectors) : lexical),
160
- [queryVector, searchable, lexical, vectors]
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
- useEffect(() => {
167
- if (open) store.setState((state) => ({ ...state, selected: 0 }));
168
- }, [open, query, store]);
169
- const embedder = useRef<Embedder | null>(null);
170
- const embedFailed = useRef(false);
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={<Badge color="gray">{KIND_WORD[hit.doc.kind]}</Badge>}
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
- <Text size="xs" c="dimmed">
326
- {unindexed ? `Searching what is read so far: ${unindexed}` : failed > 0 ? `${plural(failed, 'spec', 'specs')} could not be read and ${failed === 1 ? 'is' : 'are'} not searched` : '↑↓ move'}
327
- </Text>
328
- <Text size="xs" c="dimmed">
329
- ↵ open · esc close
330
- </Text>
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
+ }