@eric8810/catcher-web
Advanced tools
+5
-3
| { | ||
| "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", |
| export {}; |
| 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; | ||
| } | ||
| }); | ||
| }); |
| export {}; |
| 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; | ||
| } | ||
| }); | ||
| }); |
| export {}; |
| 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; | ||
| } | ||
| }); | ||
| }); |
| export {}; |
| 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; | ||
| } | ||
| }); | ||
| }); |
| export {}; |
| /** | ||
| * 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); | ||
| }); | ||
| }); | ||
| }); |
| export {}; |
| /** | ||
| * 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: ' ' }); | ||
| }); | ||
| }); | ||
| }); |
| export {}; |
| /** | ||
| * 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'); | ||
| }); | ||
| }); | ||
| }); |
| export {}; |
| /** | ||
| * 浏览器端 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% | ||
| } | ||
| }); | ||
| }); | ||
| }); |
3
-92.31%52733
-54.03%21
-43.24%1213
-54%+ Added
- Removed
Updated