mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
afd722924f
## Problem
Only one flow could be recorded at a time, anywhere on the machine.
Recording state was three module globals in `flow-utils.ts` — harmless
in a per-client process, but the tool-server is a **host-wide singleton
per install bundle**. Every MCP client, every subagent sharing a
parent's connection, every `argent` CLI call and every project on the
machine attaches to the same process, the same `Registry`, and those
same three globals. HTTP requests are served concurrently with no
serialization.
Three failures followed, all reproduced against the pre-change code:
1. **A second recording clobbered the first.** `startRecordingSession`
just overwrote, so the first agent's next `flow-add-step` silently
appended into the *other* agent's flow file — in a different project.
2. **Any agent could finish any agent's recording.**
`flow-finish-recording`'s schema was literally `z.object({})`.
3. **Replay stomped recording.** `resolveFlowFilePath` called
`setActiveProjectRoot`, so a `flow-execute` in project B rebound the
global root mid-recording of project A. Already worked around, locally
only, in `flow-add-step`.
## Approach
Recording state is now a `Map` keyed by the resolved flow file path,
`<project_root>/.argent/flows/<name>.yaml` — the identity of the
artifact being built, so "same key" means "same output file" and two
sessions on one key are a genuine collision rather than an accident of
scoping. `flow-add-step`, `flow-add-echo` and `flow-finish-recording`
each take `name` + `project_root`, so every call is self-contained;
required rather than optional-with-fallback, since a fallback would
succeed while an agent is alone and fail only once a second recording
exists.
`resolveFlowFilePath` is pure, which fixes defect 3 and retires
`FLOW_PROJECT_ROOT_REQUIRED` (the state it guarded can no longer exist).
Rejected alternatives — keying by device udid (flows are deliberately
device-portable, and `flow-add-echo`/`flow-finish-recording` have no
device at all), an opaque `recordingId` (an agent that loses the token
cannot recover), and per-caller identity from the transport
(`ToolContext` carries no caller identity, and subagents share their
parent's MCP connection, so it would fail the exact scenario being
fixed).
### Locking, and why readers needed more than a lock
Every mutation of one flow file runs under a lock keyed by that path —
appends, `flow-start-recording`'s truncate-and-register, and
`flow-finish-recording`'s read-summarize-clear. Keyed by file rather
than owned by the session, because a restart *replaces* the session: a
session-owned lock cannot exclude the operation that supersedes it. Per
file, not global, so two recordings never queue behind each other. An
append whose session was finished, restarted or evicted meanwhile fails
with `FLOW_NO_ACTIVE_RECORDING` rather than writing into a different
take.
The lock serializes writers only, and no reader of a flow YAML can join
it — `flow-execute`'s own load, its `run:` fragment load,
`flow-read-prerequisite`, `flow-add-step`'s sibling-fragment check, and
the `argent` CLI reading from another process entirely, where an
in-process lock cannot reach. Both writers used a plain `fs.writeFile`,
which opens `O_TRUNC`, so a reader could land between the truncate and
the write. That case is silent: `parseFlow("")` returns `{ steps: [] }`
with no error and `summarize` derives `ok` from "no failures", so a
`flow-execute` racing an append reports a **top-level PASS having
replayed zero steps**. Both writes now go through a sibling temp file
and a rename, so a reader sees either the whole old file or the whole
new one — and unlike the lock, that also holds cross-process.
### `stop-all-simulator-servers` gets a `devices` scope
Every agent is told to call this at session end, and unscoped it walked
the whole registry — so agent A wrapping up tore down agent B's devtools
mid-recording, degrading B's flow to brittle coordinate taps. It now
takes an optional `devices` scope and reports `unmatched` ids, so a
mistyped id doesn't read as a clean machine.
Which namespaces count as "owned by a device" turned out to be the
load-bearing part, because nothing cascades to most of them and
`unmatched` turns any omission into an actively wrong diagnosis — a
correct device id reported as a typo. The set now covers `AXService`
(the in-sim ax daemon, spawned `--timeout 3600`; an iOS session that
only ran boot/launch/describe owns this and nothing else),
`ScreenRecordingSession` (an ffmpeg child plus the touch-visualizer
overlay it enabled on the device), `NativeProfilerSession` (an xctrace
child, or an on-device perfetto process and its trace file) and
`JsRuntimeDebugger` (a bound loopback server, the CDP socket to Metro, a
log handle). The debugger URNs interpose the Metro port —
`<ns>:<port>:<deviceId>` — so the matcher understands both shapes,
consuming only the first colon so a wireless adb serial after the port
still compares whole.
`stop-simulator-server` shared none of this and had drifted: it looked
URNs up with an exact, case-sensitive `services.get()`, so a lower-cased
UDID silently no-op'd there while the scoped stop-all reaped it. Both
tools now share one matcher. Their namespace *sets* stay deliberately
different, documented where they are defined — `stop-simulator-server`
is also the documented recovery for a wedged transport, and widening it
to devtools/AX would make a routine retry drop the native-devtools
connection another agent's recording depends on, which is the hazard the
`devices` scope exists to prevent.
## Verification
**End-to-end, three devices, one shared tool-server** (iPhone 16 Pro,
iPhone 17 Pro, Pixel 3a): three interleaved recordings, plus an
unrelated `flow-execute` in a fourth project while all three were live.
Both iOS devices held their own live native-devtools concurrently (two
per-UDID sockets, `connected: true`). Each YAML contained exactly its
own steps in order, taps captured as selectors rather than coordinates,
and neither picked up another's or the third root's path. All three
replayed green — also through the **released 0.17.0 CLI** against the
modified server, which checks wire compatibility. A device-scoped
teardown of sim 1 left sim 2 and the emulator running.
Re-run against the final build:
- Two interleaved recordings across two project roots on one device,
with a **separate process** polling one of the flow files as fast as it
could for the duration. Zero empty or truncated observations; each YAML
held exactly its own steps; both replayed green (6/6 and 5/5). Against
the pre-change in-place write the same reader observes zero-step reads
interleaved among the real ones — the silent-PASS input.
- A scoped teardown of the iPhone reaped `AXService` and
`NativeDevtools` for that UDID only, reported no `unmatched`, and left
the second sim and the emulator running. Lower-casing the UDID matched;
a genuinely bogus id was still reported.
- With a screen recording live on the Pixel 3a, a scoped teardown reaped
`ScreenRecordingSession` and the **ffmpeg child actually exited** (1 →
0). Before the fix it survived to the 180 s cap while the tool called
the correct serial a typo.
The pre-change repros were re-run afterwards to confirm observed
behavior changed, not just the tests.
`npx vitest run` in `packages/tool-server` (295 files, 3154 tests) and
`packages/argent-cli` (280), `tsc --build`, `tsc --noEmit` on both test
projects, eslint and prettier — all green.
## Review notes
Several rounds of review found real defects in this change; each was
reproduced before fixing and re-verified after. Worth knowing when
reading the diff, since the tests exist to pin them:
- `flow-start-recording` truncated the `.yaml` outside any lock, so a
step from the take being discarded landed in the freshly reset file and
was reported as success (26-29 of 200 runs).
- `flow-finish-recording` never took the lock; its `await fs.readFile`
was a yield during which a concurrent append committed, leaving the
reported summary disagreeing with disk (11-16 of 200).
- A failed finish destroyed the recording. Hand-editing the `.yaml`
mid-recording is a documented workflow, so `parseFlow` can legitimately
throw — and the error told the agent to call `flow-start-recording`,
which truncates the very take it would recover.
- `unmatched` reported a device this session had just stopped, because
`disposeService` returns a node to IDLE and keeps it.
- A bare IP claimed every wireless-adb device at that address, since an
adb serial is itself `ip:port`.
- The eviction backstop stamped with `Date.now()`; ties at millisecond
resolution let it drop the session that was just used.
- `requireRecordingSession` ended with "Call flow-start-recording first"
— reached for a key that was finished, superseded or evicted as well as
one never started, and on those branches the file on disk is fully
populated while that call truncates it and reports no `restarted`.
- The atomic write regressed twice on its own terms: its scratch name
was derived from the flow file's basename, which has no length cap, so a
long flow name that appended fine before failed `ENAMETOOLONG`; and the
cleanup guard began after the temp file was created, leaking a scratch
file into a committed directory on any write that failed after opening.
- Two tests passed against implementations they claimed to reject: the
`stop-simulator-server` narrowness guard used a udid that classifies as
*android*, where the iOS-only services it guards can never appear, so
widening the iOS branch kept it green; and the client-mode finish
asserted only the *shape* of `savedTo`, never its content, though that
directive is the only thing that lands the file in client mode.
## Notes for reviewers
- Client/remote persist mode is the least-exercised path, and now has
its own concurrency tests (overlapping appends, and a restart
superseding an in-flight one) — it previously had none. The
`project_root` key is byte-stable across the file boundary (`kind:
"probe"` passes it through unchanged), which is what keeps a remote
recording addressable from `flow-add-step`, which declares no file
input.
- Two spellings of one flow file still mint two sessions — and two
independent locks — over one file, which bypasses every guarantee here.
Two ways in: the root spelled two ways (a symlink, or a case variant on
APFS), and the likelier one, the *name* cased two ways on a
case-insensitive volume. Neither is normalized away because the correct
normalization is the filesystem's: case-folding the key would wrongly
merge two distinct flows on ext4, and resolving symlinks is impossible
in client mode, where the root does not exist on this host. Documented
at `getFlowPath`.
- No recording tool can detect that another agent took its key.
`flow-add-step`, `flow-add-echo` and `flow-finish-recording` all
re-resolve the key on every call, so after a takeover they act on
whichever take is now live and report success: an append lands in the
other agent's file, and a finish finishes and clears it. The only case
that fails loudly is the narrow one the liveness check covers - an
append already IN FLIGHT when the restart lands. There is no caller
identity to key ownership on (see the rejected alternatives above), so
this is spelled out for the agent under "Pick a name unique to your
task" in `argent-create-flow` rather than mechanized.
- Skew hazard in one direction only: a *new* client against an *older*
tool-server. Zod strips the undeclared `name`/`project_root`, and the
old server falls back to its module globals — so concurrent recordings
silently collapse onto one session. The other direction (released 0.17.0
CLI against this server) was verified end-to-end.
- `packages/argent-private/docs/reference.md` still describes
`flow-add-step` as appending to "the current recording", and names two
tool ids that do not exist (`flow-insert-echo`/`flow-run` are file
names; the ids are `flow-add-echo`/`flow-execute`). It is a submodule,
so the fix cannot ride on this branch.
- A restart reported `discardedSteps` from the superseded session's
in-memory flow while it truncates the *file*. Hand-editing the `.yaml`
mid-recording is documented, so the two diverge: four steps wiped, one
reported. It now counts the file, and reports no number at all when that
file cannot be read or parsed - 0 is the answer a genuinely empty take
gives. Client mode still counts from memory (this host cannot see the
client's file), and the description now says which mode it is promising.
- `ChromiumJsRuntimeDebugger` cascades from `ChromiumCdp` and was
therefore already being torn down - silently, while `NetworkInspector`
and `ReactProfilerSession` cascade the same way and were named.
`stopped` is documented as the services that were live and got shut
down, so it is listed now too.
- The client-mode case named for a superseded in-flight append restarted
*between* calls, so the next append re-resolved the key and succeeded -
the guard was never reached, and neutering it left the whole file green.
It now parks an append in its live sub-tool call across the restart.
- `stop-tools.test.ts`' mock handed the tool the live service map where
the real `getSnapshot` copies, so a cascade rewrote the state the sweep
was still reading and the answer depended on map insertion order. The
mock copies and recurses like `_teardown` now, and the cascade case runs
under both orders.
- Prose the code contradicted, each verified against source before
rewriting: Vega owns `JsRuntimeDebugger`/`NetworkInspector` once the
debugger has run (and can match anything at all through an ERROR node);
"nothing here cascades" was denied twice in its own file; an iOS
boot/launch/describe session owns `NativeDevtools` too; `:tcp` is
mintable but unreached in production; the tool-server is a singleton
*per install bundle*, not per machine; `argent flow list` does enumerate
`.argent/flows`; the stop-tools comments narrated a "before" that only
existed on this branch.
Found while verifying, out of scope, worth their own issues: a `tool:
flow-execute` self-reference recurses past both cycle guards (`run:` is
guarded, a nested `flow-execute` reseeds the stack); react-profiler
artifacts collide host-wide on a second-resolution filename;
`stop-metro` is port-scoped and kills another agent's bundler;
`debugger-reload-metro`'s HTTP fallback broadcasts to every attached
client. A tool's `capability` is enforced only by the HTTP layer, not by
`registry.invokeTool`, so a call that reaches the registry another way -
`flow-add-step` takes `command` as a bare string - can resolve a service
the tool declares it does not support, leaving an ERROR node behind
(e.g. `SimulatorServer:<vega serial>`).
---------
Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
517 lines
21 KiB
TypeScript
517 lines
21 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. Mirrors the schema the
|
|
// registry advertises for the real tool (zodObjectToJsonSchema over
|
|
// packages/tool-server/src/tools/flows/flow-add-step.ts): recordings are keyed by
|
|
// `name` + `project_root`, so both are required alongside `command`.
|
|
//
|
|
// This fixture is hand-copied: `@argent/cli` does not depend on the tool-server,
|
|
// so it cannot derive the schema. The guard that catches drift lives where the
|
|
// schema does — `flow-tools.test.ts`'s "the flow-add-step schema the CLI tests
|
|
// hand-copy". If that fails, this fixture is what it is telling you to update.
|
|
const flowAddStepSchema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
name: { type: "string" },
|
|
project_root: { type: "string" },
|
|
command: { type: "string" },
|
|
args: { type: "string" },
|
|
delayMs: { type: "integer" },
|
|
},
|
|
required: ["name", "project_root", "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("routes the recording identity through the plain scalar path", () => {
|
|
const result = parseFlags(
|
|
[
|
|
"--name",
|
|
"checkout-e2e",
|
|
"--project_root",
|
|
"/Users/dev/My Projects/demo-app",
|
|
"--command",
|
|
"gesture-tap",
|
|
"--args",
|
|
'{"udid":"X"}',
|
|
],
|
|
flowAddStepSchema
|
|
);
|
|
expect(result.args.name).toBe("checkout-e2e");
|
|
// `project_root` is the only schema field carrying an underscore, so it pins
|
|
// that flag names reach the payload verbatim — a parser that normalised them
|
|
// to camel/kebab case would file the value under the wrong key and the server
|
|
// would reject the step for a missing `project_root`. The value also holds a
|
|
// space: argv arrives already split, so it must survive whole.
|
|
expect(result.args.project_root).toBe("/Users/dev/My Projects/demo-app");
|
|
expect(result.args.command).toBe("gesture-tap");
|
|
expect(result.args.args).toBe('{"udid":"X"}');
|
|
expect(result.rawArgs).toBeNull();
|
|
});
|
|
|
|
it("routes the recording identity through the inline --field=<value> form too", () => {
|
|
const result = parseFlags(
|
|
["--name=checkout-e2e", "--project_root=/Users/dev/demo-app", "--command=screenshot"],
|
|
flowAddStepSchema
|
|
);
|
|
expect(result.args.name).toBe("checkout-e2e");
|
|
expect(result.args.project_root).toBe("/Users/dev/demo-app");
|
|
expect(result.args.command).toBe("screenshot");
|
|
expect(result.rawArgs).toBeNull();
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
});
|