Files
software-mansion__argent/packages/tool-server/test/flows/flow-settle-min-reads.test.ts
Jakub Piasecki 568660509c fix(flow): floor every settle at two read attempts (#780)
`settleTree` bounded its polling by a 3s window but never bounded the
number of reads it took, and a tree read can fail by TIMING OUT. Every
tree source's RPC timeout outlasts that window, the shortest being iOS's
5s, so the window fit exactly one such read: "every read in the window
failed", the condition the throw reports as a tree-source outage,
collapsed into "the first read was slow", erroring a tap/type/scroll
step on one transient blip with no retry at all.

The floor makes the deadline stop deciding how much evidence an outage
verdict is drawn from. `await`/`assert` have never had this flaw, since
`waitForCondition`'s final poll already guarantees a second attempt, so
this is the same guarantee given to the directives that resolve through
a settle.

A run whose reads answer inside the window pays nothing: two reads are
what a settle already costs there. A run whose reads outlast it pays a
second read, and gets a settle it never actually got before, since past
the deadline the old code returned its single tree having compared it
against nothing. A wedged source costs two full reads rather than one,
and a read is several RPCs deep with each hop on its own timeout, so
that is tens of seconds on a step that used to fail in one read. The
alternative is the step failing on evidence the window was too short to
gather.
2026-08-17 11:15:47 +02:00

294 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } 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 type {
DescribeFrame,
DescribeNode,
DescribeTreeData,
} from "../../src/tools/describe/contract";
// Reads are driven per attempt, so a test can make the FIRST one answer slowly —
// the shape that matters here. A tree RPC allows itself ~5s, more than the 3s
// settle window, so one such read used to be the only read a settle ever took:
// the "every read failed" throw fired on a single transient blip, and a read
// that came back was returned having been compared against nothing.
let reads = 0;
let onRead: (attempt: number) => Promise<DescribeTreeData>;
vi.mock("../../src/tools/flows/flow-tree", () => ({
fetchFlowTree: vi.fn(async (): Promise<DescribeTreeData> => {
reads += 1;
return onRead(reads);
}),
}));
import { SETTLE_TIMEOUT_MS } from "../../src/tools/flows/flow-actions";
import { createRunFlowTool, type FlowRunResult } from "../../src/tools/flows/flow-run";
import { serializeFlow } from "../../src/tools/flows/flow-utils";
const DEVICE = "00000000-0000-0000-0000-0000000000ab"; // iOS UDID shape
// Outlasts the settle window on its own, the way a tree RPC's own timeout does.
// Derived rather than copied: a window raised past a pinned number would move
// these cases onto the ordinary poll path, where the scroll-to round cost still
// comes out at four reads and stops pricing the floor.
const SLOW_READ_MS = SETTLE_TIMEOUT_MS + 200;
const BUTTON_FRAME: DescribeFrame = { x: 0.2, y: 0.4, width: 0.6, height: 0.1 };
// The same button a scroll further down, i.e. a screen still in motion.
const MOVED_BUTTON_FRAME: DescribeFrame = { ...BUTTON_FRAME, y: 0.7 };
let tmpDir: string;
function screenWith(label: string, frame: DescribeFrame = BUTTON_FRAME): DescribeNode {
return {
role: "AXWindow",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [{ role: "AXButton", label, frame, children: [] }],
};
}
function tree(label = "Continue", frame?: DescribeFrame): DescribeTreeData {
return { tree: screenWith(label, frame), source: "native-devtools" };
}
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
// Set when a scroll increment goes out, so a fixture can reveal the target the
// way a real scroll does rather than on a read count it is there to measure.
let swiped = false;
function mockRegistry(calls: Array<{ tool: string; args: Record<string, unknown> }>): Registry {
return {
invokeTool: vi.fn(async (id: string, args: Record<string, unknown>) => {
if (id === "list-devices") return { devices: [] };
if (id === "gesture-swipe") swiped = true;
calls.push({ tool: id, args });
return { ok: true };
}),
getTool: vi.fn(() => ({ inputSchema: { properties: { udid: {} } } })),
} 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");
}
async function run(
name: string,
signal?: AbortSignal
): Promise<FlowRunResult & { calls: Array<{ tool: string; args: Record<string, unknown> }> }> {
const calls: Array<{ tool: string; args: Record<string, unknown> }> = [];
const ctx = signal ? ({ signal } as never) : undefined;
const r = await createRunFlowTool(mockRegistry(calls)).execute(
{},
{ name, project_root: tmpDir, device: DEVICE },
ctx
);
if (!("steps" in r)) throw new Error(`expected a run result, got notice: ${r.notice}`);
return Object.assign(r, { calls });
}
async function writeTap(name: string): Promise<void> {
await writeFlow(name, {
executionPrerequisite: "",
steps: [{ kind: "tap", selector: { text: "Continue", loose: true } }],
});
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-settle-reads-"));
reads = 0;
swiped = false;
onRead = async () => tree();
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
describe("settleTree takes at least two read attempts", () => {
it("retries past the window when the only read so far failed slowly", async () => {
onRead = async (attempt) => {
if (attempt === 1) {
await sleep(SLOW_READ_MS);
throw new Error("ui tree RPC timed out");
}
return tree("Continue", MOVED_BUTTON_FRAME);
};
await writeTap("tap-slow-blip");
const result = await run("tap-slow-blip");
// One slow blip is not an outage: the retry read the screen, so the tap
// resolved and went out instead of erroring the step.
expect(result.ok).toBe(true);
// The slow failure buys one retry, not an open-ended wait for a good read.
expect(reads).toBe(2);
// Centre of the MOVED frame, a point only the retry's tree can produce.
expect(result.calls).toEqual([
{ tool: "gesture-tap", args: { udid: DEVICE, x: 0.5, y: 0.75 } },
]);
}, 20_000);
it("settles against the retry when the only read so far succeeded slowly", async () => {
// The button moves between the two reads, so the dispatched point tells us
// which read the gesture actually acted on.
onRead = async (attempt) => {
if (attempt === 1) {
await sleep(SLOW_READ_MS);
return tree();
}
return tree("Continue", MOVED_BUTTON_FRAME);
};
await writeTap("tap-slow-settle");
const result = await run("tap-slow-settle");
expect(result.ok).toBe(true);
// A lone read has been compared against nothing and is no settle at all, so
// the window being spent does not excuse skipping the retry.
expect(reads).toBe(2);
// Centre of the MOVED frame (0.45 would be the first read's): the tap lands
// where the second read saw the button, not on the stale pre-deadline one.
expect(result.calls).toEqual([
{ tool: "gesture-tap", args: { udid: DEVICE, x: 0.5, y: 0.75 } },
]);
}, 20_000);
it("settles against the slow first read when the retry then fails", async () => {
onRead = async (attempt) => {
if (attempt === 1) {
await sleep(SLOW_READ_MS);
return tree();
}
throw new Error("ui tree RPC timed out");
};
await writeTap("tap-slow-then-blip");
const result = await run("tap-slow-then-blip");
// A read did come back, so this is no outage: the step passes, and the
// branch that would mint the outage memo is the one that throws.
expect(result.ok).toBe(true);
// The floor buys one retry; its failure is not grounds for a third read.
expect(reads).toBe(2);
// The first read's frame, handed back a read later than the bare deadline
// would have returned it rather than discarded over one blip.
expect(result.calls).toEqual([
{ tool: "gesture-tap", args: { udid: DEVICE, x: 0.5, y: 0.45 } },
]);
}, 20_000);
it("still calls a sustained outage an outage, on the read after the slow one", async () => {
onRead = async (attempt) => {
if (attempt === 1) await sleep(SLOW_READ_MS);
throw new Error("native devtools is unavailable");
};
await writeTap("tap-outage");
const result = await run("tap-outage");
expect(result.ok).toBe(false);
expect(result.steps[0].reason).toMatch(/native devtools is unavailable/);
// The floor is a floor, not an open-ended retry: the second failure settles it.
expect(reads).toBe(2);
expect(result.calls).toHaveLength(0);
}, 20_000);
it("leaves a fast-failing outage on its existing budget", async () => {
onRead = async () => {
throw new Error("native devtools is unavailable");
};
await writeTap("tap-dead");
const result = await run("tap-dead");
expect(result.ok).toBe(false);
expect(result.steps[0].reason).toMatch(/native devtools is unavailable/);
// Reads that fail fast still fill the window, so the floor changes nothing.
expect(reads).toBeGreaterThan(2);
}, 20_000);
it("adds no third read when the first two reads matched inside the window", async () => {
await writeTap("tap-healthy");
const result = await run("tap-healthy");
expect(result.ok).toBe(true);
// Two reads are what a settle has always cost a healthy run.
expect(reads).toBe(2);
});
it("ends a run cancelled while the floor's retry is in flight", async () => {
// The floor's read is the one cancelled here, not the first: read 1 answers
// past the window, so the deadline closes on a lone read and the retry goes
// out back-to-back with no poll sleep between them. Read 2 then never
// answers, and carries no budget of its own - the read keeps its own RPC
// tier so a slow cold start is ridden out - so the signal is all that ends
// the wait.
let retryStarted!: () => void;
const retryInFlight = new Promise<void>((resolve) => {
retryStarted = resolve;
});
onRead = async (attempt) => {
if (attempt === 1) {
await sleep(SLOW_READ_MS);
return tree();
}
retryStarted();
return new Promise<never>(() => {});
};
await writeTap("tap-cancelled-retry");
const controller = new AbortController();
const running = run("tap-cancelled-retry", controller.signal);
// Racing the run itself: a settle that never issued the retry fails on the
// assertions below rather than hanging here until this test's own timeout.
await Promise.race([retryInFlight, running]);
const abortedAt = Date.now();
controller.abort();
const result = await running;
// Nothing but the signal could have ended a read that never answers.
expect(Date.now() - abortedAt).toBeLessThan(2_000);
// The cancelled settle hands back no tree at all, not the pre-deadline one
// it already holds: that best-effort return would resolve a frame and land
// a tap after cancellation, reported as a pass rather than this skip.
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["tap:skip"]);
expect(result.steps[0].reason).toBe("run aborted");
expect(result.calls).toHaveLength(0);
// And the retry really was in flight: an abort on read 1 is a different
// moment, already covered by flow-gesture-settle.test.ts, which hangs the
// first read instead.
expect(reads).toBe(2);
}, 20_000);
});
describe("the settle floor is paid on every `scroll-to` round", () => {
it("spends two reads a round against a source slower than the window", async () => {
// Every read outlasts the window, so every round's settle closes it on the
// first read and pays the floor's retry. The button drifts between reads so
// no settle exits early on two matching fingerprints, which would cost two
// reads whatever the floor is set to.
onRead = async (attempt) => {
await sleep(SLOW_READ_MS);
const frame = attempt % 2 === 0 ? MOVED_BUTTON_FRAME : BUTTON_FRAME;
return tree(swiped ? "Order #1234" : "Top", frame);
};
await writeFlow("scroll-slow", {
executionPrerequisite: "",
steps: [{ kind: "scroll-to", target: { text: "Order #1234" }, direction: "down" }],
});
const result = await run("scroll-slow");
expect(result.ok).toBe(true);
// One increment, so the pass took exactly two rounds.
expect(result.calls.map((c) => c.tool)).toEqual(["gesture-swipe"]);
// Two per round where the pre-floor cost was one. The loop is bounded by
// MAX_SCROLL_ITERATIONS rather than a clock, so two pinned rounds stand in
// for the 25 it can run.
expect(reads).toBe(4);
}, 30_000);
});