streamtrust 0.3.0 → 0.5.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
@@ -10,7 +10,7 @@ StreamTrust is a CI check for streaming applications such as AI chat. Its reusab
10
10
  - **Repeated runs.** Each scenario runs N times and the report shows every repetition, because cancellation and retry bugs are often intermittent.
11
11
  - **Your contract, written down.** An adapter implements your app's start, stream, cancel, retry and saved-state operations; StreamTrust invokes the adapter scenarios and writes JSON, JUnit, Markdown and HTML reports.
12
12
 
13
- **Status:** `0.3.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.
13
+ **Status:** `0.5.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.
14
14
 
15
15
  ## Watch the 66-second demo
16
16
 
@@ -28,7 +28,31 @@ npx streamtrust init
28
28
  npx streamtrust verify --adapter ./streamtrust/adapter.mjs
29
29
  ```
30
30
 
31
- For reproducible CI, pin `streamtrust@0.3.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.
31
+ For reproducible CI, pin `streamtrust@0.5.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.
32
+
33
+ ## GitHub Action
34
+
35
+ Run StreamTrust through the [official composite GitHub Action](https://github.com/santoshsibyala/streamtrust-action), listed as [StreamTrust CI Verifier on GitHub Marketplace](https://github.com/marketplace/actions/streamtrust-ci-verifier):
36
+
37
+ ```yaml
38
+ - uses: santoshsibyala/streamtrust-action@v1
39
+ with:
40
+ adapter: ./streamtrust/adapter.mjs
41
+ node-version: "22"
42
+ artifact-name: streamtrust-report
43
+ ```
44
+
45
+ Check out your repository, install adapter dependencies, and start your test services first. The Action runs the pinned StreamTrust CLI, adds a job summary, and uploads diagnostic reports even when verification fails. By default, failed or unsupported checks and harness errors fail the job. See the [complete workflow, inputs, outputs, and failure policies](https://github.com/santoshsibyala/streamtrust-action/blob/main/README.md).
46
+
47
+ Use `@v1.0.0` to pin this Action release; `@v1` follows compatible Action updates. The generated HTTP adapter initially tests a bundled demo: adapt it to your application before treating its results as application coverage. Adapters run trusted repository code; use disposable test data and keep secrets out of uploaded reports.
48
+
49
+ ### Published HTTP/SSE demo
50
+
51
+ StreamTrust uses the published Action in its own CI. This [successful main-branch run](https://github.com/santoshsibyala/streamtrust/actions/runs/42171351265) used Action v1.0.0 and CLI 0.3.0 on Node 22: persistence, cancellation, and retry each passed three repetitions (9 PASS).
52
+
53
+ [![Published HTTP/SSE demo: StreamTrust Action job summary showing 9 PASS, 0 FAIL, 0 UNSUPPORTED, and 0 HARNESS_ERROR](docs/images/published-action-http-sse-summary.png)](https://github.com/santoshsibyala/streamtrust/actions/runs/42171351265#summary-127739661923)
54
+
55
+ This run checks the bundled HTTP/SSE teaching demo. The existing CLI, LibreChat, NextChat, and Vercel AI SDK workflows separately test the package built from the current checkout.
32
56
 
33
57
  ## LibreChat integration
34
58
 
@@ -40,7 +64,15 @@ A LibreChat preset now exercises persistence, native Stop, regeneration and erro
40
64
 
41
65
  A `vercel-ai` preset runs a minimal Next.js chat built on the AI SDK (`useChat`, `streamText`) in production mode with a synthetic OpenAI-compatible provider, so it needs no API key. It covers the same four scenarios through the browser and the app's saved state, and can be pointed at your own built AI SDK app after you adjust its selectors and saved-state read. It is not an official Vercel integration.
42
66
 
43
- **Available in StreamTrust 0.3.0.** Install from npm and generate it with `npx streamtrust init streamtrust-vercel-ai --preset vercel-ai`. See the [Vercel AI SDK quick start](examples/vercel-ai/README.md) for what it does and does not verify. It requires Node 22+ and Playwright in your verification project. An [integration workflow](.github/workflows/vercel-ai.yml) exercises the packed CLI against the reference app.
67
+ **Available since StreamTrust 0.3.0.** Install from npm and generate it with `npx streamtrust init streamtrust-vercel-ai --preset vercel-ai`. See the [Vercel AI SDK quick start](examples/vercel-ai/README.md) for what it does and does not verify. It requires Node 22+ and Playwright in your verification project. An [integration workflow](.github/workflows/vercel-ai.yml) exercises the packed CLI against the reference app.
68
+
69
+ ## NextChat integration
70
+
71
+ Run `npx streamtrust init nextchat` to generate a Playwright adapter for a separate pinned NextChat checkout. It checks saved replies, Stop, regeneration and retry after a provider disconnect against the chat history stored in the browser. The local provider needs no model key. See the [NextChat setup and CI guide](examples/nextchat/README.md) and [integration workflow](.github/workflows/nextchat.yml).
72
+
73
+ ## Open WebUI integration
74
+
75
+ Generate the Open WebUI preset with `npx streamtrust init open-webui`. The adapter starts a disposable upstream container, drives chat through a browser and compares results with the saved-chat API. The [setup and CI guide](examples/open-webui/README.md) lists the contract for each report scenario.
44
76
 
45
77
  ## Run from source
46
78
 
@@ -60,7 +92,7 @@ Build the tarball with `npm pack` in this repository.
60
92
  Put the supplied tarball in your project's `vendor/` directory, then:
61
93
 
62
94
  ```sh
63
- npm install --save-dev ./vendor/streamtrust-0.3.0.tgz
95
+ npm install --save-dev ./vendor/streamtrust-0.5.0.tgz
64
96
  npx streamtrust init
65
97
  npx streamtrust verify --adapter ./streamtrust/adapter.mjs
66
98
  ```
@@ -156,7 +188,7 @@ Add StreamTrust as a test step in your existing CI. For GitHub Actions, follow t
156
188
  1. Install StreamTrust and create the starting adapter:
157
189
 
158
190
  ```sh
159
- npm install --save-dev --save-exact streamtrust@0.3.0
191
+ npm install --save-dev --save-exact streamtrust@0.5.0
160
192
  npx streamtrust init
161
193
  ```
162
194
 
@@ -4,11 +4,11 @@ This preset runs a **built local LibreChat checkout**, native browser UI and rea
4
4
 
5
5
  ## Installation
6
6
 
7
- Use Node.js 24 and npm 11. In a separate verification project, install StreamTrust 0.3.0 from npm:
7
+ Use Node.js 24 and npm 11. In a separate verification project, install StreamTrust 0.4.0 from npm:
8
8
 
9
9
  ```sh
10
10
  npm init -y
11
- npm install --save-dev --save-exact streamtrust@0.3.0
11
+ npm install --save-dev --save-exact streamtrust@0.4.0
12
12
  npm install --save-dev --save-exact playwright@1.64.1 mongodb@6.20.0 mongodb-memory-server@11.3.0
13
13
  npx playwright install chromium
14
14
  npx streamtrust init streamtrust-librechat --preset librechat
@@ -74,6 +74,31 @@ Use a fresh report directory for each job. Restrict workflow permissions to `con
74
74
 
75
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
76
 
77
+ ## Run in CI
78
+
79
+ To use the published GitHub Action, keep the checkout, dependency installation, Playwright installation, and build steps in the CI example above. Replace only its verify step with:
80
+
81
+ ```yaml
82
+ - name: Verify LibreChat with StreamTrust Action
83
+ uses: santoshsibyala/streamtrust-action@v1
84
+ with:
85
+ adapter: streamtrust-librechat/adapter.mjs
86
+ version: '0.4.0'
87
+ node-version: '24'
88
+ output: reports/librechat
89
+ repetitions: '3'
90
+ timeout-ms: '600000'
91
+ artifact-name: librechat-action-report
92
+ upload-report: 'false'
93
+ fail-on: failures
94
+ env:
95
+ STREAMTRUST_LIBRECHAT_ROOT: ${{ runner.temp }}/librechat
96
+ ```
97
+
98
+ Keep the existing upload step: `upload-report: 'false'` disables the Action's directory upload so CI continues uploading only the four listed report files. The Action still adds a job summary and fails the job on failed or unsupported checks and harness errors. Use a fresh report directory for each invocation.
99
+
100
+ `@v1` receives compatible Action updates; `@v1.0.0` pins the exact Action release. The CLI version is pinned separately by `version`. See the [Action README](https://github.com/santoshsibyala/streamtrust-action/blob/main/README.md) for setup, inputs, outputs, and failure policies. This uses the published CLI; retain direct CLI calls when testing a locally built, unreleased StreamTrust package.
101
+
77
102
  ## Evidence and limits
78
103
 
79
104
  `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.
@@ -0,0 +1,51 @@
1
+ # NextChat CI integration
2
+
3
+ This preset drives a separate [NextChat checkout](https://github.com/ChatGPTNextWeb/NextChat/tree/1d3dc331512929a4b1d3e82354e72acce33b5c7a) with Playwright and a loopback OpenAI-compatible provider. NextChat is MIT licensed. Its source and dependencies stay in the checkout.
4
+
5
+ ## Setup
6
+
7
+ In a verification project with Node 22 (the install command selects Yarn Classic 1.22.22):
8
+
9
+ ```sh
10
+ npm init -y
11
+ npm install --save-dev --save-exact streamtrust@0.4.0 playwright@1.64.1
12
+ npx playwright install chromium
13
+ npx streamtrust init nextchat
14
+ export STREAMTRUST_NEXTCHAT_ROOT="$PWD/.cache/NextChat"
15
+ git clone https://github.com/ChatGPTNextWeb/NextChat.git "$STREAMTRUST_NEXTCHAT_ROOT"
16
+ git -C "$STREAMTRUST_NEXTCHAT_ROOT" checkout --detach 1d3dc331512929a4b1d3e82354e72acce33b5c7a
17
+ (cd "$STREAMTRUST_NEXTCHAT_ROOT" && npx --yes --package=yarn@1.22.22 -- yarn install --frozen-lockfile --ignore-scripts)
18
+ npx streamtrust verify --adapter nextchat/adapter.mjs --output reports/nextchat --repetitions 3 --timeout-ms 900000
19
+ ```
20
+
21
+ Use a dedicated checkout with clean tracked files and no development dotenv files. The adapter starts NextChat in development mode on loopback, waits for HTTP readiness, and closes the app, provider and browser after verification. Requests use a synthetic key. Each repetition starts in a fresh browser context. To choose another output directory for the template, use `streamtrust init <directory> --preset nextchat`. To use installed Chrome, set `STREAMTRUST_BROWSER_CHANNEL=chrome`.
22
+
23
+ ## What it checks
24
+
25
+ | Scenario | Expected outcome after reload |
26
+ |---|---|
27
+ | Persistence | One assistant answer matches the complete provider response, with one generation. |
28
+ | Cancellation | Clicking Stop after the first chunk preserves only the partial answer. Late completion text stays absent even if the provider can finish. |
29
+ | `regeneration.replace-turn` | Before Retry, the completed first answer is saved. After reload, the original assistant ID is absent and the sole assistant answer matches the second response. |
30
+ | `error-retry.replace-turn-same-session` | After a mid-stream disconnect, the first partial answer is saved before Retry. The replacement survives reload and the original assistant ID is absent. |
31
+
32
+ NextChat intentionally replaces the previous turn on Retry, so these NextChat-specific checks verify replacement in the active session; the shared evaluator checks branch preservation on regeneration and a saved error visible after reload before retry.
33
+
34
+ The adapter reads the selected session in the persisted Zustand record `chat-next-web-store` (IndexedDB database `keyval-store`, object store `keyval`; NextChat also supports localStorage fallback). It compares assistant text from storage with the rendered page and counts streaming provider calls. This tests browser-local persistence in one session. The Stop check covers visible and saved output. It does not establish server-side cancellation. The retry check covers recovery in the same session, without requiring a durable error flag or sibling branches. The pre-Retry read checks partial text for error retry. Whether a failure flag survives a reload before Retry is outside this scenario. Per-run evidence contains only booleans. Message text and IDs stay in memory. A changed selector or unreadable storage can produce a harness error.
35
+
36
+ ## Run in CI
37
+
38
+ After installing the locked consumer dependencies, installing Chromium, and preparing the pinned checkout above, export `STREAMTRUST_NEXTCHAT_ROOT` for the job. Commit the generated adapter and helper. With CLI 0.4.0 published, use the [StreamTrust Action](https://github.com/santoshsibyala/streamtrust-action):
39
+
40
+ ```yaml
41
+ - name: Verify NextChat
42
+ uses: santoshsibyala/streamtrust-action@v1
43
+ with:
44
+ adapter: nextchat/adapter.mjs
45
+ version: '0.4.0'
46
+ node-version: '22'
47
+ repetitions: '3'
48
+ timeout-ms: '900000'
49
+ artifact-name: nextchat-safe-reports
50
+ fail-on: failures
51
+ ```
@@ -0,0 +1,316 @@
1
+ import { chromium } from "playwright";
2
+ import { spawn, execFileSync } from "node:child_process";
3
+ import { createServer } from "node:net";
4
+ import { existsSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+
7
+ import { startProvider, until } from "./provider.mjs";
8
+ const commit = "1d3dc331512929a4b1d3e82354e72acce33b5c7a";
9
+
10
+ async function availablePort() {
11
+ const socket = createServer();
12
+ await new Promise((ready, reject) => {
13
+ socket.once("error", reject);
14
+ socket.listen(0, "127.0.0.1", ready);
15
+ });
16
+ const port = socket.address().port;
17
+ await new Promise((done) => socket.close(done));
18
+ return port;
19
+ }
20
+
21
+ async function create() {
22
+ if (!process.env.STREAMTRUST_NEXTCHAT_ROOT)
23
+ throw Error(
24
+ "Set STREAMTRUST_NEXTCHAT_ROOT to the isolated NextChat checkout",
25
+ );
26
+ const root = resolve(process.env.STREAMTRUST_NEXTCHAT_ROOT);
27
+ const git = (...args) =>
28
+ execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
29
+ if (
30
+ git("rev-parse", "HEAD") !== commit ||
31
+ git("status", "--porcelain", "--untracked-files=no")
32
+ )
33
+ throw Error("Expected clean pinned NextChat source");
34
+ for (const file of [
35
+ ".env",
36
+ ".env.local",
37
+ ".env.development",
38
+ ".env.development.local",
39
+ ]) {
40
+ if (existsSync(resolve(root, file)))
41
+ throw Error("Remove dotenv files from the isolated checkout");
42
+ }
43
+ const port = await availablePort();
44
+ const url = `http://127.0.0.1:${port}`;
45
+ const provider = await startProvider();
46
+ const app = spawn(
47
+ process.execPath,
48
+ [
49
+ resolve(root, "node_modules/next/dist/bin/next"),
50
+ "dev",
51
+ "-H",
52
+ "127.0.0.1",
53
+ "-p",
54
+ String(port),
55
+ ],
56
+ {
57
+ cwd: root,
58
+ env: {
59
+ PATH: process.env.PATH,
60
+ HOME: process.env.HOME,
61
+ TMPDIR: process.env.TMPDIR,
62
+ NEXT_TELEMETRY_DISABLED: "1",
63
+ BASE_URL: provider.url,
64
+ OPENAI_API_KEY: "local-synthetic-only",
65
+ DEFAULT_MODEL: "gpt-4o-mini",
66
+ CUSTOM_MODELS: "-all,+gpt-4o-mini",
67
+ },
68
+ stdio: "ignore",
69
+ },
70
+ );
71
+ let stopped = false;
72
+ const exited = new Promise((done) => {
73
+ const onExit = () => {
74
+ stopped = true;
75
+ done();
76
+ };
77
+ app.once("error", onExit);
78
+ app.once("exit", onExit);
79
+ });
80
+ let browser;
81
+ const close = async () => {
82
+ try {
83
+ await browser?.close();
84
+ } finally {
85
+ app.kill("SIGTERM");
86
+ try {
87
+ await provider.close();
88
+ } finally {
89
+ await exited;
90
+ }
91
+ }
92
+ };
93
+ try {
94
+ await until(
95
+ async () => {
96
+ if (stopped) throw Error("NextChat exited before readiness");
97
+ try {
98
+ return (await fetch(url)).ok;
99
+ } catch {
100
+ return false;
101
+ }
102
+ },
103
+ "NextChat readiness",
104
+ 120000,
105
+ );
106
+ browser = await chromium.launch({
107
+ channel: process.env.STREAMTRUST_BROWSER_CHANNEL,
108
+ });
109
+ } catch (e) {
110
+ await close();
111
+ throw e;
112
+ }
113
+ return {
114
+ name: "NextChat local UI and IndexedDB",
115
+ metadata: async () => ({
116
+ evidencePolicy: "summary-only-v1",
117
+ provider: "local synthetic",
118
+ browser: browser.version(),
119
+ commit,
120
+ }),
121
+ scenarios: [
122
+ "persistence",
123
+ "cancellation",
124
+ "regeneration.replace-turn",
125
+ "error-retry.replace-turn-same-session",
126
+ ],
127
+ close,
128
+ async run(scenario, { repetition }) {
129
+ const context = await browser.newContext({ locale: "en-US" }),
130
+ page = await context.newPage();
131
+ page.setDefaultTimeout(10000);
132
+ await context.route(
133
+ /^https?:\/\/(?!127\.0\.0\.1[:/]|localhost[:/])/,
134
+ (r) => r.abort(),
135
+ );
136
+ const since = provider.calls.length;
137
+ const calls = () =>
138
+ provider.calls.slice(since).filter((x) => typeof x.send === "function");
139
+ const text = async () => page.locator("body").innerText();
140
+ const read = () =>
141
+ page.evaluate(async () => {
142
+ let db, raw;
143
+ try {
144
+ db = await new Promise((ok, fail) => {
145
+ let r = indexedDB.open("keyval-store");
146
+ r.onsuccess = () => ok(r.result);
147
+ r.onerror = () => fail(r.error);
148
+ });
149
+ raw = await new Promise((ok, fail) => {
150
+ let r = db
151
+ .transaction("keyval")
152
+ .objectStore("keyval")
153
+ .get("chat-next-web-store");
154
+ r.onsuccess = () => ok(r.result);
155
+ r.onerror = () => fail(r.error);
156
+ });
157
+ } catch {
158
+ // NextChat falls back to localStorage when idb-keyval is unavailable.
159
+ } finally {
160
+ db?.close();
161
+ }
162
+ const state = JSON.parse(
163
+ raw || localStorage.getItem("chat-next-web-store") || "{}",
164
+ ).state;
165
+ return state?.sessions[state.currentSessionIndex]?.messages || [];
166
+ });
167
+ const regenerate = scenario === "regeneration.replace-turn";
168
+ const retryError = scenario === "error-retry.replace-turn-same-session";
169
+ const retry = regenerate || retryError;
170
+ let original;
171
+ const evidence = {};
172
+ const checks = [];
173
+ const check = (name, passed) => checks.push({ name, passed: !!passed });
174
+ try {
175
+ await page.goto(url + "/#/chat");
176
+ const box = page.locator("textarea:visible").last();
177
+ await box.fill(`Synthetic lifecycle check ${repetition}`);
178
+ await box.press("Enter");
179
+ await until(() => calls().length > 0, "first local provider call");
180
+ let c = calls()[0];
181
+ c.send(c.prefix);
182
+ await until(
183
+ async () => (await text()).includes(c.prefix),
184
+ "first chunk displayed",
185
+ );
186
+ if (scenario === "cancellation") {
187
+ await page
188
+ .getByText("Stop", { exact: true })
189
+ .last()
190
+ .locator("..")
191
+ .click();
192
+ await until(
193
+ async () =>
194
+ !(await page
195
+ .getByText("Stop", { exact: true })
196
+ .last()
197
+ .isVisible()),
198
+ "stop control retired",
199
+ 10000,
200
+ );
201
+ if (!c.closed) c.finish();
202
+ } else if (retryError) {
203
+ c.error();
204
+ } else {
205
+ c.finish();
206
+ }
207
+ if (retry) {
208
+ if (regenerate)
209
+ await until(
210
+ async () => (await text()).includes(c.prefix + c.suffix),
211
+ "first answer complete",
212
+ );
213
+ await page
214
+ .getByText("Stop", { exact: true })
215
+ .last()
216
+ .waitFor({ state: "hidden" });
217
+ await page
218
+ .locator('[class*="chat-message"]')
219
+ .filter({ hasText: c.prefix })
220
+ .last()
221
+ .hover();
222
+ const retryButton = page
223
+ .getByText("Retry", { exact: true })
224
+ .last()
225
+ .locator("..");
226
+ await retryButton.waitFor({ state: "visible" });
227
+ const assistants = (await read()).filter(
228
+ (m) => m.role === "assistant",
229
+ );
230
+ original = assistants[0];
231
+ evidence.originalSavedBeforeRetry =
232
+ assistants.length === 1 &&
233
+ typeof original.id === "string" &&
234
+ original.id.length > 0 &&
235
+ (regenerate
236
+ ? original.content === c.prefix + c.suffix &&
237
+ original.streaming === false
238
+ : typeof original.content === "string" &&
239
+ original.content.startsWith(c.prefix) &&
240
+ !original.content.includes(c.suffix));
241
+ check("originalSavedBeforeRetry", evidence.originalSavedBeforeRetry);
242
+ await retryButton.click();
243
+ await until(() => calls().length === 2, "second provider call");
244
+ c = calls()[1];
245
+ c.send(c.prefix);
246
+ c.finish();
247
+ }
248
+ const expected =
249
+ scenario === "cancellation" ? c.prefix : c.prefix + c.suffix;
250
+ await until(
251
+ async () => (await text()).includes(expected),
252
+ "answer visible",
253
+ );
254
+ await until(
255
+ async () =>
256
+ (await read()).some(
257
+ (m) =>
258
+ m.role === "assistant" &&
259
+ typeof m.content === "string" &&
260
+ m.content.includes(c.prefix),
261
+ ),
262
+ "IndexedDB contains displayed text",
263
+ );
264
+ await page.reload();
265
+ await page.locator("textarea:visible").last().waitFor();
266
+ await until(
267
+ async () => (await text()).includes(c.prefix),
268
+ "rehydrated answer",
269
+ );
270
+ const saved = (await read()).filter((m) => m.role === "assistant");
271
+ check(
272
+ "Saved answer equals expected text",
273
+ saved.length === 1 && saved[0].content === expected,
274
+ );
275
+ check(
276
+ "Reload shows expected answer",
277
+ (await text()).includes(expected),
278
+ );
279
+ check("No extra provider call", calls().length === (retry ? 2 : 1));
280
+ if (scenario === "cancellation")
281
+ check(
282
+ "No late completion displayed",
283
+ !(await text()).includes(c.suffix),
284
+ );
285
+ if (retry) {
286
+ evidence.originalAbsentAfterReload =
287
+ !!original?.id && saved.every((m) => m.id !== original.id);
288
+ check(
289
+ "originalAbsentAfterReload",
290
+ evidence.originalAbsentAfterReload,
291
+ );
292
+ }
293
+ return { checks, evidence };
294
+ } catch (error) {
295
+ if (process.env.STREAMTRUST_NEXTCHAT_DEBUG === "1")
296
+ console.error(error);
297
+ throw Error(
298
+ "NextChat scenario could not complete its UI or storage observation",
299
+ );
300
+ } finally {
301
+ for (const c of calls()) if (!c.closed) c.res.end();
302
+ await context.close();
303
+ }
304
+ },
305
+ };
306
+ }
307
+
308
+ export default async function createNextChatAdapter() {
309
+ try {
310
+ return await create();
311
+ } catch {
312
+ throw Error(
313
+ "NextChat setup failed. Check the pinned checkout, dotenv files, Yarn dependencies and Chromium installation.",
314
+ );
315
+ }
316
+ }
@@ -0,0 +1,107 @@
1
+ import { createServer } from "node:http";
2
+ export async function startProvider() {
3
+ const calls = [];
4
+ const server = createServer(async (req, res) => {
5
+ res.setHeader("Access-Control-Allow-Origin", "*");
6
+ res.setHeader("Access-Control-Allow-Headers", "*");
7
+ if (req.method === "OPTIONS") return res.end();
8
+ if (req.url?.endsWith("/models")) {
9
+ res.setHeader("Content-Type", "application/json");
10
+ return res.end(
11
+ JSON.stringify({
12
+ object: "list",
13
+ data: [
14
+ { id: "gpt-4o-mini", object: "model", owned_by: "local-fixture" },
15
+ ],
16
+ }),
17
+ );
18
+ }
19
+ let raw = "";
20
+ for await (const b of req) raw += b;
21
+ const body = raw ? JSON.parse(raw) : {};
22
+ const id = `fixture-${calls.length + 1}`;
23
+ const call = {
24
+ id,
25
+ path: req.url,
26
+ model: body.model,
27
+ closed: false,
28
+ sentFinish: false,
29
+ res,
30
+ prefix: `Synthetic answer ${calls.length + 1}.`,
31
+ suffix: " Completed.",
32
+ };
33
+ calls.push(call);
34
+ res.on("close", () => {
35
+ call.closed = true;
36
+ });
37
+ if (!body.stream) {
38
+ res.setHeader("Content-Type", "application/json");
39
+ return res.end(
40
+ JSON.stringify({
41
+ id,
42
+ object: "chat.completion",
43
+ choices: [
44
+ {
45
+ index: 0,
46
+ message: { role: "assistant", content: "Synthetic title" },
47
+ finish_reason: "stop",
48
+ },
49
+ ],
50
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
51
+ }),
52
+ );
53
+ }
54
+ res.writeHead(200, {
55
+ "Content-Type": "text/event-stream",
56
+ "Cache-Control": "no-cache",
57
+ });
58
+ res.flushHeaders();
59
+ call.send = (text) =>
60
+ res.write(
61
+ `data: ${JSON.stringify({
62
+ id,
63
+ object: "chat.completion.chunk",
64
+ created: 1,
65
+ model: body.model,
66
+ choices: [
67
+ { index: 0, delta: { content: text }, finish_reason: null },
68
+ ],
69
+ })}\n\n`,
70
+ );
71
+ call.finish = () => {
72
+ call.sentFinish = true;
73
+ call.send(call.suffix);
74
+ res.write(
75
+ `data: ${JSON.stringify({
76
+ id,
77
+ object: "chat.completion.chunk",
78
+ created: 1,
79
+ model: body.model,
80
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
81
+ })}\n\ndata: [DONE]\n\n`,
82
+ );
83
+ res.end();
84
+ };
85
+ call.error = () => res.destroy();
86
+ });
87
+ await new Promise((ready, reject) => {
88
+ server.once("error", reject);
89
+ server.listen(0, "127.0.0.1", ready);
90
+ });
91
+ return {
92
+ url: `http://127.0.0.1:${server.address().port}`,
93
+ calls,
94
+ async close() {
95
+ server.closeAllConnections();
96
+ await new Promise((r) => server.close(r));
97
+ },
98
+ };
99
+ }
100
+ export async function until(fn, label, ms = 30000) {
101
+ const deadline = Date.now() + ms;
102
+ while (Date.now() < deadline) {
103
+ if (await fn()) return;
104
+ await new Promise((r) => setTimeout(r, 100));
105
+ }
106
+ throw new Error(`Adapter timeout: ${label}`);
107
+ }
@@ -0,0 +1,48 @@
1
+ # Open WebUI integration
2
+
3
+ The adapter drives a disposable Open WebUI v0.13.0 instance with Playwright and a local OpenAI-compatible provider. The upstream commit is `c38885a1a737da4968f157092f304f3b5481d0bf`. The official slim image index is pinned to `ghcr.io/open-webui/open-webui@sha256:283efb20756785229a388bcd1e56a052f5284ae8c861717eed5eaaa3bba6b20a` for amd64 and arm64.
4
+
5
+ ## Setup
6
+
7
+ Install Node 22+, Docker and Playwright. Start Docker before running the verifier. From a verification project, once the CLI release is available:
8
+
9
+ ```sh
10
+ npm install --save-dev --save-exact streamtrust@0.5.0 playwright@1.64.1
11
+ npx playwright install chromium
12
+ npx streamtrust init open-webui
13
+ npx streamtrust verify --adapter open-webui/adapter.mjs --output reports/open-webui --repetitions 3 --timeout-ms 900000
14
+ ```
15
+
16
+ Use `init <directory> --preset open-webui` to choose a directory. The adapter publishes the app on a randomly assigned loopback port, waits for health, signs into a runtime-generated admin account, and dismisses the first-launch release dialog. Each scenario uses a fresh browser context and a new chat. The container data directory lives in tmpfs and is removed on cleanup. The app connects to a mock on the Docker bridge gateway on native Linux or through Docker Desktop host routing on macOS. The provider requires a disposable bearer key.
17
+
18
+ ## Saved state and contracts
19
+
20
+ The adapter reads the authenticated saved-chat API (`GET /api/v1/chats/{id}`) backed by Open WebUI’s local database. The history contains the assistant records and parent IDs. Reload selects the persisted `current_message_id`, falling back to `chat.history.currentId` when the former is absent. The adapter compares the selected record and visible reply after reload.
21
+
22
+ | Report scenario | What a pass requires |
23
+ |---|---|
24
+ | `persistence.shared` | Shared evaluator: one matching complete, error-free answer, visible before and after reload, with no new generation on reload. The active saved record must match. |
25
+ | `cancellation.provider-abort-saved-partial` | App-specific: after native Stop, the mock sees its open HTTP response close before completion. The partial answer stays active and saved after reload, no late suffix appears, and generation count stays at one. Open WebUI can mark a stopped message done. |
26
+ | `regeneration.shared-active-sibling` | Shared evaluator plus active selection: the completed original is saved before Regenerate. The original content and ID survive, the replacement has a distinct ID and the same parent, and reload selects the replacement. |
27
+ | `error-retry.shared-saved-visible` | Shared evaluator plus active selection: a synthetic SSE provider error is saved and visible after reload before Regenerate. The replacement is active in storage and visible after a second reload. |
28
+
29
+ An unavailable required storage field produces UNSUPPORTED where the adapter cannot establish the contract. Runtime or selector errors produce harness errors. Shareable evidence includes booleans and counts. Only the synthetic text-error fixture and the pinned app’s single-user chat lifecycle are in scope.
30
+
31
+ ## Run in CI
32
+
33
+ Commit the generated adapter and consumer lockfile. On an Ubuntu Docker runner, after checkout, Node setup, `npm ci` and `npx playwright install --with-deps chromium`, use the [StreamTrust Action](https://github.com/santoshsibyala/streamtrust-action):
34
+
35
+ ```yaml
36
+ - uses: santoshsibyala/streamtrust-action@v1
37
+ with:
38
+ version: '0.5.0'
39
+ adapter: open-webui/adapter.mjs
40
+ repetitions: '3'
41
+ timeout-ms: '900000'
42
+ artifact-name: open-webui-safe-reports
43
+ fail-on: failures
44
+ ```
45
+
46
+ ## Upstream license
47
+
48
+ Open WebUI runs from a separate upstream image under the [Open WebUI License](https://github.com/open-webui/open-webui/blob/c38885a1a737da4968f157092f304f3b5481d0bf/LICENSE). It imposes branding, notice and endorsement conditions. This independent preset preserves the upstream interface and branding and ships only verification code.
@@ -0,0 +1,362 @@
1
+ import { chromium } from "playwright";
2
+ import { evaluateLifecycle } from "streamtrust/checks";
3
+ import { randomUUID } from "node:crypto";
4
+ import { execFileSync } from "node:child_process";
5
+ import { startProvider, until } from "./provider.mjs";
6
+
7
+ const image =
8
+ "ghcr.io/open-webui/open-webui@sha256:283efb20756785229a388bcd1e56a052f5284ae8c861717eed5eaaa3bba6b20a";
9
+ const revision = "c38885a1a737da4968f157092f304f3b5481d0bf";
10
+ const scenarios = [
11
+ "persistence.shared",
12
+ "cancellation.provider-abort-saved-partial",
13
+ "regeneration.shared-active-sibling",
14
+ "error-retry.shared-saved-visible",
15
+ ];
16
+ const docker = (args, env = {}) =>
17
+ execFileSync("docker", args, {
18
+ encoding: "utf8",
19
+ stdio: ["ignore", "pipe", "pipe"],
20
+ env: { ...process.env, ...env },
21
+ }).trim();
22
+
23
+ // The API gives current_message_id precedence over history.currentId on hydration.
24
+ async function saved(page) {
25
+ return page.evaluate(async () => {
26
+ const id = location.pathname.split("/").pop();
27
+ const response = await fetch("/api/v1/chats/" + id, {
28
+ headers: { authorization: "Bearer " + localStorage.token },
29
+ });
30
+ if (!response.ok) throw Error("Saved-chat API unavailable");
31
+ const chat = await response.json();
32
+ const history = chat.chat.history;
33
+ return {
34
+ active: chat.current_message_id || history.currentId,
35
+ answers: Object.values(history.messages).filter(
36
+ (m) => m.role === "assistant",
37
+ ),
38
+ };
39
+ });
40
+ }
41
+
42
+ export default async function create() {
43
+ const name = "streamtrust-webui-" + randomUUID();
44
+ const key = randomUUID(),
45
+ password = randomUUID(),
46
+ email = randomUUID() + "@example.com";
47
+ let browser,
48
+ provider,
49
+ started = false;
50
+ const close = async () => {
51
+ try {
52
+ await browser?.close();
53
+ } finally {
54
+ try {
55
+ if (started) docker(["rm", "-fv", name]);
56
+ } finally {
57
+ await provider?.close();
58
+ }
59
+ }
60
+ };
61
+ try {
62
+ // Linux containers reach the host on the default bridge gateway. Docker Desktop
63
+ // provides host.docker.internal for services listening on the host loopback.
64
+ const linux = process.platform === "linux";
65
+ const gateway = linux
66
+ ? docker([
67
+ "network",
68
+ "inspect",
69
+ "bridge",
70
+ "--format",
71
+ "{{(index .IPAM.Config 0).Gateway}}",
72
+ ])
73
+ : "127.0.0.1";
74
+ provider = await startProvider(gateway, key);
75
+ const settings = {
76
+ WEBUI_ADMIN_EMAIL: email,
77
+ WEBUI_ADMIN_PASSWORD: password,
78
+ WEBUI_SECRET_KEY: randomUUID(),
79
+ ENABLE_SIGNUP: "False",
80
+ OFFLINE_MODE: "True",
81
+ ENABLE_OLLAMA_API: "False",
82
+ ENABLE_OPENAI_API: "True",
83
+ OPENAI_API_BASE_URL: `http://${
84
+ linux ? gateway : "host.docker.internal"
85
+ }:${provider.port}/v1`,
86
+ OPENAI_API_KEY: key,
87
+ DEFAULT_MODELS: "gpt-4o-mini",
88
+ ENABLE_TITLE_GENERATION: "False",
89
+ ENABLE_TAGS_GENERATION: "False",
90
+ ENABLE_FOLLOW_UP_GENERATION: "False",
91
+ ENABLE_VERSION_UPDATE_CHECK: "False",
92
+ ENABLE_PERSISTENT_CONFIG: "False",
93
+ HF_HUB_OFFLINE: "1",
94
+ RAG_EMBEDDING_MODEL_AUTO_UPDATE: "False",
95
+ RAG_RERANKING_MODEL_AUTO_UPDATE: "False",
96
+ };
97
+ docker(
98
+ [
99
+ "run",
100
+ "-d",
101
+ "--rm",
102
+ "--name",
103
+ name,
104
+ "-p",
105
+ "127.0.0.1::8080",
106
+ "--tmpfs",
107
+ "/app/backend/data",
108
+ ...Object.keys(settings).flatMap((k) => ["-e", k]),
109
+ image,
110
+ ],
111
+ settings,
112
+ );
113
+ started = true;
114
+ const url = "http://" + docker(["port", name, "8080/tcp"]);
115
+ await until(
116
+ async () => {
117
+ try {
118
+ return (await fetch(url + "/health")).ok;
119
+ } catch {
120
+ return false;
121
+ }
122
+ },
123
+ "container health",
124
+ 180000,
125
+ );
126
+ const login = await fetch(url + "/api/v1/auths/signin", {
127
+ method: "POST",
128
+ headers: { "content-type": "application/json" },
129
+ body: JSON.stringify({ email, password }),
130
+ });
131
+ if (!login.ok) throw Error("Disposable admin sign-in failed");
132
+ const { token } = await login.json();
133
+ if (!token) throw Error("Disposable admin session unavailable");
134
+ browser = await chromium.launch({
135
+ channel: process.env.STREAMTRUST_BROWSER_CHANNEL,
136
+ });
137
+ return {
138
+ name: "Open WebUI",
139
+ scenarios,
140
+ close,
141
+ metadata: async () => ({
142
+ commit: revision,
143
+ image,
144
+ evidencePolicy: "summary-only-v1",
145
+ }),
146
+ async run(label) {
147
+ const scenario = label.split(".")[0];
148
+ const ctx = await browser.newContext({
149
+ locale: "en-US",
150
+ storageState: {
151
+ cookies: [],
152
+ origins: [
153
+ { origin: url, localStorage: [{ name: "token", value: token }] },
154
+ ],
155
+ },
156
+ });
157
+ let stage = "opening the local chat";
158
+ const start = provider.calls.length;
159
+ const calls = () => provider.calls.slice(start).filter((c) => c.send);
160
+ const check = (name, passed) => ({ name, passed });
161
+ try {
162
+ await ctx.route(/^https?:\/\//, (r) =>
163
+ new URL(r.request().url()).origin === url
164
+ ? r.continue()
165
+ : r.abort(),
166
+ );
167
+ const page = await ctx.newPage();
168
+ const text = () => page.locator("body").innerText();
169
+ const has = async (value) => (await text()).includes(value);
170
+ const untilVisible = (value) =>
171
+ until(() => has(value), "rendered answer");
172
+ const stop = page.getByRole("button", { name: "Stop", exact: true });
173
+ const dialog = page.getByRole("dialog", { name: /What.s New/i });
174
+ await page.addLocatorHandler(dialog, () =>
175
+ dialog.getByRole("button", { name: "Close", exact: true }).click(),
176
+ );
177
+ await page.goto(url);
178
+ await page
179
+ .getByText("gpt-4o-mini", { exact: true })
180
+ .first()
181
+ .waitFor();
182
+ await page.locator("#chat-input").fill("Synthetic lifecycle check");
183
+ await page
184
+ .getByRole("button", { name: "Send message", exact: true })
185
+ .click();
186
+ stage = "waiting for first provider call";
187
+ await until(() => calls().length === 1, "first generation");
188
+ const first = calls()[0];
189
+ first.send(first.prefix);
190
+ await untilVisible(first.prefix);
191
+ await page.waitForURL("**/c/*");
192
+ let target = first,
193
+ aborted = false,
194
+ failure,
195
+ originalId;
196
+ stage = "settling the first response";
197
+ if (scenario === "cancellation") {
198
+ await stop.click();
199
+ await stop.waitFor({ state: "hidden" });
200
+ // A closed response counts only while the provider is still holding it open.
201
+ const deadline = Date.now() + 5000;
202
+ while (!first.closed && Date.now() < deadline)
203
+ await new Promise((r) => setTimeout(r, 50));
204
+ aborted = first.closed && !first.sentFinish;
205
+ if (!first.closed) first.finish();
206
+ } else if (scenario === "error-retry") {
207
+ first.error();
208
+ await stop.waitFor({ state: "hidden" });
209
+ const stateBeforeRetry = await saved(page);
210
+ const failed = stateBeforeRetry.answers.find(
211
+ (m) =>
212
+ m.id === stateBeforeRetry.active &&
213
+ m.content?.includes(first.prefix) &&
214
+ JSON.stringify(m.error ?? "").includes(
215
+ "Synthetic test interruption",
216
+ ),
217
+ );
218
+ await page.reload();
219
+ await untilVisible(first.prefix);
220
+ await page
221
+ .getByText("Synthetic test interruption", { exact: false })
222
+ .last()
223
+ .waitFor({ timeout: 5000 })
224
+ .catch(() => {});
225
+ failure = {
226
+ saved: !!failed,
227
+ visibleAfterReload: await has("Synthetic test interruption"),
228
+ };
229
+ if (!failure.saved || !failure.visibleAfterReload)
230
+ return {
231
+ checks: [],
232
+ unsupported:
233
+ "Saved provider error and its visible marker could not both be established across reload before retry.",
234
+ evidence: failure,
235
+ };
236
+ } else {
237
+ first.finish();
238
+ await untilVisible(first.prefix + first.suffix);
239
+ }
240
+ if (scenario === "regeneration" || scenario === "error-retry") {
241
+ stage = "observing the original and regenerating";
242
+ if (scenario === "regeneration") {
243
+ await until(async () => {
244
+ const state = await saved(page);
245
+ originalId = state.answers.find(
246
+ (m) => m.content === first.prefix + first.suffix,
247
+ )?.id;
248
+ return originalId && state.active === originalId;
249
+ }, "saved original");
250
+ }
251
+ await page
252
+ .getByRole("button", { name: "Regenerate", exact: true })
253
+ .last()
254
+ .click();
255
+ const menu = page
256
+ .getByRole("menuitem", { name: /regenerate|try again/i })
257
+ .first();
258
+ if (await menu.isVisible()) await menu.click();
259
+ await until(() => calls().length === 2, "second generation");
260
+ target = calls()[1];
261
+ target.send(target.prefix);
262
+ target.finish();
263
+ }
264
+ stage = "checking saved state and reload";
265
+ const expected =
266
+ scenario === "cancellation"
267
+ ? target.prefix
268
+ : target.prefix + target.suffix;
269
+ await untilVisible(expected);
270
+ await until(
271
+ async () =>
272
+ (await saved(page)).answers.some((m) => m.content === expected),
273
+ "saved reply",
274
+ );
275
+ const beforeReload = await text(),
276
+ callsBeforeReload = calls().length;
277
+ await page.reload();
278
+ await untilVisible(target.prefix);
279
+ const reloaded = await text(),
280
+ state = await saved(page);
281
+ const active = state.answers.find((m) => m.id === state.active);
282
+ const activeMatches = active?.content === expected;
283
+ let checks;
284
+ if (scenario === "cancellation") {
285
+ checks = [
286
+ check("Provider observed abort before finish", aborted),
287
+ check(
288
+ "Active partial saved and visible after reload",
289
+ activeMatches && reloaded.includes(expected),
290
+ ),
291
+ check("Late suffix absent", !reloaded.includes(first.suffix)),
292
+ check(
293
+ "Exactly one generation across reload",
294
+ calls().length === 1,
295
+ ),
296
+ ];
297
+ } else {
298
+ if (state.answers.some((m) => typeof m.done !== "boolean"))
299
+ return {
300
+ checks: [],
301
+ unsupported:
302
+ "Saved assistant completion state unavailable for shared checks.",
303
+ evidence: {},
304
+ };
305
+ ({ checks } = evaluateLifecycle({
306
+ scenario,
307
+ expected,
308
+ original: first.prefix + first.suffix,
309
+ previousPrefix: first.prefix,
310
+ beforeReload,
311
+ reloaded,
312
+ callsBeforeReload,
313
+ callsAfterReload: calls().length,
314
+ failure,
315
+ messages: state.answers.map((m) => ({
316
+ id: m.id,
317
+ parentId: m.parentId,
318
+ text: m.content,
319
+ assistant: true,
320
+ unfinished: !m.done,
321
+ error: !!m.error,
322
+ })),
323
+ }));
324
+ checks.push(
325
+ check("Saved active ID selects expected answer", activeMatches),
326
+ );
327
+ if (scenario === "regeneration")
328
+ checks.push(
329
+ check(
330
+ "Original ID and content retained",
331
+ state.answers.some(
332
+ (m) =>
333
+ m.id === originalId &&
334
+ m.content === first.prefix + first.suffix,
335
+ ),
336
+ ),
337
+ );
338
+ }
339
+ return {
340
+ checks,
341
+ evidence: {
342
+ callsBeforeReload,
343
+ callsAfterReload: calls().length,
344
+ assistantCount: state.answers.length,
345
+ activeMatches,
346
+ },
347
+ };
348
+ } catch {
349
+ throw Error("Open WebUI observation failed while " + stage);
350
+ } finally {
351
+ for (const c of calls()) if (!c.closed) c.res.end();
352
+ await ctx.close();
353
+ }
354
+ },
355
+ };
356
+ } catch {
357
+ await close();
358
+ throw Error(
359
+ "Open WebUI setup failed. Check Docker, the pinned image, local provider routing, admin bootstrap and Chromium.",
360
+ );
361
+ }
362
+ }
@@ -0,0 +1,113 @@
1
+ import { createServer } from "node:http";
2
+ export async function startProvider(host, key) {
3
+ const calls = [];
4
+ const server = createServer(async (req, res) => {
5
+ if (req.headers.authorization !== "Bearer " + key) {
6
+ res.writeHead(401);
7
+ return res.end();
8
+ }
9
+ res.setHeader("Access-Control-Allow-Headers", "*");
10
+ if (req.method === "OPTIONS") return res.end();
11
+ if (req.url?.endsWith("/models")) {
12
+ res.setHeader("Content-Type", "application/json");
13
+ return res.end(
14
+ JSON.stringify({
15
+ object: "list",
16
+ data: [
17
+ { id: "gpt-4o-mini", object: "model", owned_by: "local-fixture" },
18
+ ],
19
+ }),
20
+ );
21
+ }
22
+ let raw = "";
23
+ for await (const b of req) raw += b;
24
+ const body = raw ? JSON.parse(raw) : {};
25
+ const id = `fixture-${calls.length + 1}`;
26
+ const call = {
27
+ id,
28
+ path: req.url,
29
+ model: body.model,
30
+ closed: false,
31
+ sentFinish: false,
32
+ res,
33
+ prefix: `Synthetic answer ${calls.length + 1}.`,
34
+ suffix: " Completed.",
35
+ };
36
+ calls.push(call);
37
+ res.on("close", () => {
38
+ call.closed = true;
39
+ });
40
+ if (!body.stream) {
41
+ res.setHeader("Content-Type", "application/json");
42
+ return res.end(
43
+ JSON.stringify({
44
+ id,
45
+ object: "chat.completion",
46
+ choices: [
47
+ {
48
+ index: 0,
49
+ message: { role: "assistant", content: "Synthetic title" },
50
+ finish_reason: "stop",
51
+ },
52
+ ],
53
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
54
+ }),
55
+ );
56
+ }
57
+ res.writeHead(200, {
58
+ "Content-Type": "text/event-stream",
59
+ "Cache-Control": "no-cache",
60
+ });
61
+ res.flushHeaders();
62
+ call.send = (text) =>
63
+ res.write(
64
+ `data: ${JSON.stringify({
65
+ id,
66
+ object: "chat.completion.chunk",
67
+ created: 1,
68
+ model: body.model,
69
+ choices: [
70
+ { index: 0, delta: { content: text }, finish_reason: null },
71
+ ],
72
+ })}\n\n`,
73
+ );
74
+ call.finish = () => {
75
+ call.sentFinish = true;
76
+ call.send(call.suffix);
77
+ res.write(
78
+ `data: ${JSON.stringify({
79
+ id,
80
+ object: "chat.completion.chunk",
81
+ created: 1,
82
+ model: body.model,
83
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
84
+ })}\n\ndata: [DONE]\n\n`,
85
+ );
86
+ res.end();
87
+ };
88
+ call.error = () =>
89
+ res.end(
90
+ 'data: {"error":{"message":"Synthetic test interruption"}}\n\ndata: [DONE]\n\n',
91
+ );
92
+ });
93
+ await new Promise((ready, reject) => {
94
+ server.once("error", reject);
95
+ server.listen(0, host, ready);
96
+ });
97
+ return {
98
+ port: server.address().port,
99
+ calls,
100
+ async close() {
101
+ server.closeAllConnections();
102
+ await new Promise((r) => server.close(r));
103
+ },
104
+ };
105
+ }
106
+ export async function until(fn, label, ms = 30000) {
107
+ const deadline = Date.now() + ms;
108
+ while (Date.now() < deadline) {
109
+ if (await fn()) return;
110
+ await new Promise((r) => setTimeout(r, 100));
111
+ }
112
+ throw new Error(`Adapter timeout: ${label}`);
113
+ }
@@ -4,11 +4,11 @@ This preset runs a **minimal Next.js chat built on the AI SDK** (`ai`, `@ai-sdk/
4
4
 
5
5
  ## Quick start (no API key)
6
6
 
7
- Use Node.js 22 or newer (AI SDK 7 requires it). This preset is available in StreamTrust 0.3.0. In a separate verification project:
7
+ Use Node.js 22 or newer (AI SDK 7 requires it). This preset has been available since StreamTrust 0.3.0. In a separate verification project:
8
8
 
9
9
  ```sh
10
10
  npm init -y
11
- npm install --save-dev --save-exact streamtrust@0.3.0 playwright@1.64.1
11
+ npm install --save-dev --save-exact streamtrust@0.4.0 playwright@1.64.1
12
12
  npx playwright install chromium
13
13
  npx streamtrust init streamtrust-vercel-ai --preset vercel-ai
14
14
  node streamtrust-vercel-ai/setup.mjs
@@ -93,6 +93,29 @@ Run the installation steps once, then commit your verification project package.j
93
93
 
94
94
  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`.
95
95
 
96
+ ## Run in CI
97
+
98
+ To use the published GitHub Action, keep the checkout, dependency installation, Playwright installation, and build steps in the CI example above. Replace only its verify step with:
99
+
100
+ ```yaml
101
+ - name: Verify AI SDK app with StreamTrust Action
102
+ uses: santoshsibyala/streamtrust-action@v1
103
+ with:
104
+ adapter: streamtrust-vercel-ai/adapter.mjs
105
+ version: '0.4.0'
106
+ node-version: '22'
107
+ output: reports/vercel-ai
108
+ repetitions: '3'
109
+ timeout-ms: '600000'
110
+ artifact-name: vercel-ai-action-report
111
+ upload-report: 'false'
112
+ fail-on: failures
113
+ ```
114
+
115
+ Keep the existing upload step: `upload-report: 'false'` disables the Action's directory upload so CI continues uploading only the four listed report files. The Action still adds a job summary and fails the job on failed or unsupported checks and harness errors. Use a fresh report directory for each invocation.
116
+
117
+ `@v1` receives compatible Action updates; `@v1.0.0` pins the exact Action release. The CLI version is pinned separately by `version`. See the [Action README](https://github.com/santoshsibyala/streamtrust-action/blob/main/README.md) for setup, inputs, outputs, and failure policies. This uses the published CLI; retain direct CLI calls when testing a locally built, unreleased StreamTrust package.
118
+
96
119
  ## Evidence and limits
97
120
 
98
121
  Reports use `summary-only-v1`: stable check names, booleans, message counts and provider-call counts. Chat text, message ids, URLs and file paths stay in memory. Reports replace browser and app exceptions with fixed messages even in debug mode. Debug mode prints raw errors to the console; do not enable it in CI that retains logs. This is data minimization, not a sandbox: the adapter and the app under test are trusted code.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "streamtrust",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Adapter-driven CI verification for streaming application lifecycle guarantees",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,7 +37,11 @@
37
37
  "examples/vercel-ai/reference-app/app/**/*.js",
38
38
  "examples/vercel-ai/reference-app/app/**/*.jsx",
39
39
  "README.md",
40
- "LICENSE"
40
+ "LICENSE",
41
+ "examples/nextchat/*.mjs",
42
+ "examples/nextchat/README.md",
43
+ "examples/open-webui/*.mjs",
44
+ "examples/open-webui/README.md"
41
45
  ],
42
46
  "scripts": {
43
47
  "test": "node --test test/*.test.mjs",
@@ -48,7 +52,14 @@
48
52
  "streaming",
49
53
  "verification",
50
54
  "testing",
51
- "sse"
55
+ "sse",
56
+ "github-action",
57
+ "ai",
58
+ "llm",
59
+ "chat",
60
+ "playwright",
61
+ "librechat",
62
+ "vercel-ai-sdk"
52
63
  ],
53
64
  "repository": {
54
65
  "type": "git",
package/src/cli.js CHANGED
@@ -19,11 +19,15 @@ const PRESETS = {
19
19
  'reference-app/app/api/chat/route.js', 'reference-app/app/chat/[id]/chat.jsx',
20
20
  'reference-app/app/chat/[id]/page.jsx',
21
21
  ].map((name) => [`vercel-ai/${name}`, name]),
22
+ 'open-webui': ['adapter.mjs', 'provider.mjs', 'README.md'].map(
23
+ (name) => [`open-webui/${name}`, name]),
24
+ nextchat: ['adapter.mjs', 'provider.mjs', 'README.md'].map(
25
+ (name) => [`nextchat/${name}`, name]),
22
26
  };
23
27
  export function presets() { return Object.keys(PRESETS); }
24
28
  function presetFiles(preset) { return PRESETS[preset]; }
25
29
  const HELP = () =>
26
- `StreamTrust ${VERSION}\n\nUsage:\n streamtrust init [directory] [--preset <name>]\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\nPresets: ${presets().join(', ')} (default: http)\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`;
30
+ `StreamTrust ${VERSION}\n\nUsage:\n streamtrust init [directory] [--preset <name>]\n streamtrust init nextchat|open-webui\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\nPresets: ${presets().join(', ')} (default: http)\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`;
27
31
  export async function main(args) {
28
32
  let outputDir;
29
33
  try {
@@ -38,7 +42,7 @@ export async function main(args) {
38
42
  if (args[0] === 'init') {
39
43
  const initArgs = args.slice(1);
40
44
  const presetIndex = initArgs.indexOf('--preset');
41
- let preset = 'http';
45
+ let preset = initArgs.length === 1 && ['nextchat', 'open-webui'].includes(initArgs[0]) ? initArgs[0] : 'http';
42
46
  if (presetIndex !== -1) {
43
47
  preset = initArgs[presetIndex + 1];
44
48
  initArgs.splice(presetIndex, 2);