Files
schicaelmott dd5d823833 feat(flow): stream run progress as NDJSON (#855)
## Summary

- add `argent flow run --json-stream`
- stream flow progress as JSON lines
- finish with a JSON result or error

## Why

`--json` prints only after a flow finishes. `--json-stream` lets scripts
and CI see live progress.

## Why this is a separate PR

This is useful without the custom-tools feature in #856.

## Validation

- `npm test -w @argent/cli -- flow.test.ts` — 106 tests passed
- `npm run typecheck:tests -w @argent/cli`
- `npm run build -w @argent/cli`

---------

Co-authored-by: Filip131311 <contact@filipkaminski.com>
2026-08-21 12:14:34 +02:00

1983 lines
80 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import * as fs from "node:fs";
import * as fsp from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import { Writable } from "node:stream";
import { exitAfterFlush, flow, parseRunArgs } from "../src/flow.js";
import { ToolInvocationError } from "@argent/tools-client";
import { FlagParseException } from "../src/flag-parser.js";
import type { ResolvedToolsUrl } from "@argent/tools-client";
const toolsClientMock = vi.hoisted(() => ({
callTool: vi.fn(),
baseUrl: vi.fn(async () => ({ url: "http://127.0.0.1:4141", token: "tok" })),
}));
// Identity materialization; a spy so tests can assert it is only invoked for
// the failed-snapshot artifacts that --output actually copies.
const materializeArtifactsMock = vi.hoisted(() =>
vi.fn(async (data: unknown) => ({ result: data, images: [] }))
);
const getResolvedToolsUrlMock = vi.hoisted(() =>
vi.fn(
async (): Promise<ResolvedToolsUrl> => ({
url: null,
source: "none",
})
)
);
vi.mock("@argent/tools-client", async (importOriginal) => ({
// Keep the real isArtifactHandle — the display-path fallback under test
// must recognize genuine wire handles.
...(await importOriginal<typeof import("@argent/tools-client")>()),
createToolsClient: vi.fn(() => toolsClientMock),
getResolvedToolsUrl: getResolvedToolsUrlMock,
materializeArtifacts: materializeArtifactsMock,
}));
interface StepFixture {
index: number;
kind: string;
status: "pass" | "fail" | "skip" | "error";
reason?: string;
warning?: string;
tool?: string;
flow?: string;
message?: string;
snapshotKey?: string;
artifacts?: Record<string, unknown>;
/** Wire-only tool-step payload; the CLI StepReport type has no such field. */
result?: unknown;
}
/** A wire artifact handle as the tool-server emits it (image/png). */
function handle(hostPath?: string): Record<string, unknown> {
return {
__argentArtifact: true,
id: "art-1",
filename: "art.png",
mimeType: "image/png",
size: 4,
...(hostPath ? { hostPath } : {}),
};
}
/**
* Whether a mode-000 file is actually unreadable to this process. `access(R_OK)`
* succeeds for root regardless of mode (some CI containers run as root), and
* Windows has no POSIX mode bits at all — in both cases the fixture would be
* readable and the assertion would pass for the wrong reason. Skip the
* unreadable-file cases there rather than let them go green vacuously.
*/
const canDenyRead = process.platform !== "win32" && process.getuid?.() !== 0;
/**
* Whether this filesystem folds case (APFS and NTFS do, ext4 does not). Probed
* rather than assumed from the platform, since macOS volumes can be formatted
* either way. Only where it folds does a mis-cased spelling open a real file
* and reach the on-disk-spelling guard, so the cases that need it are skipped
* elsewhere instead of passing for the wrong reason.
*/
const caseInsensitiveFs = ((): boolean => {
const probe = fs.mkdtempSync(path.join(tmpdir(), "argent-cli-case-"));
try {
fs.writeFileSync(path.join(probe, "probe.yaml"), "");
return fs.existsSync(path.join(probe, "PROBE.yaml"));
} finally {
fs.rmSync(probe, { recursive: true, force: true });
}
})();
function report(overrides: Record<string, unknown> = {}): Record<string, unknown> {
const steps: StepFixture[] = [{ index: 0, kind: "tap", status: "pass" }];
return {
flow: "checkout",
device: "SIM-1",
executionPrerequisite: "",
ok: true,
passed: 1,
failed: 0,
skipped: 0,
errored: 0,
steps,
...overrides,
};
}
describe("parseRunArgs", () => {
it("returns documented defaults with just a flow path", () => {
expect(parseRunArgs(["../flows/checkout.yaml"])).toEqual({
flowRef: "../flows/checkout.yaml",
updateBaselines: false,
recursive: false,
json: false,
jsonStream: false,
});
});
it("parses every run flag alongside the path", () => {
expect(
parseRunArgs([
"checkout.yaml",
"--device",
"SIM-1",
"--platform",
"ios",
"--update-baselines",
])
).toEqual({
flowRef: "checkout.yaml",
device: "SIM-1",
platform: "ios",
updateBaselines: true,
recursive: false,
json: false,
jsonStream: false,
});
expect(parseRunArgs(["--json", "checkout.yaml"]).json).toBe(true);
expect(parseRunArgs(["--json-stream", "checkout.yaml"]).jsonStream).toBe(true);
});
it("accepts -r and --recursive in any position", () => {
expect(parseRunArgs(["flows", "-r"]).recursive).toBe(true);
expect(parseRunArgs(["--recursive", "flows"]).recursive).toBe(true);
expect(parseRunArgs(["flows"]).recursive).toBe(false);
});
it("throws when --recursive is given an inline value", () => {
expect(() => parseRunArgs(["flows", "--recursive=1"])).toThrow(FlagParseException);
expect(() => parseRunArgs(["flows", "--recursive=1"])).toThrow(
"--recursive does not take a value"
);
});
it("throws when --device is the final token", () => {
expect(() => parseRunArgs(["checkout.yaml", "--device"])).toThrow(FlagParseException);
expect(() => parseRunArgs(["checkout.yaml", "--device"])).toThrow("--device requires a value");
});
it("throws when --platform is the final token", () => {
expect(() => parseRunArgs(["checkout.yaml", "--platform"])).toThrow(
"--platform requires a value"
);
});
it("treats a following flag as a missing value, not as the value", () => {
expect(() => parseRunArgs(["checkout.yaml", "--device", "--json"])).toThrow(
"--device requires a value"
);
expect(() => parseRunArgs(["checkout.yaml", "--platform", "--update-baselines"])).toThrow(
"--platform requires a value"
);
});
it("accepts the --flag=value form for every value-taking flag", () => {
expect(
parseRunArgs(["checkout.yaml", "--device=SIM-1", "--platform=ios", "--output=dir"])
).toEqual({
flowRef: "checkout.yaml",
device: "SIM-1",
platform: "ios",
output: "dir",
updateBaselines: false,
recursive: false,
json: false,
jsonStream: false,
});
});
it("mixes = and space-separated forms freely", () => {
expect(parseRunArgs(["checkout.yaml", "--device=SIM-1", "--platform", "ios"])).toEqual({
flowRef: "checkout.yaml",
device: "SIM-1",
platform: "ios",
updateBaselines: false,
recursive: false,
json: false,
jsonStream: false,
});
});
it("does not consume the next token when the value was inline", () => {
// Guards the index bookkeeping: --device=SIM-1 must not swallow --json.
const out = parseRunArgs(["checkout.yaml", "--device=SIM-1", "--json"]);
expect(out.device).toBe("SIM-1");
expect(out.json).toBe(true);
});
it("rejects combining --json and --json-stream", () => {
expect(() => parseRunArgs(["checkout.yaml", "--json", "--json-stream"])).toThrow(
"--json and --json-stream cannot be combined"
);
});
it("throws when a boolean flag is given an inline value", () => {
expect(() => parseRunArgs(["checkout.yaml", "--json=true"])).toThrow(FlagParseException);
expect(() => parseRunArgs(["checkout.yaml", "--json=true"])).toThrow(
"--json does not take a value"
);
expect(() => parseRunArgs(["checkout.yaml", "--json-stream=true"])).toThrow(
"--json-stream does not take a value"
);
expect(() => parseRunArgs(["checkout.yaml", "--update-baselines=1"])).toThrow(
"--update-baselines does not take a value"
);
});
it("throws when an inline value is empty", () => {
expect(() => parseRunArgs(["checkout.yaml", "--device="])).toThrow("--device requires a value");
});
it("rejects unknown flags instead of silently dropping them", () => {
expect(() => parseRunArgs(["checkout.yaml", "--verbose"])).toThrow(FlagParseException);
expect(() => parseRunArgs(["checkout.yaml", "--verbose"])).toThrow(/unknown flag/);
// A typo'd value flag must not fall back to device auto-detection.
expect(() => parseRunArgs(["checkout.yaml", "--platfrom=ios"])).toThrow(/unknown flag/);
});
it("rejects extra positional arguments", () => {
expect(() => parseRunArgs(["checkout.yaml", "extra.yaml"])).toThrow(
"flow run accepts one flow name, YAML file path, or directory path"
);
});
it("takes everything after -- as the flow, so a name may start with a hyphen", () => {
// The flow-name charset admits a leading "-", so without the marker such a
// saved flow would be addressable by path only.
expect(() => parseRunArgs(["-nightly"])).toThrow(/unknown flag/);
expect(parseRunArgs(["--device", "SIM-1", "--", "-nightly"])).toEqual({
flowRef: "-nightly",
device: "SIM-1",
updateBaselines: false,
recursive: false,
json: false,
jsonStream: false,
});
// The marker relaxes flag parsing, not the one-positional rule.
expect(() => parseRunArgs(["--", "a", "b"])).toThrow(
"flow run accepts one flow name, YAML file path, or directory path"
);
});
it("counts an empty positional as the flow, not as a token to skip past", () => {
// A truthiness test here would take "extra.yaml" as the flow and run a file
// the operator never named at that position.
expect(() => parseRunArgs(["", "extra.yaml"])).toThrow(
"flow run accepts one flow name, YAML file path, or directory path"
);
});
});
describe("argent flow run", () => {
let tempRoot: string;
let checkoutPath: string;
let bundleDirPath: string;
let unreadablePath: string;
let exitSpy: ReturnType<typeof vi.spyOn>;
let logs: string[];
let errs: string[];
let logSpy: ReturnType<typeof vi.spyOn>;
let errSpy: ReturnType<typeof vi.spyOn>;
const opts = { paths: {} as never };
beforeAll(async () => {
tempRoot = await fsp.mkdtemp(path.join(tmpdir(), "argent-cli-flow-"));
checkoutPath = path.join(tempRoot, "checkout.yaml");
await fsp.writeFile(checkoutPath, "steps: []\n", "utf8");
// The two paths `run`'s filesystem acceptance check rejects after the name
// checks pass — both need real inodes, so they are built here rather than
// faked: a directory that looks like a flow, and an unreadable file.
bundleDirPath = path.join(tempRoot, "bundle.yaml");
await fsp.mkdir(bundleDirPath, { recursive: true });
// A flow saved where a bare name resolves. Deliberately not named
// "checkout": the name-vs-path tests below need a stem that exists under
// .argent/flows and nowhere else, so a `run saved.yaml` that fell back to
// the flows directory would be caught rather than passing by coincidence.
await fsp.mkdir(path.join(tempRoot, ".argent", "flows"), { recursive: true });
await fsp.writeFile(
path.join(tempRoot, ".argent", "flows", "saved.yaml"),
"steps: []\n",
"utf8"
);
unreadablePath = path.join(tempRoot, "noperm.yaml");
await fsp.writeFile(unreadablePath, "steps: []\n", "utf8");
await fsp.chmod(unreadablePath, 0o000);
});
afterAll(async () => {
// Restore the mode before removing the tree: `rm` itself only needs the
// parent's write bit, but a stray 0o000 file is a trap for anything that
// later walks tmpdir, so never leave one behind if the rm is interrupted.
await fsp.chmod(unreadablePath, 0o600).catch(() => {});
await fsp.rm(tempRoot, { recursive: true, force: true });
});
beforeEach(() => {
vi.clearAllMocks();
getResolvedToolsUrlMock.mockResolvedValue({ url: null, source: "none" });
toolsClientMock.callTool.mockResolvedValue({ data: report() });
logs = [];
errs = [];
logSpy = vi.spyOn(console, "log").mockImplementation((...a) => void logs.push(a.join(" ")));
errSpy = vi.spyOn(console, "error").mockImplementation((...a) => void errs.push(a.join(" ")));
exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code}`);
}) as typeof process.exit);
});
afterEach(() => {
exitSpy.mockRestore();
logSpy.mockRestore();
errSpy.mockRestore();
});
it("resolves the YAML path and forwards flags to flow-execute", async () => {
// Downward-relative from the flow's own directory: a relative spelling
// that climbs out of cwd would contain ".." segments, which the CLI now
// rejects. process.cwd() after chdir is fully symlink-resolved (macOS
// tmpdir sits behind /var -> /private/var), so expectations use realpath.
const runRoot = await fsp.realpath(tempRoot);
const previousCwd = process.cwd();
try {
process.chdir(tempRoot);
await expect(
flow(
["run", "checkout.yaml", "--device", "SIM-1", "--platform", "ios", "--update-baselines"],
opts
)
).rejects.toThrow("process.exit:0");
} finally {
process.chdir(previousCwd);
}
expect(toolsClientMock.callTool).toHaveBeenCalledWith(
"flow-execute",
{
flow_path: path.join(runRoot, "checkout.yaml"),
project_root: runRoot,
prerequisiteAcknowledged: true,
device: "SIM-1",
platform: "ios",
updateBaselines: true,
},
{ onProgress: expect.any(Function) }
);
expect(logs.join("\n")).toContain("PASS — 1 passed, 0 failed, 0 errored, 0 skipped");
});
it("exits 2 without calling the tool when --device is missing its value", async () => {
await expect(flow(["run", "checkout", "--device"], opts)).rejects.toThrow("process.exit:2");
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
expect(errs.join("\n")).toContain("--device requires a value");
});
it("exits 2 without calling the tool when --platform is followed by another flag", async () => {
await expect(flow(["run", "checkout", "--platform", "--json"], opts)).rejects.toThrow(
"process.exit:2"
);
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
expect(errs.join("\n")).toContain("--platform requires a value");
});
it("forwards --flag=value forms to flow-execute like the space-separated ones", async () => {
await expect(
flow(["run", checkoutPath, "--platform=ios", "--device=SIM-1"], opts)
).rejects.toThrow("process.exit:0");
expect(toolsClientMock.callTool).toHaveBeenCalledWith(
"flow-execute",
{
flow_path: checkoutPath,
project_root: process.cwd(),
prerequisiteAcknowledged: true,
device: "SIM-1",
platform: "ios",
},
{ onProgress: expect.any(Function) }
);
});
it("exits 2 without calling the tool when a boolean flag is given a value", async () => {
await expect(flow(["run", "checkout", "--json=x"], opts)).rejects.toThrow("process.exit:2");
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
expect(errs.join("\n")).toContain("--json does not take a value");
});
it("exits 2 without calling the tool on a typo'd flag instead of auto-detecting a device", async () => {
await expect(flow(["run", "checkout", "--platfrom=ios"], opts)).rejects.toThrow(
"process.exit:2"
);
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
expect(errs.join("\n")).toContain("unknown flag");
});
it("exits 2 when no flow name or path is given", async () => {
await expect(flow(["run"], opts)).rejects.toThrow("process.exit:2");
expect(errs.join("\n")).toContain(
"requires a flow name, a YAML file path, or a directory path"
);
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
it("runs a saved flow named on the command line from .argent/flows", async () => {
const runRoot = await fsp.realpath(tempRoot);
const previousCwd = process.cwd();
try {
process.chdir(tempRoot);
await expect(flow(["run", "saved", "--device", "SIM-1"], opts)).rejects.toThrow(
"process.exit:0"
);
} finally {
process.chdir(previousCwd);
}
expect(toolsClientMock.callTool).toHaveBeenCalledWith(
"flow-execute",
{
// A name is sent as the path it resolves to, never as a `name` source:
// one identity keys the report, __baselines__/, and --output whichever
// form the operator typed.
flow_path: path.join(runRoot, ".argent", "flows", "saved.yaml"),
project_root: runRoot,
prerequisiteAcknowledged: true,
device: "SIM-1",
},
{ onProgress: expect.any(Function) }
);
});
it("resolves a name and its spelled-out path to the same flow_path", async () => {
const previousCwd = process.cwd();
const sent = async (ref: string): Promise<unknown> => {
toolsClientMock.callTool.mockClear();
await expect(flow(["run", ref], opts)).rejects.toThrow("process.exit:0");
return (toolsClientMock.callTool.mock.calls[0]![1] as { flow_path: string }).flow_path;
};
try {
process.chdir(tempRoot);
expect(await sent("saved")).toBe(await sent(path.join(".argent", "flows", "saved.yaml")));
} finally {
process.chdir(previousCwd);
}
});
it("says where a name looked when no flow is saved under it", async () => {
const runRoot = await fsp.realpath(tempRoot);
const previousCwd = process.cwd();
try {
process.chdir(tempRoot);
await expect(flow(["run", "nosuchflow"], opts)).rejects.toThrow("process.exit:2");
} finally {
process.chdir(previousCwd);
}
expect(errs.join("\n")).toContain(
`Flow file not found: ${path.join(runRoot, ".argent", "flows", "nosuchflow.yaml")}`
);
expect(errs.join("\n")).toContain("argent flow list");
expect(getResolvedToolsUrlMock).not.toHaveBeenCalled();
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
it("reads a .yaml argument as a path only — never falling back to .argent/flows", async () => {
const runRoot = await fsp.realpath(tempRoot);
const previousCwd = process.cwd();
try {
// "saved.yaml" exists under .argent/flows and nowhere else. An argument
// carrying an extension is a path, so this must miss: letting it fall
// back would make the meaning of a path depend on what happens to be
// saved, and a later ./saved.yaml would silently re-point the run.
process.chdir(tempRoot);
await expect(flow(["run", "saved.yaml"], opts)).rejects.toThrow("process.exit:2");
} finally {
process.chdir(previousCwd);
}
expect(errs.join("\n")).toContain(`Flow file not found: ${path.join(runRoot, "saved.yaml")}`);
expect(errs.join("\n")).not.toContain(path.join(".argent", "flows", "saved.yaml"));
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
// Refused, but not blindly: the flow it names is saved one directory down,
// so the message points at the name form rather than leaving the operator
// to guess why a file they can see was not found.
expect(errs.join("\n")).toContain("did you mean: argent flow run saved");
});
it("does not offer the name form for a missing path with no saved flow behind it", async () => {
const previousCwd = process.cwd();
try {
process.chdir(tempRoot);
await expect(flow(["run", "nosuchflow.yaml"], opts)).rejects.toThrow("process.exit:2");
} finally {
process.chdir(previousCwd);
}
expect(errs.join("\n")).toContain("Flow file not found");
// Matched case-insensitively: every other recovery in this file spells the
// phrase "Did you mean", so a capitalized hint must fail this too.
expect(errs.join("\n")).not.toMatch(/did you mean/i);
expect(errs.join("\n")).not.toContain("is saved under");
});
it("offers the on-disk spelling of a mis-cased path, not the one that was typed", async () => {
// A hint built from the operator's spelling would say `run Saved` — which
// `run`'s own byte-exact spelling check then refuses on a case-folding
// filesystem, costing a second failure and pushing them to the path form
// for a flow that is perfectly addressable by name. Reading the directory
// entry settles the spelling once, on every filesystem.
const previousCwd = process.cwd();
try {
process.chdir(tempRoot);
await expect(flow(["run", "Saved.yaml"], opts)).rejects.toThrow("process.exit:2");
} finally {
process.chdir(previousCwd);
}
expect(errs.join("\n")).toContain("did you mean: argent flow run saved");
expect(errs.join("\n")).not.toContain("run Saved");
});
// Only a case-folding filesystem opens `.argent/flows/Saved.yaml` for an
// on-disk `saved.yaml` and so reaches the spelling guard; see caseInsensitiveFs.
it.skipIf(!caseInsensitiveFs)(
"suggests the name form when a name matched only case-insensitively",
async () => {
const previousCwd = process.cwd();
try {
process.chdir(tempRoot);
await expect(flow(["run", "Saved"], opts)).rejects.toThrow("process.exit:2");
} finally {
process.chdir(previousCwd);
}
// The operator typed a name, so the recovery is a name — not a path into a
// directory they never spelled out.
expect(errs.join("\n")).toContain("Flow path must name the file as it appears on disk");
expect(errs.join("\n")).toContain("Did you mean: argent flow run saved");
expect(errs.join("\n")).not.toContain(
`Did you mean: argent flow run ${path.join(".argent")}`
);
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
}
);
it("does not offer a same-stem sibling when the missing path is already in the flows dir", async () => {
const previousCwd = process.cwd();
try {
// A nested miss: `saved.yaml` exists at the top of .argent/flows, but it
// is a different flow from the sub/saved.yaml that was asked for, and
// the operator plainly knows where flows live.
process.chdir(tempRoot);
await expect(
flow(["run", path.join(".argent", "flows", "sub", "saved.yaml")], opts)
).rejects.toThrow("process.exit:2");
} finally {
process.chdir(previousCwd);
}
expect(errs.join("\n")).toContain("Flow file not found");
expect(errs.join("\n")).not.toMatch(/did you mean/i);
});
it("keeps the trailing-separator hint a path when trimming would read as a name", async () => {
const previousCwd = process.cwd();
try {
process.chdir(tempRoot);
await expect(flow(["run", "saved/"], opts)).rejects.toThrow("process.exit:2");
} finally {
process.chdir(previousCwd);
}
// Trimming leaves "saved", which now addresses .argent/flows/saved.yaml —
// a different file from the ./saved this argument names, and one that would
// actually run. The recovery must not re-point the operator there.
expect(errs.join("\n")).toContain("must not end in a path separator");
expect(errs.join("\n")).toContain(`Did you mean: argent flow run .${path.sep}saved`);
expect(errs.join("\n")).not.toMatch(/Did you mean: argent flow run saved$/m);
});
it("names the flow-name charset for a bare argument that is neither a name nor a path", async () => {
await expect(flow(["run", "my flow"], opts)).rejects.toThrow("process.exit:2");
expect(errs.join("\n")).toContain(
'Flow name must contain only letters, numbers, "_", or "-": my flow'
);
expect(getResolvedToolsUrlMock).not.toHaveBeenCalled();
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
it("rejects missing and invalid YAML paths before routing or tool invocation", async () => {
await expect(flow(["run", path.join(tempRoot, "missing.yaml")], opts)).rejects.toThrow(
"process.exit:2"
);
expect(errs.join("\n")).toContain("Flow file not found");
errs.length = 0;
await expect(flow(["run", path.join(tempRoot, "checkout.yml")], opts)).rejects.toThrow(
"process.exit:2"
);
expect(errs.join("\n")).toContain("Flow path must end in .yaml");
errs.length = 0;
await expect(flow(["run", path.join(tempRoot, "unsafe name.yaml")], opts)).rejects.toThrow(
"process.exit:2"
);
expect(errs.join("\n")).toContain("Flow filename must have a non-empty name");
expect(getResolvedToolsUrlMock).not.toHaveBeenCalled();
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
it("names the lowercase requirement when only the extension's case is wrong", async () => {
await expect(flow(["run", path.join(tempRoot, "Checkout.YAML")], opts)).rejects.toThrow(
"process.exit:2"
);
expect(errs.join("\n")).toContain("Flow extension must be lowercase .yaml, not .YAML");
expect(getResolvedToolsUrlMock).not.toHaveBeenCalled();
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
// The lowercase-.yaml guard checks the SUPPLIED spelling, but stat matches
// by the filesystem's rules — on APFS/NTFS a lowercase retyping of
// "Upper.YAML" finds the file, and without the exact-basename check the run
// would proceed under a flow name ("upper") no file on disk carries, mis-
// keying __baselines__/ and --output. These fixtures probe the filesystem
// rather than assume it: on a case-sensitive one (ext4 CI) the lowercase
// spelling is simply ENOENT. Both branches assert the contract that
// matters — rejected either way, flow-execute never invoked — and the
// case-insensitive branch additionally pins the message, since there the
// supplied path looks perfectly valid to the operator.
it("refuses a case-insensitive extension match, telling the operator to rename the file", async () => {
const caseRoot = path.join(tempRoot, "case-ext-project");
await fsp.mkdir(caseRoot, { recursive: true });
await fsp.writeFile(path.join(caseRoot, "Upper.YAML"), "steps: []\n");
const lowercase = path.join(caseRoot, "upper.yaml");
const caseInsensitiveFs = await fsp.stat(lowercase).then(
() => true,
() => false
);
await expect(flow(["run", lowercase], opts)).rejects.toThrow("process.exit:2");
const out = errs.join("\n");
if (caseInsensitiveFs) {
// The real name is itself unrunnable (uppercase extension), so the
// recovery must be a rename — not a Did-you-mean that would be refused.
expect(out).toContain("Flow path must name the file as it appears on disk");
expect(out).toContain('"Upper.YAML"');
expect(out).toContain("Rename Upper.YAML to upper.yaml");
} else {
expect(out).toContain("Flow file not found");
}
expect(getResolvedToolsUrlMock).not.toHaveBeenCalled();
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
it("refuses a case-insensitive stem match, hinting the file's real (runnable) name", async () => {
const caseRoot = path.join(tempRoot, "case-stem-project");
await fsp.mkdir(caseRoot, { recursive: true });
await fsp.writeFile(path.join(caseRoot, "Checkout.yaml"), "steps: []\n");
const lowercase = path.join(caseRoot, "checkout.yaml");
const caseInsensitiveFs = await fsp.stat(lowercase).then(
() => true,
() => false
);
await expect(flow(["run", lowercase], opts)).rejects.toThrow("process.exit:2");
const out = errs.join("\n");
if (caseInsensitiveFs) {
// "Checkout.yaml" passes every name guard, so here the hint can be the
// command that actually works.
expect(out).toContain("Flow path must name the file as it appears on disk");
expect(out).toContain(
`Did you mean: argent flow run ${path.join(caseRoot, "Checkout.yaml")}`
);
} else {
expect(out).toContain("Flow file not found");
}
expect(getResolvedToolsUrlMock).not.toHaveBeenCalled();
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
it("still runs a symlinked flow under the link's own name", async () => {
// Pins the exact-basename check to the directory listing rather than
// realpath: realpath would rewrite the link to its target's name and
// refuse a spelling `flow run` has always accepted (and `flow list`
// advertises).
const linkRoot = path.join(tempRoot, "run-symlink-project");
await fsp.mkdir(linkRoot, { recursive: true });
await fsp.writeFile(path.join(linkRoot, "target-flow.yaml"), "steps: []\n");
await fsp.symlink(path.join(linkRoot, "target-flow.yaml"), path.join(linkRoot, "linked.yaml"));
await expect(flow(["run", path.join(linkRoot, "linked.yaml")], opts)).rejects.toThrow(
"process.exit:0"
);
expect(toolsClientMock.callTool).toHaveBeenCalledTimes(1);
expect(errs).toEqual([]);
});
it.each([[".yaml"], ["dir/.yaml"], [".YAML"]])(
"names the missing stem when the path %s is only the extension",
async (supplied) => {
await expect(flow(["run", supplied], opts)).rejects.toThrow("process.exit:2");
expect(errs.join("\n")).toContain("Flow filename must have a non-empty name");
expect(getResolvedToolsUrlMock).not.toHaveBeenCalled();
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
}
);
it("rejects a path with a .. segment before resolving, routing, or tool invocation", async () => {
// Assembled with the separator directly — path.join would collapse the
// ".." lexically before the CLI ever saw it, which is the exact behavior
// under test. Nothing named "nope" exists, so realpath fails and the
// generic recovery line is printed instead of a Did-you-mean hint.
const supplied = [tempRoot, "nope", "..", "checkout.yaml"].join(path.sep);
await expect(flow(["run", supplied], opts)).rejects.toThrow("process.exit:2");
const out = errs.join("\n");
expect(out).toContain('Flow path must not contain ".." segments');
expect(out).toContain("Pass the fully resolved path to the flow's YAML.");
expect(getResolvedToolsUrlMock).not.toHaveBeenCalled();
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
it("hints the kernel-resolved target when .. follows a symlinked directory", async () => {
// The reviewer's repro shape: flows/link -> decoy/inner, so the kernel
// opens decoy/login.yaml for "flows/link/../login.yaml" while path.resolve
// would have collapsed it to flows/login.yaml — a different file. Both
// files exist to prove the hint names the kernel's target, not the
// lexical one.
const dotdotRoot = path.join(tempRoot, "dotdot-project");
const flowsDir = path.join(dotdotRoot, "flows");
const decoyDir = path.join(dotdotRoot, "decoy");
await fsp.mkdir(path.join(decoyDir, "inner"), { recursive: true });
await fsp.mkdir(flowsDir, { recursive: true });
await fsp.writeFile(path.join(decoyDir, "login.yaml"), "steps: []\n");
await fsp.writeFile(path.join(flowsDir, "login.yaml"), "steps: []\n");
await fsp.symlink(path.join(decoyDir, "inner"), path.join(flowsDir, "link"), "dir");
const supplied = [flowsDir, "link", "..", "login.yaml"].join(path.sep);
await expect(flow(["run", supplied], opts)).rejects.toThrow("process.exit:2");
// realpath'd expectation: on macOS tmpdir itself sits behind a symlink
// (/var -> /private/var), so the hint's path differs from the joined one.
const kernelTarget = await fsp.realpath(path.join(decoyDir, "login.yaml"));
const out = errs.join("\n");
expect(out).toContain('Flow path must not contain ".." segments');
expect(out).toContain(`Did you mean: argent flow run ${kernelTarget}`);
// Readability pin: a path a shell passes through verbatim stays bare —
// quoting is reserved for paths that need it.
expect(out).not.toContain("argent flow run '");
expect(getResolvedToolsUrlMock).not.toHaveBeenCalled();
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
it("shell-quotes the .. hint when the resolved path would be mangled by a shell", async () => {
// The hint is a command to paste into a terminal — unquoted, the space in
// the resolved path would word-split into a truncated path plus a stray
// argument the CLI then rejects. The dir name also embeds a single quote
// to pin the '\'' splice, the one escape the single-quote wrapping needs.
const quoteRoot = path.join(tempRoot, "o'brien lab");
const flowsDir = path.join(quoteRoot, "flows");
await fsp.mkdir(flowsDir, { recursive: true });
await fsp.writeFile(path.join(quoteRoot, "login.yaml"), "steps: []\n");
const supplied = [flowsDir, "..", "login.yaml"].join(path.sep);
await expect(flow(["run", supplied], opts)).rejects.toThrow("process.exit:2");
// realpath'd prefix: macOS tmpdir sits behind /var -> /private/var. The
// expectation is written out literally rather than derived through any
// quoting helper, so the test cannot inherit a helper bug.
const realRoot = await fsp.realpath(tempRoot);
expect(errs.join("\n")).toContain(
`Did you mean: argent flow run '${realRoot}/o'\\''brien lab/login.yaml'`
);
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
it("keeps the supplied basename in the .. hint for a symlinked flow", async () => {
// realpath of the whole path would rewrite linked.yaml to its target's
// name — a hint that silently renames the flow (report name,
// __baselines__/ key, --output dir) out from under the user. Only the
// directories are dishonest under "..", so only they get resolved.
const linkRoot = path.join(tempRoot, "dotdot-symlink-project");
const flowsDir = path.join(linkRoot, "flows");
await fsp.mkdir(path.join(flowsDir, "extra"), { recursive: true });
await fsp.writeFile(path.join(flowsDir, "target-flow.yaml"), "steps: []\n");
await fsp.symlink(path.join(flowsDir, "target-flow.yaml"), path.join(flowsDir, "linked.yaml"));
const supplied = [flowsDir, "extra", "..", "linked.yaml"].join(path.sep);
await expect(flow(["run", supplied], opts)).rejects.toThrow("process.exit:2");
const hinted = path.join(await fsp.realpath(flowsDir), "linked.yaml");
expect(errs.join("\n")).toContain(`Did you mean: argent flow run ${hinted}`);
// Pasting the hint back must actually work: the path exists, keeps the
// link's own name, and passes run's exact-basename guard.
await expect(flow(["run", hinted], opts)).rejects.toThrow("process.exit:0");
expect(toolsClientMock.callTool).toHaveBeenCalledTimes(1);
});
it("falls back to the generic .. recovery when the reassembled path does not exist", async () => {
// The parent realpath alone is not enough for a hint: reassembled with
// the supplied basename it must name something kernel-reachable, or the
// hint would be a command that cannot work.
const ghostRoot = path.join(tempRoot, "dotdot-ghost-project");
await fsp.mkdir(path.join(ghostRoot, "present"), { recursive: true });
const supplied = [ghostRoot, "present", "..", "ghost.yaml"].join(path.sep);
await expect(flow(["run", supplied], opts)).rejects.toThrow("process.exit:2");
const out = errs.join("\n");
expect(out).toContain('Flow path must not contain ".." segments');
expect(out).toContain("Pass the fully resolved path to the flow's YAML.");
expect(out).not.toContain("Did you mean");
});
it.each([["/"], ["\\"], ["//"]])(
"rejects a flow path with trailing separator %j before resolve drops it",
async (trailer) => {
// checkout.yaml exists, and path.resolve drops the trailing separator
// lexically — so without the guard the CLI would stat the real file and
// run it, even though the kernel refuses to open "checkout.yaml/"
// (ENOTDIR). The hint is the same string minus the separators.
await expect(flow(["run", checkoutPath + trailer], opts)).rejects.toThrow("process.exit:2");
const out = errs.join("\n");
expect(out).toContain("Flow path must not end in a path separator");
expect(out).toContain(`Did you mean: argent flow run ${checkoutPath}`);
expect(getResolvedToolsUrlMock).not.toHaveBeenCalled();
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
}
);
it("shell-quotes the trailing-separator hint when the trimmed path contains a space", async () => {
// Same defect class as the .. hint: the echoed path is pasted into a
// shell command, and a space would word-split it. Nothing needs to exist
// on disk for this guard, so the fixture is just a string.
const trimmed = path.join(tempRoot, "low space", "lab.yaml");
await expect(flow(["run", trimmed + path.sep], opts)).rejects.toThrow("process.exit:2");
const out = errs.join("\n");
expect(out).toContain("Flow path must not end in a path separator");
expect(out).toContain(`Did you mean: argent flow run '${trimmed}'`);
});
it("lets the .. guard win when a path has both a .. segment and a trailing separator", async () => {
// Both dishonest-path predicates match. The ".." recovery (a fully
// resolved path) also cures the trailing separator, while the
// separator-stripped hint would leave the ".." standing — so the ".."
// complaint must be the one printed.
const supplied = [tempRoot, "nope", "..", "checkout.yaml", ""].join(path.sep);
await expect(flow(["run", supplied], opts)).rejects.toThrow("process.exit:2");
const out = errs.join("\n");
expect(out).toContain('Flow path must not contain ".." segments');
expect(out).not.toContain("Flow path must not end in a path separator");
});
it("dispatches a path that is only separators to directory mode, not the separator guard", async () => {
// "/" names the root directory honestly — there is no separator-stripped
// spelling to hint at — so it batches like any other directory and fails
// discovery (root holds no flows) rather than drawing a shape complaint.
await expect(flow(["run", "/"], opts)).rejects.toThrow("process.exit:2");
const out = errs.join("\n");
expect(out).toContain("No flows found in /");
expect(out).not.toContain("Flow path must not end in a path separator");
});
it("exits 2 on an empty directory named like a flow instead of handing it to flow-execute", async () => {
// A directory — even one named `bundle.yaml` — dispatches to directory
// mode (the isDirectory() branch runs before any file check), so it is
// never handed to flow-execute as a file; empty, it fails discovery
// before routing is consulted or a client is built.
await expect(flow(["run", bundleDirPath], opts)).rejects.toThrow("process.exit:2");
expect(errs.join("\n")).toContain(`No flows found in ${bundleDirPath}`);
expect(getResolvedToolsUrlMock).not.toHaveBeenCalled();
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
// Skipped as root / on Windows, where a mode-000 file is still readable —
// see canDenyRead.
it.skipIf(!canDenyRead)(
"exits 2 on an unreadable flow file rather than letting flow-execute hit EACCES",
async () => {
// The exit code is the contract, not just the wording: without the
// readability probe the EACCES surfaces out of the tool call instead,
// which exits 1 — a CI wrapper that tells a usage error (2) from a run
// failure (1) would silently reclassify an unreadable file as a failing run.
await expect(flow(["run", unreadablePath], opts)).rejects.toThrow("process.exit:2");
expect(errs.join("\n")).toContain(`Could not read flow file: ${unreadablePath}`);
expect(getResolvedToolsUrlMock).not.toHaveBeenCalled();
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
}
);
it.each([
["env", "Unset ARGENT_TOOLS_URL"],
["link", "argent unlink"],
] as const)("rejects %s routing without invoking flow-execute", async (source, recovery) => {
getResolvedToolsUrlMock.mockResolvedValue({
url: "http://example.test:4141",
source,
});
await expect(flow(["run", checkoutPath], opts)).rejects.toThrow("process.exit:2");
expect(errs.join("\n")).toContain("requires the auto-started local tool server");
expect(errs.join("\n")).toContain(recovery);
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
it("names both recoveries when env routing shadows an existing link", async () => {
// Unsetting only ARGENT_TOOLS_URL would re-route through the shadowed link
// and produce a second refusal — the message must instruct both steps.
getResolvedToolsUrlMock.mockResolvedValue({
url: "http://example.test:4141",
source: "env",
shadowedLink: {
url: "http://linked.test:5252",
host: "linked.test",
port: 5252,
createdAt: "2026-07-31T00:00:00.000Z",
},
});
await expect(flow(["run", checkoutPath], opts)).rejects.toThrow("process.exit:2");
const out = errs.join("\n");
expect(out).toContain("requires the auto-started local tool server; env routing is configured");
expect(out).toContain("Unset ARGENT_TOOLS_URL");
expect(out).toContain("argent unlink");
expect(out).toContain("http://linked.test:5252");
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
it("lists runnable YAML paths without consulting remote routing", async () => {
const listRoot = path.join(tempRoot, "list-project");
const flowsDir = path.join(listRoot, ".argent", "flows");
await fsp.mkdir(flowsDir, { recursive: true });
await Promise.all([
fsp.writeFile(path.join(flowsDir, "z-last.yaml"), "steps: []\n"),
fsp.writeFile(path.join(flowsDir, "a-first.yaml"), "steps: []\n"),
fsp.writeFile(path.join(flowsDir, "ignored.yml"), "steps: []\n"),
]);
getResolvedToolsUrlMock.mockResolvedValue({
url: "http://example.test:4141",
source: "env",
});
const previousCwd = process.cwd();
try {
process.chdir(listRoot);
await flow(["list"], opts);
} finally {
process.chdir(previousCwd);
}
expect(logs.join("\n")).toBe(
[".argent/flows/a-first.yaml", ".argent/flows/z-last.yaml"].join("\n")
);
expect(getResolvedToolsUrlMock).not.toHaveBeenCalled();
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
it("lists nested flows at any depth — paths `flow run` accepts", async () => {
// run's name contract binds the filename only, so a YAML under an
// intermediate directory is just as runnable as a top-level one — a
// non-recursive listing would hide it.
const listRoot = path.join(tempRoot, "list-nested-project");
const flowsDir = path.join(listRoot, ".argent", "flows");
await fsp.mkdir(path.join(flowsDir, "suite", "deep"), { recursive: true });
await Promise.all([
fsp.writeFile(path.join(flowsDir, "top.yaml"), "steps: []\n"),
fsp.writeFile(path.join(flowsDir, "suite", "checkout.yaml"), "steps: []\n"),
fsp.writeFile(path.join(flowsDir, "suite", "deep", "login.yaml"), "steps: []\n"),
]);
const previousCwd = process.cwd();
try {
process.chdir(listRoot);
await flow(["list"], opts);
// Deterministic: sorted over the full relative paths, not walk order.
expect(logs.join("\n")).toBe(
[
".argent/flows/suite/checkout.yaml",
".argent/flows/suite/deep/login.yaml",
".argent/flows/top.yaml",
].join("\n")
);
// The other half of the agreement: the deepest advertised path runs.
await expect(flow(["run", ".argent/flows/suite/deep/login.yaml"], opts)).rejects.toThrow(
"process.exit:0"
);
expect(toolsClientMock.callTool).toHaveBeenCalledTimes(1);
} finally {
process.chdir(previousCwd);
}
});
it("never lists the inside of a __baselines__ directory, at any depth", async () => {
// Baselines are machine-managed snapshot storage living beside the flows
// that own them (nested flows keep theirs beside themselves) — a YAML
// dropped inside one is not a flow to advertise.
const listRoot = path.join(tempRoot, "list-baselines-project");
const flowsDir = path.join(listRoot, ".argent", "flows");
await fsp.mkdir(path.join(flowsDir, "__baselines__", "checkout"), { recursive: true });
await fsp.mkdir(path.join(flowsDir, "suite", "__baselines__", "login"), { recursive: true });
await Promise.all([
fsp.writeFile(path.join(flowsDir, "checkout.yaml"), "steps: []\n"),
fsp.writeFile(path.join(flowsDir, "suite", "login.yaml"), "steps: []\n"),
fsp.writeFile(path.join(flowsDir, "__baselines__", "checkout", "sneaky.yaml"), "steps: []\n"),
fsp.writeFile(
path.join(flowsDir, "suite", "__baselines__", "login", "sneaky.yaml"),
"steps: []\n"
),
]);
const previousCwd = process.cwd();
try {
process.chdir(listRoot);
await flow(["list"], opts);
} finally {
process.chdir(previousCwd);
}
expect(logs.join("\n")).toBe(
[".argent/flows/checkout.yaml", ".argent/flows/suite/login.yaml"].join("\n")
);
});
it("omits .yaml files whose names `flow run` would reject — and `run` does reject them", async () => {
const listRoot = path.join(tempRoot, "list-unsafe-project");
const flowsDir = path.join(listRoot, ".argent", "flows");
await fsp.mkdir(flowsDir, { recursive: true });
await Promise.all([
fsp.writeFile(path.join(flowsDir, "sign.in.yaml"), "steps: []\n"),
fsp.writeFile(path.join(flowsDir, "ok (copy).yaml"), "steps: []\n"),
fsp.writeFile(path.join(flowsDir, "sign-in.yaml"), "steps: []\n"),
]);
const previousCwd = process.cwd();
try {
process.chdir(listRoot);
await flow(["list"], opts);
// Silently omitted, like non-.yaml entries — every printed path is runnable.
expect(logs.join("\n")).toBe(".argent/flows/sign-in.yaml");
// The omissions are agreements, not gaps: the very fixtures `list`
// hides must be ones `run` refuses, or the omission would be hiding
// runnable paths. Pin both halves against the same files.
for (const unsafe of ["sign.in.yaml", "ok (copy).yaml"]) {
errs.length = 0;
await expect(flow(["run", `.argent/flows/${unsafe}`], opts)).rejects.toThrow(
"process.exit:2"
);
expect(errs.join("\n")).toContain("Flow filename must have a non-empty name");
}
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
} finally {
process.chdir(previousCwd);
}
});
it("omits a directory named like a flow, which `flow run` rejects as not a file", async () => {
const listRoot = path.join(tempRoot, "list-dir-project");
const flowsDir = path.join(listRoot, ".argent", "flows");
await fsp.mkdir(path.join(flowsDir, "bundle.yaml"), { recursive: true });
await fsp.writeFile(path.join(flowsDir, "checkout.yaml"), "steps: []\n");
const previousCwd = process.cwd();
try {
process.chdir(listRoot);
await flow(["list"], opts);
} finally {
process.chdir(previousCwd);
}
expect(logs.join("\n")).toBe(".argent/flows/checkout.yaml");
});
// Skipped as root / on Windows, where a mode-000 file is still readable —
// see canDenyRead.
it.skipIf(!canDenyRead)(
"omits an unreadable flow file, which `flow run` rejects as unreadable",
async () => {
const listRoot = path.join(tempRoot, "list-noperm-project");
const flowsDir = path.join(listRoot, ".argent", "flows");
await fsp.mkdir(flowsDir, { recursive: true });
const unreadable = path.join(flowsDir, "noperm.yaml");
await fsp.writeFile(path.join(flowsDir, "checkout.yaml"), "steps: []\n");
await fsp.writeFile(unreadable, "steps: []\n");
await fsp.chmod(unreadable, 0o000);
const previousCwd = process.cwd();
try {
process.chdir(listRoot);
await flow(["list"], opts);
} finally {
process.chdir(previousCwd);
// Restore before afterAll's rm walks the tree (see there).
await fsp.chmod(unreadable, 0o600);
}
// stat() succeeds on it — only the readability probe keeps `list` from
// advertising a path `flow run` then refuses.
expect(logs.join("\n")).toBe(".argent/flows/checkout.yaml");
}
);
it("lists a symlink to a flow file, which `flow run` accepts, but not a broken one", async () => {
const listRoot = path.join(tempRoot, "list-symlink-project");
const flowsDir = path.join(listRoot, ".argent", "flows");
await fsp.mkdir(flowsDir, { recursive: true });
await fsp.writeFile(path.join(tempRoot, "shared-flow.yaml"), "steps: []\n");
await fsp.symlink(path.join(tempRoot, "shared-flow.yaml"), path.join(flowsDir, "linked.yaml"));
await fsp.symlink(path.join(tempRoot, "missing-flow.yaml"), path.join(flowsDir, "broken.yaml"));
const previousCwd = process.cwd();
try {
process.chdir(listRoot);
await flow(["list"], opts);
} finally {
process.chdir(previousCwd);
}
expect(logs.join("\n")).toBe(".argent/flows/linked.yaml");
});
it("prints the no-flows message when no entry in the directory is runnable", async () => {
const listRoot = path.join(tempRoot, "list-empty-project");
const flowsDir = path.join(listRoot, ".argent", "flows");
await fsp.mkdir(path.join(flowsDir, "bundle.yaml"), { recursive: true });
const previousCwd = process.cwd();
try {
process.chdir(listRoot);
await flow(["list"], opts);
} finally {
process.chdir(previousCwd);
}
expect(logs.join("\n")).toBe("No flows found in .argent/flows");
});
it("renders the report — echo lines unnumbered, real steps numbered, reasons and fragment tags shown — and exits 1 on failure", async () => {
toolsClientMock.callTool.mockResolvedValue({
data: report({
executionPrerequisite: "App on the login screen",
ok: false,
passed: 1,
failed: 1,
skipped: 1,
steps: [
{ index: 0, kind: "echo", status: "pass", message: "Opening settings" },
{ index: 1, kind: "tap", status: "pass" },
{ index: 2, kind: "assert", status: "fail", reason: "never visible", flow: "login" },
{ index: 3, kind: "tool", tool: "screenshot", status: "skip" },
],
}),
});
await expect(flow(["run", checkoutPath], opts)).rejects.toThrow("process.exit:1");
const out = logs.join("\n");
expect(out).toContain('Flow "checkout" on SIM-1');
expect(out).toContain("assumes: App on the login screen");
// Echo is narration — no index; numbering starts at the first real step.
expect(out).toContain("› Opening settings");
expect(out).toMatch(/✓ {2}1 tap/);
expect(out).toMatch(/✗ {2}2 assert \[login\] — never visible/);
expect(out).toMatch(/· {2}3 tool screenshot/);
expect(out).toContain("FAIL — 1 passed, 1 failed, 0 errored, 1 skipped");
});
it("renders legacy warnings with the ⚠ glyph and counts them in the summary", async () => {
toolsClientMock.callTool.mockResolvedValue({
data: report({
steps: [{ index: 0, kind: "snapshot", status: "pass", warning: "no baseline; adopted" }],
}),
});
await expect(flow(["run", checkoutPath], opts)).rejects.toThrow("process.exit:0");
const out = logs.join("\n");
expect(out).toMatch(/⚠ {2}1 snapshot/);
expect(out).toContain("⚠ no baseline; adopted");
expect(out).toContain("1 warning");
});
it("prints the raw report with --json", async () => {
await expect(flow(["run", checkoutPath, "--json"], opts)).rejects.toThrow("process.exit:0");
expect(JSON.parse(logs.join("\n"))).toEqual(report());
});
it("prints each progress step followed by one final result with --json-stream", async () => {
let finish!: () => void;
const finalGate = new Promise<void>((resolve) => (finish = resolve));
const step = { index: 0, kind: "tap", status: "pass" };
const finalReport = report({ steps: [step] });
toolsClientMock.callTool.mockImplementation(
async (
_name: string,
_payload: unknown,
options?: { onProgress?: (event: unknown) => void }
) => {
options?.onProgress?.(step);
await finalGate;
return { data: finalReport };
}
);
const running = flow(["run", checkoutPath, "--json-stream"], opts);
await vi.waitFor(() => {
expect(logs).toHaveLength(1);
expect(JSON.parse(logs[0]!)).toEqual({ event: "progress", data: step });
});
finish();
await expect(running).rejects.toThrow("process.exit:0");
const records = logs.map((line) => JSON.parse(line) as Record<string, unknown>);
expect(records).toEqual([
{ event: "progress", data: step },
{ event: "result", data: finalReport },
]);
expect(errs).toEqual([]);
});
it("prints one final result and exits 1 for a failed report with --json-stream", async () => {
const failedReport = report({
ok: false,
passed: 0,
failed: 1,
steps: [{ index: 0, kind: "assert", status: "fail", reason: "not visible" }],
});
toolsClientMock.callTool.mockResolvedValue({ data: failedReport });
await expect(flow(["run", checkoutPath, "--json-stream"], opts)).rejects.toThrow(
"process.exit:1"
);
expect(logs.map((line) => JSON.parse(line))).toEqual([{ event: "result", data: failedReport }]);
});
it("prints a structured error on stdout and diagnostics on stderr with --json-stream", async () => {
toolsClientMock.callTool.mockRejectedValue(
new ToolInvocationError("invalid flow", {
errorCode: "FLOW_FILE_INVALID",
errorKind: "validation",
})
);
await expect(flow(["run", checkoutPath, "--json-stream"], opts)).rejects.toThrow(
"process.exit:1"
);
expect(logs.map((line) => JSON.parse(line))).toEqual([
{
event: "error",
error: "invalid flow",
error_code: "FLOW_FILE_INVALID",
error_kind: "validation",
},
]);
expect(errs).toEqual(["invalid flow"]);
});
it("rejects --json with --json-stream without writing human text to stdout", async () => {
await expect(flow(["run", checkoutPath, "--json", "--json-stream"], opts)).rejects.toThrow(
"process.exit:2"
);
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
expect(logs.map((line) => JSON.parse(line))).toEqual([
{ event: "error", error: "--json and --json-stream cannot be combined" },
]);
expect(errs.join("\n")).toContain("--json and --json-stream cannot be combined");
expect(errs.join("\n")).toContain("Usage: argent flow");
});
it("keeps help off stdout after --json-stream selects machine mode", async () => {
await flow(["run", checkoutPath, "--json-stream", "--help"], opts);
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
expect(logs).toEqual([]);
expect(errs.join("\n")).toContain("Usage: argent flow");
expect(errs.join("\n")).toContain("--json-stream");
});
it("emits a structured error for a pre-flight refusal in streaming mode", async () => {
const missing = path.join(path.dirname(checkoutPath), "missing.yaml");
await expect(flow(["run", missing, "--json-stream"], opts)).rejects.toThrow("process.exit:2");
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
expect(logs.map((line) => JSON.parse(line))).toEqual([
{ event: "error", error: expect.stringContaining(`Flow file not found: ${missing}`) },
]);
});
it("emits a structured error and exits 2 when artifact export fails in streaming mode", async () => {
toolsClientMock.callTool.mockResolvedValue({ data: report() });
toolsClientMock.baseUrl.mockRejectedValueOnce(new Error("tool server unreachable"));
await expect(
flow(["run", checkoutPath, "--json-stream", "--output", path.join(tempRoot, "out")], opts)
).rejects.toThrow("process.exit:2");
expect(logs.map((line) => JSON.parse(line))).toEqual([
{ event: "error", error: "tool server unreachable" },
]);
expect(errs).toEqual(["tool server unreachable"]);
});
it("emits a structured error for a primitive non-report in streaming mode", async () => {
toolsClientMock.callTool.mockResolvedValue({ data: "not-a-report" });
await expect(flow(["run", checkoutPath, "--json-stream"], opts)).rejects.toThrow(
"process.exit:2"
);
expect(logs.map((line) => JSON.parse(line))).toEqual([
{ event: "error", error: '"checkout" did not produce a run report.' },
]);
expect(errs.join("\n")).toContain('"checkout" did not produce a run report.');
});
it("renders failed-snapshot handles as server paths without fetching when --output is absent", async () => {
toolsClientMock.callTool.mockResolvedValue({
data: report({
ok: false,
passed: 0,
failed: 1,
steps: [
{
index: 0,
kind: "snapshot",
status: "fail",
reason: "1.2% differs",
snapshotKey: "home__ios-390x844",
artifacts: {
baseline: handle("/srv/base.png"),
current: handle("/srv/cur.png"),
diff: handle("/srv/diff.png"),
},
},
],
}),
});
await expect(flow(["run", checkoutPath], opts)).rejects.toThrow("process.exit:1");
// Nothing to download: paths come straight off the handles, and the
// server URL is never even resolved.
expect(materializeArtifactsMock).not.toHaveBeenCalled();
expect(toolsClientMock.baseUrl).not.toHaveBeenCalled();
const out = logs.join("\n");
expect(out).toContain("baseline: /srv/base.png");
expect(out).toContain("current: /srv/cur.png");
expect(out).toContain("diff: /srv/diff.png");
});
it("never materializes tool-step results (the CLI renders no images)", async () => {
toolsClientMock.callTool.mockResolvedValue({
data: report({
steps: [
{
index: 0,
kind: "tool",
tool: "screenshot",
status: "pass",
result: { image: handle("/srv/shot.png") },
},
],
}),
});
await expect(flow(["run", checkoutPath], opts)).rejects.toThrow("process.exit:0");
expect(materializeArtifactsMock).not.toHaveBeenCalled();
const out = logs.join("\n");
expect(out).toMatch(/✓ {2}1 tool screenshot/);
expect(out).toContain("PASS — 1 passed");
});
it("materializes only the failed snapshot's artifacts when --output is set", async () => {
const failedArtifacts = { baseline: handle("/srv/base.png") };
toolsClientMock.callTool.mockResolvedValue({
data: report({
ok: false,
failed: 1,
steps: [
{
index: 0,
kind: "tool",
tool: "screenshot",
status: "pass",
result: { image: handle("/srv/shot.png") },
},
{
index: 1,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: failedArtifacts,
},
],
}),
});
await expect(flow(["run", checkoutPath, "--output", "flow-artifacts"], opts)).rejects.toThrow(
"process.exit:1"
);
// One materialization, scoped to the failed snapshot's artifacts object —
// not the whole report (which would pull the tool-step screenshot too).
expect(toolsClientMock.baseUrl).toHaveBeenCalledTimes(1);
expect(materializeArtifactsMock).toHaveBeenCalledTimes(1);
expect(materializeArtifactsMock).toHaveBeenCalledWith(failedArtifacts, {
toolsUrl: "http://127.0.0.1:4141",
authToken: "tok",
});
});
it("emits string artifact paths in --json without --output (hostPath, or filename)", async () => {
toolsClientMock.callTool.mockResolvedValue({
data: report({
ok: false,
passed: 0,
failed: 1,
steps: [
{
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { baseline: handle("/srv/base.png"), diff: handle() },
},
],
}),
});
await expect(flow(["run", checkoutPath, "--json"], opts)).rejects.toThrow("process.exit:1");
expect(materializeArtifactsMock).not.toHaveBeenCalled();
const parsed = JSON.parse(logs.join("\n")) as {
steps: { artifacts?: Record<string, unknown> }[];
};
// Strings, not handle objects: hostPath when present, filename otherwise.
expect(parsed.steps[0]?.artifacts).toEqual({ baseline: "/srv/base.png", diff: "art.png" });
});
it("prints legacy string artifact paths as-is (pre-handle tool-server)", async () => {
toolsClientMock.callTool.mockResolvedValue({
data: report({
ok: false,
passed: 0,
failed: 1,
steps: [
{
index: 0,
kind: "snapshot",
status: "fail",
artifacts: { baseline: "/tmp/snaps/home.png", diff: "/tmp/snaps/home-diff.png" },
},
],
}),
});
await expect(flow(["run", checkoutPath], opts)).rejects.toThrow("process.exit:1");
expect(materializeArtifactsMock).not.toHaveBeenCalled();
const out = logs.join("\n");
expect(out).toContain("baseline: /tmp/snaps/home.png");
expect(out).toContain("diff: /tmp/snaps/home-diff.png");
});
it("exits 1 with the error message when the tool call fails", async () => {
toolsClientMock.callTool.mockRejectedValue(new Error("tool-server unreachable"));
await expect(flow(["run", checkoutPath], opts)).rejects.toThrow("process.exit:1");
expect(errs.join("\n")).toContain("tool-server unreachable");
});
it("exits 2 when the result is not a run report (e.g. a prerequisite notice)", async () => {
toolsClientMock.callTool.mockResolvedValue({
data: { flow: "checkout", notice: "prerequisite", executionPrerequisite: "logged in" },
});
await expect(flow(["run", checkoutPath], opts)).rejects.toThrow("process.exit:2");
expect(errs.join("\n")).toContain('"checkout" did not produce a run report');
});
it("exits 2 on an unknown subcommand", async () => {
await expect(flow(["frobnicate"], opts)).rejects.toThrow("process.exit:2");
expect(errs.join("\n")).toContain('Unknown flow subcommand "frobnicate"');
});
it("prints help and returns (no exit) with no subcommand", async () => {
await flow([], opts);
expect(logs.join("\n")).toContain("Usage: argent flow");
expect(logs.join("\n")).toContain(
"filename (minus .yaml) names the run's report and artifacts"
);
expect(logs.join("\n")).toContain('contain only letters, numbers, "_", or "-"');
expect(logs.join("\n")).toContain(
"ARGENT_TOOLS_URL and `argent link` routing are not supported"
);
expect(logs.join("\n")).toContain("--json-stream");
expect(getResolvedToolsUrlMock).not.toHaveBeenCalled();
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
it("prints help instead of running when --help follows the flow name", async () => {
await flow(["run", "checkout", "--help"], opts);
expect(logs.join("\n")).toContain("Options (run):");
expect(getResolvedToolsUrlMock).not.toHaveBeenCalled();
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
it("prints help instead of running when -h trails other run flags", async () => {
await flow(["run", "checkout", "--device", "SIM-1", "-h"], opts);
expect(logs.join("\n")).toContain("Usage: argent flow");
expect(getResolvedToolsUrlMock).not.toHaveBeenCalled();
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
});
describe("argent flow run <dir>", () => {
let tempRoot: string;
let flowsDir: string;
let exitSpy: ReturnType<typeof vi.spyOn>;
let logs: string[];
let errs: string[];
let logSpy: ReturnType<typeof vi.spyOn>;
let errSpy: ReturnType<typeof vi.spyOn>;
const opts = { paths: {} as never };
beforeAll(async () => {
tempRoot = await fsp.mkdtemp(path.join(tmpdir(), "argent-cli-flow-dir-"));
flowsDir = path.join(tempRoot, "flows");
// Two runnable top-level flows plus every kind of entry discovery must
// ignore: wrong extension, unsafe stem, a directory named like a flow,
// and (non-recursively) anything nested.
await fsp.mkdir(path.join(flowsDir, "sub"), { recursive: true });
await fsp.mkdir(path.join(flowsDir, ".hidden"), { recursive: true });
await fsp.mkdir(path.join(flowsDir, "node_modules"), { recursive: true });
await fsp.mkdir(path.join(flowsDir, "bundle.yaml"), { recursive: true });
await Promise.all([
fsp.writeFile(path.join(flowsDir, "a-login.yaml"), "steps: []\n"),
fsp.writeFile(path.join(flowsDir, "b-checkout.yaml"), "steps: []\n"),
fsp.writeFile(path.join(flowsDir, "notes.yml"), "steps: []\n"),
fsp.writeFile(path.join(flowsDir, "bad name.yaml"), "steps: []\n"),
fsp.writeFile(path.join(flowsDir, "sub", "c-search.yaml"), "steps: []\n"),
fsp.writeFile(path.join(flowsDir, ".hidden", "hidden.yaml"), "steps: []\n"),
fsp.writeFile(path.join(flowsDir, "node_modules", "dep.yaml"), "steps: []\n"),
]);
});
afterAll(async () => {
await fsp.rm(tempRoot, { recursive: true, force: true });
});
beforeEach(() => {
vi.clearAllMocks();
getResolvedToolsUrlMock.mockResolvedValue({ url: null, source: "none" });
toolsClientMock.callTool.mockResolvedValue({ data: report() });
logs = [];
errs = [];
logSpy = vi.spyOn(console, "log").mockImplementation((...a) => void logs.push(a.join(" ")));
errSpy = vi.spyOn(console, "error").mockImplementation((...a) => void errs.push(a.join(" ")));
exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code}`);
}) as typeof process.exit);
});
afterEach(() => {
exitSpy.mockRestore();
logSpy.mockRestore();
errSpy.mockRestore();
});
it("runs each top-level flow in order without live progress and exits 0 when all pass", async () => {
await expect(flow(["run", flowsDir], opts)).rejects.toThrow("process.exit:0");
// Only the two safe top-level .yaml files, in lexicographic order, and
// never with onProgress (batch output is failures-only).
expect(toolsClientMock.callTool).toHaveBeenCalledTimes(2);
expect(toolsClientMock.callTool).toHaveBeenNthCalledWith(1, "flow-execute", {
flow_path: path.join(flowsDir, "a-login.yaml"),
project_root: process.cwd(),
prerequisiteAcknowledged: true,
});
expect(toolsClientMock.callTool).toHaveBeenNthCalledWith(2, "flow-execute", {
flow_path: path.join(flowsDir, "b-checkout.yaml"),
project_root: process.cwd(),
prerequisiteAcknowledged: true,
});
const out = logs.join("\n");
expect(out).toContain("[1/2] a-login.yaml");
expect(out).toContain("[2/2] b-checkout.yaml");
expect(out).toContain("PASS (started on SIM-1) — 1 passed, 0 failed, 0 errored, 0 skipped");
// Passing steps stay silent in batch mode.
expect(out).not.toMatch(/✓ {2}1 tap/);
expect(out).toContain("PASS — 2 flows: 2 passed, 0 failed, 0 skipped");
});
it("rejects directory runs with --json-stream", async () => {
await expect(flow(["run", flowsDir, "--json-stream"], opts)).rejects.toThrow("process.exit:2");
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
expect(logs.map((line) => JSON.parse(line))).toEqual([
{
event: "error",
error: "--json-stream supports a single flow; directory runs are not supported.",
},
]);
expect(errs.join("\n")).toContain(
"--json-stream supports a single flow; directory runs are not supported"
);
});
it("forwards run flags to every flow in the batch", async () => {
await expect(
flow(["run", flowsDir, "--device", "SIM-1", "--platform", "ios", "--update-baselines"], opts)
).rejects.toThrow("process.exit:0");
for (const call of toolsClientMock.callTool.mock.calls) {
expect(call[1]).toMatchObject({ device: "SIM-1", platform: "ios", updateBaselines: true });
}
});
it("continues after a failed flow, printing only its failing steps, and exits 1", async () => {
toolsClientMock.callTool
.mockResolvedValueOnce({
data: report({
flow: "a-login",
ok: false,
passed: 1,
failed: 1,
steps: [
{ index: 0, kind: "tap", status: "pass" },
{ index: 1, kind: "assert", status: "fail", reason: "never visible" },
],
}),
})
.mockResolvedValueOnce({ data: report({ flow: "b-checkout" }) });
await expect(flow(["run", flowsDir], opts)).rejects.toThrow("process.exit:1");
expect(toolsClientMock.callTool).toHaveBeenCalledTimes(2);
const out = logs.join("\n");
// The failing step keeps its full-report number; the passing tap is silent.
expect(out).toMatch(/✗ {2}2 assert — never visible/);
expect(out).not.toMatch(/✓ {2}1 tap/);
expect(out).toContain("FAIL (started on SIM-1) — 1 passed, 1 failed, 0 errored, 0 skipped");
expect(out).toContain("FAIL — 2 flows: 1 passed, 1 failed, 0 skipped");
});
it("stops the batch on a tool-call throw and counts the remaining flows skipped", async () => {
toolsClientMock.callTool
.mockResolvedValueOnce({ data: report({ flow: "a-login" }) })
.mockRejectedValueOnce(new Error("tool-server unreachable"));
await expect(flow(["run", flowsDir, "-r"], opts)).rejects.toThrow("process.exit:1");
expect(toolsClientMock.callTool).toHaveBeenCalledTimes(2);
expect(errs.join("\n")).toContain("tool-server unreachable");
const out = logs.join("\n");
expect(out).toContain(`[3/3] ${path.join("sub", "c-search.yaml")}`);
expect(out).toContain("· not run (batch stopped)");
expect(out).toContain("FAIL — 3 flows: 1 passed, 1 failed, 1 skipped");
});
it("continues past a flow the tool-server rejects as invalid", async () => {
toolsClientMock.callTool
.mockRejectedValueOnce(
new ToolInvocationError("flow file is not valid YAML", {
errorCode: "FLOW_FILE_INVALID",
errorKind: "validation",
})
)
.mockResolvedValueOnce({ data: report({ flow: "b-checkout" }) });
await expect(flow(["run", flowsDir], opts)).rejects.toThrow("process.exit:1");
// A validation rejection is specific to that flow file — the batch ran on.
expect(toolsClientMock.callTool).toHaveBeenCalledTimes(2);
expect(errs.join("\n")).toContain("flow file is not valid YAML");
const out = logs.join("\n");
expect(out).not.toContain("not run (batch stopped)");
expect(out).toContain("FAIL — 2 flows: 1 passed, 1 failed, 0 skipped");
});
it("stops the batch on a server error the signal does not mark as validation", async () => {
toolsClientMock.callTool.mockRejectedValueOnce(
new ToolInvocationError("simulator boot failed", { errorKind: "subprocess" })
);
await expect(flow(["run", flowsDir], opts)).rejects.toThrow("process.exit:1");
expect(toolsClientMock.callTool).toHaveBeenCalledTimes(1);
expect(logs.join("\n")).toContain("FAIL — 2 flows: 0 passed, 1 failed, 1 skipped");
});
it("treats a non-report result as a failure that stops the batch", async () => {
toolsClientMock.callTool.mockResolvedValueOnce({
data: { flow: "a-login", notice: "prerequisite" },
});
await expect(flow(["run", flowsDir], opts)).rejects.toThrow("process.exit:1");
expect(toolsClientMock.callTool).toHaveBeenCalledTimes(1);
expect(errs.join("\n")).toContain('"a-login.yaml" did not produce a run report');
expect(logs.join("\n")).toContain("FAIL — 2 flows: 0 passed, 1 failed, 1 skipped");
});
it("finds nested flows with --recursive, skipping dot-directories and node_modules", async () => {
await expect(flow(["run", flowsDir, "--recursive"], opts)).rejects.toThrow("process.exit:0");
const flowPaths = toolsClientMock.callTool.mock.calls.map(
(c) => (c[1] as { flow_path: string }).flow_path
);
expect(flowPaths).toEqual([
path.join(flowsDir, "a-login.yaml"),
path.join(flowsDir, "b-checkout.yaml"),
path.join(flowsDir, "sub", "c-search.yaml"),
]);
expect(logs.join("\n")).toContain(`[3/3] ${path.join("sub", "c-search.yaml")}`);
});
it("treats a directory named like a flow file as a directory", async () => {
const dirYaml = path.join(tempRoot, "suite.yaml");
await fsp.mkdir(dirYaml, { recursive: true });
await fsp.writeFile(path.join(dirYaml, "one.yaml"), "steps: []\n");
await expect(flow(["run", dirYaml], opts)).rejects.toThrow("process.exit:0");
expect(toolsClientMock.callTool).toHaveBeenCalledWith("flow-execute", {
flow_path: path.join(dirYaml, "one.yaml"),
project_root: process.cwd(),
prerequisiteAcknowledged: true,
});
expect(logs.join("\n")).toContain("PASS — 1 flow: 1 passed, 0 failed, 0 skipped");
});
it("exits 2 when --recursive is given a file path", async () => {
await expect(flow(["run", path.join(flowsDir, "a-login.yaml"), "-r"], opts)).rejects.toThrow(
"process.exit:2"
);
expect(errs.join("\n")).toContain(
`flow run --recursive requires a directory path: ${path.join(flowsDir, "a-login.yaml")}`
);
expect(getResolvedToolsUrlMock).not.toHaveBeenCalled();
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
it("exits 2 when --recursive is given a nonexistent path", async () => {
const missing = path.join(tempRoot, "missing-dir");
await expect(flow(["run", missing, "-r"], opts)).rejects.toThrow("process.exit:2");
expect(errs.join("\n")).toContain(`Flow directory not found: ${missing}`);
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
it("exits 2 with a --recursive hint when a directory holds no flows", async () => {
const emptyDir = path.join(tempRoot, "empty");
await fsp.mkdir(emptyDir, { recursive: true });
await expect(flow(["run", emptyDir], opts)).rejects.toThrow("process.exit:2");
expect(errs.join("\n")).toContain(`No flows found in ${emptyDir}`);
expect(errs.join("\n")).toContain("-r/--recursive");
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
// Already recursive: the hint would be a dead end, so it is omitted.
errs.length = 0;
await expect(flow(["run", emptyDir, "-r"], opts)).rejects.toThrow("process.exit:2");
expect(errs.join("\n")).toContain("No flows found");
expect(errs.join("\n")).not.toContain("subdirectories");
});
it("rejects remote routing before running any flow in the directory", async () => {
getResolvedToolsUrlMock.mockResolvedValue({ url: "http://example.test:4141", source: "env" });
await expect(flow(["run", flowsDir], opts)).rejects.toThrow("process.exit:2");
expect(errs.join("\n")).toContain("requires the auto-started local tool server");
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
});
it("prints only the aggregate object with --json, tagging infra failures and skips", async () => {
toolsClientMock.callTool
.mockResolvedValueOnce({ data: report({ flow: "a-login" }) })
.mockRejectedValueOnce(new Error("boom"));
await expect(flow(["run", flowsDir, "--json", "-r"], opts)).rejects.toThrow("process.exit:1");
// A single parseable object on stdout — no headers or verdict lines.
const parsed = JSON.parse(logs.join("\n")) as Record<string, unknown>;
expect(parsed).toEqual({
ok: false,
total: 3,
passed: 1,
failed: 1,
skipped: 1,
flows: [
{ path: "a-login.yaml", status: "pass", report: report({ flow: "a-login" }) },
{ path: "b-checkout.yaml", status: "fail", error: "boom" },
{ path: path.join("sub", "c-search.yaml"), status: "skip" },
],
});
});
it("keys --output exports by the flow's subdirectory in recursive runs", async () => {
const diffSrc = path.join(tempRoot, "diff-src.png");
await fsp.writeFile(diffSrc, "png-bytes");
toolsClientMock.callTool
.mockResolvedValueOnce({ data: report({ flow: "a-login" }) })
.mockResolvedValueOnce({ data: report({ flow: "b-checkout" }) })
.mockResolvedValueOnce({
data: report({
flow: "c-search",
ok: false,
passed: 0,
failed: 1,
steps: [
{
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { diff: diffSrc },
},
],
}),
});
const outDir = path.join(tempRoot, "out");
await expect(flow(["run", flowsDir, "-r", "--output", outDir], opts)).rejects.toThrow(
"process.exit:1"
);
// The nested flow's export lands under its subdirectory so a same-stem
// flow at the top level cannot collide with it.
const dest = path.join(outDir, "sub", "c-search", "home__ios-390x844-diff.png");
await expect(fsp.readFile(dest, "utf8")).resolves.toBe("png-bytes");
expect(logs.join("\n")).toContain(`diff: ${dest}`);
});
it("keeps a top-level batch flow's --output export at <output>/<flow> exactly", async () => {
const diffSrc = path.join(tempRoot, "top-diff-src.png");
await fsp.writeFile(diffSrc, "top-png-bytes");
toolsClientMock.callTool
.mockResolvedValueOnce({ data: report({ flow: "a-login" }) })
.mockResolvedValueOnce({
data: report({
flow: "b-checkout",
ok: false,
passed: 0,
failed: 1,
steps: [
{
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { diff: diffSrc },
},
],
}),
});
const outDir = path.join(tempRoot, "out-top");
await expect(flow(["run", flowsDir, "--output", outDir], opts)).rejects.toThrow(
"process.exit:1"
);
// dirname(rel) is "." at the top level and must collapse into the base —
// the single-run <output>/<flow>/ layout, not <output>/<flow>.yaml/....
const dest = path.join(outDir, "b-checkout", "home__ios-390x844-diff.png");
await expect(fsp.readFile(dest, "utf8")).resolves.toBe("top-png-bytes");
expect(logs.join("\n")).toContain(`diff: ${dest}`);
});
it("skips an unreadable nested subdirectory instead of aborting discovery", async () => {
// Root ignores mode bits, so the unreadable-subtree scenario cannot occur.
if (process.getuid?.() === 0) return;
const suiteDir = path.join(tempRoot, "suite");
const lockedDir = path.join(suiteDir, "locked");
await fsp.mkdir(lockedDir, { recursive: true });
await fsp.writeFile(path.join(suiteDir, "open.yaml"), "steps: []\n");
await fsp.writeFile(path.join(lockedDir, "hidden.yaml"), "steps: []\n");
await fsp.chmod(lockedDir, 0o000);
try {
await expect(flow(["run", suiteDir, "-r"], opts)).rejects.toThrow("process.exit:0");
// The readable top-level flow still ran; the locked subtree was skipped
// without the top-level "Could not read flow directory" abort.
expect(toolsClientMock.callTool).toHaveBeenCalledTimes(1);
expect(toolsClientMock.callTool).toHaveBeenCalledWith(
"flow-execute",
expect.objectContaining({ flow_path: path.join(suiteDir, "open.yaml") })
);
expect(errs.join("\n")).not.toContain("Could not read flow directory");
} finally {
await fsp.chmod(lockedDir, 0o755);
}
});
});
describe("exitAfterFlush", () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code}`);
}) as typeof process.exit);
});
afterEach(() => {
exitSpy.mockRestore();
});
it("exits only after every queued write has drained (piped stdout is async)", async () => {
// Model a pipe with a slow reader: each chunk sits in the stream's queue
// until _write's deferred callback fires — exactly the state a >64KB
// `--json` report is in when the old bare process.exit() truncated it.
const flushed: string[] = [];
let exitedEarly = false;
const slow = new Writable({
highWaterMark: 1,
write(chunk: Buffer, _enc, cb) {
setTimeout(() => {
if (exitSpy.mock.calls.length > 0) exitedEarly = true;
flushed.push(chunk.toString());
cb();
}, 5);
},
});
slow.write("a".repeat(64 * 1024));
slow.write("b".repeat(64 * 1024));
await expect(exitAfterFlush(1, [slow])).rejects.toThrow("process.exit:1");
expect(exitedEarly).toBe(false);
expect(flushed.join("")).toContain("a".repeat(64 * 1024));
expect(flushed.join("")).toContain("b".repeat(64 * 1024));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("preserves the exit code with nothing queued", async () => {
const idle = new Writable({ write: (_c, _e, cb) => cb() });
await expect(exitAfterFlush(2, [idle])).rejects.toThrow("process.exit:2");
expect(exitSpy).toHaveBeenCalledWith(2);
});
});