@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
@@ -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
- function runDoc(slot: CorpusSlot, title: string, kind: string | null, file: string, explorer = false): FindDoc {
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: fieldsOf(title, '', slot.detail, file), status: slot.status, related: [], target: { to: '/', search: { test: slot.key } }, recording: null };
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: fieldsOf(title, '', slot.detail, file),
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
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * @module Search query language
3
+ *
4
+ * One reading of a search box, shared by the finder, the test list, the runs list and the results of one run, so a
5
+ * sentence means the same thing wherever it is typed:
6
+ *
7
+ * - plain words are matched by meaning, spelling and word form (see `rank.ts`), and every distinctive word must be found;
8
+ * - `"a quoted phrase"` and identifiers that contain `/`, `_` or `.` (`checkout/save.feature`) are matched literally and
9
+ * are reported as exact matches;
10
+ * - `field:value` narrows by structure instead of matching words: `status:failed`, `env:ios` (or `platform:`,
11
+ * `environment:`, `device:`), `producer:`, `domain:`, `spec:`, `tag:`, `branch:` and `has:video`. A value with spaces is
12
+ * quoted (`domain:"user account"`). Several values of one field (`status:failed,flaky`, or the field twice) match any of
13
+ * them; different fields must all hold.
14
+ *
15
+ * Nothing here is ever dropped silently: a word it cannot use as a filter (`colour:red`, `status:great`, `has:smell`) is
16
+ * returned as an {@link SearchIssue} for the interface to show, and it filters nothing.
17
+ */
18
+
19
+ /** The filters a search can carry. Each list matches any of its values; the lists combine with AND. */
20
+ export type SearchFilters = {
21
+ readonly status: readonly string[];
22
+ readonly env: readonly string[];
23
+ readonly producer: readonly string[];
24
+ readonly domain: readonly string[];
25
+ readonly spec: readonly string[];
26
+ readonly tag: readonly string[];
27
+ readonly branch: readonly string[];
28
+ /** `has:video`: only what has a playable video recording. */
29
+ readonly video: boolean;
30
+ };
31
+
32
+ /** One of the filters a search can carry, by the name a person types before the colon. */
33
+ export type FilterKey = Exclude<keyof SearchFilters, 'video'> | 'video';
34
+
35
+ /** One filter as typed, for showing as a removable chip. */
36
+ export type FilterToken = {
37
+ readonly key: FilterKey;
38
+ /** The value (`failed`), or `video` for `has:video`. */
39
+ readonly value: string;
40
+ /** The text to remove from the query to remove this filter. */
41
+ readonly text: string;
42
+ };
43
+
44
+ /** Something in the query that could not be used, and what to type instead. */
45
+ export type SearchIssue = {
46
+ /** The text as typed. */
47
+ readonly token: string;
48
+ /** A sentence for the person, naming what is supported. */
49
+ readonly message: string;
50
+ };
51
+
52
+ /** A query read into what it asks for. */
53
+ export type ParsedSearch = {
54
+ /** The query as typed. */
55
+ readonly raw: string;
56
+ /** The loose words to match by meaning and spelling (quoted phrases, identifiers and filters removed). */
57
+ readonly words: string;
58
+ /** Quoted phrases, each of which must appear literally. */
59
+ readonly phrases: readonly string[];
60
+ /** Identifier-like words (`lists/add`, `save_answer`, `a.feature`), each of which must appear literally. */
61
+ readonly identifiers: readonly string[];
62
+ readonly filters: SearchFilters;
63
+ /** The filters in the order typed. */
64
+ readonly tokens: readonly FilterToken[];
65
+ readonly issues: readonly SearchIssue[];
66
+ };
67
+
68
+ /** The statuses a `status:` filter accepts. */
69
+ export const STATUS_VALUES = ['passed', 'failed', 'flaky', 'not-run', 'running'] as const;
70
+
71
+ /** The `has:` values a search accepts. */
72
+ export const HAS_VALUES = ['video'] as const;
73
+
74
+ const FIELD_ALIASES: Readonly<Record<string, Exclude<FilterKey, 'video'>>> = {
75
+ status: 'status',
76
+ env: 'env',
77
+ environment: 'env',
78
+ platform: 'env',
79
+ device: 'env',
80
+ producer: 'producer',
81
+ domain: 'domain',
82
+ spec: 'spec',
83
+ tag: 'tag',
84
+ branch: 'branch'
85
+ };
86
+
87
+ /** The operator names a search understands, for guidance. */
88
+ export const OPERATOR_NAMES: readonly string[] = [...Object.keys(FIELD_ALIASES), 'has'];
89
+
90
+ /** A sentence that lists what can be typed, shown with every issue. */
91
+ export const SYNTAX_HELP = 'Filters: status:failed, env:ios, producer:, domain:, spec:, tag:, branch: and has:video. Quote phrases: "saved answer".';
92
+
93
+ /** A search with nothing in it. */
94
+ export const NO_FILTERS: SearchFilters = { status: [], env: [], producer: [], domain: [], spec: [], tag: [], branch: [], video: false };
95
+
96
+ const OPERATOR = /^([A-Za-z][A-Za-z-]*):(.*)$/;
97
+ const IDENTIFIER = /[A-Za-z0-9][/_.][A-Za-z0-9]/;
98
+
99
+ /** The pieces of a query: quoted strings (optionally after `name:`) and runs of non-space characters. */
100
+ function pieces(query: string): { readonly text: string; readonly quoted: boolean; readonly name?: string }[] {
101
+ const out: { text: string; quoted: boolean; name?: string }[] = [];
102
+ const pattern = /(?:([A-Za-z][A-Za-z-]*):)?"([^"]*)"|(\S+)/g;
103
+ for (const match of query.matchAll(pattern)) {
104
+ if (match[3] !== undefined) out.push({ text: match[3], quoted: false });
105
+ else out.push({ text: match[2] ?? '', quoted: true, ...(match[1] ? { name: match[1] } : {}) });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /** Reads a query: see the module comment. Never throws; whatever it cannot use is in `issues`. */
111
+ export function parseSearch(query: string): ParsedSearch {
112
+ const words: string[] = [];
113
+ const phrases: string[] = [];
114
+ const identifiers: string[] = [];
115
+ const issues: SearchIssue[] = [];
116
+ const tokens: FilterToken[] = [];
117
+ const lists: Record<Exclude<FilterKey, 'video'>, string[]> = { status: [], env: [], producer: [], domain: [], spec: [], tag: [], branch: [] };
118
+ let video = false;
119
+ const issue = (token: string, message: string) => issues.push({ token, message: `${message} ${SYNTAX_HELP}` });
120
+
121
+ for (const piece of pieces(query)) {
122
+ const operator = piece.name !== undefined ? ([piece.name, piece.text] as const) : !piece.quoted ? OPERATOR.exec(piece.text)?.slice(1, 3) : undefined;
123
+ if (piece.quoted && piece.name === undefined) {
124
+ const phrase = piece.text.trim().toLowerCase();
125
+ if (phrase) phrases.push(phrase);
126
+ else issue('""', 'is an empty phrase, which matches nothing.');
127
+ continue;
128
+ }
129
+ if (operator) {
130
+ const name = operator[0].toLowerCase();
131
+ const original = piece.quoted ? `${operator[0]}:"${operator[1]}"` : piece.text;
132
+ const rawValues = piece.quoted ? [operator[1]!] : operator[1]!.split(',');
133
+ const values = rawValues.map((value) => value.trim().replace(/^"|"$/g, '')).filter(Boolean);
134
+ if (name === 'has') {
135
+ if (!values.length) issue(original, 'needs a value after the colon (has:video).');
136
+ for (const value of values) {
137
+ if (value.toLowerCase() === 'video') {
138
+ video = true;
139
+ tokens.push({ key: 'video', value: 'video', text: original });
140
+ } else issue(original, 'is not supported (only has:video).');
141
+ }
142
+ continue;
143
+ }
144
+ const key = FIELD_ALIASES[name];
145
+ if (!key && !piece.quoted && !values.length) {
146
+ // Prose such as "Done:" is not a filter.
147
+ words.push(piece.text.replace(/:$/, ''));
148
+ continue;
149
+ }
150
+ if (!key) {
151
+ issue(original, 'is not a filter.');
152
+ continue;
153
+ }
154
+ if (!values.length) {
155
+ issue(original, 'needs a value after the colon.');
156
+ continue;
157
+ }
158
+ for (const raw of values) {
159
+ const value = key === 'tag' ? raw.replace(/^#/, '').toLowerCase() : raw.toLowerCase();
160
+ if (key === 'status' && !(STATUS_VALUES as readonly string[]).includes(value)) {
161
+ issue(original, `is not a status (use ${STATUS_VALUES.join(', ')}).`);
162
+ continue;
163
+ }
164
+ if (!lists[key].includes(value)) lists[key].push(value);
165
+ tokens.push({ key, value, text: original });
166
+ }
167
+ continue;
168
+ }
169
+ if (piece.text.startsWith('"')) {
170
+ issue(piece.text, 'has a quote that is never closed, so its words are matched as ordinary words.');
171
+ words.push(piece.text.replace(/"/g, ' '));
172
+ continue;
173
+ }
174
+ const bare = piece.text.replace(/^[("']+|[)"',;!?]+$/g, '').replace(/\.+$/, '');
175
+ if (IDENTIFIER.test(bare)) identifiers.push(bare.toLowerCase());
176
+ else words.push(piece.text);
177
+ }
178
+ return {
179
+ raw: query,
180
+ words: words.join(' ').trim(),
181
+ phrases,
182
+ identifiers,
183
+ filters: { ...lists, video },
184
+ tokens,
185
+ issues
186
+ };
187
+ }
188
+
189
+ /** True when the query narrows by structure (any filter, phrase or identifier) rather than only by loose words. */
190
+ export function isStructured(parsed: ParsedSearch): boolean {
191
+ return parsed.tokens.length > 0 || parsed.phrases.length > 0 || parsed.identifiers.length > 0;
192
+ }
193
+
194
+ /** True when the query has nothing to match at all (no words, phrases, identifiers or filters). */
195
+ export function isBlank(parsed: ParsedSearch): boolean {
196
+ return parsed.words.length === 0 && !isStructured(parsed);
197
+ }
198
+
199
+ /** The query with one filter's text removed, other text left as typed. */
200
+ export function withoutToken(query: string, token: FilterToken): string {
201
+ const at = query.indexOf(token.text);
202
+ if (at < 0) return query;
203
+ return `${query.slice(0, at)}${query.slice(at + token.text.length)}`.replace(/\s{2,}/g, ' ').trim();
204
+ }
205
+
206
+ /**
207
+ * What a document says about itself, to be filtered by structure. Lowercased on use. A document without facets (a
208
+ * flow, a spec) can satisfy no filter.
209
+ */
210
+ export type DocFacets = {
211
+ readonly status: readonly string[];
212
+ readonly env: readonly string[];
213
+ readonly producer: readonly string[];
214
+ readonly domain: readonly string[];
215
+ readonly spec: readonly string[];
216
+ readonly tag: readonly string[];
217
+ readonly branch: readonly string[];
218
+ readonly video: boolean;
219
+ };
220
+
221
+ /** The filter groups a count can leave out (status and environment buttons count what the other filters leave). */
222
+ export type FilterOmit = ReadonlySet<FilterKey>;
223
+
224
+ /**
225
+ * Whether facets satisfy the filters: every filter that is set must match at least one of the document's values.
226
+ * `omit` skips some filters (to count what a button would add).
227
+ */
228
+ export function passesFilters(facets: DocFacets | undefined, filters: SearchFilters, omit?: FilterOmit): boolean {
229
+ const wanted = (key: FilterKey) => !omit?.has(key);
230
+ const lists: (Exclude<FilterKey, 'video'>)[] = ['status', 'env', 'producer', 'domain', 'spec', 'tag', 'branch'];
231
+ const active = lists.filter((key) => filters[key].length > 0 && wanted(key));
232
+ const needsVideo = filters.video && wanted('video');
233
+ if (!active.length && !needsVideo) return true;
234
+ if (!facets) return false;
235
+ if (needsVideo && !facets.video) return false;
236
+ for (const key of active) {
237
+ const have = facets[key].map((value) => value.toLowerCase());
238
+ const ok = filters[key].some((value) => (key === 'spec' ? have.some((path) => path === value || path.endsWith(`/${value}`) || path.includes(value)) : have.includes(value)));
239
+ if (!ok) return false;
240
+ }
241
+ return true;
242
+ }
243
+
244
+ /** True when a search carries a filter (as opposed to words), counting only those not in `omit`. */
245
+ export function hasFilters(filters: SearchFilters): boolean {
246
+ return filters.video || filters.status.length + filters.env.length + filters.producer.length + filters.domain.length + filters.spec.length + filters.tag.length + filters.branch.length > 0;
247
+ }