Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion patterns/cli-option-parsing-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ last_updated: 2026-05-21
# CLI Option Parsing Tests

## Context
`src/cli.ts` calls `program.parse()` at import time. Do not import its `program` object in tests. If parser helpers must be imported from `src/cli.ts`, control `process.argv` during a dynamic import and suppress console output.
`src/cli.ts` auto-parses only when invoked as the main script (`import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href`). Do not import its `program` object in tests. If parser helpers must be imported from `src/cli.ts`, set `process.argv[1]` to a non-matching path during the dynamic import and suppress console output so the guard stays false.

## Steps
1. Export narrow parser helpers from `src/cli.ts` when direct unit coverage is needed.
Expand Down
18 changes: 17 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import chalk from "chalk";
import { Command, InvalidArgumentError } from "commander";
import { realpathSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { findConfig } from "./config.js";
import { reportConsole, reportQuiet, reportJSON, reportVerbose } from "./reporter.js";
import { VERSION } from "./version.js";
Expand Down Expand Up @@ -300,7 +302,21 @@ program
console.log();
});

program.parse();
// Skip auto-parse when imported (e.g. by tests). The bin entry is built by
// tsup as ./dist/cli.js with a shebang banner; only run program.parse() when
// this module is the script being invoked. Resolve argv[1] so symlinked bins
// (npm global, npx, node_modules/.bin) match import.meta.url.
let isMainModule = false;
if (process.argv[1]) {
try {
isMainModule = import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
} catch {
// argv[1] is missing or not on disk (e.g. test fixtures) — not the main entry.
}
}
if (isMainModule) {
program.parse();
}

function buildCompletion(shell: string): string {
const commands = [
Expand Down
41 changes: 40 additions & 1 deletion test/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
import { Command, InvalidArgumentError } from "commander";
import { readFileSync } from "node:fs";
import { execSync, spawnSync } from "node:child_process";
import { readFileSync, symlinkSync, mkdtempSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { runLog, runTimeline } from "../src/events.js";
import type { MexConfig } from "../src/types.js";
Expand Down Expand Up @@ -208,6 +210,43 @@ describe("mex timeline parsing", () => {
});
});

describe("built CLI main-module guard", () => {
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const cliPath = join(repoRoot, "dist", "cli.js");
const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")) as { version: string };

beforeAll(() => {
execSync("npm run build", { cwd: repoRoot, stdio: "pipe" });
});

it("parses argv when invoked through a symlinked bin (npm/npx layout)", () => {
const binDir = mkdtempSync(join(tmpdir(), "mex-bin-"));
const symlinkedCli = join(binDir, "mex");
try {
symlinkSync(cliPath, symlinkedCli);
const result = spawnSync(process.execPath, [symlinkedCli, "--version"], {
encoding: "utf8",
env: { ...process.env, NO_COLOR: "1" },
});
expect(result.status).toBe(0);
expect((result.stdout ?? "").trim()).toBe(pkg.version);
} finally {
rmSync(binDir, { recursive: true, force: true });
}
});

it("does not auto-parse when dist/cli.js is imported as a module", () => {
const result = spawnSync(
process.execPath,
["-e", "import('./dist/cli.js').then(() => console.log('imported'))"],
{ cwd: repoRoot, encoding: "utf8" },
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("imported");
expect(result.stdout).not.toContain(pkg.version);
});
});

describe("mex --version", () => {
it("reports the version from package.json (guards against hard-coded drift)", async () => {
// cli.js is imported (and parsed with a safe argv) in beforeAll; this
Expand Down
Loading