mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
3fff176a32
Fixes #586. ## The bug `--includeChildren false` left the flag **true** and silently dropped the word, so the tool ran with the opposite of what was asked. The reporter spent a while chasing a filter bug that did not exist — the output was simply the unfiltered kind. Reproduced on 0.18.1, device-free, with a discriminator that shows the flag's value directly: ``` $ argent run screenshot-diff --udid U --baselinePath A.png --currentPath B.png --captureCurrent=false { "summary": "Screenshot diff summary … pixel_mismatch: 1.79% …" <-- false, diff runs $ argent run screenshot-diff --udid U --baselinePath A.png --currentPath B.png --captureCurrent false [Tool:screenshot-diff] Provide either currentPath or captureCurrent, not both. <-- stayed TRUE ``` That message is the whole problem in one line: the user typed `false`, and the tool refuses on the grounds that they asked to capture. ## Why the old behaviour existed, and why it does not need to The parser deliberately declined to look ahead: > `// Bare boolean flag: true. Don't consume next argv to avoid surprising behavior when a positional follows.` That protects a case which does not exist. `parseFlags` has exactly one caller, and **`positional` is never read anywhere in the repo** — every bare token is collected and dropped, for booleans and for every other type. There is no positional to steal. The module header had documented the space form as legal all along: ``` // --name value (string / number / integer / boolean-with-value) ``` This matters because several tool booleans **default to true**, so `false` is the only way to say what you mean: `screenshot.includeImageInContext`, `network-request.includeBody`, `debugger-component-tree.onScreenOnly`, `debugger-inspect-element.resolveSourceMaps`. ## What changed A boolean flag consumes a following token when it is exactly the word `true`/`false`, case-insensitively. `1`/`0` and everything else are left alone — a bare number after a switch is more likely an unrelated argument than a value, and guessing at an ambiguous token is what caused this bug. `--flag -- false` still keeps the word positional. Three consequences are handled here rather than left to bite later: - **`argent run` now reports arguments it ignored.** This is what stops `--flag 0` and `--flag yes` being the same silent inversion, in a syntax users are now being taught works. stderr, so `--json` stays parseable. - **`--no-flag true` / `--no-flag false` now name the positive form** instead of quietly meaning false. One is a contradiction, the other a double negative. - **`--flag=True` used to throw** while `--flag True` would now work — the inline coercion was case-sensitive and did not trim. Both paths now share one helper, so a single word cannot get two answers one function apart. The **flow** and **lens** parsers take switches that accept no value at all, and would have silently ignored the syntax their users just learned elsewhere. They now refuse it — in the flow parser that also stops `false` being swallowed as the flow name, since its first bare token is the flow to run. `--help` gains **one legend line** for tools with a boolean field, rather than widening the flag column on every field of every tool: ``` Booleans: --flag or --flag true sets true; --flag false, --flag=false, or --no-flag sets false. ``` It deliberately does not begin with `--`. `scripts/e2e-full/lib/discover-tools.sh` reads any such line in the `Flags:` section as a flag row and takes the first `--token` as its name, so a legend starting with a flag would inject a phantom flag into every tool model it builds. **There is a test pinning that shape**, verified live: running the harness's awk over the new help output yields exactly the real flags and no legend row. ## Verified live ``` --captureCurrent false -> diff runs (was: "Provide either currentPath or captureCurrent, not both.") --captureCurrent=false -> unchanged --captureCurrent 0 -> "Note: ignoring unused argument(s): 0. Pass values as --flag <value> or --flag=<value>." screenshot --help -> legend present; harness awk parses only the real flags ``` 25 new tests; 8 confirmed to fail before and pass after. The rest pin what deliberately did *not* change: bare flag still true, a following flag or non-boolean word left alone, `1`/`0` still positional in the space form, `--` escape, string and unknown fields untouched, `--no-flag` semantics, boolean arrays, last-write-wins. The parser had **zero** boolean coverage before — which is why this survived. Full CLI suite 298 passing; lint, prettier and `typecheck:tests` clean. ## Scope and neighbours Only `flag-parser.ts` (the schema-driven parser for `argent run`) changes semantics. The other CLI parsers take fixed, default-off switches where `false` has no coherent meaning; three of them already reject a stray token outright, and the two that did not now do. `flow.ts`'s deliberate rejection of `--flag=value` is untouched. **#648** also edits `flag-parser.ts` (rewrites `renderFlagName`, inserts helpers at the same anchor). Behaviourally compatible — it keeps boolean → bare `--name` — so this is a textual conflict only, and either order works. ## Found while here, not fixed `scripts/e2e-full/lib/discover-tools.sh:60-64` detects flag kinds with `\bboolean\b` in awk, where `\b` is **backspace**, not a word boundary — so it never matches and every non-enum flag is typed `unknown`. That makes the `bad-type` validation case downstream unreachable. Pre-existing and unrelated to this fix; filing separately. --- > **Stacked on #648** (`filip/run-cli-validation-messages`). Both rewrite the same region of `packages/argent-cli/src/flag-parser.ts` — #648 factors out `flagNameFor`/`isJsonField` and rewrites `renderFlagName`, this one adds `booleanWord` beside them and a boolean legend to `formatSchemaUsage`. Merging them independently conflicts; this branch is rebased on #648, so review only the top commit. GitHub retargets it to `main` when #648 merges. --------- Co-authored-by: Filip131311 <f.kaminski2000@gmail.com>
470 lines
19 KiB
TypeScript
470 lines
19 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
parseFlags,
|
|
formatSchemaUsage,
|
|
FlagParseException,
|
|
type JsonSchema,
|
|
} from "../src/flag-parser.js";
|
|
|
|
const numSchema: JsonSchema = {
|
|
type: "object",
|
|
properties: { x: { type: "number" }, n: { type: "integer" } },
|
|
};
|
|
const arrSchema: JsonSchema = {
|
|
type: "object",
|
|
properties: { tags: { type: "array", items: { type: "string" } } },
|
|
};
|
|
const numArrSchema: JsonSchema = {
|
|
type: "object",
|
|
properties: { nums: { type: "array", items: { type: "number" } } },
|
|
};
|
|
|
|
describe("flag-parser number coercion rejects empty/whitespace", () => {
|
|
it("rejects --x= (empty)", () => {
|
|
expect(() => parseFlags(["--x="], numSchema)).toThrow(FlagParseException);
|
|
});
|
|
it('rejects --x " " (whitespace)', () => {
|
|
expect(() => parseFlags(["--x", " "], numSchema)).toThrow(FlagParseException);
|
|
});
|
|
it("rejects --n= (empty integer)", () => {
|
|
expect(() => parseFlags(["--n="], numSchema)).toThrow(FlagParseException);
|
|
});
|
|
it("still accepts a valid number", () => {
|
|
expect(parseFlags(["--x", "12"], numSchema).args.x).toBe(12);
|
|
});
|
|
it("still accepts a number with surrounding whitespace", () => {
|
|
// The empty/whitespace guard keys off raw.trim() === "", so a padded but
|
|
// otherwise-valid number (" 12 ") must still be accepted — Number() ignores
|
|
// the surrounding whitespace. Pins that the guard doesn't over-reject.
|
|
expect(parseFlags(["--x", " 12 "], numSchema).args.x).toBe(12);
|
|
});
|
|
it("still rejects a non-numeric string", () => {
|
|
expect(() => parseFlags(["--x", "abc"], numSchema)).toThrow(FlagParseException);
|
|
});
|
|
|
|
it("still ACCEPTS zero and negatives (guards against over-rejecting falsy numbers)", () => {
|
|
// The whole fix exists because Number("") === 0 slipped through, so the
|
|
// tempting-but-wrong guard is `if (!Number(raw))` / `Number(raw) === 0`,
|
|
// which would ALSO reject the legitimate value 0. Pin that 0 and negatives
|
|
// (and exponent form) still parse — the guard must key off emptiness, not
|
|
// falsiness.
|
|
expect(parseFlags(["--x=0"], numSchema).args.x).toBe(0);
|
|
expect(parseFlags(["--x=-5"], numSchema).args.x).toBe(-5);
|
|
expect(parseFlags(["--x=1e3"], numSchema).args.x).toBe(1000);
|
|
expect(parseFlags(["--n=0"], numSchema).args.n).toBe(0);
|
|
expect(parseFlags(["--n=-3"], numSchema).args.n).toBe(-3);
|
|
});
|
|
|
|
it("rejects a non-integer / whitespace-only integer, still accepts a valid one", () => {
|
|
expect(() => parseFlags(["--n=1.5"], numSchema)).toThrow(FlagParseException);
|
|
expect(() => parseFlags(["--n", " "], numSchema)).toThrow(FlagParseException);
|
|
expect(parseFlags(["--n", " 7 "], numSchema).args.n).toBe(7);
|
|
});
|
|
});
|
|
|
|
describe("flag-parser array + -json interleave never throws a raw error", () => {
|
|
it("throws FlagParseException (not TypeError) on interleave", () => {
|
|
let err: unknown;
|
|
try {
|
|
parseFlags(["--tags", "a", "--tags-json", '"b"', "--tags", "c"], arrSchema);
|
|
} catch (e) {
|
|
err = e;
|
|
}
|
|
expect(err).toBeInstanceOf(FlagParseException);
|
|
// Assert the message too — it names the field/flags and the guidance the
|
|
// user acts on, so a wording or flag-interpolation regression is caught.
|
|
expect((err as Error).message).toMatch(/--tags and --tags-json cannot be mixed/);
|
|
});
|
|
|
|
it("rejects the reverse order too, instead of silently discarding --tags-json", () => {
|
|
// --tags-json first, then a plain --tags: the plain flag used to hit the
|
|
// "first occurrence" branch and silently overwrite the JSON-parsed value
|
|
// with no error at all.
|
|
expect(() => parseFlags(["--tags-json", '["a","b"]', "--tags", "c"], arrSchema)).toThrow(
|
|
/--tags and --tags-json cannot be mixed/
|
|
);
|
|
});
|
|
|
|
it("rejects --tags-json arriving after a plain --tags, instead of silently overwriting it", () => {
|
|
expect(() => parseFlags(["--tags", "a", "--tags-json", '["b","c"]'], arrSchema)).toThrow(
|
|
/--tags and --tags-json cannot be mixed/
|
|
);
|
|
});
|
|
|
|
it("reports the mixing error (not a coercion error) when the plain numeric value is also invalid", () => {
|
|
// For a numeric array, the mixing check runs BEFORE scalar coercion, so a
|
|
// mixed --nums-json/--nums whose plain value ALSO fails to coerce surfaces
|
|
// the actionable "cannot be mixed" error rather than "expected a number".
|
|
expect(() => parseFlags(["--nums-json", "[1]", "--nums", "abc"], numArrSchema)).toThrow(
|
|
/--nums and --nums-json cannot be mixed/
|
|
);
|
|
// A valid plain value in the same mix still reports the mixing error.
|
|
expect(() => parseFlags(["--nums-json", "[1]", "--nums", "5"], numArrSchema)).toThrow(
|
|
/--nums and --nums-json cannot be mixed/
|
|
);
|
|
});
|
|
|
|
it("still allows repeated plain --tags with no -json involved", () => {
|
|
expect(parseFlags(["--tags", "a", "--tags", "b"], arrSchema).args.tags).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("still allows a bare --tags-json with no plain --tags involved", () => {
|
|
expect(parseFlags(["--tags-json", '["a","b"]'], arrSchema).args.tags).toEqual(["a", "b"]);
|
|
});
|
|
});
|
|
|
|
// A tool (like flow-add-step) whose schema declares its own `args` field — a
|
|
// JSON string holding the recorded step's tool arguments.
|
|
const flowAddStepSchema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
command: { type: "string" },
|
|
args: { type: "string" },
|
|
delayMs: { type: "integer" },
|
|
},
|
|
required: ["command"],
|
|
};
|
|
|
|
// A tool (like gesture-tap) with NO `args` field — here `--args` must stay the
|
|
// whole-payload escape hatch.
|
|
const gestureTapSchema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
udid: { type: "string" },
|
|
x: { type: "number" },
|
|
y: { type: "number" },
|
|
},
|
|
required: ["udid", "x", "y"],
|
|
};
|
|
|
|
describe("parseFlags — schema-aware --args", () => {
|
|
it("treats --args as the tool's own string field (space-separated form)", () => {
|
|
const result = parseFlags(
|
|
["--command", "gesture-tap", "--args", '{"udid":"X","x":0.5}'],
|
|
flowAddStepSchema
|
|
);
|
|
expect(result.args.command).toBe("gesture-tap");
|
|
// The raw JSON string is passed through untouched into the `args` field...
|
|
expect(result.args.args).toBe('{"udid":"X","x":0.5}');
|
|
// ...and NOT consumed as the whole-payload escape hatch.
|
|
expect(result.rawArgs).toBeNull();
|
|
});
|
|
|
|
it("treats --args=<value> inline form as the tool's own field", () => {
|
|
const result = parseFlags(
|
|
["--command", "gesture-tap", '--args={"udid":"X","x":0.5}'],
|
|
flowAddStepSchema
|
|
);
|
|
expect(result.args.command).toBe("gesture-tap");
|
|
expect(result.args.args).toBe('{"udid":"X","x":0.5}');
|
|
expect(result.rawArgs).toBeNull();
|
|
});
|
|
|
|
it("still parses sibling fields alongside the --args field", () => {
|
|
const result = parseFlags(
|
|
["--command", "screenshot", "--args", "{}", "--delayMs", "250"],
|
|
flowAddStepSchema
|
|
);
|
|
expect(result.args.command).toBe("screenshot");
|
|
expect(result.args.args).toBe("{}");
|
|
expect(result.args.delayMs).toBe(250);
|
|
expect(result.rawArgs).toBeNull();
|
|
});
|
|
|
|
it("treats --args - as the literal field value, NOT the stdin sentinel", () => {
|
|
// For a tool that owns `args`, `-` is just this field's value. The
|
|
// whole-payload stdin sentinel must not fire, so `rawArgs` stays null and
|
|
// nothing is read from stdin. This is the inverse of the no-`args` case
|
|
// below; asserting it here guards against a refactor that moved the `-`
|
|
// sentinel ahead of the `properties.args === undefined` gate and started
|
|
// routing flow-add-step's `--args -` to stdin.
|
|
const result = parseFlags(["--command", "gesture-tap", "--args", "-"], flowAddStepSchema);
|
|
expect(result.args.command).toBe("gesture-tap");
|
|
expect(result.args.args).toBe("-");
|
|
expect(result.rawArgs).toBeNull();
|
|
});
|
|
|
|
it("treats --args=- inline form as the literal field value too", () => {
|
|
const result = parseFlags(["--command", "gesture-tap", "--args=-"], flowAddStepSchema);
|
|
expect(result.args.args).toBe("-");
|
|
expect(result.rawArgs).toBeNull();
|
|
});
|
|
});
|
|
|
|
// A hypothetical future tool that OWNS its top-level `args` field but as an
|
|
// object / array of objects (flow-add-step's is a string). The whole-payload
|
|
// `--args '<json>'` hatch is disabled for any tool that owns `args`, so the
|
|
// error text must NOT suggest that dead-end form — only `--<field>-json` works.
|
|
const objectArgsSchema: JsonSchema = {
|
|
type: "object",
|
|
properties: { args: { type: "object" } },
|
|
};
|
|
const arrayArgsSchema: JsonSchema = {
|
|
type: "object",
|
|
properties: { args: { type: "array", items: { type: "object" } } },
|
|
};
|
|
// A control tool that does NOT own `args`, with an object field. Here the
|
|
// whole-payload hatch exists, so the "or --args '<json>'" suggestion must stay.
|
|
const objectFieldSchema: JsonSchema = {
|
|
type: "object",
|
|
properties: { filter: { type: "object" } },
|
|
};
|
|
|
|
describe("parseFlags — error hints omit the whole-payload --args form when the tool owns `args`", () => {
|
|
it("object `args`: suggests only --args-json, not the dead-end --args '<json>'", () => {
|
|
let err: unknown;
|
|
try {
|
|
parseFlags(["--args", '{"a":1}'], objectArgsSchema);
|
|
} catch (e) {
|
|
err = e;
|
|
}
|
|
expect(err).toBeInstanceOf(FlagParseException);
|
|
const msg = (err as Error).message;
|
|
expect(msg).toBe("--args is an object; pass it as --args-json '<json>'");
|
|
// The whole-payload form no longer routes to the hatch for this tool, so it
|
|
// must not be advertised as a fallback (it would just re-enter this branch).
|
|
expect(msg).not.toContain("or --args '<json>'");
|
|
});
|
|
|
|
it("array-of-objects `args`: suggests only --args-json, not the dead-end --args '<json>'", () => {
|
|
let err: unknown;
|
|
try {
|
|
parseFlags(["--args", '[{"a":1}]'], arrayArgsSchema);
|
|
} catch (e) {
|
|
err = e;
|
|
}
|
|
expect(err).toBeInstanceOf(FlagParseException);
|
|
const msg = (err as Error).message;
|
|
expect(msg).toBe("--args is an array of objects; pass it as --args-json '<json>'");
|
|
expect(msg).not.toContain("or --args '<json>'");
|
|
});
|
|
|
|
it("still suggests --args '<json>' for an object field on a tool WITHOUT its own `args`", () => {
|
|
// Control: dropping the whole-payload suggestion is scoped to tools that own
|
|
// `args`; for everyone else the hatch exists and stays advertised.
|
|
let err: unknown;
|
|
try {
|
|
parseFlags(["--filter", '{"a":1}'], objectFieldSchema);
|
|
} catch (e) {
|
|
err = e;
|
|
}
|
|
expect(err).toBeInstanceOf(FlagParseException);
|
|
expect((err as Error).message).toBe(
|
|
"--filter is an object; pass it as --filter-json '<json>' or --args '<json>'"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("parseFlags — whole-payload --args (no own `args` field)", () => {
|
|
it("keeps --args as the whole-payload escape hatch", () => {
|
|
const result = parseFlags(["--args", '{"udid":"X","x":0.5,"y":0.5}'], gestureTapSchema);
|
|
expect(result.rawArgs).toBe('{"udid":"X","x":0.5,"y":0.5}');
|
|
// `args` must not appear as a parsed field.
|
|
expect("args" in result.args).toBe(false);
|
|
});
|
|
|
|
it("keeps the --args - stdin sentinel as whole-payload", () => {
|
|
const result = parseFlags(["--args", "-"], gestureTapSchema);
|
|
expect(result.rawArgs).toBe("-");
|
|
expect("args" in result.args).toBe(false);
|
|
});
|
|
|
|
it("keeps whole-payload behavior when no schema is provided", () => {
|
|
const result = parseFlags(["--args", '{"udid":"X"}'], undefined);
|
|
expect(result.rawArgs).toBe('{"udid":"X"}');
|
|
expect("args" in result.args).toBe(false);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Issue #586: `--flag false` on a boolean left the flag TRUE and dropped the
|
|
* word. Reported after chasing a filter bug that did not exist — the tool had
|
|
* simply been asked for the opposite of what was typed. The `=` form worked, and
|
|
* `--help` gave no hint that it was required.
|
|
*
|
|
* The parser had declined to look ahead so it would not steal a following
|
|
* positional. `argent run` is the sole caller and never reads `positional`, so
|
|
* there was nothing to steal — and the module header already documented the
|
|
* space form as legal.
|
|
*/
|
|
const boolSchema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
capture: { type: "boolean" },
|
|
name: { type: "string" },
|
|
flags: { type: "array", items: { type: "boolean" } },
|
|
},
|
|
};
|
|
|
|
describe("boolean flags take a following true/false word", () => {
|
|
it("reads --capture false as false", () => {
|
|
const r = parseFlags(["--capture", "false"], boolSchema);
|
|
expect(r.args.capture).toBe(false);
|
|
// The word must be consumed, not left behind as a stray argument.
|
|
expect(r.positional).toEqual([]);
|
|
});
|
|
|
|
it("reads --capture true as true, consuming the word", () => {
|
|
const r = parseFlags(["--capture", "true"], boolSchema);
|
|
expect(r.args.capture).toBe(true);
|
|
expect(r.positional).toEqual([]);
|
|
});
|
|
|
|
it("accepts the words in any case, and padded", () => {
|
|
expect(parseFlags(["--capture", "False"], boolSchema).args.capture).toBe(false);
|
|
expect(parseFlags(["--capture", "TRUE"], boolSchema).args.capture).toBe(true);
|
|
expect(parseFlags(["--capture", " false "], boolSchema).args.capture).toBe(false);
|
|
});
|
|
|
|
it("accepts the same words in the = form, which used to be case-sensitive", () => {
|
|
// Otherwise `--capture True` and `--capture=True` would disagree about the
|
|
// same word, one function apart.
|
|
expect(parseFlags(["--capture=False"], boolSchema).args.capture).toBe(false);
|
|
expect(parseFlags(["--capture=TRUE"], boolSchema).args.capture).toBe(true);
|
|
});
|
|
|
|
it("reproduces the reported invocation", () => {
|
|
const schema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
baselinePath: { type: "string" },
|
|
currentPath: { type: "string" },
|
|
captureCurrent: { type: "boolean" },
|
|
},
|
|
};
|
|
const r = parseFlags(
|
|
["--baselinePath", "a.png", "--currentPath", "b.png", "--captureCurrent", "false"],
|
|
schema
|
|
);
|
|
expect(r.args.captureCurrent).toBe(false);
|
|
expect(r.positional).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("what the lookahead deliberately does NOT take", () => {
|
|
it("still treats a bare flag as true", () => {
|
|
const r = parseFlags(["--capture"], boolSchema);
|
|
expect(r.args.capture).toBe(true);
|
|
});
|
|
|
|
it("still treats a bare flag as true when it is the last token", () => {
|
|
expect(parseFlags(["--name", "x", "--capture"], boolSchema).args.capture).toBe(true);
|
|
});
|
|
|
|
it("leaves a following flag alone", () => {
|
|
const r = parseFlags(["--capture", "--name", "x"], boolSchema);
|
|
expect(r.args.capture).toBe(true);
|
|
expect(r.args.name).toBe("x");
|
|
});
|
|
|
|
it("leaves a non-boolean word alone rather than guessing", () => {
|
|
// Guessing at an ambiguous token is what produced #586 in the first place.
|
|
// `argent run` warns about the leftover instead.
|
|
const r = parseFlags(["--capture", "notabool"], boolSchema);
|
|
expect(r.args.capture).toBe(true);
|
|
expect(r.positional).toEqual(["notabool"]);
|
|
});
|
|
|
|
it("takes 1 and 0 in the space form too", () => {
|
|
// `--flag 0` previously set the flag TRUE and dropped the 0 — the same
|
|
// silent inversion as `--flag false`, and the reason 1/0 is not left as an
|
|
// ambiguous token: nothing else a bare 1/0 after a boolean switch can mean.
|
|
const one = parseFlags(["--capture", "1"], boolSchema);
|
|
expect(one.args.capture).toBe(true);
|
|
expect(one.positional).toEqual([]);
|
|
|
|
const zero = parseFlags(["--capture", "0"], boolSchema);
|
|
expect(zero.args.capture).toBe(false);
|
|
expect(zero.positional).toEqual([]);
|
|
});
|
|
|
|
it("reads 1 and 0 the same way in every form", () => {
|
|
// One helper behind the lookahead, the inline form and array items, so the
|
|
// same token cannot mean different things one call site apart.
|
|
expect(parseFlags(["--capture=1"], boolSchema).args.capture).toBe(true);
|
|
expect(parseFlags(["--capture=0"], boolSchema).args.capture).toBe(false);
|
|
expect(parseFlags(["--flags", "1", "--flags", "0"], boolSchema).args.flags).toEqual([
|
|
true,
|
|
false,
|
|
]);
|
|
// `--no-flag 0` is a double negative; it names the positive form rather
|
|
// than silently picking one, exactly as `--no-flag false` does.
|
|
expect(() => parseFlags(["--no-capture", "0"], boolSchema)).toThrow(/use --capture false/);
|
|
expect(() => parseFlags(["--no-capture", "1"], boolSchema)).toThrow(/use --capture true/);
|
|
});
|
|
|
|
it("still lets -- force a literal true/false positional", () => {
|
|
const r = parseFlags(["--capture", "--", "false"], boolSchema);
|
|
expect(r.args.capture).toBe(true);
|
|
expect(r.positional).toEqual(["false"]);
|
|
});
|
|
|
|
it("does not touch a string field's value", () => {
|
|
expect(parseFlags(["--name", "false"], boolSchema).args.name).toBe("false");
|
|
});
|
|
|
|
it("does not extend the lookahead to flags with no schema", () => {
|
|
// An unknown flag takes its value the ordinary way; treating it as a
|
|
// boolean would guess at a shape the CLI cannot know.
|
|
expect(parseFlags(["--unknown", "false"], boolSchema).args.unknown).toBe("false");
|
|
});
|
|
|
|
it("leaves boolean arrays and last-write-wins alone", () => {
|
|
expect(parseFlags(["--flags", "false", "--flags", "true"], boolSchema).args.flags).toEqual([
|
|
false,
|
|
true,
|
|
]);
|
|
expect(parseFlags(["--capture", "true", "--capture", "false"], boolSchema).args.capture).toBe(
|
|
false
|
|
);
|
|
});
|
|
|
|
it("rejects a value it cannot read", () => {
|
|
expect(() => parseFlags(["--capture=maybe"], boolSchema)).toThrow(FlagParseException);
|
|
});
|
|
});
|
|
|
|
describe("--no-flag", () => {
|
|
it("still sets false", () => {
|
|
expect(parseFlags(["--no-capture"], boolSchema).args.capture).toBe(false);
|
|
});
|
|
|
|
it("still rejects an attached value", () => {
|
|
expect(() => parseFlags(["--no-capture=false"], boolSchema)).toThrow(FlagParseException);
|
|
});
|
|
|
|
it("names the positive form instead of resolving a contradiction", () => {
|
|
// `--no-capture true` contradicts itself and `--no-capture false` is a
|
|
// double negative; before the lookahead existed both silently meant false.
|
|
expect(() => parseFlags(["--no-capture", "true"], boolSchema)).toThrow(
|
|
/does not take a value; use --capture true/
|
|
);
|
|
expect(() => parseFlags(["--no-capture", "false"], boolSchema)).toThrow(/use --capture false/);
|
|
});
|
|
});
|
|
|
|
describe("boolean value syntax is discoverable from --help", () => {
|
|
it("adds a legend when the tool has a boolean field", () => {
|
|
const usage = formatSchemaUsage(boolSchema);
|
|
expect(usage).toMatch(/Booleans:/);
|
|
expect(usage).toMatch(/--flag false/);
|
|
expect(usage).toMatch(/--no-flag/);
|
|
});
|
|
|
|
it("adds nothing when the tool has no boolean field", () => {
|
|
// Otherwise every tool carries a lesson that does not apply to it.
|
|
expect(formatSchemaUsage(numSchema)).not.toMatch(/Booleans:/);
|
|
});
|
|
|
|
it("keeps the legend from starting with -- so the e2e harness can parse help", () => {
|
|
// scripts/e2e-full/lib/discover-tools.sh treats any line in the Flags:
|
|
// section matching /^[[:space:]]*--/ as a flag row and takes the first
|
|
// --token as the flag name. A legend starting with a flag would inject a
|
|
// phantom flag into every tool model it builds. This pins the shape so a
|
|
// well-meaning reflow cannot quietly break that harness.
|
|
for (const line of formatSchemaUsage(boolSchema).split("\n")) {
|
|
if (line.includes("Booleans:")) expect(line.trimStart().startsWith("--")).toBe(false);
|
|
}
|
|
});
|
|
});
|