@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.
- package/CHANGELOG.md +6 -0
- package/README.md +30 -11
- package/dist/core/src/cli.d.ts +1 -1
- package/dist/core/src/cli.js +8 -3
- package/dist/core/src/config.d.ts +7 -0
- package/dist/core/src/config.js +48 -14
- package/dist/core/src/run/execute.d.ts +6 -0
- package/dist/core/src/run/execute.js +4 -2
- package/dist/reports/src/doctor.d.ts +1 -1
- package/dist/reports/src/doctor.js +3 -1
- package/dist/reports/src/http-store.d.ts +4 -0
- package/dist/reports/src/http-store.js +6 -3
- package/dist/reports/src/init.js +6 -1
- package/dist/reports/src/live-reporter.d.ts +34 -0
- package/dist/reports/src/live-reporter.js +122 -0
- package/dist/reports/src/report-cli.d.ts +1 -1
- package/dist/reports/src/report-cli.js +3 -2
- package/dist/reports/src/store-url.js +1 -1
- package/dist/runners/src/codecept.d.ts +14 -0
- package/dist/runners/src/codecept.js +16 -0
- package/dist/runners/src/jest.d.ts +50 -0
- package/dist/runners/src/jest.js +52 -0
- package/dist/runners/src/mocha.d.ts +40 -0
- package/dist/runners/src/mocha.js +39 -0
- package/dist/runners/src/node-test.d.ts +28 -0
- package/dist/runners/src/node-test.js +44 -0
- package/dist/runners/src/publish.d.ts +30 -0
- package/dist/runners/src/publish.js +68 -0
- package/dist/schema/src/api.d.ts +1 -0
- package/dist/schema/src/api.js +1 -1
- package/dist/schema/src/index.d.ts +2 -0
- package/dist/schema/src/index.js +2 -0
- package/dist/schema/src/live-run.d.ts +123 -0
- package/dist/schema/src/live-run.js +30 -0
- package/dist/schema/src/manifest.d.ts +2 -2
- package/dist/schema/src/project.d.ts +20 -0
- package/dist/schema/src/project.js +10 -0
- package/dist/schema/src/report-sources.d.ts +1 -0
- package/dist/schema/src/report-sources.js +2 -1
- package/dist/schema/src/runs-index.d.ts +1 -1
- package/dist/schema/src/server-config.d.ts +84 -0
- package/dist/schema/src/server-config.js +12 -0
- package/dist/server/src/app.d.ts +10 -0
- package/dist/server/src/app.js +148 -10
- package/dist/server/src/config.d.ts +1 -1
- package/dist/server/src/config.js +20 -3
- package/dist/server/src/dev.d.ts +3 -3
- package/dist/server/src/dev.js +22 -13
- package/dist/server/src/events.d.ts +3 -3
- package/dist/server/src/events.js +1 -1
- package/dist/server/src/index.d.ts +2 -0
- package/dist/server/src/index.js +2 -0
- package/dist/server/src/live-runs.d.ts +52 -0
- package/dist/server/src/live-runs.js +229 -0
- package/dist/server/src/main.d.ts +1 -1
- package/dist/server/src/main.js +7 -5
- package/dist/server/src/openapi.d.ts +2 -5
- package/dist/server/src/openapi.js +71 -7
- package/dist/server/src/projects.d.ts +3 -0
- package/dist/server/src/projects.js +154 -0
- package/dist/server/src/settings-page.d.ts +9 -0
- package/dist/server/src/settings-page.js +93 -0
- package/dist/server/src/settings.d.ts +23 -0
- package/dist/server/src/settings.js +72 -0
- package/dist/server/src/site.d.ts +8 -6
- package/dist/server/src/site.js +45 -10
- package/dist/server/src/store-api.js +43 -22
- package/docs/deploying.md +19 -19
- package/docs/dogfooding.md +2 -2
- package/docs/extending.md +3 -3
- package/docs/integrating.md +9 -9
- package/docs/reference/cli.md +9 -7
- package/docs/reference/core.md +6 -0
- package/docs/reference/http-api.md +498 -2
- package/docs/reference/reports.md +55 -1
- package/docs/reference/runners.md +169 -0
- package/docs/reference/schema.md +111 -0
- package/docs/reference/server.md +154 -14
- package/docs/report-backends.md +8 -8
- package/docs/report-contract.md +7 -7
- package/docs/reporting-issues.md +3 -3
- package/docs/runners.md +69 -0
- package/docs/server.md +61 -25
- package/docs/statecharts.md +22 -20
- package/llms-full.txt +713 -105
- package/llms.txt +3 -1
- package/package.json +31 -9
- package/packages/runners/README.md +6 -0
- package/packages/runners/compat/codecept.cjs +2 -0
- package/packages/runners/compat/jest.cjs +2 -0
- package/packages/runners/compat/mocha.cjs +2 -0
- package/packages/schema/schemas/atlas-change.schema.json +4 -0
- package/packages/schema/schemas/hello-event.schema.json +4 -0
- package/packages/schema/schemas/live-run-start.schema.json +42 -0
- package/packages/schema/schemas/live-run-update.schema.json +93 -0
- package/packages/schema/schemas/live-run.schema.json +225 -0
- package/packages/schema/schemas/project-slug.schema.json +8 -0
- package/packages/schema/schemas/project.schema.json +22 -0
- package/packages/schema/schemas/projects.schema.json +33 -0
- package/packages/schema/schemas/server-config.schema.json +14 -0
- package/packages/schema/schemas/server-editable-settings.schema.json +132 -0
- package/packages/schema/schemas/server-settings-snapshot.schema.json +285 -0
- package/packages/server/openapi.json +5101 -779
- package/packages/visualizer/README.md +13 -13
- package/packages/visualizer/bin/atlas-client.mjs +176 -0
- package/packages/visualizer/bin/atlas-visualizer.mjs +2 -175
- package/packages/visualizer/src/app/AppShell.tsx +26 -17
- package/packages/visualizer/src/app/report-sources.tsx +2 -1
- package/packages/visualizer/src/components/ActivityIndicator.module.css +4 -0
- package/packages/visualizer/src/components/ActivityIndicator.tsx +6 -0
- package/packages/visualizer/src/components/Header.module.css +101 -227
- package/packages/visualizer/src/components/Header.tsx +79 -41
- package/packages/visualizer/src/components/ProjectPicker.tsx +64 -0
- package/packages/visualizer/src/data/adapters/report-backend.test.ts +3 -2
- package/packages/visualizer/src/data/adapters/report-backend.ts +1 -1
- package/packages/visualizer/src/data/queries.ts +14 -7
- package/packages/visualizer/src/evidence/api.ts +2 -1
- package/packages/visualizer/src/evidence/check-detail.tsx +19 -4
- package/packages/visualizer/src/evidence/document-page.tsx +6 -2
- package/packages/visualizer/src/evidence/evidence-context.tsx +17 -12
- package/packages/visualizer/src/evidence/evidence.module.css +11 -1
- package/packages/visualizer/src/evidence/feature-page.tsx +7 -3
- package/packages/visualizer/src/evidence/model-page.tsx +6 -2
- package/packages/visualizer/src/evidence/project-home.tsx +45 -0
- package/packages/visualizer/src/evidence/recording-page.tsx +4 -1
- package/packages/visualizer/src/evidence/spec-index.tsx +2 -1
- package/packages/visualizer/src/evidence/test-filter.tsx +21 -0
- package/packages/visualizer/src/live/live-query.ts +21 -0
- package/packages/visualizer/src/live/live-runs.tsx +69 -0
- package/packages/visualizer/src/live/live-stream.ts +30 -9
- package/packages/visualizer/src/live/report-progress.ts +62 -0
- package/packages/visualizer/src/live/test-progress.tsx +34 -0
- package/packages/visualizer/src/runtime.ts +17 -1
- package/packages/visualizer/src/state/ui-store.ts +3 -2
- package/packages/visualizer/src/views/ChartPage.tsx +2 -0
- package/packages/visualizer/src/views/ChartTests.tsx +43 -0
- package/packages/visualizer/src/views/SidePanel.tsx +2 -0
- package/packages/visualizer/vite/atlas-files.test.ts +4 -4
- package/packages/visualizer/vite/atlas-files.ts +1 -5
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
151
|
-
every
|
|
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
|
|
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
|
|
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
|
|
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.
|
package/dist/core/src/cli.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/core/src/cli.js
CHANGED
|
@@ -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
|
|
32
|
-
atlas
|
|
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";
|
package/dist/core/src/config.js
CHANGED
|
@@ -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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
|
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', `${
|
|
133
|
+
return (await request('GET', `${api}/status${suffix}`)).json();
|
|
131
134
|
},
|
|
132
135
|
async ping() {
|
|
133
136
|
const started = performance.now();
|
package/dist/reports/src/init.js
CHANGED
|
@@ -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
|
+
}
|