@crvouga/atlas 0.2.0 → 0.3.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 (41) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/README.md +8 -2
  3. package/dist/reports/src/export-evidence.js +2 -10
  4. package/dist/reports/src/frames.d.ts +3 -1
  5. package/dist/reports/src/frames.js +21 -0
  6. package/dist/reports/src/publish.js +2 -11
  7. package/dist/reports/src/recording-keys.js +4 -0
  8. package/dist/reports/src/report-types.d.ts +19 -2
  9. package/dist/reports/src/source-entries.js +2 -12
  10. package/package.json +4 -3
  11. package/packages/visualizer/README.md +3 -1
  12. package/packages/visualizer/src/app/AppShell.tsx +11 -12
  13. package/packages/visualizer/src/app/gazetteer.module.css +12 -2
  14. package/packages/visualizer/src/app/gazetteer.tsx +38 -26
  15. package/packages/visualizer/src/components/Header.module.css +20 -4
  16. package/packages/visualizer/src/components/Header.tsx +6 -3
  17. package/packages/visualizer/src/data/model/build.ts +17 -5
  18. package/packages/visualizer/src/data/model/types.ts +10 -1
  19. package/packages/visualizer/src/evidence/api.ts +6 -1
  20. package/packages/visualizer/src/evidence/recording-page.tsx +19 -2
  21. package/packages/visualizer/src/evidence/recording.module.css +4 -8
  22. package/packages/visualizer/src/evidence/recording.tsx +74 -83
  23. package/packages/visualizer/src/evidence/snapshot.tsx +5 -4
  24. package/packages/visualizer/src/evidence/spec-index.tsx +3 -11
  25. package/packages/visualizer/src/find/Finder.module.css +168 -0
  26. package/packages/visualizer/src/find/Finder.tsx +303 -0
  27. package/packages/visualizer/src/find/corpus.test.ts +77 -0
  28. package/packages/visualizer/src/find/corpus.ts +368 -0
  29. package/packages/visualizer/src/find/open.ts +51 -0
  30. package/packages/visualizer/src/find/rank.test.ts +237 -0
  31. package/packages/visualizer/src/find/rank.ts +405 -0
  32. package/packages/visualizer/src/find/semantic.test.ts +62 -0
  33. package/packages/visualizer/src/find/semantic.ts +93 -0
  34. package/packages/visualizer/src/find/semantic.worker.ts +40 -0
  35. package/packages/visualizer/src/looks/Stage.tsx +322 -0
  36. package/packages/visualizer/src/looks/collect.test.ts +158 -0
  37. package/packages/visualizer/src/looks/collect.ts +245 -0
  38. package/packages/visualizer/src/looks/stage.module.css +300 -0
  39. package/packages/visualizer/src/reachable.test.ts +1 -1
  40. package/packages/visualizer/src/views/SidePanel.tsx +39 -49
  41. package/packages/visualizer/src/app/search-query.tsx +0 -14
@@ -0,0 +1,322 @@
1
+ import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
2
+
3
+ import { ChevronIcon } from '../components/icons';
4
+ import { finderBlocksKeys } from '../find/open';
5
+ import type { FilmLook, Looks, MomentLook } from './collect';
6
+ import styles from './stage.module.css';
7
+
8
+ export type LookMode = 'pictures' | 'film' | 'timeline';
9
+
10
+ const MODE_LABEL: Record<LookMode, string> = {
11
+ pictures: 'Pictures',
12
+ film: 'Video',
13
+ timeline: 'Timeline'
14
+ };
15
+
16
+ type Props = {
17
+ looks: Looks;
18
+ /** Which kind to open when a run recorded more than one. */
19
+ prefer: LookMode;
20
+ /** Replaces the stage when the run recorded nothing of this subject. */
21
+ empty?: ReactNode;
22
+ notice?: string | null;
23
+ /** Timeline position, counted from zero. Omit to let the stage keep its own. */
24
+ moment?: number;
25
+ onMoment?: (index: number) => void;
26
+ /** Draws one timeline moment. Stills and video render themselves. */
27
+ renderMoment?: (index: number) => ReactNode;
28
+ /** Ask the stage to show this kind, for example after choosing a step. */
29
+ mode?: LookMode | null;
30
+ onMode?: (mode: LookMode) => void;
31
+ failure?: boolean;
32
+ onFailure?: () => void;
33
+ };
34
+
35
+ function modesOf(looks: Looks): LookMode[] {
36
+ const modes: LookMode[] = [];
37
+ if (looks.pictures.length) modes.push('pictures');
38
+ if (looks.films.length) modes.push('film');
39
+ if (looks.moments.length) modes.push('timeline');
40
+ return modes;
41
+ }
42
+
43
+ function choose(prefer: LookMode, modes: readonly LookMode[], requested: LookMode | null | undefined): LookMode | null {
44
+ if (requested && modes.includes(requested)) return requested;
45
+ if (modes.includes(prefer)) return prefer;
46
+ return modes[0] ?? null;
47
+ }
48
+
49
+ function clock(ms: number): string {
50
+ const seconds = Math.max(0, Math.round(ms / 1000));
51
+ const minutes = Math.floor(seconds / 60);
52
+ const rest = seconds % 60;
53
+ return minutes > 0 ? `${minutes}:${String(rest).padStart(2, '0')}` : `${rest}s`;
54
+ }
55
+
56
+ function pauseFor(mode: LookMode, moment: MomentLook | undefined): number {
57
+ if (mode === 'pictures') return 1400;
58
+ return Math.max(100, Math.min(moment?.pauseMs ?? 700, 2000));
59
+ }
60
+
61
+ /**
62
+ * One player for whatever a runner captured: stills, a video, or a timeline of moments.
63
+ * Kinds the run did not record are absent. When several were recorded, each is a full view of the same evidence.
64
+ */
65
+ export function Stage({ looks, prefer, empty, notice, moment, onMoment, renderMoment, mode, onMode, failure, onFailure }: Props) {
66
+ const modes = modesOf(looks);
67
+ const [localMode, setLocalMode] = useState<LookMode | null>(null);
68
+ const [playing, setPlaying] = useState(false);
69
+ const active = choose(prefer, modes, onMode ? mode ?? null : localMode);
70
+ const pickMode = (next: LookMode) => {
71
+ setPlaying(false);
72
+ if (onMode) onMode(next);
73
+ else setLocalMode(next);
74
+ };
75
+ const [picture, setPicture] = useState(0);
76
+ const [film, setFilm] = useState(0);
77
+ const [localMoment, setLocalMoment] = useState(0);
78
+ const sectionRef = useRef<HTMLElement>(null);
79
+ const videoRef = useRef<HTMLVideoElement>(null);
80
+ const onMomentRef = useRef(onMoment);
81
+ onMomentRef.current = onMoment;
82
+
83
+ const count = active === 'pictures' ? looks.pictures.length : active === 'film' ? looks.films.length : looks.moments.length;
84
+ const timelineIndex = moment ?? localMoment;
85
+ const index = Math.max(0, Math.min(count - 1, active === 'pictures' ? picture : active === 'film' ? film : timelineIndex));
86
+
87
+ const select = (next: number) => {
88
+ const clamped = Math.max(0, Math.min(count - 1, next));
89
+ if (active === 'pictures') setPicture(clamped);
90
+ else if (active === 'film') setFilm(clamped);
91
+ else if (onMomentRef.current) onMomentRef.current(clamped);
92
+ else setLocalMoment(clamped);
93
+ };
94
+ const selectRef = useRef(select);
95
+ selectRef.current = select;
96
+
97
+ useEffect(() => {
98
+ setPlaying(false);
99
+ }, [active]);
100
+
101
+ useEffect(() => {
102
+ if (!playing || !active || active === 'film' || count < 2) return;
103
+ if (index >= count - 1) {
104
+ setPlaying(false);
105
+ return;
106
+ }
107
+ const current = active === 'timeline' ? looks.moments[index] : undefined;
108
+ const timer = setTimeout(() => selectRef.current(index + 1), pauseFor(active, current));
109
+ return () => clearTimeout(timer);
110
+ }, [playing, active, index, count, looks.moments]);
111
+
112
+ useEffect(() => {
113
+ if (!active || count < 2) return;
114
+ const onKey = (event: KeyboardEvent) => {
115
+ if (finderBlocksKeys() || event.altKey || event.ctrlKey || event.metaKey) return;
116
+ const root = sectionRef.current;
117
+ const focused = document.activeElement;
118
+ if (!root || (focused instanceof HTMLElement && focused !== document.body && !root.contains(focused))) return;
119
+ const target = event.target;
120
+ if (target instanceof HTMLElement && (target.isContentEditable || target.closest('input, textarea, select'))) return;
121
+ const next = event.key === 'ArrowRight' ? index + 1 : event.key === 'ArrowLeft' ? index - 1 : event.key === 'Home' ? 0 : event.key === 'End' ? count - 1 : null;
122
+ if (next === null || next === index || next < 0 || next >= count) return;
123
+ event.preventDefault();
124
+ setPlaying(false);
125
+ selectRef.current(next);
126
+ };
127
+ window.addEventListener('keydown', onKey);
128
+ return () => window.removeEventListener('keydown', onKey);
129
+ }, [active, index, count]);
130
+
131
+ if (!active) {
132
+ return (
133
+ <div className={styles.stage}>
134
+ {notice && <p className={styles.notice}>{notice}</p>}
135
+ {empty ?? <p className={styles.quiet}>Nothing was captured of this.</p>}
136
+ </div>
137
+ );
138
+ }
139
+
140
+ const showTransport = count > 1;
141
+ const showPlay = showTransport && active !== 'film';
142
+ const shot = looks.pictures[index];
143
+ const clip = looks.films[index];
144
+ const step = looks.moments[index];
145
+ const noun = active === 'pictures' ? 'Picture' : active === 'film' ? 'Video' : 'Moment';
146
+
147
+ return (
148
+ <section ref={sectionRef} className={styles.stage} aria-label="What it looked like">
149
+ {modes.length > 1 && (
150
+ <div className={styles.modes} role="tablist" aria-label="Captured evidence">
151
+ {modes.map((item) => (
152
+ <button key={item} type="button" role="tab" id={`look-${item}`} aria-selected={item === active} onClick={() => pickMode(item)}>
153
+ {MODE_LABEL[item]}
154
+ </button>
155
+ ))}
156
+ </div>
157
+ )}
158
+ {notice && <p className={styles.notice}>{notice}</p>}
159
+ <div className={styles.viewport}>
160
+ {active === 'pictures' && shot && (
161
+ <>
162
+ <img className={styles.shot} src={shot.src} alt={shot.alt} />
163
+ <p className={styles.caption}>{shot.label}</p>
164
+ </>
165
+ )}
166
+ {active === 'film' && clip && <Film film={clip} videoRef={videoRef} />}
167
+ {active === 'timeline' && step && (
168
+ renderMoment ? (
169
+ <div className={styles.body}>{renderMoment(index)}</div>
170
+ ) : step.thumb ? (
171
+ <figure className={styles.story}>
172
+ <Still src={step.thumb} point={step.point} />
173
+ <figcaption className={styles.storyCaption} data-status={step.status ?? undefined}>
174
+ {step.caption && <span>{step.caption}</span>}
175
+ <strong>{step.label}</strong>
176
+ </figcaption>
177
+ </figure>
178
+ ) : (
179
+ <article className={styles.moment} data-status={step.status ?? undefined}>
180
+ {step.caption && <p>{step.caption}</p>}
181
+ <h3>{step.label}</h3>
182
+ </article>
183
+ )
184
+ )}
185
+ </div>
186
+ {showTransport && (
187
+ <div className={styles.transport}>
188
+ {showPlay && (
189
+ <button
190
+ type="button"
191
+ className={styles.control}
192
+ onClick={() => {
193
+ if (!playing && index === count - 1) select(0);
194
+ setPlaying((value) => !value);
195
+ }}
196
+ >
197
+ {playing ? 'Pause' : 'Play'}
198
+ </button>
199
+ )}
200
+ <button type="button" className={`${styles.control} ${styles.stepper}`} aria-label={`Previous ${noun.toLowerCase()}`} disabled={index === 0} onClick={() => { setPlaying(false); select(index - 1); }}>
201
+ <ChevronIcon size={14} className={styles.back} />
202
+ </button>
203
+ <button type="button" className={`${styles.control} ${styles.stepper}`} aria-label={`Next ${noun.toLowerCase()}`} disabled={index >= count - 1} onClick={() => { setPlaying(false); select(index + 1); }}>
204
+ <ChevronIcon size={14} />
205
+ </button>
206
+ <input
207
+ className={styles.scrub}
208
+ type="range"
209
+ aria-label={noun}
210
+ min={0}
211
+ max={count - 1}
212
+ value={index}
213
+ onChange={(event) => {
214
+ setPlaying(false);
215
+ select(Number(event.target.value));
216
+ }}
217
+ />
218
+ <span className={styles.position}>
219
+ {noun} {index + 1} of {count}
220
+ </span>
221
+ {active === 'timeline' && onFailure && (
222
+ <button type="button" className={styles.control} disabled={!failure} onClick={onFailure}>
223
+ Next failure
224
+ </button>
225
+ )}
226
+ </div>
227
+ )}
228
+ {active === 'pictures' && <Strip items={looks.pictures} index={index} onSelect={(next) => { setPlaying(false); select(next); }} />}
229
+ {active === 'film' && <Strip items={looks.films.map((item) => ({ id: item.id, label: item.label, thumb: item.poster }))} index={index} onSelect={(next) => select(next)} />}
230
+ {active === 'timeline' && <Strip items={looks.moments} index={index} onSelect={(next) => { setPlaying(false); select(next); }} />}
231
+ {active === 'film' && clip && clip.chapters.length > 0 && (
232
+ <ol className={styles.chapters} aria-label="Moments in the video">
233
+ {clip.chapters.map((chapter) => (
234
+ <li key={`${chapter.atMs}:${chapter.label}`}>
235
+ <button
236
+ type="button"
237
+ onClick={() => {
238
+ const node = videoRef.current;
239
+ if (!node) return;
240
+ node.currentTime = chapter.atMs / 1000;
241
+ void node.play().catch(() => undefined);
242
+ }}
243
+ >
244
+ {clock(chapter.atMs)} {chapter.label}
245
+ </button>
246
+ </li>
247
+ ))}
248
+ </ol>
249
+ )}
250
+ </section>
251
+ );
252
+ }
253
+
254
+ function Film({ film, videoRef }: { film: FilmLook; videoRef: RefObject<HTMLVideoElement | null> }) {
255
+ return (
256
+ <>
257
+ <video
258
+ key={film.src}
259
+ ref={videoRef}
260
+ className={styles.film}
261
+ src={film.src}
262
+ poster={film.poster ?? undefined}
263
+ controls
264
+ preload="metadata"
265
+ playsInline
266
+ aria-label={film.label}
267
+ onLoadedMetadata={(event) => {
268
+ const node = event.currentTarget;
269
+ if (node.videoWidth > 0 && node.videoHeight > 0) node.style.setProperty('--ar', String(node.videoWidth / node.videoHeight));
270
+ }}
271
+ >
272
+ {film.captions && <track kind="captions" src={film.captions} srcLang="en" label="Steps" default />}
273
+ </video>
274
+ <p className={styles.caption}>
275
+ {film.label}
276
+ {film.durationMs !== null && ` · ${clock(film.durationMs)}`}
277
+ </p>
278
+ </>
279
+ );
280
+ }
281
+
282
+ /** A still, with a mark where the moment happened when the run recorded a point on it. */
283
+ function Still({ src, point }: { src: string; point?: { x: number; y: number } | null }) {
284
+ const [size, setSize] = useState<{ w: number; h: number } | null>(null);
285
+ const placed = point && size && point.x >= 0 && point.y >= 0 && point.x <= size.w && point.y <= size.h;
286
+ return (
287
+ <div className={styles.shotFrame}>
288
+ <img
289
+ className={styles.shot}
290
+ src={src}
291
+ alt=""
292
+ onLoad={(event) => setSize({ w: event.currentTarget.naturalWidth, h: event.currentTarget.naturalHeight })}
293
+ />
294
+ {placed && (
295
+ <span
296
+ className={styles.mark}
297
+ style={{ left: `${(point.x / size.w) * 100}%`, top: `${(point.y / size.h) * 100}%` }}
298
+ aria-hidden="true"
299
+ />
300
+ )}
301
+ </div>
302
+ );
303
+ }
304
+
305
+ function Strip({ items, index, onSelect }: { items: readonly { id: string; label: string; thumb: string | null }[]; index: number; onSelect: (index: number) => void }) {
306
+ const selected = useRef<HTMLButtonElement>(null);
307
+ useEffect(() => {
308
+ selected.current?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
309
+ }, [index]);
310
+ if (items.length < 2) return null;
311
+ return (
312
+ <ol className={styles.strip} aria-label="Captured frames">
313
+ {items.map((item, itemIndex) => (
314
+ <li key={item.id}>
315
+ <button ref={itemIndex === index ? selected : undefined} type="button" aria-current={itemIndex === index} aria-label={item.label} title={item.label} onClick={() => onSelect(itemIndex)}>
316
+ {item.thumb ? <img src={item.thumb} alt="" /> : <span className={styles.chip}>{item.label}</span>}
317
+ </button>
318
+ </li>
319
+ ))}
320
+ </ol>
321
+ );
322
+ }
@@ -0,0 +1,158 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import type { RunReport } from '@crvouga/atlas/reports/report-types';
4
+
5
+ import { buildAtlasView, type ImageMedia, type StateView, type TransitionView } from '../data/model';
6
+ import { parseManifest } from '../data/parse/manifest';
7
+ import { parseSpec } from '../data/parse/spec';
8
+ import { eventLooks, reportLooks, stateLooks } from './collect';
9
+
10
+ function shot(src: string, client: string | null = null, also: ImageMedia['also'] = []): ImageMedia {
11
+ return { state: 'available', full: src, thumb: `${src}?thumb`, client, also };
12
+ }
13
+
14
+ function state(name: string, image: ImageMedia | null, paths: StateView['result'] extends infer R ? R extends { screenshotsByPath: infer P } ? P : never : never = []): StateView {
15
+ return {
16
+ key: name,
17
+ name,
18
+ result: image ? { screenshot: image, screenshotsByPath: paths } : null
19
+ } as StateView;
20
+ }
21
+
22
+ describe('looks', () => {
23
+ it('keeps every client and every path as its own picture', () => {
24
+ const pictures = stateLooks(
25
+ state('Welcome', shot('/welcome.png', 'phone', [{ client: 'desktop', full: '/welcome-desktop.png', thumb: null }]), [
26
+ { path: { id: 'failed', name: 'Payment declined' }, image: shot('/declined.png') }
27
+ ])
28
+ ).pictures;
29
+ expect(pictures.map((picture) => picture.label)).toEqual(['Welcome · phone', 'Welcome · desktop', 'Payment declined']);
30
+ expect(pictures.map((picture) => picture.src)).toEqual(['/welcome.png', '/welcome-desktop.png', '/declined.png']);
31
+ });
32
+
33
+ it('reads a transition as a video, the screens on either side, and a timeline', () => {
34
+ const welcome = state('Welcome', shot('/welcome.png'));
35
+ const working = state('Working', shot('/working.png'));
36
+ const view = { states: new Map([['Welcome', welcome], ['Working', working]]) } as unknown as Parameters<typeof eventLooks>[0];
37
+ const transition = {
38
+ id: 'Welcome :: Start',
39
+ source: 'Welcome',
40
+ target: 'Working',
41
+ event: 'Start',
42
+ result: {
43
+ clip: { state: 'available', src: '/start.mp4', poster: '/start.webp', durationMs: 4000, captions: '/start.vtt' },
44
+ timeline: [
45
+ { atMs: 0, kind: 'tap', label: 'Tap start' },
46
+ { atMs: 1200, kind: 'screen', label: 'Working' }
47
+ ]
48
+ }
49
+ } as TransitionView;
50
+ const looks = eventLooks(view, transition);
51
+ expect(looks.films).toEqual([
52
+ {
53
+ id: 'Welcome :: Start',
54
+ label: 'Start',
55
+ src: '/start.mp4',
56
+ poster: '/start.webp',
57
+ captions: '/start.vtt',
58
+ durationMs: 4000,
59
+ chapters: [
60
+ { atMs: 0, label: 'Tap start' },
61
+ { atMs: 1200, label: 'Working' }
62
+ ]
63
+ }
64
+ ]);
65
+ expect(looks.pictures.map((picture) => picture.label)).toEqual(['Before · Welcome', 'After · Working']);
66
+ expect(looks.moments.map((moment) => moment.label)).toEqual(['Tap start', 'Working']);
67
+ expect(looks.moments.map((moment) => [moment.caption, moment.thumb])).toEqual([
68
+ ['Tap', '/welcome.png'],
69
+ ['Screen', '/working.png']
70
+ ]);
71
+ });
72
+
73
+ it('treats a video, a still, and a frame timeline from any runner as separate evidence', () => {
74
+ const step = (title: string, at: number, media: 'png' | 'video' | 'html', file: string): RunReport['steps'][number] => ({
75
+ index: at,
76
+ kind: 'when',
77
+ title,
78
+ status: 'passed',
79
+ startedAtMs: at,
80
+ endedAtMs: at + 1,
81
+ durationMs: 1,
82
+ frames: [{ index: 0, capturedAtMs: at, durationMs: 300, snapshots: [{ name: 'screen', media, file }] }],
83
+ steps: []
84
+ });
85
+ const report: RunReport = {
86
+ meta: { testId: 'add', runId: 'latest', title: 'Add', system: 'todo', runtime: 'gherkin', seed: 'add', status: 'passed', actorIds: [], startedAtMs: 0, endedAtMs: 3, durationMs: 3 },
87
+ steps: [step('Open', 0, 'html', 'frames/0.html'), step('Shown', 1, 'png', 'frames/1.png'), step('Film', 2, 'video', 'film.mp4')]
88
+ };
89
+ const looks = reportLooks(report, (file) => `/evidence/${file}`);
90
+ expect(looks.pictures.map((picture) => picture.src)).toEqual(['/evidence/frames/1.png']);
91
+ expect(looks.films.map((film) => film.src)).toEqual(['/evidence/film.mp4']);
92
+ expect(looks.moments.map((moment) => moment.label)).toEqual(['Open', 'Shown', 'Film']);
93
+ expect(looks.moments[1]?.thumb).toBe('/evidence/frames/1.png');
94
+ expect(looks.moments[0]?.thumb).toBeNull();
95
+ });
96
+
97
+ it('uses an attached video and screenshots when a runner has no frames', () => {
98
+ const step = (title: string, at: number): RunReport['steps'][number] => ({
99
+ index: at, kind: 'when', title, status: 'passed', startedAtMs: at, endedAtMs: at + 1, durationMs: 1, frames: [], steps: []
100
+ });
101
+ const report: RunReport = {
102
+ meta: { testId: 'add', runId: 'latest', title: 'Add', system: 'todo', runtime: 'gherkin', seed: 'add', status: 'passed', actorIds: [], startedAtMs: 1000, endedAtMs: 4000, durationMs: 3000 },
103
+ steps: [step('Given a list', 1200), step('When they add', 2500)],
104
+ artifacts: [
105
+ { name: 'After adding', media: 'png', file: 'shots/1.png' },
106
+ { name: 'Recording', media: 'video', file: 'recording.webm', poster: 'recording.webp' },
107
+ { name: 'Browser log', media: 'text', file: 'log.txt' }
108
+ ]
109
+ };
110
+ const looks = reportLooks(report, (file) => `/evidence/${file}`);
111
+ expect(looks.moments).toEqual([]);
112
+ expect(looks.pictures.map((picture) => picture.src)).toEqual(['/evidence/shots/1.png']);
113
+ expect(looks.films).toEqual([{
114
+ id: 'recording.webm',
115
+ label: 'Recording',
116
+ src: '/evidence/recording.webm',
117
+ poster: '/evidence/recording.webp',
118
+ captions: null,
119
+ durationMs: null,
120
+ chapters: [
121
+ { atMs: 200, label: 'Given a list' },
122
+ { atMs: 1500, label: 'When they add' }
123
+ ]
124
+ }]);
125
+ });
126
+
127
+ it('keeps the other clients a chart run captured of one screen', () => {
128
+ const spec = parseSpec({
129
+ ref: null,
130
+ files: [{ path: 'app.machine.json', text: JSON.stringify({ id: 'app', initial: 'Welcome', states: { Welcome: { on: { Start: 'Working' } }, Working: { type: 'final' } } }) }]
131
+ });
132
+ const run = parseManifest('run-1', 'manifest.json', {
133
+ schemaVersion: 1,
134
+ run: { id: 'run-1', startedAt: '2026-10-13T00:00:00Z', durationMs: 10, mode: 'showcase', specVersion: 'v1', finishedAt: '2026-10-13T00:00:01Z' },
135
+ states: { Welcome: { status: 'passed', screenshot: { png: 'welcome.png', client: 'phone', also: [{ png: 'welcome-desktop.png', client: 'desktop' }] } } },
136
+ transitions: {
137
+ 'Welcome :: Start': {
138
+ source: 'Welcome',
139
+ event: 'Start',
140
+ target: 'Working',
141
+ status: 'passed',
142
+ clip: { video: 'start.mp4', poster: 'start.webp', durationMs: 4000 },
143
+ timeline: [{ atMs: 200, kind: 'tap', label: 'Tap start' }]
144
+ }
145
+ }
146
+ });
147
+ const view = buildAtlasView({ spec, specIssues: [], runs: [], run, runIssues: [], resolveMedia: (_id, file) => `/media/${file}` });
148
+ expect(stateLooks(view.states.get('Welcome')!).pictures.map((picture) => [picture.label, picture.src])).toEqual([
149
+ ['Welcome · phone', '/media/welcome.png'],
150
+ ['Welcome · desktop', '/media/welcome-desktop.png']
151
+ ]);
152
+ const start = [...view.transitions.values()].find((transition) => transition.event === 'Start');
153
+ expect(start).toBeDefined();
154
+ const looks = eventLooks(view, start!);
155
+ expect(looks.films[0]?.src).toBe('/media/start.mp4');
156
+ expect(looks.films[0]?.chapters).toEqual([{ atMs: 200, label: 'Tap start' }]);
157
+ });
158
+ });