294 lines · 10.1 KB · TypeScript
RawDownload
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 Fiber from "effect/Fiber";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 * as TestClock from "effect/testing/TestClock";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<string, string>;  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("memoizes editor discovery and refreshes after the cache window", () => {  let statCalls = 0;  const fileInfo = { type: "File" } as FileSystem.File.Info;  const launcherLayer = ExternalLauncher.layer.pipe(    Layer.provide(      Layer.mergeAll(        FileSystem.layerNoop({          stat: () =>            Effect.sync(() => {              statCalls += 1;              return fileInfo;            }),        }),        Path.layer,        Layer.succeed(          ChildProcessSpawner.ChildProcessSpawner,          ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())),        ),      ),    ),  );   return Effect.gen(function* () {    const launcher = yield* ExternalLauncher.ExternalLauncher;     const first = yield* launcher.resolveAvailableEditors();    assert.equal(first.includes("vscode"), true);    const statCallsAfterFirstScan = statCalls;    assert.isAbove(statCallsAfterFirstScan, 0);     // Past the shared command-resolution cache TTL (30s) but within the    // discovery cache window: the memoized set is reused without any scan.    yield* TestClock.adjust("31 seconds");    const second = yield* launcher.resolveAvailableEditors();    assert.deepEqual([...second], [...first]);    assert.equal(statCalls, statCallsAfterFirstScan);     // Past the discovery cache window the next call rescans.    yield* TestClock.adjust("30 seconds");    yield* launcher.resolveAvailableEditors();    assert.isAbove(statCalls, statCallsAfterFirstScan);  }).pipe(    Effect.provide(      Layer.mergeAll(        launcherLayer,        Layer.succeed(HostProcessPlatform, "win32"),        ConfigProvider.layer(          ConfigProvider.fromEnv({            env: {              PATH: "C:\\t3-editor-discovery-cache-test",              PATHEXT: ".COM;.EXE;.BAT;.CMD",            },          }),        ),        TestClock.layer(),      ),    ),  );}); // A client that disconnects mid-scan interrupts the shared discovery effect on// the connection fiber. The cache must not retain that interrupt: doing so// replayed it to every later connect for the whole TTL, so `server.getConfig`// failed and no client could reconnect until the server restarted.it.effect("rescans after an interrupted discovery instead of caching the interrupt", () => {  const fileInfo = { type: "File" } as FileSystem.File.Info;  let blockFirstScan = true;  let scans = 0;  const launcherLayer = ExternalLauncher.layer.pipe(    Layer.provide(      Layer.mergeAll(        FileSystem.layerNoop({          // The first scan parks inside `stat` so the interrupt lands while          // discovery is in flight, which is what a client disconnecting          // mid-connect does to the shared effect.          stat: () =>            Effect.gen(function* () {              scans += 1;              if (blockFirstScan) {                return yield* Effect.never;              }              return fileInfo;            }),        }),        Path.layer,        Layer.succeed(          ChildProcessSpawner.ChildProcessSpawner,          ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())),        ),      ),    ),  );   return Effect.gen(function* () {    const launcher = yield* ExternalLauncher.ExternalLauncher;     const fiber = yield* Effect.forkChild(launcher.resolveAvailableEditors());    yield* Effect.yieldNow;    yield* Fiber.interrupt(fiber);     // The next connect must still get a real answer well inside the TTL.    blockFirstScan = false;    scans = 0;    const editors = yield* launcher.resolveAvailableEditors();    assert.equal(editors.includes("vscode"), true);    assert.isAbove(scans, 0);  }).pipe(    Effect.provide(      Layer.mergeAll(        launcherLayer,        Layer.succeed(HostProcessPlatform, "win32"),        ConfigProvider.layer(          ConfigProvider.fromEnv({            env: {              PATH: "C:\\t3-editor-discovery-interrupt-test",              PATHEXT: ".COM;.EXE;.BAT;.CMD",            },          }),        ),      ),    ),  );}); 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: "" } }))),);