diff --git a/scripts/check-changed-command.test.ts b/scripts/check-changed-command.test.ts new file mode 100644 index 0000000000000..d0d1995a9fed0 --- /dev/null +++ b/scripts/check-changed-command.test.ts @@ -0,0 +1,122 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { expect, it } from "vite-plus/test"; + +import { vitePlusCommand } from "./check-changed-command.ts"; + +function fixture() { + const root = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3 check & (vp) % ! ")), + ); + const write = (file: string, content: string) => { + const path = NodePath.join(root, file); + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + NodeFS.writeFileSync(path, content); + }; + NodeFS.copyFileSync( + NodePath.join(import.meta.dirname, "check-changed-command.ts"), + NodePath.join(root, "command.ts"), + ); + write("package.json", JSON.stringify({ type: "module" })); + write( + "node_modules/vite-plus/package.json", + JSON.stringify({ type: "module", exports: { "./bin": "./cli.js" } }), + ); + write( + "node_modules/vite-plus/cli.js", + `import fs from 'node:fs'; + fs.writeFileSync(process.env.CALL_FILE, JSON.stringify({ + args: process.argv.slice(2), cwd: process.cwd(), marker: process.env.ARG_MARKER + })); + process.stdout.write('vp stdout'); + process.stderr.write('vp stderr'); + process.exit(Number(process.env.EXIT_STATUS));`, + ); + write( + "run.mjs", + `import { spawn, spawnSync } from 'node:child_process'; + import { vitePlusCommand } from './command.ts'; + const [command, args] = vitePlusCommand(process.argv.slice(3), 'win32'); + const options = { cwd: process.env.CHILD_CWD, env: process.env }; + if (process.argv[2] === 'sync') { + const result = spawnSync(command, args, { ...options, encoding: 'utf8' }); + process.stdout.write(result.stdout ?? ''); + process.stderr.write(result.stderr ?? ''); + if (result.error) console.error(result.error.message); + process.exitCode = result.status ?? 1; + } else { + const child = spawn(command, args, options); + child.stdout.pipe(process.stdout); + child.stderr.pipe(process.stderr); + child.on('error', error => console.error(error.message)); + child.on('close', status => { process.exitCode = status ?? 1; }); + }`, + ); + const cwd = NodePath.join(root, "temporary install"); + NodeFS.mkdirSync(cwd); + return { + cwd, + run(mode: string, args: Array, status: number) { + return NodeChildProcess.spawnSync( + process.execPath, + [NodePath.join(root, "run.mjs"), mode, ...args], + { + cwd: NodeOS.tmpdir(), + encoding: "utf8", + env: { + ...process.env, + // No vp, vp.cmd or node launcher can be found on PATH. + PATH: root, + Path: root, + CALL_FILE: NodePath.join(root, "call.json"), + CHILD_CWD: cwd, + ARG_MARKER: "inherited", + EXIT_STATUS: String(status), + }, + }, + ); + }, + call: () => JSON.parse(NodeFS.readFileSync(NodePath.join(root, "call.json"), "utf8")), + [Symbol.dispose]() { + NodeFS.rmSync(root, { recursive: true, force: true }); + }, + }; +} + +it.each(["linux", "darwin"] as const)("keeps the existing %s launcher", (platform) => { + const args = ["fmt", "--check", "a file.ts"]; + expect(vitePlusCommand(args, platform)).toEqual(["vp", args]); +}); + +it.each(["sync", "async"])("launches the Windows CLI without a shim (%s)", (mode) => { + using project = fixture(); + const args = [ + "fmt", + "--check", + "space & (draft) %PATH% ! ^ 研究.ts", + 'embedded"quote', + "trailing\\", + "", + "& echo injected>injected.txt", + ]; + const result = project.run(mode, args, 0); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(result.stdout).toBe("vp stdout"); + expect(result.stderr).toBe("vp stderr"); + expect(project.call()).toEqual({ args, cwd: project.cwd, marker: "inherited" }); + expect(NodeFS.existsSync(NodePath.join(project.cwd, "injected.txt"))).toBe(false); +}); + +it.each(["sync", "async"])("preserves a failing CLI's output and status (%s)", (mode) => { + using project = fixture(); + const args = ["install", "--lockfile-only", "--ignore-scripts"]; + const result = project.run(mode, args, 23); + expect(result.status).toBe(23); + expect(result.stdout).toBe("vp stdout"); + expect(result.stderr).toBe("vp stderr"); + expect(project.call()).toEqual({ args, cwd: project.cwd, marker: "inherited" }); +}); diff --git a/scripts/check-changed-command.ts b/scripts/check-changed-command.ts new file mode 100644 index 0000000000000..ba9960127c28a --- /dev/null +++ b/scripts/check-changed-command.ts @@ -0,0 +1,19 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeURL from "node:url"; + +export function vitePlusCommand( + args: ReadonlyArray, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone Node tooling has no Effect runtime; tests pass the platform explicitly. + platform: NodeJS.Platform = process.platform, +): [command: string, args: Array] { + // Windows cannot spawn the package manager's vp.cmd shim without a shell. + // Use Vite+'s exported JS entry with this Node instead, keeping paths and + // arguments literal. Resolve here, not from the patch check's temporary cwd. + if (platform === "win32") { + return [ + process.execPath, + [NodeURL.fileURLToPath(import.meta.resolve("vite-plus/bin")), ...args], + ]; + } + return ["vp", [...args]]; +} diff --git a/scripts/check-changed.test.ts b/scripts/check-changed.test.ts index b0e0fe01e851b..d074a7920f42e 100644 --- a/scripts/check-changed.test.ts +++ b/scripts/check-changed.test.ts @@ -3,6 +3,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import { expect } from "vite-plus/test"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Option from "effect/Option"; import { assert, describe, it } from "@effect/vitest"; @@ -264,3 +265,143 @@ it("compares against an explicit ref instead of the closest remote main", async assert.strictEqual(result.base, project.commits.A); assert.strictEqual(result.ref, "origin/main"); }); + +function launcherFixture() { + const root = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3 checks & (fixture) ")), + ); + const write = (file: string, content: string) => { + const path = NodePath.join(root, file); + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + NodeFS.writeFileSync(path, content); + }; + for (const file of [ + "scripts/check-changed.ts", + "scripts/check-changed-command.ts", + ".github/scripts/ci-changes.cjs", + ]) { + write(file, NodeFS.readFileSync(NodePath.join(import.meta.dirname, "..", file), "utf8")); + } + write(".gitignore", "node_modules/\nrecords/\n"); + write("pnpm-workspace.yaml", "packages:\n - packages/*\n"); + write( + "package.json", + JSON.stringify({ type: "module", scripts: { "knip:check": "knip --include files" } }), + ); + write("packages/example/package.json", JSON.stringify({ name: "example" })); + const source = "packages/example/space & (draft) %PATH% ! 研究.ts"; + write(source, "export const value = 1;\n"); + write( + "node_modules/vite-plus/package.json", + JSON.stringify({ type: "module", exports: { "./bin": "./cli.js" } }), + ); + write( + "node_modules/vite-plus/cli.js", + ` + import fs from 'node:fs'; + import path from 'node:path'; + const args = process.argv.slice(2); + fs.writeFileSync(path.join(process.env.RECORDS, process.pid + '.json'), JSON.stringify({ args, cwd: process.cwd() })); + console.log('fixture stdout: ' + args[0]); + console.error('fixture stderr: ' + args[0]); + process.exit(args[0] === process.env.FAIL_STEP ? 23 : 0); + `, + ); + // Select the Windows launcher without changing the Effect runtime's host platform. + write( + "scripts/check-changed.ts", + NodeFS.readFileSync(NodePath.join(import.meta.dirname, "check-changed.ts"), "utf8").replace( + "vitePlusCommand(args, platform)", + 'vitePlusCommand(args, "win32")', + ), + ); + for (const name of ["effect", "@effect/platform-node", "@t3tools/shared"]) { + const dest = NodePath.join(root, "node_modules", name); + NodeFS.mkdirSync(NodePath.dirname(dest), { recursive: true }); + NodeFS.symlinkSync( + NodeFS.realpathSync(NodePath.join(import.meta.dirname, "node_modules", name)), + dest, + "junction", + ); + } + const git = (...args: Array) => + NodeChildProcess.execFileSync("git", args, { cwd: root, stdio: "pipe", env }); + git("init"); + git("add", "."); + git( + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "-c", + "core.hooksPath=", + "commit", + "-m", + "fixture", + ); + const records = NodePath.join(root, "records"); + NodeFS.mkdirSync(records); + return { + root, + source, + write, + run(failStep = "") { + return NodeChildProcess.spawnSync(process.execPath, ["scripts/check-changed.ts", "HEAD"], { + cwd: root, + encoding: "utf8", + env: { ...env, TEMP: NodeOS.tmpdir(), RECORDS: records, FAIL_STEP: failStep }, + }); + }, + calls: (): Array<{ args: Array; cwd: string }> => + NodeFS.readdirSync(records).map((file) => + JSON.parse(NodeFS.readFileSync(NodePath.join(records, file), "utf8")), + ), + [Symbol.dispose]() { + NodeFS.rmSync(root, { recursive: true, force: true }); + }, + }; +} + +it("checks changed source paths through the Windows launcher", () => { + using project = launcherFixture(); + project.write(project.source, "export const value = 2;\n"); + const result = project.run(); + expect(result.status, result.stdout + result.stderr).toBe(0); + const calls = project.calls(); + expect(calls.find(({ args }) => args[0] === "lint")).toEqual({ + args: ["lint", project.source], + cwd: project.root, + }); + expect(calls.some(({ args }) => args[0] === "fmt" && args.includes(project.source))).toBe(true); + expect(calls.some(({ args }) => args[0] === "run" && args.includes("typecheck"))).toBe(true); + expect(calls.some(({ args }) => args[0] === "install")).toBe(false); + expect(result.stdout).toContain("fixture stdout: lint"); + expect(result.stdout).toContain("fixture stderr: lint"); +}); + +it.each(["install", "lint"])("reports a failing %s step with its output", (step) => { + using project = launcherFixture(); + project.write(project.source, "export const value = 2;\n"); + project.write( + "packages/example/package.json", + JSON.stringify({ name: "example", version: "1.0.1" }), + ); + const result = project.run(step); + expect(result.status, result.stdout + result.stderr).toBe(1); + const install = project.calls().find(({ args }) => args[0] === "install"); + expect(install?.args, result.stdout + result.stderr).toEqual([ + "install", + "--lockfile-only", + "--ignore-scripts", + ]); + expect(install?.cwd).not.toBe(project.root); + expect(result.stdout + result.stderr).toContain(`fixture stdout: ${step}`); + expect(result.stdout + result.stderr).toContain(`fixture stderr: ${step}`); + expect(result.stdout).toContain(step === "install" ? "FAIL unused patches" : "FAIL lint"); + if (step === "lint") expect(result.stdout).toContain("pass unused patches"); + expect( + NodeFS.existsSync(NodePath.join(project.root, "node_modules/.cache/check-changed.json")), + ).toBe(false); +}); diff --git a/scripts/check-changed.ts b/scripts/check-changed.ts index 583b00b5361ca..a202f3c1e5e07 100644 --- a/scripts/check-changed.ts +++ b/scripts/check-changed.ts @@ -39,6 +39,7 @@ import { Argument, Command, Flag } from "effect/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { decide, readWorkspace } from "../.github/scripts/ci-changes.cjs"; +import { vitePlusCommand } from "./check-changed-command.ts"; const GiB = 1024 ** 3; @@ -390,8 +391,14 @@ const checkChanged = Effect.fn("checkChanged")(function* (input: { const vp = (args: ReadonlyArray, cwd = root) => Effect.scoped( Effect.gen(function* () { + const [command, commandArgs] = vitePlusCommand(args, platform); const handle = yield* spawner.spawn( - ChildProcess.make("vp", args, { cwd, env, extendEnv: true, forceKillAfter: "5 seconds" }), + ChildProcess.make(command, commandArgs, { + cwd, + env, + extendEnv: true, + forceKillAfter: "5 seconds", + }), ); const [output, exitCode] = yield* Effect.all( [Stream.mkString(Stream.decodeText(handle.all)), handle.exitCode],