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>
1468 lines
55 KiB
TypeScript
1468 lines
55 KiB
TypeScript
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/);
|
||
});
|
||
});
|