@crvouga/atlas 1.0.0 → 1.1.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 +6 -0
- package/docs/ui.md +7 -0
- package/llms-full.txt +7 -0
- package/package.json +1 -1
- 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/find/Finder.tsx +45 -7
- package/packages/visualizer/src/spec-kind.test.ts +14 -1
- package/packages/visualizer/src/spec-kind.ts +28 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog: @crvouga/atlas
|
|
2
2
|
|
|
3
|
+
## 1.1.0 (2026-10-28)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- make focused evidence navigation resilient to slow, missing or large catalogs ([db7932c](https://github.com/crvouga/atlas/commit/db7932cf7d912ebf8cba2ae1867e6eda2352f611))
|
|
8
|
+
|
|
3
9
|
## 1.0.0 (2026-10-28)
|
|
4
10
|
|
|
5
11
|
### ⚠️ Breaking changes
|
package/docs/ui.md
CHANGED
|
@@ -41,6 +41,13 @@ they preserve evidence semantics, provenance, URL selection, or playback behavio
|
|
|
41
41
|
`Bounded`/show-more controls. Do not fetch unopened results' recordings eagerly.
|
|
42
42
|
- Loading: named `Loader`/`Skeleton` or explicit text with `role="status"`. Error: `Alert color="red"` and
|
|
43
43
|
a retry when possible. Empty: descriptive `Text` and a relevant action. Use built-in loading/disabled states.
|
|
44
|
+
- Waiting is bounded. A focused page (a spec, a recording) loads what its link names on its own and never waits for
|
|
45
|
+
the whole catalog. `loadingPolicy.stallMs` in `evidence/load-state.tsx` is how long a read may take before the page
|
|
46
|
+
names what is affected, keeps the rest usable and offers `Try again` and a way back that keeps the selected scope.
|
|
47
|
+
A missing spec or layer is an explicit unavailable state with its path, never an endless `Loading…`. Layers that
|
|
48
|
+
fail to read are counted (`EvidenceIncomplete`) and the rest stays usable. Search disclosed as incomplete (still
|
|
49
|
+
reading, or specs it could not read) never reports "no match" as final. `tests/resilient-navigation.test.tsx`
|
|
50
|
+
holds the request budgets for a large catalog.
|
|
44
51
|
- Spec overview links use a centered `Group`: status, flexible title/folder, then outcome and time. Keep gaps
|
|
45
52
|
explicit with theme spacing; stack metadata on narrow screens instead of crowding the title.
|
|
46
53
|
|
package/llms-full.txt
CHANGED
|
@@ -3768,6 +3768,13 @@ they preserve evidence semantics, provenance, URL selection, or playback behavio
|
|
|
3768
3768
|
`Bounded`/show-more controls. Do not fetch unopened results' recordings eagerly.
|
|
3769
3769
|
- Loading: named `Loader`/`Skeleton` or explicit text with `role="status"`. Error: `Alert color="red"` and
|
|
3770
3770
|
a retry when possible. Empty: descriptive `Text` and a relevant action. Use built-in loading/disabled states.
|
|
3771
|
+
- Waiting is bounded. A focused page (a spec, a recording) loads what its link names on its own and never waits for
|
|
3772
|
+
the whole catalog. `loadingPolicy.stallMs` in `evidence/load-state.tsx` is how long a read may take before the page
|
|
3773
|
+
names what is affected, keeps the rest usable and offers `Try again` and a way back that keeps the selected scope.
|
|
3774
|
+
A missing spec or layer is an explicit unavailable state with its path, never an endless `Loading…`. Layers that
|
|
3775
|
+
fail to read are counted (`EvidenceIncomplete`) and the rest stays usable. Search disclosed as incomplete (still
|
|
3776
|
+
reading, or specs it could not read) never reports "no match" as final. `tests/resilient-navigation.test.tsx`
|
|
3777
|
+
holds the request budgets for a large catalog.
|
|
3771
3778
|
- Spec overview links use a centered `Group`: status, flexible title/folder, then outcome and time. Keep gaps
|
|
3772
3779
|
explicit with theme spacing; stack metadata on narrow screens instead of crowding the title.
|
|
3773
3780
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crvouga/atlas",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Atlas: specs of any kind, reported as layered evidence. Publish checks from any runner (JUnit, CTRF, a script), combine them across nodes, branches, environments and backends, gate CI on the result, and run statecharts against the real app.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -303,7 +303,7 @@ export function CheckDetail(props: Props) {
|
|
|
303
303
|
);
|
|
304
304
|
}
|
|
305
305
|
function RecordedCheckDetail({ slot, layerId, select, reads, written, pinned, pin }: Props) {
|
|
306
|
-
const { evidence, backends } = useEvidence();
|
|
306
|
+
const { evidence, backends, ready, pending } = useEvidence();
|
|
307
307
|
const now = useNow();
|
|
308
308
|
const [variant, setVariant] = useState(0);
|
|
309
309
|
const history = useMemo(() => checkHistory(evidence, slot.specPath, slot.id), [evidence, slot.specPath, slot.id]);
|
|
@@ -350,6 +350,11 @@ function RecordedCheckDetail({ slot, layerId, select, reads, written, pinned, pi
|
|
|
350
350
|
{note}
|
|
351
351
|
</Alert>
|
|
352
352
|
)}
|
|
353
|
+
{layerId && !earlier && ready && pending === 0 && (
|
|
354
|
+
<Alert role="status" color={'blue'} my="sm">
|
|
355
|
+
The layer this link names is not among the results held for this check. Showing the current result instead.
|
|
356
|
+
</Alert>
|
|
357
|
+
)}
|
|
353
358
|
{failure && (
|
|
354
359
|
<Code block c="red.7">
|
|
355
360
|
{failure}
|
|
@@ -36,6 +36,8 @@ export type EvidenceState = {
|
|
|
36
36
|
readonly retry: () => void;
|
|
37
37
|
/** Layers whose checks are still arriving. */
|
|
38
38
|
readonly pending: number;
|
|
39
|
+
/** Layers whose checks could not be read. The report shown leaves them out, so it may be incomplete. */
|
|
40
|
+
readonly failed: readonly LayerHeader[];
|
|
39
41
|
/** Every distinct layer, whether or not it contributes. */
|
|
40
42
|
readonly layers: readonly LayerHeader[];
|
|
41
43
|
/** The layers that contribute, with their checks, once read. */
|
|
@@ -175,7 +177,7 @@ export function EvidenceProvider({ children }: { children: ReactNode }) {
|
|
|
175
177
|
staleTime: Number.POSITIVE_INFINITY,
|
|
176
178
|
retry: 1
|
|
177
179
|
})),
|
|
178
|
-
combine: (results) => ({ data: results.map((result) => result.data), stamp: results.map((result) => result.dataUpdatedAt).join(',') })
|
|
180
|
+
combine: (results) => ({ data: results.map((result) => result.data), failed: results.map((result) => result.isError && result.data === undefined), stamp: results.map((result) => result.dataUpdatedAt).join(',') })
|
|
179
181
|
});
|
|
180
182
|
|
|
181
183
|
// A running suite's layer is re-read as tests finish. Its last read stays on screen meanwhile.
|
|
@@ -195,6 +197,8 @@ export function EvidenceProvider({ children }: { children: ReactNode }) {
|
|
|
195
197
|
// `stamp` changes exactly when a layer's rows do.
|
|
196
198
|
}, [wanted, rows.stamp]);
|
|
197
199
|
|
|
200
|
+
const failed = useMemo(() => wanted.filter((_, i) => rows.failed[i]), [wanted, rows.failed]);
|
|
201
|
+
|
|
198
202
|
const shown = effective ? overview.data?.layers ?? NONE : evidence;
|
|
199
203
|
const { tests: liveTests } = useLiveTests();
|
|
200
204
|
const view = useMemo(() => withLiveProgress(evidenceView(combineEvidence(outline, shown)), liveTests), [outline, shown, liveTests]);
|
|
@@ -206,11 +210,14 @@ export function EvidenceProvider({ children }: { children: ReactNode }) {
|
|
|
206
210
|
unavailable: effective ? (overview.data === undefined && overview.error ? overview.error.message : null) : pages === undefined && index.error ? index.error.message : null,
|
|
207
211
|
retry: () => {
|
|
208
212
|
if (effective) { void Promise.all([overview.refetch(), serverOutline.refetch()]); return; }
|
|
213
|
+
// A layer whose rows could not be read is asked for again alone, so the layers already read stay on screen.
|
|
214
|
+
void client.refetchQueries({ queryKey: ['evidence-rows'], predicate: (query) => query.state.status === 'error' });
|
|
209
215
|
void reportJson<EvidencePage>('/evidence?retry=1')
|
|
210
216
|
.catch(() => null)
|
|
211
217
|
.then(() => Promise.all([index.refetch(), serverOutline.refetch()]));
|
|
212
218
|
},
|
|
213
|
-
pending: wanted.length - evidence.length,
|
|
219
|
+
pending: Math.max(0, wanted.length - evidence.length - failed.length),
|
|
220
|
+
failed,
|
|
214
221
|
layers: effective ? effectiveHeaders : layers,
|
|
215
222
|
evidence: shown,
|
|
216
223
|
backends: effective ? overview.data?.backends ?? [] : first?.backends ?? [],
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Alert, Button, Group, Text } from '@mantine/core';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { LoadError } from '../data/adapters';
|
|
4
|
+
import { useEvidence } from './evidence-context';
|
|
5
|
+
import { plural } from './format';
|
|
6
|
+
import { ReportsError } from './api';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How long a read may take before the page says so. Waiting is never silent past this: the page names what is
|
|
10
|
+
* affected, keeps whatever else is readable, and offers another attempt. A test shortens it.
|
|
11
|
+
*/
|
|
12
|
+
export const loadingPolicy = { stallMs: 10_000 };
|
|
13
|
+
|
|
14
|
+
/** True once `active` has stayed true for the loading policy's bound. It starts over when `active` or `scope` changes. */
|
|
15
|
+
export function useStalled(active: boolean, scope = ''): boolean {
|
|
16
|
+
const [stalled, setStalled] = useState(false);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
setStalled(false);
|
|
19
|
+
if (!active) return;
|
|
20
|
+
const timer = setTimeout(() => setStalled(true), loadingPolicy.stallMs);
|
|
21
|
+
return () => clearTimeout(timer);
|
|
22
|
+
}, [active, scope]);
|
|
23
|
+
return active && stalled;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Is this error the server saying the thing is not there, as opposed to it failing to answer? */
|
|
27
|
+
export function isMissing(error: unknown): boolean {
|
|
28
|
+
return (error instanceof LoadError || error instanceof ReportsError) && error.status === 404;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Recorded results are still arriving. After the loading policy's bound it says so and offers another attempt. */
|
|
32
|
+
export function EvidenceWaiting({ label = 'Loading recorded results…', usable }: { label?: string; usable?: string }) {
|
|
33
|
+
const { retry } = useEvidence();
|
|
34
|
+
const stalled = useStalled(true);
|
|
35
|
+
return stalled ? (
|
|
36
|
+
<Alert role="status" color="orange" my="sm" title="Recorded results are taking longer than expected">
|
|
37
|
+
<Group gap="sm">
|
|
38
|
+
<Text span inherit>
|
|
39
|
+
{usable ? `${usable} ` : ''}Results will appear here when they arrive.
|
|
40
|
+
</Text>
|
|
41
|
+
<Button type="button" onClick={retry} variant="subtle">
|
|
42
|
+
Try again
|
|
43
|
+
</Button>
|
|
44
|
+
</Group>
|
|
45
|
+
</Alert>
|
|
46
|
+
) : (
|
|
47
|
+
<Text role="status" component="div" c="dimmed">
|
|
48
|
+
{label}
|
|
49
|
+
</Text>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Says when some layers could not be read, so counts and results are not taken for the whole report. */
|
|
54
|
+
export function EvidenceIncomplete() {
|
|
55
|
+
const { failed, retry } = useEvidence();
|
|
56
|
+
if (failed.length === 0) return null;
|
|
57
|
+
return (
|
|
58
|
+
<Alert role="status" color="orange" my="sm">
|
|
59
|
+
{plural(failed.length, 'layer', 'layers')} could not be read, so the results shown here may be incomplete.{' '}
|
|
60
|
+
<Button type="button" onClick={retry} variant="subtle">
|
|
61
|
+
Try again
|
|
62
|
+
</Button>
|
|
63
|
+
</Alert>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -7,6 +7,7 @@ import { folderOf, outlinePath, type SpecKind } from '../spec-kind';
|
|
|
7
7
|
import { useUiStore } from '../state/ui-store';
|
|
8
8
|
import { useEvidence } from './evidence-context';
|
|
9
9
|
import { ago, plural, useNow } from './format';
|
|
10
|
+
import { EvidenceIncomplete, EvidenceWaiting } from './load-state';
|
|
10
11
|
import { backendLabel, Bounded, CountLine, Meter, StatusMark, Tally } from './marks';
|
|
11
12
|
/** A kind's specs at a glance: how many have results, which are failing, and what just ran. */
|
|
12
13
|
export function Overview({ kind }: { kind: SpecKind }) {
|
|
@@ -104,11 +105,8 @@ export function Overview({ kind }: { kind: SpecKind }) {
|
|
|
104
105
|
</Button>
|
|
105
106
|
</Alert>
|
|
106
107
|
))}
|
|
107
|
-
{!evidence.ready && !evidence.unavailable &&
|
|
108
|
-
|
|
109
|
-
Loading recorded results…
|
|
110
|
-
</Text>
|
|
111
|
-
)}
|
|
108
|
+
{!evidence.ready && !evidence.unavailable && <EvidenceWaiting usable={`The list of ${kind.many} is readable.`} />}
|
|
109
|
+
{evidence.ready && <EvidenceIncomplete />}
|
|
112
110
|
{evidence.ready && evidence.pending > 0 && (
|
|
113
111
|
<Text role="status" component="div" c="dimmed">
|
|
114
112
|
Reading {plural(evidence.pending, 'more layer', 'more layers')}…
|
|
@@ -168,7 +166,7 @@ export function Overview({ kind }: { kind: SpecKind }) {
|
|
|
168
166
|
</Box>
|
|
169
167
|
)}
|
|
170
168
|
|
|
171
|
-
{evidence.ready && evidence.pending === 0 && withResults.length === 0 && !evidence.unavailable && (
|
|
169
|
+
{evidence.ready && evidence.pending === 0 && evidence.failed.length === 0 && withResults.length === 0 && !evidence.unavailable && (
|
|
172
170
|
<Text component="div" c="dimmed">
|
|
173
171
|
{evidence.layers.length === 0
|
|
174
172
|
? `Nothing has been recorded yet. Results appear here as a spec run finishes each test.`
|
|
@@ -10,6 +10,7 @@ import { useRecordedReport } from './check-detail';
|
|
|
10
10
|
import { useEvidence } from './evidence-context';
|
|
11
11
|
import { duration, statusWord, useNow } from './format';
|
|
12
12
|
import { Origin, StatusMark } from './marks';
|
|
13
|
+
import { EvidenceIncomplete, EvidenceWaiting } from './load-state';
|
|
13
14
|
import { Timeline } from './recording';
|
|
14
15
|
type Props = {
|
|
15
16
|
/** The check whose recording is open, once the combined report holds it. */
|
|
@@ -40,7 +41,7 @@ function needsStylesheet(report: RunReport | undefined): boolean {
|
|
|
40
41
|
/** One recorded test, opened in place of its spec. */
|
|
41
42
|
export function RecordingPage({ slot, layerId, entry, label, back, moment, onMoment, onClose }: Props) {
|
|
42
43
|
const progress = useTestProgress(slot ?? null);
|
|
43
|
-
const { evidence, backends, pending, ready, unavailable } = useEvidence();
|
|
44
|
+
const { evidence, backends, pending, ready, unavailable, failed, retry } = useEvidence();
|
|
44
45
|
const now = useNow();
|
|
45
46
|
const history = useMemo(() => (slot ? checkHistory(evidence, slot.specPath, slot.id) : []), [evidence, slot]);
|
|
46
47
|
const winner = slot?.outcomes[0];
|
|
@@ -86,17 +87,26 @@ export function RecordingPage({ slot, layerId, entry, label, back, moment, onMom
|
|
|
86
87
|
)}
|
|
87
88
|
</Box>
|
|
88
89
|
<TestProgress {...progress} />
|
|
89
|
-
{waiting &&
|
|
90
|
-
<Text role="status" component="div" c="dimmed">
|
|
91
|
-
Loading the recording…
|
|
92
|
-
</Text>
|
|
93
|
-
)}
|
|
90
|
+
{waiting && <EvidenceWaiting label="Loading the recording…" usable="The way back to the spec is available." />}
|
|
94
91
|
{!chosen && !waiting && !progress.tests.length && (
|
|
95
92
|
<Alert color={'blue'} my="sm">
|
|
96
93
|
{unavailable ??
|
|
97
|
-
|
|
94
|
+
(failed.length > 0
|
|
95
|
+
? 'This result was not found in the layers that could be read, and some layers could not be read.'
|
|
96
|
+
: 'This result is not in the combined report any more. A newer run may have replaced it, or its source is switched off.')}{' '}
|
|
97
|
+
{(unavailable || failed.length > 0) && (
|
|
98
|
+
<Button type="button" onClick={retry} variant="subtle">
|
|
99
|
+
Try again
|
|
100
|
+
</Button>
|
|
101
|
+
)}
|
|
102
|
+
</Alert>
|
|
103
|
+
)}
|
|
104
|
+
{chosen && layerId && !entry && chosen.layer.id !== layerId && ready && pending === 0 && (
|
|
105
|
+
<Alert role="status" color={'blue'} my="sm">
|
|
106
|
+
The layer this link names is not among the results held for this test. Showing the current result instead.
|
|
98
107
|
</Alert>
|
|
99
108
|
)}
|
|
109
|
+
{ready && !chosen && <EvidenceIncomplete />}
|
|
100
110
|
{chosen && !paths && (
|
|
101
111
|
<Alert color={'blue'} my="sm">
|
|
102
112
|
This result has an outcome and no recorded timeline.
|
|
@@ -6,6 +6,7 @@ import type { ReactNode } from 'react';
|
|
|
6
6
|
import type { SpecKind } from '../spec-kind';
|
|
7
7
|
import { useEvidence } from './evidence-context';
|
|
8
8
|
import { useNow } from './format';
|
|
9
|
+
import { EvidenceIncomplete, EvidenceWaiting } from './load-state';
|
|
9
10
|
import { CountLine, Meter, Origin } from './marks';
|
|
10
11
|
/** A spec's name, where it lives, and what the combined report holds for it. */
|
|
11
12
|
export function SpecHeader({
|
|
@@ -23,7 +24,7 @@ export function SpecHeader({
|
|
|
23
24
|
planned?: number;
|
|
24
25
|
children?: ReactNode;
|
|
25
26
|
}) {
|
|
26
|
-
const { backends, ready, unavailable, pending } = useEvidence();
|
|
27
|
+
const { backends, ready, unavailable, pending, failed } = useEvidence();
|
|
27
28
|
const now = useNow();
|
|
28
29
|
const summary = entry?.summary ?? NO_EVIDENCE;
|
|
29
30
|
const newest = entry?.slots
|
|
@@ -50,13 +51,13 @@ export function SpecHeader({
|
|
|
50
51
|
<Text span inherit>
|
|
51
52
|
Recorded results are unavailable.
|
|
52
53
|
</Text>
|
|
53
|
-
) : !ready ? (
|
|
54
|
-
<
|
|
55
|
-
Loading recorded results…
|
|
56
|
-
</Text>
|
|
54
|
+
) : !ready || (summary.status === 'none' && pending > 0) ? (
|
|
55
|
+
<EvidenceWaiting usable={`The ${kind.one} below is readable.`} />
|
|
57
56
|
) : summary.status === 'none' ? (
|
|
58
57
|
<Text span inherit>
|
|
59
|
-
{
|
|
58
|
+
{failed.length > 0
|
|
59
|
+
? `No completed test report for this ${kind.one} in the layers that could be read.`
|
|
60
|
+
: `No completed test report for this ${kind.one} yet.`}
|
|
60
61
|
</Text>
|
|
61
62
|
) : (
|
|
62
63
|
<>
|
|
@@ -69,6 +70,7 @@ export function SpecHeader({
|
|
|
69
70
|
</>
|
|
70
71
|
)}
|
|
71
72
|
</Flex>
|
|
73
|
+
{ready && <EvidenceIncomplete />}
|
|
72
74
|
{newest && (
|
|
73
75
|
<Flex gap="xs" align="center" wrap="wrap" c="dimmed">
|
|
74
76
|
Latest from <Origin layer={newest.layer} backends={backends} now={now} />
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { Alert, Button, Container, Text, Title } from '@mantine/core';
|
|
2
|
-
import { useQuery } from '@tanstack/react-query';
|
|
3
|
-
import { useNavigate, useParams, useSearch } from '@tanstack/react-router';
|
|
1
|
+
import { Alert, Anchor, Button, Container, Group, List, Stack, Text, Title } from '@mantine/core';
|
|
2
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { Link, useNavigate, useParams, useSearch } from '@tanstack/react-router';
|
|
4
4
|
import { lazy, Suspense } from 'react';
|
|
5
5
|
import { keys, specKey, useAdapter } from '../data/queries';
|
|
6
|
-
import { outlinePath, specFile, specKind } from '../spec-kind';
|
|
6
|
+
import { outlinePath, resolveSpec, similarSpecs, specFile, specKind, type SpecKind } from '../spec-kind';
|
|
7
7
|
import type { SelectCheck } from './check-detail';
|
|
8
8
|
import { useEvidence } from './evidence-context';
|
|
9
|
+
import { isMissing, useStalled } from './load-state';
|
|
10
|
+
import { SpecHeader } from './spec-header';
|
|
9
11
|
import { Overview } from './overview';
|
|
10
12
|
// Each kind of spec has a page with a parser of its own (Gherkin, Quint, DMN); a page loads only the one it shows.
|
|
11
13
|
const DocumentPage = lazy(() => import('./document-page').then((m) => ({ default: m.DocumentPage })));
|
|
@@ -27,6 +29,63 @@ function Loading({ children }: { children: React.ReactNode }) {
|
|
|
27
29
|
</Suspense>
|
|
28
30
|
);
|
|
29
31
|
}
|
|
32
|
+
/** What a spec that cannot be shown still says: where it was looked for, what is recorded for it, and the ways on. */
|
|
33
|
+
function SpecUnavailable({
|
|
34
|
+
kind,
|
|
35
|
+
file,
|
|
36
|
+
missing,
|
|
37
|
+
detail,
|
|
38
|
+
retry,
|
|
39
|
+
run
|
|
40
|
+
}: {
|
|
41
|
+
kind: SpecKind;
|
|
42
|
+
file: string;
|
|
43
|
+
missing: boolean;
|
|
44
|
+
detail: string;
|
|
45
|
+
retry: () => void;
|
|
46
|
+
run: string | undefined;
|
|
47
|
+
}) {
|
|
48
|
+
const evidence = useEvidence();
|
|
49
|
+
const listed = evidence.files.filter(kind.accepts);
|
|
50
|
+
const similar = similarSpecs(file, listed);
|
|
51
|
+
const entry = evidence.view.specs.get(outlinePath(file));
|
|
52
|
+
return (
|
|
53
|
+
<Container component="article" size="xl" w="100%" py="xl">
|
|
54
|
+
<SpecHeader title={file.split('/').at(-1)!} file={file} kind={kind} entry={entry}>
|
|
55
|
+
<Alert role={missing ? 'status' : 'alert'} color={missing ? 'orange' : 'red'} my="sm" title={missing ? `This ${kind.one} is not available` : `The ${kind.one} could not be read`}>
|
|
56
|
+
<Stack gap="sm">
|
|
57
|
+
<Text component="div">
|
|
58
|
+
{missing ? `specs/${file} is not in this spec set. ` : ''}
|
|
59
|
+
{detail}
|
|
60
|
+
{evidence.filesPending || evidence.filesError ? '' : ` ${listed.length} ${listed.length === 1 ? kind.one : kind.many} are listed.`}
|
|
61
|
+
{entry && entry.slots.length > 0 ? ' Results recorded against it are shown above.' : ''}
|
|
62
|
+
</Text>
|
|
63
|
+
{similar.length > 0 && (
|
|
64
|
+
<>
|
|
65
|
+
<Text component="div">Did you mean:</Text>
|
|
66
|
+
<List listStyleType="none">
|
|
67
|
+
{similar.map((candidate) => (
|
|
68
|
+
<List.Item key={candidate}>
|
|
69
|
+
<Anchor renderRoot={(props) => <Link {...props} to="/specs/$kind" params={{ kind: kind.id }} search={{ run, spec: candidate }} />}>{candidate}</Anchor>
|
|
70
|
+
</List.Item>
|
|
71
|
+
))}
|
|
72
|
+
</List>
|
|
73
|
+
</>
|
|
74
|
+
)}
|
|
75
|
+
<Group gap="sm">
|
|
76
|
+
<Button type="button" onClick={retry} variant="light">
|
|
77
|
+
Try again
|
|
78
|
+
</Button>
|
|
79
|
+
<Button variant="subtle" renderRoot={(props) => <Link {...props} to="/specs/$kind" params={{ kind: kind.id }} search={{ run }} />}>
|
|
80
|
+
Back to {kind.label}
|
|
81
|
+
</Button>
|
|
82
|
+
</Group>
|
|
83
|
+
</Stack>
|
|
84
|
+
</Alert>
|
|
85
|
+
</SpecHeader>
|
|
86
|
+
</Container>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
30
89
|
/** One spec tab: its overview, one of its documents, or a recording opened from that document. */
|
|
31
90
|
export function SpecPage() {
|
|
32
91
|
const params = useParams({ from: '/specs/$kind' });
|
|
@@ -35,7 +94,8 @@ export function SpecPage() {
|
|
|
35
94
|
const adapter = useAdapter();
|
|
36
95
|
const evidence = useEvidence();
|
|
37
96
|
const kind = specKind(params.kind);
|
|
38
|
-
|
|
97
|
+
// A link may name a spec by another form of its path (with the folder, in other letter case): the listed one is shown.
|
|
98
|
+
const file = search.spec ? resolveSpec(specFile(search.spec), evidence.files) : '';
|
|
39
99
|
const readable = kind !== null && file.length > 0 && kind.accepts(file);
|
|
40
100
|
const text = useQuery({
|
|
41
101
|
queryKey: keys.specFile(specKey(adapter), file),
|
|
@@ -43,6 +103,12 @@ export function SpecPage() {
|
|
|
43
103
|
enabled: readable,
|
|
44
104
|
retry: false
|
|
45
105
|
});
|
|
106
|
+
const stalled = useStalled(text.isLoading, file);
|
|
107
|
+
const client = useQueryClient();
|
|
108
|
+
// A read that has not answered is dropped before asking again; asking again beside it would only wait for the same answer.
|
|
109
|
+
const retryText = () => {
|
|
110
|
+
void client.cancelQueries({ queryKey: keys.specFile(specKey(adapter), file) }).then(() => text.refetch());
|
|
111
|
+
};
|
|
46
112
|
if (!kind) {
|
|
47
113
|
return (
|
|
48
114
|
<Container component="article" size="xl" w="100%" py="xl">
|
|
@@ -101,24 +167,41 @@ export function SpecPage() {
|
|
|
101
167
|
</Loading>
|
|
102
168
|
);
|
|
103
169
|
}
|
|
104
|
-
if (text.isPending)
|
|
170
|
+
if (text.isPending || (text.isError && evidence.filesPending))
|
|
105
171
|
return (
|
|
106
172
|
<Container component="article" size="xl" w="100%" py="xl">
|
|
107
173
|
<Text role="status" component="div" c="dimmed">
|
|
108
|
-
Loading
|
|
174
|
+
Loading specs/{file}…
|
|
109
175
|
</Text>
|
|
176
|
+
{stalled && (
|
|
177
|
+
<Alert role="status" color="orange" my="sm" title={`Reading the ${kind.one} is taking longer than expected`}>
|
|
178
|
+
<Group gap="sm">
|
|
179
|
+
<Text span inherit>
|
|
180
|
+
specs/{file} has not arrived yet. Recorded results for other specs are not affected.
|
|
181
|
+
</Text>
|
|
182
|
+
<Button type="button" onClick={retryText} variant="light">
|
|
183
|
+
Try again
|
|
184
|
+
</Button>
|
|
185
|
+
<Button variant="subtle" renderRoot={(props) => <Link {...props} to="/specs/$kind" params={{ kind: kind.id }} search={{ run: search.run }} />}>
|
|
186
|
+
Back to {kind.label}
|
|
187
|
+
</Button>
|
|
188
|
+
</Group>
|
|
189
|
+
</Alert>
|
|
190
|
+
)}
|
|
110
191
|
</Container>
|
|
111
192
|
);
|
|
112
193
|
if (text.isError) {
|
|
194
|
+
const listedElsewhere = !evidence.filesPending && !evidence.filesError && !evidence.files.includes(file);
|
|
195
|
+
const missing = isMissing(text.error) || listedElsewhere;
|
|
113
196
|
return (
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
197
|
+
<SpecUnavailable
|
|
198
|
+
kind={kind}
|
|
199
|
+
file={file}
|
|
200
|
+
missing={missing}
|
|
201
|
+
detail={text.error instanceof Error ? text.error.message : ''}
|
|
202
|
+
retry={retryText}
|
|
203
|
+
run={search.run}
|
|
204
|
+
/>
|
|
122
205
|
);
|
|
123
206
|
}
|
|
124
207
|
const page = { file, text: text.data, kind, entry, selection: { check: search.check, layer: search.layer }, select };
|
|
@@ -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) {
|