streamtrust 0.0.0-stage → 0.1.0-preview.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +167 -2
- package/bin/streamtrust.js +3 -0
- package/examples/demo-app.mjs +107 -0
- package/examples/http.adapter.mjs +173 -0
- package/package.json +49 -4
- package/src/cli.d.ts +1 -0
- package/src/cli.js +178 -0
- package/src/index.d.ts +76 -0
- package/src/index.js +232 -0
- package/src/worker.js +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 StreamTrust contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,168 @@
|
|
|
1
|
-
#
|
|
1
|
+
# StreamTrust CLI — pilot release
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Adapter-driven CI checks for streaming applications: compare observed output with authoritative state, exercise cancellation, and verify retry identity.
|
|
4
|
+
|
|
5
|
+
**Status:** `0.1.0-preview.1`, an experimental preview for independent pilots. APIs and report formats may change before a stable release. The CLI is dependency-free, ESM, and requires Node.js 22+. MIT licensed. Browser and database libraries belong to your adapter and are not installed by StreamTrust.
|
|
6
|
+
|
|
7
|
+
## Install the npm preview
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install --save-dev streamtrust@preview
|
|
11
|
+
npx streamtrust init
|
|
12
|
+
npx streamtrust verify --adapter ./streamtrust/adapter.mjs
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
For reproducible CI, pin `streamtrust@0.1.0-preview.1` and commit your lockfile. The `preview` tag is intentionally separate from a stable release. The generated adapter initially exercises the bundled demonstration; map it to your application before using its results as application evidence.
|
|
16
|
+
|
|
17
|
+
## Run from source
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
git clone https://github.com/santoshsibyala/streamtrust.git
|
|
21
|
+
cd streamtrust
|
|
22
|
+
node bin/streamtrust.js init demo
|
|
23
|
+
node bin/streamtrust.js verify --adapter ./demo/adapter.mjs
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Node.js 22+ is required. No dependency installation is needed to run this CLI or its tests. Run `npm test` to check the source, or `npm pack` to build an installable tarball. This repository contains the standalone CLI; the separate experimental application harnesses are not included.
|
|
27
|
+
|
|
28
|
+
## Quick start: install a locally built tarball
|
|
29
|
+
|
|
30
|
+
Build the tarball with `npm pack` in this repository.
|
|
31
|
+
|
|
32
|
+
Put the supplied tarball in your project's `vendor/` directory, then:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npm install --save-dev ./vendor/streamtrust-0.1.0-preview.1.tgz
|
|
36
|
+
npx streamtrust init
|
|
37
|
+
npx streamtrust verify --adapter ./streamtrust/adapter.mjs
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`init` creates `streamtrust/adapter.mjs` and `streamtrust/demo-app.mjs`. Existing files are never overwritten. Commit your adapted files and package-lock.json.
|
|
41
|
+
|
|
42
|
+
Without `STREAMTRUST_BASE_URL`, the example starts an isolated local HTTP/SSE service with temporary file-backed state. It checks persistence, cancellation and retry, three times each. It requires no API key, browser, Redis or database installation. The fixture is removed at the end of the run.
|
|
43
|
+
|
|
44
|
+
**The demonstration passing says nothing about your own application's reliability.** Replace the route mappings and assertions before using the result as application evidence.
|
|
45
|
+
|
|
46
|
+
Reports appear in `reports/streamtrust/`:
|
|
47
|
+
|
|
48
|
+
- `report.json`: versioned results, named assertions, evidence, metadata, run ID and suite errors.
|
|
49
|
+
- `junit.xml`: test results for CI consumers.
|
|
50
|
+
- `report.md` and `index.html`: human-readable diagnostics.
|
|
51
|
+
|
|
52
|
+
## Connect your application
|
|
53
|
+
|
|
54
|
+
The generated adapter is executable JavaScript. Map each scenario to your app's native operations and persistence contract. The bundled example uses this teaching protocol:
|
|
55
|
+
|
|
56
|
+
| Operation | Example mapping |
|
|
57
|
+
|---|---|
|
|
58
|
+
| Start | `POST /runs` → `{id, parentId, status, content}` |
|
|
59
|
+
| Observe stream | `GET /runs/:id/events` → SSE `text` and `terminal` JSON events |
|
|
60
|
+
| Release model fixture | `POST /runs/:id/complete` |
|
|
61
|
+
| Cancel | `POST /runs/:id/cancel` → `{accepted}` |
|
|
62
|
+
| Retry | `POST /runs/:id/retry` → a new run linked to the old run |
|
|
63
|
+
| Authoritative read | `GET /runs/:id` → saved state |
|
|
64
|
+
|
|
65
|
+
The completion route is a test-fixture control, not an endpoint you must add to production. An adapter can instead control a deterministic provider, use Playwright to click the real Stop button, or read your database directly. Preserve your app's semantics: for example, regeneration may create a branch instead of replacing history. Do not invent commit receipts or cancellation guarantees your app does not expose.
|
|
66
|
+
|
|
67
|
+
For an existing test service implementing this protocol:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
STREAMTRUST_BASE_URL=http://127.0.0.1:8080 \
|
|
71
|
+
npx streamtrust verify --adapter ./streamtrust/adapter.mjs \
|
|
72
|
+
--output reports/streamtrust --repetitions 3 --timeout-ms 300000
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The example optionally reads `STREAMTRUST_API_TOKEN` for bearer authentication, but does not put the token or authorization headers in reports. Run against a disposable test environment: these scenarios create, complete, cancel and retry operations. Adapters are trusted code and can access the same filesystem/network as your CI job. StreamTrust does not sandbox them or automatically redact arbitrary evidence. Return only the diagnostics you want uploaded as artifacts.
|
|
76
|
+
|
|
77
|
+
## Adapter contract
|
|
78
|
+
|
|
79
|
+
Export a default async factory. The CLI supplies `{outputDir, signal}`. Return:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
export default async function createAdapter({ signal }) {
|
|
83
|
+
return {
|
|
84
|
+
name: 'My application',
|
|
85
|
+
scenarios: ['persistence'],
|
|
86
|
+
async metadata() {
|
|
87
|
+
return { appRevision: process.env.APP_REVISION ?? 'unknown' };
|
|
88
|
+
},
|
|
89
|
+
async run(scenario, { repetition, outputDir, signal }) {
|
|
90
|
+
// Replace these calls with real observations in your adapter.
|
|
91
|
+
const displayed = await observeApplication(signal);
|
|
92
|
+
const saved = await readAuthoritativeState(signal);
|
|
93
|
+
return {
|
|
94
|
+
checks: [{
|
|
95
|
+
name: 'Displayed answer matches saved answer',
|
|
96
|
+
passed: displayed.text === saved.text,
|
|
97
|
+
}],
|
|
98
|
+
evidence: { displayed, saved },
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
async close() {
|
|
102
|
+
// Close browsers, connections and services owned by this adapter.
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The snippet illustrates the contract; `observeApplication` and `readAuthoritativeState` are your implementation. The generated HTTP adapter is the complete runnable example.
|
|
109
|
+
|
|
110
|
+
Scenarios must be unique identifiers containing letters, digits, `_`, `-` or `.`. Assertions must have unique nonempty names and **boolean** `passed` values. Empty or malformed checks are harness errors. Unknown extra result fields cannot override the verdict. Evidence must be JSON-serializable. Unsupported capabilities return `{ checks: [], evidence: {}, unsupported: 'Reason' }`; they do not count as passes.
|
|
111
|
+
|
|
112
|
+
The runner calls metadata before and after the suite, and calls close even when execution or validation fails. If your factory fails before returning an adapter, clean up resources in the factory's catch/finally. Use `signal` in network calls and release resources cooperatively. A failed assertion means the stated contract failed; it does not automatically identify a library defect.
|
|
113
|
+
|
|
114
|
+
Types are exported from `streamtrust`: `ExternalAppAdapter`, `AdapterFactory`, `ExternalObservation`, `Check`, `Report`. The programmatic `runVerification(adapter, outputDir, repetitions)` returns a report; inspect `report.exitCode`. Only the CLI provides a separate watchdog process.
|
|
115
|
+
|
|
116
|
+
JavaScript `.mjs` adapters work out of the box. For TypeScript adapters, install your own loader:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
npm install --save-dev tsx
|
|
120
|
+
node --import tsx ./node_modules/streamtrust/bin/streamtrust.js verify \
|
|
121
|
+
--adapter ./streamtrust/adapter.ts
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## CI
|
|
125
|
+
|
|
126
|
+
Install the pinned preview and commit package.json and package-lock.json so CI can use `npm ci`. If using a locally built tarball instead, keep it in `vendor/` so the committed file dependency can be resolved. The example workflow assumes the application under test is already running; add its start/wait commands before verification. Omit the base URL only when intentionally testing the bundled demonstration.
|
|
127
|
+
|
|
128
|
+
```yaml
|
|
129
|
+
name: Streaming application verification
|
|
130
|
+
on: [push, pull_request]
|
|
131
|
+
jobs:
|
|
132
|
+
verify:
|
|
133
|
+
runs-on: ubuntu-latest
|
|
134
|
+
timeout-minutes: 10
|
|
135
|
+
steps:
|
|
136
|
+
- uses: actions/checkout@v4
|
|
137
|
+
- uses: actions/setup-node@v4
|
|
138
|
+
with:
|
|
139
|
+
node-version: 22
|
|
140
|
+
cache: npm
|
|
141
|
+
- run: npm ci
|
|
142
|
+
# Start your test application and wait for readiness here.
|
|
143
|
+
- run: npx --no-install streamtrust verify --adapter ./streamtrust/adapter.mjs --output reports/streamtrust
|
|
144
|
+
env:
|
|
145
|
+
STREAMTRUST_BASE_URL: http://127.0.0.1:8080
|
|
146
|
+
STREAMTRUST_API_TOKEN: ${{ secrets.TEST_APP_TOKEN }}
|
|
147
|
+
- uses: actions/upload-artifact@v4
|
|
148
|
+
if: always()
|
|
149
|
+
with:
|
|
150
|
+
name: streamtrust-evidence
|
|
151
|
+
path: reports/streamtrust/
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
| Exit | Meaning |
|
|
155
|
+
|---|---|
|
|
156
|
+
| 0 | Every declared scenario and assertion passed. |
|
|
157
|
+
| 1 | Failed assertions or explicitly unsupported scenarios. |
|
|
158
|
+
| 2 | Invalid arguments/adapter, setup or cleanup failure, malformed observations, interrupted process or timeout. |
|
|
159
|
+
|
|
160
|
+
Use a unique output directory per concurrent job. The CLI takes an exclusive lock and records a fresh run ID; it cannot reuse an old passing report after setup failure. Reports are overwritten on subsequent runs. If a process was forcibly killed, remove a stale `.streamtrust.lock` only after confirming that process has stopped. Invalid arguments and lock contention fail before a run starts and do not replace existing evidence.
|
|
161
|
+
|
|
162
|
+
`--timeout-ms` bounds the entire invocation, including factory setup and cleanup (default five minutes), followed by up to one second of termination grace. On Linux/macOS the CLI terminates its worker process group, including ordinary child processes. Adapters that detach external services must arrange their own cleanup. Windows process-tree cleanup is not validated in this pilot. An outer CI timeout remains useful.
|
|
163
|
+
|
|
164
|
+
## Scope and attribution
|
|
165
|
+
|
|
166
|
+
StreamTrust checks what an adapter asserts. It does not prove exactly-once external effects, AI factuality, automatic recovery, production incident rates or adoption. The HTTP demo is synthetic. Separate development experiments have tested pinned Vercel Chatbot and LibreChat applications; their adapters and dependencies are not bundled here.
|
|
167
|
+
|
|
168
|
+
Local preparation: `npm pack` in this package directory runs the package tests and creates the tarball. The public repository runs tests and a clean tarball installation on Node.js 22 and 24. This preview is prepared for interactive publication; npm build provenance is not configured.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Local teaching fixture, not a production implementation or evidence of customer impact.
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
export async function startDemo() {
|
|
8
|
+
const directory = await mkdtemp(join(tmpdir(), 'streamtrust-demo-'));
|
|
9
|
+
const live = new Map();
|
|
10
|
+
const load = async (id) =>
|
|
11
|
+
JSON.parse(await readFile(join(directory, `${id}.json`), 'utf8'));
|
|
12
|
+
const save = (record) =>
|
|
13
|
+
writeFile(join(directory, `${record.id}.json`), JSON.stringify(record));
|
|
14
|
+
const first = 'The answer begins.';
|
|
15
|
+
const suffix = ' The answer is complete.';
|
|
16
|
+
const emit = (response, event) =>
|
|
17
|
+
response.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
18
|
+
const create = async (parentId) => {
|
|
19
|
+
const record = {
|
|
20
|
+
id: randomUUID(),
|
|
21
|
+
parentId,
|
|
22
|
+
status: 'streaming',
|
|
23
|
+
content: first,
|
|
24
|
+
};
|
|
25
|
+
await save(record);
|
|
26
|
+
live.set(record.id, { record, response: null });
|
|
27
|
+
return record;
|
|
28
|
+
};
|
|
29
|
+
const server = createServer(async (req, res) => {
|
|
30
|
+
const url = new URL(req.url, 'http://localhost');
|
|
31
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
32
|
+
const json = (value, status = 200) => {
|
|
33
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
34
|
+
res.end(JSON.stringify(value));
|
|
35
|
+
};
|
|
36
|
+
try {
|
|
37
|
+
if (req.method === 'POST' && url.pathname === '/runs') {
|
|
38
|
+
json(await create(null), 201);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const id = parts[1];
|
|
42
|
+
if (parts[0] !== 'runs' || !/^[0-9a-f-]{36}$/.test(id ?? '')) {
|
|
43
|
+
json({ error: 'Not found' }, 404);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const record = await load(id);
|
|
47
|
+
if (req.method === 'GET' && parts.length === 2) {
|
|
48
|
+
json(record);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (req.method === 'GET' && parts[2] === 'events') {
|
|
52
|
+
if (record.status !== 'streaming') {
|
|
53
|
+
json({ error: 'Already terminal' }, 409);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
res.writeHead(200, {
|
|
57
|
+
'content-type': 'text/event-stream',
|
|
58
|
+
'cache-control': 'no-cache',
|
|
59
|
+
});
|
|
60
|
+
live.get(id).response = res;
|
|
61
|
+
emit(res, { type: 'text', content: record.content });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (req.method === 'POST' && parts[2] === 'retry') {
|
|
65
|
+
json(await create(id), 201);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (req.method === 'POST' && ['complete', 'cancel'].includes(parts[2])) {
|
|
69
|
+
const entry = live.get(id);
|
|
70
|
+
if (record.status !== 'streaming') {
|
|
71
|
+
json({ accepted: false });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
record.status = parts[2] === 'cancel' ? 'cancelled' : 'completed';
|
|
75
|
+
if (record.status === 'completed') record.content += suffix;
|
|
76
|
+
// Explicit negative control for the package's own tests only.
|
|
77
|
+
if (process.env.STREAMTRUST_DEMO_FAULT !== 'drop-save')
|
|
78
|
+
await save(record);
|
|
79
|
+
if (entry?.response) {
|
|
80
|
+
emit(entry.response, {
|
|
81
|
+
type: 'terminal',
|
|
82
|
+
status: record.status,
|
|
83
|
+
content: record.content,
|
|
84
|
+
});
|
|
85
|
+
entry.response.end();
|
|
86
|
+
}
|
|
87
|
+
json({ accepted: true });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
json({ error: 'Not found' }, 404);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
json({ error: String(error) }, 500);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
await new Promise((ok, fail) => {
|
|
96
|
+
server.once('error', fail);
|
|
97
|
+
server.listen(0, '127.0.0.1', ok);
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
baseUrl: `http://127.0.0.1:${server.address().port}`,
|
|
101
|
+
async close() {
|
|
102
|
+
server.closeAllConnections();
|
|
103
|
+
await new Promise((r) => server.close(r));
|
|
104
|
+
await rm(directory, { recursive: true, force: true });
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { startDemo } from './demo-app.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @returns {Promise<import('streamtrust').ExternalAppAdapter>}
|
|
5
|
+
* With no URL, starts the bundled local teaching fixture.
|
|
6
|
+
* For your app, replace these route mappings and assertions with its real contracts.
|
|
7
|
+
*/
|
|
8
|
+
export default async function createAdapter({ signal } = {}) {
|
|
9
|
+
const demo = process.env.STREAMTRUST_BASE_URL ? undefined : await startDemo();
|
|
10
|
+
const baseUrl = process.env.STREAMTRUST_BASE_URL || demo.baseUrl;
|
|
11
|
+
const headers = process.env.STREAMTRUST_API_TOKEN
|
|
12
|
+
? { authorization: `Bearer ${process.env.STREAMTRUST_API_TOKEN}` }
|
|
13
|
+
: {};
|
|
14
|
+
const requestSignal = () =>
|
|
15
|
+
signal
|
|
16
|
+
? AbortSignal.any([signal, AbortSignal.timeout(10000)])
|
|
17
|
+
: AbortSignal.timeout(10000);
|
|
18
|
+
async function json(path, method = 'GET') {
|
|
19
|
+
const response = await fetch(new URL(path, baseUrl), {
|
|
20
|
+
method,
|
|
21
|
+
headers,
|
|
22
|
+
signal: requestSignal(),
|
|
23
|
+
});
|
|
24
|
+
if (!response.ok)
|
|
25
|
+
throw new Error(`${method} ${path} returned ${response.status}`);
|
|
26
|
+
return response.json();
|
|
27
|
+
}
|
|
28
|
+
async function watch(id) {
|
|
29
|
+
const response = await fetch(new URL(`/runs/${id}/events`, baseUrl), {
|
|
30
|
+
headers,
|
|
31
|
+
signal: requestSignal(),
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok || !response.body)
|
|
34
|
+
throw new Error('Expected SSE response');
|
|
35
|
+
const reader = response.body.getReader();
|
|
36
|
+
const decoder = new TextDecoder();
|
|
37
|
+
let buffer = '';
|
|
38
|
+
async function next() {
|
|
39
|
+
for (;;) {
|
|
40
|
+
const end = buffer.indexOf('\n\n');
|
|
41
|
+
if (end >= 0) {
|
|
42
|
+
const frame = buffer.slice(0, end);
|
|
43
|
+
buffer = buffer.slice(end + 2);
|
|
44
|
+
const data = frame
|
|
45
|
+
.split('\n')
|
|
46
|
+
.filter((l) => l.startsWith('data:'))
|
|
47
|
+
.map((l) => l.slice(5).trimStart())
|
|
48
|
+
.join('\n');
|
|
49
|
+
if (data) return JSON.parse(data);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const { done, value } = await reader.read();
|
|
53
|
+
if (done) throw new Error('Stream ended before the expected event');
|
|
54
|
+
buffer += decoder.decode(value, { stream: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { next, close: () => reader.cancel() };
|
|
58
|
+
}
|
|
59
|
+
async function complete(id) {
|
|
60
|
+
const stream = await watch(id);
|
|
61
|
+
try {
|
|
62
|
+
const first = await stream.next();
|
|
63
|
+
await json(`/runs/${id}/complete`, 'POST');
|
|
64
|
+
const terminal = await stream.next();
|
|
65
|
+
return { first, terminal, durable: await json(`/runs/${id}`) };
|
|
66
|
+
} finally {
|
|
67
|
+
await stream.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
name: demo
|
|
72
|
+
? 'Local HTTP/SSE demonstration'
|
|
73
|
+
: 'Your application HTTP adapter',
|
|
74
|
+
scenarios: ['persistence', 'cancellation', 'retry'],
|
|
75
|
+
async metadata() {
|
|
76
|
+
return {
|
|
77
|
+
mode: demo
|
|
78
|
+
? 'synthetic local fixture'
|
|
79
|
+
: 'configured external test service',
|
|
80
|
+
contract: 'examples/http.adapter.mjs',
|
|
81
|
+
scope:
|
|
82
|
+
'HTTP/SSE observations and authoritative GET; no browser, database crash, billing, or production coverage',
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
async run(scenario) {
|
|
86
|
+
const initial = await json('/runs', 'POST');
|
|
87
|
+
if (scenario === 'cancellation') {
|
|
88
|
+
const stream = await watch(initial.id);
|
|
89
|
+
try {
|
|
90
|
+
const first = await stream.next();
|
|
91
|
+
const cancelled = await json(`/runs/${initial.id}/cancel`, 'POST');
|
|
92
|
+
const terminal = await stream.next();
|
|
93
|
+
const late = await json(`/runs/${initial.id}/complete`, 'POST');
|
|
94
|
+
const durable = await json(`/runs/${initial.id}`);
|
|
95
|
+
return {
|
|
96
|
+
checks: [
|
|
97
|
+
{
|
|
98
|
+
name: 'Server accepts cancellation',
|
|
99
|
+
passed: cancelled.accepted === true,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'Terminal event and durable state agree on cancellation',
|
|
103
|
+
passed:
|
|
104
|
+
terminal.status === 'cancelled' &&
|
|
105
|
+
durable.status === 'cancelled',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: 'Partial content remains authoritative',
|
|
109
|
+
passed: durable.content === first.content,
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'Late completion is rejected',
|
|
113
|
+
passed: late.accepted === false,
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
evidence: { first, terminal, durable, late },
|
|
117
|
+
};
|
|
118
|
+
} finally {
|
|
119
|
+
await stream.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const original = await complete(initial.id);
|
|
123
|
+
if (scenario === 'persistence')
|
|
124
|
+
return {
|
|
125
|
+
checks: [
|
|
126
|
+
{
|
|
127
|
+
name: 'Terminal event indicates completed answer',
|
|
128
|
+
passed: original.terminal.status === 'completed',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'Read after completion matches streamed result',
|
|
132
|
+
passed:
|
|
133
|
+
original.durable.status === 'completed' &&
|
|
134
|
+
original.durable.content === original.terminal.content,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'Partial text is extended on completion',
|
|
138
|
+
passed:
|
|
139
|
+
original.terminal.content.startsWith(original.first.content) &&
|
|
140
|
+
original.terminal.content.length >
|
|
141
|
+
original.first.content.length,
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
evidence: original,
|
|
145
|
+
};
|
|
146
|
+
const retry = await json(`/runs/${initial.id}/retry`, 'POST');
|
|
147
|
+
const retried = await complete(retry.id);
|
|
148
|
+
const old = await json(`/runs/${initial.id}`);
|
|
149
|
+
return {
|
|
150
|
+
checks: [
|
|
151
|
+
{
|
|
152
|
+
name: 'Retry has a distinct identity linked to the original',
|
|
153
|
+
passed: retry.id !== initial.id && retry.parentId === initial.id,
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: 'Original result is unchanged',
|
|
157
|
+
passed: JSON.stringify(old) === JSON.stringify(original.durable),
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'Retried result is durably complete',
|
|
161
|
+
passed:
|
|
162
|
+
retried.durable.status === 'completed' &&
|
|
163
|
+
retried.durable.content === retried.terminal.content,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
evidence: { original: original.durable, retry: retried.durable },
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
async close() {
|
|
170
|
+
await demo?.close();
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "streamtrust",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-preview.1",
|
|
4
|
+
"description": "Adapter-driven CI verification for streaming application lifecycle guarantees",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"streamtrust": "bin/streamtrust.js"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./src/index.d.ts",
|
|
16
|
+
"import": "./src/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"types": "src/index.d.ts",
|
|
20
|
+
"files": [
|
|
21
|
+
"bin/",
|
|
22
|
+
"src/",
|
|
23
|
+
"examples/",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test test/*.test.mjs",
|
|
29
|
+
"prepack": "npm test"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"ci",
|
|
33
|
+
"streaming",
|
|
34
|
+
"verification",
|
|
35
|
+
"testing",
|
|
36
|
+
"sse"
|
|
37
|
+
],
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/santoshsibyala/streamtrust.git"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/santoshsibyala/streamtrust#readme",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/santoshsibyala/streamtrust/issues"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public",
|
|
48
|
+
"tag": "preview",
|
|
49
|
+
"registry": "https://registry.npmjs.org/"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function main(args: string[]): Promise<number>;
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { fork } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { mkdir, copyFile, open, unlink, readFile } from 'node:fs/promises';
|
|
6
|
+
import { constants } from 'node:fs';
|
|
7
|
+
import { VERSION, failureReport } from './index.js';
|
|
8
|
+
const HELP = `StreamTrust ${VERSION}\n\nUsage:\n streamtrust init [directory]\n streamtrust verify --adapter ./streamtrust/adapter.mjs [options]\n\nOptions:\n --output <directory> Reports (default: reports/streamtrust)\n --repetitions <1-1000> Repetitions per scenario (default: 3)\n --timeout-ms <integer> Total run deadline including setup/cleanup (default: 300000)\n --help Show help\n --version Show version\n\nExit codes: 0 all pass; 1 failed/unsupported checks; 2 setup, timeout or harness error.\nAdapters execute trusted local code. JavaScript ESM (.mjs) works without dependencies.\n`;
|
|
9
|
+
export async function main(args) {
|
|
10
|
+
let outputDir;
|
|
11
|
+
try {
|
|
12
|
+
if (args.length === 1 && ['--version', '-v'].includes(args[0])) {
|
|
13
|
+
console.log(VERSION);
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
if (!args.length || args.includes('--help')) {
|
|
17
|
+
console.log(HELP);
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
if (args[0] === 'init') {
|
|
21
|
+
if (args.length > 2 || args[1]?.startsWith('-'))
|
|
22
|
+
throw new Error('Usage: streamtrust init [directory]');
|
|
23
|
+
const dir = resolve(args[1] ?? 'streamtrust');
|
|
24
|
+
await mkdir(dir, { recursive: true });
|
|
25
|
+
// Exclusive copies preserve existing adapters. No generated config overrides user files.
|
|
26
|
+
const created = [];
|
|
27
|
+
try {
|
|
28
|
+
for (const [source, target] of [
|
|
29
|
+
['http.adapter.mjs', 'adapter.mjs'],
|
|
30
|
+
['demo-app.mjs', 'demo-app.mjs'],
|
|
31
|
+
]) {
|
|
32
|
+
const destination = resolve(dir, target);
|
|
33
|
+
await copyFile(
|
|
34
|
+
new URL(`../examples/${source}`, import.meta.url),
|
|
35
|
+
destination,
|
|
36
|
+
constants.COPYFILE_EXCL,
|
|
37
|
+
);
|
|
38
|
+
created.push(destination);
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
await Promise.all(created.map((path) => unlink(path)));
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
console.log(
|
|
45
|
+
`Created ${dir}\nRun: streamtrust verify --adapter ${JSON.stringify(resolve(dir, 'adapter.mjs'))}`,
|
|
46
|
+
);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
if (args[0] !== 'verify') throw new Error(`Unknown command: ${args[0]}`);
|
|
50
|
+
const values = new Map();
|
|
51
|
+
const known = new Set([
|
|
52
|
+
'--adapter',
|
|
53
|
+
'--output',
|
|
54
|
+
'--repetitions',
|
|
55
|
+
'--timeout-ms',
|
|
56
|
+
]);
|
|
57
|
+
for (let i = 1; i < args.length; i += 2) {
|
|
58
|
+
const key = args[i],
|
|
59
|
+
value = args[i + 1];
|
|
60
|
+
if (
|
|
61
|
+
!known.has(key) ||
|
|
62
|
+
values.has(key) ||
|
|
63
|
+
!value ||
|
|
64
|
+
value.startsWith('--')
|
|
65
|
+
)
|
|
66
|
+
throw new Error(`Invalid or duplicate option: ${key}`);
|
|
67
|
+
values.set(key, value);
|
|
68
|
+
}
|
|
69
|
+
if (!values.has('--adapter')) throw new Error('--adapter is required');
|
|
70
|
+
const repetitions = Number(values.get('--repetitions') ?? 3),
|
|
71
|
+
timeout = Number(values.get('--timeout-ms') ?? 300000);
|
|
72
|
+
if (
|
|
73
|
+
!Number.isSafeInteger(repetitions) ||
|
|
74
|
+
repetitions < 1 ||
|
|
75
|
+
repetitions > 1000
|
|
76
|
+
)
|
|
77
|
+
throw new Error('--repetitions must be 1–1000');
|
|
78
|
+
if (!Number.isSafeInteger(timeout) || timeout < 100 || timeout > 86400000)
|
|
79
|
+
throw new Error('--timeout-ms must be 100–86400000');
|
|
80
|
+
outputDir = resolve(values.get('--output') ?? 'reports/streamtrust');
|
|
81
|
+
await mkdir(outputDir, { recursive: true });
|
|
82
|
+
const lock = resolve(outputDir, '.streamtrust.lock');
|
|
83
|
+
const handle = await open(lock, 'wx').catch((error) => {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Cannot lock output directory (${error.code}); use a unique directory per CI job. Remove a stale lock only after its process has stopped.`,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
const runId = randomUUID();
|
|
89
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, runId }));
|
|
90
|
+
await handle.close();
|
|
91
|
+
try {
|
|
92
|
+
const config = {
|
|
93
|
+
adapter: resolve(values.get('--adapter')),
|
|
94
|
+
outputDir,
|
|
95
|
+
repetitions,
|
|
96
|
+
runId,
|
|
97
|
+
};
|
|
98
|
+
const child = fork(
|
|
99
|
+
fileURLToPath(new URL('./worker.js', import.meta.url)),
|
|
100
|
+
[JSON.stringify(config)],
|
|
101
|
+
{
|
|
102
|
+
execArgv: process.execArgv,
|
|
103
|
+
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
|
104
|
+
detached: process.platform !== 'win32',
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
const signalChild = (signal) => {
|
|
108
|
+
try {
|
|
109
|
+
if (process.platform !== 'win32') process.kill(-child.pid, signal);
|
|
110
|
+
else child.kill(signal);
|
|
111
|
+
} catch {}
|
|
112
|
+
};
|
|
113
|
+
let terminal, reason, timer, grace;
|
|
114
|
+
const interrupt = () => {
|
|
115
|
+
if (reason) return;
|
|
116
|
+
reason = 'Verifier interrupted';
|
|
117
|
+
signalChild('SIGTERM');
|
|
118
|
+
grace = setTimeout(() => signalChild('SIGKILL'), 1000);
|
|
119
|
+
};
|
|
120
|
+
process.once('SIGINT', interrupt);
|
|
121
|
+
process.once('SIGTERM', interrupt);
|
|
122
|
+
timer = setTimeout(() => {
|
|
123
|
+
reason = `Run deadline exceeded (${timeout}ms)`;
|
|
124
|
+
signalChild('SIGTERM');
|
|
125
|
+
grace = setTimeout(() => signalChild('SIGKILL'), 1000);
|
|
126
|
+
}, timeout);
|
|
127
|
+
let exited;
|
|
128
|
+
try {
|
|
129
|
+
child.on('message', (message) => {
|
|
130
|
+
if (message?.type === 'result' && message.runId === runId)
|
|
131
|
+
terminal = message;
|
|
132
|
+
});
|
|
133
|
+
exited = await new Promise((ok, fail) => {
|
|
134
|
+
child.once('error', fail);
|
|
135
|
+
child.once('exit', (code, signal) => ok({ code, signal }));
|
|
136
|
+
});
|
|
137
|
+
} finally {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
clearTimeout(grace);
|
|
140
|
+
process.removeListener('SIGINT', interrupt);
|
|
141
|
+
process.removeListener('SIGTERM', interrupt);
|
|
142
|
+
signalChild('SIGKILL');
|
|
143
|
+
}
|
|
144
|
+
if (
|
|
145
|
+
reason ||
|
|
146
|
+
!terminal ||
|
|
147
|
+
![0, 1, 2].includes(terminal.exitCode) ||
|
|
148
|
+
exited.code !== terminal.exitCode
|
|
149
|
+
) {
|
|
150
|
+
await failureReport(
|
|
151
|
+
outputDir,
|
|
152
|
+
runId,
|
|
153
|
+
reason ??
|
|
154
|
+
`Adapter process exited without a valid result (${JSON.stringify(exited)})`,
|
|
155
|
+
);
|
|
156
|
+
return 2;
|
|
157
|
+
}
|
|
158
|
+
const report = JSON.parse(
|
|
159
|
+
await readFile(resolve(outputDir, 'report.json'), 'utf8'),
|
|
160
|
+
);
|
|
161
|
+
if (report.runId !== runId || report.exitCode !== terminal.exitCode)
|
|
162
|
+
throw new Error('Report does not match current invocation');
|
|
163
|
+
console.log(
|
|
164
|
+
`StreamTrust: ${JSON.stringify(report.counts)}; exit ${report.exitCode}\nReports: ${outputDir}`,
|
|
165
|
+
);
|
|
166
|
+
return report.exitCode;
|
|
167
|
+
} catch (error) {
|
|
168
|
+
await failureReport(outputDir, runId, error);
|
|
169
|
+
console.error(String(error));
|
|
170
|
+
return 2;
|
|
171
|
+
} finally {
|
|
172
|
+
await unlink(lock);
|
|
173
|
+
}
|
|
174
|
+
} catch (error) {
|
|
175
|
+
console.error(`StreamTrust: ${error.message}`);
|
|
176
|
+
return 2;
|
|
177
|
+
}
|
|
178
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export const VERSION: string;
|
|
2
|
+
export type Classification = 'PASS' | 'FAIL' | 'UNSUPPORTED' | 'HARNESS_ERROR';
|
|
3
|
+
export interface Check {
|
|
4
|
+
name: string;
|
|
5
|
+
passed: boolean;
|
|
6
|
+
detail?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ExternalObservation {
|
|
9
|
+
checks: Check[];
|
|
10
|
+
evidence: unknown;
|
|
11
|
+
unsupported?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface AdapterContext {
|
|
14
|
+
repetition: number;
|
|
15
|
+
outputDir: string;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
export interface FactoryContext {
|
|
19
|
+
outputDir: string;
|
|
20
|
+
signal: AbortSignal;
|
|
21
|
+
}
|
|
22
|
+
export interface ExternalAppAdapter {
|
|
23
|
+
name: string;
|
|
24
|
+
scenarios: readonly string[];
|
|
25
|
+
metadata(): Promise<Record<string, unknown>>;
|
|
26
|
+
run(scenario: string, context: AdapterContext): Promise<ExternalObservation>;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
export type AdapterFactory = (
|
|
30
|
+
context: FactoryContext,
|
|
31
|
+
) => Promise<ExternalAppAdapter>;
|
|
32
|
+
export interface CaseResult {
|
|
33
|
+
scenario: string;
|
|
34
|
+
repetition: number;
|
|
35
|
+
classification: Classification;
|
|
36
|
+
durationMs: number;
|
|
37
|
+
checks: Check[];
|
|
38
|
+
evidence?: unknown;
|
|
39
|
+
error?: string;
|
|
40
|
+
unsupported?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface Report {
|
|
43
|
+
schemaVersion: number;
|
|
44
|
+
version: string;
|
|
45
|
+
runId: string;
|
|
46
|
+
adapter: string;
|
|
47
|
+
generatedAt: string;
|
|
48
|
+
repetitions: number;
|
|
49
|
+
scenarios: string[];
|
|
50
|
+
metadata: unknown;
|
|
51
|
+
metadataAfter: unknown;
|
|
52
|
+
counts: Partial<Record<Classification, number>>;
|
|
53
|
+
results: CaseResult[];
|
|
54
|
+
suiteErrors: string[];
|
|
55
|
+
complete: boolean;
|
|
56
|
+
exitCode: number;
|
|
57
|
+
}
|
|
58
|
+
export function classifyExternal(result: unknown): Classification;
|
|
59
|
+
export function validateAdapter(adapter: unknown): void;
|
|
60
|
+
export function runVerification(
|
|
61
|
+
adapter: ExternalAppAdapter,
|
|
62
|
+
outputDir: string,
|
|
63
|
+
repetitions?: number,
|
|
64
|
+
options?: { signal?: AbortSignal; runId?: string },
|
|
65
|
+
): Promise<Report>;
|
|
66
|
+
export function verifyExternal(
|
|
67
|
+
adapter: ExternalAppAdapter,
|
|
68
|
+
outputDir: string,
|
|
69
|
+
repetitions?: number,
|
|
70
|
+
): Promise<CaseResult[]>;
|
|
71
|
+
export function writeReports(outputDir: string, report: Report): Promise<void>;
|
|
72
|
+
export function failureReport(
|
|
73
|
+
outputDir: string,
|
|
74
|
+
runId: string,
|
|
75
|
+
error: unknown,
|
|
76
|
+
): Promise<Report>;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { mkdir, writeFile, rename, access } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
export const VERSION = '0.1.0-preview.1';
|
|
5
|
+
const object = (value) =>
|
|
6
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
const nonempty = (value) =>
|
|
8
|
+
typeof value === 'string' && value.trim().length > 0;
|
|
9
|
+
export function classifyExternal(result) {
|
|
10
|
+
if (!object(result) || !Array.isArray(result.checks)) return 'HARNESS_ERROR';
|
|
11
|
+
if (result.unsupported !== undefined)
|
|
12
|
+
return nonempty(result.unsupported) && result.checks.length === 0
|
|
13
|
+
? 'UNSUPPORTED'
|
|
14
|
+
: 'HARNESS_ERROR';
|
|
15
|
+
if (
|
|
16
|
+
!result.checks.length ||
|
|
17
|
+
result.checks.some(
|
|
18
|
+
(c) => !object(c) || !nonempty(c.name) || typeof c.passed !== 'boolean',
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
return 'HARNESS_ERROR';
|
|
22
|
+
if (new Set(result.checks.map((c) => c.name)).size !== result.checks.length)
|
|
23
|
+
return 'HARNESS_ERROR';
|
|
24
|
+
return result.checks.every((c) => c.passed) ? 'PASS' : 'FAIL';
|
|
25
|
+
}
|
|
26
|
+
export function validateAdapter(adapter) {
|
|
27
|
+
if (!object(adapter) || !nonempty(adapter.name))
|
|
28
|
+
throw new Error('Adapter must have a nonempty name');
|
|
29
|
+
if (
|
|
30
|
+
!Array.isArray(adapter.scenarios) ||
|
|
31
|
+
!adapter.scenarios.length ||
|
|
32
|
+
adapter.scenarios.some(
|
|
33
|
+
(s) => typeof s !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(s),
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
throw new Error('Adapter scenarios must be nonempty safe identifiers');
|
|
37
|
+
if (new Set(adapter.scenarios).size !== adapter.scenarios.length)
|
|
38
|
+
throw new Error('Adapter scenario identifiers must be unique');
|
|
39
|
+
for (const method of ['metadata', 'run', 'close'])
|
|
40
|
+
if (typeof adapter[method] !== 'function')
|
|
41
|
+
throw new Error(`Adapter missing ${method}()`);
|
|
42
|
+
}
|
|
43
|
+
const escape = (value) =>
|
|
44
|
+
String(value)
|
|
45
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '')
|
|
46
|
+
.replace(
|
|
47
|
+
/[&<>"']/g,
|
|
48
|
+
(c) =>
|
|
49
|
+
({
|
|
50
|
+
'&': '&',
|
|
51
|
+
'<': '<',
|
|
52
|
+
'>': '>',
|
|
53
|
+
'"': '"',
|
|
54
|
+
"'": ''',
|
|
55
|
+
})[c],
|
|
56
|
+
);
|
|
57
|
+
const jsonSafe = (value) => JSON.parse(JSON.stringify(value ?? null));
|
|
58
|
+
export async function writeReports(outputDir, report) {
|
|
59
|
+
await mkdir(outputDir, { recursive: true });
|
|
60
|
+
const rows = report.results;
|
|
61
|
+
const screenshots = new Set();
|
|
62
|
+
await Promise.all(
|
|
63
|
+
rows.map(async (row) => {
|
|
64
|
+
const path = `${row.scenario}-${row.repetition}.png`;
|
|
65
|
+
try {
|
|
66
|
+
await access(resolve(outputDir, path));
|
|
67
|
+
screenshots.add(path);
|
|
68
|
+
} catch {}
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
const suiteCases = report.suiteErrors
|
|
72
|
+
.map(
|
|
73
|
+
(error, i) =>
|
|
74
|
+
`<testcase classname="streamtrust" name="harness-${i}"><error message="${escape(error)}"/></testcase>`,
|
|
75
|
+
)
|
|
76
|
+
.join('');
|
|
77
|
+
const cases = rows
|
|
78
|
+
.map(
|
|
79
|
+
(r) =>
|
|
80
|
+
`<testcase classname="${escape(report.adapter)}" name="${escape(r.scenario)}-${r.repetition}" time="${r.durationMs / 1000}">${r.classification === 'UNSUPPORTED' ? `<skipped message="${escape(r.unsupported)}"/>` : r.classification === 'HARNESS_ERROR' ? `<error message="${escape(r.error || 'Invalid adapter observation')}"/>` : r.classification === 'FAIL' ? `<failure message="Invariant check failed">${escape(JSON.stringify(r.checks))}</failure>` : ''}</testcase>`,
|
|
81
|
+
)
|
|
82
|
+
.join('');
|
|
83
|
+
const junit = `<?xml version="1.0" encoding="UTF-8"?><testsuite name="${escape(report.adapter)}" tests="${rows.length + report.suiteErrors.length}" failures="${report.counts.FAIL ?? 0}" errors="${(report.counts.HARNESS_ERROR ?? 0) + report.suiteErrors.length}" skipped="${report.counts.UNSUPPORTED ?? 0}">${cases}${suiteCases}</testsuite>`;
|
|
84
|
+
const md = `# ${report.adapter}: external application verification\n\nRun: ${report.runId} · StreamTrust ${VERSION} · exit ${report.exitCode}\n\n${JSON.stringify(report.counts)}\n\n| Scenario | Repetition | Result |\n|---|---:|---|\n${rows.map((r) => `| ${r.scenario} | ${r.repetition} | ${r.classification} |`).join('\n')}\n\n${report.suiteErrors.map((e) => `Harness error: ${e}`).join('\n')}\n\nPASS covers only the adapter's declared assertions. FAIL needs review before attribution to an upstream defect. UNSUPPORTED is not a pass. HARNESS_ERROR is inconclusive. See report.json for evidence and junit.xml for CI results.\n`;
|
|
85
|
+
const html = `<!doctype html><meta charset="utf-8"><title>StreamTrust report</title><style>body{max-width:1000px;margin:40px auto;padding:20px;font:16px system-ui}pre{white-space:pre-wrap;overflow-wrap:anywhere}details{padding:12px;border:1px solid #ddd;margin:12px 0}</style><h1>${escape(report.adapter)}</h1><pre>${escape(md)}</pre><a href="report.json">JSON evidence</a> · <a href="junit.xml">JUnit</a>${rows.map((r) => `<details><summary>${escape(r.scenario)} #${r.repetition}: ${r.classification}</summary><pre>${escape(JSON.stringify(r, null, 2))}</pre>${screenshots.has(`${r.scenario}-${r.repetition}.png`) ? `<img style="max-width:100%" alt="Scenario screenshot" src="${r.scenario}-${r.repetition}.png">` : ''}</details>`).join('')}`;
|
|
86
|
+
for (const [name, value] of Object.entries({
|
|
87
|
+
'report.json': JSON.stringify(report, null, 2),
|
|
88
|
+
'junit.xml': junit,
|
|
89
|
+
'report.md': md,
|
|
90
|
+
'index.html': html,
|
|
91
|
+
})) {
|
|
92
|
+
const tmp = resolve(outputDir, `.${name}.${report.runId}.tmp`);
|
|
93
|
+
await writeFile(tmp, value);
|
|
94
|
+
await rename(tmp, resolve(outputDir, name));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export async function failureReport(outputDir, runId, error) {
|
|
98
|
+
const report = {
|
|
99
|
+
schemaVersion: 1,
|
|
100
|
+
version: VERSION,
|
|
101
|
+
runId,
|
|
102
|
+
adapter: 'adapter initialization',
|
|
103
|
+
generatedAt: new Date().toISOString(),
|
|
104
|
+
repetitions: 0,
|
|
105
|
+
scenarios: [],
|
|
106
|
+
metadata: {},
|
|
107
|
+
metadataAfter: {},
|
|
108
|
+
counts: {},
|
|
109
|
+
results: [],
|
|
110
|
+
suiteErrors: [String(error)],
|
|
111
|
+
complete: false,
|
|
112
|
+
exitCode: 2,
|
|
113
|
+
};
|
|
114
|
+
await writeReports(outputDir, report);
|
|
115
|
+
return report;
|
|
116
|
+
}
|
|
117
|
+
export async function runVerification(
|
|
118
|
+
adapter,
|
|
119
|
+
outputDir,
|
|
120
|
+
repetitions = 3,
|
|
121
|
+
options = {},
|
|
122
|
+
) {
|
|
123
|
+
const runId = options.runId ?? randomUUID();
|
|
124
|
+
const results = [];
|
|
125
|
+
const suiteErrors = [];
|
|
126
|
+
let metadata = {},
|
|
127
|
+
metadataAfter = {},
|
|
128
|
+
scenarios = [];
|
|
129
|
+
let name = 'invalid adapter';
|
|
130
|
+
try {
|
|
131
|
+
if (
|
|
132
|
+
!Number.isSafeInteger(repetitions) ||
|
|
133
|
+
repetitions < 1 ||
|
|
134
|
+
repetitions > 1000
|
|
135
|
+
)
|
|
136
|
+
throw new Error('repetitions must be an integer between 1 and 1000');
|
|
137
|
+
validateAdapter(adapter);
|
|
138
|
+
name = adapter.name;
|
|
139
|
+
scenarios = [...adapter.scenarios];
|
|
140
|
+
await mkdir(outputDir, { recursive: true });
|
|
141
|
+
metadata = jsonSafe(await adapter.metadata());
|
|
142
|
+
for (let repetition = 1; repetition <= repetitions; repetition++)
|
|
143
|
+
for (const scenario of scenarios) {
|
|
144
|
+
options.signal?.throwIfAborted();
|
|
145
|
+
const started = Date.now();
|
|
146
|
+
try {
|
|
147
|
+
const observation = jsonSafe(
|
|
148
|
+
await adapter.run(scenario, {
|
|
149
|
+
repetition,
|
|
150
|
+
outputDir: resolve(outputDir),
|
|
151
|
+
signal: options.signal,
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
const classification = classifyExternal(observation);
|
|
155
|
+
// Copy only contract fields; adapter evidence cannot overwrite the verdict or identity.
|
|
156
|
+
const row = {
|
|
157
|
+
scenario,
|
|
158
|
+
repetition,
|
|
159
|
+
classification,
|
|
160
|
+
durationMs: Date.now() - started,
|
|
161
|
+
checks: observation?.checks ?? [],
|
|
162
|
+
evidence: observation?.evidence ?? null,
|
|
163
|
+
};
|
|
164
|
+
if (classification === 'HARNESS_ERROR')
|
|
165
|
+
row.error =
|
|
166
|
+
'Invalid adapter observation: require named boolean checks or explicit unsupported';
|
|
167
|
+
if (classification === 'UNSUPPORTED')
|
|
168
|
+
row.unsupported = observation.unsupported;
|
|
169
|
+
results.push(row);
|
|
170
|
+
console.log(`${classification} ${name} ${scenario} #${repetition}`);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
results.push({
|
|
173
|
+
scenario,
|
|
174
|
+
repetition,
|
|
175
|
+
classification: 'HARNESS_ERROR',
|
|
176
|
+
durationMs: Date.now() - started,
|
|
177
|
+
error: String(error),
|
|
178
|
+
checks: [],
|
|
179
|
+
});
|
|
180
|
+
console.error(`HARNESS_ERROR ${scenario}: ${error}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
suiteErrors.push(String(error));
|
|
185
|
+
} finally {
|
|
186
|
+
try {
|
|
187
|
+
if (typeof adapter?.metadata === 'function')
|
|
188
|
+
metadataAfter = jsonSafe(await adapter.metadata());
|
|
189
|
+
} catch (error) {
|
|
190
|
+
suiteErrors.push(`Final metadata: ${error}`);
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
if (typeof adapter?.close === 'function') await adapter.close();
|
|
194
|
+
} catch (error) {
|
|
195
|
+
suiteErrors.push(`Cleanup: ${error}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const counts = results.reduce((a, r) => {
|
|
199
|
+
a[r.classification] = (a[r.classification] ?? 0) + 1;
|
|
200
|
+
return a;
|
|
201
|
+
}, {});
|
|
202
|
+
const complete =
|
|
203
|
+
scenarios.length > 0 && results.length === scenarios.length * repetitions;
|
|
204
|
+
const exitCode =
|
|
205
|
+
suiteErrors.length || !complete || counts.HARNESS_ERROR
|
|
206
|
+
? 2
|
|
207
|
+
: counts.FAIL || counts.UNSUPPORTED
|
|
208
|
+
? 1
|
|
209
|
+
: 0;
|
|
210
|
+
const report = {
|
|
211
|
+
schemaVersion: 1,
|
|
212
|
+
version: VERSION,
|
|
213
|
+
runId,
|
|
214
|
+
adapter: name,
|
|
215
|
+
generatedAt: new Date().toISOString(),
|
|
216
|
+
repetitions,
|
|
217
|
+
scenarios,
|
|
218
|
+
metadata,
|
|
219
|
+
metadataAfter,
|
|
220
|
+
counts,
|
|
221
|
+
results,
|
|
222
|
+
suiteErrors,
|
|
223
|
+
complete,
|
|
224
|
+
exitCode,
|
|
225
|
+
};
|
|
226
|
+
await writeReports(outputDir, report);
|
|
227
|
+
return report;
|
|
228
|
+
}
|
|
229
|
+
// Compatibility for the lab's existing programmatic callers.
|
|
230
|
+
export async function verifyExternal(adapter, outputDir, repetitions = 3) {
|
|
231
|
+
return (await runVerification(adapter, outputDir, repetitions)).results;
|
|
232
|
+
}
|
package/src/worker.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import { runVerification, failureReport } from './index.js';
|
|
3
|
+
const config = JSON.parse(process.argv[2]);
|
|
4
|
+
const controller = new AbortController();
|
|
5
|
+
process.once('SIGTERM', () =>
|
|
6
|
+
controller.abort(new Error('Verifier interrupted')),
|
|
7
|
+
);
|
|
8
|
+
process.once('SIGINT', () =>
|
|
9
|
+
controller.abort(new Error('Verifier interrupted')),
|
|
10
|
+
);
|
|
11
|
+
process.env.ST_REPORT_DIR = config.outputDir;
|
|
12
|
+
let report;
|
|
13
|
+
try {
|
|
14
|
+
const module = await import(pathToFileURL(config.adapter).href);
|
|
15
|
+
if (typeof module.default !== 'function')
|
|
16
|
+
throw new Error('Adapter must export a default async factory');
|
|
17
|
+
const adapter = await module.default({
|
|
18
|
+
outputDir: config.outputDir,
|
|
19
|
+
signal: controller.signal,
|
|
20
|
+
});
|
|
21
|
+
report = await runVerification(
|
|
22
|
+
adapter,
|
|
23
|
+
config.outputDir,
|
|
24
|
+
config.repetitions,
|
|
25
|
+
{ signal: controller.signal, runId: config.runId },
|
|
26
|
+
);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
report = await failureReport(config.outputDir, config.runId, error);
|
|
29
|
+
}
|
|
30
|
+
if (process.send)
|
|
31
|
+
await new Promise((r) =>
|
|
32
|
+
process.send(
|
|
33
|
+
{ type: 'result', runId: config.runId, exitCode: report.exitCode },
|
|
34
|
+
r,
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
// close() has finished; a forgotten adapter timer must not leave CI hanging.
|
|
38
|
+
process.exit(report.exitCode);
|