diff --git a/apps/web/src/state/desktopNetworkAccess.test.ts b/apps/web/src/state/desktopNetworkAccess.test.ts index 7af13cbbcfce198132c4f60a4fdb572febea6caa..0dde5f7d7dc8dd5c49608fb73179534f6e250c25 100644 --- a/apps/web/src/state/desktopNetworkAccess.test.ts +++ b/apps/web/src/state/desktopNetworkAccess.test.ts @@ -1,4 +1,5 @@ import type { AdvertisedEndpoint, DesktopServerExposureState } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { AtomRegistry } from "effect/unstable/reactivity"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -14,6 +15,8 @@ const serverExposureState: DesktopServerExposureState = { }; const advertisedEndpoints: ReadonlyArray = []; +const serverExposureLoadCause = new Error("exposure failed"); +const advertisedEndpointsLoadCause = new Error("endpoints failed"); describe("desktopNetworkAccessState", () => { it("retains the loaded snapshot when the settings screen remounts", async () => { @@ -47,4 +50,38 @@ describe("desktopNetworkAccessState", () => { remount(); registry.dispose(); }); + + it.each([ + { + cause: serverExposureLoadCause, + expectedTag: "DesktopServerExposureStateLoadError", + getAdvertisedEndpoints: async () => advertisedEndpoints, + getServerExposureState: async () => Promise.reject(serverExposureLoadCause), + }, + { + cause: advertisedEndpointsLoadCause, + expectedTag: "DesktopAdvertisedEndpointsLoadError", + getAdvertisedEndpoints: async () => Promise.reject(advertisedEndpointsLoadCause), + getServerExposureState: async () => serverExposureState, + }, + ])("retains the $expectedTag cause", async (testCase) => { + const atom = createDesktopNetworkAccessStateAtom(() => ({ + getAdvertisedEndpoints: testCase.getAdvertisedEndpoints, + getServerExposureState: testCase.getServerExposureState, + })); + const registry = AtomRegistry.make(); + registry.mount(atom); + + await vi.waitFor(() => expect(AsyncResult.isFailure(registry.get(atom))).toBe(true)); + const result = registry.get(atom); + if (!AsyncResult.isFailure(result)) throw new Error("Expected network access load to fail."); + + expect(Cause.squash(result.cause)).toEqual( + expect.objectContaining({ + _tag: testCase.expectedTag, + cause: testCase.cause, + }), + ); + registry.dispose(); + }); }); diff --git a/apps/web/src/state/desktopNetworkAccess.ts b/apps/web/src/state/desktopNetworkAccess.ts index 150a256bc6880225b7fb72299c81343159e681e3..07580fcd1645b49ddc6d27643f015a6d2cbf7717 100644 --- a/apps/web/src/state/desktopNetworkAccess.ts +++ b/apps/web/src/state/desktopNetworkAccess.ts @@ -21,13 +21,32 @@ export interface DesktopNetworkAccessSnapshot { readonly serverExposureState: DesktopServerExposureState; } -class DesktopNetworkAccessError extends Schema.TaggedErrorClass()( - "DesktopNetworkAccessError", - { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) {} +class DesktopNetworkAccessUnavailableError extends Schema.TaggedErrorClass()( + "DesktopNetworkAccessUnavailableError", + {}, +) { + override get message(): string { + return "Desktop network access is unavailable."; + } +} + +class DesktopServerExposureStateLoadError extends Schema.TaggedErrorClass()( + "DesktopServerExposureStateLoadError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to load desktop server exposure state."; + } +} + +class DesktopAdvertisedEndpointsLoadError extends Schema.TaggedErrorClass()( + "DesktopAdvertisedEndpointsLoadError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to load advertised desktop endpoints."; + } +} function getDesktopNetworkAccessBridge(): DesktopNetworkAccessBridge | undefined { return typeof window === "undefined" ? undefined : window.desktopBridge; @@ -39,25 +58,25 @@ export function createDesktopNetworkAccessStateAtom( const loadDesktopNetworkAccess = Effect.fn("loadDesktopNetworkAccess")(function* () { const bridge = getBridge(); if (!bridge) { - return yield* new DesktopNetworkAccessError({ - message: "Desktop network access is unavailable.", - }); + return yield* new DesktopNetworkAccessUnavailableError(); } - return yield* Effect.tryPromise({ - try: async (): Promise => { - const [serverExposureState, advertisedEndpoints] = await Promise.all([ - bridge.getServerExposureState(), - bridge.getAdvertisedEndpoints(), - ]); - return { advertisedEndpoints, serverExposureState }; - }, - catch: (cause) => - new DesktopNetworkAccessError({ - message: - cause instanceof Error ? cause.message : "Failed to load desktop network access.", - cause, + const [serverExposureState, advertisedEndpoints] = yield* Effect.all( + [ + Effect.tryPromise({ + try: () => bridge.getServerExposureState(), + catch: (cause) => new DesktopServerExposureStateLoadError({ cause }), }), - }); + Effect.tryPromise({ + try: () => bridge.getAdvertisedEndpoints(), + catch: (cause) => new DesktopAdvertisedEndpointsLoadError({ cause }), + }), + ], + { concurrency: "unbounded" }, + ); + return { + advertisedEndpoints, + serverExposureState, + } satisfies DesktopNetworkAccessSnapshot; }); return Atom.make(loadDesktopNetworkAccess()).pipe( diff --git a/apps/web/src/state/desktopSshHosts.test.ts b/apps/web/src/state/desktopSshHosts.test.ts index 571704a95f57076166cf45b1f99c1497c38c7822..83eda60158c247302d8821211955e2ce012f9116 100644 --- a/apps/web/src/state/desktopSshHosts.test.ts +++ b/apps/web/src/state/desktopSshHosts.test.ts @@ -1,4 +1,5 @@ import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import { AtomRegistry } from "effect/unstable/reactivity"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -38,4 +39,25 @@ describe("desktopSshHostsState", () => { remount(); registry.dispose(); }); + + it("retains the desktop bridge failure as the discovery error cause", async () => { + const cause = new Error("ssh config unavailable"); + const atom = createDesktopSshHostsStateAtom(() => ({ + discoverSshHosts: async () => Promise.reject(cause), + })); + const registry = AtomRegistry.make(); + registry.mount(atom); + + await vi.waitFor(() => expect(AsyncResult.isFailure(registry.get(atom))).toBe(true)); + const result = registry.get(atom); + if (!AsyncResult.isFailure(result)) throw new Error("Expected SSH host discovery to fail."); + + expect(Cause.squash(result.cause)).toEqual( + expect.objectContaining({ + _tag: "DesktopSshDiscoveryError", + cause, + }), + ); + registry.dispose(); + }); }); diff --git a/apps/web/src/state/desktopSshHosts.ts b/apps/web/src/state/desktopSshHosts.ts index 47b2c87e97c51665184d6207a1b29b7c9246c21a..8e4022cbecf808769540623bbf0d3b7f14f97714 100644 --- a/apps/web/src/state/desktopSshHosts.ts +++ b/apps/web/src/state/desktopSshHosts.ts @@ -5,13 +5,23 @@ import { Atom } from "effect/unstable/reactivity"; type DesktopSshDiscoveryBridge = Pick; +class DesktopSshDiscoveryUnavailableError extends Schema.TaggedErrorClass()( + "DesktopSshDiscoveryUnavailableError", + {}, +) { + override get message(): string { + return "Desktop SSH host discovery is unavailable."; + } +} + class DesktopSshDiscoveryError extends Schema.TaggedErrorClass()( "DesktopSshDiscoveryError", - { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) {} + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to discover SSH hosts."; + } +} function getDesktopSshDiscoveryBridge(): DesktopSshDiscoveryBridge | undefined { return typeof window === "undefined" ? undefined : window.desktopBridge; @@ -23,17 +33,11 @@ export function createDesktopSshHostsStateAtom( const discoverDesktopSshHosts = Effect.fn("discoverDesktopSshHosts")(function* () { const bridge = getBridge(); if (!bridge) { - return yield* new DesktopSshDiscoveryError({ - message: "Desktop SSH host discovery is unavailable.", - }); + return yield* new DesktopSshDiscoveryUnavailableError(); } return yield* Effect.tryPromise({ try: (): Promise> => bridge.discoverSshHosts(), - catch: (cause) => - new DesktopSshDiscoveryError({ - message: cause instanceof Error ? cause.message : "Failed to discover SSH hosts.", - cause, - }), + catch: (cause) => new DesktopSshDiscoveryError({ cause }), }); });