| 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,4 +1,5 @@ |
| 1 | import type { AdvertisedEndpoint, DesktopServerExposureState } from "@t3tools/contracts"; | 1 | import type { AdvertisedEndpoint, DesktopServerExposureState } from "@t3tools/contracts"; |
| 2 | import * as Cause from "effect/Cause"; | ||
| 2 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 3 | import { AtomRegistry } from "effect/unstable/reactivity"; | 4 | import { AtomRegistry } from "effect/unstable/reactivity"; |
| 4 | import { describe, expect, it, vi } from "vite-plus/test"; | 5 | import { describe, expect, it, vi } from "vite-plus/test"; |
| ... | @@ -14,6 +15,8 @@ const serverExposureState: DesktopServerExposureState = { | ... | @@ -14,6 +15,8 @@ const serverExposureState: DesktopServerExposureState = { |
| 14 | }; | 15 | }; |
| 15 | 16 | ||
| 16 | const advertisedEndpoints: ReadonlyArray<AdvertisedEndpoint> = []; | 17 | const advertisedEndpoints: ReadonlyArray<AdvertisedEndpoint> = []; |
| 18 | const serverExposureLoadCause = new Error("exposure failed"); | ||
| 19 | const advertisedEndpointsLoadCause = new Error("endpoints failed"); | ||
| 17 | 20 | ||
| 18 | describe("desktopNetworkAccessState", () => { | 21 | describe("desktopNetworkAccessState", () => { |
| 19 | it("retains the loaded snapshot when the settings screen remounts", async () => { | 22 | it("retains the loaded snapshot when the settings screen remounts", async () => { |
| ... | @@ -47,4 +50,38 @@ describe("desktopNetworkAccessState", () => { | ... | @@ -47,4 +50,38 @@ describe("desktopNetworkAccessState", () => { |
| 47 | remount(); | 50 | remount(); |
| 48 | registry.dispose(); | 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,13 +21,32 @@ export interface DesktopNetworkAccessSnapshot { |
| 21 | readonly serverExposureState: DesktopServerExposureState; | 21 | readonly serverExposureState: DesktopServerExposureState; |
| 22 | } | 22 | } |
| 23 | 23 | ||
| 24 | class DesktopNetworkAccessError extends Schema.TaggedErrorClass<DesktopNetworkAccessError>()( | 24 | class DesktopNetworkAccessUnavailableError extends Schema.TaggedErrorClass<DesktopNetworkAccessUnavailableError>()( |
| 25 | "DesktopNetworkAccessError", | 25 | "DesktopNetworkAccessUnavailableError", |
| 26 | { | 26 | {}, |
| 27 | message: Schema.String, | 27 | ) { |
| 28 | cause: Schema.optional(Schema.Defect()), | 28 | override get message(): string { |
| 29 | }, | 29 | return "Desktop network access is unavailable."; |
| 30 | ) {} | 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 | function getDesktopNetworkAccessBridge(): DesktopNetworkAccessBridge | undefined { | 51 | function getDesktopNetworkAccessBridge(): DesktopNetworkAccessBridge | undefined { |
| 33 | return typeof window === "undefined" ? undefined : window.desktopBridge; | 52 | return typeof window === "undefined" ? undefined : window.desktopBridge; |
| ... | @@ -39,25 +58,25 @@ export function createDesktopNetworkAccessStateAtom( | ... | @@ -39,25 +58,25 @@ export function createDesktopNetworkAccessStateAtom( |
| 39 | const loadDesktopNetworkAccess = Effect.fn("loadDesktopNetworkAccess")(function* () { | 58 | const loadDesktopNetworkAccess = Effect.fn("loadDesktopNetworkAccess")(function* () { |
| 40 | const bridge = getBridge(); | 59 | const bridge = getBridge(); |
| 41 | if (!bridge) { | 60 | if (!bridge) { |
| 42 | return yield* new DesktopNetworkAccessError({ | 61 | return yield* new DesktopNetworkAccessUnavailableError(); |
| 43 | message: "Desktop network access is unavailable.", | ||
| 44 | }); | ||
| 45 | } | 62 | } |
| 46 | return yield* Effect.tryPromise({ | 63 | const [serverExposureState, advertisedEndpoints] = yield* Effect.all( |
| 47 | try: async (): Promise<DesktopNetworkAccessSnapshot> => { | 64 | [ |
| 48 | const [serverExposureState, advertisedEndpoints] = await Promise.all([ | 65 | Effect.tryPromise({ |
| 49 | bridge.getServerExposureState(), | 66 | try: () => bridge.getServerExposureState(), |
| 50 | bridge.getAdvertisedEndpoints(), | 67 | catch: (cause) => new DesktopServerExposureStateLoadError({ cause }), |
| 51 | ]); | 68 | }), |
| 52 | return { advertisedEndpoints, serverExposureState }; | 69 | Effect.tryPromise({ |
| 53 | }, | 70 | try: () => bridge.getAdvertisedEndpoints(), |
| 54 | catch: (cause) => | 71 | catch: (cause) => new DesktopAdvertisedEndpointsLoadError({ cause }), |
| 55 | new DesktopNetworkAccessError({ | ||
| 56 | message: | ||
| 57 | cause instanceof Error ? cause.message : "Failed to load desktop network access.", | ||
| 58 | cause, | ||
| 59 | }), | 72 | }), |
| 60 | }); | 73 | ], |
| 74 | { concurrency: "unbounded" }, | ||
| 75 | ); | ||
| 76 | return { | ||
| 77 | advertisedEndpoints, | ||
| 78 | serverExposureState, | ||
| 79 | } satisfies DesktopNetworkAccessSnapshot; | ||
| 61 | }); | 80 | }); |
| 62 | 81 | ||
| 63 | return Atom.make(loadDesktopNetworkAccess()).pipe( | 82 | return Atom.make(loadDesktopNetworkAccess()).pipe( |
apps/web/src/state/desktopSshHosts.test.ts+22| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; | 1 | import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; |
| 2 | import * as Cause from "effect/Cause"; | ||
| 2 | import { AtomRegistry } from "effect/unstable/reactivity"; | 3 | import { AtomRegistry } from "effect/unstable/reactivity"; |
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | import { describe, expect, it, vi } from "vite-plus/test"; | 5 | import { describe, expect, it, vi } from "vite-plus/test"; |
| ... | @@ -38,4 +39,25 @@ describe("desktopSshHostsState", () => { | ... | @@ -38,4 +39,25 @@ describe("desktopSshHostsState", () => { |
| 38 | remount(); | 39 | remount(); |
| 39 | registry.dispose(); | 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,13 +5,23 @@ import { Atom } from "effect/unstable/reactivity"; |
| 5 | 5 | ||
| 6 | type DesktopSshDiscoveryBridge = Pick<DesktopBridge, "discoverSshHosts">; | 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 | class DesktopSshDiscoveryError extends Schema.TaggedErrorClass<DesktopSshDiscoveryError>()( | 17 | class DesktopSshDiscoveryError extends Schema.TaggedErrorClass<DesktopSshDiscoveryError>()( |
| 9 | "DesktopSshDiscoveryError", | 18 | "DesktopSshDiscoveryError", |
| 10 | { | 19 | { cause: Schema.Defect() }, |
| 11 | message: Schema.String, | 20 | ) { |
| 12 | cause: Schema.optional(Schema.Defect()), | 21 | override get message(): string { |
| 13 | }, | 22 | return "Failed to discover SSH hosts."; |
| 14 | ) {} | 23 | } |
| 24 | } | ||
| 15 | 25 | ||
| 16 | function getDesktopSshDiscoveryBridge(): DesktopSshDiscoveryBridge | undefined { | 26 | function getDesktopSshDiscoveryBridge(): DesktopSshDiscoveryBridge | undefined { |
| 17 | return typeof window === "undefined" ? undefined : window.desktopBridge; | 27 | return typeof window === "undefined" ? undefined : window.desktopBridge; |
| ... | @@ -23,17 +33,11 @@ export function createDesktopSshHostsStateAtom( | ... | @@ -23,17 +33,11 @@ export function createDesktopSshHostsStateAtom( |
| 23 | const discoverDesktopSshHosts = Effect.fn("discoverDesktopSshHosts")(function* () { | 33 | const discoverDesktopSshHosts = Effect.fn("discoverDesktopSshHosts")(function* () { |
| 24 | const bridge = getBridge(); | 34 | const bridge = getBridge(); |
| 25 | if (!bridge) { | 35 | if (!bridge) { |
| 26 | return yield* new DesktopSshDiscoveryError({ | 36 | return yield* new DesktopSshDiscoveryUnavailableError(); |
| 27 | message: "Desktop SSH host discovery is unavailable.", | ||
| 28 | }); | ||
| 29 | } | 37 | } |
| 30 | return yield* Effect.tryPromise({ | 38 | return yield* Effect.tryPromise({ |
| 31 | try: (): Promise<ReadonlyArray<DesktopDiscoveredSshHost>> => bridge.discoverSshHosts(), | 39 | try: (): Promise<ReadonlyArray<DesktopDiscoveredSshHost>> => bridge.discoverSshHosts(), |
| 32 | catch: (cause) => | 40 | catch: (cause) => new DesktopSshDiscoveryError({ cause }), |
| 33 | new DesktopSshDiscoveryError({ | ||
| 34 | message: cause instanceof Error ? cause.message : "Failed to discover SSH hosts.", | ||
| 35 | cause, | ||
| 36 | }), | ||
| 37 | }); | 41 | }); |
| 38 | }); | 42 | }); |
| 39 | 43 |