@crvouga/atlas 0.2.0 → 0.4.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 (111) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +21 -2
  3. package/dist/reports/src/evidence-live.d.ts +6 -0
  4. package/dist/reports/src/evidence-live.js +35 -3
  5. package/dist/reports/src/evidence-types.d.ts +2 -0
  6. package/dist/reports/src/evidence-view.d.ts +15 -2
  7. package/dist/reports/src/evidence-view.js +31 -3
  8. package/dist/reports/src/export-evidence.js +2 -10
  9. package/dist/reports/src/frames.d.ts +3 -1
  10. package/dist/reports/src/frames.js +21 -0
  11. package/dist/reports/src/layer-axes.d.ts +18 -0
  12. package/dist/reports/src/layer-axes.js +51 -0
  13. package/dist/reports/src/layer-catalog.js +1 -1
  14. package/dist/reports/src/layer-merge.d.ts +1 -1
  15. package/dist/reports/src/layer-merge.js +9 -4
  16. package/dist/reports/src/layer-source.js +3 -0
  17. package/dist/reports/src/layer-types.d.ts +2 -0
  18. package/dist/reports/src/layers-route.js +2 -5
  19. package/dist/reports/src/protocol.d.ts +4 -0
  20. package/dist/reports/src/publish.d.ts +2 -0
  21. package/dist/reports/src/publish.js +6 -11
  22. package/dist/reports/src/recording-keys.js +4 -0
  23. package/dist/reports/src/report-parts.js +3 -0
  24. package/dist/reports/src/report-types.d.ts +19 -2
  25. package/dist/reports/src/source-entries.js +2 -12
  26. package/package.json +5 -4
  27. package/packages/reports/README.md +4 -2
  28. package/packages/reports/STORAGE.md +1 -1
  29. package/packages/visualizer/README.md +9 -6
  30. package/packages/visualizer/example/specs/coffee/ordering/cart.qnt +7 -0
  31. package/packages/visualizer/example/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  32. package/packages/visualizer/example/specs/coffee/ordering/drink-price.dmn +24 -0
  33. package/packages/visualizer/example/specs/coffee/ordering/loyalty.feature +7 -0
  34. package/packages/visualizer/example/specs/coffee/ordering/ordering.feature +36 -0
  35. package/packages/visualizer/example/specs/coffee/ordering/payment.als +4 -0
  36. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/cart.qnt +7 -0
  37. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  38. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/drink-price.dmn +24 -0
  39. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/loyalty.feature +7 -0
  40. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/ordering.feature +36 -0
  41. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/payment.als +4 -0
  42. package/packages/visualizer/fixtures/full/specs/coffee/ordering/cart.qnt +7 -0
  43. package/packages/visualizer/fixtures/full/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  44. package/packages/visualizer/fixtures/full/specs/coffee/ordering/drink-price.dmn +24 -0
  45. package/packages/visualizer/fixtures/full/specs/coffee/ordering/loyalty.feature +7 -0
  46. package/packages/visualizer/fixtures/full/specs/coffee/ordering/ordering.feature +36 -0
  47. package/packages/visualizer/fixtures/full/specs/coffee/ordering/payment.als +4 -0
  48. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/cart.qnt +7 -0
  49. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  50. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/drink-price.dmn +24 -0
  51. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/loyalty.feature +7 -0
  52. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/ordering.feature +36 -0
  53. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/payment.als +4 -0
  54. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/cart.qnt +7 -0
  55. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  56. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/drink-price.dmn +24 -0
  57. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/loyalty.feature +7 -0
  58. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/ordering.feature +36 -0
  59. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/payment.als +4 -0
  60. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/cart.qnt +7 -0
  61. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  62. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/drink-price.dmn +24 -0
  63. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/loyalty.feature +7 -0
  64. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/ordering.feature +36 -0
  65. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/payment.als +4 -0
  66. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/cart.qnt +7 -0
  67. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  68. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/drink-price.dmn +24 -0
  69. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/loyalty.feature +7 -0
  70. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/ordering.feature +36 -0
  71. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/payment.als +4 -0
  72. package/packages/visualizer/scripts/make-fixtures.ts +2 -0
  73. package/packages/visualizer/scripts/publish-data.ts +1 -2
  74. package/packages/visualizer/src/app/AppShell.tsx +11 -12
  75. package/packages/visualizer/src/app/gazetteer.module.css +12 -2
  76. package/packages/visualizer/src/app/gazetteer.tsx +38 -26
  77. package/packages/visualizer/src/components/Header.module.css +20 -4
  78. package/packages/visualizer/src/components/Header.tsx +7 -4
  79. package/packages/visualizer/src/data/model/build.ts +17 -5
  80. package/packages/visualizer/src/data/model/types.ts +10 -1
  81. package/packages/visualizer/src/evidence/api.ts +6 -1
  82. package/packages/visualizer/src/evidence/check-detail.tsx +35 -3
  83. package/packages/visualizer/src/evidence/evidence.module.css +20 -0
  84. package/packages/visualizer/src/evidence/marks.tsx +2 -0
  85. package/packages/visualizer/src/evidence/overview.tsx +3 -3
  86. package/packages/visualizer/src/evidence/recording-page.tsx +19 -2
  87. package/packages/visualizer/src/evidence/recording.module.css +4 -8
  88. package/packages/visualizer/src/evidence/recording.tsx +74 -83
  89. package/packages/visualizer/src/evidence/snapshot.tsx +5 -4
  90. package/packages/visualizer/src/evidence/sources-panel.tsx +88 -34
  91. package/packages/visualizer/src/evidence/spec-index.tsx +3 -11
  92. package/packages/visualizer/src/find/Finder.module.css +168 -0
  93. package/packages/visualizer/src/find/Finder.tsx +303 -0
  94. package/packages/visualizer/src/find/corpus.test.ts +77 -0
  95. package/packages/visualizer/src/find/corpus.ts +368 -0
  96. package/packages/visualizer/src/find/open.ts +51 -0
  97. package/packages/visualizer/src/find/rank.test.ts +237 -0
  98. package/packages/visualizer/src/find/rank.ts +405 -0
  99. package/packages/visualizer/src/find/semantic.test.ts +62 -0
  100. package/packages/visualizer/src/find/semantic.ts +93 -0
  101. package/packages/visualizer/src/find/semantic.worker.ts +40 -0
  102. package/packages/visualizer/src/looks/Stage.tsx +322 -0
  103. package/packages/visualizer/src/looks/collect.test.ts +158 -0
  104. package/packages/visualizer/src/looks/collect.ts +245 -0
  105. package/packages/visualizer/src/looks/stage.module.css +300 -0
  106. package/packages/visualizer/src/reachable.test.ts +1 -1
  107. package/packages/visualizer/src/state/ui-store.ts +6 -2
  108. package/packages/visualizer/src/views/SidePanel.tsx +39 -49
  109. package/packages/visualizer/vite/example-reports.ts +475 -0
  110. package/packages/visualizer/vite.config.ts +9 -1
  111. package/packages/visualizer/src/app/search-query.tsx +0 -14
@@ -5,8 +5,10 @@ import type { ReactNode } from 'react';
5
5
  import { statusNote, type AtlasView, type StateView, type TransitionView } from '../data/model';
6
6
  import { ChevronIcon } from '../components/icons';
7
7
  import { KIND_LABEL } from '../components/labels';
8
- import { ScreenImage, placeholderReason } from '../components/Screen';
8
+ import { ScreenImage } from '../components/Screen';
9
9
  import { StatusBadge, StatusDot } from '../components/Status';
10
+ import { eventLooks, journeyLooks, stateLooks } from '../looks/collect';
11
+ import { Stage } from '../looks/Stage';
10
12
  import { mapScope } from '../layout/graph';
11
13
  import type { MapSelection } from '../map/ChartMap';
12
14
  import styles from './views.module.css';
@@ -87,9 +89,8 @@ function Questions({ view, ids }: { view: AtlasView; ids: string[] }) {
87
89
  function ScreenDetails({ view, state }: { view: AtlasView; state: StateView }) {
88
90
  const checks = state.result?.checks.length ? state.result.checks : state.specChecks.map((check) => ({ check, passed: null as boolean | null, expected: undefined, actual: undefined }));
89
91
  const note = statusNote(state.status);
90
- const picture = placeholderReason(state.status, state.result?.screenshot ?? null);
91
- const showPictureCaption = picture !== 'not-run' && picture !== 'not-reached';
92
92
  const events = state.result?.businessEvents;
93
+ const screen = state.kind === 'screen' || state.kind === 'final';
93
94
  const incoming = state.incoming.map((id) => view.transitions.get(id)).filter((t): t is TransitionView => Boolean(t) && !t!.carriedBy);
94
95
  const outgoing = state.outgoing.map((id) => view.transitions.get(id)).filter((t): t is TransitionView => Boolean(t));
95
96
  return (
@@ -101,24 +102,30 @@ function ScreenDetails({ view, state }: { view: AtlasView; state: StateView }) {
101
102
  </div>
102
103
  {state.description && <p className={styles.lead}>{state.description}</p>}
103
104
  {note && <p className={styles.statusNote}>{note}</p>}
104
- <div className={styles.plateSplit}>
105
- {(state.kind === 'screen' || state.kind === 'final') && (
106
- <div className={styles.bigShot}>
107
- <ScreenImage key={state.name} state={state} size="full" description={false} lines={state.hints.length > 0} caption={showPictureCaption} />
108
- </div>
109
- )}
110
- <div>
111
- <h3 className={styles.sectionTitle}>Where they go next</h3>
112
- {outgoing.length > 0 ? (
113
- <div className={styles.eventList}>
114
- {outgoing.map((transition) => (
115
- <EventLink key={transition.id} t={transition} view={view} direction="out" />
116
- ))}
105
+ <Stage
106
+ looks={stateLooks(state)}
107
+ prefer="pictures"
108
+ empty={
109
+ screen ? (
110
+ <div className={styles.bigShot}>
111
+ <ScreenImage key={state.name} state={state} size="full" description={false} lines={state.hints.length > 0} />
117
112
  </div>
118
113
  ) : (
119
- <p className={styles.lead}>{state.deadEnd ? `This is where the journey stops: ${state.deadEnd}` : 'Nothing leaves this screen.'}</p>
120
- )}
121
- </div>
114
+ <p className={styles.lead}>This part of the chart has no screen of its own.</p>
115
+ )
116
+ }
117
+ />
118
+ <div>
119
+ <h3 className={styles.sectionTitle}>Where they go next</h3>
120
+ {outgoing.length > 0 ? (
121
+ <div className={styles.eventList}>
122
+ {outgoing.map((transition) => (
123
+ <EventLink key={transition.id} t={transition} view={view} direction="out" />
124
+ ))}
125
+ </div>
126
+ ) : (
127
+ <p className={styles.lead}>{state.deadEnd ? `This is where the journey stops: ${state.deadEnd}` : 'Nothing leaves this screen.'}</p>
128
+ )}
122
129
  </div>
123
130
  {checks.length > 0 && (
124
131
  <Section title="What should be on the screen">
@@ -213,21 +220,6 @@ function ScreenDetails({ view, state }: { view: AtlasView; state: StateView }) {
213
220
  );
214
221
  }
215
222
 
216
- /** The run's recording of the event, with the steps it took as captions when the run wrote them. */
217
- function Clip({ t }: { t: TransitionView }) {
218
- const clip = t.result?.clip;
219
- if (!clip || clip.state === 'missing') return null;
220
- if (clip.state === 'processing' || !clip.src) return <p className={styles.clipPending}>The video of this event is still processing.</p>;
221
- return (
222
- <figure className={styles.clip}>
223
- <video key={clip.src} className={styles.video} src={clip.src} poster={clip.poster ?? undefined} controls preload="metadata" playsInline>
224
- {clip.captions && <track kind="captions" src={clip.captions} srcLang="en" label="Steps" default />}
225
- </video>
226
- <figcaption>What the run did{clip.captions ? ', step by step in the captions' : ''}.</figcaption>
227
- </figure>
228
- );
229
- }
230
-
231
223
  function EventDetails({ view, t }: { view: AtlasView; t: TransitionView }) {
232
224
  const from = view.states.get(t.source);
233
225
  const to = t.target ? view.states.get(t.target) : undefined;
@@ -243,21 +235,12 @@ function EventDetails({ view, t }: { view: AtlasView; t: TransitionView }) {
243
235
  From <strong>{from?.name ?? t.source}</strong> to <strong>{to?.name ?? 'a screen not in the spec'}</strong>.
244
236
  </p>
245
237
  {note && <p className={styles.statusNote}>{note}</p>}
246
- <Clip t={t} />
247
- <div className={styles.beforeAfter}>
248
- {from && (
249
- <figure>
250
- <ScreenImage state={from} size="full" />
251
- <figcaption>Before: {from.name}</figcaption>
252
- </figure>
253
- )}
254
- {to && (
255
- <figure>
256
- <ScreenImage state={to} size="full" />
257
- <figcaption>After: {to.name}</figcaption>
258
- </figure>
259
- )}
260
- </div>
238
+ <Stage
239
+ looks={eventLooks(view, t)}
240
+ prefer="film"
241
+ notice={t.result?.clip.state === 'processing' ? 'The video of this event is still processing.' : null}
242
+ empty={<p className={styles.lead}>This event has no picture, video, or timeline in this run.</p>}
243
+ />
261
244
  <Questions view={view} ids={t.questionIds} />
262
245
  <Developer
263
246
  rows={[
@@ -270,6 +253,12 @@ function EventDetails({ view, t }: { view: AtlasView; t: TransitionView }) {
270
253
  );
271
254
  }
272
255
 
256
+ function JourneyLooks({ view, journeyId }: { view: AtlasView; journeyId: string }) {
257
+ const looks = journeyLooks(view, journeyId);
258
+ if (looks.pictures.length === 0 && looks.films.length === 0 && !looks.moments.some((moment) => moment.thumb)) return null;
259
+ return <Stage looks={looks} prefer="timeline" />;
260
+ }
261
+
273
262
  function JourneyDetails({ view, journeyId, chartId }: { view: AtlasView; journeyId: string; chartId: string }) {
274
263
  const j = view.journeys.find((x) => x.id === journeyId);
275
264
  if (!j) return <p className={styles.lead}>This journey isn’t in the spec.</p>;
@@ -281,6 +270,7 @@ function JourneyDetails({ view, journeyId, chartId }: { view: AtlasView; journey
281
270
  </div>
282
271
  {j.description && <p className={styles.lead}>{j.description}</p>}
283
272
  {!j.replay.ok && <p className={styles.warn}>The chart can’t follow this journey past “{j.replay.failedAt ?? j.replay.missingEnds.join(', ')}”.</p>}
273
+ <JourneyLooks view={view} journeyId={j.id} />
284
274
  <ol className={styles.steps}>
285
275
  {j.steps.map((step, i) => {
286
276
  const t = view.transitions.get(step.transitionIds[0] ?? '');
@@ -0,0 +1,475 @@
1
+ // @io-wall-ok — example dev server reads the specs it is already serving
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import type { EvidenceCheck } from '@crvouga/atlas/reports/explorer-types';
6
+ import { specEntry } from '@crvouga/atlas/reports/spec-inventory';
7
+ import { publishLayer } from '@crvouga/atlas/reports/layer-store';
8
+ import type { LayerSummary } from '@crvouga/atlas/reports/layer-types';
9
+ import type { ObjectStore } from '@crvouga/atlas/reports/object-store';
10
+ import type { ReportFrame, ReportStep, RunReport } from '@crvouga/atlas/reports/report-types';
11
+ import { createReportsApp, type ReportsAppDeps } from '@crvouga/atlas/reports/reports-route';
12
+ import { ObjectStoreWithPrefix } from '@crvouga/atlas/reports/store-prefix';
13
+
14
+ import { listSpecFiles, resolveRoots } from './atlas-files';
15
+
16
+ /** The version the example calls current. Older layers stay in the report beside it. */
17
+ export const EXAMPLE_CURRENT = `c0ffee${'1'.repeat(34)}`;
18
+ export const EXAMPLE_PREVIOUS = `a11ce0${'0'.repeat(34)}`;
19
+ export const EXAMPLE_BRANCH = `b1a4c0${'2'.repeat(34)}`;
20
+
21
+ const EVIDENCE_FILE = /\.(feature|qnt|dmn|als)$/i;
22
+ const OPENSLO_FILE = /\.openslo\.ya?ml$/i;
23
+
24
+ const COFFEE_FEATURE = 'coffee/ordering/ordering.feature';
25
+ const TODO_FEATURE = 'share-a-list.feature';
26
+
27
+ const CSS = 'main { font-family: sans-serif; padding: 24px; } h1 { font-size: 20px; margin: 0 0 12px; } button { font: 16px sans-serif; padding: 8px 12px; }';
28
+
29
+ function memoryStore(): ObjectStore {
30
+ const objects = new Map<string, { bytes: Uint8Array; contentType: string }>();
31
+ const store: ObjectStore = {
32
+ async get(key) {
33
+ const found = objects.get(key);
34
+ if (!found) return null;
35
+ return { body: new Blob([new Uint8Array(found.bytes)]).stream(), contentType: found.contentType, size: found.bytes.length };
36
+ },
37
+ async put(key, bytes, contentType) {
38
+ objects.set(key, { bytes: new Uint8Array(bytes), contentType });
39
+ },
40
+ async head(key) {
41
+ return objects.has(key);
42
+ },
43
+ async delete(key) {
44
+ objects.delete(key);
45
+ },
46
+ async deleteMany(keys) {
47
+ for (const key of keys) objects.delete(key);
48
+ },
49
+ async getUri() {
50
+ return null;
51
+ },
52
+ async listKeys(prefix = '') {
53
+ return [...objects.keys()].filter((key) => key.startsWith(prefix)).sort();
54
+ },
55
+ withPrefix(prefix) {
56
+ return new ObjectStoreWithPrefix(store, prefix);
57
+ }
58
+ };
59
+ return store;
60
+ }
61
+
62
+ /** Spec documents the evidence tabs read, relative to the specs root. */
63
+ export function readExampleSpecs(root: string): Record<string, string> {
64
+ const specs: Record<string, string> = {};
65
+ for (const file of listSpecFiles(root)) {
66
+ if (!EVIDENCE_FILE.test(file) && !OPENSLO_FILE.test(file)) continue;
67
+ specs[file] = readFileSync(path.join(root, file), 'utf8');
68
+ }
69
+ return specs;
70
+ }
71
+
72
+ /** The coffee fixture and the todo example are the specs this dev server can fill with layers. */
73
+ export function servesExampleReports(specsRoot: string): boolean {
74
+ return existsSync(path.join(specsRoot, COFFEE_FEATURE)) || existsSync(path.join(specsRoot, TODO_FEATURE));
75
+ }
76
+
77
+ type Status = 'passed' | 'failed';
78
+ type BackendName = 'local' | 'shared' | 'device-lab';
79
+
80
+ type CheckSpec = {
81
+ readonly id: string;
82
+ readonly title: string;
83
+ readonly spec: string;
84
+ readonly status: Status;
85
+ readonly detail?: string;
86
+ readonly kind?: 'behavior' | 'performance';
87
+ readonly runtime?: string;
88
+ readonly steps?: readonly ReportStep[];
89
+ readonly files?: readonly { readonly path: string; readonly body: string }[];
90
+ readonly measurements?: EvidenceCheck['measurements'];
91
+ };
92
+
93
+ type LayerSpec = {
94
+ readonly at: number;
95
+ readonly sha: string;
96
+ readonly branch: string;
97
+ readonly environment?: string;
98
+ readonly parameters?: Record<string, string>;
99
+ readonly backend: BackendName;
100
+ readonly packageDir: string;
101
+ readonly checks: readonly CheckSpec[];
102
+ };
103
+
104
+ function at(iso: string) {
105
+ return Date.parse(iso);
106
+ }
107
+
108
+ function picture(title: string, body: string) {
109
+ return `<!doctype html><html><body><main><h1>${title}</h1>${body}</main></body></html>`;
110
+ }
111
+
112
+ function frame(index: number, file: string | null): ReportFrame {
113
+ return {
114
+ index,
115
+ capturedAtMs: index * 1000,
116
+ snapshots: file ? [{ name: 'Customer', nodeId: 'customer', media: 'html', file }] : []
117
+ };
118
+ }
119
+
120
+ /** One moment per line. A picture is kept so the recording has something to show. */
121
+ function timeline(lines: readonly { readonly title: string; readonly status?: Status; readonly error?: string; readonly picture?: string }[]) {
122
+ const files: { path: string; body: string }[] = [];
123
+ const steps: ReportStep[] = lines.map((line, index) => {
124
+ const n = index + 1;
125
+ const shot = line.picture ? `frames/m-${String(n).padStart(4, '0')}.html` : null;
126
+ if (shot && line.picture) files.push({ path: shot, body: line.picture });
127
+ return {
128
+ index: n,
129
+ title: line.title,
130
+ kind: 'when',
131
+ status: line.status ?? 'passed',
132
+ startedAtMs: n * 1000,
133
+ endedAtMs: n * 1000 + 400,
134
+ durationMs: 400,
135
+ frames: [frame(n, shot)],
136
+ steps: [],
137
+ ...(line.error ? { error: line.error } : {})
138
+ };
139
+ });
140
+ return { steps, files };
141
+ }
142
+
143
+ function quintSteps(lines: readonly { readonly title: string; readonly detail: string }[]): ReportStep[] {
144
+ return lines.map((line, index) => {
145
+ const n = index + 1;
146
+ return { index: n, title: line.title, kind: 'quint', status: 'passed', detail: line.detail, startedAtMs: n * 1000, endedAtMs: n * 1000 + 200, durationMs: 200, frames: [], steps: [] };
147
+ });
148
+ }
149
+
150
+ function named(feature: string, scenario: string) {
151
+ return `${feature}: ${scenario}`;
152
+ }
153
+
154
+ /** Five layers. Each one ran only its own checks, on one environment, from one version and backend. */
155
+ function coffeeLayers(): readonly LayerSpec[] {
156
+ const feature = 'Order a drink';
157
+ const spec = COFFEE_FEATURE;
158
+ const quint = 'coffee/ordering/cart.qnt';
159
+ const dmn = 'coffee/ordering/drink-price.dmn';
160
+ const slo = 'coffee/ordering/checkout.openslo.yaml';
161
+ const alloy = 'coffee/ordering/payment.als';
162
+ const dir = 'examples/coffee';
163
+ const card = timeline([
164
+ { title: 'Given a drink in the cart' },
165
+ { title: 'When the customer pays by card', picture: picture('Order', '<p>Oat latte, large</p><button data-testid="pay">Pay by card</button>') },
166
+ { title: 'Then the barista can start the drink' }
167
+ ]);
168
+ const declined = timeline([
169
+ { title: 'Given a drink in the cart' },
170
+ { title: 'When the card is declined', status: 'failed', error: 'The wallet never confirmed the payment', picture: picture('Checkout', '<p>The card was declined.</p><button data-testid="wallet">Pay with wallet</button>') },
171
+ { title: 'And the customer pays with a wallet' }
172
+ ]);
173
+ const iosCard = timeline([
174
+ { title: 'Given a drink in the cart' },
175
+ { title: 'When the customer pays by card', status: 'failed', error: 'The card form never appeared' },
176
+ { title: 'Then the barista can start the drink' }
177
+ ]);
178
+ const scenario = (id: string, name: string, status: Status, extra: Partial<CheckSpec> = {}): CheckSpec => ({ id, title: named(feature, name), spec, status, ...extra });
179
+ return [
180
+ {
181
+ at: at('2026-10-01T15:00:00.000Z'),
182
+ sha: EXAMPLE_PREVIOUS,
183
+ branch: 'main',
184
+ environment: 'desktop-web',
185
+ backend: 'shared',
186
+ packageDir: dir,
187
+ checks: [
188
+ scenario('pays-by-card', 'Pays by card', 'passed', { steps: card.steps, files: card.files }),
189
+ scenario('pays-with-wallet', 'Pays with a wallet', 'passed'),
190
+ scenario('card-declined', 'Card declined, then a wallet works', 'passed'),
191
+ scenario('slow-payment', 'Slow payment', 'passed'),
192
+ scenario('leaves-checkout', 'Leaves checkout and comes back', 'passed'),
193
+ scenario('drink-left', 'Drink left too long', 'passed'),
194
+ { id: 'slow-payment', title: named(feature, 'Slow payment'), spec, status: 'passed', kind: 'performance', measurements: [{ name: 'Payment', value: 74, unit: 's', limit: 60 }] },
195
+ { id: 'trace-1', title: 'cart · trace 1', spec: quint, status: 'passed', runtime: 'quint', steps: quintSteps([{ title: 'add(0)', detail: 'items = 1' }, { title: 'pay', detail: 'paid = true' }]) },
196
+ { id: 'price-large', title: 'Drink price: large oat latte', spec: dmn, status: 'passed', runtime: 'dmn' },
197
+ { id: 'checkout-latency', title: 'Checkout answers within a second', spec: slo, status: 'passed', runtime: 'openslo' },
198
+ { id: 'one-payment', title: 'An order has one payment', spec: alloy, status: 'passed', runtime: 'alloy' }
199
+ ]
200
+ },
201
+ {
202
+ at: at('2026-10-06T15:00:00.000Z'),
203
+ sha: EXAMPLE_PREVIOUS,
204
+ branch: 'main',
205
+ parameters: { environment: 'Android', device: 'phone' },
206
+ backend: 'device-lab',
207
+ packageDir: dir,
208
+ checks: [
209
+ scenario('pays-by-card', 'Pays by card', 'passed'),
210
+ scenario('pays-with-wallet', 'Pays with a wallet', 'passed'),
211
+ { id: 'checkout-latency', title: 'Checkout answers within a second', spec: slo, status: 'failed', runtime: 'openslo', detail: 'Checkout took 1.8 seconds' }
212
+ ]
213
+ },
214
+ {
215
+ at: at('2026-10-08T15:00:00.000Z'),
216
+ sha: EXAMPLE_BRANCH,
217
+ branch: 'feat/wallet',
218
+ environment: 'mobile-web',
219
+ backend: 'local',
220
+ packageDir: dir,
221
+ checks: [
222
+ scenario('pays-by-card', 'Pays by card', 'passed'),
223
+ scenario('pays-with-wallet', 'Pays with a wallet', 'failed', { detail: 'The wallet sheet did not open' }),
224
+ scenario('card-declined', 'Card declined, then a wallet works', 'passed'),
225
+ scenario('slow-payment', 'Slow payment', 'passed'),
226
+ { id: 'price-large', title: 'Drink price: large oat latte', spec: dmn, status: 'failed', runtime: 'dmn', detail: 'Large oat latte was priced at 4' }
227
+ ]
228
+ },
229
+ {
230
+ at: at('2026-10-11T15:00:00.000Z'),
231
+ sha: EXAMPLE_CURRENT,
232
+ branch: 'main',
233
+ environment: 'ios',
234
+ backend: 'device-lab',
235
+ packageDir: dir,
236
+ checks: [scenario('pays-by-card', 'Pays by card', 'failed', { detail: 'The card form never appeared', steps: iosCard.steps, files: iosCard.files })]
237
+ },
238
+ {
239
+ at: at('2026-10-12T16:00:00.000Z'),
240
+ sha: EXAMPLE_CURRENT,
241
+ branch: 'main',
242
+ environment: 'desktop-web',
243
+ backend: 'local',
244
+ packageDir: dir,
245
+ checks: [scenario('card-declined', 'Card declined, then a wallet works', 'failed', { detail: 'The wallet never confirmed the payment', steps: declined.steps, files: declined.files })]
246
+ }
247
+ ];
248
+ }
249
+
250
+ function todoLayers(): readonly LayerSpec[] {
251
+ const feature = 'Share a list';
252
+ const spec = TODO_FEATURE;
253
+ // These match the ids `examples/todo/documents.ts` records, so a desktop web run replaces that slot.
254
+ const quint = 'list.qnt';
255
+ const dmn = 'retry-delay.dmn';
256
+ const slo = 'sync.openslo.yaml';
257
+ const alloy = 'list.als';
258
+ const dir = 'examples/todo';
259
+ const weekend = timeline([
260
+ { title: 'Given no list yet' },
261
+ { title: 'When the customer names the list', picture: picture('Weekend chores', '<p>Water the plants</p><button data-testid="add">Add</button>') },
262
+ { title: 'Then the list is clear and synced' }
263
+ ]);
264
+ const outage = timeline([
265
+ { title: 'Given a list' },
266
+ { title: 'When sync fails', status: 'failed', error: 'The retry never caught up', picture: picture('Weekend chores', '<p>Sync failed. The list is still here.</p>') },
267
+ { title: 'Then the list is synced' }
268
+ ]);
269
+ const iosWeekend = timeline([
270
+ { title: 'Given no list yet' },
271
+ { title: 'When the customer names the list', status: 'failed', error: 'The list name field never appeared' },
272
+ { title: 'Then the list is clear and synced' }
273
+ ]);
274
+ const scenario = (id: string, name: string, status: Status, extra: Partial<CheckSpec> = {}): CheckSpec => ({ id, title: named(feature, name), spec, status, ...extra });
275
+ return [
276
+ {
277
+ at: at('2026-10-01T15:00:00.000Z'),
278
+ sha: EXAMPLE_PREVIOUS,
279
+ branch: 'main',
280
+ environment: 'desktop-web',
281
+ backend: 'shared',
282
+ packageDir: dir,
283
+ checks: [
284
+ scenario('gherkin.share-a-list.plans-a-weekend', 'Plans a weekend', 'passed', { steps: weekend.steps, files: weekend.files }),
285
+ scenario('gherkin.share-a-list.keeps-working-through-a-sync-outage', 'Keeps working through a sync outage', 'passed'),
286
+ scenario('gherkin.share-a-list.starts-over', 'Starts over', 'passed'),
287
+ scenario('gherkin.share-a-list.starts-over-while-offline', 'Starts over while offline', 'passed'),
288
+ { id: 'gherkin.share-a-list.starts-over-while-offline', title: named(feature, 'Starts over while offline'), spec, status: 'passed', kind: 'performance', measurements: [{ name: 'Sync', value: 2.4, unit: 's', limit: 1 }] },
289
+ { id: 'trace-1', title: 'list · trace 1', spec: quint, status: 'passed', runtime: 'quint', steps: quintSteps([{ title: 'add(0)', detail: 'items = 1' }, { title: 'sync', detail: 'synced = true' }]) },
290
+ { id: 'retry-second', title: 'Retry delay: second attempt', spec: dmn, status: 'passed', runtime: 'dmn' },
291
+ { id: 'sync-latency', title: 'Sync finishes within a second', spec: slo, status: 'passed', runtime: 'openslo' },
292
+ { id: 'list-owns-todos', title: 'A list has its todos', spec: alloy, status: 'passed', runtime: 'alloy' }
293
+ ]
294
+ },
295
+ {
296
+ at: at('2026-10-06T15:00:00.000Z'),
297
+ sha: EXAMPLE_PREVIOUS,
298
+ branch: 'main',
299
+ parameters: { environment: 'Android', device: 'phone' },
300
+ backend: 'device-lab',
301
+ packageDir: dir,
302
+ checks: [
303
+ scenario('gherkin.share-a-list.plans-a-weekend', 'Plans a weekend', 'passed'),
304
+ scenario('gherkin.share-a-list.starts-over', 'Starts over', 'passed'),
305
+ { id: 'sync-latency', title: 'Sync finishes within a second', spec: slo, status: 'failed', runtime: 'openslo', detail: 'Sync took 1.8 seconds' }
306
+ ]
307
+ },
308
+ {
309
+ at: at('2026-10-08T15:00:00.000Z'),
310
+ sha: EXAMPLE_BRANCH,
311
+ branch: 'feat/offline',
312
+ environment: 'mobile-web',
313
+ backend: 'local',
314
+ packageDir: dir,
315
+ checks: [
316
+ scenario('gherkin.share-a-list.plans-a-weekend', 'Plans a weekend', 'passed'),
317
+ scenario('gherkin.share-a-list.keeps-working-through-a-sync-outage', 'Keeps working through a sync outage', 'passed'),
318
+ scenario('gherkin.share-a-list.starts-over', 'Starts over', 'failed', { detail: 'The list was still there' }),
319
+ { id: 'retry-second', title: 'Retry delay: second attempt', spec: dmn, status: 'failed', runtime: 'dmn', detail: 'The second retry waited 10 seconds' }
320
+ ]
321
+ },
322
+ {
323
+ at: at('2026-10-11T15:00:00.000Z'),
324
+ sha: EXAMPLE_CURRENT,
325
+ branch: 'main',
326
+ environment: 'ios',
327
+ backend: 'device-lab',
328
+ packageDir: dir,
329
+ checks: [scenario('gherkin.share-a-list.plans-a-weekend', 'Plans a weekend', 'failed', { detail: 'The list name field never appeared', steps: iosWeekend.steps, files: iosWeekend.files })]
330
+ },
331
+ {
332
+ at: at('2026-10-12T16:00:00.000Z'),
333
+ sha: EXAMPLE_CURRENT,
334
+ branch: 'main',
335
+ environment: 'desktop-web',
336
+ backend: 'local',
337
+ packageDir: dir,
338
+ checks: [scenario('gherkin.share-a-list.keeps-working-through-a-sync-outage', 'Keeps working through a sync outage', 'failed', { detail: 'The retry never caught up', steps: outage.steps, files: outage.files })]
339
+ }
340
+ ];
341
+ }
342
+
343
+ function layersFor(specs: Readonly<Record<string, string>>): readonly LayerSpec[] | null {
344
+ if (COFFEE_FEATURE in specs) return coffeeLayers();
345
+ if (TODO_FEATURE in specs) return todoLayers();
346
+ return null;
347
+ }
348
+
349
+ function evidence(producer: string, check: CheckSpec): EvidenceCheck {
350
+ return {
351
+ id: `${producer}/${check.id}`,
352
+ title: check.title,
353
+ status: check.status,
354
+ detail: check.detail ?? '',
355
+ specPath: `specs/${check.spec}`,
356
+ measurements: check.measurements ?? []
357
+ };
358
+ }
359
+
360
+ function recorded(producer: string, check: CheckSpec): RunReport {
361
+ const ended = check.steps?.at(-1)?.endedAtMs ?? 1;
362
+ return {
363
+ meta: {
364
+ testId: check.id,
365
+ runId: 'example',
366
+ title: check.title,
367
+ system: 'example',
368
+ runtime: check.runtime ?? 'gherkin',
369
+ seed: check.id,
370
+ status: check.status,
371
+ actorIds: ['customer'],
372
+ startedAtMs: 0,
373
+ endedAtMs: ended,
374
+ durationMs: Math.max(1, ended),
375
+ sourceFile: `specs/${check.spec}`,
376
+ ...(check.detail ? { error: check.detail } : {})
377
+ },
378
+ steps: check.steps ?? []
379
+ };
380
+ }
381
+
382
+ async function publishExampleLayer(store: ObjectStore, producer: string, layer: LayerSpec, specs: Readonly<Record<string, string>>) {
383
+ const behavior = layer.checks.filter((check) => check.kind !== 'performance');
384
+ const timings = layer.checks.filter((check) => check.kind === 'performance');
385
+ const reports = behavior.filter((check) => check.steps);
386
+ const referenced = [...new Set(layer.checks.map((check) => check.spec))];
387
+ const summary: Omit<LayerSummary, 'id'> = {
388
+ producer,
389
+ recordedAtMs: layer.at,
390
+ sha: layer.sha,
391
+ branch: layer.branch,
392
+ ...(layer.environment ? { environment: layer.environment } : {}),
393
+ runUrl: null,
394
+ parameters: layer.parameters ?? {},
395
+ specs: referenced.map((file) => {
396
+ const text = specs[file];
397
+ if (text === undefined) throw new Error(`Example layer names a spec that was not loaded: ${file}`);
398
+ return specEntry(`specs/${file}`, text);
399
+ }),
400
+ recording: { type: 'layer' },
401
+ source: {
402
+ source: producer,
403
+ packageDir: layer.packageDir,
404
+ bucket: 'all',
405
+ status: behavior.some((check) => check.status === 'failed') ? 'failed' : 'passed',
406
+ updatedAtMs: layer.at,
407
+ ...(layer.environment ? { environment: layer.environment } : {}),
408
+ ...(layer.parameters ? { parameters: layer.parameters } : {}),
409
+ tests: reports.map((check) => recorded(producer, check).meta)
410
+ },
411
+ parts: [
412
+ { sha: layer.sha, id: producer, kind: 'behavior', title: producer, recordedAtMs: layer.at, checks: behavior.map((check) => evidence(producer, check)) },
413
+ ...(timings.length ? [{ sha: layer.sha, id: `${producer}-timing`, kind: 'performance', title: 'Timings', recordedAtMs: layer.at, checks: timings.map((check) => evidence(producer, check)) }] : [])
414
+ ]
415
+ };
416
+ async function* entries() {
417
+ if (reports.some((check) => check.steps?.some((step) => step.frames.length > 0))) yield { key: 'frame.css', bytes: new TextEncoder().encode(CSS), contentType: 'text/css; charset=utf-8' };
418
+ for (const check of reports) {
419
+ yield { key: `tests/${check.id}/report.json`, bytes: new TextEncoder().encode(JSON.stringify(recorded(producer, check))), contentType: 'application/json' };
420
+ for (const file of check.files ?? []) yield { key: `tests/${check.id}/files/${file.path}`, bytes: new TextEncoder().encode(file.body), contentType: 'text/html; charset=utf-8' };
421
+ }
422
+ }
423
+ await publishLayer(store, summary, entries());
424
+ }
425
+
426
+ /**
427
+ * Layers for the coffee example or the todo example, already written into three stores.
428
+ * Any other spec set returns null, so a project's own reports are not replaced by this one.
429
+ */
430
+ export async function exampleReports(specs: Readonly<Record<string, string>>): Promise<ReportsAppDeps | null> {
431
+ const layers = layersFor(specs);
432
+ if (!layers) return null;
433
+ const producer = COFFEE_FEATURE in specs ? 'ordering' : 'todo';
434
+ const localStore = memoryStore();
435
+ const store = memoryStore();
436
+ const lab = memoryStore();
437
+ const backends = { local: localStore, shared: store, 'device-lab': lab } as const;
438
+ for (const layer of layers) await publishExampleLayer(backends[layer.backend], producer, layer, specs);
439
+ return {
440
+ store,
441
+ localStore,
442
+ backends: [{ id: 'device-lab', label: 'Device lab', store: lab }],
443
+ clock: { now: () => Date.now() },
444
+ timers: {
445
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
446
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
447
+ setInterval: (fn, ms) => setInterval(fn, ms),
448
+ clearInterval: (handle) => clearInterval(handle as ReturnType<typeof setInterval>)
449
+ },
450
+ outline: async () => ({
451
+ specs: Object.keys(specs).sort().map((file) => specEntry(`specs/${file}`, specs[file] ?? '')),
452
+ sha: EXAMPLE_CURRENT,
453
+ changedFiles: [],
454
+ readSpec: async (specPath) => specs[specPath.replace(/^specs\//, '')] ?? null
455
+ })
456
+ };
457
+ }
458
+
459
+ let exampleApp: ReturnType<typeof appForProcess> | undefined;
460
+
461
+ async function appForProcess() {
462
+ const specs = readExampleSpecs(resolveRoots().specs);
463
+ const deps = await exampleReports(specs);
464
+ return deps ? createReportsApp(deps) : null;
465
+ }
466
+
467
+ /** `GET /api/reports/...` for the visualizer dev server, when no host module is configured. */
468
+ export async function reportsDevFetch(request: Request, _repoRoot: string): Promise<Response> {
469
+ exampleApp ??= appForProcess();
470
+ const app = await exampleApp;
471
+ if (!app) return Response.json({ error: 'not_found' }, { status: 404 });
472
+ const url = new URL(request.url);
473
+ const pathname = url.pathname.replace(/^\/api\/reports/, '') || '/';
474
+ return app.request(pathname + url.search);
475
+ }
@@ -1,12 +1,20 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
1
4
  import react from '@vitejs/plugin-react';
2
5
  import { defineConfig } from 'vite';
3
6
 
7
+ import { resolveRoots } from './vite/atlas-files';
8
+ import { servesExampleReports } from './vite/example-reports';
4
9
  import { reportsDevPlugin } from './vite/reports-dev-plugin';
5
10
  import { atlasDevPlugin } from './vite/atlas-dev-plugin';
6
11
 
12
+ const appRoot = path.dirname(fileURLToPath(import.meta.url));
13
+ const reportsModule = process.env.ATLAS_REPORTS_MODULE || (servesExampleReports(resolveRoots().specs) ? path.join(appRoot, 'vite/example-reports.ts') : '');
14
+
7
15
  export default defineConfig({
8
16
  base: './',
9
- plugins: [react(), atlasDevPlugin(), ...(process.env.ATLAS_REPORTS_MODULE ? [reportsDevPlugin({ repoRoot: process.env.INIT_CWD || process.cwd(), appModule: process.env.ATLAS_REPORTS_MODULE })] : [])],
17
+ plugins: [react(), atlasDevPlugin(), ...(reportsModule ? [reportsDevPlugin({ repoRoot: process.env.INIT_CWD || process.cwd(), appModule: reportsModule })] : [])],
10
18
  worker: { format: 'es' },
11
19
  server: { port: Number(process.env.PORT ?? 5180), strictPort: false },
12
20
  build: { outDir: 'dist', emptyOutDir: true, chunkSizeWarningLimit: 2_500 }
@@ -1,14 +0,0 @@
1
- import { createContext, useContext, useState, type ReactNode } from 'react';
2
-
3
- type SearchQuery = { query: string; setQuery: (value: string) => void };
4
- const SearchQueryContext = createContext<SearchQuery>({ query: '', setQuery: () => {} });
5
-
6
- /** What is typed in the header narrows whichever index is open. */
7
- export function useSearchQuery() {
8
- return useContext(SearchQueryContext);
9
- }
10
-
11
- export function SearchQueryProvider({ children }: { children: ReactNode }) {
12
- const [query, setQuery] = useState('');
13
- return <SearchQueryContext.Provider value={{ query, setQuery }}>{children}</SearchQueryContext.Provider>;
14
- }