Files
software-mansion__argent/packages/tool-server/test/await-ui-element.test.ts
Hubert Gancarczyk f21811d069 fix(flow): re-probe a recorded wait against the tree the runner reads (#717)
> Stacked on #715.

## The problem

`await-ui-element` evaluates against the tree it reads — the
**accessibility hierarchy** on iOS and Android, the **CDP DOM** on
Chromium, the **toolkit page source** on Vega. The `await:` / `assert:`
directive that the polish pass converts a recorded wait into is
evaluated against the **runner's** tree instead.

They overlap, but outside Vega neither contains the other — an id
present in one can be absent from the other, and on iOS even the role
vocabularies are disjoint. Both directions have been hit in practice: an
id visible to the runner but not the recorder, and text-field values
visible to the recorder but not the runner.

The consequence is that a check can pass live and fail once converted.
That makes the core promise — *each step is executed live so you verify
it works before it's recorded* — untrue at exactly the point it is
relied on.

## What changes

After a recorded `await-ui-element` succeeds, the recorder re-runs the
same condition against the tree the runner resolves directives against,
and reports the result on the step it just wrote.

**It warns; it does not refuse.** What gets written is a raw `tool:
await-ui-element` step, and at replay that tool reads the same tree it
just passed against — so "this would fail every run" was never true of
the form actually recorded. What the probe genuinely reports is whether
the **polish-time conversion** is safe, which is why the warning says to
act on it *then* rather than re-record now (re-recording would duplicate
the step).

Because polish begins after `flow-finish-recording`, each verdict is
also carried there: it is repeated on its own step's `summary` line, and
`message` counts how many steps carry one. A warning raised at step 7 of
a 40-step recording is otherwise long out of reach by the time it is
actionable.

## Three outcomes, kept distinct

| Runner tree says | Reported as |
| --- | --- |
| condition holds | nothing — step recorded clean |
| condition does not hold | recorded + warning naming the causes to rule
out before converting |
| tree unreadable, or too slow to answer | recorded + warning that the
conversion is **UNKNOWN, not known-bad** |

The middle row is deliberately not "the conversion will fail". The
identical verdict comes back when the screen simply moved on between the
live wait and the re-probe, and on `text` checks when the selector
matches several elements and the two sides elect different ones — so the
consequence stays conditional on the cause, and the message names the
causes in the order worth ruling out.

The third row matters: nothing was compared, so calling it a divergence
would send the author off to rewrite a selector that may be perfectly
good. It is also the injection-free case the flow skills explicitly
sanction, so refusing there would block a supported way of working. On
iOS the quoted reason ends "provide bundleId explicitly" — advice aimed
at the native tools. The warning corrects it, because no directive
carries a bundleId and forwarding one would make the probe answer a
question the runner never asks.

## Platform-specific explanation

The *why* differs per platform, and stating the iOS story everywhere
makes the message wrong where someone is trying to act on it. iOS gets
AX-vs-native-hierarchy; Chromium gets "the flow tree keeps only
addressable nodes"; Android gets the importance trim; Vega gets "the
trees agree, so the screen changed". The named tool for reading the
runner's side follows the same rule — pointing an Android author at
`native-find-views` points at a tool they cannot call.

The remedy is per **condition** as well as per platform: on `hidden` a
determinate verdict means the runner's tree still *has* the element, so
"retarget at an id the full hierarchy carries" is the opposite of what
helps.

## A failed live wait is reported by cause

`await-ui-element` returns `{ success: false }` for three different
things: a condition read and found false, a tree source that never
answered, and a cancelled wait. Only the first judges the condition, so
only the first is narrated as "the wait never held" and prescribes
re-recording or deleting the step. The other two observed nothing and
are reported as UNKNOWN — the same unknown-vs-known-bad distinction the
probe draws for the runner's tree.

A wait nested inside a recorded `run-sequence` gets neither warning:
that tool reports its own shape, which carries no `success` key. The
skill says so, and says to read `toolResult` there.

---------

Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
2026-08-23 22:18:26 +02:00

1358 lines
48 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 { beforeEach, describe, expect, it, vi } from "vitest";
import type { AXServiceApi, AXDescribeResponse } from "../src/blueprints/ax-service";
import type { AndroidDevtoolsApi } from "../src/blueprints/android-devtools";
import type { ChromiumCdpApi } from "../src/blueprints/chromium-cdp";
import {
createAwaitUiElementTool,
evaluateMatches,
unmetUiWaitCause,
} from "../src/tools/await-ui-element";
import { findAll } from "../src/utils/ui-tree-match";
import type { DescribeNode } from "../src/tools/describe/contract";
import { __primeDepCacheForTests, __resetDepCacheForTests } from "../src/utils/check-deps";
// execute() resolves the target's form factor before polling: isTvOsSimulator()
// shells out to `xcrun simctl list` (the fake UDID is never listed, so it never
// caches and re-probes on EVERY test) and isAndroidTv() probes the serial via
// real adb round-trips. Both take seconds under the parallel suite load — enough
// to trip the 5s per-test timeout. The devices here are plain phone shapes, so
// pin both probes to false and keep the rest of each module real.
vi.mock("../src/utils/ios-devices", async () => {
const actual = await vi.importActual<typeof import("../src/utils/ios-devices")>(
"../src/utils/ios-devices"
);
return { ...actual, isTvOsSimulator: async () => false };
});
vi.mock("../src/utils/adb", async () => {
const actual = await vi.importActual<typeof import("../src/utils/adb")>("../src/utils/adb");
return { ...actual, isAndroidTv: async () => false };
});
const IOS_UDID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA";
const ANDROID_SERIAL = "emulator-5554";
const CHROMIUM_ID = "chromium-cdp-9222";
// An AX service whose describe() walks through `responses` one call at a time,
// repeating the last entry. Lets a test simulate a screen that changes between
// polls (element appears / disappears / text updates). `degraded` mirrors the
// real service flag the iOS describe path turns into the boot hint.
function makeSequencedAXService(
responses: AXDescribeResponse[],
opts: { degraded?: boolean } = {}
): {
api: AXServiceApi;
calls: () => number;
} {
let i = 0;
const api: AXServiceApi = {
degraded: opts.degraded ?? false,
describe: async () => responses[Math.min(i++, responses.length - 1)],
alertCheck: async () => false,
ping: async () => true,
};
return { api, calls: () => i };
}
// An AX service whose describe() always throws — the "AX backend down" case the
// iOS describe path swallows into an empty tree + boot hint.
function makeFailingAXService(): AXServiceApi {
return {
degraded: true,
describe: async () => {
throw new Error("ax service unreachable");
},
alertCheck: async () => false,
ping: async () => false,
};
}
function axResponse(elements: AXDescribeResponse["elements"]): AXDescribeResponse {
return { alertVisible: false, screenFrame: { width: 440, height: 956 }, elements };
}
const FRAME = { x: 0.1, y: 0.4, width: 0.8, height: 0.05 };
// Registry mock that can serve the iOS AX service and (optionally) the Android
// devtools service. native-devtools (the iOS fallback) intentionally throws so
// an empty AX tree surfaces as a degraded read rather than a hung fallback.
function makeMockRegistry(opts: { ax?: AXServiceApi; android?: AndroidDevtoolsApi }) {
return {
resolveService: vi.fn(async (urn: string) => {
if (urn.startsWith("AXService:")) {
if (!opts.ax) throw new Error("no AX service configured");
return opts.ax;
}
if (urn.startsWith("AndroidDevtools:")) {
if (!opts.android) throw new Error("no Android devtools configured");
return opts.android;
}
// native-devtools fallback etc. — fail so the iOS path returns the empty
// AX tree instead of waiting on a fallback that isn't mocked.
throw new Error(`unexpected service: ${urn}`);
}),
} as any;
}
function iosRegistry(ax: AXServiceApi) {
return makeMockRegistry({ ax });
}
describe("await-ui-element tool", () => {
beforeEach(() => {
__resetDepCacheForTests();
__primeDepCacheForTests(["xcrun", "adb"]);
});
it("exposes the await-ui-element id", () => {
expect(createAwaitUiElementTool(iosRegistry({} as AXServiceApi)).id).toBe("await-ui-element");
});
// ── iOS happy paths ──────────────────────────────────────────────────────
it("`visible` succeeds once the element appears across polls", async () => {
const { api, calls } = makeSequencedAXService([
axResponse([]),
axResponse([{ label: "Submit", frame: FRAME, traits: ["button"] }]),
]);
const tool = createAwaitUiElementTool(iosRegistry(api));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "visible",
selector: { text: "Submit" },
timeoutMs: 2000,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(true);
expect(calls()).toBeGreaterThan(1);
});
it("`visible` times out with a diagnostic note when the element never appears", async () => {
const { api } = makeSequencedAXService([axResponse([])]);
const tool = createAwaitUiElementTool(iosRegistry(api));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "visible",
selector: { text: "Nope" },
timeoutMs: 30,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(false);
expect(result.elapsed).toBeGreaterThanOrEqual(30);
expect(result.note).toMatch(/no element matched/i);
});
it("clamps the poll sleep to the deadline so a large pollIntervalMs can't overshoot timeoutMs", async () => {
// Element never appears; pollIntervalMs (1000) dwarfs timeoutMs (100). Without
// clamping, the first sleep alone would run the full 1000ms past the initial
// poll. With the clamp, elapsed should land just past the 100ms deadline.
const { api } = makeSequencedAXService([axResponse([])]);
const tool = createAwaitUiElementTool(iosRegistry(api));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "visible",
selector: { text: "Nope" },
timeoutMs: 100,
pollIntervalMs: 1000,
}
);
expect(result.success).toBe(false);
expect(result.elapsed).toBeGreaterThanOrEqual(100);
expect(result.elapsed).toBeLessThan(600);
});
it("`exists` succeeds when the element is in the tree", async () => {
const { api } = makeSequencedAXService([
axResponse([{ label: "Widget", frame: FRAME, traits: [] }]),
]);
const tool = createAwaitUiElementTool(iosRegistry(api));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "exists",
selector: { text: "Widget" },
timeoutMs: 2000,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(true);
});
it("`hidden` returns an instant success but flags a selector that never matched", async () => {
const { api } = makeSequencedAXService([
axResponse([{ label: "Other", frame: FRAME, traits: [] }]),
]);
const tool = createAwaitUiElementTool(iosRegistry(api));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "hidden",
selector: { text: "Ghost" },
timeoutMs: 2000,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(true);
expect(result.note).toMatch(/never matched/i);
});
it("`hidden` succeeds once the element disappears", async () => {
// Second poll is a real re-rendered screen WITHOUT the Spinner (other content
// remains) — a genuinely-hidden element leaves the rest of the screen behind.
// (A wholly-empty second tree is the ambiguous transient-blank case, covered
// separately below — it must NOT confirm `hidden`.)
const { api } = makeSequencedAXService([
axResponse([{ label: "Spinner", frame: FRAME, traits: [] }]),
axResponse([{ label: "Content", frame: FRAME, traits: [] }]),
]);
const tool = createAwaitUiElementTool(iosRegistry(api));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "hidden",
selector: { text: "Spinner" },
timeoutMs: 2000,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(true);
// It actually matched before disappearing, so it must NOT carry the
// "never matched" caveat.
expect(result.note ?? "").not.toMatch(/never matched/i);
});
it("does NOT confirm `hidden` on a transient empty tree after the element was seen", async () => {
// The element matched on the first poll; every later poll lands on a wholly
// empty tree (a blank frame mid-navigation). That emptiness must not read as
// "element hidden" — otherwise a gated tap fires against a screen that only
// briefly went blank. So the wait keeps polling and times out with the
// "could not confirm … empty or unreadable" note rather than a false success.
const { api } = makeSequencedAXService([
axResponse([{ label: "Spinner", frame: FRAME, traits: [] }]),
axResponse([]),
]);
const tool = createAwaitUiElementTool(iosRegistry(api));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "hidden",
selector: { text: "Spinner" },
timeoutMs: 60,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(false);
expect(result.note).toMatch(/empty or unreadable/i);
});
it("`hidden` resolves once a real (non-empty) screen without the element renders after a blank", async () => {
// Spinner → transient blank → a real screen without the Spinner. The blank
// must not confirm `hidden`, but the subsequent populated tree (Spinner gone)
// must. Proves the guard delays rather than blocks a legitimate disappearance.
const { api } = makeSequencedAXService([
axResponse([{ label: "Spinner", frame: FRAME, traits: [] }]),
axResponse([]),
axResponse([{ label: "Loaded", frame: FRAME, traits: [] }]),
]);
const tool = createAwaitUiElementTool(iosRegistry(api));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "hidden",
selector: { text: "Spinner" },
timeoutMs: 2000,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(true);
expect(result.note ?? "").not.toMatch(/never matched/i);
});
it("`text` succeeds once the matched element contains the expected substring", async () => {
const { api } = makeSequencedAXService([
axResponse([{ label: "Status", value: "Loading", frame: FRAME, traits: [] }]),
axResponse([{ label: "Status", value: "Done", frame: FRAME, traits: [] }]),
]);
const tool = createAwaitUiElementTool(iosRegistry(api));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "text",
selector: { text: "Status" },
expectedText: "done",
timeoutMs: 2000,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(true);
});
it("`text` timeout note reports the last-seen text", async () => {
const { api } = makeSequencedAXService([
axResponse([{ label: "Status", value: "Loading", frame: FRAME, traits: [] }]),
]);
const tool = createAwaitUiElementTool(iosRegistry(api));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "text",
selector: { text: "Status" },
expectedText: "Done",
timeoutMs: 30,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(false);
expect(result.note).toMatch(/Loading/);
});
it("`text` check and timeout note both read the visible match, not a zero-area shadow", async () => {
// A stale zero-area "Total 0" sits above the visible "Total 42". Both the
// condition and the note must read the visible node — otherwise the check
// fails against the shadow while the note quotes the visible element,
// producing a self-contradictory message. Driven through the Chromium path
// because the iOS AX adapter prunes zero-area elements before they reach
// the tree, while Chromium deliberately keeps zero-height anchors.
const tree = {
role: "html",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
{
role: "generic",
label: "Total 0",
frame: { x: 0.1, y: 0.1, width: 0, height: 0 },
children: [],
},
{
role: "generic",
label: "Total 42",
frame: { x: 0.1, y: 0.5, width: 0.5, height: 0.05 },
children: [],
},
],
};
const tool = createAwaitUiElementTool(makeMockRegistry({}));
const met = await tool.execute(
{ chromium: makeChromiumApi(tree) },
{
udid: CHROMIUM_ID,
condition: "text",
selector: { text: "Total" },
expectedText: "42",
timeoutMs: 60,
pollIntervalMs: 10,
}
);
expect(met.success).toBe(true);
const unmet = await tool.execute(
{ chromium: makeChromiumApi(tree) },
{
udid: CHROMIUM_ID,
condition: "text",
selector: { text: "Total" },
expectedText: "99",
timeoutMs: 60,
pollIntervalMs: 10,
}
);
expect(unmet.success).toBe(false);
expect(unmet.note).toMatch(/its text was "Total 42"/);
expect(unmet.note).not.toMatch(/Total 0/);
});
// ── Cancellation ─────────────────────────────────────────────────────────
it("aborts promptly when the request signal fires mid-wait", async () => {
// Tree never matches, so without cancellation this would run the full 5s.
const { api } = makeSequencedAXService([axResponse([])]);
const tool = createAwaitUiElementTool(iosRegistry(api));
const controller = new AbortController();
setTimeout(() => controller.abort(), 30);
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "visible",
selector: { text: "Nope" },
timeoutMs: 5000,
pollIntervalMs: 10,
},
{ signal: controller.signal } as never
);
expect(result.success).toBe(false);
expect(result.note).toMatch(/cancel/i);
expect(result.elapsed).toBeLessThan(2000);
});
// ── Synthetic-root exclusion (matching must mirror describe's ROOT line) ───
describe("synthetic root is never matched", () => {
it("`visible` does not instantly succeed on a role selector matching the AXGroup root", async () => {
// Empty screen → tree is just the synthetic AXGroup root, no children.
const { api } = makeSequencedAXService([axResponse([])]);
const tool = createAwaitUiElementTool(iosRegistry(api));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "visible",
selector: { role: "AXGroup" },
timeoutMs: 40,
pollIntervalMs: 10,
}
);
// Previously the 1×1 root matched and returned instant success.
expect(result.success).toBe(false);
});
// `isBlindRead` keys off `hint` / `should_restart`, so an empty tree
// carrying neither is taken as a trustworthy screen — and `hidden` is the
// one condition that resolves TRUE on an empty tree. With a live service and
// no connected app the read is unexplained, not empty.
it("`hidden` refuses to resolve off an empty tree the hierarchy could not corroborate", async () => {
const { api } = makeSequencedAXService([axResponse([])]);
const nativeApi = {
listConnectedBundleIds: () => [],
appConnectionState: async () => "unregistered" as const,
};
const registry = {
resolveService: vi.fn(async (urn: string) => {
if (urn.startsWith("AXService:")) return api;
if (urn.startsWith("NativeDevtools:")) return nativeApi;
throw new Error(`unexpected service: ${urn}`);
}),
} as any;
const tool = createAwaitUiElementTool(registry);
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "hidden",
selector: { text: "30 November" },
timeoutMs: 300,
pollIntervalMs: 50,
}
);
expect(result.success).toBe(false);
expect(result.note).toMatch(/empty or unreadable/i);
// The exact shape this guard prevents: a gated wait released in
// milliseconds against an element never confirmed gone.
expect(result.note).not.toMatch(/condition met immediately/);
});
it("`hidden` can succeed for a role selector that would otherwise pin the root", async () => {
const { api } = makeSequencedAXService([axResponse([])]);
// The hierarchy must be READ and empty, not merely unread. Here the
// native source answers with zero elements, which IS evidence.
const nativeApi = {
listConnectedBundleIds: () => ["com.example.app"],
appConnectionState: async () => "connected" as const,
getAppState: async () => ({
bundleId: "com.example.app",
applicationState: "active",
foregroundActiveSceneCount: 1,
foregroundInactiveSceneCount: 0,
backgroundSceneCount: 0,
unattachedSceneCount: 0,
isFrontmostCandidate: true,
}),
queryViewHierarchy: async () => ({
screenFrame: { x: 0, y: 0, width: 440, height: 956 },
elements: [],
}),
};
const tool = createAwaitUiElementTool({
resolveService: vi.fn(async (urn: string) => {
if (urn.startsWith("AXService:")) return api;
if (urn.startsWith("NativeDevtools:")) return nativeApi;
throw new Error(`unexpected service: ${urn}`);
}),
} as any);
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "hidden",
selector: { role: "AXGroup" },
timeoutMs: 2000,
pollIntervalMs: 10,
}
);
// The root never disappears, so before the fix `hidden` could never hold.
expect(result.success).toBe(true);
});
});
// ── Android branch ───────────────────────────────────────────────────────
it("drives the Android devtools path and matches a node", async () => {
const xml =
`<?xml version="1.0" encoding="UTF-8"?>` +
`<hierarchy rotation="0">` +
`<node class="android.widget.FrameLayout" bounds="[0,0][1080,2400]">` +
`<node text="Sign in" resource-id="com.demo:id/signin" class="android.widget.Button" clickable="true" bounds="[100,200][980,320]" />` +
`</node>` +
`</hierarchy>`;
const android: AndroidDevtoolsApi = {
getHierarchy: async () => ({ xml }),
getScreenSize: async () => ({ width: 1080, height: 2400, rotation: 0 }),
} as unknown as AndroidDevtoolsApi;
const tool = createAwaitUiElementTool(makeMockRegistry({ android }));
const result = await tool.execute(
{},
{
udid: ANDROID_SERIAL,
condition: "visible",
selector: { text: "Sign in" },
timeoutMs: 2000,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(true);
});
it("`hidden` on Android succeeds when the node is gone", async () => {
const emptyXml = `<hierarchy rotation="0"><node class="android.widget.FrameLayout" bounds="[0,0][1080,2400]" /></hierarchy>`;
const android: AndroidDevtoolsApi = {
getHierarchy: async () => ({ xml: emptyXml }),
getScreenSize: async () => ({ width: 1080, height: 2400, rotation: 0 }),
} as unknown as AndroidDevtoolsApi;
const tool = createAwaitUiElementTool(makeMockRegistry({ android }));
const result = await tool.execute(
{},
{
udid: ANDROID_SERIAL,
condition: "hidden",
selector: { text: "Sign in" },
timeoutMs: 1000,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(true);
});
it("does NOT confirm `hidden` on Android when a seen element is followed by an empty tree", async () => {
// Android never sets hint / should_restart, so before the everMatched guard a
// transient empty `uiautomator dump` after the element had appeared would let
// `hidden` resolve on a blank frame. Poll 1 shows the button; later polls
// return an empty hierarchy. The wait must keep polling and time out, not
// confirm hidden.
const withButton =
`<hierarchy rotation="0">` +
`<node text="Sign in" resource-id="com.demo:id/signin" class="android.widget.Button" clickable="true" bounds="[100,200][980,320]" />` +
`</hierarchy>`;
const empty = `<hierarchy rotation="0"></hierarchy>`;
let i = 0;
const android: AndroidDevtoolsApi = {
getHierarchy: async () => ({ xml: i++ === 0 ? withButton : empty }),
getScreenSize: async () => ({ width: 1080, height: 2400, rotation: 0 }),
} as unknown as AndroidDevtoolsApi;
const tool = createAwaitUiElementTool(makeMockRegistry({ android }));
const result = await tool.execute(
{},
{
udid: ANDROID_SERIAL,
condition: "hidden",
selector: { text: "Sign in" },
timeoutMs: 60,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(false);
expect(result.note).toMatch(/empty or unreadable/i);
});
// ── Chromium branch ──────────────────────────────────────────────────────
function makeChromiumApi(treeJson: unknown): ChromiumCdpApi {
return {
refreshViewport: async () => ({ width: 1024, height: 768 }),
cdp: {
send: async (method: string) => {
if (method === "Runtime.evaluate") {
return { result: { value: JSON.stringify({ tree: treeJson, truncated: false }) } };
}
return {};
},
},
} as unknown as ChromiumCdpApi;
}
it("drives the Chromium CDP path and matches a DOM node", async () => {
const tree = {
role: "html",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
{
role: "button",
label: "Continue",
clickable: true,
frame: { x: 0.4, y: 0.8, width: 0.2, height: 0.05 },
children: [],
},
],
};
const tool = createAwaitUiElementTool(makeMockRegistry({}));
const result = await tool.execute(
{ chromium: makeChromiumApi(tree) },
{
udid: CHROMIUM_ID,
condition: "visible",
selector: { text: "Continue" },
timeoutMs: 2000,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(true);
});
it("surfaces a Chromium fetch failure in the timeout note (last tree fetch failed)", async () => {
const failingApi = {
refreshViewport: async () => ({ width: 1024, height: 768 }),
cdp: {
send: async () => {
throw new Error("renderer detached");
},
},
} as unknown as ChromiumCdpApi;
const tool = createAwaitUiElementTool(makeMockRegistry({}));
const result = await tool.execute(
{ chromium: failingApi },
{
udid: CHROMIUM_ID,
condition: "visible",
selector: { text: "Continue" },
timeoutMs: 40,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(false);
expect(result.note).toMatch(/last tree fetch failed/i);
expect(result.note).toMatch(/renderer detached/);
});
// ── Degraded iOS AX (backend down) ───────────────────────────────────────
it("does NOT report `hidden` success while the AX backend is down", async () => {
// describe() throws → iOS path returns an empty tree + boot hint. Absence in
// that tree is not evidence the element is gone, so hidden must not succeed.
const tool = createAwaitUiElementTool(iosRegistry(makeFailingAXService()));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "hidden",
selector: { text: "Spinner" },
timeoutMs: 40,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(false);
// The boot hint is surfaced so the agent learns the real cause.
expect(result.note).toMatch(/boot-device/i);
});
it("surfaces the degraded boot hint in a timeout note even when the tree has elements", async () => {
const { api } = makeSequencedAXService(
[axResponse([{ label: "Header", frame: FRAME, traits: [] }])],
{ degraded: true }
);
const tool = createAwaitUiElementTool(iosRegistry(api));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "visible",
selector: { text: "Nope" },
timeoutMs: 40,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(false);
expect(result.note).toMatch(/boot-device/i);
});
// ── The cause an unmet wait reports ──────────────────────────────────────
//
// Only `unmet` judges the condition. `flow-add-step` quotes it to tell an
// author to rewrite or delete a step. These tests drive the real tool, so
// they cover cases a hand-written note fixture cannot produce.
it("calls a wait that never got a readable tree unreadable, hint and all", async () => {
// A dead AX backend gives an empty tree plus a boot hint, which
// `appendDiagnostics` folds onto the note. The note is thus always
// decorated, so a match on the plain note text fails.
const tool = createAwaitUiElementTool(iosRegistry(makeFailingAXService()));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "hidden",
selector: { text: "Spinner" },
timeoutMs: 40,
pollIntervalMs: 10,
}
);
expect(result.note).toMatch(/could not confirm the element is hidden/i);
expect(result.note).toMatch(/boot-device/i); // decorated, so `===` cannot fire
expect(unmetUiWaitCause(result)).toBe("unreadable");
});
it.each(["visible", "exists", "text"] as const)(
"calls a blind `%s` wait unreadable, though its note reads like a genuine miss",
async (condition) => {
// A blind window gives the same note text as a real miss. Only the loop
// knows that no read was trustworthy, so the cause comes from there.
const tool = createAwaitUiElementTool(iosRegistry(makeFailingAXService()));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition,
selector: { text: "Spinner" },
expectedText: condition === "text" ? "done" : undefined,
timeoutMs: 40,
pollIntervalMs: 10,
}
);
expect(result.note).toMatch(/no element matched the selector before timeout/i);
expect(unmetUiWaitCause(result)).toBe("unreadable");
}
);
it("calls a genuine miss on a readable tree unmet", async () => {
const { api } = makeSequencedAXService([
axResponse([{ label: "Header", frame: FRAME, traits: [] }]),
]);
const tool = createAwaitUiElementTool(iosRegistry(api));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "visible",
selector: { text: "Nope" },
timeoutMs: 40,
pollIntervalMs: 10,
}
);
expect(unmetUiWaitCause(result)).toBe("unmet");
});
// A Chromium session that serves one tree and then throws. The loop sees a
// fetch error, not the empty tree plus hint that iOS degrades to.
function makeChromiumApiThatDiesAfterOneRead(): ChromiumCdpApi {
const tree = {
role: "html",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
{
role: "heading",
label: "Header",
frame: { x: 0.1, y: 0.1, width: 0.5, height: 0.05 },
children: [],
},
],
};
let reads = 0;
return {
refreshViewport: async () => ({ width: 1024, height: 768 }),
cdp: {
send: async (method: string) => {
if (method !== "Runtime.evaluate") return {};
if (reads++ > 0) throw new Error("renderer detached");
return { result: { value: JSON.stringify({ tree, truncated: false }) } };
},
},
} as unknown as ChromiumCdpApi;
}
it("keeps a genuine miss unmet when only the final poll fails", async () => {
// `lastError` describes only the last fetch. One bad trailing read must not
// read as "nothing was ever compared", for a step that hard-fails at
// replay. The trusted read is one clamped sleep back, inside the tolerance.
// `pollIntervalMs` is above `timeoutMs` on purpose: the sleep clamps to the
// deadline, and the 2000ms ceiling absorbs scheduler slip. At 500 a ~400ms
// slip flipped the cause under load.
const tool = createAwaitUiElementTool(makeMockRegistry({}));
const result = await tool.execute(
{ chromium: makeChromiumApiThatDiesAfterOneRead() },
{
udid: CHROMIUM_ID,
condition: "visible",
selector: { text: "Nope" },
timeoutMs: 600,
pollIntervalMs: 2000,
}
);
expect(result.note).toMatch(/last tree fetch failed/i);
expect(unmetUiWaitCause(result)).toBe("unmet");
});
it("holds `hidden` to a stricter bar than the dark-tail tolerance", async () => {
// Same fixture and timings as the test above, which comes back `unmet`.
// `hidden` waits for absence, so an unjudgeable final read cannot confirm
// the element left. The selector matches the first tree, so this is a real
// "still there" case.
const tool = createAwaitUiElementTool(makeMockRegistry({}));
const result = await tool.execute(
{ chromium: makeChromiumApiThatDiesAfterOneRead() },
{
udid: CHROMIUM_ID,
condition: "hidden",
selector: { text: "Header" },
timeoutMs: 600,
pollIntervalMs: 2000,
}
);
expect(result.success).toBe(false);
expect(unmetUiWaitCause(result)).toBe("unreadable");
});
it("stops vouching for a verdict the reads went dark long before", async () => {
// Same shape, but the trusted read is many poll intervals back. What it saw
// no longer describes the screen at the deadline.
const tool = createAwaitUiElementTool(makeMockRegistry({}));
const result = await tool.execute(
{ chromium: makeChromiumApiThatDiesAfterOneRead() },
{
udid: CHROMIUM_ID,
condition: "visible",
selector: { text: "Nope" },
timeoutMs: 600,
pollIntervalMs: 20,
}
);
expect(result.note).toMatch(/last tree fetch failed/i);
expect(unmetUiWaitCause(result)).toBe("unreadable");
});
// An AX service that answers once, then hangs on every later describe().
// Unlike `makeChromiumApiThatDiesAfterOneRead`, no read throws, so nothing
// sets `lastError`. A silent source (a wedged AX service, an Android
// `getHierarchy` RPC on its 15s bound) leaves the loop with a stale tree.
function makeAXServiceThatHangsAfterOneRead(): AXServiceApi {
let reads = 0;
return {
degraded: false,
describe: () =>
reads++ === 0
? Promise.resolve(axResponse([{ label: "Header", frame: FRAME, traits: [] }]))
: new Promise(() => {}),
alertCheck: async () => false,
ping: async () => true,
};
}
it("calls a wait unreadable when the tree source went silent for most of the window", async () => {
// The screen is read once at t≈0 and never again. `unmet` here would send
// the recorder's author to delete a step that nothing re-checked.
const tool = createAwaitUiElementTool(iosRegistry(makeAXServiceThatHangsAfterOneRead()));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "visible",
selector: { text: "Nope" },
timeoutMs: 300,
pollIntervalMs: 20,
}
);
// No fetch threw, so the note reads exactly like a genuine miss.
expect(result.note).toMatch(/no element matched/i);
expect(result.note ?? "").not.toMatch(/fetch failed|did not complete/i);
expect(unmetUiWaitCause(result)).toBe("unreadable");
});
it("holds a silent source to the same bar on `hidden`", async () => {
// `hidden` resolves on absence. The note says the element is still visible,
// but that reading is 300ms stale.
const tool = createAwaitUiElementTool(iosRegistry(makeAXServiceThatHangsAfterOneRead()));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "hidden",
selector: { text: "Header" },
timeoutMs: 300,
pollIntervalMs: 20,
}
);
expect(result.note).toMatch(/still visible at timeout/i);
expect(unmetUiWaitCause(result)).toBe("unreadable");
});
it.each(["visible", "hidden"] as const)(
"keeps `%s` unmet when only the deadline poll straddles, with the reads still fresh",
async (condition) => {
// The over-correction guard. `pollIntervalMs` exceeds `timeoutMs`, so the
// last poll starts with ~0ms of budget and can never complete. If that
// routine straddle counted as an untrusted read, every timeout here would
// be `unreadable`. The fresh tail, one interval back, keeps it `unmet`.
const tool = createAwaitUiElementTool(iosRegistry(makeAXServiceThatHangsAfterOneRead()));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
// `hidden` needs a selector that MATCHED, or it resolves true on the
// first read; `visible` needs one that did not.
condition,
selector: { text: condition === "hidden" ? "Header" : "Nope" },
timeoutMs: 600,
pollIntervalMs: 2000,
}
);
expect(result.success).toBe(false);
expect(unmetUiWaitCause(result)).toBe("unmet");
}
);
// The tolerance, bracketed. Each case has one trusted read at t≈0, so the
// dark tail is `timeoutMs` and only its span in poll intervals varies. 1.3
// intervals must stay a blip and 2.6 must not, which pins the multiple to 2.
// Scheduler slip only lengthens a tail, and the first case has 700ms of
// headroom.
it.each([
{ intervals: "1.3", timeoutMs: 1300, cause: "unmet" },
{ intervals: "2.6", timeoutMs: 2600, cause: "unreadable" },
])("calls a dark tail of $intervals poll intervals $cause", async ({ timeoutMs, cause }) => {
const tool = createAwaitUiElementTool(iosRegistry(makeAXServiceThatHangsAfterOneRead()));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "visible",
selector: { text: "Nope" },
timeoutMs,
pollIntervalMs: 1000,
}
);
expect(unmetUiWaitCause(result)).toBe(cause);
});
it("stops a long poll interval buying a verdict for a window nobody watched", async () => {
// The tolerance is a multiple of the caller's interval, and `pollIntervalMs`
// goes up to 5000ms, so two intervals reach 10s. Here 2.5s of silence sits
// inside the 3000ms the multiple alone allows. A rare poll must not buy the
// `unmet` cause that licenses deleting the step.
const tool = createAwaitUiElementTool(iosRegistry(makeAXServiceThatHangsAfterOneRead()));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "visible",
selector: { text: "Nope" },
timeoutMs: 2500,
pollIntervalMs: 1500,
}
);
expect(unmetUiWaitCause(result)).toBe("unreadable");
});
it("does not trust a final read that landed but was blind", async () => {
// The third term: a fetch that succeeds and still cannot be judged. An
// empty tree after a match is a transient blank frame, not evidence.
// `text` keeps the wait running through it. `visible` would resolve on the
// first read, and `hidden` has its own guard for the same blank.
const { api } = makeSequencedAXService([
axResponse([{ label: "Spinner", frame: FRAME, traits: [] }]),
axResponse([]),
]);
const tool = createAwaitUiElementTool(iosRegistry(api));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "text",
selector: { text: "Spinner" },
expectedText: "done",
timeoutMs: 400,
pollIntervalMs: 20,
}
);
expect(result.success).toBe(false);
// Every later fetch returned, so there is no error to read the cause off.
// Without the blind term this becomes a confident `unmet` on a blank screen.
expect(result.note ?? "").not.toMatch(/fetch failed|did not complete/i);
expect(unmetUiWaitCause(result)).toBe("unreadable");
});
it("calls a cancelled wait cancelled", async () => {
const controller = new AbortController();
controller.abort();
const { api } = makeSequencedAXService([axResponse([])]);
const tool = createAwaitUiElementTool(iosRegistry(api));
const result = await tool.execute(
{},
{ udid: IOS_UDID, condition: "visible", selector: { text: "Nope" }, timeoutMs: 40 },
{ signal: controller.signal } as never
);
expect(unmetUiWaitCause(result)).toBe("cancelled");
});
it("leaves `cause` off a wait that was met", async () => {
// `unmetUiWaitCause` falls back to `unmet` for any note it does not know,
// so a stray `cause` on a passing wait accuses a check that held. This
// covers both success shapes: the plain one, and the `hidden` arm that
// returns a note of its own.
const { api } = makeSequencedAXService([
axResponse([{ label: "Header", frame: FRAME, traits: [] }]),
]);
const tool = createAwaitUiElementTool(iosRegistry(api));
const met = await tool.execute(
{},
{ udid: IOS_UDID, condition: "visible", selector: { text: "Header" }, timeoutMs: 200 }
);
expect(met.success).toBe(true);
expect(met.cause).toBeUndefined();
const alreadyGone = await tool.execute(
{},
{ udid: IOS_UDID, condition: "hidden", selector: { text: "Nope" }, timeoutMs: 200 }
);
expect(alreadyGone.success).toBe(true);
expect(alreadyGone.note).toMatch(/condition met immediately/i);
expect(alreadyGone.cause).toBeUndefined();
});
it("defaults to unmet for a result carrying no cause at all", async () => {
// The note fallback, for a result that crossed a boundary without the
// field: `unmet` is what every caller acted on before the cause existed.
expect(unmetUiWaitCause({ success: false, elapsed: 10 })).toBe("unmet");
expect(unmetUiWaitCause({ success: false, elapsed: 10, note: "no element matched" })).toBe(
"unmet"
);
expect(
unmetUiWaitCause({ success: false, elapsed: 10, note: "last tree fetch failed: boom" })
).toBe("unreadable");
});
// ── Per-fetch deadline ───────────────────────────────────────────────────
it("bounds a single slow fetch to the wait budget instead of its internal timeout", async () => {
// describe() hangs far longer than timeoutMs. The wait must give up at ~its
// own deadline, not block on the slow fetch.
const slowApi: AXServiceApi = {
degraded: false,
describe: () => new Promise(() => {}), // never resolves
alertCheck: async () => false,
ping: async () => true,
};
const tool = createAwaitUiElementTool(iosRegistry(slowApi));
const start = Date.now();
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "visible",
selector: { text: "Nope" },
timeoutMs: 80,
pollIntervalMs: 10,
}
);
const elapsed = Date.now() - start;
expect(result.success).toBe(false);
expect(elapsed).toBeLessThan(1000);
expect(result.note).toMatch(/did not complete within/i);
});
it("a final fetch straddling the deadline still reports the condition note, not a fetch failure", async () => {
// First poll returns a usable (empty) tree; later polls hang. The wait must
// give up at the deadline, and because it already read the screen once, the
// note must be the normal "no element matched" — not "tree fetch did not
// complete" (that's reserved for never having read the screen at all).
let i = 0;
const api: AXServiceApi = {
degraded: false,
describe: () =>
i++ === 0
? Promise.resolve(axResponse([{ label: "Header", frame: FRAME, traits: [] }]))
: new Promise(() => {}),
alertCheck: async () => false,
ping: async () => true,
};
const tool = createAwaitUiElementTool(iosRegistry(api));
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "visible",
selector: { text: "Nope" },
timeoutMs: 120,
pollIntervalMs: 30,
}
);
expect(result.success).toBe(false);
expect(result.note).toMatch(/no element matched/i);
expect(result.note ?? "").not.toMatch(/did not complete/i);
// The same run also produces a cause. One read at t≈0 and a 120ms window
// leave the note 120ms stale, so `unmet` is not available here.
expect(unmetUiWaitCause(result)).toBe("unreadable");
});
it("aborts promptly even while a fetch is in flight", async () => {
const slowApi: AXServiceApi = {
degraded: false,
describe: () => new Promise(() => {}), // never resolves
alertCheck: async () => false,
ping: async () => true,
};
const tool = createAwaitUiElementTool(iosRegistry(slowApi));
const controller = new AbortController();
setTimeout(() => controller.abort(), 30);
const result = await tool.execute(
{},
{
udid: IOS_UDID,
condition: "visible",
selector: { text: "Nope" },
timeoutMs: 5000,
pollIntervalMs: 10,
},
{ signal: controller.signal } as never
);
expect(result.success).toBe(false);
expect(result.note).toMatch(/cancel/i);
expect(result.elapsed).toBeLessThan(2000);
});
// ── Pure matching / ordering units ───────────────────────────────────────
// The selector is a substring match, so it can hit more than one node. These
// pin the "evaluate across all matches" behaviour and the reading-order
// "first" used by `text`.
describe("multi-match condition evaluation", () => {
const VISIBLE = { x: 0.1, y: 0.4, width: 0.5, height: 0.05 };
const ZERO = { x: 0.1, y: 0.4, width: 0, height: 0 };
const node = (
label: string,
frame: { x: number; y: number; width: number; height: number },
children: DescribeNode[] = []
): DescribeNode => ({ role: "AXStaticText", frame, children, label });
const tree = (children: DescribeNode[]): DescribeNode => ({
role: "AXGroup",
frame: { x: 0, y: 0, width: 1, height: 1 },
children,
});
type EvalParams = Parameters<typeof evaluateMatches>[0];
const params = (condition: string, expectedText?: string): EvalParams =>
({ condition, selector: { text: "Item" }, expectedText }) as unknown as EvalParams;
it("findAll collects every match, including nested ones, but NOT the root", () => {
const t = tree([node("Row", VISIBLE, [node("Item", VISIBLE)]), node("Item", ZERO)]);
expect(findAll(t, { text: "Item" })).toHaveLength(2);
});
it("findAll never matches the synthetic root node itself", () => {
// Root role is AXGroup; a role:"AXGroup" selector must skip the root.
const t = tree([node("Leaf", VISIBLE)]);
expect(findAll(t, { role: "AXGroup" })).toHaveLength(0);
});
it("`visible` succeeds when a later match is visible even if an earlier match is zero-area", () => {
const matches = findAll(tree([node("Item", ZERO), node("Item", VISIBLE)]), { text: "Item" });
expect(matches).toHaveLength(2);
expect(evaluateMatches(params("visible"), matches)).toBe(true);
});
it("`hidden` stays unsatisfied while any match is still visible (earlier match zero-area)", () => {
const matches = findAll(tree([node("Item", ZERO), node("Item", VISIBLE)]), { text: "Item" });
expect(evaluateMatches(params("hidden"), matches)).toBe(false);
});
it("`visible` fails when every match is zero-area", () => {
const matches = findAll(tree([node("Item", ZERO), node("Item", ZERO)]), { text: "Item" });
expect(evaluateMatches(params("visible"), matches)).toBe(false);
});
it("`hidden` succeeds when every match is zero-area", () => {
const matches = findAll(tree([node("Item", ZERO)]), { text: "Item" });
expect(evaluateMatches(params("hidden"), matches)).toBe(true);
});
it("`text` inspects the first match in READING order, not DFS order", () => {
// DFS order reaches "Item bottom" (higher y) first because it appears
// earlier in the children array. Reading order must pick the visually
// topmost node (smaller y) — mirroring how describe lists them.
const bottom = node("Item bottom", { x: 0.1, y: 0.8, width: 0.5, height: 0.05 });
const top = node("Item top", { x: 0.1, y: 0.2, width: 0.5, height: 0.05 });
const matches = findAll(tree([bottom, top]), { text: "Item" });
expect(evaluateMatches(params("text", "top"), matches)).toBe(true);
expect(evaluateMatches(params("text", "bottom"), matches)).toBe(false);
});
});
// ── Schema ───────────────────────────────────────────────────────────────
describe("schema validation", () => {
const schema = createAwaitUiElementTool(iosRegistry({} as AXServiceApi)).zodSchema!;
it("rejects the removed `time` condition", () => {
expect(schema.safeParse({ condition: "time", udid: IOS_UDID, durationMs: 100 }).success).toBe(
false
);
});
it("requires udid for every condition", () => {
expect(schema.safeParse({ condition: "visible", selector: { text: "x" } }).success).toBe(
false
);
});
it("requires a selector", () => {
expect(schema.safeParse({ condition: "visible", udid: IOS_UDID }).success).toBe(false);
});
it("rejects `text` without expectedText", () => {
expect(
schema.safeParse({ condition: "text", udid: IOS_UDID, selector: { text: "x" } }).success
).toBe(false);
});
it("rejects a selector with no fields", () => {
expect(schema.safeParse({ condition: "exists", udid: IOS_UDID, selector: {} }).success).toBe(
false
);
});
it("rejects unknown selector constraints instead of silently dropping them", () => {
const result = schema.safeParse({
condition: "visible",
udid: IOS_UDID,
selector: { text: "Order", textMatches: "^Order #\\d+$" },
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toContainEqual(
expect.objectContaining({
code: "unrecognized_keys",
path: ["selector"],
keys: ["textMatches"],
})
);
}
});
it("rejects a selector containing only an unknown field with a pointed error", () => {
const result = schema.safeParse({
condition: "exists",
udid: IOS_UDID,
selector: { roel: "button" },
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toContainEqual(
expect.objectContaining({
code: "unrecognized_keys",
path: ["selector"],
keys: ["roel"],
})
);
}
});
it.each([
{ text: "Order" },
{ identifier: "order-row" },
{ role: "button" },
{ text: "Order", identifier: "order-row", role: "button" },
])("accepts the documented selector fields: %j", (selector) => {
expect(schema.safeParse({ condition: "visible", udid: IOS_UDID, selector }).success).toBe(
true
);
});
});
});