diff --git a/test/vitest/vitest.ui-isolated-paths.mjs b/test/vitest/vitest.ui-isolated-paths.mjs index b06df916b538..5727f47d48d5 100644 --- a/test/vitest/vitest.ui-isolated-paths.mjs +++ b/test/vitest/vitest.ui-isolated-paths.mjs @@ -2,6 +2,7 @@ // Tests in this list depend on module singletons or custom-element registration // matching the current registry, so they need a fresh graph in the isolated lane. export const uiIsolatedTestFiles = [ + "ui/src/app/app-host.server-prefs.test.ts", "ui/src/app/bootstrap.test.ts", "ui/src/app/router-outlet.test.ts", "ui/src/components/resizable-divider.test.ts", diff --git a/ui/src/app/gateway-readiness.test.ts b/ui/src/app/gateway-readiness.test.ts new file mode 100644 index 000000000000..3deebaf94276 --- /dev/null +++ b/ui/src/app/gateway-readiness.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import { waitForGatewayClient } from "./gateway-readiness.ts"; + +type Gateway = Parameters[0]; +type GatewaySnapshot = Gateway["snapshot"]; + +function createGateway(connected = false) { + const client = {} as GatewayBrowserClient; + let snapshot = { + phase: connected ? "connected" : "connecting", + client: connected ? client : null, + } as GatewaySnapshot; + const listeners = new Set<(next: GatewaySnapshot) => void>(); + const unsubscribe = vi.fn((listener: (next: GatewaySnapshot) => void) => { + listeners.delete(listener); + }); + const subscribe = vi.fn((listener: (next: GatewaySnapshot) => void) => { + listeners.add(listener); + return () => unsubscribe(listener); + }); + const gateway: Gateway = { + get snapshot() { + return snapshot; + }, + subscribe, + }; + return { + gateway, + client, + subscribe, + unsubscribe, + connect: () => { + snapshot = { ...snapshot, phase: "connected", client }; + for (const listener of Array.from(listeners)) { + listener(snapshot); + } + }, + }; +} + +describe("waitForGatewayClient", () => { + it("rejects an already-aborted signal before returning a connected client", async () => { + const { gateway, subscribe } = createGateway(true); + const cancellation = new Error("navigation canceled"); + const controller = new AbortController(); + controller.abort(cancellation); + + await expect(waitForGatewayClient(gateway, controller.signal)).rejects.toBe(cancellation); + expect(subscribe).not.toHaveBeenCalled(); + }); + + it("rejects an already-aborted signal before subscribing to a disconnected gateway", async () => { + const { gateway, subscribe } = createGateway(); + const controller = new AbortController(); + controller.abort(); + + await expect(waitForGatewayClient(gateway, controller.signal)).rejects.toBe( + controller.signal.reason, + ); + expect(subscribe).not.toHaveBeenCalled(); + }); + + it("keeps the existing AbortError contract for a non-error cancellation reason", async () => { + const { gateway } = createGateway(true); + const controller = new AbortController(); + controller.abort("navigation canceled"); + + await expect(waitForGatewayClient(gateway, controller.signal)).rejects.toMatchObject({ + name: "AbortError", + message: "Gateway wait aborted", + }); + }); + + it("returns a connected client without subscribing while its signal remains active", async () => { + const { gateway, client, subscribe } = createGateway(true); + + await expect(waitForGatewayClient(gateway, new AbortController().signal)).resolves.toBe(client); + expect(subscribe).not.toHaveBeenCalled(); + }); + + it("removes a pending gateway subscription when its signal is aborted", async () => { + const { gateway, unsubscribe } = createGateway(); + const cancellation = new Error("navigation canceled"); + const controller = new AbortController(); + const pending = waitForGatewayClient(gateway, controller.signal); + + controller.abort(cancellation); + + await expect(pending).rejects.toBe(cancellation); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + + it("releases a pending subscription once its gateway connects", async () => { + const { gateway, client, connect, unsubscribe } = createGateway(); + const controller = new AbortController(); + const pending = waitForGatewayClient(gateway, controller.signal); + + connect(); + + await expect(pending).resolves.toBe(client); + expect(unsubscribe).toHaveBeenCalledOnce(); + controller.abort(); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + + it("does not retain an abort listener after synchronous connection replay", async () => { + const client = {} as GatewayBrowserClient; + const snapshot = { phase: "connected", client } as GatewaySnapshot; + const unsubscribe = vi.fn(); + const gateway: Gateway = { + snapshot: { phase: "connecting", client: null } as GatewaySnapshot, + subscribe(listener) { + listener(snapshot); + return unsubscribe; + }, + }; + const controller = new AbortController(); + const addAbortListener = vi.spyOn(controller.signal, "addEventListener"); + const removeAbortListener = vi.spyOn(controller.signal, "removeEventListener"); + + await expect(waitForGatewayClient(gateway, controller.signal)).resolves.toBe(client); + + const abortListener = addAbortListener.mock.calls[0]?.[1]; + expect(abortListener).toBeTypeOf("function"); + expect(removeAbortListener).toHaveBeenCalledWith("abort", abortListener); + expect(unsubscribe).toHaveBeenCalledOnce(); + + controller.abort(new Error("late navigation cancellation")); + + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + + it("releases a subscription when it synchronously aborts during registration", async () => { + const cancellation = new Error("navigation canceled during registration"); + const controller = new AbortController(); + const unsubscribe = vi.fn(); + const gateway: Gateway = { + snapshot: { phase: "connecting", client: null } as GatewaySnapshot, + subscribe() { + controller.abort(cancellation); + return unsubscribe; + }, + }; + + await expect(waitForGatewayClient(gateway, controller.signal)).rejects.toBe(cancellation); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/src/app/gateway-readiness.ts b/ui/src/app/gateway-readiness.ts index bed6e65ecf5d..3ccdb9269451 100644 --- a/ui/src/app/gateway-readiness.ts +++ b/ui/src/app/gateway-readiness.ts @@ -12,12 +12,15 @@ export function waitForGatewayClient( signal: AbortSignal, ): Promise { const current = gateway.snapshot.client; + if (signal.aborted) { + return Promise.reject(abortError(signal)); + } if (current && gateway.snapshot.phase === "connected") { return Promise.resolve(current); } return new Promise((resolve, reject) => { let unsubscribe: () => void = () => undefined; - let settled = false; + let settled = false; // A synchronous replay must not retain its abort listener. const cleanup = () => { unsubscribe(); signal.removeEventListener("abort", onAbort); @@ -26,6 +29,7 @@ export function waitForGatewayClient( cleanup(); reject(abortError(signal)); }; + signal.addEventListener("abort", onAbort, { once: true }); unsubscribe = gateway.subscribe((snapshot) => { if (snapshot.phase === "connected" && snapshot.client) { settled = true; @@ -33,12 +37,8 @@ export function waitForGatewayClient( resolve(snapshot.client); } }); - if (settled) { + if (settled || signal.aborted) { unsubscribe(); } - signal.addEventListener("abort", onAbort, { once: true }); - if (signal.aborted) { - onAbort(); - } }); }