From 578bd121a64a19ca762a89c5ea1472f0abc13473 Mon Sep 17 00:00:00 2001 From: ComBba Date: Tue, 7 Apr 2026 11:48:42 +0900 Subject: [PATCH] test(ci): add web-test CI job, harden security gates, add P0 tests CI Changes: - Add web-test job running vitest (was missing entirely) - bandit: remove || true, enforce --severity-level high - npm audit: remove || true, enforce --audit-level=high - mypy: replace || true with continue-on-error (visible but non-blocking) New Test Files: - web/src/lib/__tests__/zero-prompt-api.test.ts (8 tests) - startSession POST, SSE response parsing, error handling - queueBuild, passCard, deleteCard action payloads - getBuildEventsUrl format - web/src/lib/__tests__/sse-client.test.ts (6 tests) - JSON/plain-text SSE parsing, error handling, stream completion - abort function, comment line filtering - agent/tests/test_event_bus.py (8 tests) - register/unregister, session-scoped fan-out - queue overflow handling, multi-client delivery Total: 5 web test files (31 tests), 82 agent test files Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 24 ++- agent/tests/test_event_bus.py | 81 +++++++++ web/src/lib/__tests__/sse-client.test.ts | 172 ++++++++++++++++++ web/src/lib/__tests__/zero-prompt-api.test.ts | 84 +++++++++ 4 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 agent/tests/test_event_bus.py create mode 100644 web/src/lib/__tests__/sse-client.test.ts create mode 100644 web/src/lib/__tests__/zero-prompt-api.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0f6a5f..f7323e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,23 @@ jobs: - run: npm ci - run: npx eslint . + web-test: + name: Web Tests (vitest) + runs-on: ubuntu-latest + needs: web-lint + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: web/package-lock.json + - run: npm ci + - run: npm test + web-build: name: Web Build (next) runs-on: ubuntu-latest @@ -104,7 +121,8 @@ jobs: pip install -r requirements.txt - run: pip install pytest pytest-cov mypy - run: pytest tests/ --cov=. --cov-report=term-missing --cov-fail-under=80 - - run: mypy agent/ --ignore-missing-imports --no-strict || true + - run: mypy agent/ --ignore-missing-imports --no-strict + continue-on-error: true agent-security: name: Agent Security (bandit) @@ -118,7 +136,7 @@ jobs: with: python-version: "3.12" - run: pip install bandit - - run: bandit -r . -x ./tests/,./.venv/ --severity-level medium -f json || true + - run: bandit -r . -x ./tests/,./.venv/ --severity-level high web-audit: name: Web Audit (npm audit) @@ -130,7 +148,7 @@ jobs: node-version: "20" - run: npm ci working-directory: web - - run: npm audit --audit-level=moderate || true + - run: npm audit --audit-level=high working-directory: web web-typecheck: diff --git a/agent/tests/test_event_bus.py b/agent/tests/test_event_bus.py new file mode 100644 index 0000000..139a74e --- /dev/null +++ b/agent/tests/test_event_bus.py @@ -0,0 +1,81 @@ +"""Tests for zero_prompt/event_bus.py — event fan-out to SSE clients.""" + +import asyncio + +import pytest + +from agent.zero_prompt.event_bus import ( + _client_sessions, + _event_queues, + push_zp_event, + register_zp_client, + unregister_zp_client, +) + + +@pytest.fixture(autouse=True) +def _clean_bus(): + _event_queues.clear() + _client_sessions.clear() + yield + _event_queues.clear() + _client_sessions.clear() + + +class TestRegisterUnregister: + def test_register_creates_queue(self): + q = register_zp_client("c1", "s1") + assert isinstance(q, asyncio.Queue) + assert "c1" in _event_queues + + def test_unregister_removes_client(self): + register_zp_client("c1", "s1") + unregister_zp_client("c1") + assert "c1" not in _event_queues + assert "c1" not in _client_sessions + + def test_unregister_nonexistent_is_safe(self): + unregister_zp_client("nonexistent") + + +class TestPushEvent: + def test_event_reaches_subscribed_client(self): + q = register_zp_client("c1", "s1") + push_zp_event({"session_id": "s1", "type": "card.update"}) + assert q.qsize() == 1 + event = q.get_nowait() + assert event["type"] == "card.update" + + def test_event_skips_different_session(self): + q = register_zp_client("c1", "s1") + push_zp_event({"session_id": "s2", "type": "card.update"}) + assert q.qsize() == 0 + + def test_event_without_session_reaches_all(self): + q1 = register_zp_client("c1", "s1") + q2 = register_zp_client("c2", "s2") + push_zp_event({"type": "global_event"}) + assert q1.qsize() == 1 + assert q2.qsize() == 1 + + def test_client_without_session_receives_all(self): + q = register_zp_client("c1", None) + push_zp_event({"session_id": "any-session", "type": "card.update"}) + assert q.qsize() == 1 + + def test_queue_full_event_dropped_silently(self): + q = register_zp_client("c1", "s1") + # Fill the queue to max + for i in range(300): + push_zp_event({"session_id": "s1", "type": f"event_{i}"}) + assert q.qsize() == 300 + # This should not raise + push_zp_event({"session_id": "s1", "type": "overflow"}) + assert q.qsize() == 300 + + def test_multiple_clients_same_session(self): + q1 = register_zp_client("c1", "s1") + q2 = register_zp_client("c2", "s1") + push_zp_event({"session_id": "s1", "type": "test"}) + assert q1.qsize() == 1 + assert q2.qsize() == 1 diff --git a/web/src/lib/__tests__/sse-client.test.ts b/web/src/lib/__tests__/sse-client.test.ts new file mode 100644 index 0000000..bf502a6 --- /dev/null +++ b/web/src/lib/__tests__/sse-client.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createSSEClient } from "../sse-client"; + +function mockReadableStream(chunks: string[]) { + let index = 0; + return { + getReader: () => ({ + read: async () => { + if (index >= chunks.length) return { done: true, value: undefined }; + const value = new TextEncoder().encode(chunks[index++]); + return { done: false, value }; + }, + }), + }; +} + +describe("sse-client", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it("parses JSON SSE data lines and calls onEvent", async () => { + const events: Array<{ type: string; data: Record }> = []; + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + body: mockReadableStream([ + 'data: {"type":"phase","step":"council"}\n\n', + 'data: {"type":"score","value":85}\n\n', + ]), + }), + ); + + const abort = createSSEClient({ + url: "http://test/run", + body: { prompt: "test" }, + onEvent: (event) => events.push(event), + onComplete: () => {}, + }); + + // Wait for async stream processing + await new Promise((r) => setTimeout(r, 50)); + + expect(events.length).toBe(2); + expect(events[0].type).toBe("phase"); + expect(events[1].data).toEqual({ type: "score", value: 85 }); + + abort(); + }); + + it("handles plain text SSE data gracefully", async () => { + const events: Array<{ type: string; data: Record }> = []; + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + body: mockReadableStream(["data: not-json-content\n\n"]), + }), + ); + + createSSEClient({ + url: "http://test/run", + body: { prompt: "test" }, + onEvent: (event) => events.push(event), + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(events.length).toBe(1); + expect(events[0].type).toBe("message"); + expect(events[0].data).toEqual({ text: "not-json-content" }); + }); + + it("calls onError on non-ok response", async () => { + const errors: Error[] = []; + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }), + ); + + createSSEClient({ + url: "http://test/run", + body: { prompt: "test" }, + onEvent: () => {}, + onError: (err) => errors.push(err), + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(errors.length).toBe(1); + expect(errors[0].message).toContain("500"); + }); + + it("calls onComplete when stream ends", async () => { + let completed = false; + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + body: mockReadableStream([]), + }), + ); + + createSSEClient({ + url: "http://test/run", + body: { prompt: "test" }, + onEvent: () => {}, + onComplete: () => { + completed = true; + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(completed).toBe(true); + }); + + it("abort returns a function", () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + body: mockReadableStream([]), + }), + ); + + const abort = createSSEClient({ + url: "http://test/run", + body: { prompt: "test" }, + onEvent: () => {}, + }); + + expect(typeof abort).toBe("function"); + abort(); // Should not throw + }); + + it("ignores SSE comment lines starting with colon", async () => { + const events: Array<{ type: string }> = []; + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + body: mockReadableStream([ + ": this is a comment\n", + 'data: {"type":"real"}\n\n', + ]), + }), + ); + + createSSEClient({ + url: "http://test/run", + body: { prompt: "test" }, + onEvent: (event) => events.push(event), + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(events.length).toBe(1); + expect(events[0].type).toBe("real"); + }); +}); diff --git a/web/src/lib/__tests__/zero-prompt-api.test.ts b/web/src/lib/__tests__/zero-prompt-api.test.ts new file mode 100644 index 0000000..3436ef9 --- /dev/null +++ b/web/src/lib/__tests__/zero-prompt-api.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +describe("zero-prompt-api", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ session_id: "test-session", status: "exploring", cards: [] }), + text: async () => JSON.stringify({ session_id: "test-session", status: "exploring", cards: [] }), + }), + ); + }); + + it("startSession sends POST with goal", async () => { + const { startSession } = await import("../zero-prompt-api"); + const session = await startSession(5); + expect(session.session_id).toBe("test-session"); + + const [url, init] = (fetch as ReturnType).mock.calls[0]; + expect(url).toContain("/zero-prompt/start"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body)).toEqual({ goal: 5 }); + }); + + it("startSession parses SSE-wrapped response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + text: async () => 'data: {"type":"zp.session.start","session_id":"sse-sess","session_status":"exploring","goal_go_cards":3}\n', + json: async () => ({}), + }), + ); + const { startSession } = await import("../zero-prompt-api"); + const session = await startSession(3); + expect(session.session_id).toBe("sse-sess"); + }); + + it("startSession throws on non-ok response", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 500 })); + const { startSession } = await import("../zero-prompt-api"); + await expect(startSession()).rejects.toThrow("Failed to start session"); + }); + + it("getDashboard returns session data", async () => { + const { getDashboard } = await import("../zero-prompt-api"); + const result = await getDashboard(); + expect(result.session_id).toBe("test-session"); + }); + + it("queueBuild sends correct action payload", async () => { + const { queueBuild } = await import("../zero-prompt-api"); + await queueBuild("session-1", "card-abc"); + + const [url, init] = (fetch as ReturnType).mock.calls[0]; + expect(url).toContain("/zero-prompt/session-1/actions"); + expect(JSON.parse(init.body)).toEqual({ action: "queue_build", card_id: "card-abc" }); + }); + + it("passCard sends correct action payload", async () => { + const { passCard } = await import("../zero-prompt-api"); + await passCard("session-1", "card-xyz"); + + const body = JSON.parse((fetch as ReturnType).mock.calls[0][1].body); + expect(body).toEqual({ action: "pass_card", card_id: "card-xyz" }); + }); + + it("deleteCard sends correct action payload", async () => { + const { deleteCard } = await import("../zero-prompt-api"); + await deleteCard("session-1", "card-del"); + + const body = JSON.parse((fetch as ReturnType).mock.calls[0][1].body); + expect(body).toEqual({ action: "delete_card", card_id: "card-del" }); + }); + + it("getBuildEventsUrl includes session and card IDs", async () => { + const { getBuildEventsUrl } = await import("../zero-prompt-api"); + const url = getBuildEventsUrl("sess-1", "card-1"); + expect(url).toContain("/zero-prompt/sess-1/build/card-1/events"); + }); +});