Files
software-mansion__argent/packages/tool-server/test/flows/flow-hidden-blank-reads.test.ts
Jakub Piasecki 3f602a7a9b feat(flow): conditional when blocks (#498)
## What

A `when:` block runs a step list only when one condition holds — a UI
condition (`exists`/`visible`/`hidden`/`text`, the await/assert shapes)
or a static `platform` test. **No else**, by design: a block exists to
restore determinism (dismiss an interstitial, reconverge), never to
test two paths. Tap-if-present is a one-step block:
`when: { visible: "Got it" }` + `steps: [tap: "Got it"]`.

No per-step `optional:` — `when:` covers every directive once. The key
is rejected at parse with a pointer to `when:`, not silently dropped (a
Maestro habit that would leave a "can't fail" step hard-stopping the
flow).

## How

- The guard reuses the assert engine at the 1s grace (`timeout` is a
parse error), so a skipped block barely costs a clean run.
- Verdicts are evidence-based: no trustworthy read in the window →
indeterminate → the step **errors** ("could not evaluate" is not
"condition false"); a transient read failure after trusted reads showed
the condition false stays a clean skip.
- `platform` folds `ios-remote` into `ios` — the parser rejects that
spelling, so the fold is the only way a guard matches a remote sim.
- A skipped block reports one line per authored step, same shape for
unmet guard, hard stop, or cancellation; everything goes through
`pushReport`, so when lines stream live. Failures inside an entered
block hard-stop as usual.
- Results carry an explicit `aborted` flag; `ok` keys off
fail/error/cancel, not skip lines.
- Nesting is capped at 20, so a cyclic YAML alias is a structured parse
error, not a stack overflow.

## Testing

`vitest` 2546 pass (17 when-specific); `tsc --noEmit` (tool-server,
argent-mcp, argent-cli), eslint, prettier clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:26:34 +02:00

320 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 { DescribeNode, DescribeTreeData } from "../../src/tools/describe/contract";
// Serve the flow tree directly (flows hard-fail rather than degrade to the AX
// tree). The mock scripts the reads to shape evidence gaps: a trusted read
// followed by blank trees or throws — the shapes where waitForCondition must
// distinguish "condition false" from "could not look" (blind-read guard for
// `hidden`, dark-tail rule for every condition).
let currentFetch: () => DescribeTreeData;
vi.mock("../../src/tools/flows/flow-tree", () => ({
fetchFlowTree: vi.fn(async (): Promise<DescribeTreeData> => currentFetch()),
}));
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
let tmpDir: string;
function n(partial: Partial<DescribeNode> & { frame: DescribeNode["frame"] }): DescribeNode {
return { role: "AXOther", children: [], ...partial };
}
function screen(children: DescribeNode[]): DescribeNode {
return n({ role: "AXWindow", frame: { x: 0, y: 0, width: 1, height: 1 }, children });
}
function mockRegistry(): Registry {
return {
invokeTool: vi.fn(async (id: string) => {
if (id === "list-devices") return { devices: [] };
return { ok: true };
}),
getTool: vi.fn(() => undefined),
} 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;
}
async function run(name: string): Promise<FlowRunResult> {
return asRun(
await createRunFlowTool(mockRegistry()).execute(
{},
{ name, project_root: tmpDir, device: DEVICE }
)
);
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-hidden-"));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
describe("hidden timeout diagnostics", () => {
it("does not claim the element was still visible when the final reads were blank", async () => {
// Read 1: the spinner is visible (a trusted read — everMatched flips on).
// Every later read is an empty tree, which the blind-read guard refuses to
// trust for `hidden` once the selector has matched. The timeout reason must
// say the check could not be confirmed — not that an element the last reads
// never saw was "still visible".
let reads = 0;
currentFetch = () => {
reads++;
return {
tree:
reads === 1
? screen([
n({ identifier: "spinner", frame: { x: 0.4, y: 0.4, width: 0.2, height: 0.2 } }),
])
: screen([]),
source: "native-devtools",
};
};
await writeFlow("blank-hidden", {
executionPrerequisite: "",
steps: [{ kind: "assert", condition: "hidden", selector: { identifier: "spinner" } }],
});
const result = await run("blank-hidden");
expect(result.ok).toBe(false);
expect(result.steps[0].status).toBe("fail");
expect(result.steps[0].reason).toMatch(/could not confirm/);
expect(result.steps[0].reason).not.toMatch(/still visible/);
});
it("does not claim the element was still visible when the final reads threw", async () => {
// Same evidence gap as the blank-tree case, surfaced as a THROW: read 1
// is trusted and sees the spinner, then the tree source disconnects and
// every later fetch rejects. The stale read-1 match must not stand in as
// current evidence — the failure must say the check could not be
// confirmed (and why), not that the element was "still visible".
let reads = 0;
currentFetch = () => {
if (reads++ === 0) {
return {
tree: screen([
n({ identifier: "spinner", frame: { x: 0.4, y: 0.4, width: 0.2, height: 0.2 } }),
]),
source: "native-devtools",
};
}
throw new Error("native devtools disconnected");
};
await writeFlow("dark-hidden", {
executionPrerequisite: "",
steps: [{ kind: "assert", condition: "hidden", selector: { identifier: "spinner" } }],
});
const result = await run("dark-hidden");
expect(result.ok).toBe(false);
expect(result.steps[0].status).toBe("fail");
expect(result.steps[0].reason).toMatch(/could not confirm/);
expect(result.steps[0].reason).toMatch(/native devtools disconnected/);
expect(result.steps[0].reason).not.toMatch(/still visible/);
});
it("still reports a genuinely visible element as still visible", async () => {
currentFetch = () => ({
tree: screen([
n({ identifier: "spinner", frame: { x: 0.4, y: 0.4, width: 0.2, height: 0.2 } }),
]),
source: "native-devtools",
});
await writeFlow("stuck-spinner", {
executionPrerequisite: "",
steps: [{ kind: "assert", condition: "hidden", selector: { identifier: "spinner" } }],
});
const result = await run("stuck-spinner");
expect(result.ok).toBe(false);
expect(result.steps[0].status).toBe("fail");
expect(result.steps[0].reason).toMatch(/still visible/);
});
it("reports still visible when a mid-window throw is followed by a trusted read", async () => {
// A blip that RECOVERS: read 2 throws, but every later read is trusted
// and still shows the spinner. The final read is honest evidence, so the
// determinate "still visible" verdict stands — indeterminacy is only for
// windows whose last look at the screen was blind or failed.
let reads = 0;
currentFetch = () => {
if (reads++ === 1) throw new Error("native devtools disconnected");
return {
tree: screen([
n({ identifier: "spinner", frame: { x: 0.4, y: 0.4, width: 0.2, height: 0.2 } }),
]),
source: "native-devtools",
};
};
await writeFlow("blip-spinner", {
executionPrerequisite: "",
steps: [{ kind: "assert", condition: "hidden", selector: { identifier: "spinner" } }],
});
const result = await run("blip-spinner");
expect(result.ok).toBe(false);
expect(result.steps[0].status).toBe("fail");
expect(result.steps[0].reason).toMatch(/still visible/);
});
});
describe("dark-tail diagnostics (non-hidden conditions)", () => {
it("assert exists: reads going dark after one trusted read report indeterminate, not a stale verdict", async () => {
// Read 1: trusted, "Done" absent — the expected STARTING state of a
// wait, not evidence about the deadline. Reads 2+: the tree source dies
// for the rest of the window. A determinate "no element matched" here
// would narrate a screen nobody saw at the deadline and drop the fetch
// error entirely — the verdict must say the screen was unreadable, and
// say why.
let reads = 0;
currentFetch = () => {
if (reads++ === 0) {
return {
tree: screen([n({ label: "Home", frame: { x: 0, y: 0, width: 1, height: 0.1 } })]),
source: "native-devtools",
};
}
throw new Error("native devtools disconnected");
};
await writeFlow("dark-exists", {
executionPrerequisite: "",
steps: [{ kind: "assert", condition: "exists", selector: { text: "Done" } }],
});
const result = await run("dark-exists");
expect(result.ok).toBe(false);
expect(result.steps[0].status).toBe("fail");
expect(result.steps[0].reason).toMatch(/unreadable for the final \d+ms/i);
expect(result.steps[0].reason).toMatch(/native devtools disconnected/);
expect(result.steps[0].reason).not.toMatch(/no element matched/);
});
it("await exists: the same dark tail under an await window surfaces the fetch error", async () => {
let reads = 0;
currentFetch = () => {
if (reads++ === 0) {
return {
tree: screen([n({ label: "Home", frame: { x: 0, y: 0, width: 1, height: 0.1 } })]),
source: "native-devtools",
};
}
throw new Error("native devtools disconnected");
};
await writeFlow("dark-await", {
executionPrerequisite: "",
steps: [{ kind: "await", condition: "exists", selector: { text: "Done" }, timeout: 1000 }],
});
const result = await run("dark-await");
expect(result.ok).toBe(false);
expect(result.steps[0].status).toBe("fail");
expect(result.steps[0].reason).toMatch(/unreadable for the final \d+ms/i);
expect(result.steps[0].reason).toMatch(/native devtools disconnected/);
expect(result.steps[0].reason).not.toMatch(/no element matched/);
});
it("assert text: does not quote stale element text from before the reads went dark", async () => {
// Read 1 sees the banner saying "Loading"; then the source dies. Quoting
// `its text was "Loading"` at the deadline would present a first-poll
// snapshot as the state of a screen that was unreadable for essentially
// the whole window.
let reads = 0;
currentFetch = () => {
if (reads++ === 0) {
return {
tree: screen([
n({
identifier: "banner",
label: "Loading",
frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 },
}),
]),
source: "native-devtools",
};
}
throw new Error("native devtools disconnected");
};
await writeFlow("dark-text", {
executionPrerequisite: "",
steps: [
{
kind: "assert",
condition: "text",
selector: { identifier: "banner" },
expectedText: "Done",
textMatch: "contains",
},
],
});
const result = await run("dark-text");
expect(result.ok).toBe(false);
expect(result.steps[0].status).toBe("fail");
expect(result.steps[0].reason).toMatch(/unreadable for the final \d+ms/i);
expect(result.steps[0].reason).toMatch(/native devtools disconnected/);
expect(result.steps[0].reason).not.toMatch(/Loading/);
});
it("keeps the determinate verdict — with the error appended — when only the final polls throw", async () => {
// The deliberate trailing tolerance: trusted reads showed "Done" absent
// until ~one poll before the 1s assert deadline, so a fetch error on the
// trailing polls is a blip, not doubt — the determinate reason stands.
// The failed final read is appended rather than silently dropped (main
// surfaced `could not read the UI tree: <err>` here; losing it was a
// report-quality regression).
let firstReadAt: number | undefined;
currentFetch = () => {
firstReadAt ??= Date.now();
if (Date.now() - firstReadAt >= 950) throw new Error("native devtools disconnected");
return {
tree: screen([n({ label: "Home", frame: { x: 0, y: 0, width: 1, height: 0.1 } })]),
source: "native-devtools",
};
};
await writeFlow("blip-exists", {
executionPrerequisite: "",
steps: [{ kind: "assert", condition: "exists", selector: { text: "Done" } }],
});
const result = await run("blip-exists");
expect(result.ok).toBe(false);
expect(result.steps[0].status).toBe("fail");
expect(result.steps[0].reason).toMatch(/no element matched/);
expect(result.steps[0].reason).toMatch(
/final poll could not read the UI tree: native devtools disconnected/
);
});
});