mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
cc2f907ce9
A `run:` value is now a YAML path resolved against the directory of the flow file that contains the step, matching the CLI cutover: ```yaml - run: login.yaml - run: ../shared/login.yaml # cross-directory composition now works ``` Bare names fail at parse with a migration hint (`run: login` → `run: login.yaml`). The filename stem stays the fragment's report attribution; reports also carry the as-written path as `target`. Cycle detection compares realpath-canonical paths (same stem in two dirs ≠ cycle; same file via symlink/spelling = cycle). Snapshot baselines are unchanged — keyed by and stored beside the root flow. An uploaded root flow with `run:` steps is now rejected up front with a contract error instead of a mid-run temp-dir ENOENT (its referenced files stayed on the client, and relative resolution in the temp dir could reach host files); same for a relative chromium app path. The recorder emits the sibling form (`run: <name>.yaml`). **Breaking:** existing `run: <name>` directives fail at parse until renamed to `run: <name>.yaml`. Snapshot baselines for a saved flow whose YAML is a symlink now anchor beside the real file (the symlink target's directory) — move any committed `.argent/flows/__baselines__/<flow>/` set beside the real file once, or the next run fails with a missing baseline. A `run:` target must also resolve inside the project root or beside/below the flow file that references it (arbitrary host YAML is no longer reachable), and an uploaded flow with a `snapshot` step is rejected up front (its baselines cannot anchor to the per-call temp dir).
213 lines
7.0 KiB
TypeScript
213 lines
7.0 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, ToolContext } from "@argent/registry";
|
|
import {
|
|
createRunFlowTool,
|
|
type FlowRunResult,
|
|
type StepReport,
|
|
} from "../../src/tools/flows/flow-run";
|
|
import { serializeFlow } from "../../src/tools/flows/flow-utils";
|
|
|
|
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: [] };
|
|
if (id === "boom") throw new Error("kaput");
|
|
return { ok: true };
|
|
}),
|
|
getTool: vi.fn(() => undefined),
|
|
// iOS flows gate on a native-devtools connection: report connected so the
|
|
// run proceeds, but expose no target app so the tree fetch falls back to
|
|
// the AX tree (these tests don't drive the native hierarchy).
|
|
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;
|
|
}
|
|
|
|
/** ToolContext carrying only the progress hook — the flows under test touch nothing else. */
|
|
function progressCtx(events: StepReport[]): ToolContext {
|
|
return { emitProgress: (e: unknown) => events.push(e as StepReport) } as unknown as ToolContext;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-progress-"));
|
|
});
|
|
afterEach(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("flow progress streaming (ctx.emitProgress)", () => {
|
|
it("emits every report in execution order, identical to the final result", async () => {
|
|
await writeFlow("main", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "echo", message: "starting" },
|
|
{ kind: "tool", name: "tap", args: { x: 0.5 } },
|
|
{ kind: "tool", name: "swipe", args: {} },
|
|
],
|
|
});
|
|
|
|
const events: StepReport[] = [];
|
|
const runFlow = createRunFlowTool(mockRegistry());
|
|
const result = asRun(
|
|
await runFlow.execute(
|
|
{},
|
|
{ name: "main", project_root: tmpDir, device: DEVICE },
|
|
progressCtx(events)
|
|
)
|
|
);
|
|
|
|
// Every appended report streamed, in order, as the same objects.
|
|
expect(events).toEqual(result.steps);
|
|
expect(events.map((e) => `${e.kind}:${e.status}`)).toEqual([
|
|
"echo:pass",
|
|
"tool:pass",
|
|
"tool:pass",
|
|
]);
|
|
// Echo is narration — the counters cover only the two real steps.
|
|
expect(result.passed).toBe(2);
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it("streams nested fragment steps (run:) with their source-flow attribution", async () => {
|
|
await writeFlow("login", {
|
|
executionPrerequisite: "On login screen",
|
|
steps: [{ kind: "tool", name: "tap", args: { x: 0.5 } }],
|
|
});
|
|
await writeFlow("main", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "run", flow: "login.yaml" },
|
|
{ kind: "echo", message: "done" },
|
|
],
|
|
});
|
|
|
|
const events: StepReport[] = [];
|
|
const runFlow = createRunFlowTool(mockRegistry());
|
|
const result = asRun(
|
|
await runFlow.execute(
|
|
{},
|
|
{ name: "main", project_root: tmpDir, device: DEVICE },
|
|
progressCtx(events)
|
|
)
|
|
);
|
|
|
|
expect(events).toEqual(result.steps);
|
|
// The run marker and the fragment's expanded step both stream, attributed
|
|
// to the fragment.
|
|
expect(events.map((e) => `${e.kind}:${e.flow}`)).toEqual([
|
|
"run:login",
|
|
"tool:login",
|
|
"echo:main",
|
|
]);
|
|
});
|
|
|
|
it("streams the failing step and the skip-flood that follows it", async () => {
|
|
await writeFlow("main", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "tool", name: "tap", args: {} },
|
|
{ kind: "tool", name: "boom", args: {} },
|
|
{ kind: "tool", name: "swipe", args: {} },
|
|
{ kind: "echo", message: "never reached" },
|
|
],
|
|
});
|
|
|
|
const events: StepReport[] = [];
|
|
const runFlow = createRunFlowTool(mockRegistry());
|
|
const result = asRun(
|
|
await runFlow.execute(
|
|
{},
|
|
{ name: "main", project_root: tmpDir, device: DEVICE },
|
|
progressCtx(events)
|
|
)
|
|
);
|
|
|
|
expect(events).toEqual(result.steps);
|
|
expect(events.map((e) => e.status)).toEqual(["pass", "error", "skip", "skip"]);
|
|
expect(result.ok).toBe(false);
|
|
// The skipped echo stays out of the counters like a passed one would.
|
|
expect(result.passed).toBe(1);
|
|
expect(result.errored).toBe(1);
|
|
expect(result.skipped).toBe(1);
|
|
});
|
|
|
|
it("labels directive steps with their target selector", async () => {
|
|
// A failing first step hard-stops the run, so every directive below is
|
|
// reported as a skip — which must still carry its target label.
|
|
await writeFlow("main", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "tool", name: "boom", args: {} },
|
|
{ kind: "tap", selector: { text: "Clear logs", loose: true } },
|
|
{ kind: "tap", x: 0.5, y: 0.25 },
|
|
{ kind: "long-press", selector: { text: "Row 3", loose: true }, duration: 1200 },
|
|
{
|
|
kind: "assert",
|
|
condition: "hidden",
|
|
selector: { text: "] outer Touchable", loose: true },
|
|
},
|
|
{
|
|
kind: "await",
|
|
condition: "text",
|
|
selector: { identifier: "total" },
|
|
expectedText: "Total",
|
|
textMatch: "contains",
|
|
},
|
|
{ kind: "scroll-to", target: { text: "Nested touchables", loose: true }, direction: "up" },
|
|
{ kind: "type", into: { identifier: "email" }, text: "a@b.c" },
|
|
{ kind: "snapshot", name: "home" },
|
|
],
|
|
});
|
|
|
|
const runFlow = createRunFlowTool(mockRegistry());
|
|
const result = asRun(
|
|
await runFlow.execute({}, { name: "main", project_root: tmpDir, device: DEVICE })
|
|
);
|
|
|
|
expect(result.steps.map((s) => s.target)).toEqual([
|
|
undefined,
|
|
'"Clear logs"',
|
|
"(0.5, 0.25)",
|
|
'"Row 3"',
|
|
'hidden "] outer Touchable"',
|
|
'id=total contains "Total"',
|
|
'"Nested touchables" (up)',
|
|
"into id=email",
|
|
'"home"',
|
|
]);
|
|
});
|
|
|
|
it("runs identically when no progress consumer is attached", async () => {
|
|
await writeFlow("main", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "tool", name: "tap", args: {} }],
|
|
});
|
|
|
|
const runFlow = createRunFlowTool(mockRegistry());
|
|
const result = asRun(
|
|
await runFlow.execute({}, { name: "main", project_root: tmpDir, device: DEVICE })
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
expect(result.steps).toHaveLength(1);
|
|
});
|
|
});
|