@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
@@ -1,5 +1,7 @@
1
1
  import { recordingPaths } from '@crvouga/atlas/reports/evidence-types';
2
2
  import { checkHistory, type CheckSlot, type HistoryEntry } from '@crvouga/atlas/reports/evidence-view';
3
+ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
4
+ import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
3
5
  import { flattenFrames } from '@crvouga/atlas/reports/frames';
4
6
  import type { ReportStep, RunReport } from '@crvouga/atlas/reports/report-types';
5
7
  import { useQuery } from '@tanstack/react-query';
@@ -34,15 +36,45 @@ export function useRecordedReport(entry: HistoryEntry | null, enabled = true) {
34
36
  }
35
37
 
36
38
  /** One check in a list: its outcome at a glance, and everything recorded for it when opened. */
39
+ const ENV_RANK = { passed: 0, 'not-run': 1, running: 2, failed: 3 } as const;
40
+
41
+ /** One mark per client when the same check was recorded on more than one. */
42
+ function environmentMarks(slot: CheckSlot) {
43
+ const seen = new Map<string, CheckSlot['status']>();
44
+ for (const outcome of slot.outcomes) {
45
+ const id = outcome.layer.environment ?? '';
46
+ const prior = seen.get(id);
47
+ if (prior === undefined || ENV_RANK[outcome.row.status] > ENV_RANK[prior]) seen.set(id, outcome.row.status);
48
+ }
49
+ if (seen.size < 2) return [];
50
+ return [...seen].map(([id, status]) => ({ id, status }));
51
+ }
52
+
53
+ function outcomeName(outcome: EvidenceResult) {
54
+ const environment = outcome.layer.environment ? environmentLabel(outcome.layer.environment) : '';
55
+ const parameters = Object.entries(outcome.parameters).map(([name, value]) => `${name}: ${value}`).join(', ');
56
+ return [environment, parameters].filter(Boolean).join(' · ') || outcome.layer.producer;
57
+ }
58
+
37
59
  export function ResultRow({ label, slot, open, onToggle, children }: { label: string; slot: CheckSlot | null; open: boolean; onToggle: () => void; children: ReactNode }) {
38
60
  const now = useNow();
39
61
  const newest = slot?.outcomes[0];
62
+ const marks = slot ? environmentMarks(slot) : [];
40
63
  const failure = slot?.status === 'failed' ? slot.outcomes.find((outcome) => outcome.row.status === 'failed')?.row.detail.split('\n')[0] : '';
41
64
  return (
42
65
  <li className={styles.result} data-status={slot?.status ?? 'none'} data-open={open || undefined}>
43
66
  <button type="button" className={styles.resultHead} aria-expanded={open} onClick={onToggle}>
44
67
  <StatusMark status={slot?.status ?? 'none'} />
45
- <span className={styles.resultName}>{label}</span>
68
+ <span className={styles.resultName}>
69
+ {label}
70
+ {marks.length > 0 && (
71
+ <span className={styles.envMarks}>
72
+ {marks.map((mark) => (
73
+ <span key={mark.id || 'unspecified'} data-status={mark.status}>{environmentLabel(mark.id || null)}</span>
74
+ ))}
75
+ </span>
76
+ )}
77
+ </span>
46
78
  {slot?.live && <span className={styles.liveTag}>Running now</span>}
47
79
  <span className={styles.resultAside}>
48
80
  {newest ? [duration(newest.row.durationMs), slot.live ? '' : ago(slot.latestAtMs, now)].filter(Boolean).join(' · ') : 'No result'}
@@ -185,11 +217,11 @@ export function CheckDetail({ slot, layerId, select, reads, written }: Props) {
185
217
  return (
186
218
  <div className={styles.detail}>
187
219
  {slot.outcomes.length > 1 && !earlier && (
188
- <div className={styles.variants} role="group" aria-label="Parameter sets">
220
+ <div className={styles.variants} role="group" aria-label="Environments">
189
221
  {slot.outcomes.map((outcome, index) => (
190
222
  <button key={outcome.key} type="button" aria-pressed={index === variant} onClick={() => setVariant(index)}>
191
223
  <StatusMark status={outcome.row.status} size={12} />
192
- {Object.entries(outcome.layer.parameters).map(([name, value]) => `${name}: ${value}`).join(', ') || outcome.layer.producer}
224
+ {outcomeName(outcome)}
193
225
  </button>
194
226
  ))}
195
227
  </div>
@@ -455,11 +455,31 @@
455
455
  }
456
456
 
457
457
  .resultName {
458
+ display: flex;
458
459
  flex: 1;
460
+ flex-direction: column;
459
461
  min-width: 0;
460
462
  font-weight: 500;
461
463
  }
462
464
 
465
+ .envMarks {
466
+ display: flex;
467
+ flex-wrap: wrap;
468
+ gap: 8px;
469
+ margin-top: 2px;
470
+ color: var(--ink-3);
471
+ font-size: 0.75rem;
472
+ font-weight: 400;
473
+ }
474
+
475
+ .envMarks span[data-status='passed'] {
476
+ color: var(--passed-ink);
477
+ }
478
+
479
+ .envMarks span[data-status='failed'] {
480
+ color: var(--failed-ink);
481
+ }
482
+
463
483
  .result[data-status='none'] .resultName {
464
484
  color: var(--ink-3);
465
485
  font-weight: 400;
@@ -1,5 +1,6 @@
1
1
  import type { BackendInfo, LayerHeader, StatusCounts } from '@crvouga/atlas/reports/evidence-types';
2
2
  import type { SpecStatus, SpecSummary } from '@crvouga/atlas/reports/evidence-view';
3
+ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
3
4
  import { useState, type ReactNode } from 'react';
4
5
 
5
6
  import styles from './evidence.module.css';
@@ -89,6 +90,7 @@ export function Origin({ layer, backends, now }: { layer: LayerHeader; backends:
89
90
  {layer.live && <span className={styles.liveTag}>Running now</span>}
90
91
  <span>{backendLabel(backends, layer.backend)}</span>
91
92
  {layer.branch && <span>{layer.branch}</span>}
93
+ {layer.environment && <span>{environmentLabel(layer.environment)}</span>}
92
94
  {sha && <code>{sha}</code>}
93
95
  {layer.freshness === 'modified' && <span>with local changes</span>}
94
96
  {layer.freshness === 'unknown' && <span>origin not captured</span>}
@@ -67,7 +67,7 @@ export function Overview({ kind }: { kind: SpecKind }) {
67
67
  </p>
68
68
  ))}
69
69
  {!evidence.ready && !evidence.unavailable && <p className={styles.aside} role="status">Loading recorded results…</p>}
70
- {evidence.ready && evidence.pending > 0 && <p className={styles.aside} role="status">Reading {plural(evidence.pending, 'more run', 'more runs')}…</p>}
70
+ {evidence.ready && evidence.pending > 0 && <p className={styles.aside} role="status">Reading {plural(evidence.pending, 'more layer', 'more layers')}…</p>}
71
71
 
72
72
  {evidence.active.length > 0 && (
73
73
  <section className={styles.block}>
@@ -111,13 +111,13 @@ export function Overview({ kind }: { kind: SpecKind }) {
111
111
  <p className={styles.aside}>
112
112
  {evidence.layers.length === 0
113
113
  ? `Nothing has been recorded yet. Results appear here as a spec run finishes each test.`
114
- : `None of the ${plural(evidence.layers.length, 'run', 'runs')} in the combined report checked a ${kind.one}.`}
114
+ : `None of the ${plural(evidence.layers.length, 'layer', 'layers')} in the combined report checked a ${kind.one}.`}
115
115
  </p>
116
116
  )}
117
117
 
118
118
  {evidence.ready && (
119
119
  <p className={styles.footLine}>
120
- {plural(evidence.layers.length, 'run', 'runs')} from {evidence.backends.filter((backend) => backend.available).map((backend) => backendLabel(evidence.backends, backend.id)).join(' and ') || 'no report store'}.{' '}
120
+ {plural(evidence.layers.length, 'layer', 'layers')} from {evidence.backends.filter((backend) => backend.available).map((backend) => backendLabel(evidence.backends, backend.id)).join(' and ') || 'no report store'}.{' '}
121
121
  <button type="button" className={styles.link} onClick={() => setSourcesOpen(true)}>Choose sources</button>
122
122
  {empty > 0 && (
123
123
  <>
@@ -1,4 +1,5 @@
1
1
  import { checkHistory, type CheckSlot, type HistoryEntry } from '@crvouga/atlas/reports/evidence-view';
2
+ import type { RunReport } from '@crvouga/atlas/reports/report-types';
2
3
  import { useQuery } from '@tanstack/react-query';
3
4
  import { useMemo } from 'react';
4
5
 
@@ -21,6 +22,21 @@ type Props = {
21
22
  onClose: () => void;
22
23
  };
23
24
 
25
+ /** HTML snapshots are the only pictures that paint with the run's stylesheet. */
26
+ function needsStylesheet(report: RunReport | undefined): boolean {
27
+ if (!report) return false;
28
+ const visit = (steps: RunReport['steps']): boolean => {
29
+ for (const step of steps) {
30
+ for (const frame of step.frames) {
31
+ if (frame.snapshots.some((blob) => blob.media === 'html')) return true;
32
+ }
33
+ if (visit(step.steps)) return true;
34
+ }
35
+ return false;
36
+ };
37
+ return visit(report.steps) || (report.artifacts ?? []).some((artifact) => artifact.media === 'html');
38
+ }
39
+
24
40
  /** One recorded test, opened in place of its spec. */
25
41
  export function RecordingPage({ slot, layerId, label, back, moment, onMoment, onClose }: Props) {
26
42
  const { evidence, backends, pending, ready, unavailable } = useEvidence();
@@ -29,7 +45,8 @@ export function RecordingPage({ slot, layerId, label, back, moment, onMoment, on
29
45
  const winner = slot?.outcomes[0];
30
46
  const chosen: HistoryEntry | null = (layerId ? history.find((entry) => entry.layer.id === layerId) : undefined) ?? (winner ? { layer: winner.layer, row: winner.row } : null);
31
47
  const { paths, report } = useRecordedReport(chosen);
32
- const css = useQuery({ queryKey: ['evidence-css', paths?.css ?? ''], queryFn: ({ signal }) => reportText(paths!.css, signal), enabled: paths !== null, staleTime: Number.POSITIVE_INFINITY, retry: false });
48
+ const wantsCss = needsStylesheet(report.data);
49
+ const css = useQuery({ queryKey: ['evidence-css', paths?.css ?? ''], queryFn: ({ signal }) => reportText(paths!.css, signal), enabled: paths !== null && wantsCss, staleTime: Number.POSITIVE_INFINITY, retry: false });
33
50
  const waiting = !chosen && !unavailable && (!ready || pending > 0);
34
51
  return (
35
52
  <article className={styles.page} data-wide="true" aria-label="Recorded test">
@@ -57,7 +74,7 @@ export function RecordingPage({ slot, layerId, label, back, moment, onMoment, on
57
74
  <button type="button" className={styles.link} onClick={() => void report.refetch()}>Try again</button>
58
75
  </p>
59
76
  )}
60
- {css.isError && (
77
+ {wantsCss && css.isError && (
61
78
  <p className={styles.notice} role="alert">
62
79
  The recorded stylesheet could not be read, so pictures appear unstyled.{' '}
63
80
  <button type="button" className={styles.link} onClick={() => void css.refetch()}>Try again</button>
@@ -214,10 +214,8 @@
214
214
  display: flex;
215
215
  flex-wrap: wrap;
216
216
  align-content: flex-start;
217
+ justify-content: center;
217
218
  gap: 20px;
218
- height: 29rem;
219
- overflow: auto;
220
- scrollbar-gutter: stable;
221
219
  }
222
220
 
223
221
  .snapshot {
@@ -265,17 +263,15 @@
265
263
  .image {
266
264
  display: block;
267
265
  width: 100%;
268
- height: 13rem;
266
+ height: auto;
267
+ max-height: min(68vh, 640px);
269
268
  object-fit: contain;
270
- aspect-ratio: 9 / 16;
271
- background: #fff;
272
- max-width: 100%;
269
+ background: var(--bg-2);
273
270
  border: 1px solid var(--line-2);
274
271
  border-radius: 12px;
275
272
  }
276
273
 
277
274
  @media (max-width: 800px) {
278
- .pictures { height: 24rem; }
279
275
  .snapshot { flex-basis: min(270px, 100%); width: min(270px, 100%); }
280
276
  }
281
277
 
@@ -1,8 +1,11 @@
1
1
  import { flattenSteps, neighborFailure, orderedUiBlobs, selectedFrame, testFrames } from '@crvouga/atlas/reports/frames';
2
2
  import { performanceFrameIndex, performanceRows, type PerformanceRow } from '@crvouga/atlas/reports/performance';
3
3
  import type { ReportStep, RunReport } from '@crvouga/atlas/reports/report-types';
4
- import { useEffect, useMemo, useRef, useState } from 'react';
4
+ import { useMemo, useState } from 'react';
5
5
 
6
+ import { reportLooks } from '../looks/collect';
7
+ import { Stage, type LookMode } from '../looks/Stage';
8
+ import { reportAssetUrl } from './api';
6
9
  import { duration } from './format';
7
10
  import { StatusMark } from './marks';
8
11
  import styles from './recording.module.css';
@@ -20,63 +23,35 @@ type Props = {
20
23
 
21
24
  /** A recorded test, one captured moment at a time. */
22
25
  export function Timeline({ report, fileUrl, css, moment, onMoment }: Props) {
23
- const [playing, setPlaying] = useState(false);
24
26
  const [actor, setActor] = useState('all');
25
27
  const [tab, setTab] = useState<'moments' | 'timing'>('moments');
28
+ const [mode, setMode] = useState<LookMode | null>(null);
26
29
  const test = useMemo(() => ({ report, blobs: {} }), [report]);
27
30
  const frames = useMemo(() => testFrames(test), [test]);
31
+ const looks = useMemo(() => reportLooks(report, (file) => reportAssetUrl(fileUrl(file))), [report, fileUrl]);
32
+ const extras = (report.artifacts ?? []).filter((artifact) => artifact.media !== 'png' && artifact.media !== 'video');
28
33
  const frame = selectedFrame(frames, { view: 'test', testId: report.meta.testId, params: { frame: moment === null ? '' : String(moment) } });
29
34
  const index = frame?.index ?? 0;
30
35
  const last = frames.length - 1;
31
36
  const go = (target: number) => {
32
37
  if (frames.length) onMoment(Math.max(0, Math.min(last, target)) + 1, true);
33
38
  };
34
- // Timers and key handlers outlive a render; they always move from the moment on screen.
35
- const goNow = useRef(go);
36
- goNow.current = go;
37
39
  const steps = useMemo(() => flattenSteps(report.steps).filter((entry) => entry.step.kind !== 'quint'), [report.steps]);
38
40
  const rows = useMemo(() => performanceRows(report.steps, report.meta.performance?.slowStepMs ?? 50), [report]);
39
41
  const byStep = useMemo(() => performanceFrameIndex(rows, frames), [rows, frames]);
40
42
  const jump = (step: ReportStep) => {
41
43
  const target = byStep.get(step);
42
44
  if (!target) return;
43
- setPlaying(false);
45
+ setMode('timeline');
44
46
  setTab('moments');
45
47
  go(target.index);
46
48
  };
47
-
48
- const pause = frame?.frame.durationMs;
49
- useEffect(() => {
50
- if (!playing) return;
51
- if (index >= last) {
52
- setPlaying(false);
53
- return;
54
- }
55
- const timer = setTimeout(() => goNow.current(index + 1), Math.max(100, Math.min(pause ?? 500, 2000)));
56
- return () => clearTimeout(timer);
57
- }, [playing, index, last, pause]);
58
-
59
- useEffect(() => {
60
- const onKey = (event: KeyboardEvent) => {
61
- if (event.altKey || event.ctrlKey || event.metaKey || !frames.length) return;
62
- if (event.target instanceof HTMLElement && (event.target.isContentEditable || event.target.closest('input, textarea, select, button'))) return;
63
- const target = event.key === 'ArrowRight' ? index + 1 : event.key === 'ArrowLeft' ? index - 1 : event.key === 'Home' ? 0 : event.key === 'End' ? last : null;
64
- if (target === null) return;
65
- event.preventDefault();
66
- setPlaying(false);
67
- goNow.current(target);
68
- };
69
- window.addEventListener('keydown', onKey);
70
- return () => window.removeEventListener('keydown', onKey);
71
- }, [index, last, frames.length]);
72
-
73
- const ui = frame ? orderedUiBlobs(frame, test) : [];
74
- const pictures = ui.filter((entry) => entry.blob && (actor === 'all' || entry.nodeId === actor));
75
- const absent = ui.filter((entry) => !entry.blob && (actor === 'all' || entry.nodeId === actor));
76
- const observations = frame?.frame.snapshots.filter((blob) => blob.media !== 'html' && blob.media !== 'png' && (actor === 'all' || !blob.nodeId || blob.nodeId === actor)) ?? [];
77
49
  const nextFailure = () => {
78
50
  const failure = neighborFailure(frames, index, 1);
79
- if (failure) go(failure.index);
51
+ if (failure) {
52
+ setMode('timeline');
53
+ go(failure.index);
54
+ }
80
55
  };
81
56
 
82
57
  return (
@@ -115,53 +90,69 @@ export function Timeline({ report, fileUrl, css, moment, onMoment }: Props) {
115
90
  })}
116
91
  </ol>
117
92
  <section className={styles.stage} aria-label="Recorded moment">
118
- <div className={styles.transport}>
119
- <button type="button" className={styles.control} disabled={!frames.length} onClick={() => { if (!playing && index === last) go(0); setPlaying((value) => !value); }}>
120
- {playing ? 'Pause' : 'Play'}
121
- </button>
122
- <button type="button" className={styles.control} aria-label="Previous moment" disabled={index === 0} onClick={() => go(index - 1)}>‹</button>
123
- <button type="button" className={styles.control} aria-label="Next moment" disabled={index >= last} onClick={() => go(index + 1)}>›</button>
124
- <input className={styles.scrub} type="range" aria-label="Recorded moment" min={0} max={Math.max(0, last)} value={index} disabled={frames.length < 2} onChange={(event) => { setPlaying(false); go(Number(event.target.value)); }} />
125
- <span className={styles.position}>{frames.length ? `Moment ${index + 1} of ${frames.length}` : 'No recorded moments'}</span>
126
- <button type="button" className={styles.control} disabled={!frames.some((entry) => entry.step.status === 'failed')} onClick={nextFailure}>Next failure</button>
127
- {report.meta.actorIds.length > 1 && (
128
- <label className={styles.actor}>
129
- <span className="sr-only">Person</span>
130
- <select value={actor} onChange={(event) => setActor(event.target.value)}>
131
- <option value="all">Everyone</option>
132
- {report.meta.actorIds.map((id) => (
133
- <option key={id}>{id}</option>
134
- ))}
135
- </select>
136
- </label>
137
- )}
138
- </div>
139
- {!frame && <p className={styles.quietLine}>This test recorded no moments. Its steps and outcome are on the left.</p>}
140
- {frame && (
141
- <>
142
- <h3 className={styles.momentTitle} data-status={frame.step.status}>{frame.step.title}</h3>
143
- <p className={styles.quietLine}>
144
- {frame.step.status === 'failed' ? 'Failed' : 'Passed'} · at {duration(Math.max(0, frame.frame.capturedAtMs - report.meta.startedAtMs)) || '0 ms'}
145
- {frame.frame.hit && <> · acted on {frame.frame.hit.path.join(' › ')}</>}
146
- </p>
147
- {frame.step.error && <pre className={styles.error} role="alert">{frame.step.error}</pre>}
148
- {frame.step.detail && (
149
- <div className={styles.observed}>
150
- <SnapshotData text={frame.step.detail} />
151
- </div>
152
- )}
153
- <div className={styles.pictures} aria-label="Recorded snapshots">
154
- {pictures.map((entry) => (
155
- <Snapshot key={entry.blob!.file} url={fileUrl(entry.blob!.file)} blob={entry.blob!} css={css} hit={entry.stale ? null : frame.frame.hit ?? null} earlier={entry.stale} />
156
- ))}
157
- {absent.map((entry) => (
158
- <p key={entry.nodeId} className={styles.quietLine}>No picture of {entry.nodeId} had been recorded by this moment.</p>
93
+ {report.meta.actorIds.length > 1 && (
94
+ <label className={styles.actor}>
95
+ <span className="sr-only">Person</span>
96
+ <select value={actor} onChange={(event) => setActor(event.target.value)}>
97
+ <option value="all">Everyone</option>
98
+ {report.meta.actorIds.map((id) => (
99
+ <option key={id}>{id}</option>
159
100
  ))}
160
- {observations.map((blob) => (
161
- <Snapshot key={blob.file} url={fileUrl(blob.file)} blob={blob} css={css} hit={null} />
162
- ))}
163
- </div>
164
- </>
101
+ </select>
102
+ </label>
103
+ )}
104
+ <Stage
105
+ looks={looks}
106
+ prefer="timeline"
107
+ mode={mode}
108
+ onMode={setMode}
109
+ moment={frames.length ? index : undefined}
110
+ onMoment={go}
111
+ failure={frames.some((entry) => entry.step.status === 'failed')}
112
+ onFailure={nextFailure}
113
+ empty={extras.length > 0
114
+ ? <p className={styles.quietLine}>This runner attached files instead of a step-by-step timeline. They are below.</p>
115
+ : <p className={styles.quietLine}>This test recorded no picture, video, or timeline. Its steps and outcome are on the left.</p>}
116
+ renderMoment={(at) => {
117
+ const current = frames[at];
118
+ if (!current) return null;
119
+ const ui = orderedUiBlobs(current, test);
120
+ const pictures = ui.filter((entry) => entry.blob && entry.blob.media !== 'video' && (actor === 'all' || entry.nodeId === actor));
121
+ const absent = ui.filter((entry) => !entry.blob && (actor === 'all' || !entry.nodeId || entry.nodeId === actor));
122
+ const observations = current.frame.snapshots.filter((blob) => blob.media !== 'html' && blob.media !== 'png' && blob.media !== 'video' && (actor === 'all' || !blob.nodeId || blob.nodeId === actor));
123
+ return (
124
+ <>
125
+ <p className={styles.quietLine}>
126
+ {current.step.status === 'failed' ? 'Failed' : 'Passed'} · at {duration(Math.max(0, current.frame.capturedAtMs - report.meta.startedAtMs)) || '0 ms'}
127
+ {current.frame.hit && <> · acted on {current.frame.hit.path.join(' › ')}</>}
128
+ </p>
129
+ {current.step.error && <pre className={styles.error} role="alert">{current.step.error}</pre>}
130
+ {current.step.detail && (
131
+ <div className={styles.observed}>
132
+ <SnapshotData text={current.step.detail} />
133
+ </div>
134
+ )}
135
+ <div className={styles.pictures} aria-label="Recorded snapshots">
136
+ {pictures.map((entry) => (
137
+ <Snapshot key={entry.blob!.file} url={fileUrl(entry.blob!.file)} blob={entry.blob!} css={css} hit={entry.stale ? null : current.frame.hit ?? null} earlier={entry.stale} />
138
+ ))}
139
+ {absent.map((entry) => (
140
+ <p key={entry.nodeId} className={styles.quietLine}>No picture of {entry.nodeId} had been recorded by this moment.</p>
141
+ ))}
142
+ {observations.map((blob) => (
143
+ <Snapshot key={blob.file} url={fileUrl(blob.file)} blob={blob} css={css} hit={null} />
144
+ ))}
145
+ </div>
146
+ </>
147
+ );
148
+ }}
149
+ />
150
+ {extras.length > 0 && (
151
+ <div className={styles.pictures} aria-label="Other attachments">
152
+ {extras.map((artifact) => (
153
+ <Snapshot key={artifact.file} url={fileUrl(artifact.file)} blob={{ name: artifact.name, media: artifact.media, file: artifact.file }} css={css} hit={null} />
154
+ ))}
155
+ </div>
165
156
  )}
166
157
  </section>
167
158
  </div>
@@ -4,7 +4,7 @@ import { createSnapshotHost, paintSnapshotHost, type HitResult } from '@crvouga/
4
4
  import { useQuery } from '@tanstack/react-query';
5
5
  import { useEffect, useRef, useState } from 'react';
6
6
 
7
- import { reportResponse } from './api';
7
+ import { reportAssetUrl, reportResponse } from './api';
8
8
  import styles from './recording.module.css';
9
9
  import { traceStates } from './quint';
10
10
  import { ModelTrace } from './model-trace';
@@ -45,9 +45,9 @@ export function SnapshotPicture({ file, html, css, hit, who }: { file: string; h
45
45
 
46
46
  /** One captured picture or value, exactly as recorded. */
47
47
  export function Snapshot({ url, blob, css, hit, earlier = false }: { url: string; blob: ReportSnapshotBlob; css: string; hit: ReportStepHit | null; earlier?: boolean }) {
48
- const content = useQuery({ queryKey: ['evidence-blob', url], queryFn: ({ signal }) => readBlob(url, blob, signal), retry: false });
48
+ const content = useQuery({ queryKey: ['evidence-blob', url], queryFn: ({ signal }) => readBlob(url, blob, signal), enabled: blob.media !== 'video', retry: false });
49
49
  const who = blob.nodeId ?? blob.name;
50
- const picture = blob.media === 'html' || blob.media === 'png';
50
+ const picture = blob.media === 'html' || blob.media === 'png' || blob.media === 'video';
51
51
  return (
52
52
  <figure className={styles.snapshot} data-picture={picture || undefined}>
53
53
  <figcaption>
@@ -55,7 +55,8 @@ export function Snapshot({ url, blob, css, hit, earlier = false }: { url: string
55
55
  {blob.nodeId && blob.name !== blob.nodeId && <span>{blob.name}</span>}
56
56
  {earlier && <span className={styles.earlier}>Earlier picture</span>}
57
57
  </figcaption>
58
- {content.isPending && <p className={styles.quietLine} role="status">Loading…</p>}
58
+ {blob.media === 'video' && <video className={styles.image} src={reportAssetUrl(url)} controls playsInline aria-label={`Recorded ${who}`} />}
59
+ {blob.media !== 'video' && content.isPending && <p className={styles.quietLine} role="status">Loading…</p>}
59
60
  {content.isError && (
60
61
  <p role="alert" className={styles.quietLine}>
61
62
  {content.error.message}{' '}
@@ -1,5 +1,6 @@
1
1
  import type { LayerHeader } from '@crvouga/atlas/reports/evidence-types';
2
- import { branchOf, contributes, sourceFacets, type SourceSelection } from '@crvouga/atlas/reports/evidence-view';
2
+ import { ALL_SOURCES, branchOf, contributes, sourceFacets, sourceSelection, type SourceFacet, type SourceSelection } from '@crvouga/atlas/reports/evidence-view';
3
+ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
3
4
  import * as Dialog from '@radix-ui/react-dialog';
4
5
  import { useMemo } from 'react';
5
6
 
@@ -20,16 +21,23 @@ function counts(layer: LayerHeader) {
20
21
  }
21
22
 
22
23
  /** How many sources are switched off, for the button that opens the panel. */
23
- export function excludedCount(excluded: SourceSelection) {
24
- return excluded.backends.length + excluded.branches.length + excluded.layers.length;
24
+ export function excludedCount(excluded: Partial<SourceSelection>) {
25
+ const selection = sourceSelection(excluded);
26
+ return selection.backends.length + selection.branches.length + selection.versions.length + selection.environments.length + selection.layers.length;
27
+ }
28
+
29
+ function versionLabel(id: string) {
30
+ return id === 'unknown' ? 'Unknown version' : id.slice(0, 7);
25
31
  }
26
32
 
27
33
  /**
28
- * Where the combined report comes from. Every backend, branch and run contributes unless it is
29
- * switched off here, which is also how an earlier result is brought back into view.
34
+ * Where the combined report comes from. Every backend, environment, branch, version and layer
35
+ * contributes unless it is switched off here, which is also how an earlier result is brought back.
30
36
  */
31
37
  export function SourcesPanel() {
32
- const { layers, backends, excluded, setExcluded, notices, retry } = useEvidence();
38
+ const evidence = useEvidence();
39
+ const { layers, backends, setExcluded, notices, retry } = evidence;
40
+ const excluded = sourceSelection(evidence.excluded);
33
41
  const open = useUiStore((s) => s.sourcesOpen);
34
42
  const setOpen = useUiStore((s) => s.setSourcesOpen);
35
43
  const now = useNow();
@@ -48,10 +56,10 @@ export function SourcesPanel() {
48
56
  </Dialog.Close>
49
57
  </div>
50
58
  <p className={styles.lead}>
51
- {contributing} of {plural(layers.length, 'run', 'runs')} contribute. For each check the newest completed result is shown. Switch a source off to see what earlier runs recorded.
59
+ {contributing} of {plural(layers.length, 'layer', 'layers')} contribute. For each check and environment, the newest completed result is shown. A layer fills only the checks it ran, so the rest of the report stays in place. Switch a source off to see an earlier result.
52
60
  </p>
53
61
  {excludedCount(excluded) > 0 && (
54
- <button type="button" className={styles.reset} onClick={() => setExcluded({ backends: [], branches: [], layers: [] })}>
62
+ <button type="button" className={styles.reset} onClick={() => setExcluded(ALL_SOURCES)}>
55
63
  Use every source
56
64
  </button>
57
65
  )}
@@ -71,42 +79,52 @@ export function SourcesPanel() {
71
79
  <label className={styles.option}>
72
80
  <input type="checkbox" checked={!excluded.backends.includes(backend.id)} onChange={() => setExcluded({ ...excluded, backends: toggled(excluded.backends, backend.id) })} />
73
81
  <span className={styles.optionName}>{backend.label}</span>
74
- <span className={styles.optionMeta}>{backend.available ? plural(layerCount(backend.id), 'run', 'runs') : 'Unavailable'}</span>
82
+ <span className={styles.optionMeta}>{backend.available ? plural(layerCount(backend.id), 'layer', 'layers') : 'Unavailable'}</span>
75
83
  </label>
76
84
  </li>
77
85
  ))}
78
86
  </ul>
79
87
  </section>
80
88
 
81
- <section className={styles.group}>
82
- <h3>Branches</h3>
83
- {facets.branches.length === 0 && <p className={styles.empty}>No runs yet.</p>}
84
- <ul>
85
- {facets.branches.map((branch) => (
86
- <li key={branch.id}>
87
- <label className={styles.option}>
88
- <input type="checkbox" checked={!excluded.branches.includes(branch.id)} onChange={() => setExcluded({ ...excluded, branches: toggled(excluded.branches, branch.id) })} />
89
- <span className={styles.optionName}>{branch.id}</span>
90
- <span className={styles.optionMeta}>
91
- {plural(branch.layers, 'run', 'runs')} · {ago(branch.latestAtMs, now)}
92
- </span>
93
- </label>
94
- {facets.branches.length > 1 && (
95
- <button type="button" className={styles.only} onClick={() => setExcluded({ ...excluded, branches: facets.branches.map((facet) => facet.id).filter((id) => id !== branch.id) })}>
96
- Only
97
- </button>
98
- )}
99
- </li>
100
- ))}
101
- </ul>
102
- </section>
89
+ <FacetGroup
90
+ title="Environments"
91
+ facets={facets.environments.filter((facet) => facets.environments.length > 1 || facet.id !== 'unspecified')}
92
+ excluded={excluded.environments}
93
+ label={(id) => environmentLabel(id === 'unspecified' ? null : id)}
94
+ empty={null}
95
+ now={now}
96
+ onToggle={(id) => setExcluded({ ...excluded, environments: toggled(excluded.environments, id) })}
97
+ onOnly={(id) => setExcluded({ ...excluded, environments: facets.environments.map((facet) => facet.id).filter((facet) => facet !== id) })}
98
+ />
99
+
100
+ <FacetGroup
101
+ title="Branches"
102
+ facets={facets.branches}
103
+ excluded={excluded.branches}
104
+ label={(id) => id}
105
+ empty="No layers yet."
106
+ now={now}
107
+ onToggle={(id) => setExcluded({ ...excluded, branches: toggled(excluded.branches, id) })}
108
+ onOnly={(id) => setExcluded({ ...excluded, branches: facets.branches.map((facet) => facet.id).filter((facet) => facet !== id) })}
109
+ />
110
+
111
+ <FacetGroup
112
+ title="Versions"
113
+ facets={facets.versions}
114
+ excluded={excluded.versions}
115
+ label={versionLabel}
116
+ empty={null}
117
+ now={now}
118
+ onToggle={(id) => setExcluded({ ...excluded, versions: toggled(excluded.versions, id) })}
119
+ onOnly={(id) => setExcluded({ ...excluded, versions: facets.versions.map((facet) => facet.id).filter((facet) => facet !== id) })}
120
+ />
103
121
 
104
122
  <section className={styles.group}>
105
- <h3>Runs</h3>
123
+ <h3>Layers</h3>
106
124
  <ul>
107
- <Bounded items={layers} size={40} more="Earlier runs">
125
+ <Bounded items={layers} size={40} more="Earlier layers">
108
126
  {(layer) => {
109
- const off = excluded.backends.includes(layer.backend) || excluded.branches.includes(branchOf(layer));
127
+ const off = !contributes(layer, { ...excluded, layers: [] });
110
128
  return (
111
129
  <li key={`${layer.backend}|${layer.id}`}>
112
130
  <label className={styles.option} data-off={off || undefined}>
@@ -115,6 +133,7 @@ export function SourcesPanel() {
115
133
  {layer.packageDir ?? layer.producer}
116
134
  <span className={styles.run}>
117
135
  {layer.live ? 'Running now' : <time title={exactTime(layer.recordedAtMs)}>{ago(layer.recordedAtMs, now)}</time>} · {branchOf(layer)}
136
+ {layer.environment && <> · {environmentLabel(layer.environment)}</>}
118
137
  {shortSha(layer.sha) && <> · <code>{shortSha(layer.sha)}</code></>}
119
138
  {layer.freshness === 'modified' && ' · local changes'}
120
139
  </span>
@@ -132,3 +151,38 @@ export function SourcesPanel() {
132
151
  </Dialog.Root>
133
152
  );
134
153
  }
154
+
155
+ function FacetGroup({ title, facets, excluded, label, empty, now, onToggle, onOnly }: {
156
+ title: string;
157
+ facets: readonly SourceFacet[];
158
+ excluded: readonly string[];
159
+ label: (id: string) => string;
160
+ empty: string | null;
161
+ now: number;
162
+ onToggle: (id: string) => void;
163
+ onOnly: (id: string) => void;
164
+ }) {
165
+ if (!facets.length && empty === null) return null;
166
+ return (
167
+ <section className={styles.group}>
168
+ <h3>{title}</h3>
169
+ {facets.length === 0 && <p className={styles.empty}>{empty}</p>}
170
+ <ul>
171
+ {facets.map((facet) => (
172
+ <li key={facet.id}>
173
+ <label className={styles.option}>
174
+ <input type="checkbox" checked={!excluded.includes(facet.id)} onChange={() => onToggle(facet.id)} />
175
+ <span className={styles.optionName} title={facet.id}>{label(facet.id)}</span>
176
+ <span className={styles.optionMeta}>
177
+ {plural(facet.layers, 'layer', 'layers')} · {ago(facet.latestAtMs, now)}
178
+ </span>
179
+ </label>
180
+ {facets.length > 1 && (
181
+ <button type="button" className={styles.only} onClick={() => onOnly(facet.id)}>Only</button>
182
+ )}
183
+ </li>
184
+ ))}
185
+ </ul>
186
+ </section>
187
+ );
188
+ }