import { expect, it } from "@effect/vitest"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import { SERVICE_LAUNCHER_CONTEXT_ENV, SERVICE_LAUNCHER_PROTOCOL, type ServiceLauncherChildMessage, type ServiceLauncherParentMessage, } from "./serviceProtocol.ts"; import * as ServiceLauncherClient from "./serviceLauncherClient.ts"; class FakeLauncherProcess { readonly connected = true; readonly env: Record; readonly sent: ServiceLauncherChildMessage[] = []; readonly #listeners = new Map) => void>>(); constructor(context: unknown) { this.env = { [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) }; } send = (message: ServiceLauncherChildMessage, callback?: (error: Error | null) => void) => { this.sent.push(message); callback?.(null); return true; }; on = (event: "message" | "disconnect", listener: (...args: ReadonlyArray) => void) => { const listeners = this.#listeners.get(event) ?? new Set(); listeners.add(listener); this.#listeners.set(event, listeners); }; off = (event: "message" | "disconnect", listener: (...args: ReadonlyArray) => void) => { this.#listeners.get(event)?.delete(listener); }; emit(message: ServiceLauncherParentMessage) { for (const listener of this.#listeners.get("message") ?? []) listener(message); } } const makeClient = (host: FakeLauncherProcess, currentVersion: string) => ServiceLauncherClient.make({ currentVersion }).pipe( Effect.provideService(ServiceLauncherClient.ServiceLauncherHostProcess, host), Effect.provideService(HostProcessEnvironment, host.env), ); it.effect("waits for the launcher to durably commit the trial update ID", () => Effect.gen(function* () { const pending = { id: "update-1", fromVersion: "1.0.0", targetVersion: "1.1.0", dbPath: "/tmp/state.sqlite", status: "pending" as const, }; const host = new FakeLauncherProcess({ protocol: SERVICE_LAUNCHER_PROTOCOL, childVersion: "1.1.0", update: pending, }); const client = yield* makeClient(host, "1.1.0"); const prepared = yield* Effect.forkChild(client.prepareTrial, { startImmediately: true }); yield* Effect.yieldNow; expect(host.sent).toEqual([{ type: "prepared", updateId: "update-1" }]); const committed = { id: pending.id, fromVersion: pending.fromVersion, targetVersion: pending.targetVersion, status: "committed" as const, }; host.emit({ type: "committed", updateId: committed.id }); expect(yield* Fiber.join(prepared)).toEqual(committed); }), ); it.effect("returns the launcher-generated ID only after update acceptance", () => Effect.gen(function* () { const host = new FakeLauncherProcess({ protocol: SERVICE_LAUNCHER_PROTOCOL, childVersion: "1.0.0", }); const client = yield* makeClient(host, "1.0.0"); const requested = yield* Effect.forkChild( client.requestUpdate({ targetVersion: "1.1.0", dbPath: "/tmp/state.sqlite" }), { startImmediately: true }, ); yield* Effect.yieldNow; host.emit({ type: "update-accepted", updateId: "launcher-id", }); expect(yield* Fiber.join(requested)).toBe("launcher-id"); }), ); it.effect("preserves a launcher rejection as a distinct error", () => Effect.gen(function* () { const host = new FakeLauncherProcess({ protocol: SERVICE_LAUNCHER_PROTOCOL, childVersion: "1.0.0", }); const client = yield* makeClient(host, "1.0.0"); const requested = yield* Effect.forkChild( client.requestUpdate({ targetVersion: "1.1.0", dbPath: "/tmp/state.sqlite" }), { startImmediately: true }, ); yield* Effect.yieldNow; host.emit({ type: "update-rejected", reason: "requires local update" }); expect(yield* Fiber.join(requested).pipe(Effect.flip)).toMatchObject({ _tag: "ServiceLauncherRejectedError", targetVersion: "1.1.0", reason: "requires local update", }); }), ); it.effect("rejects contradictory trial context instead of leaving activation closed", () => Effect.gen(function* () { const host = new FakeLauncherProcess({ protocol: SERVICE_LAUNCHER_PROTOCOL, childVersion: "1.1.0", update: { id: "update-1", fromVersion: "1.0.0", targetVersion: "1.2.0", dbPath: "/tmp/state.sqlite", status: "pending", }, }); const error = yield* makeClient(host, "1.1.0").pipe(Effect.flip); expect(error.message).toBe("The service launcher supplied invalid startup context."); }), );