diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index a4337e8..c1fcabb 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -44,6 +44,11 @@ jobs: export PATH="$HOME/.bun/bin:$PATH" bun run build + - name: Testes unitários (bun test) + run: | + export PATH="$HOME/.bun/bin:$PATH" + bun run test:unit + - name: Instalar navegador do Playwright run: | export PATH="$HOME/.bun/bin:$PATH" diff --git a/bun.lock b/bun.lock index 2c29083..731d8f7 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ }, "devDependencies": { "@playwright/test": "^1.62.1", + "@types/bun": "^1.3.14", "@types/node": "^24.13.3", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", @@ -531,6 +532,8 @@ "@trickfilm400/rollup-plugin-off-main-thread": ["@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1", "", { "dependencies": { "ejs": "^3.1.10", "json5": "^2.2.3", "magic-string": "^0.30.21", "string.prototype.matchall": "^4.0.12" } }, "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], @@ -599,6 +602,8 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], diff --git a/docs/plans/testes-unitarios-para-src-lib-ws-ts-protocolo-zeroclaw-v1-impl.md b/docs/plans/testes-unitarios-para-src-lib-ws-ts-protocolo-zeroclaw-v1-impl.md new file mode 100644 index 0000000..4903d00 --- /dev/null +++ b/docs/plans/testes-unitarios-para-src-lib-ws-ts-protocolo-zeroclaw-v1-impl.md @@ -0,0 +1,109 @@ +# Impl: I1 — Testes unitários para src/lib/ws.ts (protocolo zeroclaw.v1) + +Status: aprovado +Atualizado em: 2026-08-18 +Issue: #3 +Intenção: body da Issue (sem plano de intenção — body é a spec) +Appetite restante: P2, escopo único de arquivo + config — sem corte necessário + +## Leitura da intenção + +- **Outcome:** `WsClient` coberto por testes unitários (reconexão com backoff, + parse de frames, envio) para o contrato do protocolo `zeroclaw.v1` não + regredir. +- **O que NÃO negociar:** sem mudança de semântica de protocolo (tipos de + `src/lib/ws.ts` são contrato); zero servidor/estado novo (local-first). +- **O que reavaliar:** runner de testes, localização dos arquivos e estratégia + de typecheck — o repo não tem runner unitário hoje (só Playwright e2e). + +## Abordagem recomendada + +```mermaid +flowchart LR + A[bun:test] --> B[tests/unit/ws.test.ts] + B --> C[helpers: FakeWebSocket + fake timers] + B --> D[contrato: parse + envio + backoff + disconnect] + A --> E[tsconfig.test.json + @types/bun] + E --> F[tsc -b cobre testes] + A --> G[scripts: test:unit + gate + CI step] +``` + +**Opções consideradas:** A) bun:test + stubs de global | B) vitest com fake timers nativos | C) bun:test com timers reais +**Recomendação:** A — zero dependência de runtime, CI já roda bun, e os dois +stubs necessários (WebSocket e setTimeout) foram **verificados empiricamente** +neste ambiente (bun 1.3.14): `globalThis.WebSocket` é sobrescrevível e +`bun:test` **não** expõe `mock.timer` (probe falhou), então o fake timer é um +helper pequeno (~25 linhas) em vez de feature do runner. +**Rejeitadas:** B porque adiciona devDependency e roda sobre bun sem suporte +oficial (risco no CI host); C porque o backoff hardcoded vai a 30 s — testar o +cap exigiria espera real ou mudança de código de produção para injetar delay. + +### Componentes / mudanças + +- **`tests/unit/ws.test.ts`** (novo): suites por área — parse de frames, + envio, reconexão com backoff, disconnect, status. +- **`tests/helpers/ws-test-utils.ts`** (novo): `FakeWebSocket` (constantes + estáticas, `readyState`, captura de `url`/`protocols`, recorder de `send`, + `close()` dispara `onclose` — espelha o browser) + `installFakeTimers()` + (override de `globalThis.setTimeout`/`clearTimeout` com `advance()`; + instala/restaura nos hooks). +- **`tsconfig.test.json`** (novo): project de testes, `lib: [ES2023, DOM]`, + `types: ["bun"]`, referenciado no `tsconfig.json` raiz — `tsc -b` do build + typechecka os testes sem contaminar o app. **Verificado** em probe: ws.ts + + bun:test + DOM compilam sem conflito. +- **`package.json`**: devDependency `@types/bun`; script `"test:unit": "bun test tests/unit"`. +- **`package.json` gate**: `"gate": "lint && build && test:unit"` — o contrato + passa a ser checado no gate local. +- **`.forgejo/workflows/ci.yml`**: step `Testes unitários` (`bun run test:unit`) + entre Build e E2E. +- **Migration:** sem migration. **Access/Consent:** n/a (testes sem dados + reais; nenhum token/URL real — só literais `ws://x`). + +### Contrato testado (espelha `ws.ts` linha a linha) + +- Parse: frame JSON → `onEvent` com o evento; frame não-JSON → ignorado sem + throw; frame JSON de tipo desconhecido → repassado sem crash (semântica + atual preservada). +- Envio: `sendMessage`/`sendApproval` (approve/deny/always) só com + `readyState === OPEN`; fora disso `send` do socket não é chamado. +- Backoff: close não-intencional → `reconnecting` + novo socket após 1000 ms; + delay dobra (1000 → 2000 → 4000…); cap em 30000; `onopen` reseta para 1000. +- Disconnect: intencional → `closed` sem reconnect; durante backoff → timer + limpo, nenhum socket novo. +- Status: `connecting → open`, `connecting → reconnecting → open`. + +## Fases verificáveis + +1. **Scaffold** — `@types/bun`, `tsconfig.test.json` (+ ref no raiz), + script `test:unit`, gate, CI step. Gate verde com teste placeholder. +2. **Helpers** — `ws-test-utils.ts` (FakeWebSocket + fake timers). +3. **Testes** — `tests/unit/ws.test.ts` (matriz acima). `bun run test:unit` + verde; `bun run gate` verde. +4. **Fechamento** — /simplify no diff → débitos → PR `Closes #3` com auto-merge. + +## Rabbit holes / Não escopo (engenharia) + +- **Nada de refactor em `ws.ts`** para "testabilidade" (injeção de socket/delay + em produção) — os stubs de global bastam; mexer em produção é risco de + contrato sem ganho. +- **Sem mudança de semântica** para frames não-JSON / tipos desconhecidos. +- **Sem `--coverage`** e sem medição de % (não pedido; P2). +- **Fora de escopo:** `messages.ts`, `settings.ts`, `voice.ts`, componentes + UI, e2e Playwright (já coberto no CI). +- Sem vitest/jsdom/novo tooling além de `@types/bun`. + +## Riscos e mitigação + +- `@types/bun` conflitar com lib DOM do app → tipos bun restritos ao + `tsconfig.test.json`; conflito descartado por probe empírico. +- Fake timers vazarem entre testes → install/restore em `beforeEach`/ + `afterEach` do helper; teste do probe validou o padrão de override global. +- `bun test` não typechecka → o `tsconfig.test.json` no `tsc -b` cobre (erro de + tipo em teste quebra o build, que é parte do gate). +- CI (runner host) sem `bun` → já usa `bun install`/`bunx playwright`; nada novo. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto (WsClient: backoff, parse, envio) +- [x] Invariantes AGENTS/engineering-standards (zero servidor novo; sem token real em teste; copy pt-BR) +- [x] Testes de domínio previstos onde o contrato pode regredir (única mudança de código é teste/config) diff --git a/package.json b/package.json index 8394622..3f7561f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "tsc -b && vite build", "lint": "oxlint", "preview": "vite preview", - "gate": "bun run lint && bun run build", + "gate": "bun run lint && bun run build && bun run test:unit", + "test:unit": "bun test tests/unit", "test:e2e": "playwright test", "worktree": "node scripts/worktree.mjs", "issue": "node scripts/issue.mjs", @@ -34,6 +35,7 @@ }, "devDependencies": { "@playwright/test": "^1.62.1", + "@types/bun": "^1.3.14", "@types/node": "^24.13.3", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", diff --git a/tests/helpers/ws-test-utils.ts b/tests/helpers/ws-test-utils.ts new file mode 100644 index 0000000..eadb56e --- /dev/null +++ b/tests/helpers/ws-test-utils.ts @@ -0,0 +1,96 @@ +export class FakeWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + readonly url: string; + readonly protocols: string[]; + readonly sent: string[] = []; + readyState: number = FakeWebSocket.CONNECTING; + onopen: (() => void) | null = null; + onmessage: ((ev: { data: string }) => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + + constructor(url: string, protocols: string[]) { + this.url = url; + this.protocols = protocols; + instances.push(this); + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + if (this.readyState === FakeWebSocket.CLOSING || this.readyState === FakeWebSocket.CLOSED) return; + this.readyState = FakeWebSocket.CLOSED; + this.onclose?.(); + } + + emitOpen(): void { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(); + } + + emitMessage(data: string): void { + this.onmessage?.({ data }); + } +} + +export const instances: FakeWebSocket[] = []; + +const originalWebSocket = globalThis.WebSocket; + +export function installFakeWebSocket(): void { + instances.length = 0; + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket; +} + +export function restoreWebSocket(): void { + globalThis.WebSocket = originalWebSocket; +} + +export interface FakeTimers { + advance(ms: number): void; + restore(): void; +} + +export function installFakeTimers(): FakeTimers { + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + let now = 0; + let nextId = 1; + const pending = new Map void; fireAt: number }>(); + + globalThis.setTimeout = ((callback: () => void, ms = 0) => { + const id = nextId++; + pending.set(id, { callback, fireAt: now + ms }); + return id; + }) as typeof setTimeout; + + globalThis.clearTimeout = ((id: number | undefined) => { + if (id !== undefined) pending.delete(id); + }) as typeof clearTimeout; + + return { + advance(ms: number): void { + now += ms; + for (;;) { + const due = [...pending.entries()].filter(([, t]) => t.fireAt <= now); + if (due.length === 0) break; + for (const [id, t] of due) { + if (pending.has(id)) { + pending.delete(id); + t.callback(); + } + } + } + }, + restore(): void { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + }, + }; +} diff --git a/tests/unit/ws.test.ts b/tests/unit/ws.test.ts new file mode 100644 index 0000000..3a01d32 --- /dev/null +++ b/tests/unit/ws.test.ts @@ -0,0 +1,247 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { WsClient } from "../../src/lib/ws"; +import type { ChatEvent } from "../../src/lib/ws"; +import { + FakeWebSocket, + installFakeTimers, + installFakeWebSocket, + instances, + restoreWebSocket, +} from "../helpers/ws-test-utils"; +import type { FakeTimers } from "../helpers/ws-test-utils"; + +const URL = "wss://gateway/ws/chat"; +const PROTOCOLS = ["zeroclaw.v1"]; + +const PROTOCOL_FRAMES: Array<[string, ChatEvent]> = [ + [ + JSON.stringify({ type: "session_start", session_id: "s", resumed: true, message_count: 2 }), + { type: "session_start", session_id: "s", resumed: true, message_count: 2 }, + ], + [JSON.stringify({ type: "connected" }), { type: "connected" }], + [JSON.stringify({ type: "chunk", content: "olá" }), { type: "chunk", content: "olá" }], + [JSON.stringify({ type: "chunk_reset" }), { type: "chunk_reset" }], + [JSON.stringify({ type: "thinking" }), { type: "thinking" }], + [JSON.stringify({ type: "thinking", content: "r" }), { type: "thinking", content: "r" }], + [ + JSON.stringify({ type: "tool_call", name: "create_event", args: { title: "x" } }), + { type: "tool_call", name: "create_event", args: { title: "x" } }, + ], + [ + JSON.stringify({ type: "tool_result", name: "create_event", output: "ok" }), + { type: "tool_result", name: "create_event", output: "ok" }, + ], + [JSON.stringify({ type: "done", full_response: "f", max_context_tokens: 1, input_tokens: 2, output_tokens: 3 }), + { type: "done", full_response: "f", max_context_tokens: 1, input_tokens: 2, output_tokens: 3 }], + [JSON.stringify({ type: "done" }), { type: "done" }], + [JSON.stringify({ type: "error" }), { type: "error" }], + [JSON.stringify({ type: "error", message: "m", code: "c" }), { type: "error", message: "m", code: "c" }], + [ + JSON.stringify({ type: "approval_request", request_id: "r1", tool: "create_event" }), + { type: "approval_request", request_id: "r1", tool: "create_event" }, + ], + [ + JSON.stringify({ type: "approval_request", request_id: "r2", tool: "create_event", arguments_summary: "s", timeout_secs: 30 }), + { type: "approval_request", request_id: "r2", tool: "create_event", arguments_summary: "s", timeout_secs: 30 }, + ], + [JSON.stringify({ type: "aborted" }), { type: "aborted" }], + [JSON.stringify({ type: "aborted", reason: "r" }), { type: "aborted", reason: "r" }], + [JSON.stringify({ type: "history_trimmed" }), { type: "history_trimmed" }], + [ + JSON.stringify({ type: "history_trimmed", dropped_messages: 3, kept_turns: 2 }), + { type: "history_trimmed", dropped_messages: 3, kept_turns: 2 }, + ], + [ + JSON.stringify({ type: "cron_result", job_id: "j", success: true }), + { type: "cron_result", job_id: "j", success: true }, + ], +]; + +describe("WsClient", () => { + let events: ChatEvent[]; + let statuses: string[]; + let client: WsClient; + let fakeTimers: FakeTimers; + + beforeEach(() => { + events = []; + statuses = []; + installFakeWebSocket(); + fakeTimers = installFakeTimers(); + client = new WsClient({ + url: URL, + protocols: PROTOCOLS, + onEvent: (ev) => events.push(ev), + onStatus: (s) => statuses.push(s), + }); + }); + + afterEach(() => { + restoreWebSocket(); + fakeTimers.restore(); + }); + + describe("parse de frames (server → client)", () => { + test("cada tipo do protocolo chega ao onEvent sem perder campos", () => { + client.connect(); + for (const [raw] of PROTOCOL_FRAMES) { + instances[0].emitMessage(raw); + } + expect(events).toEqual(PROTOCOL_FRAMES.map(([, expected]) => expected)); + }); + + test("frame não-JSON é ignorado sem erro", () => { + client.connect(); + instances[0].emitMessage("isto não é json"); + expect(events).toEqual([]); + }); + + test("frame JSON de tipo desconhecido chega ao onEvent sem crash", () => { + client.connect(); + instances[0].emitMessage(JSON.stringify({ type: "tipo_futuro", extra: 1 })); + expect(events).toEqual([{ type: "tipo_futuro", extra: 1 }] as unknown as ChatEvent[]); + }); + }); + + describe("envio (client → server)", () => { + test("sendMessage envia frame message quando o socket está OPEN", () => { + client.connect(); + const ws = instances[0]; + ws.readyState = FakeWebSocket.OPEN; + client.sendMessage("olá"); + expect(ws.sent).toEqual([JSON.stringify({ type: "message", content: "olá" })]); + }); + + test("sendApproval envia frame approval_response com a decisão", () => { + client.connect(); + const ws = instances[0]; + ws.readyState = FakeWebSocket.OPEN; + client.sendApproval("r1", "approve"); + client.sendApproval("r2", "deny"); + client.sendApproval("r3", "always"); + expect(ws.sent).toEqual([ + JSON.stringify({ type: "approval_response", request_id: "r1", decision: "approve" }), + JSON.stringify({ type: "approval_response", request_id: "r2", decision: "deny" }), + JSON.stringify({ type: "approval_response", request_id: "r3", decision: "always" }), + ]); + }); + + test("não envia quando o socket não está OPEN", () => { + client.connect(); + const ws = instances[0]; + client.sendMessage("olá"); + client.sendApproval("r1", "approve"); + expect(ws.sent).toEqual([]); + }); + + test("não envia quando o socket está CLOSING ou CLOSED", () => { + client.connect(); + const ws = instances[0]; + ws.readyState = FakeWebSocket.CLOSING; + client.sendMessage("olá"); + ws.readyState = FakeWebSocket.CLOSED; + client.sendApproval("r1", "deny"); + expect(ws.sent).toEqual([]); + }); + + test("enviar antes de conectar não cria socket nem lança erro", () => { + client.sendMessage("olá"); + client.sendApproval("r1", "approve"); + expect(instances).toEqual([]); + }); + }); + + describe("reconexão com backoff", () => { + test("close não intencional agenda reconnect após 1000ms e reconstroi o socket", () => { + client.connect(); + instances[0].close(); + expect(statuses).toEqual(["connecting", "reconnecting"]); + expect(instances.length).toBe(1); + fakeTimers.advance(999); + expect(instances.length).toBe(1); + fakeTimers.advance(1); + expect(instances.length).toBe(2); + expect(instances[1].url).toBe(URL); + expect(instances[1].protocols).toEqual(PROTOCOLS); + }); + + test("delay dobra a cada tentativa até o cap de 30s", () => { + client.connect(); + instances[0].close(); + fakeTimers.advance(1000); + instances[1].close(); + fakeTimers.advance(2000); + instances[2].close(); + fakeTimers.advance(4000); + instances[3].close(); + fakeTimers.advance(8000); + instances[4].close(); + fakeTimers.advance(16000); + instances[5].close(); + fakeTimers.advance(29999); + expect(instances.length).toBe(6); + fakeTimers.advance(1); + expect(instances.length).toBe(7); + }); + + test("onopen reseta o delay para 1000 (não no connect)", () => { + client.connect(); + instances[0].close(); + fakeTimers.advance(1000); + instances[1].close(); + fakeTimers.advance(1000); + expect(instances.length).toBe(2); + fakeTimers.advance(1000); + expect(instances.length).toBe(3); + instances[2].emitOpen(); + instances[2].close(); + fakeTimers.advance(1000); + expect(instances.length).toBe(4); + }); + }); + + describe("disconnect", () => { + test("close intencional emite closed e não reconecta", () => { + client.connect(); + const ws = instances[0]; + ws.readyState = FakeWebSocket.OPEN; + client.disconnect(); + expect(statuses).toEqual(["connecting", "closed"]); + fakeTimers.advance(10000); + expect(instances.length).toBe(1); + }); + + test("disconnect durante o backoff limpa o timer pendente", () => { + client.connect(); + instances[0].close(); + client.disconnect(); + fakeTimers.advance(10000); + expect(instances.length).toBe(1); + }); + }); + + describe("transições de status", () => { + test("connecting → open quando o socket abre", () => { + client.connect(); + expect(statuses).toEqual(["connecting"]); + instances[0].emitOpen(); + expect(statuses).toEqual(["connecting", "open"]); + }); + + test("connecting → reconnecting → connecting → open", () => { + client.connect(); + instances[0].close(); + fakeTimers.advance(1000); + instances[1].emitOpen(); + expect(statuses).toEqual(["connecting", "reconnecting", "connecting", "open"]); + }); + + test("erro fecha o socket e dispara reconexão", () => { + client.connect(); + instances[0].onerror?.(); + expect(statuses).toEqual(["connecting", "reconnecting"]); + fakeTimers.advance(1000); + expect(instances.length).toBe(2); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 1ffef60..01490aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.test.json" } ] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..73b7127 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["bun"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["tests/unit", "tests/helpers"] +}