mirror of https://github.com/garrytan/gstack.git
114 lines
4.7 KiB
TypeScript
114 lines
4.7 KiB
TypeScript
/**
|
|
* design/src/flag-utils.ts — integer-flag contract (#2032, eng-review 7A/8A).
|
|
*
|
|
* Lives under test/ (NOT design/test/) deliberately: design/test/ is invisible
|
|
* to the bun test glob, scripts/test-free-shards TEST_ROOTS, and every CI
|
|
* workflow (eng-review 11A), so a tripwire there guards nothing. flag-utils is
|
|
* a pure module, so importing it from here is clean.
|
|
*
|
|
* The bug class: the design CLI parser yields string | true | undefined;
|
|
* parseInt produced NaN that flowed silently into loop bounds and setTimeout —
|
|
* `variants --count abc` generated ZERO variants and exited 0, `generate
|
|
* --retry abc` was a silent no-op, `serve --timeout abc` died at boot.
|
|
*/
|
|
|
|
import { describe, test, expect } from "bun:test";
|
|
import { spawnSync } from "child_process";
|
|
import * as path from "path";
|
|
import { parseIntFlag } from "../design/src/flag-utils";
|
|
|
|
const ROOT = path.resolve(import.meta.dir, "..");
|
|
|
|
const COUNT_SPEC = { name: "count", def: 3, min: 1, max: 7 } as const;
|
|
const RETRY_SPEC = { name: "retry", def: 0, min: 0 } as const;
|
|
const TIMEOUT_SPEC = { name: "timeout", def: 600, min: 1 } as const;
|
|
|
|
describe("parseIntFlag contract (#2032, codex 17a-c)", () => {
|
|
test("undefined → default (flag absent)", () => {
|
|
expect(parseIntFlag(undefined, COUNT_SPEC)).toEqual({ ok: true, value: 3 });
|
|
expect(parseIntFlag(undefined, RETRY_SPEC)).toEqual({ ok: true, value: 0 });
|
|
});
|
|
|
|
test("non-numeric string → error, never a silent default ('--count abc')", () => {
|
|
const r = parseIntFlag("abc", COUNT_SPEC);
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toContain('"abc" is not an integer');
|
|
});
|
|
|
|
test("bare flag (parser yields true) → error 'requires a value'", () => {
|
|
const r = parseIntFlag(true, COUNT_SPEC);
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toContain("requires a value");
|
|
});
|
|
|
|
test("empty string → error 'requires a value'", () => {
|
|
const r = parseIntFlag("", COUNT_SPEC);
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toContain("requires a value");
|
|
});
|
|
|
|
test("non-integer '3.7' → error (rejected, not silently truncated to 3)", () => {
|
|
const r = parseIntFlag("3.7", COUNT_SPEC);
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toContain("not an integer");
|
|
});
|
|
|
|
test("below min → error ('--count 0' billed the user for 1 they asked 0 of; now loud)", () => {
|
|
expect(parseIntFlag("0", COUNT_SPEC).ok).toBe(false);
|
|
expect(parseIntFlag("-2", COUNT_SPEC).ok).toBe(false);
|
|
// retry allows 0 (min: 0)
|
|
expect(parseIntFlag("0", RETRY_SPEC)).toEqual({ ok: true, value: 0 });
|
|
});
|
|
|
|
test("above max → clamp WITH warning (capability limit, not a user mistake)", () => {
|
|
const r = parseIntFlag("99", COUNT_SPEC);
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok) {
|
|
expect(r.value).toBe(7);
|
|
expect(r.warning).toContain("exceeds the maximum");
|
|
}
|
|
});
|
|
|
|
test("in-range integers pass through untouched", () => {
|
|
expect(parseIntFlag("3", COUNT_SPEC)).toEqual({ ok: true, value: 3 });
|
|
expect(parseIntFlag(5, COUNT_SPEC)).toEqual({ ok: true, value: 5 });
|
|
expect(parseIntFlag("120", TIMEOUT_SPEC)).toEqual({ ok: true, value: 120 });
|
|
});
|
|
|
|
test("retry-NaN and timeout-NaN are errors, not silent no-ops (#2032 siblings)", () => {
|
|
// Pre-fix: --retry abc → generate() loop never ran (attempt <= NaN),
|
|
// printed null, exited 0. --timeout abc → setTimeout(NaN) ≈ immediate
|
|
// SERVE_TIMEOUT. Both members of the same NaN class, same file.
|
|
expect(parseIntFlag("abc", RETRY_SPEC).ok).toBe(false);
|
|
expect(parseIntFlag("abc", TIMEOUT_SPEC).ok).toBe(false);
|
|
});
|
|
|
|
test("NaN number input (legacy pre-parsed callers) → error", () => {
|
|
expect(parseIntFlag(Number.NaN, COUNT_SPEC).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("normalizeIntFlag CLI wrapper (exit-1 semantics)", () => {
|
|
function runWrapper(rawExpr: string, specExpr: string): { status: number; stderr: string } {
|
|
const script = `
|
|
import { normalizeIntFlag } from "${ROOT}/design/src/flag-utils";
|
|
const v = normalizeIntFlag(${rawExpr}, ${specExpr});
|
|
console.log("VALUE:" + v);
|
|
`;
|
|
const res = spawnSync("bun", ["-e", script], { encoding: "utf-8", cwd: ROOT });
|
|
return { status: res.status ?? -1, stderr: res.stderr ?? "" };
|
|
}
|
|
|
|
test("invalid input exits 1 with the error on stderr", () => {
|
|
const r = runWrapper('"abc"', '{ name: "count", def: 3, min: 1, max: 7 }');
|
|
expect(r.status).toBe(1);
|
|
expect(r.stderr).toContain("not an integer");
|
|
});
|
|
|
|
test("clamp warns on stderr but exits 0", () => {
|
|
const r = runWrapper('"99"', '{ name: "count", def: 3, min: 1, max: 7 }');
|
|
expect(r.status).toBe(0);
|
|
expect(r.stderr).toContain("exceeds the maximum");
|
|
});
|
|
});
|