@crvouga/atlas 0.9.0 → 0.10.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 (53) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/playwright-reporter/src/evidence.d.ts +122 -0
  3. package/dist/playwright-reporter/src/evidence.js +327 -0
  4. package/dist/playwright-reporter/src/index.d.ts +114 -0
  5. package/dist/playwright-reporter/src/index.js +312 -0
  6. package/dist/reports/src/compose.js +7 -14
  7. package/dist/reports/src/recorded-specs.d.ts +33 -0
  8. package/dist/reports/src/recorded-specs.js +42 -0
  9. package/dist/reports/src/report-index.d.ts +6 -0
  10. package/dist/reports/src/report-index.js +14 -2
  11. package/dist/reports/src/test-catalog.d.ts +386 -0
  12. package/dist/reports/src/test-catalog.js +655 -0
  13. package/dist/schema/src/api.d.ts +585 -0
  14. package/dist/schema/src/api.js +105 -0
  15. package/dist/server/src/app.js +82 -4
  16. package/dist/server/src/llms.js +14 -0
  17. package/dist/server/src/openapi.d.ts +1 -1
  18. package/dist/server/src/openapi.js +60 -3
  19. package/docs/agents.md +1 -0
  20. package/docs/integrating.md +1 -0
  21. package/docs/reference/http-api.md +134 -2
  22. package/docs/reference/playwright-reporter.md +332 -0
  23. package/docs/reference/reports.md +632 -0
  24. package/docs/reference/schema.md +57 -0
  25. package/docs/rich-evidence.md +198 -0
  26. package/docs/server.md +41 -0
  27. package/llms-full.txt +378 -2
  28. package/llms.txt +2 -0
  29. package/package.json +6 -1
  30. package/packages/schema/schemas/report-run.schema.json +251 -0
  31. package/packages/schema/schemas/report-runs.schema.json +177 -0
  32. package/packages/schema/schemas/test-catalog.schema.json +1177 -0
  33. package/packages/schema/schemas/test-history.schema.json +696 -0
  34. package/packages/server/openapi.json +8676 -5351
  35. package/packages/visualizer/src/app/AppShell.tsx +7 -3
  36. package/packages/visualizer/src/app/router.tsx +26 -3
  37. package/packages/visualizer/src/components/Header.tsx +2 -2
  38. package/packages/visualizer/src/evidence/evidence-context.tsx +10 -1
  39. package/packages/visualizer/src/explorer/Explorer.tsx +433 -0
  40. package/packages/visualizer/src/explorer/ExplorerIndex.tsx +120 -0
  41. package/packages/visualizer/src/explorer/RunsView.tsx +164 -0
  42. package/packages/visualizer/src/explorer/TestDetail.tsx +214 -0
  43. package/packages/visualizer/src/explorer/catalog.tsx +274 -0
  44. package/packages/visualizer/src/explorer/explorer.module.css +1238 -0
  45. package/packages/visualizer/src/explorer/groups.test.ts +87 -0
  46. package/packages/visualizer/src/explorer/groups.ts +126 -0
  47. package/packages/visualizer/src/explorer/navigate.ts +36 -0
  48. package/packages/visualizer/src/explorer/parts.tsx +59 -0
  49. package/packages/visualizer/src/explorer/search.ts +97 -0
  50. package/packages/visualizer/src/find/Finder.tsx +2 -0
  51. package/packages/visualizer/src/find/corpus.ts +13 -6
  52. package/packages/visualizer/src/find/rank.ts +2 -0
  53. package/packages/visualizer/src/evidence/project-home.tsx +0 -45
@@ -0,0 +1,312 @@
1
+ /**
2
+ * @module Playwright Test reporter
3
+ *
4
+ * `@crvouga/atlas/playwright-reporter` is a Playwright Test reporter that streams every test to an Atlas server (or any
5
+ * store) as it runs, with the evidence Playwright already has: durations, failure details, steps as a timeline, retries
6
+ * as attempts (and `flaky`), tags, annotations, and the videos, screenshots and traces it attached. Each Playwright
7
+ * project is its own stream of layers, so a desktop and a mobile project are two environments of the same checks, and
8
+ * every check is labelled with its browser, device, viewport, operating system and Playwright version.
9
+ *
10
+ * It never throws into the test run: a destination that is not configured, a store that is down or a file that cannot
11
+ * be read is said once, and the run goes on. Playwright is only imported for its types, so importing this module loads
12
+ * nothing of Playwright.
13
+ *
14
+ * @see docs/rich-evidence.md
15
+ */
16
+ import path from 'node:path';
17
+ import { createReporter } from '../../reports/src/reporter.js';
18
+ import { detectFreshness, detectOrigin } from '../../reports/src/report-cli.js';
19
+ import { openStore } from '../../reports/src/store-url.js';
20
+ import { bddFeaturePath, checkOf, findRepoRoot, projectEnvironment, projectLabels, repoPath, willRetry } from './evidence.js';
21
+ export { bddFeaturePath, checkOf, errorDetail, findRepoRoot, flattenSteps, projectEnvironment, projectLabels, repoPath, resultAttachments, resultStatus, stepKind, stripAnsi, willRetry } from './evidence.js';
22
+ const MiB = 1024 * 1024;
23
+ function perProject(value, project) {
24
+ if (typeof value !== 'function')
25
+ return value;
26
+ return project ? value(project) : undefined;
27
+ }
28
+ function projectSuiteOf(test) {
29
+ for (let suite = test.parent; suite; suite = suite.parent)
30
+ if (suite.type === 'project')
31
+ return suite;
32
+ return undefined;
33
+ }
34
+ /**
35
+ * The Playwright Test reporter of Atlas. Add it beside your console reporter; it streams each test as `running` when
36
+ * it starts and with its full evidence when its last try ends, one stream of layers per Playwright project, and
37
+ * prints one summary line when the run ends. It never fails or slows the run on account of Atlas.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * // playwright.config.ts
42
+ * import { defineConfig } from '@playwright/test';
43
+ *
44
+ * export default defineConfig({
45
+ * reporter: [['list'], ['@crvouga/atlas/playwright-reporter', { project: 'checkout', producer: 'e2e-web' }]],
46
+ * use: { video: 'retain-on-failure', trace: 'retain-on-failure', screenshot: 'only-on-failure' }
47
+ * });
48
+ * ```
49
+ */
50
+ export default class AtlasPlaywrightReporter {
51
+ options;
52
+ config;
53
+ store = null;
54
+ label = '';
55
+ root = process.cwd();
56
+ identity = { sha: null, branch: null, runUrl: null };
57
+ streams = new Map();
58
+ identities = new WeakMap();
59
+ specPaths = new Map();
60
+ running = new Set();
61
+ pending = new Set();
62
+ warned = false;
63
+ recorded = 0;
64
+ /** Playwright passes the options given in `playwright.config.ts`. */
65
+ constructor(options = {}) {
66
+ this.options = options ?? {};
67
+ }
68
+ /** This reporter prints only a summary line, so Playwright keeps its own console output. */
69
+ printsToStdio() {
70
+ return false;
71
+ }
72
+ say(line) {
73
+ if (this.options.quiet)
74
+ return;
75
+ if (this.options.log)
76
+ this.options.log(line);
77
+ else
78
+ process.stdout.write(`${line}\n`);
79
+ }
80
+ /** Said once per run: Atlas problems never flood the test output. */
81
+ warn(message) {
82
+ if (this.warned || this.options.quiet)
83
+ return;
84
+ this.warned = true;
85
+ const line = `Atlas: ${message}`;
86
+ if (this.options.log)
87
+ this.options.log(line);
88
+ else
89
+ process.stderr.write(`${line}\n`);
90
+ }
91
+ /** Resolves where layers go, where the run came from, and the directory paths are relative to. Never throws. */
92
+ onBegin(config, _suite) {
93
+ this.config = config;
94
+ const options = this.options;
95
+ const env = options.env ?? process.env;
96
+ try {
97
+ const base = config?.rootDir ?? process.cwd();
98
+ this.root = options.rootDir ? path.resolve(base, options.rootDir) : (findRepoRoot(base) ?? base);
99
+ }
100
+ catch { /* keep the working directory */ }
101
+ try {
102
+ if (options.store) {
103
+ this.store = options.store;
104
+ this.label = 'the given store';
105
+ }
106
+ else {
107
+ const url = options.url ?? env.ATLAS_SERVER_URL ?? env.ATLAS_STORE;
108
+ if (!url) {
109
+ this.warn('not publishing: set ATLAS_SERVER_URL (and ATLAS_TOKEN, ATLAS_PROJECT), or give the reporter { url, token, project }');
110
+ }
111
+ else {
112
+ const token = options.token ?? env.ATLAS_PUBLISH_TOKEN;
113
+ const project = options.project ?? env.ATLAS_PROJECT;
114
+ const opened = openStore(url, { base: this.root, env: { ...env, ...(token ? { ATLAS_TOKEN: token } : {}), ...(project ? { ATLAS_PROJECT: project } : {}) } });
115
+ this.store = opened.store;
116
+ this.label = opened.label;
117
+ }
118
+ }
119
+ }
120
+ catch (error) {
121
+ this.store = null;
122
+ this.warn(`not publishing: ${error instanceof Error ? error.message : String(error)}`);
123
+ }
124
+ if (!this.store)
125
+ return;
126
+ try {
127
+ const explicitSha = options.sha ?? env.ATLAS_SHA;
128
+ const origin = detectOrigin(env);
129
+ const sha = explicitSha ?? origin.sha;
130
+ const freshness = detectFreshness({ sha, ci: origin.ci && !explicitSha });
131
+ this.identity = { sha, branch: options.branch ?? env.ATLAS_BRANCH ?? origin.branch, runUrl: options.runUrl ?? origin.runUrl, ...(freshness ? { freshness } : {}) };
132
+ }
133
+ catch {
134
+ this.identity = { sha: options.sha ?? env.ATLAS_SHA ?? null, branch: options.branch ?? env.ATLAS_BRANCH ?? null, runUrl: options.runUrl ?? null, freshness: 'unknown' };
135
+ }
136
+ }
137
+ specPathOf(test) {
138
+ try {
139
+ const given = this.options.specPath?.(test);
140
+ if (given)
141
+ return given.replaceAll('\\', '/');
142
+ }
143
+ catch { /* fall back to the file */ }
144
+ const file = test.location?.file;
145
+ if (!file)
146
+ return '';
147
+ let known = this.specPaths.get(file);
148
+ if (known === undefined) {
149
+ known = repoPath(bddFeaturePath(file) ?? file, this.root);
150
+ this.specPaths.set(file, known);
151
+ }
152
+ return known;
153
+ }
154
+ titleOf(test, specPath) {
155
+ // playwright-bdd names a Scenario Outline's examples `Example #1`; the outline's title says which scenario it is.
156
+ if (specPath.endsWith('.feature') && /^Example #\d+/.test(test.title) && test.parent?.type === 'describe' && test.parent.title)
157
+ return `${test.parent.title}: ${test.title}`;
158
+ return test.title;
159
+ }
160
+ specsOf(projectSuite) {
161
+ if (this.options.specs === false || !projectSuite)
162
+ return [];
163
+ const specs = new Map();
164
+ for (const test of projectSuite.allTests?.() ?? []) {
165
+ const specPath = this.specPathOf(test);
166
+ if (!specPath || specs.has(specPath))
167
+ continue;
168
+ let top = test.parent;
169
+ while (top?.parent && top.parent.type === 'describe')
170
+ top = top.parent;
171
+ const segments = specPath.split('/');
172
+ const file = segments.at(-1) ?? specPath;
173
+ const title = specPath.endsWith('.feature') && top?.type === 'describe' && top.title ? top.title : file;
174
+ specs.set(specPath, { path: specPath, domain: segments.length > 1 ? segments.at(-2) : 'Overview', kind: file.includes('.') ? file.slice(file.lastIndexOf('.') + 1) : 'document', title });
175
+ }
176
+ return [...specs.values()];
177
+ }
178
+ streamOf(projectSuite) {
179
+ if (this.streams.has(projectSuite))
180
+ return this.streams.get(projectSuite);
181
+ let stream = null;
182
+ const store = this.store;
183
+ if (store) {
184
+ try {
185
+ const options = this.options;
186
+ const project = projectSuite?.project?.();
187
+ const environment = perProject(options.environment, project) ?? projectEnvironment(project);
188
+ const parameters = perProject(options.parameters, project) ?? {};
189
+ const shard = this.config?.shard;
190
+ const labels = { ...projectLabels(project, this.config?.version), ...(shard ? { shard: `${shard.current}/${shard.total}` } : {}), ...(perProject(options.labels, project) ?? {}) };
191
+ const reporter = createReporter({
192
+ producer: options.producer ?? 'playwright',
193
+ kind: options.kind ?? 'checks',
194
+ title: `Playwright${project?.name ? `: ${project.name}` : ''}`,
195
+ sha: this.identity.sha,
196
+ branch: this.identity.branch,
197
+ runUrl: this.identity.runUrl,
198
+ ...(this.identity.freshness ? { freshness: this.identity.freshness } : {}),
199
+ environment,
200
+ parameters: { ...parameters },
201
+ specs: this.specsOf(projectSuite),
202
+ labels
203
+ }, {
204
+ store,
205
+ flushMs: options.flushMs ?? 5000,
206
+ batchSize: options.batchSize ?? 50,
207
+ attempts: options.attempts ?? 5,
208
+ onError: (error, willRetryLayer) => { if (!willRetryLayer)
209
+ this.warn(`could not send results to ${this.label}: ${error.message}`); }
210
+ });
211
+ stream = { reporter, labels };
212
+ }
213
+ catch (error) {
214
+ this.warn(`not publishing project ${projectSuite?.title ?? ''}: ${error instanceof Error ? error.message : String(error)}`);
215
+ }
216
+ }
217
+ this.streams.set(projectSuite, stream);
218
+ return stream;
219
+ }
220
+ identityOf(test) {
221
+ const known = this.identities.get(test);
222
+ if (known)
223
+ return known;
224
+ const specPath = this.specPathOf(test);
225
+ const titles = [test.title];
226
+ for (let suite = test.parent; suite && suite.type === 'describe'; suite = suite.parent)
227
+ titles.unshift(suite.title);
228
+ const repeat = test.repeatEachIndex ? ` [repeat ${test.repeatEachIndex}]` : '';
229
+ const identity = { stream: this.streamOf(projectSuiteOf(test)), id: `${specPath || test.id}#${titles.join(' > ')}${repeat}`, title: this.titleOf(test, specPath), specPath };
230
+ this.identities.set(test, identity);
231
+ return identity;
232
+ }
233
+ send(stream, check) {
234
+ try {
235
+ stream.reporter.check(check);
236
+ if (check.status !== 'running')
237
+ this.recorded += 1;
238
+ }
239
+ catch (error) {
240
+ this.warn(`a result could not be recorded (${check.id}): ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`);
241
+ }
242
+ }
243
+ /** Reports the test as `running`, so the report shows it while it runs. */
244
+ onTestBegin(test, _result) {
245
+ if (!this.store)
246
+ return;
247
+ try {
248
+ const { stream, id, title, specPath } = this.identityOf(test);
249
+ if (!stream)
250
+ return;
251
+ this.running.add(test);
252
+ this.send(stream, { id, title, status: 'running', ...(specPath ? { specPath } : {}), labels: { ...stream.labels } });
253
+ }
254
+ catch (error) {
255
+ this.warn(`a result could not be recorded: ${error instanceof Error ? error.message : String(error)}`);
256
+ }
257
+ }
258
+ /** On a test's last try, collects its evidence (reading its attached files) and reports it. A try Playwright will repeat leaves the test `running`. */
259
+ onTestEnd(test, result) {
260
+ if (!this.store)
261
+ return;
262
+ try {
263
+ if (willRetry(test, result))
264
+ return;
265
+ this.running.delete(test);
266
+ const { stream, id, title, specPath } = this.identityOf(test);
267
+ if (!stream)
268
+ return;
269
+ const options = this.options;
270
+ const results = test.results?.length ? test.results : [result];
271
+ const work = checkOf(test, results, {
272
+ id, title, specPath, labels: stream.labels, root: this.root,
273
+ attachments: { mode: options.attachments ?? 'all', maxBytes: options.maxAttachmentBytes ?? 50 * MiB },
274
+ maxSteps: options.maxSteps ?? 500
275
+ }).then(check => this.send(stream, check), error => {
276
+ this.warn(`evidence of ${id} could not be collected: ${error instanceof Error ? error.message : String(error)}`);
277
+ this.send(stream, { id, title, status: result.status === 'skipped' ? 'not-run' : result.status === test.expectedStatus ? 'passed' : 'failed', ...(specPath ? { specPath } : {}), labels: { ...stream.labels } });
278
+ });
279
+ this.pending.add(work);
280
+ void work.finally(() => this.pending.delete(work));
281
+ }
282
+ catch (error) {
283
+ this.warn(`a result could not be recorded: ${error instanceof Error ? error.message : String(error)}`);
284
+ }
285
+ }
286
+ /** Waits for every result, marks tests the run did not finish as `not-run`, sends the rest, and prints one summary line. */
287
+ async onEnd() {
288
+ if (!this.store)
289
+ return;
290
+ try {
291
+ await Promise.allSettled([...this.pending]);
292
+ for (const test of this.running) {
293
+ const { stream, id, title, specPath } = this.identityOf(test);
294
+ if (stream)
295
+ this.send(stream, { id, title, status: 'not-run', detail: 'The run ended before this test finished', ...(specPath ? { specPath } : {}), labels: { ...stream.labels } });
296
+ }
297
+ this.running.clear();
298
+ let layers = 0, lost = 0;
299
+ for (const stream of this.streams.values()) {
300
+ if (!stream)
301
+ continue;
302
+ const result = await stream.reporter.close();
303
+ layers += result.layers;
304
+ lost += result.lost;
305
+ }
306
+ this.say(`Atlas: ${this.recorded} checks in ${layers} layer${layers === 1 ? '' : 's'} to ${this.label}${lost ? `; ${lost} could not be sent` : ''}`);
307
+ }
308
+ catch (error) {
309
+ this.warn(`could not finish publishing: ${error instanceof Error ? error.message : String(error)}`);
310
+ }
311
+ }
312
+ }
@@ -11,7 +11,8 @@
11
11
  */
12
12
  import { eachLimited } from './completion-order.js';
13
13
  import { catalogEntries, catalogLayers } from './layer-catalog.js';
14
- import { combineEvidence, distinctHeaders, emptyCounts, isDiagnosticKind, layerEvidence, specPath } from './layer-merge.js';
14
+ import { combineEvidence, distinctHeaders, emptyCounts, isDiagnosticKind, layerEvidence } from './layer-merge.js';
15
+ import { RecordedSpecs } from './recorded-specs.js';
15
16
  const BACKEND_ID = /^[a-z0-9-]+$/;
16
17
  /**
17
18
  * Counts results by status. Diagnostic results (parts of kind `performance`, which only qualify a check) are left out
@@ -98,19 +99,11 @@ export async function combineBackends(backends, options = {}) {
98
99
  const kept = include ? loaded.filter(item => include(item.evidence.layer)) : loaded;
99
100
  const layers = kept.map(item => item.evidence);
100
101
  // The specs a report is laid over come from the layers that are in it, not from ones left out.
101
- const recorded = new Map();
102
- if (!options.outline) {
103
- for (const { evidence, specs } of kept) {
104
- for (const spec of specs) {
105
- const key = specPath(spec.path);
106
- const prior = recorded.get(key);
107
- const at = evidence.layer.recordedAtMs;
108
- if (!prior || at > prior.at || (at === prior.at && (spec.fingerprint ?? '') > (prior.entry.fingerprint ?? '')))
109
- recorded.set(key, { entry: { ...spec, path: key }, at });
110
- }
111
- }
112
- }
113
- const outline = options.outline ?? { specs: [...recorded.values()].map(item => item.entry).sort((a, b) => (a.path < b.path ? -1 : 1)), sha: null, changedFiles: [] };
102
+ const recorded = new RecordedSpecs();
103
+ if (!options.outline)
104
+ for (const { evidence, specs } of kept)
105
+ recorded.add(specs, evidence.layer.recordedAtMs);
106
+ const outline = options.outline ?? { specs: recorded.entries(), sha: null, changedFiles: [] };
114
107
  const combined = combineEvidence(outline, layers);
115
108
  const headers = distinctHeaders(layers.map(evidence => evidence.layer)).sort((a, b) => b.recordedAtMs - a.recordedAtMs || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
116
109
  return { combined, layers: headers, backends: info.sort((a, b) => (a.id < b.id ? -1 : 1)), notices: [...new Set(notices)].sort(), summary: summarize(combined) };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @module Specs the layers recorded
3
+ *
4
+ * When no spec set was published, a report is laid over the specs its layers say they ran against (each layer's
5
+ * `specs[]`). `RecordedSpecs` keeps one entry per spec path: the one from the newest layer, and at the same time the
6
+ * one with the larger fingerprint, so the outline it gives does not depend on the order layers are read in. The CLI's
7
+ * composition and the server's live report use it alike, so the two cannot disagree about which specs a report has.
8
+ *
9
+ * @see docs/report-contract.md#how-layers-compose
10
+ */
11
+ import type { SpecEntry } from './explorer-types.js';
12
+ /**
13
+ * The specs layers recorded, newest first per path. Add every included layer's `specs[]` with its `recordedAtMs`;
14
+ * `entries()` is the outline, sorted by path.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * const recorded = new RecordedSpecs();
19
+ * for (const layer of layers) recorded.add(layer.specs, layer.recordedAtMs);
20
+ * const outline = { specs: recorded.entries(), sha: null, changedFiles: [] };
21
+ * ```
22
+ */
23
+ export declare class RecordedSpecs {
24
+ #private;
25
+ /** Takes in the specs one layer recorded at `at` (epoch ms). A spec whose path is empty is ignored. */
26
+ add(specs: readonly SpecEntry[], at: number): void;
27
+ /** The recorded spec at a path (as `specPath` normalises it), or undefined. */
28
+ get(path: string): SpecEntry | undefined;
29
+ /** How many distinct specs were recorded. */
30
+ get size(): number;
31
+ /** Every recorded spec, sorted by path. */
32
+ entries(): readonly SpecEntry[];
33
+ }
@@ -0,0 +1,42 @@
1
+ import { specPath } from './layer-merge.js';
2
+ /**
3
+ * The specs layers recorded, newest first per path. Add every included layer's `specs[]` with its `recordedAtMs`;
4
+ * `entries()` is the outline, sorted by path.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * const recorded = new RecordedSpecs();
9
+ * for (const layer of layers) recorded.add(layer.specs, layer.recordedAtMs);
10
+ * const outline = { specs: recorded.entries(), sha: null, changedFiles: [] };
11
+ * ```
12
+ */
13
+ export class RecordedSpecs {
14
+ #byPath = new Map();
15
+ #sorted = null;
16
+ /** Takes in the specs one layer recorded at `at` (epoch ms). A spec whose path is empty is ignored. */
17
+ add(specs, at) {
18
+ for (const spec of specs) {
19
+ if (!spec.path)
20
+ continue;
21
+ const key = specPath(spec.path);
22
+ const prior = this.#byPath.get(key);
23
+ if (!prior || at > prior.at || (at === prior.at && (spec.fingerprint ?? '') > (prior.entry.fingerprint ?? ''))) {
24
+ this.#byPath.set(key, { entry: { ...spec, path: key }, at });
25
+ this.#sorted = null;
26
+ }
27
+ }
28
+ }
29
+ /** The recorded spec at a path (as `specPath` normalises it), or undefined. */
30
+ get(path) {
31
+ return this.#byPath.get(specPath(path))?.entry;
32
+ }
33
+ /** How many distinct specs were recorded. */
34
+ get size() {
35
+ return this.#byPath.size;
36
+ }
37
+ /** Every recorded spec, sorted by path. */
38
+ entries() {
39
+ this.#sorted ??= [...this.#byPath.values()].map(item => item.entry).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
40
+ return this.#sorted;
41
+ }
42
+ }
@@ -14,6 +14,7 @@
14
14
  import type { BackendInfo, LayerEvidence, LayerHeader } from './evidence-types.js';
15
15
  import type { BackendInput } from './compose.js';
16
16
  import type { ReportLayer } from './layer-types.js';
17
+ import { TestHistory } from './test-catalog.js';
17
18
  /** What a viewer needs to show the report, and nothing that is only history. */
18
19
  export type EffectiveEvidence = {
19
20
  /** A hash of the report itself: the same on every replica, and different exactly when the report is. Use it as an ETag. */
@@ -87,6 +88,11 @@ export declare class ReportIndex {
87
88
  readonly text: string;
88
89
  readonly version: string;
89
90
  } | null;
91
+ /**
92
+ * Every recorded result of every test and every run, as layers were folded in: what the test catalog, a test's history
93
+ * and the run list are built from. Read-only for callers; `history.rev` changes whenever a layer is taken in.
94
+ */
95
+ get history(): TestHistory;
90
96
  /** For `/readyz` and logs. */
91
97
  stats(): {
92
98
  backends: number;
@@ -17,6 +17,7 @@ import { LAYER_INDEX, layerSummary, readLayer } from './layer-store.js';
17
17
  import { layerEvidence, layerResultKeys, replaces } from './layer-merge.js';
18
18
  import { readUtf8 } from './object-io.js';
19
19
  import { isPaged } from './object-store.js';
20
+ import { TestHistory } from './test-catalog.js';
20
21
  const RETRY_FAILED_MS = 5 * 60_000;
21
22
  /** Versions a viewer may still be holding, remembered so it can be sent only what changed. */
22
23
  const REMEMBERED_VERSIONS = 256;
@@ -34,6 +35,8 @@ export class ReportIndex {
34
35
  #concurrency;
35
36
  #now;
36
37
  #winners = new Map();
38
+ /** Every result of every test (bounded per test) and every run, for the test catalog. It never decides a winner. */
39
+ #history = new TestHistory();
37
40
  #held = new Map();
38
41
  /** The smallest backend id a layer was found in, so attribution does not depend on read order. */
39
42
  #attribution = new Map();
@@ -195,7 +198,8 @@ export class ReportIndex {
195
198
  }
196
199
  if (!id || !layer)
197
200
  throw new Error('an indexed layer is missing');
198
- this.#fold(layerEvidence(layerSummary(layer), backend.id), backend.id);
201
+ const summary = layerSummary(layer);
202
+ this.#fold(layerEvidence(summary, backend.id), backend.id, summary.specs);
199
203
  this.#folded.add(mark);
200
204
  this.#failed.delete(mark);
201
205
  this.#refreshedAt ||= this.#now();
@@ -208,7 +212,7 @@ export class ReportIndex {
208
212
  const failure = this.#failed.get(mark);
209
213
  return failure !== undefined && this.#now() - failure.at < RETRY_FAILED_MS;
210
214
  }
211
- #fold(evidence, backend) {
215
+ #fold(evidence, backend, specs = []) {
212
216
  const id = evidence.id;
213
217
  const known = this.#attribution.get(id);
214
218
  if (known !== undefined) {
@@ -227,6 +231,7 @@ export class ReportIndex {
227
231
  if (!this.#include(evidence.layer))
228
232
  return;
229
233
  this.#added.push({ ...evidence.layer, backend });
234
+ this.#history.add(evidence.layer, evidence.rows, specs, backend);
230
235
  const wins = new Set();
231
236
  const keyOf = layerResultKeys(evidence.layer);
232
237
  for (const row of evidence.rows) {
@@ -330,6 +335,13 @@ export class ReportIndex {
330
335
  text: `{"version":${JSON.stringify(value.version)},"base":${JSON.stringify(since)},"delta":true,"layers":[${changed.map(item => (item.json ??= JSON.stringify(this.#entryOf(item)))).join(',')}],"removed":${JSON.stringify(gone)},"backends":${JSON.stringify(value.backends)},"notices":${JSON.stringify(value.notices)},"totalLayers":${value.totalLayers},"active":[]}`
331
336
  };
332
337
  }
338
+ /**
339
+ * Every recorded result of every test and every run, as layers were folded in: what the test catalog, a test's history
340
+ * and the run list are built from. Read-only for callers; `history.rev` changes whenever a layer is taken in.
341
+ */
342
+ get history() {
343
+ return this.#history;
344
+ }
333
345
  /** For `/readyz` and logs. */
334
346
  stats() {
335
347
  return { backends: this.#backends.length, layers: this.#attribution.size, slots: this.#winners.size, holding: this.#held.size, failed: this.#failed.size, refreshedAt: this.#refreshedAt };