diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 65c6f8edd3424..f444c64cb16ad 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -8,6 +8,7 @@ import { import { createBrowserHistory } from "@tanstack/react-router"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import { HttpClientError, HttpClientRequest } from "effect/http"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { installEnvironmentHttpTest } from "../test/environmentHttpTest"; @@ -165,6 +166,54 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]); }); + it("retries the desktop bootstrap while the backend is still unreachable", async () => { + const nextSession = sequence( + unauthenticatedSession(DESKTOP_AUTH), + authenticatedSession(DESKTOP_AUTH), + ); + const testApi = await installAuthApi({ + session: nextSession, + browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), + }); + // What FetchHttpClient yields when nothing listens on the backend port. + const connectionRefused = new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request: HttpClientRequest.get("http://localhost:3773/api/auth/session"), + cause: new TypeError("Failed to fetch"), + }), + }); + let unreachableAttempts = 1; + __setPrimaryHttpRunnerForTests((effect) => { + if (unreachableAttempts > 0) { + unreachableAttempts -= 1; + return Promise.reject(connectionRefused); + } + return testApi.run(effect); + }); + + const testWindow = installTestBrowser("http://localhost/"); + testWindow.desktopBridge = { + getLocalEnvironmentBootstraps: () => [ + { + id: "primary", + label: "Local", + httpBaseUrl: "http://localhost:3773", + wsBaseUrl: "ws://localhost:3773", + bootstrapToken: "desktop-bootstrap-token", + }, + ], + } as unknown as DesktopBridge; + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + vi.useFakeTimers({ toFake: ["setTimeout", "Date"] }); + await Promise.all([ + expect(resolveInitialServerAuthGateState()).resolves.toEqual({ status: "authenticated" }), + vi.advanceTimersByTimeAsync(1_000), + ]); + expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]); + }); + it("takes a pairing token from the location hash and strips it immediately", async () => { const testWindow = installTestBrowser("http://localhost/#token=pairing-token"); const { takePairingTokenFromUrl } = await import("./environments/primary"); diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index e9affcfbdd727..988cd3b04a873 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -291,7 +291,7 @@ function waitForBootstrapRetry(delayMs: number): Promise { function isTransientBootstrapError(error: unknown): boolean { if (isPrimaryEnvironmentRequestError(error)) { - return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status); + return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status) || isTransportError(error.cause); } if (error instanceof TypeError) { @@ -301,6 +301,10 @@ function isTransientBootstrapError(error: unknown): boolean { return error instanceof DOMException && error.name === "AbortError"; } +function isTransportError(error: unknown): boolean { + return HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError"; +} + async function bootstrapServerAuth(urlCredential: string | null): Promise { const currentSession = await fetchSessionState(); if (currentSession.authenticated && !urlCredential) { diff --git a/apps/web/test/environmentHttpTest.ts b/apps/web/test/environmentHttpTest.ts index 93843cff65c9d..50e6cc36fe95a 100644 --- a/apps/web/test/environmentHttpTest.ts +++ b/apps/web/test/environmentHttpTest.ts @@ -23,7 +23,7 @@ import { HttpApiTest } from "effect/http-api"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import { PrimaryEnvironmentHttpClient } from "../src/environments/primary/httpClient"; -import { __setPrimaryHttpRunnerForTests } from "../src/lib/runtime"; +import { __setPrimaryHttpRunnerForTests, type PrimaryHttpEffectRunner } from "../src/lib/runtime"; type BrowserSessionHandler = ( payload: AuthBrowserSessionRequest, @@ -125,10 +125,12 @@ export async function installEnvironmentHttpTest(scenario: EnvironmentHttpTestSc ); const runtime = ManagedRuntime.make(Layer.succeed(PrimaryEnvironmentHttpClient, client)); - __setPrimaryHttpRunnerForTests((effect) => runtime.runPromise(effect)); + const run: PrimaryHttpEffectRunner = (effect) => runtime.runPromise(effect); + __setPrimaryHttpRunnerForTests(run); return { calls, + run, async dispose() { __setPrimaryHttpRunnerForTests(); await runtime.dispose();