Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/fresh-tomatoes-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@node-minify/cli": patch
"@node-minify/core": patch
"@node-minify/run": patch
"@node-minify/utils": patch
---

Fix empty in-memory content handling, benchmark CLI defaults, subprocess close handling, and multi-output routing regressions.
49 changes: 49 additions & 0 deletions packages/cli/__tests__/cli-benchmark-bin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*!
* node-minify
* Copyright (c) 2011-2026 Rodolphe Stoclin
* MIT Licensed
*/

import { execFile } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { describe, expect, test } from "vitest";

const execFileAsync = promisify(execFile);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "../../..");
const cliEntry = path.join(repoRoot, "packages/cli/src/bin/cli.ts");

describe("cli benchmark command", () => {
test("should emit clean JSON and benchmark defaults when compressors are not provided", async () => {
const { stdout, stderr } = await execFileAsync(
"bun",
[
cliEntry,
"benchmark",
"tests/fixtures/es5/fixture-1.js",
"--format",
"json",
"--iterations",
"1",
],
{
cwd: repoRoot,
}
);

expect(stderr).toBe("");

const trimmedStdout = stdout.trim();
const parsed = JSON.parse(trimmedStdout) as {
files: Array<{
results: Array<{ compressor: string }>;
}>;
};

expect(
parsed.files[0]?.results.map((result) => result.compressor)
).toEqual(["terser", "esbuild", "swc", "oxc"]);
}, 60000);
});
21 changes: 12 additions & 9 deletions packages/cli/src/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,32 +96,35 @@ function setupProgram(): Command {
.option("--brotli", "include brotli size")
.option("-v, --verbose", "verbose output")
.action(async (input, options) => {
const globalOpts = program.opts();
const spinner = ora("Benchmarking...").start();
const shouldUseSpinner =
options.format !== "json" && !!process.stderr.isTTY;
const spinner = shouldUseSpinner
? ora("Benchmarking...").start()
: null;
try {
const results = await benchmark({
input,
compressors:
options.compressors?.split(",") ||
globalOpts.compressor?.split(","),
compressors: options.compressors?.split(","),
iterations: parseInt(options.iterations, 10),
format: options.format,
output: options.output,
includeGzip: !!options.gzip,
includeBrotli: !!options.brotli,
type: globalOpts.type,
type: program.opts().type,
verbose: !!options.verbose,
onProgress: (compressor: string, file: string) => {
spinner.text = `Benchmarking ${compressor} on ${file}...`;
if (spinner) {
spinner.text = `Benchmarking ${compressor} on ${file}...`;
}
},
});

spinner.stop();
spinner?.stop();
const reporter = getReporter(options.format);
console.log(reporter(results));
process.exit(0);
} catch (error) {
spinner.fail("Benchmark failed");
spinner?.fail("Benchmark failed");
console.error(error);
process.exit(1);
}
Expand Down
9 changes: 9 additions & 0 deletions packages/core/__tests__/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,15 @@ describe("Package: core", async () => {
expect(min).toBeDefined();
});

test("should accept empty string content in memory mode", async () => {
const settings: Settings = {
compressor: noCompress,
content: "",
};

await expect(minify(settings)).resolves.toBe("");
});

test("should throw an error if binary does not exist", async () => {
const settings: Settings = {
compressor: "fake" as unknown as Compressor,
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export async function minify<T extends CompressorOptions = CompressorOptions>(
settings: Settings<T>
): Promise<string> {
const compressorSettings = setup(settings);
const method = settings.content ? compressSingleFile : compress;
const method =
compressorSettings.content !== undefined
? compressSingleFile
: compress;
Comment on lines +25 to +28
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. No changeset added 📘 Rule violation ⛯ Reliability

This PR includes user-facing behavior changes but does not add a changeset file for release
notes/versioning. This violates the requirement to include a changeset for user-facing changes.
Agent Prompt
## Issue description
User-facing behavior changes were made without adding a changeset file.

## Issue Context
Changeset files are required for release notes/versioning whenever user-facing behavior changes.

## Fix Focus Areas
- packages/core/src/index.ts[25-28]
- packages/cli/src/bin/cli.ts[96-112]
- packages/utils/src/run.ts[176-233]
- packages/run/src/index.ts[99-99]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

return await method(compressorSettings);
}
4 changes: 2 additions & 2 deletions packages/core/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ function setup<T extends CompressorOptions = CompressorOptions>(
} as Settings<T>;

// In memory
if (settings.content) {
validateMandatoryFields(inputSettings, ["compressor", "content"]);
if (settings.content !== undefined) {
validateMandatoryFields(inputSettings, ["compressor"]);
return settings;
}

Expand Down
37 changes: 35 additions & 2 deletions packages/run/__tests__/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ describe("Package: run", () => {
mockStdout.emit("error", new Error("stdout error"));
mockStderr.emit("error", new Error("stderr error"));
mockStdout.emit("data", Buffer.from("output"));
mockChild.emit("exit", 0);
mockChild.emit("close", 0);
});

const result = await promise;
Expand Down Expand Up @@ -221,7 +221,7 @@ describe("Package: run", () => {
setImmediate(() => {
mockStdin.emit("error", new Error("stdin error"));
mockStdout.emit("data", Buffer.from("output"));
mockChild.emit("exit", 0);
mockChild.emit("close", 0);
});

const result = await promise;
Expand All @@ -231,6 +231,39 @@ describe("Package: run", () => {
consoleSpy.mockRestore();
});

test("should wait for close before resolving stdout", async () => {
const mockChild = new EventEmitter() as ReturnType<
typeof childProcess.spawn
>;
const mockStdin = new EventEmitter();
const mockStdout = new EventEmitter();
const mockStderr = new EventEmitter();

Object.assign(mockChild, {
stdin: Object.assign(mockStdin, {
end: vi.fn(),
}),
stdout: mockStdout,
stderr: mockStderr,
kill: vi.fn(),
});

spy.mockReturnValue(mockChild);

const promise = runCommandLine({
args: ["-jar", "fake.jar"],
data: "test",
});

setImmediate(() => {
mockChild.emit("exit", 0);
mockStdout.emit("data", Buffer.from("delayed-output"));
mockChild.emit("close", 0);
});

await expect(promise).resolves.toBe("delayed-output");
});

test("should reject when maxBuffer is exceeded", async () => {
const mockChild = new EventEmitter() as ReturnType<
typeof childProcess.spawn
Expand Down
2 changes: 1 addition & 1 deletion packages/run/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export async function run({
child.stdout?.on("error", handleError("child.stdout"));
child.stderr?.on("error", handleError("child.stderr"));

child.on("exit", (code: number | null) => {
child.on("close", (code: number | null) => {
if (settled) return;
settled = true;
if (timeoutId) clearTimeout(timeoutId);
Expand Down
58 changes: 58 additions & 0 deletions packages/utils/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { existsSync, lstatSync, unlinkSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Settings } from "@node-minify/types";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -1468,6 +1469,63 @@ describe("Package: utils", () => {
);
});

test("should derive multi-format targets from the current input when processing input arrays", async () => {
const firstInput = `${tmpDir}/multi-array-first.png`;
const secondInput = `${tmpDir}/multi-array-second.png`;
const firstWebp = `${tmpDir}/multi-array-first.webp`;
const firstAvif = `${tmpDir}/multi-array-first.avif`;
const secondWebp = `${tmpDir}/multi-array-second.webp`;
const secondAvif = `${tmpDir}/multi-array-second.avif`;
filesToCleanup.add(firstInput);
filesToCleanup.add(secondInput);
filesToCleanup.add(firstWebp);
filesToCleanup.add(firstAvif);
filesToCleanup.add(secondWebp);
filesToCleanup.add(secondAvif);
writeFile({ file: firstInput, content: "first" });
writeFile({ file: secondInput, content: "second" });

const compressor: Settings["compressor"] = async () => ({
code: "",
outputs: [
{ format: "webp", content: Buffer.from("FORMAT_WEBP") },
{ format: "avif", content: Buffer.from("FORMAT_AVIF") },
],
});
const settings: Settings = {
compressor,
input: [firstInput, secondInput],
output: [
firstInput.replace(/\.png$/, ""),
secondInput.replace(/\.png$/, ""),
],
};

await run({
settings,
content: Buffer.from("first"),
index: 0,
});
await run({
settings,
content: Buffer.from("second"),
index: 1,
});

expect(readFile(firstWebp, true)).toEqual(
Buffer.from("FORMAT_WEBP")
);
expect(readFile(firstAvif, true)).toEqual(
Buffer.from("FORMAT_AVIF")
);
expect(readFile(secondWebp, true)).toEqual(
Buffer.from("FORMAT_WEBP")
);
expect(readFile(secondAvif, true)).toEqual(
Buffer.from("FORMAT_AVIF")
);
});

test("should fallback to auto-generated path when output is non-string truthy value", async () => {
const testFile = `${tmpDir}/fallback-nonstring.png`;
filesToCleanup.add(testFile);
Expand Down
2 changes: 1 addition & 1 deletion packages/utils/src/compressSingleFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export async function compressSingleFile<
async function determineContent<
T extends CompressorOptions = CompressorOptions,
>(settings: Settings<T>): Promise<string | Buffer | Buffer[]> {
if (settings.content) {
if (settings.content !== undefined) {
return settings.content;
}

Expand Down
45 changes: 31 additions & 14 deletions packages/utils/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ async function writeOutput<T extends CompressorOptions = CompressorOptions>(
settings: Settings<T>,
index?: number
): Promise<void> {
const isInMemoryMode = Boolean(settings.content);
const isInMemoryMode = settings.content !== undefined;
if (isInMemoryMode || !settings.output) {
return;
}
Expand Down Expand Up @@ -144,16 +144,23 @@ async function writeOutput<T extends CompressorOptions = CompressorOptions>(
}

/**
* Extract the first input file path from the input configuration.
* Extract an input file path from the input configuration.
*
* @param input - A single file path, an array of paths, or undefined
* @returns The first input file path, or an empty string if none available
* @param index - Optional index of the current input being processed when `input` is an array
* @returns The indexed input file path when available, otherwise the first input file path or an empty string
*/
function getFirstInputFile(input: string | string[] | undefined): string {
function getInputFile(
input: string | string[] | undefined,
index?: number
): string {
if (typeof input === "string") {
return input;
}
if (Array.isArray(input) && input.length > 0) {
if (index !== undefined) {
return input[index] ?? "";
}
return input[0] ?? "";
}
return "";
Expand All @@ -176,11 +183,12 @@ async function writeMultipleOutputs<
index?: number
): Promise<void> {
const output = settings.output;
const isArrayOutput = Array.isArray(output);
const outputsArray = isArrayOutput ? output : [output];
const inputFile = getFirstInputFile(settings.input);
const perFormatOutputs = Array.isArray(output) && index === undefined;
const inputFile = getInputFile(settings.input, index);
const inputDir = parse(inputFile).dir;
const inputBase = parse(inputFile).name;
const currentOutput =
Array.isArray(output) && index !== undefined ? output[index] : output;

const promises = outputs.map(async (outputResult, i) => {
if (!outputResult) {
Expand All @@ -191,30 +199,39 @@ async function writeMultipleOutputs<
const format = outputResult.format || "out";
let targetFile: string;

const arrayItem = outputsArray[i];
const arrayItem = perFormatOutputs ? output[i] : undefined;

if (
isArrayOutput &&
perFormatOutputs &&
arrayItem !== undefined &&
arrayItem !== "$1" &&
arrayItem.trim() !== ""
) {
// Explicit output path provided in array - use as-is
targetFile = arrayItem;
} else if (typeof output === "string" && output === "$1") {
} else if (
typeof currentOutput === "string" &&
currentOutput === "$1"
) {
// $1 only: auto-generate from input filename in input directory
const baseName = inputBase || "output";
targetFile = inputDir
? join(inputDir, `${baseName}.${format}`)
: `${baseName}.${format}`;
} else if (typeof output === "string" && output.includes("$1")) {
} else if (
typeof currentOutput === "string" &&
currentOutput.includes("$1")
) {
// $1 pattern in path: replace and append format
const extensionlessName = inputBase || "output";
const outputFile = output.replace(/\$1/g, extensionlessName);
const outputFile = currentOutput.replace(/\$1/g, extensionlessName);
targetFile = `${outputFile}.${format}`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
} else if (typeof output === "string") {
} else if (
typeof currentOutput === "string" &&
currentOutput.trim() !== ""
) {
// Single string output: append format extension
targetFile = `${output}.${format}`;
targetFile = `${currentOutput}.${format}`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
} else {
// Fallback
const baseName = inputBase || "output";
Expand Down