@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,5 +1,5 @@
|
|
|
1
1
|
import { IconAlertTriangle, IconArrowLeft, IconVideo } from '@tabler/icons-react';
|
|
2
|
-
import { Alert, Anchor, Box, Button, Code, Flex, Group, List, Stack, Table, Text, TextInput, Title, VisuallyHidden } from '@mantine/core';
|
|
2
|
+
import { Alert, Anchor, Badge, Box, Button, Code, Flex, Group, List, NativeSelect, Stack, Table, Text, TextInput, Title, VisuallyHidden } from '@mantine/core';
|
|
3
3
|
/**
|
|
4
4
|
* @module Runs view
|
|
5
5
|
*
|
|
@@ -16,16 +16,22 @@ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
|
16
16
|
import type { FilterName } from '@crvouga/atlas/reports/artifact-query';
|
|
17
17
|
import { formatMs, type RunResult, type RunSummary } from '@crvouga/atlas/reports/test-catalog';
|
|
18
18
|
import { Link } from '@tanstack/react-router';
|
|
19
|
-
import { useMemo, useState } from 'react';
|
|
19
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
20
|
+
import { behaviorDocs, behaviorId, describeHit, forRuns, resultDoc, runDoc } from '../find/behaviors';
|
|
21
|
+
import { useDocSearch } from '../find/doc-search';
|
|
22
|
+
import { hasFilters, passesFilters, type ParsedSearch, type SearchFilters } from '../find/query';
|
|
23
|
+
import type { FindDoc, Ranked } from '../find/rank';
|
|
20
24
|
import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
|
|
21
25
|
import { Meter, StatusMark } from '../evidence/marks';
|
|
22
|
-
import { useRunDetail, useRunList } from './catalog';
|
|
26
|
+
import { useRunDetail, useRunList, useTestCatalog } from './catalog';
|
|
23
27
|
import { RunArtifacts } from './RunArtifacts';
|
|
24
28
|
import { FilterBar } from './filters';
|
|
29
|
+
import { SearchNotes, TypedFilters } from './search-notes';
|
|
25
30
|
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
26
31
|
import { runName } from './parts';
|
|
27
32
|
import { filtered, filterOf, NO_FILTERS } from './search';
|
|
28
33
|
const RUN_ROWS = 50;
|
|
34
|
+
const NO_RESULTS: readonly RunResult[] = [];
|
|
29
35
|
const RESULT_ROWS = 200;
|
|
30
36
|
/** The Runs tab: the run list, or the run opened in it. */
|
|
31
37
|
export function RunsView() {
|
|
@@ -151,6 +157,56 @@ function HasVideo({ count, pressed, noun, onToggle }: { count: number; pressed:
|
|
|
151
157
|
</Button>
|
|
152
158
|
);
|
|
153
159
|
}
|
|
160
|
+
/** The search box of the Runs tab and of an opened run: one query in the address, so a link reproduces it. */
|
|
161
|
+
function SearchBox({ label, placeholder, parsed, onSearch }: { label: string; placeholder: string; parsed: ParsedSearch; onSearch: (value: string) => void }) {
|
|
162
|
+
const search = useExplorerSearch();
|
|
163
|
+
const [draft, setDraft] = useState(search.q ?? '');
|
|
164
|
+
// The address may change from elsewhere (a link, back, a chip): follow it.
|
|
165
|
+
useEffect(() => setDraft((current) => (current.trim() === (search.q ?? '').trim() ? current : (search.q ?? ''))), [search.q]);
|
|
166
|
+
return (
|
|
167
|
+
<Flex gap="xs" align="center" wrap="wrap">
|
|
168
|
+
<TextInput
|
|
169
|
+
type="search"
|
|
170
|
+
aria-label={label}
|
|
171
|
+
placeholder={placeholder}
|
|
172
|
+
value={draft}
|
|
173
|
+
miw={{ base: '100%', sm: '20rem' }}
|
|
174
|
+
onChange={(event) => {
|
|
175
|
+
setDraft(event.target.value);
|
|
176
|
+
onSearch(event.target.value.trim());
|
|
177
|
+
}}
|
|
178
|
+
/>
|
|
179
|
+
<TypedFilters
|
|
180
|
+
parsed={parsed}
|
|
181
|
+
query={draft}
|
|
182
|
+
onChange={(next) => {
|
|
183
|
+
setDraft(next);
|
|
184
|
+
onSearch(next);
|
|
185
|
+
}}
|
|
186
|
+
/>
|
|
187
|
+
</Flex>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
/** Why a result is in the list, beside it: exact, or the fields the words were found in. */
|
|
191
|
+
function WhyMatched({ hit }: { hit: Ranked | undefined }) {
|
|
192
|
+
const why = hit ? describeHit(hit) : '';
|
|
193
|
+
if (!hit || !why) return null;
|
|
194
|
+
return (
|
|
195
|
+
<Text size="xs" c="dimmed" data-why>
|
|
196
|
+
{hit.exact && (
|
|
197
|
+
<Badge component="span" color="blue" mr="xs">
|
|
198
|
+
Exact
|
|
199
|
+
</Badge>
|
|
200
|
+
)}
|
|
201
|
+
{why}
|
|
202
|
+
</Text>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const RUN_SORTS = [
|
|
206
|
+
{ id: 'recent', label: 'Newest' },
|
|
207
|
+
{ id: 'failures', label: 'Most failures' },
|
|
208
|
+
{ id: 'relevance', label: 'Best match' }
|
|
209
|
+
] as const;
|
|
154
210
|
/** What a filter is called when a page says a server could not apply it. */
|
|
155
211
|
const FILTER_WORDS: Partial<Record<FilterName, string>> = { artifactTag: 'tag', type: 'media type', label: 'label', domain: 'domain', spec: 'spec' };
|
|
156
212
|
/** Says which filters the list is not narrowed by, because the server that answered ignored them. */
|
|
@@ -167,11 +223,32 @@ function RunTable() {
|
|
|
167
223
|
const search = useExplorerSearch();
|
|
168
224
|
const go = useExplorerNavigate();
|
|
169
225
|
const video = search.video === true;
|
|
170
|
-
const
|
|
226
|
+
const searching = !!search.q;
|
|
227
|
+
// The structured filters of the address narrow the runs on the server; the words are matched here by the shared search,
|
|
228
|
+
// over every run, so the Runs tab judges "savde answer" as the finder and the test list do.
|
|
229
|
+
const filter = useMemo(() => {
|
|
230
|
+
const { q: _words, ...rest } = filterOf(search);
|
|
231
|
+
return rest;
|
|
232
|
+
}, [search]);
|
|
171
233
|
const narrow = filtered(search);
|
|
172
|
-
const runs = useRunList(true, filter);
|
|
234
|
+
const runs = useRunList(true, filter, searching);
|
|
173
235
|
const now = useNow();
|
|
174
236
|
const [shown, setShown] = useState(RUN_ROWS);
|
|
237
|
+
const docs = useMemo(() => (runs.data?.runs ?? []).map((run) => runDoc(run, runLabel(run))), [runs.data]);
|
|
238
|
+
const found = useDocSearch(docs, search.q);
|
|
239
|
+
const parsed = useMemo(() => forRuns(found.parsed), [found.parsed]);
|
|
240
|
+
const sortValue = search.sort === 'failures' ? 'failures' : (search.sort === 'relevance' || !search.sort) && found.hits ? 'relevance' : 'recent';
|
|
241
|
+
const visible = useMemo(() => {
|
|
242
|
+
const runsOf = runs.data?.runs ?? [];
|
|
243
|
+
const order = found.hits ? new Map([...found.hits.keys()].map((id, at) => [id, at] as const)) : null;
|
|
244
|
+
const kept = runsOf.filter((run, at) => {
|
|
245
|
+
const doc = docs[at]!;
|
|
246
|
+
return (!found.hits || found.hits.has(doc.id)) && passesFilters(doc.facets, parsed.filters);
|
|
247
|
+
});
|
|
248
|
+
if (sortValue === 'failures') return [...kept].sort((a, b) => b.counts.failed - a.counts.failed || b.endedAtMs - a.endedAtMs);
|
|
249
|
+
if (sortValue === 'relevance' && order) return [...kept].sort((a, b) => (order.get(`runs:${a.key}`) ?? 0) - (order.get(`runs:${b.key}`) ?? 0));
|
|
250
|
+
return kept;
|
|
251
|
+
}, [runs.data, docs, found.hits, parsed.filters, sortValue]);
|
|
175
252
|
if (runs.isPending)
|
|
176
253
|
return (
|
|
177
254
|
<Text role="status" component="div" c="dimmed">
|
|
@@ -184,24 +261,60 @@ function RunTable() {
|
|
|
184
261
|
The runs could not be read. {runs.error.message}
|
|
185
262
|
</Alert>
|
|
186
263
|
);
|
|
187
|
-
const
|
|
264
|
+
const everything = runs.data.runs;
|
|
265
|
+
const list = visible;
|
|
188
266
|
const withVideo = runs.data.withVideo;
|
|
189
267
|
const noun = narrow ? 'matching' : '';
|
|
268
|
+
const hasVideoNow = video || parsed.filters.video;
|
|
190
269
|
return (
|
|
191
270
|
<Box aria-label="Runs" component="section" mb="lg">
|
|
192
|
-
{(narrow ||
|
|
271
|
+
{(narrow || everything.length > 0) && (
|
|
272
|
+
<FilterBar
|
|
273
|
+
facets={runs.data.facets}
|
|
274
|
+
search={search}
|
|
275
|
+
tab="runs"
|
|
276
|
+
extra={
|
|
277
|
+
withVideo !== undefined || video ? (
|
|
278
|
+
<HasVideo count={withVideo ?? list.length} pressed={hasVideoNow} noun={['run', 'runs']} onToggle={() => go({ video: video ? undefined : true })} />
|
|
279
|
+
) : undefined
|
|
280
|
+
}
|
|
281
|
+
/>
|
|
282
|
+
)}
|
|
193
283
|
<NotApplied names={runs.data.notApplied} noun="runs" />
|
|
194
|
-
{
|
|
284
|
+
{(parsed.tokens.length > 0 || parsed.issues.length > 0 || found.status !== 'idle') && (
|
|
285
|
+
<>
|
|
286
|
+
<Flex gap="xs" align="center" wrap="wrap">
|
|
287
|
+
<TypedFilters parsed={parsed} query={search.q ?? ''} onChange={(next) => go({ q: next || undefined }, { replace: true })} />
|
|
288
|
+
</Flex>
|
|
289
|
+
<SearchNotes issues={parsed.issues} semantic={found.status} />
|
|
290
|
+
</>
|
|
291
|
+
)}
|
|
292
|
+
{!everything.length && !narrow && (
|
|
195
293
|
<Text component="div" c="dimmed">
|
|
196
294
|
No runs recorded yet.
|
|
197
295
|
</Text>
|
|
198
296
|
)}
|
|
199
|
-
{
|
|
200
|
-
<
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
297
|
+
{everything.length > 0 && (
|
|
298
|
+
<Flex gap="xs" align="center" wrap="wrap" role="status">
|
|
299
|
+
<Text span inherit>
|
|
300
|
+
{runs.data.total > everything.length
|
|
301
|
+
? `The newest ${everything.length} of ${plural(runs.data.total, 'run', 'runs')} ${noun}`.trim()
|
|
302
|
+
: searching || list.length !== everything.length
|
|
303
|
+
? `${list.length.toLocaleString()} of ${plural(everything.length, 'run', 'runs')} ${noun}`.trim()
|
|
304
|
+
: `${plural(list.length, 'run', 'runs')} ${noun}`.trim()}
|
|
305
|
+
</Text>
|
|
306
|
+
<NativeSelect
|
|
307
|
+
aria-label="Sort runs"
|
|
308
|
+
value={sortValue}
|
|
309
|
+
onChange={(event) => go({ sort: event.target.value === (found.hits ? 'relevance' : 'recent') ? undefined : (event.target.value as 'recent' | 'failures' | 'relevance') })}
|
|
310
|
+
>
|
|
311
|
+
{RUN_SORTS.filter((option) => option.id !== 'relevance' || found.hits !== null).map((option) => (
|
|
312
|
+
<option key={option.id} value={option.id}>
|
|
313
|
+
{option.label}
|
|
314
|
+
</option>
|
|
315
|
+
))}
|
|
316
|
+
</NativeSelect>
|
|
317
|
+
</Flex>
|
|
205
318
|
)}
|
|
206
319
|
{!list.length && narrow && (
|
|
207
320
|
<Stack gap="xs" align="flex-start" role="status" data-no-match>
|
|
@@ -249,6 +362,7 @@ function RunTable() {
|
|
|
249
362
|
{run.title}
|
|
250
363
|
</Text>
|
|
251
364
|
)}
|
|
365
|
+
<WhyMatched hit={found.hits?.get(`runs:${run.key}`)} />
|
|
252
366
|
</Table.Td>
|
|
253
367
|
<Table.Td title={run.branch ?? ''} c="dimmed">
|
|
254
368
|
{run.branch ?? '—'}
|
|
@@ -322,20 +436,39 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
322
436
|
const detail = useRunDetail(runKey);
|
|
323
437
|
const now = useNow();
|
|
324
438
|
const [status, setStatus] = useState('');
|
|
325
|
-
const [words, setWords] = useState('');
|
|
326
439
|
const [shown, setShown] = useState(RESULT_ROWS);
|
|
327
|
-
const
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
);
|
|
338
|
-
}, [
|
|
440
|
+
const catalog = useTestCatalog();
|
|
441
|
+
const model = catalog.model;
|
|
442
|
+
const results = detail.data?.results ?? NO_RESULTS;
|
|
443
|
+
// The words are judged against every behavior of the report, as in the test list, so the same words make the same
|
|
444
|
+
// behaviors eligible; the run's results are then the ones of those behaviors it recorded.
|
|
445
|
+
const docs = useMemo<readonly FindDoc[]>(() => {
|
|
446
|
+
if (!search.q) return [];
|
|
447
|
+
const base = model ? behaviorDocs(model.tests) : new Map<string, FindDoc>();
|
|
448
|
+
const extra = new Map<string, FindDoc>();
|
|
449
|
+
for (const result of results) if (!base.has(result.key) && !extra.has(result.key)) extra.set(result.key, resultDoc(result, undefined));
|
|
450
|
+
return [...base.values(), ...extra.values()];
|
|
451
|
+
}, [search.q, model, results]);
|
|
452
|
+
const found = useDocSearch(docs, search.q);
|
|
453
|
+
const resultDocs = useMemo(() => results.map((result) => resultDoc(result, model?.byKey.get(result.key))), [results, model]);
|
|
454
|
+
const narrowedResults = useMemo(
|
|
455
|
+
() =>
|
|
456
|
+
results.filter(
|
|
457
|
+
(result, at) =>
|
|
458
|
+
(!found.hits || found.hits.has(behaviorId(result.key))) &&
|
|
459
|
+
(!video || (videoCounts(result.videos)?.recordings ?? 0) > 0) &&
|
|
460
|
+
passesFilters(resultDocs[at]!.facets, { ...found.parsed.filters, status: [] })
|
|
461
|
+
),
|
|
462
|
+
[results, resultDocs, found, video]
|
|
463
|
+
);
|
|
464
|
+
const statusFilters = found.parsed.filters.status;
|
|
465
|
+
const filtered = useMemo(
|
|
466
|
+
() =>
|
|
467
|
+
narrowedResults.filter(
|
|
468
|
+
(result) => (!status || result.status === status) && (!statusFilters.length || statusFilters.some((wanted) => (wanted === 'flaky' ? model?.byKey.get(result.key)?.stats.flaky === true : result.status === wanted)))
|
|
469
|
+
),
|
|
470
|
+
[narrowedResults, status, statusFilters, model]
|
|
471
|
+
);
|
|
339
472
|
const bySpec = useMemo(() => {
|
|
340
473
|
const groups = new Map<string, RunResult[]>();
|
|
341
474
|
for (const result of filtered.slice(0, shown)) {
|
|
@@ -458,14 +591,19 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
458
591
|
)}
|
|
459
592
|
</Flex>
|
|
460
593
|
<Flex gap="xs" align="center" wrap="wrap">
|
|
461
|
-
<
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
onChange={(event) => setWords(event.target.value)}
|
|
594
|
+
<SearchBox
|
|
595
|
+
label="Search this run"
|
|
596
|
+
placeholder='Search this run… or filter: status:failed has:video "exact phrase"'
|
|
597
|
+
parsed={found.parsed}
|
|
598
|
+
onSearch={(value) => go({ q: value || undefined }, { replace: true })}
|
|
467
599
|
/>
|
|
468
600
|
</Flex>
|
|
601
|
+
<SearchNotes issues={found.parsed.issues} semantic={found.status} />
|
|
602
|
+
{(search.q || found.parsed.tokens.length > 0) && (
|
|
603
|
+
<Text role="status" size="sm" c="dimmed">
|
|
604
|
+
{filtered.length.toLocaleString()} of {plural(results.length, 'result', 'results')} match
|
|
605
|
+
</Text>
|
|
606
|
+
)}
|
|
469
607
|
<Flex role="group" aria-label="Result status" gap="xs" align="center" wrap="wrap">
|
|
470
608
|
{RESULT_FILTERS.map((filter) => (
|
|
471
609
|
<Button
|
|
@@ -478,7 +616,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
478
616
|
>
|
|
479
617
|
{filter.label}{' '}
|
|
480
618
|
<Text span inherit>
|
|
481
|
-
{filter.id ?
|
|
619
|
+
{filter.id ? narrowedResults.filter((result) => result.status === filter.id).length : narrowedResults.length}
|
|
482
620
|
</Text>
|
|
483
621
|
</Button>
|
|
484
622
|
))}
|
|
@@ -487,7 +625,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
487
625
|
<Flex gap="xs" align="center" wrap="wrap">
|
|
488
626
|
<HasVideo
|
|
489
627
|
count={results.filter((result) => (videoCounts(result.videos)?.recordings ?? 0) > 0).length}
|
|
490
|
-
pressed={video}
|
|
628
|
+
pressed={video || found.parsed.filters.video}
|
|
491
629
|
noun={['result', 'results']}
|
|
492
630
|
onToggle={() => go({ video: video ? undefined : true })}
|
|
493
631
|
/>
|
|
@@ -52,6 +52,8 @@ import { useEvidence } from '../evidence/evidence-context';
|
|
|
52
52
|
|
|
53
53
|
/** How many runs the Runs tab lists. */
|
|
54
54
|
export const RUNS_SHOWN = 500;
|
|
55
|
+
/** How many runs are read to search them all (a search must see every run, not only the newest). */
|
|
56
|
+
export const RUNS_SEARCHED = 20_000;
|
|
55
57
|
/** How many recorded results the Artifacts tab lists. */
|
|
56
58
|
export const ARTIFACTS_SHOWN = 200;
|
|
57
59
|
/** The server's catalog is read again at most this often while results keep arriving. */
|
|
@@ -284,22 +286,24 @@ export function runsAnswer(list: RunList, filter: ArtifactFilter): RunsAnswer {
|
|
|
284
286
|
|
|
285
287
|
/**
|
|
286
288
|
* CI runs, newest first, narrowed by a query (see `artifact-query.ts`). The server filters every run before it pages
|
|
287
|
-
* them, so counts are the whole dataset's; a server that does not know a filter is handled by {@link runsAnswer}.
|
|
289
|
+
* them, so counts are the whole dataset's; a server that does not know a filter is handled by {@link runsAnswer}. With
|
|
290
|
+
* `all` every run is read (up to {@link RUNS_SEARCHED}), because a typed search must match the whole history rather than
|
|
291
|
+
* the newest page of it.
|
|
288
292
|
*/
|
|
289
|
-
export function useRunList(enabled = true, filter: ArtifactFilter = {}) {
|
|
293
|
+
export function useRunList(enabled = true, filter: ArtifactFilter = {}, all = false) {
|
|
290
294
|
const catalog = useTestCatalog();
|
|
291
295
|
const query = forRuns(filter);
|
|
292
296
|
const key = filterParams(query).toString();
|
|
293
297
|
return useQuery({
|
|
294
|
-
queryKey: ['test-runs', catalog.version, key],
|
|
298
|
+
queryKey: ['test-runs', catalog.version, key, all],
|
|
295
299
|
queryFn: async ({ signal }): Promise<RunsAnswer> => {
|
|
296
300
|
if (catalog.source === 'server') {
|
|
297
301
|
const params = filterParams(query);
|
|
298
|
-
params.set('limit', String(RUNS_SHOWN));
|
|
302
|
+
params.set('limit', String(all ? RUNS_SEARCHED : RUNS_SHOWN));
|
|
299
303
|
return runsAnswer(await reportJson<RunList>(`/runs?${params}`, signal), query);
|
|
300
304
|
}
|
|
301
305
|
const history = catalog.model?.history;
|
|
302
|
-
const list = history ? runList(history, catalog.version, RUNS_SHOWN, query) : { schemaVersion: 1 as const, version: catalog.version, total: 0, runs: [] };
|
|
306
|
+
const list = history ? runList(history, catalog.version, all ? RUNS_SEARCHED : RUNS_SHOWN, query) : { schemaVersion: 1 as const, version: catalog.version, total: 0, runs: [] };
|
|
303
307
|
return runsAnswer(list, query);
|
|
304
308
|
},
|
|
305
309
|
enabled: enabled && catalog.model !== null,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Alert, Button, Stack, Text } from '@mantine/core';
|
|
2
|
+
import { IconX } from '@tabler/icons-react';
|
|
3
|
+
/**
|
|
4
|
+
* @module Search guidance
|
|
5
|
+
*
|
|
6
|
+
* What every search box in the explorer says beside its results, from one place: the filters typed in the box shown as
|
|
7
|
+
* removable chips, the parts of the query that were not understood (never silently dropped), and whether meaning-based
|
|
8
|
+
* matching is working, still refining, or off, so a search that is words only says so.
|
|
9
|
+
*/
|
|
10
|
+
import { withoutToken, type FilterKey, type ParsedSearch } from '../find/query';
|
|
11
|
+
import { semanticNote, type SemanticStatus } from '../find/semantic-search';
|
|
12
|
+
|
|
13
|
+
const FILTER_LABEL: Record<FilterKey, string> = {
|
|
14
|
+
status: 'Status',
|
|
15
|
+
env: 'Environment',
|
|
16
|
+
producer: 'Producer',
|
|
17
|
+
domain: 'Domain',
|
|
18
|
+
spec: 'Spec',
|
|
19
|
+
tag: 'Tag',
|
|
20
|
+
branch: 'Branch',
|
|
21
|
+
video: 'Has video'
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** The label of a typed filter chip: `Status: failed`, or `Has video`. */
|
|
25
|
+
export function filterLabel(key: FilterKey, value: string): string {
|
|
26
|
+
return key === 'video' ? FILTER_LABEL.video : `${FILTER_LABEL[key]}: ${value}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The filters typed in the box, each a button that removes its text from the query. */
|
|
30
|
+
export function TypedFilters({ parsed, query, onChange }: { parsed: ParsedSearch; query: string; onChange: (next: string) => void }) {
|
|
31
|
+
return (
|
|
32
|
+
<>
|
|
33
|
+
{parsed.tokens.map((token) => {
|
|
34
|
+
const label = filterLabel(token.key, token.value);
|
|
35
|
+
return (
|
|
36
|
+
<Button key={`typed:${token.text}:${token.value}`} type="button" onClick={() => onChange(withoutToken(query, token))} aria-label={`Remove ${label}`} variant="light">
|
|
37
|
+
{label}{' '}
|
|
38
|
+
<Text aria-hidden="true" span inherit>
|
|
39
|
+
<IconX size={16} aria-hidden="true" />
|
|
40
|
+
</Text>
|
|
41
|
+
</Button>
|
|
42
|
+
);
|
|
43
|
+
})}
|
|
44
|
+
</>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The parts of a query that could not be used, with what to type instead, and the meaning-based search's state. */
|
|
49
|
+
export function SearchNotes({ issues, semantic }: { issues: ParsedSearch['issues']; semantic: SemanticStatus }) {
|
|
50
|
+
const note = semanticNote(semantic);
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
{issues.length > 0 && (
|
|
54
|
+
<Alert role="status" color="yellow" my="sm" title="Part of this search was not understood">
|
|
55
|
+
<Stack gap="xs">
|
|
56
|
+
{issues.map((issue) => (
|
|
57
|
+
<Text key={issue.token + issue.message} size="sm">
|
|
58
|
+
<Text span inherit fw={600}>
|
|
59
|
+
{issue.token}
|
|
60
|
+
</Text>{' '}
|
|
61
|
+
{issue.message}
|
|
62
|
+
</Text>
|
|
63
|
+
))}
|
|
64
|
+
</Stack>
|
|
65
|
+
</Alert>
|
|
66
|
+
)}
|
|
67
|
+
{note && (
|
|
68
|
+
<Text role="status" size="xs" c="dimmed">
|
|
69
|
+
{note}
|
|
70
|
+
</Text>
|
|
71
|
+
)}
|
|
72
|
+
</>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* `type`, `label`, `branch`, `outcome`, `since` and `until`, so a link to one tab's filters opens the same query on another.
|
|
11
11
|
*/
|
|
12
12
|
import { joinList, splitList, type ArtifactFilter } from '@crvouga/atlas/reports/artifact-query';
|
|
13
|
-
import { TEST_SORTS, type TestQuery
|
|
13
|
+
import { TEST_SORTS, type TestQuery } from '@crvouga/atlas/reports/test-catalog';
|
|
14
14
|
import { z } from 'zod/v4';
|
|
15
15
|
|
|
16
16
|
/** How the explorer groups its tests. `spec` is the default. */
|
|
@@ -18,6 +18,11 @@ export const TEST_GROUPS = ['spec', 'domain', 'environment', 'status', 'producer
|
|
|
18
18
|
/** One of {@link TEST_GROUPS}. */
|
|
19
19
|
export type TestGroup = (typeof TEST_GROUPS)[number];
|
|
20
20
|
|
|
21
|
+
/** The orders the explorer offers: `relevance` (best match first, only with words to match) and those of the catalog. */
|
|
22
|
+
export const EXPLORER_SORTS = ['relevance', ...TEST_SORTS] as const;
|
|
23
|
+
/** One of {@link EXPLORER_SORTS}. */
|
|
24
|
+
export type ExplorerSort = (typeof EXPLORER_SORTS)[number];
|
|
25
|
+
|
|
21
26
|
/** A search value as text: the router reads `123` as a number and `["a"]` as an array, both of which are meant as text here. */
|
|
22
27
|
const text = z
|
|
23
28
|
.preprocess((value) => (typeof value === 'number' || typeof value === 'boolean' ? String(value) : value !== null && typeof value === 'object' ? JSON.stringify(value) : value), z.string().min(1))
|
|
@@ -56,7 +61,7 @@ export const ExplorerSearchFields = {
|
|
|
56
61
|
/** Only the tests a CI run recorded (a `RunSummary.key`). Not `run`, which already names a recorded statechart run. */
|
|
57
62
|
inRun: text,
|
|
58
63
|
group: z.enum(TEST_GROUPS).optional().catch(undefined),
|
|
59
|
-
sort: z.enum(
|
|
64
|
+
sort: z.enum(EXPLORER_SORTS).optional().catch(undefined),
|
|
60
65
|
tab: z.enum(['runs', 'artifacts']).optional().catch(undefined),
|
|
61
66
|
/** The run opened in the Runs tab. */
|
|
62
67
|
openRun: text,
|
|
@@ -89,7 +94,7 @@ export type ExplorerSearch = {
|
|
|
89
94
|
readonly until?: string;
|
|
90
95
|
readonly inRun?: string;
|
|
91
96
|
readonly group?: TestGroup;
|
|
92
|
-
readonly sort?:
|
|
97
|
+
readonly sort?: ExplorerSort;
|
|
93
98
|
readonly tab?: 'runs' | 'artifacts';
|
|
94
99
|
readonly openRun?: string;
|
|
95
100
|
readonly layer?: string;
|
|
@@ -113,7 +118,12 @@ export function toggled(value: string | undefined, item: string): string | undef
|
|
|
113
118
|
return joined(list.includes(item) ? list.filter((entry) => entry !== item) : [...list, item]);
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
/** The
|
|
121
|
+
/** The order a list is in: what the address says, else best match when words are searched, else by spec. */
|
|
122
|
+
export function sortOf(search: ExplorerSearch): ExplorerSort {
|
|
123
|
+
return search.sort ?? (search.q ? 'relevance' : 'spec');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The catalog query an address asks for, without the run (it is matched separately) and without paging. The words in `q` are matched by the shared search, not by the catalog. */
|
|
117
127
|
export function queryOf(search: ExplorerSearch): TestQuery {
|
|
118
128
|
return {
|
|
119
129
|
...(search.q ? { q: search.q } : {}),
|
|
@@ -126,7 +136,7 @@ export function queryOf(search: ExplorerSearch): TestQuery {
|
|
|
126
136
|
...(values(search.scope).length ? { scope: values(search.scope) } : {}),
|
|
127
137
|
artifactTag: values(search.artifactTag),
|
|
128
138
|
...(search.inherited ? { inherited: true } : {}),
|
|
129
|
-
sort: search.sort
|
|
139
|
+
sort: search.sort === 'relevance' || !search.sort ? 'spec' : search.sort
|
|
130
140
|
};
|
|
131
141
|
}
|
|
132
142
|
|
|
@@ -137,7 +147,7 @@ export function explorerSearch(search: Record<string, unknown>): ExplorerSearch
|
|
|
137
147
|
|
|
138
148
|
/** True when any filter or search narrows the list. */
|
|
139
149
|
export function narrowed(search: ExplorerSearch): boolean {
|
|
140
|
-
return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.scope || search.artifactTag || search.inRun);
|
|
150
|
+
return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.scope || search.artifactTag || search.inRun || search.video);
|
|
141
151
|
}
|
|
142
152
|
|
|
143
153
|
/** The filters of the Runs and Artifacts tabs, in the address. */
|