| author | |
| committer | |
| log | 2c16edd0cb1169ce916e3da6a736d371954532a1 |
| tree | d67c128b671fa1815478b0a71ef36a8c600d62da |
| parent | 708bc7091dcdf5073a374464ffcbe45ba520b8fb |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>4 files changed, 119 insertions(+), 37 deletions(-)
apps/web/src/state/desktopNetworkAccess.test.ts+37| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import type { AdvertisedEndpoint, DesktopServerExposureState } from "@t3tools/contracts"; |
| 2 | import * as Cause from "effect/Cause"; | |
| 2 | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 3 | 4 | import { AtomRegistry } from "effect/unstable/reactivity"; |
| 4 | 5 | import { describe, expect, it, vi } from "vite-plus/test"; |
| ... | ... | @@ -14,6 +15,8 @@ const serverExposureState: DesktopServerExposureState = { |
| 14 | 15 | }; |
| 15 | 16 | |
| 16 | 17 | const advertisedEndpoints: ReadonlyArray<AdvertisedEndpoint> = []; |
| 18 | const serverExposureLoadCause = new Error("exposure failed"); | |
| 19 | const advertisedEndpointsLoadCause = new Error("endpoints failed"); | |
| 17 | 20 | |
| 18 | 21 | describe("desktopNetworkAccessState", () => { |
| 19 | 22 | it("retains the loaded snapshot when the settings screen remounts", async () => { |
| ... | ... | @@ -47,4 +50,38 @@ describe("desktopNetworkAccessState", () => { |
| 47 | 50 | remount(); |
| 48 | 51 | registry.dispose(); |
| 49 | 52 | }); |
| 53 | ||
| 54 | it.each([ | |
| 55 | { | |
| 56 | cause: serverExposureLoadCause, | |
| 57 | expectedTag: "DesktopServerExposureStateLoadError", | |
| 58 | getAdvertisedEndpoints: async () => advertisedEndpoints, | |
| 59 | getServerExposureState: async () => Promise.reject(serverExposureLoadCause), | |
| 60 | }, | |
| 61 | { | |
| 62 | cause: advertisedEndpointsLoadCause, | |
| 63 | expectedTag: "DesktopAdvertisedEndpointsLoadError", | |
| 64 | getAdvertisedEndpoints: async () => Promise.reject(advertisedEndpointsLoadCause), | |
| 65 | getServerExposureState: async () => serverExposureState, | |
| 66 | }, | |
| 67 | ])("retains the $expectedTag cause", async (testCase) => { | |
| 68 | const atom = createDesktopNetworkAccessStateAtom(() => ({ | |
| 69 | getAdvertisedEndpoints: testCase.getAdvertisedEndpoints, | |
| 70 | getServerExposureState: testCase.getServerExposureState, | |
| 71 | })); | |
| 72 | const registry = AtomRegistry.make(); | |
| 73 | registry.mount(atom); | |
| 74 | ||
| 75 | await vi.waitFor(() => expect(AsyncResult.isFailure(registry.get(atom))).toBe(true)); | |
| 76 | const result = registry.get(atom); | |
| 77 | if (!AsyncResult.isFailure(result)) throw new Error("Expected network access load to fail."); | |
| 78 | ||
| 79 | expect(Cause.squash(result.cause)).toEqual( | |
| 80 | expect.objectContaining({ | |
| 81 | _tag: testCase.expectedTag, | |
| 82 | cause: testCase.cause, | |
| 83 | }), | |
| 84 | ); | |
| 85 | registry.dispose(); | |
| 86 | }); | |
| 50 | 87 | }); |
apps/web/src/state/desktopNetworkAccess.ts+43-24| ... | ... | @@ -21,13 +21,32 @@ export interface DesktopNetworkAccessSnapshot { |
| 21 | 21 | readonly serverExposureState: DesktopServerExposureState; |
| 22 | 22 | } |
| 23 | 23 | |
| 24 | class DesktopNetworkAccessError extends Schema.TaggedErrorClass<DesktopNetworkAccessError>()( | |
| 25 | "DesktopNetworkAccessError", | |
| 26 | { | |
| 27 | message: Schema.String, | |
| 28 | cause: Schema.optional(Schema.Defect()), | |
| 29 | }, | |
| 30 | ) {} | |
| 24 | class DesktopNetworkAccessUnavailableError extends Schema.TaggedErrorClass<DesktopNetworkAccessUnavailableError>()( | |
| 25 | "DesktopNetworkAccessUnavailableError", | |
| 26 | {}, | |
| 27 | ) { | |
| 28 | override get message(): string { | |
| 29 | return "Desktop network access is unavailable."; | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | class DesktopServerExposureStateLoadError extends Schema.TaggedErrorClass<DesktopServerExposureStateLoadError>()( | |
| 34 | "DesktopServerExposureStateLoadError", | |
| 35 | { cause: Schema.Defect() }, | |
| 36 | ) { | |
| 37 | override get message(): string { | |
| 38 | return "Failed to load desktop server exposure state."; | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | class DesktopAdvertisedEndpointsLoadError extends Schema.TaggedErrorClass<DesktopAdvertisedEndpointsLoadError>()( | |
| 43 | "DesktopAdvertisedEndpointsLoadError", | |
| 44 | { cause: Schema.Defect() }, | |
| 45 | ) { | |
| 46 | override get message(): string { | |
| 47 | return "Failed to load advertised desktop endpoints."; | |
| 48 | } | |
| 49 | } | |
| 31 | 50 | |
| 32 | 51 | function getDesktopNetworkAccessBridge(): DesktopNetworkAccessBridge | undefined { |
| 33 | 52 | return typeof window === "undefined" ? undefined : window.desktopBridge; |
| ... | ... | @@ -39,25 +58,25 @@ export function createDesktopNetworkAccessStateAtom( |
| 39 | 58 | const loadDesktopNetworkAccess = Effect.fn("loadDesktopNetworkAccess")(function* () { |
| 40 | 59 | const bridge = getBridge(); |
| 41 | 60 | if (!bridge) { |
| 42 | return yield* new DesktopNetworkAccessError({ | |
| 43 | message: "Desktop network access is unavailable.", | |
| 44 | }); | |
| 61 | return yield* new DesktopNetworkAccessUnavailableError(); | |
| 45 | 62 | } |
| 46 | return yield* Effect.tryPromise({ | |
| 47 | try: async (): Promise<DesktopNetworkAccessSnapshot> => { | |
| 48 | const [serverExposureState, advertisedEndpoints] = await Promise.all([ | |
| 49 | bridge.getServerExposureState(), | |
| 50 | bridge.getAdvertisedEndpoints(), | |
| 51 | ]); | |
| 52 | return { advertisedEndpoints, serverExposureState }; | |
| 53 | }, | |
| 54 | catch: (cause) => | |
| 55 | new DesktopNetworkAccessError({ | |
| 56 | message: | |
| 57 | cause instanceof Error ? cause.message : "Failed to load desktop network access.", | |
| 58 | cause, | |
| 63 | const [serverExposureState, advertisedEndpoints] = yield* Effect.all( | |
| 64 | [ | |
| 65 | Effect.tryPromise({ | |
| 66 | try: () => bridge.getServerExposureState(), | |
| 67 | catch: (cause) => new DesktopServerExposureStateLoadError({ cause }), | |
| 68 | }), | |
| 69 | Effect.tryPromise({ | |
| 70 | try: () => bridge.getAdvertisedEndpoints(), | |
| 71 | catch: (cause) => new DesktopAdvertisedEndpointsLoadError({ cause }), | |
| 59 | 72 | }), |
| 60 | }); | |
| 73 | ], | |
| 74 | { concurrency: "unbounded" }, | |
| 75 | ); | |
| 76 | return { | |
| 77 | advertisedEndpoints, | |
| 78 | serverExposureState, | |
| 79 | } satisfies DesktopNetworkAccessSnapshot; | |
| 61 | 80 | }); |
| 62 | 81 | |
| 63 | 82 | return Atom.make(loadDesktopNetworkAccess()).pipe( |
apps/web/src/state/desktopSshHosts.test.ts+22| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; |
| 2 | import * as Cause from "effect/Cause"; | |
| 2 | 3 | import { AtomRegistry } from "effect/unstable/reactivity"; |
| 3 | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | 5 | import { describe, expect, it, vi } from "vite-plus/test"; |
| ... | ... | @@ -38,4 +39,25 @@ describe("desktopSshHostsState", () => { |
| 38 | 39 | remount(); |
| 39 | 40 | registry.dispose(); |
| 40 | 41 | }); |
| 42 | ||
| 43 | it("retains the desktop bridge failure as the discovery error cause", async () => { | |
| 44 | const cause = new Error("ssh config unavailable"); | |
| 45 | const atom = createDesktopSshHostsStateAtom(() => ({ | |
| 46 | discoverSshHosts: async () => Promise.reject(cause), | |
| 47 | })); | |
| 48 | const registry = AtomRegistry.make(); | |
| 49 | registry.mount(atom); | |
| 50 | ||
| 51 | await vi.waitFor(() => expect(AsyncResult.isFailure(registry.get(atom))).toBe(true)); | |
| 52 | const result = registry.get(atom); | |
| 53 | if (!AsyncResult.isFailure(result)) throw new Error("Expected SSH host discovery to fail."); | |
| 54 | ||
| 55 | expect(Cause.squash(result.cause)).toEqual( | |
| 56 | expect.objectContaining({ | |
| 57 | _tag: "DesktopSshDiscoveryError", | |
| 58 | cause, | |
| 59 | }), | |
| 60 | ); | |
| 61 | registry.dispose(); | |
| 62 | }); | |
| 41 | 63 | }); |
apps/web/src/state/desktopSshHosts.ts+17-13| ... | ... | @@ -5,13 +5,23 @@ import { Atom } from "effect/unstable/reactivity"; |
| 5 | 5 | |
| 6 | 6 | type DesktopSshDiscoveryBridge = Pick<DesktopBridge, "discoverSshHosts">; |
| 7 | 7 | |
| 8 | class DesktopSshDiscoveryUnavailableError extends Schema.TaggedErrorClass<DesktopSshDiscoveryUnavailableError>()( | |
| 9 | "DesktopSshDiscoveryUnavailableError", | |
| 10 | {}, | |
| 11 | ) { | |
| 12 | override get message(): string { | |
| 13 | return "Desktop SSH host discovery is unavailable."; | |
| 14 | } | |
| 15 | } | |
| 16 | ||
| 8 | 17 | class DesktopSshDiscoveryError extends Schema.TaggedErrorClass<DesktopSshDiscoveryError>()( |
| 9 | 18 | "DesktopSshDiscoveryError", |
| 10 | { | |
| 11 | message: Schema.String, | |
| 12 | cause: Schema.optional(Schema.Defect()), | |
| 13 | }, | |
| 14 | ) {} | |
| 19 | { cause: Schema.Defect() }, | |
| 20 | ) { | |
| 21 | override get message(): string { | |
| 22 | return "Failed to discover SSH hosts."; | |
| 23 | } | |
| 24 | } | |
| 15 | 25 | |
| 16 | 26 | function getDesktopSshDiscoveryBridge(): DesktopSshDiscoveryBridge | undefined { |
| 17 | 27 | return typeof window === "undefined" ? undefined : window.desktopBridge; |
| ... | ... | @@ -23,17 +33,11 @@ export function createDesktopSshHostsStateAtom( |
| 23 | 33 | const discoverDesktopSshHosts = Effect.fn("discoverDesktopSshHosts")(function* () { |
| 24 | 34 | const bridge = getBridge(); |
| 25 | 35 | if (!bridge) { |
| 26 | return yield* new DesktopSshDiscoveryError({ | |
| 27 | message: "Desktop SSH host discovery is unavailable.", | |
| 28 | }); | |
| 36 | return yield* new DesktopSshDiscoveryUnavailableError(); | |
| 29 | 37 | } |
| 30 | 38 | return yield* Effect.tryPromise({ |
| 31 | 39 | try: (): Promise<ReadonlyArray<DesktopDiscoveredSshHost>> => bridge.discoverSshHosts(), |
| 32 | catch: (cause) => | |
| 33 | new DesktopSshDiscoveryError({ | |
| 34 | message: cause instanceof Error ? cause.message : "Failed to discover SSH hosts.", | |
| 35 | cause, | |
| 36 | }), | |
| 40 | catch: (cause) => new DesktopSshDiscoveryError({ cause }), | |
| 37 | 41 | }); |
| 38 | 42 | }); |
| 39 | 43 |