@crvouga/atlas 0.3.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 (76) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +15 -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/layer-axes.d.ts +18 -0
  9. package/dist/reports/src/layer-axes.js +51 -0
  10. package/dist/reports/src/layer-catalog.js +1 -1
  11. package/dist/reports/src/layer-merge.d.ts +1 -1
  12. package/dist/reports/src/layer-merge.js +9 -4
  13. package/dist/reports/src/layer-source.js +3 -0
  14. package/dist/reports/src/layer-types.d.ts +2 -0
  15. package/dist/reports/src/layers-route.js +2 -5
  16. package/dist/reports/src/protocol.d.ts +4 -0
  17. package/dist/reports/src/publish.d.ts +2 -0
  18. package/dist/reports/src/publish.js +4 -0
  19. package/dist/reports/src/report-parts.js +3 -0
  20. package/package.json +2 -2
  21. package/packages/reports/README.md +4 -2
  22. package/packages/reports/STORAGE.md +1 -1
  23. package/packages/visualizer/README.md +6 -5
  24. package/packages/visualizer/example/specs/coffee/ordering/cart.qnt +7 -0
  25. package/packages/visualizer/example/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  26. package/packages/visualizer/example/specs/coffee/ordering/drink-price.dmn +24 -0
  27. package/packages/visualizer/example/specs/coffee/ordering/loyalty.feature +7 -0
  28. package/packages/visualizer/example/specs/coffee/ordering/ordering.feature +36 -0
  29. package/packages/visualizer/example/specs/coffee/ordering/payment.als +4 -0
  30. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/cart.qnt +7 -0
  31. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  32. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/drink-price.dmn +24 -0
  33. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/loyalty.feature +7 -0
  34. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/ordering.feature +36 -0
  35. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/payment.als +4 -0
  36. package/packages/visualizer/fixtures/full/specs/coffee/ordering/cart.qnt +7 -0
  37. package/packages/visualizer/fixtures/full/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  38. package/packages/visualizer/fixtures/full/specs/coffee/ordering/drink-price.dmn +24 -0
  39. package/packages/visualizer/fixtures/full/specs/coffee/ordering/loyalty.feature +7 -0
  40. package/packages/visualizer/fixtures/full/specs/coffee/ordering/ordering.feature +36 -0
  41. package/packages/visualizer/fixtures/full/specs/coffee/ordering/payment.als +4 -0
  42. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/cart.qnt +7 -0
  43. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  44. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/drink-price.dmn +24 -0
  45. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/loyalty.feature +7 -0
  46. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/ordering.feature +36 -0
  47. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/payment.als +4 -0
  48. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/cart.qnt +7 -0
  49. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  50. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/drink-price.dmn +24 -0
  51. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/loyalty.feature +7 -0
  52. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/ordering.feature +36 -0
  53. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/payment.als +4 -0
  54. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/cart.qnt +7 -0
  55. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  56. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/drink-price.dmn +24 -0
  57. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/loyalty.feature +7 -0
  58. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/ordering.feature +36 -0
  59. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/payment.als +4 -0
  60. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/cart.qnt +7 -0
  61. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  62. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/drink-price.dmn +24 -0
  63. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/loyalty.feature +7 -0
  64. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/ordering.feature +36 -0
  65. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/payment.als +4 -0
  66. package/packages/visualizer/scripts/make-fixtures.ts +2 -0
  67. package/packages/visualizer/scripts/publish-data.ts +1 -2
  68. package/packages/visualizer/src/components/Header.tsx +1 -1
  69. package/packages/visualizer/src/evidence/check-detail.tsx +35 -3
  70. package/packages/visualizer/src/evidence/evidence.module.css +20 -0
  71. package/packages/visualizer/src/evidence/marks.tsx +2 -0
  72. package/packages/visualizer/src/evidence/overview.tsx +3 -3
  73. package/packages/visualizer/src/evidence/sources-panel.tsx +88 -34
  74. package/packages/visualizer/src/state/ui-store.ts +6 -2
  75. package/packages/visualizer/vite/example-reports.ts +475 -0
  76. package/packages/visualizer/vite.config.ts +9 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
+ ## 0.4.0 (2026-10-14)
4
+
5
+ ### Features
6
+
7
+ - compose evidence by environment (#6) ([030f86b](https://github.com/crvouga/atlas/commit/030f86b1680851e3744cc977dc1c2a44e91f9926))
8
+
3
9
  ## 0.3.0 (2026-10-13)
4
10
 
5
11
  ### Features
package/README.md CHANGED
@@ -114,12 +114,25 @@ visualizer updates when a run finishes.
114
114
 
115
115
  ```sh
116
116
  bun run atlas:run # example:clients, then example:run: every chart of the example, in showcase mode
117
- bun run atlas:viz # the example's specs, chart runs, Gherkin features, and Quint model
117
+ bun run atlas:viz # the example's specs, chart runs, and layered reports
118
118
  ```
119
119
 
120
120
  Each config writes its own run and the visualizer shows one run at a time: the latest is the
121
121
  single-browser run, and the run across clients is the one before it in the run picker.
122
122
 
123
+ `bun run viz:dev` opens the full coffee example with a layered report, without waiting
124
+ for a run. Gherkin, Quint, DMN, Alloy and OpenSLO documents sit beside the charts. The same scenarios
125
+ are filled by separate layers across desktop web, mobile web, iOS and Android, and across versions,
126
+ branches and backends (this machine, shared reports, and a device lab). A layer that repeated one
127
+ scenario leaves the others as the last layer that ran them. One feature is left with no results.
128
+ **Sources** in the header switches any of those off. The chart runs stay on the statechart map.
129
+
130
+ `bun run atlas:viz` opens the todo example the same way. The Gherkin, Quint, DMN, Alloy and OpenSLO
131
+ tabs already show five layers, so each environment is visible before a run. `example:run` adds one
132
+ desktop web layer for the scenarios and traces it actually ran. That layer replaces only those
133
+ desktop web results. The other environments stay. `ATLAS_REPORTS_MODULE` replaces this example API
134
+ with a host's reports.
135
+
123
136
  A showcase run ends with:
124
137
 
125
138
  ```
@@ -128,7 +141,7 @@ Transitions 14/14 passed, 0 failed, 0 not reached
128
141
  Manifest: examples/todo/atlas-runs/<run-id>/manifest.json
129
142
  ```
130
143
 
131
- `example:run` then runs the Gherkin features in a browser and the Quint model. `atlas:viz` shows those results on the scenario and trace rows.
144
+ `example:run` then runs the Gherkin features in a browser and the Quint model, and publishes that desktop web layer beside the ones already in the report.
132
145
 
133
146
  `bun test examples` runs [`model.test.ts`](examples/todo/model.test.ts): the same charts and journeys
134
147
  against `TodoModel`, a plain class, through `functionDriver` and `describeAtlas`. Only the driver
@@ -1,5 +1,6 @@
1
1
  import type { Clock } from './clock.js';
2
2
  import type { ActiveRun, LayerEvidence, LayerHeader } from './evidence-types.js';
3
+ import type { LayerParameters } from './layer-types.js';
3
4
  import type { ObjectStore } from './object-store.js';
4
5
  /** What a running spec suite announces under `active/` in the local store. */
5
6
  export type LiveRunRecord = {
@@ -7,7 +8,12 @@ export type LiveRunRecord = {
7
8
  readonly bucket: string;
8
9
  readonly startedAtMs: number;
9
10
  readonly plannedChecks?: number;
11
+ /** The client this suite is checking. A different client does not replace it. */
12
+ readonly environment?: string;
13
+ readonly parameters?: LayerParameters;
10
14
  };
15
+ /** A run announcement with the fields the live layer can trust. Null when it cannot be a run. */
16
+ export declare function liveRunRecord(value: unknown): LiveRunRecord | null;
11
17
  /** How the host reaches the results a spec run has written so far. */
12
18
  export type LiveSource = {
13
19
  /** The bucket the run writes each finished test into, or null when it cannot be read. */
@@ -1,11 +1,40 @@
1
1
  import { eachLimited } from './completion-order.js';
2
+ import { environmentOf } from './evidence-view.js';
2
3
  import { sourceId } from './keys.js';
4
+ import { normalizeEnvironment } from './layer-axes.js';
3
5
  import { layerEvidence } from './layer-merge.js';
4
6
  import { readUtf8 } from './object-io.js';
5
7
  import { SPEC_RUN_ID } from './publish.js';
6
8
  import { testPrefix } from './recording-keys.js';
7
9
  import { sourcePart } from './source-part.js';
8
10
  import { suiteStatus } from './status.js';
11
+ function readParameters(value) {
12
+ if (!value || typeof value !== 'object' || Array.isArray(value))
13
+ return {};
14
+ const parameters = {};
15
+ for (const [key, item] of Object.entries(value))
16
+ if (typeof item === 'string' && item)
17
+ parameters[key] = item;
18
+ return parameters;
19
+ }
20
+ /** A run announcement with the fields the live layer can trust. Null when it cannot be a run. */
21
+ export function liveRunRecord(value) {
22
+ if (!value || typeof value !== 'object')
23
+ return null;
24
+ const run = value;
25
+ if (!run.packageDir || !run.bucket || typeof run.startedAtMs !== 'number')
26
+ return null;
27
+ const parameters = readParameters(run.parameters);
28
+ const environment = typeof run.environment === 'string' ? normalizeEnvironment(run.environment) : null;
29
+ return {
30
+ packageDir: run.packageDir,
31
+ bucket: run.bucket,
32
+ startedAtMs: run.startedAtMs,
33
+ ...(typeof run.plannedChecks === 'number' ? { plannedChecks: run.plannedChecks } : {}),
34
+ ...(environment ? { environment } : {}),
35
+ ...(Object.keys(parameters).length > 0 ? { parameters } : {}),
36
+ };
37
+ }
9
38
  /** A finished run stays visible this long while its layer is being collected. */
10
39
  const LINGER_MS = 30_000;
11
40
  const REPORT = '/report.json';
@@ -46,8 +75,9 @@ export function createLiveEvidence(args) {
46
75
  }
47
76
  });
48
77
  const tests = [...state.metas.values()].sort((a, b) => (a.testId < b.testId ? -1 : a.testId > b.testId ? 1 : 0));
49
- const record = { source, packageDir: state.run.packageDir, bucket: state.run.bucket, status: suiteStatus(tests), updatedAtMs: args.clock.now(), tests };
50
- const summary = { id: liveLayerId(source), producer: source, recordedAtMs: record.updatedAtMs, sha: origin.sha, branch: origin.branch, runUrl: null, parameters: {}, specs: [], parts: [sourcePart(origin.sha ?? '', record)], source: record, recording: null };
78
+ const environment = normalizeEnvironment(state.run.environment ?? state.run.parameters?.environment);
79
+ const record = { source, packageDir: state.run.packageDir, bucket: state.run.bucket, status: suiteStatus(tests), updatedAtMs: args.clock.now(), ...(environment ? { environment } : {}), ...(state.run.parameters ? { parameters: state.run.parameters } : {}), tests };
80
+ const summary = { id: liveLayerId(source), producer: source, recordedAtMs: record.updatedAtMs, sha: origin.sha, branch: origin.branch, runUrl: null, ...(environment ? { environment } : {}), parameters: state.run.parameters ?? {}, specs: [], parts: [sourcePart(origin.sha ?? '', record)], source: record, recording: null };
51
81
  const evidence = layerEvidence(summary, args.backend);
52
82
  const layer = { ...evidence.layer, live: true, recording: { type: 'live', source }, execution: `live:${state.run.startedAtMs}:${tests.length}` };
53
83
  state.evidence = { ...evidence, layer };
@@ -98,7 +128,9 @@ export function createLiveEvidence(args) {
98
128
  /* keep what was read while it ran */
99
129
  }
100
130
  }
101
- const archived = collected.some(layer => layer.producer === source && !layer.live && layer.recordedAtMs >= state.run.startedAtMs);
131
+ const declared = normalizeEnvironment(state.run.environment ?? state.run.parameters?.environment) ?? 'unspecified';
132
+ // A collected layer of another client is a different result, so this suite stays up.
133
+ const archived = collected.some(layer => layer.producer === source && !layer.live && layer.recordedAtMs >= state.run.startedAtMs && environmentOf(layer) === declared);
102
134
  if (archived || now - state.goneAt > LINGER_MS)
103
135
  tracked.delete(source);
104
136
  }
@@ -30,6 +30,8 @@ export type LayerHeader = {
30
30
  readonly recordedAtMs: number;
31
31
  readonly sha: string | null;
32
32
  readonly branch: string | null;
33
+ /** The client this layer checked. Null when the layer did not say. */
34
+ readonly environment: string | null;
33
35
  readonly runUrl: string | null;
34
36
  readonly parameters: LayerParameters;
35
37
  readonly freshness?: 'unknown' | 'modified';
@@ -42,15 +42,26 @@ export type EvidenceView = {
42
42
  readonly total: SpecSummary;
43
43
  };
44
44
  export declare function evidenceView(combined: CombinedEvidence): EvidenceView;
45
- /** Sources left out of the combined report. Everything else, including layers that arrive later, contributes. */
45
+ /**
46
+ * Sources left out of the combined report. An empty list means every layer
47
+ * on that axis contributes, including layers that arrive later.
48
+ */
46
49
  export type SourceSelection = {
47
50
  readonly backends: readonly string[];
48
51
  readonly branches: readonly string[];
52
+ readonly versions: readonly string[];
53
+ readonly environments: readonly string[];
49
54
  readonly layers: readonly string[];
50
55
  };
51
56
  export declare const ALL_SOURCES: SourceSelection;
57
+ /** A saved selection from before versions and environments were axes still means "exclude nothing new". */
58
+ export declare function sourceSelection(excluded: Partial<SourceSelection> | null | undefined): SourceSelection;
52
59
  export declare function branchOf(layer: LayerHeader): string;
53
- export declare function contributes(layer: LayerHeader, excluded: SourceSelection): boolean;
60
+ /** The commit a layer checked, or `unknown` when the layer did not record one. */
61
+ export declare function versionOf(layer: Pick<LayerHeader, 'sha'>): string;
62
+ /** The client a layer checked, or `unspecified` when it did not say. */
63
+ export declare function environmentOf(layer: Pick<LayerHeader, 'environment'>): string;
64
+ export declare function contributes(layer: LayerHeader, excluded: Partial<SourceSelection>): boolean;
54
65
  export type SourceFacet = {
55
66
  readonly id: string;
56
67
  readonly layers: number;
@@ -59,6 +70,8 @@ export type SourceFacet = {
59
70
  export declare function sourceFacets(layers: readonly LayerHeader[]): {
60
71
  backends: SourceFacet[];
61
72
  branches: SourceFacet[];
73
+ versions: SourceFacet[];
74
+ environments: SourceFacet[];
62
75
  };
63
76
  export type HistoryEntry = {
64
77
  readonly layer: LayerHeader;
@@ -79,12 +79,35 @@ export function evidenceView(combined) {
79
79
  }
80
80
  return { specs, unassigned: checkSlots(combined.unassigned), total: addSummaries([...specs.values()].map(entry => entry.summary)) };
81
81
  }
82
- export const ALL_SOURCES = { backends: [], branches: [], layers: [] };
82
+ export const ALL_SOURCES = { backends: [], branches: [], versions: [], environments: [], layers: [] };
83
+ /** A saved selection from before versions and environments were axes still means "exclude nothing new". */
84
+ export function sourceSelection(excluded) {
85
+ return {
86
+ backends: excluded?.backends ?? [],
87
+ branches: excluded?.branches ?? [],
88
+ versions: excluded?.versions ?? [],
89
+ environments: excluded?.environments ?? [],
90
+ layers: excluded?.layers ?? [],
91
+ };
92
+ }
83
93
  export function branchOf(layer) {
84
94
  return layer.branch ?? 'Unknown branch';
85
95
  }
96
+ /** The commit a layer checked, or `unknown` when the layer did not record one. */
97
+ export function versionOf(layer) {
98
+ return layer.sha ?? 'unknown';
99
+ }
100
+ /** The client a layer checked, or `unspecified` when it did not say. */
101
+ export function environmentOf(layer) {
102
+ return layer.environment || 'unspecified';
103
+ }
86
104
  export function contributes(layer, excluded) {
87
- return !excluded.backends.includes(layer.backend) && !excluded.branches.includes(branchOf(layer)) && !excluded.layers.includes(layer.id);
105
+ const selection = sourceSelection(excluded);
106
+ return !selection.backends.includes(layer.backend)
107
+ && !selection.branches.includes(branchOf(layer))
108
+ && !selection.versions.includes(versionOf(layer))
109
+ && !selection.environments.includes(environmentOf(layer))
110
+ && !selection.layers.includes(layer.id);
88
111
  }
89
112
  function facets(layers, id) {
90
113
  const found = new Map();
@@ -97,7 +120,12 @@ function facets(layers, id) {
97
120
  return [...found].map(([key, facet]) => ({ id: key, ...facet })).sort((a, b) => b.latestAtMs - a.latestAtMs || (a.id < b.id ? -1 : 1));
98
121
  }
99
122
  export function sourceFacets(layers) {
100
- return { backends: facets(layers, layer => layer.backend), branches: facets(layers, branchOf) };
123
+ return {
124
+ backends: facets(layers, layer => layer.backend),
125
+ branches: facets(layers, branchOf),
126
+ versions: facets(layers, versionOf),
127
+ environments: facets(layers, environmentOf),
128
+ };
101
129
  }
102
130
  /** Every layer's own record of one check, newest first. A superseded result stays readable here. */
103
131
  export function checkHistory(layers, path, id) {
@@ -0,0 +1,18 @@
1
+ import type { LayerParameters, LayerSummary } from './layer-types.js';
2
+ /**
3
+ * Clients a product is checked on. Any other recorded name is kept, so a new
4
+ * client layers beside these instead of being dropped or renamed away.
5
+ */
6
+ export declare const REPORT_ENVIRONMENTS: readonly ["desktop-web", "mobile-web", "ios", "android"];
7
+ /** A stable environment id. Blank means the layer did not say, so older layers still compose. */
8
+ export declare function normalizeEnvironment(value: string | null | undefined): string | null;
9
+ /**
10
+ * Environment is its own axis. A layer may name it on the field or as a
11
+ * parameter; the parameter is then not also a free choice.
12
+ */
13
+ export declare function layerPlace(summary: Pick<LayerSummary, 'environment' | 'parameters'>): {
14
+ environment: string | null;
15
+ parameters: LayerParameters;
16
+ };
17
+ /** How an environment id reads in the report. */
18
+ export declare function environmentLabel(id: string | null | undefined): string;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Clients a product is checked on. Any other recorded name is kept, so a new
3
+ * client layers beside these instead of being dropped or renamed away.
4
+ */
5
+ export const REPORT_ENVIRONMENTS = ['desktop-web', 'mobile-web', 'ios', 'android'];
6
+ const ALIASES = {
7
+ desktop: 'desktop-web',
8
+ 'desktop-web': 'desktop-web',
9
+ 'desktop-browser': 'desktop-web',
10
+ 'web-desktop': 'desktop-web',
11
+ 'mobile-web': 'mobile-web',
12
+ 'mobile-browser': 'mobile-web',
13
+ 'web-mobile': 'mobile-web',
14
+ ios: 'ios',
15
+ iphone: 'ios',
16
+ ipad: 'ios',
17
+ android: 'android',
18
+ };
19
+ /** A stable environment id. Blank means the layer did not say, so older layers still compose. */
20
+ export function normalizeEnvironment(value) {
21
+ if (value == null)
22
+ return null;
23
+ const slug = value.trim().toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
24
+ if (!slug)
25
+ return null;
26
+ return ALIASES[slug] ?? slug;
27
+ }
28
+ /**
29
+ * Environment is its own axis. A layer may name it on the field or as a
30
+ * parameter; the parameter is then not also a free choice.
31
+ */
32
+ export function layerPlace(summary) {
33
+ const { environment: named, ...rest } = summary.parameters;
34
+ return { environment: normalizeEnvironment(summary.environment ?? named), parameters: rest };
35
+ }
36
+ const LABELS = {
37
+ 'desktop-web': 'Desktop web',
38
+ 'mobile-web': 'Mobile web',
39
+ ios: 'iOS',
40
+ android: 'Android',
41
+ };
42
+ /** How an environment id reads in the report. */
43
+ export function environmentLabel(id) {
44
+ if (!id)
45
+ return 'Unspecified';
46
+ const known = LABELS[id];
47
+ if (known)
48
+ return known;
49
+ const [first, ...rest] = id.split('-');
50
+ return [first.charAt(0).toUpperCase() + first.slice(1), ...rest].join(' ');
51
+ }
@@ -31,7 +31,7 @@ export async function catalogLayers(store, entry) {
31
31
  const stored = await readStoredSource(store, sha, sourceId);
32
32
  if (!stored)
33
33
  return;
34
- layers.push({ id: `source-${sha}-${sourceId}`, producer: sourceId, recordedAtMs: Math.max(commit?.startedAtMs ?? 0, stored.record.updatedAtMs), sha, branch: commit?.branch ?? null, runUrl: commit?.runUrl ?? null, parameters: {}, specs, parts: sourceParts(sha, stored.record), source: stored.record, recording: { type: 'commit', sha, source: sourceId } });
34
+ layers.push({ ...(stored.record.environment ? { environment: stored.record.environment } : {}), id: `source-${sha}-${sourceId}`, producer: sourceId, recordedAtMs: Math.max(commit?.startedAtMs ?? 0, stored.record.updatedAtMs), sha, branch: commit?.branch ?? null, runUrl: commit?.runUrl ?? null, parameters: stored.record.parameters ?? {}, specs, parts: sourceParts(sha, stored.record), source: stored.record, recording: { type: 'commit', sha, source: sourceId } });
35
35
  });
36
36
  const signature = (part) => JSON.stringify([part.id, part.kind, part.checks]);
37
37
  const sourceEvidence = new Set(layers.filter(layer => layer.source).flatMap(layer => layer.parts.map(signature)));
@@ -29,7 +29,7 @@ export type CombinedEvidence = {
29
29
  }[];
30
30
  readonly unassigned: EvidenceResult[];
31
31
  };
32
- /** One linear pass. Latest completed result wins only its own check/parameter slot. */
32
+ /** One linear pass. The newest completed result wins only its own check, environment and parameter slot. */
33
33
  export declare function combineEvidence(outline: ReportOutline, layers: readonly LayerEvidence[]): CombinedEvidence;
34
34
  export declare function slotKey(path: string, id: string): string;
35
35
  /** Suppress the mutable address of an execution already archived as an immutable layer. */
@@ -1,3 +1,4 @@
1
+ import { layerPlace } from './layer-axes.js';
1
2
  /** Advisory identity, never a cryptographic integrity check. */
2
3
  export function textFingerprint(text) {
3
4
  let a = 2166136261, b = 5381;
@@ -30,6 +31,7 @@ export function outcomeCounts(rows) {
30
31
  }
31
32
  /** A layer as the explorer reads it: its origin, and each check as one row. */
32
33
  export function layerEvidence(summary, backend) {
34
+ const place = layerPlace(summary);
33
35
  const source = summary.source;
34
36
  const tests = new Map(source?.tests.map(test => [`${source.source}/${test.testId}`, test]) ?? []);
35
37
  const rows = summary.parts.flatMap(part => part.checks.map(check => {
@@ -58,8 +60,9 @@ export function layerEvidence(summary, backend) {
58
60
  recordedAtMs: summary.recordedAtMs,
59
61
  sha: summary.sha,
60
62
  branch: summary.branch,
63
+ environment: place.environment,
61
64
  runUrl: summary.runUrl,
62
- parameters: summary.parameters,
65
+ parameters: place.parameters,
63
66
  recording: recording === null ? null : recording.type === 'layer' ? { type: 'layer', layer: summary.id } : recording,
64
67
  live: false,
65
68
  counts: outcomeCounts(rows),
@@ -106,7 +109,7 @@ function replaces(next, prior) {
106
109
  return RANK[next.row.status] > RANK[prior.row.status];
107
110
  return next.layer.id > prior.layer.id;
108
111
  }
109
- /** One linear pass. Latest completed result wins only its own check/parameter slot. */
112
+ /** One linear pass. The newest completed result wins only its own check, environment and parameter slot. */
110
113
  export function combineEvidence(outline, layers) {
111
114
  const specs = new Map(outline.specs.map(spec => [specPath(spec.path), { spec, results: [] }]));
112
115
  const changed = new Set(outline.changedFiles.map(specPath));
@@ -115,9 +118,11 @@ export function combineEvidence(outline, layers) {
115
118
  const scope = layer.packageDir;
116
119
  const related = outline.changedFiles.some(file => scope ? file.startsWith(`${scope}/`) : !specPath(file).startsWith('specs/'));
117
120
  for (const row of rows) {
118
- const parameters = { ...layer.parameters, ...row.variant };
121
+ // Environment and the layer's other choices split the slot. A harness
122
+ // seed does not: a focused rerun replaces the check it repeated.
123
+ const parameters = layer.parameters;
119
124
  const path = row.specPath ?? '';
120
- const key = JSON.stringify([layer.producer, row.kind, path, row.id, parameterKey(parameters)]);
125
+ const key = JSON.stringify([layer.producer, row.kind, path, row.id, layer.environment ?? '', parameterKey(parameters)]);
121
126
  const result = { key, slot: slotKey(path, row.id), layer, row, parameters, warning: warning(layer, path, specs.get(path)?.spec, outline, fingerprints[path], related || changed.has(path)) };
122
127
  const prior = winners.get(key);
123
128
  if (!prior || replaces(result, prior))
@@ -1,4 +1,5 @@
1
1
  import { writeBundle } from './bundle.js';
2
+ import { normalizeEnvironment } from './layer-axes.js';
2
3
  import { readCommit, readFrameCss } from './read.js';
3
4
  import { readInventory } from './spec-inventory.js';
4
5
  import { sourceParts } from './source-part.js';
@@ -25,8 +26,10 @@ export async function archiveSourceLayer(store, args, source, tests, bundle) {
25
26
  const index = await writeBundle(store, entries(), 'layers/blobs/');
26
27
  for (const [key, value] of Object.entries(bundle ?? {}))
27
28
  index[layerPath(key)] = value;
29
+ const environment = normalizeEnvironment(args.environment ?? args.parameters?.environment);
28
30
  return publishLayerBundle(store, {
29
31
  ...(args.freshness ? { freshness: args.freshness } : {}), producer: source.source, recordedAtMs, sha: args.sha, branch: commit?.branch ?? null, runUrl: commit?.runUrl ?? null,
32
+ ...(environment ? { environment } : {}),
30
33
  parameters: args.parameters ?? {}, specs: inventory?.entries ?? [], parts: sourceParts(args.sha, source), source, recording: { type: 'layer' },
31
34
  }, index);
32
35
  }
@@ -9,6 +9,8 @@ export type LayerSummary = {
9
9
  readonly producer: string;
10
10
  readonly sha: string | null;
11
11
  readonly branch: string | null;
12
+ /** The client this layer checked, when it said. Normalized when the layer is read. */
13
+ readonly environment?: string | null;
12
14
  readonly runUrl: string | null;
13
15
  readonly parameters: LayerParameters;
14
16
  readonly specs: readonly SpecEntry[];
@@ -1,7 +1,7 @@
1
1
  import { Hono } from 'hono';
2
2
  import { createBundleReader } from './bundle.js';
3
3
  import { eachLimited } from './completion-order.js';
4
- import { createLiveEvidence } from './evidence-live.js';
4
+ import { createLiveEvidence, liveRunRecord } from './evidence-live.js';
5
5
  import { isFramePath, isKeySegment } from './keys.js';
6
6
  import { catalogEntries, catalogLayers } from './layer-catalog.js';
7
7
  import { layerEvidence } from './layer-merge.js';
@@ -130,10 +130,7 @@ export function createLayersApp(deps) {
130
130
  const raw = await readUtf8(local, key);
131
131
  if (raw === null)
132
132
  return null;
133
- const run = JSON.parse(raw);
134
- if (!run.packageDir || !run.bucket || typeof run.startedAtMs !== 'number')
135
- return null;
136
- return { packageDir: run.packageDir, bucket: run.bucket, startedAtMs: run.startedAtMs, ...(typeof run.plannedChecks === 'number' ? { plannedChecks: run.plannedChecks } : {}) };
133
+ return liveRunRecord(JSON.parse(raw));
137
134
  }
138
135
  catch {
139
136
  return null;
@@ -30,6 +30,10 @@ export type SourceRecord = {
30
30
  readonly bucket: string;
31
31
  readonly status: ReportTestStatus;
32
32
  readonly updatedAtMs: number;
33
+ /** The client this source checked, when the run said. */
34
+ readonly environment?: string;
35
+ /** Recorded choices other than the client, such as an example or a device size. */
36
+ readonly parameters?: Readonly<Record<string, string>>;
33
37
  /** Each test's meta, test-runner outcomes already applied. */
34
38
  readonly tests: readonly RunReportMeta[];
35
39
  };
@@ -24,6 +24,8 @@ export type PublishSourceArgs = {
24
24
  /** Execution completion time, separate from a virtual timeline clock. */
25
25
  readonly recordedAtMs?: number;
26
26
  readonly freshness?: 'unknown' | 'modified';
27
+ /** The client this run checked: desktop web, mobile web, iOS, Android, or another recorded name. */
28
+ readonly environment?: string;
27
29
  readonly parameters?: LayerParameters;
28
30
  readonly sha: string;
29
31
  readonly packageDir: string;
@@ -1,4 +1,5 @@
1
1
  import { archiveSourceLayer } from './layer-source.js';
2
+ import { normalizeEnvironment } from './layer-axes.js';
2
3
  import { performancePart } from './performance-part.js';
3
4
  import { applyJunitOutcomes, parseJunit, } from './junit.js';
4
5
  import { testFrameKey, testPrefix, testReportKey, } from './recording-keys.js';
@@ -113,12 +114,15 @@ export async function publishSource(store, args) {
113
114
  await writeUtf8(store, `${contentPrefix}${report.meta.testId}/report.json`, JSON.stringify(report), JSON_TYPE);
114
115
  });
115
116
  }
117
+ const environment = normalizeEnvironment(args.environment ?? args.parameters?.environment);
116
118
  const record = {
117
119
  source,
118
120
  packageDir: args.packageDir,
119
121
  bucket: args.bucket,
120
122
  status,
121
123
  updatedAtMs: args.recordedAtMs ?? metas.reduce((latest, meta) => Math.max(latest, meta.endedAtMs), 0),
124
+ ...(environment ? { environment } : {}),
125
+ ...(args.parameters && Object.keys(args.parameters).length > 0 ? { parameters: args.parameters } : {}),
122
126
  tests: metas,
123
127
  ...(await sourceEvidence(args.local, args.sha, source, tests)),
124
128
  };
@@ -1,3 +1,4 @@
1
+ import { normalizeEnvironment } from './layer-axes.js';
1
2
  import { publishLayer } from './layer-store.js';
2
3
  import { readCommit } from './read.js';
3
4
  import { readInventory } from './spec-inventory.js';
@@ -68,8 +69,10 @@ export async function publishPart(store, part) {
68
69
  await writeUtf8(store, `${partsPrefix(part.sha)}${part.id}/${part.recordedAtMs}-${hash}.json`, json, 'application/json');
69
70
  await recordExplorerVersion(store, part.sha);
70
71
  const commit = await readCommit(store, part.sha);
72
+ const environment = normalizeEnvironment(part.parameters?.environment);
71
73
  await publishLayer(store, {
72
74
  ...(part.freshness ? { freshness: part.freshness } : {}), producer: part.id, recordedAtMs: part.recordedAtMs, sha: part.sha, branch: commit?.branch ?? null, runUrl: commit?.runUrl ?? null,
75
+ ...(environment ? { environment } : {}),
73
76
  parameters: part.parameters ?? {}, specs: (await readInventory(store, part.sha))?.entries ?? [], parts: [part], source: null, recording: null,
74
77
  }, (async function* () { })());
75
78
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crvouga/atlas",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Atlas: executable statecharts. Spec a product's flows as statecharts, run them against the real app with any driver, and browse what happened.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -107,7 +107,7 @@
107
107
  "test": "bun test && bun test --cwd packages/visualizer --preload ./tests/preload.ts tests",
108
108
  "type-check": "for dir in packages/* scripts examples/todo; do tsc --noEmit -p \"$dir\" || exit 1; done",
109
109
  "schemas": "bun packages/schema/scripts/emit-json-schemas.ts",
110
- "viz:dev": "cd packages/visualizer && INIT_CWD=\"$OLDPWD\" bun --bun vite --configLoader runner",
110
+ "viz:dev": "bun packages/visualizer/bin/atlas-visualizer.mjs dev --specs \"$(pwd)/packages/visualizer/fixtures/full/specs\" --runs \"$(pwd)/packages/visualizer/fixtures/full/runs\"",
111
111
  "viz:build": "cd packages/visualizer && INIT_CWD=\"$OLDPWD\" bun --bun vite build --configLoader runner",
112
112
  "viz:publish-data": "bun packages/visualizer/scripts/publish-data.ts",
113
113
  "viz:fixtures": "bun packages/visualizer/scripts/make-fixtures.ts",
@@ -10,11 +10,11 @@ The Atlas visualizer shows each kind of spec in its own tab, with every spec's r
10
10
 
11
11
  ## Combining evidence
12
12
 
13
- Current specs define the outline. `combineEvidence` fills it with the newest completed result for each producer, check and parameter set, across backends, branches and commits. Equal-time conflicts favor failure. Unfinished runs do not erase completed outcomes. Missing specs stay visible and removed specs retain their evidence outside the outline. `evidenceView` groups those results by check: the outcomes, the recorded timings that qualify them, and a summary per spec. A timing over its allowance never fails a check. `checkHistory` returns every layer's own record of one check, so a superseded result stays readable. Leaving a backend, a branch or one layer out (`contributes`) is how earlier outcomes are inspected.
13
+ Current specs define the outline. A layer is one focused recording: the checks it actually ran, on one environment, from one version and branch, in one store. `combineEvidence` fills the outline from every layer. The newest completed result wins only its own producer, check, environment and parameter set. Desktop web, mobile web, iOS and Android of the same check all stay visible, and any other recorded environment does too. A layer that ran one scenario leaves every other scenario as the newest layer that did run it, so the report fills without one suite that runs everything. Equal-time conflicts favor failure. Unfinished runs do not erase completed outcomes. Missing specs stay visible and removed specs retain their evidence outside the outline. `evidenceView` groups those results by check: the outcomes, the recorded timings that qualify them, and a summary per spec. A timing over its allowance never fails a check. `checkHistory` returns every layer's own record of one check, so a superseded result stays readable. Leaving a backend, an environment, a branch, a version or one layer out (`contributes`) is how earlier outcomes are inspected.
14
14
 
15
15
  ## Backends
16
16
 
17
- `createReportsApp` combines layers from several stores. `localStore` is the backend `local` ("This machine"), `store` is `shared` ("Shared reports"), and `backends` adds any number of further named stores, such as another team's bucket or a device lab. Every layer says which backend it came from. An unavailable backend is named in the page's notices and leaves the others usable; `GET /evidence?retry=1` asks it again without waiting.
17
+ `createReportsApp` combines layers from several stores. `localStore` is the backend `local` ("This machine"), `store` is `shared` ("Shared reports"), and `backends` adds any number of further named stores. A backend is any `ObjectStore`: the local disk, a shared bucket, S3, or a device lab. Every layer says which backend, version, branch and environment it came from. An unavailable backend is named in the page's notices and leaves the others usable; `GET /evidence?retry=1` asks it again without waiting.
18
18
 
19
19
  | Route | Returns |
20
20
  | --- | --- |
@@ -40,6 +40,8 @@ Freshness warnings preserve the recorded outcome. Changed spec fingerprints or r
40
40
 
41
41
  Configure `ATLAS_REPORTS_MODULE` with a host composition module exporting `reportsDevFetch(request, repoRoot)`. Atlas's Vite middleware serves it at `/api/reports`, alongside specs and chart runs. `INIT_CWD` identifies the consuming repository. Credentials stay in that server module.
42
42
 
43
+ When that module is unset and the open specs are the coffee example or the todo example, the visualizer dev server serves an in-memory layered report for those documents instead. Any other spec set is left alone. The todo example's own module serves that same layered report and adds the desktop web layer `example:run` writes.
44
+
43
45
  For a separately hosted reports API, set `VITE_ATLAS_REPORTS_URL` when building the visualizer. Otherwise a built site reads `data/evidence/`, populated with `exportEvidence`. Atlas still browses specs if evidence is absent.
44
46
 
45
47
  ## Verification
@@ -7,7 +7,7 @@ storage and time ports from its caller.
7
7
 
8
8
  `layers/index/<inverted-time>-<digest>.json` discovers immutable manifests at `layers/manifests/<digest>.json`. Each records producer, origin, parameters, checks, spec fingerprints and a recording bundle. The manifest is written after its blobs and the index last. Distinct results at the same timestamp retain separate identities. Compressed blobs under `layers/blobs/` are addressed by content and reused across runs; ordinary source publication shares its bundle directly with its layer without recompressing snapshots.
9
9
 
10
- Git commits are provenance and exact historical recording addresses, never a report partition. `combineEvidence` chooses completed results per check and parameters, retaining gaps from older layers. `publishSource` accepts `parameters`, and standalone parts may include them too. Inventory must be published before results for fingerprint comparison to be available.
10
+ Git commits are provenance and exact historical recording addresses, never a report partition. `combineEvidence` chooses the newest completed result per check, environment and parameters, and keeps every other cell. An environment is the client that was checked (`desktop-web`, `mobile-web`, `ios`, `android`, or any other recorded name). The same check on two environments contributes twice. A layer that checks one scenario fills only that scenario. `publishSource` accepts `environment` and `parameters`, and a part may name its environment in `parameters.environment`. Inventory must be published before results for fingerprint comparison to be available.
11
11
 
12
12
  ## Commit inventory
13
13
 
@@ -38,8 +38,8 @@ atlas-visualizer dev --specs ./specs --runs ./atlas-runs
38
38
  | `--port <n>`, `--host <host>` | `dev`: where to listen (default `localhost:5180`, or the next free port). |
39
39
 
40
40
  Relative paths resolve against the directory the command was run from. Without flags the
41
- environment is read: `ATLAS_SPECS_ROOT`, `ATLAS_RUNS_ROOT` (relative to `INIT_CWD` when your package
42
- manager sets it to where you ran the command, or the current directory) and `ATLAS_FIXTURE`. With nothing configured and no
41
+ environment is read: `ATLAS_SPECS_ROOT` and `ATLAS_RUNS_ROOT` (relative to `INIT_CWD` when your package
42
+ manager sets it to where you ran the command, or the current directory). With nothing configured and no
43
43
  `specs/` directory, the `full` example is shown.
44
44
 
45
45
  Editing a spec file or a run publishing a snapshot updates the open page in about a second, without a reload,
@@ -62,8 +62,7 @@ sets how often the site checks for new runs (default 60 s, plus when the tab reg
62
62
  ### Inside this repository
63
63
 
64
64
  ```sh
65
- bun run viz:dev # ATLAS_SPECS_ROOT / ATLAS_RUNS_ROOT, or the example
66
- ATLAS_FIXTURE=partial bun run viz:dev # one of the fixtures
65
+ bun run viz:dev # the full coffee example, charts and layered report
67
66
  bun test packages/visualizer/src packages/visualizer/vite # parsing, composition, statuses, every fixture
68
67
  bun run viz:fixtures # regenerate the fixtures (needs ImageMagick, cwebp, ffmpeg)
69
68
  ```
@@ -149,7 +148,9 @@ MIT
149
148
 
150
149
  Beside the statechart map, every other kind of spec has its own tab: Gherkin features, Quint models, DMN decision tables, Alloy structures and OpenSLO objectives. A tab appears when the specs folder has files of its kind. The index lists every spec with what was recorded for it, and filters to those with results, those failing, or those with none. A spec page shows each scenario, trace or check with its outcome; opening one shows the recorded steps, the error, the timings, every other run of the same check, and its recording.
151
150
 
152
- Results come from the Atlas reports API (`/api/reports` in dev, `VITE_ATLAS_REPORTS_URL` when hosted elsewhere) or from a static `data/evidence` export. They combine across backends, branches and runs; **Sources** in the header switches any of them off. While a spec suite is running, the header says so and each finished test appears within a couple of seconds. See [Atlas reports](../reports/README.md) for the API and host configuration.
151
+ Results come from the Atlas reports API (`/api/reports` in dev, `VITE_ATLAS_REPORTS_URL` when hosted elsewhere) or from a static `data/evidence` export. They combine across backends, branches, versions, environments and layers; **Sources** in the header switches any of them off. While a spec suite is running, the header says so and each finished test appears within a couple of seconds. See [Atlas reports](../reports/README.md) for the API and host configuration.
152
+
153
+ The coffee example ships those documents and a layered report. `bun run viz:dev` opens that full example, and the same report is the fallback when a working directory has no `specs/` and `ATLAS_REPORTS_MODULE` is unset. `atlas:viz` serves the same kind of report for the todo example, and adds the desktop web layer `example:run` writes. Each built-in report is five layers: an older desktop web run, a mobile web branch, Android from a device lab, a focused iOS run, and a newer desktop web run that repeated one scenario. The same check stays visible on each environment. A feature with no layer stays empty.
153
154
 
154
155
  The header title is the product's name, taken from `atlas.yaml`'s `title` up to its first dash or colon, or "Atlas".
155
156
 
@@ -0,0 +1,7 @@
1
+ # Cart
2
+ module cart {
3
+ var items: int
4
+ var paid: bool
5
+ action add = items' = items + 1
6
+ action pay = paid' = true
7
+ }
@@ -0,0 +1,10 @@
1
+ # Checkout latency
2
+ apiVersion: openslo/v1
3
+ kind: SLO
4
+ metadata:
5
+ name: checkout-latency
6
+ spec:
7
+ description: Checkout answers within a second.
8
+ objectives:
9
+ - displayName: Checkout latency
10
+ target: 0.99
@@ -0,0 +1,24 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <definitions xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/">
3
+ <decision id="drink-price" name="Drink price">
4
+ <decisionTable id="Drink price" hitPolicy="UNIQUE">
5
+ <input label="Size">
6
+ <inputExpression typeRef="string"><text>size</text></inputExpression>
7
+ </input>
8
+ <input label="Milk">
9
+ <inputExpression typeRef="string"><text>milk</text></inputExpression>
10
+ </input>
11
+ <output label="Price" name="price" />
12
+ <rule>
13
+ <inputEntry><text>"Small"</text></inputEntry>
14
+ <inputEntry><text>"None"</text></inputEntry>
15
+ <outputEntry><text>3</text></outputEntry>
16
+ </rule>
17
+ <rule>
18
+ <inputEntry><text>"Large"</text></inputEntry>
19
+ <inputEntry><text>"Oat"</text></inputEntry>
20
+ <outputEntry><text>5</text></outputEntry>
21
+ </rule>
22
+ </decisionTable>
23
+ </decision>
24
+ </definitions>