@crvouga/atlas 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/docs/ui.md +32 -2
  3. package/llms-full.txt +32 -2
  4. package/package.json +1 -1
  5. package/packages/visualizer/README.md +28 -0
  6. package/packages/visualizer/src/evidence/artifacts.tsx +17 -8
  7. package/packages/visualizer/src/evidence/behavior-panel.tsx +291 -0
  8. package/packages/visualizer/src/evidence/behavior.test.ts +99 -0
  9. package/packages/visualizer/src/evidence/behavior.ts +268 -0
  10. package/packages/visualizer/src/evidence/check-detail.tsx +4 -0
  11. package/packages/visualizer/src/evidence/feature-page.tsx +82 -36
  12. package/packages/visualizer/src/evidence/gherkin.ts +31 -1
  13. package/packages/visualizer/src/evidence/spec-index.tsx +48 -15
  14. package/packages/visualizer/src/explorer/Explorer.tsx +105 -25
  15. package/packages/visualizer/src/explorer/RunsView.tsx +173 -35
  16. package/packages/visualizer/src/explorer/catalog.tsx +9 -5
  17. package/packages/visualizer/src/explorer/search-notes.tsx +74 -0
  18. package/packages/visualizer/src/explorer/search.ts +16 -6
  19. package/packages/visualizer/src/find/Finder.tsx +127 -81
  20. package/packages/visualizer/src/find/behaviors.ts +205 -0
  21. package/packages/visualizer/src/find/corpus.ts +21 -7
  22. package/packages/visualizer/src/find/doc-search.ts +66 -0
  23. package/packages/visualizer/src/find/query.ts +247 -0
  24. package/packages/visualizer/src/find/rank.ts +195 -47
  25. package/packages/visualizer/src/find/search.test.ts +322 -0
  26. package/packages/visualizer/src/find/semantic-search.ts +153 -0
  27. package/packages/visualizer/src/find/semantic.ts +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
+ ## 1.6.0 (2026-10-29)
4
+
5
+ ### Features
6
+
7
+ - unify fuzzy semantic search with complete filterable sortable results ([3b0b90e](https://github.com/crvouga/atlas/commit/3b0b90e4960dcbcbcfee924f33aca4ce02beaaec))
8
+
9
+ ## 1.5.0 (2026-10-28)
10
+
11
+ ### Features
12
+
13
+ - connect readable behavior with recordings and coverage gaps (#55) ([0a22022](https://github.com/crvouga/atlas/commit/0a22022dac9f9956aa77214af5de7af4fd2b2d88))
14
+
3
15
  ## 1.4.0 (2026-10-28)
4
16
 
5
17
  ### Features
package/docs/ui.md CHANGED
@@ -67,6 +67,26 @@ and component defaults still live only in `ui/theme.tsx`. Quint/Alloy grammar so
67
67
  [`ui/grammars/README.md`](../packages/visualizer/src/ui/grammars/README.md). Unknown formats remain plain
68
68
  text, and parse failures must leave the source visible. Syntax highlighting never means a spec passed.
69
69
 
70
+ ## Behaviors: Read, Watch, Inspect
71
+
72
+ A Gherkin page reads as behaviors: each scenario, or each example row of an outline (never the outline template as an
73
+ extra), is one behavior in one coverage state (`evidence/behavior.ts`). Opening a behavior shows Mantine `Tabs`:
74
+ **Read** (the steps with the example's values filled in), **Watch** (what is expected next to the matched recording,
75
+ its status and provenance) and **Inspect** (the existing check detail: runs, environments, attempts, files). A behavior
76
+ with a playable recording opens on Watch, one in a run in progress on Inspect, otherwise on Read. The video is read only
77
+ when Watch is on screen.
78
+
79
+ - States stay distinct and none but `demonstrated` is a pass with proof: recording missing, recording of an older spec
80
+ revision, no recording, API only, failed, running, skipped, parked (tagged `@parked`, `@wip`, `@pending`) and not executed.
81
+ Counts are in the Coverage section above the source; an empty state is left out except `demonstrated`.
82
+ - The promise's revision (the spec's fingerprint now) and the evidence's revision (the fingerprint the layer recorded)
83
+ are shown separately. A known difference demotes a recording to "Recording is older" with an alert; a missing
84
+ fingerprint is "not recorded", never a match.
85
+ - A recording is always labelled illustrative: it shows one run and does not approve a behavior for production.
86
+ `demo`/`representative` tags only add a "Representative demo" label.
87
+ - The spec index searches titles, paths and declared domains, and can group by the domain from spec metadata (specs with
88
+ none are listed under "No domain declared"; the Tests view reports domain problems).
89
+
70
90
  ## Icons and notation marks
71
91
 
72
92
  Use named imports from `@tabler/icons-react` for all UI glyphs. The icon package supplies SVG paths;
@@ -81,6 +101,16 @@ visible notation label; marks are decorative. Sources, licenses and the DMN nota
81
101
  and update this mapping when adding a format. Brand artwork preserves upstream colors independently of
82
102
  Atlas's theme. SVG data plots and statechart arrow markers are geometry, not handcrafted UI icons.
83
103
 
104
+ ## Search
105
+
106
+ All search boxes (finder, test list, Runs tab, an opened run) use `src/find/query.ts` for the query language and
107
+ `src/find/rank.ts` for matching; do not add a second matcher or filter on `includes`. Build documents from
108
+ `src/find/behaviors.ts` so a behavior has the same fields and facets everywhere. Match first, then count, filter and sort
109
+ the whole set, then page. Show filters typed in the box as removable chips, show what was not understood
110
+ (`SearchNotes`), show why a result matched and mark exact ones, and say when matching is words only (no embedder, or
111
+ a failed one). A bounded list says it is bounded and offers all results. Selection follows result identity, never a
112
+ position, when results change. The query, filters and sort stay in the address.
113
+
84
114
  ## Accessibility and responsive behavior
85
115
 
86
116
  The shell collapses its index below Mantine's `md` breakpoint; its named Burger toggles mobile navigation.
@@ -127,11 +157,11 @@ The narrowly scoped exceptions are:
127
157
  | Shared shell | Project switching, spec navigation, run selection, connection status, diagnostics, mobile index | AppShell, Burger, NavLink, Menu, Popover, NativeSelect, Drawer |
128
158
  | Project home | Tests/runs/artifacts tabs, a run's Artifacts (files of its layers, shared recordings, direct links), metrics, search, status/environment/domain/artifact-tag filters, run and artifact facets with saved views, grouping, sorting, show more | Container, Tabs, SimpleGrid, Paper, TextInput, MultiSelect, Popover, Button, Accordion |
129
159
  | Test detail | Current outcomes, statistics, duration graph, history, artifacts, recording; the `layer` address parameter pins it to one run's result, labelled "This run" against "Latest combined evidence" | Drawer, Table, Badge, Alert, Stage |
130
- | Specs | All six existing kinds, overview/index, Gherkin rules/scenarios/examples, Quint declarations/traces, DMN tables, source text | SpecHeader, Accordion, Table, CodeHighlightTabs, ResultRow |
160
+ | Specs | All six existing kinds, overview/index with title search and folder or declared-domain grouping, Gherkin rules/scenarios/examples with a coverage summary and Read / Watch / Inspect per behavior, Quint declarations/traces, DMN tables, source text | SpecHeader, Accordion, Tabs, Table, CodeHighlightTabs, ResultRow |
131
161
  | Statecharts | Graph, screen/event/journey details, developer metadata, chart test results | Mantine cards/navigation and product compositions; graph exception above |
132
162
  | Evidence | Steps, retries, annotations, facts, unknown extensions, safe downloads and previews | List, Badge, Accordion, Code, Image, native media |
133
163
  | Recordings | Pictures/video/timeline, actor selection, moments, failure jumps, timing, model-state differences | Tabs, Slider, Button, ScrollArea, Table, Progress |
134
- | Search/help | Ranked and semantic search, shortcut help, recording shortcuts | Spotlight, Modal, Kbd |
164
+ | Search/help | Ranked, filterable and semantic search across behaviors, runs and results; complete results; shortcut help, recording shortcuts | Spotlight, TextInput, NativeSelect, Button chips, Alert, Modal, Kbd |
135
165
  | Administration | Token access, S3 storage notice, garbage collection policy (saved in S3), live timeouts, report rules, limits, raw JSON, conflicts | Same provider/theme, Fieldset, Mantine inputs, Accordion, Alert |
136
166
 
137
167
  The hash routes (`/`, `/chart/$chartId`, `/specs/$kind`), query contracts, project scoping, streaming,
package/llms-full.txt CHANGED
@@ -3899,6 +3899,26 @@ and component defaults still live only in `ui/theme.tsx`. Quint/Alloy grammar so
3899
3899
  [`ui/grammars/README.md`](../packages/visualizer/src/ui/grammars/README.md). Unknown formats remain plain
3900
3900
  text, and parse failures must leave the source visible. Syntax highlighting never means a spec passed.
3901
3901
 
3902
+ ## Behaviors: Read, Watch, Inspect
3903
+
3904
+ A Gherkin page reads as behaviors: each scenario, or each example row of an outline (never the outline template as an
3905
+ extra), is one behavior in one coverage state (`evidence/behavior.ts`). Opening a behavior shows Mantine `Tabs`:
3906
+ **Read** (the steps with the example's values filled in), **Watch** (what is expected next to the matched recording,
3907
+ its status and provenance) and **Inspect** (the existing check detail: runs, environments, attempts, files). A behavior
3908
+ with a playable recording opens on Watch, one in a run in progress on Inspect, otherwise on Read. The video is read only
3909
+ when Watch is on screen.
3910
+
3911
+ - States stay distinct and none but `demonstrated` is a pass with proof: recording missing, recording of an older spec
3912
+ revision, no recording, API only, failed, running, skipped, parked (tagged `@parked`, `@wip`, `@pending`) and not executed.
3913
+ Counts are in the Coverage section above the source; an empty state is left out except `demonstrated`.
3914
+ - The promise's revision (the spec's fingerprint now) and the evidence's revision (the fingerprint the layer recorded)
3915
+ are shown separately. A known difference demotes a recording to "Recording is older" with an alert; a missing
3916
+ fingerprint is "not recorded", never a match.
3917
+ - A recording is always labelled illustrative: it shows one run and does not approve a behavior for production.
3918
+ `demo`/`representative` tags only add a "Representative demo" label.
3919
+ - The spec index searches titles, paths and declared domains, and can group by the domain from spec metadata (specs with
3920
+ none are listed under "No domain declared"; the Tests view reports domain problems).
3921
+
3902
3922
  ## Icons and notation marks
3903
3923
 
3904
3924
  Use named imports from `@tabler/icons-react` for all UI glyphs. The icon package supplies SVG paths;
@@ -3913,6 +3933,16 @@ visible notation label; marks are decorative. Sources, licenses and the DMN nota
3913
3933
  and update this mapping when adding a format. Brand artwork preserves upstream colors independently of
3914
3934
  Atlas's theme. SVG data plots and statechart arrow markers are geometry, not handcrafted UI icons.
3915
3935
 
3936
+ ## Search
3937
+
3938
+ All search boxes (finder, test list, Runs tab, an opened run) use `src/find/query.ts` for the query language and
3939
+ `src/find/rank.ts` for matching; do not add a second matcher or filter on `includes`. Build documents from
3940
+ `src/find/behaviors.ts` so a behavior has the same fields and facets everywhere. Match first, then count, filter and sort
3941
+ the whole set, then page. Show filters typed in the box as removable chips, show what was not understood
3942
+ (`SearchNotes`), show why a result matched and mark exact ones, and say when matching is words only (no embedder, or
3943
+ a failed one). A bounded list says it is bounded and offers all results. Selection follows result identity, never a
3944
+ position, when results change. The query, filters and sort stay in the address.
3945
+
3916
3946
  ## Accessibility and responsive behavior
3917
3947
 
3918
3948
  The shell collapses its index below Mantine's `md` breakpoint; its named Burger toggles mobile navigation.
@@ -3959,11 +3989,11 @@ The narrowly scoped exceptions are:
3959
3989
  | Shared shell | Project switching, spec navigation, run selection, connection status, diagnostics, mobile index | AppShell, Burger, NavLink, Menu, Popover, NativeSelect, Drawer |
3960
3990
  | Project home | Tests/runs/artifacts tabs, a run's Artifacts (files of its layers, shared recordings, direct links), metrics, search, status/environment/domain/artifact-tag filters, run and artifact facets with saved views, grouping, sorting, show more | Container, Tabs, SimpleGrid, Paper, TextInput, MultiSelect, Popover, Button, Accordion |
3961
3991
  | Test detail | Current outcomes, statistics, duration graph, history, artifacts, recording; the `layer` address parameter pins it to one run's result, labelled "This run" against "Latest combined evidence" | Drawer, Table, Badge, Alert, Stage |
3962
- | Specs | All six existing kinds, overview/index, Gherkin rules/scenarios/examples, Quint declarations/traces, DMN tables, source text | SpecHeader, Accordion, Table, CodeHighlightTabs, ResultRow |
3992
+ | Specs | All six existing kinds, overview/index with title search and folder or declared-domain grouping, Gherkin rules/scenarios/examples with a coverage summary and Read / Watch / Inspect per behavior, Quint declarations/traces, DMN tables, source text | SpecHeader, Accordion, Tabs, Table, CodeHighlightTabs, ResultRow |
3963
3993
  | Statecharts | Graph, screen/event/journey details, developer metadata, chart test results | Mantine cards/navigation and product compositions; graph exception above |
3964
3994
  | Evidence | Steps, retries, annotations, facts, unknown extensions, safe downloads and previews | List, Badge, Accordion, Code, Image, native media |
3965
3995
  | Recordings | Pictures/video/timeline, actor selection, moments, failure jumps, timing, model-state differences | Tabs, Slider, Button, ScrollArea, Table, Progress |
3966
- | Search/help | Ranked and semantic search, shortcut help, recording shortcuts | Spotlight, Modal, Kbd |
3996
+ | Search/help | Ranked, filterable and semantic search across behaviors, runs and results; complete results; shortcut help, recording shortcuts | Spotlight, TextInput, NativeSelect, Button chips, Alert, Modal, Kbd |
3967
3997
  | Administration | Token access, S3 storage notice, garbage collection policy (saved in S3), live timeouts, report rules, limits, raw JSON, conflicts | Same provider/theme, Fieldset, Mantine inputs, Accordion, Alert |
3968
3998
 
3969
3999
  The hash routes (`/`, `/chart/$chartId`, `/specs/$kind`), query contracts, project scoping, streaming,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crvouga/atlas",
3
- "version": "1.4.0",
3
+ "version": "1.6.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",
@@ -154,6 +154,34 @@ Components read only the view model (`AtlasView`), never raw files.
154
154
  its own worker. Positions are cached under a hash of the chart's structure only, so new results
155
155
  never move anything.
156
156
 
157
+ ## Searching behaviors, runs and recordings
158
+
159
+ Every search box (the finder opened with `Ctrl+K` or `/`, the test list, the Runs tab and the results of an opened
160
+ run) reads one query language and matches with one engine (`src/find/query.ts`, `src/find/rank.ts`), so the same words
161
+ make the same behaviors eligible everywhere.
162
+
163
+ - **Words** match titles, steps, descriptions, specs, tags, environments and failure messages by stem, everyday synonym
164
+ and close spelling (`savde answer` finds "Keep a saved answer"), and the start of a longer word while it is typed.
165
+ Every distinctive word must be found; a word present in most behaviors is optional. When an embedder is available the
166
+ results are also re-ranked by meaning and a paraphrase with no word in common is added (`Similar meaning`); when none
167
+ is, or it fails, the box says it is matching words and spellings only.
168
+ - **Exact matches.** `"a quoted phrase"` and identifiers with `/`, `_` or `.` (`memory/keep`) must appear as written, are
169
+ marked `Exact` and are listed first.
170
+ - **Filters** narrow by structure instead of matching words: `status:failed` (`passed`, `failed`, `flaky`, `not-run`,
171
+ `running`), `env:ios` (also `platform:`, `environment:`, `device:`), `producer:`, `domain:`, `spec:`, `tag:`, `branch:`
172
+ and `has:video` (a playable recording, not the word). Values of one field are alternatives (`status:failed,flaky`);
173
+ different fields all hold. Each shows as a removable chip, and the status, environment and Has video buttons are the
174
+ same filters without the syntax.
175
+ - **Nothing is dropped silently.** An unknown operator, an unknown status, an unclosed quote, or a filter a list does not
176
+ have (`domain:` on runs) is listed under the box with what to type instead.
177
+ - **Complete results.** Counts, filters and sorting (including Best match, offered with words to match) apply to the whole
178
+ dataset before anything is shown in pages. The finder shows its first 40 results, says so, and opens all of them
179
+ (`Shift+Enter`, or the explorer's list for the same query). The query, filters and sort are in the address, so a link
180
+ and the back button restore them. Results that change under the cursor keep the highlighted result highlighted by
181
+ identity, and say so when it is no longer listed.
182
+
183
+ The standing rules are in [the UI conventions](../../docs/ui.md#search).
184
+
157
185
  ## Fixtures
158
186
 
159
187
  `fixtures/` holds seven scenarios generated by `scripts/make-fixtures.ts` from `example/specs`, a
@@ -146,6 +146,13 @@ function sizeOf(bytes: number | undefined) {
146
146
  if (bytes < 1024) return `${bytes} B`;
147
147
  return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(0)} KiB` : `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
148
148
  }
149
+ /** Where an attachment's bytes are, if a browser can reach them: its stored file in the layer, else its safe `http(s)` address. */
150
+ export function attachmentSource(attachment: Attachment, layer: string): { path: string | null; url: string | null } {
151
+ const path = attachment.bundleKey
152
+ ? `/layers/${encodeURIComponent(layer)}/blobs/${attachment.bundleKey.split('/').map(encodeURIComponent).join('/')}`
153
+ : null;
154
+ return { path, url: path ? reportAssetUrl(path) : safeUri(attachment.uri) };
155
+ }
149
156
  /**
150
157
  * One attachment as a card: it plays or shows itself when it can, and always says what it is (name, role, type, size,
151
158
  * duration, tags) and how to get it. A file that has no address, has an address that is not a web address, or that
@@ -154,10 +161,7 @@ function sizeOf(bytes: number | undefined) {
154
161
  */
155
162
  export function ArtifactCard({ attachment, layer, link, note, selected }: { attachment: Attachment; layer: string; link?: ReactNode; note?: ReactNode; selected?: boolean }) {
156
163
  const type = mediaType(attachment);
157
- const path = attachment.bundleKey
158
- ? `/layers/${encodeURIComponent(layer)}/blobs/${attachment.bundleKey.split('/').map(encodeURIComponent).join('/')}`
159
- : null;
160
- const url = path ? reportAssetUrl(path) : safeUri(attachment.uri);
164
+ const { path, url } = attachmentSource(attachment, layer);
161
165
  const renderer = artifactRenderers.find((candidate) => candidate.matches(attachment, type));
162
166
  const body = renderer?.render(attachment, { url, path, type });
163
167
  const item = useRef<HTMLLIElement>(null);
@@ -421,20 +425,25 @@ function Evidence({ holder, layer, steps }: { holder: EvidenceHolder; layer: str
421
425
  </ScopeProvider>
422
426
  );
423
427
  }
424
- /** Everything one result carries beyond its outcome, read when it is opened, and what the run around it carried. */
425
- export function ResultEvidence({ layer, row }: { layer: LayerHeader; row: LayerRow }) {
428
+ /** The full evidence of one result, read only when `enabled` and the result has some to read (an archived layer). Shared by every view of the result, so it is read once. */
429
+ export function useCheckDetail(layer: LayerHeader, row: LayerRow, enabled = true) {
426
430
  const wanted = row.evidence !== undefined || layer.evidence !== undefined;
427
- const detail = useQuery({
431
+ return useQuery({
428
432
  queryKey: ['evidence-detail', layer.backend, layer.id, row.kind, row.id],
429
433
  queryFn: ({ signal }) =>
430
434
  reportJson<CheckDetail>(
431
435
  `/evidence/detail/${encodeURIComponent(layer.backend)}/${encodeURIComponent(layer.id)}?check=${encodeURIComponent(row.id)}&kind=${encodeURIComponent(row.kind)}`,
432
436
  signal
433
437
  ),
434
- enabled: wanted && /^[0-9a-f]{64}$/.test(layer.id),
438
+ enabled: enabled && wanted && /^[0-9a-f]{64}$/.test(layer.id),
435
439
  staleTime: Number.POSITIVE_INFINITY,
436
440
  retry: false
437
441
  });
442
+ }
443
+ /** Everything one result carries beyond its outcome, read when it is opened, and what the run around it carried. */
444
+ export function ResultEvidence({ layer, row }: { layer: LayerHeader; row: LayerRow }) {
445
+ const wanted = row.evidence !== undefined || layer.evidence !== undefined;
446
+ const detail = useCheckDetail(layer, row);
438
447
  if (!wanted) return null;
439
448
  if (detail.isLoading)
440
449
  return (
@@ -0,0 +1,291 @@
1
+ /**
2
+ * @module Behavior panel
3
+ *
4
+ * How a product reader meets one behavior of a feature: Read (the promise and what is expected, with an outline's
5
+ * concrete example filled in), Watch (the matched recording beside what it should show) and Inspect (the technical
6
+ * record: run, environment, source, attempts). Also the coverage summary of the whole feature. A recording is always
7
+ * labelled as illustrative evidence with its own status, provenance and revision; it never stands for approval.
8
+ */
9
+ import type { EvidenceHolder } from '@crvouga/atlas/reports/evidence-detail';
10
+ import { isVideoAttachment } from '@crvouga/atlas/reports/evidence-detail';
11
+ import { effectiveScope } from '@crvouga/atlas/reports/evidence-scope';
12
+ import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
13
+ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
14
+ import type { Attachment } from '@crvouga/atlas/schema';
15
+ import { Alert, Badge, Box, Group, List, Paper, Stack, Tabs, Text, Title } from '@mantine/core';
16
+ import { useMemo, type ReactNode } from 'react';
17
+
18
+ import { attachmentSource, useCheckDetail } from './artifacts';
19
+ import { COVERAGE, COVERAGE_ORDER, type Behavior, type CoverageCounts, type CoverageState, type Revision } from './behavior';
20
+ import { PlainSteps } from './check-detail';
21
+ import { useEvidence } from './evidence-context';
22
+ import { ScopeNotice } from './evidence-scope';
23
+ import { plural, statusWord, useNow } from './format';
24
+ import { exampleIndexFor, exampleRows, fillExample, type FeatureStep } from './gherkin';
25
+ import { Origin, StatusMark } from './marks';
26
+
27
+ /** The short, readable form of a spec fingerprint (`<length>:<hash>:<hash>`): its length and the start of its hash. */
28
+ export function shortRevision(fingerprint: string): string {
29
+ const [length, hash] = fingerprint.split(':');
30
+ return hash ? `${hash.slice(0, 8)} (${length} characters)` : fingerprint.slice(0, 12);
31
+ }
32
+
33
+ /** A behavior's coverage state, always in words. */
34
+ export function CoverageBadge({ state }: { state: CoverageState }) {
35
+ return (
36
+ <Badge data-coverage={state} color={COVERAGE[state].color} variant="light" title={COVERAGE[state].meaning}>
37
+ {COVERAGE[state].label}
38
+ </Badge>
39
+ );
40
+ }
41
+
42
+ /**
43
+ * The promise and the evidence side by side, each with its own revision. A mismatch is stated, not hidden; a missing
44
+ * fingerprint is stated as unknown rather than treated as a match.
45
+ */
46
+ export function RevisionLine({ revision }: { revision: Revision }) {
47
+ return (
48
+ <Stack gap="xs" data-revision={revision.match}>
49
+ <Group gap="md" wrap="wrap" c="dimmed">
50
+ <Text span inherit>
51
+ Promise revision:{' '}
52
+ <Text span inherit fw={600}>
53
+ {revision.promise ? shortRevision(revision.promise) : 'not recorded'}
54
+ </Text>
55
+ </Text>
56
+ <Text span inherit>
57
+ Evidence revision:{' '}
58
+ <Text span inherit fw={600}>
59
+ {revision.evidence ? shortRevision(revision.evidence) : 'not recorded'}
60
+ </Text>
61
+ </Text>
62
+ </Group>
63
+ {revision.match === 'different' && (
64
+ <Alert color="orange" role="status">
65
+ This evidence was recorded against an earlier revision of the spec. It does not show that the current promise holds.
66
+ </Alert>
67
+ )}
68
+ {revision.match === 'unknown' && (
69
+ <Text component="div" c="dimmed">
70
+ The revisions cannot be compared, so this evidence is not tied to the current promise.
71
+ </Text>
72
+ )}
73
+ </Stack>
74
+ );
75
+ }
76
+
77
+ /** The `Then` steps (and the `And`/`But` after them): what should be true when the behavior has run. */
78
+ function expectedSteps(steps: readonly FeatureStep[]): FeatureStep[] {
79
+ const at = steps.findIndex((step) => step.keyword === 'Then');
80
+ return at < 0 ? [] : steps.slice(at);
81
+ }
82
+
83
+ function videosIn(holder: EvidenceHolder | undefined): Attachment[] {
84
+ if (!holder) return [];
85
+ const own = (holder.attachments ?? []).filter(isVideoAttachment);
86
+ return [
87
+ ...own,
88
+ ...(holder.steps ?? []).flatMap((step) => (step.attachments ?? []).filter(isVideoAttachment)),
89
+ ...(holder.attempts ?? []).flatMap((attempt) => [
90
+ ...(attempt.attachments ?? []).filter(isVideoAttachment),
91
+ ...(attempt.steps ?? []).flatMap((step) => (step.attachments ?? []).filter(isVideoAttachment))
92
+ ])
93
+ ];
94
+ }
95
+
96
+ /** The recording of one result, read only when this is on screen. */
97
+ function Player({ result }: { result: EvidenceResult }) {
98
+ const detail = useCheckDetail(result.layer, result.row);
99
+ const videos = useMemo(() => videosIn(detail.data?.check), [detail.data]);
100
+ const sources = videos.flatMap((attachment) => {
101
+ const { url } = attachmentSource(attachment, result.layer.id);
102
+ return url ? [{ attachment, url }] : [];
103
+ });
104
+ if (detail.isLoading) {
105
+ return (
106
+ <Text role="status" component="div" c="dimmed">
107
+ Loading the recording…
108
+ </Text>
109
+ );
110
+ }
111
+ if (detail.isError) {
112
+ return (
113
+ <Alert role="alert" color="red">
114
+ The recording could not be read.
115
+ </Alert>
116
+ );
117
+ }
118
+ if (!sources.length) {
119
+ return (
120
+ <Text component="div" c="dimmed">
121
+ This result names a recording, but no file can be played from here.
122
+ </Text>
123
+ );
124
+ }
125
+ return (
126
+ <Stack gap="sm">
127
+ {sources.slice(0, 1).map(({ attachment, url }) => (
128
+ <Stack key={url} gap="xs">
129
+ {/* What the recording does and does not show, as its producer declared it; undeclared says unknown. */}
130
+ <ScopeNotice
131
+ scope={effectiveScope(attachment.scope, detail.data?.check.scope, detail.data?.inheritedScope)}
132
+ name={attachment.name}
133
+ />
134
+ <Box
135
+ component="video"
136
+ src={url}
137
+ controls
138
+ preload="metadata"
139
+ aria-label={`Recording: ${attachment.description ?? attachment.name}`}
140
+ maw="100%"
141
+ w="100%"
142
+ mah="70vh"
143
+ />
144
+ </Stack>
145
+ ))}
146
+ {sources.length > 1 && (
147
+ <Text component="div" c="dimmed">
148
+ {plural(sources.length - 1, 'more recording', 'more recordings')} in Inspect.
149
+ </Text>
150
+ )}
151
+ </Stack>
152
+ );
153
+ }
154
+
155
+ /** Read, Watch and Inspect for one behavior. `inspect` is the technical record, shown only under Inspect. */
156
+ export function BehaviorPanel({
157
+ behavior,
158
+ inspect,
159
+ start
160
+ }: {
161
+ behavior: Behavior;
162
+ inspect: ReactNode;
163
+ /** The tab to open on; by default Watch when there is something to watch, Inspect while a run is in progress (its progress lives there), else Read. */ start?:
164
+ 'read' | 'watch' | 'inspect';
165
+ }) {
166
+ const { backends } = useEvidence();
167
+ const now = useNow();
168
+ const { scenario, example, demo, subject, revision, state } = behavior;
169
+ const at = example === null ? -1 : exampleIndexFor(scenario, example);
170
+ const entry = at < 0 ? null : exampleRows(scenario)[at]!;
171
+ const steps = useMemo(() => (entry ? fillExample(scenario.steps, entry) : scenario.steps), [scenario, entry]);
172
+ const expected = expectedSteps(steps);
173
+ const named = example !== null && entry === null;
174
+ return (
175
+ <Tabs
176
+ defaultValue={start ?? (demo ? 'watch' : state === 'running' || behavior.slot?.live ? 'inspect' : 'read')}
177
+ keepMounted={false}
178
+ aria-label={`${scenario.name}${example ? `: ${example}` : ''}`}
179
+ >
180
+ <Tabs.List mb="sm">
181
+ <Tabs.Tab value="read">Read</Tabs.Tab>
182
+ <Tabs.Tab value="watch">Watch</Tabs.Tab>
183
+ <Tabs.Tab value="inspect">Inspect</Tabs.Tab>
184
+ </Tabs.List>
185
+ <Tabs.Panel value="read">
186
+ <Stack gap="sm" miw={0}>
187
+ <Group gap="sm">
188
+ <CoverageBadge state={state} />
189
+ <Text component="span" c="dimmed">
190
+ {COVERAGE[state].meaning}
191
+ </Text>
192
+ </Group>
193
+ {example !== null && (
194
+ <Text component="div" c="dimmed">
195
+ Example: {example}
196
+ </Text>
197
+ )}
198
+ <PlainSteps steps={steps} examples={entry ? [] : scenario.examples} />
199
+ {named && (
200
+ <Text component="div" c="dimmed">
201
+ The recorded example name does not match one row of the examples, so the values are not filled in.
202
+ </Text>
203
+ )}
204
+ </Stack>
205
+ </Tabs.Panel>
206
+ <Tabs.Panel value="watch">
207
+ <Stack gap="sm" miw={0}>
208
+ <Group gap="sm">
209
+ <CoverageBadge state={state} />
210
+ {behavior.representative && <Badge variant="outline">Representative demo</Badge>}
211
+ </Group>
212
+ {expected.length > 0 && (
213
+ <Box component="section" aria-label="Expected outcome">
214
+ <Title order={4} mb="xs">
215
+ Expected
216
+ </Title>
217
+ <PlainSteps steps={expected} />
218
+ </Box>
219
+ )}
220
+ {demo ? (
221
+ <>
222
+ <Player result={demo} />
223
+ <Text component="div" c="dimmed">
224
+ Illustrative recording of one run. It shows what happened in that run and does not approve the behavior for production.
225
+ </Text>
226
+ </>
227
+ ) : (
228
+ <Text component="div" c="dimmed">
229
+ Nothing to watch. {COVERAGE[state].meaning}
230
+ </Text>
231
+ )}
232
+ {subject && (
233
+ <Group gap="xs" wrap="wrap" align="center">
234
+ <StatusMark status={subject.row.status} size={14} />
235
+ <Text span inherit fw={600}>
236
+ {statusWord(subject.row.status)}
237
+ </Text>
238
+ <Text span inherit c="dimmed">
239
+ in {environmentLabel(subject.layer.environment)}
240
+ </Text>
241
+ <Origin layer={subject.layer} backends={backends} now={now} />
242
+ </Group>
243
+ )}
244
+ <RevisionLine revision={revision} />
245
+ </Stack>
246
+ </Tabs.Panel>
247
+ <Tabs.Panel value="inspect">
248
+ <Stack gap="sm" miw={0}>
249
+ <RevisionLine revision={revision} />
250
+ {inspect}
251
+ </Stack>
252
+ </Tabs.Panel>
253
+ </Tabs>
254
+ );
255
+ }
256
+
257
+ /**
258
+ * What the feature's behaviors add up to, one count per state. A state with none is left out except
259
+ * `demonstrated`, which is always named so its absence is not mistaken for a pass.
260
+ */
261
+ export function CoverageSummary({ counts, total }: { counts: CoverageCounts; total: number }) {
262
+ if (total === 0) return null;
263
+ return (
264
+ <Paper component="section" aria-label="Coverage" withBorder p="md" my="md">
265
+ <Stack gap="xs">
266
+ <Title order={2}>Coverage</Title>
267
+ <Text component="div">
268
+ {counts.demonstrated} of {plural(total, 'behavior', 'behaviors')} demonstrated with a recording of the current spec.
269
+ </Text>
270
+ <List listStyleType="none" spacing="xs">
271
+ {COVERAGE_ORDER.filter((state) => state === 'demonstrated' || counts[state] > 0).map((state) => (
272
+ <List.Item key={state} data-coverage={state}>
273
+ <Group gap="sm" wrap="wrap">
274
+ <CoverageBadge state={state} />
275
+ <Text span inherit fw={600}>
276
+ {counts[state]}
277
+ </Text>
278
+ <Text span inherit c="dimmed">
279
+ {COVERAGE[state].meaning}
280
+ </Text>
281
+ </Group>
282
+ </List.Item>
283
+ ))}
284
+ </List>
285
+ <Text component="div" c="dimmed">
286
+ A recording shows one run. It is illustrative evidence and does not mean the behavior is live or ready for production.
287
+ </Text>
288
+ </Stack>
289
+ </Paper>
290
+ );
291
+ }
@@ -0,0 +1,99 @@
1
+ import type { LayerHeader, LayerRow } from '@crvouga/atlas/reports/evidence-types';
2
+ import type { CheckSlot } from '@crvouga/atlas/reports/evidence-view';
3
+ import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
4
+ import { expect, test } from 'bun:test';
5
+
6
+ import { behaviorOf, compareRevisions, coverageCounts, isRepresentative, scenarioBehaviors, type RevisionSource } from './behavior';
7
+ import { exampleIndexFor, featureModel, fillExample, exampleRows } from './gherkin';
8
+
9
+ const FEATURE = featureModel(`Feature: Lists
10
+ @demo
11
+ Scenario: Add
12
+ When adding tea
13
+
14
+ @parked
15
+ Scenario: Archive
16
+ When archiving
17
+
18
+ Scenario Outline: Several
19
+ When adding "<item>" for <who>
20
+ Examples:
21
+ | item | who |
22
+ | Tea | Ann |
23
+ | Milk | Bo |
24
+ `)!;
25
+ const [ADD, ARCHIVE, SEVERAL] = FEATURE.rules[0]!.scenarios;
26
+
27
+ const header = (layer: string, environment: string | null = 'desktop-web'): LayerHeader => ({
28
+ id: layer, backend: 'b', producer: 'p', packageDir: null, recordedAtMs: 1, sha: null, branch: null, environment, runUrl: null, parameters: {},
29
+ recording: null, live: false, counts: { passed: 0, failed: 0, running: 0, 'not-run': 0 }, execution: layer
30
+ });
31
+ function result(layer: string, row: Partial<LayerRow>, environment: string | null = 'desktop-web'): EvidenceResult {
32
+ return {
33
+ key: layer, slot: 's', layer: header(layer, environment), parameters: {}, warning: null,
34
+ row: { kind: 'behavior', id: 'x', title: 'Lists: Add', status: 'passed', specPath: 'specs/a.feature', detail: '', measurements: [], testId: null, variant: {}, durationMs: null, ...row }
35
+ };
36
+ }
37
+ const slot = (status: CheckSlot['status'], outcomes: EvidenceResult[], title = 'Lists: Add'): CheckSlot => ({
38
+ key: title, id: title, title, specPath: 'specs/a.feature', status, outcomes, diagnostics: [], latestAtMs: 1, live: false
39
+ });
40
+ const VIDEO = { attachments: 1, steps: 0, annotations: 0, attempts: 0, links: 0, tags: [], labels: {}, roles: ['video'], videos: [{ name: 'a.webm', owner: 'check' as const, recording: 'sha256:1', available: true }] };
41
+ const revisions = (current: string | null, recorded: Record<string, string> = {}): RevisionSource => ({ current, recorded: (id) => recorded[id] });
42
+ const stateOf = (s: CheckSlot | null, rev = revisions('f1', { l1: 'f1', l2: 'f0' }), scenario = ADD!) => behaviorOf(scenario, s, null, rev, 'k').state;
43
+
44
+ test('revisions compare only when both are known', () => {
45
+ expect(compareRevisions('a', 'a').match).toBe('same');
46
+ expect(compareRevisions('a', 'b').match).toBe('different');
47
+ expect(compareRevisions('a', undefined).match).toBe('unknown');
48
+ expect(compareRevisions(null, 'a').match).toBe('unknown');
49
+ });
50
+
51
+ test('only a passing result with a playable recording of the current promise is demonstrated', () => {
52
+ expect(stateOf(slot('passed', [result('l1', { evidence: VIDEO })]))).toBe('demonstrated');
53
+ // The same recording of an older revision is not proof of the new promise.
54
+ expect(stateOf(slot('passed', [result('l2', { evidence: VIDEO })]))).toBe('outdated-recording');
55
+ // An unplayable reference is a gap, and a pass with nothing to watch says so.
56
+ expect(stateOf(slot('passed', [result('l1', { evidence: { ...VIDEO, videos: [{ ...VIDEO.videos[0]!, available: false }] } })]))).toBe('recording-missing');
57
+ expect(stateOf(slot('passed', [result('l1', { recording: { state: 'skipped', reason: 'too-large' } })]))).toBe('recording-missing');
58
+ expect(stateOf(slot('passed', [result('l1', {})]))).toBe('no-recording');
59
+ expect(stateOf(slot('passed', [result('l1', {}, 'api')]))).toBe('api-only');
60
+ // A video on the API does not turn it into API-only evidence: it can be watched.
61
+ expect(stateOf(slot('passed', [result('l1', { evidence: VIDEO }, 'api')]))).toBe('demonstrated');
62
+ });
63
+
64
+ test('failed, running, skipped, parked and unexecuted stay distinct and never read as passes', () => {
65
+ expect(stateOf(slot('failed', [result('l1', { status: 'failed', evidence: VIDEO })]))).toBe('failed');
66
+ expect(stateOf(slot('running', [result('l1', { status: 'running' })]))).toBe('running');
67
+ expect(stateOf(slot('not-run', [result('l1', { status: 'not-run' })]))).toBe('skipped');
68
+ expect(stateOf(null, undefined, ARCHIVE)).toBe('parked');
69
+ expect(stateOf(null)).toBe('unexecuted');
70
+ // One environment passing with a clip does not hide another that failed.
71
+ expect(stateOf(slot('failed', [result('l1', { evidence: VIDEO }), result('l3', { status: 'failed' })]))).toBe('failed');
72
+ });
73
+
74
+ test('a demo tag marks the scenario as the representative one', () => {
75
+ expect(isRepresentative(['#Demo'])).toBe(true);
76
+ expect(isRepresentative(['smoke'])).toBe(false);
77
+ expect(behaviorOf(ADD!, null, null, revisions(null), 'k').representative).toBe(true);
78
+ expect(behaviorOf(ARCHIVE!, null, null, revisions(null), 'k').representative).toBe(false);
79
+ });
80
+
81
+ test('an outline is one behavior per example, never the template as well', () => {
82
+ const tea = slot('passed', [result('l1', {})], 'Lists: Several — Tea');
83
+ const behaviors = scenarioBehaviors(SEVERAL!, [tea], 'Lists', revisions('f1'));
84
+ expect(behaviors.map((behavior) => [behavior.example, behavior.state])).toEqual([
85
+ ['Tea', 'no-recording'],
86
+ ['Milk, Bo', 'unexecuted']
87
+ ]);
88
+ expect(coverageCounts(behaviors).unexecuted).toBe(1);
89
+ // With nothing recorded there are still only the two examples.
90
+ expect(scenarioBehaviors(SEVERAL!, [], 'Lists', revisions(null))).toHaveLength(2);
91
+ });
92
+
93
+ test('a recorded example is matched to its row by cells or column pairs, and its values are filled in', () => {
94
+ expect(exampleIndexFor(SEVERAL!, 'Milk')).toBe(1);
95
+ expect(exampleIndexFor(SEVERAL!, 'item=Tea, who=Ann')).toBe(0);
96
+ expect(exampleIndexFor(SEVERAL!, 'Unrelated wording')).toBe(-1);
97
+ const filled = fillExample(SEVERAL!.steps, exampleRows(SEVERAL!)[1]!);
98
+ expect(filled[0]!.text).toBe('adding "Milk" for Bo');
99
+ });