@crvouga/atlas 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/reports/src/artifact-query.d.ts +123 -0
- package/dist/reports/src/artifact-query.js +234 -0
- package/dist/reports/src/evidence-detail.js +2 -1
- package/dist/reports/src/evidence-types.d.ts +2 -0
- package/dist/reports/src/test-catalog.d.ts +101 -5
- package/dist/reports/src/test-catalog.js +140 -5
- package/dist/schema/src/api.d.ts +225 -0
- package/dist/schema/src/api.js +41 -2
- package/dist/server/src/app.js +16 -5
- package/dist/server/src/openapi.js +41 -3
- package/docs/reference/http-api.md +84 -2
- package/docs/reference/reports.md +371 -7
- package/docs/reference/schema.md +32 -0
- package/docs/rich-evidence.md +31 -0
- package/docs/server.md +4 -1
- package/docs/ui.md +8 -1
- package/llms-full.txt +127 -4
- package/package.json +1 -1
- package/packages/schema/schemas/effective-delta.schema.json +14 -0
- package/packages/schema/schemas/effective-evidence.schema.json +14 -0
- package/packages/schema/schemas/evidence-page.schema.json +7 -0
- package/packages/schema/schemas/layer-rows.schema.json +7 -0
- package/packages/schema/schemas/report-run.schema.json +35 -0
- package/packages/schema/schemas/report-runs.schema.json +221 -0
- package/packages/schema/schemas/test-catalog.schema.json +7 -0
- package/packages/schema/schemas/test-history.schema.json +14 -0
- package/packages/server/openapi.json +2473 -1035
- package/packages/visualizer/src/evidence/check-detail.tsx +6 -1
- package/packages/visualizer/src/evidence/evidence-context.tsx +9 -2
- package/packages/visualizer/src/evidence/load-state.tsx +65 -0
- package/packages/visualizer/src/evidence/overview.tsx +4 -6
- package/packages/visualizer/src/evidence/recording-page.tsx +17 -7
- package/packages/visualizer/src/evidence/spec-header.tsx +8 -6
- package/packages/visualizer/src/evidence/spec-page.tsx +98 -15
- package/packages/visualizer/src/explorer/ArtifactsView.tsx +162 -0
- package/packages/visualizer/src/explorer/Explorer.tsx +28 -3
- package/packages/visualizer/src/explorer/RunsView.tsx +43 -19
- package/packages/visualizer/src/explorer/catalog.tsx +97 -12
- package/packages/visualizer/src/explorer/filters.tsx +279 -0
- package/packages/visualizer/src/explorer/search.ts +81 -6
- package/packages/visualizer/src/explorer/views.ts +75 -0
- package/packages/visualizer/src/find/Finder.tsx +45 -7
- package/packages/visualizer/src/spec-kind.test.ts +14 -1
- package/packages/visualizer/src/spec-kind.ts +28 -2
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Everything the test explorer shows is in the address of the `/` route, so a link reproduces the view: the open test,
|
|
5
5
|
* the words searched, the filters, the grouping, the order, the Runs tab with the run opened in it, and the exact layer a test's detail is pinned to. Filters that
|
|
6
|
-
* take several values are comma-separated (`status=failed,flaky`)
|
|
6
|
+
* take several values are comma-separated (`status=failed,flaky`); a comma or backslash inside a value is written `\,`
|
|
7
|
+
* or `\\`, and the router encodes `#` and spaces. Anything unreadable is dropped, never an error.
|
|
8
|
+
*
|
|
9
|
+
* The Runs and Artifacts tabs share the tests' `q`, `env`, `producer`, `domain` and `spec` and add `artifactTag`,
|
|
10
|
+
* `type`, `label`, `branch`, `outcome`, `since` and `until`, so a link to one tab's filters opens the same query on another.
|
|
7
11
|
*/
|
|
12
|
+
import { joinList, splitList, type ArtifactFilter } from '@crvouga/atlas/reports/artifact-query';
|
|
8
13
|
import { TEST_SORTS, type TestQuery, type TestSort } from '@crvouga/atlas/reports/test-catalog';
|
|
9
14
|
import { z } from 'zod/v4';
|
|
10
15
|
|
|
@@ -30,11 +35,27 @@ export const ExplorerSearchFields = {
|
|
|
30
35
|
domain: text,
|
|
31
36
|
spec: text,
|
|
32
37
|
tag: text,
|
|
38
|
+
/** Artifact tags (`demo`, shown `#demo`): tests with an attachment carrying one; runs and artifacts likewise. */
|
|
39
|
+
artifactTag: text,
|
|
40
|
+
/** With `artifactTag`: also the tags attachments inherit from their check and run. */
|
|
41
|
+
inherited: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined),
|
|
42
|
+
/** Media types of attachments (`video`, `image`…): runs and artifacts. */
|
|
43
|
+
type: text,
|
|
44
|
+
/** `key=value` labels: runs and artifacts. */
|
|
45
|
+
label: text,
|
|
46
|
+
/** Branches: runs and artifacts. */
|
|
47
|
+
branch: text,
|
|
48
|
+
/** Outcomes of a run or recorded result (`passed`, `failed`…). */
|
|
49
|
+
outcome: text,
|
|
50
|
+
/** Only records from this day (`YYYY-MM-DD`, the viewer's local time). */
|
|
51
|
+
since: text,
|
|
52
|
+
/** Only records until the end of this day (`YYYY-MM-DD`, local time). */
|
|
53
|
+
until: text,
|
|
33
54
|
/** Only the tests a CI run recorded (a `RunSummary.key`). Not `run`, which already names a recorded statechart run. */
|
|
34
55
|
inRun: text,
|
|
35
56
|
group: z.enum(TEST_GROUPS).optional().catch(undefined),
|
|
36
57
|
sort: z.enum(TEST_SORTS).optional().catch(undefined),
|
|
37
|
-
tab: z.enum(['runs']).optional().catch(undefined),
|
|
58
|
+
tab: z.enum(['runs', 'artifacts']).optional().catch(undefined),
|
|
38
59
|
/** The run opened in the Runs tab. */
|
|
39
60
|
openRun: text,
|
|
40
61
|
/** Pins the open test's detail to one exact layer (a run's record of it), instead of the latest combined evidence. */
|
|
@@ -53,10 +74,18 @@ export type ExplorerSearch = {
|
|
|
53
74
|
readonly domain?: string;
|
|
54
75
|
readonly spec?: string;
|
|
55
76
|
readonly tag?: string;
|
|
77
|
+
readonly artifactTag?: string;
|
|
78
|
+
readonly inherited?: true;
|
|
79
|
+
readonly type?: string;
|
|
80
|
+
readonly label?: string;
|
|
81
|
+
readonly branch?: string;
|
|
82
|
+
readonly outcome?: string;
|
|
83
|
+
readonly since?: string;
|
|
84
|
+
readonly until?: string;
|
|
56
85
|
readonly inRun?: string;
|
|
57
86
|
readonly group?: TestGroup;
|
|
58
87
|
readonly sort?: TestSort;
|
|
59
|
-
readonly tab?: 'runs';
|
|
88
|
+
readonly tab?: 'runs' | 'artifacts';
|
|
60
89
|
readonly openRun?: string;
|
|
61
90
|
readonly layer?: string;
|
|
62
91
|
readonly video?: true;
|
|
@@ -64,12 +93,12 @@ export type ExplorerSearch = {
|
|
|
64
93
|
|
|
65
94
|
/** The values of a comma-separated filter, empty when it is not set. */
|
|
66
95
|
export function values(value: string | undefined): string[] {
|
|
67
|
-
return value ? value
|
|
96
|
+
return value ? splitList(value) : [];
|
|
68
97
|
}
|
|
69
98
|
|
|
70
99
|
/** A list of values as one comma-separated parameter, or undefined (left out of the address) when empty. */
|
|
71
100
|
export function joined(list: readonly string[]): string | undefined {
|
|
72
|
-
return list
|
|
101
|
+
return joinList(list);
|
|
73
102
|
}
|
|
74
103
|
|
|
75
104
|
/** Adds a value to a comma-separated filter, or removes it when it is there. */
|
|
@@ -88,6 +117,8 @@ export function queryOf(search: ExplorerSearch): TestQuery {
|
|
|
88
117
|
domain: values(search.domain),
|
|
89
118
|
spec: values(search.spec),
|
|
90
119
|
tag: values(search.tag),
|
|
120
|
+
artifactTag: values(search.artifactTag),
|
|
121
|
+
...(search.inherited ? { inherited: true } : {}),
|
|
91
122
|
sort: search.sort ?? 'spec'
|
|
92
123
|
};
|
|
93
124
|
}
|
|
@@ -99,5 +130,49 @@ export function explorerSearch(search: Record<string, unknown>): ExplorerSearch
|
|
|
99
130
|
|
|
100
131
|
/** True when any filter or search narrows the list. */
|
|
101
132
|
export function narrowed(search: ExplorerSearch): boolean {
|
|
102
|
-
return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.inRun);
|
|
133
|
+
return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.artifactTag || search.inRun);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The filters of the Runs and Artifacts tabs, in the address. */
|
|
137
|
+
export const FILTER_FIELDS = ['q', 'artifactTag', 'inherited', 'type', 'label', 'env', 'producer', 'branch', 'outcome', 'domain', 'spec', 'since', 'until', 'video'] as const;
|
|
138
|
+
|
|
139
|
+
/** A patch that removes every filter of the Runs and Artifacts tabs. */
|
|
140
|
+
export const NO_FILTERS = Object.fromEntries(FILTER_FIELDS.map((field) => [field, undefined])) as { [K in (typeof FILTER_FIELDS)[number]]?: undefined };
|
|
141
|
+
|
|
142
|
+
/** True when an address narrows the Runs and Artifacts tabs. */
|
|
143
|
+
export function filtered(search: ExplorerSearch): boolean {
|
|
144
|
+
return FILTER_FIELDS.some((field) => field !== 'inherited' && search[field] !== undefined);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Midnight at the start (or the last millisecond) of a `YYYY-MM-DD` day in the viewer's local time, or undefined. */
|
|
148
|
+
function dayMs(day: string | undefined, end: boolean): number | undefined {
|
|
149
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day ?? '');
|
|
150
|
+
if (!match) return undefined;
|
|
151
|
+
const [year, month, date] = [Number(match[1]), Number(match[2]) - 1, Number(match[3])];
|
|
152
|
+
const value = new Date(year, month, date + (end ? 1 : 0)).getTime() - (end ? 1 : 0);
|
|
153
|
+
return Number.isFinite(value) ? value : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The query the Runs and Artifacts tabs ask for: the address's filters in the shape the report API and `matchesFilter` read. */
|
|
157
|
+
export function filterOf(search: ExplorerSearch): ArtifactFilter {
|
|
158
|
+
const list = (value: string | undefined) => (values(value).length ? values(value) : undefined);
|
|
159
|
+
const since = dayMs(search.since, false);
|
|
160
|
+
const until = dayMs(search.until, true);
|
|
161
|
+
const parts = {
|
|
162
|
+
q: search.q,
|
|
163
|
+
artifactTag: list(search.artifactTag),
|
|
164
|
+
inherited: search.inherited,
|
|
165
|
+
type: list(search.type),
|
|
166
|
+
label: list(search.label),
|
|
167
|
+
environment: list(search.env),
|
|
168
|
+
producer: list(search.producer),
|
|
169
|
+
branch: list(search.branch),
|
|
170
|
+
outcome: list(search.outcome),
|
|
171
|
+
domain: list(search.domain),
|
|
172
|
+
spec: list(search.spec),
|
|
173
|
+
since,
|
|
174
|
+
until,
|
|
175
|
+
video: search.video
|
|
176
|
+
};
|
|
177
|
+
return Object.fromEntries(Object.entries(parts).filter(([, value]) => value !== undefined)) as ArtifactFilter;
|
|
103
178
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Saved views
|
|
3
|
+
*
|
|
4
|
+
* A saved view is a name for a set of filters on the Runs or Artifacts tab of one project, kept in this browser. It is a
|
|
5
|
+
* live query, not a snapshot: opening it asks again and shows what matches the newest report, so its results change as
|
|
6
|
+
* runs arrive. (An immutable collection of reviewed results is a different thing, and is not what this stores.) Unreadable
|
|
7
|
+
* storage is treated as no saved views, never as an error.
|
|
8
|
+
*/
|
|
9
|
+
import { FILTER_FIELDS, type ExplorerSearch } from './search';
|
|
10
|
+
|
|
11
|
+
/** The storage key of the saved views of this browser, all projects. */
|
|
12
|
+
export const VIEWS_KEY = 'atlas.savedViews.v1';
|
|
13
|
+
|
|
14
|
+
/** A saved live query. `filters` holds only the filter fields of the address, as the address writes them. */
|
|
15
|
+
export type SavedView = {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly name: string;
|
|
18
|
+
/** The tab it opens on. */
|
|
19
|
+
readonly tab: 'runs' | 'artifacts';
|
|
20
|
+
/** The project it belongs to (the page's `?project=`), or an empty string. */
|
|
21
|
+
readonly project: string;
|
|
22
|
+
readonly filters: Readonly<Record<string, string | true>>;
|
|
23
|
+
readonly savedAtMs: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** The filters of an address, ready to save: only the fields that narrow, with their values. */
|
|
27
|
+
export function filtersOf(search: ExplorerSearch): Record<string, string | true> {
|
|
28
|
+
const filters: Record<string, string | true> = {};
|
|
29
|
+
for (const field of FILTER_FIELDS) {
|
|
30
|
+
const value = search[field];
|
|
31
|
+
if (value !== undefined) filters[field] = value;
|
|
32
|
+
}
|
|
33
|
+
return filters;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether an address is exactly a saved view's query on its tab (ignoring anything that is not a filter). */
|
|
37
|
+
export function isView(view: SavedView, search: ExplorerSearch): boolean {
|
|
38
|
+
if ((search.tab ?? 'tests') !== view.tab) return false;
|
|
39
|
+
const now = filtersOf(search);
|
|
40
|
+
const keys = Object.keys(now);
|
|
41
|
+
return keys.length === Object.keys(view.filters).length && keys.every((key) => now[key] === view.filters[key]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The saved views of this browser, oldest first; empty when none or when storage cannot be read. */
|
|
45
|
+
export function readViews(storage: Pick<Storage, 'getItem'> | undefined = safeStorage()): SavedView[] {
|
|
46
|
+
try {
|
|
47
|
+
const parsed: unknown = JSON.parse(storage?.getItem(VIEWS_KEY) ?? '[]');
|
|
48
|
+
if (!Array.isArray(parsed)) return [];
|
|
49
|
+
return parsed.filter(
|
|
50
|
+
(item): item is SavedView =>
|
|
51
|
+
!!item && typeof item.id === 'string' && typeof item.name === 'string' && (item.tab === 'runs' || item.tab === 'artifacts') && typeof item.project === 'string' && !!item.filters && typeof item.filters === 'object'
|
|
52
|
+
);
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Keeps the saved views. Returns false when storage refused them (full, private or unavailable), so the page can say so. */
|
|
59
|
+
export function writeViews(views: readonly SavedView[], storage: Pick<Storage, 'setItem'> | undefined = safeStorage()): boolean {
|
|
60
|
+
try {
|
|
61
|
+
if (!storage) return false;
|
|
62
|
+
storage.setItem(VIEWS_KEY, JSON.stringify(views));
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function safeStorage(): Storage | undefined {
|
|
70
|
+
try {
|
|
71
|
+
return typeof localStorage === 'undefined' ? undefined : localStorage;
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -6,6 +6,7 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboa
|
|
|
6
6
|
import { useAtlas } from '../app/atlas-context';
|
|
7
7
|
import { keys, specKey, useAdapter } from '../data/queries';
|
|
8
8
|
import { useEvidence } from '../evidence/evidence-context';
|
|
9
|
+
import { plural } from '../evidence/format';
|
|
9
10
|
import { StatusMark } from '../evidence/marks';
|
|
10
11
|
import { kindOfFile } from '../spec-kind';
|
|
11
12
|
import { corpusFrom } from './corpus';
|
|
@@ -41,15 +42,29 @@ function isTypingTarget(target: EventTarget | null) {
|
|
|
41
42
|
const tag = target.tagName;
|
|
42
43
|
return target.isContentEditable || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'BUTTON' || tag === 'A';
|
|
43
44
|
}
|
|
44
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Reads the text of every spec for search, a few at a time, once `enabled`. Each file lands as it arrives, so a search
|
|
47
|
+
* can use what is read so far; `read` of `total` say how far it is, and `failed` how many could not be read at all.
|
|
48
|
+
*/
|
|
49
|
+
function useSpecTexts(files: readonly string[], enabled: boolean, open: boolean) {
|
|
45
50
|
const adapter = useAdapter();
|
|
46
51
|
const client = useQueryClient();
|
|
47
52
|
const source = specKey(adapter);
|
|
48
53
|
const cache = useRef(new Map<string, string>());
|
|
54
|
+
const unreadable = useRef(new Set<string>());
|
|
49
55
|
const [texts, setTexts] = useState<ReadonlyMap<string, string>>(() => new Map());
|
|
50
56
|
const [pending, setPending] = useState(false);
|
|
57
|
+
const [failedFiles, setFailedFiles] = useState<ReadonlySet<string>>(() => new Set());
|
|
51
58
|
const key = files.join('\n');
|
|
59
|
+
// Opening the finder is another chance for the files that could not be read.
|
|
52
60
|
useEffect(() => {
|
|
61
|
+
if (!open || unreadable.current.size === 0) return;
|
|
62
|
+
for (const file of unreadable.current) cache.current.delete(file);
|
|
63
|
+
unreadable.current.clear();
|
|
64
|
+
setFailedFiles(new Set());
|
|
65
|
+
}, [open]);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!enabled) return;
|
|
53
68
|
const wanted = key.length === 0 ? [] : key.split('\n');
|
|
54
69
|
let cancelled = false;
|
|
55
70
|
const missing = wanted.filter((file) => !cache.current.has(file));
|
|
@@ -65,13 +80,15 @@ function useSpecTexts(files: readonly string[]) {
|
|
|
65
80
|
if (file === undefined || cancelled) return;
|
|
66
81
|
let text = '';
|
|
67
82
|
try {
|
|
68
|
-
text = await client.fetchQuery({ queryKey: keys.specFile(source, file), queryFn: () => adapter.specFile(file) });
|
|
83
|
+
text = await client.fetchQuery({ queryKey: keys.specFile(source, file), queryFn: () => adapter.specFile(file), retry: false });
|
|
69
84
|
} catch {
|
|
70
85
|
text = '';
|
|
86
|
+
unreadable.current.add(file);
|
|
71
87
|
}
|
|
72
88
|
if (cancelled) return;
|
|
73
89
|
cache.current.set(file, text);
|
|
74
90
|
setTexts(new Map(cache.current));
|
|
91
|
+
if (unreadable.current.has(file)) setFailedFiles(new Set(unreadable.current));
|
|
75
92
|
}
|
|
76
93
|
};
|
|
77
94
|
void Promise.all([pull(), pull(), pull(), pull()]).then(() => {
|
|
@@ -80,8 +97,10 @@ function useSpecTexts(files: readonly string[]) {
|
|
|
80
97
|
return () => {
|
|
81
98
|
cancelled = true;
|
|
82
99
|
};
|
|
83
|
-
}, [adapter, client, key, source]);
|
|
84
|
-
|
|
100
|
+
}, [adapter, client, enabled, key, source]);
|
|
101
|
+
const read = useMemo(() => files.filter((file) => texts.has(file)).length, [files, texts]);
|
|
102
|
+
const failed = useMemo(() => files.filter((file) => failedFiles.has(file)).length, [files, failedFiles]);
|
|
103
|
+
return { texts, pending, total: files.length, read, failed };
|
|
85
104
|
}
|
|
86
105
|
/** The vector cache outlives a keystroke, so a live run does not embed the suite again. */
|
|
87
106
|
const vectorCache = new Map<string, Float32Array>();
|
|
@@ -101,12 +120,25 @@ export function Finder() {
|
|
|
101
120
|
const navigate = useNavigate();
|
|
102
121
|
const routeSearch = useSearch({ strict: false });
|
|
103
122
|
const readable = useMemo(() => evidence.files.filter((file) => kindOfFile(file)), [evidence.files]);
|
|
104
|
-
|
|
123
|
+
// Reading every spec's text is for search, and a focused page needs its own spec first: start only once search is wanted.
|
|
124
|
+
const [wanted, setWanted] = useState(false);
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (open) setWanted(true);
|
|
127
|
+
}, [open]);
|
|
128
|
+
const { texts, pending, total, read, failed } = useSpecTexts(readable, wanted, open);
|
|
105
129
|
const docs = useMemo(
|
|
106
130
|
() => corpusFrom(view, evidence.files, evidence.titleOf, evidence.view, texts),
|
|
107
131
|
[view, evidence.files, evidence.titleOf, evidence.view, texts]
|
|
108
132
|
);
|
|
109
133
|
const index = useMemo(() => indexCorpus(docs), [docs]);
|
|
134
|
+
// What search has not covered yet, so an empty answer is not read as "no such behavior".
|
|
135
|
+
const unindexed = evidence.filesPending
|
|
136
|
+
? 'The spec list is still loading'
|
|
137
|
+
: pending
|
|
138
|
+
? `${total - read} of ${total} specs are still being read`
|
|
139
|
+
: !evidence.ready && !evidence.unavailable
|
|
140
|
+
? 'Recorded results are still loading'
|
|
141
|
+
: '';
|
|
110
142
|
const [query, setQuery] = useState('');
|
|
111
143
|
const store = finderStore;
|
|
112
144
|
const spotlightState = useSpotlight(store);
|
|
@@ -254,7 +286,13 @@ export function Finder() {
|
|
|
254
286
|
<Spotlight.ActionsList id="atlas-finder-actions" role="listbox" aria-label="Flows, specs and runs">
|
|
255
287
|
{shown.length === 0 && (
|
|
256
288
|
<Spotlight.Empty>
|
|
257
|
-
{query.trim()
|
|
289
|
+
{!query.trim()
|
|
290
|
+
? 'Type what someone does. For example, add a todo.'
|
|
291
|
+
: unindexed
|
|
292
|
+
? `No match yet. ${unindexed}, so this is not the final answer.`
|
|
293
|
+
: failed > 0
|
|
294
|
+
? `Nothing close to that. ${plural(failed, 'spec', 'specs')} could not be read, so search may miss ${failed === 1 ? 'it' : 'them'}.`
|
|
295
|
+
: 'Nothing close to that.'}
|
|
258
296
|
</Spotlight.Empty>
|
|
259
297
|
)}
|
|
260
298
|
{FIND_GROUPS.map((group) => {
|
|
@@ -285,7 +323,7 @@ export function Finder() {
|
|
|
285
323
|
<Spotlight.Footer>
|
|
286
324
|
<Group gap="md" justify="space-between">
|
|
287
325
|
<Text size="xs" c="dimmed">
|
|
288
|
-
{
|
|
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'}
|
|
289
327
|
</Text>
|
|
290
328
|
<Text size="xs" c="dimmed">
|
|
291
329
|
↵ open · esc close
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test';
|
|
2
2
|
|
|
3
|
-
import { fileName, folderOf, kindOfFile, outlinePath, specFile, specKind } from './spec-kind';
|
|
3
|
+
import { fileName, folderOf, kindOfFile, outlinePath, resolveSpec, similarSpecs, specFile, specKind } from './spec-kind';
|
|
4
4
|
|
|
5
5
|
describe('spec kinds', () => {
|
|
6
6
|
it('gives each notation its own tab and leaves statecharts and notes to the map', () => {
|
|
@@ -24,4 +24,17 @@ describe('spec kinds', () => {
|
|
|
24
24
|
expect(folderOf('auth/sign-in.feature')).toBe('auth');
|
|
25
25
|
expect(folderOf('atlas.yaml')).toBe('');
|
|
26
26
|
});
|
|
27
|
+
|
|
28
|
+
it('reads a link to a spec by any form of its path and finds the listed spec it means', () => {
|
|
29
|
+
expect(specFile('./auth/sign-in.feature')).toBe('auth/sign-in.feature');
|
|
30
|
+
expect(specFile('/specs/auth\\sign-in.feature')).toBe('auth/sign-in.feature');
|
|
31
|
+
const listed = ['auth/sign-in.feature', 'billing/Pay.feature', 'billing/pay-later.feature', 'a/x.feature', 'b/x.feature'];
|
|
32
|
+
expect(resolveSpec('auth/sign-in.feature', listed)).toBe('auth/sign-in.feature');
|
|
33
|
+
expect(resolveSpec('Auth/Sign-In.feature', listed)).toBe('auth/sign-in.feature');
|
|
34
|
+
expect(resolveSpec('sign-in.feature', listed)).toBe('auth/sign-in.feature');
|
|
35
|
+
// Two candidates, or none, leave the link as written.
|
|
36
|
+
expect(resolveSpec('x.feature', listed)).toBe('x.feature');
|
|
37
|
+
expect(resolveSpec('nothing.feature', listed)).toBe('nothing.feature');
|
|
38
|
+
expect(similarSpecs('other/pay.feature', listed)).toEqual(['billing/Pay.feature']);
|
|
39
|
+
});
|
|
27
40
|
});
|
|
@@ -42,9 +42,35 @@ export function outlinePath(file: string) {
|
|
|
42
42
|
return normal.startsWith('specs/') ? normal : `specs/${normal}`;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* A file path relative to the specs folder, from any form a link may name it by: with or without the `specs/` folder,
|
|
47
|
+
* with a leading `./` or `/`, with backslashes. Nothing here can leave the folder; `..` parts are kept for the reader to refuse.
|
|
48
|
+
*/
|
|
46
49
|
export function specFile(path: string) {
|
|
47
|
-
return path
|
|
50
|
+
return path
|
|
51
|
+
.replaceAll('\\', '/')
|
|
52
|
+
.replace(/^(\.?\/)+/, '')
|
|
53
|
+
.replace(/^specs\//, '')
|
|
54
|
+
.replace(/\/{2,}/g, '/');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The listed spec a link means. An exact entry wins; otherwise the one listed spec that differs only in letter case, or that
|
|
59
|
+
* the link names by a trailing part of its path. More than one candidate, or none, leaves the link as written.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveSpec(file: string, listed: readonly string[]): string {
|
|
62
|
+
if (listed.includes(file)) return file;
|
|
63
|
+
const lower = file.toLowerCase();
|
|
64
|
+
const sameCase = listed.filter((candidate) => candidate.toLowerCase() === lower);
|
|
65
|
+
if (sameCase.length === 1) return sameCase[0]!;
|
|
66
|
+
const tail = listed.filter((candidate) => candidate.toLowerCase().endsWith(`/${lower}`));
|
|
67
|
+
return tail.length === 1 ? tail[0]! : file;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The listed specs with the same file name as `file`, to offer when a link names one that is not there. */
|
|
71
|
+
export function similarSpecs(file: string, listed: readonly string[], limit = 5): string[] {
|
|
72
|
+
const name = fileName(file).toLowerCase();
|
|
73
|
+
return listed.filter((candidate) => fileName(candidate).toLowerCase() === name).slice(0, limit);
|
|
48
74
|
}
|
|
49
75
|
|
|
50
76
|
export function fileName(path: string) {
|