Files
software-mansion__argent/packages/tool-server/test/await-ui-element.test.ts
Hubert Gancarczyk 236db0077f fix(tool-server): support ios-remote in the await tools (#1051)
## Problem

The `await-ui-element` and `await-screen-idle` tools refused a remote
simulator. The tool server sent this error:

```
Tool 'await-ui-element' is not supported on ios-remote simulator (no ios-remote support declared).
```

Both tools poll the same accessibility tree as `describe`. The
`describe` tool reads that tree correctly on a remote simulator.
Therefore the tree source was never the problem.

## Cause

Each tool declares its platform support in a capability record. The
`describe` tool declares `appleRemote`. The two wait tools did not
declare it. The HTTP layer rejects the request before the tool starts.

Three more branches test `platform === "ios"`. That test is false for a
remote device. These branches select the tree source, the dependency
preflight, and the tvOS probe.

Nobody excluded the platform deliberately. Git history has no commit
that adds `appleRemote` to either file. The `appleRemote` concept
arrived on 2026-07-01. The `await-screen-idle` tool arrived two days
later.

## Effect on an agent session

The MCP layer calls `await-screen-idle` before each automatic
screenshot. On a remote simulator that call failed. The fallback then
slept for the full budget:

- 1500 ms for each tap or gesture
- 2000 ms for `open-url`
- 3000 ms for `launch-app` and `restart-app`
- 15000 ms for `run-sequence`

The user saw no error. The fallback exists for an old tool server, not
for a current server that refuses a device.

## Change

This change adds three lines to each tool:

1. The capability record declares `appleRemote: { simulator: true }`.
2. The tree fetch sends a remote device to `describeIos`. Before, a
remote device fell through to the Chromium branch.
3. The dependency preflight requires `sim-remote`, and not `xcrun`.

The `describe` tool uses the same three settings. The ax-service
blueprint sends the accessibility read through the sim-remote tunnel.

## Test result

I ran the branch tool server against a leased Argent Cloud runner. The
device was a remote iPhone 17 Pro on iOS 26.5. The app was Bluesky. The
app read its bundle from a local Metro server through the sim-remote
tunnel.

| ID | Case | Result |
| --- | --- | --- |
| T1 | `visible` by text | `success: true` |
| T2 | `exists` by identifier | `success: true` |
| T3 | `exists` by a second identifier | `success: true` |
| T4 | `text` condition matches the label | `success: true` |
| T5 | compound role and text selector | `success: true` |
| T6 | absent text gives `unmet` | `success: false` |
| T7 | wrong expected text gives `unmet` | note quotes the actual text |
| T8 | 5000 ms budget honoured | `elapsed: 5003` |
| T9 | `hidden` on an absent element | `success: true` |
| T10 | screen settles | `settled: true, polls: 2` |
| T11 | screen honours `minStableMs: 2000` | `waitedMs: 2265, polls: 8`
|
| T12 | wait across an app restart | `success: true, elapsed: 27301` |
| T13 | `hidden` after the restart | `success: true` |
| T14 | flow record boundary | UNKNOWN verdict |

All 14 cases passed. Two results give the strongest evidence:

- Case T12 blocked for 27.3 seconds across an app restart. The app
rebuilt its bundle, the element appeared, and the tool returned success.
- Case T6 returned `cause: "unmet"`. A failed tree read returns `cause:
"unreadable"`. Therefore the tool read the remote tree and judged the
condition.

### Unit tests

Five new unit tests drive both tools with a remote udid. They use a mock
accessibility service. Each test fails against `main` and passes with
this change.

Local checks:

- `npm run build`: clean
- `npm test -w @argent/tool-server`: 431 files, 6153 tests, 0 failures
- `npm run lint`: clean
- `npx prettier --check`: clean

## Note on flow records

The flow tools declare no capability. Therefore they accept a remote
device today. This change lets the re-probe reach `fetchTree`, which has
no support for `ios-remote`. The recorder catches that error. The
recorder then keeps the step and marks the verdict UNKNOWN, not
known-bad.

An earlier test asserted the refusal. This change inverts that test and
records the new behavior in its comment.

## Documentation

This change needs no documentation update. The tool reference lists
these two tools with a description only. That page has no platform
column, and no page describes remote simulators.

---------

Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
2026-09-07 14:42:23 +02:00

1436 lines
51 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", "sim-remote"]);
});
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
);
});
});
// ── ios-remote (a cloud sim reached through sim-remote) ──────────────────
// The AX service is the same one the local branch resolves; the blueprint
// puts it on a TCP transport across the tunnel. These pin that a remote udid
// actually EXECUTES, not merely that the capability gate lets it through.
describe("ios-remote", () => {
const IOS_REMOTE_UDID = `remote:${IOS_UDID}`;
it("polls the AX tree and succeeds when the element appears", 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_REMOTE_UDID,
condition: "visible",
selector: { text: "Submit" },
timeoutMs: 2000,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(true);
expect(calls()).toBeGreaterThan(1);
});
// The tree WAS read, so a miss is `unmet`. A failed read reports
// `unreadable`, which is what a wrong platform branch would produce.
it("reports `unmet`, not `unreadable`, when the selector misses", async () => {
const { api } = makeSequencedAXService([
axResponse([{ label: "Other", frame: FRAME, traits: ["button"] }]),
]);
const tool = createAwaitUiElementTool(iosRegistry(api));
const result = await tool.execute(
{},
{
udid: IOS_REMOTE_UDID,
condition: "visible",
selector: { text: "Submit" },
timeoutMs: 40,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(false);
expect(result.cause).toBe("unmet");
});
// The second poll is a real re-rendered screen without the Spinner. A wholly
// empty tree is the ambiguous transient-blank case and must not confirm
// `hidden` — that rule holds on a remote sim exactly as it does locally.
it("resolves `hidden` once the element goes away", async () => {
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_REMOTE_UDID,
condition: "hidden",
selector: { text: "Spinner" },
timeoutMs: 2000,
pollIntervalMs: 10,
}
);
expect(result.success).toBe(true);
});
});
});