/** * Waiting for the repository rather than racing it. * * The defect: the player page asks for the repository *on mount*, but the * session is restored asynchronously at startup. Losing that race produced * "Not connected to a server" as a fatal playback error for a stream that was * perfectly fine. * * These test the waiting contract itself rather than the auth store's internals, * because the contract is the part the player depends on: resolve as soon as it * exists, still reject when it genuinely is not there, and never settle twice. * * TRACES: UR-002, UR-004 | DR-013 | UT-215 */ import { describe, expect, it, vi } from "vitest"; type Listener = () => void; /** * The shape `waitForRepository` is built on: a store you can subscribe to, and * a value that appears at some later point. Mirrors the real implementation * without dragging in Tauri. */ function makeWaiter() { let repository: object | null = null; const listeners = new Set(); const subscribe = (fn: Listener) => { listeners.add(fn); fn(); // stores fire synchronously on subscribe return () => listeners.delete(fn); }; const publish = (value: object | null) => { repository = value; listeners.forEach((fn) => fn()); }; async function waitForRepository(timeoutMs = 5000): Promise { if (repository) return repository; return new Promise((resolve, reject) => { let settled = false; const finish = (fn: () => void) => { if (settled) return; settled = true; clearTimeout(timer); unsubscribe(); fn(); }; const unsubscribe = subscribe(() => { if (repository) finish(() => resolve(repository as object)); }); const timer = setTimeout( () => finish(() => reject(new Error("Not connected to a server"))), timeoutMs, ); }); } return { waitForRepository, publish, listenerCount: () => listeners.size }; } describe("waitForRepository", () => { it("resolves immediately when the session is already restored", async () => { const w = makeWaiter(); const repo = {}; w.publish(repo); await expect(w.waitForRepository(50)).resolves.toBe(repo); }); it("resolves when the session arrives later — the race the player lost", async () => { const w = makeWaiter(); const repo = {}; const pending = w.waitForRepository(1000); // Nothing yet; the page has already mounted and asked. Published on a // microtask rather than a timer: the point is *ordering* (asked before it // arrived), and a wall-clock delay would make this a race under load. await Promise.resolve(); w.publish(repo); await expect(pending).resolves.toBe(repo); }); it("still rejects when there genuinely is no session", async () => { vi.useFakeTimers(); const w = makeWaiter(); const pending = w.waitForRepository(500); const assertion = expect(pending).rejects.toThrow("Not connected to a server"); await vi.advanceTimersByTimeAsync(600); await assertion; vi.useRealTimers(); }); it("unsubscribes once settled, so a later change cannot resolve it twice", async () => { const w = makeWaiter(); const repo = {}; const pending = w.waitForRepository(1000); expect(w.listenerCount()).toBe(1); w.publish(repo); await pending; expect(w.listenerCount()).toBe(0); // A further change must not throw or re-settle. expect(() => w.publish(null)).not.toThrow(); }); it("does not leave a pending timer that fires after success", async () => { vi.useFakeTimers(); const w = makeWaiter(); const repo = {}; const pending = w.waitForRepository(200); w.publish(repo); await expect(pending).resolves.toBe(repo); // If the timeout were still armed it would reject an already-settled // promise, which surfaces as an unhandled rejection rather than a failure. await vi.advanceTimersByTimeAsync(500); vi.useRealTimers(); }); });