@crvouga/atlas 0.5.0 → 0.6.1

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 (148) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +51 -16
  3. package/dist/core/src/cli.d.ts +1 -1
  4. package/dist/core/src/cli.js +8 -3
  5. package/dist/core/src/config.d.ts +7 -0
  6. package/dist/core/src/config.js +48 -14
  7. package/dist/core/src/run/execute.d.ts +6 -0
  8. package/dist/core/src/run/execute.js +4 -2
  9. package/dist/reports/src/doctor.d.ts +1 -1
  10. package/dist/reports/src/doctor.js +3 -1
  11. package/dist/reports/src/http-store.d.ts +4 -0
  12. package/dist/reports/src/http-store.js +6 -3
  13. package/dist/reports/src/init.js +24 -16
  14. package/dist/reports/src/live-reporter.d.ts +34 -0
  15. package/dist/reports/src/live-reporter.js +122 -0
  16. package/dist/reports/src/package-managers.d.ts +35 -0
  17. package/dist/reports/src/package-managers.js +23 -0
  18. package/dist/reports/src/report-cli.d.ts +1 -1
  19. package/dist/reports/src/report-cli.js +3 -2
  20. package/dist/reports/src/store-url.js +1 -1
  21. package/dist/runners/src/codecept.d.ts +14 -0
  22. package/dist/runners/src/codecept.js +16 -0
  23. package/dist/runners/src/jest.d.ts +50 -0
  24. package/dist/runners/src/jest.js +52 -0
  25. package/dist/runners/src/mocha.d.ts +40 -0
  26. package/dist/runners/src/mocha.js +39 -0
  27. package/dist/runners/src/node-test.d.ts +28 -0
  28. package/dist/runners/src/node-test.js +44 -0
  29. package/dist/runners/src/publish.d.ts +30 -0
  30. package/dist/runners/src/publish.js +68 -0
  31. package/dist/schema/src/api.d.ts +1 -0
  32. package/dist/schema/src/api.js +1 -1
  33. package/dist/schema/src/cli-results.d.ts +233 -2
  34. package/dist/schema/src/cli-results.js +14 -0
  35. package/dist/schema/src/index.d.ts +2 -0
  36. package/dist/schema/src/index.js +2 -0
  37. package/dist/schema/src/live-run.d.ts +123 -0
  38. package/dist/schema/src/live-run.js +30 -0
  39. package/dist/schema/src/manifest.d.ts +2 -2
  40. package/dist/schema/src/project.d.ts +20 -0
  41. package/dist/schema/src/project.js +10 -0
  42. package/dist/schema/src/report-sources.d.ts +1 -0
  43. package/dist/schema/src/report-sources.js +2 -1
  44. package/dist/schema/src/runs-index.d.ts +1 -1
  45. package/dist/schema/src/server-config.d.ts +84 -0
  46. package/dist/schema/src/server-config.js +12 -0
  47. package/dist/server/src/app.d.ts +10 -0
  48. package/dist/server/src/app.js +148 -10
  49. package/dist/server/src/config.d.ts +1 -1
  50. package/dist/server/src/config.js +20 -3
  51. package/dist/server/src/dev.d.ts +3 -3
  52. package/dist/server/src/dev.js +22 -13
  53. package/dist/server/src/events.d.ts +3 -3
  54. package/dist/server/src/events.js +1 -1
  55. package/dist/server/src/index.d.ts +2 -0
  56. package/dist/server/src/index.js +2 -0
  57. package/dist/server/src/live-runs.d.ts +52 -0
  58. package/dist/server/src/live-runs.js +229 -0
  59. package/dist/server/src/llms.js +17 -8
  60. package/dist/server/src/main.d.ts +1 -1
  61. package/dist/server/src/main.js +7 -5
  62. package/dist/server/src/openapi.d.ts +2 -5
  63. package/dist/server/src/openapi.js +71 -7
  64. package/dist/server/src/projects.d.ts +3 -0
  65. package/dist/server/src/projects.js +154 -0
  66. package/dist/server/src/settings-page.d.ts +9 -0
  67. package/dist/server/src/settings-page.js +93 -0
  68. package/dist/server/src/settings.d.ts +23 -0
  69. package/dist/server/src/settings.js +72 -0
  70. package/dist/server/src/site.d.ts +8 -6
  71. package/dist/server/src/site.js +45 -10
  72. package/dist/server/src/store-api.js +43 -22
  73. package/docs/agents.md +166 -15
  74. package/docs/deploying.md +24 -20
  75. package/docs/dogfooding.md +7 -3
  76. package/docs/extending.md +3 -3
  77. package/docs/integrating.md +19 -11
  78. package/docs/quickstart.md +81 -0
  79. package/docs/reference/cli.md +9 -7
  80. package/docs/reference/core.md +6 -0
  81. package/docs/reference/http-api.md +498 -2
  82. package/docs/reference/reports.md +84 -1
  83. package/docs/reference/runners.md +169 -0
  84. package/docs/reference/schema.md +120 -0
  85. package/docs/reference/server.md +154 -14
  86. package/docs/report-backends.md +8 -8
  87. package/docs/report-contract.md +7 -7
  88. package/docs/reporting-issues.md +3 -3
  89. package/docs/runners.md +69 -0
  90. package/docs/server.md +155 -25
  91. package/docs/statecharts.md +22 -20
  92. package/llms-full.txt +1079 -126
  93. package/llms.txt +7 -4
  94. package/package.json +33 -9
  95. package/packages/core/README.md +3 -1
  96. package/packages/runners/README.md +6 -0
  97. package/packages/runners/compat/codecept.cjs +2 -0
  98. package/packages/runners/compat/jest.cjs +2 -0
  99. package/packages/runners/compat/mocha.cjs +2 -0
  100. package/packages/schema/schemas/atlas-change.schema.json +4 -0
  101. package/packages/schema/schemas/hello-event.schema.json +4 -0
  102. package/packages/schema/schemas/live-run-start.schema.json +42 -0
  103. package/packages/schema/schemas/live-run-update.schema.json +93 -0
  104. package/packages/schema/schemas/live-run.schema.json +225 -0
  105. package/packages/schema/schemas/project-slug.schema.json +8 -0
  106. package/packages/schema/schemas/project.schema.json +22 -0
  107. package/packages/schema/schemas/projects.schema.json +33 -0
  108. package/packages/schema/schemas/server-config.schema.json +14 -0
  109. package/packages/schema/schemas/server-editable-settings.schema.json +132 -0
  110. package/packages/schema/schemas/server-settings-snapshot.schema.json +285 -0
  111. package/packages/server/openapi.json +5101 -779
  112. package/packages/visualizer/README.md +13 -13
  113. package/packages/visualizer/bin/atlas-client.mjs +176 -0
  114. package/packages/visualizer/bin/atlas-visualizer.mjs +2 -175
  115. package/packages/visualizer/src/app/AppShell.tsx +26 -17
  116. package/packages/visualizer/src/app/report-sources.tsx +2 -1
  117. package/packages/visualizer/src/components/ActivityIndicator.module.css +4 -0
  118. package/packages/visualizer/src/components/ActivityIndicator.tsx +6 -0
  119. package/packages/visualizer/src/components/Header.module.css +101 -227
  120. package/packages/visualizer/src/components/Header.tsx +79 -41
  121. package/packages/visualizer/src/components/ProjectPicker.tsx +64 -0
  122. package/packages/visualizer/src/data/adapters/report-backend.test.ts +3 -2
  123. package/packages/visualizer/src/data/adapters/report-backend.ts +1 -1
  124. package/packages/visualizer/src/data/queries.ts +14 -7
  125. package/packages/visualizer/src/evidence/api.ts +2 -1
  126. package/packages/visualizer/src/evidence/check-detail.tsx +19 -4
  127. package/packages/visualizer/src/evidence/document-page.tsx +6 -2
  128. package/packages/visualizer/src/evidence/evidence-context.tsx +17 -12
  129. package/packages/visualizer/src/evidence/evidence.module.css +11 -1
  130. package/packages/visualizer/src/evidence/feature-page.tsx +7 -3
  131. package/packages/visualizer/src/evidence/model-page.tsx +6 -2
  132. package/packages/visualizer/src/evidence/project-home.tsx +45 -0
  133. package/packages/visualizer/src/evidence/recording-page.tsx +4 -1
  134. package/packages/visualizer/src/evidence/spec-index.tsx +2 -1
  135. package/packages/visualizer/src/evidence/test-filter.tsx +21 -0
  136. package/packages/visualizer/src/live/live-query.ts +21 -0
  137. package/packages/visualizer/src/live/live-runs.tsx +69 -0
  138. package/packages/visualizer/src/live/live-stream.ts +30 -9
  139. package/packages/visualizer/src/live/report-progress.ts +62 -0
  140. package/packages/visualizer/src/live/test-progress.tsx +34 -0
  141. package/packages/visualizer/src/runtime.ts +17 -1
  142. package/packages/visualizer/src/state/ui-store.ts +3 -2
  143. package/packages/visualizer/src/views/ChartPage.tsx +2 -0
  144. package/packages/visualizer/src/views/ChartTests.tsx +43 -0
  145. package/packages/visualizer/src/views/SidePanel.tsx +2 -0
  146. package/packages/visualizer/vite/atlas-files.test.ts +4 -4
  147. package/packages/visualizer/vite/atlas-files.ts +1 -5
  148. package/packages/visualizer/vite/atlas-server-proxy.ts +2 -1
@@ -19,6 +19,7 @@ import { InitPlanSchema } from '../../schema/src/cli-results.js';
19
19
  import { collectSpecFiles, specKindOf } from './directory-outline.js';
20
20
  import { httpStore } from './http-store.js';
21
21
  import { serverToken } from './store-url.js';
22
+ import { PACKAGE_MANAGERS } from './package-managers.js';
22
23
  const BEGIN = '<!-- atlas:begin -->';
23
24
  const END = '<!-- atlas:end -->';
24
25
  function defaultGit(args, cwd) {
@@ -50,9 +51,6 @@ function packageManagerOf(directory, pkg) {
50
51
  const declared = typeof pkg?.packageManager === 'string' ? pkg.packageManager.split('@')[0] : null;
51
52
  return declared === 'bun' || declared === 'pnpm' || declared === 'yarn' || declared === 'npm' ? declared : pkg ? 'npm' : null;
52
53
  }
53
- /** How the package manager runs a package's binary without installing it: `bunx`, `pnpm dlx`, `yarn dlx`, `npx`. */
54
- const EXEC = { bun: 'bunx', pnpm: 'pnpm dlx', yarn: 'yarn dlx', npm: 'npx' };
55
- const INSTALL_DEV = { bun: 'bun add -d', pnpm: 'pnpm add -D', yarn: 'yarn add -D', npm: 'npm install -D' };
56
54
  /** The spec files of a project by kind, and the folder to pass as `--specs`. */
57
55
  async function specsOf(directory) {
58
56
  const candidates = ['specs', 'spec', 'features', 'docs/specs', 'test/features', 'tests/features'];
@@ -88,6 +86,9 @@ function runnersOf(directory, pkg) {
88
86
  dep('cypress', 'cypress');
89
87
  dep('jest', 'jest');
90
88
  dep('mocha', 'mocha');
89
+ dep('codeceptjs', 'codeceptjs');
90
+ if (/\bnode\s+--test\b/.test(scripts))
91
+ found.push({ runner: 'node-test', evidence: 'package.json scripts: node --test' });
91
92
  if (!found.some(item => item.runner === 'vitest' || item.runner === 'jest' || item.runner === 'mocha') && /\bbun test\b/.test(scripts))
92
93
  found.push({ runner: 'bun-test', evidence: 'package.json scripts: bun test' });
93
94
  else if (!found.length && pkg && (existsSync(path.join(directory, 'bun.lock')) || existsSync(path.join(directory, 'bunfig.toml'))) && readdirSafe(directory).some(name => /\.(test|spec)\.[cm]?[tj]sx?$/.test(name) || name === 'tests' || name === 'test'))
@@ -115,7 +116,7 @@ function readdirSafe(directory) {
115
116
  /** The suite for a detected runner: how to run it so it writes a JUnit file (or the run manifest), and how to publish it. */
116
117
  function suiteOf(detected, context) {
117
118
  const specs = context.specs ? ` --specs ${context.specs}` : '';
118
- const publishCommand = (producer, input, environment) => `${context.exec} @crvouga/atlas report publish --producer ${producer} ${input}${specs}${environment ? ` --environment ${environment}` : ''}`;
119
+ const publishCommand = (producer, input, environment) => `${context.exec} atlas report publish --producer ${producer} ${input}${specs}${environment ? ` --environment ${environment}` : ''}`;
119
120
  const make = (id, output, run, environment, prerequisites = [], format = 'junit', stream = null) => ({
120
121
  id, runner: detected.runner, evidence: detected.evidence, run, output, format, environment,
121
122
  publish: publishCommand(id, format === 'manifest' ? `--manifest ${output} --upload-run` : `--${format} ${output}`, environment), stream, prerequisites
@@ -128,10 +129,12 @@ function suiteOf(detected, context) {
128
129
  case 'cypress': return make('cypress', out('cypress'), `${context.exec} cypress run --reporter junit --reporter-options mochaFile=${out('cypress')}`, 'desktop-web');
129
130
  case 'cucumber': return make('cucumber', out('cucumber'), `${context.exec} cucumber-js --format summary --format junit:${out('cucumber')}`, null);
130
131
  case 'mocha': return make('mocha', out('mocha'), `MOCHA_FILE=${out('mocha')} ${context.exec} mocha --reporter mocha-junit-reporter`, null, [`${context.install} mocha-junit-reporter`]);
132
+ case 'codeceptjs': return make('codeceptjs', out('codeceptjs'), `MOCHA_FILE=${out('codeceptjs')} ${context.exec} codeceptjs run --reporter mocha-junit-reporter`, 'desktop-web', [`${context.install} mocha-junit-reporter`]);
133
+ case 'node-test': return make('node-test', out('node-test'), `node --test --test-reporter=junit --test-reporter-destination=${out('node-test')}`, null);
131
134
  case 'bun-test': return make('bun-test', out('bun'), `bun test --reporter=junit --reporter-outfile=${out('bun')}`, null);
132
135
  case 'pytest': return make('pytest', out('pytest'), `pytest --junitxml=${out('pytest')}`, null);
133
136
  case 'go-test': return make('go-test', out('go'), `go test -v ./... 2>&1 | go-junit-report > ${out('go')}`, null, ['go install github.com/jstemmer/go-junit-report/v2@latest']);
134
- default: return make('statecharts', 'atlas-runs/latest/manifest.json', `${context.exec} @crvouga/atlas run --mode fast`, null, [], 'manifest');
137
+ default: return make('statecharts', 'atlas-runs/latest/manifest.json', `${context.exec} atlas run --mode fast`, null, [], 'manifest');
135
138
  }
136
139
  }
137
140
  /** A suite that does not set its own exit-code handling must still publish: a failure is evidence. */
@@ -139,7 +142,7 @@ function githubWorkflow(suites, context) {
139
142
  const setup = context.manager === 'bun'
140
143
  ? ' - uses: oven-sh/setup-bun@v2\n - run: bun install --frozen-lockfile\n'
141
144
  : ` - uses: actions/setup-node@v4\n with: { node-version: 22 }\n - run: ${context.manager === 'pnpm' ? 'corepack enable && pnpm install --frozen-lockfile' : context.manager === 'yarn' ? 'corepack enable && yarn install --immutable' : 'npm ci'}\n`;
142
- const exec = EXEC[context.manager ?? 'npm'];
145
+ const exec = PACKAGE_MANAGERS[context.manager ?? 'npm'].exec;
143
146
  const jobs = suites.map(suite => ` ${suite.id.replace(/[^a-z0-9]+/g, '-')}:
144
147
  runs-on: ubuntu-latest
145
148
  steps:
@@ -152,13 +155,15 @@ ${setup} # A failing suite must still publish: a failure is evidence. The j
152
155
  `).join('\n');
153
156
  return `# Sends every suite's results to the project's Atlas server and gates on the combined report.
154
157
  # Generated by \`atlas init\`; safe to edit. Needs the repository variable ATLAS_URL (https://atlas.example.com) and the
155
- # secrets ATLAS_PUBLISH_TOKEN (a \`publish\` token) and ATLAS_READ_TOKEN (a \`read\` token) of that server.
158
+ # variable ATLAS_PROJECT (a stable project slug), and secrets ATLAS_PUBLISH_TOKEN (a \`publish\` token) and
159
+ # ATLAS_READ_TOKEN (a \`read\` token) of that server.
156
160
  name: atlas
157
161
  on: [push, pull_request]
158
162
  permissions:
159
163
  contents: read
160
164
  env:
161
165
  ATLAS_STORE: \${{ vars.ATLAS_URL }}
166
+ ATLAS_PROJECT: \${{ vars.ATLAS_PROJECT }}
162
167
  ATLAS_TOKEN: \${{ secrets.ATLAS_PUBLISH_TOKEN }}
163
168
  jobs:
164
169
  ${jobs}
@@ -170,26 +175,27 @@ ${jobs}
170
175
  ATLAS_TOKEN: \${{ secrets.ATLAS_READ_TOKEN }}
171
176
  steps:
172
177
  - uses: actions/checkout@v4
173
- - uses: oven-sh/setup-bun@v2
178
+ ${setup.trimEnd()}
174
179
  # This branch laid over ${context.defaultBranch}: what this run did not re-run is inherited from ${context.defaultBranch}'s latest
175
180
  # results, and a laptop's work in progress or another branch's results cannot turn this red.
176
- - run: bunx @crvouga/atlas report status --branch "\${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" --branch ${context.defaultBranch} --ignore-modified${context.specs ? ' --require-all-specs' : ''}
181
+ - run: ${exec} atlas report status --branch "\${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" --branch ${context.defaultBranch} --ignore-modified${context.specs ? ' --require-all-specs' : ''}
177
182
  `;
178
183
  }
179
184
  /** The `AGENTS.md` section: what Atlas is in this project and the commands to use, replaced between its markers. */
180
- function agentsSection(plan) {
185
+ function agentsSection(plan, exec) {
181
186
  const url = plan.server.url ?? '<the project\'s Atlas server>';
182
187
  const lines = [
183
188
  BEGIN,
184
189
  '## Atlas: test evidence for this project',
185
190
  '',
186
191
  `This project sends the results of its test suites to an [Atlas](https://github.com/crvouga/atlas) server (${url}), which shows everyone one combined, live report. A check is identified by a stable id; the newest completed result of each check wins; unknown is never a pass. Set \`ATLAS_STORE=${url}\` and \`ATLAS_TOKEN\` (a \`publish\` token for sending, a \`read\` token for asking).`,
192
+ 'Set `ATLAS_PROJECT` to the same stable project slug locally and in CI; this selects the project on a shared node. Use the installed `@crvouga/atlas` and the project\'s lockfile. Integration guide: https://github.com/crvouga/atlas/blob/main/docs/agents.md (version-matched copy: `node_modules/@crvouga/atlas/docs/agents.md`).',
187
193
  '',
188
194
  '- **Machine-readable API:** `' + `${url}/api/openapi.json` + '` (OpenAPI 3.1). **Guide for agents:** `' + `${url}/llms.txt` + '`.',
189
195
  '- **After you change behaviour,** run the suite, then send its results (publish even when it fails: a failure is evidence).',
190
196
  ...plan.suites.flatMap(suite => ['', ` - **${suite.id}**: \`${suite.run}\``, ` then \`${suite.publish}\`` + (suite.stream ? ` (or stream: \`${suite.stream}\`)` : '')]),
191
197
  '',
192
- `- **Is it passing?** \`bunx @crvouga/atlas report status --branch ${plan.git.defaultBranch} --ignore-modified --format json\` (exit 0 passes, 1 does not, 2 means the command was wrong). The failing checks are in \`failing\`.`,
198
+ `- **Is it passing?** \`${exec} atlas report status --branch ${plan.git.defaultBranch} --ignore-modified --format json\` (exit 0 passes, 1 does not, 2 means the command was wrong). The failing checks are in \`failing\`.`,
193
199
  plan.specs.directory ? `- **Specs** live in \`${plan.specs.directory}/\` (${Object.entries(plan.specs.kinds).map(([kind, count]) => `${count} ${kind}`).join(', ')}). Results are laid over them by path.` : '- No spec files were found. Pass `--spec <path>` or `--specs <dir>` to `publish` to link checks to specs.',
194
200
  '- Keep each check\'s `id` stable across runs: that is what lets a new result replace an old one.',
195
201
  '- Evidence (screenshots, videos, traces, logs) rides on checks: https://github.com/crvouga/atlas/blob/main/docs/extending.md',
@@ -223,16 +229,17 @@ export async function planInit(options) {
223
229
  const env = options.env ?? {};
224
230
  const git = options.git ?? defaultGit;
225
231
  const pkg = readJson(path.join(directory, 'package.json'));
232
+ const dependencies = { ...pkg?.dependencies, ...pkg?.devDependencies };
226
233
  const manager = packageManagerOf(directory, pkg);
227
234
  const specs = await specsOf(directory);
228
235
  const configured = env.ATLAS_STORE?.split(/[\n,]+/).map(item => item.trim()).find(item => /^https?:\/\//i.test(item));
229
236
  const serverUrl = (options.server ?? configured ?? null)?.replace(/\/+$/, '') ?? null;
230
237
  const warnings = [];
231
- const exec = EXEC[manager ?? 'npm'];
232
- const context = { exec, install: INSTALL_DEV[manager ?? 'npm'], specs: specs.directory, server: serverUrl };
238
+ const { exec, installDev } = PACKAGE_MANAGERS[manager ?? 'npm'];
239
+ const context = { exec, install: installDev, specs: specs.directory, server: serverUrl };
233
240
  const suites = runnersOf(directory, pkg).map(found => suiteOf(found, context));
234
241
  if (!suites.length)
235
- warnings.push('No test runner was recognised (looked for vitest, jest, playwright, cypress, cucumber, mocha, bun test, pytest, go test and Atlas statecharts). Add one suite by hand: run it so it writes a JUnit XML or CTRF file, then `atlas report publish --producer <name> --junit <file>`.');
242
+ warnings.push('No test runner was recognised (looked for vitest, jest, playwright, cypress, cucumber, mocha, codeceptjs, node --test, bun test, pytest, go test and Atlas statecharts). Add one suite by hand: run it so it writes a JUnit XML or CTRF file, then `atlas report publish --producer <name> --junit <file>`.');
236
243
  if (!serverUrl)
237
244
  warnings.push('No server given. Pass --server https://… (or set ATLAS_STORE) so the plan can name it; until then the generated text says "the project\'s Atlas server".');
238
245
  if (!specs.directory)
@@ -247,7 +254,7 @@ export async function planInit(options) {
247
254
  warnings.push(`${ci} detected: no workflow is generated for it. In its jobs run each suite, then its publish command (with ATLAS_STORE and ATLAS_TOKEN set from secrets), then the gate command from AGENTS.md.`);
248
255
  const base = { suites, specs, server: { url: serverUrl, tokenEnv: 'ATLAS_TOKEN' }, git: { branch, defaultBranch } };
249
256
  const wanted = [
250
- { path: 'AGENTS.md', purpose: 'Tells every agent working in this repository how to send and read test evidence.', next: current => withSection(current, agentsSection(base)) }
257
+ { path: 'AGENTS.md', purpose: 'Tells every agent working in this repository how to send and read test evidence.', next: current => withSection(current, agentsSection(base, exec)) }
251
258
  ];
252
259
  if (ci === 'github-actions' && suites.length)
253
260
  wanted.push({ path: '.github/workflows/atlas.yml', purpose: 'Runs each suite in CI, sends its results to the server even when it fails, and gates on the combined report.', next: () => githubWorkflow(suites, { manager, defaultBranch, specs: specs.directory }) });
@@ -286,10 +293,11 @@ export async function planInit(options) {
286
293
  else if (options.verify)
287
294
  warnings.push('--verify needs a server: pass --server or set ATLAS_STORE.');
288
295
  const nextSteps = [
296
+ ...(dependencies['@crvouga/atlas'] ? [] : [`Install Atlas in this project before running the planned commands: ${context.install} @crvouga/atlas. Commit the manifest and lockfile so local runs and CI use the same version.`]),
289
297
  ...(serverUrl ? [] : ['Decide which server to send results to (run one with `atlas serve`), then run this again with --server <url>.']),
290
298
  ...(options.write ? [] : ['Review the plan, then run again with --write to create the files in `files`.']),
291
299
  ...suites.flatMap(suite => suite.prerequisites.map(command => `Install what ${suite.id} needs: ${command}`)),
292
- ...(serverUrl ? [`Set ATLAS_STORE=${serverUrl} and ATLAS_TOKEN (a publish token) in CI and on each machine that should send results.`, 'Run `atlas report doctor` to prove the server can be written to and read.'] : []),
300
+ ...(serverUrl ? [`Set ATLAS_STORE=${serverUrl}, ATLAS_PROJECT (the same stable project slug) and ATLAS_TOKEN (a publish token) in CI and on each machine that should send results.`, 'Run `atlas report doctor` to prove the server can be written to and read.'] : []),
293
301
  ...suites.map(suite => `Prove ${suite.id}: run \`${suite.run}\`, then \`${suite.publish}\`, then open ${serverUrl ?? 'the server'} and find its results.`)
294
302
  ];
295
303
  const ok = verification ? verification.reachable && verification.canPublish && verification.error === null : true;
@@ -0,0 +1,34 @@
1
+ import type { z } from 'zod/v4';
2
+ import type { LiveCheck, LiveRunStartSchema } from '../../schema/src/live-run.js';
3
+ /** Fixed identity of a live invocation; a UUID is generated when omitted. */
4
+ export type LiveIdentity = Omit<z.input<typeof LiveRunStartSchema>, 'id'> & {
5
+ id?: string;
6
+ };
7
+ /** Transport and batching configuration. Defaults: 250 ms batches, 5 s heartbeats and 10 s request timeouts. */
8
+ export type LiveReporterOptions = {
9
+ url: string;
10
+ project?: string;
11
+ token?: string;
12
+ flushMs?: number;
13
+ heartbeatMs?: number;
14
+ timeoutMs?: number;
15
+ onError?: (error: Error) => void;
16
+ };
17
+ /** Producer handle. check never throws transport errors into a test; finish flushes and rejects if publication failed. */
18
+ export type LiveReporter = {
19
+ readonly id: string;
20
+ readonly finishedAtMs?: number;
21
+ check(check: LiveCheck): void;
22
+ flush(): Promise<void>;
23
+ finish(status?: 'completed' | 'interrupted', reason?: string): Promise<void>;
24
+ };
25
+ /**
26
+ * Start publishing immediately, batch changing checks, and send independent heartbeats while the producer is alive.
27
+ * The same failed batch is retried with the same sequence and content; never silently replace a lost update.
28
+ * @example
29
+ * const run = createLiveReporter({ producer: 'unit', plannedChecks: 10 }, { url: 'http://localhost:5180' });
30
+ * run.check({ id: 'a', title: 'Adds a todo', status: 'running' });
31
+ * run.check({ id: 'a', title: 'Adds a todo', status: 'passed' });
32
+ * await run.finish();
33
+ */
34
+ export declare function createLiveReporter(identity: LiveIdentity, options: LiveReporterOptions): LiveReporter;
@@ -0,0 +1,122 @@
1
+ /** @module Reporting a live run to Atlas
2
+ * Streams bounded check deltas while tests execute. Heartbeats are independent of progress; finished results are
3
+ * published by the server as ordinary immutable layers. A final flush is awaited before a runner exits.
4
+ */
5
+ import { randomUUID } from 'node:crypto';
6
+ /**
7
+ * Start publishing immediately, batch changing checks, and send independent heartbeats while the producer is alive.
8
+ * The same failed batch is retried with the same sequence and content; never silently replace a lost update.
9
+ * @example
10
+ * const run = createLiveReporter({ producer: 'unit', plannedChecks: 10 }, { url: 'http://localhost:5180' });
11
+ * run.check({ id: 'a', title: 'Adds a todo', status: 'running' });
12
+ * run.check({ id: 'a', title: 'Adds a todo', status: 'passed' });
13
+ * await run.finish();
14
+ */
15
+ export function createLiveReporter(identity, options) {
16
+ const base = new URL(options.url);
17
+ if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash)
18
+ throw new Error('Atlas live reporter needs an HTTP(S) server URL without credentials, query or fragment.');
19
+ const id = identity.id ?? randomUUID();
20
+ const endpoint = `${base.href.replace(/\/$/, '')}/api/live/${id}`;
21
+ let sequence = 0;
22
+ let finishedAtMs;
23
+ let closed = false;
24
+ let timer;
25
+ let error;
26
+ const pending = new Map();
27
+ const known = new Map();
28
+ let failed;
29
+ const reportError = (caught) => { error = caught instanceof Error ? caught : new Error(String(caught)); options.onError?.(error); };
30
+ async function request(method, payload, suffix = '') {
31
+ const text = payload === undefined ? undefined : JSON.stringify(payload);
32
+ for (let attempt = 0;; attempt++) {
33
+ try {
34
+ const response = await fetch(endpoint + suffix, { method, redirect: 'error', signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
35
+ headers: { 'content-type': 'application/json', ...(options.project ? { 'x-atlas-project': options.project } : {}), ...(options.token ? { authorization: `Bearer ${options.token}` } : {}) }, body: text });
36
+ const status = response.status;
37
+ const json = response.headers.get('content-type')?.includes('application/json');
38
+ if (response.ok && !json) {
39
+ await response.body?.cancel();
40
+ throw Object.assign(new Error('Atlas live reporter: the endpoint is not an Atlas server. Start or restart `bun atlas`.'), { permanent: true });
41
+ }
42
+ if (!response.ok) {
43
+ await response.body?.cancel();
44
+ throw Object.assign(new Error(`Atlas live reporter: HTTP ${status}`), { permanent: status < 500 && status !== 429 });
45
+ }
46
+ return await response.json();
47
+ }
48
+ catch (caught) {
49
+ if (attempt >= 2 || caught.permanent)
50
+ throw caught;
51
+ await new Promise(resolve => setTimeout(resolve, 250 * 2 ** attempt));
52
+ }
53
+ }
54
+ }
55
+ const ready = request('POST', { ...identity, id });
56
+ void ready.catch(reportError);
57
+ let queue = Promise.resolve();
58
+ let draining;
59
+ const flush = () => {
60
+ if (draining)
61
+ return draining;
62
+ queue = queue.then(async () => {
63
+ await ready;
64
+ while (failed || pending.size) {
65
+ if (!failed) {
66
+ const checks = [...pending.values()].slice(0, 1000);
67
+ checks.forEach(check => pending.delete(check.id));
68
+ failed = { sequence: sequence + 1, checks };
69
+ }
70
+ await request('PATCH', failed);
71
+ sequence = failed.sequence;
72
+ failed = undefined;
73
+ }
74
+ error = undefined;
75
+ }).catch(reportError);
76
+ draining = queue.finally(() => { draining = undefined; });
77
+ return draining;
78
+ };
79
+ const heartbeat = setInterval(() => { if (!closed)
80
+ void ready.then(async () => { await request('POST', undefined, '/heartbeat'); if (!closed && (failed || pending.size))
81
+ void flush(); }).catch(caught => { if (!closed)
82
+ reportError(caught); }); }, options.heartbeatMs ?? 5000);
83
+ heartbeat.unref?.();
84
+ const check = (given) => {
85
+ if (closed)
86
+ throw new Error('This live reporter is closed.');
87
+ const check = { id: given.id, title: given.title.slice(0, 2048), status: given.status, detail: (given.detail ?? '').slice(0, 8000),
88
+ ...(given.specPath ? { specPath: given.specPath } : {}), ...(given.durationMs === undefined ? {} : { durationMs: given.durationMs }),
89
+ ...(given.steps?.length ? { steps: given.steps.slice(-20).map(step => ({ name: step.name.slice(0, 2048), ...(step.status ? { status: step.status.slice(0, 100) } : {}) })) } : {}) };
90
+ if (JSON.stringify(known.get(check.id)) === JSON.stringify(check))
91
+ return;
92
+ known.set(check.id, check);
93
+ pending.set(check.id, check);
94
+ if (!timer) {
95
+ timer = setTimeout(() => { timer = undefined; void flush(); }, options.flushMs ?? 250);
96
+ timer.unref?.();
97
+ }
98
+ };
99
+ return { id, get finishedAtMs() { return finishedAtMs; }, check, flush, async finish(status = 'completed', reason) {
100
+ if (closed) {
101
+ await queue;
102
+ if (error)
103
+ throw error;
104
+ return;
105
+ }
106
+ const unfinished = [...known.values()].filter(check => check.status === 'running');
107
+ if (unfinished.length) {
108
+ status = 'interrupted';
109
+ reason ??= 'The producer ended without a final outcome for running checks.';
110
+ unfinished.forEach(value => check({ ...value, status: 'not-run', detail: reason }));
111
+ }
112
+ closed = true;
113
+ clearInterval(heartbeat);
114
+ clearTimeout(timer);
115
+ await flush();
116
+ if (error)
117
+ throw error;
118
+ const result = await request('PATCH', { sequence: sequence + 1, checks: [], finish: status, ...(reason ? { reason } : {}) });
119
+ finishedAtMs = result.finishedAtMs;
120
+ sequence++;
121
+ } };
122
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @module Consumer package-manager commands
3
+ *
4
+ * One source for commands emitted by init, the running server's agent guide, and generated documentation.
5
+ * Execute the installed CLI so a consumer keeps the versions selected by its own manifest and lockfile.
6
+ */
7
+ /** Supported consumer package managers, their development install command and installed-binary executor. */
8
+ export declare const PACKAGE_MANAGERS: {
9
+ readonly bun: {
10
+ readonly label: "Bun";
11
+ readonly installDev: "bun add -d";
12
+ readonly exec: "bunx";
13
+ };
14
+ readonly npm: {
15
+ readonly label: "npm";
16
+ readonly installDev: "npm install -D";
17
+ readonly exec: "npm exec --";
18
+ };
19
+ readonly pnpm: {
20
+ readonly label: "pnpm";
21
+ readonly installDev: "pnpm add -D";
22
+ readonly exec: "pnpm exec";
23
+ };
24
+ readonly yarn: {
25
+ readonly label: "Yarn";
26
+ readonly installDev: "yarn add -D";
27
+ readonly exec: "yarn exec";
28
+ };
29
+ };
30
+ /**
31
+ * Markdown installation table for consumer and server agent documentation.
32
+ * @example
33
+ * const markdown = packageManagerTable(); // the same commands `atlas init` emits
34
+ */
35
+ export declare function packageManagerTable(): string;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @module Consumer package-manager commands
3
+ *
4
+ * One source for commands emitted by init, the running server's agent guide, and generated documentation.
5
+ * Execute the installed CLI so a consumer keeps the versions selected by its own manifest and lockfile.
6
+ */
7
+ /** Supported consumer package managers, their development install command and installed-binary executor. */
8
+ export const PACKAGE_MANAGERS = {
9
+ bun: { label: 'Bun', installDev: 'bun add -d', exec: 'bunx' },
10
+ npm: { label: 'npm', installDev: 'npm install -D', exec: 'npm exec --' },
11
+ pnpm: { label: 'pnpm', installDev: 'pnpm add -D', exec: 'pnpm exec' },
12
+ yarn: { label: 'Yarn', installDev: 'yarn add -D', exec: 'yarn exec' }
13
+ };
14
+ /**
15
+ * Markdown installation table for consumer and server agent documentation.
16
+ * @example
17
+ * const markdown = packageManagerTable(); // the same commands `atlas init` emits
18
+ */
19
+ export function packageManagerTable() {
20
+ return ['| Package manager | Install | Run the installed CLI |', '| --- | --- | --- |',
21
+ ...Object.values(PACKAGE_MANAGERS).map(manager => `| ${manager.label} | \`${manager.installDev} @crvouga/atlas\` | \`${manager.exec} atlas\` |`)
22
+ ].join('\n');
23
+ }
@@ -13,7 +13,7 @@
13
13
  * @see docs/agents.md
14
14
  */
15
15
  /** The text `atlas report --help` prints: every subcommand, its flags, what each does and the exit codes. Also the source of the command reference in `docs/reference/cli.md`. */
16
- export declare const REPORT_USAGE = "atlas report \u2014 layered evidence for any kind of spec\n\n atlas report publish [--store <dir|s3://bucket/prefix>] [--producer <name>]\n ( --ctrf <file> | --junit <file> | --manifest <file> | --checks <file.json> )\n [--environment <name>] [--branch <b>] [--sha <s>] [--run-url <u>]\n [--spec <path> | --specs <dir>] [--upload-run]\n [--param key=value]... [--kind <k>] [--title <t>] [--recorded-at <iso|ms>]\n [--attach [role=]<file>]... [--label key=value]... [--link [name=]<url>]...\n [--attach-root <dir>]... [--max-attachment-mb <n>] [--max-total-attachment-mb <n>]\n atlas report stream [--store <dir|s3://\u2026|https://atlas.example.com>] --producer <name> [--environment <name>]\n [--branch <b>] [--sha <s>] [--run-url <u>] [--kind <k>] [--param key=value]...\n [--flush-ms 1000] [--batch 100] (checks as JSON lines on standard input)\n atlas report status [--store <dir|s3://\u2026|id=url>]... [--backends <module>]\n [--specs <dir>] [--format text|json|markdown]\n [--require-environments a,b] [--require-all-specs]\n [--branch <name>]... [--ignore-modified]\n atlas report merge [--store \u2026]... [--backends <module>] --into <dir|s3://\u2026>\n atlas report doctor [--store \u2026]... [--role publisher|reader|both]\n atlas report policy --store s3://bucket/prefix [--role publisher|reader|both]\n\n A store is a directory or s3://bucket/prefix (add ?region=\u2026&endpoint=\u2026 for R2, MinIO and the\n rest). When no --store is given, ATLAS_STORE is used. Keys never go in the URL: set\n AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or sign in with `aws sso login`, or use a role.\n\n publish Write one immutable layer: the checks of one producer, on one environment. Safe to repeat.\n --producer is required, unless the --checks file names one\n --spec <path> every check in the report belongs to this spec (a report per feature file)\n --specs <dir> link checks to the spec files in <dir> by path, or by the suite, class or\n file name the report gives; also publishes the specs themselves, so the\n node can show them without a checkout\n --upload-run with --manifest: also upload the run's screenshots and clips for the viewer\n --attach <file> attach a file to the whole layer: a CI log, a coverage report, a trace archive\n (screenshots, videos and traces of a single test go in its CTRF or checks file)\n --label k=v a fact about the run, not part of what it checks: build, os, browser\n --link [n=]<url> a link to the run, a dashboard, an issue\n Files a report names (CTRF attachments, JUnit [[ATTACHMENT|path]] lines) come from the report's own\n folder and each --attach-root, and never from a hidden file or a link out: a report is data from a\n test run, not instructions. One that is missing or not allowed is skipped with a warning.\n From a checkout with uncommitted changes the layer is marked \"modified\".\n stream Send checks as they finish: one JSON object per line on standard input (the fields of one\n check: id, title, status, and any evidence). Each second's worth becomes one layer, so a server\n shows the run while it is still going. A check sent again with the same id (running, then\n passed) is replaced by the later. Ends when the input does; exit 1 if anything could not be sent.\n Point it at an Atlas server with --store https://\u2026 and a token in ATLAS_TOKEN.\n status Combine every layer of every store, and exit 1 unless the report passes.\n --specs <dir> lay the results over these spec files (default: the specs in the store)\n --require-all-specs fail while any spec has no results\n --branch <name> only layers recorded on this branch (repeatable)\n --ignore-modified leave out layers from modified checkouts and unknown origins\n merge Copy the layers of several stores into one.\n doctor Check that a store works: credentials, listing, writing, and what it holds.\n policy Print the least-privilege IAM policy for publishers, readers, or both.\n\n --backends <module> A TypeScript or JavaScript module whose default export is a list of\n { id, label?, store } (or a function returning it): a device lab, anything\n that implements ObjectStore.\n\n --format json (alias --json) publish, stream, merge, doctor and status print one JSON document instead of prose\n (schemas: publish-result, stream-result, merge-result, doctor-result, report-status). A command\n that is wrong is {\"ok\":false,\"error\":{\"message\":\u2026,\"code\":\"usage\"}} on standard error.\n A store may be an Atlas server (https://\u2026): ATLAS_STORE=https://atlas.example.com, with ATLAS_TOKEN.\n\n Exit codes: 0 ok, 1 the report does not pass (or a check failed), 2 the command was wrong.\n";
16
+ export declare const REPORT_USAGE = "atlas report \u2014 layered evidence for any kind of spec\n\n atlas report publish [--store <dir|s3://bucket/prefix>] [--producer <name>]\n ( --ctrf <file> | --junit <file> | --manifest <file> | --checks <file.json> )\n [--environment <name>] [--branch <b>] [--sha <s>] [--run-url <u>]\n [--spec <path> | --specs <dir>] [--upload-run]\n [--param key=value]... [--kind <k>] [--title <t>] [--recorded-at <iso|ms>]\n [--attach [role=]<file>]... [--label key=value]... [--link [name=]<url>]...\n [--attach-root <dir>]... [--max-attachment-mb <n>] [--max-total-attachment-mb <n>]\n atlas report stream [--store <dir|s3://\u2026|https://atlas.example.com>] --producer <name> [--environment <name>]\n [--branch <b>] [--sha <s>] [--run-url <u>] [--kind <k>] [--param key=value]...\n [--flush-ms 1000] [--batch 100] (checks as JSON lines on standard input)\n atlas report status [--store <dir|s3://\u2026|id=url>]... [--backends <module>]\n [--specs <dir>] [--format text|json|markdown]\n [--require-environments a,b] [--require-all-specs]\n [--branch <name>]... [--ignore-modified]\n atlas report merge [--store \u2026]... [--backends <module>] --into <dir|s3://\u2026>\n atlas report doctor [--store \u2026]... [--role publisher|reader|both]\n atlas report policy --store s3://bucket/prefix [--role publisher|reader|both]\n\n A store is a directory or s3://bucket/prefix (add ?region=\u2026&endpoint=\u2026 for R2, MinIO and the\n rest). Select a project on an Atlas node with --project <slug> or ATLAS_PROJECT. This scopes specs, reports and recordings together.\n When no --store is given, ATLAS_STORE is used. Keys never go in the URL: set\n AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or sign in with `aws sso login`, or use a role.\n\n publish Write one immutable layer: the checks of one producer, on one environment. Safe to repeat.\n --producer is required, unless the --checks file names one\n --spec <path> every check in the report belongs to this spec (a report per feature file)\n --specs <dir> link checks to the spec files in <dir> by path, or by the suite, class or\n file name the report gives; also publishes the specs themselves, so the\n node can show them without a checkout\n --upload-run with --manifest: also upload the run's screenshots and clips for the viewer\n --attach <file> attach a file to the whole layer: a CI log, a coverage report, a trace archive\n (screenshots, videos and traces of a single test go in its CTRF or checks file)\n --label k=v a fact about the run, not part of what it checks: build, os, browser\n --link [n=]<url> a link to the run, a dashboard, an issue\n Files a report names (CTRF attachments, JUnit [[ATTACHMENT|path]] lines) come from the report's own\n folder and each --attach-root, and never from a hidden file or a link out: a report is data from a\n test run, not instructions. One that is missing or not allowed is skipped with a warning.\n From a checkout with uncommitted changes the layer is marked \"modified\".\n stream Send checks as they finish: one JSON object per line on standard input (the fields of one\n check: id, title, status, and any evidence). Each second's worth becomes one layer, so a server\n shows the run while it is still going. A check sent again with the same id (running, then\n passed) is replaced by the later. Ends when the input does; exit 1 if anything could not be sent.\n Point it at an Atlas server with --store https://\u2026 and a token in ATLAS_TOKEN.\n status Combine every layer of every store, and exit 1 unless the report passes.\n --specs <dir> lay the results over these spec files (default: the specs in the store)\n --require-all-specs fail while any spec has no results\n --branch <name> only layers recorded on this branch (repeatable)\n --ignore-modified leave out layers from modified checkouts and unknown origins\n merge Copy the layers of several stores into one.\n doctor Check that a store works: credentials, listing, writing, and what it holds.\n policy Print the least-privilege IAM policy for publishers, readers, or both.\n\n --backends <module> A TypeScript or JavaScript module whose default export is a list of\n { id, label?, store } (or a function returning it): a device lab, anything\n that implements ObjectStore.\n\n --format json (alias --json) publish, stream, merge, doctor and status print one JSON document instead of prose\n (schemas: publish-result, stream-result, merge-result, doctor-result, report-status). A command\n that is wrong is {\"ok\":false,\"error\":{\"message\":\u2026,\"code\":\"usage\"}} on standard error.\n A store may be an Atlas server (https://\u2026): ATLAS_STORE=https://atlas.example.com, with ATLAS_TOKEN.\n\n Exit codes: 0 ok, 1 the report does not pass (or a check failed), 2 the command was wrong.\n";
17
17
  type Env = Readonly<Record<string, string | undefined>>;
18
18
  type Git = (args: readonly string[]) => string | null;
19
19
  /** What `reportMain` is given besides arguments: where output goes (`out`), and, for tests and embedding, the environment, working directory, git, clock and standard input (`stdin`, where `stream` reads checks from). Defaults are the real ones. */
@@ -54,7 +54,8 @@ export const REPORT_USAGE = `atlas report — layered evidence for any kind of s
54
54
  atlas report policy --store s3://bucket/prefix [--role publisher|reader|both]
55
55
 
56
56
  A store is a directory or s3://bucket/prefix (add ?region=…&endpoint=… for R2, MinIO and the
57
- rest). When no --store is given, ATLAS_STORE is used. Keys never go in the URL: set
57
+ rest). Select a project on an Atlas node with --project <slug> or ATLAS_PROJECT. This scopes specs, reports and recordings together.
58
+ When no --store is given, ATLAS_STORE is used. Keys never go in the URL: set
58
59
  AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or sign in with \`aws sso login\`, or use a role.
59
60
 
60
61
  publish Write one immutable layer: the checks of one producer, on one environment. Safe to repeat.
@@ -211,7 +212,7 @@ function printChecks(out, label, checks) {
211
212
  export async function reportMain(argv, io = { out: text => process.stdout.write(text) }) {
212
213
  const [command, ...rest] = argv;
213
214
  const base = io.cwd ?? process.cwd();
214
- const env = io.env ?? process.env;
215
+ const env = { ...(io.env ?? process.env), ...(value(rest, 'project') !== undefined ? { ATLAS_PROJECT: value(rest, 'project') } : {}) };
215
216
  const clock = io.clock ?? Date.now;
216
217
  if (command === 'publish') {
217
218
  const target = writeStoreOf(value(rest, 'store'), base, env);
@@ -77,7 +77,7 @@ export function openStore(spec, options) {
77
77
  const token = serverToken(env, options.role ?? { id: '', soleServer: true });
78
78
  if (token)
79
79
  refuseClearToken(location.url, env);
80
- const server = httpStore({ url: location.url, ...(token ? { token } : {}), ...(options.fetch ? { fetch: options.fetch } : {}) });
80
+ const server = httpStore({ url: location.url, ...(env.ATLAS_PROJECT ? { project: env.ATLAS_PROJECT } : {}), ...(token ? { token } : {}), ...(options.fetch ? { fetch: options.fetch } : {}) });
81
81
  return { store: server, label: server.label, location, server };
82
82
  }
83
83
  const s3 = s3Store({
@@ -0,0 +1,14 @@
1
+ /** @module CodeceptJS reporter
2
+ * CodeceptJS's Mocha reporter interface carries scenario outcomes and steps to the singleton Atlas server.
3
+ */
4
+ import AtlasMochaReporter from './mocha.js';
5
+ /**
6
+ * Use `codeceptjs run --reporter @crvouga/atlas/codecept` with ATLAS_SERVER_URL and ATLAS_PUBLISH_TOKEN.
7
+ * Upload failures fail the run; skipped scenarios remain not-run.
8
+ * @example
9
+ * // codecept.conf.js
10
+ * export const config = { tests: './tests/*_test.js', reporter: '@crvouga/atlas/codecept' };
11
+ */
12
+ export default class AtlasCodeceptReporter extends AtlasMochaReporter {
13
+ constructor(...args: [ConstructorParameters<typeof AtlasMochaReporter>[0], ConstructorParameters<typeof AtlasMochaReporter>[1]?]);
14
+ }
@@ -0,0 +1,16 @@
1
+ /** @module CodeceptJS reporter
2
+ * CodeceptJS's Mocha reporter interface carries scenario outcomes and steps to the singleton Atlas server.
3
+ */
4
+ import AtlasMochaReporter from './mocha.js';
5
+ /**
6
+ * Use `codeceptjs run --reporter @crvouga/atlas/codecept` with ATLAS_SERVER_URL and ATLAS_PUBLISH_TOKEN.
7
+ * Upload failures fail the run; skipped scenarios remain not-run.
8
+ * @example
9
+ * // codecept.conf.js
10
+ * export const config = { tests: './tests/*_test.js', reporter: '@crvouga/atlas/codecept' };
11
+ */
12
+ export default class AtlasCodeceptReporter extends AtlasMochaReporter {
13
+ constructor(...args) {
14
+ super(args[0], args[1], 'codeceptjs');
15
+ }
16
+ }
@@ -0,0 +1,50 @@
1
+ import { type ReporterOptions } from './publish.js';
2
+ type Result = {
3
+ testFilePath: string;
4
+ testExecError?: {
5
+ message?: string;
6
+ stack?: string;
7
+ };
8
+ failureMessage?: string;
9
+ testResults: {
10
+ title: string;
11
+ fullName: string;
12
+ status: string;
13
+ duration?: number | null;
14
+ failureMessages: string[];
15
+ invocations?: number;
16
+ }[];
17
+ };
18
+ /**
19
+ * Jest's native reporter; retain `default` in the reporter list for console output. Upload errors fail Jest.
20
+ * @example
21
+ * export default { reporters: ['default', ['@crvouga/atlas/jest', { url: 'https://atlas.example.com' }]] };
22
+ */
23
+ export default class AtlasJestReporter {
24
+ private readonly options;
25
+ private live;
26
+ private error;
27
+ constructor(_config: unknown, options?: ReporterOptions);
28
+ /** Reset publishing errors for each watch-mode invocation. */
29
+ onRunStart(result?: {
30
+ numTotalTests?: number;
31
+ }): void;
32
+ /** Publish the test as running before its body executes in a worker. */
33
+ onTestCaseStart(test: {
34
+ path: string;
35
+ }, info: {
36
+ fullName: string;
37
+ title: string;
38
+ }): void;
39
+ /** Stream each test outcome immediately, without waiting for other files to finish. */
40
+ onTestCaseResult(test: {
41
+ path: string;
42
+ }, result: Result['testResults'][number]): void;
43
+ /** Await uploading every final result, including test-file failures before any test ran. */
44
+ onRunComplete(_contexts: unknown, result: {
45
+ testResults: Result[];
46
+ }): Promise<void>;
47
+ /** Jest uses this to turn a publishing error into a failed invocation. */
48
+ getLastError(): Error | undefined;
49
+ }
50
+ export {};
@@ -0,0 +1,52 @@
1
+ import { testFile, runnerLive, finishRunner } from './publish.js';
2
+ /**
3
+ * Jest's native reporter; retain `default` in the reporter list for console output. Upload errors fail Jest.
4
+ * @example
5
+ * export default { reporters: ['default', ['@crvouga/atlas/jest', { url: 'https://atlas.example.com' }]] };
6
+ */
7
+ export default class AtlasJestReporter {
8
+ options;
9
+ live;
10
+ error;
11
+ constructor(_config, options = {}) {
12
+ this.options = options;
13
+ }
14
+ /** Reset publishing errors for each watch-mode invocation. */
15
+ onRunStart(result) { this.error = undefined; this.live = runnerLive('jest', this.options, result?.numTotalTests || undefined); }
16
+ /** Publish the test as running before its body executes in a worker. */
17
+ onTestCaseStart(test, info) {
18
+ this.live ??= runnerLive('jest', this.options);
19
+ this.live.check({ id: `${testFile(test.path, this.options.rootDir)} > ${info.fullName}`, title: info.title, status: 'running' });
20
+ }
21
+ /** Stream each test outcome immediately, without waiting for other files to finish. */
22
+ onTestCaseResult(test, result) {
23
+ this.live ??= runnerLive('jest', this.options);
24
+ this.live.check({ id: `${testFile(test.path, this.options.rootDir)} > ${result.fullName}`, title: result.title,
25
+ status: result.status === 'passed' ? 'passed' : result.status === 'failed' ? 'failed' : 'not-run', detail: result.failureMessages.join('\n') });
26
+ }
27
+ /** Await uploading every final result, including test-file failures before any test ran. */
28
+ async onRunComplete(_contexts, result) {
29
+ const checks = [];
30
+ for (const suite of result.testResults) {
31
+ const file = testFile(suite.testFilePath, this.options.rootDir);
32
+ for (const test of suite.testResults)
33
+ checks.push({ id: `${file} > ${test.fullName}`, title: test.title,
34
+ status: test.status === 'passed' ? 'passed' : test.status === 'failed' ? 'failed' : 'not-run',
35
+ detail: test.failureMessages.join('\n'), measurements: [],
36
+ ...(typeof test.duration === 'number' && Number.isFinite(test.duration) && test.duration >= 0 ? { durationMs: test.duration } : {}),
37
+ ...((test.invocations ?? 1) > 1 ? { labels: { retries: String(test.invocations - 1), ...(test.status === 'passed' ? { flaky: 'true' } : {}) } } : {}) });
38
+ if (suite.testExecError)
39
+ checks.push({ id: `${file} > [suite error]`, title: `${file}: suite error`, status: 'failed', detail: suite.testExecError.stack ?? suite.testExecError.message ?? suite.failureMessage ?? 'Suite did not execute', measurements: [] });
40
+ }
41
+ this.live ??= runnerLive('jest', this.options);
42
+ checks.forEach(check => this.live.check(check));
43
+ try {
44
+ await finishRunner(this.live, 'jest', checks, this.options);
45
+ }
46
+ catch (error) {
47
+ this.error = error instanceof Error ? error : new Error(String(error));
48
+ }
49
+ }
50
+ /** Jest uses this to turn a publishing error into a failed invocation. */
51
+ getLastError() { return this.error; }
52
+ }
@@ -0,0 +1,40 @@
1
+ import { type ReporterOptions } from './publish.js';
2
+ type Test = {
3
+ title: string;
4
+ file?: string;
5
+ duration?: number;
6
+ fullTitle(): string;
7
+ err?: {
8
+ stack?: string;
9
+ message?: string;
10
+ };
11
+ steps?: {
12
+ name?: string;
13
+ status?: string;
14
+ }[];
15
+ currentRetry?(): number;
16
+ };
17
+ type Runner = {
18
+ total?: number;
19
+ on(event: string, listener: (test: Test, error?: Error) => void): unknown;
20
+ };
21
+ /**
22
+ * Mocha reporter; use `--reporter @crvouga/atlas/mocha` or CodeceptJS `--reporter @crvouga/atlas/codecept`.
23
+ * Reporter options go in `reporterOptions`; environment variables are supported by every runner.
24
+ * A failed upload adds a runner failure through Mocha's completion callback.
25
+ * @example
26
+ * // mocha configuration
27
+ * export default { reporter: '@crvouga/atlas/mocha', reporterOptions: { url: 'https://atlas.example.com' } };
28
+ */
29
+ export default class AtlasMochaReporter {
30
+ private readonly checks;
31
+ private readonly options;
32
+ private readonly runnerName;
33
+ private readonly live;
34
+ constructor(runner: Runner, options?: {
35
+ reporterOptions?: ReporterOptions;
36
+ }, runnerName?: string);
37
+ /** Mocha waits for this callback before exiting. Publishing errors fail the invocation. */
38
+ done(failures: number, callback: (failures: number) => void): void;
39
+ }
40
+ export {};