import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ExternalLauncher from "./externalLauncher.ts"; function makeMockDetachedHandle(onUnref: () => void = () => undefined) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), isRunning: Effect.succeed(true), kill: () => Effect.void, unref: Effect.sync(() => { onUnref(); return Effect.void; }), stdin: Sink.drain, stdout: Stream.empty, stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }); } const testLayer = (input: { readonly platform: NodeJS.Platform; readonly env?: Record; readonly resolveExecutable?: (command: string) => string | undefined; readonly onSpawn?: (command: ChildProcess.StandardCommand) => void; readonly onUnref?: () => void; }) => { const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => Effect.sync(() => { assert.equal(ChildProcess.isStandardCommand(command), true); if (!ChildProcess.isStandardCommand(command)) { throw new Error("Expected a standard command"); } input.onSpawn?.(command); return makeMockDetachedHandle(input.onUnref); }), ), ); return Layer.mergeAll( ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), Layer.succeed(HostProcessPlatform, input.platform), Layer.succeed( SpawnExecutableResolution, (command) => input.resolveExecutable?.(command) ?? command, ), ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), ); }; it.effect("launches the default browser through the platform command", () => { let spawned: ChildProcess.StandardCommand | undefined; let didUnref = false; return Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; yield* launcher.launchBrowser("https://example.com/some path"); assert.ok(spawned); assert.equal(spawned.command, "xdg-open"); assert.deepEqual(spawned.args, ["https://example.com/some path"]); assert.equal(spawned.options.detached, true); assert.equal(didUnref, true); }).pipe( Effect.provide( testLayer({ platform: "linux", onSpawn: (command) => { spawned = command; }, onUnref: () => { didUnref = true; }, }), ), ); }); it.effect("launches an installed editor with platform-safe arguments", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); let spawned: ChildProcess.StandardCommand | undefined; yield* Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; yield* launcher.launchEditor({ editor: "vscode", cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", }); }).pipe( Effect.provide( testLayer({ platform: "win32", env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, resolveExecutable: (command) => command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command, onSpawn: (command) => { spawned = command; }, }), ), ); assert.ok(spawned); assert.equal(spawned.command, '^"C:\\Program^ Files\\Microsoft^ VS^ Code\\bin\\code.CMD^"'); assert.deepEqual(spawned.args, [ '^"--goto^"', '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"', ]); assert.equal(spawned.options.shell, true); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); it.effect("discovers editors through the service API", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); const editors = yield* Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; return yield* launcher.resolveAvailableEditors(); }).pipe( Effect.provide( testLayer({ platform: "win32", env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, }), ), ); assert.equal(editors.includes("vscode"), true); assert.equal(editors.includes("file-manager"), true); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); it.effect("rejects unknown editors through the service API", () => Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; const error = yield* launcher .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" }) .pipe(Effect.flip); assert.instanceOf(error, ExternalLauncher.ExternalLauncherUnknownEditorError); assert.equal(error.editor, "missing-editor"); assert.equal(error.message, "Unknown editor: missing-editor"); }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "" } }))), );