Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
144 lines
5.1 KiB
TypeScript
144 lines
5.1 KiB
TypeScript
import { afterEach, describe, it, expect, vi } from 'vitest';
|
|
import { sseResponse } from '../src/webui/sse-response';
|
|
|
|
describe('sseResponse', () => {
|
|
afterEach(() => {
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('releases subscriptions immediately when the response body is canceled', async () => {
|
|
vi.useFakeTimers();
|
|
const request = new AbortController();
|
|
const cleanup = vi.fn();
|
|
let channel!: Parameters<Parameters<typeof sseResponse>[1]>[0];
|
|
const response = sseResponse(request.signal, (ch) => {
|
|
channel = ch;
|
|
ch.onClose(cleanup);
|
|
});
|
|
|
|
await response.body!.cancel();
|
|
expect(channel.isClosed()).toBe(true);
|
|
expect(cleanup).toHaveBeenCalledOnce();
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
request.abort();
|
|
expect(cleanup).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('does not subscribe when the request was already aborted', () => {
|
|
vi.useFakeTimers();
|
|
const request = new AbortController();
|
|
request.abort();
|
|
const start = vi.fn();
|
|
sseResponse(request.signal, start);
|
|
expect(start).not.toHaveBeenCalled();
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('releases subscriptions registered before and after an abort during setup', () => {
|
|
vi.useFakeTimers();
|
|
const request = new AbortController();
|
|
const before = vi.fn();
|
|
const after = vi.fn();
|
|
sseResponse(request.signal, (channel) => {
|
|
channel.onClose(before);
|
|
request.abort();
|
|
channel.onClose(after);
|
|
});
|
|
expect(before).toHaveBeenCalledOnce();
|
|
expect(after).toHaveBeenCalledOnce();
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('releases subscriptions when endpoint setup throws', () => {
|
|
vi.useFakeTimers();
|
|
const request = new AbortController();
|
|
const cleanup = vi.fn();
|
|
expect(() =>
|
|
sseResponse(request.signal, (channel) => {
|
|
channel.onClose(cleanup);
|
|
throw new Error('setup failed');
|
|
}),
|
|
).toThrow('setup failed');
|
|
expect(cleanup).toHaveBeenCalledOnce();
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('finishes active streams and releases subscriptions during server shutdown', async () => {
|
|
const request = new AbortController();
|
|
const shutdown = new AbortController();
|
|
let cleanups = 0;
|
|
const response = sseResponse(
|
|
request.signal,
|
|
(channel) => {
|
|
channel.onClose(() => {
|
|
cleanups++;
|
|
});
|
|
},
|
|
shutdown.signal,
|
|
);
|
|
const reader = response.body!.getReader();
|
|
shutdown.abort();
|
|
expect(await reader.read()).toEqual({ done: true, value: undefined });
|
|
request.abort();
|
|
expect(cleanups).toBe(1);
|
|
});
|
|
|
|
it('returns an event-stream Response with the SSE headers', () => {
|
|
const ac = new AbortController();
|
|
const res = sseResponse(ac.signal, () => {
|
|
/* no-op */
|
|
});
|
|
expect(res.headers.get('Content-Type')).toBe('text/event-stream');
|
|
expect(res.headers.get('Cache-Control')).toBe('no-cache');
|
|
expect(res.headers.get('Connection')).toBe('keep-alive');
|
|
ac.abort(); // clear the heartbeat timer
|
|
});
|
|
|
|
it('ch.send frames JSON as `data: …` and ch.raw enqueues verbatim', async () => {
|
|
const ac = new AbortController();
|
|
const res = sseResponse(ac.signal, (ch) => {
|
|
ch.send({ type: 'ready' });
|
|
ch.raw(ch.encode(': heartbeat\n\n'));
|
|
});
|
|
const reader = res.body!.getReader();
|
|
const dec = new TextDecoder();
|
|
expect(dec.decode((await reader.read()).value)).toBe('data: {"type":"ready"}\n\n');
|
|
expect(dec.decode((await reader.read()).value)).toBe(': heartbeat\n\n');
|
|
ac.abort();
|
|
await reader.cancel();
|
|
});
|
|
|
|
it('abort runs registered cleanups exactly once, flips isClosed, and closes the stream', async () => {
|
|
const ac = new AbortController();
|
|
const cleanupA = vi.fn();
|
|
const cleanupB = vi.fn();
|
|
let channel!: Parameters<Parameters<typeof sseResponse>[1]>[0];
|
|
const res = sseResponse(ac.signal, (ch) => {
|
|
channel = ch;
|
|
ch.onClose(cleanupA);
|
|
ch.onClose(cleanupB);
|
|
});
|
|
expect(channel.isClosed()).toBe(false);
|
|
|
|
ac.abort();
|
|
expect(cleanupA).toHaveBeenCalledTimes(1);
|
|
expect(cleanupB).toHaveBeenCalledTimes(1);
|
|
expect(channel.isClosed()).toBe(true);
|
|
|
|
const r = await res.body!.getReader().read();
|
|
expect(r.done).toBe(true);
|
|
});
|
|
|
|
it('send after teardown is a silent no-op', () => {
|
|
const ac = new AbortController();
|
|
let channel!: Parameters<Parameters<typeof sseResponse>[1]>[0];
|
|
sseResponse(ac.signal, (ch) => {
|
|
channel = ch;
|
|
});
|
|
ac.abort();
|
|
expect(() => channel.send({ x: 1 })).not.toThrow();
|
|
expect(channel.isClosed()).toBe(true);
|
|
});
|
|
});
|