mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
a0d1bbb1e7
Flow reports rendered every step flat, hiding which steps ran inside a `when:` block or a `run:` fragment. The tool-server now stamps `StepReport.depth` (+1 per block directive, omitted at top level) and the CLI/MCP renderers indent step labels and under-step lines by it: ``` ✓ 1 launch ✓ 2 when visible "What's new" — condition met ✓ 3 tap "Skip" ✓ 4 run dismiss [dismiss] ✓ 5 tap "OK" [dismiss] ``` Invariants: - **Wire-compatible both ways** — a flow with no block directives reports byte-identical to the pre-depth shape; a pre-depth server renders flat as before. - **Skipped ≡ entered** — skipped/errored/aborted blocks report the same depths as entered ones, keeping reports comparable run-to-run. - **Untrusted wire data** — renderers clamp depth (display cap, not a producer bound): hostile values render flat or at the cap, never throw or allocate huge strings. Also fixes a pre-existing bug: under-step warning/artifact lines now stay aligned past 99 steps. Tests pin depth on every producer path (entered/skipped/errored/aborted blocks, `run:` success/failure markers, `run:`-in-`when:`) and renderer alignment incl. the clamp boundary. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import type { Registry } from "@argent/registry";
|
|
import { createRunFlowTool, type FlowRunResult } from "../../src/tools/flows/flow-run";
|
|
import { serializeFlow, parseFlow } from "../../src/tools/flows/flow-utils";
|
|
import { bindDeviceArgs, stripDeviceKeys } from "../../src/tools/flows/flow-device";
|
|
|
|
const DEVICE = "00000000-0000-0000-0000-0000000000ab";
|
|
let tmpDir: string;
|
|
|
|
function mockRegistry(): Registry {
|
|
return {
|
|
invokeTool: vi.fn(async (id: string) => {
|
|
if (id === "list-devices") return { devices: [] };
|
|
return { ok: true };
|
|
}),
|
|
getTool: vi.fn(() => undefined),
|
|
// iOS launch steps gate on a native-devtools connection: report connected
|
|
// so the run proceeds. No selector directives run in these tests, so the
|
|
// flow tree is never fetched.
|
|
resolveService: vi.fn(async () => ({
|
|
isConnected: () => true,
|
|
listConnectedBundleIds: () => [],
|
|
})),
|
|
} as unknown as Registry;
|
|
}
|
|
|
|
async function writeFlow(name: string, yaml: Parameters<typeof serializeFlow>[0]): Promise<void> {
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
await fs.writeFile(path.join(dir, `${name}.yaml`), serializeFlow(yaml), "utf8");
|
|
}
|
|
|
|
function asRun(r: FlowRunResult | { notice: string }): FlowRunResult {
|
|
if (!("steps" in r)) throw new Error(`expected a run result, got notice: ${r.notice}`);
|
|
return r;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-compose-"));
|
|
});
|
|
afterEach(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("flow composition (run:)", () => {
|
|
it("expands a referenced fragment's steps inline", async () => {
|
|
await writeFlow("login", {
|
|
executionPrerequisite: "On login screen",
|
|
steps: [
|
|
{ kind: "echo", message: "logging in" },
|
|
{ kind: "tool", name: "tap", args: { x: 0.5 } },
|
|
],
|
|
});
|
|
await writeFlow("main", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "run", flow: "login" },
|
|
{ kind: "echo", message: "done" },
|
|
],
|
|
});
|
|
|
|
const runFlow = createRunFlowTool(mockRegistry());
|
|
const result = asRun(
|
|
await runFlow.execute({}, { name: "main", project_root: tmpDir, device: DEVICE })
|
|
);
|
|
|
|
// run marker, login's echo + tap, then main's echo.
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
|
|
"run:pass",
|
|
"echo:pass",
|
|
"tool:pass",
|
|
"echo:pass",
|
|
]);
|
|
// The expanded steps are attributed to the fragment.
|
|
expect(result.steps[1].flow).toBe("login");
|
|
expect(result.steps[3].flow).toBe("main");
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it("stamps nesting depth on expanded fragment steps (omitted at top level)", async () => {
|
|
await writeFlow("inner", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "echo", message: "deepest" }],
|
|
});
|
|
await writeFlow("login", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "tool", name: "tap", args: { x: 0.5 } },
|
|
{ kind: "run", flow: "inner" },
|
|
],
|
|
});
|
|
await writeFlow("main", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "run", flow: "login" },
|
|
{ kind: "echo", message: "done" },
|
|
],
|
|
});
|
|
|
|
const runFlow = createRunFlowTool(mockRegistry());
|
|
const result = asRun(
|
|
await runFlow.execute({}, { name: "main", project_root: tmpDir, device: DEVICE })
|
|
);
|
|
|
|
// Each run marker sits at its enclosing depth; the fragment it expands runs
|
|
// one deeper. Top-level steps omit the field entirely, so a flow with no
|
|
// block directives reports byte-identically to the pre-depth shape.
|
|
expect(result.steps.map((s) => `${s.kind}:${s.depth ?? 0}`)).toEqual([
|
|
"run:0",
|
|
"tool:1",
|
|
"run:1",
|
|
"echo:2",
|
|
"echo:0",
|
|
]);
|
|
expect(result.steps[0].depth).toBeUndefined();
|
|
expect(result.steps[4].depth).toBeUndefined();
|
|
});
|
|
|
|
it("expands a referenced e2e flow inline, launch step and all", async () => {
|
|
await writeFlow("other-e2e", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "launch", app: "com.acme.app" },
|
|
{ kind: "echo", message: "in nested e2e" },
|
|
],
|
|
});
|
|
await writeFlow("main", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "run", flow: "other-e2e" }],
|
|
});
|
|
const runFlow = createRunFlowTool(mockRegistry());
|
|
const result = asRun(
|
|
await runFlow.execute({}, { name: "main", project_root: tmpDir, device: DEVICE })
|
|
);
|
|
// run marker, then the nested e2e's launch + echo expanded inline.
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
|
|
"run:pass",
|
|
"launch:pass",
|
|
"echo:pass",
|
|
]);
|
|
expect(result.steps[1].flow).toBe("other-e2e");
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it("detects a cyclic run reference", async () => {
|
|
await writeFlow("a", { executionPrerequisite: "", steps: [{ kind: "run", flow: "b" }] });
|
|
await writeFlow("b", { executionPrerequisite: "", steps: [{ kind: "run", flow: "a" }] });
|
|
await writeFlow("main", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "run", flow: "a" }],
|
|
});
|
|
const runFlow = createRunFlowTool(mockRegistry());
|
|
const result = asRun(
|
|
await runFlow.execute({}, { name: "main", project_root: tmpDir, device: DEVICE })
|
|
);
|
|
const errored = result.steps.find((s) => s.status === "error");
|
|
expect(errored?.reason).toMatch(/cyclic/i);
|
|
// The cycle is detected two fragments down; its error marker keeps that
|
|
// depth (the fail() path stamps depthOf(scope) like the success marker),
|
|
// so the error line renders inside the block that caused it.
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}:${s.depth ?? 0}`)).toEqual([
|
|
"run:pass:0",
|
|
"run:pass:1",
|
|
"run:error:2",
|
|
]);
|
|
});
|
|
|
|
it("executes a leading launch step from scratch (restart-app) and reports it", async () => {
|
|
await writeFlow("main", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "launch", app: "com.acme.app" },
|
|
{ kind: "echo", message: "running" },
|
|
],
|
|
});
|
|
const registry = mockRegistry();
|
|
const result = asRun(
|
|
await createRunFlowTool(registry).execute(
|
|
{},
|
|
{ name: "main", project_root: tmpDir, device: DEVICE }
|
|
)
|
|
);
|
|
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["launch:pass", "echo:pass"]);
|
|
// e2e contract: terminate + relaunch, so a running copy can't leak state.
|
|
expect(registry.invokeTool).toHaveBeenCalledWith("restart-app", { bundleId: "com.acme.app" });
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it("errors the launch step when no app id is declared for the platform", async () => {
|
|
await writeFlow("main", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "launch", app: { android: "com.acme.app" } }, // DEVICE is iOS
|
|
{ kind: "echo", message: "should never run" },
|
|
],
|
|
});
|
|
const result = asRun(
|
|
await createRunFlowTool(mockRegistry()).execute(
|
|
{},
|
|
{ name: "main", project_root: tmpDir, device: DEVICE }
|
|
)
|
|
);
|
|
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["launch:error", "echo:skip"]);
|
|
expect(result.steps[0].reason).toMatch(/no app id declared for platform/i);
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it("errors the launch step when native devtools never connects on iOS", async () => {
|
|
await writeFlow("main", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "launch", app: "com.acme.app" },
|
|
{ kind: "echo", message: "should never run" },
|
|
],
|
|
});
|
|
// Registry whose native-devtools service is unavailable: the launch step
|
|
// must fail rather than let selectors silently fall back to the AX tree.
|
|
// (An unresolvable service fails fast; a resolvable-but-never-connected
|
|
// one hits the same guard after the connect timeout.)
|
|
const registry = {
|
|
invokeTool: vi.fn(async (id: string) =>
|
|
id === "list-devices" ? { devices: [] } : { ok: true }
|
|
),
|
|
getTool: vi.fn(() => undefined),
|
|
resolveService: vi.fn(async () => {
|
|
throw new Error("native-devtools unavailable");
|
|
}),
|
|
} as unknown as Registry;
|
|
|
|
const result = asRun(
|
|
await createRunFlowTool(registry).execute(
|
|
{},
|
|
{ name: "main", project_root: tmpDir, device: DEVICE }
|
|
)
|
|
);
|
|
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["launch:error", "echo:skip"]);
|
|
expect(result.steps[0].reason).toMatch(/could not connect to native devtools/i);
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("device binding (portability)", () => {
|
|
const reg = (props: Record<string, unknown>) =>
|
|
({ getTool: () => ({ inputSchema: { properties: props } }) }) as unknown as Registry;
|
|
|
|
it("the resolved device wins over a stale stored udid", () => {
|
|
const out = bindDeviceArgs(reg({ udid: {}, x: {}, y: {} }), "gesture-tap", "RESOLVED", {
|
|
udid: "STALE",
|
|
x: 0.5,
|
|
y: 0.5,
|
|
});
|
|
expect(out).toEqual({ udid: "RESOLVED", x: 0.5, y: 0.5 });
|
|
});
|
|
|
|
it("drops a device id entirely for a tool that doesn't declare one", () => {
|
|
const out = bindDeviceArgs(reg({ foo: {} }), "x", "R", { device_id: "STALE", foo: 1 });
|
|
expect(out).toEqual({ foo: 1 });
|
|
});
|
|
|
|
it("stripDeviceKeys removes udid / device_id", () => {
|
|
expect(stripDeviceKeys({ udid: "A", device_id: "B", x: 1 })).toEqual({ x: 1 });
|
|
});
|
|
});
|
|
|
|
describe("flow validation", () => {
|
|
it("rejects an e2e flow that declares executionPrerequisite", () => {
|
|
expect(() =>
|
|
parseFlow("executionPrerequisite: nope\nsteps:\n - launch: com.acme.app\n")
|
|
).toThrow(/must not declare executionPrerequisite/i);
|
|
});
|
|
|
|
it("a leading echo does not hide the launch step from the e2e check", () => {
|
|
expect(() =>
|
|
parseFlow(
|
|
"executionPrerequisite: nope\nsteps:\n - echo: starting\n - launch: com.acme.app\n"
|
|
)
|
|
).toThrow(/must not declare executionPrerequisite/i);
|
|
});
|
|
|
|
it("rejects a path-unsafe snapshot name (no traversal into baseline path)", () => {
|
|
expect(() => parseFlow("steps:\n - snapshot:\n name: ../../etc/evil\n")).toThrow(
|
|
/snapshot name/i
|
|
);
|
|
});
|
|
|
|
it("round-trips the new step kinds through YAML", () => {
|
|
const flow = {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "launch" as const, app: "com.acme.app" },
|
|
// Text-only selectors serialize to bare strings, which parse back loose.
|
|
{ kind: "tap" as const, selector: { text: "Login", loose: true } },
|
|
{ kind: "tap" as const, x: 0.5, y: 0.57 },
|
|
{ kind: "type" as const, into: { identifier: "email" }, text: "a@b.com" },
|
|
{
|
|
kind: "assert" as const,
|
|
condition: "visible" as const,
|
|
selector: { text: "Welcome", loose: true },
|
|
},
|
|
{ kind: "snapshot" as const, name: "home", maxMismatch: 0.5 },
|
|
{ kind: "run" as const, flow: "login" },
|
|
// Mid-flow relaunch with a per-platform map.
|
|
{ kind: "launch" as const, app: { ios: "com.acme.app", android: "com.acme.android" } },
|
|
],
|
|
};
|
|
const parsed = parseFlow(serializeFlow(flow));
|
|
expect(parsed.steps).toEqual(flow.steps);
|
|
});
|
|
|
|
it("rejects a launch step with an invalid body", () => {
|
|
// An unrecognized platform key is named (strict unknown-key rejection)…
|
|
expect(() => parseFlow("steps:\n - launch: { web: foo }\n")).toThrow(
|
|
/launch has unknown key `web`/
|
|
);
|
|
// …while a non-map, non-string body still gets the shape error.
|
|
expect(() => parseFlow("steps:\n - launch: 42\n")).toThrow(/launch needs an app id/i);
|
|
});
|
|
});
|