@evolu/web
Version:
Evolu for web
249 lines (248 loc) • 10.9 kB
JavaScript
import { expect, test, vi, beforeEach, afterEach } from "vitest";
import { createSharedWebWorker } from "../src/SharedWebWorker.js";
import { SimpleName, wait } from "@evolu/common";
// Mock BroadcastChannel
class MockBroadcastChannel {
onmessage = null;
name;
constructor(name) {
this.name = name;
}
postMessage = vi.fn();
close = vi.fn();
}
// Mock Web Locks API
const mockLocks = {
request: vi.fn(),
};
// Mock Web Worker
class MockWorker {
onmessage = null;
postMessage = vi.fn();
terminate = vi.fn();
}
beforeEach(() => {
// Create a spy for BroadcastChannel constructor
globalThis.BroadcastChannel = vi.fn().mockImplementation(function (name) {
return new MockBroadcastChannel(name);
});
globalThis.document = {}; // Simulate browser environment
// Mock navigator.locks properly - remove the globalThis.navigator assignment
Object.defineProperty(globalThis.navigator, "locks", {
value: mockLocks,
writable: true,
configurable: true,
});
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
test("createSharedWebWorker creates BroadcastChannel and requests lock", () => {
const mockCreateWorker = vi.fn(() => new MockWorker());
const sharedWorker = createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
// Should create BroadcastChannel with namespaced name
expect(globalThis.BroadcastChannel).toHaveBeenCalledWith("evolu-sharedwebworker-test-worker");
// Should request owner-ready immediately
const channelInstance = vi.mocked(globalThis.BroadcastChannel).mock.results[0]
.value;
expect(channelInstance.postMessage).toHaveBeenCalledWith({
type: "request-owner-ready",
});
// Should attempt to acquire Web Lock
expect(mockLocks.request).toHaveBeenCalledWith("evolu-sharedwebworker-test-worker", expect.any(Function));
// Should return worker interface
expect(sharedWorker).toHaveProperty("postMessage");
expect(sharedWorker).toHaveProperty("onMessage");
expect(typeof sharedWorker.postMessage).toBe("function");
expect(typeof sharedWorker.onMessage).toBe("function");
});
test("createSharedWebWorker returns no-op on server", () => {
// Simulate server environment
delete globalThis.document;
const mockCreateWorker = vi.fn();
const sharedWorker = createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
// Should not create BroadcastChannel or request locks
expect(globalThis.BroadcastChannel).not.toHaveBeenCalled();
expect(mockLocks.request).not.toHaveBeenCalled();
// Should return no-op worker
expect(sharedWorker.postMessage).toBeDefined();
expect(sharedWorker.onMessage).toBeDefined();
// Restore document for other tests
globalThis.document = {};
});
test("createSharedWebWorker queues messages when owner not ready", () => {
const mockCreateWorker = vi.fn(() => new MockWorker());
const sharedWorker = createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
const channelInstance = vi.mocked(globalThis.BroadcastChannel).mock.results[0]
.value;
// Send message before owner is ready
sharedWorker.postMessage({ type: "test-message" });
// Should not send to channel yet (owner not ready)
expect(channelInstance.postMessage).toHaveBeenCalledTimes(1); // Only request-owner-ready
// Simulate owner-ready message
const ownerReadyEvent = new MessageEvent("message", {
data: { type: "owner-ready" },
});
channelInstance.onmessage?.(ownerReadyEvent);
// Should now send the queued message
expect(channelInstance.postMessage).toHaveBeenCalledWith({
type: "to-worker",
message: { type: "test-message" },
});
});
test("createSharedWebWorker forwards messages when owner ready", () => {
const mockCreateWorker = vi.fn(() => new MockWorker());
const sharedWorker = createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
const channelInstance = vi.mocked(globalThis.BroadcastChannel).mock.results[0]
.value;
// Simulate owner-ready message
const ownerReadyEvent = new MessageEvent("message", {
data: { type: "owner-ready" },
});
channelInstance.onmessage?.(ownerReadyEvent);
// Send message after owner is ready
sharedWorker.postMessage({ type: "test-message" });
// Should send directly to channel
expect(channelInstance.postMessage).toHaveBeenCalledWith({
type: "to-worker",
message: { type: "test-message" },
});
});
test("createSharedWebWorker handles onMessage callback", () => {
const mockCreateWorker = vi.fn(() => new MockWorker());
const onMessageCallback = vi.fn();
const sharedWorker = createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
sharedWorker.onMessage(onMessageCallback);
const channelInstance = vi.mocked(globalThis.BroadcastChannel).mock.results[0]
.value;
// Simulate message from worker
const workerMessage = new MessageEvent("message", {
data: { type: "from-worker", message: { result: "test" } },
});
channelInstance.onmessage?.(workerMessage);
// Should call the callback
expect(onMessageCallback).toHaveBeenCalledWith({ result: "test" });
});
test("createSharedWebWorker handles multiple tabs - first tab becomes owner", async () => {
let lockCallback;
// Mock locks.request to capture the callback but not execute it immediately
mockLocks.request.mockImplementation((_name, callback) => {
if (!lockCallback) {
lockCallback = callback;
// Simulate first tab acquiring the lock
setTimeout(() => {
void lockCallback?.();
}, 0);
}
return Promise.resolve();
});
const mockCreateWorker = vi.fn(() => new MockWorker());
// Create first tab (will become owner)
createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
// Create second tab
createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
// Both tabs should create BroadcastChannels
expect(globalThis.BroadcastChannel).toHaveBeenCalledTimes(2);
const tab1Channel = vi.mocked(globalThis.BroadcastChannel).mock.results[0]
.value;
const tab2Channel = vi.mocked(globalThis.BroadcastChannel).mock.results[1]
.value;
// Both tabs should request owner-ready
expect(tab1Channel.postMessage).toHaveBeenCalledWith({
type: "request-owner-ready",
});
expect(tab2Channel.postMessage).toHaveBeenCalledWith({
type: "request-owner-ready",
});
// Wait for lock acquisition
await wait("10ms")();
// Only first tab should create worker (it became owner)
expect(mockCreateWorker).toHaveBeenCalledTimes(1);
// First tab should announce ownership
expect(tab1Channel.postMessage).toHaveBeenCalledWith({ type: "owner-ready" });
});
test("createSharedWebWorker handles cross-tab message forwarding", () => {
const mockCreateWorker = vi.fn(() => new MockWorker());
// Create two tabs
const tab1 = createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
const tab2 = createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
const tab1Channel = vi.mocked(globalThis.BroadcastChannel).mock.results[0]
.value;
const tab2Channel = vi.mocked(globalThis.BroadcastChannel).mock.results[1]
.value;
// Simulate tab1 receiving owner-ready (tab1 becomes aware of owner)
const ownerReadyEvent = new MessageEvent("message", {
data: { type: "owner-ready" },
});
tab1Channel.onmessage?.(ownerReadyEvent);
// Simulate tab2 receiving owner-ready
tab2Channel.onmessage?.(ownerReadyEvent);
// Both tabs send messages
tab1.postMessage({ from: "tab1" });
tab2.postMessage({ from: "tab2" });
// Both should forward to channel
expect(tab1Channel.postMessage).toHaveBeenCalledWith({
type: "to-worker",
message: { from: "tab1" },
});
expect(tab2Channel.postMessage).toHaveBeenCalledWith({
type: "to-worker",
message: { from: "tab2" },
});
});
test("createSharedWebWorker handles worker responses across tabs", () => {
const mockCreateWorker = vi.fn(() => new MockWorker());
const tab1Callback = vi.fn();
const tab2Callback = vi.fn();
// Create two tabs
const tab1 = createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
const tab2 = createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
// Set up message callbacks
tab1.onMessage(tab1Callback);
tab2.onMessage(tab2Callback);
const tab1Channel = vi.mocked(globalThis.BroadcastChannel).mock.results[0]
.value;
const tab2Channel = vi.mocked(globalThis.BroadcastChannel).mock.results[1]
.value;
// Simulate worker response broadcast
const workerResponse = new MessageEvent("message", {
data: { type: "from-worker", message: { result: "shared-result" } },
});
// Both tabs receive the same worker response
tab1Channel.onmessage?.(workerResponse);
tab2Channel.onmessage?.(workerResponse);
// Both callbacks should be called with the same data
expect(tab1Callback).toHaveBeenCalledWith({ result: "shared-result" });
expect(tab2Callback).toHaveBeenCalledWith({ result: "shared-result" });
});
test("createSharedWebWorker multi-tab scenario", () => {
const mockCreateWorker = vi.fn(() => new MockWorker());
const channelInstances = [];
// Track all BroadcastChannel instances
globalThis.BroadcastChannel = vi.fn().mockImplementation(function (name) {
const instance = new MockBroadcastChannel(name);
channelInstances.push(instance);
// Override postMessage to broadcast to all instances with same name
instance.postMessage = vi.fn().mockImplementation((data) => {
channelInstances
.filter((ch) => ch.name === name)
.forEach((ch) => ch.onmessage?.(new MessageEvent("message", { data })));
});
return instance;
});
// Mock lock - only first caller gets the lock
let lockAcquired = false;
mockLocks.request.mockImplementation((_name, callback) => {
if (!lockAcquired) {
lockAcquired = true;
void callback(); // First tab becomes owner
}
return Promise.resolve();
});
// Create two tabs
createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
// Only first tab should create worker (owner)
expect(mockCreateWorker).toHaveBeenCalledTimes(1);
});