mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
5ac69349f0
## What
Adds the CSS combinators to flow selectors, read off frames. Any action
or condition can now scope its matches by where an element sits relative
to another:
```yaml
- tap: { text: Delete, within: { id: profile-card } } # CSS "A B" — inside that container
- tap: { role: Switch, next: { text: Wi-Fi } } # CSS "A + B" — the nearest follower
- assert: { visible: { role: Button, after: { text: Danger zone } } } # CSS "A ~ B" — any follower
- assert: { hidden: { any: true, within: { id: empty-state } } } # CSS "*" — the universal selector
```
| Scope | CSS | Reads as |
| --- | --- | --- |
| `within: <sel>` | `A B` | the match's frame sits inside the scope
element's frame |
| `after: <sel>` | `A ~ B` | the match follows the scope element in
reading order |
| `next: <sel>` | `A + B` | as `after`, narrowed to the nearest follower
of each anchor |
Plus `any: true` — the CSS `*` universal selector, accepted only
alongside a scope and never beside `text`/`id`/`role`.
The child combinator `>` has deliberately **no analog**: flow trees
flatten to leaves under one synthetic root, so parent/child structure
does not survive to replay.
## How
- **Geometric, not ancestry-based.** Frames are what survives
flattening, so every scope is resolved from them by `findAll` in
`ui-tree-match`. The synthetic root never counts as a scope element, and
every relation requires a *distinct* node (nothing scopes itself).
- **Reading order is row-band aware.** An element follows the anchor
when it starts below the anchor's bottom edge, *or* shares its row band
and sits entirely to its right. That band rule is what makes `{ role:
Switch, next: { text: Wi-Fi } }` resolve the Wi-Fi row's own switch even
though the taller switch's frame starts a couple of pixels *higher* than
the label's — a raw top-y comparison gets that backwards. `followKind`
is the single classification both the follower test and the nearest-pick
grouping read, so they cannot disagree.
- **`next` is the union over anchors**, exactly as CSS `A + B` is: three
rows on screen give three switches, one per label.
- **Scopes compose and nest**, capped at `MAX_SELECTOR_SCOPES` (6)
counted across the whole relation *tree* — a size bound, not a depth
bound, because three relations branch and the runner's loose-alternative
expansion is exponential in the bare-string scopes it finds.
- **Flow-YAML only.** `await-ui-element`'s strict schema rejects
`within`/`after`/`next`/`any`, so a scope can never be silently dropped
on that path.
- Adding a fourth relation is a **compile error**: `RELATION_RESOLVERS`
is a `Record` keyed by the relation union, so a new scope cannot be
wired through parse/serialize/reporting and then ignored by the match
engine.
## Testing
- **Unit:** full tool-server suite green — **3006 passed / 1 skipped**.
New coverage in `ui-tree-match.test.ts`, `flow-utils.test.ts`,
`flow-selector-scopes.test.ts` (renamed from
`flow-within-selector.test.ts`, now covering all scopes) and a new
`flow-skill-docs.test.ts` that parses every YAML snippet in the skill so
the docs cannot rot. Each fix is mutation-checked: reverting it fails at
least one test.
- **Real-device E2E** (Pixel 3a, Bluesky on Metro 8081, via a branch
tool-server): three flows, 20 steps, all green — geometric ordering both
ways, two `text` asserts that pin `next`'s pick *by identity* out of
five candidates, empty-scope `hidden` semantics, and two observable taps
(`next`-scoped and `any: true`-scoped) that navigated to the *adjacent*
topic rather than the anchor's own.
- **iOS not covered.** Native-devtools does not attach on either booted
simulator in this environment, so *every* flow selector fails there — an
unscoped `{ text: "Video" }` fails identically. Verified environmental,
not caused by this branch: the globally-installed argent reports the
same, direct `SIMCTL_CHILD_DYLD_INSERT_LIBRARIES` injection also fails
to connect, and it survives a forced simulator reboot on both iOS 18.3
and 26.5. Worth re-running the iOS leg once that is healthy.
## Notes for reviewers
Two deliberate deviations from CSS, both documented in the skill:
- **`next` is looser than `+`.** Where `A + B` matches nothing unless
the immediate sibling is a `B`, `next` keeps looking and returns the
nearest match further on. That is what makes it survive the wrapper and
spacer nodes a flattened tree is full of — but a row *missing* the
control you asked for resolves to the next row's rather than failing.
Happy to switch to strict `+` semantics if the loud failure is
preferred.
- **"follows" is not transitive.** Against a tall anchor an element can
follow something that follows the anchor without following it directly,
so nesting `after` scopes is not the same as chaining `~`.
Other things worth a look:
- `nearestAfter` and `afterTester` are direct scans, not indexed. An
earlier sorted index was a *second* implementation of the nearest rule
and the two disagreed on frames near the tolerance; it also measured
2-13x slower than the scan on realistic shapes, with a prune that
provably never fired. The quadratic tail is documented with measured
numbers: 1.2 ms for a single-anchor `next` at the 12k-node tree cap,
~555 ms only when the anchor selector matches essentially every node.
- A bare-string *anchor* cannot self-correct the way the loose fallback
usually does — a decoy `testID` matching your anchor text wins the
identifier pass whenever anything sits after it. The skill tells authors
to spell sibling anchors as maps.
- `MAX_SELECTOR_SCOPES = 6` replaces the previous
`MAX_SELECTOR_WITHIN_DEPTH = 10`, so a `within` chain deeper than 6 that
parsed on the earlier commits of this branch now rejects. Nothing
released depends on it.
---------
Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
370 lines
13 KiB
TypeScript
370 lines
13 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import type { Registry } from "@argent/registry";
|
|
import type { DescribeNode, DescribeTreeData } from "../../src/tools/describe/contract";
|
|
|
|
// Serve the flow tree directly: flows resolve selectors against the platform's
|
|
// full-hierarchy source and hard-fail rather than degrade to the AX tree, so
|
|
// these unit tests stub the tree fetch itself.
|
|
let currentTree: () => DescribeNode;
|
|
vi.mock("../../src/tools/flows/flow-tree", () => ({
|
|
fetchFlowTree: vi.fn(
|
|
async (): Promise<DescribeTreeData> => ({
|
|
tree: currentTree(),
|
|
source: "native-devtools",
|
|
})
|
|
),
|
|
}));
|
|
|
|
import { createRunFlowTool, type FlowRunResult } from "../../src/tools/flows/flow-run";
|
|
import { serializeFlow } from "../../src/tools/flows/flow-utils";
|
|
|
|
const DEVICE = "00000000-0000-0000-0000-0000000000ab"; // iOS UDID shape
|
|
let tmpDir: string;
|
|
|
|
function n(partial: Partial<DescribeNode> & { frame: DescribeNode["frame"] }): DescribeNode {
|
|
return { role: "AXOther", children: [], ...partial };
|
|
}
|
|
|
|
function screen(children: DescribeNode[]): DescribeNode {
|
|
return n({ role: "AXWindow", frame: { x: 0, y: 0, width: 1, height: 1 }, children });
|
|
}
|
|
|
|
interface TapCall {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
function mockRegistry(taps: TapCall[]): Registry {
|
|
return {
|
|
invokeTool: vi.fn(async (id: string, args: Record<string, unknown>) => {
|
|
if (id === "list-devices") return { devices: [] };
|
|
if (id === "gesture-tap") {
|
|
taps.push({ x: args.x as number, y: args.y as number });
|
|
return { tapped: true };
|
|
}
|
|
return { ok: true };
|
|
}),
|
|
getTool: vi.fn((id: string) =>
|
|
id === "gesture-tap" ? { inputSchema: { properties: { udid: {} } } } : undefined
|
|
),
|
|
} as unknown as Registry;
|
|
}
|
|
|
|
async function writeFlow(name: string, yaml: Parameters<typeof serializeFlow>[0]): Promise<void> {
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
await fs.writeFile(path.join(dir, `${name}.yaml`), serializeFlow(yaml), "utf8");
|
|
}
|
|
|
|
function asRun(r: FlowRunResult | { notice: string }): FlowRunResult {
|
|
if (!("steps" in r)) throw new Error(`expected a run result, got notice: ${r.notice}`);
|
|
return r;
|
|
}
|
|
|
|
async function run(name: string): Promise<FlowRunResult & { taps: TapCall[] }> {
|
|
const taps: TapCall[] = [];
|
|
const tool = createRunFlowTool(mockRegistry(taps));
|
|
const result = asRun(await tool.execute({}, { name, project_root: tmpDir, device: DEVICE }));
|
|
return Object.assign(result, { taps });
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-within-"));
|
|
});
|
|
afterEach(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// Two identical "Delete" buttons in different cards — the screen shape the
|
|
// `within` scope exists to disambiguate. FLAT leaves under the root, exactly
|
|
// the shape every flow adapter emits (flow-tree-flatten): the scope must
|
|
// resolve from frames, since ancestry does not survive flattening.
|
|
function twoCards(): DescribeNode {
|
|
return screen([
|
|
n({ identifier: "profile-card", frame: { x: 0, y: 0.1, width: 1, height: 0.3 } }),
|
|
n({ role: "AXButton", label: "Delete", frame: { x: 0.1, y: 0.2, width: 0.3, height: 0.1 } }),
|
|
n({ identifier: "billing-card", frame: { x: 0, y: 0.5, width: 1, height: 0.3 } }),
|
|
n({ role: "AXButton", label: "Delete", frame: { x: 0.1, y: 0.6, width: 0.3, height: 0.1 } }),
|
|
]);
|
|
}
|
|
|
|
describe("within (descendant) selector resolution", () => {
|
|
it("tap resolves the button inside the named container, not the first one on screen", async () => {
|
|
currentTree = twoCards;
|
|
|
|
await writeFlow("scoped", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{
|
|
kind: "tap",
|
|
selector: { text: "Delete", within: { identifier: "billing-card" } },
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await run("scoped");
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["tap:pass"]);
|
|
// Centre of the billing-card button (y 0.6 + 0.1/2), not profile-card's.
|
|
expect(result.taps).toHaveLength(1);
|
|
expect(result.taps[0].y).toBeCloseTo(0.65, 6);
|
|
});
|
|
|
|
it("a bare-string within resolves identifier-first, like every loose selector", async () => {
|
|
// The container is exposed ONLY via testID — a strict text scope would
|
|
// never find it; the loose `within: billing-card` must.
|
|
currentTree = twoCards;
|
|
|
|
await writeFlow("loosescope", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{
|
|
kind: "tap",
|
|
selector: {
|
|
text: "Delete",
|
|
within: { text: "billing-card", loose: true },
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await run("loosescope");
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["tap:pass"]);
|
|
expect(result.taps[0].y).toBeCloseTo(0.65, 6);
|
|
});
|
|
|
|
it("await passes only when the target is inside the container", async () => {
|
|
currentTree = () =>
|
|
screen([
|
|
n({ identifier: "toast-area", frame: { x: 0, y: 0.8, width: 1, height: 0.2 } }),
|
|
n({ label: "Saved", frame: { x: 0.3, y: 0.85, width: 0.4, height: 0.05 } }),
|
|
]);
|
|
|
|
await writeFlow("scopedawait", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{
|
|
kind: "await",
|
|
condition: "visible",
|
|
selector: { text: "Saved", within: { identifier: "toast-area" } },
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await run("scopedawait");
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["await:pass"]);
|
|
});
|
|
|
|
it("assert hidden holds when the only match sits OUTSIDE the container", async () => {
|
|
// "Saved" exists on screen, but not inside toast-area — the scoped
|
|
// `hidden` must pass where an unscoped one would fail.
|
|
currentTree = () =>
|
|
screen([
|
|
n({ identifier: "toast-area", frame: { x: 0, y: 0.8, width: 1, height: 0.2 } }),
|
|
n({ label: "Saved", frame: { x: 0.3, y: 0.1, width: 0.4, height: 0.05 } }),
|
|
]);
|
|
|
|
await writeFlow("scopedhidden", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{
|
|
kind: "assert",
|
|
condition: "hidden",
|
|
selector: { text: "Saved", within: { identifier: "toast-area" } },
|
|
},
|
|
{ kind: "assert", condition: "visible", selector: { text: "Saved" } },
|
|
],
|
|
});
|
|
|
|
const result = await run("scopedhidden");
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
|
|
"assert:pass",
|
|
"assert:pass",
|
|
]);
|
|
});
|
|
|
|
it("the step report target renders the within scope so scoped steps aren't indistinguishable", async () => {
|
|
// Two taps that differ ONLY by their `within` container must not collapse to
|
|
// the same report `target` label — the report stringifier (`selectorLabel`)
|
|
// has to render the scope, in lockstep with the reason stringifier
|
|
// (`describeSelector`).
|
|
currentTree = twoCards;
|
|
|
|
await writeFlow("scopedlabel", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "tap", selector: { text: "Delete", within: { identifier: "billing-card" } } },
|
|
{ kind: "tap", selector: { text: "Delete", within: { identifier: "profile-card" } } },
|
|
],
|
|
});
|
|
|
|
const result = await run("scopedlabel");
|
|
expect(result.steps.map((s) => s.target)).toEqual([
|
|
'"Delete" within (id=billing-card)',
|
|
'"Delete" within (id=profile-card)',
|
|
]);
|
|
});
|
|
|
|
// An unresolved action intentionally consumes its 7.5s auto-wait budget.
|
|
it("a failed scoped tap names the scope in its reason", async () => {
|
|
currentTree = () =>
|
|
screen([n({ label: "Delete", frame: { x: 0.1, y: 0.2, width: 0.3, height: 0.1 } })]);
|
|
|
|
await writeFlow("scopedmiss", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{
|
|
kind: "tap",
|
|
selector: { text: "Delete", within: { identifier: "billing-card" } },
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await run("scopedmiss");
|
|
expect(result.steps[0].status).toBe("fail");
|
|
expect(result.steps[0].reason).toContain('within (id="billing-card")');
|
|
expect(result.taps).toHaveLength(0);
|
|
}, 10_000);
|
|
});
|
|
|
|
// A settings list: three rows, each a label plus a switch to its right. The
|
|
// switches are taller than their labels, so each sits a hair HIGHER — the shape
|
|
// that makes a naive top-to-bottom reading order pick the wrong row's control.
|
|
function settingsRows(): DescribeNode {
|
|
return screen(
|
|
[
|
|
["Airplane Mode", 0.2],
|
|
["Wi-Fi", 0.35],
|
|
["Bluetooth", 0.5],
|
|
].flatMap(([label, y]) => [
|
|
n({ label: label as string, frame: { x: 0.05, y: y as number, width: 0.4, height: 0.04 } }),
|
|
n({
|
|
role: "AXSwitch",
|
|
frame: { x: 0.8, y: (y as number) - 0.005, width: 0.15, height: 0.05 },
|
|
}),
|
|
])
|
|
);
|
|
}
|
|
|
|
describe("after / next (sibling) selector resolution", () => {
|
|
it("tap resolves the switch belonging to the named row, not the first on screen", async () => {
|
|
currentTree = settingsRows;
|
|
|
|
await writeFlow("nextrow", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "tap", selector: { role: "AXSwitch", next: { text: "Wi-Fi" } } }],
|
|
});
|
|
|
|
const result = await run("nextrow");
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["tap:pass"]);
|
|
// Centre of the Wi-Fi row's switch (y 0.345 + 0.05/2), not Airplane Mode's.
|
|
expect(result.taps).toHaveLength(1);
|
|
expect(result.taps[0].x).toBeCloseTo(0.875, 6);
|
|
expect(result.taps[0].y).toBeCloseTo(0.37, 6);
|
|
});
|
|
|
|
it("a universal `any: true` selector taps the element right after the anchor", async () => {
|
|
currentTree = settingsRows;
|
|
|
|
await writeFlow("anynext", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "tap", selector: { any: true, next: { text: "Bluetooth" } } }],
|
|
});
|
|
|
|
const result = await run("anynext");
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["tap:pass"]);
|
|
expect(result.taps[0].y).toBeCloseTo(0.52, 6);
|
|
});
|
|
|
|
it("assert hidden holds when the only match sits BEFORE the anchor", async () => {
|
|
// The Airplane Mode row is above the Wi-Fi one, so it follows nothing there
|
|
// — while the very same target is plainly visible unscoped, and the rows
|
|
// below Airplane Mode do follow it.
|
|
currentTree = settingsRows;
|
|
|
|
await writeFlow("afterhidden", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{
|
|
kind: "assert",
|
|
condition: "hidden",
|
|
selector: { text: "Airplane Mode", after: { text: "Wi-Fi" } },
|
|
},
|
|
{ kind: "assert", condition: "visible", selector: { text: "Airplane Mode" } },
|
|
{
|
|
kind: "assert",
|
|
condition: "visible",
|
|
selector: { text: "Bluetooth", after: { text: "Wi-Fi" } },
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await run("afterhidden");
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
|
|
"assert:pass",
|
|
"assert:pass",
|
|
"assert:pass",
|
|
]);
|
|
});
|
|
|
|
it("a bare-string sibling scope resolves identifier-first, like every loose selector", async () => {
|
|
// The anchor is exposed ONLY via testID — a strict text scope would never
|
|
// find it, so the loose fallback has to reach the identifier pass at depth.
|
|
currentTree = () =>
|
|
screen([
|
|
n({ identifier: "wifi-row", frame: { x: 0.05, y: 0.35, width: 0.4, height: 0.04 } }),
|
|
n({ role: "AXSwitch", frame: { x: 0.8, y: 0.345, width: 0.15, height: 0.05 } }),
|
|
]);
|
|
|
|
await writeFlow("loosenext", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{
|
|
kind: "tap",
|
|
selector: { role: "AXSwitch", next: { text: "wifi-row", loose: true } },
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await run("loosenext");
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["tap:pass"]);
|
|
expect(result.taps[0].y).toBeCloseTo(0.37, 6);
|
|
});
|
|
|
|
it("the step report target renders sibling scopes so scoped steps aren't indistinguishable", async () => {
|
|
currentTree = settingsRows;
|
|
|
|
await writeFlow("scopelabels", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "tap", selector: { role: "AXSwitch", next: { text: "Wi-Fi" } } },
|
|
{ kind: "tap", selector: { any: true, next: { text: "Bluetooth" } } },
|
|
],
|
|
});
|
|
|
|
const result = await run("scopelabels");
|
|
expect(result.steps.map((s) => s.target)).toEqual([
|
|
'role=AXSwitch next ("Wi-Fi")',
|
|
'* next ("Bluetooth")',
|
|
]);
|
|
});
|
|
|
|
// An unresolved action intentionally consumes its 7.5s auto-wait budget.
|
|
it("a failed sibling-scoped tap names the scope in its reason", async () => {
|
|
currentTree = () =>
|
|
screen([n({ role: "AXSwitch", frame: { x: 0.8, y: 0.1, width: 0.15, height: 0.05 } })]);
|
|
|
|
await writeFlow("nextmiss", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "tap", selector: { role: "AXSwitch", next: { text: "Wi-Fi" } } }],
|
|
});
|
|
|
|
const result = await run("nextmiss");
|
|
expect(result.steps[0].status).toBe("fail");
|
|
expect(result.steps[0].reason).toContain('next (text="Wi-Fi")');
|
|
expect(result.taps).toHaveLength(0);
|
|
}, 10_000);
|
|
});
|