1// @effect-diagnostics nodeBuiltinImport:off - the Windows launch smoke tests use filesystem notifications to observe real PowerShell launches.
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { assert, it } from "@effect/vitest";
7import * as ConfigProvider from "effect/ConfigProvider";
8import * as Deferred from "effect/Deferred";
9import * as Cause from "effect/Cause";
10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
12import * as Fiber from "effect/Fiber";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Path from "effect/Path";
16import * as Sink from "effect/Sink";
17import * as Stream from "effect/Stream";
18import * as TestClock from "effect/testing/TestClock";
19import { ChildProcess, ChildProcessSpawner } from "effect/process";
20
21import * as HostProcess from "@t3tools/shared/HostProcess";
22import { SpawnExecutableResolution } from "@t3tools/shared/shell";
23import * as ExternalLauncher from "./externalLauncher.ts";
24
25// Tests below write `#!/bin/sh` stubs into a real temp dir and hand that
26// directory to a posix-mocked resolver as PATH. On a Windows host the temp
27// path carries a drive letter, so the posix `:` split shatters it; there is
28// no posix executable to find there anyway.
29const windowsHost = HostProcess.Platform.defaultValue() === "win32";
30
31interface MockSpawnResult {
32 readonly exitCode?: number;
33 readonly stdout?: string;
34 /** Never deliver an exit code, like a child wedged on a broken desktop session. */
35 readonly stall?: boolean;
36}
37
38function makeMockDetachedHandle(input: MockSpawnResult & { readonly onUnref?: () => void } = {}) {
39 return ChildProcessSpawner.makeHandle({
40 pid: ChildProcessSpawner.ProcessId(1),
41 exitCode: input.stall
42 ? Effect.never
43 : Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)),
44 isRunning: Effect.succeed(true),
45 kill: () => Effect.void,
46 unref: Effect.sync(() => {
47 input.onUnref?.();
48 return Effect.void;
49 }),
50 stdin: Sink.drain,
51 stdout:
52 input.stdout === undefined
53 ? Stream.empty
54 : Stream.make(new TextEncoder().encode(input.stdout)),
55 stderr: Stream.empty,
56 all: Stream.empty,
57 getInputFd: () => Sink.drain,
58 getOutputFd: () => Stream.empty,
59 });
60}
61
62const layerTest = (input: {
63 readonly platform: NodeJS.Platform;
64 readonly env?: Record<string, string>;
65 readonly resolveExecutable?: (command: string) => string | undefined;
66 readonly onSpawn?: (command: ChildProcess.StandardCommand) => void;
67 readonly onUnref?: () => void;
68 readonly spawnResult?: (command: ChildProcess.StandardCommand) => MockSpawnResult | undefined;
69}) => {
70 const layerSpawner = Layer.succeed(
71 ChildProcessSpawner.ChildProcessSpawner,
72 ChildProcessSpawner.make((command) =>
73 Effect.sync(() => {
74 assert.equal(ChildProcess.isStandardCommand(command), true);
75 if (!ChildProcess.isStandardCommand(command)) {
76 throw new Error("Expected a standard command");
77 }
78 input.onSpawn?.(command);
79 return makeMockDetachedHandle({
80 ...(input.onUnref === undefined ? {} : { onUnref: input.onUnref }),
81 ...input.spawnResult?.(command),
82 });
83 }),
84 ),
85 );
86
87 return Layer.mergeAll(
88 ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, layerSpawner))),
89 Layer.succeed(HostProcess.Platform, input.platform),
90 Layer.succeed(
91 SpawnExecutableResolution,
92 (command) => input.resolveExecutable?.(command) ?? command,
93 ),
94 ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })),
95 );
96};
97
98it.effect("launches the default browser through the platform command", () => {
99 let spawned: ChildProcess.StandardCommand | undefined;
100 let didUnref = false;
101 return Effect.gen(function* () {
102 const launcher = yield* ExternalLauncher.ExternalLauncher;
103
104 yield* launcher.launchBrowser("https://example.com/some path");
105
106 assert.ok(spawned);
107 assert.equal(spawned.command, "xdg-open");
108 assert.deepEqual(spawned.args, ["https://example.com/some path"]);
109 assert.equal(spawned.options.detached, true);
110 assert.equal(didUnref, true);
111 }).pipe(
112 Effect.provide(
113 layerTest({
114 platform: "linux",
115 onSpawn: (command) => {
116 spawned = command;
117 },
118 onUnref: () => {
119 didUnref = true;
120 },
121 }),
122 ),
123 );
124});
125
126it.effect.each(["win32", "linux"] as const)(
127 "launches the browser through PowerShell on %s",
128 (platform) => {
129 let spawned: ChildProcess.StandardCommand | undefined;
130 return Effect.gen(function* () {
131 const launcher = yield* ExternalLauncher.ExternalLauncher;
132
133 yield* launcher.launchBrowser("https://example.com/some path");
134
135 assert.ok(spawned);
136 assert.equal(
137 spawned.command,
138 platform === "win32"
139 ? "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
140 : "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
141 );
142 assert.deepEqual(spawned.args.slice(0, -1), [
143 "-NoProfile",
144 "-NonInteractive",
145 "-ExecutionPolicy",
146 "Bypass",
147 "-EncodedCommand",
148 ]);
149 const encodedCommand = spawned.args[spawned.args.length - 1] ?? "";
150 const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le");
151 assert.equal(
152 decodedCommand,
153 "$ProgressPreference = 'SilentlyContinue'; Start 'https://example.com/some path'",
154 );
155 assert.equal(spawned.options.detached, platform !== "win32");
156 }).pipe(
157 Effect.provide(
158 layerTest({
159 platform,
160 env: { SYSTEMROOT: "C:\\Windows", WSL_DISTRO_NAME: "Ubuntu" },
161 onSpawn: (command) => {
162 spawned = command;
163 },
164 }),
165 ),
166 );
167 },
168);
169
170it.effect("launches an installed editor with platform-safe arguments", () =>
171 Effect.gen(function* () {
172 const fileSystem = yield* FileSystem.FileSystem;
173 const path = yield* Path.Path;
174 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
175 yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n");
176
177 let spawned: ChildProcess.StandardCommand | undefined;
178 yield* Effect.gen(function* () {
179 const launcher = yield* ExternalLauncher.ExternalLauncher;
180 yield* launcher.launchEditor({
181 editor: "vscode",
182 cwd: "C:\\workspace with spaces\\src\\index.ts:12:4",
183 });
184 }).pipe(
185 Effect.provide(
186 layerTest({
187 platform: "win32",
188 env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" },
189 resolveExecutable: (command) =>
190 command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command,
191 onSpawn: (command) => {
192 spawned = command;
193 },
194 }),
195 ),
196 );
197
198 assert.ok(spawned);
199 assert.equal(
200 spawned.command,
201 [
202 '^"C:\\Program^ Files\\Microsoft^ VS^ Code\\bin\\code.CMD^"',
203 '^"--goto^"',
204 '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"',
205 ].join(" "),
206 );
207 assert.deepEqual(spawned.args, []);
208 assert.equal(spawned.options.shell, true);
209 assert.equal(spawned.options.detached, true);
210 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
211);
212
213for (const platform of ["darwin", "linux"] as const) {
214 it.effect.skipIf(windowsHost)(`launches Cursor in classic IDE mode on ${platform}`, () =>
215 Effect.gen(function* () {
216 const fileSystem = yield* FileSystem.FileSystem;
217 const path = yield* Path.Path;
218 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
219 const cursorPath = path.join(binDir, "cursor");
220 yield* fileSystem.writeFileString(cursorPath, "#!/bin/sh\n");
221 yield* fileSystem.chmod(cursorPath, 0o755);
222
223 const spawned: ChildProcess.StandardCommand[] = [];
224 yield* Effect.gen(function* () {
225 const launcher = yield* ExternalLauncher.ExternalLauncher;
226 for (const cwd of [
227 "/workspace with spaces",
228 "/workspace with spaces/src/index.ts",
229 "/workspace with spaces/src/index.ts:12",
230 "/workspace with spaces/src/index.ts:12:4",
231 ]) {
232 yield* launcher.launchEditor({ editor: "cursor", cwd });
233 }
234 }).pipe(
235 Effect.provide(
236 layerTest({
237 platform,
238 env: { PATH: binDir },
239 onSpawn: (command) => spawned.push(command),
240 }),
241 ),
242 );
243
244 assert.deepEqual(
245 spawned.map((command) => ({ command: command.command, args: command.args })),
246 [
247 { command: "cursor", args: ["--classic", "/workspace with spaces"] },
248 { command: "cursor", args: ["--classic", "/workspace with spaces/src/index.ts"] },
249 {
250 command: "cursor",
251 args: ["--classic", "--goto", "/workspace with spaces/src/index.ts:12"],
252 },
253 {
254 command: "cursor",
255 args: ["--classic", "--goto", "/workspace with spaces/src/index.ts:12:4"],
256 },
257 ],
258 );
259 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
260 );
261}
262
263it.effect("launches Cursor in classic IDE mode through the Windows command shim", () =>
264 Effect.gen(function* () {
265 const fileSystem = yield* FileSystem.FileSystem;
266 const path = yield* Path.Path;
267 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
268 yield* fileSystem.writeFileString(path.join(binDir, "cursor.CMD"), "@echo off\r\n");
269
270 let spawned: ChildProcess.StandardCommand | undefined;
271 yield* Effect.gen(function* () {
272 const launcher = yield* ExternalLauncher.ExternalLauncher;
273 yield* launcher.launchEditor({
274 editor: "cursor",
275 cwd: "C:\\workspace with spaces\\src\\index.ts:12:4",
276 });
277 }).pipe(
278 Effect.provide(
279 layerTest({
280 platform: "win32",
281 env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" },
282 resolveExecutable: (command) =>
283 command === "cursor" ? "C:\\Program Files\\Cursor\\bin\\cursor.CMD" : command,
284 onSpawn: (command) => {
285 spawned = command;
286 },
287 }),
288 ),
289 );
290
291 assert.ok(spawned);
292 assert.equal(
293 spawned.command,
294 [
295 '^"C:\\Program^ Files\\Cursor\\bin\\cursor.CMD^"',
296 '^"--classic^"',
297 '^"--goto^"',
298 '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"',
299 ].join(" "),
300 );
301 assert.deepEqual(spawned.args, []);
302 assert.equal(spawned.options.shell, true);
303 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
304);
305
306it.effect.skipIf(windowsHost)("reveals a file in Finder with open -R on macOS", () =>
307 Effect.gen(function* () {
308 const fileSystem = yield* FileSystem.FileSystem;
309 const path = yield* Path.Path;
310 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
311 const openPath = path.join(binDir, "open");
312 yield* fileSystem.writeFileString(openPath, "#!/bin/sh\n");
313 yield* fileSystem.chmod(openPath, 0o755);
314
315 let spawned: ChildProcess.StandardCommand | undefined;
316 yield* Effect.gen(function* () {
317 const launcher = yield* ExternalLauncher.ExternalLauncher;
318 yield* launcher.launchEditor({
319 editor: "file-manager",
320 cwd: "/workspace/media/linux-mini-v2.mp4",
321 reveal: true,
322 });
323 }).pipe(
324 Effect.provide(
325 layerTest({
326 platform: "darwin",
327 env: { PATH: binDir },
328 onSpawn: (command) => {
329 spawned = command;
330 },
331 }),
332 ),
333 );
334
335 assert.ok(spawned);
336 assert.equal(spawned.command, "open");
337 assert.deepEqual(spawned.args, ["-R", "/workspace/media/linux-mini-v2.mp4"]);
338 assert.equal(spawned.options.detached, true);
339 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
340);
341
342it.effect("reveals a file in File Explorer through PowerShell on Windows", () =>
343 Effect.gen(function* () {
344 const fileSystem = yield* FileSystem.FileSystem;
345 const path = yield* Path.Path;
346 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
347 yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n");
348 // resolvePowerShellPath builds `${SYSTEMROOT}\System32\...` with Windows
349 // separators, which on the posix test filesystem is one file name.
350 const systemRoot = path.join(binDir, "system-root");
351 const powerShellPath = `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
352 yield* fileSystem.makeDirectory(path.dirname(powerShellPath), { recursive: true });
353 yield* fileSystem.writeFileString(powerShellPath, "");
354
355 let spawned: ChildProcess.StandardCommand | undefined;
356 const kind = yield* Effect.gen(function* () {
357 const launcher = yield* ExternalLauncher.ExternalLauncher;
358 yield* launcher.launchEditor({
359 editor: "file-manager",
360 // Web file links normalize separators even when the server runs on
361 // Windows. Explorer's `/select` switch requires Windows separators.
362 cwd: "C:/workspace with spaces/media/author's clip.mp4",
363 reveal: true,
364 });
365 return yield* launcher.resolveFileManagerRevealKind();
366 }).pipe(
367 Effect.provide(
368 layerTest({
369 platform: "win32",
370 env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD", SYSTEMROOT: systemRoot },
371 onSpawn: (command) => {
372 spawned = command;
373 },
374 }),
375 ),
376 );
377
378 assert.equal(kind, "file-explorer");
379 assert.ok(spawned);
380 assert.equal(spawned.command, powerShellPath);
381 assert.deepEqual(spawned.args.slice(0, -1), [
382 "-NoProfile",
383 "-NonInteractive",
384 "-ExecutionPolicy",
385 "Bypass",
386 "-EncodedCommand",
387 ]);
388 const encodedCommand = spawned.args[spawned.args.length - 1] ?? "";
389 const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le");
390 // explorer.exe expects `/select,"<path>"` with only the path quoted;
391 // PowerShell 5.1's Start-Process passes the argument string verbatim.
392 assert.equal(
393 decodedCommand,
394 "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')",
395 );
396 assert.equal(spawned.options.shell, false);
397 assert.equal(spawned.options.detached, false);
398 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
399);
400
401// Exercise real spawning, ignored stdio, unref, and scope cleanup. Only the
402// destination application is replaced, so tests never open Explorer or a browser.
403for (const kind of ["browser", "reveal"] as const) {
404 it.live.skipIf(!windowsHost)(
405 `completes a ${kind} launch through real PowerShell after the launcher scope closes`,
406 () =>
407 Effect.gen(function* () {
408 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-"));
409 const outputPath = NodePath.join(tempDir, "argv.txt");
410 const watcher = NodeFS.watch(tempDir, { signal: AbortSignal.timeout(20_000) });
411 try {
412 const recorderPath = NodePath.join(tempDir, "recorder.cmd");
413 const stagingPath = NodePath.join(tempDir, "argv.tmp");
414 NodeFS.writeFileSync(
415 recorderPath,
416 `@echo off\r\n>"${stagingPath}" echo(%*\r\nmove /y "${stagingPath}" "${outputPath}" >nul\r\n`,
417 );
418 // The rename publishes a complete result; no sleeps or file polling.
419 const recorded = new Promise<string>((resolve, reject) => {
420 watcher.on("change", () => {
421 if (NodeFS.existsSync(outputPath)) {
422 resolve(NodeFS.readFileSync(outputPath, "utf8").trim());
423 }
424 });
425 watcher.on("error", reject);
426 watcher.on("close", () =>
427 reject(new Error("PowerShell did not run the recording stub")),
428 );
429 });
430
431 const target = "C:/workspace with spaces/media/author's clip.mp4";
432 yield* Effect.all(
433 [
434 Effect.gen(function* () {
435 const realSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
436 const recordingSpawner = ChildProcessSpawner.make((command) => {
437 assert.ok(ChildProcess.isStandardCommand(command));
438 const source = Buffer.from(command.args.at(-1) ?? "", "base64")
439 .toString("utf16le")
440 .replace("'explorer.exe'", `'${recorderPath.replaceAll("'", "''")}'`);
441 return realSpawner.spawn(
442 ChildProcess.make(
443 command.command,
444 [
445 ...command.args.slice(0, -1),
446 Buffer.from(source, "utf16le").toString("base64"),
447 ],
448 command.options,
449 ),
450 );
451 });
452 const launcher = yield* ExternalLauncher.make.pipe(
453 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, recordingSpawner),
454 );
455 if (kind === "browser") {
456 yield* launcher.launchBrowser(recorderPath);
457 } else {
458 yield* launcher.launchEditor({
459 editor: "file-manager",
460 cwd: target,
461 reveal: true,
462 });
463 }
464 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
465 Effect.promise(() =>
466 recorded.then((value) => {
467 assert.equal(
468 value,
469 kind === "browser" ? "" : `/select,"${target.replaceAll("/", "\\")}"`,
470 );
471 }),
472 ),
473 ],
474 { concurrency: "unbounded" },
475 );
476 } finally {
477 watcher.close();
478 // The recording stub's cmd.exe can still hold the directory for a
479 // moment after it publishes its output, which Windows reports as EPERM.
480 NodeFS.rmSync(tempDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
481 }
482 }),
483 { timeout: 30_000 },
484 );
485}
486
487it.effect("does not advertise reveal on Windows when PowerShell is missing", () =>
488 Effect.gen(function* () {
489 const fileSystem = yield* FileSystem.FileSystem;
490 const path = yield* Path.Path;
491 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
492 yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n");
493
494 const result = yield* Effect.gen(function* () {
495 const launcher = yield* ExternalLauncher.ExternalLauncher;
496 return {
497 kind: yield* launcher.resolveFileManagerRevealKind(),
498 editors: yield* launcher.resolveAvailableEditors(),
499 };
500 }).pipe(
501 Effect.provide(
502 layerTest({
503 platform: "win32",
504 env: {
505 PATH: binDir,
506 PATHEXT: ".COM;.EXE;.BAT;.CMD",
507 SYSTEMROOT: path.join(binDir, "missing-system-root"),
508 },
509 }),
510 ),
511 );
512
513 // Plain "open in file manager" still works through explorer; only the
514 // reveal capability, which launches PowerShell, must stay hidden.
515 assert.equal(result.editors.includes("file-manager"), true);
516 assert.isUndefined(result.kind);
517 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
518);
519
520it.effect.skipIf(windowsHost)(
521 "reveals a WSL file in Windows File Explorer through its UNC path",
522 () =>
523 Effect.gen(function* () {
524 const fileSystem = yield* FileSystem.FileSystem;
525 const path = yield* Path.Path;
526 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
527 for (const name of ["explorer.exe", "powershell.exe", "xdg-open"]) {
528 const filePath = path.join(binDir, name);
529 yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n");
530 yield* fileSystem.chmod(filePath, 0o755);
531 }
532
533 let spawned: ChildProcess.StandardCommand | undefined;
534 const result = yield* Effect.gen(function* () {
535 const launcher = yield* ExternalLauncher.ExternalLauncher;
536 const kind = yield* launcher.resolveFileManagerRevealKind();
537 const editors = yield* launcher.resolveAvailableEditors();
538 yield* launcher.launchEditor({
539 editor: "file-manager",
540 cwd: "/home/t3/workspace/media/clip.mp4",
541 reveal: true,
542 });
543 return { kind, editors };
544 }).pipe(
545 Effect.provide(
546 layerTest({
547 platform: "linux",
548 env: {
549 PATH: binDir,
550 WSL_DISTRO_NAME: "Ubuntu-24.04",
551 WSL_INTEROP: "/run/WSL/1_interop",
552 },
553 onSpawn: (command) => {
554 spawned = command;
555 },
556 }),
557 ),
558 );
559
560 assert.equal(result.kind, "file-explorer");
561 assert.equal(result.editors.includes("file-manager"), true);
562 assert.ok(spawned);
563 // The reveal routes through interop PowerShell so Explorer receives its
564 // raw `/select,"<path>"` switch even for spaced paths.
565 assert.equal(spawned.command, "powershell.exe");
566 assert.equal(spawned.options.detached, true);
567 const encodedCommand = spawned.args[spawned.args.length - 1] ?? "";
568 const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le");
569 assert.equal(
570 decodedCommand,
571 "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace\\media\\clip.mp4' + '\"')",
572 );
573 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
574);
575
576it.effect.skipIf(windowsHost)(
577 "does not advertise reveal from WSL when interop PowerShell is missing",
578 () =>
579 Effect.gen(function* () {
580 const fileSystem = yield* FileSystem.FileSystem;
581 const path = yield* Path.Path;
582 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
583 const explorerPath = path.join(binDir, "explorer.exe");
584 yield* fileSystem.writeFileString(explorerPath, "");
585 yield* fileSystem.chmod(explorerPath, 0o755);
586
587 const result = yield* Effect.gen(function* () {
588 const launcher = yield* ExternalLauncher.ExternalLauncher;
589 return {
590 kind: yield* launcher.resolveFileManagerRevealKind(),
591 editors: yield* launcher.resolveAvailableEditors(),
592 };
593 }).pipe(
594 Effect.provide(
595 layerTest({
596 platform: "linux",
597 env: {
598 PATH: binDir,
599 WSL_DISTRO_NAME: "Ubuntu-24.04",
600 WSL_INTEROP: "/run/WSL/1_interop",
601 },
602 }),
603 ),
604 );
605
606 assert.equal(result.editors.includes("file-manager"), true);
607 assert.isUndefined(result.kind);
608 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
609);
610
611// When interop PowerShell is missing the capability advertises the Linux
612// "files" kind (or nothing), so the reveal must open the Linux file manager
613// the label promised even though plain open still prefers File Explorer.
614it.effect.skipIf(windowsHost)(
615 "reveals through the Linux file manager when WSL lacks interop PowerShell",
616 () =>
617 Effect.gen(function* () {
618 const fileSystem = yield* FileSystem.FileSystem;
619 const path = yield* Path.Path;
620 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
621 for (const name of ["explorer.exe", "xdg-open", "xdg-mime"]) {
622 const filePath = path.join(binDir, name);
623 yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n");
624 yield* fileSystem.chmod(filePath, 0o755);
625 }
626
627 const spawnedCommands: ChildProcess.StandardCommand[] = [];
628 const kind = yield* Effect.gen(function* () {
629 const launcher = yield* ExternalLauncher.ExternalLauncher;
630 const revealKind = yield* launcher.resolveFileManagerRevealKind();
631 yield* launcher.launchEditor({
632 editor: "file-manager",
633 cwd: "/home/t3/workspace/media/clip.mp4",
634 reveal: true,
635 });
636 return revealKind;
637 }).pipe(
638 Effect.provide(
639 layerTest({
640 platform: "linux",
641 env: {
642 PATH: binDir,
643 WSL_DISTRO_NAME: "Ubuntu-24.04",
644 WSL_INTEROP: "/run/WSL/1_interop",
645 DISPLAY: ":0",
646 },
647 onSpawn: (command) => {
648 spawnedCommands.push(command);
649 },
650 spawnResult: (command) =>
651 command.command === "xdg-mime"
652 ? { stdout: "org.gnome.Nautilus.desktop\n" }
653 : undefined,
654 }),
655 ),
656 );
657
658 assert.equal(kind, "files");
659 const launch = spawnedCommands.find((command) => command.command === "xdg-open");
660 assert.ok(launch);
661 assert.deepEqual(launch.args, ["/home/t3/workspace/media"]);
662 assert.isUndefined(spawnedCommands.find((command) => command.command === "explorer.exe"));
663 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
664);
665
666// Interop can exist without `explorer.exe` on PATH (appendWindowsPath=false)
667// while WSLg still provides a working Linux file manager; the host must keep
668// the Linux open/reveal path instead of losing the editor entirely.
669it.effect.skipIf(windowsHost)(
670 "falls back to the Linux file manager when WSL lacks the Explorer bridge",
671 () =>
672 Effect.gen(function* () {
673 const fileSystem = yield* FileSystem.FileSystem;
674 const path = yield* Path.Path;
675 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
676 for (const name of ["xdg-open", "xdg-mime"]) {
677 const filePath = path.join(binDir, name);
678 yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n");
679 yield* fileSystem.chmod(filePath, 0o755);
680 }
681
682 const spawnedCommands: ChildProcess.StandardCommand[] = [];
683 const result = yield* Effect.gen(function* () {
684 const launcher = yield* ExternalLauncher.ExternalLauncher;
685 const editors = yield* launcher.resolveAvailableEditors();
686 const kind = yield* launcher.resolveFileManagerRevealKind();
687 yield* launcher.launchEditor({
688 editor: "file-manager",
689 cwd: "/home/t3/workspace/media/clip.mp4",
690 reveal: true,
691 });
692 return { editors, kind };
693 }).pipe(
694 Effect.provide(
695 layerTest({
696 platform: "linux",
697 env: {
698 PATH: binDir,
699 WSL_DISTRO_NAME: "Ubuntu-24.04",
700 WSL_INTEROP: "/run/WSL/1_interop",
701 DISPLAY: ":0",
702 },
703 onSpawn: (command) => {
704 spawnedCommands.push(command);
705 },
706 spawnResult: (command) =>
707 command.command === "xdg-mime"
708 ? { stdout: "org.gnome.Nautilus.desktop\n" }
709 : undefined,
710 }),
711 ),
712 );
713
714 assert.equal(result.editors.includes("file-manager"), true);
715 assert.equal(result.kind, "files");
716 const launch = spawnedCommands.find((command) => command.command === "xdg-open");
717 assert.ok(launch);
718 assert.deepEqual(launch.args, ["/home/t3/workspace/media"]);
719 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
720);
721
722it.effect.skipIf(windowsHost)(
723 "falls back to opening the containing directory for WSL paths Explorer cannot select",
724 () =>
725 Effect.gen(function* () {
726 const fileSystem = yield* FileSystem.FileSystem;
727 const path = yield* Path.Path;
728 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
729 for (const name of ["explorer.exe", "powershell.exe"]) {
730 const filePath = path.join(binDir, name);
731 yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n");
732 yield* fileSystem.chmod(filePath, 0o755);
733 }
734
735 let spawned: ChildProcess.StandardCommand | undefined;
736 yield* Effect.gen(function* () {
737 const launcher = yield* ExternalLauncher.ExternalLauncher;
738 yield* launcher.launchEditor({
739 editor: "file-manager",
740 cwd: '/home/t3/work "quoted"/clip.mp4',
741 reveal: true,
742 });
743 }).pipe(
744 Effect.provide(
745 layerTest({
746 platform: "linux",
747 env: {
748 PATH: binDir,
749 WSL_DISTRO_NAME: "Ubuntu-24.04",
750 WSL_INTEROP: "/run/WSL/1_interop",
751 },
752 onSpawn: (command) => {
753 spawned = command;
754 },
755 }),
756 ),
757 );
758
759 // Explorer's raw switch cannot express a double quote, so the launch
760 // opens the parent directory instead of misparsing a /select argument.
761 assert.ok(spawned);
762 assert.equal(spawned.command, "explorer.exe");
763 assert.deepEqual(spawned.args, ['\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\work "quoted"']);
764 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
765);
766
767it.effect.skipIf(windowsHost)("reveals by opening the containing directory on Linux", () =>
768 Effect.gen(function* () {
769 const fileSystem = yield* FileSystem.FileSystem;
770 const path = yield* Path.Path;
771 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
772 for (const name of ["xdg-open", "xdg-mime"]) {
773 const filePath = path.join(binDir, name);
774 yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n");
775 yield* fileSystem.chmod(filePath, 0o755);
776 }
777
778 const spawnedCommands: ChildProcess.StandardCommand[] = [];
779 yield* Effect.gen(function* () {
780 const launcher = yield* ExternalLauncher.ExternalLauncher;
781 yield* launcher.launchEditor({
782 editor: "file-manager",
783 cwd: "/workspace/media/linux-mini-v2.mp4",
784 reveal: true,
785 });
786 }).pipe(
787 Effect.provide(
788 layerTest({
789 platform: "linux",
790 env: { PATH: binDir, DISPLAY: ":0" },
791 onSpawn: (command) => {
792 spawnedCommands.push(command);
793 },
794 spawnResult: (command) =>
795 command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined,
796 }),
797 ),
798 );
799
800 const spawned = spawnedCommands.find((command) => command.command === "xdg-open");
801 assert.ok(spawned);
802 assert.deepEqual(spawned.args, ["/workspace/media"]);
803 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
804);
805
806it.effect.skipIf(windowsHost)(
807 "does not advertise a Linux file manager without a graphical session",
808 () =>
809 Effect.gen(function* () {
810 const fileSystem = yield* FileSystem.FileSystem;
811 const path = yield* Path.Path;
812 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
813 const xdgOpenPath = path.join(binDir, "xdg-open");
814 yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n");
815 yield* fileSystem.chmod(xdgOpenPath, 0o755);
816
817 const editors = yield* Effect.gen(function* () {
818 const launcher = yield* ExternalLauncher.ExternalLauncher;
819 return yield* launcher.resolveAvailableEditors();
820 }).pipe(Effect.provide(layerTest({ platform: "linux", env: { PATH: binDir } })));
821
822 assert.equal(editors.includes("file-manager"), false);
823 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
824);
825
826it.effect.skipIf(windowsHost)(
827 "advertises a Linux file manager when a directory handler is installed",
828 () =>
829 Effect.gen(function* () {
830 const fileSystem = yield* FileSystem.FileSystem;
831 const path = yield* Path.Path;
832 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
833 for (const name of ["xdg-open", "xdg-mime"]) {
834 const filePath = path.join(binDir, name);
835 yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n");
836 yield* fileSystem.chmod(filePath, 0o755);
837 }
838
839 let probe: ChildProcess.StandardCommand | undefined;
840 const editors = yield* Effect.gen(function* () {
841 const launcher = yield* ExternalLauncher.ExternalLauncher;
842 return yield* launcher.resolveAvailableEditors();
843 }).pipe(
844 Effect.provide(
845 layerTest({
846 platform: "linux",
847 env: { PATH: binDir, DISPLAY: ":0" },
848 onSpawn: (command) => {
849 probe = command;
850 },
851 spawnResult: (command) =>
852 command.command === "xdg-mime"
853 ? { stdout: "org.gnome.Nautilus.desktop\n" }
854 : undefined,
855 }),
856 ),
857 );
858
859 assert.equal(editors.includes("file-manager"), true);
860 assert.ok(probe);
861 assert.equal(probe.command, "xdg-mime");
862 assert.deepEqual(probe.args, ["query", "default", "inode/directory"]);
863 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
864);
865
866// `xdg-open` with a display variable but no `inode/directory` handler exits
867// nonzero after the launch has already detached: without this gate the server
868// advertises a reveal that is a silent no-op.
869it.effect.skipIf(windowsHost)(
870 "does not advertise a Linux file manager without a directory handler",
871 () =>
872 Effect.gen(function* () {
873 const fileSystem = yield* FileSystem.FileSystem;
874 const path = yield* Path.Path;
875 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
876 for (const name of ["xdg-open", "xdg-mime"]) {
877 const filePath = path.join(binDir, name);
878 yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n");
879 yield* fileSystem.chmod(filePath, 0o755);
880 }
881
882 const editors = yield* Effect.gen(function* () {
883 const launcher = yield* ExternalLauncher.ExternalLauncher;
884 return yield* launcher.resolveAvailableEditors();
885 }).pipe(
886 Effect.provide(
887 layerTest({
888 platform: "linux",
889 env: { PATH: binDir, DISPLAY: ":0" },
890 spawnResult: (command) => (command.command === "xdg-mime" ? { stdout: "" } : undefined),
891 }),
892 ),
893 );
894
895 assert.equal(editors.includes("file-manager"), false);
896 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
897);
898
899it.effect.skipIf(windowsHost)(
900 "does not advertise a Linux file manager when the handler query fails",
901 () =>
902 Effect.gen(function* () {
903 const fileSystem = yield* FileSystem.FileSystem;
904 const path = yield* Path.Path;
905 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
906 for (const name of ["xdg-open", "xdg-mime"]) {
907 const filePath = path.join(binDir, name);
908 yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n");
909 yield* fileSystem.chmod(filePath, 0o755);
910 }
911
912 const editors = yield* Effect.gen(function* () {
913 const launcher = yield* ExternalLauncher.ExternalLauncher;
914 return yield* launcher.resolveAvailableEditors();
915 }).pipe(
916 Effect.provide(
917 layerTest({
918 platform: "linux",
919 env: { PATH: binDir, DISPLAY: ":0" },
920 spawnResult: (command) =>
921 command.command === "xdg-mime"
922 ? { exitCode: 47, stdout: "org.gnome.Nautilus.desktop\n" }
923 : undefined,
924 }),
925 ),
926 );
927
928 assert.equal(editors.includes("file-manager"), false);
929 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
930);
931
932// The handler probe carries its own timeout because the editor scan's outer
933// timeout in server.getConfig degrades to an EMPTY editor list: a wedged
934// xdg-mime must cost only the file manager, never the other editors. Runs on
935// the live clock so the probe's real timeout fires.
936it.live.skipIf(windowsHost)("a stalled handler probe drops only the file manager", () =>
937 Effect.gen(function* () {
938 const fileSystem = yield* FileSystem.FileSystem;
939 const path = yield* Path.Path;
940 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
941 for (const name of ["xdg-open", "xdg-mime", "code"]) {
942 const filePath = path.join(binDir, name);
943 yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n");
944 yield* fileSystem.chmod(filePath, 0o755);
945 }
946
947 const editors = yield* Effect.gen(function* () {
948 const launcher = yield* ExternalLauncher.ExternalLauncher;
949 return yield* launcher.resolveAvailableEditors();
950 }).pipe(
951 Effect.provide(
952 layerTest({
953 platform: "linux",
954 env: { PATH: binDir, DISPLAY: ":0" },
955 spawnResult: (command) => (command.command === "xdg-mime" ? { stall: true } : undefined),
956 }),
957 ),
958 );
959
960 assert.equal(editors.includes("vscode"), true);
961 assert.equal(editors.includes("file-manager"), false);
962 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
963);
964
965it.effect.skipIf(windowsHost)(
966 "does not advertise a Linux file manager when xdg-mime is missing",
967 () =>
968 Effect.gen(function* () {
969 const fileSystem = yield* FileSystem.FileSystem;
970 const path = yield* Path.Path;
971 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
972 const xdgOpenPath = path.join(binDir, "xdg-open");
973 yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n");
974 yield* fileSystem.chmod(xdgOpenPath, 0o755);
975
976 const editors = yield* Effect.gen(function* () {
977 const launcher = yield* ExternalLauncher.ExternalLauncher;
978 return yield* launcher.resolveAvailableEditors();
979 }).pipe(
980 Effect.provide(layerTest({ platform: "linux", env: { PATH: binDir, DISPLAY: ":0" } })),
981 );
982
983 assert.equal(editors.includes("file-manager"), false);
984 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
985);
986
987it.effect("discovers editors through the service API", () =>
988 Effect.gen(function* () {
989 const fileSystem = yield* FileSystem.FileSystem;
990 const path = yield* Path.Path;
991 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
992 yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n");
993 yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n");
994
995 const editors = yield* Effect.gen(function* () {
996 const launcher = yield* ExternalLauncher.ExternalLauncher;
997 return yield* launcher.resolveAvailableEditors();
998 }).pipe(
999 Effect.provide(
1000 layerTest({
1001 platform: "win32",
1002 env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" },
1003 }),
1004 ),
1005 );
1006
1007 assert.equal(editors.includes("vscode"), true);
1008 assert.equal(editors.includes("file-manager"), true);
1009 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1010);
1011
1012for (const { platform, installPath, editor, args } of [
1013 {
1014 platform: "darwin",
1015 installPath: "Applications/Antigravity IDE.app/Contents/Resources/app/bin/antigravity-ide",
1016 editor: "antigravity",
1017 args: ["--goto", "/workspace with spaces/file.ts:12:4"],
1018 },
1019 {
1020 platform: "linux",
1021 installPath: ".local/bin/antigravity-ide",
1022 editor: "antigravity",
1023 args: ["--goto", "/workspace with spaces/file.ts:12:4"],
1024 },
1025 {
1026 platform: "darwin",
1027 installPath: "Applications/Cursor.app/Contents/Resources/app/bin/code",
1028 editor: "cursor",
1029 args: ["--classic", "--goto", "/workspace with spaces/file.ts:12:4"],
1030 },
1031 {
1032 platform: "darwin",
1033 installPath: "Applications/Kiro.app/Contents/Resources/app/bin/code",
1034 editor: "kiro",
1035 args: ["--goto", "/workspace with spaces/file.ts:12:4"],
1036 },
1037 {
1038 platform: "darwin",
1039 installPath: "Applications/WebStorm.app/Contents/MacOS/webstorm",
1040 editor: "webstorm",
1041 args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"],
1042 },
1043 {
1044 platform: "darwin",
1045 installPath: "Applications/Zed.app/Contents/MacOS/cli",
1046 editor: "zed",
1047 args: ["/workspace with spaces/file.ts:12:4"],
1048 },
1049 {
1050 platform: "win32",
1051 installPath: "Programs/Cursor/resources/app/bin/cursor.cmd",
1052 editor: "cursor",
1053 args: ['^"--classic^"', '^"--goto^"', '^"/workspace^ with^ spaces/file.ts:12:4^"'],
1054 },
1055 {
1056 platform: "win32",
1057 installPath: "Programs/Microsoft VS Code/bin/code.cmd",
1058 editor: "vscode",
1059 args: ['^"--goto^"', '^"/workspace^ with^ spaces/file.ts:12:4^"'],
1060 },
1061 {
1062 platform: "win32",
1063 installPath: "Programs/JetBrains/WebStorm 2026.2/bin/webstorm64.exe",
1064 editor: "webstorm",
1065 args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"],
1066 },
1067 {
1068 platform: "win32",
1069 installPath: "Programs/WebStorm/bin/webstorm64.exe",
1070 editor: "webstorm",
1071 args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"],
1072 },
1073 {
1074 platform: "win32",
1075 installPath: "Programs/Zed/bin/zed.exe",
1076 editor: "zed",
1077 args: ["/workspace with spaces/file.ts:12:4"],
1078 },
1079 {
1080 platform: "linux",
1081 installPath: ".local/share/JetBrains/Toolbox/scripts/idea",
1082 editor: "idea",
1083 args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"],
1084 },
1085 {
1086 platform: "linux",
1087 installPath: ".local/bin/zed",
1088 editor: "zed",
1089 args: ["/workspace with spaces/file.ts:12:4"],
1090 },
1091] as const) {
1092 it.effect.skipIf(windowsHost && platform !== "win32")(
1093 `discovers and launches ${editor} outside PATH on ${platform}`,
1094 () =>
1095 Effect.gen(function* () {
1096 const fs = yield* FileSystem.FileSystem;
1097 const path = yield* Path.Path;
1098 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor installs-" });
1099 const executable = path.join(home, installPath);
1100 yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
1101 yield* fs.writeFileString(executable, "#!/bin/sh\n");
1102 yield* fs.chmod(executable, 0o755);
1103 let spawned: ChildProcess.StandardCommand | undefined;
1104 yield* Effect.gen(function* () {
1105 const launcher = yield* ExternalLauncher.ExternalLauncher;
1106 assert.include(yield* launcher.resolveAvailableEditors(), editor);
1107 yield* launcher.launchEditor({ editor, cwd: "/workspace with spaces/file.ts:12:4" });
1108 }).pipe(
1109 Effect.provide(
1110 layerTest({
1111 platform,
1112 env: { HOME: home, LOCALAPPDATA: home, PATH: path.join(home, "empty") },
1113 onSpawn: (command) => {
1114 spawned = command;
1115 },
1116 }),
1117 ),
1118 );
1119 assert.ok(spawned);
1120 const shim = executable.endsWith(".cmd");
1121 assert.equal(
1122 spawned.command,
1123 shim ? [`^"${executable.replaceAll(" ", "^ ")}^"`, ...args].join(" ") : executable,
1124 );
1125 assert.deepEqual(spawned.args, shim ? [] : args);
1126 assert.equal(spawned.options.shell, shim);
1127 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1128 );
1129}
1130
1131for (const cwd of ["/workspace with spaces/", "/workspace with spaces/file.ts:12:4"]) {
1132 it.effect.skipIf(windowsHost)(
1133 `opens ${cwd} with the macOS Zed bundle when another zed is on PATH`,
1134 () =>
1135 Effect.gen(function* () {
1136 const fs = yield* FileSystem.FileSystem;
1137 const path = yield* Path.Path;
1138 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-collision-" });
1139 const executable = path.join(home, "Applications/Zed.app/Contents/MacOS/cli");
1140 const binDir = path.join(home, "zfs/bin");
1141 yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
1142 yield* fs.makeDirectory(binDir, { recursive: true });
1143 for (const command of [executable, path.join(binDir, "zed")]) {
1144 yield* fs.writeFileString(command, "#!/bin/sh\n");
1145 yield* fs.chmod(command, 0o755);
1146 }
1147
1148 let spawned: ChildProcess.StandardCommand | undefined;
1149 yield* Effect.gen(function* () {
1150 const launcher = yield* ExternalLauncher.ExternalLauncher;
1151 assert.include(yield* launcher.resolveAvailableEditors(), "zed");
1152 yield* launcher.launchEditor({ editor: "zed", cwd });
1153 }).pipe(
1154 Effect.provide(
1155 layerTest({
1156 platform: "darwin",
1157 env: { HOME: home, PATH: binDir },
1158 onSpawn: (command) => {
1159 spawned = command;
1160 },
1161 }),
1162 ),
1163 );
1164 assert.ok(spawned);
1165 assert.equal(spawned.command, executable);
1166 assert.deepEqual(spawned.args, [cwd]);
1167 assert.equal(spawned.options.shell, false);
1168 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1169 );
1170}
1171
1172// JetBrains Toolbox names its bundles after the app and the installed version
1173// (`IntelliJ IDEA 2026.1.4.app`), so the darwin branch has to list the
1174// Applications directories the way the win32 branch does instead of probing
1175// exact `<name>.app` paths. The fixtures only use the mocked platform and a
1176// temp directory, so this runs on a Windows host too.
1177it.effect.each([
1178 { editor: "webstorm", installPath: "Applications/WebStorm 2026.2.app/Contents/MacOS/webstorm" },
1179 { editor: "idea", installPath: "Applications/IntelliJ IDEA 2026.1.4.app/Contents/MacOS/idea" },
1180] as const)(
1181 "discovers and launches $editor from a versioned macOS bundle",
1182 ({ editor, installPath }) =>
1183 Effect.gen(function* () {
1184 const fs = yield* FileSystem.FileSystem;
1185 const path = yield* Path.Path;
1186 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-versioned-" });
1187 const executable = path.join(home, installPath);
1188 yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
1189 yield* fs.writeFileString(executable, "#!/bin/sh\n");
1190 yield* fs.chmod(executable, 0o755);
1191
1192 let spawned: ChildProcess.StandardCommand | undefined;
1193 yield* Effect.gen(function* () {
1194 const launcher = yield* ExternalLauncher.ExternalLauncher;
1195 assert.include(yield* launcher.resolveAvailableEditors(), editor);
1196 yield* launcher.launchEditor({ editor, cwd: "/workspace with spaces/file.ts:12:4" });
1197 }).pipe(
1198 Effect.provide(
1199 layerTest({
1200 platform: "darwin",
1201 env: { HOME: home, PATH: path.join(home, "empty") },
1202 onSpawn: (command) => {
1203 spawned = command;
1204 },
1205 }),
1206 ),
1207 );
1208
1209 assert.ok(spawned);
1210 assert.equal(spawned.command, executable);
1211 assert.deepEqual(spawned.args, [
1212 "--line",
1213 "12",
1214 "--column",
1215 "4",
1216 "/workspace with spaces/file.ts",
1217 ]);
1218 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1219);
1220
1221// An app whose name merely starts with the same letters is a different IDE, and
1222// an exact `<name>.app` bundle keeps resolving the way it did before.
1223it.effect("matches macOS bundles on the name boundary only", () =>
1224 Effect.gen(function* () {
1225 const fs = yield* FileSystem.FileSystem;
1226 const path = yield* Path.Path;
1227 const unrelatedHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-unrelated-" });
1228 const unrelated = path.join(
1229 unrelatedHome,
1230 "Applications/WebStormHelper.app/Contents/MacOS/webstorm",
1231 );
1232 yield* fs.makeDirectory(path.dirname(unrelated), { recursive: true });
1233 yield* fs.writeFileString(unrelated, "#!/bin/sh\n");
1234 yield* fs.chmod(unrelated, 0o755);
1235
1236 const editorsFor = (home: string) =>
1237 Effect.gen(function* () {
1238 const launcher = yield* ExternalLauncher.ExternalLauncher;
1239 return yield* launcher.resolveAvailableEditors();
1240 }).pipe(
1241 Effect.provide(
1242 layerTest({ platform: "darwin", env: { HOME: home, PATH: path.join(home, "bin") } }),
1243 ),
1244 );
1245
1246 assert.notInclude(yield* editorsFor(unrelatedHome), "webstorm");
1247
1248 const exactHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-exact-" });
1249 const exact = path.join(exactHome, "Applications/WebStorm.app/Contents/MacOS/webstorm");
1250 yield* fs.makeDirectory(path.dirname(exact), { recursive: true });
1251 yield* fs.writeFileString(exact, "#!/bin/sh\n");
1252 yield* fs.chmod(exact, 0o755);
1253
1254 assert.include(yield* editorsFor(exactHome), "webstorm");
1255 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1256);
1257
1258// `Visual Studio Code - Insiders.app` starts with the stable VS Code install
1259// name, so a plain prefix match let the stable editor resolve to the Insiders
1260// bundle. Insiders has to stay reachable under its own name, stable VS Code has
1261// to stay unresolved.
1262it.effect("keeps the macOS VS Code Insiders bundle out of stable VS Code", () =>
1263 Effect.gen(function* () {
1264 const fs = yield* FileSystem.FileSystem;
1265 const path = yield* Path.Path;
1266 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-insiders-" });
1267 // The real Insiders bundle ships both `code-insiders` and `code`.
1268 const bin = path.join(
1269 home,
1270 "Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin",
1271 );
1272 yield* fs.makeDirectory(bin, { recursive: true });
1273 for (const name of ["code", "code-insiders"]) {
1274 yield* fs.writeFileString(path.join(bin, name), "#!/bin/sh\n");
1275 yield* fs.chmod(path.join(bin, name), 0o755);
1276 }
1277
1278 let spawned: ChildProcess.StandardCommand | undefined;
1279 const editors = yield* Effect.gen(function* () {
1280 const launcher = yield* ExternalLauncher.ExternalLauncher;
1281 const available = yield* launcher.resolveAvailableEditors();
1282 yield* launcher.launchEditor({ editor: "vscode-insiders", cwd: "/workspace/file.ts" });
1283 return available;
1284 }).pipe(
1285 Effect.provide(
1286 layerTest({
1287 platform: "darwin",
1288 env: { HOME: home, PATH: path.join(home, "empty") },
1289 onSpawn: (command) => {
1290 spawned = command;
1291 },
1292 }),
1293 ),
1294 );
1295
1296 assert.notInclude(editors, "vscode");
1297 assert.include(editors, "vscode-insiders");
1298 assert.equal(spawned?.command, path.join(bin, "code-insiders"));
1299 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1300);
1301
1302// `agy` is the standalone Antigravity CLI, which installs to ~/.local/bin on
1303// macOS and Linux and to its own bin folder on Windows. It is not the IDE.
1304for (const { platform, installPath, onPath } of [
1305 { platform: "darwin", installPath: ".local/bin/agy", onPath: true },
1306 { platform: "linux", installPath: ".local/bin/agy", onPath: false },
1307 { platform: "win32", installPath: "agy/bin/agy.cmd", onPath: true },
1308] as const) {
1309 it.effect.skipIf(windowsHost && platform !== "win32")(
1310 `does not report the agy CLI as the Antigravity IDE on ${platform}`,
1311 () =>
1312 Effect.gen(function* () {
1313 const fs = yield* FileSystem.FileSystem;
1314 const path = yield* Path.Path;
1315 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-cli-" });
1316 const executable = path.join(home, installPath);
1317 yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
1318 yield* fs.writeFileString(executable, "#!/bin/sh\n");
1319 yield* fs.chmod(executable, 0o755);
1320 const editors = yield* Effect.gen(function* () {
1321 const launcher = yield* ExternalLauncher.ExternalLauncher;
1322 return yield* launcher.resolveAvailableEditors();
1323 }).pipe(
1324 Effect.provide(
1325 layerTest({
1326 platform,
1327 env: {
1328 HOME: home,
1329 LOCALAPPDATA: home,
1330 PATH: onPath ? path.dirname(executable) : path.join(home, "empty"),
1331 PATHEXT: ".COM;.EXE;.BAT;.CMD",
1332 },
1333 }),
1334 ),
1335 );
1336 assert.notInclude(editors, "antigravity");
1337 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1338 );
1339}
1340
1341it.effect.skipIf(windowsHost)("ignores unusable app bundles and keeps PATH launchers first", () =>
1342 Effect.gen(function* () {
1343 const fs = yield* FileSystem.FileSystem;
1344 const path = yield* Path.Path;
1345 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-priority-" });
1346 const executable = path.join(home, "Applications/Cursor.app/Contents/Resources/app/bin/code");
1347 const env = { HOME: home, PATH: path.join(home, "bin") };
1348 const discover = Effect.gen(function* () {
1349 const launcher = yield* ExternalLauncher.ExternalLauncher;
1350 return yield* launcher.resolveAvailableEditors();
1351 });
1352 const before = yield* discover.pipe(Effect.provide(layerTest({ platform: "darwin", env })));
1353 yield* fs.makeDirectory(executable, { recursive: true });
1354 assert.deepEqual(
1355 yield* discover.pipe(Effect.provide(layerTest({ platform: "darwin", env }))),
1356 before,
1357 );
1358 yield* fs.remove(executable, { recursive: true });
1359 yield* fs.writeFileString(executable, "#!/bin/sh\n");
1360 yield* fs.chmod(executable, 0o644);
1361 assert.deepEqual(
1362 yield* discover.pipe(Effect.provide(layerTest({ platform: "darwin", env }))),
1363 before,
1364 );
1365 yield* fs.chmod(executable, 0o755);
1366 yield* fs.makeDirectory(env.PATH);
1367 yield* fs.writeFileString(path.join(env.PATH, "cursor"), "#!/bin/sh\n");
1368 yield* fs.chmod(path.join(env.PATH, "cursor"), 0o755);
1369 let spawned: ChildProcess.StandardCommand | undefined;
1370 yield* Effect.gen(function* () {
1371 const launcher = yield* ExternalLauncher.ExternalLauncher;
1372 yield* launcher.launchEditor({ editor: "cursor", cwd: "/workspace" });
1373 }).pipe(
1374 Effect.provide(
1375 layerTest({
1376 platform: "darwin",
1377 env: { ...env, PATH: `${env.PATH}:/usr/bin` },
1378 onSpawn: (command) => {
1379 spawned = command;
1380 },
1381 }),
1382 ),
1383 );
1384 assert.equal(spawned?.command, "cursor");
1385 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1386);
1387
1388it.effect("memoizes editor discovery and refreshes after the cache window", () => {
1389 let statCalls = 0;
1390 const fileInfo = { type: "File" } as FileSystem.File.Info;
1391 const layerLauncher = ExternalLauncher.layer.pipe(
1392 Layer.provide(
1393 Layer.mergeAll(
1394 FileSystem.layerNoop({
1395 stat: () =>
1396 Effect.sync(() => {
1397 statCalls += 1;
1398 return fileInfo;
1399 }),
1400 }),
1401 Path.layer,
1402 Layer.succeed(
1403 ChildProcessSpawner.ChildProcessSpawner,
1404 ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())),
1405 ),
1406 ),
1407 ),
1408 );
1409
1410 return Effect.gen(function* () {
1411 const launcher = yield* ExternalLauncher.ExternalLauncher;
1412
1413 const first = yield* launcher.resolveAvailableEditors();
1414 assert.equal(first.includes("vscode"), true);
1415 const statCallsAfterFirstScan = statCalls;
1416 assert.isAbove(statCallsAfterFirstScan, 0);
1417
1418 // Past the shared command-resolution cache TTL (30s) but within the
1419 // discovery cache window: the memoized set is reused without any scan.
1420 yield* TestClock.adjust("31 seconds");
1421 const second = yield* launcher.resolveAvailableEditors();
1422 assert.deepEqual([...second], [...first]);
1423 assert.equal(statCalls, statCallsAfterFirstScan);
1424
1425 // Past the discovery cache window the next call rescans.
1426 yield* TestClock.adjust("30 seconds");
1427 yield* launcher.resolveAvailableEditors();
1428 assert.isAbove(statCalls, statCallsAfterFirstScan);
1429 }).pipe(
1430 Effect.provide(
1431 Layer.mergeAll(
1432 layerLauncher,
1433 Layer.succeed(HostProcess.Platform, "win32"),
1434 ConfigProvider.layer(
1435 ConfigProvider.fromEnv({
1436 env: {
1437 PATH: "C:\\t3-editor-discovery-cache-test",
1438 PATHEXT: ".COM;.EXE;.BAT;.CMD",
1439 },
1440 }),
1441 ),
1442 TestClock.layer(),
1443 ),
1444 ),
1445 );
1446});
1447
1448// Connects run discovery under a timeout and may disconnect mid-scan. Neither
1449// may cancel the scan: on a busy host every connect would time out partway
1450// through, cache nothing, and leave every client without editors.
1451it.effect("keeps scanning after the caller is interrupted and shares that scan", () => {
1452 const fileInfo = { type: "File" } as FileSystem.File.Info;
1453 const release = Deferred.makeUnsafe<void>();
1454 let parkedStats = 0;
1455 const layerLauncher = ExternalLauncher.layer.pipe(
1456 Layer.provide(
1457 Layer.mergeAll(
1458 FileSystem.layerNoop({
1459 // Scans park inside `stat` until released, so the interrupt lands
1460 // while discovery is in flight.
1461 stat: () =>
1462 Effect.gen(function* () {
1463 if (!Deferred.isDoneUnsafe(release)) {
1464 parkedStats += 1;
1465 yield* Deferred.await(release);
1466 }
1467 return fileInfo;
1468 }),
1469 }),
1470 Path.layer,
1471 Layer.succeed(
1472 ChildProcessSpawner.ChildProcessSpawner,
1473 ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())),
1474 ),
1475 ),
1476 ),
1477 );
1478
1479 return Effect.gen(function* () {
1480 const launcher = yield* ExternalLauncher.ExternalLauncher;
1481
1482 const interrupted = yield* Effect.forkChild(launcher.resolveAvailableEditors());
1483 yield* Effect.yieldNow;
1484 yield* Fiber.interrupt(interrupted);
1485
1486 // The next connect joins the running scan instead of starting its own.
1487 const next = yield* Effect.forkChild(launcher.resolveAvailableEditors());
1488 yield* Effect.yieldNow;
1489 assert.equal(parkedStats, 1);
1490
1491 yield* Deferred.succeed(release, undefined);
1492 const editors = yield* Fiber.join(next);
1493 assert.equal(editors.includes("vscode"), true);
1494 }).pipe(
1495 Effect.provide(
1496 Layer.mergeAll(
1497 layerLauncher,
1498 Layer.succeed(HostProcess.Platform, "win32"),
1499 ConfigProvider.layer(
1500 ConfigProvider.fromEnv({
1501 env: {
1502 PATH: "C:\\t3-editor-discovery-interrupt-test",
1503 PATHEXT: ".COM;.EXE;.BAT;.CMD",
1504 },
1505 }),
1506 ),
1507 ),
1508 ),
1509 );
1510});
1511
1512const launchWindowsShimEditor = (cwd: string) =>
1513 Effect.gen(function* () {
1514 const fileSystem = yield* FileSystem.FileSystem;
1515 const path = yield* Path.Path;
1516 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
1517 yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n");
1518 let spawned: ChildProcess.StandardCommand | undefined;
1519 const exit = yield* Effect.gen(function* () {
1520 const launcher = yield* ExternalLauncher.ExternalLauncher;
1521 return yield* launcher.launchEditor({ editor: "vscode", cwd }).pipe(Effect.exit);
1522 }).pipe(
1523 Effect.provide(
1524 layerTest({
1525 platform: "win32",
1526 env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" },
1527 resolveExecutable: (command) =>
1528 command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command,
1529 onSpawn: (command) => {
1530 spawned = command;
1531 },
1532 }),
1533 ),
1534 );
1535 return { exit, spawned };
1536 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
1537
1538it.effect.each([
1539 ["CRLF", "C:\\workspace\\file.ts\r\ncalc.exe"],
1540 ["LF", "C:\\workspace\\file.ts\ncalc.exe"],
1541 ["double quote", 'C:\\workspace\\file.ts" & calc.exe & "'],
1542] as const)("does not start a Windows command shim for a path with a $0", ([, cwd]) =>
1543 Effect.gen(function* () {
1544 const { exit, spawned } = yield* launchWindowsShimEditor(cwd);
1545 assert.equal(spawned, undefined);
1546 assert.isTrue(Exit.isFailure(exit));
1547 if (Exit.isFailure(exit)) {
1548 assert.instanceOf(
1549 Cause.squash(exit.cause),
1550 ExternalLauncher.ExternalLauncherUnsupportedTargetError,
1551 );
1552 }
1553 }),
1554);
1555
1556it.effect("starts a Windows command shim for unicode paths with cmd metacharacters", () =>
1557 Effect.gen(function* () {
1558 const { exit, spawned } = yield* launchWindowsShimEditor(
1559 "C:\\Users\\jö\\R&D (100%)\\naïve ^file!.ts:3:7",
1560 );
1561 assert.isTrue(Exit.isSuccess(exit));
1562 assert.ok(spawned);
1563 assert.equal(spawned.options.shell, true);
1564 assert.deepEqual(spawned.args, []);
1565 assert.isTrue(
1566 spawned.command.endsWith(
1567 ' ^"--goto^" ^"C:\\Users\\jö\\R^&D^ ^(100^%^)\\naïve^ ^^file^!.ts:3:7^"',
1568 ),
1569 );
1570 }),
1571);
1572
1573it.effect.skipIf(windowsHost)("passes line breaks through to editors started without a shell", () =>
1574 Effect.gen(function* () {
1575 const fileSystem = yield* FileSystem.FileSystem;
1576 const path = yield* Path.Path;
1577 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
1578 const zed = path.join(binDir, "zed");
1579 yield* fileSystem.writeFileString(zed, "#!/bin/sh\n");
1580 yield* fileSystem.chmod(zed, 0o755);
1581 let spawned: ChildProcess.StandardCommand | undefined;
1582 yield* Effect.gen(function* () {
1583 const launcher = yield* ExternalLauncher.ExternalLauncher;
1584 yield* launcher.launchEditor({ editor: "zed", cwd: "/workspace/odd\nname.ts" });
1585 }).pipe(
1586 Effect.provide(
1587 layerTest({
1588 platform: "linux",
1589 env: { PATH: binDir, HOME: binDir },
1590 onSpawn: (command) => {
1591 spawned = command;
1592 },
1593 }),
1594 ),
1595 );
1596 assert.ok(spawned);
1597 assert.deepEqual(spawned.args, ["/workspace/odd\nname.ts"]);
1598 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1599);
1600
1601it.effect("rejects unknown editors through the service API", () =>
1602 Effect.gen(function* () {
1603 const launcher = yield* ExternalLauncher.ExternalLauncher;
1604 const error = yield* launcher
1605 .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" })
1606 .pipe(Effect.flip);
1607 assert.instanceOf(error, ExternalLauncher.ExternalLauncherUnknownEditorError);
1608 assert.equal(error.editor, "missing-editor");
1609 assert.equal(error.message, "Unknown editor: missing-editor");
1610 }).pipe(Effect.provide(layerTest({ platform: "linux", env: { PATH: "" } }))),
1611);