337 lines
10 KiB
TypeScript
337 lines
10 KiB
TypeScript
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
import type { PropsWithChildren } from "react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
getRequestToken: vi.fn(),
|
|
refreshAccessToken: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/connect", () => ({
|
|
getRequestToken: mocks.getRequestToken,
|
|
refreshAccessToken: mocks.refreshAccessToken,
|
|
}));
|
|
|
|
vi.mock("@/contexts/AuthContext", () => ({
|
|
useAuth: () => ({ currentUser: { name: "users/test" } }),
|
|
}));
|
|
|
|
vi.mock("@/hooks/useAttachmentQueries", () => ({
|
|
attachmentKeys: {
|
|
lists: () => ["attachments", "list"],
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/hooks/useMemoQueries", () => ({
|
|
memoKeys: {
|
|
all: ["memos"],
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/hooks/useSpaceQueries", () => ({
|
|
spaceKeys: {
|
|
all: ["spaces"],
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/hooks/useUserQueries", () => ({
|
|
userKeys: {
|
|
stats: () => ["users", "stats"],
|
|
notifications: () => ["users", "notifications"],
|
|
},
|
|
}));
|
|
|
|
import { useLiveMemoRefresh } from "@/hooks/useLiveMemoRefresh";
|
|
|
|
type MessageListener = (event: MessageEvent) => void;
|
|
|
|
class TestBroadcastChannel {
|
|
static channels = new Map<string, Set<TestBroadcastChannel>>();
|
|
|
|
readonly name: string;
|
|
private readonly listeners = new Set<MessageListener>();
|
|
|
|
constructor(name: string) {
|
|
this.name = name;
|
|
const peers = TestBroadcastChannel.channels.get(name) ?? new Set<TestBroadcastChannel>();
|
|
peers.add(this);
|
|
TestBroadcastChannel.channels.set(name, peers);
|
|
}
|
|
|
|
postMessage(data: unknown): void {
|
|
for (const peer of TestBroadcastChannel.channels.get(this.name) ?? []) {
|
|
if (peer === this) continue;
|
|
queueMicrotask(() => {
|
|
const event = new MessageEvent("message", { data });
|
|
for (const listener of peer.listeners) {
|
|
listener(event);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
addEventListener(type: string, listener: MessageListener): void {
|
|
if (type === "message") {
|
|
this.listeners.add(listener);
|
|
}
|
|
}
|
|
|
|
removeEventListener(type: string, listener: MessageListener): void {
|
|
if (type === "message") {
|
|
this.listeners.delete(listener);
|
|
}
|
|
}
|
|
|
|
close(): void {
|
|
TestBroadcastChannel.channels.get(this.name)?.delete(this);
|
|
this.listeners.clear();
|
|
}
|
|
}
|
|
|
|
interface PendingLock {
|
|
callback: (lock: Lock) => Promise<unknown> | unknown;
|
|
name: string;
|
|
onAbort: () => void;
|
|
reject: (reason: unknown) => void;
|
|
resolve: (value: unknown) => void;
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
class TestLockManager {
|
|
private held = false;
|
|
private readonly queue: PendingLock[] = [];
|
|
|
|
request(name: string, options: LockOptions, callback: (lock: Lock | null) => Promise<unknown> | unknown): Promise<unknown> {
|
|
return new Promise((resolve, reject) => {
|
|
const pending: PendingLock = {
|
|
callback: (lock) => callback(lock),
|
|
name,
|
|
onAbort: () => {
|
|
const index = this.queue.indexOf(pending);
|
|
if (index >= 0) {
|
|
this.queue.splice(index, 1);
|
|
reject(new DOMException("The lock request was aborted", "AbortError"));
|
|
}
|
|
},
|
|
reject,
|
|
resolve,
|
|
signal: options.signal,
|
|
};
|
|
options.signal?.addEventListener("abort", pending.onAbort, { once: true });
|
|
this.queue.push(pending);
|
|
this.drain();
|
|
});
|
|
}
|
|
|
|
private drain(): void {
|
|
if (this.held) return;
|
|
|
|
const pending = this.queue.shift();
|
|
if (!pending) return;
|
|
if (pending.signal?.aborted) {
|
|
pending.reject(new DOMException("The lock request was aborted", "AbortError"));
|
|
this.drain();
|
|
return;
|
|
}
|
|
|
|
this.held = true;
|
|
pending.signal?.removeEventListener("abort", pending.onAbort);
|
|
const lock = { mode: "exclusive", name: pending.name } as Lock;
|
|
void Promise.resolve(pending.callback(lock))
|
|
.then(pending.resolve, pending.reject)
|
|
.finally(() => {
|
|
this.held = false;
|
|
this.drain();
|
|
});
|
|
}
|
|
}
|
|
|
|
let visibilityState: DocumentVisibilityState;
|
|
|
|
function createQueryClient() {
|
|
return new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function createWrapper(queryClient: QueryClient) {
|
|
return function QueryWrapper({ children }: PropsWithChildren) {
|
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
|
};
|
|
}
|
|
|
|
function installBrowserState(lockManager?: TestLockManager) {
|
|
vi.stubGlobal("BroadcastChannel", TestBroadcastChannel);
|
|
Object.defineProperty(document, "visibilityState", {
|
|
configurable: true,
|
|
get: () => visibilityState,
|
|
});
|
|
Object.defineProperty(navigator, "onLine", {
|
|
configurable: true,
|
|
value: true,
|
|
});
|
|
Object.defineProperty(navigator, "locks", {
|
|
configurable: true,
|
|
value: lockManager,
|
|
});
|
|
}
|
|
|
|
async function flushAsyncWork() {
|
|
await act(async () => {
|
|
for (let index = 0; index < 5; index++) {
|
|
await Promise.resolve();
|
|
}
|
|
});
|
|
}
|
|
|
|
describe("useLiveMemoRefresh", () => {
|
|
beforeEach(() => {
|
|
visibilityState = "visible";
|
|
TestBroadcastChannel.channels.clear();
|
|
mocks.getRequestToken.mockResolvedValue("access-token");
|
|
mocks.refreshAccessToken.mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
Object.defineProperty(navigator, "locks", {
|
|
configurable: true,
|
|
value: undefined,
|
|
});
|
|
});
|
|
|
|
it("shares one SSE connection across tabs and broadcasts events to followers", async () => {
|
|
const lockManager = new TestLockManager();
|
|
installBrowserState(lockManager);
|
|
|
|
const streamControllers: ReadableStreamDefaultController<Uint8Array>[] = [];
|
|
const fetchMock = vi.fn().mockImplementation(
|
|
() =>
|
|
new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
streamControllers.push(controller);
|
|
},
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const firstClient = createQueryClient();
|
|
const secondClient = createQueryClient();
|
|
const firstInvalidate = vi.spyOn(firstClient, "invalidateQueries");
|
|
const secondInvalidate = vi.spyOn(secondClient, "invalidateQueries");
|
|
|
|
const first = renderHook(() => useLiveMemoRefresh(), { wrapper: createWrapper(firstClient) });
|
|
const second = renderHook(() => useLiveMemoRefresh(), { wrapper: createWrapper(secondClient) });
|
|
|
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
|
|
|
act(() => {
|
|
streamControllers[0].enqueue(new TextEncoder().encode('data: {"type":"memo.changed"}\n\n'));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(firstInvalidate).toHaveBeenCalledWith({ queryKey: ["memos"], refetchType: "active" });
|
|
expect(secondInvalidate).toHaveBeenCalledWith({ queryKey: ["memos"], refetchType: "active" });
|
|
expect(firstInvalidate).toHaveBeenCalledWith({ queryKey: ["users", "stats"], refetchType: "active" });
|
|
expect(secondInvalidate).toHaveBeenCalledWith({ queryKey: ["users", "stats"], refetchType: "active" });
|
|
expect(firstInvalidate).toHaveBeenCalledWith({ queryKey: ["attachments", "list"], refetchType: "active" });
|
|
expect(secondInvalidate).toHaveBeenCalledWith({ queryKey: ["attachments", "list"], refetchType: "active" });
|
|
});
|
|
expect(firstInvalidate).not.toHaveBeenCalledWith({ queryKey: ["spaces"], refetchType: "active" });
|
|
expect(secondInvalidate).not.toHaveBeenCalledWith({ queryKey: ["spaces"], refetchType: "active" });
|
|
|
|
firstInvalidate.mockClear();
|
|
secondInvalidate.mockClear();
|
|
act(() => {
|
|
streamControllers[0].enqueue(new TextEncoder().encode('data: {"type":"space.changed"}\n\n'));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
for (const invalidate of [firstInvalidate, secondInvalidate]) {
|
|
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["spaces"], refetchType: "active" });
|
|
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["memos"], refetchType: "active" });
|
|
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["users", "stats"], refetchType: "active" });
|
|
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["attachments", "list"], refetchType: "active" });
|
|
}
|
|
});
|
|
|
|
first.unmount();
|
|
second.unmount();
|
|
});
|
|
|
|
it("keeps the connection during the hidden grace period, then reconnects when visible", async () => {
|
|
vi.useFakeTimers();
|
|
installBrowserState(new TestLockManager());
|
|
|
|
const fetchMock = vi.fn().mockImplementation(
|
|
() =>
|
|
new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start() {},
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const hook = renderHook(() => useLiveMemoRefresh(), { wrapper: createWrapper(createQueryClient()) });
|
|
await flushAsyncWork();
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
const firstSignal = (fetchMock.mock.calls[0][1] as RequestInit).signal as AbortSignal;
|
|
visibilityState = "hidden";
|
|
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
|
|
|
act(() => vi.advanceTimersByTime(29_999));
|
|
expect(firstSignal.aborted).toBe(false);
|
|
|
|
act(() => vi.advanceTimersByTime(1));
|
|
expect(firstSignal.aborted).toBe(true);
|
|
|
|
visibilityState = "visible";
|
|
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
|
await flushAsyncWork();
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
|
|
hook.unmount();
|
|
});
|
|
|
|
it("keeps backing off when a successful response closes before becoming stable", async () => {
|
|
vi.useFakeTimers();
|
|
installBrowserState();
|
|
vi.spyOn(Math, "random").mockReturnValue(1);
|
|
|
|
const fetchMock = vi.fn().mockImplementation(() => new Response(new Uint8Array(), { status: 200 }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const queryClient = createQueryClient();
|
|
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
|
|
const hook = renderHook(() => useLiveMemoRefresh(), { wrapper: createWrapper(queryClient) });
|
|
await flushAsyncWork();
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => vi.advanceTimersByTimeAsync(1000));
|
|
await flushAsyncWork();
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["spaces"], refetchType: "active" });
|
|
|
|
await act(async () => vi.advanceTimersByTimeAsync(1000));
|
|
await flushAsyncWork();
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
|
|
await act(async () => vi.advanceTimersByTimeAsync(1000));
|
|
await flushAsyncWork();
|
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
|
|
hook.unmount();
|
|
});
|
|
});
|