855 lines
29 KiB
TypeScript
855 lines
29 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { createWorkerHostEventTarget, createWorkerThreadEventTarget } from "../src/event-target-worker.js";
|
|
import type { EventEnvelope, TypedEvent, TypedEventTarget } from "../src/types.js";
|
|
|
|
type TestEvent = TypedEvent<string, EventEnvelope>;
|
|
|
|
function createMockWorker() {
|
|
const posted: unknown[] = [];
|
|
let onmessageHandler: ((event: MessageEvent) => void) | null = null;
|
|
let onerrorHandler: ((event: ErrorEvent) => void) | null = null;
|
|
|
|
const worker = {
|
|
postMessage: vi.fn((data: unknown) => {
|
|
posted.push(data);
|
|
}),
|
|
get onmessage() {
|
|
return onmessageHandler;
|
|
},
|
|
set onmessage(handler: ((event: MessageEvent) => void) | null) {
|
|
onmessageHandler = handler;
|
|
},
|
|
get onerror() {
|
|
return onerrorHandler;
|
|
},
|
|
set onerror(handler: ((event: ErrorEvent) => void) | null) {
|
|
onerrorHandler = handler;
|
|
},
|
|
posted,
|
|
simulateMessage(data: unknown) {
|
|
if (onmessageHandler) {
|
|
onmessageHandler({ data } as MessageEvent);
|
|
}
|
|
},
|
|
};
|
|
|
|
Object.defineProperty(worker, "onmessage", {
|
|
get() {
|
|
return onmessageHandler;
|
|
},
|
|
set(handler: ((event: MessageEvent) => void) | null) {
|
|
onmessageHandler = handler;
|
|
},
|
|
});
|
|
|
|
Object.defineProperty(worker, "onerror", {
|
|
get() {
|
|
return onerrorHandler;
|
|
},
|
|
set(handler: ((event: ErrorEvent) => void) | null) {
|
|
onerrorHandler = handler;
|
|
},
|
|
});
|
|
|
|
return worker;
|
|
}
|
|
|
|
function createMockGlobalThis() {
|
|
const posted: unknown[] = [];
|
|
let onmessageHandler: ((event: MessageEvent) => void) | null = null;
|
|
|
|
const global = {
|
|
onmessage: null as ((event: MessageEvent) => void) | null,
|
|
postMessage: vi.fn((data: unknown) => {
|
|
posted.push(data);
|
|
}),
|
|
posted,
|
|
simulateMessage(data: unknown) {
|
|
if (onmessageHandler) {
|
|
onmessageHandler({ data } as MessageEvent);
|
|
}
|
|
},
|
|
};
|
|
|
|
Object.defineProperty(global, "onmessage", {
|
|
get() {
|
|
return onmessageHandler;
|
|
},
|
|
set(handler: ((event: MessageEvent) => void) | null) {
|
|
onmessageHandler = handler;
|
|
},
|
|
});
|
|
|
|
return global;
|
|
}
|
|
|
|
describe("createWorkerHostEventTarget", () => {
|
|
describe("dispatchEvent (send path)", () => {
|
|
it("posts event.detail to worker via worker.postMessage", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const envelope: EventEnvelope<"call.responded", { status: string }> = {
|
|
type: "call.responded",
|
|
id: "uuid-123",
|
|
payload: { status: "ok" },
|
|
};
|
|
|
|
const event = new CustomEvent("call.responded:uuid-123", {
|
|
detail: envelope,
|
|
}) as TestEvent;
|
|
|
|
eventTarget.dispatchEvent(event);
|
|
|
|
expect(worker.postMessage).toHaveBeenCalledTimes(1);
|
|
expect(worker.postMessage).toHaveBeenCalledWith(envelope);
|
|
});
|
|
|
|
it("returns true from dispatchEvent", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const event = new CustomEvent("test:event", {
|
|
detail: { type: "test", id: "event", payload: null },
|
|
}) as TestEvent;
|
|
|
|
const result = eventTarget.dispatchEvent(event);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("posts envelope with null payload", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const event = new CustomEvent("notify:1", {
|
|
detail: { type: "notify", id: "1", payload: null },
|
|
}) as TestEvent;
|
|
|
|
eventTarget.dispatchEvent(event);
|
|
|
|
expect(worker.posted[0]).toEqual({ type: "notify", id: "1", payload: null });
|
|
});
|
|
|
|
it("posts multiple events in order", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const event1 = new CustomEvent("a:1", { detail: { type: "a", id: "1", payload: "first" } }) as TestEvent;
|
|
const event2 = new CustomEvent("b:2", { detail: { type: "b", id: "2", payload: "second" } }) as TestEvent;
|
|
|
|
eventTarget.dispatchEvent(event1);
|
|
eventTarget.dispatchEvent(event2);
|
|
|
|
expect(worker.posted).toHaveLength(2);
|
|
expect(worker.posted[0]).toEqual({ type: "a", id: "1", payload: "first" });
|
|
expect(worker.posted[1]).toEqual({ type: "b", id: "2", payload: "second" });
|
|
});
|
|
});
|
|
|
|
describe("addEventListener (subscribe path)", () => {
|
|
it("registers a listener for a topic", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("call.responded:uuid-123", listener);
|
|
|
|
const envelope: EventEnvelope = { type: "call.responded", id: "uuid-123", payload: "hello" };
|
|
worker.simulateMessage(envelope);
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does nothing when callback is null", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
eventTarget.addEventListener("topic:a", null as any);
|
|
|
|
const envelope: EventEnvelope = { type: "topic", id: "a", payload: null };
|
|
worker.simulateMessage(envelope);
|
|
});
|
|
|
|
it("supports EventListenerObject with handleEvent", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const handleEvent = vi.fn();
|
|
const listenerObject = { handleEvent };
|
|
|
|
eventTarget.addEventListener("obj:test", listenerObject);
|
|
|
|
const envelope: EventEnvelope = { type: "obj", id: "test", payload: null };
|
|
worker.simulateMessage(envelope);
|
|
|
|
expect(handleEvent).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("supports multiple listeners on the same topic", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const listener1 = vi.fn();
|
|
const listener2 = vi.fn();
|
|
eventTarget.addEventListener("topic:x", listener1);
|
|
eventTarget.addEventListener("topic:x", listener2);
|
|
|
|
const envelope: EventEnvelope = { type: "topic", id: "x", payload: "data" };
|
|
worker.simulateMessage(envelope);
|
|
|
|
expect(listener1).toHaveBeenCalledTimes(1);
|
|
expect(listener2).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("removeEventListener (unsubscribe path)", () => {
|
|
it("removes a listener so it no longer receives events", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
eventTarget.removeEventListener("topic:a", listener);
|
|
|
|
const envelope: EventEnvelope = { type: "topic", id: "a", payload: null };
|
|
worker.simulateMessage(envelope);
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("removes only the specified listener, keeping others", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const listener1 = vi.fn();
|
|
const listener2 = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener1);
|
|
eventTarget.addEventListener("topic:a", listener2);
|
|
|
|
eventTarget.removeEventListener("topic:a", listener1);
|
|
|
|
const envelope: EventEnvelope = { type: "topic", id: "a", payload: "data" };
|
|
worker.simulateMessage(envelope);
|
|
|
|
expect(listener1).not.toHaveBeenCalled();
|
|
expect(listener2).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("supports EventListenerObject removal", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const handleEvent = vi.fn();
|
|
const listenerObject = { handleEvent };
|
|
|
|
eventTarget.addEventListener("obj:test", listenerObject);
|
|
eventTarget.removeEventListener("obj:test", listenerObject);
|
|
|
|
const envelope: EventEnvelope = { type: "obj", id: "test", payload: null };
|
|
worker.simulateMessage(envelope);
|
|
|
|
expect(handleEvent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does nothing when removing a callback that was never registered", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const unregisteredListener = vi.fn();
|
|
eventTarget.removeEventListener("topic:a", unregisteredListener);
|
|
|
|
expect(() => eventTarget.removeEventListener("topic:a", unregisteredListener)).not.toThrow();
|
|
});
|
|
|
|
it("does nothing when callback is null", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
eventTarget.removeEventListener("topic:a", null as any);
|
|
});
|
|
});
|
|
|
|
describe("receive path (worker.onmessage)", () => {
|
|
it("parses envelope from event.data, creates CustomEvent with type:id topic, and dispatches to listeners", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("message.sent:msg1", listener);
|
|
|
|
const envelope: EventEnvelope = { type: "message.sent", id: "msg1", payload: "hello world" };
|
|
worker.simulateMessage(envelope);
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
const receivedEvent = listener.mock.calls[0][0] as TestEvent;
|
|
expect(receivedEvent.type).toBe("message.sent:msg1");
|
|
expect(receivedEvent.detail).toEqual(envelope);
|
|
});
|
|
|
|
it("delivers messages to all listeners on the same topic", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const listener1 = vi.fn();
|
|
const listener2 = vi.fn();
|
|
eventTarget.addEventListener("topic:x", listener1);
|
|
eventTarget.addEventListener("topic:x", listener2);
|
|
|
|
const envelope: EventEnvelope = { type: "topic", id: "x", payload: "data" };
|
|
worker.simulateMessage(envelope);
|
|
|
|
expect(listener1).toHaveBeenCalledTimes(1);
|
|
expect(listener2).toHaveBeenCalledTimes(1);
|
|
expect((listener1.mock.calls[0][0] as TestEvent).detail).toEqual(envelope);
|
|
expect((listener2.mock.calls[0][0] as TestEvent).detail).toEqual(envelope);
|
|
});
|
|
|
|
it("ignores messages for topics with no registered listeners", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
const envelope: EventEnvelope = { type: "other", id: "b", payload: null };
|
|
worker.simulateMessage(envelope);
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("malformed message handling", () => {
|
|
it("ignores messages where envelope.type is not a string", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
worker.simulateMessage({ type: 123, id: "a", payload: null });
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores messages where envelope is null", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
worker.simulateMessage(null);
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores messages where envelope is undefined", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
worker.simulateMessage(undefined);
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores messages where envelope.type starts with __", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("__custom:thing", listener);
|
|
|
|
worker.simulateMessage({ type: "__custom", id: "thing", payload: null });
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("topic scoping", () => {
|
|
it("forms topic from envelope type and id fields", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("user.action:abc-123", listener);
|
|
|
|
const envelope: EventEnvelope = { type: "user.action", id: "abc-123", payload: { done: true } };
|
|
worker.simulateMessage(envelope);
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
expect((listener.mock.calls[0][0] as TestEvent).type).toBe("user.action:abc-123");
|
|
});
|
|
|
|
it("does not match when type differs even if id is the same", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("user.created:id1", listener);
|
|
|
|
worker.simulateMessage({ type: "user.deleted", id: "id1", payload: null });
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not match when id differs even if type is the same", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("event:alpha", listener);
|
|
|
|
worker.simulateMessage({ type: "event", id: "beta", payload: null });
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("EventEnvelope round-trip", () => {
|
|
it("round-trips full { type, id, payload } envelope through dispatch and receive", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("user.joined:user-99", listener);
|
|
|
|
const originalEnvelope: EventEnvelope<"user.joined", { name: string; role: string }> = {
|
|
type: "user.joined",
|
|
id: "user-99",
|
|
payload: { name: "Bob", role: "admin" },
|
|
};
|
|
|
|
const event = new CustomEvent("user.joined:user-99", {
|
|
detail: originalEnvelope,
|
|
}) as TestEvent;
|
|
|
|
eventTarget.dispatchEvent(event);
|
|
|
|
const dispatchedData = worker.posted[0];
|
|
expect(dispatchedData).toEqual(originalEnvelope);
|
|
|
|
worker.simulateMessage(dispatchedData);
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
const receivedDetail = (listener.mock.calls[0][0] as TestEvent).detail as EventEnvelope;
|
|
expect(receivedDetail).toEqual(originalEnvelope);
|
|
expect(receivedDetail.type).toBe("user.joined");
|
|
expect(receivedDetail.id).toBe("user-99");
|
|
expect(receivedDetail.payload).toEqual({ name: "Bob", role: "admin" });
|
|
});
|
|
|
|
it("round-trips envelope with null payload", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("ping:1", listener);
|
|
|
|
const envelope: EventEnvelope = { type: "ping", id: "1", payload: null };
|
|
worker.simulateMessage(envelope);
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
const receivedDetail = (listener.mock.calls[0][0] as TestEvent).detail as EventEnvelope;
|
|
expect(receivedDetail).toEqual(envelope);
|
|
});
|
|
});
|
|
|
|
describe("worker.onerror", () => {
|
|
it("does not propagate worker errors to event target listeners", () => {
|
|
const worker = createMockWorker();
|
|
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
if (worker.onerror) {
|
|
worker.onerror(new ErrorEvent("error", { message: "Worker failed" }));
|
|
}
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("createWorkerThreadEventTarget", () => {
|
|
let originalGlobalThis: typeof globalThis;
|
|
let mockGlobal: ReturnType<typeof createMockGlobalThis>;
|
|
|
|
beforeEach(() => {
|
|
originalGlobalThis = globalThis;
|
|
mockGlobal = createMockGlobalThis();
|
|
});
|
|
|
|
function createThreadEventTargetWithMock() {
|
|
const callbacksForTopic = new Map<string, Set<EventListener>>();
|
|
|
|
mockGlobal.onmessage = (event: MessageEvent) => {
|
|
const envelope = event.data as EventEnvelope;
|
|
if (typeof envelope?.type !== "string" || envelope.type.startsWith("__")) {
|
|
return;
|
|
}
|
|
|
|
const topic = `${envelope.type}:${envelope.id}`;
|
|
const callbacks = callbacksForTopic.get(topic);
|
|
if (callbacks === undefined) {
|
|
return;
|
|
}
|
|
|
|
const customEvent = new CustomEvent(topic, {
|
|
detail: envelope,
|
|
}) as TestEvent;
|
|
|
|
for (const callback of callbacks) {
|
|
callback(customEvent);
|
|
}
|
|
};
|
|
|
|
function addCallback(topic: string, callback: EventListener) {
|
|
let callbacks = callbacksForTopic.get(topic);
|
|
if (callbacks === undefined) {
|
|
callbacks = new Set();
|
|
callbacksForTopic.set(topic, callbacks);
|
|
}
|
|
callbacks.add(callback);
|
|
}
|
|
|
|
function removeCallback(topic: string, callback: EventListener) {
|
|
const callbacks = callbacksForTopic.get(topic);
|
|
if (callbacks === undefined) {
|
|
return;
|
|
}
|
|
callbacks.delete(callback);
|
|
if (callbacks.size === 0) {
|
|
callbacksForTopic.delete(topic);
|
|
}
|
|
}
|
|
|
|
return {
|
|
eventTarget: {
|
|
addEventListener(topic: string, callbackOrOptions: EventListenerOrEventListenerObject) {
|
|
if (callbackOrOptions != null) {
|
|
const callback =
|
|
"handleEvent" in callbackOrOptions ? callbackOrOptions.handleEvent : callbackOrOptions;
|
|
addCallback(topic, callback);
|
|
}
|
|
},
|
|
dispatchEvent(event: TestEvent) {
|
|
mockGlobal.postMessage(event.detail);
|
|
return true;
|
|
},
|
|
removeEventListener(topic: string, callbackOrOptions: EventListenerOrEventListenerObject) {
|
|
if (callbackOrOptions != null) {
|
|
const callback =
|
|
"handleEvent" in callbackOrOptions ? callbackOrOptions.handleEvent : callbackOrOptions;
|
|
removeCallback(topic, callback);
|
|
}
|
|
},
|
|
} as TypedEventTarget<TestEvent>,
|
|
callbacksForTopic,
|
|
};
|
|
}
|
|
|
|
describe("dispatchEvent (send path)", () => {
|
|
it("posts event.detail via globalThis.postMessage", () => {
|
|
const { eventTarget } = createThreadEventTargetWithMock();
|
|
|
|
const envelope: EventEnvelope<"call.responded", { status: string }> = {
|
|
type: "call.responded",
|
|
id: "uuid-123",
|
|
payload: { status: "ok" },
|
|
};
|
|
|
|
const event = new CustomEvent("call.responded:uuid-123", {
|
|
detail: envelope,
|
|
}) as TestEvent;
|
|
|
|
eventTarget.dispatchEvent(event);
|
|
|
|
expect(mockGlobal.postMessage).toHaveBeenCalledTimes(1);
|
|
expect(mockGlobal.postMessage).toHaveBeenCalledWith(envelope);
|
|
});
|
|
|
|
it("returns true from dispatchEvent", () => {
|
|
const { eventTarget } = createThreadEventTargetWithMock();
|
|
|
|
const event = new CustomEvent("test:event", {
|
|
detail: { type: "test", id: "event", payload: null },
|
|
}) as TestEvent;
|
|
|
|
const result = eventTarget.dispatchEvent(event);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("posts envelope with null payload", () => {
|
|
const { eventTarget } = createThreadEventTargetWithMock();
|
|
|
|
const event = new CustomEvent("notify:1", {
|
|
detail: { type: "notify", id: "1", payload: null },
|
|
}) as TestEvent;
|
|
|
|
eventTarget.dispatchEvent(event);
|
|
|
|
expect(mockGlobal.posted[0]).toEqual({ type: "notify", id: "1", payload: null });
|
|
});
|
|
});
|
|
|
|
describe("receive path (globalThis.onmessage)", () => {
|
|
it("parses envelope from event.data, creates CustomEvent with type:id topic, and dispatches to listeners", () => {
|
|
const { eventTarget } = createThreadEventTargetWithMock();
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("message.sent:msg1", listener);
|
|
|
|
const envelope: EventEnvelope = { type: "message.sent", id: "msg1", payload: "hello" };
|
|
mockGlobal.simulateMessage(envelope);
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
const receivedEvent = listener.mock.calls[0][0] as TestEvent;
|
|
expect(receivedEvent.type).toBe("message.sent:msg1");
|
|
expect(receivedEvent.detail).toEqual(envelope);
|
|
});
|
|
|
|
it("delivers messages to all listeners on the same topic", () => {
|
|
const { eventTarget } = createThreadEventTargetWithMock();
|
|
|
|
const listener1 = vi.fn();
|
|
const listener2 = vi.fn();
|
|
eventTarget.addEventListener("topic:x", listener1);
|
|
eventTarget.addEventListener("topic:x", listener2);
|
|
|
|
const envelope: EventEnvelope = { type: "topic", id: "x", payload: "data" };
|
|
mockGlobal.simulateMessage(envelope);
|
|
|
|
expect(listener1).toHaveBeenCalledTimes(1);
|
|
expect(listener2).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("ignores messages for topics with no registered listeners", () => {
|
|
const { eventTarget } = createThreadEventTargetWithMock();
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
const envelope: EventEnvelope = { type: "other", id: "b", payload: null };
|
|
mockGlobal.simulateMessage(envelope);
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("addEventListener", () => {
|
|
it("supports EventListenerObject with handleEvent", () => {
|
|
const { eventTarget } = createThreadEventTargetWithMock();
|
|
|
|
const handleEvent = vi.fn();
|
|
const listenerObject = { handleEvent };
|
|
|
|
eventTarget.addEventListener("obj:test", listenerObject);
|
|
|
|
const envelope: EventEnvelope = { type: "obj", id: "test", payload: null };
|
|
mockGlobal.simulateMessage(envelope);
|
|
|
|
expect(handleEvent).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("removeEventListener", () => {
|
|
it("removes a listener so it no longer receives events", () => {
|
|
const { eventTarget } = createThreadEventTargetWithMock();
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
eventTarget.removeEventListener("topic:a", listener);
|
|
|
|
const envelope: EventEnvelope = { type: "topic", id: "a", payload: null };
|
|
mockGlobal.simulateMessage(envelope);
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("supports EventListenerObject removal", () => {
|
|
const { eventTarget } = createThreadEventTargetWithMock();
|
|
|
|
const handleEvent = vi.fn();
|
|
const listenerObject = { handleEvent };
|
|
|
|
eventTarget.addEventListener("obj:test", listenerObject);
|
|
eventTarget.removeEventListener("obj:test", listenerObject);
|
|
|
|
const envelope: EventEnvelope = { type: "obj", id: "test", payload: null };
|
|
mockGlobal.simulateMessage(envelope);
|
|
|
|
expect(handleEvent).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("malformed message handling", () => {
|
|
it("ignores messages where envelope.type is not a string", () => {
|
|
const { eventTarget } = createThreadEventTargetWithMock();
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
mockGlobal.simulateMessage({ type: 123, id: "a", payload: null });
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores messages where envelope is null", () => {
|
|
const { eventTarget } = createThreadEventTargetWithMock();
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
mockGlobal.simulateMessage(null);
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores messages where envelope.type starts with __", () => {
|
|
const { eventTarget } = createThreadEventTargetWithMock();
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("__custom:thing", listener);
|
|
|
|
mockGlobal.simulateMessage({ type: "__custom", id: "thing", payload: null });
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("EventEnvelope round-trip", () => {
|
|
it("round-trips full envelope through dispatch and receive", () => {
|
|
const { eventTarget } = createThreadEventTargetWithMock();
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("user.joined:user-99", listener);
|
|
|
|
const originalEnvelope: EventEnvelope<"user.joined", { name: string; role: string }> = {
|
|
type: "user.joined",
|
|
id: "user-99",
|
|
payload: { name: "Bob", role: "admin" },
|
|
};
|
|
|
|
const event = new CustomEvent("user.joined:user-99", {
|
|
detail: originalEnvelope,
|
|
}) as TestEvent;
|
|
|
|
eventTarget.dispatchEvent(event);
|
|
|
|
const dispatchedData = mockGlobal.posted[0];
|
|
expect(dispatchedData).toEqual(originalEnvelope);
|
|
|
|
mockGlobal.simulateMessage(dispatchedData);
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
const receivedDetail = (listener.mock.calls[0][0] as TestEvent).detail as EventEnvelope;
|
|
expect(receivedDetail).toEqual(originalEnvelope);
|
|
});
|
|
});
|
|
|
|
describe("topic scoping", () => {
|
|
it("forms topic from envelope type and id fields", () => {
|
|
const { eventTarget } = createThreadEventTargetWithMock();
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("user.action:abc-123", listener);
|
|
|
|
const envelope: EventEnvelope = { type: "user.action", id: "abc-123", payload: { done: true } };
|
|
mockGlobal.simulateMessage(envelope);
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
expect((listener.mock.calls[0][0] as TestEvent).type).toBe("user.action:abc-123");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("bidirectional communication (host + thread)", () => {
|
|
it("host sends envelope that thread receives", () => {
|
|
const worker = createMockWorker();
|
|
const hostTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
let threadOnmessage: ((event: { data: unknown }) => void) | null = null;
|
|
const threadCallbacks = new Map<string, Set<EventListener>>();
|
|
const threadTarget = {
|
|
addEventListener(topic: string, callbackOrOptions: EventListenerOrEventListenerObject) {
|
|
if (callbackOrOptions != null) {
|
|
const callback =
|
|
"handleEvent" in callbackOrOptions ? callbackOrOptions.handleEvent : callbackOrOptions;
|
|
let callbacks = threadCallbacks.get(topic);
|
|
if (callbacks === undefined) {
|
|
callbacks = new Set();
|
|
threadCallbacks.set(topic, callbacks);
|
|
}
|
|
callbacks.add(callback);
|
|
}
|
|
},
|
|
dispatchEvent(event: TestEvent) {
|
|
return true;
|
|
},
|
|
removeEventListener(topic: string, callbackOrOptions: EventListenerOrEventListenerObject) {
|
|
if (callbackOrOptions != null) {
|
|
const callback =
|
|
"handleEvent" in callbackOrOptions ? callbackOrOptions.handleEvent : callbackOrOptions;
|
|
const callbacks = threadCallbacks.get(topic);
|
|
if (callbacks) {
|
|
callbacks.delete(callback);
|
|
if (callbacks.size === 0) {
|
|
threadCallbacks.delete(topic);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
} as TypedEventTarget<TestEvent>;
|
|
|
|
threadOnmessage = (event: { data: unknown }) => {
|
|
const envelope = event.data as EventEnvelope;
|
|
if (typeof envelope?.type !== "string" || envelope.type.startsWith("__")) {
|
|
return;
|
|
}
|
|
const topic = `${envelope.type}:${envelope.id}`;
|
|
const callbacks = threadCallbacks.get(topic);
|
|
if (callbacks === undefined) return;
|
|
const customEvent = new CustomEvent(topic, { detail: envelope }) as TestEvent;
|
|
for (const callback of callbacks) {
|
|
callback(customEvent);
|
|
}
|
|
};
|
|
|
|
const threadListener = vi.fn();
|
|
threadTarget.addEventListener("task.assigned:task-1", threadListener);
|
|
|
|
const envelope: EventEnvelope = { type: "task.assigned", id: "task-1", payload: { work: "compute" } };
|
|
const hostEvent = new CustomEvent("task.assigned:task-1", { detail: envelope }) as TestEvent;
|
|
|
|
hostTarget.dispatchEvent(hostEvent);
|
|
expect(worker.postMessage).toHaveBeenCalledWith(envelope);
|
|
|
|
const postedEnvelope = worker.posted[0] as EventEnvelope;
|
|
threadOnmessage!({ data: postedEnvelope });
|
|
|
|
expect(threadListener).toHaveBeenCalledTimes(1);
|
|
const receivedDetail = (threadListener.mock.calls[0][0] as TestEvent).detail as EventEnvelope;
|
|
expect(receivedDetail).toEqual(envelope);
|
|
});
|
|
|
|
it("thread sends envelope that host receives", () => {
|
|
const worker = createMockWorker();
|
|
const hostTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
|
|
|
|
const hostListener = vi.fn();
|
|
hostTarget.addEventListener("result.ready:res-1", hostListener);
|
|
|
|
const envelope: EventEnvelope = { type: "result.ready", id: "res-1", payload: { output: 42 } };
|
|
worker.simulateMessage(envelope);
|
|
|
|
expect(hostListener).toHaveBeenCalledTimes(1);
|
|
const receivedDetail = (hostListener.mock.calls[0][0] as TestEvent).detail as EventEnvelope;
|
|
expect(receivedDetail).toEqual(envelope);
|
|
});
|
|
}); |