Files
OmniRoute/tests/unit/cli-negated-flags.test.ts
Nguyen Thanh Dat 840ae79612 fix(cli): read commander's negated --no-* flags as opts.<name> === false (#13322)
Commander exposes a negated `--no-x` flag as `opts.x === false`, not as `opts.noX`, so every `--no-*` flag on `chat`, `contexts` and `serve` was being read as undefined and silently ignored. Probe on your head: 5/5 pass in `tests/unit/cli-negated-flags.test.ts` — a real Commander parser with mocked fetch and a temp `DATA_DIR`, exercising all three commands end to end rather than asserting on the parser in isolation.

Thanks, @datrixlab.

**Batch validation** — boarded with the other 10 PRs of your batch into one worktree cut from `release/v3.8.51`; every PR verified as an ancestor of the combined HEAD before validating.

- Focused tests across all 11 PRs: **104/104 pass** on the combined tree.
- Gates on the combined tree: `check-changelog-integrity` PASS, `check-complexity` PASS, `check-cognitive-complexity` PASS, `typecheck:core` PASS, `check:open-sse-typecheck` PASS.
- `check-file-size` is red, but reproduces with byte-identical line counts on the pure `release/v3.8.51` tip (`open-sse/handlers/imageGeneration.ts` 3304, `open-sse/services/combo/roundRobinCombo.ts` 1221, `open-sse/utils/stream.ts` 3115). Inherited base-red, nothing added by this batch — it is also why this PR's "Fast Quality Gates" check was red.
2026-09-16 21:02:19 -03:00

148 lines
5.3 KiB
TypeScript

/**
* Commander stores a negated option such as `--no-history` as `history === false`; it never
* sets `noHistory`. `chat --no-history`, `contexts export --no-secrets` and
* `serve --no-recovery` read the `noX` name, so each flag was accepted and did nothing:
* the prompt was still written to cli-history.jsonl, and the export still carried the
* access tokens and API keys. These tests drive the real Commander parser instead of
* hand-building an options object Commander never produces.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Command } from "commander";
const FAKE_RESPONSE = {
id: "chatcmpl-abc",
model: "claude-sonnet-4-6",
choices: [{ message: { role: "assistant", content: "Hello!" } }],
usage: { prompt_tokens: 5, completion_tokens: 10, total_tokens: 15 },
};
async function withDataDir(prefix: string, fn: (dir: string) => Promise<void>) {
const dir = mkdtempSync(join(tmpdir(), prefix));
const prevDataDir = process.env.DATA_DIR;
const prevKeychain = process.env.OMNIROUTE_KEYCHAIN_DISABLED;
process.env.DATA_DIR = dir;
process.env.OMNIROUTE_KEYCHAIN_DISABLED = "1";
try {
await fn(dir);
} finally {
if (prevDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = prevDataDir;
if (prevKeychain === undefined) delete process.env.OMNIROUTE_KEYCHAIN_DISABLED;
else process.env.OMNIROUTE_KEYCHAIN_DISABLED = prevKeychain;
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
}
async function quietly(fn: () => Promise<unknown>) {
const out = process.stdout.write.bind(process.stdout);
const err = process.stderr.write.bind(process.stderr);
process.stdout.write = () => true;
process.stderr.write = () => true;
try {
await fn();
} finally {
process.stdout.write = out;
process.stderr.write = err;
}
}
async function runChat(args: string[]) {
const origFetch = globalThis.fetch;
globalThis.fetch = (() =>
Promise.resolve(
new Response(JSON.stringify(FAKE_RESPONSE), {
status: 200,
headers: { "content-type": "application/json" },
})
)) as typeof fetch;
try {
const { registerChat } = await import("../../bin/cli/commands/chat.mjs");
const program = new Command().exitOverride();
registerChat(program);
await quietly(() => program.parseAsync(["chat", ...args], { from: "user" }));
} finally {
globalThis.fetch = origFetch;
}
}
test("chat --no-history does not write cli-history.jsonl", async () => {
await withDataDir("chat-no-history-", async (dir) => {
await runChat(["secret prompt", "--no-history"]);
assert.equal(existsSync(join(dir, "cli-history.jsonl")), false);
});
});
test("chat without --no-history still records the exchange", async () => {
await withDataDir("chat-history-", async (dir) => {
await runChat(["keep this"]);
const line = JSON.parse(readFileSync(join(dir, "cli-history.jsonl"), "utf8").trim());
assert.equal(line.prompt, "keep this");
});
});
async function exportContexts(dir: string, args: string[]) {
const { saveContexts } = await import("../../bin/cli/contexts.mjs");
saveContexts({
currentContext: "remote",
contexts: {
remote: {
baseUrl: "https://omniroute.example",
accessToken: "oma_SECRET_TOKEN",
apiKey: "sk-SECRET-KEY",
},
},
});
const { registerContexts } = await import("../../bin/cli/commands/contexts.mjs");
const program = new Command().exitOverride();
registerContexts(program);
const outFile = join(dir, "export.json");
await quietly(() =>
program.parseAsync(["contexts", "export", "--out", outFile, ...args], { from: "user" })
);
return readFileSync(outFile, "utf8");
}
test("contexts export --no-secrets leaves the token and API key out", async () => {
await withDataDir("ctx-no-secrets-", async (dir) => {
const json = await exportContexts(dir, ["--no-secrets"]);
assert.doesNotMatch(json, /oma_SECRET_TOKEN|sk-SECRET-KEY/);
assert.equal(JSON.parse(json).contexts.remote.baseUrl, "https://omniroute.example");
});
});
test("contexts export without --no-secrets keeps the full config", async () => {
await withDataDir("ctx-secrets-", async (dir) => {
const json = await exportContexts(dir, []);
assert.match(json, /sk-SECRET-KEY/);
});
});
test("serve --no-recovery reaches runServe as recovery === false", async () => {
const { registerServe } = await import("../../bin/cli/commands/serve.mjs");
const program = new Command().exitOverride();
registerServe(program);
let parsed: Record<string, unknown> | undefined;
program.commands
.find((cmd) => cmd.name() === "serve")!
.action((opts: Record<string, unknown>) => {
parsed = opts;
});
await program.parseAsync(["serve", "--no-recovery"], { from: "user" });
assert.equal(parsed?.recovery, false);
assert.equal(parsed?.noRecovery, undefined);
const fs = await import("node:fs");
const path = await import("node:path");
const source = fs.readFileSync(
path.resolve(import.meta.dirname, "../../bin/cli/commands/serve.mjs"),
"utf-8"
);
assert.match(source, /if \(opts\.recovery === false[^)]*\) \{\s*return runWithoutRecovery\(/);
});