@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
@@ -25,6 +25,7 @@ import { reportStatus } from '../../reports/src/report-status.js';
25
25
  import { ReportIndex } from '../../reports/src/report-index.js';
26
26
  import { listRunSummaries, readRunFile } from '../../reports/src/run-bundle.js';
27
27
  import { latestSpecSet, readSpecBody } from '../../reports/src/spec-set.js';
28
+ import { catalogMarkdown, catalogModel, historyMarkdown, parseTestQuery, runDetail, runList, runMarkdown, runsMarkdown, testCatalog, testHistory } from '../../reports/src/test-catalog.js';
28
29
  import { llmsTxt } from './llms.js';
29
30
  import { openApiDocument } from './openapi.js';
30
31
  import { asProblem, problemResponse } from './problem.js';
@@ -132,8 +133,9 @@ function createProjectApplication(options) {
132
133
  });
133
134
  const outline = async () => {
134
135
  const found = await specs();
136
+ // Without a published spec set, the report is laid over the specs its layers recorded, as the CLI does.
135
137
  if (!found)
136
- return { specs: [], sha: null, changedFiles: [], readSpec: async () => null };
138
+ return { specs: [...index.history.specs.entries()], sha: null, changedFiles: [], readSpec: async () => null };
137
139
  const entries = found.set.files.map(({ path: file, domain, kind, title, fingerprint }) => ({ path: file, domain, kind, title, fingerprint }));
138
140
  return { specs: entries, sha: found.set.sha, changedFiles: [], readSpec: async (file) => {
139
141
  const entry = found.set.files.find(item => item.path === file);
@@ -251,7 +253,7 @@ function createProjectApplication(options) {
251
253
  name: 'atlas', version: options.version ?? null, role: identity?.role ?? 'read', open: auth.open,
252
254
  write: options.write, ready: index.ready, viewers: events.count, time: now(),
253
255
  backends: snapshot.backends.map(({ id, label, available }) => ({ id, label, available, write: id === options.write })),
254
- ...(({ backends: _unused, ...rest }) => rest)(index.stats()), capabilities: { events: true, ingest: true, store: true, checks: true, status: true, effective: true, live: true }
256
+ ...(({ backends: _unused, ...rest }) => rest)(index.stats()), capabilities: { events: true, ingest: true, store: true, checks: true, status: true, effective: true, live: true, tests: true }
255
257
  });
256
258
  });
257
259
  app.get('/api/live', async (c) => c.json({ runs: await live.list() }, 200, { 'cache-control': 'no-store' }));
@@ -354,6 +356,81 @@ function createProjectApplication(options) {
354
356
  return body(c, delta.text, 'application/json', headers);
355
357
  return body(c, text, 'application/json', headers, `effective|${version}`);
356
358
  });
359
+ // Tests, their history and the runs that recorded them: the report as a person or an agent explores it.
360
+ let model = null;
361
+ const catalog = async () => {
362
+ const snapshot = index.snapshot();
363
+ const set = await specs();
364
+ const key = `${snapshot.version}|${index.history.rev}|${set?.set.id ?? ''}`;
365
+ if (model?.key !== key) {
366
+ const entries = set?.set.files.map(({ path: file, domain, kind, title }) => ({ path: file, domain, kind, title }));
367
+ model = { key, value: catalogModel(snapshot.layers, { history: index.history, version: createHash('sha256').update(key).digest('hex').slice(0, 20), now: now(), ...(entries ? { specs: entries } : {}) }) };
368
+ }
369
+ return model.value;
370
+ };
371
+ const wantsMarkdown = (c) => {
372
+ const format = c.req.query('format');
373
+ return format === 'markdown' || format === 'md' || (format === undefined && /\btext\/markdown\b/.test(c.req.header('accept') ?? ''));
374
+ };
375
+ const notReady = (c) => c.json({ error: 'Atlas is still reading its backends; try again in a moment.' }, 503, { 'retry-after': '1' });
376
+ // What was written for a version and a question is written once, however many viewers poll for it.
377
+ const rendered = new Map();
378
+ const answer = async (c, version, document, markdown, cacheKey) => {
379
+ const key = `${version}|${cacheKey}`;
380
+ const etag = `"${version}-${createHash('sha256').update(cacheKey).digest('hex').slice(0, 12)}"`;
381
+ const headers = { etag, 'cache-control': 'no-cache' };
382
+ if (c.req.header('if-none-match') === etag)
383
+ return new Response(null, { status: 304, headers });
384
+ const asMarkdown = wantsMarkdown(c);
385
+ let text = rendered.get(key);
386
+ if (text === undefined) {
387
+ text = asMarkdown ? markdown() : JSON.stringify(document());
388
+ if (rendered.size >= 32)
389
+ rendered.delete(rendered.keys().next().value);
390
+ rendered.set(key, text);
391
+ }
392
+ return body(c, text, asMarkdown ? 'text/markdown; charset=utf-8' : 'application/json', headers, key);
393
+ };
394
+ app.get('/api/reports/tests', async (c) => {
395
+ if (!index.ready)
396
+ return notReady(c);
397
+ const found = await catalog();
398
+ const query = parseTestQuery(new URL(c.req.url).searchParams);
399
+ const document = () => testCatalog(found, query);
400
+ return answer(c, found.version, document, () => catalogMarkdown(document()), `tests|${new URL(c.req.url).search}|${wantsMarkdown(c)}`);
401
+ });
402
+ app.get('/api/reports/tests/history', async (c) => {
403
+ if (!index.ready)
404
+ return notReady(c);
405
+ const key = c.req.query('key');
406
+ if (!key)
407
+ return problemResponse(400, 'Name the test with ?key=, a key from GET /api/reports/tests.');
408
+ const found = await catalog();
409
+ const document = testHistory(found, key);
410
+ if (!document)
411
+ return problemResponse(404, 'No test in the report has that key.');
412
+ return answer(c, found.version, () => document, () => historyMarkdown(document), `history|${key}|${wantsMarkdown(c)}`);
413
+ });
414
+ app.get('/api/reports/runs', async (c) => {
415
+ if (!index.ready)
416
+ return notReady(c);
417
+ const found = await catalog();
418
+ const limit = /^\d+$/.test(c.req.query('limit') ?? '') ? Number(c.req.query('limit')) : 100;
419
+ const document = () => runList(index.history, found.version, limit);
420
+ return answer(c, found.version, document, () => runsMarkdown(document()), `runs|${limit}|${wantsMarkdown(c)}`);
421
+ });
422
+ app.get('/api/reports/runs/detail', async (c) => {
423
+ if (!index.ready)
424
+ return notReady(c);
425
+ const key = c.req.query('key');
426
+ if (!key)
427
+ return problemResponse(400, 'Name the run with ?key=, a key from GET /api/reports/runs.');
428
+ const found = await catalog();
429
+ const document = runDetail(index.history, found.version, key);
430
+ if (!document)
431
+ return problemResponse(404, 'No run has that key.');
432
+ return answer(c, found.version, () => document, () => runMarkdown(document), `run|${key}|${wantsMarkdown(c)}`);
433
+ });
357
434
  app.route('/api/reports', layers);
358
435
  // Upload: the store protocol, and a simple JSON way in for anything that can make a request.
359
436
  app.route('/api/store', storeApi({
@@ -410,7 +487,7 @@ function createProjectApplication(options) {
410
487
  const snapshot = index.snapshot();
411
488
  const found = await outline();
412
489
  // The gate is a function of the report and the specs, so it is worked out once for each pair, however often CI asks.
413
- const asked = `${snapshot.version}|${(await specs())?.set.id ?? ''}|${[...branches].sort().join(',')}|${ignoreModified}|${required.join(',')}|${requireAllSpecs}`;
490
+ const asked = `${snapshot.version}|${(await specs())?.set.id ?? `recorded:${index.history.specs.size}`}|${[...branches].sort().join(',')}|${ignoreModified}|${required.join(',')}|${requireAllSpecs}`;
414
491
  if (!verdicts.has(asked)) {
415
492
  if (verdicts.size >= 16)
416
493
  verdicts.delete(verdicts.keys().next().value);
@@ -426,7 +503,8 @@ function createProjectApplication(options) {
426
503
  if (!filtered.has(key)) {
427
504
  if (filtered.size >= 8)
428
505
  filtered.delete(filtered.keys().next().value);
429
- filtered.set(key, combineBackends(backends, { outline: found, include: layer => (options.include?.(layer) ?? true) && (!branches.length || (layer.branch !== null && branches.includes(layer.branch))) && (!ignoreModified || layer.freshness === undefined) }));
506
+ const specSet = await specs();
507
+ filtered.set(key, combineBackends(backends, { ...(specSet ? { outline: found } : {}), include: layer => (options.include?.(layer) ?? true) && (!branches.length || (layer.branch !== null && branches.includes(layer.branch))) && (!ignoreModified || layer.freshness === undefined) }));
430
508
  }
431
509
  report = await filtered.get(key);
432
510
  }
@@ -60,6 +60,20 @@ bunx atlas report status --format json --branch main --ignore-modified # exit
60
60
  curl ${context.open ? '' : '-H "authorization: Bearer $ATLAS_TOKEN" '}"${url}/api/status?project=$ATLAS_PROJECT" # the same verdict; header X-Atlas-Ok: true|false
61
61
  \`\`\`
62
62
 
63
+ ## Read the tests, their history and the runs
64
+
65
+ Every endpoint below answers JSON, or Markdown with \`format=markdown\`. Prefix it with \`/api/projects/<slug>\` (or add \`?project=<slug>\`) for a project; \`GET ${url}/api/projects\` lists them.
66
+
67
+ \`\`\`sh
68
+ curl "${url}/api/projects/$ATLAS_PROJECT/reports/tests?format=markdown&limit=50" # summary, environments, domains, failing, flaky, slowest, then the tests
69
+ curl "${url}/api/projects/$ATLAS_PROJECT/reports/tests?status=failed,flaky&environment=desktop-web" # filter: q, status, environment, producer, domain, spec, tag, run; sort, offset, limit
70
+ curl "${url}/api/projects/$ATLAS_PROJECT/reports/tests/history?key=<url-encoded test key>" # one test: every recorded result, durations, branches, CI runs
71
+ curl "${url}/api/projects/$ATLAS_PROJECT/reports/runs?format=markdown" # CI runs, newest first, with their results by status
72
+ curl "${url}/api/projects/$ATLAS_PROJECT/reports/runs/detail?key=<url-encoded run key>" # every test one run recorded
73
+ \`\`\`
74
+
75
+ A test's \`key\` is \`JSON.stringify([specPath, id])\`; its \`outcomes\` are its current results per environment and \`stats\` its pass rate, flips, flakiness and durations. The evidence of one result (attachments, steps, retries) is at \`/api/reports/evidence/detail/<backend>/<layer>?check=<id>\`, its files at \`/api/reports/layers/<layer>/blobs/<bundleKey>\`.
76
+
63
77
  ## Rules worth knowing
64
78
 
65
79
  - **Layered, never partitioned.** A check is identified by its \`id\` (keep it stable across runs) plus producer, kind, spec, environment and parameters. A layer that re-ran one check leaves every other check as the last layer that ran it.
@@ -1,5 +1,5 @@
1
1
  /** The version of the API described, `major.minor.patch`: the minor grows when an operation or field is added, the major never within `/api`. */
2
- export declare const API_VERSION = "1.3.0";
2
+ export declare const API_VERSION = "1.4.0";
3
3
  /** Who may call an operation: `none` (anyone), or the least role that may (`read` < `publish` < `admin`). */
4
4
  export type Role = 'none' | 'read' | 'publish' | 'admin';
5
5
  type JsonSchema = Record<string, unknown>;
@@ -14,10 +14,10 @@
14
14
  */
15
15
  import { z } from 'zod/v4';
16
16
  import { ProjectsSchema } from '../../schema/src/project.js';
17
- import { EffectiveDeltaSchema, EffectiveEvidenceSchema, EvidencePageSchema, HealthSchema, LayerRowsSchema, CheckDetailSchema, OutlineSchema, ProblemSchema, ReadySchema, ServerInfoSchema, StoreListingSchema, StoreWriteResultSchema, ChecksResultSchema, HelloEventSchema, ReportEventSchema, PingEventSchema } from '../../schema/src/api.js';
17
+ import { EffectiveDeltaSchema, EffectiveEvidenceSchema, EvidencePageSchema, HealthSchema, LayerRowsSchema, CheckDetailSchema, OutlineSchema, ProblemSchema, ReadySchema, ServerInfoSchema, StoreListingSchema, StoreWriteResultSchema, ChecksResultSchema, HelloEventSchema, ReportEventSchema, PingEventSchema, TestCatalogSchema, TestHistorySchema, RunListSchema, RunDetailSchema } from '../../schema/src/api.js';
18
18
  import { LiveRunSchema, LiveRunStartSchema, LiveRunUpdateSchema, ServerEditableSettingsSchema, ServerSettingsSnapshotSchema, AtlasChangeSchema, PublishChecksSchema, ReportSourcesSchema, ReportStatusSchema, RunsIndexSchema, SpecIndexSchema } from '../../schema/src/index.js';
19
19
  /** The version of the API described, `major.minor.patch`: the minor grows when an operation or field is added, the major never within `/api`. */
20
- export const API_VERSION = '1.3.0';
20
+ export const API_VERSION = '1.4.0';
21
21
  /** The JSON Schema of a Zod schema, as OpenAPI 3.1 embeds it. `output` for what the server sends, `input` for what it accepts. */
22
22
  function schemaOf(schema, io = 'output') {
23
23
  const { $schema: _dialect, ...json } = z.toJSONSchema(schema, { target: 'draft-2020-12', unrepresentable: 'any', io });
@@ -35,6 +35,7 @@ const COMPONENTS = {
35
35
  PublishChecks: [PublishChecksSchema.partial({ recordedAtMs: true }), 'input'], ChecksResult: [ChecksResultSchema, 'output'],
36
36
  StoreListing: [StoreListingSchema, 'output'], StoreWriteResult: [StoreWriteResultSchema, 'output'],
37
37
  Outline: [OutlineSchema, 'output'], EvidencePage: [EvidencePageSchema, 'output'], LayerRows: [LayerRowsSchema, 'output'], CheckDetail: [CheckDetailSchema, 'output'],
38
+ TestCatalog: [TestCatalogSchema, 'output'], TestHistory: [TestHistorySchema, 'output'], RunList: [RunListSchema, 'output'], RunDetail: [RunDetailSchema, 'output'],
38
39
  SpecIndex: [SpecIndexSchema, 'output'], ReportSources: [ReportSourcesSchema, 'output'], RunsIndex: [RunsIndexSchema, 'output'],
39
40
  RunEvent: [LiveRunSchema, 'output'], HelloEvent: [HelloEventSchema, 'output'], ReportEvent: [ReportEventSchema, 'output'], AtlasChangeEvent: [AtlasChangeSchema, 'output'], PingEvent: [PingEventSchema, 'output']
40
41
  };
@@ -252,10 +253,66 @@ const BASE_OPERATIONS = [
252
253
  parameters: [KEY],
253
254
  responses: { '204': { description: 'Removed (or it was not there).' }, '401': UNAUTHORIZED, '403': problem('Not a scratch object, or the token may not.') }
254
255
  },
256
+ {
257
+ route: 'GET /api/reports/tests', path: '/api/reports/tests', operationId: 'getTests', tag: 'Report', role: 'read',
258
+ summary: 'Every test, with insights, facets and history statistics',
259
+ description: [
260
+ 'The report as a list of tests, for exploring it or for an agent to read in one request. A test is a check identity (its spec path and id) across producers and environments; `outcomes` holds its current result on each environment, and `stats` how it went over its recorded history (pass rate, flips, flaky, durations avg/p50/p95, runs, branches).',
261
+ '',
262
+ '`summary` and `facets` cover every test (counts by status, by environment, by producer and by domain, the slowest, flakiest and failing tests); `tests` is the page the query matched. Without a published spec set, specs come from the `specs[]` the layers recorded.',
263
+ '',
264
+ 'Send `format=markdown` (or `Accept: text/markdown`) for the same as a Markdown report. The current status of a test is always the report\'s winner; history never changes it.'
265
+ ].join('\n'),
266
+ parameters: [
267
+ { name: 'q', in: 'query', description: 'Words that must all appear in the title, id, spec, domain, tags, labels, environments or producers. Case does not matter.', schema: str, example: 'checkout card' },
268
+ { name: 'status', in: 'query', description: 'Current status, or `flaky`. Repeatable or comma-separated.', schema: str, example: 'failed,flaky' },
269
+ { name: 'environment', in: 'query', description: 'Only tests with a result on this environment. Repeatable or comma-separated.', schema: str, example: 'desktop-web' },
270
+ { name: 'producer', in: 'query', description: 'Only tests from this producer. Repeatable or comma-separated.', schema: str },
271
+ { name: 'domain', in: 'query', description: 'Only tests whose spec is in this domain (`(no spec)` for none). Repeatable or comma-separated.', schema: str },
272
+ { name: 'spec', in: 'query', description: 'Only tests of this spec path. Repeatable or comma-separated.', schema: str, example: 'specs/checkout/pay.feature' },
273
+ { name: 'tag', in: 'query', description: 'Only tests with this tag. Repeatable or comma-separated.', schema: str },
274
+ { name: 'run', in: 'query', description: 'Only tests recorded in this run (a `key` from `/api/reports/runs`).', schema: str },
275
+ { name: 'sort', in: 'query', description: 'The order: `spec` (default: domain, spec, title), `title`, `recent`, `slowest`, `failures`, `flaky` or `runs`.', schema: { type: 'string', enum: ['spec', 'title', 'recent', 'slowest', 'failures', 'flaky', 'runs'] } },
276
+ { name: 'offset', in: 'query', description: 'Skip this many matching tests.', schema: { type: 'integer', minimum: 0 } },
277
+ { name: 'limit', in: 'query', description: 'At most this many tests (default 500, at most 20000).', schema: { type: 'integer', minimum: 0, maximum: 20000 } },
278
+ { name: 'format', in: 'query', description: '`markdown` for a Markdown report instead of JSON.', schema: { type: 'string', enum: ['json', 'markdown'] } }
279
+ ],
280
+ responses: { '200': { ...json('The catalog.', 'TestCatalog'), headers: { ETag: { description: 'Changes with the report, its history and the query.', schema: str } } }, '304': { description: 'Unchanged since the `If-None-Match` ETag.' }, '401': UNAUTHORIZED, '503': NOT_READY }
281
+ },
282
+ {
283
+ route: 'GET /api/reports/tests/history', path: '/api/reports/tests/history', operationId: 'getTestHistory', tag: 'Report', role: 'read',
284
+ summary: 'One test and every result recorded for it',
285
+ description: 'The test (as `/api/reports/tests` lists it) and up to 100 recorded results, newest first: status, duration, environment, producer, branch, commit, CI run, layer and the first 500 characters of the detail. Open a result\'s evidence (attachments, steps, retries) with `/api/reports/evidence/detail/{backend}/{id}?check=<id>`. Send `format=markdown` for Markdown.',
286
+ parameters: [
287
+ { name: 'key', in: 'query', required: true, description: 'The test\'s `key` from `/api/reports/tests`: `JSON.stringify([specPath, id])`, URL-encoded.', schema: str, example: '["specs/checkout/pay.feature","Pay with a card"]' },
288
+ { name: 'format', in: 'query', description: '`markdown` for Markdown instead of JSON.', schema: { type: 'string', enum: ['json', 'markdown'] } }
289
+ ],
290
+ responses: { '200': json('The test and its history.', 'TestHistory'), '304': { description: 'Unchanged since the `If-None-Match` ETag.' }, '400': problem('No `key`.'), '401': UNAUTHORIZED, '404': problem('No test in the report has that key.'), '503': NOT_READY }
291
+ },
292
+ {
293
+ route: 'GET /api/reports/runs', path: '/api/reports/runs', operationId: 'getReportRuns', tag: 'Report', role: 'read',
294
+ summary: 'The runs that recorded the report',
295
+ description: 'Every run, newest first: layers grouped by their CI run URL (else branch and commit), with their environments, producers, how many tests they recorded, their results by status, the time they spanned and up to twenty failures. Send `format=markdown` for Markdown.',
296
+ parameters: [
297
+ { name: 'limit', in: 'query', description: 'At most this many runs (default 100).', schema: { type: 'integer', minimum: 0 } },
298
+ { name: 'format', in: 'query', description: '`markdown` for Markdown instead of JSON.', schema: { type: 'string', enum: ['json', 'markdown'] } }
299
+ ],
300
+ responses: { '200': json('The runs.', 'RunList'), '304': { description: 'Unchanged since the `If-None-Match` ETag.' }, '401': UNAUTHORIZED, '503': NOT_READY }
301
+ },
302
+ {
303
+ route: 'GET /api/reports/runs/detail', path: '/api/reports/runs/detail', operationId: 'getReportRun', tag: 'Report', role: 'read',
304
+ summary: 'Every test one run recorded',
305
+ description: 'One run and its last result for every test, environment and producer it recorded, failures first. Send `format=markdown` for Markdown.',
306
+ parameters: [
307
+ { name: 'key', in: 'query', required: true, description: 'The run\'s `key` from `/api/reports/runs`, URL-encoded.', schema: str },
308
+ { name: 'format', in: 'query', description: '`markdown` for Markdown instead of JSON.', schema: { type: 'string', enum: ['json', 'markdown'] } }
309
+ ],
310
+ responses: { '200': json('The run and its results.', 'RunDetail'), '304': { description: 'Unchanged since the `If-None-Match` ETag.' }, '400': problem('No `key`.'), '401': UNAUTHORIZED, '404': problem('No run has that key.'), '503': NOT_READY }
311
+ },
255
312
  {
256
313
  route: 'GET /api/reports/outline', path: '/api/reports/outline', operationId: 'getOutline', tag: 'Report', role: 'read',
257
314
  summary: 'The specs a report is laid over',
258
- description: 'The specs of the newest committed spec set (of `main`, else the newest), so the report needs no checkout. A spec here with no result shows as having none.',
315
+ description: 'The specs of the newest committed spec set (of `main`, else the newest), so the report needs no checkout. Without a spec set, the specs the layers recorded (`specs[]`), newest per path. A spec here with no result shows as having none.',
259
316
  responses: { '200': json('The outline.', 'Outline'), '401': UNAUTHORIZED }
260
317
  },
261
318
  {
package/docs/agents.md CHANGED
@@ -111,6 +111,7 @@ The complete public export map is generated from the package manifest (wildcards
111
111
  @crvouga/atlas/codecept
112
112
  @crvouga/atlas/jest
113
113
  @crvouga/atlas/node-test
114
+ @crvouga/atlas/playwright-reporter
114
115
  ```
115
116
  <!-- /generated:public-exports -->
116
117
 
@@ -221,6 +221,7 @@ bunx atlas report publish --producer e2e --junit out.xml --attach ci.log --attac
221
221
  Files are stored in the layer by content, shown by what they are (pictures, videos, page snapshots, text) and
222
222
  offered for download otherwise. [Evidence](extending.md) is the full model, and why a new kind of evidence never
223
223
  breaks anything.
224
+ [Send rich evidence](rich-evidence.md) lists what to send and what the viewer does with it, and covers the Playwright Test reporter.
224
225
 
225
226
  ### Statecharts
226
227
 
@@ -450,11 +450,143 @@ Scoped to one project. The same project may be selected on the unprefixed API us
450
450
 
451
451
  Responses: `204` Removed (or it was not there).; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `403` Not a scratch object, or the token may not.
452
452
 
453
+ ### Every test, with insights, facets and history statistics
454
+
455
+ `GET /api/reports/tests` · role: read
456
+
457
+ The report as a list of tests, for exploring it or for an agent to read in one request. A test is a check identity (its spec path and id) across producers and environments; `outcomes` holds its current result on each environment, and `stats` how it went over its recorded history (pass rate, flips, flaky, durations avg/p50/p95, runs, branches).
458
+
459
+ `summary` and `facets` cover every test (counts by status, by environment, by producer and by domain, the slowest, flakiest and failing tests); `tests` is the page the query matched. Without a published spec set, specs come from the `specs[]` the layers recorded.
460
+
461
+ Send `format=markdown` (or `Accept: text/markdown`) for the same as a Markdown report. The current status of a test is always the report's winner; history never changes it.
462
+
463
+ - `q` (query): Words that must all appear in the title, id, spec, domain, tags, labels, environments or producers. Case does not matter.
464
+ - `status` (query): Current status, or `flaky`. Repeatable or comma-separated.
465
+ - `environment` (query): Only tests with a result on this environment. Repeatable or comma-separated.
466
+ - `producer` (query): Only tests from this producer. Repeatable or comma-separated.
467
+ - `domain` (query): Only tests whose spec is in this domain (`(no spec)` for none). Repeatable or comma-separated.
468
+ - `spec` (query): Only tests of this spec path. Repeatable or comma-separated.
469
+ - `tag` (query): Only tests with this tag. Repeatable or comma-separated.
470
+ - `run` (query): Only tests recorded in this run (a `key` from `/api/reports/runs`).
471
+ - `sort` (query): The order: `spec` (default: domain, spec, title), `title`, `recent`, `slowest`, `failures`, `flaky` or `runs`.
472
+ - `offset` (query): Skip this many matching tests.
473
+ - `limit` (query): At most this many tests (default 500, at most 20000).
474
+ - `format` (query): `markdown` for a Markdown report instead of JSON.
475
+ - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
476
+
477
+ Responses: `200` The catalog.; `304` Unchanged since the `If-None-Match` ETag.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
478
+
479
+ ### Every test, with insights, facets and history statistics
480
+
481
+ `GET /api/projects/{project}/reports/tests` · role: read
482
+
483
+ The report as a list of tests, for exploring it or for an agent to read in one request. A test is a check identity (its spec path and id) across producers and environments; `outcomes` holds its current result on each environment, and `stats` how it went over its recorded history (pass rate, flips, flaky, durations avg/p50/p95, runs, branches).
484
+
485
+ `summary` and `facets` cover every test (counts by status, by environment, by producer and by domain, the slowest, flakiest and failing tests); `tests` is the page the query matched. Without a published spec set, specs come from the `specs[]` the layers recorded.
486
+
487
+ Send `format=markdown` (or `Accept: text/markdown`) for the same as a Markdown report. The current status of a test is always the report's winner; history never changes it.
488
+
489
+ Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
490
+
491
+ - `project` (path, required): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
492
+ - `q` (query): Words that must all appear in the title, id, spec, domain, tags, labels, environments or producers. Case does not matter.
493
+ - `status` (query): Current status, or `flaky`. Repeatable or comma-separated.
494
+ - `environment` (query): Only tests with a result on this environment. Repeatable or comma-separated.
495
+ - `producer` (query): Only tests from this producer. Repeatable or comma-separated.
496
+ - `domain` (query): Only tests whose spec is in this domain (`(no spec)` for none). Repeatable or comma-separated.
497
+ - `spec` (query): Only tests of this spec path. Repeatable or comma-separated.
498
+ - `tag` (query): Only tests with this tag. Repeatable or comma-separated.
499
+ - `run` (query): Only tests recorded in this run (a `key` from `/api/reports/runs`).
500
+ - `sort` (query): The order: `spec` (default: domain, spec, title), `title`, `recent`, `slowest`, `failures`, `flaky` or `runs`.
501
+ - `offset` (query): Skip this many matching tests.
502
+ - `limit` (query): At most this many tests (default 500, at most 20000).
503
+ - `format` (query): `markdown` for a Markdown report instead of JSON.
504
+
505
+ Responses: `200` The catalog.; `304` Unchanged since the `If-None-Match` ETag.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
506
+
507
+ ### One test and every result recorded for it
508
+
509
+ `GET /api/reports/tests/history` · role: read
510
+
511
+ The test (as `/api/reports/tests` lists it) and up to 100 recorded results, newest first: status, duration, environment, producer, branch, commit, CI run, layer and the first 500 characters of the detail. Open a result's evidence (attachments, steps, retries) with `/api/reports/evidence/detail/{backend}/{id}?check=<id>`. Send `format=markdown` for Markdown.
512
+
513
+ - `key` (query, required): The test's `key` from `/api/reports/tests`: `JSON.stringify([specPath, id])`, URL-encoded.
514
+ - `format` (query): `markdown` for Markdown instead of JSON.
515
+ - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
516
+
517
+ Responses: `200` The test and its history.; `304` Unchanged since the `If-None-Match` ETag.; `400` No `key`.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `404` No test in the report has that key.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
518
+
519
+ ### One test and every result recorded for it
520
+
521
+ `GET /api/projects/{project}/reports/tests/history` · role: read
522
+
523
+ The test (as `/api/reports/tests` lists it) and up to 100 recorded results, newest first: status, duration, environment, producer, branch, commit, CI run, layer and the first 500 characters of the detail. Open a result's evidence (attachments, steps, retries) with `/api/reports/evidence/detail/{backend}/{id}?check=<id>`. Send `format=markdown` for Markdown.
524
+
525
+ Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
526
+
527
+ - `project` (path, required): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
528
+ - `key` (query, required): The test's `key` from `/api/reports/tests`: `JSON.stringify([specPath, id])`, URL-encoded.
529
+ - `format` (query): `markdown` for Markdown instead of JSON.
530
+
531
+ Responses: `200` The test and its history.; `304` Unchanged since the `If-None-Match` ETag.; `400` No `key`.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `404` No test in the report has that key.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
532
+
533
+ ### The runs that recorded the report
534
+
535
+ `GET /api/reports/runs` · role: read
536
+
537
+ Every run, newest first: layers grouped by their CI run URL (else branch and commit), with their environments, producers, how many tests they recorded, their results by status, the time they spanned and up to twenty failures. Send `format=markdown` for Markdown.
538
+
539
+ - `limit` (query): At most this many runs (default 100).
540
+ - `format` (query): `markdown` for Markdown instead of JSON.
541
+ - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
542
+
543
+ Responses: `200` The runs.; `304` Unchanged since the `If-None-Match` ETag.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
544
+
545
+ ### The runs that recorded the report
546
+
547
+ `GET /api/projects/{project}/reports/runs` · role: read
548
+
549
+ Every run, newest first: layers grouped by their CI run URL (else branch and commit), with their environments, producers, how many tests they recorded, their results by status, the time they spanned and up to twenty failures. Send `format=markdown` for Markdown.
550
+
551
+ Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
552
+
553
+ - `project` (path, required): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
554
+ - `limit` (query): At most this many runs (default 100).
555
+ - `format` (query): `markdown` for Markdown instead of JSON.
556
+
557
+ Responses: `200` The runs.; `304` Unchanged since the `If-None-Match` ETag.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
558
+
559
+ ### Every test one run recorded
560
+
561
+ `GET /api/reports/runs/detail` · role: read
562
+
563
+ One run and its last result for every test, environment and producer it recorded, failures first. Send `format=markdown` for Markdown.
564
+
565
+ - `key` (query, required): The run's `key` from `/api/reports/runs`, URL-encoded.
566
+ - `format` (query): `markdown` for Markdown instead of JSON.
567
+ - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
568
+
569
+ Responses: `200` The run and its results.; `304` Unchanged since the `If-None-Match` ETag.; `400` No `key`.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `404` No run has that key.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
570
+
571
+ ### Every test one run recorded
572
+
573
+ `GET /api/projects/{project}/reports/runs/detail` · role: read
574
+
575
+ One run and its last result for every test, environment and producer it recorded, failures first. Send `format=markdown` for Markdown.
576
+
577
+ Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
578
+
579
+ - `project` (path, required): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
580
+ - `key` (query, required): The run's `key` from `/api/reports/runs`, URL-encoded.
581
+ - `format` (query): `markdown` for Markdown instead of JSON.
582
+
583
+ Responses: `200` The run and its results.; `304` Unchanged since the `If-None-Match` ETag.; `400` No `key`.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `404` No run has that key.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
584
+
453
585
  ### The specs a report is laid over
454
586
 
455
587
  `GET /api/reports/outline` · role: read
456
588
 
457
- The specs of the newest committed spec set (of `main`, else the newest), so the report needs no checkout. A spec here with no result shows as having none.
589
+ The specs of the newest committed spec set (of `main`, else the newest), so the report needs no checkout. Without a spec set, the specs the layers recorded (`specs[]`), newest per path. A spec here with no result shows as having none.
458
590
 
459
591
  - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
460
592
 
@@ -464,7 +596,7 @@ Responses: `200` The outline.; `401` No token, or an unknown one. Send `Authoriz
464
596
 
465
597
  `GET /api/projects/{project}/reports/outline` · role: read
466
598
 
467
- The specs of the newest committed spec set (of `main`, else the newest), so the report needs no checkout. A spec here with no result shows as having none.
599
+ The specs of the newest committed spec set (of `main`, else the newest), so the report needs no checkout. Without a spec set, the specs the layers recorded (`specs[]`), newest per path. A spec here with no result shows as having none.
468
600
 
469
601
  Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
470
602