1import { expect, it } from "@effect/vitest";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5
6import {
7 SERVICE_LAUNCHER_CONTEXT_ENV,
8 SERVICE_LAUNCHER_PROTOCOL,
9 type ServiceLauncherChildMessage,
10 type ServiceLauncherParentMessage,
11} from "./serviceProtocol.ts";
12import * as ServiceLauncherClient from "./serviceLauncherClient.ts";
13
14class FakeLauncherProcess {
15 readonly connected = true;
16 readonly env: Record<string, string | undefined>;
17 readonly sent: ServiceLauncherChildMessage[] = [];
18 readonly #listeners = new Map<string, Set<(...args: ReadonlyArray<unknown>) => void>>();
19
20 constructor(context: unknown) {
21 this.env = { [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) };
22 }
23
24 send = (message: ServiceLauncherChildMessage, callback?: (error: Error | null) => void) => {
25 this.sent.push(message);
26 callback?.(null);
27 return true;
28 };
29
30 on = (event: "message" | "disconnect", listener: (...args: ReadonlyArray<unknown>) => void) => {
31 const listeners = this.#listeners.get(event) ?? new Set();
32 listeners.add(listener);
33 this.#listeners.set(event, listeners);
34 };
35
36 off = (event: "message" | "disconnect", listener: (...args: ReadonlyArray<unknown>) => void) => {
37 this.#listeners.get(event)?.delete(listener);
38 };
39
40 emit(message: ServiceLauncherParentMessage) {
41 for (const listener of this.#listeners.get("message") ?? []) listener(message);
42 }
43}
44
45const makeClient = (host: FakeLauncherProcess, currentVersion: string) =>
46 ServiceLauncherClient.make({ currentVersion }).pipe(
47 Effect.provideService(ServiceLauncherClient.ServiceLauncherHostProcess, host),
48 Effect.provideService(HostProcess.Environment, host.env),
49 );
50
51it.effect("waits for the launcher to durably commit the trial update ID", () =>
52 Effect.gen(function* () {
53 const pending = {
54 id: "update-1",
55 fromVersion: "1.0.0",
56 targetVersion: "1.1.0",
57 dbPath: "/tmp/state.sqlite",
58 status: "pending" as const,
59 };
60 const host = new FakeLauncherProcess({
61 protocol: SERVICE_LAUNCHER_PROTOCOL,
62 childVersion: "1.1.0",
63 update: pending,
64 });
65 const client = yield* makeClient(host, "1.1.0");
66 const prepared = yield* Effect.forkChild(client.prepareTrial, { startImmediately: true });
67 yield* Effect.yieldNow;
68 expect(host.sent).toEqual([{ type: "prepared", updateId: "update-1" }]);
69
70 const committed = {
71 id: pending.id,
72 fromVersion: pending.fromVersion,
73 targetVersion: pending.targetVersion,
74 status: "committed" as const,
75 };
76 host.emit({ type: "committed", updateId: committed.id });
77 expect(yield* Fiber.join(prepared)).toEqual(committed);
78 }),
79);
80
81it.effect("returns the launcher-generated ID only after update acceptance", () =>
82 Effect.gen(function* () {
83 const host = new FakeLauncherProcess({
84 protocol: SERVICE_LAUNCHER_PROTOCOL,
85 childVersion: "1.0.0",
86 });
87 const client = yield* makeClient(host, "1.0.0");
88 const requested = yield* Effect.forkChild(
89 client.requestUpdate({ targetVersion: "1.1.0", dbPath: "/tmp/state.sqlite" }),
90 { startImmediately: true },
91 );
92 yield* Effect.yieldNow;
93 host.emit({
94 type: "update-accepted",
95 updateId: "launcher-id",
96 });
97 expect(yield* Fiber.join(requested)).toBe("launcher-id");
98 }),
99);
100
101it.effect("preserves a launcher rejection as a distinct error", () =>
102 Effect.gen(function* () {
103 const host = new FakeLauncherProcess({
104 protocol: SERVICE_LAUNCHER_PROTOCOL,
105 childVersion: "1.0.0",
106 });
107 const client = yield* makeClient(host, "1.0.0");
108 const requested = yield* Effect.forkChild(
109 client.requestUpdate({ targetVersion: "1.1.0", dbPath: "/tmp/state.sqlite" }),
110 { startImmediately: true },
111 );
112 yield* Effect.yieldNow;
113 host.emit({ type: "update-rejected", reason: "requires local update" });
114 expect(yield* Fiber.join(requested).pipe(Effect.flip)).toMatchObject({
115 _tag: "ServiceLauncherRejectedError",
116 targetVersion: "1.1.0",
117 reason: "requires local update",
118 });
119 }),
120);
121
122it.effect("rejects contradictory trial context instead of leaving activation closed", () =>
123 Effect.gen(function* () {
124 const host = new FakeLauncherProcess({
125 protocol: SERVICE_LAUNCHER_PROTOCOL,
126 childVersion: "1.1.0",
127 update: {
128 id: "update-1",
129 fromVersion: "1.0.0",
130 targetVersion: "1.2.0",
131 dbPath: "/tmp/state.sqlite",
132 status: "pending",
133 },
134 });
135 const error = yield* makeClient(host, "1.1.0").pipe(Effect.flip);
136 expect(error.message).toBe("The service launcher supplied invalid startup context.");
137 }),
138);