diff --git a/scripts/pnpm-cli-resolver.mjs b/scripts/pnpm-cli-resolver.mjs new file mode 100644 index 000000000..6a4d1bb38 --- /dev/null +++ b/scripts/pnpm-cli-resolver.mjs @@ -0,0 +1,37 @@ +// resolvePnpmCliPath 的独立模块:run-affected-unit-tests.mjs 顶层即执行 main(), +// 无法被测试直接导入,这里抽出纯函数便于单测覆盖(scripts 测试走 node --test)。 +import { existsSync } from "node:fs"; +import { delimiter, join } from "node:path"; + +// 候选会被 process.execPath 当作 JS 入口执行,只接受 JS 形态的文件: +// mise 独立版 pnpm 的 npm_execpath 指向原生 pnpm.exe(MZ 头二进制), +// 直接喂给 node 会以 SyntaxError 崩溃(#54),此时应回落到 PATH 搜索或 shell 兜底。 +const JS_ENTRY_PATH = /\.(?:cjs|mjs|js)$/iu; + +export function isJsEntryPath(file) { + return JS_ENTRY_PATH.test(file); +} + +export function resolvePnpmCliPath(env) { + const npmExecPath = env.npm_execpath; + if ( + npmExecPath && + (npmExecPath.toLowerCase().includes("\\pnpm\\") || npmExecPath.toLowerCase().includes("/pnpm/")) && + isJsEntryPath(npmExecPath) + ) { + return npmExecPath; + } + + const pathEntries = (env.PATH ?? env.Path ?? "") + .split(delimiter) + .map((entry) => entry.trim()) + .filter(Boolean); + for (const pathEntry of pathEntries) { + const candidate = join(pathEntry, "node_modules", "pnpm", "bin", "pnpm.cjs"); + if (existsSync(candidate)) { + return candidate; + } + } + + return undefined; +} diff --git a/scripts/pnpm-cli-resolver.test.mjs b/scripts/pnpm-cli-resolver.test.mjs new file mode 100644 index 000000000..27bae30b2 --- /dev/null +++ b/scripts/pnpm-cli-resolver.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { isJsEntryPath, resolvePnpmCliPath } from "./pnpm-cli-resolver.mjs"; + +test("isJsEntryPath 只认 .cjs/.mjs/.js 形态", () => { + assert.equal(isJsEntryPath("tools/pnpm/bin/pnpm.cjs"), true); + assert.equal(isJsEntryPath("tools\\pnpm\\pnpm.mjs"), true); + assert.equal(isJsEntryPath("tools/pnpm/10.33.2/pnpm"), false); + assert.equal(isJsEntryPath("tools/pnpm/10.33.2/pnpm.EXE"), false); +}); + +test("npm_execpath 指向原生 pnpm.exe(mise 独立版)时不得作为 JS 入口候选", async () => { + const dir = await mkdtemp(join(tmpdir(), "pnpm-resolver-")); + try { + // join 的分隔符随平台变化,但两种形态都命中 npm_execpath 的 \pnpm\ / /pnpm/ 判定, + // 再由扩展名校验拒绝:这正是 #54 里 mise 独立版 pnpm 的实际形态。 + const env = { npm_execpath: join(dir, "installs", "pnpm", "10.33.2", "pnpm.exe"), PATH: "" }; + assert.equal(resolvePnpmCliPath(env), undefined); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("npm_execpath 为 JS 形态时原样采用", () => { + const windowsStyle = { npm_execpath: "X:\\tools\\pnpm\\10.33.2\\pnpm.cjs", PATH: "" }; + assert.equal(resolvePnpmCliPath(windowsStyle), windowsStyle.npm_execpath); + const posixStyle = { npm_execpath: "/home/dev/.local/share/pnpm/pnpm.cjs", PATH: "" }; + assert.equal(resolvePnpmCliPath(posixStyle), posixStyle.npm_execpath); +}); + +test("npm_execpath 缺席或非法时回退 PATH 上的 node_modules/pnpm/bin/pnpm.cjs", async () => { + const dir = await mkdtemp(join(tmpdir(), "pnpm-resolver-")); + try { + const candidate = join(dir, "node_modules", "pnpm", "bin", "pnpm.cjs"); + await mkdir(join(dir, "node_modules", "pnpm", "bin"), { recursive: true }); + await writeFile(candidate, "// pnpm cli stub\n"); + + const withBadExecPath = { npm_execpath: join(dir, "pnpm.exe"), PATH: dir }; + assert.equal(resolvePnpmCliPath(withBadExecPath), candidate); + assert.equal(resolvePnpmCliPath({ PATH: dir }), candidate); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/run-affected-unit-tests.mjs b/scripts/run-affected-unit-tests.mjs index 109c5d8d9..728113ae8 100644 --- a/scripts/run-affected-unit-tests.mjs +++ b/scripts/run-affected-unit-tests.mjs @@ -3,9 +3,10 @@ import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { delimiter, join, resolve } from "node:path"; +import { join, resolve } from "node:path"; import { selectDefaultBranchRef } from "./affected-unit-test-baseline.mjs"; +import { resolvePnpmCliPath } from "./pnpm-cli-resolver.mjs"; const CODE_EXTENSIONS = new Set([ ".cjs", @@ -21,29 +22,6 @@ const CODE_EXTENSIONS = new Set([ const CHANGED_DIFF_FILTER = "ACMRTUXB"; const ZERO_SHA = /^0{40}$/u; -function resolvePnpmCliPath(env) { - const npmExecPath = env.npm_execpath; - if ( - npmExecPath && - (npmExecPath.toLowerCase().includes("\\pnpm\\") || npmExecPath.toLowerCase().includes("/pnpm/")) - ) { - return npmExecPath; - } - - const pathEntries = (env.PATH ?? env.Path ?? "") - .split(delimiter) - .map((entry) => entry.trim()) - .filter(Boolean); - for (const pathEntry of pathEntries) { - const candidate = join(pathEntry, "node_modules", "pnpm", "bin", "pnpm.cjs"); - if (existsSync(candidate)) { - return candidate; - } - } - - return undefined; -} - function run(command, args, options = {}) { const spawnOptions = { cwd: options.cwd,