test: unit tests do WsClient (protocolo zeroclaw.v1) #9

Merged
fsolla merged 1 commit from I1-testes-unitarios-para-src-lib-ws-ts-protocolo-zeroclaw-v1 into main 2026-08-18 11:23:18 +00:00
8 changed files with 491 additions and 2 deletions

View file

@ -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"

View file

@ -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=="],

View file

@ -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)

View file

@ -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",

View file

@ -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<number, { callback: () => 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;
},
};
}

247
tests/unit/ws.test.ts Normal file
View file

@ -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);
});
});
});

View file

@ -2,6 +2,7 @@
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.test.json" }
]
}

24
tsconfig.test.json Normal file
View file

@ -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"]
}