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>
2060 lines
81 KiB
TypeScript
2060 lines
81 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||
import * as fs from "node:fs/promises";
|
||
import { constants as fsConstants } from "node:fs";
|
||
import * as os from "node:os";
|
||
import * as path from "node:path";
|
||
import { FAILURE_CODES, getFailureSignal } from "@argent/registry";
|
||
import {
|
||
countStepsOnDisk,
|
||
serializeFlow,
|
||
parseFlow,
|
||
describeSelector,
|
||
assertValidProjectRoot,
|
||
startRecordingSession,
|
||
getRecordingSession,
|
||
requireRecordingSession,
|
||
clearRecordingSession,
|
||
listActiveRecordings,
|
||
__resetRecordingsForTesting,
|
||
MAX_RECORDINGS,
|
||
getFlowPath,
|
||
appIdForPlatform,
|
||
chromiumLaunchSpec,
|
||
writeNewFlowFile,
|
||
type FlowFile,
|
||
} from "../../src/tools/flows/flow-utils";
|
||
|
||
// ── serializeFlow ────────────────────────────────────────────────────
|
||
|
||
describe("serializeFlow", () => {
|
||
it("serializes an empty flow with prerequisite", async () => {
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "App on home screen",
|
||
steps: [],
|
||
};
|
||
const result = serializeFlow(flow);
|
||
expect(result).toContain("executionPrerequisite: App on home screen");
|
||
expect(result).toContain("steps: []");
|
||
});
|
||
|
||
it("serializes echo steps", async () => {
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "Fresh reload",
|
||
steps: [{ kind: "echo", message: "Hello" }],
|
||
};
|
||
const result = serializeFlow(flow);
|
||
expect(result).toContain("- echo: Hello");
|
||
});
|
||
|
||
it("serializes tool steps with args", async () => {
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "",
|
||
steps: [{ kind: "tool", name: "tap", args: { x: 0.5, y: 0.3 } }],
|
||
};
|
||
const result = serializeFlow(flow);
|
||
expect(result).toContain("- tool: tap");
|
||
expect(result).toContain(" x: 0.5");
|
||
expect(result).toContain(" y: 0.3");
|
||
});
|
||
|
||
it("serializes tool steps with empty args (omits args key)", async () => {
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "",
|
||
steps: [{ kind: "tool", name: "screenshot", args: {} }],
|
||
};
|
||
const result = serializeFlow(flow);
|
||
expect(result).toContain("- tool: screenshot");
|
||
expect(result).not.toContain("args:");
|
||
});
|
||
|
||
it("rejects gesture targets that cannot round-trip through the parser", async () => {
|
||
const serializeStep = (step: FlowFile["steps"][number]) =>
|
||
serializeFlow({ executionPrerequisite: "", steps: [step] });
|
||
|
||
expect(() => serializeStep({ kind: "tap", x: 1.5, y: 0.5 })).toThrow(
|
||
/normalized 0–1 fractions/i
|
||
);
|
||
expect(() => serializeStep({ kind: "long-press", x: Number.NaN, y: 0.5 })).toThrow(
|
||
/normalized 0–1 fractions/i
|
||
);
|
||
expect(() => serializeStep({ kind: "tap", x: 0.5 })).toThrow(/needs numeric x and y/i);
|
||
expect(() =>
|
||
serializeStep({ kind: "long-press", selector: { text: "Row" }, x: 0.5, y: 0.5 })
|
||
).toThrow(/selector or x\/y coordinates, not both/i);
|
||
});
|
||
});
|
||
|
||
// ── describeSelector ─────────────────────────────────────────────────
|
||
|
||
describe("describeSelector", () => {
|
||
it("spells identifier as id, the flow-YAML spelling", async () => {
|
||
expect(describeSelector({ identifier: "submit" })).toBe('id="submit"');
|
||
});
|
||
|
||
it("renders a text selector", async () => {
|
||
expect(describeSelector({ text: "Login" })).toBe('text="Login"');
|
||
});
|
||
|
||
it("drops the internal loose flag", async () => {
|
||
expect(describeSelector({ text: "Login", loose: true })).toBe('text="Login"');
|
||
});
|
||
|
||
it("joins multiple keys with spaces", async () => {
|
||
expect(describeSelector({ text: "Login", role: "button" })).toBe('text="Login" role="button"');
|
||
});
|
||
});
|
||
|
||
// ── parseFlow ────────────────────────────────────────────────────────
|
||
|
||
describe("parseFlow", () => {
|
||
it("parses a flow with executionPrerequisite and echo steps", async () => {
|
||
const content = "executionPrerequisite: App on home screen\nsteps:\n - echo: Hello\n";
|
||
const flow = parseFlow(content);
|
||
expect(flow.executionPrerequisite).toBe("App on home screen");
|
||
expect(flow.steps).toEqual([{ kind: "echo", message: "Hello" }]);
|
||
});
|
||
|
||
it("parses tool entries with args", async () => {
|
||
const content =
|
||
'executionPrerequisite: ""\nsteps:\n - tool: tap\n args:\n x: 0.5\n y: 0.3\n';
|
||
const flow = parseFlow(content);
|
||
expect(flow.steps).toEqual([{ kind: "tool", name: "tap", args: { x: 0.5, y: 0.3 } }]);
|
||
});
|
||
|
||
it("parses tool entries with no args", async () => {
|
||
const content = 'executionPrerequisite: ""\nsteps:\n - tool: screenshot\n';
|
||
const flow = parseFlow(content);
|
||
expect(flow.steps).toEqual([{ kind: "tool", name: "screenshot", args: {} }]);
|
||
});
|
||
|
||
it("parses a multi-step flow", async () => {
|
||
const content = [
|
||
"executionPrerequisite: Settings open",
|
||
"steps:",
|
||
" - echo: Step 1",
|
||
" - tool: tap",
|
||
" args:",
|
||
" x: 0.5",
|
||
" - echo: Step 2",
|
||
" - tool: screenshot",
|
||
" args:",
|
||
" udid: ABC",
|
||
].join("\n");
|
||
|
||
const flow = parseFlow(content);
|
||
expect(flow.executionPrerequisite).toBe("Settings open");
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "echo", message: "Step 1" },
|
||
{ kind: "tool", name: "tap", args: { x: 0.5 } },
|
||
{ kind: "echo", message: "Step 2" },
|
||
{ kind: "tool", name: "screenshot", args: { udid: "ABC" } },
|
||
]);
|
||
});
|
||
|
||
it("returns empty steps for empty content", async () => {
|
||
const flow = parseFlow("");
|
||
expect(flow.executionPrerequisite).toBe("");
|
||
expect(flow.steps).toEqual([]);
|
||
});
|
||
|
||
it("defaults executionPrerequisite to empty string when missing", async () => {
|
||
const content = "steps:\n - echo: Hello\n";
|
||
const flow = parseFlow(content);
|
||
expect(flow.executionPrerequisite).toBe("");
|
||
expect(flow.steps).toEqual([{ kind: "echo", message: "Hello" }]);
|
||
});
|
||
|
||
it("throws on unrecognized entries", async () => {
|
||
const content = 'executionPrerequisite: ""\nsteps:\n - bogus: line\n';
|
||
expect(() => parseFlow(content)).toThrow("Unrecognized flow entry");
|
||
});
|
||
|
||
it("renders a small unrecognized entry in full", async () => {
|
||
// The common authoring error: a short mistyped step. The echo cap must
|
||
// leave it untouched — seeing the whole entry is what makes it fixable.
|
||
const content = 'executionPrerequisite: ""\nsteps:\n - bogus: line\n';
|
||
expect(() => parseFlow(content)).toThrow(': {"bogus":"line"}');
|
||
});
|
||
|
||
it("caps the echoed entry so an oversized value cannot ride the diagnostic", async () => {
|
||
// A mistyped run: path can point parseFlow at any in-project YAML file,
|
||
// and this message flows verbatim to stdout and into agent context — so
|
||
// the render must be bounded, and the tail of the value must not appear.
|
||
const content = `steps:\n - db_password: "hunter2-${"x".repeat(5000)}-SECRET-TAIL"\n`;
|
||
let message = "";
|
||
try {
|
||
parseFlow(content);
|
||
} catch (error) {
|
||
message = error instanceof Error ? error.message : String(error);
|
||
}
|
||
expect(message).toContain("Unrecognized flow entry");
|
||
expect(message).toContain("…(+");
|
||
expect(message).not.toContain("SECRET-TAIL");
|
||
expect(message.length).toBeLessThan(400);
|
||
});
|
||
|
||
it("throws when content is not an object with steps", async () => {
|
||
expect(() => parseFlow("- echo: Hello\n")).toThrow("expected an object with a steps array");
|
||
});
|
||
|
||
it("classifies a YAML syntax error as a validation failure with the parser's detail", async () => {
|
||
let thrown: unknown;
|
||
try {
|
||
parseFlow("steps: ][\n");
|
||
} catch (err) {
|
||
thrown = err;
|
||
}
|
||
expect(thrown).toBeInstanceOf(Error);
|
||
const signal = getFailureSignal(thrown);
|
||
expect(signal?.error_kind).toBe("validation");
|
||
expect(signal?.error_code).toBe(FAILURE_CODES.FLOW_FILE_INVALID);
|
||
// The yaml library's line/column detail must survive so the user can
|
||
// locate the syntax error.
|
||
expect((thrown as Error).message).toContain("Invalid flow file:");
|
||
expect((thrown as Error).message).toContain("line 1");
|
||
});
|
||
|
||
it("throws a validation error (not a TypeError) on a primitive step entry", async () => {
|
||
const content = 'executionPrerequisite: ""\nsteps:\n - tap\n';
|
||
expect(() => parseFlow(content)).toThrow("Unrecognized flow entry");
|
||
});
|
||
|
||
it("throws a validation error on a null step entry", async () => {
|
||
const content = 'executionPrerequisite: ""\nsteps:\n - ~\n';
|
||
expect(() => parseFlow(content)).toThrow("Unrecognized flow entry");
|
||
});
|
||
|
||
it("sugars a bare-string selector into a loose { text } for tap", async () => {
|
||
const flow = parseFlow("steps:\n - tap: Settings\n");
|
||
// Bare string ⇒ loose: resolves identifier-first, then falls back to text.
|
||
expect(flow.steps).toEqual([{ kind: "tap", selector: { text: "Settings", loose: true } }]);
|
||
});
|
||
|
||
it("sugars a bare-string selector for type.into", async () => {
|
||
const flow = parseFlow('steps:\n - type: { into: email, text: "a@b.com" }\n');
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "type", into: { text: "email", loose: true }, text: "a@b.com" },
|
||
]);
|
||
});
|
||
|
||
it("defaults type.submit to on (no submit key in the parsed model)", async () => {
|
||
const flow = parseFlow('steps:\n - type: { into: email, text: "a@b.com" }\n');
|
||
expect(flow.steps[0]).not.toHaveProperty("submit");
|
||
});
|
||
|
||
it("parses and round-trips an explicit type.submit: false opt-out", async () => {
|
||
const flow = parseFlow('steps:\n - type: { into: email, text: "a@b.com", submit: false }\n');
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "type", into: { text: "email", loose: true }, text: "a@b.com", submit: false },
|
||
]);
|
||
expect(serializeFlow(flow)).toContain("submit: false");
|
||
expect(parseFlow(serializeFlow(flow)).steps).toEqual(flow.steps);
|
||
});
|
||
|
||
it("rejects a non-boolean type.submit", async () => {
|
||
expect(() => parseFlow('steps:\n - type: { into: email, text: "x", submit: 3 }\n')).toThrow();
|
||
});
|
||
|
||
it("keeps an explicit { text } map strict (no loose fallback)", async () => {
|
||
const flow = parseFlow("steps:\n - tap: { text: Settings }\n");
|
||
expect(flow.steps).toEqual([{ kind: "tap", selector: { text: "Settings" } }]);
|
||
});
|
||
|
||
it.each([
|
||
[
|
||
"selector",
|
||
"steps:\n - tap: { text: { matches: '^Order #\\d+$' } }\n",
|
||
{ kind: "tap", selector: { textMatches: "^Order #\\d+$" } },
|
||
],
|
||
[
|
||
"text condition",
|
||
"steps:\n - assert: { text: { in: total, matches: '^Total: \\$\\d+$' } }\n",
|
||
{
|
||
kind: "assert",
|
||
condition: "text",
|
||
selector: { text: "total", loose: true },
|
||
expectedText: "^Total: \\$\\d+$",
|
||
textMatch: "matches",
|
||
},
|
||
],
|
||
] as const)("accepts a valid regex pattern at the %s ingress", (_ingress, yaml, expected) => {
|
||
expect(parseFlow(yaml).steps).toEqual([expected]);
|
||
});
|
||
|
||
it("accepts a regex selector combined with id and role", async () => {
|
||
expect(
|
||
parseFlow(
|
||
"steps:\n - tap: { text: { matches: '^Order #\\d+$' }, id: order-row, role: button }\n"
|
||
).steps
|
||
).toEqual([
|
||
{
|
||
kind: "tap",
|
||
selector: {
|
||
textMatches: "^Order #\\d+$",
|
||
identifier: "order-row",
|
||
role: "button",
|
||
},
|
||
},
|
||
]);
|
||
});
|
||
|
||
it.each([
|
||
["id", "''"],
|
||
["id", "42"],
|
||
["role", "''"],
|
||
["role", "42"],
|
||
])(
|
||
"validates an invalid regex-selector %s through the same schema as a literal selector (%s)",
|
||
(field, value) => {
|
||
const validationDetail = (text: string | { matches: string }): string => {
|
||
const yamlText = typeof text === "string" ? text : `{ matches: '${text.matches}' }`;
|
||
try {
|
||
parseFlow(`steps:\n - tap: { text: ${yamlText}, ${field}: ${value} }\n`);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
const detail = /^Unrecognized flow entry \((.*)\): /.exec(message)?.[1];
|
||
expect(detail).toBeDefined();
|
||
return detail!;
|
||
}
|
||
throw new Error("expected selector validation to fail");
|
||
};
|
||
|
||
const literalDetail = validationDetail("Order");
|
||
const regexDetail = validationDetail({ matches: "^Order #\\d+$" });
|
||
|
||
expect(literalDetail).toMatch(/^tap: /);
|
||
expect(regexDetail).toBe(literalDetail);
|
||
}
|
||
);
|
||
|
||
it.each([
|
||
[
|
||
"selector",
|
||
"steps:\n - assert: { visible: { text: { matches: '(' } } }\n",
|
||
"assert.visible: text",
|
||
],
|
||
[
|
||
"text condition",
|
||
"steps:\n - assert: { text: { in: total, matches: '(' } }\n",
|
||
"assert text",
|
||
],
|
||
])("reports invalid regex syntax consistently at the %s ingress", (_ingress, yaml, where) => {
|
||
expect(() => parseFlow(yaml)).toThrow(`${where} \`matches\` is not a valid regular expression`);
|
||
});
|
||
|
||
it("parses the map form's `id` as the internal identifier field (strict)", async () => {
|
||
const flow = parseFlow("steps:\n - tap: { id: submit-btn }\n");
|
||
expect(flow.steps).toEqual([{ kind: "tap", selector: { identifier: "submit-btn" } }]);
|
||
});
|
||
|
||
it("accepts `identifier` as a parse-only alias for `id`", async () => {
|
||
const flow = parseFlow("steps:\n - tap: { identifier: submit-btn }\n");
|
||
expect(flow.steps).toEqual([{ kind: "tap", selector: { identifier: "submit-btn" } }]);
|
||
});
|
||
|
||
it("rejects a selector map carrying both `id` and `identifier`", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { id: a, identifier: b }\n")).toThrow(
|
||
/`id` or `identifier`.*not both/
|
||
);
|
||
});
|
||
|
||
it("re-serializes an identifier-spelled flow with the `id` spelling", async () => {
|
||
// Old files parse via the alias; the next write (appendStep re-serializes
|
||
// the whole file) migrates them to the canonical `id` spelling.
|
||
const yaml = serializeFlow(parseFlow("steps:\n - tap: { identifier: submit-btn }\n"));
|
||
expect(yaml).toContain("id: submit-btn");
|
||
expect(yaml).not.toContain("identifier:");
|
||
});
|
||
|
||
it("parses condition-as-key await/assert sugar (visible/exists/hidden)", async () => {
|
||
const flow = parseFlow(
|
||
[
|
||
"steps:",
|
||
" - await: { visible: Account }",
|
||
" - assert: { exists: { id: row } }",
|
||
" - await: { hidden: spinner }",
|
||
].join("\n")
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "await", condition: "visible", selector: { text: "Account", loose: true } },
|
||
{ kind: "assert", condition: "exists", selector: { identifier: "row" } },
|
||
{ kind: "await", condition: "hidden", selector: { text: "spinner", loose: true } },
|
||
]);
|
||
});
|
||
|
||
it("parses the text sugar { in, contains } as a substring match", async () => {
|
||
const flow = parseFlow(
|
||
'steps:\n - assert: { text: { in: { id: counter }, contains: "Taps: 0" } }\n'
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "assert",
|
||
condition: "text",
|
||
selector: { identifier: "counter" },
|
||
expectedText: "Taps: 0",
|
||
textMatch: "contains",
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("parses the text sugar { in, equals } as an exact match", async () => {
|
||
const flow = parseFlow(
|
||
'steps:\n - assert: { text: { in: { id: counter }, equals: "Taps: 0" } }\n'
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "assert",
|
||
condition: "text",
|
||
selector: { identifier: "counter" },
|
||
expectedText: "Taps: 0",
|
||
textMatch: "equals",
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("rejects text sugar with both contains and equals", async () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - assert: { text: { in: counter, contains: a, equals: b } }\n")
|
||
).toThrow(/exactly one of `contains`, `equals`, or `matches`/);
|
||
});
|
||
|
||
it("rejects the explicit { condition, selector, expectedText } form (sugar only)", async () => {
|
||
expect(() =>
|
||
parseFlow(
|
||
[
|
||
"steps:",
|
||
" - assert:",
|
||
" condition: text",
|
||
" selector: { text: 'Taps:' }",
|
||
" expectedText: 'Taps: 0'",
|
||
].join("\n")
|
||
)
|
||
).toThrow(/exactly one condition key/);
|
||
});
|
||
|
||
it("rejects an await/assert body with no condition key", async () => {
|
||
expect(() => parseFlow("steps:\n - assert: { selector: foo }\n")).toThrow(
|
||
/exactly one condition key/
|
||
);
|
||
});
|
||
|
||
it("rejects text sugar with neither contains nor equals", async () => {
|
||
expect(() => parseFlow("steps:\n - assert: { text: { in: counter } }\n")).toThrow(
|
||
/exactly one of `contains`, `equals`, or `matches`/
|
||
);
|
||
});
|
||
|
||
it("rejects text sugar with an empty contains", async () => {
|
||
expect(() =>
|
||
parseFlow('steps:\n - assert: { text: { in: counter, contains: "" } }\n')
|
||
).toThrow(/non-empty `contains`/);
|
||
});
|
||
|
||
it("serializes await/assert with the condition-as-key sugar (no condition: field)", async () => {
|
||
const yaml = serializeFlow({
|
||
executionPrerequisite: "",
|
||
steps: [
|
||
// loose ⇒ bare-string sugar; a strict { text } would keep the map form
|
||
{ kind: "assert", condition: "visible", selector: { text: "Welcome", loose: true } },
|
||
{
|
||
kind: "assert",
|
||
condition: "text",
|
||
selector: { identifier: "counter" },
|
||
expectedText: "Taps: 0",
|
||
textMatch: "contains",
|
||
},
|
||
{
|
||
kind: "assert",
|
||
condition: "text",
|
||
selector: { identifier: "total" },
|
||
expectedText: "1",
|
||
textMatch: "equals",
|
||
},
|
||
],
|
||
});
|
||
expect(yaml).toContain("visible: Welcome");
|
||
expect(yaml).not.toContain("condition:");
|
||
expect(yaml).toContain('contains: "Taps: 0"');
|
||
expect(yaml).toContain('equals: "1"');
|
||
expect(yaml).toContain("id: counter");
|
||
expect(yaml).not.toContain("identifier:");
|
||
// An assert never emits a `timeout` key (the parser would reject it back).
|
||
expect(yaml).not.toContain("timeout");
|
||
});
|
||
|
||
it.each([
|
||
["contains", "contains: Expected"],
|
||
["equals", "equals: Expected"],
|
||
["matches", "matches: Expected"],
|
||
] as const)("serializes and round-trips the %s text comparator", (textMatch, yamlComparator) => {
|
||
const step = {
|
||
kind: "assert" as const,
|
||
condition: "text" as const,
|
||
selector: { identifier: "status" },
|
||
expectedText: "Expected",
|
||
textMatch,
|
||
};
|
||
|
||
const yaml = serializeFlow({ executionPrerequisite: "", steps: [step] });
|
||
|
||
expect(yaml).toContain(yamlComparator);
|
||
expect(parseFlow(yaml).steps).toEqual([step]);
|
||
});
|
||
|
||
it("roundtrips the sugared step kinds through YAML", async () => {
|
||
// The spelling carries the loose bit exactly both ways: a LOOSE text-only
|
||
// selector serializes to a bare string (which parses back loose); a strict
|
||
// `{ text }` keeps the map form (which parses back strict). Identifier
|
||
// selectors keep the map form and stay strict.
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "",
|
||
steps: [
|
||
{ kind: "tap", selector: { text: "Login", loose: true } },
|
||
{ kind: "tap", selector: { text: "Save" } },
|
||
{ kind: "type", into: { text: "email", loose: true }, text: "a@b.com" },
|
||
{ kind: "type", into: { text: "Password" }, text: "hunter2" },
|
||
{ kind: "await", condition: "hidden", selector: { identifier: "spinner" } },
|
||
{ kind: "await", condition: "visible", selector: { text: "Welcome" } },
|
||
{ kind: "wait", ms: 500 },
|
||
{
|
||
kind: "assert",
|
||
condition: "text",
|
||
selector: { text: "Taps:", loose: true },
|
||
expectedText: "Taps: 0",
|
||
textMatch: "contains",
|
||
},
|
||
{
|
||
kind: "assert",
|
||
condition: "text",
|
||
selector: { identifier: "total" },
|
||
expectedText: "1",
|
||
textMatch: "equals",
|
||
},
|
||
{ kind: "scroll-to", target: { text: "Order #1234", loose: true }, direction: "down" },
|
||
{
|
||
kind: "scroll-to",
|
||
target: { text: "Summer Sale", loose: true },
|
||
direction: "right",
|
||
within: { identifier: "promotions" },
|
||
},
|
||
{
|
||
kind: "scroll-to",
|
||
target: { text: "Checkout" },
|
||
direction: "down",
|
||
within: { text: "Cart items" },
|
||
},
|
||
],
|
||
};
|
||
expect(parseFlow(serializeFlow(flow)).steps).toEqual(flow.steps);
|
||
});
|
||
|
||
it("keeps a strict { text } selector strict across repeated round-trips (never collapsed to a bare loose string)", async () => {
|
||
// The recorder derives strict `{ text }` selectors, and every recorded step
|
||
// re-reads and re-writes the whole file (appendStep) — so a single lossy
|
||
// serialization would silently promote them to loose, sending them through
|
||
// the identifier-first fallback they were never verified against.
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "",
|
||
steps: [{ kind: "tap", selector: { text: "Save" } }],
|
||
};
|
||
const once = serializeFlow(flow);
|
||
expect(once).toContain("text: Save");
|
||
expect(once).not.toContain("tap: Save");
|
||
const reparsed = parseFlow(once);
|
||
expect(reparsed.steps).toEqual(flow.steps); // no `loose` flag introduced
|
||
expect(parseFlow(serializeFlow(reparsed)).steps).toEqual(flow.steps);
|
||
});
|
||
|
||
it("sugars a bare-string scroll-to target and keeps the within map", async () => {
|
||
const flow = parseFlow(
|
||
["steps:", " - scroll-to: { target: Account, direction: down }"].join("\n")
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "scroll-to", target: { text: "Account", loose: true }, direction: "down" },
|
||
]);
|
||
});
|
||
|
||
it("parses a bare-number wait as milliseconds", async () => {
|
||
const flow = parseFlow("steps:\n - wait: 750\n");
|
||
expect(flow.steps).toEqual([{ kind: "wait", ms: 750 }]);
|
||
});
|
||
|
||
it("rejects a wait that is not a non-negative number", async () => {
|
||
expect(() => parseFlow("steps:\n - wait: soon\n")).toThrow("wait needs a non-negative number");
|
||
expect(() => parseFlow("steps:\n - wait: -5\n")).toThrow("wait needs a non-negative number");
|
||
});
|
||
|
||
it("parses an await timeout in milliseconds", async () => {
|
||
const flow = parseFlow("steps:\n - await: { visible: Account, timeout: 10000 }\n");
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "await",
|
||
condition: "visible",
|
||
selector: { text: "Account", loose: true },
|
||
timeout: 10000,
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("rejects an await timeout that is not a positive finite number", async () => {
|
||
// `.inf`, `.nan`, and an overflowing literal all parse to a typeof-number
|
||
// value; letting Infinity through would make the runner's poll deadline
|
||
// unreachable (an unbounded await).
|
||
for (const bad of ["soon", "0", "-5", ".inf", ".nan", "1e400"]) {
|
||
expect(() => parseFlow(`steps:\n - await: { visible: Account, timeout: ${bad} }\n`)).toThrow(
|
||
"await.timeout needs a positive number of milliseconds"
|
||
);
|
||
}
|
||
});
|
||
|
||
it("rejects a timeout on an assert step (an assert is an immediate check)", async () => {
|
||
// The internal assert step has no timeout field, so a YAML `timeout` used
|
||
// to be silently dropped; reject it loudly instead — a check that needs
|
||
// time to become true is a wait, spelled `await`.
|
||
expect(() => parseFlow("steps:\n - assert: { visible: Account, timeout: 9000 }\n")).toThrow(
|
||
/assert has no timeout/
|
||
);
|
||
expect(() =>
|
||
parseFlow('steps:\n - assert: { text: { in: counter, equals: "0" }, timeout: 5000 }\n')
|
||
).toThrow(/assert has no timeout/);
|
||
});
|
||
|
||
it("rejects a scroll-to with an invalid direction", async () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - scroll-to: { target: Account, direction: sideways }\n")
|
||
).toThrow("scroll-to direction must be one of");
|
||
});
|
||
|
||
it("defaults scroll-to direction to down", async () => {
|
||
const flow = parseFlow("steps:\n - scroll-to: { target: Account }\n");
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "scroll-to", target: { text: "Account", loose: true }, direction: "down" },
|
||
]);
|
||
});
|
||
|
||
it("parses a bare-string scroll-to as a down-scroll to that target", async () => {
|
||
const flow = parseFlow("steps:\n - scroll-to: Account\n");
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "scroll-to", target: { text: "Account", loose: true }, direction: "down" },
|
||
]);
|
||
});
|
||
|
||
it("serializes the default scroll-to back to the bare-string sugar", async () => {
|
||
const steps = [
|
||
{ kind: "scroll-to", target: { text: "Account", loose: true }, direction: "down" },
|
||
] as FlowFile["steps"];
|
||
const yaml = serializeFlow({ executionPrerequisite: "", steps });
|
||
expect(yaml).toContain("- scroll-to: Account");
|
||
expect(parseFlow(yaml).steps).toEqual(steps);
|
||
});
|
||
|
||
it("parses a bare-string snapshot as its name", async () => {
|
||
const flow = parseFlow("steps:\n - snapshot: home\n");
|
||
expect(flow.steps).toEqual([{ kind: "snapshot", name: "home" }]);
|
||
});
|
||
|
||
it("serializes a name-only snapshot as a bare string, keeps the map with maxMismatch", async () => {
|
||
const steps = [
|
||
{ kind: "snapshot", name: "home" },
|
||
{ kind: "snapshot", name: "cart", maxMismatch: 1.5 },
|
||
] as FlowFile["steps"];
|
||
const yaml = serializeFlow({ executionPrerequisite: "", steps });
|
||
expect(yaml).toContain("- snapshot: home");
|
||
expect(yaml).toContain("maxMismatch: 1.5");
|
||
expect(parseFlow(yaml).steps).toEqual(steps);
|
||
});
|
||
|
||
it("rejects a snapshot name that is not path-safe", async () => {
|
||
expect(() => parseFlow("steps:\n - snapshot: ../evil\n")).toThrow(/must match/);
|
||
});
|
||
|
||
it("accepts a string-number maxMismatch", async () => {
|
||
const flow = parseFlow('steps:\n - snapshot: { name: home, maxMismatch: "1.5" }\n');
|
||
expect(flow.steps).toEqual([{ kind: "snapshot", name: "home", maxMismatch: 1.5 }]);
|
||
});
|
||
|
||
it("rejects a non-numeric, negative, or out-of-range maxMismatch", async () => {
|
||
for (const bad of ['"5%"', "-1", "101", ".nan"]) {
|
||
expect(() =>
|
||
parseFlow(`steps:\n - snapshot: { name: home, maxMismatch: ${bad} }\n`)
|
||
).toThrow("snapshot maxMismatch must be a number between 0 and 100");
|
||
}
|
||
});
|
||
|
||
it("parses snapshot cropOn as a selector (bare-string loose, map strict)", async () => {
|
||
const flow = parseFlow(
|
||
"steps:\n" +
|
||
" - snapshot: { name: home, cropOn: Header }\n" +
|
||
" - snapshot: { name: cart, cropOn: { id: cart-total } }\n"
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "snapshot", name: "home", cropOn: { text: "Header", loose: true } },
|
||
{ kind: "snapshot", name: "cart", cropOn: { identifier: "cart-total" } },
|
||
]);
|
||
});
|
||
|
||
it("serializes snapshot cropOn in the map form and round-trips", async () => {
|
||
const steps = [
|
||
{ kind: "snapshot", name: "home", cropOn: { text: "Header", loose: true } },
|
||
{ kind: "snapshot", name: "cart", maxMismatch: 1.5, cropOn: { identifier: "cart-total" } },
|
||
] as FlowFile["steps"];
|
||
const yaml = serializeFlow({ executionPrerequisite: "", steps });
|
||
expect(yaml).toContain("cropOn: Header");
|
||
expect(parseFlow(yaml).steps).toEqual(steps);
|
||
});
|
||
|
||
it("rejects a point-form cropOn — a point has no extent to crop to", async () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - snapshot: { name: home, cropOn: { x: 0.5, y: 0.5 } }\n")
|
||
).toThrow(/snapshot\.cropOn: selector has unknown keys `x`, `y`/);
|
||
});
|
||
|
||
it("rejects a tap body mixing a selector with coordinates", async () => {
|
||
for (const key of ["id", "identifier"]) {
|
||
expect(() => parseFlow(`steps:\n - tap: { ${key}: box, x: 0.5, y: 0.5 }\n`)).toThrow(
|
||
"tap takes a selector or x/y coordinates, not both"
|
||
);
|
||
}
|
||
});
|
||
|
||
it("rejects a coordinate tap with a missing or non-numeric x/y", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { x: 0.5 }\n")).toThrow(
|
||
"tap: a coordinate target needs numeric x and y"
|
||
);
|
||
expect(() => parseFlow('steps:\n - tap: { x: "0.5", y: 0.5 }\n')).toThrow(
|
||
"tap: a coordinate target needs numeric x and y"
|
||
);
|
||
});
|
||
|
||
it("round-trips free-text values exactly, including whitespace-only lines", async () => {
|
||
// The parser stores every free-text field verbatim — `type.text`, `echo`,
|
||
// await/assert `contains`/`equals`, and `executionPrerequisite` are never
|
||
// trimmed — so serialization must be byte-exact too. Default yamlStringify
|
||
// emits multi-line values as block scalars, whose chomping silently strips
|
||
// whitespace-only lines on re-parse (" \n" came back as "\n"); serializeFlow
|
||
// disables blockQuote so these values round-trip via double-quoted escapes.
|
||
const values = [
|
||
"line1\nline2",
|
||
"line1\nline2 ", // trailing space on the final content line
|
||
" \n", // whitespace-only line — silently corrupted by a block scalar
|
||
" \n \n",
|
||
"\t\n",
|
||
" hi ",
|
||
"plain single line",
|
||
];
|
||
for (const value of values) {
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: value,
|
||
steps: [
|
||
{ kind: "echo", message: value },
|
||
{ kind: "type", into: { text: "email", loose: true }, text: value },
|
||
{
|
||
kind: "await",
|
||
condition: "text",
|
||
selector: { identifier: "counter" },
|
||
expectedText: value,
|
||
textMatch: "contains",
|
||
timeout: 5000,
|
||
},
|
||
{
|
||
kind: "assert",
|
||
condition: "text",
|
||
selector: { identifier: "total" },
|
||
expectedText: value,
|
||
textMatch: "equals",
|
||
},
|
||
],
|
||
};
|
||
expect(parseFlow(serializeFlow(flow))).toEqual(flow);
|
||
}
|
||
});
|
||
|
||
it("never serializes a whitespace-only-line value as a block scalar", async () => {
|
||
const steps = [{ kind: "echo", message: "step one \n \ndone" }] as FlowFile["steps"];
|
||
const yaml = serializeFlow({ executionPrerequisite: "", steps });
|
||
// Block (|) and folded (>) scalars are not round-trip-safe for this shape;
|
||
// the value must be emitted as a double-quoted flow scalar instead.
|
||
expect(yaml).not.toContain("|");
|
||
expect(yaml).not.toContain(">");
|
||
expect(yaml).toContain("\\n"); // newlines spelled as escapes inside quotes
|
||
expect(parseFlow(yaml).steps).toEqual(steps);
|
||
});
|
||
|
||
// Flows are hand-authored YAML, so a misspelled option key must fail at
|
||
// parse time — silently dropping it would apply the default instead and
|
||
// surface later as a misleading runtime failure (wrong scroll direction,
|
||
// lost submit opt-out, lost timeout, lost snapshot tolerance).
|
||
describe("unknown option keys are rejected at parse time", () => {
|
||
it("rejects a misspelled scroll-to direction key with a suggestion", async () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - scroll-to: { target: Order-1234, directon: up }\n")
|
||
).toThrow(/scroll-to has unknown key `directon` \(did you mean `direction`\?\)/);
|
||
});
|
||
|
||
it("rejects a misspelled type.submit key with a suggestion", async () => {
|
||
expect(() =>
|
||
parseFlow('steps:\n - type: { into: email, text: "a@b.com", sumbit: false }\n')
|
||
).toThrow(/type has unknown key `sumbit` \(did you mean `submit`\?\)/);
|
||
});
|
||
|
||
it("rejects a misspelled await.timeout key with a suggestion", async () => {
|
||
expect(() => parseFlow("steps:\n - await: { visible: Account, timeut: 10000 }\n")).toThrow(
|
||
/await has unknown key `timeut` \(did you mean `timeout`\?\)/
|
||
);
|
||
});
|
||
|
||
it("rejects a misspelled snapshot.maxMismatch key with a suggestion", async () => {
|
||
expect(() => parseFlow("steps:\n - snapshot: { name: home, maxMissmatch: 1.5 }\n")).toThrow(
|
||
/snapshot has unknown key `maxMissmatch` \(did you mean `maxMismatch`\?\)/
|
||
);
|
||
});
|
||
|
||
it("rejects a miscased snapshot.cropOn key with a suggestion", async () => {
|
||
expect(() => parseFlow("steps:\n - snapshot: { name: home, cropon: Header }\n")).toThrow(
|
||
/snapshot has unknown key `cropon` \(did you mean `cropOn`\?\)/
|
||
);
|
||
});
|
||
|
||
it("rejects an unknown key on a selector map", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { text: Save, roel: button }\n")).toThrow(
|
||
/tap: selector has unknown key `roel` \(did you mean `role`\?\)/
|
||
);
|
||
expect(() => parseFlow("steps:\n - await: { visible: { txt: Save } }\n")).toThrow(
|
||
/await.visible: selector has unknown key `txt` \(did you mean `text`\?\)/
|
||
);
|
||
expect(() =>
|
||
parseFlow("steps:\n - scroll-to: { target: { text: Row }, within: { identfier: list } }\n")
|
||
).toThrow(
|
||
/scroll-to.within: selector has unknown key `identfier` \(did you mean `identifier`\?\)/
|
||
);
|
||
});
|
||
|
||
it("rejects an unknown key without a suggestion when nothing is close", async () => {
|
||
expect(() => parseFlow("steps:\n - scroll-to: { target: Row, sideways: true }\n")).toThrow(
|
||
/scroll-to has unknown key `sideways` — allowed keys: target, direction, within/
|
||
);
|
||
});
|
||
|
||
it("rejects an unknown key in an await/assert text body", async () => {
|
||
expect(() =>
|
||
parseFlow('steps:\n - assert: { text: { in: counter, contians: "Taps: 0" } }\n')
|
||
).toThrow(/assert.text has unknown key `contians` \(did you mean `contains`\?\)/);
|
||
});
|
||
|
||
it("rejects a stray key on a coordinate tap", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { x: 0.5, y: 0.5, why: 0.6 }\n")).toThrow(
|
||
/tap: a coordinate target takes only \{ x, y \}/
|
||
);
|
||
});
|
||
|
||
it("rejects an unknown key in a launch map and its chromium value", async () => {
|
||
expect(() => parseFlow("steps:\n - launch: { amdroid: com.acme.app }\n")).toThrow(
|
||
/launch has unknown key `amdroid` \(did you mean `android`\?\)/
|
||
);
|
||
expect(() =>
|
||
parseFlow("steps:\n - launch: { chromium: { path: ./app, arg: [--e2e] } }\n")
|
||
).toThrow(/launch.chromium has unknown key `arg` \(did you mean `args`\?\)/);
|
||
});
|
||
|
||
it("rejects a step-level sibling key (options belong inside the directive value)", async () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - await: { visible: Account }\n timeout: 5000\n")
|
||
).toThrow(
|
||
/a `await` step has unknown key `timeout` — step options go inside the `await:` value/
|
||
);
|
||
});
|
||
|
||
it("rejects a step carrying two directive keys", async () => {
|
||
expect(() => parseFlow("steps:\n - echo: hi\n tap: Save\n")).toThrow(
|
||
/a step takes exactly one directive key, found `echo`, `tap`/
|
||
);
|
||
});
|
||
|
||
it("suggests the directive key for a misspelled step kind", async () => {
|
||
expect(() => parseFlow("steps:\n - snapshoot: home\n")).toThrow(
|
||
/unrecognized step kind \(did you mean `snapshot`\?\)/
|
||
);
|
||
});
|
||
|
||
it("rejects an unknown top-level flow file key", async () => {
|
||
expect(() =>
|
||
parseFlow("executionPrerequisit: Settings open\nsteps:\n - echo: hi\n")
|
||
).toThrow(/unknown key `executionPrerequisit` \(did you mean `executionPrerequisite`\?\)/);
|
||
});
|
||
});
|
||
|
||
it("roundtrips: serialize then parse", async () => {
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "App freshly loaded on home screen",
|
||
steps: [
|
||
{ kind: "echo", message: "Launch app" },
|
||
{ kind: "tool", name: "launch-app", args: { bundleId: "com.test" } },
|
||
{ kind: "tool", name: "tap", args: { x: 0.5, y: 0.3 } },
|
||
{ kind: "echo", message: "Done" },
|
||
],
|
||
};
|
||
const serialized = serializeFlow(flow);
|
||
expect(parseFlow(serialized)).toEqual(flow);
|
||
});
|
||
});
|
||
|
||
// ── chromium launch (app path) ───────────────────────────────────────
|
||
|
||
describe("chromium launch parsing", () => {
|
||
it("parses a chromium launch with a bare-string app path", async () => {
|
||
const flow = parseFlow("steps:\n - launch: { chromium: ./app }\n");
|
||
expect(flow.steps).toEqual([{ kind: "launch", app: { chromium: "./app" } }]);
|
||
});
|
||
|
||
it("parses a chromium launch with a { path, args } map", async () => {
|
||
const flow = parseFlow("steps:\n - launch: { chromium: { path: ./app, args: [--e2e] } }\n");
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "launch", app: { chromium: { path: "./app", args: ["--e2e"] } } },
|
||
]);
|
||
});
|
||
|
||
it("parses a mixed per-platform launch (ios id + chromium path)", async () => {
|
||
const flow = parseFlow("steps:\n - launch: { ios: com.acme.app, chromium: ./app }\n");
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "launch", app: { ios: "com.acme.app", chromium: "./app" } },
|
||
]);
|
||
});
|
||
|
||
it("round-trips a chromium { path, args } launch through YAML", async () => {
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "",
|
||
steps: [
|
||
{ kind: "launch", app: { chromium: { path: "/abs/app", args: ["--foo", "--bar"] } } },
|
||
],
|
||
};
|
||
expect(parseFlow(serializeFlow(flow)).steps).toEqual(flow.steps);
|
||
});
|
||
|
||
it("rejects a chromium map with no path", async () => {
|
||
expect(() => parseFlow("steps:\n - launch: { chromium: { args: [--e2e] } }\n")).toThrow(
|
||
/launch needs/
|
||
);
|
||
});
|
||
|
||
it("rejects a chromium map with non-string args", async () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - launch: { chromium: { path: ./app, args: [1, 2] } }\n")
|
||
).toThrow(/launch needs/);
|
||
});
|
||
});
|
||
|
||
describe("chromiumLaunchSpec", () => {
|
||
it("reads a bare-string launch as the app path", async () => {
|
||
expect(chromiumLaunchSpec("./app")).toEqual({ path: "./app" });
|
||
});
|
||
|
||
it("reads a chromium string value as the path", async () => {
|
||
expect(chromiumLaunchSpec({ chromium: "./app" })).toEqual({ path: "./app" });
|
||
});
|
||
|
||
it("reads a chromium { path, args } value", async () => {
|
||
expect(chromiumLaunchSpec({ chromium: { path: "./app", args: ["--e2e"] } })).toEqual({
|
||
path: "./app",
|
||
args: ["--e2e"],
|
||
});
|
||
});
|
||
|
||
it("returns null when no chromium target is declared", async () => {
|
||
expect(chromiumLaunchSpec({ ios: "com.acme.app" })).toBeNull();
|
||
expect(chromiumLaunchSpec(undefined)).toBeNull();
|
||
});
|
||
|
||
it("appIdForPlatform returns the chromium path (the runner's declared-target guard)", async () => {
|
||
expect(appIdForPlatform({ chromium: { path: "./app", args: ["--e2e"] } }, "chromium")).toBe(
|
||
"./app"
|
||
);
|
||
expect(appIdForPlatform({ chromium: "./app" }, "chromium")).toBe("./app");
|
||
expect(appIdForPlatform({ ios: "com.acme.app" }, "chromium")).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── native shorthand ─────────────────────────────────────────────────
|
||
|
||
describe("native launch shorthand", () => {
|
||
it("parses a native-only launch and round-trips it", async () => {
|
||
const flow = parseFlow("steps:\n - launch: { native: com.acme.app }\n");
|
||
expect(flow.steps).toEqual([{ kind: "launch", app: { native: "com.acme.app" } }]);
|
||
expect(parseFlow(serializeFlow(flow)).steps).toEqual(flow.steps);
|
||
});
|
||
|
||
it("parses native alongside a per-platform override and a chromium path", async () => {
|
||
const flow = parseFlow(
|
||
"steps:\n - launch: { native: com.acme.app, android: com.acme.app.debug, chromium: ./app }\n"
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "launch",
|
||
app: { native: "com.acme.app", android: "com.acme.app.debug", chromium: "./app" },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("rejects an empty native id", async () => {
|
||
expect(() => parseFlow('steps:\n - launch: { native: "" }\n')).toThrow(/launch needs/);
|
||
});
|
||
|
||
it("appIdForPlatform falls back to native for installed platforms, override wins", async () => {
|
||
const app = { native: "com.acme.app", android: "com.acme.app.debug" };
|
||
// native fills in for platforms without a specific key…
|
||
expect(appIdForPlatform(app, "ios")).toBe("com.acme.app");
|
||
expect(appIdForPlatform(app, "vega")).toBe("com.acme.app");
|
||
// …and a specific key overrides it.
|
||
expect(appIdForPlatform(app, "android")).toBe("com.acme.app.debug");
|
||
});
|
||
|
||
it("native never applies to chromium (chromium takes a path, not an id)", async () => {
|
||
expect(appIdForPlatform({ native: "com.acme.app" }, "chromium")).toBeNull();
|
||
expect(chromiumLaunchSpec({ native: "com.acme.app" })).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── Recording sessions ───────────────────────────────────────────────
|
||
|
||
// Recordings live in a map keyed by the resolved flow file path, so a session
|
||
// has no identity beyond (project_root, name) — two agents recording at once
|
||
// must never write into each other's take. What is isolated is the artifact,
|
||
// not the fact that a recording exists: the not-found message deliberately
|
||
// names the other live flows in the caller's own project and counts the rest,
|
||
// and the two cases below pin that disclosure as bounded rather than absent.
|
||
describe("recording sessions", () => {
|
||
beforeEach(() => {
|
||
__resetRecordingsForTesting();
|
||
});
|
||
|
||
const emptyFlow = (): FlowFile => ({ executionPrerequisite: "", steps: [] });
|
||
|
||
const start = (projectRoot: string, name: string, flow: FlowFile = emptyFlow()) =>
|
||
startRecordingSession({
|
||
name,
|
||
projectRoot,
|
||
persist: "host",
|
||
filePath: getFlowPath(projectRoot, name),
|
||
flow,
|
||
});
|
||
|
||
it("throws when the key has no recording", async () => {
|
||
await expect(requireRecordingSession("/tmp/proj-a", "my-flow")).rejects.toThrow(
|
||
/No active recording for flow "my-flow"/
|
||
);
|
||
});
|
||
|
||
it("classifies the not-found throw as FLOW_NO_ACTIVE_RECORDING", async () => {
|
||
let caught: unknown;
|
||
try {
|
||
await requireRecordingSession("/tmp/proj-a", "my-flow");
|
||
} catch (err) {
|
||
caught = err;
|
||
}
|
||
expect(getFailureSignal(caught)?.error_code).toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING);
|
||
});
|
||
|
||
it("names the asked-for key and this project's live recordings in the not-found message", async () => {
|
||
// With concurrent recordings the usual cause is a typo or the wrong
|
||
// project_root; the agent can only self-correct if it sees the live keys.
|
||
await start("/tmp/proj-a", "checkout");
|
||
await start("/tmp/proj-b", "login");
|
||
await expect(requireRecordingSession("/tmp/proj-a", "chekout")).rejects.toThrow(
|
||
/No active recording for flow "chekout" in \/tmp\/proj-a\./
|
||
);
|
||
await expect(requireRecordingSession("/tmp/proj-a", "chekout")).rejects.toThrow(
|
||
/Active recordings: "checkout" \(plus 1 in other projects\)\./
|
||
);
|
||
});
|
||
|
||
it("counts other projects' recordings without naming them", async () => {
|
||
// A tool-server bound beyond loopback serves unrelated callers; another
|
||
// project's flow names and absolute paths are not this caller's to see.
|
||
await start("/tmp/proj-b", "login");
|
||
await start("/tmp/proj-c", "secret-onboarding");
|
||
const message = await (async () => {
|
||
try {
|
||
await requireRecordingSession("/tmp/proj-a", "my-flow");
|
||
} catch (err) {
|
||
return (err as Error).message;
|
||
}
|
||
throw new Error("expected a throw");
|
||
})();
|
||
expect(message).toMatch(
|
||
/Active recordings: none in this project \(plus 2 in other projects\)\./
|
||
);
|
||
expect(message).not.toContain("login");
|
||
expect(message).not.toContain("secret-onboarding");
|
||
expect(message).not.toContain("/tmp/proj-b");
|
||
expect(message).not.toContain("/tmp/proj-c");
|
||
});
|
||
|
||
it("treats a differently-spelled but identical root as THIS project", async () => {
|
||
// The partition compares path.join-normalized flows dirs, not raw strings.
|
||
// A caller that spells its own root with a trailing slash must still be
|
||
// shown its own live recordings — a strict === would answer "none in this
|
||
// project (plus 1 in other projects)", degrading the message in exactly the
|
||
// wrong-project_root case it exists to diagnose. Every other test here
|
||
// spells both sides identically, so only this one separates the two.
|
||
await start("/tmp/proj-a", "checkout");
|
||
const message = await (async () => {
|
||
try {
|
||
await requireRecordingSession("/tmp/proj-a/", "chekout");
|
||
} catch (err) {
|
||
return (err as Error).message;
|
||
}
|
||
throw new Error("expected a throw");
|
||
})();
|
||
expect(message).toMatch(/Active recordings: "checkout"\./);
|
||
expect(message).not.toContain("other projects");
|
||
});
|
||
|
||
it("does not tell the agent to just call flow-start-recording", async () => {
|
||
// This message is reached for a key that was never started, but equally for
|
||
// one that was finished, superseded, or dropped by the concurrency cap —
|
||
// and in those cases the flow file on disk is fully populated. Naming
|
||
// flow-start-recording as the fix destroys it, because it truncates
|
||
// unconditionally and reports no `restarted` when no session was replaced.
|
||
const message = await (async () => {
|
||
try {
|
||
await requireRecordingSession("/tmp/proj-a", "finished-earlier");
|
||
} catch (err) {
|
||
return (err as Error).message;
|
||
}
|
||
throw new Error("expected a throw");
|
||
})();
|
||
expect(message).toContain("truncates");
|
||
expect(message).toMatch(/record under a fresh name|copy it aside/);
|
||
expect(message).not.toMatch(/Call flow-start-recording first/);
|
||
});
|
||
|
||
it('reports "none in this project" when nothing is being recorded', async () => {
|
||
await expect(requireRecordingSession("/tmp/proj-a", "my-flow")).rejects.toThrow(
|
||
/Active recordings: none in this project\./
|
||
);
|
||
});
|
||
|
||
it("returns the session that was started for that key", async () => {
|
||
await start("/tmp/proj-a", "my-flow");
|
||
const session = await requireRecordingSession("/tmp/proj-a", "my-flow");
|
||
expect(session.name).toBe("my-flow");
|
||
expect(session.projectRoot).toBe("/tmp/proj-a");
|
||
expect(session.persist).toBe("host");
|
||
expect(session.filePath).toBe(getFlowPath("/tmp/proj-a", "my-flow"));
|
||
});
|
||
|
||
it("getRecordingSession returns undefined for a key with no recording", async () => {
|
||
expect(await getRecordingSession("/tmp/proj-a", "my-flow")).toBeUndefined();
|
||
});
|
||
|
||
it("getRecordingSession returns the live session", async () => {
|
||
await start("/tmp/proj-a", "my-flow");
|
||
expect((await getRecordingSession("/tmp/proj-a", "my-flow"))?.name).toBe("my-flow");
|
||
});
|
||
|
||
it("clearRecordingSession removes only that key", async () => {
|
||
await start("/tmp/proj-a", "my-flow");
|
||
await start("/tmp/proj-a", "other-flow");
|
||
clearRecordingSession(await requireRecordingSession("/tmp/proj-a", "my-flow"));
|
||
expect(await getRecordingSession("/tmp/proj-a", "my-flow")).toBeUndefined();
|
||
await expect(requireRecordingSession("/tmp/proj-a", "my-flow")).rejects.toThrow(
|
||
/No active recording for flow "my-flow"/
|
||
);
|
||
// The unrelated recording is untouched.
|
||
expect((await requireRecordingSession("/tmp/proj-a", "other-flow")).name).toBe("other-flow");
|
||
});
|
||
|
||
it("clearRecordingSession deletes by the key the session HOLDS, not a fresh resolution", async () => {
|
||
// The same choice appendStepToFlow documents. Re-resolving the spelling
|
||
// looks up a key the map may no longer hold once the flow file's identity
|
||
// has moved under the session — a symlink repointed mid-recording — so the
|
||
// delete missed silently and the finish reported success while the session
|
||
// stayed live, unfinishable, and holding the key against its own restart.
|
||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clear-moved-key-"));
|
||
try {
|
||
const root = path.join(dir, "proj");
|
||
const flows = path.join(root, ".argent", "flows");
|
||
await fs.mkdir(flows, { recursive: true });
|
||
const first = path.join(dir, "first.yaml");
|
||
const second = path.join(dir, "second.yaml");
|
||
for (const f of [first, second]) await fs.writeFile(f, "steps: []\n", "utf8");
|
||
const link = path.join(flows, "shared.yaml");
|
||
await fs.symlink(first, link);
|
||
|
||
await startRecordingSession({
|
||
name: "shared",
|
||
projectRoot: root,
|
||
persist: "host",
|
||
filePath: link,
|
||
flow: emptyFlow(),
|
||
});
|
||
const session = (await getRecordingSession(root, "shared"))!;
|
||
|
||
await fs.rm(link);
|
||
await fs.symlink(second, link);
|
||
// The spelling now resolves to a different file entirely.
|
||
expect(await getRecordingSession(root, "shared")).toBeUndefined();
|
||
|
||
clearRecordingSession(session);
|
||
expect(listActiveRecordings()).toEqual([]);
|
||
} finally {
|
||
await fs.rm(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("keeps same-named recordings under different project roots independent", async () => {
|
||
await start("/tmp/proj-a", "my-flow", { executionPrerequisite: "A", steps: [] });
|
||
await start("/tmp/proj-b", "my-flow", { executionPrerequisite: "B", steps: [] });
|
||
expect(
|
||
(await requireRecordingSession("/tmp/proj-a", "my-flow")).flow.executionPrerequisite
|
||
).toBe("A");
|
||
expect(
|
||
(await requireRecordingSession("/tmp/proj-b", "my-flow")).flow.executionPrerequisite
|
||
).toBe("B");
|
||
// Finishing one leaves the other recording.
|
||
clearRecordingSession(await requireRecordingSession("/tmp/proj-a", "my-flow"));
|
||
expect(await getRecordingSession("/tmp/proj-a", "my-flow")).toBeUndefined();
|
||
expect(
|
||
(await requireRecordingSession("/tmp/proj-b", "my-flow")).flow.executionPrerequisite
|
||
).toBe("B");
|
||
});
|
||
|
||
it("returns null when starting a recording on a free key", async () => {
|
||
expect(await start("/tmp/proj-a", "my-flow")).toBeNull();
|
||
// A second, unrelated recording is the common concurrent case — not a replace.
|
||
expect(await start("/tmp/proj-a", "other-flow")).toBeNull();
|
||
expect(await start("/tmp/proj-b", "my-flow")).toBeNull();
|
||
});
|
||
|
||
it("returns the replaced session when re-recording the same key", async () => {
|
||
await start("/tmp/proj-a", "my-flow", { executionPrerequisite: "first take", steps: [] });
|
||
const replaced = await start("/tmp/proj-a", "my-flow", {
|
||
executionPrerequisite: "second take",
|
||
steps: [],
|
||
});
|
||
expect(replaced?.flow.executionPrerequisite).toBe("first take");
|
||
// The later take wins — one key, one writer.
|
||
expect(
|
||
(await requireRecordingSession("/tmp/proj-a", "my-flow")).flow.executionPrerequisite
|
||
).toBe("second take");
|
||
});
|
||
|
||
it("evicts the least recently USED recording, not the oldest one", async () => {
|
||
// The cap is a leak backstop, but which entry it drops matters: evicting a
|
||
// recording an agent is actively using would strand its steps. Fill past
|
||
// the cap, touching the first-registered key just before the overflow — it
|
||
// must survive and the untouched next-oldest must go. A FIFO eviction fails
|
||
// this deterministically.
|
||
//
|
||
// It does NOT reliably catch an LRU keyed on a millisecond clock: that only
|
||
// ties when the whole fill and the touch land inside one millisecond, which
|
||
// holds when this file runs alone but not under full-suite load. The
|
||
// counter's tie-freedom is argued at `touch()` rather than pinned here.
|
||
const cap = MAX_RECORDINGS;
|
||
for (let i = 0; i < cap; i++) await start("/tmp/proj-a", `flow-${i}`);
|
||
expect(listActiveRecordings()).toHaveLength(cap);
|
||
|
||
await requireRecordingSession("/tmp/proj-a", "flow-0"); // now most-recently-used
|
||
await start("/tmp/proj-a", "overflow");
|
||
|
||
const live = new Set(listActiveRecordings().map((r) => r.name));
|
||
expect(live.size).toBe(cap);
|
||
expect(live.has("flow-0")).toBe(true); // touched, so kept
|
||
expect(live.has("flow-1")).toBe(false); // untouched and now the oldest use
|
||
expect(live.has("overflow")).toBe(true);
|
||
});
|
||
|
||
it("listActiveRecordings reflects what is live", async () => {
|
||
expect(listActiveRecordings()).toEqual([]);
|
||
await start("/tmp/proj-a", "my-flow", {
|
||
executionPrerequisite: "",
|
||
steps: [{ kind: "echo", message: "hi" }],
|
||
});
|
||
await start("/tmp/proj-b", "my-flow");
|
||
expect(listActiveRecordings()).toEqual([
|
||
{ name: "my-flow", projectRoot: "/tmp/proj-a", steps: 1 },
|
||
{ name: "my-flow", projectRoot: "/tmp/proj-b", steps: 0 },
|
||
]);
|
||
clearRecordingSession(await requireRecordingSession("/tmp/proj-a", "my-flow"));
|
||
expect(listActiveRecordings()).toEqual([
|
||
{ name: "my-flow", projectRoot: "/tmp/proj-b", steps: 0 },
|
||
]);
|
||
__resetRecordingsForTesting();
|
||
expect(listActiveRecordings()).toEqual([]);
|
||
});
|
||
|
||
it("keys a session by the normalized flow path, so a trailing slash rejoins it", async () => {
|
||
await start("/tmp/proj-a", "my-flow");
|
||
expect((await requireRecordingSession("/tmp/proj-a/", "my-flow")).name).toBe("my-flow");
|
||
expect(await start("/tmp/proj-a/", "my-flow")).not.toBeNull();
|
||
expect(listActiveRecordings()).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
// ── getFlowPath name validation ──────────────────────────────────────
|
||
|
||
describe("getFlowPath name validation", () => {
|
||
// Pure path math over two explicit inputs — the root is a parameter, never
|
||
// shared state, so two callers naming two projects can never collide.
|
||
const root = "/tmp/argent-flow-name-test";
|
||
|
||
it("accepts plain alphanumeric names", async () => {
|
||
expect(getFlowPath(root, "my-flow_1")).toBe(
|
||
path.join(root, ".argent", "flows", "my-flow_1.yaml")
|
||
);
|
||
});
|
||
|
||
it("normalizes a trailing slash on the project root", async () => {
|
||
// The flow path doubles as the recording-session key: a trailing slash must
|
||
// not mint a second identity for the same file.
|
||
expect(getFlowPath("/tmp/x/", "f")).toBe(getFlowPath("/tmp/x", "f"));
|
||
});
|
||
|
||
it("rejects path-traversal segments", async () => {
|
||
expect(() => getFlowPath(root, "../../etc/passwd")).toThrow(/Invalid flow name/);
|
||
expect(() => getFlowPath(root, "../foo")).toThrow(/Invalid flow name/);
|
||
});
|
||
|
||
it("rejects path separators", async () => {
|
||
expect(() => getFlowPath(root, "foo/bar")).toThrow(/Invalid flow name/);
|
||
expect(() => getFlowPath(root, "/abs/path")).toThrow(/Invalid flow name/);
|
||
});
|
||
|
||
it("rejects names with spaces or shell metacharacters", async () => {
|
||
expect(() => getFlowPath(root, "foo bar")).toThrow(/Invalid flow name/);
|
||
expect(() => getFlowPath(root, "foo;bar")).toThrow(/Invalid flow name/);
|
||
expect(() => getFlowPath(root, "foo$(id)")).toThrow(/Invalid flow name/);
|
||
});
|
||
|
||
it("rejects empty names", async () => {
|
||
expect(() => getFlowPath(root, "")).toThrow(/Invalid flow name/);
|
||
});
|
||
});
|
||
|
||
// PR #194 follow-up C: project_root must be absolute AND free of ".."
|
||
// segments (path.join collapses ".." and would relocate the flows dir).
|
||
describe("assertValidProjectRoot validation", () => {
|
||
it("rejects a relative project_root", async () => {
|
||
expect(() => assertValidProjectRoot("relative/path")).toThrow(/absolute path/);
|
||
});
|
||
|
||
it('rejects an absolute project_root containing ".." segments', async () => {
|
||
expect(() => assertValidProjectRoot("/a/../../../etc")).toThrow(/must not contain "\.\."/);
|
||
expect(() => assertValidProjectRoot("/home/user/../../root")).toThrow(
|
||
/must not contain "\.\."/
|
||
);
|
||
});
|
||
|
||
it("accepts a clean absolute project_root", async () => {
|
||
expect(() => assertValidProjectRoot("/tmp/argent-pr194-c-test")).not.toThrow();
|
||
});
|
||
});
|
||
|
||
// ── within (descendant) selector scoping ─────────────────────────────
|
||
|
||
describe("within selector scoping", () => {
|
||
it("parses a within scope on a tap selector", async () => {
|
||
const flow = parseFlow("steps:\n - tap: { text: Delete, within: { id: profile-card } }\n");
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "tap",
|
||
selector: { text: "Delete", within: { identifier: "profile-card" } },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("a bare-string within stays loose (identifier-first, then text)", async () => {
|
||
const flow = parseFlow("steps:\n - tap: { text: Delete, within: profile-card }\n");
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "tap",
|
||
selector: { text: "Delete", within: { text: "profile-card", loose: true } },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("within chains outward and round-trips exactly", async () => {
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "",
|
||
steps: [
|
||
{
|
||
kind: "tap",
|
||
selector: {
|
||
text: "Delete",
|
||
within: { identifier: "cards", within: { text: "Settings" } },
|
||
},
|
||
},
|
||
{
|
||
kind: "await",
|
||
condition: "visible",
|
||
selector: { text: "Saved", within: { text: "toast-area", loose: true } },
|
||
},
|
||
{
|
||
kind: "assert",
|
||
condition: "text",
|
||
selector: { identifier: "total", within: { role: "AXTable" } },
|
||
expectedText: "Total: 3",
|
||
textMatch: "equals",
|
||
},
|
||
],
|
||
};
|
||
expect(parseFlow(serializeFlow(flow))).toEqual(flow);
|
||
});
|
||
|
||
it("serializes a loose within back to its bare-string spelling", async () => {
|
||
const yaml = serializeFlow({
|
||
executionPrerequisite: "",
|
||
steps: [
|
||
{
|
||
kind: "tap",
|
||
selector: { text: "Delete", within: { text: "profile-card", loose: true } },
|
||
},
|
||
],
|
||
});
|
||
expect(yaml).toContain("within: profile-card");
|
||
});
|
||
|
||
it("accepts the regex text matcher inside a within scope", async () => {
|
||
const flow = parseFlow(
|
||
"steps:\n - assert: { visible: { text: Delete, within: { text: { matches: '^Card \\d+$' } } } }\n"
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "assert",
|
||
condition: "visible",
|
||
selector: { text: "Delete", within: { textMatches: "^Card \\d+$" } },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("rejects a selector that is ONLY a within scope", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { within: { id: card } }\n")).toThrow(
|
||
/still needs its own text\/id\/role/
|
||
);
|
||
});
|
||
|
||
it("rejects unknown keys inside a within scope, naming the nested slot", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { text: Delete, within: { idd: card } }\n")).toThrow(
|
||
/tap\.within: selector has unknown key `idd` \(did you mean `id`\?\)/
|
||
);
|
||
});
|
||
|
||
it("rejects id+identifier both set inside a within scope", async () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - tap: { text: A, within: { id: x, identifier: x } }\n")
|
||
).toThrow(/`id` or `identifier` \(its alias\), not both/);
|
||
});
|
||
|
||
it("rejects a cyclic within alias via the depth cap", async () => {
|
||
const yaml = "steps:\n - tap: &s { text: Delete, within: *s }\n";
|
||
expect(() => parseFlow(yaml)).toThrow(/nest deeper than|cyclic YAML alias/);
|
||
});
|
||
|
||
it("rejects a within selector mixed with coordinates", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { within: { id: card }, x: 0.5, y: 0.5 }\n")).toThrow(
|
||
/takes a selector or x\/y coordinates, not both/
|
||
);
|
||
});
|
||
|
||
it("rejects a within key beside the tap options form", async () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - tap: { on: Photo, times: 2, within: { id: card } }\n")
|
||
).toThrow(/the tap options form takes a nested selector/);
|
||
});
|
||
|
||
it("within works in scroll-to's target while scroll-to's own within stays the container anchor", async () => {
|
||
const flow = parseFlow(
|
||
[
|
||
"steps:",
|
||
" - scroll-to:",
|
||
" target: { text: Delete, within: { id: cards } }",
|
||
" direction: down",
|
||
" within: { id: settings-list }",
|
||
].join("\n") + "\n"
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "scroll-to",
|
||
target: { text: "Delete", within: { identifier: "cards" } },
|
||
direction: "down",
|
||
within: { identifier: "settings-list" },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("describeSelector renders the scope chain in parentheses", async () => {
|
||
expect(
|
||
describeSelector({
|
||
text: "Delete",
|
||
within: { identifier: "cards", within: { text: "Settings" } },
|
||
})
|
||
).toBe('text="Delete" within (id="cards" within (text="Settings"))');
|
||
});
|
||
|
||
it("when guards reject a {{secret:…}} placeholder hidden in a within scope", async () => {
|
||
expect(() =>
|
||
parseFlow(
|
||
[
|
||
"steps:",
|
||
" - when: { visible: { text: Delete, within: { text: '{{secret:TOKEN}}' } } }",
|
||
" steps:",
|
||
" - echo: hi",
|
||
].join("\n") + "\n"
|
||
)
|
||
).toThrow(/secret/);
|
||
});
|
||
});
|
||
|
||
// ── sibling scopes (`after`/`next`) and the `any` universal selector ──
|
||
|
||
describe("sibling selector scopes and the universal selector", () => {
|
||
it("parses `after` (CSS ~) and `next` (CSS +) scopes", async () => {
|
||
const flow = parseFlow(
|
||
[
|
||
"steps:",
|
||
" - tap: { role: Switch, next: { text: Wi-Fi } }",
|
||
" - assert: { visible: { role: Button, after: { text: Danger zone } } }",
|
||
].join("\n") + "\n"
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "tap", selector: { role: "Switch", next: { text: "Wi-Fi" } } },
|
||
{
|
||
kind: "assert",
|
||
condition: "visible",
|
||
selector: { role: "Button", after: { text: "Danger zone" } },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("parses `any: true` paired with a scope and round-trips exactly", async () => {
|
||
const yaml =
|
||
[
|
||
"steps:",
|
||
" - tap: { any: true, next: { text: Airplane }, within: { id: row } }",
|
||
" - assert: { hidden: { any: true, within: { id: empty-state } } }",
|
||
].join("\n") + "\n";
|
||
const flow = parseFlow(yaml);
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "tap",
|
||
selector: { any: true, within: { identifier: "row" }, next: { text: "Airplane" } },
|
||
},
|
||
{
|
||
kind: "assert",
|
||
condition: "hidden",
|
||
selector: { any: true, within: { identifier: "empty-state" } },
|
||
},
|
||
]);
|
||
expect(parseFlow(serializeFlow(flow))).toEqual(flow);
|
||
});
|
||
|
||
it("a bare-string sibling scope stays loose, and serializes back to the bare spelling", async () => {
|
||
const flow = parseFlow("steps:\n - tap: { role: Switch, next: wifi-row }\n");
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "tap", selector: { role: "Switch", next: { text: "wifi-row", loose: true } } },
|
||
]);
|
||
expect(serializeFlow(flow)).toContain("next: wifi-row");
|
||
expect(parseFlow(serializeFlow(flow))).toEqual(flow);
|
||
});
|
||
|
||
it("scopes combine and nest, round-tripping through YAML", async () => {
|
||
const flow = parseFlow(
|
||
[
|
||
"steps:",
|
||
" - tap:",
|
||
" role: Button",
|
||
" within: { id: cards }",
|
||
" after: { text: Danger, within: { id: cards } }",
|
||
].join("\n") + "\n"
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "tap",
|
||
selector: {
|
||
role: "Button",
|
||
within: { identifier: "cards" },
|
||
after: { text: "Danger", within: { identifier: "cards" } },
|
||
},
|
||
},
|
||
]);
|
||
expect(parseFlow(serializeFlow(flow))).toEqual(flow);
|
||
});
|
||
|
||
it("accepts the regex text matcher inside a sibling scope", async () => {
|
||
const flow = parseFlow(
|
||
"steps:\n - tap: { role: Switch, next: { text: { matches: '^Row \\d+$' } } }\n"
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "tap", selector: { role: "Switch", next: { textMatches: "^Row \\d+$" } } },
|
||
]);
|
||
});
|
||
|
||
it("rejects a selector that is ONLY a sibling scope", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { after: { text: Danger } }\n")).toThrow(
|
||
/`after` only scopes where to look — the selector still needs its own text\/id\/role/
|
||
);
|
||
});
|
||
|
||
it("rejects `any: true` alongside the fields it would make redundant", async () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - tap: { any: true, role: Button, next: { text: Wi-Fi } }\n")
|
||
).toThrow(/already matches every element — drop it, or drop the `role`/);
|
||
});
|
||
|
||
it("rejects a bare `any: true` with no scope to narrow it", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { any: true }\n")).toThrow(
|
||
/matches every element on screen — pair it with a scope \(within\/after\/next\)/
|
||
);
|
||
});
|
||
|
||
it("rejects a non-`true` any value rather than reading it as a locator", async () => {
|
||
// Falsy AND truthy: a truthiness check would wave `any: 1` / `any: yes`
|
||
// through as the universal selector — a spelling no reader can predict and
|
||
// the serializer cannot reproduce.
|
||
for (const value of ["false", "1", "yes", "'true'", "0"]) {
|
||
expect(() => parseFlow(`steps:\n - tap: { any: ${value}, within: { id: card } }\n`)).toThrow(
|
||
/`any` takes only `true`/
|
||
);
|
||
}
|
||
});
|
||
|
||
it("rejects unknown keys inside a sibling scope, naming the nested slot", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { role: Switch, next: { roel: Button } }\n")).toThrow(
|
||
/tap\.next: selector has unknown key `roel` \(did you mean `role`\?\)/
|
||
);
|
||
});
|
||
|
||
it("rejects a cyclic sibling alias via the scope budget", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: &s { text: Delete, after: *s }\n")).toThrow(
|
||
/more than \d+ scopes|cyclic YAML alias/
|
||
);
|
||
});
|
||
|
||
it("bounds a selector's whole scope TREE, not just its depth", async () => {
|
||
// Three relations per level means a depth cap alone still admits 3^depth
|
||
// scopes — and the runner expands one alternative per combination of
|
||
// bare-string scopes, so a few hundred bytes of YAML would exhaust the heap
|
||
// before any tree is ever read. Six scopes down one branch is fine; the
|
||
// same six spread across branches must cost the same budget.
|
||
const chain = (n: number): string =>
|
||
Array.from({ length: n }, () => "{ id: c, within: ").join("") +
|
||
"{ id: leaf }" +
|
||
Array.from({ length: n }, () => " }").join("");
|
||
expect(() => parseFlow(`steps:\n - tap: { text: T, within: ${chain(5)} }\n`)).not.toThrow();
|
||
expect(() => parseFlow(`steps:\n - tap: { text: T, within: ${chain(6)} }\n`)).toThrow(
|
||
/more than 6 scopes/
|
||
);
|
||
// The branching form the depth cap alone would have let through: 3 + 9 = 12
|
||
// scopes across two levels, all shallow.
|
||
const branch = "{ id: b, within: x, after: y, next: z }";
|
||
expect(() =>
|
||
parseFlow(
|
||
`steps:\n - tap: { text: T, within: ${branch}, after: ${branch}, next: ${branch} }\n`
|
||
)
|
||
).toThrow(/more than 6 scopes/);
|
||
});
|
||
|
||
it("serializeFlow refuses an `any` selector the parser would reject on read-back", async () => {
|
||
// appendStep re-parses the whole file on every recorded step, so a selector
|
||
// that violates the parser's `any` rules must fail where it was built, not
|
||
// on some later append.
|
||
const step = (selector: Record<string, unknown>): Parameters<typeof serializeFlow>[0] => ({
|
||
executionPrerequisite: "",
|
||
steps: [{ kind: "tap", selector } as never],
|
||
});
|
||
expect(() => serializeFlow(step({ any: true }))).toThrow(/needs a scope/);
|
||
expect(() =>
|
||
serializeFlow(step({ any: true, text: "Save", within: { identifier: "c" } }))
|
||
).toThrow(/cannot be combined with text\/id\/role/);
|
||
expect(() => serializeFlow(step({ any: false, within: { identifier: "c" } }))).toThrow(
|
||
/takes only `true`/
|
||
);
|
||
expect(() => serializeFlow(step({ within: { identifier: "c" } }))).toThrow(
|
||
/only narrows where to look/
|
||
);
|
||
// The legal shape still round-trips.
|
||
const yaml = serializeFlow(step({ any: true, within: { identifier: "c" } }));
|
||
expect(parseFlow(yaml).steps).toEqual([
|
||
{ kind: "tap", selector: { any: true, within: { identifier: "c" } } },
|
||
]);
|
||
});
|
||
|
||
it("rejects a sibling-scoped selector mixed with coordinates or tap options", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { after: { id: card }, x: 0.5, y: 0.5 }\n")).toThrow(
|
||
/takes a selector or x\/y coordinates, not both/
|
||
);
|
||
expect(() =>
|
||
parseFlow("steps:\n - tap: { on: Photo, times: 2, next: { id: card } }\n")
|
||
).toThrow(/the tap options form takes a nested selector/);
|
||
});
|
||
|
||
it("a loose bare-string selector cannot carry a scope through serialization", async () => {
|
||
expect(() =>
|
||
serializeFlow({
|
||
executionPrerequisite: "",
|
||
steps: [
|
||
{ kind: "tap", selector: { text: "Delete", loose: true, after: { identifier: "hdr" } } },
|
||
],
|
||
})
|
||
).toThrow(/incompatible fields: after/);
|
||
});
|
||
|
||
it("names the missing scroll-to target instead of leaking a schema message", async () => {
|
||
// `within` is a selector key now, so this body reads like a scoped selector
|
||
// — it is actually the options map, missing its target.
|
||
for (const body of ["{ within: { id: list } }", "{ direction: up }", "{}"]) {
|
||
expect(() => parseFlow(`steps:\n - scroll-to: ${body}\n`)).toThrow(
|
||
/scroll-to needs a `target`/
|
||
);
|
||
}
|
||
// The sibling scopes are not step options at all.
|
||
expect(() => parseFlow("steps:\n - scroll-to: { target: Row, after: { id: hdr } }\n")).toThrow(
|
||
/unknown key `after` — allowed keys: target, direction, within/
|
||
);
|
||
// ...while they are welcome inside the target.
|
||
expect(() =>
|
||
parseFlow(
|
||
"steps:\n - scroll-to: { target: { text: Delete, after: { id: hdr } }, within: { id: list } }\n"
|
||
)
|
||
).not.toThrow();
|
||
});
|
||
|
||
it("describeSelector renders each scope, and `*` for the universal selector", async () => {
|
||
expect(describeSelector({ role: "Switch", next: { text: "Wi-Fi" } })).toBe(
|
||
'role="Switch" next (text="Wi-Fi")'
|
||
);
|
||
expect(
|
||
describeSelector({ any: true, within: { identifier: "row" }, after: { text: "Name" } })
|
||
).toBe('* within (id="row") after (text="Name")');
|
||
});
|
||
|
||
it("when guards reject a {{secret:…}} placeholder hidden in ANY scope", async () => {
|
||
// Every relation, so no branch of the walk can be skipped unnoticed.
|
||
for (const scope of ["within", "after", "next"]) {
|
||
expect(() =>
|
||
parseFlow(
|
||
[
|
||
"steps:",
|
||
` - when: { visible: { role: Switch, ${scope}: { text: '{{secret:TOKEN}}' } } }`,
|
||
" steps:",
|
||
" - echo: hi",
|
||
].join("\n") + "\n"
|
||
)
|
||
).toThrow(/secret/);
|
||
}
|
||
// ...including one buried two relations deep.
|
||
expect(() =>
|
||
parseFlow(
|
||
[
|
||
"steps:",
|
||
" - when: { visible: { role: Switch, after: { id: row, within: { text: '{{secret:TOKEN}}' } } } }",
|
||
" steps:",
|
||
" - echo: hi",
|
||
].join("\n") + "\n"
|
||
)
|
||
).toThrow(/secret/);
|
||
});
|
||
});
|
||
|
||
// ── countStepsOnDisk ─────────────────────────────────────────────────
|
||
|
||
// The count `flow-start-recording` reports for a take it is about to truncate.
|
||
// Its contract is the distinction between "0 steps" and "no answer": an empty
|
||
// take really did hold nothing, while an unreadable one is a loss of unknown
|
||
// size, and reporting the first for the second understates it in exactly the
|
||
// case that produced it.
|
||
describe("countStepsOnDisk", () => {
|
||
let dir: string;
|
||
|
||
beforeEach(async () => {
|
||
dir = await fs.mkdtemp(path.join(os.tmpdir(), "count-steps-"));
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await fs.rm(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
const write = async (content: string) => {
|
||
const file = path.join(dir, "flow.yaml");
|
||
await fs.writeFile(file, content, "utf8");
|
||
return file;
|
||
};
|
||
|
||
it("counts the steps a readable flow file holds", async () => {
|
||
const file = await write(
|
||
serializeFlow({
|
||
executionPrerequisite: "",
|
||
steps: [
|
||
{ kind: "echo", message: "one" },
|
||
{ kind: "echo", message: "two" },
|
||
{ kind: "echo", message: "three" },
|
||
],
|
||
})
|
||
);
|
||
expect(await countStepsOnDisk(file)).toBe(3);
|
||
});
|
||
|
||
it("counts an empty take as 0, which is a real answer", async () => {
|
||
const file = await write(serializeFlow({ executionPrerequisite: "", steps: [] }));
|
||
expect(await countStepsOnDisk(file)).toBe(0);
|
||
});
|
||
|
||
it("returns undefined for a file that does not exist", async () => {
|
||
expect(await countStepsOnDisk(path.join(dir, "absent.yaml"))).toBeUndefined();
|
||
});
|
||
|
||
it("returns undefined rather than 0 for YAML the parser rejects", async () => {
|
||
// A hand-edit can leave this behind, and `parseFlow("")` returning an empty
|
||
// flow with no error is the reason 0 cannot double as "unknown".
|
||
const file = await write("steps: [ this: is: not: a: flow\n");
|
||
expect(await countStepsOnDisk(file)).toBeUndefined();
|
||
});
|
||
|
||
it("returns undefined for a directory in the file's place", async () => {
|
||
const asDir = path.join(dir, "flow-dir.yaml");
|
||
await fs.mkdir(asDir);
|
||
expect(await countStepsOnDisk(asDir)).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe("writeFlowFile failure hints", () => {
|
||
let root: string;
|
||
|
||
beforeEach(async () => {
|
||
root = await fs.mkdtemp(path.join(os.tmpdir(), "flow-write-hint-"));
|
||
});
|
||
|
||
afterEach(async () => {
|
||
// Restore write permission first, or the recursive rm cannot descend.
|
||
for (const dir of [path.join(root, "vault"), path.join(root, ".argent", "flows")]) {
|
||
await fs.chmod(dir, 0o755).catch(() => {});
|
||
}
|
||
await fs.rm(root, { recursive: true, force: true });
|
||
});
|
||
|
||
/** Whether this process can be denied by mode bits at all (root cannot). */
|
||
async function modeBitsBite(dir: string): Promise<boolean> {
|
||
await fs.chmod(dir, 0o555);
|
||
const probe = path.join(dir, ".probe");
|
||
const denied = await fs
|
||
.writeFile(probe, "x", "utf8")
|
||
.then(() => false)
|
||
.catch(() => true);
|
||
if (!denied) await fs.rm(probe, { force: true });
|
||
return denied;
|
||
}
|
||
|
||
it("does not call the flow file a symlink when only an ANCESTOR is one", async () => {
|
||
// On macOS the temp dir is reached through /var -> /private/var, so the
|
||
// resolved swap directory differs from the spelled one for a flow file that
|
||
// is a perfectly ordinary regular file. Comparing the two spellings made
|
||
// every such failure claim a symlink and then contrast one directory with
|
||
// itself.
|
||
const flowsDir = path.join(root, ".argent", "flows");
|
||
await fs.mkdir(flowsDir, { recursive: true });
|
||
if (!(await modeBitsBite(flowsDir))) return;
|
||
|
||
const err = await writeNewFlowFile(path.join(flowsDir, "x.yaml"), "steps: []\n").catch(
|
||
(e: unknown) => e
|
||
);
|
||
|
||
const message = (err as Error).message;
|
||
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_FILE_WRITE_FAILED);
|
||
expect(message).toContain("must be writable");
|
||
expect(message).not.toMatch(/is a symlink/);
|
||
});
|
||
|
||
it("blames the name length, not the directory, on ENAMETOOLONG", async () => {
|
||
// The arm the hint was split for: an over-long flow name comes out of
|
||
// `rename` (the scratch name is short), and reporting it as a
|
||
// directory-permissions problem sent the reader looking for one that is not
|
||
// there.
|
||
const flowsDir = path.join(root, ".argent", "flows");
|
||
await fs.mkdir(flowsDir, { recursive: true });
|
||
|
||
const err = await writeNewFlowFile(
|
||
path.join(flowsDir, `${"n".repeat(400)}.yaml`),
|
||
"steps: []\n"
|
||
).catch((e: unknown) => e);
|
||
|
||
const message = (err as Error).message;
|
||
expect(message).toContain("(ENAMETOOLONG)");
|
||
expect(message).toContain("use a shorter name");
|
||
expect(message).not.toContain("must be writable");
|
||
});
|
||
|
||
it("names the missing VAULT directory when the link points into one", async () => {
|
||
// ENOENT out of the scratch write, in the directory the swap actually uses.
|
||
// Naming `.argent/flows` here would point at a directory that exists.
|
||
const flowsDir = path.join(root, ".argent", "flows");
|
||
await fs.mkdir(flowsDir, { recursive: true });
|
||
const absentVault = path.join(root, "no-such-vault");
|
||
await fs.symlink(path.join(absentVault, "shared.yaml"), path.join(flowsDir, "shared.yaml"));
|
||
|
||
const err = await writeNewFlowFile(path.join(flowsDir, "shared.yaml"), "steps: []\n").catch(
|
||
(e: unknown) => e
|
||
);
|
||
|
||
const message = (err as Error).message;
|
||
expect(message).toContain("(ENOENT)");
|
||
expect(message).toContain(`${absentVault} does not exist`);
|
||
expect(message).toContain("shared.yaml is a symlink");
|
||
});
|
||
|
||
it("still points at the vault when the flow file really is a symlink", async () => {
|
||
// The case the clause exists for: naming `.argent/flows` here would send the
|
||
// reader to a directory that is already writable while the vault, the only
|
||
// unwritable thing in the picture, went unmentioned.
|
||
const flowsDir = path.join(root, ".argent", "flows");
|
||
const vault = path.join(root, "vault");
|
||
await fs.mkdir(flowsDir, { recursive: true });
|
||
await fs.mkdir(vault, { recursive: true });
|
||
await fs.writeFile(path.join(vault, "shared.yaml"), "steps: []\n", "utf8");
|
||
await fs.symlink(path.join(vault, "shared.yaml"), path.join(flowsDir, "shared.yaml"));
|
||
if (!(await modeBitsBite(vault))) return;
|
||
|
||
const err = await writeNewFlowFile(path.join(flowsDir, "shared.yaml"), "steps: []\n").catch(
|
||
(e: unknown) => e
|
||
);
|
||
|
||
const message = (err as Error).message;
|
||
expect(message).toContain("shared.yaml is a symlink, so the write lands in");
|
||
expect(message).toContain(await fs.realpath(vault));
|
||
});
|
||
});
|
||
|
||
describe("flow file permissions across an atomic append", () => {
|
||
let root: string;
|
||
|
||
beforeEach(async () => {
|
||
root = await fs.mkdtemp(path.join(os.tmpdir(), "flow-mode-"));
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await fs.rm(root, { recursive: true, force: true });
|
||
});
|
||
|
||
/** Whether mode bits can refuse this process at all (root ignores them). */
|
||
async function modeBitsBite(file: string): Promise<boolean> {
|
||
return fs
|
||
.access(file, fsConstants.W_OK)
|
||
.then(() => false)
|
||
.catch(() => true);
|
||
}
|
||
|
||
it("carries the flow file's mode across the swap", async () => {
|
||
// The scratch file is created under the process umask and rename carries
|
||
// ITS mode over, so without preserving it every append quietly rewrote the
|
||
// flow file's permissions to 0644.
|
||
const file = path.join(root, "flow.yaml");
|
||
await fs.writeFile(file, "steps: []\n", "utf8");
|
||
await fs.chmod(file, 0o600);
|
||
|
||
await writeNewFlowFile(file, "steps: []\n");
|
||
|
||
expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
|
||
});
|
||
|
||
it("refuses to overwrite a read-only flow file", async () => {
|
||
// The swap needs permission on the DIRECTORY, so it would replace a
|
||
// `chmod 0444` file regardless — turning a plain write's EACCES into a
|
||
// silent success that also relaxed the mode.
|
||
const file = path.join(root, "flow.yaml");
|
||
await fs.writeFile(file, "steps: []\nkeep: me\n", "utf8");
|
||
await fs.chmod(file, 0o444);
|
||
if (!(await modeBitsBite(file))) return;
|
||
|
||
const err = await writeNewFlowFile(file, "steps: []\n").catch((e: unknown) => e);
|
||
|
||
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_FILE_WRITE_FAILED);
|
||
expect((err as Error).message).toMatch(/not writable \(mode 0444\)/);
|
||
// And it really did not touch the file.
|
||
expect(await fs.readFile(file, "utf8")).toContain("keep: me");
|
||
});
|
||
|
||
it("leaves no scratch file behind when it refuses", async () => {
|
||
const flows = path.join(root, ".argent", "flows");
|
||
await fs.mkdir(flows, { recursive: true });
|
||
const file = path.join(flows, "flow.yaml");
|
||
await fs.writeFile(file, "steps: []\n", "utf8");
|
||
await fs.chmod(file, 0o444);
|
||
if (!(await modeBitsBite(file))) return;
|
||
|
||
await writeNewFlowFile(file, "steps: []\n").catch(() => {});
|
||
|
||
expect(await fs.readdir(flows)).toEqual(["flow.yaml"]);
|
||
});
|
||
|
||
it("still creates a flow file that does not exist yet", async () => {
|
||
// The control: nothing to preserve and nothing to be refused by.
|
||
const file = path.join(root, "fresh.yaml");
|
||
await writeNewFlowFile(file, "steps: []\n");
|
||
expect(await fs.readFile(file, "utf8")).toBe("steps: []\n");
|
||
});
|
||
});
|
||
|
||
describe("mkdirFailureHint arms", () => {
|
||
// The flows-directory half of writeNewFlowFile's classification. Only its
|
||
// wrapping was covered; each errno arm names a different cause, and the
|
||
// ENOTDIR one — a `project_root` that names a FILE — is the mistake the hint
|
||
// exists for.
|
||
let root: string;
|
||
|
||
beforeEach(async () => {
|
||
root = await fs.mkdtemp(path.join(os.tmpdir(), "flow-mkdir-hint-"));
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await fs.chmod(root, 0o755).catch(() => {});
|
||
await fs.rm(root, { recursive: true, force: true });
|
||
});
|
||
|
||
it("blames a project_root that names a file, not a directory", async () => {
|
||
const asFile = path.join(root, "notadir");
|
||
await fs.writeFile(asFile, "", "utf8");
|
||
const flows = path.join(asFile, ".argent", "flows");
|
||
|
||
const err = await writeNewFlowFile(path.join(flows, "x.yaml"), "steps: []\n").catch(
|
||
(e: unknown) => e
|
||
);
|
||
|
||
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_FILE_WRITE_FAILED);
|
||
expect(getFailureSignal(err)?.failure_stage).toBe("flow_dir_create");
|
||
expect((err as Error).message).toContain("(ENOTDIR)");
|
||
expect((err as Error).message).toContain(
|
||
"check that project_root names a directory rather than a file"
|
||
);
|
||
});
|
||
|
||
it("blames the nearest existing parent when it is not writable", async () => {
|
||
await fs.chmod(root, 0o555);
|
||
if (
|
||
await fs.access(root, fsConstants.W_OK).then(
|
||
() => true,
|
||
() => false
|
||
)
|
||
)
|
||
return;
|
||
const flows = path.join(root, ".argent", "flows");
|
||
|
||
const err = await writeNewFlowFile(path.join(flows, "x.yaml"), "steps: []\n").catch(
|
||
(e: unknown) => e
|
||
);
|
||
|
||
expect((err as Error).message).toMatch(/\((EACCES|EPERM)\)/);
|
||
expect((err as Error).message).toContain("nearest existing parent");
|
||
});
|
||
|
||
it("blames the name length when the path is too long for the filesystem", async () => {
|
||
// ENAMETOOLONG out of mkdir -p, which must not read as a permissions
|
||
// problem the user would then go and not find.
|
||
const tooLong = path.join(root, "d".repeat(512), ".argent", "flows");
|
||
|
||
const err = await writeNewFlowFile(path.join(tooLong, "x.yaml"), "steps: []\n").catch(
|
||
(e: unknown) => e
|
||
);
|
||
|
||
expect((err as Error).message).toContain("(ENAMETOOLONG)");
|
||
expect((err as Error).message).toContain("longer than this filesystem allows");
|
||
});
|
||
});
|