import { describe, it } from "@effect/vitest";import { expect } from "vite-plus/test";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Fiber from "effect/Fiber";import * as Layer from "effect/Layer";import * as Sink from "effect/Sink";import * as Stream from "effect/Stream";import * as TestClock from "effect/testing/TestClock";import { ChildProcessSpawner } from "effect/unstable/process"; import { buildWslNodeEnvPreamble, DesktopWslDistroListError, formatMissingToolsReason, formatNodePtyProbeFailureReason, formatWslShellTransportFailureReason, parseNodePath, parseNodeVersion, parseResolvedPath, parseToolchainReport, probeWslDistros,} from "./DesktopWslEnvironment.ts"; const encoder = new TextEncoder(); const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => ChildProcessSpawner.make(() => Effect.succeed( ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), exitCode: result.exitCode === undefined ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), isRunning: Effect.succeed(result.exitCode === undefined), kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, stdout: Stream.make(encoder.encode(result.stdout ?? "")), stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }), ), ); describe("probeWslDistros", () => { it.effect("preserves a successful empty distro list", () => Effect.gen(function* () { const distros = yield* probeWslDistros; expect(distros).toEqual([]); }).pipe( Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, makeDistroListSpawner({ stdout: "", exitCode: 0 }), ), ), ); it.effect("fails when the distro-list command exits unsuccessfully", () => Effect.gen(function* () { const error = yield* probeWslDistros.pipe(Effect.flip); expect(error).toBeInstanceOf(DesktopWslDistroListError); expect(error.message).toContain("exited with code 1"); }).pipe( Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, makeDistroListSpawner({ exitCode: 1 }), ), ), ); it.effect("fails when the distro-list command times out", () => { const layer = Layer.merge( TestClock.layer(), Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, makeDistroListSpawner({})), ); return Effect.gen(function* () { const fiber = yield* probeWslDistros.pipe(Effect.flip, Effect.forkScoped); yield* Effect.yieldNow; yield* TestClock.adjust(Duration.seconds(8)); const error = yield* Fiber.join(fiber); expect(error).toBeInstanceOf(DesktopWslDistroListError); expect(error.message).toContain("timed out"); }).pipe(Effect.provide(layer)); });}); describe("formatNodePtyProbeFailureReason", () => { it("identifies a packaged build that omitted the Linux node-pty prebuild", () => { const reason = formatNodePtyProbeFailureReason(4); expect(reason).toContain("packaged Linux node-pty binary was not included"); expect(reason).toContain("--wsl-prebuild"); }); it("leaves other node-pty load failures to the compatibility diagnostic", () => { expect(formatNodePtyProbeFailureReason(1)).toBeNull(); });}); describe("formatWslShellTransportFailureReason", () => { it("distinguishes timeouts and spawn failures from normal shell exit codes", () => { expect(formatWslShellTransportFailureReason("timeout")).toContain("timed out"); expect(formatWslShellTransportFailureReason("spawn")).toContain("could not start wsl.exe"); expect(formatWslShellTransportFailureReason("process")).toContain("lost communication"); expect(formatWslShellTransportFailureReason(null)).toBeNull(); });}); describe("buildWslNodeEnvPreamble", () => { it("passes the required Node engine range into the shared resolver", () => { const preamble = buildWslNodeEnvPreamble("^22.16 || ^23.11 || >=24.10"); expect(preamble).toContain("T3_NODE_ENGINE_RANGE='^22.16 || ^23.11 || >=24.10'"); expect(preamble.indexOf("T3_NODE_ENGINE_RANGE=")).toBeLessThan( preamble.lastIndexOf("ensure_remote_node_path || true"), ); }); it("keeps the shared resolver permissive when no Node engine range is provided", () => { expect(buildWslNodeEnvPreamble()).toContain("T3_NODE_ENGINE_RANGE=''"); });}); describe("parseToolchainReport", () => { it("returns no missing tools and no node version on empty output", () => { expect(parseToolchainReport("")).toEqual({ missingTools: [], nodeVersion: null }); }); it("collects all missing: lines", () => { const stdout = ["missing:make", "missing:g++", "nodeVersion:24.10.0"].join("\n"); expect(parseToolchainReport(stdout)).toEqual({ missingTools: ["make", "g++"], nodeVersion: "24.10.0", }); }); it("ignores blank lines and trims whitespace", () => { const stdout = [" missing:python3 ", "", " nodeVersion:v22.16.0 "].join("\n"); expect(parseToolchainReport(stdout)).toEqual({ missingTools: ["python3"], nodeVersion: "v22.16.0", }); }); it("returns null node version when value after prefix is empty", () => { expect(parseToolchainReport("nodeVersion:")).toEqual({ missingTools: [], nodeVersion: null, }); });}); describe("parseNodePath", () => { it("extracts the absolute node path from a nodePath: line", () => { const stdout = "nodePath:/home/josh/.nvm/versions/node/v22.16.0/bin/node"; expect(parseNodePath(stdout)).toBe("/home/josh/.nvm/versions/node/v22.16.0/bin/node"); }); it("returns null when node was not found (empty value after prefix)", () => { expect(parseNodePath("nodePath:")).toBeNull(); }); it("returns null when there is no nodePath line at all", () => { expect(parseNodePath("missing:node\nnodeVersion:")).toBeNull(); }); it("ignores surrounding noise and trims whitespace", () => { const stdout = ["some preamble noise", " nodePath:/usr/bin/node ", "trailing"].join("\n"); expect(parseNodePath(stdout)).toBe("/usr/bin/node"); });}); describe("parseNodeVersion", () => { it("extracts the node version from a nodeVersion: line", () => { expect(parseNodeVersion("nodeVersion:24.10.0")).toBe("24.10.0"); }); it("returns null when the version value is empty", () => { expect(parseNodeVersion("nodeVersion:")).toBeNull(); }); it("returns null when there is no nodeVersion line at all", () => { expect(parseNodeVersion("nodePath:/usr/bin/node\nresolvedPath:/usr/bin")).toBeNull(); }); it("ignores surrounding noise and trims whitespace", () => { const stdout = [ "some preamble noise", " nodeVersion:22.16.0 ", "nodePath:/usr/bin/node", ].join("\n"); expect(parseNodeVersion(stdout)).toBe("22.16.0"); });}); describe("parseResolvedPath", () => { it("preserves spaces and apostrophes in the resolved login-shell PATH", () => { const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin"; expect(parseResolvedPath(`nodePath:/usr/bin/node\nresolvedPath:${resolvedPath}\n`)).toBe( resolvedPath, ); }); it("accepts CRLF output without retaining the carriage return", () => { expect(parseResolvedPath("resolvedPath:/usr/local/bin:/usr/bin\r\n")).toBe( "/usr/local/bin:/usr/bin", ); }); it("returns null when the resolved PATH is absent or empty", () => { expect(parseResolvedPath("nodePath:/usr/bin/node\n")).toBeNull(); expect(parseResolvedPath("resolvedPath:\n")).toBeNull(); });}); describe("formatMissingToolsReason", () => { it("returns null when everything is present and node is in range", () => { expect( formatMissingToolsReason({ missingTools: [], nodeVersion: "24.10.0" }, "^24.10"), ).toBeNull(); }); it("returns null when range is not specified and tools are present", () => { expect(formatMissingToolsReason({ missingTools: [], nodeVersion: "18.0.0" }, null)).toBeNull(); }); it("flags missing node first", () => { const reason = formatMissingToolsReason( { missingTools: ["node", "make"], nodeVersion: null }, "^24.10", ); expect(reason).toContain("node"); expect(reason).toContain("^24.10"); expect(reason).toContain("make"); expect(reason).toContain("nvm"); }); it("flags an out-of-range node version with the actual version surfaced", () => { const reason = formatMissingToolsReason( { missingTools: [], nodeVersion: "20.0.0" }, "^24.10 || ^22.16", ); expect(reason).toContain("node 20.0.0"); expect(reason).toContain("requires ^24.10 || ^22.16"); }); it("flags missing build tools without node when node is fine", () => { const reason = formatMissingToolsReason( { missingTools: ["g++", "python3"], nodeVersion: "24.10.0" }, "^24.10", ); expect(reason).toContain("g++"); expect(reason).toContain("python3"); expect(reason).toContain("build-essential"); expect(reason).not.toContain("nvm"); });});
import { describe, it } from "@effect/vitest";
import { expect } from "vite-plus/test";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import { ChildProcessSpawner } from "effect/unstable/process";
import {
buildWslNodeEnvPreamble,
DesktopWslDistroListError,
formatMissingToolsReason,
formatNodePtyProbeFailureReason,
formatWslShellTransportFailureReason,
parseNodePath,
parseNodeVersion,
parseResolvedPath,
parseToolchainReport,
probeWslDistros,
} from "./DesktopWslEnvironment.ts";
const encoder = new TextEncoder();
const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) =>
ChildProcessSpawner.make(() =>
Effect.succeed(
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
exitCode:
result.exitCode === undefined
? Effect.never
: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)),
isRunning: Effect.succeed(result.exitCode === undefined),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
stdin: Sink.drain,
stdout: Stream.make(encoder.encode(result.stdout ?? "")),
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
}),
),
);
describe("probeWslDistros", () => {
it.effect("preserves a successful empty distro list", () =>
Effect.gen(function* () {
const distros = yield* probeWslDistros;
expect(distros).toEqual([]);
}).pipe(
Effect.provideService(
ChildProcessSpawner.ChildProcessSpawner,
makeDistroListSpawner({ stdout: "", exitCode: 0 }),
),
),
);
it.effect("fails when the distro-list command exits unsuccessfully", () =>
Effect.gen(function* () {
const error = yield* probeWslDistros.pipe(Effect.flip);
expect(error).toBeInstanceOf(DesktopWslDistroListError);
expect(error.message).toContain("exited with code 1");
}).pipe(
Effect.provideService(
ChildProcessSpawner.ChildProcessSpawner,
makeDistroListSpawner({ exitCode: 1 }),
),
),
);
it.effect("fails when the distro-list command times out", () => {
const layer = Layer.merge(
TestClock.layer(),
Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, makeDistroListSpawner({})),
);
return Effect.gen(function* () {
const fiber = yield* probeWslDistros.pipe(Effect.flip, Effect.forkScoped);
yield* Effect.yieldNow;
yield* TestClock.adjust(Duration.seconds(8));
const error = yield* Fiber.join(fiber);
expect(error).toBeInstanceOf(DesktopWslDistroListError);
expect(error.message).toContain("timed out");
}).pipe(Effect.provide(layer));
});
});
describe("formatNodePtyProbeFailureReason", () => {
it("identifies a packaged build that omitted the Linux node-pty prebuild", () => {
const reason = formatNodePtyProbeFailureReason(4);
expect(reason).toContain("packaged Linux node-pty binary was not included");
expect(reason).toContain("--wsl-prebuild");
});
it("leaves other node-pty load failures to the compatibility diagnostic", () => {
expect(formatNodePtyProbeFailureReason(1)).toBeNull();
});
});
describe("formatWslShellTransportFailureReason", () => {
it("distinguishes timeouts and spawn failures from normal shell exit codes", () => {
expect(formatWslShellTransportFailureReason("timeout")).toContain("timed out");
expect(formatWslShellTransportFailureReason("spawn")).toContain("could not start wsl.exe");
expect(formatWslShellTransportFailureReason("process")).toContain("lost communication");
expect(formatWslShellTransportFailureReason(null)).toBeNull();
});
});
describe("buildWslNodeEnvPreamble", () => {
it("passes the required Node engine range into the shared resolver", () => {
const preamble = buildWslNodeEnvPreamble("^22.16 || ^23.11 || >=24.10");
expect(preamble).toContain("T3_NODE_ENGINE_RANGE='^22.16 || ^23.11 || >=24.10'");
expect(preamble.indexOf("T3_NODE_ENGINE_RANGE=")).toBeLessThan(
preamble.lastIndexOf("ensure_remote_node_path || true"),
);
});
it("keeps the shared resolver permissive when no Node engine range is provided", () => {
expect(buildWslNodeEnvPreamble()).toContain("T3_NODE_ENGINE_RANGE=''");
});
});
describe("parseToolchainReport", () => {
it("returns no missing tools and no node version on empty output", () => {
expect(parseToolchainReport("")).toEqual({ missingTools: [], nodeVersion: null });
});
it("collects all missing: lines", () => {
const stdout = ["missing:make", "missing:g++", "nodeVersion:24.10.0"].join("\n");
expect(parseToolchainReport(stdout)).toEqual({
missingTools: ["make", "g++"],
nodeVersion: "24.10.0",
});
});
it("ignores blank lines and trims whitespace", () => {
const stdout = [" missing:python3 ", "", " nodeVersion:v22.16.0 "].join("\n");
expect(parseToolchainReport(stdout)).toEqual({
missingTools: ["python3"],
nodeVersion: "v22.16.0",
});
});
it("returns null node version when value after prefix is empty", () => {
expect(parseToolchainReport("nodeVersion:")).toEqual({
missingTools: [],
nodeVersion: null,
});
});
});
describe("parseNodePath", () => {
it("extracts the absolute node path from a nodePath: line", () => {
const stdout = "nodePath:/home/josh/.nvm/versions/node/v22.16.0/bin/node";
expect(parseNodePath(stdout)).toBe("/home/josh/.nvm/versions/node/v22.16.0/bin/node");
});
it("returns null when node was not found (empty value after prefix)", () => {
expect(parseNodePath("nodePath:")).toBeNull();
});
it("returns null when there is no nodePath line at all", () => {
expect(parseNodePath("missing:node\nnodeVersion:")).toBeNull();
});
it("ignores surrounding noise and trims whitespace", () => {
const stdout = ["some preamble noise", " nodePath:/usr/bin/node ", "trailing"].join("\n");
expect(parseNodePath(stdout)).toBe("/usr/bin/node");
});
});
describe("parseNodeVersion", () => {
it("extracts the node version from a nodeVersion: line", () => {
expect(parseNodeVersion("nodeVersion:24.10.0")).toBe("24.10.0");
});
it("returns null when the version value is empty", () => {
expect(parseNodeVersion("nodeVersion:")).toBeNull();
});
it("returns null when there is no nodeVersion line at all", () => {
expect(parseNodeVersion("nodePath:/usr/bin/node\nresolvedPath:/usr/bin")).toBeNull();
});
it("ignores surrounding noise and trims whitespace", () => {
const stdout = [
"some preamble noise",
" nodeVersion:22.16.0 ",
"nodePath:/usr/bin/node",
].join("\n");
expect(parseNodeVersion(stdout)).toBe("22.16.0");
});
});
describe("parseResolvedPath", () => {
it("preserves spaces and apostrophes in the resolved login-shell PATH", () => {
const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin";
expect(parseResolvedPath(`nodePath:/usr/bin/node\nresolvedPath:${resolvedPath}\n`)).toBe(
resolvedPath,
);
});
it("accepts CRLF output without retaining the carriage return", () => {
expect(parseResolvedPath("resolvedPath:/usr/local/bin:/usr/bin\r\n")).toBe(
"/usr/local/bin:/usr/bin",
);
});
it("returns null when the resolved PATH is absent or empty", () => {
expect(parseResolvedPath("nodePath:/usr/bin/node\n")).toBeNull();
expect(parseResolvedPath("resolvedPath:\n")).toBeNull();
});
});
describe("formatMissingToolsReason", () => {
it("returns null when everything is present and node is in range", () => {
expect(
formatMissingToolsReason({ missingTools: [], nodeVersion: "24.10.0" }, "^24.10"),
).toBeNull();
});
it("returns null when range is not specified and tools are present", () => {
expect(formatMissingToolsReason({ missingTools: [], nodeVersion: "18.0.0" }, null)).toBeNull();
});
it("flags missing node first", () => {
const reason = formatMissingToolsReason(
{ missingTools: ["node", "make"], nodeVersion: null },
"^24.10",
);
expect(reason).toContain("node");
expect(reason).toContain("^24.10");
expect(reason).toContain("make");
expect(reason).toContain("nvm");
});
it("flags an out-of-range node version with the actual version surfaced", () => {
const reason = formatMissingToolsReason(
{ missingTools: [], nodeVersion: "20.0.0" },
"^24.10 || ^22.16",
);
expect(reason).toContain("node 20.0.0");
expect(reason).toContain("requires ^24.10 || ^22.16");
});
it("flags missing build tools without node when node is fine", () => {
const reason = formatMissingToolsReason(
{ missingTools: ["g++", "python3"], nodeVersion: "24.10.0" },
"^24.10",
);
expect(reason).toContain("g++");
expect(reason).toContain("python3");
expect(reason).toContain("build-essential");
expect(reason).not.toContain("nvm");
});
});