| 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. |
| 2 | import * as NodeChildProcess from "node:child_process"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { spawnMspConnection, type MspHandshake, type SpawnedMspConnection } from "@muse-code/sdk"; |
| 5 | import { it } from "@effect/vitest"; |
| 6 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 7 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Fiber from "effect/Fiber"; |
| 10 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Path from "effect/Path"; |
| 12 | import { afterEach, describe, expect, vi } from "vite-plus/test"; |
| 13 | import { |
| 14 | createMuseSdkHost, |
| 15 | createMuseSdkHostEffect, |
| 16 | makeMuseEnvironment, |
| 17 | museApprovalMode, |
| 18 | museLaunch, |
| 19 | museVerbatimPath, |
| 20 | museWorkspaceRoot, |
| 21 | type MuseSdkHost, |
| 22 | } from "./sdk.ts"; |
| 23 | |
| 24 | vi.mock("@muse-code/sdk", () => ({ spawnMspConnection: vi.fn() })); |
| 25 | |
| 26 | function 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 | |
| 36 | function 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 | |
| 60 | afterEach(() => { |
| 61 | vi.useRealTimers(); |
| 62 | vi.clearAllMocks(); |
| 63 | }); |
| 64 | |
| 65 | describe("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 | |
| 278 | describe("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 | |
| 420 | describe("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 | }); |