@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/dist/reports/src/evidence-detail.js +13 -3
  3. package/dist/reports/src/evidence-types.d.ts +4 -0
  4. package/dist/schema/src/api.d.ts +105 -0
  5. package/dist/schema/src/api.js +35 -0
  6. package/dist/server/src/app.js +3 -1
  7. package/dist/server/src/openapi.d.ts +1 -1
  8. package/dist/server/src/openapi.js +24 -2
  9. package/dist/server/src/recording-verification.d.ts +34 -0
  10. package/dist/server/src/recording-verification.js +118 -0
  11. package/docs/reference/http-api.md +52 -0
  12. package/docs/reference/reports.md +4 -0
  13. package/docs/reference/schema.md +41 -0
  14. package/docs/reference/server.md +39 -0
  15. package/docs/rich-evidence.md +13 -0
  16. package/docs/server.md +12 -0
  17. package/docs/ui.md +40 -1
  18. package/llms-full.txt +117 -1
  19. package/package.json +1 -1
  20. package/packages/schema/schemas/effective-delta.schema.json +18 -0
  21. package/packages/schema/schemas/effective-evidence.schema.json +18 -0
  22. package/packages/schema/schemas/evidence-page.schema.json +9 -0
  23. package/packages/schema/schemas/layer-rows.schema.json +9 -0
  24. package/packages/schema/schemas/report-run.schema.json +9 -0
  25. package/packages/schema/schemas/review-collection.schema.json +9 -0
  26. package/packages/schema/schemas/review-manifest.schema.json +9 -0
  27. package/packages/schema/schemas/test-catalog.schema.json +9 -0
  28. package/packages/schema/schemas/test-history.schema.json +18 -0
  29. package/packages/server/openapi.json +653 -1
  30. package/packages/visualizer/src/app/AppShell.tsx +2 -2
  31. package/packages/visualizer/src/app/router.tsx +25 -1
  32. package/packages/visualizer/src/components/Header.tsx +15 -4
  33. package/packages/visualizer/src/evidence/behavior-index.test.ts +119 -0
  34. package/packages/visualizer/src/evidence/behavior-index.ts +162 -0
  35. package/packages/visualizer/src/evidence/behavior-panel.tsx +36 -22
  36. package/packages/visualizer/src/evidence/behavior-return.test.ts +41 -0
  37. package/packages/visualizer/src/evidence/behavior-return.ts +59 -0
  38. package/packages/visualizer/src/evidence/behavior.ts +32 -1
  39. package/packages/visualizer/src/evidence/behaviors-page.tsx +465 -0
  40. package/packages/visualizer/src/evidence/feature-page.tsx +5 -26
  41. package/packages/visualizer/src/evidence/scoped-summary-view.tsx +110 -0
  42. package/packages/visualizer/src/evidence/scoped-summary.test.ts +62 -0
  43. package/packages/visualizer/src/evidence/scoped-summary.ts +118 -0
  44. package/packages/visualizer/src/evidence/spec-texts.ts +88 -0
  45. package/packages/visualizer/src/explorer/ArtifactsView.tsx +4 -4
  46. package/packages/visualizer/src/explorer/Explorer.tsx +1 -1
  47. package/packages/visualizer/src/explorer/RunsView.tsx +36 -10
  48. package/packages/visualizer/src/explorer/catalog.tsx +24 -1
  49. package/packages/visualizer/src/explorer/collections.tsx +5 -5
  50. package/packages/visualizer/src/explorer/navigate.ts +17 -0
  51. package/packages/visualizer/src/explorer/playlist-entries.test.ts +10 -1
  52. package/packages/visualizer/src/explorer/playlist-entries.ts +10 -0
  53. package/packages/visualizer/src/explorer/playlist.tsx +43 -8
  54. package/packages/visualizer/src/explorer/recording-verification.ts +58 -0
  55. package/packages/visualizer/src/explorer/search.ts +7 -1
  56. package/packages/visualizer/src/find/Finder.tsx +1 -62
@@ -26,7 +26,7 @@ function Index() {
26
26
  <UiCatalogFilters />
27
27
  ) : kind ? (
28
28
  <SpecIndex kind={kind} />
29
- ) : pathname.startsWith('/specs/') ? (
29
+ ) : pathname.startsWith('/specs/') || pathname === '/behaviors' ? (
30
30
  <Box component="nav" aria-label="Specs" />
31
31
  ) : evidence.ready && !evidence.files.some(isStatechartFile) ? (
32
32
  <ProjectIndex />
@@ -39,7 +39,7 @@ function Body() {
39
39
  const { view, spec } = useAtlas();
40
40
  const { pathname } = useLocation();
41
41
  if (pathname === '/' && evidence.ready && !evidence.filesPending && !evidence.files.some(isStatechartFile)) return <Explorer />;
42
- if (!pathname.startsWith('/specs/') && pathname !== '/catalog' && !view && spec.error)
42
+ if (!pathname.startsWith('/specs/') && pathname !== '/catalog' && pathname !== '/behaviors' && !view && spec.error)
43
43
  return (
44
44
  <Container py="xl">
45
45
  <Alert color="red" title="The spec couldn’t be loaded">
@@ -12,6 +12,8 @@ import {
12
12
  import { z } from 'zod/v4';
13
13
  import { CatalogSearchFields } from '../catalog/search';
14
14
  import { UiCatalogPage } from '../catalog/UiCatalogPage';
15
+ import { COVERAGE_ORDER } from '../evidence/behavior';
16
+ import { BehaviorsPage } from '../evidence/behaviors-page';
15
17
  import { SpecPage } from '../evidence/spec-page';
16
18
  import { ExplorerSearchFields } from '../explorer/search';
17
19
  import { ProductPage } from '../views/ProductPage';
@@ -44,6 +46,21 @@ const SpecSearch = RootSearch.extend({
44
46
  evidence: z.enum(['stale']).optional().catch(undefined)
45
47
  });
46
48
  export type SpecSearch = z.infer<typeof SpecSearch>;
49
+ /**
50
+ * The project-wide behavior index and its read-and-watch workspace: the search words, the coverage filter, the open behavior
51
+ * (`behavior`, an id from `evidence/behavior-index.ts`), whether it is expanded into the workspace, and the run and recorded
52
+ * moment shown. Scope and selection live here so that a link, a reload and the way back from the workspace all reproduce them.
53
+ */
54
+ const BehaviorsSearch = RootSearch.extend({
55
+ q: z.string().min(1).optional().catch(undefined),
56
+ coverage: z.enum(COVERAGE_ORDER as [(typeof COVERAGE_ORDER)[number], ...(typeof COVERAGE_ORDER)[number][]]).optional().catch(undefined),
57
+ behavior: z.string().min(1).optional().catch(undefined),
58
+ workspace: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined),
59
+ layer: z.string().min(1).optional().catch(undefined),
60
+ clip: z.coerce.string().min(1).optional().catch(undefined),
61
+ clipAt: z.coerce.number().nonnegative().optional().catch(undefined)
62
+ });
63
+ export type BehaviorsSearch = z.infer<typeof BehaviorsSearch>;
47
64
  /** A fresh route tree per router, so a test can run its own viewer beside the page's. */
48
65
  function routeTree() {
49
66
  const rootRoute = createRootRoute({
@@ -96,7 +113,14 @@ function routeTree() {
96
113
  validateSearch: (search) => RootSearch.extend(CatalogSearchFields).parse(search),
97
114
  component: UiCatalogPage
98
115
  });
99
- return rootRoute.addChildren([productRoute, chartRoute, specRoute, catalogRoute]);
116
+ /** Every behavior of the project in one searchable list, and the dedicated workspace to read and watch one. */
117
+ const behaviorsRoute = createRoute({
118
+ getParentRoute: () => rootRoute,
119
+ path: '/behaviors',
120
+ validateSearch: (search) => BehaviorsSearch.parse(search),
121
+ component: BehaviorsPage
122
+ });
123
+ return rootRoute.addChildren([productRoute, chartRoute, specRoute, catalogRoute, behaviorsRoute]);
100
124
  }
101
125
  export function createAtlasRouter(history: RouterHistory) {
102
126
  return createRouter({ routeTree: routeTree(), history, defaultPreload: false, scrollRestoration: true });
@@ -1,5 +1,5 @@
1
1
  import { SpecLogo } from './SpecLogo';
2
- import { IconDots, IconLayoutGrid } from '@tabler/icons-react';
2
+ import { IconDots, IconLayoutGrid, IconListSearch } from '@tabler/icons-react';
3
3
  import { isUiKind } from '@crvouga/atlas/reports/ui-state';
4
4
  import { isStatechartFile } from '@crvouga/atlas/schema';
5
5
  import { ActionIcon, Anchor, Badge, Box, Button, Group, Menu, NativeSelect, Popover, Stack, Tooltip } from '@mantine/core';
@@ -139,6 +139,8 @@ export function Header() {
139
139
  );
140
140
  const project = currentProject();
141
141
  const catalogOpen = pathname === '/catalog';
142
+ const behaviorsOpen = pathname === '/behaviors';
143
+ const hasFeatures = evidence.files.some(SPEC_KINDS[0]!.accepts);
142
144
  // The catalog is offered once the report holds a state or a plan for one.
143
145
  const hasCatalog = useMemo(() => evidence.evidence.some((layer) => layer.rows.some((row) => isUiKind(row.kind))), [evidence.evidence]);
144
146
  return (
@@ -155,13 +157,13 @@ export function Header() {
155
157
  leftSection={kind ? <SpecLogo kind={kind} /> : undefined}
156
158
  rightSection={<ChevronDownIcon aria-hidden="true" size={12} />}
157
159
  >
158
- {catalogOpen ? 'UI catalog' : (kind?.label ?? (hasCharts ? 'Statecharts' : evidence.ready && !tabs.length ? 'Tests' : 'Specs'))}
160
+ {catalogOpen ? 'UI catalog' : behaviorsOpen ? 'Behaviors' : (kind?.label ?? (hasCharts ? 'Statecharts' : evidence.ready && !tabs.length ? 'Tests' : 'Specs'))}
159
161
  </Button>
160
162
  </Menu.Target>
161
163
  <Menu.Dropdown>
162
164
  <Box component="nav" aria-label="Spec type">
163
165
  <Menu.Item
164
- aria-current={pathname.startsWith('/specs/') || catalogOpen ? undefined : 'page'}
166
+ aria-current={pathname.startsWith('/specs/') || catalogOpen || behaviorsOpen ? undefined : 'page'}
165
167
  renderRoot={(props) => <Link {...props} to="/" search={(prev) => runOnly(prev)} />}
166
168
  >
167
169
  {hasCharts || !evidence.ready ? 'Statecharts' : 'Tests'}
@@ -183,6 +185,15 @@ export function Header() {
183
185
  {tab.kind.label}
184
186
  </Menu.Item>
185
187
  ))}
188
+ {(hasFeatures || behaviorsOpen) && (
189
+ <Menu.Item
190
+ leftSection={<IconListSearch size={16} aria-hidden="true" />}
191
+ aria-current={behaviorsOpen ? 'page' : undefined}
192
+ renderRoot={(props) => <Link {...props} to="/behaviors" search={{}} />}
193
+ >
194
+ Behaviors
195
+ </Menu.Item>
196
+ )}
186
197
  {(hasCatalog || catalogOpen) && (
187
198
  <Menu.Item
188
199
  leftSection={<IconLayoutGrid size={16} aria-hidden="true" />}
@@ -263,7 +274,7 @@ function SpecBreadcrumb() {
263
274
  name: item.name,
264
275
  running: (evidence.view.specs.get(outlinePath(item.file))?.summary.counts.running ?? 0) > 0
265
276
  }));
266
- if (!options.length || pathname === '/catalog') return null;
277
+ if (!options.length || pathname === '/catalog' || pathname === '/behaviors') return null;
267
278
  return (
268
279
  <NativeSelect
269
280
  data-running={options.some((option) => option.running) || undefined}
@@ -0,0 +1,119 @@
1
+ import type { LayerHeader, LayerRow } from '@crvouga/atlas/reports/evidence-types';
2
+ import type { CheckSlot, EvidenceView } 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 { passesFilters, parseSearch } from '../find/query';
7
+ import { behaviorDoc, indexBehaviors } from './behavior-index';
8
+
9
+ const PAY = `Feature: Pay an invoice
10
+ Rule: Cards
11
+ @smoke
12
+ Scenario: Pay by card
13
+ Given an invoice
14
+ When paying by card
15
+ Then the invoice is paid
16
+
17
+ Scenario Outline: Pay in a currency
18
+ When paying in "<code>"
19
+ Examples:
20
+ | code |
21
+ | EUR |
22
+ | USD |
23
+ `;
24
+ const LISTS = 'Feature: Share a list\n Scenario: Add an item\n When adding tea\n';
25
+
26
+ const header = (layer: string): LayerHeader => ({
27
+ id: layer, backend: 'b', producer: 'e2e', packageDir: null, recordedAtMs: 1, sha: null, branch: 'main', environment: 'desktop-web', runUrl: null, parameters: {},
28
+ recording: null, live: false, counts: { passed: 0, failed: 0, running: 0, 'not-run': 0 }, execution: layer
29
+ });
30
+ 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 }] };
31
+ function slot(title: string, layer: string, row: Partial<LayerRow> = {}): CheckSlot {
32
+ const result: EvidenceResult = {
33
+ key: layer, slot: title, layer: header(layer), parameters: {}, warning: null,
34
+ row: { kind: 'behavior', id: title, title, status: 'passed', specPath: 'specs/billing/pay.feature', detail: '', measurements: [], testId: null, variant: {}, durationMs: null, ...row }
35
+ };
36
+ return { key: title, id: title, title, specPath: result.row.specPath, status: result.row.status, outcomes: [result], diagnostics: [], latestAtMs: 1, live: false };
37
+ }
38
+ const view = (slots: Record<string, CheckSlot[]>) => ({ specs: new Map(Object.entries(slots).map(([path, list]) => [path, { slots: list }])) }) as unknown as EvidenceView;
39
+ const specs = [
40
+ { path: 'specs/billing/pay.feature', domain: 'billing', kind: 'gherkin', title: 'Pay', fingerprint: 'now' },
41
+ { path: 'specs/lists/add.feature', domain: '', kind: 'gherkin', title: 'Lists' }
42
+ ];
43
+
44
+ test('every feature\'s behaviors are listed once, an outline as one per example row and never as its template', () => {
45
+ const index = indexBehaviors({
46
+ files: ['billing/pay.feature', 'lists/add.feature'],
47
+ texts: new Map([['billing/pay.feature', PAY], ['lists/add.feature', LISTS]]),
48
+ specs,
49
+ view: view({ 'specs/billing/pay.feature': [slot('Pay an invoice: Pay by card', 'l1', { evidence: VIDEO })] }),
50
+ evidence: [{ layer: header('l1'), fingerprints: { 'specs/billing/pay.feature': 'now' } }]
51
+ });
52
+ expect(index.entries.map((entry) => entry.title)).toEqual(['Pay by card', 'Pay in a currency — EUR', 'Pay in a currency — USD', 'Add an item']);
53
+ expect(index.entries.map((entry) => entry.behavior.state)).toEqual(['demonstrated', 'unexecuted', 'unexecuted', 'unexecuted']);
54
+ expect(index.entries[0]).toMatchObject({ file: 'billing/pay.feature', feature: 'Pay an invoice', rule: 'Cards', domain: 'billing' });
55
+ // A domain nobody declared is not invented.
56
+ expect(index.entries[3]!.domain).toBe('');
57
+ expect(new Set(index.entries.map((entry) => entry.id)).size).toBe(4);
58
+ expect(index).toMatchObject({ features: 2, unread: 0, unreadable: [] });
59
+ });
60
+
61
+ test('a recording of an older revision is not demonstrated here either', () => {
62
+ const index = indexBehaviors({
63
+ files: ['billing/pay.feature'],
64
+ texts: new Map([['billing/pay.feature', PAY]]),
65
+ specs,
66
+ view: view({ 'specs/billing/pay.feature': [slot('Pay an invoice: Pay by card', 'l1', { evidence: VIDEO })] }),
67
+ evidence: [{ layer: header('l1'), fingerprints: { 'specs/billing/pay.feature': 'earlier' } }]
68
+ });
69
+ expect(index.entries[0]!.behavior.state).toBe('outdated-recording');
70
+ expect(index.entries[0]!.assessment.verdict).toBe('stale');
71
+ });
72
+
73
+ test('features not read yet or not readable are counted and named, never silently absent', () => {
74
+ const index = indexBehaviors({
75
+ files: ['billing/pay.feature', 'lists/add.feature', 'broken.feature', 'gone.feature'],
76
+ texts: new Map([['billing/pay.feature', PAY], ['broken.feature', 'this is not gherkin'], ['gone.feature', '']]),
77
+ specs,
78
+ view: view({}),
79
+ evidence: []
80
+ });
81
+ expect(index.unread).toBe(1);
82
+ expect(index.unreadable).toEqual(['broken.feature', 'gone.feature']);
83
+ expect(index.features).toBe(4);
84
+ expect(index.entries).toHaveLength(3);
85
+ });
86
+
87
+ test('a result no scenario claims is still a behavior that was checked', () => {
88
+ const index = indexBehaviors({
89
+ files: ['lists/add.feature'],
90
+ texts: new Map([['lists/add.feature', LISTS]]),
91
+ specs,
92
+ view: view({ 'specs/lists/add.feature': [slot('Share a list: Renamed since', 'l9', { specPath: 'specs/lists/add.feature' })] }),
93
+ evidence: []
94
+ });
95
+ expect(index.entries.map((entry) => entry.title)).toEqual(['Add an item', 'Renamed since']);
96
+ expect(index.entries[1]!.behavior.state).toBe('no-recording');
97
+ });
98
+
99
+ test('the search document carries wording, steps, domain and tags, and its facets feed the typed filters', () => {
100
+ const index = indexBehaviors({
101
+ files: ['billing/pay.feature'],
102
+ texts: new Map([['billing/pay.feature', PAY]]),
103
+ specs,
104
+ view: view({ 'specs/billing/pay.feature': [slot('Pay an invoice: Pay by card', 'l1', { evidence: VIDEO })] }),
105
+ evidence: []
106
+ });
107
+ const doc = behaviorDoc(index.entries[0]!);
108
+ expect(doc.fields.find((field) => field.name === 'steps')!.text).toContain('paying by card');
109
+ expect(doc.fields.find((field) => field.name === 'metadata')!.text).toContain('billing');
110
+ expect(doc.target).toMatchObject({ to: '/specs/$kind', kind: 'gherkin', search: { spec: 'billing/pay.feature' } });
111
+ const passes = (query: string) => passesFilters(doc.facets, parseSearch(query).filters);
112
+ expect(passes('has:video')).toBe(true);
113
+ expect(passes('status:passed tag:smoke domain:billing')).toBe(true);
114
+ expect(passes('status:failed')).toBe(false);
115
+ // A behavior nothing reported on has no status to match and no video.
116
+ const unreported = behaviorDoc(index.entries[1]!);
117
+ expect(passesFilters(unreported.facets, parseSearch('has:video').filters)).toBe(false);
118
+ expect(unreported.status).toBe('none');
119
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @module Project-wide behavior index
3
+ *
4
+ * Every behavior of every Gherkin feature in the project as one list, so a reader can search "what does this product
5
+ * promise?" without knowing which feature file holds the answer. It adds no source of truth: each feature is read
6
+ * through the same {@link featureBehaviors} the feature page uses, so a behavior has the same coverage state, revisions
7
+ * and recording here as there. Features whose text has not arrived, or cannot be parsed, are counted and named rather
8
+ * than silently absent, so an empty answer is never mistaken for "no such behavior".
9
+ */
10
+ import type { EvidenceView } from '@crvouga/atlas/reports/evidence-view';
11
+ import type { LayerEvidence } from '@crvouga/atlas/reports/evidence-types';
12
+ import type { SpecEntry } from '@crvouga/atlas/reports/explorer-types';
13
+
14
+ import type { DocFacets } from '../find/query';
15
+ import type { FindDoc, FindField, FindStatus } from '../find/rank';
16
+ import { outlinePath } from '../spec-kind';
17
+ import { assessBehavior, type Assessment, type ReleaseIdentity } from './applicability';
18
+ import { COVERAGE, featureBehaviors, type Behavior, type RevisionSource } from './behavior';
19
+ import { featureModel, scenarioLabel, type FeatureModel } from './gherkin';
20
+
21
+ /** One behavior of the project, with where it is written. */
22
+ export type IndexedBehavior = {
23
+ /** Stable for the behavior within the project: the feature file and the behavior's key in it. Safe in an address. */
24
+ readonly id: string;
25
+ /** The feature file relative to the specs folder. */
26
+ readonly file: string;
27
+ /** The path reports name the feature by (`specs/…`). */
28
+ readonly specPath: string;
29
+ /** The feature's title. */
30
+ readonly feature: string;
31
+ /** The domain the spec metadata declares; empty when it declares none (nothing is invented). */
32
+ readonly domain: string;
33
+ /** The rule the scenario sits under, or null. */
34
+ readonly rule: string | null;
35
+ /** The behavior in words: the scenario, and for an outline's example its example label. */
36
+ readonly title: string;
37
+ readonly behavior: Behavior;
38
+ /** Whether the evidence applies to the promise as written now (no release is named in this index). */
39
+ readonly assessment: Assessment;
40
+ };
41
+
42
+ /** The behaviors of the project and what could not be read into them. */
43
+ export type BehaviorIndex = {
44
+ readonly entries: readonly IndexedBehavior[];
45
+ /** Feature files in the project. */
46
+ readonly features: number;
47
+ /** Feature files whose text has not arrived yet; their behaviors are not here yet. */
48
+ readonly unread: number;
49
+ /** Feature files that could not be read or parsed; their behaviors are not here. */
50
+ readonly unreadable: readonly string[];
51
+ };
52
+
53
+ /** What the index reads: the feature files and their texts, the outline (domains, fingerprints) and the report. */
54
+ export type IndexInput = {
55
+ /** The feature files, relative to the specs folder. */
56
+ readonly files: readonly string[];
57
+ /** The text of each file read so far; an empty text is a file that could not be read. */
58
+ readonly texts: ReadonlyMap<string, string>;
59
+ readonly specs: readonly SpecEntry[];
60
+ readonly view: EvidenceView;
61
+ /** The layers the report holds, for the spec fingerprint each recorded. */
62
+ readonly evidence: readonly Pick<LayerEvidence, 'layer' | 'fingerprints'>[];
63
+ readonly release?: ReleaseIdentity;
64
+ };
65
+
66
+ function parse(text: string): FeatureModel | null {
67
+ try {
68
+ return featureModel(text);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /** Reads every feature into behaviors. Pure: the same inputs give the same list, in file then page order. */
75
+ export function indexBehaviors(input: IndexInput): BehaviorIndex {
76
+ const fingerprints = new Map(input.evidence.map((layer) => [layer.layer.id, layer.fingerprints] as const));
77
+ const entries: IndexedBehavior[] = [];
78
+ const unreadable: string[] = [];
79
+ let unread = 0;
80
+ for (const file of input.files) {
81
+ const text = input.texts.get(file);
82
+ if (text === undefined) {
83
+ unread += 1;
84
+ continue;
85
+ }
86
+ const feature = text === '' ? null : parse(text);
87
+ if (!feature) {
88
+ unreadable.push(file);
89
+ continue;
90
+ }
91
+ const specPath = outlinePath(file);
92
+ const spec = input.specs.find((candidate) => candidate.path === specPath);
93
+ const revisions: RevisionSource = { current: spec?.fingerprint ?? null, recorded: (id) => fingerprints.get(id)?.[specPath] };
94
+ const found = featureBehaviors(feature, input.view.specs.get(specPath)?.slots ?? [], revisions);
95
+ const ruleOf = new Map(feature.rules.flatMap((rule) => rule.scenarios.map((scenario) => [scenario, rule.name] as const)));
96
+ for (const behavior of found.all) {
97
+ const claimed = found.byScenario.has(behavior.scenario);
98
+ entries.push({
99
+ id: `${file}#${behavior.key}`,
100
+ file,
101
+ specPath,
102
+ feature: feature.name || file.split('/').at(-1)!,
103
+ domain: spec?.domain ?? '',
104
+ rule: ruleOf.get(behavior.scenario) ?? null,
105
+ title: claimed
106
+ ? behavior.example
107
+ ? `${behavior.scenario.name} — ${behavior.example}`
108
+ : behavior.scenario.name
109
+ : scenarioLabel(feature.name, behavior.scenario.name),
110
+ behavior,
111
+ assessment: assessBehavior(behavior, revisions, input.release ?? {})
112
+ });
113
+ }
114
+ }
115
+ return { entries, features: input.files.length, unread, unreadable };
116
+ }
117
+
118
+ /** The check status a behavior's search document filters by, or none when nothing reported on it. */
119
+ function statusOf(behavior: Behavior): FindStatus {
120
+ return behavior.slot?.status ?? 'none';
121
+ }
122
+
123
+ /**
124
+ * A behavior as a searchable document for the shared matcher (`find/`): its wording and steps are what a reader searches
125
+ * by; the feature, rule, domain, tags and coverage state are metadata. Its facets feed the typed filters (`status:`,
126
+ * `domain:`, `spec:`, `tag:`, `env:`, `has:video`).
127
+ */
128
+ export function behaviorDoc(entry: IndexedBehavior): FindDoc {
129
+ const { behavior } = entry;
130
+ const outcomes = behavior.slot?.outcomes ?? [];
131
+ const steps = behavior.scenario.steps.map((step) => `${step.keyword} ${step.text}`).join('\n');
132
+ const fields: FindField[] = [{ name: 'title', text: entry.title }];
133
+ if (steps) fields.push({ name: 'steps', text: steps });
134
+ const description = [entry.feature, entry.rule ?? '', behavior.scenario.description].filter(Boolean).join('\n');
135
+ if (description) fields.push({ name: 'description', text: description });
136
+ const metadata = [entry.domain, ...behavior.scenario.tags, COVERAGE[behavior.state].label].filter(Boolean);
137
+ fields.push({ name: 'metadata', text: metadata.join('\n') });
138
+ fields.push({ name: 'path', text: entry.specPath });
139
+ const facets: DocFacets = {
140
+ status: behavior.slot ? [behavior.slot.status] : [],
141
+ env: [...new Set(outcomes.map((result) => result.layer.environment ?? '(none)'))],
142
+ producer: [...new Set(outcomes.map((result) => result.layer.producer))],
143
+ domain: [entry.domain || '(no domain)'],
144
+ spec: [entry.specPath],
145
+ tag: behavior.scenario.tags.map((tag) => tag.replace(/^@/, '')),
146
+ branch: [...new Set(outcomes.flatMap((result) => (result.layer.branch ? [result.layer.branch] : [])))],
147
+ video: behavior.demo !== null
148
+ };
149
+ return {
150
+ id: entry.id,
151
+ group: 'Specs',
152
+ kind: 'scenario',
153
+ title: entry.title,
154
+ subtitle: entry.feature,
155
+ fields,
156
+ status: statusOf(behavior),
157
+ related: [],
158
+ target: { to: '/specs/$kind', kind: 'gherkin', search: { spec: entry.file, ...(behavior.slot ? { check: behavior.slot.id } : {}) } },
159
+ recording: null,
160
+ facets
161
+ };
162
+ }
@@ -26,6 +26,7 @@ import { exampleIndexFor, exampleRows, fillExample, type FeatureStep } from './g
26
26
  import { Origin, StatusMark } from './marks';
27
27
  import { MomentPlayback, stepsOfRecording, useMomentVideo, useRequestedMoment } from './moments';
28
28
  import { outcomeHeadline, videosIn } from './outcome';
29
+ import { ScopedSummary } from './scoped-summary-view';
29
30
 
30
31
  /** The short, readable form of a spec fingerprint (`<length>:<hash>:<hash>`): its length and the start of its hash. */
31
32
  export function shortRevision(fingerprint: string): string {
@@ -83,6 +84,18 @@ function expectedSteps(steps: readonly FeatureStep[]): FeatureStep[] {
83
84
  return at < 0 ? [] : steps.slice(at);
84
85
  }
85
86
 
87
+ /**
88
+ * The steps a behavior reads as: an outline's example with its values filled in, else the scenario's own. `expected` is
89
+ * what should be true afterwards; `named` is true when the recorded example name matches no row, so nothing was filled in.
90
+ */
91
+ export function behaviorSteps(behavior: Behavior) {
92
+ const { scenario, example } = behavior;
93
+ const at = example === null ? -1 : exampleIndexFor(scenario, example);
94
+ const entry = at < 0 ? null : exampleRows(scenario)[at]!;
95
+ const steps = entry ? fillExample(scenario.steps, entry) : scenario.steps;
96
+ return { steps, entry, expected: expectedSteps(steps), named: example !== null && entry === null };
97
+ }
98
+
86
99
  /** The recording itself: the one player the steps beside it seek, which marks the step playing. */
87
100
  function WatchVideo({ attachment, url }: { attachment: Attachment; url: string }) {
88
101
  const moments = useMomentVideo(attachment);
@@ -101,8 +114,8 @@ function WatchVideo({ attachment, url }: { attachment: Attachment; url: string }
101
114
  );
102
115
  }
103
116
 
104
- /** The recording of one result, read only when this is on screen. */
105
- function Player({ result }: { result: EvidenceResult }) {
117
+ /** The recording of one result, read only when this is on screen, with the steps beside it that seek it. */
118
+ export function Player({ result }: { result: EvidenceResult }) {
106
119
  const detail = useCheckDetail(result.layer, result.row);
107
120
  const videos = useMemo(() => videosIn(detail.data?.check), [detail.data]);
108
121
  const requested = useRequestedMoment();
@@ -158,6 +171,24 @@ function Player({ result }: { result: EvidenceResult }) {
158
171
  );
159
172
  }
160
173
 
174
+ /** What the result shown recorded and where it came from: its status, environment, run, commit and time. */
175
+ export function Provenance({ subject }: { subject: EvidenceResult }) {
176
+ const { backends } = useEvidence();
177
+ const now = useNow();
178
+ return (
179
+ <Group gap="xs" wrap="wrap" align="center">
180
+ <StatusMark status={subject.row.status} size={14} />
181
+ <Text span inherit fw={600}>
182
+ {statusWord(subject.row.status)}
183
+ </Text>
184
+ <Text span inherit c="dimmed">
185
+ in {environmentLabel(subject.layer.environment)}
186
+ </Text>
187
+ <Origin layer={subject.layer} backends={backends} now={now} />
188
+ </Group>
189
+ );
190
+ }
191
+
161
192
  /** Read, Watch and Inspect for one behavior. `inspect` is the technical record, shown only under Inspect. */
162
193
  export function BehaviorPanel({
163
194
  behavior,
@@ -175,14 +206,8 @@ export function BehaviorPanel({
175
206
  /** 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?:
176
207
  'read' | 'watch' | 'inspect';
177
208
  }) {
178
- const { backends } = useEvidence();
179
- const now = useNow();
180
209
  const { scenario, example, demo, subject, revision, state } = behavior;
181
- const at = example === null ? -1 : exampleIndexFor(scenario, example);
182
- const entry = at < 0 ? null : exampleRows(scenario)[at]!;
183
- const steps = useMemo(() => (entry ? fillExample(scenario.steps, entry) : scenario.steps), [scenario, entry]);
184
- const expected = expectedSteps(steps);
185
- const named = example !== null && entry === null;
210
+ const { steps, entry, expected, named } = useMemo(() => behaviorSteps(behavior), [behavior]);
186
211
  // The coverage state is the check's combined status; this says what the recording or result shown here recorded, apart from it.
187
212
  const outcome =
188
213
  subject && behavior.slot ? (
@@ -229,7 +254,6 @@ export function BehaviorPanel({
229
254
  <CoverageBadge state={state} />
230
255
  {behavior.representative && <Badge variant="outline">Representative demo</Badge>}
231
256
  </Group>
232
- {outcome}
233
257
  {expected.length > 0 && (
234
258
  <Box component="section" aria-label="Expected outcome">
235
259
  <Title order={4} mb="xs">
@@ -250,19 +274,9 @@ export function BehaviorPanel({
250
274
  Nothing to watch. {COVERAGE[state].meaning}
251
275
  </Text>
252
276
  )}
253
- {subject && (
254
- <Group gap="xs" wrap="wrap" align="center">
255
- <StatusMark status={subject.row.status} size={14} />
256
- <Text span inherit fw={600}>
257
- {statusWord(subject.row.status)}
258
- </Text>
259
- <Text span inherit c="dimmed">
260
- in {environmentLabel(subject.layer.environment)}
261
- </Text>
262
- <Origin layer={subject.layer} backends={backends} now={now} />
263
- </Group>
264
- )}
277
+ {subject && <Provenance subject={subject} />}
265
278
  <RevisionLine revision={revision} />
279
+ <ScopedSummary behavior={behavior} />
266
280
  {applicability && <ApplicabilitySummary assessment={applicability} release={release} />}
267
281
  </Stack>
268
282
  </Tabs.Panel>
@@ -0,0 +1,41 @@
1
+ import { beforeEach, expect, test } from 'bun:test';
2
+
3
+ import { forgetReturn, recallReturn, rememberReturn, scopeKey } from './behavior-return';
4
+
5
+ const store = new Map<string, string>();
6
+ beforeEach(() => {
7
+ store.clear();
8
+ (globalThis as { sessionStorage?: unknown }).sessionStorage = {
9
+ getItem: (key: string) => store.get(key) ?? null,
10
+ setItem: (key: string, value: string) => void store.set(key, value),
11
+ removeItem: (key: string) => void store.delete(key)
12
+ };
13
+ });
14
+
15
+ test('a remembered point comes back only to the scope it was taken in', () => {
16
+ const scope = scopeKey({ q: 'refund', coverage: 'failed' });
17
+ rememberReturn({ scope, scrollY: 480, shown: 80, focus: 'a.feature#Refund' });
18
+ expect(recallReturn(scope)).toEqual({ scope, scrollY: 480, shown: 80, focus: 'a.feature#Refund' });
19
+ // Another search, or none, never gets the scroll of this one.
20
+ expect(recallReturn(scopeKey({ q: 'pay' }))).toBeNull();
21
+ expect(recallReturn(scopeKey({}))).toBeNull();
22
+ });
23
+
24
+ test('reading a point does not forget it until it is given back', () => {
25
+ const scope = scopeKey({});
26
+ rememberReturn({ scope, scrollY: 10, shown: 40, focus: null });
27
+ expect(recallReturn(scope)).not.toBeNull();
28
+ expect(recallReturn(scope)).not.toBeNull();
29
+ forgetReturn();
30
+ expect(recallReturn(scope)).toBeNull();
31
+ });
32
+
33
+ test('unreadable or unavailable storage only means nothing is restored', () => {
34
+ store.set('atlas-behaviors-return', '{not json');
35
+ expect(recallReturn(scopeKey({}))).toBeNull();
36
+ store.set('atlas-behaviors-return', JSON.stringify({ scope: scopeKey({}), scrollY: 'far' }));
37
+ expect(recallReturn(scopeKey({}))).toBeNull();
38
+ (globalThis as { sessionStorage?: unknown }).sessionStorage = undefined;
39
+ expect(() => rememberReturn({ scope: 's', scrollY: 1, shown: 1, focus: null })).not.toThrow();
40
+ expect(recallReturn('s')).toBeNull();
41
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @module Returning from the read-and-watch workspace
3
+ *
4
+ * Opening a behavior in the dedicated workspace replaces the index, so the index would come back at the top with
5
+ * nothing selected. The scope (the search words and the coverage filter) and the selection are in the address and come
6
+ * back by themselves. What an address cannot hold is where the reader had scrolled to, how much of a long list had been
7
+ * shown, and which row had focus: they are remembered here for the session, against the scope they were taken in, and
8
+ * given back once, only to the same scope.
9
+ */
10
+
11
+ const KEY = 'atlas-behaviors-return';
12
+
13
+ /** What the index remembers when it is left for the workspace. */
14
+ export type ReturnPoint = {
15
+ /** The scope the point was taken in ({@link scopeKey}); it is only used for that scope. */
16
+ readonly scope: string;
17
+ /** How far the page was scrolled, in pixels. */
18
+ readonly scrollY: number;
19
+ /** How many rows of the list had been shown. */
20
+ readonly shown: number;
21
+ /** The behavior whose row had focus, or null. */
22
+ readonly focus: string | null;
23
+ };
24
+
25
+ /** The scope of an index: its search words and its coverage filter. */
26
+ export function scopeKey(search: { readonly q?: string | undefined; readonly coverage?: string | undefined }): string {
27
+ return JSON.stringify([search.q ?? '', search.coverage ?? '']);
28
+ }
29
+
30
+ /** Remembers where the reader was. Storage that is unavailable or full only means nothing is restored. */
31
+ export function rememberReturn(point: ReturnPoint): void {
32
+ try {
33
+ sessionStorage.setItem(KEY, JSON.stringify(point));
34
+ } catch {
35
+ // Not being able to remember means the list opens at its top.
36
+ }
37
+ }
38
+
39
+ /** The remembered point, if it was taken in `scope`; a point of another scope is never used. Reading does not forget it. */
40
+ export function recallReturn(scope: string): ReturnPoint | null {
41
+ try {
42
+ const raw = sessionStorage.getItem(KEY);
43
+ if (!raw) return null;
44
+ const point = JSON.parse(raw) as Partial<ReturnPoint>;
45
+ if (point.scope !== scope || typeof point.scrollY !== 'number' || typeof point.shown !== 'number') return null;
46
+ return { scope, scrollY: point.scrollY, shown: point.shown, focus: typeof point.focus === 'string' ? point.focus : null };
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /** Forgets the remembered point, once it has been given back. */
53
+ export function forgetReturn(): void {
54
+ try {
55
+ sessionStorage.removeItem(KEY);
56
+ } catch {
57
+ // Nothing was remembered.
58
+ }
59
+ }
@@ -11,7 +11,7 @@
11
11
  import type { CheckSlot } from '@crvouga/atlas/reports/evidence-view';
12
12
  import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
13
13
 
14
- import { exampleIndexFor, exampleRows, scenarioLabel, type FeatureScenario } from './gherkin';
14
+ import { exampleIndexFor, exampleRows, scenarioLabel, slotsByScenario, type FeatureModel, type FeatureScenario } from './gherkin';
15
15
 
16
16
  /** Where a behavior stands, from the most reassuring to the least. */
17
17
  export type CoverageState =
@@ -266,3 +266,34 @@ export function coverageCounts(behaviors: readonly Behavior[]): CoverageCounts {
266
266
  for (const behavior of behaviors) counts[behavior.state] += 1;
267
267
  return counts;
268
268
  }
269
+
270
+ /** Every behavior of a feature, by scenario, with the ones no scenario claims. */
271
+ export type FeatureBehaviors = {
272
+ /** The behaviors of each scenario (a background is not one), in the order the file has them. */
273
+ readonly byScenario: ReadonlyMap<FeatureScenario, readonly Behavior[]>;
274
+ /** The recorded checks no scenario of the file claims: recorded against the file under a name it does not have now. */
275
+ readonly leftOver: readonly CheckSlot[];
276
+ /** Every behavior, claimed or not, in page order; a result no scenario claims is still a behavior that was checked. */
277
+ readonly all: readonly Behavior[];
278
+ };
279
+
280
+ /** A scenario that stands for a recorded check no scenario of the file names. */
281
+ function strayScenario(name: string): FeatureScenario {
282
+ return { keyword: 'Scenario', name, description: '', background: false, tags: [], steps: [], examples: [] };
283
+ }
284
+
285
+ /**
286
+ * Reads a whole feature as behaviors: its scenarios (an outline as one per example), each with the recorded checks filed
287
+ * under it, and the checks no scenario claims. The feature page and the project-wide behavior index both read a feature
288
+ * through this, so a behavior is the same behavior wherever it is listed. `feature` is null when the file could not be parsed.
289
+ */
290
+ export function featureBehaviors(feature: FeatureModel | null, slots: readonly CheckSlot[], revisions: RevisionSource): FeatureBehaviors {
291
+ // A file that cannot be read has no scenarios: every check recorded against it is one nothing claims.
292
+ const filed = feature ? slotsByScenario(feature, slots) : { byScenario: new Map<string, CheckSlot[]>(), leftOver: [...slots] };
293
+ const byScenario = new Map<FeatureScenario, Behavior[]>();
294
+ for (const scenario of feature?.rules.flatMap((rule) => rule.scenarios.filter((each) => !each.background)) ?? []) {
295
+ byScenario.set(scenario, scenarioBehaviors(scenario, filed.byScenario.get(scenario.name) ?? [], feature!.name, revisions));
296
+ }
297
+ const other = filed.leftOver.map((slot) => behaviorOf(strayScenario(slot.title), slot, null, revisions, slot.key));
298
+ return { byScenario, leftOver: filed.leftOver, all: [...[...byScenario.values()].flat(), ...other] };
299
+ }