Files
Hubert Gancarczyk 5ac69349f0 feat(flow): CSS combinator scopes for flow selectors (#569)
## 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>
2026-07-27 18:25:27 +02:00

1468 lines
55 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 { describe, it, expect, beforeEach } from "vitest";
import * as path from "node:path";
import {
serializeFlow,
parseFlow,
describeSelector,
setActiveFlow,
getActiveFlow,
getActiveFlowOrNull,
clearActiveFlow,
setActiveProjectRoot,
clearActiveProjectRoot,
getFlowPath,
appIdForPlatform,
chromiumLaunchSpec,
type FlowFile,
} from "../../src/tools/flows/flow-utils";
// ── serializeFlow ────────────────────────────────────────────────────
describe("serializeFlow", () => {
it("serializes an empty flow with prerequisite", () => {
const flow: FlowFile = {
executionPrerequisite: "App on home screen",
steps: [],
};
const result = serializeFlow(flow);
expect(result).toContain("executionPrerequisite: App on home screen");
expect(result).toContain("steps: []");
});
it("serializes echo steps", () => {
const flow: FlowFile = {
executionPrerequisite: "Fresh reload",
steps: [{ kind: "echo", message: "Hello" }],
};
const result = serializeFlow(flow);
expect(result).toContain("- echo: Hello");
});
it("serializes tool steps with args", () => {
const flow: FlowFile = {
executionPrerequisite: "",
steps: [{ kind: "tool", name: "tap", args: { x: 0.5, y: 0.3 } }],
};
const result = serializeFlow(flow);
expect(result).toContain("- tool: tap");
expect(result).toContain(" x: 0.5");
expect(result).toContain(" y: 0.3");
});
it("serializes tool steps with empty args (omits args key)", () => {
const flow: FlowFile = {
executionPrerequisite: "",
steps: [{ kind: "tool", name: "screenshot", args: {} }],
};
const result = serializeFlow(flow);
expect(result).toContain("- tool: screenshot");
expect(result).not.toContain("args:");
});
it("rejects gesture targets that cannot round-trip through the parser", () => {
const serializeStep = (step: FlowFile["steps"][number]) =>
serializeFlow({ executionPrerequisite: "", steps: [step] });
expect(() => serializeStep({ kind: "tap", x: 1.5, y: 0.5 })).toThrow(
/normalized 0–1 fractions/i
);
expect(() => serializeStep({ kind: "long-press", x: Number.NaN, y: 0.5 })).toThrow(
/normalized 0–1 fractions/i
);
expect(() => serializeStep({ kind: "tap", x: 0.5 })).toThrow(/needs numeric x and y/i);
expect(() =>
serializeStep({ kind: "long-press", selector: { text: "Row" }, x: 0.5, y: 0.5 })
).toThrow(/selector or x\/y coordinates, not both/i);
});
});
// ── describeSelector ─────────────────────────────────────────────────
describe("describeSelector", () => {
it("spells identifier as id, the flow-YAML spelling", () => {
expect(describeSelector({ identifier: "submit" })).toBe('id="submit"');
});
it("renders a text selector", () => {
expect(describeSelector({ text: "Login" })).toBe('text="Login"');
});
it("drops the internal loose flag", () => {
expect(describeSelector({ text: "Login", loose: true })).toBe('text="Login"');
});
it("joins multiple keys with spaces", () => {
expect(describeSelector({ text: "Login", role: "button" })).toBe('text="Login" role="button"');
});
});
// ── parseFlow ────────────────────────────────────────────────────────
describe("parseFlow", () => {
it("parses a flow with executionPrerequisite and echo steps", () => {
const content = "executionPrerequisite: App on home screen\nsteps:\n - echo: Hello\n";
const flow = parseFlow(content);
expect(flow.executionPrerequisite).toBe("App on home screen");
expect(flow.steps).toEqual([{ kind: "echo", message: "Hello" }]);
});
it("parses tool entries with args", () => {
const content =
'executionPrerequisite: ""\nsteps:\n - tool: tap\n args:\n x: 0.5\n y: 0.3\n';
const flow = parseFlow(content);
expect(flow.steps).toEqual([{ kind: "tool", name: "tap", args: { x: 0.5, y: 0.3 } }]);
});
it("parses tool entries with no args", () => {
const content = 'executionPrerequisite: ""\nsteps:\n - tool: screenshot\n';
const flow = parseFlow(content);
expect(flow.steps).toEqual([{ kind: "tool", name: "screenshot", args: {} }]);
});
it("parses a multi-step flow", () => {
const content = [
"executionPrerequisite: Settings open",
"steps:",
" - echo: Step 1",
" - tool: tap",
" args:",
" x: 0.5",
" - echo: Step 2",
" - tool: screenshot",
" args:",
" udid: ABC",
].join("\n");
const flow = parseFlow(content);
expect(flow.executionPrerequisite).toBe("Settings open");
expect(flow.steps).toEqual([
{ kind: "echo", message: "Step 1" },
{ kind: "tool", name: "tap", args: { x: 0.5 } },
{ kind: "echo", message: "Step 2" },
{ kind: "tool", name: "screenshot", args: { udid: "ABC" } },
]);
});
it("returns empty steps for empty content", () => {
const flow = parseFlow("");
expect(flow.executionPrerequisite).toBe("");
expect(flow.steps).toEqual([]);
});
it("defaults executionPrerequisite to empty string when missing", () => {
const content = "steps:\n - echo: Hello\n";
const flow = parseFlow(content);
expect(flow.executionPrerequisite).toBe("");
expect(flow.steps).toEqual([{ kind: "echo", message: "Hello" }]);
});
it("throws on unrecognized entries", () => {
const content = 'executionPrerequisite: ""\nsteps:\n - bogus: line\n';
expect(() => parseFlow(content)).toThrow("Unrecognized flow entry");
});
it("throws when content is not an object with steps", () => {
expect(() => parseFlow("- echo: Hello\n")).toThrow("expected an object with a steps array");
});
it("throws a validation error (not a TypeError) on a primitive step entry", () => {
const content = 'executionPrerequisite: ""\nsteps:\n - tap\n';
expect(() => parseFlow(content)).toThrow("Unrecognized flow entry");
});
it("throws a validation error on a null step entry", () => {
const content = 'executionPrerequisite: ""\nsteps:\n - ~\n';
expect(() => parseFlow(content)).toThrow("Unrecognized flow entry");
});
it("sugars a bare-string selector into a loose { text } for tap", () => {
const flow = parseFlow("steps:\n - tap: Settings\n");
// Bare string ⇒ loose: resolves identifier-first, then falls back to text.
expect(flow.steps).toEqual([{ kind: "tap", selector: { text: "Settings", loose: true } }]);
});
it("sugars a bare-string selector for type.into", () => {
const flow = parseFlow('steps:\n - type: { into: email, text: "a@b.com" }\n');
expect(flow.steps).toEqual([
{ kind: "type", into: { text: "email", loose: true }, text: "a@b.com" },
]);
});
it("defaults type.submit to on (no submit key in the parsed model)", () => {
const flow = parseFlow('steps:\n - type: { into: email, text: "a@b.com" }\n');
expect(flow.steps[0]).not.toHaveProperty("submit");
});
it("parses and round-trips an explicit type.submit: false opt-out", () => {
const flow = parseFlow('steps:\n - type: { into: email, text: "a@b.com", submit: false }\n');
expect(flow.steps).toEqual([
{ kind: "type", into: { text: "email", loose: true }, text: "a@b.com", submit: false },
]);
expect(serializeFlow(flow)).toContain("submit: false");
expect(parseFlow(serializeFlow(flow)).steps).toEqual(flow.steps);
});
it("rejects a non-boolean type.submit", () => {
expect(() => parseFlow('steps:\n - type: { into: email, text: "x", submit: 3 }\n')).toThrow();
});
it("keeps an explicit { text } map strict (no loose fallback)", () => {
const flow = parseFlow("steps:\n - tap: { text: Settings }\n");
expect(flow.steps).toEqual([{ kind: "tap", selector: { text: "Settings" } }]);
});
it.each([
[
"selector",
"steps:\n - tap: { text: { matches: '^Order #\\d+$' } }\n",
{ kind: "tap", selector: { textMatches: "^Order #\\d+$" } },
],
[
"text condition",
"steps:\n - assert: { text: { in: total, matches: '^Total: \\$\\d+$' } }\n",
{
kind: "assert",
condition: "text",
selector: { text: "total", loose: true },
expectedText: "^Total: \\$\\d+$",
textMatch: "matches",
},
],
] as const)("accepts a valid regex pattern at the %s ingress", (_ingress, yaml, expected) => {
expect(parseFlow(yaml).steps).toEqual([expected]);
});
it("accepts a regex selector combined with id and role", () => {
expect(
parseFlow(
"steps:\n - tap: { text: { matches: '^Order #\\d+$' }, id: order-row, role: button }\n"
).steps
).toEqual([
{
kind: "tap",
selector: {
textMatches: "^Order #\\d+$",
identifier: "order-row",
role: "button",
},
},
]);
});
it.each([
["id", "''"],
["id", "42"],
["role", "''"],
["role", "42"],
])(
"validates an invalid regex-selector %s through the same schema as a literal selector (%s)",
(field, value) => {
const validationDetail = (text: string | { matches: string }): string => {
const yamlText = typeof text === "string" ? text : `{ matches: '${text.matches}' }`;
try {
parseFlow(`steps:\n - tap: { text: ${yamlText}, ${field}: ${value} }\n`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const detail = /^Unrecognized flow entry \((.*)\): /.exec(message)?.[1];
expect(detail).toBeDefined();
return detail!;
}
throw new Error("expected selector validation to fail");
};
const literalDetail = validationDetail("Order");
const regexDetail = validationDetail({ matches: "^Order #\\d+$" });
expect(literalDetail).toMatch(/^tap: /);
expect(regexDetail).toBe(literalDetail);
}
);
it.each([
[
"selector",
"steps:\n - assert: { visible: { text: { matches: '(' } } }\n",
"assert.visible: text",
],
[
"text condition",
"steps:\n - assert: { text: { in: total, matches: '(' } }\n",
"assert text",
],
])("reports invalid regex syntax consistently at the %s ingress", (_ingress, yaml, where) => {
expect(() => parseFlow(yaml)).toThrow(`${where} \`matches\` is not a valid regular expression`);
});
it("parses the map form's `id` as the internal identifier field (strict)", () => {
const flow = parseFlow("steps:\n - tap: { id: submit-btn }\n");
expect(flow.steps).toEqual([{ kind: "tap", selector: { identifier: "submit-btn" } }]);
});
it("accepts `identifier` as a parse-only alias for `id`", () => {
const flow = parseFlow("steps:\n - tap: { identifier: submit-btn }\n");
expect(flow.steps).toEqual([{ kind: "tap", selector: { identifier: "submit-btn" } }]);
});
it("rejects a selector map carrying both `id` and `identifier`", () => {
expect(() => parseFlow("steps:\n - tap: { id: a, identifier: b }\n")).toThrow(
/`id` or `identifier`.*not both/
);
});
it("re-serializes an identifier-spelled flow with the `id` spelling", () => {
// Old files parse via the alias; the next write (appendStep re-serializes
// the whole file) migrates them to the canonical `id` spelling.
const yaml = serializeFlow(parseFlow("steps:\n - tap: { identifier: submit-btn }\n"));
expect(yaml).toContain("id: submit-btn");
expect(yaml).not.toContain("identifier:");
});
it("parses condition-as-key await/assert sugar (visible/exists/hidden)", () => {
const flow = parseFlow(
[
"steps:",
" - await: { visible: Account }",
" - assert: { exists: { id: row } }",
" - await: { hidden: spinner }",
].join("\n")
);
expect(flow.steps).toEqual([
{ kind: "await", condition: "visible", selector: { text: "Account", loose: true } },
{ kind: "assert", condition: "exists", selector: { identifier: "row" } },
{ kind: "await", condition: "hidden", selector: { text: "spinner", loose: true } },
]);
});
it("parses the text sugar { in, contains } as a substring match", () => {
const flow = parseFlow(
'steps:\n - assert: { text: { in: { id: counter }, contains: "Taps: 0" } }\n'
);
expect(flow.steps).toEqual([
{
kind: "assert",
condition: "text",
selector: { identifier: "counter" },
expectedText: "Taps: 0",
textMatch: "contains",
},
]);
});
it("parses the text sugar { in, equals } as an exact match", () => {
const flow = parseFlow(
'steps:\n - assert: { text: { in: { id: counter }, equals: "Taps: 0" } }\n'
);
expect(flow.steps).toEqual([
{
kind: "assert",
condition: "text",
selector: { identifier: "counter" },
expectedText: "Taps: 0",
textMatch: "equals",
},
]);
});
it("rejects text sugar with both contains and equals", () => {
expect(() =>
parseFlow("steps:\n - assert: { text: { in: counter, contains: a, equals: b } }\n")
).toThrow(/exactly one of `contains`, `equals`, or `matches`/);
});
it("rejects the explicit { condition, selector, expectedText } form (sugar only)", () => {
expect(() =>
parseFlow(
[
"steps:",
" - assert:",
" condition: text",
" selector: { text: 'Taps:' }",
" expectedText: 'Taps: 0'",
].join("\n")
)
).toThrow(/exactly one condition key/);
});
it("rejects an await/assert body with no condition key", () => {
expect(() => parseFlow("steps:\n - assert: { selector: foo }\n")).toThrow(
/exactly one condition key/
);
});
it("rejects text sugar with neither contains nor equals", () => {
expect(() => parseFlow("steps:\n - assert: { text: { in: counter } }\n")).toThrow(
/exactly one of `contains`, `equals`, or `matches`/
);
});
it("rejects text sugar with an empty contains", () => {
expect(() =>
parseFlow('steps:\n - assert: { text: { in: counter, contains: "" } }\n')
).toThrow(/non-empty `contains`/);
});
it("serializes await/assert with the condition-as-key sugar (no condition: field)", () => {
const yaml = serializeFlow({
executionPrerequisite: "",
steps: [
// loose ⇒ bare-string sugar; a strict { text } would keep the map form
{ kind: "assert", condition: "visible", selector: { text: "Welcome", loose: true } },
{
kind: "assert",
condition: "text",
selector: { identifier: "counter" },
expectedText: "Taps: 0",
textMatch: "contains",
},
{
kind: "assert",
condition: "text",
selector: { identifier: "total" },
expectedText: "1",
textMatch: "equals",
},
],
});
expect(yaml).toContain("visible: Welcome");
expect(yaml).not.toContain("condition:");
expect(yaml).toContain('contains: "Taps: 0"');
expect(yaml).toContain('equals: "1"');
expect(yaml).toContain("id: counter");
expect(yaml).not.toContain("identifier:");
// An assert never emits a `timeout` key (the parser would reject it back).
expect(yaml).not.toContain("timeout");
});
it.each([
["contains", "contains: Expected"],
["equals", "equals: Expected"],
["matches", "matches: Expected"],
] as const)("serializes and round-trips the %s text comparator", (textMatch, yamlComparator) => {
const step = {
kind: "assert" as const,
condition: "text" as const,
selector: { identifier: "status" },
expectedText: "Expected",
textMatch,
};
const yaml = serializeFlow({ executionPrerequisite: "", steps: [step] });
expect(yaml).toContain(yamlComparator);
expect(parseFlow(yaml).steps).toEqual([step]);
});
it("roundtrips the sugared step kinds through YAML", () => {
// The spelling carries the loose bit exactly both ways: a LOOSE text-only
// selector serializes to a bare string (which parses back loose); a strict
// `{ text }` keeps the map form (which parses back strict). Identifier
// selectors keep the map form and stay strict.
const flow: FlowFile = {
executionPrerequisite: "",
steps: [
{ kind: "tap", selector: { text: "Login", loose: true } },
{ kind: "tap", selector: { text: "Save" } },
{ kind: "type", into: { text: "email", loose: true }, text: "a@b.com" },
{ kind: "type", into: { text: "Password" }, text: "hunter2" },
{ kind: "await", condition: "hidden", selector: { identifier: "spinner" } },
{ kind: "await", condition: "visible", selector: { text: "Welcome" } },
{ kind: "wait", ms: 500 },
{
kind: "assert",
condition: "text",
selector: { text: "Taps:", loose: true },
expectedText: "Taps: 0",
textMatch: "contains",
},
{
kind: "assert",
condition: "text",
selector: { identifier: "total" },
expectedText: "1",
textMatch: "equals",
},
{ kind: "scroll-to", target: { text: "Order #1234", loose: true }, direction: "down" },
{
kind: "scroll-to",
target: { text: "Summer Sale", loose: true },
direction: "right",
within: { identifier: "promotions" },
},
{
kind: "scroll-to",
target: { text: "Checkout" },
direction: "down",
within: { text: "Cart items" },
},
],
};
expect(parseFlow(serializeFlow(flow)).steps).toEqual(flow.steps);
});
it("keeps a strict { text } selector strict across repeated round-trips (never collapsed to a bare loose string)", () => {
// The recorder derives strict `{ text }` selectors, and every recorded step
// re-reads and re-writes the whole file (appendStep) — so a single lossy
// serialization would silently promote them to loose, sending them through
// the identifier-first fallback they were never verified against.
const flow: FlowFile = {
executionPrerequisite: "",
steps: [{ kind: "tap", selector: { text: "Save" } }],
};
const once = serializeFlow(flow);
expect(once).toContain("text: Save");
expect(once).not.toContain("tap: Save");
const reparsed = parseFlow(once);
expect(reparsed.steps).toEqual(flow.steps); // no `loose` flag introduced
expect(parseFlow(serializeFlow(reparsed)).steps).toEqual(flow.steps);
});
it("sugars a bare-string scroll-to target and keeps the within map", () => {
const flow = parseFlow(
["steps:", " - scroll-to: { target: Account, direction: down }"].join("\n")
);
expect(flow.steps).toEqual([
{ kind: "scroll-to", target: { text: "Account", loose: true }, direction: "down" },
]);
});
it("parses a bare-number wait as milliseconds", () => {
const flow = parseFlow("steps:\n - wait: 750\n");
expect(flow.steps).toEqual([{ kind: "wait", ms: 750 }]);
});
it("rejects a wait that is not a non-negative number", () => {
expect(() => parseFlow("steps:\n - wait: soon\n")).toThrow("wait needs a non-negative number");
expect(() => parseFlow("steps:\n - wait: -5\n")).toThrow("wait needs a non-negative number");
});
it("parses an await timeout in milliseconds", () => {
const flow = parseFlow("steps:\n - await: { visible: Account, timeout: 10000 }\n");
expect(flow.steps).toEqual([
{
kind: "await",
condition: "visible",
selector: { text: "Account", loose: true },
timeout: 10000,
},
]);
});
it("rejects an await timeout that is not a positive finite number", () => {
// `.inf`, `.nan`, and an overflowing literal all parse to a typeof-number
// value; letting Infinity through would make the runner's poll deadline
// unreachable (an unbounded await).
for (const bad of ["soon", "0", "-5", ".inf", ".nan", "1e400"]) {
expect(() => parseFlow(`steps:\n - await: { visible: Account, timeout: ${bad} }\n`)).toThrow(
"await.timeout needs a positive number of milliseconds"
);
}
});
it("rejects a timeout on an assert step (an assert is an immediate check)", () => {
// The internal assert step has no timeout field, so a YAML `timeout` used
// to be silently dropped; reject it loudly instead — a check that needs
// time to become true is a wait, spelled `await`.
expect(() => parseFlow("steps:\n - assert: { visible: Account, timeout: 9000 }\n")).toThrow(
/assert has no timeout/
);
expect(() =>
parseFlow('steps:\n - assert: { text: { in: counter, equals: "0" }, timeout: 5000 }\n')
).toThrow(/assert has no timeout/);
});
it("rejects a scroll-to with an invalid direction", () => {
expect(() =>
parseFlow("steps:\n - scroll-to: { target: Account, direction: sideways }\n")
).toThrow("scroll-to direction must be one of");
});
it("defaults scroll-to direction to down", () => {
const flow = parseFlow("steps:\n - scroll-to: { target: Account }\n");
expect(flow.steps).toEqual([
{ kind: "scroll-to", target: { text: "Account", loose: true }, direction: "down" },
]);
});
it("parses a bare-string scroll-to as a down-scroll to that target", () => {
const flow = parseFlow("steps:\n - scroll-to: Account\n");
expect(flow.steps).toEqual([
{ kind: "scroll-to", target: { text: "Account", loose: true }, direction: "down" },
]);
});
it("serializes the default scroll-to back to the bare-string sugar", () => {
const steps = [
{ kind: "scroll-to", target: { text: "Account", loose: true }, direction: "down" },
] as FlowFile["steps"];
const yaml = serializeFlow({ executionPrerequisite: "", steps });
expect(yaml).toContain("- scroll-to: Account");
expect(parseFlow(yaml).steps).toEqual(steps);
});
it("parses a bare-string snapshot as its name", () => {
const flow = parseFlow("steps:\n - snapshot: home\n");
expect(flow.steps).toEqual([{ kind: "snapshot", name: "home" }]);
});
it("serializes a name-only snapshot as a bare string, keeps the map with maxMismatch", () => {
const steps = [
{ kind: "snapshot", name: "home" },
{ kind: "snapshot", name: "cart", maxMismatch: 1.5 },
] as FlowFile["steps"];
const yaml = serializeFlow({ executionPrerequisite: "", steps });
expect(yaml).toContain("- snapshot: home");
expect(yaml).toContain("maxMismatch: 1.5");
expect(parseFlow(yaml).steps).toEqual(steps);
});
it("rejects a snapshot name that is not path-safe", () => {
expect(() => parseFlow("steps:\n - snapshot: ../evil\n")).toThrow(/must match/);
});
it("accepts a string-number maxMismatch", () => {
const flow = parseFlow('steps:\n - snapshot: { name: home, maxMismatch: "1.5" }\n');
expect(flow.steps).toEqual([{ kind: "snapshot", name: "home", maxMismatch: 1.5 }]);
});
it("rejects a non-numeric, negative, or out-of-range maxMismatch", () => {
for (const bad of ['"5%"', "-1", "101", ".nan"]) {
expect(() =>
parseFlow(`steps:\n - snapshot: { name: home, maxMismatch: ${bad} }\n`)
).toThrow("snapshot maxMismatch must be a number between 0 and 100");
}
});
it("parses snapshot cropOn as a selector (bare-string loose, map strict)", () => {
const flow = parseFlow(
"steps:\n" +
" - snapshot: { name: home, cropOn: Header }\n" +
" - snapshot: { name: cart, cropOn: { id: cart-total } }\n"
);
expect(flow.steps).toEqual([
{ kind: "snapshot", name: "home", cropOn: { text: "Header", loose: true } },
{ kind: "snapshot", name: "cart", cropOn: { identifier: "cart-total" } },
]);
});
it("serializes snapshot cropOn in the map form and round-trips", () => {
const steps = [
{ kind: "snapshot", name: "home", cropOn: { text: "Header", loose: true } },
{ kind: "snapshot", name: "cart", maxMismatch: 1.5, cropOn: { identifier: "cart-total" } },
] as FlowFile["steps"];
const yaml = serializeFlow({ executionPrerequisite: "", steps });
expect(yaml).toContain("cropOn: Header");
expect(parseFlow(yaml).steps).toEqual(steps);
});
it("rejects a point-form cropOn — a point has no extent to crop to", () => {
expect(() =>
parseFlow("steps:\n - snapshot: { name: home, cropOn: { x: 0.5, y: 0.5 } }\n")
).toThrow(/snapshot\.cropOn: selector has unknown keys `x`, `y`/);
});
it("rejects a tap body mixing a selector with coordinates", () => {
for (const key of ["id", "identifier"]) {
expect(() => parseFlow(`steps:\n - tap: { ${key}: box, x: 0.5, y: 0.5 }\n`)).toThrow(
"tap takes a selector or x/y coordinates, not both"
);
}
});
it("rejects a coordinate tap with a missing or non-numeric x/y", () => {
expect(() => parseFlow("steps:\n - tap: { x: 0.5 }\n")).toThrow(
"tap: a coordinate target needs numeric x and y"
);
expect(() => parseFlow('steps:\n - tap: { x: "0.5", y: 0.5 }\n')).toThrow(
"tap: a coordinate target needs numeric x and y"
);
});
it("round-trips free-text values exactly, including whitespace-only lines", () => {
// The parser stores every free-text field verbatim — `type.text`, `echo`,
// await/assert `contains`/`equals`, and `executionPrerequisite` are never
// trimmed — so serialization must be byte-exact too. Default yamlStringify
// emits multi-line values as block scalars, whose chomping silently strips
// whitespace-only lines on re-parse (" \n" came back as "\n"); serializeFlow
// disables blockQuote so these values round-trip via double-quoted escapes.
const values = [
"line1\nline2",
"line1\nline2 ", // trailing space on the final content line
" \n", // whitespace-only line — silently corrupted by a block scalar
" \n \n",
"\t\n",
" hi ",
"plain single line",
];
for (const value of values) {
const flow: FlowFile = {
executionPrerequisite: value,
steps: [
{ kind: "echo", message: value },
{ kind: "type", into: { text: "email", loose: true }, text: value },
{
kind: "await",
condition: "text",
selector: { identifier: "counter" },
expectedText: value,
textMatch: "contains",
timeout: 5000,
},
{
kind: "assert",
condition: "text",
selector: { identifier: "total" },
expectedText: value,
textMatch: "equals",
},
],
};
expect(parseFlow(serializeFlow(flow))).toEqual(flow);
}
});
it("never serializes a whitespace-only-line value as a block scalar", () => {
const steps = [{ kind: "echo", message: "step one \n \ndone" }] as FlowFile["steps"];
const yaml = serializeFlow({ executionPrerequisite: "", steps });
// Block (|) and folded (>) scalars are not round-trip-safe for this shape;
// the value must be emitted as a double-quoted flow scalar instead.
expect(yaml).not.toContain("|");
expect(yaml).not.toContain(">");
expect(yaml).toContain("\\n"); // newlines spelled as escapes inside quotes
expect(parseFlow(yaml).steps).toEqual(steps);
});
// Flows are hand-authored YAML, so a misspelled option key must fail at
// parse time — silently dropping it would apply the default instead and
// surface later as a misleading runtime failure (wrong scroll direction,
// lost submit opt-out, lost timeout, lost snapshot tolerance).
describe("unknown option keys are rejected at parse time", () => {
it("rejects a misspelled scroll-to direction key with a suggestion", () => {
expect(() =>
parseFlow("steps:\n - scroll-to: { target: Order-1234, directon: up }\n")
).toThrow(/scroll-to has unknown key `directon` \(did you mean `direction`\?\)/);
});
it("rejects a misspelled type.submit key with a suggestion", () => {
expect(() =>
parseFlow('steps:\n - type: { into: email, text: "a@b.com", sumbit: false }\n')
).toThrow(/type has unknown key `sumbit` \(did you mean `submit`\?\)/);
});
it("rejects a misspelled await.timeout key with a suggestion", () => {
expect(() => parseFlow("steps:\n - await: { visible: Account, timeut: 10000 }\n")).toThrow(
/await has unknown key `timeut` \(did you mean `timeout`\?\)/
);
});
it("rejects a misspelled snapshot.maxMismatch key with a suggestion", () => {
expect(() => parseFlow("steps:\n - snapshot: { name: home, maxMissmatch: 1.5 }\n")).toThrow(
/snapshot has unknown key `maxMissmatch` \(did you mean `maxMismatch`\?\)/
);
});
it("rejects a miscased snapshot.cropOn key with a suggestion", () => {
expect(() => parseFlow("steps:\n - snapshot: { name: home, cropon: Header }\n")).toThrow(
/snapshot has unknown key `cropon` \(did you mean `cropOn`\?\)/
);
});
it("rejects an unknown key on a selector map", () => {
expect(() => parseFlow("steps:\n - tap: { text: Save, roel: button }\n")).toThrow(
/tap: selector has unknown key `roel` \(did you mean `role`\?\)/
);
expect(() => parseFlow("steps:\n - await: { visible: { txt: Save } }\n")).toThrow(
/await.visible: selector has unknown key `txt` \(did you mean `text`\?\)/
);
expect(() =>
parseFlow("steps:\n - scroll-to: { target: { text: Row }, within: { identfier: list } }\n")
).toThrow(
/scroll-to.within: selector has unknown key `identfier` \(did you mean `identifier`\?\)/
);
});
it("rejects an unknown key without a suggestion when nothing is close", () => {
expect(() => parseFlow("steps:\n - scroll-to: { target: Row, sideways: true }\n")).toThrow(
/scroll-to has unknown key `sideways` — allowed keys: target, direction, within/
);
});
it("rejects an unknown key in an await/assert text body", () => {
expect(() =>
parseFlow('steps:\n - assert: { text: { in: counter, contians: "Taps: 0" } }\n')
).toThrow(/assert.text has unknown key `contians` \(did you mean `contains`\?\)/);
});
it("rejects a stray key on a coordinate tap", () => {
expect(() => parseFlow("steps:\n - tap: { x: 0.5, y: 0.5, why: 0.6 }\n")).toThrow(
/tap: a coordinate target takes only \{ x, y \}/
);
});
it("rejects an unknown key in a launch map and its chromium value", () => {
expect(() => parseFlow("steps:\n - launch: { amdroid: com.acme.app }\n")).toThrow(
/launch has unknown key `amdroid` \(did you mean `android`\?\)/
);
expect(() =>
parseFlow("steps:\n - launch: { chromium: { path: ./app, arg: [--e2e] } }\n")
).toThrow(/launch.chromium has unknown key `arg` \(did you mean `args`\?\)/);
});
it("rejects a step-level sibling key (options belong inside the directive value)", () => {
expect(() =>
parseFlow("steps:\n - await: { visible: Account }\n timeout: 5000\n")
).toThrow(
/a `await` step has unknown key `timeout` — step options go inside the `await:` value/
);
});
it("rejects a step carrying two directive keys", () => {
expect(() => parseFlow("steps:\n - echo: hi\n tap: Save\n")).toThrow(
/a step takes exactly one directive key, found `echo`, `tap`/
);
});
it("suggests the directive key for a misspelled step kind", () => {
expect(() => parseFlow("steps:\n - snapshoot: home\n")).toThrow(
/unrecognized step kind \(did you mean `snapshot`\?\)/
);
});
it("rejects an unknown top-level flow file key", () => {
expect(() =>
parseFlow("executionPrerequisit: Settings open\nsteps:\n - echo: hi\n")
).toThrow(/unknown key `executionPrerequisit` \(did you mean `executionPrerequisite`\?\)/);
});
});
it("roundtrips: serialize then parse", () => {
const flow: FlowFile = {
executionPrerequisite: "App freshly loaded on home screen",
steps: [
{ kind: "echo", message: "Launch app" },
{ kind: "tool", name: "launch-app", args: { bundleId: "com.test" } },
{ kind: "tool", name: "tap", args: { x: 0.5, y: 0.3 } },
{ kind: "echo", message: "Done" },
],
};
const serialized = serializeFlow(flow);
expect(parseFlow(serialized)).toEqual(flow);
});
});
// ── chromium launch (app path) ───────────────────────────────────────
describe("chromium launch parsing", () => {
it("parses a chromium launch with a bare-string app path", () => {
const flow = parseFlow("steps:\n - launch: { chromium: ./app }\n");
expect(flow.steps).toEqual([{ kind: "launch", app: { chromium: "./app" } }]);
});
it("parses a chromium launch with a { path, args } map", () => {
const flow = parseFlow("steps:\n - launch: { chromium: { path: ./app, args: [--e2e] } }\n");
expect(flow.steps).toEqual([
{ kind: "launch", app: { chromium: { path: "./app", args: ["--e2e"] } } },
]);
});
it("parses a mixed per-platform launch (ios id + chromium path)", () => {
const flow = parseFlow("steps:\n - launch: { ios: com.acme.app, chromium: ./app }\n");
expect(flow.steps).toEqual([
{ kind: "launch", app: { ios: "com.acme.app", chromium: "./app" } },
]);
});
it("round-trips a chromium { path, args } launch through YAML", () => {
const flow: FlowFile = {
executionPrerequisite: "",
steps: [
{ kind: "launch", app: { chromium: { path: "/abs/app", args: ["--foo", "--bar"] } } },
],
};
expect(parseFlow(serializeFlow(flow)).steps).toEqual(flow.steps);
});
it("rejects a chromium map with no path", () => {
expect(() => parseFlow("steps:\n - launch: { chromium: { args: [--e2e] } }\n")).toThrow(
/launch needs/
);
});
it("rejects a chromium map with non-string args", () => {
expect(() =>
parseFlow("steps:\n - launch: { chromium: { path: ./app, args: [1, 2] } }\n")
).toThrow(/launch needs/);
});
});
describe("chromiumLaunchSpec", () => {
it("reads a bare-string launch as the app path", () => {
expect(chromiumLaunchSpec("./app")).toEqual({ path: "./app" });
});
it("reads a chromium string value as the path", () => {
expect(chromiumLaunchSpec({ chromium: "./app" })).toEqual({ path: "./app" });
});
it("reads a chromium { path, args } value", () => {
expect(chromiumLaunchSpec({ chromium: { path: "./app", args: ["--e2e"] } })).toEqual({
path: "./app",
args: ["--e2e"],
});
});
it("returns null when no chromium target is declared", () => {
expect(chromiumLaunchSpec({ ios: "com.acme.app" })).toBeNull();
expect(chromiumLaunchSpec(undefined)).toBeNull();
});
it("appIdForPlatform returns the chromium path (the runner's declared-target guard)", () => {
expect(appIdForPlatform({ chromium: { path: "./app", args: ["--e2e"] } }, "chromium")).toBe(
"./app"
);
expect(appIdForPlatform({ chromium: "./app" }, "chromium")).toBe("./app");
expect(appIdForPlatform({ ios: "com.acme.app" }, "chromium")).toBeNull();
});
});
// ── native shorthand ─────────────────────────────────────────────────
describe("native launch shorthand", () => {
it("parses a native-only launch and round-trips it", () => {
const flow = parseFlow("steps:\n - launch: { native: com.acme.app }\n");
expect(flow.steps).toEqual([{ kind: "launch", app: { native: "com.acme.app" } }]);
expect(parseFlow(serializeFlow(flow)).steps).toEqual(flow.steps);
});
it("parses native alongside a per-platform override and a chromium path", () => {
const flow = parseFlow(
"steps:\n - launch: { native: com.acme.app, android: com.acme.app.debug, chromium: ./app }\n"
);
expect(flow.steps).toEqual([
{
kind: "launch",
app: { native: "com.acme.app", android: "com.acme.app.debug", chromium: "./app" },
},
]);
});
it("rejects an empty native id", () => {
expect(() => parseFlow('steps:\n - launch: { native: "" }\n')).toThrow(/launch needs/);
});
it("appIdForPlatform falls back to native for installed platforms, override wins", () => {
const app = { native: "com.acme.app", android: "com.acme.app.debug" };
// native fills in for platforms without a specific key…
expect(appIdForPlatform(app, "ios")).toBe("com.acme.app");
expect(appIdForPlatform(app, "vega")).toBe("com.acme.app");
// …and a specific key overrides it.
expect(appIdForPlatform(app, "android")).toBe("com.acme.app.debug");
});
it("native never applies to chromium (chromium takes a path, not an id)", () => {
expect(appIdForPlatform({ native: "com.acme.app" }, "chromium")).toBeNull();
expect(chromiumLaunchSpec({ native: "com.acme.app" })).toBeNull();
});
});
// ── Active flow state ────────────────────────────────────────────────
describe("active flow state", () => {
beforeEach(() => {
clearActiveFlow();
});
it("throws when no active flow", () => {
expect(() => getActiveFlow()).toThrow("No active flow");
});
it("returns the active flow after setActiveFlow", () => {
setActiveFlow("my-flow");
expect(getActiveFlow()).toBe("my-flow");
});
it("clears the active flow", () => {
setActiveFlow("my-flow");
clearActiveFlow();
expect(() => getActiveFlow()).toThrow("No active flow");
});
it("overwrites previous active flow", () => {
setActiveFlow("first");
setActiveFlow("second");
expect(getActiveFlow()).toBe("second");
});
it("getActiveFlowOrNull returns null when no active flow", () => {
expect(getActiveFlowOrNull()).toBeNull();
});
it("getActiveFlowOrNull returns the active flow name", () => {
setActiveFlow("my-flow");
expect(getActiveFlowOrNull()).toBe("my-flow");
});
it("getActiveFlowOrNull returns null after clearing", () => {
setActiveFlow("my-flow");
clearActiveFlow();
expect(getActiveFlowOrNull()).toBeNull();
});
});
// ── getFlowPath name validation ──────────────────────────────────────
describe("getFlowPath name validation", () => {
beforeEach(() => {
clearActiveProjectRoot();
setActiveProjectRoot("/tmp/argent-flow-name-test");
});
it("accepts plain alphanumeric names", () => {
expect(getFlowPath("my-flow_1")).toBe(
path.join("/tmp/argent-flow-name-test", ".argent", "flows", "my-flow_1.yaml")
);
});
it("rejects path-traversal segments", () => {
expect(() => getFlowPath("../../etc/passwd")).toThrow(/Invalid flow name/);
expect(() => getFlowPath("../foo")).toThrow(/Invalid flow name/);
});
it("rejects path separators", () => {
expect(() => getFlowPath("foo/bar")).toThrow(/Invalid flow name/);
expect(() => getFlowPath("/abs/path")).toThrow(/Invalid flow name/);
});
it("rejects names with spaces or shell metacharacters", () => {
expect(() => getFlowPath("foo bar")).toThrow(/Invalid flow name/);
expect(() => getFlowPath("foo;bar")).toThrow(/Invalid flow name/);
expect(() => getFlowPath("foo$(id)")).toThrow(/Invalid flow name/);
});
it("rejects empty names", () => {
expect(() => getFlowPath("")).toThrow(/Invalid flow name/);
});
});
// PR #194 follow-up C: project_root must be absolute AND free of ".."
// segments (path.join collapses ".." and would relocate the flows dir).
describe("setActiveProjectRoot validation", () => {
it("rejects a relative project_root", () => {
expect(() => setActiveProjectRoot("relative/path")).toThrow(/absolute path/);
});
it('rejects an absolute project_root containing ".." segments', () => {
expect(() => setActiveProjectRoot("/a/../../../etc")).toThrow(/must not contain "\.\."/);
expect(() => setActiveProjectRoot("/home/user/../../root")).toThrow(/must not contain "\.\."/);
});
it("accepts a clean absolute project_root", () => {
expect(() => setActiveProjectRoot("/tmp/argent-pr194-c-test")).not.toThrow();
});
});
// ── within (descendant) selector scoping ─────────────────────────────
describe("within selector scoping", () => {
it("parses a within scope on a tap selector", () => {
const flow = parseFlow("steps:\n - tap: { text: Delete, within: { id: profile-card } }\n");
expect(flow.steps).toEqual([
{
kind: "tap",
selector: { text: "Delete", within: { identifier: "profile-card" } },
},
]);
});
it("a bare-string within stays loose (identifier-first, then text)", () => {
const flow = parseFlow("steps:\n - tap: { text: Delete, within: profile-card }\n");
expect(flow.steps).toEqual([
{
kind: "tap",
selector: { text: "Delete", within: { text: "profile-card", loose: true } },
},
]);
});
it("within chains outward and round-trips exactly", () => {
const flow: FlowFile = {
executionPrerequisite: "",
steps: [
{
kind: "tap",
selector: {
text: "Delete",
within: { identifier: "cards", within: { text: "Settings" } },
},
},
{
kind: "await",
condition: "visible",
selector: { text: "Saved", within: { text: "toast-area", loose: true } },
},
{
kind: "assert",
condition: "text",
selector: { identifier: "total", within: { role: "AXTable" } },
expectedText: "Total: 3",
textMatch: "equals",
},
],
};
expect(parseFlow(serializeFlow(flow))).toEqual(flow);
});
it("serializes a loose within back to its bare-string spelling", () => {
const yaml = serializeFlow({
executionPrerequisite: "",
steps: [
{
kind: "tap",
selector: { text: "Delete", within: { text: "profile-card", loose: true } },
},
],
});
expect(yaml).toContain("within: profile-card");
});
it("accepts the regex text matcher inside a within scope", () => {
const flow = parseFlow(
"steps:\n - assert: { visible: { text: Delete, within: { text: { matches: '^Card \\d+$' } } } }\n"
);
expect(flow.steps).toEqual([
{
kind: "assert",
condition: "visible",
selector: { text: "Delete", within: { textMatches: "^Card \\d+$" } },
},
]);
});
it("rejects a selector that is ONLY a within scope", () => {
expect(() => parseFlow("steps:\n - tap: { within: { id: card } }\n")).toThrow(
/still needs its own text\/id\/role/
);
});
it("rejects unknown keys inside a within scope, naming the nested slot", () => {
expect(() => parseFlow("steps:\n - tap: { text: Delete, within: { idd: card } }\n")).toThrow(
/tap\.within: selector has unknown key `idd` \(did you mean `id`\?\)/
);
});
it("rejects id+identifier both set inside a within scope", () => {
expect(() =>
parseFlow("steps:\n - tap: { text: A, within: { id: x, identifier: x } }\n")
).toThrow(/`id` or `identifier` \(its alias\), not both/);
});
it("rejects a cyclic within alias via the depth cap", () => {
const yaml = "steps:\n - tap: &s { text: Delete, within: *s }\n";
expect(() => parseFlow(yaml)).toThrow(/nest deeper than|cyclic YAML alias/);
});
it("rejects a within selector mixed with coordinates", () => {
expect(() => parseFlow("steps:\n - tap: { within: { id: card }, x: 0.5, y: 0.5 }\n")).toThrow(
/takes a selector or x\/y coordinates, not both/
);
});
it("rejects a within key beside the tap options form", () => {
expect(() =>
parseFlow("steps:\n - tap: { on: Photo, times: 2, within: { id: card } }\n")
).toThrow(/the tap options form takes a nested selector/);
});
it("within works in scroll-to's target while scroll-to's own within stays the container anchor", () => {
const flow = parseFlow(
[
"steps:",
" - scroll-to:",
" target: { text: Delete, within: { id: cards } }",
" direction: down",
" within: { id: settings-list }",
].join("\n") + "\n"
);
expect(flow.steps).toEqual([
{
kind: "scroll-to",
target: { text: "Delete", within: { identifier: "cards" } },
direction: "down",
within: { identifier: "settings-list" },
},
]);
});
it("describeSelector renders the scope chain in parentheses", () => {
expect(
describeSelector({
text: "Delete",
within: { identifier: "cards", within: { text: "Settings" } },
})
).toBe('text="Delete" within (id="cards" within (text="Settings"))');
});
it("when guards reject a {{secret:…}} placeholder hidden in a within scope", () => {
expect(() =>
parseFlow(
[
"steps:",
" - when: { visible: { text: Delete, within: { text: '{{secret:TOKEN}}' } } }",
" steps:",
" - echo: hi",
].join("\n") + "\n"
)
).toThrow(/secret/);
});
});
// ── sibling scopes (`after`/`next`) and the `any` universal selector ──
describe("sibling selector scopes and the universal selector", () => {
it("parses `after` (CSS ~) and `next` (CSS +) scopes", () => {
const flow = parseFlow(
[
"steps:",
" - tap: { role: Switch, next: { text: Wi-Fi } }",
" - assert: { visible: { role: Button, after: { text: Danger zone } } }",
].join("\n") + "\n"
);
expect(flow.steps).toEqual([
{ kind: "tap", selector: { role: "Switch", next: { text: "Wi-Fi" } } },
{
kind: "assert",
condition: "visible",
selector: { role: "Button", after: { text: "Danger zone" } },
},
]);
});
it("parses `any: true` paired with a scope and round-trips exactly", () => {
const yaml =
[
"steps:",
" - tap: { any: true, next: { text: Airplane }, within: { id: row } }",
" - assert: { hidden: { any: true, within: { id: empty-state } } }",
].join("\n") + "\n";
const flow = parseFlow(yaml);
expect(flow.steps).toEqual([
{
kind: "tap",
selector: { any: true, within: { identifier: "row" }, next: { text: "Airplane" } },
},
{
kind: "assert",
condition: "hidden",
selector: { any: true, within: { identifier: "empty-state" } },
},
]);
expect(parseFlow(serializeFlow(flow))).toEqual(flow);
});
it("a bare-string sibling scope stays loose, and serializes back to the bare spelling", () => {
const flow = parseFlow("steps:\n - tap: { role: Switch, next: wifi-row }\n");
expect(flow.steps).toEqual([
{ kind: "tap", selector: { role: "Switch", next: { text: "wifi-row", loose: true } } },
]);
expect(serializeFlow(flow)).toContain("next: wifi-row");
expect(parseFlow(serializeFlow(flow))).toEqual(flow);
});
it("scopes combine and nest, round-tripping through YAML", () => {
const flow = parseFlow(
[
"steps:",
" - tap:",
" role: Button",
" within: { id: cards }",
" after: { text: Danger, within: { id: cards } }",
].join("\n") + "\n"
);
expect(flow.steps).toEqual([
{
kind: "tap",
selector: {
role: "Button",
within: { identifier: "cards" },
after: { text: "Danger", within: { identifier: "cards" } },
},
},
]);
expect(parseFlow(serializeFlow(flow))).toEqual(flow);
});
it("accepts the regex text matcher inside a sibling scope", () => {
const flow = parseFlow(
"steps:\n - tap: { role: Switch, next: { text: { matches: '^Row \\d+$' } } }\n"
);
expect(flow.steps).toEqual([
{ kind: "tap", selector: { role: "Switch", next: { textMatches: "^Row \\d+$" } } },
]);
});
it("rejects a selector that is ONLY a sibling scope", () => {
expect(() => parseFlow("steps:\n - tap: { after: { text: Danger } }\n")).toThrow(
/`after` only scopes where to look — the selector still needs its own text\/id\/role/
);
});
it("rejects `any: true` alongside the fields it would make redundant", () => {
expect(() =>
parseFlow("steps:\n - tap: { any: true, role: Button, next: { text: Wi-Fi } }\n")
).toThrow(/already matches every element — drop it, or drop the `role`/);
});
it("rejects a bare `any: true` with no scope to narrow it", () => {
expect(() => parseFlow("steps:\n - tap: { any: true }\n")).toThrow(
/matches every element on screen — pair it with a scope \(within\/after\/next\)/
);
});
it("rejects a non-`true` any value rather than reading it as a locator", () => {
// Falsy AND truthy: a truthiness check would wave `any: 1` / `any: yes`
// through as the universal selector — a spelling no reader can predict and
// the serializer cannot reproduce.
for (const value of ["false", "1", "yes", "'true'", "0"]) {
expect(() => parseFlow(`steps:\n - tap: { any: ${value}, within: { id: card } }\n`)).toThrow(
/`any` takes only `true`/
);
}
});
it("rejects unknown keys inside a sibling scope, naming the nested slot", () => {
expect(() => parseFlow("steps:\n - tap: { role: Switch, next: { roel: Button } }\n")).toThrow(
/tap\.next: selector has unknown key `roel` \(did you mean `role`\?\)/
);
});
it("rejects a cyclic sibling alias via the scope budget", () => {
expect(() => parseFlow("steps:\n - tap: &s { text: Delete, after: *s }\n")).toThrow(
/more than \d+ scopes|cyclic YAML alias/
);
});
it("bounds a selector's whole scope TREE, not just its depth", () => {
// Three relations per level means a depth cap alone still admits 3^depth
// scopes — and the runner expands one alternative per combination of
// bare-string scopes, so a few hundred bytes of YAML would exhaust the heap
// before any tree is ever read. Six scopes down one branch is fine; the
// same six spread across branches must cost the same budget.
const chain = (n: number): string =>
Array.from({ length: n }, () => "{ id: c, within: ").join("") +
"{ id: leaf }" +
Array.from({ length: n }, () => " }").join("");
expect(() => parseFlow(`steps:\n - tap: { text: T, within: ${chain(5)} }\n`)).not.toThrow();
expect(() => parseFlow(`steps:\n - tap: { text: T, within: ${chain(6)} }\n`)).toThrow(
/more than 6 scopes/
);
// The branching form the depth cap alone would have let through: 3 + 9 = 12
// scopes across two levels, all shallow.
const branch = "{ id: b, within: x, after: y, next: z }";
expect(() =>
parseFlow(
`steps:\n - tap: { text: T, within: ${branch}, after: ${branch}, next: ${branch} }\n`
)
).toThrow(/more than 6 scopes/);
});
it("serializeFlow refuses an `any` selector the parser would reject on read-back", () => {
// appendStep re-parses the whole file on every recorded step, so a selector
// that violates the parser's `any` rules must fail where it was built, not
// on some later append.
const step = (selector: Record<string, unknown>): Parameters<typeof serializeFlow>[0] => ({
executionPrerequisite: "",
steps: [{ kind: "tap", selector } as never],
});
expect(() => serializeFlow(step({ any: true }))).toThrow(/needs a scope/);
expect(() =>
serializeFlow(step({ any: true, text: "Save", within: { identifier: "c" } }))
).toThrow(/cannot be combined with text\/id\/role/);
expect(() => serializeFlow(step({ any: false, within: { identifier: "c" } }))).toThrow(
/takes only `true`/
);
expect(() => serializeFlow(step({ within: { identifier: "c" } }))).toThrow(
/only narrows where to look/
);
// The legal shape still round-trips.
const yaml = serializeFlow(step({ any: true, within: { identifier: "c" } }));
expect(parseFlow(yaml).steps).toEqual([
{ kind: "tap", selector: { any: true, within: { identifier: "c" } } },
]);
});
it("rejects a sibling-scoped selector mixed with coordinates or tap options", () => {
expect(() => parseFlow("steps:\n - tap: { after: { id: card }, x: 0.5, y: 0.5 }\n")).toThrow(
/takes a selector or x\/y coordinates, not both/
);
expect(() =>
parseFlow("steps:\n - tap: { on: Photo, times: 2, next: { id: card } }\n")
).toThrow(/the tap options form takes a nested selector/);
});
it("a loose bare-string selector cannot carry a scope through serialization", () => {
expect(() =>
serializeFlow({
executionPrerequisite: "",
steps: [
{ kind: "tap", selector: { text: "Delete", loose: true, after: { identifier: "hdr" } } },
],
})
).toThrow(/incompatible fields: after/);
});
it("names the missing scroll-to target instead of leaking a schema message", () => {
// `within` is a selector key now, so this body reads like a scoped selector
// — it is actually the options map, missing its target.
for (const body of ["{ within: { id: list } }", "{ direction: up }", "{}"]) {
expect(() => parseFlow(`steps:\n - scroll-to: ${body}\n`)).toThrow(
/scroll-to needs a `target`/
);
}
// The sibling scopes are not step options at all.
expect(() => parseFlow("steps:\n - scroll-to: { target: Row, after: { id: hdr } }\n")).toThrow(
/unknown key `after` — allowed keys: target, direction, within/
);
// ...while they are welcome inside the target.
expect(() =>
parseFlow(
"steps:\n - scroll-to: { target: { text: Delete, after: { id: hdr } }, within: { id: list } }\n"
)
).not.toThrow();
});
it("describeSelector renders each scope, and `*` for the universal selector", () => {
expect(describeSelector({ role: "Switch", next: { text: "Wi-Fi" } })).toBe(
'role="Switch" next (text="Wi-Fi")'
);
expect(
describeSelector({ any: true, within: { identifier: "row" }, after: { text: "Name" } })
).toBe('* within (id="row") after (text="Name")');
});
it("when guards reject a {{secret:…}} placeholder hidden in ANY scope", () => {
// Every relation, so no branch of the walk can be skipped unnoticed.
for (const scope of ["within", "after", "next"]) {
expect(() =>
parseFlow(
[
"steps:",
` - when: { visible: { role: Switch, ${scope}: { text: '{{secret:TOKEN}}' } } }`,
" steps:",
" - echo: hi",
].join("\n") + "\n"
)
).toThrow(/secret/);
}
// ...including one buried two relations deep.
expect(() =>
parseFlow(
[
"steps:",
" - when: { visible: { role: Switch, after: { id: row, within: { text: '{{secret:TOKEN}}' } } } }",
" steps:",
" - echo: hi",
].join("\n") + "\n"
)
).toThrow(/secret/);
});
});