Skip to content

Commit dd99add

Browse files
committed
test(launcher): cover the cmd.exe quoting for npm.cmd off Windows
1 parent 4c3d68f commit dd99add

1 file changed

Lines changed: 82 additions & 0 deletions

File tree

‎packages/cli/test/launcher.test.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { tmpdir } from "node:os";
1414
import { delimiter, join } from "node:path";
1515
import process from "node:process";
16+
import { pathToFileURL } from "node:url";
1617
import { afterEach, beforeEach, describe, expect, it } from "vitest";
1718

1819
import {
@@ -191,6 +192,23 @@ async function listing(directory: string): Promise<string[]> {
191192
}
192193
const RELEASE = { install: { launchers: { "@taskless/cli": "0.13.0" } } };
193194

195+
/** One argument as the launcher quotes it for a batch file such as npm.cmd. */
196+
function quoted(inner: string): string {
197+
return `^^^"${inner}^^^"`;
198+
}
199+
200+
/**
201+
* A stand-in for cmd.exe: it logs the arguments it was handed, then does what
202+
* the real one would, which is to run npm, here the fake one.
203+
*/
204+
const FAKE_CMD = String.raw`#!/usr/bin/env node
205+
const fs = require("node:fs");
206+
const { spawnSync } = require("node:child_process");
207+
fs.appendFileSync(process.env.FAKE_CMD_LOG, JSON.stringify(process.argv.slice(2)) + "\n");
208+
const npm = spawnSync(process.execPath, [process.env.FAKE_NPM_PATH, "ci"], { stdio: "inherit" });
209+
process.exit(npm.status ?? 1);
210+
`;
211+
194212
describe.skipIf(process.platform === "win32")("project launcher", () => {
195213
it("installs the recorded version once, then runs it with every argument", async () => {
196214
const path = await install("@taskless/cli", RELEASE, "0.13.0");
@@ -272,6 +290,70 @@ describe.skipIf(process.platform === "win32")("project launcher", () => {
272290
).toBe(1);
273291
});
274292

293+
it("quotes npm's arguments for cmd.exe on Windows, and passes the CLI's untouched", async () => {
294+
const path = await install("@taskless/cli", RELEASE, "0.13.0");
295+
// Pretend to be Windows, and stand in for cmd.exe, so the quoting runs
296+
// anywhere. npm.cmd is a batch file and goes through cmd.exe; the CLI is
297+
// started with node directly, so its arguments are never quoted.
298+
const preload = join(root, "as-windows.mjs");
299+
await writeFile(
300+
preload,
301+
'Object.defineProperty(process, "platform", { value: "win32" });\n'
302+
);
303+
const comspec = join(fakeBin, "cmd");
304+
await writeFile(comspec, FAKE_CMD);
305+
await chmod(comspec, 0o755);
306+
const cmdLog = join(root, "cmd.log");
307+
308+
const result = spawnSync(
309+
process.execPath,
310+
[
311+
"--import",
312+
pathToFileURL(preload).href,
313+
path,
314+
"check",
315+
"src/a file.ts",
316+
'say "hi" & calc',
317+
"C:\\dir\\",
318+
],
319+
{
320+
cwd: root,
321+
encoding: "utf8",
322+
env: {
323+
...process.env,
324+
comspec,
325+
FAKE_CMD_LOG: cmdLog,
326+
FAKE_NPM_PATH: join(fakeBin, "npm"),
327+
FAKE_NPM_LOG: npmLog,
328+
FAKE_CLI_SOURCE: FAKE_CLI,
329+
},
330+
}
331+
);
332+
expect(result.status).toBe(0);
333+
const calls = await readFile(cmdLog, "utf8");
334+
// Each argument is quoted, then every metacharacter is escaped twice:
335+
// once for cmd.exe and once for the batch file's own parse.
336+
expect(JSON.parse(calls.trim())).toEqual([
337+
"/d",
338+
"/s",
339+
"/c",
340+
`"${[
341+
"npm",
342+
quoted("ci"),
343+
quoted("--ignore-scripts"),
344+
quoted("--no-audit"),
345+
quoted("--no-fund"),
346+
quoted("--loglevel=error"),
347+
].join(" ")}"`,
348+
]);
349+
expect(reported(result.stdout).argv).toEqual([
350+
"check",
351+
"src/a file.ts",
352+
'say "hi" & calc',
353+
"C:\\dir\\",
354+
]);
355+
});
356+
275357
it("ends by the same signal that ended the CLI", async () => {
276358
const path = await install("@taskless/cli", RELEASE, "0.13.0");
277359
const result = launch(path, ["check"], {

0 commit comments

Comments
 (0)