diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index a80a9fe24fb..9b5ed56d1f3 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -1,7 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { vi } from "vite-plus/test"; +import { beforeEach, vi } from "vite-plus/test"; const { createClerkBridgeMock, storageAdapter, storageMock } = vi.hoisted(() => ({ createClerkBridgeMock: vi.fn(), @@ -24,7 +25,23 @@ vi.mock("@clerk/electron/storage", () => ({ import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +const makeDesktopClerkLayer = (isDevelopment = true) => { + const environment = DesktopEnvironment.DesktopEnvironment.of({ + stateDir: "/tmp/t3-state", + isDevelopment, + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + + return DesktopClerk.layer.pipe( + Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)), + ); +}; + describe("DesktopClerk", () => { + beforeEach(() => { + createClerkBridgeMock.mockReset(); + storageMock.mockReset(); + }); + it("derives the Clerk Frontend API hostname used by the desktop CSP", () => { const publishableKey = `pk_test_${btoa("clerk.t3.codes$")}`; @@ -40,19 +57,9 @@ describe("DesktopClerk", () => { const cleanup = vi.fn(); storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue({ cleanup }); - const environment = DesktopEnvironment.DesktopEnvironment.of({ - stateDir: "/tmp/t3-state", - isDevelopment: true, - } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); return Effect.gen(function* () { - yield* Effect.scoped( - Layer.build( - DesktopClerk.layer.pipe( - Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)), - ), - ), - ); + yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())); assert.deepEqual(createClerkBridgeMock.mock.calls, [ [ @@ -69,6 +76,54 @@ describe("DesktopClerk", () => { }); }); + it.effect("preserves bridge initialization failures", () => { + const cause = new Error("bridge initialization failed"); + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockImplementationOnce(() => { + throw cause; + }); + + return Effect.gen(function* () { + const error = yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())).pipe(Effect.flip); + + assert.instanceOf(error, DesktopClerk.DesktopClerkBridgeInitializationError); + assert.equal(error.stateDir, "/tmp/t3-state"); + assert.equal(error.isDevelopment, true); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + 'Failed to initialize the desktop Clerk bridge for state directory "/tmp/t3-state" (development: true).', + ); + }); + }); + + it.effect("preserves bridge cleanup failures", () => { + const cause = new Error("bridge cleanup failed"); + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ + cleanup: () => { + throw cause; + }, + }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(Effect.scoped(Layer.build(makeDesktopClerkLayer(false)))); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, DesktopClerk.DesktopClerkBridgeCleanupError); + assert.equal(error.stateDir, "/tmp/t3-state"); + assert.equal(error.isDevelopment, false); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + 'Failed to clean up the desktop Clerk bridge for state directory "/tmp/t3-state" (development: false).', + ); + } + }); + }); + it.each([ { isDevelopment: true, scheme: "t3code-dev" }, { isDevelopment: false, scheme: "t3code" }, diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 1fa5640b2ee..0e283f8dd0c 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -4,6 +4,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; @@ -14,6 +15,32 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; +export class DesktopClerkBridgeInitializationError extends Schema.TaggedErrorClass()( + "DesktopClerkBridgeInitializationError", + { + stateDir: Schema.String, + isDevelopment: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to initialize the desktop Clerk bridge for state directory "${this.stateDir}" (development: ${this.isDevelopment}).`; + } +} + +export class DesktopClerkBridgeCleanupError extends Schema.TaggedErrorClass()( + "DesktopClerkBridgeCleanupError", + { + stateDir: Schema.String, + isDevelopment: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to clean up the desktop Clerk bridge for state directory "${this.stateDir}" (development: ${this.isDevelopment}).`; + } +} + export class DesktopClerk extends Context.Service< DesktopClerk, { @@ -55,11 +82,28 @@ export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolea }); } -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; yield* Effect.acquireRelease( - Effect.sync(() => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment)), - (bridge) => Effect.sync(() => bridge.cleanup()), + Effect.try({ + try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), + catch: (cause) => + new DesktopClerkBridgeInitializationError({ + stateDir: environment.stateDir, + isDevelopment: environment.isDevelopment, + cause, + }), + }), + (bridge) => + Effect.try({ + try: () => bridge.cleanup(), + catch: (cause) => + new DesktopClerkBridgeCleanupError({ + stateDir: environment.stateDir, + isDevelopment: environment.isDevelopment, + cause, + }), + }).pipe(Effect.orDie), ); return DesktopClerk.of({