Files
software-mansion__argent/packages/tool-server/test/describe-format-tree.test.ts
Sebastian Flajszer 1e69c90981 feat(ios-device): add physical iOS support (#944)
# Physical iOS device support

Try it now (cabled iPhone, unlocked, Developer Mode on):

```bash
argent run list-devices                                            # copy the 25-char UDID
argent run launch-app --udid <UDID> --bundleId com.apple.Preferences
```

First launch builds and signs the on-device runner: 3 to 5 minutes, keep
the phone unlocked. Every later start: seconds, from cache. No signing
setup needed if an Apple ID is signed into Xcode; the team is detected
from the keychain.

## What this adds

- Physical iPhone support, on by default: a cabled iPhone simply appears
in `list-devices`. iPhone-only: physical iPads are skipped at discovery.
- 15 tools ported to hardware. Flows replay on device.
- Zero-config signing from the keychain; `ARGENT_IOS_TEAM_ID` is an
override, nothing is persisted.
- Self-contained npm install: the runner's Swift sources ship in the
package. New package `@argent/ios-device-runner` (an XCUITest bundle
hosting an HTTP command server on the device), new tool-server backend
under `src/utils/ios-device/`.
- The one always-on cost: `list-devices` on macOS now runs physical
discovery in parallel with the other platform probes, under the existing
8s devicectl timeout; off-macOS it early-returns.

## Suggested review order

Rough estimate: half a day for the full pass.

1. `packages/ios-device-runner/PROTOCOL.md`, then `README.md` (wire
contract and reliability model, ~15 min)
2. `packages/tool-server/src/utils/ios-device/` (devicectl, usbmux,
team-detect, runner-build, runner-commands: the core)
3. `packages/tool-server/src/blueprints/ios-device-runner.ts` (service
lifecycle, crash recovery)
4. Per-tool device arms and the capability gates
(`ios-physical-device-classification.test.ts` is the ratchet)
5. Docs page, the two new skills, the Swift runner sources

## Requirements

1. macOS host with full Xcode (iOS platform installed); uses `xcrun
devicectl` and `xcodebuild`.
2. An Apple ID signed into Xcode; automatic signing mints the runner's
provisioning profile.
3. USB cable only. Wi-Fi is deliberately unsupported: the transport is
usbmux, and a Wi-Fi-only device lists as `paired`, not `connected`.
4. iPhone paired with and trusting the Mac, Developer Mode on, A12/2018
or newer (modern 8+16-hex UDID, the CoreDevice floor). Legacy 40-hex
UDIDs are not classified as iOS.
5. Signing resolves as: `ARGENT_IOS_TEAM_ID` in the tool-server's
environment (explicit override, CI) -> keychain detection (newest
identity first, memoized per server run, never persisted). One
certificate: nothing to do. Several teams: newest wins; the first tool
result names the pick, the runner-ups, and the override. No certificate:
the error walks through Xcode > Settings > Accounts and Manage
Certificates > + > Apple Development, then retry; it never names the env
var, since a team id alone cannot sign. Bundle ids derive per team
(`com.argent.runner.t<teamid>`). `ARGENT_IOS_RUNNER_PROJECT` overrides
the project path for unusual layouts.

Build artifacts cache under `~/.argent/ios-device-runner`, keyed by
runner sources, Xcode/SDK version, and signing. A team with no
registered devices self-heals: the failed generic build retries against
the connected phone, which registers it.

## How it works

1. `xcrun devicectl` (CoreDevice) is the control plane: discovery,
readiness, app lifecycle. Readiness gates on the wired transport, so an
unplugged phone is refused before any build starts.
2. A signed XCUITest runner is built lazily with the resolved team,
started via `xcodebuild test-without-building`, and parks as an HTTP
command server for 24h. On an npm install the Xcode project is the copy
shipped inside the package.
3. The listener binds to the device's loopback interface; the host
reaches it over usbmuxd with a dependency-free `node:net` client.
4. Mutating commands are journaled by `commandId`; a lost reply is
resolved by asking `status` for the journaled fate, never by replay
(at-most-once delivery).
5. Routing hangs off `resolveDevice` UDID-shape classification plus a
per-tool capability allowlist. CLI and MCP layers are untouched; outside
tool-server, docs, and the runner package the diff is failure-code
constants (registry), two agent skills plus rule updates (skills), the
pack step (packages/argent), and the e2e smoke script.

## Supported tools

| Tool | Status | Notes |
| ----------------- | --------- |
------------------------------------------------------------------------------------------------------------------------------------------------------------
|
| launch-app | Supported | Plain `devicectl` launch. System UI
(SpringBoard/Spotlight) registers a session only; no launch attempt.
Carries a note when signing is not resolvable yet. |
| restart-app | Supported | System UI bundle ids rejected before any
device contact. |
| reinstall-app | Supported | Same system UI pre-flight. |
| screenshot | Supported | Served through the runner; device-wide, needs
no registered app. (No host-side devicectl screenshot exists on any
toolchain; that dead route was removed.) |
| describe | Supported | AX tree snapshot via the runner. Snapshots the
session app's process, so another process's dialogs are not in the tree.
A backgrounded session app fails with an actionable error instead of
being silently re-fronted. Focus polling is off for this source; typing
uses a fixed settle. |
| keyboard | Supported | Types into the focused element; typing with
nothing focused is refused with "tap the text field first". `enter` and
`backspace` are the named keys (both exist as software-keyboard
characters; there is no per-keycode HID surface on hardware).
Whitespace-only keys rejected, not silently "typed". |
| gesture-tap | Supported | `clickCount > 1` dispatches as one native
multi-tap gesture, not separate taps. |
| gesture-swipe | Supported | Single-finger drag between two points. |
| gesture-custom | Supported | Two shapes only: press-hold and straight
single-finger drag. Second finger or `Move` waypoints rejected with
authoring guidance, before any runner starts. |
| button | Supported | `home`, `volumeUp`, `volumeDown`, `actionButton`
(volume buttons are simulator-unavailable in the SDK, not
device-unavailable). `power`/`appSwitch` have no public XCUIDevice API
and reject before device contact; a button the model lacks gets a typed
refusal via `hasHardwareButton`. |
| await-ui-element | Supported | Polls the same runner snapshot describe
returns; waits and taps see identical frames. Degrades to `cause:
"unreadable"` with a note on a backgrounded app, never a raw throw. |
| await-screen-idle | Supported | Same describe path; resolves
not-settled instead of throwing, so a follow-up screenshot still runs. |
| run-sequence | Supported | Outer gate only; each step pre-flights its
own capability. |
| open-url | Supported | devicectl launches the receiving app with the
URL as its launch payload. http(s) defaults to Safari; any other scheme
needs the new optional `bundleId` param (hardware cannot resolve a
scheme's handler system-wide). |
| screenshot-diff | Supported | Live captures through the runner
(full-resolution, device-wide, no registered app needed); the diff
itself runs on the host. Baselines are per device model/resolution;
`rotation` is ignored on hardware. |

Flows run through these tools; the device flow tree gets the same
`subtreeText` hoisting as every other platform, so container-scoped
`text` asserts work.

## Not supported (400 at the capability gate)

1. gesture-pinch, gesture-rotate: no two-finger coordinate API on
hardware. Pinch/rotate flow directives also fail upfront with a clear
error.
2. screen-recording-start/stop (video): simulator-only capture pipeline.
3. rotate, paste, shake, settings-permissions: simulator-only backends,
not ported (paste also hits the iOS 16+ cross-app paste permission
prompt).
4. native-devtools, debugger, profiler families: simulator-side
instrumentation (DYLD injection does not exist on hardware).
5. Physical iPads: filtered at discovery for now.

A test ratchet (`ios-physical-device-classification.test.ts`) keeps
`apple.device` declarations in sync with the ported list.

## Core contracts

1. Observation never mutates the screen: a backgrounded session app
makes `describe`/`await-*` fail with an error naming the three ways out
(screenshot, launch-app, or launch-app com.apple.springboard). Mutating
commands do re-front, and their result carries `reactivated: true`.
2. The runner listener is unauthenticated; mitigation is the
loopback-only bind.
3. `list-devices` states: `connected` (cabled now, usable) vs `paired`
(unreachable, never auto-bound). Wired transport alone decides; a
Wi-Fi-migrated CoreDevice tunnel does not count.
4. The multi-team signing pick rides the first affected tool result as a
one-shot note (the pre-existing notes channel). The `list-devices`
result shape is unchanged from main: devices and avds, nothing else.
5. Rejections are typed 400 validation errors end to end, so a batch
flow run skips one failing flow instead of aborting.

Also: the runner is a first-class device-owned service
(`stop-simulator-server` and `stop-all-simulator-servers` reap it,
detached xcodebuild child included; a crashed runner respawns on the
next call). A passcode-locked phone refuses launches; the devicectl
10002 hint also covers a pending system prompt on an unlocked phone.
Build failures surface xcodebuild's own `error:` lines with signing
hints (no registered devices, free-team app-id cap, missing
provisioning, the `errSecInternalComponent` partition-list fix). An app
XCTest reports as `.unknown` (a swipe-killed foreign app) is refused
with launch-app guidance. Status-bar pinning is skipped on hardware;
pixel-settle masks the top band.

## Testing

- Unit coverage, hardware-independent: UDID classification and
capability routing; usbmux framing and transport; devicectl list/hints
parsing incl. the silent iPhone-only filter; keychain detection against
real certificate fixtures; runner build/cache self-heals (poisoned
cache, concurrent checkout, fresh-team retry); crash/journal recovery;
the backgrounded-observation and reactivated contracts; keyboard guards
incl. the not-focused refusal; system-UI pre-flight; per-tool device
arms.
- Manual smoke on a real device: `scripts/e2e-ios-physical-device.mjs`.

## Docs and skills

1. `features/physical-ios-devices.mdx`: the user-facing walkthrough
(zero-config signing with the sign-in fallback, prerequisites,
app-scoped model, troubleshooting).
2. `fundamentals/supported-platforms.mdx`: physical iPhones in the
platform matrix (iPads not supported yet).
3. `reference/tools.mdx` and `reference/configuration.mdx`: list-devices
states and the `ARGENT_IOS_TEAM_ID` override.
4. Two skills ship with the package via `argent init`:
`argent-ios-device-setup` (setup ladder plus a troubleshooting table
keyed to real error texts) and `argent-ios-device-interact` (the
app-scoped hardware contract, supported subset with alternatives,
gesture recipes, pitfalls). The agent rule and sibling skills route to
them.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Added experimental USB-connected physical iPhone support.
* Physical iPhones now support discovery, app lifecycle actions, URL
opening, accessibility snapshots, screenshots, taps, swipes, custom
gestures, keyboard input, waits, flows, and screenshot comparisons.
* Added automatic signing-team detection with optional
`ARGENT_IOS_TEAM_ID` configuration.
* Added clear handling for paired or unavailable devices and unsupported
hardware actions.

* **Documentation**
* Added setup, troubleshooting, capability, and physical-device usage
guidance.
* Clarified that physical iPads and several simulator-only tools and
gestures remain unsupported.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-03 14:33:11 +02:00

563 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import { formatDescribeTree } from "../src/tools/describe/format-tree";
import type { DescribeNode } from "../src/tools/describe/contract";
function leaf(
partial: Partial<DescribeNode> & { role: string; frame: DescribeNode["frame"] }
): DescribeNode {
return { children: [], ...partial };
}
function elementLines(out: string): string[] {
return out.split("\n").filter((l) => /^ {2}\S/.test(l));
}
describe("formatDescribeTree", () => {
it("renders flat ax-service children in reading order, one node per line", () => {
const root: DescribeNode = {
role: "AXGroup",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
leaf({ role: "AXButton", label: "C", frame: { x: 0.3, y: 0.5, width: 0.1, height: 0.05 } }),
leaf({
role: "AXButton",
label: "A",
frame: { x: 0.05, y: 0.05, width: 0.1, height: 0.05 },
}),
leaf({ role: "AXButton", label: "B", frame: { x: 0.2, y: 0.5, width: 0.1, height: 0.05 } }),
],
};
const out = formatDescribeTree(root, { source: "ax-service" });
expect(out).toContain("Source: ax-service");
expect(out).toContain("Mode: flat");
const lines = elementLines(out);
expect(lines).toHaveLength(3);
// top-to-bottom, then left-to-right
expect(lines[0]).toContain('"A"');
expect(lines[1]).toContain('"B"');
expect(lines[2]).toContain('"C"');
});
it("renders a nested uiautomator tree with depth indentation and flags", () => {
const root: DescribeNode = {
role: "Screen",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
{
role: "ScrollView",
frame: { x: 0, y: 0.1, width: 1, height: 0.8 },
scrollable: true,
children: [
leaf({
role: "Button",
label: "Like",
frame: { x: 0.1, y: 0.2, width: 0.3, height: 0.1 },
clickable: true,
}),
leaf({
role: "WebView",
label: "[web-view] About",
frame: { x: 0, y: 0.4, width: 1, height: 0.4 },
}),
],
},
],
};
const out = formatDescribeTree(root, { source: "uiautomator" });
expect(out).toContain("Mode: nested");
expect(out).toContain("ScrollView");
expect(out).toMatch(/Button\s+"Like".*\[clickable\]/);
expect(out).toContain("[web-view] About");
const lines = out.split("\n");
const buttonLine = lines.find((l) => l.includes('"Like"'))!;
const scrollLine = lines.find((l) => l.includes("ScrollView"))!;
// child indented deeper than its parent
expect(buttonLine.search(/\S/)).toBeGreaterThan(scrollLine.search(/\S/));
});
it("escapes embedded newlines so per-line alignment survives", () => {
const root: DescribeNode = {
role: "AXGroup",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
leaf({
role: "AXGroup",
label: "Hello\nWorld",
frame: { x: 0.1, y: 0.2, width: 0.8, height: 0.1 },
}),
],
};
const out = formatDescribeTree(root, { source: "ax-service" });
expect(out).toContain('"Hello\\nWorld"');
const labelLines = out.split("\n").filter((l) => l.includes("Hello"));
expect(labelLines).toHaveLength(1);
});
it("handles an empty tree without crashing", () => {
const root: DescribeNode = {
role: "AXGroup",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [],
};
const out = formatDescribeTree(root, { source: "ax-service" });
expect(out).toContain("Mode: flat");
expect(out).toContain("ROOT AXGroup");
expect(elementLines(out)).toHaveLength(0);
});
// Regression for the user-reported bug: a Bluesky landing screen full of
// images was rendering with both images stripped. Any node whose role marks
// it as content (AXImage, AXButton, …) must survive even when label is
// absent, so the agent at least sees that something is at that frame.
it("keeps unlabeled content roles visible in flat mode", () => {
const root: DescribeNode = {
role: "AXGroup",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
leaf({ role: "AXImage", frame: { x: 0.1, y: 0.1, width: 0.3, height: 0.2 } }),
leaf({
role: "AXImage",
label: "Hero illustration",
frame: { x: 0.1, y: 0.3, width: 0.3, height: 0.2 },
}),
leaf({ role: "AXButton", frame: { x: 0.4, y: 0.5, width: 0.2, height: 0.1 } }),
// AXGroup with no label is a pure container — should still be dropped.
leaf({ role: "AXGroup", frame: { x: 0.7, y: 0.7, width: 0.2, height: 0.1 } }),
],
};
const out = formatDescribeTree(root, { source: "ax-service" });
const lines = elementLines(out);
expect(lines.some((l) => /^\s*AXImage\b/.test(l) && !l.includes('"'))).toBe(true);
expect(lines.some((l) => l.includes('"Hero illustration"'))).toBe(true);
expect(lines.some((l) => /^\s*AXButton\b/.test(l) && !l.includes('"'))).toBe(true);
expect(lines.some((l) => /^\s*AXGroup\b/.test(l))).toBe(false);
});
// Regression for issue #3 — the header already prints
// `ROOT <role> <frame>`, so renderNested must not re-emit the root as the
// first body line.
it("does not duplicate the root node in nested mode", () => {
const root: DescribeNode = {
role: "Screen",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
{
role: "FrameLayout",
frame: { x: 0, y: 0.1, width: 1, height: 0.4 },
children: [
leaf({
role: "Button",
label: "Tap",
frame: { x: 0.1, y: 0.2, width: 0.3, height: 0.1 },
clickable: true,
}),
],
},
],
};
const out = formatDescribeTree(root, { source: "uiautomator" });
// Exactly one mention of Screen — the ROOT header line, never the body.
const screenLines = out.split("\n").filter((l) => l.includes("Screen"));
expect(screenLines).toHaveLength(1);
expect(screenLines[0]).toMatch(/^ROOT\s/);
});
// Regression for issue #5 — iOS reports placeholder text under both `label`
// and `value` for text inputs, doubling the byte cost for zero added signal.
it("omits value= when value matches label", () => {
const root: DescribeNode = {
role: "AXGroup",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
leaf({
role: "AXGroup",
label: "Username",
value: "Username",
frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.05 },
}),
leaf({
role: "AXGroup",
label: "Password",
value: "•••••",
frame: { x: 0.1, y: 0.2, width: 0.8, height: 0.05 },
}),
],
};
const out = formatDescribeTree(root, { source: "ax-service" });
const usernameLine = out.split("\n").find((l) => l.includes('"Username"'))!;
expect(usernameLine).not.toContain("value=");
const passwordLine = out.split("\n").find((l) => l.includes('"Password"'))!;
expect(passwordLine).toContain('value="•••••"');
});
// Regression for issue #4 — a long Compose / uiautomator class name no
// longer pushes adjacent columns out of alignment. The role is emitted
// verbatim (no truncation) with a single space before the rest.
it("emits long role names without truncation and without padding-driven misalignment", () => {
const root: DescribeNode = {
role: "android.widget.FrameLayout",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
{
role: "androidx.compose.ui.platform.ComposeView",
frame: { x: 0, y: 0.1, width: 1, height: 0.4 },
children: [
leaf({
role: "Image",
label: "avatar",
frame: { x: 0.1, y: 0.2, width: 0.2, height: 0.2 },
}),
],
},
],
};
const out = formatDescribeTree(root, { source: "uiautomator" });
expect(out).toContain("androidx.compose.ui.platform.ComposeView");
// Each body line: indentation, then the role token, then a single space,
// then either annotations + double-space + frame, or directly the frame.
for (const line of elementLines(out)) {
const match = line.match(/^(\s+)(\S+)( .*)?$/);
expect(match).not.toBeNull();
}
});
// Regression for issue #7 — mode is decided by `source`, not by counting
// grandchildren of the root. A native-devtools fallback that happens to
// produce a flat-shaped tree must NOT drop to flat mode, and an ax-service
// payload that happens to have a wrapping group with one grandchild must
// stay flat.
it("picks mode by source, not by tree silhouette", () => {
const flatShaped: DescribeNode = {
role: "Screen",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
leaf({ role: "Button", label: "X", frame: { x: 0.1, y: 0.1, width: 0.2, height: 0.05 } }),
],
};
expect(formatDescribeTree(flatShaped, { source: "uiautomator" })).toContain("Mode: nested");
const accidentallyDeep: DescribeNode = {
role: "AXGroup",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
{
role: "AXGroup",
frame: { x: 0, y: 0, width: 1, height: 0.5 },
children: [
leaf({
role: "AXStaticText",
label: "Hi",
frame: { x: 0.1, y: 0.1, width: 0.5, height: 0.04 },
}),
],
},
],
};
expect(formatDescribeTree(accidentallyDeep, { source: "ax-service" })).toContain("Mode: flat");
});
// The iOS native-devtools fallback shares the flat layout that ax-service
// emits, so it should also report `Mode: flat`. (Source line still
// distinguishes the two for agents that care.)
it("treats native-devtools as flat", () => {
const root: DescribeNode = {
role: "AXGroup",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
leaf({
role: "AXButton",
label: "Settings",
frame: { x: 0.1, y: 0.1, width: 0.2, height: 0.05 },
}),
],
};
const out = formatDescribeTree(root, { source: "native-devtools" });
expect(out).toContain("Source: native-devtools");
expect(out).toContain("Mode: flat");
});
// The header text is part of the agent-visible response, so it must keep
// pointing at gesture-tap / gesture-swipe / gesture-pinch and the centre
// formula. If this drifts again the runtime help is silently misleading.
it("renders the coordinate-space + tap-formula header on every call", () => {
const empty: DescribeNode = {
role: "AXGroup",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [],
};
const out = formatDescribeTree(empty, { source: "ax-service" });
expect(out).toContain("normalized [0,1] fractions of the screen");
expect(out).toContain("gesture-tap");
expect(out).toContain("tap_x = frame.x + frame.width / 2");
expect(out).toContain("tap_y = frame.y + frame.height / 2");
});
// Physical iOS: the XCUITest runner is single-finger only, so the header
// must not recommend gesture-pinch (agents follow it verbatim, straight into
// a capability reject) and must say where zoom lives instead.
it("omits gesture-pinch from the xcuitest-runner header and names the alternative", () => {
const root: DescribeNode = {
role: "Application",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
leaf({
role: "AXButton",
label: "Zoom in",
frame: { x: 0.1, y: 0.1, width: 0.2, height: 0.05 },
}),
],
};
const out = formatDescribeTree(root, { source: "xcuitest-runner" });
expect(out).toContain("Source: xcuitest-runner");
expect(out).toContain("Mode: nested");
expect(out).not.toContain("gesture-pinch");
expect(out).toContain(
"Pass them straight to gesture-tap / gesture-swipe, which expect this same space. " +
"No two-finger gestures on physical iOS."
);
});
// format-tree.ts is deployed shared code: the xcuitest-runner branch above
// must leave every other source byte-identical. Pin the full simulator
// (ax-service) output, not a substring, so any accidental drift in deployed
// header text fails loudly.
it("keeps the simulator-source output byte-identical to the deployed string", () => {
const empty: DescribeNode = {
role: "AXGroup",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [],
};
const out = formatDescribeTree(empty, { source: "ax-service" });
expect(out).toBe(
"Source: ax-service\n" +
"Mode: flat\n" +
"Coordinates are normalized [0,1] fractions of the screen (x, y, width, height), not pixels.\n" +
"Pass them straight to gesture-tap / gesture-swipe / gesture-pinch, which expect this same space.\n" +
"To tap an element, use its centre: tap_x = frame.x + frame.width / 2, tap_y = frame.y + frame.height / 2.\n" +
"\n" +
"ROOT AXGroup (0.000, 0.000, 1.000, 1.000)\n"
);
});
// Bluesky-style names mix emoji, ZWJ sequences, and bidirectional isolate
// markers (U+202A/U+202C). Those must pass through escapeForLine unchanged
// — only ASCII control chars (\\n, \\r, \\t) get backslash-escaped.
it("preserves emoji and bidi isolates in labels", () => {
const root: DescribeNode = {
role: "AXGroup",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
leaf({
role: "AXStaticText",
label: "‪Dovewoman 💙 🇺🇦 💙‬",
frame: { x: 0.1, y: 0.1, width: 0.5, height: 0.02 },
}),
],
};
const out = formatDescribeTree(root, { source: "ax-service" });
expect(out).toContain("‪Dovewoman 💙 🇺🇦 💙‬");
expect(out).not.toContain("\\u");
});
// Combined interactivity flags all need to surface in a single `[…]` group
// in a deterministic order, otherwise an LLM cannot pattern-match on them.
it("emits all interactivity flags together in a stable order", () => {
const root: DescribeNode = {
role: "Screen",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
leaf({
role: "Switch",
label: "Wifi",
frame: { x: 0.1, y: 0.1, width: 0.3, height: 0.05 },
clickable: true,
longClickable: true,
scrollable: false,
checkable: true,
checked: true,
disabled: true,
}),
leaf({
role: "View",
frame: { x: 0.1, y: 0.2, width: 0.3, height: 0.05 },
scrollHidden: 7,
}),
],
};
const out = formatDescribeTree(root, { source: "uiautomator" });
expect(out).toMatch(/\[clickable,long-clickable,checked,disabled\]/);
expect(out).toMatch(/\[scrollHidden=7\]/);
});
// scrollHidden=0 is the "no clipped children" signal from the Android
// parser; emitting the flag at zero would be misleading.
it("does not emit scrollHidden when the count is zero", () => {
const root: DescribeNode = {
role: "Screen",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
leaf({
role: "Button",
label: "Tap",
frame: { x: 0.1, y: 0.1, width: 0.3, height: 0.05 },
scrollHidden: 0,
}),
],
};
const out = formatDescribeTree(root, { source: "uiautomator" });
expect(out).not.toContain("scrollHidden");
});
// identifier is the Android resource-id (and the iOS native-devtools
// accessibility identifier). It's the most stable thing to match on, so it
// must round-trip through the formatter verbatim.
it("surfaces the identifier as an id= attribute", () => {
const root: DescribeNode = {
role: "AXGroup",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
leaf({
role: "AXButton",
identifier: "com.bluesky:id/like_button",
label: "Like",
frame: { x: 0.1, y: 0.5, width: 0.2, height: 0.05 },
}),
],
};
const out = formatDescribeTree(root, { source: "ax-service" });
expect(out).toContain('id="com.bluesky:id/like_button"');
});
// The reading-order sort in flat mode must be stable, so two elements that
// share the same (y, x) keep their source order. (Bluesky-style timelines
// have rows where action buttons sometimes report identical frames.)
it("stably sorts equal-frame nodes in flat mode", () => {
const sharedFrame = { x: 0.1, y: 0.5, width: 0.2, height: 0.05 };
const root: DescribeNode = {
role: "AXGroup",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
leaf({ role: "AXButton", label: "first", frame: sharedFrame }),
leaf({ role: "AXButton", label: "second", frame: sharedFrame }),
leaf({ role: "AXButton", label: "third", frame: sharedFrame }),
],
};
const lines = elementLines(formatDescribeTree(root, { source: "ax-service" }));
expect(lines).toHaveLength(3);
expect(lines[0]).toContain('"first"');
expect(lines[1]).toContain('"second"');
expect(lines[2]).toContain('"third"');
});
// Frames are always rendered to exactly three decimals. This is the
// precision agents pass back to gesture-tap; drifting it would silently
// change tap targets across screens.
it("always renders frames to three decimal places", () => {
const root: DescribeNode = {
role: "AXGroup",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
leaf({
role: "AXButton",
label: "X",
frame: { x: 0.123456789, y: 0.5, width: 0.1, height: 0.05 },
}),
],
};
const out = formatDescribeTree(root, { source: "ax-service" });
expect(out).toMatch(/\(0\.123, 0\.500, 0\.100, 0\.050\)/);
});
// Long Compose / RN trees can stack 200+ wrapper layers. The renderer uses
// an iterative DFS specifically to avoid a recursive stack overflow there.
it("handles deeply nested trees without recursing the JS stack", () => {
let inner: DescribeNode = leaf({
role: "Button",
label: "deep",
frame: { x: 0.4, y: 0.4, width: 0.2, height: 0.05 },
clickable: true,
});
for (let i = 0; i < 500; i++) {
inner = {
role: "FrameLayout",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [inner],
};
}
const root: DescribeNode = {
role: "Screen",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [inner],
};
const out = formatDescribeTree(root, { source: "uiautomator" });
expect(out).toContain('"deep"');
expect(out.split("\n").length).toBeGreaterThan(500);
});
// The trim rule must only strip "value === label" — value strings that
// happen to start with the label (e.g. a search field whose label is
// "Search" and value is "Search query") need to survive.
it("keeps value when it differs from label even by a suffix", () => {
const root: DescribeNode = {
role: "AXGroup",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
leaf({
role: "AXTextField",
label: "Search",
value: "Search ",
frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.05 },
}),
],
};
const out = formatDescribeTree(root, { source: "ax-service" });
expect(out).toContain('value="Search "');
});
// hasContent() must NOT count a wrapper as content just because checked is
// false — the field defaults to `undefined`, but a node where the
// adapter explicitly set checkable=false should still drop out.
it("does not treat checkable=false as a content signal", () => {
const root: DescribeNode = {
role: "AXGroup",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
leaf({
role: "AXGroup",
frame: { x: 0.1, y: 0.1, width: 0.3, height: 0.05 },
checkable: false,
}),
],
};
const out = formatDescribeTree(root, { source: "ax-service" });
expect(elementLines(out)).toHaveLength(0);
});
// Regression: nested mode previously keyed on "uiautomator" only, so
// "android-devtools" responses rendered flat and lost all descendants.
it("renders android-devtools source in nested mode", () => {
const root: DescribeNode = {
role: "Screen",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
{
role: "ScrollView",
frame: { x: 0, y: 0.1, width: 1, height: 0.8 },
scrollable: true,
children: [
leaf({
role: "Button",
label: "Like",
frame: { x: 0.1, y: 0.2, width: 0.3, height: 0.1 },
clickable: true,
}),
],
},
],
};
const out = formatDescribeTree(root, { source: "android-devtools" });
expect(out).toContain("Mode: nested");
expect(out).toMatch(/Button\s+"Like"/);
});
});