diff --git a/cli/tests/e2e/helpers.ts b/cli/tests/e2e/helpers.ts index 304fdac57..a82d40ab2 100644 --- a/cli/tests/e2e/helpers.ts +++ b/cli/tests/e2e/helpers.ts @@ -1,10 +1,18 @@ import { execFile } from "node:child_process"; -import { accessSync, constants, existsSync, mkdtempSync, symlinkSync } from "node:fs"; +import { + accessSync, + constants, + copyFileSync, + existsSync, + mkdtempSync, + rmSync, + symlinkSync, +} from "node:fs"; import { copyFile, cp, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { delimiter, dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; -import { inject } from "vitest"; +import { afterAll, inject } from "vitest"; import { InitUseCase } from "../../src/contexts/framework/application/init-use-case.js"; import { CLIOutput } from "../../src/presentation/output.js"; import { environmentWithoutGitVariables as withoutGitEnv } from "../../src/runtime/git/git-environment.js"; @@ -84,7 +92,24 @@ export async function createTestEnv(prefix: string): Promise<{ * A sandboxed run must reach none of these: the CLI registers marketplaces through a tool's * own command when its binary is there, making recorded output depend on the machine. */ -const DRIVABLE_TOOL_BINARIES = ["claude", "codex", "copilot", "cursor-agent"]; +const DRIVABLE_TOOL_BINARIES = ["opencode", "claude", "codex", "copilot", "cursor-agent"]; + +let isolatedNodeDir: string | undefined; +afterAll(() => { + if (isolatedNodeDir) rmSync(isolatedNodeDir, { recursive: true, force: true }); + isolatedNodeDir = undefined; +}); + +function sandboxNodeDir(nodeDir: string): string { + if (withoutDrivableToolBinary(nodeDir) && !hasExecutable(nodeDir, "aidd")) return nodeDir; + if (!isolatedNodeDir) { + isolatedNodeDir = mkdtempSync(join(tmpdir(), "aidd-e2e-node-")); + const target = join(isolatedNodeDir, process.platform === "win32" ? "node.exe" : "node"); + if (process.platform === "win32") copyFileSync(process.execPath, target); + else symlinkSync(process.execPath, target); + } + return isolatedNodeDir; +} /** * Judged by what a directory holds, never by a keep-list: `node` and `copilot` share @@ -190,7 +215,7 @@ export function pathWithoutAidd( const kept = pathDirsWithoutAidd({ platform: process.platform, - nodeDir, + nodeDir: process.platform === "win32" ? sandboxNodeDir(nodeDir) : nodeDir, gitDir, systemRoot: process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows", pathDirs, diff --git a/cli/tests/e2e/sandbox-reaches-no-tool-binary.e2e.test.ts b/cli/tests/e2e/sandbox-reaches-no-tool-binary.e2e.test.ts index 919b2eba2..d051ea31a 100644 --- a/cli/tests/e2e/sandbox-reaches-no-tool-binary.e2e.test.ts +++ b/cli/tests/e2e/sandbox-reaches-no-tool-binary.e2e.test.ts @@ -1,6 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtemp, symlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { copyFile, mkdir, symlink, writeFile } from "node:fs/promises"; import { delimiter, join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; @@ -25,6 +24,30 @@ async function whichUnderSandbox(binary: string, cwd: string, env: NodeJS.Proces } describe("E2E: the sandbox a test spawns into", () => { + it("keeps node executable when an AI tool is installed beside it", async () => { + const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("sandbox-shared-node-"); + try { + const sharedDir = join(tempDir, "bin"); + await mkdir(sharedDir); + const nodePath = join(sharedDir, process.platform === "win32" ? "node.exe" : "node"); + if (process.platform === "win32") await copyFile(process.execPath, nodePath); + else await symlink(process.execPath, nodePath); + await writeFile(join(sharedDir, "codex"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + + const path = pathWithoutAidd({ nodeDir: sharedDir }); + const env = { ...sandboxedEnv(fakeHome), PATH: path, Path: path }; + expect(path.split(delimiter)).not.toContain(sharedDir); + expect(await whichUnderSandbox("codex", projectDir, env)).toBe(""); + const { stdout } = await execFileAsync("node", ["-p", "process.version"], { + cwd: projectDir, + env, + }); + expect(stdout.trim()).toBe(process.version); + } finally { + await cleanup(); + } + }); + it("reaches no AI tool binary, whatever the runner has installed", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("sandbox-path-"); try { @@ -56,25 +79,6 @@ describe("E2E: the sandbox a test spawns into", () => { } }); - it.skipIf(process.platform === "win32")( - "keeps node reachable when npm has installed a tool binary beside it", - async () => { - const { projectDir, cleanup } = await createTestEnv("sandbox-path-crowded-"); - try { - const crowded = await mkdtemp(join(tmpdir(), "aidd-crowded-bin-")); - await symlink(process.execPath, join(crowded, "node")); - await writeFile(join(crowded, "codex"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); - - const env = { PATH: pathWithoutAidd({ nodeDir: crowded }) }; - - expect(await whichUnderSandbox("node", projectDir, env)).not.toBe(""); - expect(await whichUnderSandbox("codex", projectDir, env)).toBe(""); - } finally { - await cleanup(); - } - } - ); - it("sets a PATH of its own rather than inheriting the runner's", async () => { const { fakeHome, cleanup } = await createTestEnv("sandbox-path-own-"); try {