test: unit tests do WsClient (protocolo zeroclaw.v1) #9
8 changed files with 491 additions and 2 deletions
|
|
@ -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"
|
||||
|
|
|
|||
5
bun.lock
5
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=="],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
96
tests/helpers/ws-test-utils.ts
Normal file
96
tests/helpers/ws-test-utils.ts
Normal 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
247
tests/unit/ws.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
24
tsconfig.test.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in a new issue