From 9d87a5511fefd472d2a86f78827b1dd22955e4ee Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 8 Oct 2026 12:09:37 +0800 Subject: [PATCH 1/2] ci: run the full unit suite on all supported operating systems --- .github/workflows/test.yml | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f88a62f..c7275a7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] merge_group: workflow_dispatch: inputs: @@ -1137,12 +1136,6 @@ jobs: - name: Build portable bundles run: vp run build - # The full unit suite runs only on Ubuntu and skips PowerShell execution. - # Run these regressions in the OS matrix to exercise Bash on Linux/macOS - # and native powershell.exe on Windows; syntax checks cannot catch injection. - - name: Test Azure template ref validation and shell injection regressions - run: vp run test src/azure/template.test.ts - - name: Verify Azure runtime invalid phase smoke test shell: bash run: | @@ -1162,9 +1155,14 @@ jobs: [scriptblock]::Create((Get-Content -Raw azure/bootstrap.ps1)) > $null [scriptblock]::Create((Get-Content -Raw gitlab/bootstrap.ps1)) > $null - test-portable-process: - name: Portable Windows command and cache regressions - runs-on: windows-latest + test-unit: + name: Unit tests (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + # Use the published CLI for repository checks, including manual runs. env: VP_PR_VERSION: "" steps: @@ -1173,13 +1171,16 @@ jobs: uses: ./ with: node-version-file: .node-version - - name: Test native Windows executables and cache links - run: vp run test src/ci/process.test.ts src/ci/cache-snapshot.test.ts + # Some tests inspect or execute dist/, so build on each runner first. + - name: Build + run: vp run build + - name: Unit tests + run: vp run test build: runs-on: ubuntu-latest # This job verifies the action's own artifacts (dist/ committed and up to - # date, types, unit tests). Pin it to the latest published Vite+ even on a + # date, types, lint). Pin it to the latest published Vite+ even on a # pr_version dispatch: a pre-release bundler could shift dist/ output and # fail the "dist is up to date" diff for reasons unrelated to the change. env: @@ -1200,9 +1201,6 @@ jobs: - name: Check run: vp run check - - name: Unit tests - run: vp run test - - name: Build run: vp run build From d0f77b40887b646aa75b64d8dccca7150a17cd44 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 8 Oct 2026 12:17:52 +0800 Subject: [PATCH 2/2] test: make unit fixtures portable across operating systems --- src/auth.test.ts | 8 +- src/azure/install-viteplus.test.ts | 6 +- src/ci/auth.test.ts | 3 +- src/gitlab/auth.test.ts | 3 +- src/gitlab/bootstrap.test.ts | 74 +++++++++-------- src/gitlab/install-sfw.test.ts | 93 +++++++++------------- src/gitlab/run-install.test.ts | 123 ++++++++++------------------- src/install-viteplus.test.ts | 37 ++++++--- src/lockfile-version.test.ts | 13 +-- src/node-version-file.test.ts | 7 +- src/version-file.test.ts | 16 ++-- 11 files changed, 179 insertions(+), 204 deletions(-) diff --git a/src/auth.test.ts b/src/auth.test.ts index 92664a4..75d6b37 100644 --- a/src/auth.test.ts +++ b/src/auth.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { configAuthentication, propagateProjectNpmrcAuth } from "./auth.js"; import { exportVariable, info } from "@actions/core"; @@ -21,7 +21,7 @@ vi.mock("node:fs", async () => { }); describe("configAuthentication", () => { - const runnerTemp = "/tmp/runner"; + const runnerTemp = resolve("/tmp/runner"); beforeEach(() => { vi.stubEnv("RUNNER_TEMP", runnerTemp); @@ -189,8 +189,8 @@ describe("configAuthentication", () => { }); describe("propagateProjectNpmrcAuth", () => { - const runnerTemp = "/tmp/runner"; - const projectDir = "/workspace/project"; + const runnerTemp = resolve("/tmp/runner"); + const projectDir = resolve("/workspace/project"); const npmrcPath = join(projectDir, ".npmrc"); const supplementalPath = join(runnerTemp, ".npmrc"); diff --git a/src/azure/install-viteplus.test.ts b/src/azure/install-viteplus.test.ts index 7251315..c9ef7dd 100644 --- a/src/azure/install-viteplus.test.ts +++ b/src/azure/install-viteplus.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { writeFileSync } from "node:fs"; +import { join } from "node:path"; import { installVitePlus } from "./install-viteplus.js"; function writeDirsFile(env: Record, bin: string): void { @@ -122,8 +123,9 @@ describe("installVitePlus", () => { logWarningFn: () => undefined, }); - expect(prependPath).toHaveBeenCalledWith("/home/runner/.vite-plus/bin"); - expect(env.PATH).toBe("/home/runner/.vite-plus/bin:/usr/bin"); + const bin = join("/home/runner", ".vite-plus", "bin"); + expect(prependPath).toHaveBeenCalledWith(bin); + expect(env.PATH).toBe(`${bin}:/usr/bin`); }); it.each([ diff --git a/src/ci/auth.test.ts b/src/ci/auth.test.ts index 0e0d549..60f21df 100644 --- a/src/ci/auth.test.ts +++ b/src/ci/auth.test.ts @@ -36,7 +36,8 @@ describe("portable project auth", () => { expect(result).toContain("//registry.example/npm/:_authToken=${NODE_AUTH_TOKEN}"); expect(result).not.toContain("secret"); expect(readFileSync(path.join(project, ".npmrc"), "utf8")).toBe(content); - expect(statSync(npmrc).mode & 0o777).toBe(0o600); + // Windows does not expose POSIX owner/group permission bits. + if (process.platform !== "win32") expect(statSync(npmrc).mode & 0o777).toBe(0o600); expect(exporter).toHaveBeenCalledWith("NODE_AUTH_TOKEN", "secret"); expect(exporter).toHaveBeenCalledWith("CUSTOM_TOKEN", "other-secret"); expect(env.PNPM_CONFIG_USERCONFIG).toBe(npmrc); diff --git a/src/gitlab/auth.test.ts b/src/gitlab/auth.test.ts index 7a99008..b0b6d49 100644 --- a/src/gitlab/auth.test.ts +++ b/src/gitlab/auth.test.ts @@ -31,7 +31,8 @@ describe("configureAuth", () => { expect(targetEnv.NODE_AUTH_TOKEN).toBe("XXXXX-XXXXX-XXXXX-XXXXX"); expect(path.basename(path.dirname(npmrc))).toMatch(/^setup-vp-npmrc-/); expect(npmrc).not.toBe(path.join(tmpdir(), `setup-vp-npmrc.${process.pid}`)); - expect(statSync(npmrc).mode & 0o777).toBe(0o600); + // Windows does not expose POSIX owner/group permission bits. + if (process.platform !== "win32") expect(statSync(npmrc).mode & 0o777).toBe(0o600); expect(readFileSync(npmrc, "utf8")).toBe( "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}\n@myorg:registry=https://npm.pkg.github.com/\n", ); diff --git a/src/gitlab/bootstrap.test.ts b/src/gitlab/bootstrap.test.ts index b9fc55c..c2c8409 100644 --- a/src/gitlab/bootstrap.test.ts +++ b/src/gitlab/bootstrap.test.ts @@ -22,46 +22,50 @@ describe("GitLab bootstrap", () => { expect(result.status, result.stderr).toBe(0); }); - it.each([0, 23])("uses the image Node and preserves runtime exit code %i", (code) => { - const fixture = mkdtempSync(join(tmpdir(), "setup-vp-bootstrap-")); - const bin = join(fixture, "image bin"); - mkdirSync(bin); - symlinkSync(process.execPath, join(bin, "node")); - writeFileSync( - join(bin, "curl"), - `#!/usr/bin/env bash + // This exercises the Unix bootstrap; Windows runners use bootstrap.ps1. + it.skipIf(process.platform === "win32").each([0, 23])( + "uses the image Node and preserves runtime exit code %i", + (code) => { + const fixture = mkdtempSync(join(tmpdir(), "setup-vp-bootstrap-")); + const bin = join(fixture, "image bin"); + mkdirSync(bin); + symlinkSync(process.execPath, join(bin, "node")); + writeFileSync( + join(bin, "curl"), + `#!/usr/bin/env bash case "$6" in */test-ref/dist/gitlab/index.mjs) cp "$SETUP_VP_TEST_DIR/runtime.mjs" "$8" ;; *) exit 90 ;; esac `, - ); - chmodSync(join(bin, "curl"), 0o755); - writeFileSync( - join(fixture, "runtime.mjs"), - `console.log("runtime started:", process.execPath); process.exit(${code});`, - ); - try { - const result = spawnSync("bash", [bootstrapPath], { - encoding: "utf8", - env: { - ...process.env, - PATH: `${bin}:${process.env.PATH}`, - TMPDIR: fixture, - SETUP_VP_TEST_DIR: fixture, - SETUP_VP_SETUP_REF: "test-ref", - SETUP_VP_NODE_MANAGER: "false", - }, - }); - expect(result.status, result.stderr).toBe(code); - expect(result.stdout).toContain(`runtime started: ${process.execPath}`); - expect( - readdirSync(fixture).filter((name) => name.startsWith("setup-vp-gitlab-runtime.")), - ).toEqual([]); - } finally { - rmSync(fixture, { recursive: true, force: true }); - } - }); + ); + chmodSync(join(bin, "curl"), 0o755); + writeFileSync( + join(fixture, "runtime.mjs"), + `console.log("runtime started:", process.execPath); process.exit(${code});`, + ); + try { + const result = spawnSync("bash", [bootstrapPath], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + TMPDIR: fixture, + SETUP_VP_TEST_DIR: fixture, + SETUP_VP_SETUP_REF: "test-ref", + SETUP_VP_NODE_MANAGER: "false", + }, + }); + expect(result.status, result.stderr).toBe(code); + expect(result.stdout).toContain(`runtime started: ${process.execPath}`); + expect( + readdirSync(fixture).filter((name) => name.startsWith("setup-vp-gitlab-runtime.")), + ).toEqual([]); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } + }, + ); it("uses the shared runtime on Windows and checks native command failures", () => { const bootstrap = readFileSync(new URL("../../gitlab/bootstrap.ps1", import.meta.url), "utf8"); diff --git a/src/gitlab/install-sfw.test.ts b/src/gitlab/install-sfw.test.ts index 23ac738..85a79bd 100644 --- a/src/gitlab/install-sfw.test.ts +++ b/src/gitlab/install-sfw.test.ts @@ -1,10 +1,15 @@ -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { EventEmitter } from "node:events"; import type { IncomingMessage } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { downloadFile, getSfwAssetName, setupSfw } from "./install-sfw.js"; +import { commandPath } from "../ci/process.js"; + +vi.mock("node:child_process", () => ({ spawnSync: vi.fn() })); +vi.mock("../ci/process.js", () => ({ commandPath: vi.fn() })); const tempDirs: string[] = []; @@ -14,11 +19,8 @@ function tempDir(): string { return dir; } -function shellQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'`; -} - afterEach(() => { + vi.resetAllMocks(); for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } @@ -40,61 +42,44 @@ describe("GitLab sfw setup", () => { }); it("uses an existing sfw command from PATH", async () => { - const dir = tempDir(); - const binDir = path.join(dir, "bin"); - mkdirSync(binDir); - const sfwBin = path.join(binDir, "sfw"); - writeFileSync(sfwBin, "#!/usr/bin/env sh\nexit 0\n", "utf8"); - chmodSync(sfwBin, 0o755); - - const previousPath = process.env.PATH; - try { - process.env.PATH = `${binDir}:${previousPath || ""}`; - expect(await setupSfw([{}], { SETUP_VP_SFW: "true", PATH: process.env.PATH })).toBe("sfw"); - } finally { - process.env.PATH = previousPath; - } + vi.mocked(commandPath).mockReturnValue(path.join(tempDir(), "sfw")); + + expect(await setupSfw([{}], { SETUP_VP_SFW: "true" })).toBe("sfw"); + expect(commandPath).toHaveBeenCalledWith("sfw"); + expect(spawnSync).not.toHaveBeenCalled(); }); it("uses curl for default downloads", async () => { const dir = tempDir(); - const binDir = path.join(dir, "bin"); const outputPath = path.join(dir, "sfw"); - const logFile = path.join(dir, "curl.log"); - mkdirSync(binDir); - - const curlBin = path.join(binDir, "curl"); - writeFileSync( - curlBin, + const curl = path.join(dir, "bin with spaces", "curl"); + vi.mocked(commandPath).mockReturnValue(curl); + vi.mocked(spawnSync).mockReturnValue({ + pid: 1, + output: [], + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + status: 0, + signal: null, + }); + + await downloadFile("https://example.test/sfw", outputPath); + + expect(commandPath).toHaveBeenCalledWith("curl"); + expect(spawnSync).toHaveBeenCalledExactlyOnceWith( + curl, [ - "#!/usr/bin/env sh", - `printf '%s\\n' "$*" > ${shellQuote(logFile)}`, - 'out=""', - 'while [ "$#" -gt 0 ]; do', - ' if [ "$1" = "-o" ]; then', - " shift", - ' out="$1"', - " break", - " fi", - " shift", - "done", - 'printf "sfw" > "$out"', - ].join("\n"), - "utf8", + "-fsSL", + "--connect-timeout", + "5", + "--max-time", + "60", + "https://example.test/sfw", + "-o", + outputPath, + ], + { stdio: "ignore" }, ); - chmodSync(curlBin, 0o755); - - const previousPath = process.env.PATH; - try { - process.env.PATH = `${binDir}:${previousPath || ""}`; - await downloadFile("https://example.test/sfw", outputPath); - } finally { - process.env.PATH = previousPath; - } - - expect(readFileSync(outputPath, "utf8")).toBe("sfw"); - expect(readFileSync(logFile, "utf8")).toContain("https://example.test/sfw"); - expect(readFileSync(logFile, "utf8")).toContain("--max-time 60"); }); it("times out stalled downloads", async () => { diff --git a/src/gitlab/run-install.test.ts b/src/gitlab/run-install.test.ts index 6f7f034..b95c59f 100644 --- a/src/gitlab/run-install.test.ts +++ b/src/gitlab/run-install.test.ts @@ -1,16 +1,9 @@ -import { - chmodSync, - mkdirSync, - mkdtempSync, - readFileSync, - realpathSync, - rmSync, - writeFileSync, -} from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vite-plus/test"; import { parseFlowArray, parseRunInstall, runInstall } from "./run-install.js"; +import { runWithOutput } from "../ci/process.js"; const tempDirs: string[] = []; @@ -86,90 +79,60 @@ describe("GitLab run-install parsing", () => { }); }); +// Run the recorder through Node so cwd, arguments, and environment reach a real +// child process on every OS. Native command lookup is covered in ci/process.test.ts. +function recorder(directory: string): typeof runWithOutput { + const script = path.join(directory, "record.cjs"); + const output = path.join(directory, "run.json"); + writeFileSync( + script, + `const fs = require('node:fs'); +fs.writeFileSync(${JSON.stringify(output)}, JSON.stringify({ + cwd: fs.realpathSync(process.cwd()), + command: process.argv[2], + args: process.argv.slice(3), + envFile: process.env.SETUP_VP_ENV_FILE, +}));`, + ); + return (command, args, options) => + runWithOutput(process.execPath, [script, command, ...args], options); +} + describe("GitLab run-install execution", () => { it("runs install entries with cwd and args", async () => { const dir = tempDir(); - const binDir = path.join(dir, "bin"); const appDir = path.join(dir, "app"); - const logFile = path.join(dir, "run.log"); - mkdirSync(binDir); mkdirSync(appDir); - const vpBin = path.join(binDir, "vp"); - writeFileSync( - vpBin, - `#!/usr/bin/env sh\nprintf '%s\\n%s\\n' "$PWD" "$*" > "${logFile}"\n`, - "utf8", - ); - chmodSync(vpBin, 0o755); - - const previousPath = process.env.PATH; - try { - process.env.PATH = `${binDir}:${previousPath || ""}`; - await runInstall([{ cwd: "app", args: ["--frozen-lockfile"] }], dir, "vp"); - } finally { - process.env.PATH = previousPath; - } - - expect(readFileSync(logFile, "utf8")).toBe( - `${realpathSync(appDir)}\ninstall --frozen-lockfile\n`, - ); + await runInstall([{ cwd: "app", args: ["--frozen-lockfile"] }], dir, "vp", process.env, { + execute: recorder(dir), + }); + + expect(JSON.parse(readFileSync(path.join(dir, "run.json"), "utf8"))).toEqual({ + cwd: realpathSync(appDir), + command: "vp", + args: ["install", "--frozen-lockfile"], + }); }); it("runs install entries through sfw when requested", async () => { const dir = tempDir(); - const binDir = path.join(dir, "bin"); - const logFile = path.join(dir, "sfw.log"); - mkdirSync(binDir); - const sfwBin = path.join(binDir, "sfw"); - writeFileSync(sfwBin, `#!/usr/bin/env sh\nprintf '%s\\n' "$*" > "${logFile}"\n`, "utf8"); - chmodSync(sfwBin, 0o755); - - const previousPath = process.env.PATH; - try { - process.env.PATH = `${binDir}:${previousPath || ""}`; - await runInstall([{}], dir, "sfw"); - } finally { - process.env.PATH = previousPath; - } - - expect(readFileSync(logFile, "utf8")).toBe("vp install\n"); + await runInstall([{}], dir, "sfw", process.env, { execute: recorder(dir) }); + + expect(JSON.parse(readFileSync(path.join(dir, "run.json"), "utf8"))).toEqual({ + cwd: realpathSync(dir), + command: "sfw", + args: ["vp", "install"], + }); }); it("does not expose SETUP_VP_ENV_FILE to install subprocesses", async () => { const dir = tempDir(); - const binDir = path.join(dir, "bin"); - const logFile = path.join(dir, "env.log"); - mkdirSync(binDir); - const vpBin = path.join(binDir, "vp"); - writeFileSync( - vpBin, - [ - "#!/usr/bin/env sh", - 'if [ "${SETUP_VP_ENV_FILE+x}" = "x" ]; then', - ` printf 'present:%s\\n' "$SETUP_VP_ENV_FILE" > "${logFile}"`, - "else", - ` printf 'missing\\n' > "${logFile}"`, - "fi", - ].join("\n"), - "utf8", + const env = { ...process.env, SETUP_VP_ENV_FILE: path.join(dir, "setup-vp.env") }; + await runInstall([{}], dir, "vp", env, { execute: recorder(dir) }); + + expect(JSON.parse(readFileSync(path.join(dir, "run.json"), "utf8"))).not.toHaveProperty( + "envFile", ); - chmodSync(vpBin, 0o755); - - const previousPath = process.env.PATH; - const previousEnvFile = process.env.SETUP_VP_ENV_FILE; - try { - process.env.PATH = `${binDir}:${previousPath || ""}`; - process.env.SETUP_VP_ENV_FILE = path.join(dir, "setup-vp.env"); - await runInstall([{}], dir, "vp"); - } finally { - process.env.PATH = previousPath; - if (previousEnvFile === undefined) { - delete process.env.SETUP_VP_ENV_FILE; - } else { - process.env.SETUP_VP_ENV_FILE = previousEnvFile; - } - } - - expect(readFileSync(logFile, "utf8")).toBe("missing\n"); + expect(env.SETUP_VP_ENV_FILE).toBe(path.join(dir, "setup-vp.env")); }); }); diff --git a/src/install-viteplus.test.ts b/src/install-viteplus.test.ts index f5c41f6..3a0b933 100644 --- a/src/install-viteplus.test.ts +++ b/src/install-viteplus.test.ts @@ -8,6 +8,9 @@ import { installVitePlus } from "./install-viteplus.js"; import { findReusableVitePlus } from "./reuse-viteplus.js"; import type { Inputs } from "./types.js"; +const windows = process.platform === "win32"; +const installerExtension = windows ? "ps1" : "sh"; + vi.mock("@actions/core", () => ({ info: vi.fn(), warning: vi.fn(), @@ -146,6 +149,7 @@ describe("installVitePlus", () => { it("should fall back to the legacy bin for Vite+ releases without VpDirs", async () => { vi.stubEnv("HOME", "/home/runner"); + vi.stubEnv("USERPROFILE", "/home/runner"); vi.stubEnv("PATH", "/usr/bin"); vi.stubEnv("VP_VPDIRS_AWARE", "1"); vi.stubEnv("SETUP_VP_DIRS_FILE", "/tmp/stale-vp-dirs"); @@ -153,9 +157,9 @@ describe("installVitePlus", () => { await installVitePlus({ ...baseInputs, version: "0.2.9" }); - expect(addPath).toHaveBeenCalledWith("/home/runner/.vite-plus/bin"); + expect(addPath).toHaveBeenCalledWith(join("/home/runner", ".vite-plus", "bin")); const [_command, args, options] = vi.mocked(exec).mock.calls[0]; - expect((args as string[])[1]).toContain("| bash"); + expect((args as string[])[1]).toContain(windows ? "[scriptblock]::Create" : "| bash"); expect((args as string[])[1]).not.toContain("VP_DUMP_DIRS"); expect((options as { env: Record }).env.VP_VPDIRS_AWARE).toBeUndefined(); expect((options as { env: Record }).env.SETUP_VP_DIRS_FILE).toBeUndefined(); @@ -163,6 +167,7 @@ describe("installVitePlus", () => { it("should use the installed version to resolve the latest dist-tag", async () => { vi.stubEnv("HOME", "/home/runner"); + vi.stubEnv("USERPROFILE", "/home/runner"); vi.stubEnv("PATH", "/usr/bin"); vi.mocked(exec).mockImplementationOnce(async (_command, _args, options) => { const env = (options as { env: Record }).env; @@ -172,7 +177,7 @@ describe("installVitePlus", () => { await installVitePlus(baseInputs); - expect(addPath).toHaveBeenCalledWith("/home/runner/.vite-plus/bin"); + expect(addPath).toHaveBeenCalledWith(join("/home/runner", ".vite-plus", "bin")); }); it.each([ @@ -233,11 +238,11 @@ describe("installVitePlus", () => { expect(exec).toHaveBeenCalledTimes(2); const primaryScript = (vi.mocked(exec).mock.calls[0][1] as string[])[1]; - expect(primaryScript).toContain("https://viteplus.dev/install.sh"); + expect(primaryScript).toContain(`https://viteplus.dev/install.${installerExtension}`); const fallbackScript = (vi.mocked(exec).mock.calls[1][1] as string[])[1]; expect(fallbackScript).toContain( - "https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.sh", + `https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.${installerExtension}`, ); }); @@ -248,9 +253,9 @@ describe("installVitePlus", () => { const scripts = vi.mocked(exec).mock.calls.map((call) => (call[1] as string[])[1]); expect(scripts).toHaveLength(4); - expect(scripts[0]).toContain("viteplus.dev/install.sh"); + expect(scripts[0]).toContain(`viteplus.dev/install.${installerExtension}`); expect(scripts[1]).toContain("raw.githubusercontent.com"); - expect(scripts[2]).toContain("viteplus.dev/install.sh"); + expect(scripts[2]).toContain(`viteplus.dev/install.${installerExtension}`); expect(scripts[3]).toContain("raw.githubusercontent.com"); }); @@ -268,7 +273,7 @@ describe("installVitePlus", () => { const script = (vi.mocked(exec).mock.calls[0][1] as string[])[1]; expect(script).toContain( - `https://raw.githubusercontent.com/voidzero-dev/vite-plus/${ref}/packages/cli/install.sh`, + `https://raw.githubusercontent.com/voidzero-dev/vite-plus/${ref}/packages/cli/install.${installerExtension}`, ); }, ); @@ -281,7 +286,7 @@ describe("installVitePlus", () => { const fallbackScript = (vi.mocked(exec).mock.calls[1][1] as string[])[1]; expect(fallbackScript).toContain( - "https://cdn.jsdelivr.net/gh/voidzero-dev/vite-plus@v0.2.9/packages/cli/install.sh", + `https://cdn.jsdelivr.net/gh/voidzero-dev/vite-plus@v0.2.9/packages/cli/install.${installerExtension}`, ); // If both pinned attempts fail, the code warns before it uses the latest // URLs. Success on the mirror must not trigger the compatibility fallback. @@ -304,7 +309,7 @@ describe("installVitePlus", () => { expect(scripts[1]).toContain("jsdelivr"); expect(scripts[2]).toContain("/v0.2.9/"); expect(scripts[3]).toContain("jsdelivr"); - expect(scripts[4]).toContain("https://viteplus.dev/install.sh"); + expect(scripts[4]).toContain(`https://viteplus.dev/install.${installerExtension}`); expect(warning).toHaveBeenCalledWith( expect.stringContaining("The latest script may not be compatible with 0.2.9"), ); @@ -320,14 +325,22 @@ describe("installVitePlus", () => { expect(exec).toHaveBeenCalledTimes(8); }); - it("should source the downloaded bash installer and dump its resolved directories", async () => { + it("should source the platform installer and dump its resolved directories", async () => { mockSuccessfulInstallOnce(); await installVitePlus(baseInputs); const [cmd, args] = vi.mocked(exec).mock.calls[0]; - expect(cmd).toBe("bash"); + expect(cmd).toBe(windows ? "pwsh" : "bash"); const script = (args as string[])[1]; + if (windows) { + expect(script).toContain(". ([scriptblock]::Create((irm -TimeoutSec"); + expect(script).toContain("Join-Path $vpDir 'vp.exe'"); + expect(script).toContain("& $vpPath --version"); + expect(script).toContain("$env:VP_DUMP_DIRS = '1'"); + expect(script).toContain("& $vpPath | Add-Content -LiteralPath $dirsFile"); + return; + } expect(script).toMatch(/^set \+u\nset -eo pipefail\n/); expect(script).toContain("--connect-timeout"); expect(script).toContain("--max-time"); diff --git a/src/lockfile-version.test.ts b/src/lockfile-version.test.ts index 15c33da..0b41525 100644 --- a/src/lockfile-version.test.ts +++ b/src/lockfile-version.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test"; import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join, normalize } from "node:path"; import { debug } from "@actions/core"; import { parseVitePlusVersionFromLockfile, @@ -25,10 +26,10 @@ vi.mock("node:fs", async () => ({ function lock(filename: string, type: LockFileType, content: string): LockFileInfo { vi.mocked(readFileSync).mockImplementation((path) => { - if (path === `/repo/${filename}`) return content; + if (path === join("/repo", filename)) return content; throw new Error(`ENOENT: ${String(path)}`); }); - return { filename, type, path: `/repo/${filename}` }; + return { filename, type, path: join("/repo", filename) }; } describe("parseVitePlusVersionFromLockfile", () => { @@ -364,10 +365,10 @@ describe("tryResolveVitePlusVersionFromLockfile", () => { it("searches upward to the workspace root for a monorepo package's lockfile", () => { // Lockfile lives at the repo root; the working-directory is a subpackage. vi.mocked(readdirSync).mockImplementation( - (dir) => (dir === "/repo" ? ["pnpm-lock.yaml"] : []) as never, + (dir) => (normalize(String(dir)) === normalize("/repo") ? ["pnpm-lock.yaml"] : []) as never, ); vi.mocked(readFileSync).mockImplementation((p) => { - if (p === "/repo/pnpm-lock.yaml") { + if (p === join("/repo", "pnpm-lock.yaml")) { return [ "importers:", " packages/app:", @@ -393,9 +394,9 @@ describe("tryResolveVitePlusVersionFromLockfile", () => { it("prefers a readable text bun.lock beside a binary bun.lockb", () => { vi.mocked(readdirSync).mockReturnValue(["bun.lockb", "bun.lock"] as never); - vi.mocked(existsSync).mockImplementation((p) => p === "/repo/bun.lock"); + vi.mocked(existsSync).mockImplementation((p) => p === join("/repo", "bun.lock")); vi.mocked(readFileSync).mockImplementation((p) => { - if (p === "/repo/bun.lock") { + if (p === join("/repo", "bun.lock")) { return JSON.stringify({ packages: { "vite-plus": ["vite-plus@0.2.0", "", {}, "sha512-x"] }, }) as never; diff --git a/src/node-version-file.test.ts b/src/node-version-file.test.ts index 455fc77..73bc503 100644 --- a/src/node-version-file.test.ts +++ b/src/node-version-file.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test"; import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { resolveNodeVersionFile } from "./node-version-file.js"; vi.mock("@actions/core", () => ({ @@ -28,7 +29,7 @@ describe("resolveNodeVersionFile", () => { resolveNodeVersionFile(".nvmrc"); - expect(readFileSync).toHaveBeenCalledWith("/workspace/.nvmrc", "utf-8"); + expect(readFileSync).toHaveBeenCalledWith(join("/workspace", ".nvmrc"), "utf-8"); }); it("should use absolute path as-is", () => { @@ -44,7 +45,7 @@ describe("resolveNodeVersionFile", () => { resolveNodeVersionFile(".nvmrc", "/workspace/web"); - expect(readFileSync).toHaveBeenCalledWith("/workspace/web/.nvmrc", "utf-8"); + expect(readFileSync).toHaveBeenCalledWith(join("/workspace/web", ".nvmrc"), "utf-8"); }); it("should throw if file does not exist", () => { @@ -53,7 +54,7 @@ describe("resolveNodeVersionFile", () => { }); expect(() => resolveNodeVersionFile(".nvmrc")).toThrow( - "node-version-file not found: /workspace/.nvmrc", + `node-version-file not found: ${join("/workspace", ".nvmrc")}`, ); }); }); diff --git a/src/version-file.test.ts b/src/version-file.test.ts index 18bd67f..02919de 100644 --- a/src/version-file.test.ts +++ b/src/version-file.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test"; import { readFileSync, readdirSync } from "node:fs"; +import { join, normalize, sep } from "node:path"; import { warning } from "@actions/core"; import { resolveVitePlusVersion, @@ -30,17 +31,20 @@ vi.mock("node:fs", async () => ({ * map so lockfile auto-detection sees the same fixture files. */ function mockFiles(files: Record): void { + const nativeFiles = Object.fromEntries( + Object.entries(files).map(([file, content]) => [normalize(file), content]), + ); vi.mocked(readFileSync).mockImplementation((path) => { - const content = files[path as string]; + const content = nativeFiles[normalize(path as string)]; if (content === undefined) { throw new Error(`ENOENT: ${String(path)}`); } return content; }); vi.mocked(readdirSync).mockImplementation((dir) => { - const prefix = `${String(dir)}/`; - return Object.keys(files) - .filter((p) => p.startsWith(prefix) && !p.slice(prefix.length).includes("/")) + const prefix = `${normalize(String(dir))}${sep}`; + return Object.keys(nativeFiles) + .filter((p) => p.startsWith(prefix) && !p.slice(prefix.length).includes(sep)) .map((p) => p.slice(prefix.length)) as never; }); } @@ -64,7 +68,7 @@ describe("resolveVitePlusVersionFile", () => { }); expect(resolveVitePlusVersionFile("package.json")).toBe("0.2.0"); - expect(readFileSync).toHaveBeenCalledWith("/workspace/package.json", "utf-8"); + expect(readFileSync).toHaveBeenCalledWith(join("/workspace", "package.json"), "utf-8"); }); it("should use absolute path as-is", () => { @@ -89,7 +93,7 @@ describe("resolveVitePlusVersionFile", () => { mockFiles({}); expect(() => resolveVitePlusVersionFile("package.json")).toThrow( - "version-file not found: /workspace/package.json", + `version-file not found: ${join("/workspace", "package.json")}`, ); });