@crvouga/atlas 0.5.0 → 0.6.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 (140) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +30 -11
  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 +6 -1
  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/report-cli.d.ts +1 -1
  17. package/dist/reports/src/report-cli.js +3 -2
  18. package/dist/reports/src/store-url.js +1 -1
  19. package/dist/runners/src/codecept.d.ts +14 -0
  20. package/dist/runners/src/codecept.js +16 -0
  21. package/dist/runners/src/jest.d.ts +50 -0
  22. package/dist/runners/src/jest.js +52 -0
  23. package/dist/runners/src/mocha.d.ts +40 -0
  24. package/dist/runners/src/mocha.js +39 -0
  25. package/dist/runners/src/node-test.d.ts +28 -0
  26. package/dist/runners/src/node-test.js +44 -0
  27. package/dist/runners/src/publish.d.ts +30 -0
  28. package/dist/runners/src/publish.js +68 -0
  29. package/dist/schema/src/api.d.ts +1 -0
  30. package/dist/schema/src/api.js +1 -1
  31. package/dist/schema/src/index.d.ts +2 -0
  32. package/dist/schema/src/index.js +2 -0
  33. package/dist/schema/src/live-run.d.ts +123 -0
  34. package/dist/schema/src/live-run.js +30 -0
  35. package/dist/schema/src/manifest.d.ts +2 -2
  36. package/dist/schema/src/project.d.ts +20 -0
  37. package/dist/schema/src/project.js +10 -0
  38. package/dist/schema/src/report-sources.d.ts +1 -0
  39. package/dist/schema/src/report-sources.js +2 -1
  40. package/dist/schema/src/runs-index.d.ts +1 -1
  41. package/dist/schema/src/server-config.d.ts +84 -0
  42. package/dist/schema/src/server-config.js +12 -0
  43. package/dist/server/src/app.d.ts +10 -0
  44. package/dist/server/src/app.js +148 -10
  45. package/dist/server/src/config.d.ts +1 -1
  46. package/dist/server/src/config.js +20 -3
  47. package/dist/server/src/dev.d.ts +3 -3
  48. package/dist/server/src/dev.js +22 -13
  49. package/dist/server/src/events.d.ts +3 -3
  50. package/dist/server/src/events.js +1 -1
  51. package/dist/server/src/index.d.ts +2 -0
  52. package/dist/server/src/index.js +2 -0
  53. package/dist/server/src/live-runs.d.ts +52 -0
  54. package/dist/server/src/live-runs.js +229 -0
  55. package/dist/server/src/main.d.ts +1 -1
  56. package/dist/server/src/main.js +7 -5
  57. package/dist/server/src/openapi.d.ts +2 -5
  58. package/dist/server/src/openapi.js +71 -7
  59. package/dist/server/src/projects.d.ts +3 -0
  60. package/dist/server/src/projects.js +154 -0
  61. package/dist/server/src/settings-page.d.ts +9 -0
  62. package/dist/server/src/settings-page.js +93 -0
  63. package/dist/server/src/settings.d.ts +23 -0
  64. package/dist/server/src/settings.js +72 -0
  65. package/dist/server/src/site.d.ts +8 -6
  66. package/dist/server/src/site.js +45 -10
  67. package/dist/server/src/store-api.js +43 -22
  68. package/docs/deploying.md +19 -19
  69. package/docs/dogfooding.md +2 -2
  70. package/docs/extending.md +3 -3
  71. package/docs/integrating.md +9 -9
  72. package/docs/reference/cli.md +9 -7
  73. package/docs/reference/core.md +6 -0
  74. package/docs/reference/http-api.md +498 -2
  75. package/docs/reference/reports.md +55 -1
  76. package/docs/reference/runners.md +169 -0
  77. package/docs/reference/schema.md +111 -0
  78. package/docs/reference/server.md +154 -14
  79. package/docs/report-backends.md +8 -8
  80. package/docs/report-contract.md +7 -7
  81. package/docs/reporting-issues.md +3 -3
  82. package/docs/runners.md +69 -0
  83. package/docs/server.md +61 -25
  84. package/docs/statecharts.md +22 -20
  85. package/llms-full.txt +713 -105
  86. package/llms.txt +3 -1
  87. package/package.json +31 -9
  88. package/packages/runners/README.md +6 -0
  89. package/packages/runners/compat/codecept.cjs +2 -0
  90. package/packages/runners/compat/jest.cjs +2 -0
  91. package/packages/runners/compat/mocha.cjs +2 -0
  92. package/packages/schema/schemas/atlas-change.schema.json +4 -0
  93. package/packages/schema/schemas/hello-event.schema.json +4 -0
  94. package/packages/schema/schemas/live-run-start.schema.json +42 -0
  95. package/packages/schema/schemas/live-run-update.schema.json +93 -0
  96. package/packages/schema/schemas/live-run.schema.json +225 -0
  97. package/packages/schema/schemas/project-slug.schema.json +8 -0
  98. package/packages/schema/schemas/project.schema.json +22 -0
  99. package/packages/schema/schemas/projects.schema.json +33 -0
  100. package/packages/schema/schemas/server-config.schema.json +14 -0
  101. package/packages/schema/schemas/server-editable-settings.schema.json +132 -0
  102. package/packages/schema/schemas/server-settings-snapshot.schema.json +285 -0
  103. package/packages/server/openapi.json +5101 -779
  104. package/packages/visualizer/README.md +13 -13
  105. package/packages/visualizer/bin/atlas-client.mjs +176 -0
  106. package/packages/visualizer/bin/atlas-visualizer.mjs +2 -175
  107. package/packages/visualizer/src/app/AppShell.tsx +26 -17
  108. package/packages/visualizer/src/app/report-sources.tsx +2 -1
  109. package/packages/visualizer/src/components/ActivityIndicator.module.css +4 -0
  110. package/packages/visualizer/src/components/ActivityIndicator.tsx +6 -0
  111. package/packages/visualizer/src/components/Header.module.css +101 -227
  112. package/packages/visualizer/src/components/Header.tsx +79 -41
  113. package/packages/visualizer/src/components/ProjectPicker.tsx +64 -0
  114. package/packages/visualizer/src/data/adapters/report-backend.test.ts +3 -2
  115. package/packages/visualizer/src/data/adapters/report-backend.ts +1 -1
  116. package/packages/visualizer/src/data/queries.ts +14 -7
  117. package/packages/visualizer/src/evidence/api.ts +2 -1
  118. package/packages/visualizer/src/evidence/check-detail.tsx +19 -4
  119. package/packages/visualizer/src/evidence/document-page.tsx +6 -2
  120. package/packages/visualizer/src/evidence/evidence-context.tsx +17 -12
  121. package/packages/visualizer/src/evidence/evidence.module.css +11 -1
  122. package/packages/visualizer/src/evidence/feature-page.tsx +7 -3
  123. package/packages/visualizer/src/evidence/model-page.tsx +6 -2
  124. package/packages/visualizer/src/evidence/project-home.tsx +45 -0
  125. package/packages/visualizer/src/evidence/recording-page.tsx +4 -1
  126. package/packages/visualizer/src/evidence/spec-index.tsx +2 -1
  127. package/packages/visualizer/src/evidence/test-filter.tsx +21 -0
  128. package/packages/visualizer/src/live/live-query.ts +21 -0
  129. package/packages/visualizer/src/live/live-runs.tsx +69 -0
  130. package/packages/visualizer/src/live/live-stream.ts +30 -9
  131. package/packages/visualizer/src/live/report-progress.ts +62 -0
  132. package/packages/visualizer/src/live/test-progress.tsx +34 -0
  133. package/packages/visualizer/src/runtime.ts +17 -1
  134. package/packages/visualizer/src/state/ui-store.ts +3 -2
  135. package/packages/visualizer/src/views/ChartPage.tsx +2 -0
  136. package/packages/visualizer/src/views/ChartTests.tsx +43 -0
  137. package/packages/visualizer/src/views/SidePanel.tsx +2 -0
  138. package/packages/visualizer/vite/atlas-files.test.ts +4 -4
  139. package/packages/visualizer/vite/atlas-files.ts +1 -5
  140. package/packages/visualizer/vite/atlas-server-proxy.ts +2 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
+ ## 0.6.0 (2026-10-15)
4
+
5
+ ### Features
6
+
7
+ - add project-scoped live test reporting (#8) ([70936ab](https://github.com/crvouga/atlas/commit/70936ab5b2c6bec5e50b458fc1c3030a3f20bc55))
8
+
3
9
  ## 0.5.0 (2026-10-14)
4
10
 
5
11
  ### Features
package/README.md CHANGED
@@ -7,7 +7,7 @@ backends, and tells you in one command whether the product still does what it wa
7
7
 
8
8
  - **Any spec, any runner.** Anything that ends in "this check passed or failed" publishes a *layer*:
9
9
  from a JUnit or CTRF file, a ten-line script, or code. No runner and no notation is privileged.
10
- - **Layered, never partitioned.** A report is computed from layers, not stored as "the run". Partial
10
+ - **Layered within each project.** A report is computed from layers, not stored as "the run". Partial
11
11
  runs on separate nodes, branches and devices are how a report is built, and they compose without
12
12
  coordinating: the same layers give the same report in any order, split any way.
13
13
  - **Open evidence.** A result carries whatever your framework produces: screenshots, videos, traces, logs, DOM
@@ -16,9 +16,9 @@ backends, and tells you in one command whether the product still does what it wa
16
16
  Gherkin, Quint, DMN, Alloy, OpenSLO); evidence stays open.
17
17
  - **Open standards, stable schemas.** JUnit XML and CTRF in, JSON Schema 2020-12 contracts, a
18
18
  versioned layer format with a golden fixture, and an `ObjectStore` interface any bucket fits.
19
- - **One server, live.** A project runs one `atlas serve`: a Bun API that serves the viewer from the same host.
19
+ - **One node, many projects.** `atlas server` runs a Bun API and serves the Atlas client from the same host. Reports, specs, recordings and live progress are partitioned by project slug.
20
20
  Every laptop and CI job sends results to it as they finish (`atlas report publish`, or `atlas report stream` while
21
- a suite runs), it keeps them on its own disk (or in S3, R2, MinIO), and every viewer sees the combined report
21
+ a suite runs), it keeps them on its own disk (or in S3, R2, MinIO), and every client sees the combined report
22
22
  update live, receiving only what changed ([the server](docs/server.md), [deploying](docs/deploying.md)).
23
23
  - **Fast and fail-closed.** Publishing is two small writes. A thousand layers of a hundred checks
24
24
  combine in about a second. An unreadable layer, a downed backend or a check still running is never
@@ -28,7 +28,7 @@ backends, and tells you in one command whether the product still does what it wa
28
28
 
29
29
  ```
30
30
  any suite ──▶ layer ─┐ atlas report status exit 1 unless it passes
31
- any node ──▶ layer ─┼─▶ backends ─▶ combine ─▶ the visualizer browse it beside the specs
31
+ any node ──▶ layer ─┼─▶ backends ─▶ combine ─▶ the Atlas client browse it beside the specs
32
32
  any branch ──▶ layer ─┘ (directories, buckets, …) combineBackends() your own code
33
33
  ```
34
34
 
@@ -40,6 +40,23 @@ backends, and tells you in one command whether the product still does what it wa
40
40
  > [using Atlas from another project](docs/dogfooding.md) and [reporting issues](docs/reporting-issues.md).
41
41
  > Releases are automatic: see [releasing](https://github.com/crvouga/atlas/blob/main/docs/releasing.md).
42
42
 
43
+ ## Run this repository's example
44
+
45
+ Use two terminals:
46
+
47
+ ```sh
48
+ bun atlas # starts the project-agnostic Atlas node at http://localhost:5180
49
+ bun example:run # runs tests and streams results, current steps and progress as they execute
50
+ ```
51
+
52
+ The server starts independently with data in `./atlas-data`; it never loads example specs or reports. `bun example:run` publishes the Todo example under `todo-app`. Select a project using the top-right picker, then use the spec dropdown breadcrumbs to navigate. Other projects set `ATLAS_SERVER_URL`, `ATLAS_PROJECT` (for example `checkout-service`) and their publish token. Every uploader talks to the node; storage credentials stay on the server.
53
+
54
+ Every Gherkin scenario runs, including sharing and renaming. Test reports show running spinners,
55
+ live step logs and a Running status filter. Producers send heartbeats every
56
+ 5 seconds. A run becomes interrupted after 30 seconds without a heartbeat, or 15 minutes without progress;
57
+ configure `ATLAS_RUN_TIMEOUT_SECONDS` and `ATLAS_RUN_STALL_SECONDS` (0 disables the progress timeout),
58
+ or edit these settings in the client. An interruption never turns missing outcomes into passes.
59
+
43
60
  ## For AI agents
44
61
 
45
62
  Atlas is built to be integrated and operated by agents, with nothing to click and nothing to guess.
@@ -105,10 +122,10 @@ bun add -d @crvouga/atlas
105
122
  | [`@crvouga/atlas/webdriver`](packages/webdriver) | W3C WebDriver driver with no dependencies: Safari, Chrome, Firefox and Edge, and native iOS and Android apps through Appium. |
106
123
  | [`@crvouga/atlas/detox`](packages/detox) | Detox driver for iOS and Android apps, inside a Detox test run. |
107
124
  | [`@crvouga/atlas/vitest`](packages/vitest) | Run a config as a test suite in Vitest, `bun test` or any Jest-style runner, one test per planned path. |
108
- | [`atlas-visualizer`](packages/visualizer) (command) | Browse specs, charts and layered reports: every spec with its result per environment, every state with its screenshots, every transition with its clip. |
125
+ | [`atlas client`](packages/visualizer) (command) | Browse specs, charts and layered reports: every spec with its result per environment, every state with its screenshots, every transition with its clip. |
109
126
 
110
127
  Two commands install with it: `atlas` (`report`, `lint`, `plan`, `run`, `merge`, `export`) and
111
- `atlas-visualizer`.
128
+ `atlas client`.
112
129
 
113
130
  ## Reports for any spec
114
131
 
@@ -147,10 +164,10 @@ has results. `--format json` is a [stable schema](packages/schema/schemas/report
147
164
  means the report does not pass; 2 means the command itself was wrong (a missing store directory, a bad
148
165
  flag).
149
166
 
150
- **The server** is `atlas serve`: one process that serves the viewer, takes in results and streams the report to
151
- every viewer. Point `ATLAS_STORE` at it and every developer's laptop and every CI job sends results with the same
167
+ **The server** is `atlas serve`: one process that serves the client, takes in results and streams the report to
168
+ every client. Point `ATLAS_STORE` at it and every developer's laptop and every CI job sends results with the same
152
169
  commands that write to a folder; everyone then looks at one report, live. Results are kept in its data folder
153
- unless you choose a bucket. `atlas dev` runs the same server with the viewer's dev server in front, so
170
+ unless you choose a bucket. `atlas dev` runs the same server with the client's dev server in front, so
154
171
  development is one host too. See [the server](docs/server.md) and
155
172
  [running Atlas for a project](docs/deploying.md).
156
173
 
@@ -163,7 +180,7 @@ my-suite --json | atlas report stream --producer e2e --environment ios # or as
163
180
  atlas report status --branch main --ignore-modified # the gate, answered by the server
164
181
  ```
165
182
 
166
- **The viewer** shows each spec (Gherkin, Quint, DMN, Alloy, OpenSLO, and the rest by file type) with
183
+ **The client** shows each spec (Gherkin, Quint, DMN, Alloy, OpenSLO, and the rest by file type) with
167
184
  its combined result per environment and the layer behind it. See
168
185
  [integrating Atlas](docs/integrating.md#6-browse-it).
169
186
 
@@ -190,7 +207,7 @@ understand, or a JSON Schema it publishes.
190
207
  | [JUnit XML](https://github.com/testmoapp/junitxml) | Read: any test runner's results become a layer. Written: `junit.xml`, one test case per statechart path. |
191
208
  | [CTRF](https://ctrf.io) | Read: any CTRF report becomes a layer. Written: `ctrf.json` for each statechart run. |
192
209
  | [JSON Schema 2020-12](https://json-schema.org/draft/2020-12) | The contract: layers, check files, report status, run manifests, journeys, state nodes, timelines and the runs index (`@crvouga/atlas/schemas/*.schema.json`) |
193
- | [Gherkin](https://cucumber.io/docs/gherkin/), [Quint](https://quint-lang.org), [DMN](https://www.omg.org/spec/DMN), [Alloy](https://alloytools.org), [OpenSLO](https://openslo.com) | Spec kinds the viewer lays results over, each in its own tab. Any other file is a spec of its own type. |
210
+ | [Gherkin](https://cucumber.io/docs/gherkin/), [Quint](https://quint-lang.org), [DMN](https://www.omg.org/spec/DMN), [Alloy](https://alloytools.org), [OpenSLO](https://openslo.com) | Spec kinds the client lays results over, each in its own tab. Any other file is a spec of its own type. |
194
211
  | [W3C SCXML](https://www.w3.org/TR/scxml/) | Statecharts (`*.scxml`), with business metadata in an `atlas` XML namespace |
195
212
  | [XState v5](https://stately.ai/docs/machines) machine config | Statecharts (`*.machine.json`, `*.machine.yaml`): the JSON shape Stately Studio imports and exports |
196
213
  | [WebVTT](https://www.w3.org/TR/webvtt1/) | Captions for every clip: each tap, keystroke, system call and screen change |
@@ -228,3 +245,5 @@ in **[Executable statecharts](docs/statecharts.md)**, which starts with a runnab
228
245
  ## License
229
246
 
230
247
  [MIT](LICENSE)
248
+
249
+ Native [CodeceptJS, Mocha, Jest and Node test reporters](docs/runners.md) upload directly to the project server. Open **Settings** in the client to configure storage and report settings for the next restart.
@@ -9,6 +9,6 @@
9
9
  * @see docs/reference/cli.md
10
10
  */
11
11
  /** The text `atlas --help` prints: every command, and the flags of the statechart commands. */
12
- export declare const USAGE = "atlas \u2014 executable statecharts\n\n atlas lint [--config atlas.config.ts | --specs <dir>] [--require-implementations]\n atlas plan [--config atlas.config.ts] [--state <name>]\n atlas run [--config atlas.config.ts] [--mode fast|showcase] [--workers n] [--state <name>]\n [--seed <name>]... [--paths id,id] [--shard i/n] [--rerun-failed | --reuse <run-dir>]\n [--no-retry] [--output <dir>]\n atlas merge [--config atlas.config.ts] [--output <dir>] <run-dir> <run-dir>...\n atlas export --specs <dir> --format scxml|mermaid|markdown|json [--out <file>]\n atlas serve [--data <dir>] [--port 8080] [--store <url>]... the Atlas server: results in, the live report out\n atlas dev [--port 5180] the server and the viewer's dev server, on one host\n atlas init [--server <url>] [--write] integrate this project: detect its suites, print the plan (atlas init --help)\n atlas report publish|stream|status|merge|doctor|policy \u2026 layered evidence for any kind of spec (atlas report --help)\n\n --workers path attempts that run at once (default: one per CPU, capped by the driver)\n --seed only paths that start from this seed (\"start\" for paths setup starts); repeatable\n --shard 2/4 run the second quarter of the plan; merge the shards' runs afterwards\n --rerun-failed keep the newest run's passed paths and run only the rest\n";
12
+ export declare const USAGE = "atlas \u2014 executable statecharts\n\n atlas lint [--config atlas.config.ts | --specs <dir>] [--require-implementations]\n atlas plan [--config atlas.config.ts] [--state <name>]\n atlas run [--config atlas.config.ts] [--mode fast|showcase] [--workers n] [--state <name>]\n [--seed <name>]... [--paths id,id] [--shard i/n] [--rerun-failed | --reuse <run-dir>]\n [--no-retry] [--output <dir>]\n atlas merge [--config atlas.config.ts] [--output <dir>] <run-dir> <run-dir>...\n atlas export --specs <dir> --format scxml|mermaid|markdown|json [--out <file>]\n atlas server [--data <dir>] [--port 8080] [--store <url>]... one Atlas node: API, projects and client\n atlas client build [--no-data] [--out <dir>] build the project-agnostic Atlas client\n atlas dev [--port 5180] develop the Atlas server and client on one host\n atlas init [--server <url>] [--write] integrate this project: detect its suites, print the plan (atlas init --help)\n atlas report publish|stream|status|merge|doctor|policy \u2026 layered evidence for any kind of spec (atlas report --help)\n\n --workers path attempts that run at once (default: one per CPU, capped by the driver)\n --seed only paths that start from this seed (\"start\" for paths setup starts); repeatable\n --shard 2/4 run the second quarter of the plan; merge the shards' runs afterwards\n --rerun-failed keep the newest run's passed paths and run only the rest\n";
13
13
  /** Runs `atlas` with `argv` (default: the process arguments) and returns the exit code. Throws for a command that is wrong, which the `atlas` binary reports as a message and exit code 2. */
14
14
  export declare function main(argv?: string[]): Promise<number>;
@@ -28,8 +28,9 @@ export const USAGE = `atlas — executable statecharts
28
28
  [--no-retry] [--output <dir>]
29
29
  atlas merge [--config atlas.config.ts] [--output <dir>] <run-dir> <run-dir>...
30
30
  atlas export --specs <dir> --format scxml|mermaid|markdown|json [--out <file>]
31
- atlas serve [--data <dir>] [--port 8080] [--store <url>]... the Atlas server: results in, the live report out
32
- atlas dev [--port 5180] the server and the viewer's dev server, on one host
31
+ atlas server [--data <dir>] [--port 8080] [--store <url>]... one Atlas node: API, projects and client
32
+ atlas client build [--no-data] [--out <dir>] build the project-agnostic Atlas client
33
+ atlas dev [--port 5180] develop the Atlas server and client on one host
33
34
  atlas init [--server <url>] [--write] integrate this project: detect its suites, print the plan (atlas init --help)
34
35
  atlas report publish|stream|status|merge|doctor|policy … layered evidence for any kind of spec (atlas report --help)
35
36
 
@@ -88,10 +89,14 @@ async function loadConfig(file) {
88
89
  export async function main(argv = process.argv.slice(2)) {
89
90
  const [command] = argv;
90
91
  // Loaded on demand: lint, plan and run do not pay for the reports.
91
- if (command === 'serve') {
92
+ if (command === 'serve' || command === 'server') {
92
93
  const { serveMain } = await import('../../server/src/main.js');
93
94
  return serveMain(argv.slice(1), { out: text => process.stdout.write(text) });
94
95
  }
96
+ if (command === 'client') {
97
+ const { clientMain } = await import('../../server/src/site.js');
98
+ return clientMain(argv.slice(1));
99
+ }
95
100
  if (command === 'dev') {
96
101
  const { devMain } = await import('../../server/src/dev.js');
97
102
  return devMain(argv.slice(1), { out: text => process.stdout.write(text) });
@@ -1,3 +1,4 @@
1
+ import { type LiveReporterOptions } from '../../reports/src/live-reporter.js';
1
2
  import type { StateValue } from './graph.js';
2
3
  import type { Shard } from './run/execute.js';
3
4
  import type { Driver, EventMatcher, EventSource, Implementation, PrivacyRules, RunMode } from './run/types.js';
@@ -8,6 +9,8 @@ import type { Driver, EventMatcher, EventSource, Implementation, PrivacyRules, R
8
9
  export type AtlasConfig<C = unknown> = {
9
10
  /** The directory holding the charts (`*.scxml`, `*.machine.{json,yaml}`) and `journeys.yaml`. */
10
11
  specs: string;
12
+ /** Live reporting endpoint. Defaults to ATLAS_SERVER_URL when set; false disables streaming. */
13
+ reporting?: LiveReporterOptions | false;
11
14
  /** The chart to run; the root chart when omitted. */
12
15
  chart?: string;
13
16
  /** Where generated paths start (an XState state value); the initial state when omitted. */
@@ -125,6 +128,8 @@ export declare function prepare<C>(config: AtlasConfig<C>, baseDirectory?: strin
125
128
  export type RunCommandOptions = PrepareOptions & {
126
129
  /** `fast` when omitted. */
127
130
  mode?: RunMode;
131
+ /** Override live reporting. Defaults to config.reporting or ATLAS_SERVER_URL. */
132
+ reporting?: LiveReporterOptions | false;
128
133
  /** Only these paths, by id. */
129
134
  paths?: string[];
130
135
  /** Only paths that start from these seeds ("start" for paths that `setup` starts). */
@@ -260,6 +265,7 @@ export declare function runConfig<C>(config: AtlasConfig<C>, options?: RunComman
260
265
  key: string;
261
266
  name: string;
262
267
  kind: import('./plan.js').PlannedPath["kind"];
268
+ progress?: "queued" | "running" | "complete";
263
269
  status: import('./index.js').Status;
264
270
  attempts: {
265
271
  status: "passed" | "failed";
@@ -458,6 +464,7 @@ export declare function mergeConfigRuns<C>(config: AtlasConfig<C>, runDirs: stri
458
464
  key: string;
459
465
  name: string;
460
466
  kind: import('./plan.js').PlannedPath["kind"];
467
+ progress?: "queued" | "running" | "complete";
461
468
  status: import('./index.js').Status;
462
469
  attempts: {
463
470
  status: "passed" | "failed";
@@ -11,6 +11,11 @@
11
11
  */
12
12
  import { availableParallelism } from 'node:os';
13
13
  import path from 'node:path';
14
+ import { createLiveReporter } from '../../reports/src/live-reporter.js';
15
+ import { httpStore } from '../../reports/src/http-store.js';
16
+ import { publishChecks } from '../../reports/src/publish-checks.js';
17
+ import { publishRunBundle } from '../../reports/src/run-bundle.js';
18
+ import { checksFromManifest } from '../../reports/src/ingest.js';
14
19
  import { lintBundle } from './lint.js';
15
20
  import { chartScope, planChart } from './plan.js';
16
21
  import { assertAllowedTarget, mergePrivacy } from './privacy.js';
@@ -129,20 +134,49 @@ export async function runConfig(config, options = {}, baseDirectory = process.cw
129
134
  const reused = reuseDir ? readOutcomes(reuseDir) : null;
130
135
  if (options.reuse && !reused)
131
136
  throw new Error(`There is no earlier run to reuse${reuseDir ? ` at ${reuseDir}` : ''}`);
132
- return runPaths({
133
- ...contextOf(config, prepared),
134
- paths,
135
- driver,
136
- mode: options.mode ?? 'fast',
137
- outputRoot,
138
- ...(options.retry === undefined ? {} : { retry: options.retry }),
139
- ...(config.stepTimeoutMs === undefined ? {} : { stepTimeoutMs: config.stepTimeoutMs }),
140
- eventSources: config.eventSources ? await config.eventSources() : [],
141
- workers: options.workers ?? config.workers ?? availableParallelism(),
142
- ...(options.shard ? { shard: options.shard } : {}),
143
- ...(reused && reuseDir ? { reuse: { runDir: reuseDir, outcomes: reused.outcomes } } : {}),
144
- ...(options.log ? { log: options.log } : {})
145
- });
137
+ const requestedReporting = options.reporting ?? config.reporting ?? (process.env.ATLAS_SERVER_URL ? { url: process.env.ATLAS_SERVER_URL, token: process.env.ATLAS_PUBLISH_TOKEN ?? process.env.ATLAS_TOKEN } : undefined);
138
+ const reporting = requestedReporting ? { ...requestedReporting, project: requestedReporting.project ?? process.env.ATLAS_PROJECT, token: requestedReporting.token ?? process.env.ATLAS_PUBLISH_TOKEN ?? process.env.ATLAS_TOKEN } : undefined;
139
+ let live;
140
+ const onProgress = reporting ? (manifest) => {
141
+ if (!manifest.privacy.passed)
142
+ return;
143
+ live ??= createLiveReporter({ producer: process.env.ATLAS_PRODUCER ?? 'atlas-statecharts', title: `${prepared.scope.chart} · ${options.mode ?? 'fast'}`,
144
+ plannedChecks: paths.length, environment: process.env.ATLAS_ENVIRONMENT ?? 'desktop-web', branch: manifest.run.branch ?? undefined, sha: manifest.run.commit ?? undefined }, reporting);
145
+ const ingested = checksFromManifest(manifest);
146
+ ingested.checks.forEach((check, index) => {
147
+ if (manifest.paths[index]?.progress === 'queued' && !manifest.run.finishedAt)
148
+ return;
149
+ live.check({ ...check, steps: check.steps?.slice(-20).map(step => ({ name: step.name, status: step.status })) });
150
+ });
151
+ } : undefined;
152
+ try {
153
+ const outcome = await runPaths({
154
+ ...contextOf(config, prepared),
155
+ paths,
156
+ driver,
157
+ mode: options.mode ?? 'fast',
158
+ outputRoot,
159
+ ...(options.retry === undefined ? {} : { retry: options.retry }),
160
+ ...(config.stepTimeoutMs === undefined ? {} : { stepTimeoutMs: config.stepTimeoutMs }),
161
+ eventSources: config.eventSources ? await config.eventSources() : [],
162
+ workers: options.workers ?? config.workers ?? availableParallelism(),
163
+ ...(options.shard ? { shard: options.shard } : {}),
164
+ ...(reused && reuseDir ? { reuse: { runDir: reuseDir, outcomes: reused.outcomes } } : {}),
165
+ ...(options.log ? { log: options.log } : {}), ...(onProgress ? { onProgress } : {})
166
+ });
167
+ await live?.finish(outcome.manifest.privacy.passed ? 'completed' : 'interrupted', outcome.manifest.privacy.passed ? undefined : 'Run failed its privacy scan; unsafe progress was withheld.');
168
+ if (reporting && outcome.manifest.privacy.passed) {
169
+ const store = httpStore({ url: reporting.url, project: reporting.project, token: reporting.token });
170
+ await publishChecks(store, { producer: process.env.ATLAS_PRODUCER ?? 'atlas-statecharts', environment: process.env.ATLAS_ENVIRONMENT ?? 'desktop-web',
171
+ branch: outcome.manifest.run.branch, sha: outcome.manifest.run.commit, recordedAtMs: Math.max(Date.now(), (live?.finishedAtMs ?? 0) + 1), checks: checksFromManifest(outcome.manifest).checks.map(check => ({ ...check, measurements: [...check.measurements] })) });
172
+ await publishRunBundle(store, outcome.runDir);
173
+ }
174
+ return outcome;
175
+ }
176
+ catch (error) {
177
+ await live?.finish('interrupted', 'The producer exited before completing the run.').catch(() => { });
178
+ throw error;
179
+ }
146
180
  }
147
181
  /**
148
182
  * Composes runs of a config (its shards, or a rerun and the run before it) into one new run
@@ -58,6 +58,8 @@ export type RunOptions<C> = RunContext<C> & {
58
58
  };
59
59
  /** Receives progress lines; writes to stdout when omitted. */
60
60
  log?: (line: string) => void;
61
+ /** Receives privacy-checked manifest snapshots as paths and steps change. */
62
+ onProgress?: (manifest: Manifest) => void;
61
63
  };
62
64
  type StepResult = {
63
65
  transition: string;
@@ -268,6 +270,7 @@ export declare function buildManifest<C>(context: RunContext<C>, paths: PlannedP
268
270
  key: string;
269
271
  name: string;
270
272
  kind: PlannedPath["kind"];
273
+ progress?: "queued" | "running" | "complete";
271
274
  status: Status;
272
275
  attempts: Attempt[];
273
276
  steps: StepResult[];
@@ -447,6 +450,7 @@ export declare function writeRun<C>(runDir: string, context: RunContext<C>, path
447
450
  key: string;
448
451
  name: string;
449
452
  kind: PlannedPath["kind"];
453
+ progress?: "queued" | "running" | "complete";
450
454
  status: Status;
451
455
  attempts: Attempt[];
452
456
  steps: StepResult[];
@@ -638,6 +642,7 @@ export declare function mergeRuns<C>(context: RunContext<C>, paths: PlannedPath[
638
642
  key: string;
639
643
  name: string;
640
644
  kind: PlannedPath["kind"];
645
+ progress?: "queued" | "running" | "complete";
641
646
  status: Status;
642
647
  attempts: Attempt[];
643
648
  steps: StepResult[];
@@ -832,6 +837,7 @@ export declare function runPaths<C>(options: RunOptions<C>): Promise<{
832
837
  key: string;
833
838
  name: string;
834
839
  kind: PlannedPath["kind"];
840
+ progress?: "queued" | "running" | "complete";
835
841
  status: Status;
836
842
  attempts: Attempt[];
837
843
  steps: StepResult[];
@@ -891,7 +891,8 @@ export async function runPaths(options) {
891
891
  }
892
892
  });
893
893
  function publish() {
894
- writeRun(runDir, options, options.paths, new Map([...outcomes, ...activeOutcomes]), runInfo(false));
894
+ const snapshot = writeRun(runDir, options, options.paths, new Map([...outcomes, ...activeOutcomes]), runInfo(false));
895
+ options.onProgress?.(snapshot);
895
896
  }
896
897
  const history = durationHistory(options.outputRoot);
897
898
  const cost = (p) => history[p.key] ?? estimateMs(p);
@@ -899,7 +900,7 @@ export async function runPaths(options) {
899
900
  if (reused.size)
900
901
  log(`Reusing ${reused.size} passed path(s) from ${path.basename(options.reuse.runDir)}`);
901
902
  log(`Running ${queue.length} path(s) on ${workers} worker(s)`);
902
- writeRun(runDir, options, options.paths, outcomes, runInfo(false));
903
+ options.onProgress?.(writeRun(runDir, options, options.paths, outcomes, runInfo(false)));
903
904
  // A driver can throw outside any awaited call (a message for a context that just closed); it
904
905
  // belongs to one attempt, so it is logged and the run goes on instead of the process dying.
905
906
  const stray = (error) => log(` (ignored an error thrown outside any step: ${(error instanceof Error ? error.message : String(error)).split('\n')[0]})`);
@@ -924,5 +925,6 @@ export async function runPaths(options) {
924
925
  await driver.dispose?.();
925
926
  await Promise.all(sources.map((s) => s.close?.()));
926
927
  const manifest = writeRun(runDir, options, options.paths, outcomes, runInfo(true));
928
+ options.onProgress?.(manifest);
927
929
  return { runDir, manifest, ok: manifest.privacy.passed && manifest.paths.every((r) => r.status !== 'failed') };
928
930
  }
@@ -16,7 +16,7 @@ export type Check = {
16
16
  readonly detail: string;
17
17
  readonly fix?: string;
18
18
  };
19
- /** The least an account needs, as an AWS IAM policy. R2, MinIO and the rest have their own policy languages: grant the same actions. */
19
+ /** The least an account needs, as an AWS IAM policy. The combined server role also stores operational leases under server/live/. R2, MinIO and the rest have their own policy languages: grant the same actions. */
20
20
  export declare function iamPolicy(location: {
21
21
  bucket: string;
22
22
  prefix: string;
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { LAYER_INDEX } from './layer-store.js';
12
12
  import { verifyObjectStore } from './object-store-conformance.js';
13
- /** The least an account needs, as an AWS IAM policy. R2, MinIO and the rest have their own policy languages: grant the same actions. */
13
+ /** The least an account needs, as an AWS IAM policy. The combined server role also stores operational leases under server/live/. R2, MinIO and the rest have their own policy languages: grant the same actions. */
14
14
  export function iamPolicy(location, role) {
15
15
  const base = location.prefix ? `${location.prefix}/` : '';
16
16
  const bucketArn = `arn:aws:s3:::${location.bucket}`;
@@ -21,6 +21,8 @@ export function iamPolicy(location, role) {
21
21
  if (role !== 'publisher') {
22
22
  statements.push({ Sid: 'AtlasRead', Effect: 'Allow', Action: 's3:GetObject', Resource: `${bucketArn}/${base}layers/*` });
23
23
  }
24
+ if (role === 'both')
25
+ statements.push({ Sid: 'AtlasLiveRuns', Effect: 'Allow', Action: ['s3:GetObject', 's3:PutObject'], Resource: `${bucketArn}/${base}server/live/*` }, { Sid: 'AtlasProjects', Effect: 'Allow', Action: ['s3:GetObject', 's3:PutObject', 's3:DeleteObject'], Resource: [`${bucketArn}/${base}projects/*`, `${bucketArn}/${base}server/projects/*`] });
24
26
  // Publishers need this too: without it S3 answers 403, not 404, when asked whether a layer exists yet.
25
27
  statements.push({ Sid: 'AtlasList', Effect: 'Allow', Action: 's3:ListBucket', Resource: bucketArn, // IfExists: a listing always carries a prefix and is held to it; a HEAD or GET of one object carries none, and
26
28
  // must still be answered "not found" rather than "forbidden".
@@ -11,6 +11,8 @@
11
11
  import type { ObjectStore, PagedListing } from './object-store.js';
12
12
  /** How to reach a server: its `url`, the `token` to send (never in the URL), and, for tests and special runtimes, `fetch`, the retry count, the per-attempt timeout and `sleep`. */
13
13
  export type HttpStoreOptions = {
14
+ /** Project namespace on a shared Atlas node. Omit for the legacy default project. */
15
+ readonly project?: string;
14
16
  /** The Atlas server, such as `https://atlas.example.com`. A path on it (`/atlas`) is kept. */
15
17
  readonly url: string;
16
18
  /** A publish or admin token to write with, a read token to read with. Never put it in the URL. */
@@ -30,6 +32,8 @@ export declare class AtlasHttpError extends Error {
30
32
  }
31
33
  /** An `ObjectStore` (with paged listing) over an Atlas server, plus what only a server can answer: `ping` (is it there, and what may this token do) and `status` (the gate, worked out by the server). */
32
34
  export type HttpStore = ObjectStore & PagedListing & {
35
+ /** The same authenticated node connection, scoped to another project. */
36
+ forProject(project: string): HttpStore;
33
37
  /** `https://atlas.example.com`, with no secrets. */
34
38
  readonly label: string;
35
39
  readonly url: string;
@@ -9,6 +9,7 @@
9
9
  * @see docs/server.md#the-store-protocol
10
10
  */
11
11
  import { ObjectStoreWithPrefix } from './store-prefix.js';
12
+ import { ProjectSlugSchema } from '../../schema/src/project.js';
12
13
  /** What an Atlas server said no to, with what to do about it. */
13
14
  export class AtlasHttpError extends Error {
14
15
  status;
@@ -40,7 +41,8 @@ function explain(status, base, message) {
40
41
  */
41
42
  export function httpStore(options) {
42
43
  const base = options.url.replace(/\/+$/, '');
43
- const root = `${base}/api/store`;
44
+ const api = options.project ? `${base}/api/projects/${ProjectSlugSchema.parse(options.project)}` : `${base}/api`;
45
+ const root = `${api}/store`;
44
46
  const doFetch = options.fetch ?? fetch;
45
47
  const retries = options.retries ?? 4;
46
48
  const timeoutMs = options.timeoutMs ?? 30_000;
@@ -66,7 +68,7 @@ export function httpStore(options) {
66
68
  await response.body?.cancel();
67
69
  return null;
68
70
  }
69
- const said = await response.json().then((body) => body.error ?? null, () => null);
71
+ const said = await response.json().then((body) => body && typeof body === 'object' && 'error' in body && typeof body.error === 'string' ? body.error : null, () => null);
70
72
  failure = new AtlasHttpError(explain(response.status, base, said), response.status, response.status >= 500 || response.status === 429 || response.status === 408);
71
73
  }
72
74
  catch (error) {
@@ -78,6 +80,7 @@ export function httpStore(options) {
78
80
  }
79
81
  }
80
82
  const store = {
83
+ forProject: project => httpStore({ ...options, project }),
81
84
  label: base,
82
85
  url: base,
83
86
  async get(key) {
@@ -127,7 +130,7 @@ export function httpStore(options) {
127
130
  },
128
131
  async status(query = new URLSearchParams()) {
129
132
  const suffix = query.size ? `?${query}` : '';
130
- return (await request('GET', `${base}/api/status${suffix}`)).json();
133
+ return (await request('GET', `${api}/status${suffix}`)).json();
131
134
  },
132
135
  async ping() {
133
136
  const started = performance.now();
@@ -88,6 +88,9 @@ function runnersOf(directory, pkg) {
88
88
  dep('cypress', 'cypress');
89
89
  dep('jest', 'jest');
90
90
  dep('mocha', 'mocha');
91
+ dep('codeceptjs', 'codeceptjs');
92
+ if (/\bnode\s+--test\b/.test(scripts))
93
+ found.push({ runner: 'node-test', evidence: 'package.json scripts: node --test' });
91
94
  if (!found.some(item => item.runner === 'vitest' || item.runner === 'jest' || item.runner === 'mocha') && /\bbun test\b/.test(scripts))
92
95
  found.push({ runner: 'bun-test', evidence: 'package.json scripts: bun test' });
93
96
  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'))
@@ -128,6 +131,8 @@ function suiteOf(detected, context) {
128
131
  case 'cypress': return make('cypress', out('cypress'), `${context.exec} cypress run --reporter junit --reporter-options mochaFile=${out('cypress')}`, 'desktop-web');
129
132
  case 'cucumber': return make('cucumber', out('cucumber'), `${context.exec} cucumber-js --format summary --format junit:${out('cucumber')}`, null);
130
133
  case 'mocha': return make('mocha', out('mocha'), `MOCHA_FILE=${out('mocha')} ${context.exec} mocha --reporter mocha-junit-reporter`, null, [`${context.install} mocha-junit-reporter`]);
134
+ 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`]);
135
+ case 'node-test': return make('node-test', out('node-test'), `node --test --test-reporter=junit --test-reporter-destination=${out('node-test')}`, null);
131
136
  case 'bun-test': return make('bun-test', out('bun'), `bun test --reporter=junit --reporter-outfile=${out('bun')}`, null);
132
137
  case 'pytest': return make('pytest', out('pytest'), `pytest --junitxml=${out('pytest')}`, null);
133
138
  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']);
@@ -232,7 +237,7 @@ export async function planInit(options) {
232
237
  const context = { exec, install: INSTALL_DEV[manager ?? 'npm'], specs: specs.directory, server: serverUrl };
233
238
  const suites = runnersOf(directory, pkg).map(found => suiteOf(found, context));
234
239
  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>`.');
240
+ 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
241
  if (!serverUrl)
237
242
  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
243
  if (!specs.directory)
@@ -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
+ }