Repository navigation
Add isolated WebSocket test harness, mutation controls, and pull-request CI #727
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
791f92d
fd8d0b9
6c1fbb8
d5a6f30
02cc37d
9bb38be
e28b635
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| name: Websocket harness | ||
| on: | ||
| pull_request: | ||
| branches: [main] | ||
| push: | ||
| branches: [main] | ||
| permissions: | ||
| contents: read | ||
| concurrency: | ||
| group: websocket-${{ github.workflow }}-${{ github.ref }} | ||
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | ||
| jobs: | ||
| websocket: | ||
| name: Websocket harness (no service) | ||
| runs-on: ubuntu-24.04 | ||
| timeout-minutes: 15 | ||
| steps: | ||
| - uses: actions/checkout@v7 | ||
| with: | ||
| ref: ${{ github.sha }} | ||
| - uses: oven-sh/setup-bun@v2 | ||
| with: | ||
| bun-version: 1.4.2 | ||
| - name: Isolate the websocket run from any herdr service | ||
| run: | | ||
| echo "HERDR_SOCKET=$RUNNER_TEMP/herdr-websocket-no-service.sock" >> "$GITHUB_ENV" | ||
| echo "SODAM_WS_RUN_ROOT=$RUNNER_TEMP/herdr-websocket-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_ENV" | ||
| - run: bun install --frozen-lockfile | ||
| - run: bun run typecheck | ||
| - run: bun run build | ||
| - name: Assert no service socket exists | ||
| run: test ! -e "$HERDR_SOCKET" | ||
| - run: bun --config=bunfig.websocket.toml test --isolate src/lib server/updater.test.ts | ||
| - run: bun run test:websocket | ||
| - run: bun run test:websocket:mutations | ||
| - name: Upload websocket failure artifacts | ||
| if: failure() | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: websocket-test-artifacts-${{ github.run_id }}-${{ github.run_attempt }} | ||
| path: ${{ runner.temp }}/herdr-websocket-*/** | ||
| if-no-files-found: warn | ||
| retention-days: 7 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| [test] | ||
| preload = [] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,9 @@ | |
| "test": "bun test", | ||
| "test:unit": "bun scripts/ci-tests.ts unit", | ||
| "test:integration": "bun scripts/ci-tests.ts integration", | ||
| "test:websocket": "bun --config=bunfig.websocket.toml test --isolate server/output-window.test.ts server/websocket-test/fake-herdr-client.test.ts server/websocket-test/fake-pty-session.test.ts server/websocket-test/create-server-harness.test.ts server/websocket-test/create-server.smoke.integration.test.ts server/websocket-test/ack-replay.integration.test.ts server/websocket-test/ack-state.integration.test.ts server/websocket-test/reattach-reconnect.integration.test.ts server/websocket-test/backpressure-stall.integration.test.ts server/websocket-test/protocol-guards.integration.test.ts", | ||
| "test:websocket:mutations": "bun --config=bunfig.websocket.toml test --isolate server/websocket-test/mutation-source.test.ts server/websocket-test/mutation-controls.integration.test.ts", | ||
| "test:ui": "bun run build && bun scripts/ui-regression.ts", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/sh
set -eu
bun run | rg -n -A1 'test:ui'Repository: devswha/herdr-web-ui Length of output: 241 🏁 Script executed: set -eu
printf '%s\n' '--- package.json ---'
nl -ba package.json | sed -n '1,45p'
printf '%s\n' '--- diff against merge base ---'
git diff -- package.json 6005735c0f195a4fb8272970bdef8b1af0a559dc 9bb38bee22578389d9a83e369d36e307671f7c7b || true
printf '%s\n' '--- validation configuration ---'
rg -n -F --glob '*.{json,jsonc,toml,yaml,yml}' -- 'duplicate' . || test "$?" -eq 1
rg -n -F --glob '*.{json,jsonc,toml,yaml,yml}' -- 'biome' . || test "$?" -eq 1Repository: devswha/herdr-web-ui Length of output: 2922 Keep only one
🐛 Suggested fix--- "a/package.json"
+++ "b/package.json"
@@ -21,8 +21,7 @@
"test:integration": "bun scripts/ci-tests.ts integration",
"test:websocket": "bun --config=bunfig.websocket.toml test --isolate server/output-window.test.ts server/websocket-test/fake-herdr-client.test.ts server/websocket-test/fake-pty-session.test.ts server/websocket-test/create-server-harness.test.ts server/websocket-test/create-server.smoke.integration.test.ts server/websocket-test/ack-replay.integration.test.ts server/websocket-test/ack-state.integration.test.ts server/websocket-test/reattach-reconnect.integration.test.ts server/websocket-test/backpressure-stall.integration.test.ts server/websocket-test/protocol-guards.integration.test.ts",
"test:websocket:mutations": "bun --config=bunfig.websocket.toml test --isolate server/websocket-test/mutation-source.test.ts server/websocket-test/mutation-controls.integration.test.ts",
"test:ui": "bun run build && bun scripts/ui-regression.ts",
- "test:ui": "bun run build && bun scripts/ui-regression.ts && bun scripts/sticky-modifiers-regression.ts",
"generate:types": "bun run scripts/generate-protocol-types.ts",
"build:remote": "bun run scripts/build-remote-bundle.ts",
"build:site": "bun scripts/build-site.ts",🧰 Tools🪛 Biome (2.5.14)[error] 24-24: The key test:ui was already declared. (lint/suspicious/noDuplicateObjectKeys) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| "test:ui": "bun run build && bun scripts/ui-regression.ts && bun scripts/sticky-modifiers-regression.ts", | ||
| "generate:types": "bun run scripts/generate-protocol-types.ts", | ||
| "build:remote": "bun run scripts/build-remote-bundle.ts", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| import { access, mkdir, writeFile } from "node:fs/promises"; | ||
| import { join } from "node:path"; | ||
| import { expect, test } from "bun:test"; | ||
| import { createServerHarness, websocketRunRoot } from "./create-server-harness.ts"; | ||
| import { onPtyEvent } from "./fake-pty-session.ts"; | ||
| import { OUTPUT_HARD_BYTES, OUTPUT_LOW_BYTES } from "../output-window.ts"; | ||
|
|
||
| const evidencePath = join(websocketRunRoot, "evidence", "ack-replay.jsonl"); | ||
| const cleanupPath = join(websocketRunRoot, "evidence", "ack-replay-cleanup.json"); | ||
|
|
||
| interface WsFrame { readonly type: string; readonly [key: string]: unknown } | ||
|
|
||
| function message(socket: WebSocket, diagnostic: string): Promise<WsFrame> { | ||
| return new Promise((resolvePromise, rejectPromise) => { | ||
| let timer: ReturnType<typeof setTimeout>; | ||
| const cleanup = () => { | ||
| clearTimeout(timer); | ||
| socket.removeEventListener("message", onMessage); | ||
| socket.removeEventListener("close", onClose); | ||
| socket.removeEventListener("error", onError); | ||
| }; | ||
| const onMessage = (event: MessageEvent) => { | ||
| cleanup(); | ||
| const parsed: unknown = JSON.parse(String(event.data)); | ||
| if (typeof parsed !== "object" || parsed === null || !("type" in parsed) || typeof parsed.type !== "string") { | ||
| rejectPromise(new Error("WebSocket frame has no string type")); | ||
| return; | ||
| } | ||
| const frame = parsed as WsFrame; | ||
| // Upstream announces an attachment's readiness with a broadcast input-ready frame; it is not scenario data. | ||
| if (frame.type === "input-ready") { | ||
| socket.addEventListener("message", onMessage); | ||
| return; | ||
| } | ||
| resolvePromise(frame); | ||
| }; | ||
| const onClose = () => { cleanup(); rejectPromise(new Error(`${diagnostic}: socket closed`)); }; | ||
| const onError = () => { cleanup(); rejectPromise(new Error(`${diagnostic}: socket errored`)); }; | ||
| timer = setTimeout(() => { cleanup(); rejectPromise(new Error(diagnostic)); }, 5000); | ||
| socket.addEventListener("message", onMessage); | ||
| socket.addEventListener("close", onClose); | ||
| socket.addEventListener("error", onError); | ||
| }); | ||
| } | ||
|
|
||
| test("real replay preserves UTF-8 offsets and ACK resumes output", async () => { | ||
| const harness = await createServerHarness(); | ||
| let socket: WebSocket | undefined; | ||
| let fakeExited: Promise<void> | undefined; | ||
| let exitEvent: Promise<void> | undefined; | ||
| let unsubscribeExit: (() => void) | undefined; | ||
| const waits: Promise<unknown>[] = []; | ||
| let outcome: Record<string, unknown> = { case: "ack-replay", passed: false }; | ||
| try { | ||
| await mkdir(join(websocketRunRoot, "evidence"), { recursive: true }); | ||
| harness.queueInitialPtyOutput("\x1b[?1049h\x1b[?1000h" + "한🙂".repeat(40000)); | ||
| socket = harness.openSocket(); | ||
| const openWait = harness.prearmSocketEvent("open"); | ||
| const snapshotWait = message(socket, "timed out waiting for initial snapshot"); | ||
| waits.push(openWait, snapshotWait); | ||
| const [opened, snapshot] = await Promise.all([openWait, snapshotWait]); | ||
| expect(opened.socket).toBe(socket); | ||
| expect(snapshot.type).toBe("snapshot"); | ||
|
|
||
| const pauseWait = harness.waitForPtyEvent("fake-pane", "pause"); | ||
| const replayWait = message(socket, "timed out waiting for replay frame"); | ||
| waits.push(pauseWait.promise, replayWait); | ||
| exitEvent = new Promise<void>((resolvePromise) => { | ||
| const unsubscribe = onPtyEvent((event) => { | ||
| if (event.type !== "exit") return; | ||
| unsubscribe(); | ||
| resolvePromise(); | ||
| }); | ||
| unsubscribeExit = unsubscribe; | ||
| }); | ||
| socket.send('{"type":"attach","pane_id":"fake-pane","cols":100,"rows":30,"flow_control":"ack"}'); | ||
| fakeExited = harness.instances.at(-1)?.exited; | ||
| const [pause, frame] = await Promise.all([pauseWait.promise, replayWait]); | ||
| expect(pause.type).toBe("pause"); | ||
| expect(frame.type).toBe("pty-data"); | ||
| const data = String(frame.data); | ||
| const flow = frame.flow; | ||
| if (typeof flow !== "object" || flow === null || !("stream_id" in flow) || !("offset" in flow)) throw new Error("replay frame omitted flow metadata"); | ||
| const offset = Number(flow.offset); | ||
| expect(String(flow.stream_id).length).toBeGreaterThan(0); | ||
| expect(offset).toBe(Buffer.byteLength(data)); | ||
| expect(data).toContain("\x1b[?1049h"); | ||
| expect(data).toContain("\x1b[?1000h"); | ||
| expect(offset).toBeLessThan(OUTPUT_HARD_BYTES); | ||
|
|
||
| harness.queuePtyOutputOnResume("fake-pane", "ACK_RESUMED"); | ||
| const nextDataWait = message(socket, "timed out waiting for resumed sentinel"); | ||
| const resumeWait = harness.waitForPtyEvent("fake-pane", "resume"); | ||
| waits.push(nextDataWait, resumeWait.promise); | ||
| socket.send(JSON.stringify({ type: "pty-ack", pane_id: "fake-pane", stream_id: flow.stream_id, offset: offset - OUTPUT_LOW_BYTES })); | ||
| const [resume, nextFrame] = await Promise.all([resumeWait.promise, nextDataWait]); | ||
| expect(resume.type).toBe("resume"); | ||
| expect(nextFrame.type).toBe("pty-data"); | ||
| const sentinelData = String(nextFrame.data); | ||
| expect(sentinelData).toContain("ACK_RESUMED"); | ||
| const nextFlow = nextFrame.flow; | ||
| if (typeof nextFlow !== "object" || nextFlow === null || !("offset" in nextFlow)) throw new Error("sentinel frame omitted flow offset"); | ||
| const resumedBytes = Number(nextFlow.offset); | ||
| expect(resumedBytes).toBe(offset + Buffer.byteLength(sentinelData)); | ||
| outcome = { case: "ack-replay", passed: true, replayBytes: offset, replayContainsModes: true, hardBytes: OUTPUT_HARD_BYTES, ackOffset: offset - OUTPUT_LOW_BYTES, resumedBytes, sentinel: "ACK_RESUMED" }; | ||
| } finally { | ||
| const stateDir = harness.stateDir; | ||
| const sessionExit = fakeExited ?? harness.instances.at(-1)?.exited; | ||
| let cleanupResult: Awaited<ReturnType<typeof harness.cleanup>> | undefined; | ||
| let fakeExitedResolved = false; | ||
| try { | ||
| cleanupResult = await harness.cleanup(); | ||
| await Promise.allSettled(waits); | ||
| if (sessionExit) { | ||
| await sessionExit; | ||
| fakeExitedResolved = true; | ||
| if (exitEvent) await exitEvent; | ||
| } | ||
| unsubscribeExit?.(); | ||
| let stateDirAbsent = false; | ||
| try { | ||
| await access(stateDir); | ||
| } catch (error) { | ||
| if (error instanceof Error && "code" in error && error.code === "ENOENT") stateDirAbsent = true; | ||
| else throw error; | ||
| } | ||
| const stoppedPortHandshakeFailed = await new Promise<boolean>((resolvePromise, rejectPromise) => { | ||
| const stopped = new WebSocket(new URL("/ws", harness.baseUrl)); | ||
| const timer = setTimeout(() => { stopped.close(); rejectPromise(new Error("stopped-port handshake did not settle")); }, 1000); | ||
| const settle = (failed: boolean) => { | ||
| clearTimeout(timer); | ||
| stopped.removeEventListener("error", onError); | ||
| stopped.removeEventListener("open", onOpen); | ||
| if (failed) resolvePromise(true); | ||
| else { stopped.close(); resolvePromise(false); } | ||
| }; | ||
| const onError = () => settle(true); | ||
| const onOpen = () => settle(false); | ||
| stopped.addEventListener("error", onError); | ||
| stopped.addEventListener("open", onOpen); | ||
| }); | ||
| const receipt = { | ||
| case: "ack-replay", | ||
| socketsClosed: socket?.readyState === WebSocket.CLOSED ? 1 : 0, | ||
| fakeExitedResolved, | ||
| subscriptionCloseDelta: cleanupResult.subscriptionCloseDelta, | ||
| subscriptionsClosed: cleanupResult.subscriptionCloseDelta > 0, | ||
| stateDirAbsent, | ||
| stoppedPortHandshakeFailed, | ||
| }; | ||
| await writeFile(evidencePath, `${JSON.stringify(outcome)}\n`, "utf8"); | ||
| await writeFile(cleanupPath, `${JSON.stringify({ cases: [receipt] }, null, 2)}\n`, "utf8"); | ||
| expect(receipt.socketsClosed).toBe(socket ? 1 : 0); | ||
| expect(receipt.fakeExitedResolved).toBe(true); | ||
| expect(receipt.subscriptionsClosed).toBe(true); | ||
| expect(receipt.stateDirAbsent).toBe(true); | ||
| expect(receipt.stoppedPortHandshakeFailed).toBe(true); | ||
| } finally { | ||
| if (cleanupResult === undefined) { | ||
| await harness.cleanup(); | ||
| unsubscribeExit?.(); | ||
| } | ||
| } | ||
| } | ||
| }, 15000); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟡 Minor | ⚡ Quick win
Sensitive Data Exposure
Reachability: External
Exploitability: Moderate
CWE: CWE-522 — Insufficiently Protected Credentials
View Security blast radius
Disable checkout credential persistence.
The workflow runs dependency and test commands after checkout. Set
persist-credentials: falseso PR code or installed dependencies cannot read the persistedcontents: readtoken.Proposed change
📝 Committable suggestion
🧰 Tools
🪛 zizmor (1.30.1)
[warning] 18-20: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
🤖 Prompt for AI Agents
Sources: Path instructions, Learnings, Linters/SAST tools