1// @effect-diagnostics nodeBuiltinImport:off -- This adapter test binds a real local socket or Windows named pipe and verifies its cleanup.
2import * as NodeFSP from "node:fs/promises";
3import * as NodeNet from "node:net";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6
7import {
8 ProjectId,
9 ThreadId,
10 type DesktopAppActivationRequest,
11 type DesktopAppActivationResponse,
12} from "@t3tools/contracts";
13import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl";
14import * as HostProcess from "@t3tools/shared/HostProcess";
15import { it } from "@effect/vitest";
16import * as Effect from "effect/Effect";
17import { afterEach, describe, expect } from "vite-plus/test";
18
19import { startDesktopAppControlServer } from "./DesktopAppActivation.ts";
20
21const openServers: Array<{ close: () => Promise<void> }> = [];
22
23afterEach(async () => {
24 await Promise.all(openServers.splice(0).map((server) => server.close()));
25});
26
27function makeTarget(stateDir: string, platform: NodeJS.Platform, userId: number | undefined) {
28 return resolveDesktopAppControlAddress({
29 stateDir,
30 platform,
31 tempDir: NodeOS.tmpdir(),
32 userId,
33 joinPath: NodePath.join,
34 });
35}
36
37function request(requestId: string, platform: NodeJS.Platform): DesktopAppActivationRequest {
38 return {
39 version: 1,
40 requestId,
41 type: "open-workspace",
42 workspaceRoot: NodePath.join(NodeOS.tmpdir(), "project"),
43 platform: platform === "win32" ? "win32" : platform === "darwin" ? "darwin" : "linux",
44 };
45}
46
47function startOkServer(target: ReturnType<typeof makeTarget>, userId: number | undefined) {
48 return startDesktopAppControlServer({
49 ...target,
50 userId,
51 handle: async (input) => ({
52 version: 1,
53 requestId: input.requestId,
54 ok: true,
55 projectId: ProjectId.make("project-1"),
56 threadId: ThreadId.make("thread-1"),
57 }),
58 cancel: () => undefined,
59 onReclaimError: () => undefined,
60 }).then((server) => {
61 openServers.push(server);
62 return server;
63 });
64}
65
66function exchange(address: string, payload: DesktopAppActivationRequest) {
67 return new Promise<DesktopAppActivationResponse>((resolve, reject) => {
68 const socket = NodeNet.createConnection(address);
69 socket.setEncoding("utf8");
70 let buffer = "";
71 socket.once("error", reject);
72 socket.once("connect", () => socket.write(`${JSON.stringify(payload)}\n`));
73 socket.on("data", (chunk) => {
74 buffer += chunk;
75 const newline = buffer.indexOf("\n");
76 if (newline === -1) return;
77 socket.destroy();
78 resolve(JSON.parse(buffer.slice(0, newline)) as DesktopAppActivationResponse);
79 });
80 });
81}
82
83describe("desktop app control server", () => {
84 it.effect("roundtrips a request and removes its socket on shutdown", () =>
85 Effect.gen(function* () {
86 const platform = yield* HostProcess.Platform;
87 const userId = yield* HostProcess.UserId;
88 yield* Effect.promise(async () => {
89 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-control-test-"));
90 const target = makeTarget(NodePath.join(root, "userdata"), platform, userId);
91 const received: DesktopAppActivationRequest[] = [];
92 const server = await startDesktopAppControlServer({
93 ...target,
94 userId,
95 handle: async (input) => {
96 received.push(input);
97 return {
98 version: 1,
99 requestId: input.requestId,
100 ok: true,
101 projectId: ProjectId.make("project-1"),
102 threadId: ThreadId.make("thread-1"),
103 };
104 },
105 cancel: () => undefined,
106 onReclaimError: () => undefined,
107 });
108 openServers.push(server);
109
110 const response = await exchange(target.address, request("request-1", platform));
111
112 expect(received).toHaveLength(1);
113 expect(response).toMatchObject({ ok: true, requestId: "request-1" });
114 await server.close();
115 openServers.splice(openServers.indexOf(server), 1);
116 if (target.directory !== null) {
117 await expect(NodeFSP.stat(target.address)).rejects.toMatchObject({ code: "ENOENT" });
118 }
119 await NodeFSP.rm(root, { recursive: true, force: true });
120 });
121 }),
122 );
123
124 it.effect("cancels a queued request when the client disconnects", () =>
125 Effect.gen(function* () {
126 const platform = yield* HostProcess.Platform;
127 const userId = yield* HostProcess.UserId;
128 yield* Effect.promise(async () => {
129 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-cancel-test-"));
130 const target = makeTarget(NodePath.join(root, "userdata"), platform, userId);
131 let resolveCanceled: (requestId: string) => void = () => undefined;
132 const canceled = new Promise<string>((resolve) => {
133 resolveCanceled = resolve;
134 });
135 const server = await startDesktopAppControlServer({
136 ...target,
137 userId,
138 handle: () => new Promise(() => undefined),
139 cancel: resolveCanceled,
140 onReclaimError: () => undefined,
141 });
142 openServers.push(server);
143 const socket = NodeNet.createConnection(target.address);
144 await new Promise<void>((resolve, reject) => {
145 socket.once("error", reject);
146 socket.once("connect", () => {
147 socket.write(`${JSON.stringify(request("request-canceled", platform))}\n`, () => {
148 socket.destroy();
149 resolve();
150 });
151 });
152 });
153
154 await expect(canceled).resolves.toBe("request-canceled");
155 await server.close();
156 openServers.splice(openServers.indexOf(server), 1);
157 await NodeFSP.rm(root, { recursive: true, force: true });
158 });
159 }),
160 );
161
162 // Two desktop apps can share one state dir, such as nightly and a preview build.
163 it.effect("keeps a newer app's socket when an older app on the same state dir quits", () =>
164 Effect.gen(function* () {
165 const platform = yield* HostProcess.Platform;
166 const userId = yield* HostProcess.UserId;
167 if (platform === "win32") return;
168 yield* Effect.promise(async () => {
169 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-takeover-test-"));
170 const target = makeTarget(NodePath.join(root, "userdata"), platform, userId);
171 const older = await startOkServer(target, userId);
172 await startOkServer(target, userId);
173
174 await older.close();
175
176 await expect(
177 exchange(target.address, request("after-quit", platform)),
178 ).resolves.toMatchObject({ ok: true, requestId: "after-quit" });
179 await NodeFSP.rm(root, { recursive: true, force: true });
180 });
181 }),
182 );
183
184 it.effect("binds its address again after the socket file is removed", () =>
185 Effect.gen(function* () {
186 const platform = yield* HostProcess.Platform;
187 const userId = yield* HostProcess.UserId;
188 if (platform === "win32") return;
189 yield* Effect.promise(async () => {
190 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-reclaim-test-"));
191 const target = makeTarget(NodePath.join(root, "userdata"), platform, userId);
192 const server = await startOkServer(target, userId);
193
194 await NodeFSP.unlink(target.address);
195 await server.reclaim();
196
197 await expect(
198 exchange(target.address, request("reclaimed", platform)),
199 ).resolves.toMatchObject({
200 ok: true,
201 requestId: "reclaimed",
202 });
203 await NodeFSP.rm(root, { recursive: true, force: true });
204 });
205 }),
206 );
207});