1// @effect-diagnostics-next-line nodeBuiltinImport:off -- The Muse SDK spawns with Node's child_process, so the launch test quotes arguments the same way.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { spawnMspConnection, type MspHandshake, type SpawnedMspConnection } from "@muse-code/sdk";
5import { it } from "@effect/vitest";
6import * as HostProcess from "@t3tools/shared/HostProcess";
7import { SpawnExecutableResolution } from "@t3tools/shared/shell";
8import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
10import * as FileSystem from "effect/FileSystem";
11import * as Path from "effect/Path";
12import { afterEach, describe, expect, vi } from "vite-plus/test";
13import {
14 createMuseSdkHost,
15 createMuseSdkHostEffect,
16 makeMuseEnvironment,
17 museApprovalMode,
18 museLaunch,
19 museVerbatimPath,
20 museWorkspaceRoot,
21 type MuseSdkHost,
22} from "./sdk.ts";
23
24vi.mock("@muse-code/sdk", () => ({ spawnMspConnection: vi.fn() }));
25
26function pending<A>() {
27 let resolve = (_value: A) => {};
28 let reject = (_error: unknown) => {};
29 const promise = new Promise<A>((yes, no) => {
30 resolve = yes;
31 reject = no;
32 });
33 return { promise, resolve, reject };
34}
35
36function mockSpawn() {
37 const startup = pending<SpawnedMspConnection>();
38 const shutdown = pending<{ code: number; signal: null }>();
39 const closing = pending<void>();
40 const initializing = pending<void>();
41 const initialize = vi.fn(() => {
42 initializing.resolve();
43 return startup.promise;
44 });
45 const close = vi.fn(() => {
46 closing.resolve();
47 return shutdown.promise;
48 });
49 const handshake = { initialize, close } as unknown as MspHandshake;
50 vi.mocked(spawnMspConnection).mockReturnValue(handshake);
51 const ready = {
52 connection: {},
53 initializeResult: { grantedCapabilities: [], schema: { version: 1 } },
54 exited: shutdown.promise,
55 fingerprintWarning: { warning: "additive optional" },
56 } as unknown as SpawnedMspConnection;
57 return { startup, shutdown, closing, initializing, initialize, close, ready };
58}
59
60afterEach(() => {
61 vi.useRealTimers();
62 vi.clearAllMocks();
63});
64
65describe("Muse SDK host", () => {
66 it.each(["META_API_KEY", "meta_api_key", "Meta_Api_Key"])(
67 "excludes %s without reading its value or mutating the supplied environment",
68 (key) => {
69 const source: NodeJS.ProcessEnv = { PATH: "/bin", MUSE_TEST: "yes" };
70 const read = vi.fn(() => {
71 throw new Error("The API override must not be read.");
72 });
73 Object.defineProperty(source, key, { enumerable: true, get: read });
74 expect(makeMuseEnvironment(source)).toEqual({
75 PATH: "/bin",
76 MUSE_TEST: "yes",
77 MUSE_NO_AUTO_UPDATE: "1",
78 });
79 expect(read).not.toHaveBeenCalled();
80 expect(Object.keys(source)).toContain(key);
81 },
82 );
83
84 it("selects full access explicitly and keeps other runtime modes sandboxed", async () => {
85 for (const mode of ["approval-required", "auto-accept-edits", "auto", "full-access"] as const) {
86 const fake = mockSpawn();
87 const running = createMuseSdkHost({
88 binaryPath: "/custom/muse",
89 cwd: "/workspace",
90 runtimeMode: mode,
91 environment: { PATH: "/bin" },
92 });
93 expect(vi.mocked(spawnMspConnection).mock.lastCall?.[0]).toMatchObject({
94 command: "/custom/muse",
95 cwd: "/workspace",
96 args:
97 mode === "full-access"
98 ? ["serve", "--trust-workspace", "--disable-sandbox"]
99 : ["serve", "--trust-workspace"],
100 });
101 expect(museApprovalMode(mode)).toBe(mode === "full-access" ? "allowAll" : "promptUnmatched");
102 fake.startup.resolve(fake.ready);
103 const host = await running;
104 fake.shutdown.resolve({ code: 0, signal: null });
105 await host.close();
106 await host.close();
107 expect(fake.close).toHaveBeenCalledOnce();
108 }
109 });
110
111 it("bounds startup and waits for native shutdown after timeout", async () => {
112 vi.useFakeTimers();
113 const fake = mockSpawn();
114 const startup = createMuseSdkHost({ binaryPath: "muse", startupTimeoutMs: 30 });
115 let settled = false;
116 const outcome = startup.then(
117 () => {
118 settled = true;
119 },
120 (error: unknown) => {
121 settled = true;
122 return error;
123 },
124 );
125 await vi.advanceTimersByTimeAsync(31);
126 expect(fake.close).toHaveBeenCalledOnce();
127 expect(settled).toBe(false);
128 fake.shutdown.resolve({ code: 0, signal: null });
129 expect(await outcome).toMatchObject({ message: "Muse SDK initialization timed out." });
130 });
131
132 it("closes the pre-handshake host on abort and rejects only after shutdown", async () => {
133 const fake = mockSpawn();
134 const abort = new AbortController();
135 const started = createMuseSdkHost({ binaryPath: "muse", signal: abort.signal });
136 const reason = new Error("Cancelled by caller");
137 const outcome = started.catch((error: unknown) => error);
138 abort.abort(reason);
139 expect(fake.close).toHaveBeenCalledOnce();
140 fake.shutdown.resolve({ code: 0, signal: null });
141 expect(await outcome).toBe(reason);
142 });
143
144 it("closes rejected initialization and keeps successful schema advisories usable", async () => {
145 const failed = mockSpawn();
146 const started = createMuseSdkHost({ binaryPath: "muse" });
147 const outcome = started.catch((error: unknown) => error);
148 failed.startup.reject(new Error("Handshake failed"));
149 failed.shutdown.resolve({ code: 0, signal: null });
150 expect(await outcome).toMatchObject({ message: "Handshake failed" });
151 expect(failed.close).toHaveBeenCalledOnce();
152 const success = mockSpawn();
153 const ready = createMuseSdkHost({ binaryPath: "muse", readOnly: true });
154 success.startup.resolve(success.ready);
155 const host = await ready;
156 expect(host.initializeResult.grantedCapabilities).toEqual([]);
157 expect(vi.mocked(spawnMspConnection).mock.lastCall?.[0].args).toEqual([
158 "serve",
159 "--disable-shell",
160 "--disable-write",
161 "--no-session-log",
162 ]);
163 success.shutdown.resolve({ code: 0, signal: null });
164 await host.close();
165 });
166
167 it.each([undefined, 2])(
168 "rejects unsupported envelope version %s and awaits shutdown",
169 async (version) => {
170 const fake = mockSpawn();
171 const started = createMuseSdkHost({ binaryPath: "muse" });
172 let settled = false;
173 const outcome = started.catch((error: unknown) => {
174 settled = true;
175 return error;
176 });
177 fake.startup.resolve({
178 ...fake.ready,
179 initializeResult: {
180 ...fake.ready.initializeResult,
181 schema: { fingerprint: "test", version },
182 },
183 } as unknown as SpawnedMspConnection);
184 await fake.closing.promise;
185 expect(fake.close).toHaveBeenCalledOnce();
186 expect(settled).toBe(false);
187 fake.shutdown.resolve({ code: 0, signal: null });
188 expect(await outcome).toMatchObject({
189 message: "Muse SDK returned an unsupported protocol envelope version.",
190 });
191 },
192 );
193
194 it("does not spawn when already aborted and closes a ready host on later abort", async () => {
195 const aborted = new AbortController();
196 aborted.abort();
197 await expect(
198 createMuseSdkHost({ binaryPath: "muse", signal: aborted.signal }),
199 ).rejects.toBeDefined();
200 expect(spawnMspConnection).not.toHaveBeenCalled();
201 const fake = mockSpawn();
202 const abort = new AbortController();
203 const running = createMuseSdkHost({ binaryPath: "muse", signal: abort.signal });
204 fake.startup.resolve(fake.ready);
205 const host = await running;
206 abort.abort();
207 fake.shutdown.resolve({ code: 0, signal: null });
208 await host.close();
209 expect(fake.close).toHaveBeenCalledOnce();
210 });
211
212 it.effect(
213 "keeps Effect startup resources alive until interrupted initialization shuts down",
214 () =>
215 Effect.gen(function* () {
216 const fake = mockSpawn();
217 let released = false;
218 const fiber = yield* Effect.forkChild(
219 createMuseSdkHostEffect({ binaryPath: "muse" }).pipe(
220 Effect.ensuring(
221 Effect.sync(() => {
222 released = true;
223 }),
224 ),
225 ),
226 );
227 yield* Effect.promise(() => fake.initializing.promise);
228 const interrupted = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild);
229 yield* Effect.promise(() => fake.closing.promise);
230 expect(released).toBe(false);
231 fake.shutdown.resolve({ code: 0, signal: null });
232 yield* Fiber.join(interrupted);
233 expect(released).toBe(true);
234 expect(fake.close).toHaveBeenCalledOnce();
235 }),
236 );
237
238 it.effect("closes a fulfilled host when interruption wins before acquisition is delivered", () =>
239 Effect.gen(function* () {
240 const started = pending<void>();
241 const startup = pending<MuseSdkHost>();
242 const closing = pending<void>();
243 const shutdown = pending<void>();
244 const host = {
245 close: vi.fn(() => {
246 closing.resolve();
247 return shutdown.promise;
248 }),
249 } as unknown as MuseSdkHost;
250 let released = false;
251 const fiber = yield* Effect.forkChild(
252 createMuseSdkHostEffect({ binaryPath: "muse" }, () => {
253 started.resolve();
254 return startup.promise;
255 }).pipe(
256 Effect.ensuring(
257 Effect.sync(() => {
258 released = true;
259 }),
260 ),
261 ),
262 );
263 yield* Effect.promise(() => started.promise);
264 startup.resolve(host);
265 const interrupted = yield* Fiber.interrupt(fiber).pipe(
266 Effect.forkChild({ startImmediately: true }),
267 );
268 yield* Effect.promise(() => closing.promise);
269 expect(released).toBe(false);
270 shutdown.resolve();
271 yield* Fiber.join(interrupted);
272 expect(released).toBe(true);
273 expect(host.close).toHaveBeenCalledOnce();
274 }),
275 );
276});
277
278describe("Muse launch", () => {
279 const CMD = "C:\\Windows\\system32\\cmd.exe";
280 const onWindows = (resolved: string | undefined, env: NodeJS.ProcessEnv = { ComSpec: CMD }) =>
281 museLaunch("muse", env).pipe(
282 Effect.provideService(HostProcess.Platform, "win32"),
283 Effect.provideService(SpawnExecutableResolution, () => resolved),
284 );
285
286 it.effect("starts the binary itself off Windows", () =>
287 Effect.gen(function* () {
288 expect(yield* museLaunch("muse")).toEqual({ command: "muse", args: [] });
289 }).pipe(
290 Effect.provideService(HostProcess.Platform, "linux"),
291 Effect.provideService(SpawnExecutableResolution, () => {
292 throw new Error("Nothing is resolved off Windows.");
293 }),
294 ),
295 );
296
297 // Node spawns neither a bare `muse` nor a `.cmd` file without a shell on Windows.
298 it.effect("runs an exe directly and a launcher script under cmd.exe on Windows", () =>
299 Effect.gen(function* () {
300 expect(yield* onWindows("C:\\Muse\\muse.exe")).toEqual({
301 command: "C:\\Muse\\muse.exe",
302 args: [],
303 });
304 // Node quotes a path with spaces, and the `@` before it keeps cmd from dropping the quotes.
305 const spaced = "C:\\Users\\Jane Doe\\AppData\\Local\\Programs\\muse\\muse.cmd";
306 expect(yield* onWindows(spaced)).toEqual({ command: CMD, args: ["/d", "/c", "@", spaced] });
307 const spacedAmpersand = "C:\\Users\\R&D Team\\AppData\\Local\\Programs\\muse\\muse.cmd";
308 expect(yield* onWindows(spacedAmpersand)).toEqual({
309 command: CMD,
310 args: ["/d", "/c", "@", spacedAmpersand],
311 });
312 // Unquoted, cmd would split the command at `&`.
313 expect(yield* onWindows("C:\\Users\\R&D(1)\\muse.cmd")).toEqual({
314 command: CMD,
315 args: ["/d", "/c", "C:\\Users\\R^&D^(1^)\\muse.cmd"],
316 });
317 expect((yield* onWindows("C:\\Muse\\muse.bat", { SYSTEMROOT: "C:\\Windows" })).command).toBe(
318 "C:\\Windows\\System32\\cmd.exe",
319 );
320 // A binary that cannot be found is spawned as given, so the failure reports it.
321 expect(yield* onWindows(undefined)).toEqual({ command: "muse", args: [] });
322 }),
323 );
324
325 // cmd's quote rules only show with a real cmd.exe, spawning as the SDK does.
326 it.effect("starts a real launcher from a folder with spaces and cmd metacharacters", () =>
327 Effect.gen(function* () {
328 if ((yield* HostProcess.Platform) !== "win32") return;
329 const fs = yield* FileSystem.FileSystem;
330 const path = yield* Path.Path;
331 const folder = path.join(
332 yield* fs.makeTempDirectoryScoped({ prefix: "MuseLaunch-" }),
333 "R&D Team (x) @ ^y",
334 );
335 yield* fs.makeDirectory(folder);
336 const launcher = path.join(folder, "muse.cmd");
337 // Records its arguments next to itself; reaching the file at all proves cmd found it.
338 yield* fs.writeFileString(launcher, '@echo off\r\necho %*>"%~dp0args.txt"\r\n');
339 const launch = yield* museLaunch(launcher);
340 const run = NodeChildProcess.spawnSync(launch.command, [...launch.args, "serve", "--x"], {
341 cwd: folder,
342 encoding: "utf8",
343 });
344 expect(run.status, run.stderr).toBe(0);
345 expect((yield* fs.readFileString(path.join(folder, "args.txt"))).trim()).toBe("serve --x");
346 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
347 );
348
349 it.effect("starts Muse's Windows launcher through cmd.exe", () =>
350 Effect.gen(function* () {
351 const fake = mockSpawn();
352 const starting = yield* Effect.forkChild(
353 createMuseSdkHostEffect({
354 binaryPath: "muse",
355 environment: { ComSpec: CMD },
356 readOnly: true,
357 }),
358 );
359 yield* Effect.promise(() => fake.initializing.promise);
360 const spawned = vi.mocked(spawnMspConnection).mock.lastCall?.[0];
361 fake.startup.resolve(fake.ready);
362 const host = yield* Fiber.join(starting);
363 fake.shutdown.resolve({ code: 0, signal: null });
364 yield* Effect.promise(() => host.close());
365 expect(spawned).toMatchObject({
366 command: CMD,
367 args: [
368 "/d",
369 "/c",
370 "C:\\Muse\\muse.cmd",
371 "serve",
372 "--disable-shell",
373 "--disable-write",
374 "--no-session-log",
375 ],
376 });
377 }).pipe(
378 Effect.provideService(HostProcess.Platform, "win32"),
379 Effect.provideService(SpawnExecutableResolution, () => "C:\\Muse\\muse.cmd"),
380 ),
381 );
382
383 it.effect("passes launcher arguments to the binary that cmd.exe starts", () =>
384 Effect.gen(function* () {
385 const fake = mockSpawn();
386 const starting = yield* Effect.forkChild(
387 createMuseSdkHostEffect({
388 binaryPath: "muse",
389 launchArgs: ["--launcher-arg"],
390 environment: { ComSpec: CMD },
391 readOnly: true,
392 }),
393 );
394 yield* Effect.promise(() => fake.initializing.promise);
395 const spawned = vi.mocked(spawnMspConnection).mock.lastCall?.[0];
396 fake.startup.resolve(fake.ready);
397 const host = yield* Fiber.join(starting);
398 fake.shutdown.resolve({ code: 0, signal: null });
399 yield* Effect.promise(() => host.close());
400 expect(spawned).toMatchObject({
401 command: CMD,
402 args: [
403 "/d",
404 "/c",
405 "C:\\Muse\\muse.cmd",
406 "--launcher-arg",
407 "serve",
408 "--disable-shell",
409 "--disable-write",
410 "--no-session-log",
411 ],
412 });
413 }).pipe(
414 Effect.provideService(HostProcess.Platform, "win32"),
415 Effect.provideService(SpawnExecutableResolution, () => "C:\\Muse\\muse.cmd"),
416 ),
417 );
418});
419
420describe("Muse workspace root", () => {
421 it("writes Windows paths in their verbatim form", () => {
422 expect(museVerbatimPath("C:\\Users\\Person\\repo")).toBe("\\\\?\\C:\\Users\\Person\\repo");
423 expect(museVerbatimPath("\\\\server\\share\\repo")).toBe("\\\\?\\UNC\\server\\share\\repo");
424 expect(museVerbatimPath("\\\\?\\C:\\repo")).toBe("\\\\?\\C:\\repo");
425 });
426
427 it.effect("leaves the root alone off Windows", () =>
428 Effect.gen(function* () {
429 expect(yield* museWorkspaceRoot("/workspace/repo")).toBe("/workspace/repo");
430 }).pipe(Effect.provideService(HostProcess.Platform, "linux")),
431 );
432
433 it.effect("keeps a Windows root it cannot resolve, in verbatim form", () =>
434 Effect.gen(function* () {
435 expect(yield* museWorkspaceRoot("C:\\definitely-missing\\repo")).toBe(
436 "\\\\?\\C:\\definitely-missing\\repo",
437 );
438 // `/` would be taken literally inside a verbatim path.
439 expect(yield* museWorkspaceRoot("C:/definitely-missing/repo")).toBe(
440 "\\\\?\\C:\\definitely-missing\\repo",
441 );
442 expect(yield* museWorkspaceRoot("//server/share/missing")).toBe(
443 "\\\\?\\UNC\\server\\share\\missing",
444 );
445 }).pipe(Effect.provideService(HostProcess.Platform, "win32")),
446 );
447
448 // FileSystem.realPath keeps the case it is given; Muse rejects a root whose case differs from the disk's.
449 it.effect("fixes the case of a Windows root natively", () =>
450 Effect.gen(function* () {
451 if ((yield* HostProcess.Platform) !== "win32") return;
452 const fs = yield* FileSystem.FileSystem;
453 const path = yield* Path.Path;
454 const created = yield* fs.makeTempDirectoryScoped({ prefix: "MuseRoot-" });
455 const root = yield* museWorkspaceRoot(created.toLowerCase());
456 expect(root.startsWith("\\\\?\\")).toBe(true);
457 expect(root.endsWith(`\\${path.basename(created)}`)).toBe(true);
458 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
459 );
460});