🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@eric8810/catcher-web

Package Overview
Dependencies
Maintainers
1
Versions
19
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@eric8810/catcher-web - npm Package Compare versions

Comparing version
0.3.8
to
0.3.9
+5
-3
package.json
{
"name": "@eric8810/catcher-web",
"version": "0.3.8",
"version": "0.3.9",
"description": "Catcher HTTP client for browsers — fetch-based with retry, circuit breaker & priority queue",

@@ -15,3 +15,5 @@ "type": "module",

"files": [
"dist"
"dist",
"!dist/**/__tests__",
"!dist/**/*.test.*"
],

@@ -22,3 +24,3 @@ "dependencies": {

"p-queue": "^8.0.0",
"@eric8810/catcher-core": "0.3.8"
"@eric8810/catcher-core": "0.3.9"
},

@@ -25,0 +27,0 @@ "license": "MIT",

import { describe, it, expect, vi } from 'vitest';
import { createWebClient } from '../client.js';
/**
* Browser auth tests (AU5-AU7).
* Mock globalThis.fetch and document.cookie since we're in Node.js.
*/
function mockResponse(status = 200, body = { ok: true }) {
return {
status,
ok: status >= 200 && status < 300,
headers: new Headers(),
json: () => Promise.resolve(body),
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
body: null,
};
}
describe('AU5 — XSRF cookie → header injection', () => {
it('reads XSRF cookie and sends as header', async () => {
const fetchSpy = vi.fn().mockResolvedValue(mockResponse());
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchSpy;
// Mock document.cookie
const originalDocument = globalThis.document;
Object.defineProperty(globalThis, 'document', {
value: { cookie: 'XSRF-TOKEN=test-xsrf-value' },
writable: true,
configurable: true,
});
try {
const client = createWebClient({
baseURL: 'http://localhost',
xsrfCookieName: 'XSRF-TOKEN',
xsrfHeaderName: 'X-XSRF-TOKEN',
});
await client.get('/test');
expect(fetchSpy).toHaveBeenCalled();
const init = fetchSpy.mock.calls[0][1];
const headers = init.headers;
expect(headers['X-XSRF-TOKEN']).toBe('test-xsrf-value');
}
finally {
globalThis.fetch = originalFetch;
if (originalDocument) {
Object.defineProperty(globalThis, 'document', { value: originalDocument, writable: true, configurable: true });
}
else {
delete globalThis.document;
}
}
});
});
describe('AU6 — XSRF cookie not present → no header', () => {
it('does not inject XSRF header when cookie is absent', async () => {
const fetchSpy = vi.fn().mockResolvedValue(mockResponse());
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchSpy;
// Mock document.cookie without XSRF token
const originalDocument = globalThis.document;
Object.defineProperty(globalThis, 'document', {
value: { cookie: '' },
writable: true,
configurable: true,
});
try {
const client = createWebClient({
baseURL: 'http://localhost',
xsrfCookieName: 'XSRF-TOKEN',
xsrfHeaderName: 'X-XSRF-TOKEN',
});
await client.get('/test');
expect(fetchSpy).toHaveBeenCalled();
const init = fetchSpy.mock.calls[0][1];
const headers = init.headers;
expect(headers['X-XSRF-TOKEN']).toBeUndefined();
}
finally {
globalThis.fetch = originalFetch;
if (originalDocument) {
Object.defineProperty(globalThis, 'document', { value: originalDocument, writable: true, configurable: true });
}
else {
delete globalThis.document;
}
}
});
});
describe('AU7 — Bearer Token auto-injection (browser)', () => {
it('sends Authorization: Bearer header', async () => {
const fetchSpy = vi.fn().mockResolvedValue(mockResponse());
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchSpy;
try {
const client = createWebClient({
baseURL: 'http://localhost',
bearerToken: 'browser-token',
});
await client.get('/test');
expect(fetchSpy).toHaveBeenCalled();
const init = fetchSpy.mock.calls[0][1];
const headers = init.headers;
expect(headers['Authorization']).toBe('Bearer browser-token');
}
finally {
globalThis.fetch = originalFetch;
}
});
});
import { describe, it, expect, vi } from 'vitest';
import { createWebClient } from '../client.js';
/**
* Browser CORS/credentials tests (C5-C9).
* Mock globalThis.fetch since we're running in Node.js.
*/
function mockResponse(status = 200, body = { ok: true }, headers = {}) {
return {
status,
ok: status >= 200 && status < 300,
headers: new Headers(headers),
json: () => Promise.resolve(body),
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
body: null,
};
}
describe('C5 — credentials: "include" passed to fetch', () => {
it('fetch receives credentials: "include"', async () => {
const fetchSpy = vi.fn().mockResolvedValue(mockResponse());
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchSpy;
try {
const client = createWebClient({
baseURL: 'http://localhost',
credentials: 'include',
});
await client.get('/test');
expect(fetchSpy).toHaveBeenCalled();
const init = fetchSpy.mock.calls[0][1];
expect(init.credentials).toBe('include');
}
finally {
globalThis.fetch = originalFetch;
}
});
});
describe('C6 — fetchMode: "no-cors" passed to fetch', () => {
it('fetch receives mode: "no-cors"', async () => {
const fetchSpy = vi.fn().mockResolvedValue(mockResponse());
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchSpy;
try {
const client = createWebClient({
baseURL: 'http://localhost',
fetchMode: 'no-cors',
});
await client.get('/test');
expect(fetchSpy).toHaveBeenCalled();
const init = fetchSpy.mock.calls[0][1];
expect(init.mode).toBe('no-cors');
}
finally {
globalThis.fetch = originalFetch;
}
});
});
describe('C7 — Default credentials is "same-origin"', () => {
it('fetch receives credentials: "same-origin" by default', async () => {
const fetchSpy = vi.fn().mockResolvedValue(mockResponse());
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchSpy;
try {
const client = createWebClient({ baseURL: 'http://localhost' });
await client.get('/test');
expect(fetchSpy).toHaveBeenCalled();
const init = fetchSpy.mock.calls[0][1];
expect(init.credentials).toBe('same-origin');
}
finally {
globalThis.fetch = originalFetch;
}
});
});
describe('C8 — Default mode is "cors"', () => {
it('fetch receives mode: "cors" by default', async () => {
const fetchSpy = vi.fn().mockResolvedValue(mockResponse());
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchSpy;
try {
const client = createWebClient({ baseURL: 'http://localhost' });
await client.get('/test');
expect(fetchSpy).toHaveBeenCalled();
const init = fetchSpy.mock.calls[0][1];
expect(init.mode).toBe('cors');
}
finally {
globalThis.fetch = originalFetch;
}
});
});
describe('C9 — Per-request credentials override', () => {
it('request-level credentials override instance default', async () => {
const fetchSpy = vi.fn().mockResolvedValue(mockResponse());
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchSpy;
try {
const client = createWebClient({
baseURL: 'http://localhost',
credentials: 'omit',
});
await client.get('/test', { credentials: 'include' });
expect(fetchSpy).toHaveBeenCalled();
const init = fetchSpy.mock.calls[0][1];
expect(init.credentials).toBe('include');
}
finally {
globalThis.fetch = originalFetch;
}
});
});
import { describe, it, expect, vi } from 'vitest';
import { createWebClient } from '../client.js';
/**
* Browser redirect tests (RD7-RD8).
* Mock globalThis.fetch since we're running in Node.js.
*/
function mockResponse(status = 200, body = { ok: true }) {
return {
status,
ok: status >= 200 && status < 300,
headers: new Headers(),
json: () => Promise.resolve(body),
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
body: null,
};
}
describe('RD7 — redirect: { follow: false } passes redirect: "manual"', () => {
it('fetch receives redirect: "manual"', async () => {
const fetchSpy = vi.fn().mockResolvedValue(mockResponse());
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchSpy;
try {
const client = createWebClient({
baseURL: 'http://localhost',
redirect: { follow: false },
});
await client.get('/test', { validateStatus: () => true });
expect(fetchSpy).toHaveBeenCalled();
const init = fetchSpy.mock.calls[0][1];
expect(init.redirect).toBe('manual');
}
finally {
globalThis.fetch = originalFetch;
}
});
});
describe('RD8 — Default redirect is "follow"', () => {
it('fetch receives redirect: "follow" by default', async () => {
const fetchSpy = vi.fn().mockResolvedValue(mockResponse());
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchSpy;
try {
const client = createWebClient({ baseURL: 'http://localhost' });
await client.get('/test');
expect(fetchSpy).toHaveBeenCalled();
const init = fetchSpy.mock.calls[0][1];
expect(init.redirect).toBe('follow');
}
finally {
globalThis.fetch = originalFetch;
}
});
});
import { describe, it, expect, vi } from 'vitest';
import { createWebClient } from '../client.js';
/**
* Browser stream tests (ST5-ST6).
* Mock globalThis.fetch to return a Response with a ReadableStream body.
*/
function createMockReadableStream(chunks) {
return new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(new TextEncoder().encode(chunk));
}
controller.close();
},
});
}
describe('ST5 — responseType: "stream" returns ReadableStream', () => {
it('returns an object with data being a ReadableStream', async () => {
const stream = createMockReadableStream(['hello', ' world']);
const mockResp = {
status: 200,
ok: true,
headers: new Headers({ 'content-type': 'text/plain' }),
body: stream,
json: () => Promise.resolve({}),
text: () => Promise.resolve(''),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
};
const fetchSpy = vi.fn().mockResolvedValue(mockResp);
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchSpy;
try {
const client = createWebClient({ baseURL: 'http://localhost' });
const result = await client.get('/stream', { responseType: 'stream' });
expect(result.status).toBe(200);
expect(result.data).toBeDefined();
// Should be a ReadableStream (has getReader / locked)
expect(result.data).toBe(stream);
}
finally {
globalThis.fetch = originalFetch;
}
});
});
describe('ST6 — Stream reads complete data', () => {
it('reads all chunks from the ReadableStream', async () => {
const stream = createMockReadableStream(['chunk1', 'chunk2', 'chunk3']);
const mockResp = {
status: 200,
ok: true,
headers: new Headers(),
body: stream,
json: () => Promise.resolve({}),
text: () => Promise.resolve(''),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
};
const fetchSpy = vi.fn().mockResolvedValue(mockResp);
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchSpy;
try {
const client = createWebClient({ baseURL: 'http://localhost' });
const result = await client.get('/stream', { responseType: 'stream' });
// Read all chunks from the ReadableStream
const reader = result.data.getReader();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done)
break;
chunks.push(value);
}
const fullText = chunks.map(c => new TextDecoder().decode(c)).join('');
expect(fullText).toBe('chunk1chunk2chunk3');
}
finally {
globalThis.fetch = originalFetch;
}
});
});
/**
* createSSEClient 集成测试 — catcher-web 浏览器版
*
* 验证浏览器端 SSE 长连接 + 自动重连行为。
* Mock 方式:vi.spyOn(globalThis, 'fetch'),与 catcher-http-ts 版相同。
*
* 用例编号 C1-C11,与设计文档 docs/arch-ts/10-sse.md 一一对应。
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createSSEClient } from '../client.js';
// ── Mock 工具函数 ──────────────────────────────────────────
function mockResponse(stream, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
headers: new Headers({ 'Content-Type': 'text/event-stream' }),
body: stream,
};
}
function mockSSEResponse(lines, options) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
for (const line of lines) {
controller.enqueue(encoder.encode(line + '\n'));
}
controller.close();
},
});
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockResponse(stream, options?.status));
}
async function collectUntil(client, count, timeoutMs = 5000) {
const result = [];
const deadline = Date.now() + timeoutMs;
for await (const line of client) {
result.push(line);
if (result.length >= count)
break;
if (Date.now() > deadline)
break;
}
return result;
}
beforeEach(() => { vi.restoreAllMocks(); });
afterEach(() => { vi.restoreAllMocks(); });
describe('createSSEClient (catcher-web)', () => {
// ── 3.1 基础连接和消费 ──────────────────────────────────
describe('基础连接和消费', () => {
it('C1 连接并消费内容行', async () => {
mockSSEResponse(['data: Hello', '', 'data: World', '']);
const client = createSSEClient({
url: 'http://test/sse',
reconnect: { enabled: false },
});
const lines = await collectUntil(client, 2);
expect(lines).toEqual(['data: Hello', 'data: World']);
client.close();
});
it('C2 readyState 变化', async () => {
const encoder = new TextEncoder();
let pulled = false;
const stream = new ReadableStream({
pull(controller) {
if (!pulled) {
pulled = true;
controller.enqueue(encoder.encode('data: hi\n'));
}
},
});
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockResponse(stream));
const client = createSSEClient({
url: 'http://test/sse',
reconnect: { enabled: false },
});
expect(client.readyState).toBe('CONNECTING');
const lines = await collectUntil(client, 1);
expect(lines).toEqual(['data: hi']);
expect(client.readyState).toBe('OPEN');
client.close();
expect(client.readyState).toBe('CLOSED');
});
it('C3 close() 停止迭代', async () => {
const encoder = new TextEncoder();
let pulled = false;
const stream = new ReadableStream({
pull(controller) {
if (!pulled) {
pulled = true;
controller.enqueue(encoder.encode('data: ongoing\n'));
}
},
});
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockResponse(stream));
const client = createSSEClient({
url: 'http://test/sse',
reconnect: { enabled: false },
});
const lines = await collectUntil(client, 1);
expect(lines).toEqual(['data: ongoing']);
client.close();
const more = [];
for await (const line of client) {
more.push(line);
break;
}
expect(more).toEqual([]);
});
it('C4 lastEventId 提取', async () => {
mockSSEResponse(['id: abc123', 'data: payload', '']);
const client = createSSEClient({
url: 'http://test/sse',
reconnect: { enabled: false },
});
await collectUntil(client, 1);
expect(client.lastEventId).toBe('abc123');
client.close();
});
});
// ── 3.2 自动重连 ────────────────────────────────────────
describe('自动重连', () => {
it('C5 流结束后自动重连 — 两次内容都收到', async () => {
const encoder = new TextEncoder();
const stream1 = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: first\n'));
controller.close();
},
});
const stream2 = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: second\n'));
controller.close();
},
});
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockImplementationOnce(() => Promise.resolve(mockResponse(stream1)));
const client = createSSEClient({
url: 'http://test/sse',
reconnect: { enabled: true, initialDelay: 10, maxDelay: 50, maxRetries: 1 },
});
const first = await collectUntil(client, 1, 2000);
expect(first).toEqual(['data: first']);
fetchSpy.mockImplementationOnce(() => Promise.resolve(mockResponse(stream2)));
const iter = client[Symbol.asyncIterator]();
const second = await iter.next();
expect(second.value).toBe('data: second');
client.close();
});
it('C6 重连携带 Last-Event-ID', async () => {
const encoder = new TextEncoder();
const stream1 = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('id: reconnect-me\ndata: A\n'));
controller.close();
},
});
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockImplementationOnce(() => Promise.resolve(mockResponse(stream1)));
const client = createSSEClient({
url: 'http://test/sse',
reconnect: { enabled: true, initialDelay: 10, maxDelay: 50, maxRetries: 1 },
});
const first = await collectUntil(client, 1, 2000);
expect(first).toEqual(['data: A']);
const stream2 = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: B\n'));
controller.close();
},
});
fetchSpy.mockImplementationOnce(() => Promise.resolve(mockResponse(stream2)));
const iter = client[Symbol.asyncIterator]();
const second = await iter.next();
expect(second.value).toBe('data: B');
expect(fetchSpy).toHaveBeenCalledTimes(2);
const secondCall = fetchSpy.mock.calls[1];
const init = secondCall[1];
const headers = init.headers;
expect(headers['Last-Event-ID']).toBe('reconnect-me');
client.close();
});
it('C7 网络错误后重连', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockRejectedValueOnce(new Error('Network error'));
const encoder = new TextEncoder();
const stream2 = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: recovered\n'));
controller.close();
},
});
const client = createSSEClient({
url: 'http://test/sse',
reconnect: { enabled: true, initialDelay: 10, maxDelay: 50, maxRetries: 1 },
});
fetchSpy.mockImplementationOnce(() => Promise.resolve(mockResponse(stream2)));
const lines = await collectUntil(client, 1, 3000);
expect(lines).toEqual(['data: recovered']);
client.close();
});
it('C8 达到 maxRetries 停止', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockRejectedValue(new Error('Always fail'));
const client = createSSEClient({
url: 'http://test/sse',
reconnect: { enabled: true, maxRetries: 1, initialDelay: 10, maxDelay: 50 },
});
const lines = [];
for await (const line of client) {
lines.push(line);
}
expect(lines).toEqual([]);
expect(fetchSpy.mock.calls.length).toBeLessThanOrEqual(3);
});
it('C9 enabled: false 不重连', async () => {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: once\n'));
controller.close();
},
});
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockImplementationOnce(() => Promise.resolve(mockResponse(stream)));
const client = createSSEClient({
url: 'http://test/sse',
reconnect: { enabled: false },
});
const lines = await collectUntil(client, 1, 2000);
expect(lines).toEqual(['data: once']);
await new Promise(r => setTimeout(r, 100));
expect(fetchSpy).toHaveBeenCalledTimes(1);
client.close();
});
});
// ── 3.3 204 停止重连 ────────────────────────────────────
describe('204 停止重连', () => {
it('C10 204 停止重连', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const emptyStream = new ReadableStream({ start(c) { c.close(); } });
fetchSpy.mockImplementationOnce(() => Promise.resolve({
ok: false,
status: 204,
headers: new Headers(),
body: emptyStream,
}));
const client = createSSEClient({
url: 'http://test/sse',
reconnect: { enabled: true, initialDelay: 10 },
});
const lines = [];
for await (const line of client) {
lines.push(line);
}
expect(lines).toEqual([]);
await new Promise(r => setTimeout(r, 100));
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});
// ── 3.4 熔断器 ──────────────────────────────────────────
describe('熔断器', () => {
it('C11 circuitBreaker 集成 — 连续失败后停止', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockRejectedValue(new Error('Connection refused'));
const client = createSSEClient({
url: 'http://test/sse',
reconnect: { enabled: true, maxRetries: 10, initialDelay: 10, maxDelay: 50 },
circuitBreaker: { failureThreshold: 2, resetTimeout: 10_000 },
});
const lines = [];
for await (const line of client) {
lines.push(line);
}
expect(lines).toEqual([]);
expect(fetchSpy.mock.calls.length).toBeLessThan(10);
});
});
});
/**
* Line Router 单元测试 — catcher-web 版
*
* 代码与 catcher-http-ts/src/sse/router.ts 完全相同,
* 本测试验证 catcher-web 打包的 router 行为一致。
*
* 用例编号 #1-#24,与设计文档 docs/arch-ts/10-sse.md 一一对应。
*/
import { describe, it, expect } from 'vitest';
import { routeLine } from '../router.js';
describe('Line Router (catcher-web)', () => {
// ── 1.1 控制行 → Silent ────────────────────────────────────
describe('控制行 → Silent', () => {
it('#1 空行 → Silent (事件分隔符)', () => {
expect(routeLine('')).toEqual({ kind: 'silent' });
});
it('#2 `: keepalive` → Silent (心跳)', () => {
expect(routeLine(': keepalive')).toEqual({ kind: 'silent' });
});
it('#3 `: this is a comment` → Silent', () => {
expect(routeLine(': this is a comment')).toEqual({ kind: 'silent' });
});
it('#4 `:` → Silent (最短注释)', () => {
expect(routeLine(':')).toEqual({ kind: 'silent' });
});
});
// ── 1.2 id: 行 → SetLastEventId ─────────────────────────────
describe('id: 行 → SetLastEventId', () => {
it('#5 `id: msg_001` → SetLastEventId("msg_001")', () => {
expect(routeLine('id: msg_001')).toEqual({ kind: 'setLastEventId', id: 'msg_001' });
});
it('#6 `id:msg_002` → SetLastEventId("msg_002") (无空格)', () => {
expect(routeLine('id:msg_002')).toEqual({ kind: 'setLastEventId', id: 'msg_002' });
});
it('#7 `id: multi space` → trimStart 只去前导空格', () => {
expect(routeLine('id: multi space')).toEqual({ kind: 'setLastEventId', id: 'multi space' });
});
it('#8 `id:` → SetLastEventId("") (空 id)', () => {
expect(routeLine('id:')).toEqual({ kind: 'setLastEventId', id: '' });
});
it('#9 `id: 42` → SetLastEventId("42") (数字 id)', () => {
expect(routeLine('id: 42')).toEqual({ kind: 'setLastEventId', id: '42' });
});
});
// ── 1.3 retry: 行 → SetRetry ────────────────────────────────
describe('retry: 行', () => {
it('#10 `retry: 5000` → SetRetry(5000)', () => {
expect(routeLine('retry: 5000')).toEqual({ kind: 'setRetry', ms: 5000 });
});
it('#11 `retry:1000` → SetRetry(1000)', () => {
expect(routeLine('retry:1000')).toEqual({ kind: 'setRetry', ms: 1000 });
});
it('#12 `retry: abc` → Yield 原样 (非数字)', () => {
expect(routeLine('retry: abc')).toEqual({ kind: 'yield', line: 'retry: abc' });
});
it('#13 `retry: -1` → Yield 原样 (负数)', () => {
expect(routeLine('retry: -1')).toEqual({ kind: 'yield', line: 'retry: -1' });
});
it('#14 `retry: 0` → SetRetry(0) (零合法,立即重连)', () => {
expect(routeLine('retry: 0')).toEqual({ kind: 'setRetry', ms: 0 });
});
});
// ── 1.4 内容行 → Yield 原样输出 ─────────────────────────────
describe('内容行 → Yield 原样输出', () => {
it('#15 `data: Hello` → Yield 原样', () => {
expect(routeLine('data: Hello')).toEqual({ kind: 'yield', line: 'data: Hello' });
});
it('#16 `data: {"type":"start"}` → Yield 原样 (JSON payload)', () => {
expect(routeLine('data: {"type":"start"}')).toEqual({ kind: 'yield', line: 'data: {"type":"start"}' });
});
it('#17 `data: world` → Yield 原样 (两个空格保留)', () => {
expect(routeLine('data: world')).toEqual({ kind: 'yield', line: 'data: world' });
});
it('#18 `data: [DONE]` → Yield 原样', () => {
expect(routeLine('data: [DONE]')).toEqual({ kind: 'yield', line: 'data: [DONE]' });
});
it('#19 `event: message_start` → Yield 原样', () => {
expect(routeLine('event: message_start')).toEqual({ kind: 'yield', line: 'event: message_start' });
});
it('#20 `data:` → Yield 原样 (空 data)', () => {
expect(routeLine('data:')).toEqual({ kind: 'yield', line: 'data:' });
});
it('#21 `custom: value` → Yield 原样 (非标准前缀)', () => {
expect(routeLine('custom: value')).toEqual({ kind: 'yield', line: 'custom: value' });
});
it('#22 `just text` → Yield 原样 (无前缀行)', () => {
expect(routeLine('just text')).toEqual({ kind: 'yield', line: 'just text' });
});
it('#23 `ID: uppercase` → Yield 原样 (大写不是 id:)', () => {
expect(routeLine('ID: uppercase')).toEqual({ kind: 'yield', line: 'ID: uppercase' });
});
it('#24 ` ` → Yield 原样 (空格非控制前缀)', () => {
expect(routeLine(' ')).toEqual({ kind: 'yield', line: ' ' });
});
});
});
/**
* createSSEStream 集成测试 — catcher-web 浏览器版
*
* 验证浏览器端 SSE 流消费行为(使用 ReadableStream.getReader())。
* Mock 方式:vi.spyOn(globalThis, 'fetch'),与 catcher-http-ts 版相同。
*
* 用例编号 S1-S23,与设计文档 docs/arch-ts/10-sse.md 一一对应。
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createSSEStream } from '../stream.js';
// ── Mock 工具函数 ──────────────────────────────────────────
function mockResponse(stream, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
headers: new Headers({ 'Content-Type': 'text/event-stream' }),
body: stream,
};
}
function mockSSEResponse(lines, options) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
for (const line of lines) {
controller.enqueue(encoder.encode(line + '\n'));
}
controller.close();
},
});
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockResponse(stream, options?.status));
}
function mockSSEChunked(chunks, options) {
const encoder = new TextEncoder();
let i = 0;
const stream = new ReadableStream({
pull(controller) {
if (i < chunks.length) {
controller.enqueue(encoder.encode(chunks[i++]));
}
else {
controller.close();
}
},
});
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockResponse(stream, options?.status));
}
/** 模拟空闲超时:发一行后永远不 pull */
function mockSSEIdleHang(options) {
const encoder = new TextEncoder();
let pulled = false;
const stream = new ReadableStream({
pull(controller) {
if (!pulled) {
pulled = true;
controller.enqueue(encoder.encode('data: first\n'));
}
},
});
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockResponse(stream, options?.status));
}
function setupFetchSpy(lines, options) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
for (const line of lines) {
controller.enqueue(encoder.encode(line + '\n'));
}
controller.close();
},
});
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockResponse(stream, options?.status));
return { fetchSpy };
}
async function collectStream(stream) {
const result = [];
for await (const line of stream) {
result.push(line);
}
return result;
}
// ── Tests ──────────────────────────────────────────────────
beforeEach(() => { vi.restoreAllMocks(); });
afterEach(() => { vi.restoreAllMocks(); });
describe('createSSEStream (catcher-web)', () => {
// ── 2.1 基础流式消费 ────────────────────────────────────
describe('基础流式消费', () => {
it('S1 完整 SSE 事件', async () => {
mockSSEResponse(['data: Hello', '', 'data: World', '']);
const stream = createSSEStream({ url: 'http://test/sse' });
expect(await collectStream(stream)).toEqual(['data: Hello', 'data: World']);
});
it('S2 混合控制行和内容行', async () => {
mockSSEResponse([': comment', 'data: A', 'id: 1', '', 'data: B']);
const stream = createSSEStream({ url: 'http://test/sse' });
expect(await collectStream(stream)).toEqual(['data: A', 'data: B']);
expect(stream.lastEventId).toBe('1');
});
it('S3 心跳行被过滤', async () => {
mockSSEResponse([': ping', ': pong', 'data: real', '']);
const stream = createSSEStream({ url: 'http://test/sse' });
expect(await collectStream(stream)).toEqual(['data: real']);
});
it('S4 空行被过滤', async () => {
mockSSEResponse(['data: X', '', '', 'data: Y', '']);
const stream = createSSEStream({ url: 'http://test/sse' });
expect(await collectStream(stream)).toEqual(['data: X', 'data: Y']);
});
});
// ── 2.2 Chunk 分片处理 ──────────────────────────────────
describe('Chunk 分片处理', () => {
it('S5 跨 chunk 的行 — 无半行', async () => {
mockSSEChunked(['data: Hel', 'lo\n']);
const stream = createSSEStream({ url: 'http://test/sse' });
expect(await collectStream(stream)).toEqual(['data: Hello']);
});
it('S6 单 chunk 多行', async () => {
mockSSEChunked(['data: A\ndata: B\n']);
const stream = createSSEStream({ url: 'http://test/sse' });
expect(await collectStream(stream)).toEqual(['data: A', 'data: B']);
});
it('S7 空 chunk + 数据 chunk', async () => {
mockSSEChunked(['', 'data: X\n']);
const stream = createSSEStream({ url: 'http://test/sse' });
expect(await collectStream(stream)).toEqual(['data: X']);
});
it('S8 UTF-8 多字节跨 chunk — 无乱码', async () => {
const prefix = new TextEncoder().encode('data: Héll');
const chunk1 = new Uint8Array([...prefix, 0xc3]);
const chunk2 = new Uint8Array([0xa9, ...new TextEncoder().encode('\n')]);
const stream = new ReadableStream({
start(controller) {
controller.enqueue(chunk1);
controller.enqueue(chunk2);
controller.close();
},
});
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockResponse(stream));
const sse = createSSEStream({ url: 'http://test/sse' });
expect(await collectStream(sse)).toEqual(['data: Héllé']);
});
});
// ── 2.3 行尾处理 ────────────────────────────────────────
describe('行尾处理', () => {
it('S9 \\r\\n 换行', async () => {
mockSSEChunked(['data: A\r\n\r\n']);
const stream = createSSEStream({ url: 'http://test/sse' });
expect(await collectStream(stream)).toEqual(['data: A']);
});
it('S10 混合 \\n 和 \\r\\n', async () => {
mockSSEChunked(['data: A\ndata: B\r\n']);
const stream = createSSEStream({ url: 'http://test/sse' });
expect(await collectStream(stream)).toEqual(['data: A', 'data: B']);
});
it('S11 最后一行无 \\n', async () => {
mockSSEChunked(['data: end']);
const stream = createSSEStream({ url: 'http://test/sse' });
expect(await collectStream(stream)).toEqual(['data: end']);
});
});
// ── 2.4 id: 和 retry: 提取 ──────────────────────────────
describe('id: 提取', () => {
it('S12 lastEventId 提取', async () => {
mockSSEResponse(['id: msg_42', 'data: X']);
const stream = createSSEStream({ url: 'http://test/sse' });
await collectStream(stream);
expect(stream.lastEventId).toBe('msg_42');
});
it('S13 多次 id 覆盖', async () => {
mockSSEResponse(['id: first', 'data: A', '', 'id: second', 'data: B']);
const stream = createSSEStream({ url: 'http://test/sse' });
await collectStream(stream);
expect(stream.lastEventId).toBe('second');
});
});
// ── 2.5 错误处理 ────────────────────────────────────────
describe('错误处理', () => {
it('S14 HTTP 非 200 → throw', async () => {
mockSSEResponse([], { status: 500 });
const stream = createSSEStream({ url: 'http://test/sse' });
await expect(collectStream(stream)).rejects.toThrow('HTTP 500');
});
it('S15 AbortSignal 中断 → 迭代器抛错', async () => {
const encoder = new TextEncoder();
let pulled = false;
const stream = new ReadableStream({
pull(controller) {
if (!pulled) {
pulled = true;
controller.enqueue(encoder.encode('data: hello\n'));
}
},
});
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockResponse(stream));
const controller = new AbortController();
const sseStream = createSSEStream({
url: 'http://test/sse',
signal: controller.signal,
});
const iter = sseStream[Symbol.asyncIterator]();
const first = await iter.next();
expect(first.value).toBe('data: hello');
controller.abort();
await expect(iter.next()).rejects.toThrow('Aborted');
});
});
// ── 2.6 Idle Timeout ────────────────────────────────────
describe('Idle Timeout', () => {
it('S16 idle timeout 触发 → SSETimeoutError', async () => {
mockSSEIdleHang();
const stream = createSSEStream({ url: 'http://test/sse', timeout: 100 });
await expect(collectStream(stream)).rejects.toThrow('SSE timeout after 100ms');
});
});
// ── 2.7 event: 行原样通过 ───────────────────────────────
describe('event: 行原样通过', () => {
it('S17 event: 行原样输出', async () => {
mockSSEResponse(['event: message_start', 'data: {"role":"assistant"}', '']);
const stream = createSSEStream({ url: 'http://test/sse' });
expect(await collectStream(stream)).toEqual([
'event: message_start',
'data: {"role":"assistant"}',
]);
});
it('S18 多个 event: + data: 混合', async () => {
mockSSEResponse([
'event: ping', 'data: ok', '',
'event: message', 'data: hi', '',
]);
const stream = createSSEStream({ url: 'http://test/sse' });
expect(await collectStream(stream)).toEqual([
'event: ping', 'data: ok',
'event: message', 'data: hi',
]);
});
});
// ── 2.8 POST / headers 验证 ─────────────────────────────
describe('POST / headers 验证', () => {
it('S19 POST + JSON body — fetch 请求构造正确', async () => {
const { fetchSpy } = setupFetchSpy(['data: ok', '']);
const body = { model: 'gpt-4', messages: [{ role: 'user', content: 'hi' }] };
const stream = createSSEStream({ url: 'http://test/sse', method: 'POST', body });
await collectStream(stream);
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('http://test/sse');
expect(init.method).toBe('POST');
expect(init.body).toBe(JSON.stringify(body));
const headers = init.headers;
expect(headers['Content-Type']).toBe('application/json');
});
it('S20 自定义 headers 透传', async () => {
const { fetchSpy } = setupFetchSpy(['data: ok', '']);
const stream = createSSEStream({
url: 'http://test/sse',
headers: { Authorization: 'Bearer sk-xxx' },
});
await collectStream(stream);
const init = fetchSpy.mock.calls[0][1];
const headers = init.headers;
expect(headers['Authorization']).toBe('Bearer sk-xxx');
});
it('S21 POST + 已有 Content-Type 不覆盖', async () => {
const { fetchSpy } = setupFetchSpy(['data: ok', '']);
const stream = createSSEStream({
url: 'http://test/sse',
method: 'POST',
body: 'raw text',
headers: { 'Content-Type': 'text/plain' },
});
await collectStream(stream);
const init = fetchSpy.mock.calls[0][1];
const headers = init.headers;
expect(headers['Content-Type']).toBe('text/plain');
});
it('S22 string body 不 JSON.stringify', async () => {
const { fetchSpy } = setupFetchSpy(['data: ok', '']);
const stream = createSSEStream({
url: 'http://test/sse',
method: 'POST',
body: 'raw string',
});
await collectStream(stream);
const init = fetchSpy.mock.calls[0][1];
expect(init.body).toBe('raw string');
});
});
// ── 2.9 只能迭代一次 ────────────────────────────────────
describe('只能迭代一次', () => {
it('S23 第二次迭代抛错', async () => {
mockSSEResponse(['data: once']);
const stream = createSSEStream({ url: 'http://test/sse' });
await collectStream(stream);
expect(() => stream[Symbol.asyncIterator]()).toThrow('can only be iterated once');
});
});
});
/**
* 浏览器端 WebSocket 客户端测试 — catcher-web 版
*
* Mock 方式:拦截全局 WebSocket 构造函数,控制 onopen/onclose/onmessage/onerror 回调。
* 用例编号 BW1-BW25,与设计文档 docs/arch-ts/14-web-ws-tests.md 一一对应。
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createWebSocketClient } from '../client.js';
// ── Polyfill CloseEvent for Node.js ──────────────────────
// CloseEvent is a browser-only API; vitest runs in Node by default.
if (typeof globalThis.CloseEvent === 'undefined') {
;
globalThis.CloseEvent = class CloseEvent extends Event {
code;
reason;
wasClean;
constructor(type, init) {
super(type);
this.code = init?.code ?? 0;
this.reason = init?.reason ?? '';
this.wasClean = init?.wasClean ?? false;
}
};
}
let instances = [];
let OriginalWS;
function createMockWS(url, protocols) {
const inst = {
url: typeof url === 'string' ? url : url.toString(),
protocols,
binaryType: 'blob',
readyState: 0, // CONNECTING
send: vi.fn(),
close: vi.fn(((code, _reason) => {
inst.readyState = 3; // CLOSED
})),
onopen: null,
onclose: null,
onmessage: null,
onerror: null,
};
instances.push(inst);
return inst;
}
function mockWebSocket() {
const ctor = vi.fn((url, protocols) => {
return createMockWS(url, protocols);
});
// Preserve static constants used by client.ts
ctor.CONNECTING = 0;
ctor.OPEN = 1;
ctor.CLOSING = 2;
ctor.CLOSED = 3;
OriginalWS = globalThis.WebSocket;
globalThis.WebSocket = ctor;
return ctor;
}
function restoreWebSocket() {
if (OriginalWS) {
globalThis.WebSocket = OriginalWS;
}
}
function lastInstance() {
return instances[instances.length - 1];
}
// ── Setup / Teardown ──────────────────────────────────────
beforeEach(() => {
instances = [];
vi.useFakeTimers();
mockWebSocket();
});
afterEach(() => {
vi.useRealTimers();
restoreWebSocket();
vi.restoreAllMocks();
});
// ── Tests ──────────────────────────────────────────────────
describe('createWebSocketClient (catcher-web)', () => {
// ── 一、连接生命周期 ────────────────────────────────────
describe('连接生命周期', () => {
it('BW1 成功连接 → open 事件', () => {
const onOpen = vi.fn();
const client = createWebSocketClient({ url: 'ws://test' });
client.addEventListener('open', onOpen);
const ws = lastInstance();
ws.readyState = 1; // OPEN
ws.onopen(new Event('open'));
expect(client.status).toBe('CONNECTED');
expect(onOpen).toHaveBeenCalledTimes(1);
});
it('BW2 连接时设置 binaryType', () => {
createWebSocketClient({ url: 'ws://test', binaryType: 'arraybuffer' });
expect(lastInstance().binaryType).toBe('arraybuffer');
});
it('BW3 使用 protocols', () => {
const ctor = globalThis.WebSocket;
createWebSocketClient({ url: 'ws://test', protocols: ['proto1'] });
expect(ctor).toHaveBeenCalledWith('ws://test', ['proto1']);
});
it('BW4 多 URL 时使用第一个', () => {
createWebSocketClient({ url: ['ws://a', 'ws://b'] });
expect(lastInstance().url).toBe('ws://a');
});
});
// ── 二、消息收发 ────────────────────────────────────────
describe('消息收发', () => {
it('BW5 send 发送文本', () => {
const client = createWebSocketClient({ url: 'ws://test' });
const ws = lastInstance();
ws.readyState = 1; // OPEN
ws.onopen(new Event('open'));
client.send('hello');
expect(ws.send).toHaveBeenCalledWith('hello');
});
it('BW6 send 发送 ArrayBuffer', () => {
const client = createWebSocketClient({ url: 'ws://test' });
const ws = lastInstance();
ws.readyState = 1;
ws.onopen(new Event('open'));
const buf = new ArrayBuffer(4);
client.send(buf);
expect(ws.send).toHaveBeenCalledWith(buf);
});
it('BW7 未连接时 send 不抛错', () => {
const client = createWebSocketClient({ url: 'ws://test' });
// Don't trigger onopen — activeSocket stays null
expect(() => client.send('hello')).not.toThrow();
expect(lastInstance().send).not.toHaveBeenCalled();
});
it('BW8 接收消息 → message 事件', () => {
const onMessage = vi.fn();
const client = createWebSocketClient({ url: 'ws://test' });
client.addEventListener('message', onMessage);
const ws = lastInstance();
const msgEvent = { data: 'hello' };
ws.onmessage(msgEvent);
expect(onMessage).toHaveBeenCalledTimes(1);
});
});
// ── 三、关闭与重连 ──────────────────────────────────────
describe('握手超时', () => {
it('BW15 握手超时 → close(4000)', () => {
createWebSocketClient({
url: 'ws://test',
reconnect: { initialDelay: 10, maxDelay: 50, maxAttempts: 5 },
});
const ws = lastInstance();
// Advance past 10s handshake timeout — client calls ws.close(4000)
vi.advanceTimersByTime(10_000);
expect(ws.close).toHaveBeenCalledWith(4000, 'Handshake timeout');
});
});
describe('关闭与重连', () => {
it('BW9 close() → close 事件', () => {
const onClose = vi.fn();
const client = createWebSocketClient({ url: 'ws://test' });
client.addEventListener('close', onClose);
const ws = lastInstance();
ws.readyState = 1;
ws.onopen(new Event('open'));
client.close();
expect(ws.close).toHaveBeenCalled();
ws.readyState = 3;
ws.onclose(new CloseEvent('close', { code: 1000 }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('BW10 close() 后不重连', () => {
const client = createWebSocketClient({ url: 'ws://test' });
const ws = lastInstance();
ws.readyState = 1;
ws.onopen(new Event('open'));
client.close();
ws.readyState = 3;
ws.onclose(new CloseEvent('close'));
vi.advanceTimersByTime(60_000);
expect(instances.length).toBe(1);
});
it('BW11 close(code, reason) 传递参数', () => {
const client = createWebSocketClient({ url: 'ws://test' });
const ws = lastInstance();
ws.readyState = 1;
ws.onopen(new Event('open'));
client.close(1000, 'done');
expect(ws.close).toHaveBeenCalledWith(1000, 'done');
});
it('BW12 服务端关闭后自动重连', () => {
createWebSocketClient({
url: 'ws://test',
reconnect: { initialDelay: 10, maxDelay: 50, maxAttempts: 5 },
});
const ws1 = lastInstance();
ws1.readyState = 1;
ws1.onopen(new Event('open'));
ws1.readyState = 3;
ws1.onclose(new CloseEvent('close'));
vi.advanceTimersByTime(100);
expect(instances.length).toBe(2);
});
it('BW13 maxAttempts 耗尽停止', () => {
createWebSocketClient({
url: 'ws://test',
reconnect: { initialDelay: 10, maxDelay: 50, maxAttempts: 2 },
});
for (let i = 0; i < 10; i++) {
if (instances.length <= i)
break;
const ws = instances[i];
ws.readyState = 3;
ws.onclose(new CloseEvent('close'));
vi.advanceTimersByTime(100);
}
// initial + 2 reconnect attempts = 3 total
expect(instances.length).toBeLessThanOrEqual(3);
});
it('BW14 重连成功后退避重置', () => {
createWebSocketClient({
url: 'ws://test',
reconnect: { initialDelay: 10, maxDelay: 50, maxAttempts: 5 },
});
// First connection + close
const ws1 = lastInstance();
ws1.readyState = 1;
ws1.onopen(new Event('open'));
ws1.readyState = 3;
ws1.onclose(new CloseEvent('close'));
vi.advanceTimersByTime(100);
// Second connection succeeds → reset()
const ws2 = lastInstance();
ws2.readyState = 1;
ws2.onopen(new Event('open'));
ws2.readyState = 3;
ws2.onclose(new CloseEvent('close'));
vi.advanceTimersByTime(100);
// Third connection (reset means attempt count starts fresh)
expect(instances.length).toBe(3);
});
});
// ── 四、事件系统 ────────────────────────────────────────
describe('事件系统', () => {
it('BW16 addEventListener 注册', () => {
const onOpen = vi.fn();
const client = createWebSocketClient({ url: 'ws://test' });
client.addEventListener('open', onOpen);
lastInstance().onopen(new Event('open'));
expect(onOpen).toHaveBeenCalledTimes(1);
});
it('BW17 removeEventListener 移除', () => {
const onOpen = vi.fn();
const client = createWebSocketClient({ url: 'ws://test' });
client.addEventListener('open', onOpen);
client.removeEventListener('open', onOpen);
lastInstance().onopen(new Event('open'));
expect(onOpen).not.toHaveBeenCalled();
});
it('BW18 多 listener 同类型', () => {
const l1 = vi.fn();
const l2 = vi.fn();
const client = createWebSocketClient({ url: 'ws://test' });
client.addEventListener('open', l1);
client.addEventListener('open', l2);
lastInstance().onopen(new Event('open'));
expect(l1).toHaveBeenCalledTimes(1);
expect(l2).toHaveBeenCalledTimes(1);
});
it('BW19 error 事件分发', () => {
const onError = vi.fn();
const client = createWebSocketClient({ url: 'ws://test' });
client.addEventListener('error', onError);
lastInstance().onerror(new Event('error'));
expect(onError).toHaveBeenCalledTimes(1);
});
it('BW20 url 属性', () => {
const client = createWebSocketClient({ url: 'ws://test/ws' });
expect(client.url).toBe('ws://test/ws');
});
});
// ── 五、退避策略 ────────────────────────────────────────
// createReconnectState 是内部函数,通过观察重连间隔间接测试
describe('退避策略', () => {
it('BW21 首次延迟 ≈ initialDelay', () => {
createWebSocketClient({
url: 'ws://test',
reconnect: { initialDelay: 500, maxDelay: 5000, maxAttempts: 5 },
});
// First connection succeeds
const ws = lastInstance();
ws.readyState = 1;
ws.onopen(new Event('open'));
// Server closes
ws.readyState = 3;
ws.onclose(new CloseEvent('close'));
// Advance less than 250ms — no reconnect yet (500ms ± 25% jitter)
vi.advanceTimersByTime(249);
expect(instances.length).toBe(1);
// Advance past the full delay — reconnect should happen
vi.advanceTimersByTime(500);
expect(instances.length).toBe(2);
});
it('BW22 指数增长 — 构造失败时延迟递增', () => {
// Make WebSocket constructor throw on first 3 calls to test exponential backoff
const ctor = globalThis.WebSocket;
let throwCount = 0;
const originalImpl = ctor.getMockImplementation();
ctor.mockImplementation((url, protocols) => {
throwCount++;
if (throwCount <= 3) {
throw new Error('Connection refused');
}
return createMockWS(url, protocols);
});
createWebSocketClient({
url: 'ws://test',
reconnect: { initialDelay: 100, maxDelay: 100_000, backoffMultiplier: 2, maxAttempts: 5 },
});
// Track when each new WS constructor call happens
const reconnectTimes = [];
let elapsed = 0;
// Wait for all 3 failed attempts + 1 successful
for (let i = 0; i < 4; i++) {
const prevCallCount = ctor.mock.calls.length;
const prevElapsed = elapsed;
for (let t = 0; t < 500_000; t += 10) {
vi.advanceTimersByTime(10);
elapsed += 10;
if (ctor.mock.calls.length > prevCallCount) {
reconnectTimes.push(elapsed - prevElapsed);
break;
}
}
}
// Constructor was called 4 times: initial + 3 reconnects
expect(throwCount).toBe(4);
// Delays should increase: ~100 → ~200 → ~400 (from the 3 reconnect delays)
// reconnectTimes[0] is from initial connect (no delay), so check indices 1+
if (reconnectTimes.length >= 3) {
expect(reconnectTimes[2]).toBeGreaterThan(reconnectTimes[1] * 1.3);
}
});
it('BW23 maxDelay 上限', () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
createWebSocketClient({
url: 'ws://test',
reconnect: { initialDelay: 1000, maxDelay: 5000, backoffMultiplier: 10, maxAttempts: 5 },
});
for (let i = 0; i < 3; i++) {
const ws = instances[instances.length - 1];
ws.readyState = 3;
ws.onclose(new CloseEvent('close'));
vi.advanceTimersByTime(100_000);
}
// Filter to reconnect delays only (< maxDelay * 1.25)
const delayCalls = setTimeoutSpy.mock.calls.filter(c => typeof c[1] === 'number' && c[1] > 0 && c[1] < 10000);
for (const call of delayCalls) {
expect(call[1]).toBeLessThanOrEqual(6250);
}
});
it('BW24 maxAttempts 后返回 -1 → 不重连', () => {
createWebSocketClient({
url: 'ws://test',
reconnect: { initialDelay: 10, maxDelay: 50, maxAttempts: 0 },
});
const ws = lastInstance();
ws.readyState = 3;
ws.onclose(new CloseEvent('close'));
vi.advanceTimersByTime(60_000);
expect(instances.length).toBe(1);
});
it('BW25 reset() 重置计数(重连成功后退避恢复初始值)', () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
createWebSocketClient({
url: 'ws://test',
reconnect: { initialDelay: 100, maxDelay: 5000, backoffMultiplier: 2, maxAttempts: 10 },
});
// First connection succeeds → reset()
const ws1 = lastInstance();
ws1.readyState = 1;
ws1.onopen(new Event('open'));
// Server closes
ws1.readyState = 3;
ws1.onclose(new CloseEvent('close'));
vi.advanceTimersByTime(1000);
// Second connection succeeds → reset()
const ws2 = lastInstance();
ws2.readyState = 1;
ws2.onopen(new Event('open'));
// The reconnect delay after reset should be near initialDelay
const delayCalls = setTimeoutSpy.mock.calls.filter(c => typeof c[1] === 'number' && c[1] > 0 && c[1] < 5000);
if (delayCalls.length > 0) {
const delay = delayCalls[0][1];
expect(delay).toBeLessThanOrEqual(150); // ~100ms ± 25%
}
});
});
});