@crvouga/atlas 1.18.0 → 1.20.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 +14 -1
- package/dist/reports/src/evidence-detail.js +13 -3
- package/dist/reports/src/evidence-types.d.ts +4 -0
- package/dist/schema/src/api.d.ts +105 -0
- package/dist/schema/src/api.js +35 -0
- package/dist/server/src/app.js +3 -1
- package/dist/server/src/openapi.d.ts +1 -1
- package/dist/server/src/openapi.js +24 -2
- package/dist/server/src/recording-verification.d.ts +34 -0
- package/dist/server/src/recording-verification.js +118 -0
- package/docs/reference/http-api.md +52 -0
- package/docs/reference/reports.md +4 -0
- package/docs/reference/schema.md +41 -0
- package/docs/reference/server.md +39 -0
- package/docs/rich-evidence.md +13 -0
- package/docs/server.md +12 -0
- package/docs/ui.md +40 -1
- package/llms-full.txt +117 -1
- package/package.json +1 -1
- package/packages/schema/schemas/effective-delta.schema.json +18 -0
- package/packages/schema/schemas/effective-evidence.schema.json +18 -0
- package/packages/schema/schemas/evidence-page.schema.json +9 -0
- package/packages/schema/schemas/layer-rows.schema.json +9 -0
- package/packages/schema/schemas/report-run.schema.json +9 -0
- package/packages/schema/schemas/review-collection.schema.json +9 -0
- package/packages/schema/schemas/review-manifest.schema.json +9 -0
- package/packages/schema/schemas/test-catalog.schema.json +9 -0
- package/packages/schema/schemas/test-history.schema.json +18 -0
- package/packages/server/openapi.json +653 -1
- package/packages/visualizer/src/app/AppShell.tsx +2 -2
- package/packages/visualizer/src/app/router.tsx +25 -1
- package/packages/visualizer/src/components/Header.tsx +15 -4
- package/packages/visualizer/src/evidence/behavior-index.test.ts +119 -0
- package/packages/visualizer/src/evidence/behavior-index.ts +162 -0
- package/packages/visualizer/src/evidence/behavior-panel.tsx +36 -22
- package/packages/visualizer/src/evidence/behavior-return.test.ts +41 -0
- package/packages/visualizer/src/evidence/behavior-return.ts +59 -0
- package/packages/visualizer/src/evidence/behavior.ts +32 -1
- package/packages/visualizer/src/evidence/behaviors-page.tsx +465 -0
- package/packages/visualizer/src/evidence/feature-page.tsx +5 -26
- package/packages/visualizer/src/evidence/scoped-summary-view.tsx +110 -0
- package/packages/visualizer/src/evidence/scoped-summary.test.ts +62 -0
- package/packages/visualizer/src/evidence/scoped-summary.ts +118 -0
- package/packages/visualizer/src/evidence/spec-texts.ts +88 -0
- package/packages/visualizer/src/explorer/ArtifactsView.tsx +4 -4
- package/packages/visualizer/src/explorer/Explorer.tsx +1 -1
- package/packages/visualizer/src/explorer/RunsView.tsx +36 -10
- package/packages/visualizer/src/explorer/catalog.tsx +24 -1
- package/packages/visualizer/src/explorer/collections.tsx +5 -5
- package/packages/visualizer/src/explorer/navigate.ts +17 -0
- package/packages/visualizer/src/explorer/playlist-entries.test.ts +10 -1
- package/packages/visualizer/src/explorer/playlist-entries.ts +10 -0
- package/packages/visualizer/src/explorer/playlist.tsx +43 -8
- package/packages/visualizer/src/explorer/recording-verification.ts +58 -0
- package/packages/visualizer/src/explorer/search.ts +7 -1
- package/packages/visualizer/src/find/Finder.tsx +1 -62
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Behaviors across the project
|
|
3
|
+
*
|
|
4
|
+
* `/behaviors`: every behavior of every Gherkin feature in one searchable list, each with its coverage state, and the
|
|
5
|
+
* Read / Watch / Inspect panel of the one selected. A behavior opens into a dedicated read-and-watch workspace that uses
|
|
6
|
+
* the width of the screen: what should happen beside its recording, the scoped outcome, reliability and performance
|
|
7
|
+
* summary below, and the technical record behind a disclosure. The search words, coverage filter and selection are in
|
|
8
|
+
* the address; where the reader had scrolled to, how much of the list was shown and which row had focus are remembered
|
|
9
|
+
* for the session, so leaving the workspace gives the list back as it was, with focus on the behavior that was open.
|
|
10
|
+
*/
|
|
11
|
+
import { Accordion, Alert, Anchor, Badge, Box, Button, Container, Grid, Group, NavLink, Paper, SimpleGrid, Stack, Text, TextInput, Title } from '@mantine/core';
|
|
12
|
+
import { IconArrowLeft, IconArrowsMaximize, IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
|
|
13
|
+
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
|
|
14
|
+
import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
|
|
15
|
+
|
|
16
|
+
import { SearchNotes, TypedFilters } from '../explorer/search-notes';
|
|
17
|
+
import { useDocSearch } from '../find/doc-search';
|
|
18
|
+
import { passesFilters } from '../find/query';
|
|
19
|
+
import type { BehaviorsSearch } from '../app/router';
|
|
20
|
+
import { specKind } from '../spec-kind';
|
|
21
|
+
import { ApplicabilitySummary } from './applicability-view';
|
|
22
|
+
import { behaviorDoc, indexBehaviors, type IndexedBehavior } from './behavior-index';
|
|
23
|
+
import { BehaviorPanel, behaviorSteps, CoverageBadge, Player, Provenance, RevisionLine } from './behavior-panel';
|
|
24
|
+
import { forgetReturn, recallReturn, rememberReturn, scopeKey } from './behavior-return';
|
|
25
|
+
import { COVERAGE, COVERAGE_ORDER, coverageCounts, type CoverageState } from './behavior';
|
|
26
|
+
import { CheckDetail, PlainSteps, type SelectCheck } from './check-detail';
|
|
27
|
+
import { useEvidence } from './evidence-context';
|
|
28
|
+
import { plural } from './format';
|
|
29
|
+
import { ScopedSummary } from './scoped-summary-view';
|
|
30
|
+
import { useSpecTexts } from './spec-texts';
|
|
31
|
+
|
|
32
|
+
/** Rows of the list shown before "Show more". */
|
|
33
|
+
const PAGE = 40;
|
|
34
|
+
|
|
35
|
+
/** The domain heading for behaviors whose spec declares none. */
|
|
36
|
+
const NO_DOMAIN = 'No domain declared';
|
|
37
|
+
|
|
38
|
+
/** Reads every feature and files its behaviors; `pending` while features are still being read. */
|
|
39
|
+
function useBehaviorIndex() {
|
|
40
|
+
const evidence = useEvidence();
|
|
41
|
+
const gherkin = specKind('gherkin')!;
|
|
42
|
+
const files = useMemo(() => evidence.files.filter(gherkin.accepts), [evidence.files, gherkin]);
|
|
43
|
+
const { texts, pending } = useSpecTexts(files, true, false);
|
|
44
|
+
const index = useMemo(
|
|
45
|
+
() => indexBehaviors({ files, texts, specs: evidence.specs, view: evidence.view, evidence: evidence.evidence }),
|
|
46
|
+
[files, texts, evidence.specs, evidence.view, evidence.evidence]
|
|
47
|
+
);
|
|
48
|
+
return { index, pending: pending || evidence.filesPending, filesError: evidence.filesError };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What the search and coverage filter leave of the project's behaviors, best match first when words were typed. */
|
|
52
|
+
function useListing(entries: readonly IndexedBehavior[], search: BehaviorsSearch) {
|
|
53
|
+
const docs = useMemo(() => new Map(entries.map((entry) => [entry.id, behaviorDoc(entry)] as const)), [entries]);
|
|
54
|
+
const list = useMemo(() => [...docs.values()], [docs]);
|
|
55
|
+
const found = useDocSearch(list, useDeferredValue(search.q));
|
|
56
|
+
const scoped = useMemo(() => {
|
|
57
|
+
const kept = entries.filter((entry) => (!found.hits || found.hits.has(entry.id)) && passesFilters(docs.get(entry.id)?.facets, found.parsed.filters));
|
|
58
|
+
if (!found.hits) return kept;
|
|
59
|
+
const order = new Map([...found.hits.keys()].map((id, at) => [id, at] as const));
|
|
60
|
+
return [...kept].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
|
|
61
|
+
}, [entries, docs, found.hits, found.parsed]);
|
|
62
|
+
const counts = useMemo(() => coverageCounts(scoped.map((entry) => entry.behavior)), [scoped]);
|
|
63
|
+
const shown = useMemo(() => (search.coverage ? scoped.filter((entry) => entry.behavior.state === search.coverage) : scoped), [scoped, search.coverage]);
|
|
64
|
+
return { found, scoped, shown, counts, ranked: found.hits !== null };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type Listing = ReturnType<typeof useListing>;
|
|
68
|
+
|
|
69
|
+
/** The technical record of a behavior: runs, environments, attempts and files, as on the feature page. */
|
|
70
|
+
function Inspect({ entry }: { entry: IndexedBehavior }) {
|
|
71
|
+
const search = useSearch({ from: '/behaviors' });
|
|
72
|
+
const navigate = useNavigate({ from: '/behaviors' });
|
|
73
|
+
const { slot } = entry.behavior;
|
|
74
|
+
const written = <PlainSteps steps={entry.behavior.scenario.steps} examples={entry.behavior.scenario.examples} />;
|
|
75
|
+
if (!slot) {
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
<Text component="div" c="dimmed">
|
|
79
|
+
No completed test report for this scenario yet.
|
|
80
|
+
</Text>
|
|
81
|
+
{written}
|
|
82
|
+
</>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const select: SelectCheck = (patch) => {
|
|
86
|
+
// A full recording page belongs to the feature's own page.
|
|
87
|
+
if (patch.view === 'recording') {
|
|
88
|
+
void navigate({ to: '/specs/$kind', params: { kind: 'gherkin' }, search: { spec: entry.file, check: slot.id, layer: patch.layer ?? undefined, view: 'recording' } });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (patch.layer !== undefined) void navigate({ search: (prev) => ({ ...prev, layer: patch.layer ?? undefined }), replace: true, resetScroll: false });
|
|
92
|
+
};
|
|
93
|
+
return <CheckDetail slot={slot} layerId={search.layer} select={select} reads="steps" written={written} />;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The feature a behavior is written in, with its rule, as a line. */
|
|
97
|
+
function whereIs(entry: IndexedBehavior): string {
|
|
98
|
+
return [entry.feature, entry.rule].filter(Boolean).join(' › ');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The address of a behavior in the index: its selection replaces the previous one and its pinned run and moment are dropped. */
|
|
102
|
+
function selecting(id: string, workspace: boolean) {
|
|
103
|
+
return (prev: BehaviorsSearch): BehaviorsSearch => ({
|
|
104
|
+
...prev,
|
|
105
|
+
behavior: id,
|
|
106
|
+
workspace: workspace ? true : undefined,
|
|
107
|
+
...(workspace ? {} : { layer: undefined, clip: undefined, clipAt: undefined })
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One behavior in the list: its words, where it is written and its coverage state, as a link that selects it. */
|
|
112
|
+
function Row({ entry, selected }: { entry: IndexedBehavior; selected: boolean }) {
|
|
113
|
+
return (
|
|
114
|
+
<NavLink
|
|
115
|
+
label={entry.title}
|
|
116
|
+
description={whereIs(entry)}
|
|
117
|
+
active={selected}
|
|
118
|
+
aria-current={selected ? 'true' : undefined}
|
|
119
|
+
data-behavior-row={entry.id}
|
|
120
|
+
data-coverage={entry.behavior.state}
|
|
121
|
+
rightSection={<CoverageBadge state={entry.behavior.state} />}
|
|
122
|
+
renderRoot={(props) => <Link {...props} to="/behaviors" search={selecting(entry.id, false)} resetScroll={false} />}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** What the project's behaviors add up to in this search: one button per coverage state that filters the list to it. */
|
|
128
|
+
function CoverageFilter({ listing, coverage }: { listing: Listing; coverage: CoverageState | undefined }) {
|
|
129
|
+
const navigate = useNavigate({ from: '/behaviors' });
|
|
130
|
+
const set = (next: CoverageState | undefined) => void navigate({ search: (prev) => ({ ...prev, coverage: next }), replace: true, resetScroll: false });
|
|
131
|
+
const { counts, scoped } = listing;
|
|
132
|
+
return (
|
|
133
|
+
<Stack gap="xs" component="section" aria-label="Coverage">
|
|
134
|
+
<Text component="div">
|
|
135
|
+
{counts.demonstrated} of {plural(scoped.length, 'behavior', 'behaviors')} demonstrated with a recording of the current spec.
|
|
136
|
+
</Text>
|
|
137
|
+
<Group role="group" aria-label="Coverage state" gap="xs">
|
|
138
|
+
<Button type="button" aria-pressed={!coverage} variant={coverage ? 'subtle' : 'light'} onClick={() => set(undefined)}>
|
|
139
|
+
All{' '}
|
|
140
|
+
<Text span inherit>
|
|
141
|
+
{scoped.length}
|
|
142
|
+
</Text>
|
|
143
|
+
</Button>
|
|
144
|
+
{COVERAGE_ORDER.filter((state) => state === 'demonstrated' || counts[state] > 0 || coverage === state).map((state) => (
|
|
145
|
+
<Button key={state} type="button" aria-pressed={coverage === state} variant={coverage === state ? 'light' : 'subtle'} title={COVERAGE[state].meaning} onClick={() => set(coverage === state ? undefined : state)}>
|
|
146
|
+
{COVERAGE[state].label}{' '}
|
|
147
|
+
<Text span inherit>
|
|
148
|
+
{counts[state]}
|
|
149
|
+
</Text>
|
|
150
|
+
</Button>
|
|
151
|
+
))}
|
|
152
|
+
</Group>
|
|
153
|
+
<Text component="div" c="dimmed">
|
|
154
|
+
A recording shows one run. It is illustrative evidence and does not mean a behavior is live or ready for production.
|
|
155
|
+
</Text>
|
|
156
|
+
</Stack>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** What could not be put in the list: features still being read, and features that could not be read. */
|
|
161
|
+
function Disclosure({ unread, features, unreadable, pending, error }: { unread: number; features: number; unreadable: readonly string[]; pending: boolean; error: boolean }) {
|
|
162
|
+
return (
|
|
163
|
+
<>
|
|
164
|
+
{error && (
|
|
165
|
+
<Alert role="alert" color="red" my="sm">
|
|
166
|
+
The list of features could not be loaded, so this list may be missing behaviors.
|
|
167
|
+
</Alert>
|
|
168
|
+
)}
|
|
169
|
+
{pending && unread > 0 && (
|
|
170
|
+
<Text role="status" component="div" c="dimmed" my="sm">
|
|
171
|
+
Reading features: {features - unread} of {features} read. Behaviors from the rest are not listed yet, so an empty answer is not final.
|
|
172
|
+
</Text>
|
|
173
|
+
)}
|
|
174
|
+
{unreadable.length > 0 && (
|
|
175
|
+
<Alert role="status" color="orange" my="sm" title={`${plural(unreadable.length, 'feature', 'features')} could not be read`}>
|
|
176
|
+
Their behaviors are not in this list: {unreadable.join(', ')}.
|
|
177
|
+
</Alert>
|
|
178
|
+
)}
|
|
179
|
+
</>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The list of behaviors, grouped by declared domain, or ranked best match first when words were typed. */
|
|
184
|
+
function BehaviorList({ listing, selected, limit, onMore }: { listing: Listing; selected: string | undefined; limit: number; onMore: () => void }) {
|
|
185
|
+
const { shown, ranked } = listing;
|
|
186
|
+
const visible = shown.slice(0, limit);
|
|
187
|
+
const groups = useMemo(() => {
|
|
188
|
+
if (ranked) return [['', visible] as const];
|
|
189
|
+
const byDomain = new Map<string, IndexedBehavior[]>();
|
|
190
|
+
for (const entry of visible) byDomain.set(entry.domain || NO_DOMAIN, [...(byDomain.get(entry.domain || NO_DOMAIN) ?? []), entry]);
|
|
191
|
+
return [...byDomain];
|
|
192
|
+
}, [visible, ranked]);
|
|
193
|
+
return (
|
|
194
|
+
<Stack gap="md" component="section" aria-label="Behavior list">
|
|
195
|
+
{groups.map(([domain, entries]) => (
|
|
196
|
+
<Box key={domain || 'ranked'} component="section" aria-label={domain || undefined}>
|
|
197
|
+
{domain && (
|
|
198
|
+
<Title order={2} mb="xs">
|
|
199
|
+
{domain}
|
|
200
|
+
</Title>
|
|
201
|
+
)}
|
|
202
|
+
<Stack gap="xs">
|
|
203
|
+
{entries.map((entry) => (
|
|
204
|
+
<Row key={entry.id} entry={entry} selected={selected === entry.id} />
|
|
205
|
+
))}
|
|
206
|
+
</Stack>
|
|
207
|
+
</Box>
|
|
208
|
+
))}
|
|
209
|
+
{shown.length > limit && (
|
|
210
|
+
<Button type="button" variant="subtle" onClick={onMore}>
|
|
211
|
+
Show more behaviors ({shown.length - limit} more)
|
|
212
|
+
</Button>
|
|
213
|
+
)}
|
|
214
|
+
</Stack>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The index: search, coverage filter, the list, and beside it the Read / Watch / Inspect panel of the selected behavior. */
|
|
219
|
+
function Index({ listing, search, index, pending, filesError }: { listing: Listing; search: BehaviorsSearch; index: ReturnType<typeof useBehaviorIndex>['index']; pending: boolean; filesError: boolean }) {
|
|
220
|
+
const navigate = useNavigate({ from: '/behaviors' });
|
|
221
|
+
const scope = scopeKey(search);
|
|
222
|
+
const [draft, setDraft] = useState(search.q ?? '');
|
|
223
|
+
// What the workspace remembered for this scope; it is kept until it has been given back.
|
|
224
|
+
const [point] = useState(() => recallReturn(scope));
|
|
225
|
+
const [limit, setLimit] = useState(Math.max(PAGE, point?.shown ?? 0));
|
|
226
|
+
const selected = search.behavior ? (index.entries.find((entry) => entry.id === search.behavior) ?? null) : null;
|
|
227
|
+
// The selected behavior is always in the list that is shown, however far down it is.
|
|
228
|
+
const at = selected ? listing.shown.findIndex((entry) => entry.id === selected.id) : -1;
|
|
229
|
+
const shownCount = Math.max(limit, at + 1);
|
|
230
|
+
const restored = useRef(false);
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (restored.current || pending || index.entries.length === 0) return;
|
|
233
|
+
restored.current = true;
|
|
234
|
+
const row = (id: string | null | undefined) =>
|
|
235
|
+
id ? [...document.querySelectorAll<HTMLElement>('[data-behavior-row]')].find((element) => element.getAttribute('data-behavior-row') === id) : undefined;
|
|
236
|
+
if (point) {
|
|
237
|
+
window.scrollTo(0, point.scrollY);
|
|
238
|
+
row(point.focus)?.focus({ preventScroll: true });
|
|
239
|
+
forgetReturn();
|
|
240
|
+
} else row(search.behavior)?.scrollIntoView?.({ block: 'nearest' });
|
|
241
|
+
}, [pending, index.entries.length, point, search.behavior]);
|
|
242
|
+
const setQuery = (q: string) => void navigate({ search: (prev) => ({ ...prev, q: q || undefined }), replace: true, resetScroll: false });
|
|
243
|
+
return (
|
|
244
|
+
<Container component="article" size="xl" w="100%" py="xl" aria-label="Behaviors">
|
|
245
|
+
<Stack gap="md">
|
|
246
|
+
<Box component="header">
|
|
247
|
+
<Title order={1} mb="xs">
|
|
248
|
+
Behaviors
|
|
249
|
+
</Title>
|
|
250
|
+
<Text component="div" c="dimmed">
|
|
251
|
+
Everything the project's features say should happen, found by its words. Read what should happen, watch a recording of it, and inspect the record behind it.
|
|
252
|
+
</Text>
|
|
253
|
+
</Box>
|
|
254
|
+
<TextInput
|
|
255
|
+
type="search"
|
|
256
|
+
aria-label="Search behaviors"
|
|
257
|
+
placeholder='Search behaviors, steps, tags… or filter: status:failed has:video domain: "exact phrase"'
|
|
258
|
+
value={draft}
|
|
259
|
+
onChange={(event) => {
|
|
260
|
+
setDraft(event.currentTarget.value);
|
|
261
|
+
setQuery(event.currentTarget.value.trim());
|
|
262
|
+
}}
|
|
263
|
+
/>
|
|
264
|
+
<Group gap="xs">
|
|
265
|
+
<TypedFilters
|
|
266
|
+
parsed={listing.found.parsed}
|
|
267
|
+
query={search.q ?? ''}
|
|
268
|
+
onChange={(next) => {
|
|
269
|
+
setDraft(next);
|
|
270
|
+
setQuery(next.trim());
|
|
271
|
+
}}
|
|
272
|
+
/>
|
|
273
|
+
</Group>
|
|
274
|
+
<SearchNotes issues={listing.found.parsed.issues} semantic={listing.found.status} />
|
|
275
|
+
<CoverageFilter listing={listing} coverage={search.coverage} />
|
|
276
|
+
<Disclosure unread={index.unread} features={index.features} unreadable={index.unreadable} pending={pending} error={filesError} />
|
|
277
|
+
{selected && (
|
|
278
|
+
<Group gap="sm" wrap="wrap" role="group" aria-label="Selected behavior">
|
|
279
|
+
<Text span fw={600}>
|
|
280
|
+
Selected: {selected.title}
|
|
281
|
+
</Text>
|
|
282
|
+
<Button
|
|
283
|
+
leftSection={<IconArrowsMaximize size={16} aria-hidden="true" />}
|
|
284
|
+
renderRoot={(props) => (
|
|
285
|
+
<Link
|
|
286
|
+
{...props}
|
|
287
|
+
to="/behaviors"
|
|
288
|
+
search={selecting(selected.id, true)}
|
|
289
|
+
resetScroll={false}
|
|
290
|
+
onClick={() => rememberReturn({ scope, scrollY: window.scrollY, shown: shownCount, focus: selected.id })}
|
|
291
|
+
/>
|
|
292
|
+
)}
|
|
293
|
+
>
|
|
294
|
+
Read and watch
|
|
295
|
+
</Button>
|
|
296
|
+
</Group>
|
|
297
|
+
)}
|
|
298
|
+
{index.entries.length === 0 && !pending && index.unreadable.length === 0 && !filesError ? (
|
|
299
|
+
<Text component="div">No behaviors: this project has no Gherkin features.</Text>
|
|
300
|
+
) : listing.shown.length === 0 && !pending ? (
|
|
301
|
+
<Text role="status" component="div">
|
|
302
|
+
{search.q || search.coverage ? 'No behavior matches this search.' : 'No behaviors yet.'}
|
|
303
|
+
</Text>
|
|
304
|
+
) : (
|
|
305
|
+
<Grid gap="lg">
|
|
306
|
+
<Grid.Col span={{ base: 12, lg: 5 }}>
|
|
307
|
+
<BehaviorList listing={listing} selected={selected?.id} limit={shownCount} onMore={() => setLimit(shownCount + PAGE)} />
|
|
308
|
+
</Grid.Col>
|
|
309
|
+
<Grid.Col span={{ base: 12, lg: 7 }}>
|
|
310
|
+
{selected ? (
|
|
311
|
+
<Paper component="section" aria-label={`Behavior: ${selected.title}`} withBorder p="md">
|
|
312
|
+
<Stack gap="sm" miw={0}>
|
|
313
|
+
<Title order={2}>{selected.title}</Title>
|
|
314
|
+
<Text component="div" c="dimmed">
|
|
315
|
+
{whereIs(selected)}
|
|
316
|
+
{' · '}
|
|
317
|
+
<Anchor renderRoot={(props) => <Link {...props} to="/specs/$kind" params={{ kind: 'gherkin' }} search={{ spec: selected.file, check: selected.behavior.slot?.id }} />}>Open the feature</Anchor>
|
|
318
|
+
</Text>
|
|
319
|
+
<BehaviorPanel key={selected.id} behavior={selected.behavior} applicability={selected.assessment} inspect={<Inspect entry={selected} />} />
|
|
320
|
+
</Stack>
|
|
321
|
+
</Paper>
|
|
322
|
+
) : search.behavior ? (
|
|
323
|
+
<Alert role="status" color="blue">
|
|
324
|
+
{pending ? 'Reading features to find that behavior…' : 'That behavior is not in the project now. It may have been renamed or removed from its feature.'}
|
|
325
|
+
</Alert>
|
|
326
|
+
) : (
|
|
327
|
+
<Text component="div" c="dimmed">
|
|
328
|
+
Select a behavior to read it and watch its recording.
|
|
329
|
+
</Text>
|
|
330
|
+
)}
|
|
331
|
+
</Grid.Col>
|
|
332
|
+
</Grid>
|
|
333
|
+
)}
|
|
334
|
+
</Stack>
|
|
335
|
+
</Container>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The dedicated workspace: what should happen beside its recording, the scoped summary, and the record behind a disclosure. */
|
|
340
|
+
function Workspace({ entry, listing }: { entry: IndexedBehavior; listing: Listing }) {
|
|
341
|
+
const heading = useRef<HTMLHeadingElement>(null);
|
|
342
|
+
const [inspecting, setInspecting] = useState<string | null>(null);
|
|
343
|
+
const { behavior } = entry;
|
|
344
|
+
const { steps, named } = useMemo(() => behaviorSteps(behavior), [behavior]);
|
|
345
|
+
const at = listing.shown.findIndex((candidate) => candidate.id === entry.id);
|
|
346
|
+
const previous = at > 0 ? listing.shown[at - 1] : undefined;
|
|
347
|
+
const next = at >= 0 ? listing.shown[at + 1] : undefined;
|
|
348
|
+
// A reader who arrives, or moves to the next behavior, lands on its heading: the page reads from the top and keyboard focus is there.
|
|
349
|
+
useEffect(() => heading.current?.focus({ preventScroll: true }), [entry.id]);
|
|
350
|
+
return (
|
|
351
|
+
<Container component="article" size="xl" w="100%" py="xl" aria-label={`Read and watch: ${entry.title}`}>
|
|
352
|
+
<Stack gap="md">
|
|
353
|
+
<Group justify="space-between" wrap="wrap">
|
|
354
|
+
<Button
|
|
355
|
+
variant="subtle"
|
|
356
|
+
leftSection={<IconArrowLeft size={16} aria-hidden="true" />}
|
|
357
|
+
renderRoot={(props) => <Link {...props} to="/behaviors" search={(prev) => ({ ...prev, workspace: undefined })} resetScroll={false} />}
|
|
358
|
+
>
|
|
359
|
+
Back to behaviors
|
|
360
|
+
</Button>
|
|
361
|
+
<Group gap="xs" role="group" aria-label="Move through the behaviors of this search">
|
|
362
|
+
{at >= 0 && (
|
|
363
|
+
<Text span c="dimmed">
|
|
364
|
+
{at + 1} of {listing.shown.length}
|
|
365
|
+
</Text>
|
|
366
|
+
)}
|
|
367
|
+
{previous && (
|
|
368
|
+
<Button variant="default" leftSection={<IconChevronLeft size={16} aria-hidden="true" />} renderRoot={(props) => <Link {...props} to="/behaviors" search={selecting(previous.id, true)} resetScroll={false} />}>
|
|
369
|
+
Previous behavior
|
|
370
|
+
</Button>
|
|
371
|
+
)}
|
|
372
|
+
{next && (
|
|
373
|
+
<Button variant="default" rightSection={<IconChevronRight size={16} aria-hidden="true" />} renderRoot={(props) => <Link {...props} to="/behaviors" search={selecting(next.id, true)} resetScroll={false} />}>
|
|
374
|
+
Next behavior
|
|
375
|
+
</Button>
|
|
376
|
+
)}
|
|
377
|
+
</Group>
|
|
378
|
+
</Group>
|
|
379
|
+
<Box component="header">
|
|
380
|
+
<Title order={1} tabIndex={-1} ref={heading} mb="xs">
|
|
381
|
+
{entry.title}
|
|
382
|
+
</Title>
|
|
383
|
+
<Group gap="sm" wrap="wrap">
|
|
384
|
+
<CoverageBadge state={behavior.state} />
|
|
385
|
+
{behavior.representative && <Badge variant="outline">Representative demo</Badge>}
|
|
386
|
+
<Text span c="dimmed">
|
|
387
|
+
{whereIs(entry)}
|
|
388
|
+
{entry.domain ? ` · ${entry.domain}` : ''}
|
|
389
|
+
</Text>
|
|
390
|
+
</Group>
|
|
391
|
+
</Box>
|
|
392
|
+
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
|
|
393
|
+
<Stack gap="sm" miw={0} component="section" aria-label="What should happen">
|
|
394
|
+
<Title order={2}>What should happen</Title>
|
|
395
|
+
{behavior.example !== null && (
|
|
396
|
+
<Text component="div" c="dimmed">
|
|
397
|
+
Example: {behavior.example}
|
|
398
|
+
</Text>
|
|
399
|
+
)}
|
|
400
|
+
<PlainSteps steps={steps} examples={behavior.example === null ? behavior.scenario.examples : []} />
|
|
401
|
+
{named && (
|
|
402
|
+
<Text component="div" c="dimmed">
|
|
403
|
+
The recorded example name does not match one row of the examples, so the values are not filled in.
|
|
404
|
+
</Text>
|
|
405
|
+
)}
|
|
406
|
+
</Stack>
|
|
407
|
+
<Stack gap="sm" miw={0} component="section" aria-label="Recording">
|
|
408
|
+
<Title order={2}>Recording</Title>
|
|
409
|
+
{behavior.demo ? (
|
|
410
|
+
<>
|
|
411
|
+
<Player result={behavior.demo} />
|
|
412
|
+
<Text component="div" c="dimmed">
|
|
413
|
+
Illustrative recording of one run. It shows what happened in that run and does not approve the behavior for production.
|
|
414
|
+
</Text>
|
|
415
|
+
</>
|
|
416
|
+
) : (
|
|
417
|
+
<Text component="div" c="dimmed">
|
|
418
|
+
Nothing to watch. {COVERAGE[behavior.state].meaning}
|
|
419
|
+
</Text>
|
|
420
|
+
)}
|
|
421
|
+
{behavior.subject && <Provenance subject={behavior.subject} />}
|
|
422
|
+
<RevisionLine revision={behavior.revision} />
|
|
423
|
+
<ApplicabilitySummary assessment={entry.assessment} release={{}} />
|
|
424
|
+
</Stack>
|
|
425
|
+
</SimpleGrid>
|
|
426
|
+
<ScopedSummary behavior={behavior} />
|
|
427
|
+
<Accordion variant="separated" value={inspecting} onChange={setInspecting}>
|
|
428
|
+
<Accordion.Item value="inspect">
|
|
429
|
+
<Accordion.Control>Inspect: runs, environments, attempts and files</Accordion.Control>
|
|
430
|
+
<Accordion.Panel>{inspecting === 'inspect' && <Inspect entry={entry} />}</Accordion.Panel>
|
|
431
|
+
</Accordion.Item>
|
|
432
|
+
</Accordion>
|
|
433
|
+
</Stack>
|
|
434
|
+
</Container>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** What the workspace shows while its behavior is not there: still being looked for, or gone. */
|
|
439
|
+
function WorkspaceMissing({ pending }: { pending: boolean }) {
|
|
440
|
+
return (
|
|
441
|
+
<Container component="article" size="xl" w="100%" py="xl">
|
|
442
|
+
<Stack gap="md">
|
|
443
|
+
<Title order={1}>Read and watch</Title>
|
|
444
|
+
<Alert role="status" color="blue">
|
|
445
|
+
{pending ? 'Reading features to find that behavior…' : 'That behavior is not in the project now. It may have been renamed or removed from its feature.'}
|
|
446
|
+
</Alert>
|
|
447
|
+
<Button variant="subtle" renderRoot={(props) => <Link {...props} to="/behaviors" search={(prev) => ({ ...prev, workspace: undefined, behavior: undefined })} />}>
|
|
448
|
+
Back to behaviors
|
|
449
|
+
</Button>
|
|
450
|
+
</Stack>
|
|
451
|
+
</Container>
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** The behaviors route: the project-wide index, or the workspace when one behavior is expanded. */
|
|
456
|
+
export function BehaviorsPage() {
|
|
457
|
+
const search = useSearch({ from: '/behaviors' });
|
|
458
|
+
const { index, pending, filesError } = useBehaviorIndex();
|
|
459
|
+
const listing = useListing(index.entries, search);
|
|
460
|
+
if (search.workspace) {
|
|
461
|
+
const entry = index.entries.find((candidate) => candidate.id === search.behavior);
|
|
462
|
+
return entry ? <Workspace entry={entry} listing={listing} /> : <WorkspaceMissing pending={pending} />;
|
|
463
|
+
}
|
|
464
|
+
return <Index listing={listing} search={search} index={index} pending={pending} filesError={filesError} />;
|
|
465
|
+
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SpecEvidence } from '@crvouga/atlas/reports/evidence-view';
|
|
2
2
|
import { Alert, Box, Container, Group, List, Stack, Text, Title } from '@mantine/core';
|
|
3
3
|
import { useMemo } from 'react';
|
|
4
4
|
import { outlinePath, type SpecKind } from '../spec-kind';
|
|
5
5
|
import { CheckDetail, PlainSteps, ResultRow, type CheckSelection, type SelectCheck } from './check-detail';
|
|
6
6
|
import { assessBehavior, hasRelease, needsAttention, type Assessment } from './applicability';
|
|
7
7
|
import { ApplicabilityControls, useApplicabilityState, VerdictBadge } from './applicability-view';
|
|
8
|
-
import {
|
|
8
|
+
import { coverageCounts, featureBehaviors, type RevisionSource } from './behavior';
|
|
9
9
|
import { BehaviorPanel, CoverageBadge, CoverageSummary } from './behavior-panel';
|
|
10
10
|
import { useEvidence } from './evidence-context';
|
|
11
|
-
import { featureModel, scenarioLabel,
|
|
11
|
+
import { featureModel, scenarioLabel, type FeatureModel, type FeatureScenario } from './gherkin';
|
|
12
12
|
import { Bounded } from './marks';
|
|
13
13
|
import { SpecHeader } from './spec-header';
|
|
14
14
|
import { SourceText } from './source-text';
|
|
@@ -39,10 +39,6 @@ export function FeaturePage({ file, text, kind, entry, selection, select }: Prop
|
|
|
39
39
|
const { feature, error } = useMemo(() => parse(text), [text]);
|
|
40
40
|
const slots = entry?.slots ?? [];
|
|
41
41
|
const runningOnly = useRunningFilter();
|
|
42
|
-
const filed = useMemo(
|
|
43
|
-
() => (feature ? slotsByScenario(feature, slots) : { byScenario: new Map<string, CheckSlot[]>(), leftOver: slots }),
|
|
44
|
-
[feature, slots]
|
|
45
|
-
);
|
|
46
42
|
const { specs, evidence } = useEvidence();
|
|
47
43
|
const specPath = outlinePath(file);
|
|
48
44
|
const revisions = useMemo<RevisionSource>(() => {
|
|
@@ -52,24 +48,7 @@ export function FeaturePage({ file, text, kind, entry, selection, select }: Prop
|
|
|
52
48
|
recorded: (id) => recorded.get(id)
|
|
53
49
|
};
|
|
54
50
|
}, [specs, evidence, specPath]);
|
|
55
|
-
const behaviors = useMemo(() =>
|
|
56
|
-
const byScenario = new Map<FeatureScenario, Behavior[]>();
|
|
57
|
-
for (const scenario of feature?.rules.flatMap((rule) => rule.scenarios.filter((each) => !each.background)) ?? []) {
|
|
58
|
-
byScenario.set(scenario, scenarioBehaviors(scenario, filed.byScenario.get(scenario.name) ?? [], feature!.name, revisions));
|
|
59
|
-
}
|
|
60
|
-
// A result no scenario claims is still a behavior that was checked, so it is counted.
|
|
61
|
-
const stray: FeatureScenario = {
|
|
62
|
-
keyword: 'Scenario',
|
|
63
|
-
name: '',
|
|
64
|
-
description: '',
|
|
65
|
-
background: false,
|
|
66
|
-
tags: [],
|
|
67
|
-
steps: [],
|
|
68
|
-
examples: []
|
|
69
|
-
};
|
|
70
|
-
const other = filed.leftOver.map((slot) => behaviorOf({ ...stray, name: slot.title }, slot, null, revisions, slot.key));
|
|
71
|
-
return { byScenario, all: [...[...byScenario.values()].flat(), ...other] };
|
|
72
|
-
}, [feature, filed, revisions]);
|
|
51
|
+
const behaviors = useMemo(() => featureBehaviors(feature, slots, revisions), [feature, slots, revisions]);
|
|
73
52
|
const counts = useMemo(() => coverageCounts(behaviors.all), [behaviors]);
|
|
74
53
|
const applicability = useApplicabilityState();
|
|
75
54
|
const { release, onlyStale } = applicability;
|
|
@@ -80,7 +59,7 @@ export function FeaturePage({ file, text, kind, entry, selection, select }: Prop
|
|
|
80
59
|
const staleCount = useMemo(() => [...assessments.values()].filter(needsAttention).length, [assessments]);
|
|
81
60
|
const hidden = (key: string) => onlyStale && assessments.get(key) !== undefined && !needsAttention(assessments.get(key)!);
|
|
82
61
|
const planned = behaviors.all.length;
|
|
83
|
-
const leftOver =
|
|
62
|
+
const leftOver = behaviors.leftOver.filter((slot) => (!runningOnly || slot.status === 'running') && !hidden(slot.key));
|
|
84
63
|
const toggle = (id: string) => select({ check: selection.check === id ? null : id, layer: null });
|
|
85
64
|
const rows = (scenario: FeatureScenario) =>
|
|
86
65
|
(behaviors.byScenario.get(scenario) ?? []).map((behavior) => {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Scoped behavior summary view
|
|
3
|
+
*
|
|
4
|
+
* A compact outcome, reliability and performance summary for one behavior, shown beside its recording. The three
|
|
5
|
+
* answers are kept apart and each is qualified: the outcome is what the shown result says (never the combined status),
|
|
6
|
+
* reliability names the independent executions it rests on, and product latency is only what a producer measured as such.
|
|
7
|
+
* Where there is no evidence the section says it is unknown. It reads the check's history only when it is on screen.
|
|
8
|
+
*/
|
|
9
|
+
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
10
|
+
import { Alert, Anchor, Badge, Box, Group, List, Paper, Stack, Text, Title } from '@mantine/core';
|
|
11
|
+
import { Link } from '@tanstack/react-router';
|
|
12
|
+
import { useMemo } from 'react';
|
|
13
|
+
|
|
14
|
+
import { useTestHistory } from '../explorer/catalog';
|
|
15
|
+
import type { Behavior } from './behavior';
|
|
16
|
+
import { outcomeHeadline } from './outcome';
|
|
17
|
+
import { scopedFigures, type SummaryLine } from './scoped-summary';
|
|
18
|
+
|
|
19
|
+
function Line({ line }: { line: SummaryLine }) {
|
|
20
|
+
return (
|
|
21
|
+
<Stack gap={0} data-tone={line.tone}>
|
|
22
|
+
<Group gap="xs" wrap="wrap">
|
|
23
|
+
<Text span inherit fw={600}>
|
|
24
|
+
{line.headline}
|
|
25
|
+
</Text>
|
|
26
|
+
{line.tone === 'weak' && (
|
|
27
|
+
<Badge color="orange" variant="light">
|
|
28
|
+
Read with care
|
|
29
|
+
</Badge>
|
|
30
|
+
)}
|
|
31
|
+
</Group>
|
|
32
|
+
<List listStyleType="none" c="dimmed">
|
|
33
|
+
{line.qualifiers.map((qualifier) => (
|
|
34
|
+
<List.Item key={qualifier}>{qualifier}</List.Item>
|
|
35
|
+
))}
|
|
36
|
+
</List>
|
|
37
|
+
</Stack>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The scoped summary of a behavior: the outcome of the result shown, and the reliability and product latency of the
|
|
43
|
+
* same check in the same environment. `behavior` supplies the result and check; with no recorded check the summary says
|
|
44
|
+
* every figure is unknown.
|
|
45
|
+
*/
|
|
46
|
+
export function ScopedSummary({ behavior }: { behavior: Behavior }) {
|
|
47
|
+
const { slot, subject } = behavior;
|
|
48
|
+
const history = useTestHistory(slot?.key);
|
|
49
|
+
const environment = subject?.layer.environment ?? null;
|
|
50
|
+
const figures = useMemo(
|
|
51
|
+
() => (history.data ? scopedFigures(slot!.key, history.data.history, { environment }) : null),
|
|
52
|
+
[history.data, slot, environment]
|
|
53
|
+
);
|
|
54
|
+
if (!slot || !subject) {
|
|
55
|
+
return (
|
|
56
|
+
<Paper component="section" aria-label="Scoped summary" withBorder p="md">
|
|
57
|
+
<Title order={4} mb="xs">
|
|
58
|
+
Outcome, reliability and performance
|
|
59
|
+
</Title>
|
|
60
|
+
<Text component="div" c="dimmed">
|
|
61
|
+
Nothing has been recorded for this behavior, so its outcome, reliability and product latency are unknown.
|
|
62
|
+
</Text>
|
|
63
|
+
</Paper>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return (
|
|
67
|
+
<Paper component="section" aria-label="Scoped summary" withBorder p="md">
|
|
68
|
+
<Stack gap="sm" miw={0}>
|
|
69
|
+
<Group gap="xs" wrap="wrap" justify="space-between">
|
|
70
|
+
<Title order={4}>Outcome, reliability and performance</Title>
|
|
71
|
+
<Text span c="dimmed">
|
|
72
|
+
{environment === null ? 'All environments pooled' : `In ${environmentLabel(environment)}`}
|
|
73
|
+
</Text>
|
|
74
|
+
</Group>
|
|
75
|
+
<Box>
|
|
76
|
+
<Text c="dimmed">Outcome</Text>
|
|
77
|
+
<Text component="div" fw={600} data-outcome-headline>
|
|
78
|
+
{outcomeHeadline({ layer: subject.layer, row: subject.row }, slot).text}
|
|
79
|
+
</Text>
|
|
80
|
+
</Box>
|
|
81
|
+
{history.isError ? (
|
|
82
|
+
<Alert role="alert" color="red">
|
|
83
|
+
The history of this behavior could not be read, so its reliability and product latency are unknown.
|
|
84
|
+
</Alert>
|
|
85
|
+
) : !figures ? (
|
|
86
|
+
<Text role="status" component="div" c="dimmed">
|
|
87
|
+
Reading the history…
|
|
88
|
+
</Text>
|
|
89
|
+
) : (
|
|
90
|
+
<>
|
|
91
|
+
<Box aria-label="Reliability" component="section">
|
|
92
|
+
<Text c="dimmed">Reliability</Text>
|
|
93
|
+
<Line line={figures.reliability} />
|
|
94
|
+
</Box>
|
|
95
|
+
<Box aria-label="Product latency" component="section">
|
|
96
|
+
<Text c="dimmed">Performance</Text>
|
|
97
|
+
{figures.latency.map((line) => (
|
|
98
|
+
<Line key={line.headline} line={line} />
|
|
99
|
+
))}
|
|
100
|
+
</Box>
|
|
101
|
+
</>
|
|
102
|
+
)}
|
|
103
|
+
<Text component="div" c="dimmed">
|
|
104
|
+
These describe the checks Atlas holds, not production. A passing recording is one clip of one run and does not approve the behavior.{' '}
|
|
105
|
+
<Anchor renderRoot={(props) => <Link {...props} to="/" search={{ test: slot.key, layer: subject.layer.id }} />}>See reliability and latency detail</Anchor>
|
|
106
|
+
</Text>
|
|
107
|
+
</Stack>
|
|
108
|
+
</Paper>
|
|
109
|
+
);
|
|
110
|
+
}
|