Open History to see the last commit
1// @effect-diagnostics nodeBuiltinImport:off - the Windows launch smoke tests use filesystem notifications to observe real PowerShell launches.
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Deferred from "effect/Deferred";
6import * as Cause from "effect/Cause";
7import * as Effect from "effect/Effect";
8import * as TestClock from "effect/testing/TestClock";
9import * as Exit from "effect/Exit";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import * as Path from "effect/Path";
13import * as Option from "effect/Option";
14import * as PlatformError from "effect/PlatformError";
15import * as Sink from "effect/Sink";
16import * as Stream from "effect/Stream";
17import { ChildProcess, ChildProcessSpawner } from "effect/process";
18
19import { EDITORS } from "@t3tools/contracts";
20import { resolveEditorCommand } from "@t3tools/shared/editor";
21import * as HostProcess from "@t3tools/shared/HostProcess";
22import { SpawnExecutableResolution } from "@t3tools/shared/shell";
23import * as ExternalLauncher from "./externalLauncher.ts";
24
25// Windows drive letters break mocked POSIX PATH splitting; these tests also require /bin/sh stubs.
26const windowsHost = HostProcess.Platform.defaultValue() === "win32";
27
28interface MockSpawnResult {
29 readonly spawnError?: PlatformError.PlatformError;
30 readonly exitCode?: number;
31 readonly stdout?: string;
32 readonly stall?: boolean;
33}
34
35function makeMockDetachedHandle(input: MockSpawnResult & { readonly onUnref?: () => void } = {}) {
36 return ChildProcessSpawner.makeHandle({
37 pid: ChildProcessSpawner.ProcessId(1),
38 exitCode: input.stall
39 ? Effect.never
40 : Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)),
41 isRunning: Effect.succeed(true),
42 kill: () => Effect.void,
43 unref: Effect.sync(() => {
44 input.onUnref?.();
45 return Effect.void;
46 }),
47 stdin: Sink.drain,
48 stdout:
49 input.stdout === undefined
50 ? Stream.empty
51 : Stream.make(new TextEncoder().encode(input.stdout)),
52 stderr: Stream.empty,
53 all: Stream.empty,
54 getInputFd: () => Sink.drain,
55 getOutputFd: () => Stream.empty,
56 });
57}
58
59const layerTest = (input: {
60 readonly platform: NodeJS.Platform;
61 readonly env?: Record<string, string>;
62 readonly path?: Path.Path;
63 readonly resolveExecutable?: (command: string) => string | undefined;
64 readonly onSpawn?: (command: ChildProcess.StandardCommand) => void;
65 readonly onUnref?: () => void;
66 readonly spawnResult?: (command: ChildProcess.StandardCommand) => MockSpawnResult | undefined;
67}) => {
68 const layerSpawner = Layer.succeed(
69 ChildProcessSpawner.ChildProcessSpawner,
70 ChildProcessSpawner.make((command) =>
71 Effect.gen(function* () {
72 assert.equal(ChildProcess.isStandardCommand(command), true);
73 if (!ChildProcess.isStandardCommand(command)) {
74 throw new Error("Expected a standard command");
75 }
76 input.onSpawn?.(command);
77 const result = input.spawnResult?.(command);
78 if (result?.spawnError) return yield* result.spawnError;
79 return makeMockDetachedHandle({
80 ...(input.onUnref === undefined ? {} : { onUnref: input.onUnref }),
81 ...result,
82 });
83 }),
84 ),
85 );
86
87 return Layer.mergeAll(
88 ExternalLauncher.layer.pipe(
89 Layer.provide(
90 Layer.mergeAll(
91 NodeServices.layer,
92 layerSpawner,
93 input.path ? Layer.succeed(Path.Path, input.path) : Layer.empty,
94 ),
95 ),
96 ),
97 Layer.succeed(HostProcess.Platform, input.platform),
98 Layer.succeed(
99 SpawnExecutableResolution,
100 (command) => input.resolveExecutable?.(command) ?? command,
101 ),
102 ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })),
103 );
104};
105
106// macOS command lookup cannot search a Windows drive-letter PATH.
107it.effect.skipIf(windowsHost)(
108 "opens home-relative app bundles using the server home directory",
109 () =>
110 Effect.gen(function* () {
111 const fs = yield* FileSystem.FileSystem;
112 const path = yield* Path.Path;
113 const bin = yield* fs.makeTempDirectoryScoped({ prefix: "t3-app-open-" });
114 yield* fs.writeFileString(path.join(bin, "open"), "#!/bin/sh\n");
115 yield* fs.chmod(path.join(bin, "open"), 0o755);
116 const spawned: ChildProcess.StandardCommand[] = [];
117 yield* Effect.gen(function* () {
118 const launcher = yield* ExternalLauncher.ExternalLauncher;
119 for (const cwd of ["~/Applications/My App.app", "/Applications/My App.app"]) {
120 yield* launcher.launchEditor({ editor: "file-manager", cwd });
121 }
122 }).pipe(
123 Effect.provide(
124 layerTest({
125 platform: "darwin",
126 env: { PATH: bin },
127 onSpawn: (command) => spawned.push(command),
128 }),
129 ),
130 Effect.provideService(HostProcess.HomeDirectory, "/Users/server"),
131 );
132 assert.deepEqual(
133 spawned.map((command) => command.args),
134 [["/Users/server/Applications/My App.app"], ["/Applications/My App.app"]],
135 );
136 }).pipe(Effect.provide(NodeServices.layer)),
137);
138
139it.effect.skipIf(windowsHost)(
140 "launched browsers and editors do not inherit ELECTRON_RUN_AS_NODE",
141 () =>
142 Effect.gen(function* () {
143 const fileSystem = yield* FileSystem.FileSystem;
144 const path = yield* Path.Path;
145 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
146 const codePath = path.join(binDir, "code");
147 yield* fileSystem.writeFileString(codePath, "#!/bin/sh\n");
148 yield* fileSystem.chmod(codePath, 0o755);
149
150 const spawned: ChildProcess.StandardCommand[] = [];
151 yield* Effect.gen(function* () {
152 const launcher = yield* ExternalLauncher.ExternalLauncher;
153 yield* launcher.launchBrowser("https://example.com");
154 yield* launcher.launchEditor({ editor: "vscode", cwd: "/workspace" });
155 }).pipe(
156 Effect.provide(
157 layerTest({
158 platform: "linux",
159 env: { PATH: binDir, DISPLAY: ":0" },
160 onSpawn: (command) => spawned.push(command),
161 }),
162 ),
163 Effect.provideService(HostProcess.Environment, {
164 PATH: binDir,
165 DISPLAY: ":0",
166 ELECTRON_RUN_AS_NODE: "1",
167 }),
168 );
169
170 assert.deepEqual(
171 spawned.map((command) => ({
172 command: command.command,
173 env: command.options.env,
174 extendEnv: command.options.extendEnv,
175 })),
176 ["xdg-open", "code"].map((command) => ({
177 command,
178 env: { PATH: binDir, DISPLAY: ":0" },
179 extendEnv: false,
180 })),
181 );
182 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
183);
184
185// A project opened from T3 must not replace the editor's last active window.
186it.effect.skipIf(windowsHost)(
187 "opens a project folder in a new VS Code window and a file in the existing one",
188 () =>
189 Effect.gen(function* () {
190 const fileSystem = yield* FileSystem.FileSystem;
191 const path = yield* Path.Path;
192 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
193 const project = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-project-" });
194 const codePath = path.join(binDir, "code");
195 yield* fileSystem.writeFileString(codePath, "#!/bin/sh\n");
196 yield* fileSystem.chmod(codePath, 0o755);
197 const file = `${path.join(project, "file.ts")}:3:7`;
198
199 const spawned: ChildProcess.StandardCommand[] = [];
200 yield* Effect.gen(function* () {
201 const launcher = yield* ExternalLauncher.ExternalLauncher;
202 yield* launcher.launchEditor({ editor: "vscode", cwd: project });
203 yield* launcher.launchEditor({ editor: "vscode", cwd: file });
204 }).pipe(
205 Effect.provide(
206 layerTest({
207 platform: "linux",
208 env: { PATH: binDir, DISPLAY: ":0" },
209 onSpawn: (command) => spawned.push(command),
210 }),
211 ),
212 Effect.provideService(HostProcess.Environment, { PATH: binDir, DISPLAY: ":0" }),
213 );
214
215 assert.deepEqual(
216 spawned.map((command) => command.args),
217 [
218 ["--new-window", project],
219 ["--goto", file],
220 ],
221 );
222 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
223);
224
225// Windows drive letters cannot resolve the POSIX editor stub.
226it.effect.skipIf(windowsHost)(
227 "a Linux editor launch without a display takes the desktop session's display from the systemd user manager",
228 () =>
229 Effect.gen(function* () {
230 const fileSystem = yield* FileSystem.FileSystem;
231 const path = yield* Path.Path;
232 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
233 const codiumPath = path.join(binDir, "codium");
234 yield* fileSystem.writeFileString(codiumPath, "#!/bin/sh\n");
235 yield* fileSystem.chmod(codiumPath, 0o755);
236
237 const launchEnv = (sessionManager: MockSpawnResult) =>
238 Effect.gen(function* () {
239 const spawned: ChildProcess.StandardCommand[] = [];
240 yield* Effect.gen(function* () {
241 const launcher = yield* ExternalLauncher.ExternalLauncher;
242 yield* launcher.launchEditor({ editor: "vscodium", cwd: "/workspace" });
243 yield* launcher.launchBrowser("https://example.com");
244 }).pipe(
245 Effect.provide(
246 layerTest({
247 platform: "linux",
248 env: { PATH: binDir },
249 onSpawn: (command) => spawned.push(command),
250 spawnResult: (command) =>
251 command.command === "busctl" ? sessionManager : undefined,
252 }),
253 ),
254 Effect.provideService(HostProcess.Environment, { PATH: binDir }),
255 );
256 // Only editors borrow the session display; a browser must not open on that screen.
257 assert.deepEqual(spawned.find((command) => command.command === "xdg-open")?.options.env, {
258 PATH: binDir,
259 });
260 return spawned.find((command) => command.command === "codium")?.options.env;
261 });
262
263 assert.deepEqual(
264 yield* launchEnv({
265 stdout: JSON.stringify({
266 type: "as",
267 data: [
268 "DISPLAY=:0",
269 "HOME=/home/user",
270 "WAYLAND_DISPLAY=wayland-1",
271 "XAUTHORITY=/home/user/auth files/cookie",
272 ],
273 }),
274 }),
275 {
276 PATH: binDir,
277 DISPLAY: ":0",
278 WAYLAND_DISPLAY: "wayland-1",
279 // Raw, not the $'...' quoting `systemctl show-environment` would print.
280 XAUTHORITY: "/home/user/auth files/cookie",
281 },
282 );
283 assert.deepEqual(yield* launchEnv({ stdout: "invalid JSON" }), { PATH: binDir });
284 assert.deepEqual(yield* launchEnv({ stdout: '{"data":[42]}' }), { PATH: binDir });
285 assert.deepEqual(
286 yield* launchEnv({
287 spawnError: PlatformError.systemError({
288 _tag: "NotFound",
289 module: "ChildProcess",
290 method: "spawn",
291 }),
292 }),
293 { PATH: binDir },
294 );
295 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
296);
297
298it.effect("launches an installed editor with platform-safe arguments", () =>
299 Effect.gen(function* () {
300 const fileSystem = yield* FileSystem.FileSystem;
301 const path = yield* Path.Path;
302 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
303 yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n");
304
305 let spawned: ChildProcess.StandardCommand | undefined;
306 yield* Effect.gen(function* () {
307 const launcher = yield* ExternalLauncher.ExternalLauncher;
308 yield* launcher.launchEditor({
309 editor: "vscode",
310 cwd: "C:\\workspace with spaces\\src\\index.ts:12:4",
311 });
312 }).pipe(
313 Effect.provide(
314 layerTest({
315 platform: "win32",
316 env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" },
317 resolveExecutable: (command) =>
318 command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command,
319 onSpawn: (command) => {
320 spawned = command;
321 },
322 }),
323 ),
324 );
325
326 assert.ok(spawned);
327 assert.equal(
328 spawned.command,
329 [
330 '^"C:\\Program^ Files\\Microsoft^ VS^ Code\\bin\\code.CMD^"',
331 '^"--goto^"',
332 '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"',
333 ].join(" "),
334 );
335 assert.deepEqual(spawned.args, []);
336 assert.equal(spawned.options.shell, true);
337 assert.equal(spawned.options.detached, true);
338 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
339);
340
341it.effect.skipIf(windowsHost)(
342 "reveals a WSL file in Windows File Explorer through its UNC path",
343 () =>
344 Effect.gen(function* () {
345 const fileSystem = yield* FileSystem.FileSystem;
346 const path = yield* Path.Path;
347 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
348 for (const name of ["explorer.exe", "powershell.exe", "xdg-open"]) {
349 const filePath = path.join(binDir, name);
350 yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n");
351 yield* fileSystem.chmod(filePath, 0o755);
352 }
353
354 let spawned: ChildProcess.StandardCommand | undefined;
355 const result = yield* Effect.gen(function* () {
356 const launcher = yield* ExternalLauncher.ExternalLauncher;
357 const kind = yield* launcher.resolveFileManagerRevealKind();
358 const editors = yield* launcher.resolveAvailableEditors();
359 yield* launcher.launchEditor({
360 editor: "file-manager",
361 cwd: "/home/t3/workspace [draft]/author's $media/clip|draft.mp4",
362 reveal: true,
363 });
364 return { kind, editors };
365 }).pipe(
366 Effect.provide(
367 layerTest({
368 platform: "linux",
369 env: {
370 PATH: binDir,
371 WSL_DISTRO_NAME: "Ubuntu-24.04",
372 WSL_INTEROP: "/run/WSL/1_interop",
373 },
374 onSpawn: (command) => {
375 spawned = command;
376 },
377 }),
378 ),
379 );
380
381 assert.equal(result.kind, "file-explorer");
382 assert.equal(result.editors.includes("file-manager"), true);
383 assert.ok(spawned);
384 assert.equal(spawned.command, "powershell.exe");
385 assert.equal(spawned.options.detached, true);
386 const encodedCommand = String(spawned.args[spawned.args.length - 1] ?? "");
387 const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le");
388 assert.equal(
389 decodedCommand,
390 "$ProgressPreference = 'SilentlyContinue'; $folderVerb = (Get-ItemProperty -LiteralPath 'Registry::HKEY_CLASSES_ROOT\\Directory\\shell' -ErrorAction SilentlyContinue).'(default)'; if ($folderVerb -and $folderVerb -ne 'none') { Invoke-Item -LiteralPath '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace [draft]\\author''s $media' } else { Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace [draft]\\author''s $media\\clip|draft.mp4' + '\"') }",
391 );
392 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
393);
394
395for (const wsl of [false, true]) {
396 it.effect.skipIf(windowsHost)(
397 "selects a file over D-Bus on " + (wsl ? "WSLg without interop PowerShell" : "Linux"),
398 () =>
399 Effect.gen(function* () {
400 const fileSystem = yield* FileSystem.FileSystem;
401 const path = yield* Path.Path;
402 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
403 for (const name of ["xdg-open", "xdg-mime", "gdbus", ...(wsl ? ["explorer.exe"] : [])]) {
404 const filePath = path.join(binDir, name);
405 yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n");
406 yield* fileSystem.chmod(filePath, 0o755);
407 }
408 const target = "/workspace/media/a 'quoted' \"clip\" #100% ? é\\name.mp4";
409 const spawnedCommands: ChildProcess.StandardCommand[] = [];
410 yield* Effect.gen(function* () {
411 const launcher = yield* ExternalLauncher.ExternalLauncher;
412 yield* launcher.launchEditor({ editor: "file-manager", cwd: target, reveal: true });
413 }).pipe(
414 Effect.provide(
415 layerTest({
416 platform: "linux",
417 env: {
418 PATH: binDir,
419 WAYLAND_DISPLAY: "wayland-1",
420 ...(wsl
421 ? { WSL_DISTRO_NAME: "Ubuntu-24.04", WSL_INTEROP: "/run/WSL/1_interop" }
422 : {}),
423 },
424 onSpawn: (command) => spawnedCommands.push(command),
425 spawnResult: (command) =>
426 command.command === "xdg-mime"
427 ? { stdout: "com.thisisgm.flea.desktop\n" }
428 : undefined,
429 }),
430 ),
431 );
432
433 const reveal = spawnedCommands.find((command) => command.command === "gdbus");
434 assert.ok(reveal);
435 assert.deepEqual(reveal.args, [
436 "call",
437 "--session",
438 "--dest",
439 "org.freedesktop.FileManager1",
440 "--object-path",
441 "/org/freedesktop/FileManager1",
442 "--method",
443 "org.freedesktop.FileManager1.ShowItems",
444 "[\"file:///workspace/media/a%20'quoted'%20%22clip%22%20%23100%25%20%3F%20%C3%A9%5Cname.mp4\"]",
445 "",
446 ]);
447 assert.deepEqual(
448 spawnedCommands.filter((command) => command.command !== "xdg-mime"),
449 [reveal],
450 );
451 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
452 );
453}
454
455for (const cwd of ["/workspace with spaces/", "/workspace with spaces/file.ts:12:4"]) {
456 it.effect.skipIf(windowsHost)(
457 `opens ${cwd} with the macOS Zed bundle when another zed is on PATH`,
458 () =>
459 Effect.gen(function* () {
460 const fs = yield* FileSystem.FileSystem;
461 const path = yield* Path.Path;
462 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-collision-" });
463 const executable = path.join(home, "Applications/Zed.app/Contents/MacOS/cli");
464 const binDir = path.join(home, "zfs/bin");
465 yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
466 yield* fs.makeDirectory(binDir, { recursive: true });
467 for (const command of [executable, path.join(binDir, "zed")]) {
468 yield* fs.writeFileString(command, "#!/bin/sh\n");
469 yield* fs.chmod(command, 0o755);
470 }
471
472 let spawned: ChildProcess.StandardCommand | undefined;
473 yield* Effect.gen(function* () {
474 const launcher = yield* ExternalLauncher.ExternalLauncher;
475 assert.include(yield* launcher.resolveAvailableEditors(), "zed");
476 yield* launcher.launchEditor({ editor: "zed", cwd });
477 }).pipe(
478 Effect.provide(
479 layerTest({
480 platform: "darwin",
481 env: { HOME: home, PATH: binDir },
482 onSpawn: (command) => {
483 spawned = command;
484 },
485 }),
486 ),
487 );
488 assert.ok(spawned);
489 assert.equal(spawned.command, executable);
490 assert.deepEqual(spawned.args, [cwd]);
491 assert.equal(spawned.options.shell, false);
492 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
493 );
494}
495
496for (const { layout, pathNames, fallbackNames, expected } of [
497 { layout: "PATH", pathNames: ["zed", "zeditor"], fallbackNames: [], expected: "zeditor" },
498 {
499 layout: "mixed locations",
500 pathNames: ["zed"],
501 fallbackNames: ["zeditor"],
502 expected: "fallback",
503 },
504 {
505 layout: "fallback directory",
506 pathNames: [],
507 fallbackNames: ["zed", "zeditor"],
508 expected: "fallback",
509 },
510]) {
511 it.effect.skipIf(windowsHost)(`prefers the Zed editor on Linux with ${layout}`, () =>
512 Effect.gen(function* () {
513 const fs = yield* FileSystem.FileSystem;
514 const path = yield* Path.Path;
515 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-linux-" });
516 const binDir = path.join(home, "bin");
517 const fallbackDir = path.join(home, ".local/bin");
518 for (const [dir, names] of [
519 [binDir, pathNames],
520 [fallbackDir, fallbackNames],
521 ] as const) {
522 yield* fs.makeDirectory(dir, { recursive: true });
523 for (const name of names) {
524 yield* fs.writeFileString(path.join(dir, name), "#!/bin/sh\n");
525 yield* fs.chmod(path.join(dir, name), 0o755);
526 }
527 }
528
529 let spawned: ChildProcess.StandardCommand | undefined;
530 yield* Effect.gen(function* () {
531 const launcher = yield* ExternalLauncher.ExternalLauncher;
532 assert.include(yield* launcher.resolveAvailableEditors(), "zed");
533 yield* launcher.launchEditor({ editor: "zed", cwd: "/workspace" });
534 }).pipe(
535 Effect.provide(
536 layerTest({
537 platform: "linux",
538 env: { HOME: home, PATH: binDir },
539 onSpawn: (command) => {
540 spawned = command;
541 },
542 }),
543 ),
544 );
545 assert.ok(spawned);
546 assert.equal(
547 spawned.command,
548 expected === "fallback" ? path.join(fallbackDir, "zeditor") : expected,
549 );
550 assert.deepEqual(spawned.args, ["/workspace"]);
551 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
552 );
553}
554
555// Linux editor discovery also probes fixed system directories; tests point them at a temp
556// directory so an editor installed on the host cannot satisfy the lookup.
557const withSystemBin = (path: Path.Path, systemBin: string): Path.Path => ({
558 ...path,
559 join: (...parts) =>
560 path.join(
561 ...parts.map((part) =>
562 ["/usr/local/bin", "/usr/bin", "/snap/bin"].includes(part) ? systemBin : part,
563 ),
564 ),
565});
566
567it.effect.skipIf(windowsHost)("prefers zeditor in a later Linux fallback directory", () =>
568 Effect.gen(function* () {
569 const fs = yield* FileSystem.FileSystem;
570 const path = yield* Path.Path;
571 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-fallback-" });
572 const localBin = path.join(home, ".local/bin");
573 const systemBin = path.join(home, "system-bin");
574 for (const [dir, name] of [
575 [localBin, "zed"],
576 [systemBin, "zeditor"],
577 ] as const) {
578 yield* fs.makeDirectory(dir, { recursive: true });
579 const executable = path.join(dir, name);
580 yield* fs.writeFileString(executable, "#!/bin/sh\necho 'Zed 0.200.0'\n");
581 yield* fs.chmod(executable, 0o755);
582 }
583 const editor = EDITORS.find((editor) => editor.id === "zed")!;
584 const resolve = resolveEditorCommand(editor, { HOME: home, PATH: "" }).pipe(
585 Effect.provideService(HostProcess.Platform, "linux"),
586 Effect.provideService(Path.Path, withSystemBin(path, systemBin)),
587 );
588 assert.deepEqual(
589 yield* resolve,
590 Option.some({ command: path.join(systemBin, "zeditor"), baseArgs: [] }),
591 );
592 yield* fs.remove(path.join(systemBin, "zeditor"));
593 assert.deepEqual(
594 yield* resolve,
595 Option.some({ command: path.join(localBin, "zed"), baseArgs: [] }),
596 );
597 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
598);
599
600it.effect.skipIf(windowsHost)("does not offer the OpenZFS zed daemon as Zed on Linux", () =>
601 Effect.gen(function* () {
602 const fs = yield* FileSystem.FileSystem;
603 const path = yield* Path.Path;
604 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-zfs-" });
605 const binDir = path.join(home, "bin");
606 const systemBin = path.join(home, "system-bin");
607 yield* fs.makeDirectory(binDir, { recursive: true });
608 yield* fs.makeDirectory(systemBin, { recursive: true });
609 const zfsZed = path.join(binDir, "zed");
610 // OpenZFS zed only takes short options: `--version` is rejected with usage on stderr.
611 yield* fs.writeFileString(
612 zfsZed,
613 "#!/bin/sh\necho \"Invalid option '--'\" >&2\necho 'Usage: zed [OPTION]...' >&2\nexit 1\n",
614 );
615 yield* fs.chmod(zfsZed, 0o755);
616 const editor = EDITORS.find((editor) => editor.id === "zed")!;
617 const resolve = resolveEditorCommand(editor, { HOME: home, PATH: binDir }).pipe(
618 Effect.provideService(HostProcess.Platform, "linux"),
619 Effect.provideService(Path.Path, withSystemBin(path, systemBin)),
620 );
621 assert.deepEqual(yield* resolve, Option.none());
622
623 const zedEditor = path.join(home, ".local/bin/zed");
624 yield* fs.makeDirectory(path.dirname(zedEditor), { recursive: true });
625 yield* fs.writeFileString(zedEditor, "#!/bin/sh\necho 'Zed 0.200.0'\n");
626 yield* fs.chmod(zedEditor, 0o755);
627 assert.deepEqual(yield* resolve, Option.some({ command: zedEditor, baseArgs: [] }));
628 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
629);
630
631it.effect.skipIf(windowsHost)("does not open Zed with the OpenZFS zed daemon on Linux", () =>
632 Effect.gen(function* () {
633 const fs = yield* FileSystem.FileSystem;
634 const path = yield* Path.Path;
635 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-zfs-launch-" });
636 const binDir = path.join(home, "bin");
637 const systemBin = path.join(home, "system-bin");
638 yield* fs.makeDirectory(binDir, { recursive: true });
639 yield* fs.writeFileString(path.join(binDir, "zed"), "#!/bin/sh\nexit 1\n");
640 yield* fs.chmod(path.join(binDir, "zed"), 0o755);
641
642 const spawned: ChildProcess.StandardCommand[] = [];
643 const exit = yield* Effect.gen(function* () {
644 const launcher = yield* ExternalLauncher.ExternalLauncher;
645 return yield* launcher.launchEditor({ editor: "zed", cwd: "/workspace" }).pipe(Effect.exit);
646 }).pipe(
647 Effect.provide(
648 layerTest({
649 platform: "linux",
650 env: { HOME: home, PATH: binDir },
651 path: withSystemBin(path, systemBin),
652 onSpawn: (command) => spawned.push(command),
653 spawnResult: (command) => (command.args[0] === "--version" ? { exitCode: 1 } : undefined),
654 }),
655 ),
656 );
657 assert.isTrue(Exit.isFailure(exit));
658 if (Exit.isFailure(exit)) {
659 assert.instanceOf(
660 Cause.squash(exit.cause),
661 ExternalLauncher.ExternalLauncherCommandNotFoundError,
662 );
663 }
664 assert.deepEqual(
665 spawned.map((command) => command.args),
666 [["--version"]],
667 );
668 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
669);
670
671it.effect.each([
672 { editor: "webstorm", installPath: "Applications/WebStorm 2026.2.app/Contents/MacOS/webstorm" },
673 { editor: "idea", installPath: "Applications/IntelliJ IDEA 2026.1.4.app/Contents/MacOS/idea" },
674] as const)(
675 "discovers and launches $editor from a versioned macOS bundle",
676 ({ editor, installPath }) =>
677 Effect.gen(function* () {
678 const fs = yield* FileSystem.FileSystem;
679 const path = yield* Path.Path;
680 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-versioned-" });
681 const executable = path.join(home, installPath);
682 yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
683 yield* fs.writeFileString(executable, "#!/bin/sh\n");
684 yield* fs.chmod(executable, 0o755);
685
686 let spawned: ChildProcess.StandardCommand | undefined;
687 yield* Effect.gen(function* () {
688 const launcher = yield* ExternalLauncher.ExternalLauncher;
689 assert.include(yield* launcher.resolveAvailableEditors(), editor);
690 yield* launcher.launchEditor({
691 editor,
692 cwd: "/workspace with spaces/file.ts:12:4",
693 projectRoot: "/workspace with spaces",
694 });
695 }).pipe(
696 Effect.provide(
697 layerTest({
698 platform: "darwin",
699 env: { HOME: home, PATH: path.join(home, "empty") },
700 onSpawn: (command) => {
701 spawned = command;
702 },
703 }),
704 ),
705 );
706
707 assert.ok(spawned);
708 assert.equal(spawned.command, executable);
709 assert.deepEqual(spawned.args, [
710 "/workspace with spaces",
711 "--line",
712 "12",
713 "--column",
714 "3",
715 "/workspace with spaces/file.ts",
716 ]);
717 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
718);
719
720it.effect("matches macOS bundles on the name boundary only", () =>
721 Effect.gen(function* () {
722 const fs = yield* FileSystem.FileSystem;
723 const path = yield* Path.Path;
724 const unrelatedHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-unrelated-" });
725 const unrelated = path.join(
726 unrelatedHome,
727 "Applications/WebStormHelper.app/Contents/MacOS/webstorm",
728 );
729 yield* fs.makeDirectory(path.dirname(unrelated), { recursive: true });
730 yield* fs.writeFileString(unrelated, "#!/bin/sh\n");
731 yield* fs.chmod(unrelated, 0o755);
732
733 const editorsFor = (home: string) =>
734 Effect.gen(function* () {
735 const launcher = yield* ExternalLauncher.ExternalLauncher;
736 return yield* launcher.resolveAvailableEditors();
737 }).pipe(
738 Effect.provide(
739 layerTest({ platform: "darwin", env: { HOME: home, PATH: path.join(home, "bin") } }),
740 ),
741 );
742
743 assert.notInclude(yield* editorsFor(unrelatedHome), "webstorm");
744
745 const exactHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-exact-" });
746 const exact = path.join(exactHome, "Applications/WebStorm.app/Contents/MacOS/webstorm");
747 yield* fs.makeDirectory(path.dirname(exact), { recursive: true });
748 yield* fs.writeFileString(exact, "#!/bin/sh\n");
749 yield* fs.chmod(exact, 0o755);
750
751 assert.include(yield* editorsFor(exactHome), "webstorm");
752 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
753);
754
755it.effect("keeps the macOS VS Code Insiders bundle out of stable VS Code", () =>
756 Effect.gen(function* () {
757 const fs = yield* FileSystem.FileSystem;
758 const path = yield* Path.Path;
759 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-insiders-" });
760 // The real Insiders bundle ships both `code-insiders` and `code`.
761 const bin = path.join(
762 home,
763 "Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin",
764 );
765 yield* fs.makeDirectory(bin, { recursive: true });
766 for (const name of ["code", "code-insiders"]) {
767 yield* fs.writeFileString(path.join(bin, name), "#!/bin/sh\n");
768 yield* fs.chmod(path.join(bin, name), 0o755);
769 }
770
771 let spawned: ChildProcess.StandardCommand | undefined;
772 const editors = yield* Effect.gen(function* () {
773 const launcher = yield* ExternalLauncher.ExternalLauncher;
774 const available = yield* launcher.resolveAvailableEditors();
775 yield* launcher.launchEditor({ editor: "vscode-insiders", cwd: "/workspace/file.ts" });
776 return available;
777 }).pipe(
778 Effect.provide(
779 layerTest({
780 platform: "darwin",
781 env: { HOME: home, PATH: path.join(home, "empty") },
782 onSpawn: (command) => {
783 spawned = command;
784 },
785 }),
786 ),
787 );
788
789 assert.notInclude(editors, "vscode");
790 assert.include(editors, "vscode-insiders");
791 assert.equal(spawned?.command, path.join(bin, "code-insiders"));
792 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
793);
794
795for (const { platform, installPath, onPath } of [
796 { platform: "darwin", installPath: ".local/bin/agy", onPath: true },
797 { platform: "linux", installPath: ".local/bin/agy", onPath: false },
798 { platform: "win32", installPath: "agy/bin/agy.cmd", onPath: true },
799] as const) {
800 it.effect.skipIf(windowsHost && platform !== "win32")(
801 `does not report the agy CLI as the Antigravity IDE on ${platform}`,
802 () =>
803 Effect.gen(function* () {
804 const fs = yield* FileSystem.FileSystem;
805 const path = yield* Path.Path;
806 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-cli-" });
807 const executable = path.join(home, installPath);
808 yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
809 yield* fs.writeFileString(executable, "#!/bin/sh\n");
810 yield* fs.chmod(executable, 0o755);
811 const editors = yield* Effect.gen(function* () {
812 const launcher = yield* ExternalLauncher.ExternalLauncher;
813 return yield* launcher.resolveAvailableEditors();
814 }).pipe(
815 Effect.provide(
816 layerTest({
817 platform,
818 env: {
819 HOME: home,
820 LOCALAPPDATA: home,
821 PATH: onPath ? path.dirname(executable) : path.join(home, "empty"),
822 PATHEXT: ".COM;.EXE;.BAT;.CMD",
823 },
824 }),
825 ),
826 );
827 assert.notInclude(editors, "antigravity");
828 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
829 );
830}
831
832it.effect.skipIf(windowsHost)("ignores unusable app bundles and keeps PATH launchers first", () =>
833 Effect.gen(function* () {
834 const fs = yield* FileSystem.FileSystem;
835 const path = yield* Path.Path;
836 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-priority-" });
837 const executable = path.join(home, "Applications/Cursor.app/Contents/Resources/app/bin/code");
838 const env = { HOME: home, PATH: path.join(home, "bin") };
839 const discover = Effect.gen(function* () {
840 const launcher = yield* ExternalLauncher.ExternalLauncher;
841 return yield* launcher.resolveAvailableEditors();
842 });
843 const before = yield* discover.pipe(Effect.provide(layerTest({ platform: "darwin", env })));
844 yield* fs.makeDirectory(executable, { recursive: true });
845 assert.deepEqual(
846 yield* discover.pipe(Effect.provide(layerTest({ platform: "darwin", env }))),
847 before,
848 );
849 yield* fs.remove(executable, { recursive: true });
850 yield* fs.writeFileString(executable, "#!/bin/sh\n");
851 yield* fs.chmod(executable, 0o644);
852 assert.deepEqual(
853 yield* discover.pipe(Effect.provide(layerTest({ platform: "darwin", env }))),
854 before,
855 );
856 yield* fs.chmod(executable, 0o755);
857 yield* fs.makeDirectory(env.PATH);
858 yield* fs.writeFileString(path.join(env.PATH, "cursor"), "#!/bin/sh\n");
859 yield* fs.chmod(path.join(env.PATH, "cursor"), 0o755);
860 let spawned: ChildProcess.StandardCommand | undefined;
861 yield* Effect.gen(function* () {
862 const launcher = yield* ExternalLauncher.ExternalLauncher;
863 yield* launcher.launchEditor({ editor: "cursor", cwd: "/workspace" });
864 }).pipe(
865 Effect.provide(
866 layerTest({
867 platform: "darwin",
868 env: { ...env, PATH: `${env.PATH}:/usr/bin` },
869 onSpawn: (command) => {
870 spawned = command;
871 },
872 }),
873 ),
874 );
875 assert.equal(spawned?.command, "cursor");
876 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
877);
878
879it.effect(
880 "memoizes editor discovery and refreshes in the background after the cache window",
881 () => {
882 let statCalls = 0;
883 const rescanned = Deferred.makeUnsafe<void>();
884 let initialScanDone = false;
885 const fileInfo = { type: "File" } as FileSystem.File.Info;
886 const layerLauncher = ExternalLauncher.layer.pipe(
887 Layer.provide(
888 Layer.mergeAll(
889 FileSystem.layerNoop({
890 stat: () =>
891 Effect.sync(() => {
892 statCalls += 1;
893 return fileInfo;
894 }).pipe(
895 Effect.tap(() =>
896 initialScanDone ? Deferred.succeed(rescanned, undefined) : Effect.void,
897 ),
898 ),
899 }),
900 Path.layer,
901 Layer.succeed(
902 ChildProcessSpawner.ChildProcessSpawner,
903 ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())),
904 ),
905 ),
906 ),
907 );
908
909 return Effect.gen(function* () {
910 const launcher = yield* ExternalLauncher.ExternalLauncher;
911
912 const first = yield* launcher.resolveAvailableEditors();
913 assert.equal(first.includes("vscode"), true);
914 const statCallsAfterFirstScan = statCalls;
915 initialScanDone = true;
916 assert.isAbove(statCallsAfterFirstScan, 0);
917
918 // Past the shared command-resolution cache TTL (30s) but within the
919 // discovery cache window: the memoized set is reused without any scan.
920 yield* TestClock.adjust("31 seconds");
921 const second = yield* launcher.resolveAvailableEditors();
922 assert.deepEqual([...second], [...first]);
923 assert.equal(statCalls, statCallsAfterFirstScan);
924
925 // Past the discovery cache window the next call still answers from the
926 // memoized set and rescans in the background.
927 yield* TestClock.adjust("30 seconds");
928 const third = yield* launcher.resolveAvailableEditors();
929 assert.deepEqual([...third], [...first]);
930 yield* Deferred.await(rescanned);
931 assert.isAbove(statCalls, statCallsAfterFirstScan);
932 }).pipe(
933 Effect.provide(
934 Layer.mergeAll(
935 layerLauncher,
936 Layer.succeed(HostProcess.Platform, "win32"),
937 ConfigProvider.layer(
938 ConfigProvider.fromEnv({
939 env: {
940 PATH: "C:\\t3-editor-discovery-cache-test",
941 PATHEXT: ".COM;.EXE;.BAT;.CMD",
942 },
943 }),
944 ),
945 TestClock.layer(),
946 ),
947 ),
948 );
949 },
950);
951
952const launchWindowsShimEditor = (cwd: string) =>
953 Effect.gen(function* () {
954 const fileSystem = yield* FileSystem.FileSystem;
955 const path = yield* Path.Path;
956 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
957 yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n");
958 let spawned: ChildProcess.StandardCommand | undefined;
959 const exit = yield* Effect.gen(function* () {
960 const launcher = yield* ExternalLauncher.ExternalLauncher;
961 return yield* launcher.launchEditor({ editor: "vscode", cwd }).pipe(Effect.exit);
962 }).pipe(
963 Effect.provide(
964 layerTest({
965 platform: "win32",
966 env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" },
967 resolveExecutable: (command) =>
968 command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command,
969 onSpawn: (command) => {
970 spawned = command;
971 },
972 }),
973 ),
974 );
975 return { exit, spawned };
976 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
977
978it.effect.each([
979 ["CRLF", "C:\\workspace\\file.ts\r\ncalc.exe"],
980 ["LF", "C:\\workspace\\file.ts\ncalc.exe"],
981 ["double quote", 'C:\\workspace\\file.ts" & calc.exe & "'],
982] as const)("does not start a Windows command shim for a path with a $0", ([, cwd]) =>
983 Effect.gen(function* () {
984 const { exit, spawned } = yield* launchWindowsShimEditor(cwd);
985 assert.equal(spawned, undefined);
986 assert.isTrue(Exit.isFailure(exit));
987 if (Exit.isFailure(exit)) {
988 assert.instanceOf(
989 Cause.squash(exit.cause),
990 ExternalLauncher.ExternalLauncherUnsupportedTargetError,
991 );
992 }
993 }),
994);
995
996it.effect("starts a Windows command shim for unicode paths with cmd metacharacters", () =>
997 Effect.gen(function* () {
998 const { exit, spawned } = yield* launchWindowsShimEditor(
999 "C:\\Users\\jö\\R&D (100%)\\naïve ^file!.ts:3:7",
1000 );
1001 assert.isTrue(Exit.isSuccess(exit));
1002 assert.ok(spawned);
1003 assert.equal(spawned.options.shell, true);
1004 assert.deepEqual(spawned.args, []);
1005 assert.isTrue(
1006 spawned.command.endsWith(
1007 ' ^"--goto^" ^"C:\\Users\\jö\\R^&D^ ^(100^%^)\\naïve^ ^^file^!.ts:3:7^"',
1008 ),
1009 );
1010 }),
1011);