streamtrust 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,8 +1,20 @@
1
1
  # StreamTrust CLI
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/streamtrust.svg)](https://www.npmjs.com/package/streamtrust)
4
+
5
+ **[View StreamTrust on npm](https://www.npmjs.com/package/streamtrust)**
6
+
3
7
  Adapter-driven CI checks for streaming applications: compare observed output with authoritative state, exercise cancellation, and verify retry identity.
4
8
 
5
- **Status:** `0.1.0`, an early 0.x release for independent teams. APIs and report formats may change before 1.0. 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.
9
+ **Status:** `0.2.0`, an early 0.x release for independent teams. APIs and report formats may change before 1.0. 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.
10
+
11
+ ## Watch the 66-second demo
12
+
13
+ [![Open the narrated StreamTrust demo: persistence, cancellation, retry, and CI diagnostics](docs/media/streamtrust-demo-poster.png)](https://santoshsibyala.github.io/streamtrust/)
14
+
15
+ **[Watch the narrated demo in your browser](https://santoshsibyala.github.io/streamtrust/)** · [Read the transcript](docs/media/streamtrust-demo-transcript.txt) · [Download subtitles](docs/media/streamtrust-demo-0.1.1-brian.srt) · [Silent version](https://raw.githubusercontent.com/santoshsibyala/streamtrust/main/docs/media/streamtrust-demo-0.1.1.mp4)
16
+
17
+ A 66-second walkthrough narrated with ElevenLabs’ Brian voice (synthetic English narration), on-screen captions, and an optional English subtitle track: install StreamTrust, run the example checks, inspect the diagnostic report, and see a deliberate save failure return a nonzero CI exit code. Recorded with version 0.1.1. The bundled application and injected failure are demonstrations; connect an adapter to your own application to verify its behavior. The browser player includes playback controls, captions, and an MP4 download option.
6
18
 
7
19
  ## Install
8
20
 
@@ -12,7 +24,13 @@ npx streamtrust init
12
24
  npx streamtrust verify --adapter ./streamtrust/adapter.mjs
13
25
  ```
14
26
 
15
- For reproducible CI, pin `streamtrust@0.1.0` and commit your lockfile. The generated adapter initially exercises the bundled demonstration; map it to your application before using its results as application evidence.
27
+ For reproducible CI, pin `streamtrust@0.2.0` and commit your lockfile. The generated adapter initially exercises the bundled demonstration; map it to your application before using its results as application evidence.
28
+
29
+ ## LibreChat integration
30
+
31
+ A LibreChat preset now exercises persistence, native Stop, regeneration and error-retry through its browser UI and disposable MongoDB. It uses reusable `streamtrust/checks` assertions and summary-only evidence; no chat text, screenshots or application logs are included in its reports.
32
+
33
+ **Available in StreamTrust 0.2.0.** Install from npm using the [LibreChat quick start and CI guide](examples/librechat/README.md). It requires Node 24 and optional Playwright/MongoDB dependencies. An [integration workflow](.github/workflows/librechat.yml) exercises the packed CLI against the pinned upstream version. The default `init` demonstration remains available.
16
34
 
17
35
  ## Run from source
18
36
 
@@ -32,7 +50,7 @@ Build the tarball with `npm pack` in this repository.
32
50
  Put the supplied tarball in your project's `vendor/` directory, then:
33
51
 
34
52
  ```sh
35
- npm install --save-dev ./vendor/streamtrust-0.1.0.tgz
53
+ npm install --save-dev ./vendor/streamtrust-0.2.0.tgz
36
54
  npx streamtrust init
37
55
  npx streamtrust verify --adapter ./streamtrust/adapter.mjs
38
56
  ```
@@ -123,34 +141,82 @@ node --import tsx ./node_modules/streamtrust/bin/streamtrust.js verify \
123
141
 
124
142
  ## CI
125
143
 
126
- Install the pinned version 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.
144
+ Add StreamTrust as a test step in your existing CI. For GitHub Actions, follow these steps:
145
+
146
+ 1. Install StreamTrust and create the starting adapter:
147
+
148
+ ```sh
149
+ npm install --save-dev --save-exact streamtrust@0.2.0
150
+ npx streamtrust init
151
+ ```
152
+
153
+ 2. Adapt `streamtrust/adapter.mjs` to your application's native start, stream, cancel, retry, and saved-state operations. **The generated adapter initially tests a bundled demo. Setting a base URL alone does not adapt it to arbitrary application APIs.** See [Connect your application](#connect-your-application) above.
154
+ 3. Commit the adapter, `package.json`, and `package-lock.json`. If the adapter imports browser or database libraries, add those dependencies and their required CI setup too.
155
+ 4. Save the workflow below as `.github/workflows/streamtrust.yml`, or copy its relevant steps into an existing job.
156
+
157
+ **Replace `npm run start:test`, port `3000`, and `/health` with your application's test-server command and readiness endpoint.** These are placeholders, not commands or routes supplied by StreamTrust. Add any required build, database, migration, fixture, or browser-install steps before starting your app. Use a disposable test environment because the checks create and modify application state.
127
158
 
128
159
  ```yaml
129
160
  name: Streaming application verification
130
161
  on: [push, pull_request]
162
+ permissions:
163
+ contents: read
131
164
  jobs:
132
165
  verify:
133
166
  runs-on: ubuntu-latest
134
167
  timeout-minutes: 10
135
168
  steps:
136
169
  - uses: actions/checkout@v4
170
+
137
171
  - uses: actions/setup-node@v4
138
172
  with:
139
173
  node-version: 22
140
174
  cache: npm
175
+
141
176
  - 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
177
+
178
+ # Add your application's build and test-service setup here.
179
+ - name: Start application and wait for readiness
180
+ shell: bash
181
+ run: |
182
+ npm run start:test > app.log 2>&1 &
183
+ app_pid=$!
184
+ for attempt in {1..30}; do
185
+ if ! kill -0 "$app_pid" 2>/dev/null; then
186
+ cat app.log
187
+ exit 1
188
+ fi
189
+ if curl --fail --silent --max-time 2 http://localhost:3000/health; then
190
+ exit 0
191
+ fi
192
+ sleep 2
193
+ done
194
+ cat app.log
195
+ echo "Application did not become ready in time."
196
+ exit 1
197
+
198
+ - name: Verify streaming behavior
199
+ run: |
200
+ npx --no-install streamtrust verify \
201
+ --adapter ./streamtrust/adapter.mjs \
202
+ --output ./reports/streamtrust
144
203
  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
204
+ STREAMTRUST_BASE_URL: http://localhost:3000
205
+
206
+ - name: Save diagnostic reports
148
207
  if: always()
208
+ uses: actions/upload-artifact@v4
149
209
  with:
150
- name: streamtrust-evidence
210
+ name: streamtrust-reports
151
211
  path: reports/streamtrust/
152
212
  ```
153
213
 
214
+ If your application is already running in the job, omit the startup step and point your adapter at its test URL. For the bundled HTTP adapter's optional bearer authentication, add `STREAMTRUST_API_TOKEN: ${{ secrets.TEST_APP_TOKEN }}` to the verification step's `env`, and configure that secret in GitHub. Fork pull requests generally do not receive repository secrets; use a suitable unauthenticated test fixture or an explicitly trusted workflow for those runs. Never put credentials in the adapter or uploaded evidence.
215
+
216
+ A failed assertion or harness error fails the verification step. The report-upload step runs even after a failure, so teams can inspect diagnostics when they were generated; startup failures may have no StreamTrust report. To block merging, configure the repository's branch protection or ruleset to require this workflow's `verify` check. StreamTrust does not configure merge rules itself.
217
+
218
+ If you use a locally built tarball instead of npm, keep it in `vendor/` and commit the corresponding file dependency and lockfile so `npm ci` can resolve it.
219
+
154
220
  | Exit | Meaning |
155
221
  |---|---|
156
222
  | 0 | Every declared scenario and assertion passed. |
@@ -163,6 +229,6 @@ Use a unique output directory per concurrent job. The CLI takes an exclusive loc
163
229
 
164
230
  ## Scope and attribution
165
231
 
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.
232
+ 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; the LibreChat adapter is now available as an npm preset with optional dependencies. The Vercel experiment remains separate.
167
233
 
168
234
  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 release is published interactively; npm build provenance is not configured.
@@ -0,0 +1,91 @@
1
+ # LibreChat CI integration
2
+
3
+ This preset runs a **built local LibreChat checkout**, native browser UI and real disposable MongoDB. A controlled OpenAI-compatible HTTP provider supplies synthetic text. No live model key or existing database is used. Four scenarios cover saved answers, Stop, regeneration branches, and retry after a provider disconnect. This is not an official LibreChat integration.
4
+
5
+ ## Installation
6
+
7
+ Use Node.js 24 and npm 11. In a separate verification project, install StreamTrust 0.2.0 from npm:
8
+
9
+ ```sh
10
+ npm init -y
11
+ npm install --save-dev --save-exact streamtrust@0.2.0
12
+ npm install --save-dev --save-exact playwright@1.64.1 mongodb@6.20.0 mongodb-memory-server@11.3.0
13
+ npx playwright install chromium
14
+ npx streamtrust init streamtrust-librechat --preset librechat
15
+ ```
16
+
17
+ Use Node.js 24 and npm 11 for this preset. Commit the generated adapter and your package lock. The core CLI still has no runtime dependencies; browser/database dependencies belong to the verification project. Browser, MongoDB binary and LibreChat installation require network access and disk space. The first build takes several minutes; this is not a five-minute cold installation.
18
+
19
+ ## Run the supported upstream version
20
+
21
+ ```sh
22
+ export STREAMTRUST_LIBRECHAT_ROOT="$PWD/.cache/librechat"
23
+ node streamtrust-librechat/setup.mjs
24
+ npx streamtrust verify --adapter streamtrust-librechat/adapter.mjs \
25
+ --output reports/librechat --repetitions 3 --timeout-ms 600000
26
+ ```
27
+
28
+ The setup script clones and builds commit `a378657583567310c841d652b0f6b00c83e2bd6b`. It refuses to reset an existing or dirty checkout. The verifier starts its own loopback-only server, chooses an available port, creates a synthetic test user, and tears down its browser, provider and disposable database. The preset refuses checkouts containing `.env`, `.env.local` or `.env.production`. Do not place production configuration in this checkout.
29
+
30
+ To test your team's committed changes, point `STREAMTRUST_LIBRECHAT_ROOT` at your **built, clean checkout** and set `STREAMTRUST_LIBRECHAT_COMMIT` to its full SHA. Skip setup if already built. This executes the actual application under test; it does not copy a test app into your repository. Compatibility with other commits is not guaranteed: changed selectors/routes/schema can cause HARNESS_ERROR rather than a product failure.
31
+
32
+ Optional settings:
33
+
34
+ | Variable | Purpose |
35
+ |---|---|
36
+ | `STREAMTRUST_LIBRECHAT_ROOT` | Required path to the built checkout |
37
+ | `STREAMTRUST_LIBRECHAT_COMMIT` | Expected full SHA; defaults to the tested upstream commit |
38
+ | `STREAMTRUST_LIBRECHAT_PORT` | Optional fixed loopback port; defaults to an ephemeral port |
39
+ | `STREAMTRUST_BROWSER_CHANNEL` | Optional installed browser channel, such as `chrome`; default is Playwright Chromium |
40
+
41
+ ## CI
42
+
43
+ In your verification project, install once and commit package.json/package-lock.json and the generated preset. Add these steps to a Node 24 GitHub Actions job:
44
+
45
+ ```yaml
46
+ - uses: actions/checkout@v4
47
+ - uses: actions/setup-node@v4
48
+ with:
49
+ node-version: '24'
50
+ cache: npm
51
+ - run: npm ci
52
+ - run: npx --no-install playwright install --with-deps chromium
53
+ - name: Build isolated LibreChat checkout
54
+ run: node streamtrust-librechat/setup.mjs
55
+ env:
56
+ STREAMTRUST_LIBRECHAT_ROOT: ${{ runner.temp }}/librechat
57
+ - name: Verify LibreChat
58
+ run: npx --no-install streamtrust verify --adapter streamtrust-librechat/adapter.mjs --output reports/librechat --repetitions 3 --timeout-ms 600000
59
+ env:
60
+ STREAMTRUST_LIBRECHAT_ROOT: ${{ runner.temp }}/librechat
61
+ - uses: actions/upload-artifact@v4
62
+ if: always()
63
+ with:
64
+ name: librechat-verification
65
+ path: |
66
+ reports/librechat/report.json
67
+ reports/librechat/report.md
68
+ reports/librechat/junit.xml
69
+ reports/librechat/index.html
70
+ retention-days: 7
71
+ ```
72
+
73
+ Use a fresh report directory for each job. Restrict workflow permissions to `contents: read`, set a 30-minute job timeout, and use normal pull-request workflows without production secrets. To verify your fork, replace the build step with checkout/build of the intended commit and pass its SHA to the verifier.
74
+
75
+ Exit 0 means all declared checks passed; 1 means failed or unsupported checks; 2 means setup, timeout, or harness error. Keep the exit status: do not use `continue-on-error`. Inspect reports before calling a failure an upstream defect.
76
+
77
+ ## Evidence and limits
78
+
79
+ `summary-only-v1` returns stable check identifiers, booleans, message counts and provider-call counts. Raw chat text, database records, user identifiers, network URLs and tokens remain in memory and are not returned as report evidence. The preset does not write screenshots, browser traces or application logs. Browser/database exceptions are replaced with fixed errors. App subprocess output is discarded. Upload only the four report files, not the entire working directory.
80
+
81
+ This is data minimization, not a universal PII detector or sandbox. Other adapters, changes to this adapter, setup/build logs and third-party processes are outside that guarantee. The core verifier still runs trusted local code. The preset deliberately does not forward application environment variables, but LibreChat can load files in its checkout: use an isolated checkout with no production configuration.
82
+
83
+ The model provider is synthetic. The test uses one server and an in-memory stream store: no Redis, real model behavior, database crash, worker kill, multi-replica or production incident-rate coverage. A single matching saved answer is not proof of exactly-once external side effects. Regeneration is a distinct sibling branch, not reuse of a request idempotency key.
84
+
85
+ The exported `streamtrust/checks` evaluator can be reused by other adapters. Map observations to `LifecycleObservation`; the evaluator validates required types, computes checks, and returns summary-only evidence. Missing or malformed observations throw, becoming HARNESS_ERROR rather than a green result.
86
+
87
+ ## Validation
88
+
89
+ Local validation used macOS arm64, Node 24.22.1, Playwright 1.64.1 Chromium 156.0.8078.4, MongoDB 8.2.6, and the pinned LibreChat commit. A clean tarball consumer passed all four scenarios with three repetitions (12 executions, 99 assertions). A checkout containing an environment file was rejected with exit 2. Success and error artifact checks found only the four intended files and no tested secret/chat-text markers. The package suite has 13 tests, including negative controls for shared checks and stale-screenshot exclusion.
90
+
91
+ The [Linux GitHub Actions run](https://github.com/santoshsibyala/streamtrust/actions/runs/40451851475) also passed a cold clone/install/build, clean tarball installation, all 12 scenario executions, and upload of exactly the four report files. The verified implementation commit is `b1b2b27a19b512c6abce164502901d1716bf86a3`. Passing results do not establish an upstream defect or production reliability.
@@ -0,0 +1,515 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { evaluateLifecycle } from 'streamtrust/checks';
3
+ import { createRequire } from 'node:module';
4
+ import { spawn, execFileSync } from 'node:child_process';
5
+ import { mkdtemp, rm } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { createServer } from 'node:net';
8
+ import { readFile, writeFile } from 'node:fs/promises';
9
+ import { resolve, dirname } from 'node:path';
10
+ import { createHash, randomBytes } from 'node:crypto';
11
+
12
+ import { startProvider } from './provider.mjs';
13
+
14
+ const PIN = 'a378657583567310c841d652b0f6b00c83e2bd6b';
15
+ export async function until(check, label, ms = 20000) {
16
+ const end = Date.now() + ms;
17
+ while (Date.now() < end) {
18
+ if (await check()) return;
19
+ await new Promise((r) => setTimeout(r, 100));
20
+ }
21
+ throw new Error(`Timeout: ${label}`);
22
+ }
23
+ let phase = 'preflight';
24
+ async function createAdapter() {
25
+ if (!process.env.STREAMTRUST_LIBRECHAT_ROOT)
26
+ throw new Error(
27
+ 'Set STREAMTRUST_LIBRECHAT_ROOT to a built LibreChat checkout',
28
+ );
29
+ if (Number(process.versions.node.split('.')[0]) < 24)
30
+ throw new Error('LibreChat preset requires Node.js 24+');
31
+ const root = resolve(process.env.STREAMTRUST_LIBRECHAT_ROOT);
32
+ if (
33
+ ['.env', '.env.local', '.env.production'].some((name) =>
34
+ existsSync(resolve(root, name)),
35
+ )
36
+ )
37
+ throw new Error('Use an isolated checkout without environment files');
38
+ const expectedCommit = process.env.STREAMTRUST_LIBRECHAT_COMMIT || PIN;
39
+ if (!/^[a-f0-9]{40}$/.test(expectedCommit))
40
+ throw new Error('Expected commit must be a full Git SHA');
41
+ const configPort = Number(process.env.STREAMTRUST_LIBRECHAT_PORT || 0);
42
+ if (!Number.isInteger(configPort) || configPort < 0 || configPort > 65535)
43
+ throw new Error('Invalid LibreChat port');
44
+ const port =
45
+ configPort ||
46
+ (await new Promise((ok, fail) => {
47
+ const server = createServer();
48
+ server.once('error', fail);
49
+ server.listen(0, '127.0.0.1', () => {
50
+ const value = server.address().port;
51
+ server.close(() => ok(value));
52
+ });
53
+ }));
54
+ const baseUrl = `http://127.0.0.1:${port}`;
55
+ const commit = execFileSync('git', ['rev-parse', 'HEAD'], {
56
+ cwd: root,
57
+ encoding: 'utf8',
58
+ }).trim();
59
+ const dirty = () =>
60
+ execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], {
61
+ cwd: root,
62
+ encoding: 'utf8',
63
+ }).trim();
64
+ if (commit !== expectedCommit || dirty())
65
+ throw new Error('Expected clean pinned LibreChat checkout');
66
+ const require = createRequire(resolve(process.cwd(), 'package.json'));
67
+ const { chromium } = require('playwright');
68
+ const { MongoMemoryServer } = require('mongodb-memory-server');
69
+ const { MongoClient } = require('mongodb');
70
+ const cleanups = [];
71
+ let cleaned = false;
72
+ const cleanup = async () => {
73
+ if (cleaned) return;
74
+ cleaned = true;
75
+ let cleanupFailed = false;
76
+ for (const close of cleanups.reverse()) {
77
+ try {
78
+ await close();
79
+ } catch {
80
+ cleanupFailed = true;
81
+ }
82
+ }
83
+ if (cleanupFailed) throw new Error('LibreChat cleanup failed');
84
+ };
85
+ try {
86
+ phase = 'disposable database startup';
87
+ const mongo = await MongoMemoryServer.create({
88
+ instance: { ip: '127.0.0.1', dbName: 'streamtrust' },
89
+ });
90
+ cleanups.push(() => mongo.stop());
91
+ const client = new MongoClient(mongo.getUri());
92
+ cleanups.push(() => client.close());
93
+ await client.connect();
94
+ const db = client.db('streamtrust');
95
+ const provider = await startProvider();
96
+ cleanups.push(() => provider.close());
97
+ const temporary = await mkdtemp(
98
+ resolve(tmpdir(), 'streamtrust-librechat-'),
99
+ );
100
+ cleanups.unshift(() => rm(temporary, { recursive: true, force: true }));
101
+ const config = resolve(temporary, 'librechat.yaml');
102
+ await writeFile(
103
+ config,
104
+ `version: 1.3.11\ncache: true\nendpoints:\n custom:\n - name: StreamTrust Fixture\n apiKey: fixture-not-a-real-key\n baseURL: ${provider.url}\n models:\n default: [fixture-model]\n fetch: false\n titleConvo: false\n modelDisplayLabel: Fixture\n`,
105
+ );
106
+ const runtime = process.execPath;
107
+ const env = {
108
+ // Do not forward provider keys, database URLs or application secrets.
109
+ ...Object.fromEntries(
110
+ ['PATH', 'HOME', 'TMPDIR', 'TEMP', 'SystemRoot']
111
+ .filter((k) => process.env[k])
112
+ .map((k) => [k, process.env[k]]),
113
+ ),
114
+ PATH: `${dirname(runtime)}:${process.env.PATH}`,
115
+ NODE_ENV: 'production',
116
+ HOST: '127.0.0.1',
117
+ PORT: String(port),
118
+ DOMAIN_CLIENT: baseUrl,
119
+ DOMAIN_SERVER: baseUrl,
120
+ MONGO_URI: mongo.getUri('streamtrust'),
121
+ CONFIG_PATH: config,
122
+ ENDPOINTS: 'custom',
123
+ OPENAI_API_KEY: '',
124
+ SEARCH: 'false',
125
+ NO_INDEX: 'true',
126
+ ALLOW_REGISTRATION: 'true',
127
+ ALLOW_SOCIAL_LOGIN: 'false',
128
+ ALLOW_EMAIL_LOGIN: 'true',
129
+ JWT_SECRET: randomBytes(32).toString('hex'),
130
+ JWT_REFRESH_SECRET: randomBytes(32).toString('hex'),
131
+ CREDS_KEY: randomBytes(32).toString('hex'),
132
+ CREDS_IV: randomBytes(16).toString('hex'),
133
+ SCHEDULES_SINGLE_PROCESS: 'true',
134
+ USE_REDIS: 'false',
135
+ USE_REDIS_STREAMS: 'false',
136
+ REGISTER_MAX: '100',
137
+ LOGIN_MAX: '100',
138
+ };
139
+ phase = 'application startup';
140
+ const app = spawn(runtime, ['api/server/index.js'], {
141
+ cwd: root,
142
+ env,
143
+ stdio: ['ignore', 'ignore', 'ignore'],
144
+ });
145
+ let appError = false;
146
+ app.on('error', () => {
147
+ appError = true;
148
+ });
149
+ cleanups.push(async () => {
150
+ if (!appError && app.exitCode === null && app.signalCode === null) {
151
+ const stopped = new Promise((r) => app.once('exit', () => r()));
152
+ app.kill('SIGTERM');
153
+ const timer = setTimeout(() => app.kill('SIGKILL'), 10000);
154
+ await stopped;
155
+ clearTimeout(timer);
156
+ }
157
+ });
158
+ phase = 'browser startup';
159
+ const browser = await chromium.launch(
160
+ !process.env.STREAMTRUST_BROWSER_CHANNEL
161
+ ? { headless: true }
162
+ : { channel: process.env.STREAMTRUST_BROWSER_CHANNEL, headless: true },
163
+ );
164
+ cleanups.push(() => browser.close());
165
+ phase = 'application readiness';
166
+ await until(
167
+ async () => {
168
+ if (appError || app.exitCode !== null)
169
+ throw new Error(
170
+ 'LibreChat startup failed; check the build and port configuration',
171
+ );
172
+ try {
173
+ return (await fetch(baseUrl, { signal: AbortSignal.timeout(2000) }))
174
+ .ok;
175
+ } catch {
176
+ return false;
177
+ }
178
+ },
179
+ 'app ready',
180
+ 120000,
181
+ );
182
+ phase = 'synthetic user registration';
183
+ const email = `verifier-${Date.now()}@example.com`,
184
+ password = `Fixture-${randomBytes(12).toString('hex')}!`;
185
+ const registration = await browser.newContext();
186
+ const page = await registration.newPage();
187
+ await page.goto(`${baseUrl}/register`);
188
+ await page.getByLabel('Full name').fill('StreamTrust Verifier');
189
+ await page.getByLabel('Email', { exact: true }).fill(email);
190
+ await page.getByTestId('password').fill(password);
191
+ await page.getByTestId('confirm_password').fill(password);
192
+ await page.getByLabel('Submit registration').click();
193
+ await page.waitForURL('**/login**');
194
+ await page.getByLabel('Email', { exact: true }).fill(email);
195
+ await page.getByLabel('Password', { exact: true }).fill(password);
196
+ await page.getByTestId('login-button').click();
197
+ await page
198
+ .getByRole('textbox', { name: 'Message input' })
199
+ .waitFor({ timeout: 30000 });
200
+ await page.close();
201
+ async function messages(id) {
202
+ return db
203
+ .collection('messages')
204
+ .find(
205
+ { conversationId: id },
206
+ {
207
+ projection: {
208
+ _id: 0,
209
+ messageId: 1,
210
+ parentMessageId: 1,
211
+ conversationId: 1,
212
+ text: 1,
213
+ content: 1,
214
+ isCreatedByUser: 1,
215
+ error: 1,
216
+ unfinished: 1,
217
+ },
218
+ },
219
+ )
220
+ .toArray();
221
+ }
222
+ const textOf = (m) =>
223
+ m.text ||
224
+ (m.content ?? [])
225
+ .filter((p) => p.type === 'text')
226
+ .map((p) =>
227
+ typeof p.text === 'string' ? p.text : (p.text?.value ?? ''),
228
+ )
229
+ .join('');
230
+ const hasError = (m) =>
231
+ (m.content ?? []).some((part) => part.type === 'error');
232
+ const observed = async (check) => {
233
+ const deadline = Date.now() + 15000;
234
+ while (Date.now() < deadline) {
235
+ if (await check()) return true;
236
+ await new Promise((r) => setTimeout(r, 100));
237
+ }
238
+ return false;
239
+ };
240
+ const visible = (p) => p.getByTestId('messages-view').innerText();
241
+ async function waitGeneration(p, index) {
242
+ await until(
243
+ () => provider.generations.length > index,
244
+ 'provider request',
245
+ );
246
+ const g = provider.generations[index];
247
+ await until(
248
+ async () => (await visible(p)).includes(g.prefix),
249
+ 'first rendered chunk',
250
+ );
251
+ return g;
252
+ }
253
+ return {
254
+ name: 'LibreChat',
255
+ scenarios: ['persistence', 'cancellation', 'regeneration', 'error-retry'],
256
+ async metadata() {
257
+ return {
258
+ evidencePolicy: 'summary-only-v1',
259
+ checkVersion: 1,
260
+ mongodb: (await db.admin().command({ buildInfo: 1 })).version,
261
+ upstream: 'https://github.com/LibreChat-AI/LibreChat',
262
+ commit,
263
+ trackedSourceModified: !!dirty(),
264
+ node: execFileSync(runtime, ['--version'], {
265
+ encoding: 'utf8',
266
+ }).trim(),
267
+ browser: browser.version(),
268
+ lockSha256: createHash('sha256')
269
+ .update(await readFile(resolve(root, 'package-lock.json')))
270
+ .digest('hex'),
271
+ scope:
272
+ 'Real native UI, authentication, application routes, generation jobs and MongoDB. Only external OpenAI-compatible provider responses are synthetic. Single server, in-memory stream store; no Redis, worker-kill, database-crash or multi-replica coverage. Regeneration is a branch, not overwriting history. No coordinator installed.',
273
+ };
274
+ },
275
+ async run(scenario, { repetition }) {
276
+ const context = registration;
277
+ const p = await context.newPage();
278
+ const network = [];
279
+
280
+ p.on('response', (r) => {
281
+ const path = new URL(r.url()).pathname;
282
+ if (path.startsWith('/api/') && /chat|abort|message/.test(path))
283
+ network.push({
284
+ path,
285
+ method: r.request().method(),
286
+ status: r.status(),
287
+ });
288
+ });
289
+ let first;
290
+ let second;
291
+ try {
292
+ await p.goto(`${baseUrl}/c/new`);
293
+ await p.getByRole('textbox', { name: 'Message input' }).waitFor();
294
+ const selector = p
295
+ .getByRole('button', { name: 'Select a model' })
296
+ .first();
297
+ if (
298
+ (await selector.count()) &&
299
+ !(await selector.innerText()).includes('fixture-model')
300
+ ) {
301
+ await selector.click();
302
+ await p
303
+ .getByRole('option', { name: 'StreamTrust Fixture', exact: true })
304
+ .click();
305
+ const model = p.getByRole('option', {
306
+ name: 'fixture-model',
307
+ exact: true,
308
+ });
309
+ if (await model.isVisible().catch(() => false)) await model.click();
310
+ }
311
+ const before = provider.generations.length;
312
+ await p
313
+ .getByRole('textbox', { name: 'Message input' })
314
+ .fill(
315
+ `StreamTrust ${scenario} ${repetition}. Write two sentences.`,
316
+ );
317
+ const admission = p.waitForResponse(
318
+ (r) =>
319
+ r.request().method() === 'POST' &&
320
+ new URL(r.url()).pathname.startsWith('/api/agents/chat') &&
321
+ !r.url().endsWith('/abort'),
322
+ );
323
+ await p
324
+ .getByRole('textbox', { name: 'Message input' })
325
+ .press('Enter');
326
+ first = await waitGeneration(p, before);
327
+ const accepted = await (await admission).json();
328
+ const id = accepted.conversationId;
329
+ if (!id || id === 'new')
330
+ throw new Error('Missing native admission conversation identity');
331
+ const checks = [];
332
+ let expected = first.prefix + first.suffix;
333
+ if (scenario === 'cancellation') {
334
+ await p
335
+ .getByRole('button', { name: 'Stop generating', exact: true })
336
+ .click();
337
+ await observed(() => first.closed);
338
+ expected = first.prefix;
339
+ checks.push({
340
+ name: 'Native server acknowledges Stop',
341
+ passed: await observed(() =>
342
+ network.some(
343
+ (e) =>
344
+ e.method === 'POST' &&
345
+ e.path.endsWith('/abort') &&
346
+ e.status === 200,
347
+ ),
348
+ ),
349
+ });
350
+ checks.push({
351
+ name: 'Stop aborts upstream HTTP response before completion',
352
+ passed: first.closed && !first.finished,
353
+ });
354
+ checks.push({
355
+ name: 'Late provider completion cannot be delivered',
356
+ passed: !provider.finish(first),
357
+ });
358
+ } else if (scenario === 'error-retry') {
359
+ provider.fail(first);
360
+ } else provider.finish(first);
361
+ await until(
362
+ async () =>
363
+ !(await p
364
+ .getByRole('button', { name: 'Stop generating', exact: true })
365
+ .count()),
366
+ 'generation settles',
367
+ );
368
+
369
+ if (scenario === 'error-retry') {
370
+ const errorSaved = await observed(async () =>
371
+ (await messages(id)).some(hasError),
372
+ );
373
+
374
+ checks.push({
375
+ name: 'Provider failure retained as a native error part',
376
+ passed: errorSaved,
377
+ });
378
+ await p.reload();
379
+ await p.getByRole('textbox', { name: 'Message input' }).waitFor();
380
+ const errorVisible = await observed(async () =>
381
+ (await visible(p)).includes(
382
+ 'model provider closed the connection',
383
+ ),
384
+ );
385
+ checks.push({
386
+ name: 'Provider error context survives reload before retry',
387
+ passed: errorVisible,
388
+ });
389
+ }
390
+ if (scenario === 'regeneration' || scenario === 'error-retry') {
391
+ const nextIndex = provider.generations.length;
392
+ await p
393
+ .getByRole('button', { name: 'Regenerate', exact: true })
394
+ .last()
395
+ .click();
396
+ second = await waitGeneration(p, nextIndex);
397
+ provider.finish(second);
398
+ expected = second.prefix + second.suffix;
399
+ await until(
400
+ async () =>
401
+ !(await p
402
+ .getByRole('button', { name: 'Stop generating', exact: true })
403
+ .count()),
404
+ 'retry settles',
405
+ );
406
+ }
407
+ await observed(async () =>
408
+ (await messages(id)).some(
409
+ (m) => !m.isCreatedByUser && textOf(m).trim() === expected.trim(),
410
+ ),
411
+ );
412
+ const durable = await messages(id);
413
+ const beforeReload = await visible(p);
414
+ await p.reload();
415
+ await p.getByRole('textbox', { name: 'Message input' }).waitFor();
416
+ await observed(async () => (await visible(p)).includes(expected));
417
+ const reloaded = await visible(p);
418
+ return evaluateLifecycle({
419
+ scenario,
420
+ expected,
421
+ original: first.prefix + first.suffix,
422
+ previousPrefix: first.prefix,
423
+ beforeReload,
424
+ reloaded,
425
+ messages: durable
426
+ .filter((m) => !m.isCreatedByUser)
427
+ .map((m) => ({
428
+ id: m.messageId,
429
+ parentId: m.parentMessageId,
430
+ text: textOf(m),
431
+ assistant: !m.isCreatedByUser,
432
+ unfinished: m.unfinished,
433
+ error: !!m.error || hasError(m),
434
+ })),
435
+ callsBeforeReload: second ? second.id : first.id,
436
+ callsAfterReload: provider.generations.length,
437
+ cancellation:
438
+ scenario === 'cancellation'
439
+ ? {
440
+ acknowledged: checks.find(
441
+ (c) => c.name === 'Native server acknowledges Stop',
442
+ ).passed,
443
+ providerClosed: first.closed,
444
+ providerFinished: first.finished,
445
+ lateCompletionRejected: checks.find(
446
+ (c) =>
447
+ c.name ===
448
+ 'Late provider completion cannot be delivered',
449
+ ).passed,
450
+ }
451
+ : undefined,
452
+ failure:
453
+ scenario === 'error-retry'
454
+ ? {
455
+ saved: checks.find(
456
+ (c) =>
457
+ c.name ===
458
+ 'Provider failure retained as a native error part',
459
+ ).passed,
460
+ visibleAfterReload: checks.find(
461
+ (c) =>
462
+ c.name ===
463
+ 'Provider error context survives reload before retry',
464
+ ).passed,
465
+ }
466
+ : undefined,
467
+ });
468
+ } catch {
469
+ // Browser/DB exceptions can contain prompts, selectors, URLs and credentials.
470
+ throw new Error(
471
+ 'LibreChat scenario could not complete (details withheld by summary-only policy)',
472
+ );
473
+ } finally {
474
+ if (first && !first.closed) provider.finish(first);
475
+ if (second && !second.closed) provider.finish(second);
476
+ await p.close();
477
+ }
478
+ },
479
+ close: cleanup,
480
+ };
481
+ } catch (error) {
482
+ await cleanup();
483
+ throw error;
484
+ }
485
+ }
486
+
487
+ export default async function safeFactory(context) {
488
+ try {
489
+ phase = 'preflight';
490
+ const adapter = await createAdapter();
491
+ const run = adapter.run.bind(adapter);
492
+ adapter.run = async (...args) => {
493
+ try {
494
+ return await run(...args);
495
+ } catch {
496
+ throw new Error(
497
+ 'LibreChat scenario could not complete (details withheld by summary-only policy)',
498
+ );
499
+ }
500
+ };
501
+ const metadata = adapter.metadata.bind(adapter);
502
+ adapter.metadata = async () => {
503
+ try {
504
+ return await metadata();
505
+ } catch {
506
+ throw new Error('LibreChat metadata unavailable (details withheld)');
507
+ }
508
+ };
509
+ return adapter;
510
+ } catch {
511
+ throw new Error(
512
+ `LibreChat setup failed during ${phase}. Check Node 24+, optional dependencies, built clean checkout without .env files, expected commit, and port. Raw details withheld.`,
513
+ );
514
+ }
515
+ }
@@ -0,0 +1,79 @@
1
+ import { createServer } from 'node:http';
2
+
3
+ export async function startProvider() {
4
+ const generations = [];
5
+ const chunk = (g, delta, finish = null) =>
6
+ g.response.write(
7
+ `data: ${JSON.stringify({ id: `chatcmpl-fixture-${g.id}`, object: 'chat.completion.chunk', created: 1, model: 'fixture-model', choices: [{ index: 0, delta: { content: delta }, finish_reason: finish }] })}\n\n`,
8
+ );
9
+ const server = createServer(async (req, res) => {
10
+ let body = '';
11
+ for await (const part of req) body += part;
12
+ if (req.method !== 'POST') {
13
+ res.setHeader('content-type', 'application/json');
14
+ res.end(
15
+ JSON.stringify({ data: [{ id: 'fixture-model', object: 'model' }] }),
16
+ );
17
+ return;
18
+ }
19
+ const input = JSON.parse(body);
20
+ if (!input.stream) {
21
+ res.setHeader('content-type', 'application/json');
22
+ res.end(
23
+ JSON.stringify({
24
+ choices: [
25
+ {
26
+ message: { role: 'assistant', content: 'Fixture conversation' },
27
+ finish_reason: 'stop',
28
+ },
29
+ ],
30
+ }),
31
+ );
32
+ return;
33
+ }
34
+ const id = generations.length + 1;
35
+ const g = {
36
+ id,
37
+ prefix: `Response ${id} begins.`,
38
+ suffix: ` Response ${id} completes.`,
39
+ closed: false,
40
+ finished: false,
41
+ failed: false,
42
+ path: req.url,
43
+ response: res,
44
+ };
45
+ generations.push(g);
46
+ res.on('close', () => {
47
+ g.closed = true;
48
+ });
49
+ res.writeHead(200, {
50
+ 'content-type': 'text/event-stream',
51
+ 'cache-control': 'no-cache',
52
+ });
53
+ chunk(g, g.prefix);
54
+ });
55
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
56
+ return {
57
+ url: `http://127.0.0.1:${server.address().port}/v1`,
58
+ generations,
59
+ finish(g) {
60
+ if (g.closed) return false;
61
+ chunk(g, g.suffix);
62
+ chunk(g, '', 'stop');
63
+ g.finished = true;
64
+ g.response.end('data: [DONE]\n\n');
65
+ return true;
66
+ },
67
+ fail(g) {
68
+ g.failed = true;
69
+ g.response.destroy();
70
+ },
71
+ observations() {
72
+ return generations.map(({ response, ...g }) => g);
73
+ },
74
+ async close() {
75
+ server.closeAllConnections();
76
+ await new Promise((r) => server.close(() => r()));
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,42 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ const root = process.env.STREAMTRUST_LIBRECHAT_ROOT;
5
+ const pin =
6
+ process.env.STREAMTRUST_LIBRECHAT_COMMIT ||
7
+ 'a378657583567310c841d652b0f6b00c83e2bd6b';
8
+ if (
9
+ !root ||
10
+ Number(process.versions.node.split('.')[0]) < 24 ||
11
+ !/^[a-f0-9]{40}$/.test(pin)
12
+ )
13
+ throw new Error(
14
+ 'Set STREAMTRUST_LIBRECHAT_ROOT; use Node 24+ and a full commit SHA',
15
+ );
16
+ const path = resolve(root);
17
+ const run = (cmd, args, cwd) =>
18
+ execFileSync(cmd, args, { cwd, stdio: 'inherit' });
19
+ if (!existsSync(path)) {
20
+ run('git', [
21
+ 'clone',
22
+ '--no-checkout',
23
+ 'https://github.com/LibreChat-AI/LibreChat.git',
24
+ path,
25
+ ]);
26
+ run('git', ['checkout', '--detach', pin], path);
27
+ }
28
+ if (
29
+ execFileSync('git', ['rev-parse', 'HEAD'], {
30
+ cwd: path,
31
+ encoding: 'utf8',
32
+ }).trim() !== pin ||
33
+ execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], {
34
+ cwd: path,
35
+ encoding: 'utf8',
36
+ }).trim()
37
+ )
38
+ throw new Error(
39
+ 'Checkout must be clean and match the expected commit; setup never resets existing source',
40
+ );
41
+ run('npm', ['ci', '--no-audit', '--no-fund'], path);
42
+ run('npm', ['run', 'frontend'], path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "streamtrust",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Adapter-driven CI verification for streaming application lifecycle guarantees",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -14,6 +14,10 @@
14
14
  ".": {
15
15
  "types": "./src/index.d.ts",
16
16
  "import": "./src/index.js"
17
+ },
18
+ "./checks": {
19
+ "types": "./src/checks.d.ts",
20
+ "import": "./src/checks.js"
17
21
  }
18
22
  },
19
23
  "types": "src/index.d.ts",
@@ -0,0 +1,36 @@
1
+ import type { ExternalObservation } from './index.js';
2
+ export type LifecycleScenario =
3
+ | 'persistence'
4
+ | 'cancellation'
5
+ | 'regeneration'
6
+ | 'error-retry';
7
+ export const LIFECYCLE_SCENARIOS: readonly LifecycleScenario[];
8
+ export interface LifecycleObservation {
9
+ scenario: LifecycleScenario;
10
+ expected: string;
11
+ original: string;
12
+ previousPrefix: string;
13
+ beforeReload: string;
14
+ reloaded: string;
15
+ messages: Array<{
16
+ id: string;
17
+ parentId: string;
18
+ text: string;
19
+ assistant: boolean;
20
+ unfinished: boolean;
21
+ error: boolean;
22
+ }>;
23
+ callsBeforeReload: number;
24
+ callsAfterReload: number;
25
+ cancellation?: {
26
+ acknowledged: boolean;
27
+ providerClosed: boolean;
28
+ providerFinished: boolean;
29
+ lateCompletionRejected: boolean;
30
+ };
31
+ failure?: { saved: boolean; visibleAfterReload: boolean };
32
+ }
33
+ /** Evaluate in-memory observations; returns only fixed check names and allowlisted numeric evidence. */
34
+ export function evaluateLifecycle(
35
+ observation: LifecycleObservation,
36
+ ): ExternalObservation;
package/src/checks.js ADDED
@@ -0,0 +1,105 @@
1
+ // Raw observations remain in memory. Returned evidence is deliberately allowlisted.
2
+ export const LIFECYCLE_SCENARIOS = Object.freeze([
3
+ 'persistence',
4
+ 'cancellation',
5
+ 'regeneration',
6
+ 'error-retry',
7
+ ]);
8
+ export function evaluateLifecycle(o) {
9
+ const text = (v) => typeof v === 'string' && v.length > 0;
10
+ const bool = (v) => typeof v === 'boolean';
11
+ if (
12
+ !o ||
13
+ !LIFECYCLE_SCENARIOS.includes(o.scenario) ||
14
+ !text(o.expected) ||
15
+ !text(o.original) ||
16
+ !text(o.previousPrefix) ||
17
+ typeof o.beforeReload !== 'string' ||
18
+ typeof o.reloaded !== 'string' ||
19
+ !Array.isArray(o.messages) ||
20
+ o.messages.some(
21
+ (m) =>
22
+ !m ||
23
+ !text(m.id) ||
24
+ typeof m.parentId !== 'string' ||
25
+ typeof m.text !== 'string' ||
26
+ !bool(m.assistant) ||
27
+ !bool(m.unfinished) ||
28
+ !bool(m.error),
29
+ ) ||
30
+ !Number.isSafeInteger(o.callsBeforeReload) ||
31
+ o.callsBeforeReload < 1 ||
32
+ !Number.isSafeInteger(o.callsAfterReload) ||
33
+ o.callsAfterReload < 0
34
+ )
35
+ throw new Error('Invalid lifecycle observation');
36
+ const checks = [];
37
+ const check = (name, passed) => checks.push({ name, passed });
38
+ const matching = o.messages.filter(
39
+ (m) => m.assistant && m.text.trim() === o.expected.trim(),
40
+ );
41
+ check('saved.answer-present', matching.length > 0);
42
+ check('saved.single-matching-answer', matching.length === 1);
43
+ check('ui.answer-visible', o.beforeReload.includes(o.expected));
44
+ check(
45
+ 'saved.lifecycle-state',
46
+ matching.length === 1 &&
47
+ (o.scenario === 'cancellation'
48
+ ? matching[0].unfinished === true
49
+ : matching[0].unfinished === false && !matching[0].error),
50
+ );
51
+ check('reload.answer-preserved', o.reloaded.includes(o.expected));
52
+ check('reload.no-new-generation', o.callsAfterReload === o.callsBeforeReload);
53
+ if (o.scenario === 'cancellation') {
54
+ const c = o.cancellation;
55
+ if (
56
+ !c ||
57
+ [
58
+ 'acknowledged',
59
+ 'providerClosed',
60
+ 'providerFinished',
61
+ 'lateCompletionRejected',
62
+ ].some((k) => !bool(c[k]))
63
+ )
64
+ throw new Error('Invalid cancellation observation');
65
+ check('cancel.server-acknowledged', c.acknowledged);
66
+ check('cancel.provider-aborted', c.providerClosed && !c.providerFinished);
67
+ check('cancel.late-completion-rejected', c.lateCompletionRejected);
68
+ }
69
+ if (o.scenario === 'regeneration') {
70
+ const original = o.messages.filter(
71
+ (m) => m.assistant && m.text === o.original,
72
+ );
73
+ check('retry.original-branch-preserved', original.length === 1);
74
+ check(
75
+ 'retry.distinct-sibling',
76
+ original.length === 1 &&
77
+ matching.length === 1 &&
78
+ original[0].id !== matching[0].id &&
79
+ original[0].parentId === matching[0].parentId,
80
+ );
81
+ }
82
+ if (o.scenario === 'error-retry') {
83
+ const f = o.failure;
84
+ if (!f || !bool(f.saved) || !bool(f.visibleAfterReload))
85
+ throw new Error('Invalid failure observation');
86
+ check('error.saved-before-retry', f.saved);
87
+ check('error.visible-after-reload', f.visibleAfterReload);
88
+ }
89
+ if (['regeneration', 'error-retry'].includes(o.scenario))
90
+ check(
91
+ 'retry.previous-answer-not-active',
92
+ !o.reloaded.includes(o.previousPrefix),
93
+ );
94
+ return {
95
+ checks,
96
+ evidence: {
97
+ policy: 'summary-only-v1',
98
+ checkVersion: 1,
99
+ savedMessageCount: o.messages.length,
100
+ matchingAnswerCount: matching.length,
101
+ providerCallsBeforeReload: o.callsBeforeReload,
102
+ providerCallsAfterReload: o.callsAfterReload,
103
+ },
104
+ };
105
+ }
package/src/cli.js CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
  import { mkdir, copyFile, open, unlink, readFile } from 'node:fs/promises';
6
6
  import { constants } from 'node:fs';
7
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`;
8
+ const HELP = `StreamTrust ${VERSION}\n\nUsage:\n streamtrust init [directory] [--preset librechat]\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
9
  export async function main(args) {
10
10
  let outputDir;
11
11
  try {
@@ -18,17 +18,32 @@ export async function main(args) {
18
18
  return 0;
19
19
  }
20
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');
21
+ const initArgs = args.slice(1);
22
+ const presetIndex = initArgs.indexOf('--preset');
23
+ let preset = 'http';
24
+ if (presetIndex !== -1) {
25
+ preset = initArgs[presetIndex + 1];
26
+ initArgs.splice(presetIndex, 2);
27
+ if (preset !== 'librechat') throw new Error('Unknown init preset');
28
+ }
29
+ if (initArgs.length > 1 || initArgs[0]?.startsWith('-'))
30
+ throw new Error('Invalid init arguments');
31
+ const dir = resolve(initArgs[0] ?? 'streamtrust');
24
32
  await mkdir(dir, { recursive: true });
25
33
  // Exclusive copies preserve existing adapters. No generated config overrides user files.
26
34
  const created = [];
27
35
  try {
28
- for (const [source, target] of [
29
- ['http.adapter.mjs', 'adapter.mjs'],
30
- ['demo-app.mjs', 'demo-app.mjs'],
31
- ]) {
36
+ for (const [source, target] of preset === 'librechat'
37
+ ? [
38
+ ['librechat/adapter.mjs', 'adapter.mjs'],
39
+ ['librechat/provider.mjs', 'provider.mjs'],
40
+ ['librechat/setup.mjs', 'setup.mjs'],
41
+ ['librechat/README.md', 'README.md'],
42
+ ]
43
+ : [
44
+ ['http.adapter.mjs', 'adapter.mjs'],
45
+ ['demo-app.mjs', 'demo-app.mjs'],
46
+ ]) {
32
47
  const destination = resolve(dir, target);
33
48
  await copyFile(
34
49
  new URL(`../examples/${source}`, import.meta.url),
package/src/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { mkdir, writeFile, rename, access } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
- export const VERSION = '0.1.0-preview.1';
4
+ import { readFileSync } from 'node:fs';
5
+ export const VERSION = JSON.parse(
6
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
7
+ ).version;
5
8
  const object = (value) =>
6
9
  value !== null && typeof value === 'object' && !Array.isArray(value);
7
10
  const nonempty = (value) =>
@@ -60,13 +63,15 @@ export async function writeReports(outputDir, report) {
60
63
  const rows = report.results;
61
64
  const screenshots = new Set();
62
65
  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
- }),
66
+ (report.metadata?.evidencePolicy === 'summary-only-v1' ? [] : rows).map(
67
+ async (row) => {
68
+ const path = `${row.scenario}-${row.repetition}.png`;
69
+ try {
70
+ await access(resolve(outputDir, path));
71
+ screenshots.add(path);
72
+ } catch {}
73
+ },
74
+ ),
70
75
  );
71
76
  const suiteCases = report.suiteErrors
72
77
  .map(