diff --git a/src/utils/ChannelFetch.test.ts b/src/utils/ChannelFetch.test.ts new file mode 100644 index 00000000..62bf9a46 --- /dev/null +++ b/src/utils/ChannelFetch.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { FETCH_ABORTED, FETCH_REQUEST, FETCH_RESPONSE } from "../constants"; +import { ChannelFetch } from "./ChannelFetch"; +import { MockChannel } from "./MockChannel"; + +const resolveAfter = (ms: number, value: any) => + new Promise((resolve) => setTimeout(resolve, ms, value)); + +const rejectAfter = (ms: number, reason: any) => + new Promise((_, reject) => setTimeout(reject, ms, reason)); + +describe("ChannelFetch", () => { + let channel: MockChannel; + + beforeEach(() => { + channel = new MockChannel(); + }); + + it("should handle fetch requests", async () => { + const fetch = vi.fn(() => resolveAfter(100, { headers: [], text: async () => "data" })); + ChannelFetch.subscribe("req", channel, fetch as any); + + channel.emit(FETCH_REQUEST, { + requestId: "req", + input: "https://example.com", + init: { headers: { foo: "bar" } }, + }); + + await vi.waitFor(() => { + expect(fetch).toHaveBeenCalledWith("https://example.com", { + headers: { foo: "bar" }, + signal: expect.any(AbortSignal), + }); + }); + }); + + it("should send fetch responses", async () => { + const fetch = vi.fn(() => resolveAfter(100, { headers: [], text: async () => "data" })); + const instance = ChannelFetch.subscribe("res", channel, fetch as any); + + const promise = new Promise((resolve) => { + channel.on(FETCH_RESPONSE, ({ response, error }) => { + expect(response.body).toBe("data"); + expect(error).toBeUndefined(); + resolve(); + }); + }); + + channel.emit(FETCH_REQUEST, { requestId: "res", input: "https://example.com" }); + await vi.waitFor(() => { + expect(instance.abortControllers.size).toBe(1); + }); + + await promise; + + expect(instance.abortControllers.size).toBe(0); + }); + + it("should send fetch error responses", async () => { + const fetch = vi.fn(() => rejectAfter(100, new Error("oops"))); + const instance = ChannelFetch.subscribe("err", channel, fetch as any); + + const promise = new Promise((resolve) => { + channel.on(FETCH_RESPONSE, ({ response, error }) => { + expect(response).toBeUndefined(); + expect(error).toMatch(/oops/); + resolve(); + }); + }); + + channel.emit(FETCH_REQUEST, { requestId: "err", input: "https://example.com" }); + await vi.waitFor(() => { + expect(instance.abortControllers.size).toBe(1); + }); + + await promise; + expect(instance.abortControllers.size).toBe(0); + }); + + it("should abort fetch requests", async () => { + const fetch = vi.fn((input, init) => new Promise(() => {})); + const instance = ChannelFetch.subscribe("abort", channel, fetch); + + channel.emit(FETCH_REQUEST, { requestId: "abort", input: "https://example.com" }); + await vi.waitFor(() => { + expect(instance.abortControllers.size).toBe(1); + }); + + channel.emit(FETCH_ABORTED, { requestId: "abort" }); + await vi.waitFor(() => { + expect(fetch.mock.lastCall?.[1].signal.aborted).toBe(true); + expect(instance.abortControllers.size).toBe(0); + }); + }); +}); diff --git a/src/utils/ChannelFetch.ts b/src/utils/ChannelFetch.ts index c95a70c6..d6fb1371 100644 --- a/src/utils/ChannelFetch.ts +++ b/src/utils/ChannelFetch.ts @@ -11,7 +11,7 @@ export class ChannelFetch { abortControllers: Map; - constructor(channel: ChannelLike) { + constructor(channel: ChannelLike, _fetch = fetch) { this.channel = channel; this.abortControllers = new Map(); @@ -25,12 +25,13 @@ export class ChannelFetch { this.abortControllers.set(requestId, controller); try { - const res = await fetch(input as RequestInfo, { ...init, signal: controller.signal }); + const res = await _fetch(input as RequestInfo, { ...init, signal: controller.signal }); const body = await res.text(); const headers = Array.from(res.headers as any); const response = { body, headers, status: res.status, statusText: res.statusText }; this.channel.emit(FETCH_RESPONSE, { requestId, response }); - } catch (error) { + } catch (err) { + const error = err instanceof Error ? err.message : String(err); this.channel.emit(FETCH_RESPONSE, { requestId, error }); } finally { this.abortControllers.delete(requestId); @@ -38,8 +39,8 @@ export class ChannelFetch { }); } - static subscribe(key: string, channel: ChannelLike) { - const instance = instances.get(key) || new ChannelFetch(channel); + static subscribe(key: string, channel: ChannelLike, _fetch = fetch) { + const instance = instances.get(key) || new ChannelFetch(channel, _fetch); if (!instances.has(key)) instances.set(key, instance); return instance; } diff --git a/src/utils/MockChannel.ts b/src/utils/MockChannel.ts new file mode 100644 index 00000000..209e4f39 --- /dev/null +++ b/src/utils/MockChannel.ts @@ -0,0 +1,16 @@ +export class MockChannel { + private listeners: Record void)[]> = {}; + + on(event: string, listener: (...args: any[]) => void) { + this.listeners[event] = [...(this.listeners[event] ?? []), listener]; + } + + off(event: string, listener: (...args: any[]) => void) { + this.listeners[event] = (this.listeners[event] ?? []).filter((l) => l !== listener); + } + + emit(event: string, ...args: any[]) { + // setTimeout is used to simulate the asynchronous nature of the real channel + (this.listeners[event] || []).forEach((listener) => setTimeout(() => listener(...args))); + } +} diff --git a/src/utils/SharedState.test.ts b/src/utils/SharedState.test.ts index 7b2d1ecb..ca98b216 100644 --- a/src/utils/SharedState.test.ts +++ b/src/utils/SharedState.test.ts @@ -1,24 +1,8 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { MockChannel } from "./MockChannel"; import { SharedState } from "./SharedState"; -class MockChannel { - private listeners: Record void)[]> = {}; - - on(event: string, listener: (...args: any[]) => void) { - this.listeners[event] = [...(this.listeners[event] ?? []), listener]; - } - - off(event: string, listener: (...args: any[]) => void) { - this.listeners[event] = (this.listeners[event] ?? []).filter((l) => l !== listener); - } - - emit(event: string, ...args: any[]) { - // setTimeout is used to simulate the asynchronous nature of the real channel - (this.listeners[event] || []).forEach((listener) => setTimeout(() => listener(...args))); - } -} - const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); describe("SharedState", () => {