Open History to see the last commit
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("cancels a queued request when the client disconnects", () =>
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-cancel-test-"));
90 const target = makeTarget(NodePath.join(root, "userdata"), platform, userId);
91 let resolveCanceled: (requestId: string) => void = () => undefined;
92 const canceled = new Promise<string>((resolve) => {
93 resolveCanceled = resolve;
94 });
95 const server = await startDesktopAppControlServer({
96 ...target,
97 userId,
98 handle: () => new Promise(() => undefined),
99 cancel: resolveCanceled,
100 onReclaimError: () => undefined,
101 });
102 openServers.push(server);
103 const socket = NodeNet.createConnection(target.address);
104 await new Promise<void>((resolve, reject) => {
105 socket.once("error", reject);
106 socket.once("connect", () => {
107 socket.write(`${JSON.stringify(request("request-canceled", platform))}\n`, () => {
108 socket.destroy();
109 resolve();
110 });
111 });
112 });
113
114 await expect(canceled).resolves.toBe("request-canceled");
115 await server.close();
116 openServers.splice(openServers.indexOf(server), 1);
117 await NodeFSP.rm(root, { recursive: true, force: true });
118 });
119 }),
120 );
121
122 it.effect("keeps a newer app's socket when an older app on the same state dir quits", () =>
123 Effect.gen(function* () {
124 const platform = yield* HostProcess.Platform;
125 const userId = yield* HostProcess.UserId;
126 if (platform === "win32") return;
127 yield* Effect.promise(async () => {
128 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-takeover-test-"));
129 const target = makeTarget(NodePath.join(root, "userdata"), platform, userId);
130 const older = await startOkServer(target, userId);
131 await startOkServer(target, userId);
132
133 await older.close();
134
135 await expect(
136 exchange(target.address, request("after-quit", platform)),
137 ).resolves.toMatchObject({ ok: true, requestId: "after-quit" });
138 await NodeFSP.rm(root, { recursive: true, force: true });
139 });
140 }),
141 );
142
143 it.effect("binds its address again after the socket file is removed", () =>
144 Effect.gen(function* () {
145 const platform = yield* HostProcess.Platform;
146 const userId = yield* HostProcess.UserId;
147 if (platform === "win32") return;
148 yield* Effect.promise(async () => {
149 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-reclaim-test-"));
150 const target = makeTarget(NodePath.join(root, "userdata"), platform, userId);
151 const server = await startOkServer(target, userId);
152
153 await NodeFSP.unlink(target.address);
154 await server.reclaim();
155
156 await expect(
157 exchange(target.address, request("reclaimed", platform)),
158 ).resolves.toMatchObject({
159 ok: true,
160 requestId: "reclaimed",
161 });
162 await NodeFSP.rm(root, { recursive: true, force: true });
163 });
164 }),
165 );
166});