Files
Ignacy Łątka f05d17c1d3 refactor(flow): let each step kind own how it reads (#744)
Stacked on #574 (merged; retargeted at `main`).

## Summary

`summarizeSteps` (flow-finish-recording.ts) was a 50-line `switch` over
`FlowStep["kind"]`. `stepTarget` (flow-run.ts) was a second one, over
the same union, with its own selector and condition spellings. Neither
could be found from the other, and `summarizeSteps`' `case "tool":
default:` arm meant a step kind added without wording rendered as a
`tool:` step instead of failing.

Both surfaces now read from one definition per kind — the same shape as
a tool owning its own log wording in `ToolDefinition.interaction`
(#582):

```ts
const FLOW_STEP_DEFINITIONS: {
  [K in FlowStep["kind"]]: FlowStepDefinition<Extract<FlowStep, { kind: K }>>;
} = {
  "scroll-to": {
    summary: (step) => `${yamlSelectorLabel(step.target)} (${step.direction})`,
    target: (step) => { ... },
  },
  ...
};
```

Keying the record by `FlowStep["kind"]` is what replaces the default
arm: a new kind is a compile error until it says how it reads on
**both** surfaces. That also retires the `renderToolArgs` interpolation
guard, which existed only because the default arm could hand it a step
with no `args` field.

`flow-run.ts` sheds a net 113 lines and five imports;
`flow-finish-recording.ts` sheds a net 180 and is now just the tool. The
two shared label templates (`conditionLabel` parameterized by selector
spelling, and a new `whenLabel` for the platform-vs-UI guard arm) mean
the recorder's `when:` tail and the runner's no longer duplicate the
platform arm.

## Test

The extraction is rendering-neutral: every summary line and report
target the switches produced moved over byte-for-byte (with four later
corrections, below).

- Proved with a throwaway equivalence test that inlined both
pre-refactor switches verbatim and compared them against the new
definitions over 1264 generated steps: all 15 kinds, 10 selector shapes
(loose, regex, scoped, quote-bearing), every wait condition × text-match
mode × expectation, every scroll direction, and the cyclic-`args` path.
Identical on all of them. Mutating two renderings (`equals` comparator,
default scroll direction) failed it, so it discriminates.
- Replaced by
`packages/tool-server/test/flows/flow-step-definitions.test.ts`, kept:
assertions pinning the exact summary line and report target of every
kind, in a table keyed by `FlowStep["kind"]` so a new kind fails
`typecheck:tests` here too (verified by deleting the `wait` entry:
`TS1360`). Deleting a kind from the definitions record fails the build
the same way (`TS2741`).
- `npm run build`, `eslint --max-warnings 0`, `prettier --check .`, `npm
run typecheck:tests --workspaces`: clean.
- `packages/tool-server`: 329 files / 3906 tests passed.
`packages/argent-cli`: 459 tests passed.

## Review follow-up (9cf51322)

Four corners where the carried-over rendering misdescribed what replays,
fixed with pinning tests:

- `delayLabel`: a hand-edited `delayMs` above `setTimeout`'s 2³¹−1
ceiling sleeps a clamped 1ms tick but read `(after 3000000000ms)` — the
summary now describes no delay there.
- `scroll-to`: the `within` container is parsed, serialized, and changes
what replays, yet neither surface rendered it; both do now.
- `snapshot`: the summary now carries `cropOn` and `maxMismatch`
(baseline key and tolerance). Only `cropOn` was on the report target;
`maxMismatch` was rendered on neither surface (the run report surfaces
the tolerance through the step's `reason`, not its target).
- `type`: the summary JSON-quotes the text, the module's own convention
for literals, so embedded quotes/newlines stay one unambiguous line.


## Review follow-up (7c804907)

A second pass (7 lenses + a 32-mutation pass that also re-derived the
neutrality claim against main over a 136-step corpus, and confirmed the
mapped-type compile gate by adding a kind) found no HIGH or MEDIUM. One
rendering defect this PR introduced, plus the boundaries the case table
left open:

- `scroll-to`'s report target spelled the scroll container ` within (…)`
— exactly how `selectorLabel` spells a selector's own relational scope.
`scroll-to: { target: Footer, within: { id: list } }` (scroll that
container) and `scroll-to: { target: { text: Footer, within: { id: list
} } }` (match only inside it) both rendered `"Footer" within (id=list)`;
carrying both put the direction between two identical-looking scopes. It
now reads ` in scroll container (…)`, the noun flow-actions already uses
in its own "scroll container … is not visible" failure. `main` did not
collide here only because it dropped `within` entirely.
- New pinning cases, each verified to fail without the code it pins:
`setTimeout`'s ceiling from both sides (`2**31-1` keeps its label,
`2**31` loses it) and its 1ms floor; `maxMismatch: 0`, the one value
separating `!== undefined` from a truthiness test; `cropOn` +
`maxMismatch` together, which fixes their suffix order; and a
multi-field selector, which fixes the report target's field order.
- `Number.isFinite(ms) &&` in `delayLabel` became redundant the moment
correction #1 added the ceiling — NaN and ±Infinity both fail the range
test. Removed (proved a no-op by mutation).
- Prose corrected where it had stopped matching the code: the
point-gesture comment argued a criterion the file does not follow
(`snapshot` and `scroll-to` are equally not recorder-built and this PR
renders their fields anyway) and listed two of the four still-unrendered
replay-affecting fields; `flow-tools.test.ts` still claimed to be the
only coverage of the long-press rendering; and the new case table
justified a no-op `as number` cast with a reachability claim that is
wrong in both halves (`flow_add_step`'s `delayMs` is
`z.number().int().min(0)` with no ceiling, so the recorder reaches these
values too).

Follow-ups filed rather than folded in, both pre-existing and unchanged
here: #808 (an `echo` message or `tool` name containing a newline breaks
the one-line-per-step summary contract, the same hazard correction #4
fixed for `type`) and #809 (`type.submit`, `await.timeout`,
`idle.timeout`, `idle.stableFor` change what replays and render on
neither surface).


## Review follow-up (4560f412)

Round from @hubgan's review (11 inline) plus CodeRabbit's one. Nine
acted on, two verified and rejected — the rejection reason is now in the
code, so it stays answered rather than being re-raised.

**Rendering**

- `type`'s report target carried the typed text with **no bound**.
Unlike the summary, that target is emitted on every replay — stdout,
`--json`, the MCP blocks handed to the agent, and the appended
`~/.argent/mcp-calls.log`, which has no cap or rotation — so a literal
written into a flow file shipped its whole length to each. Now capped at
200 chars with the same `…(+N chars)` elision `parseFlow` uses on the
entry it echoes (`MAX_ENTRY_RENDER_CHARS`). The **summary keeps the text
whole**: it is also `stepAnchor`, so eliding there would collide two
steps differing only past the cap and drop the recording's verdicts on
both. Elides before quoting, so a cut cannot halve an escape.
- `selectorLabel` and `describeSelector` interpolated `textMatches`,
`identifier` and `role` raw — a newline in one splits a step across two
report lines, an escape byte drives the terminal. That is the hazard
`text`'s quoting already names, so it now covers its neighbours: new
`escapeInline` (flow-utils) spells out control characters **only**. Not
JSON escaping: doubling a backslash would print a regex source nobody
could copy back into the .yaml. `describeSelector`'s literal arm moved
from a raw `"${v}"` to `JSON.stringify` — byte-identical for ordinary
values, unambiguous for a quote in the content.

**Types**

- `FlowStepDefinition`'s two renderers were method shorthand, and a
method's parameters are checked **bivariantly even under `strict`**: a
definition annotated narrower than its slot (one requiring `times` on
`tap`) was accepted and then read a field the step need not carry, with
a green typecheck. Property types now — the same assignment is `TS2322`.

**Tests** — each verified against the mutation it exists to catch

- Every case in the table goes through `serializeFlow` → `parseFlow` and
is compared on its anchor. That round-trip stability is what
`scroll-to.within`, `snapshot.cropOn` and `snapshot.maxMismatch` newly
depend on, and nothing asserted it; deleting `yamlSelectorLabel`'s key
sort now reddens the suite. The `when` case with `steps: []` became a
two-step block — `parseFlow` refuses an empty guarded block, so no count
below 1 is renderable, and `when #1` already pins the singular.
- The case table's value type is a non-empty tuple of cases **narrowed
to the key's own kind**. `"wait": []` and a `wait` slot holding an
`echo` case both typechecked clean before; both are `TS2322` now.
- The `when:` pluralization coupling only ran a two-step block, so
hardcoding either surface's tail to `(N steps)` left everything green.
Both counts run now, and against the whole skip reason rather than its
suffix.
- New pins for the escaping above, on both surfaces, each confirmed to
fail without the line it pins.

**Prose corrected against the code**

- `stepAnchor`: there are **two** anchor comparisons, not three, and
only the per-verdict one is unconditionally raw-vs-parsed — host mode
re-parses both sides of the whole-file check, which is raw-vs-parsed
only in client persist mode.
- `delayMs` bounds: `.int()` admits nothing strictly between 0 and 1, so
the 1 ms floor is hand-edit-only; the ceiling is recorder-reachable, as
stated.
- `0` is not a parse-only `delayMs`: `min(0)` accepts it and
`serializeFlow` writes it back out.

**Rejected, verified**

- Escaping `snapshot.name` (CodeRabbit) would be dead code: `parseFlow`
gates it on `FLOW_NAME_PATTERN` — letters, digits, `_`, `-` — because it
becomes a baseline filename, and that parse is the **only** construction
site of a snapshot step. A comment there now says so.
- "`textMatches`' `/…/` delimiters are unpinned" no longer holds at this
head: `flow-progress.test.ts`'s swipe-`from` target asserts `to (0.1,
0.5) from /^Card \d+$/`, and dropping the delimiters fails it. Measured
by mutation.

**Verification**: 371 files / 4950 tests in `packages/tool-server`;
`argent-cli` 527, `argent-mcp` 87, `registry` 120. `npm run build`,
`typecheck:tests --workspaces`, `eslint --max-warnings 0`, `prettier
--check .`, `npm run knip`: clean. 13 mutations aimed at the new code,
all killed.

**Docs**: no update needed. `packages/docs/` documents flow YAML, the
CLI and the tools; it does not specify how a step summary or a
`StepReport.target` is spelled, so nothing there goes stale. That covers
the two earlier rendering changes from `3e2244b3` as well —
report-target text literals becoming JSON-quoted, and the `type` target
carrying the typed text (now bounded, above).



## Review follow-up (9f8a1e29)

The two threads left open after approval.

- `typedTextLabel` compared and cut on `text.length`, which counts
UTF-16 units. 199 ASCII chars plus one astral char is 200 characters but
201 units, so the target quoted a lone high surrogate and announced `(+1
chars)` for text that fits under the cap whole. It counts and slices
code points; the boundary is pinned on both sides of the cap and each
assertion is red without the fix. Two sibling truncations are
deliberately left, both pre-existing and outside the step-summary code:
`badEntry` slices an already JSON-stringified entry, and
`cappedReason`'s cap bounds the EMITTED length, where counting code
points would let the emitted string overshoot its own bound.
- The `textMatches` delimiter thread, re-measured at this head: dropping
the `/…/` fails `flow-progress.test.ts:231` and
`flow-step-definitions.test.ts:451` - 2 failed | 1571 passed of 1573.
Restoring them returns the suite to green.

**Verification**: `packages/tool-server` 371 files / 4950 tests; `eslint
--max-warnings 0`, `prettier --check .`, `tsc --noEmit` on both
`tsconfig.json` and `tsconfig.test.json`: clean.


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

* **Improvements**
* Flow step summaries and run reports now use consistent formatting
across supported step types.
* Type steps display their target identifier and entered text more
clearly.
* Selector, gesture, condition, delay, and snapshot details are rendered
consistently, including safer handling of special characters.
  * Tool steps with cyclic arguments are handled safely in summaries.

* **Tests**
* Added comprehensive coverage for step rendering, flow numbering,
conditions, edge cases, and report output.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Hubert Gancarczyk <hubert.gancarczyk@swmansion.com>
Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
2026-09-04 12:54:13 +02:00

1330 lines
49 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { FAILURE_CODES, getFailureSignal, type Registry } from "@argent/registry";
import type { DescribeNode, DescribeTreeData } from "../../src/tools/describe/contract";
// Serve the flow tree directly: flows resolve selectors against the platform's
// full-hierarchy source and hard-fail rather than degrade to the AX tree, so
// these unit tests stub the tree fetch itself. `currentHint` flags the read as
// degraded — an empty tree + hint is a blind read the guard must not trust.
let currentTree: () => DescribeNode;
let currentHint: string | undefined;
vi.mock("../../src/tools/flows/flow-tree", () => ({
fetchFlowTree: vi.fn(
async (): Promise<DescribeTreeData> => ({
tree: currentTree(),
source: "native-devtools",
...(currentHint !== undefined ? { hint: currentHint } : {}),
})
),
}));
import { createRunFlowTool, type FlowRunResult } from "../../src/tools/flows/flow-run";
import { describeWhenCondition, summarizeStep } from "../../src/tools/flows/flow-step-definitions";
import { serializeFlow, parseFlow } from "../../src/tools/flows/flow-utils";
const DEVICE = "00000000-0000-0000-0000-0000000000ab"; // iOS UDID shape
let tmpDir: string;
function n(partial: Partial<DescribeNode> & { frame: DescribeNode["frame"] }): DescribeNode {
return { role: "AXOther", children: [], ...partial };
}
function screen(children: DescribeNode[]): DescribeNode {
return n({ role: "AXWindow", frame: { x: 0, y: 0, width: 1, height: 1 }, children });
}
function mockRegistry(taps: Array<{ x: number; y: number }>): Registry {
return {
invokeTool: vi.fn(async (id: string, args: Record<string, unknown>) => {
if (id === "list-devices") return { devices: [] };
if (id === "gesture-tap") {
taps.push({ x: args.x as number, y: args.y as number });
return { tapped: true };
}
return { ok: true };
}),
getTool: vi.fn((id: string) =>
id === "gesture-tap" ? { inputSchema: { properties: { udid: {} } } } : undefined
),
} as unknown as Registry;
}
async function writeFlow(name: string, yaml: Parameters<typeof serializeFlow>[0]): Promise<void> {
const dir = path.join(tmpDir, ".argent", "flows");
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, `${name}.yaml`), serializeFlow(yaml), "utf8");
}
function asRun(r: FlowRunResult | { notice: string }): FlowRunResult {
if (!("steps" in r)) throw new Error(`expected a run result, got notice: ${r.notice}`);
return r;
}
async function run(
name: string,
device: string = DEVICE,
signal?: AbortSignal
): Promise<FlowRunResult & { taps: Array<{ x: number; y: number }> }> {
const taps: Array<{ x: number; y: number }> = [];
const tool = createRunFlowTool(mockRegistry(taps));
const result = asRun(
await tool.execute(
{},
{ name, project_root: tmpDir, device },
(signal ? { signal } : undefined) as never
)
);
return Object.assign(result, { taps });
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-when-"));
currentTree = () => screen([]);
currentHint = undefined;
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
describe("when: parse/serialize", () => {
it("round-trips ui and platform guards and nested blocks", () => {
const flow = {
executionPrerequisite: "",
steps: [
{
kind: "when" as const,
condition: {
kind: "ui" as const,
condition: "visible" as const,
selector: { text: "What's new", loose: true },
},
steps: [
{ kind: "tap" as const, selector: { text: "Skip", loose: true } },
{
kind: "when" as const,
condition: { kind: "platform" as const, platform: "android" as const },
steps: [{ kind: "tool" as const, name: "button", args: { button: "back" } }],
},
],
},
{
kind: "when" as const,
condition: {
kind: "ui" as const,
condition: "text" as const,
selector: { identifier: "banner" },
expectedText: "Sale",
textMatch: "contains" as const,
},
steps: [{ kind: "echo" as const, message: "sale banner up" }],
},
{
kind: "when" as const,
condition: {
kind: "ui" as const,
condition: "text" as const,
selector: { identifier: "total" },
expectedText: "^Total: \\$\\d+$",
textMatch: "matches" as const,
},
steps: [{ kind: "echo" as const, message: "total rendered" }],
},
],
};
expect(parseFlow(serializeFlow(flow)).steps).toEqual(flow.steps);
});
it("rejects an else branch with the design rationale", () => {
expect(() =>
parseFlow(
"steps:\n - when: { visible: X }\n steps:\n - tap: Skip\n else:\n - tap: Other\n"
)
).toThrow(/when has no else/i);
});
it("rejects unknown sibling keys and a missing/empty steps list", () => {
expect(() =>
parseFlow("steps:\n - when: { visible: X }\n steps: [{ tap: A }]\n retries: 2\n")
).toThrow(/takes exactly \{ when: <condition>, steps: \[\.\.\.\] \}/i);
expect(() => parseFlow("steps:\n - when: { visible: X }\n")).toThrow(/non-empty steps list/i);
expect(() => parseFlow("steps:\n - when: { visible: X }\n steps: []\n")).toThrow(
/non-empty steps list/i
);
});
it("requires exactly one condition key and no timeout", () => {
expect(() => parseFlow("steps:\n - when: {}\n steps: [{ tap: A }]\n")).toThrow(
/exactly one condition key \(exists, visible, hidden, text, platform\)/i
);
expect(() =>
parseFlow("steps:\n - when: { visible: X, hidden: Y }\n steps: [{ tap: A }]\n")
).toThrow(/exactly one condition key/i);
expect(() =>
parseFlow("steps:\n - when: { visible: X, timeout: 5000 }\n steps: [{ tap: A }]\n")
).toThrow(/when takes no timeout/i);
});
it("validates the platform guard value", () => {
expect(() =>
parseFlow("steps:\n - when: { platform: windows }\n steps: [{ tap: A }]\n")
).toThrow(/when\.platform must be one of ios, android, chromium, vega/i);
expect(() =>
parseFlow("steps:\n - when: { platform: ios, foo: 1 }\n steps: [{ tap: A }]\n")
).toThrow(/no other keys/i);
});
// Hand-written, and ONE copy for the three tests below: the prefix names the
// registered block directives, so a second one must update this literal
// consciously. Deriving it from BLOCK_DIRECTIVE_KEYS would move it with the
// source and pin nothing; restating it per test made that three edits to find.
const DEPTH_CAP_MESSAGE = "`when:` blocks nest deeper than 20 levels";
it("rejects a cyclic YAML alias on when steps with a structured error", () => {
// The yaml library materializes `steps: *s` as a cyclic object; without
// the depth cap the parser would recurse forever and escape as a raw
// RangeError instead of a flow parse error.
let thrown: unknown;
try {
parseFlow("steps: &s\n - when: { visible: X }\n steps: *s\n");
} catch (err) {
thrown = err;
}
// The only site that pins the whole message: the alias hint is what points
// the author at the actual mistake here, so it is part of the contract.
expect(thrown).toBeInstanceOf(Error);
expect((thrown as Error).message).toContain(
`${DEPTH_CAP_MESSAGE} — check for a cyclic YAML alias (\`steps: &s … steps: *s\`)`
);
// A raw RangeError would carry no signal — this is what makes it structured.
const signal = getFailureSignal(thrown);
expect(signal?.error_kind).toBe("validation");
expect(signal?.error_code).toBe(FAILURE_CODES.FLOW_ENTRY_UNRECOGNIZED);
});
it("parses when: nested to the depth cap and rejects one level deeper", () => {
// The cyclic-alias test above only proves SOME cap exists; this pins its
// value from both sides so it cannot drift in either direction. The literal
// 20 IS the pin — reading MAX_BLOCK_DEPTH would move with the constant and
// assert nothing.
const nestedWhens = (levels: number): string => {
let steps = "[{ echo: deepest }]";
for (let i = 0; i < levels; i++) steps = `[{ when: { platform: ios }, steps: ${steps} }]`;
return `steps: ${steps}\n`;
};
let step = parseFlow(nestedWhens(20)).steps[0];
let depth = 0;
while (step.kind === "when") {
depth++;
step = step.steps[0];
}
expect(depth).toBe(20);
expect(step).toEqual({ kind: "echo", message: "deepest" });
// 21 levels is plain over-deep YAML, not a cycle, so it must still land as
// the structured parse error rather than escaping as a RangeError.
let thrown: unknown;
try {
parseFlow(nestedWhens(21));
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(Error); // a raised cap would leave this unset
expect((thrown as Error).message).toContain(DEPTH_CAP_MESSAGE);
expect(getFailureSignal(thrown)?.error_code).toBe(FAILURE_CODES.FLOW_ENTRY_UNRECOGNIZED);
});
// The only pin on parseWhenStep's EARLY assertBlockDepth call: the cap itself
// outlives it (parseBlockSteps asserts again before recursing) and the cyclic
// alias above reports the depth error either way, so deleting the early call
// leaves the rest of the suite green while exactly these entries — at the cap
// and carrying a second defect — flip to the shallower check's message.
it.each([
["an else branch", "when: { platform: ios }, else: 1, steps: [{ echo: x }]", "has no else"],
[
"a third sibling key",
"when: { platform: ios }, foo: 1, steps: [{ echo: x }]",
"takes exactly { when: <condition>, steps: [...] }",
],
["an unknown condition key", "when: { nope: X }, steps: [{ echo: x }]", "one condition key"],
])(
"reports the depth, not %s, for the entry sitting at the cap",
(_defect, innermost, shallow) => {
// 20 wrappers occupy depths 0-19, so the defective entry is the one at 20.
let steps = `[{ ${innermost} }]`;
for (let i = 0; i < 20; i++) steps = `[{ when: { platform: ios }, steps: ${steps} }]`;
let thrown: unknown;
try {
parseFlow(`steps: ${steps}\n`);
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(Error);
expect((thrown as Error).message).toContain(DEPTH_CAP_MESSAGE);
// Not just "the depth error is in there": the shallower check must not be
// what answered, which is the whole difference the early call makes.
expect((thrown as Error).message).not.toContain(shallow);
}
);
it("rejects a per-step optional key, pointing at when:", () => {
// No silent drop: an ignored `optional: true` would leave a step the
// author believes can't fail hard-stopping the flow.
expect(() => parseFlow("steps:\n - tap: A\n optional: true\n")).toThrow(
/optional is not supported — guard the step with a when: block/i
);
expect(() => parseFlow("steps:\n - await: { visible: X }\n optional: false\n")).toThrow(
/optional is not supported/i
);
});
it("rejects a {{secret:…}} placeholder in a guard's expected text", () => {
// Placeholders resolve only in the text-entry tools; condition evaluation
// sees the literal placeholder, so the guard degenerates into a constant —
// permanently false here (vacuously true for hidden) — same silently-wrong
// class as optional:.
expect(() =>
parseFlow(
'steps:\n - when: { text: { in: account, equals: "{{secret:USERNAME}}" } }\n steps: [{ tap: A }]\n'
)
).toThrow(/when takes no \{\{secret:…\}\} placeholder/i);
expect(() =>
parseFlow(
'steps:\n - when: { text: { in: banner, contains: "{{secret:PROMO}}" } }\n steps: [{ tap: A }]\n'
)
).toThrow(/never in condition evaluation/i);
});
it("rejects a {{secret:…}} placeholder in a guard's selector", () => {
// Same degenerate-constant class as the expected-text case: the tree
// probe would look for the literal placeholder string on screen — and a
// hidden guard would flip the failure to always-run instead of never-run.
expect(() =>
parseFlow('steps:\n - when: { exists: "{{secret:TOKEN}}" }\n steps: [{ tap: A }]\n')
).toThrow(/when takes no \{\{secret:…\}\} placeholder/i);
expect(() =>
parseFlow(
'steps:\n - when: { visible: { id: "{{secret:TOKEN}}" } }\n steps: [{ tap: A }]\n'
)
).toThrow(/use the literal on-screen text instead/i);
expect(() =>
parseFlow('steps:\n - when: { hidden: "{{secret:TOKEN}}" }\n steps: [{ tap: A }]\n')
).toThrow(/vacuously true/i);
expect(() =>
parseFlow(
'steps:\n - when: { text: { in: "{{secret:FIELD}}", equals: ok } }\n steps: [{ tap: A }]\n'
)
).toThrow(/when takes no \{\{secret:…\}\} placeholder/i);
});
it("still parses a type: step carrying a placeholder (resolved by the keyboard tool)", () => {
const flow = parseFlow('steps:\n - type: { into: password, text: "{{secret:PASSWORD}}" }\n');
expect(flow.steps).toEqual([
{ kind: "type", into: { text: "password", loose: true }, text: "{{secret:PASSWORD}}" },
]);
});
it("still parses assert: and await: conditions carrying a placeholder (they fail loudly at runtime)", () => {
// The rejection is when-only by design: an assert/await comparing the
// literal placeholder fails loudly on the first run, unlike a guard's
// silent degeneration. A refactor hoisting the check into parseWaitFields
// must trip this test.
expect(() =>
parseFlow('steps:\n - assert: { text: { in: account, equals: "{{secret:USERNAME}}" } }\n')
).not.toThrow();
expect(() => parseFlow('steps:\n - await: { visible: "{{secret:TOKEN}}" }\n')).not.toThrow();
});
});
describe("when: execution", () => {
it("runs the guarded steps when the condition holds, then continues", async () => {
currentTree = () =>
screen([
n({ label: "What's new", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.2 } }),
n({ label: "Skip", frame: { x: 0.4, y: 0.8, width: 0.2, height: 0.1 } }),
]);
await writeFlow("dismiss", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: {
kind: "ui",
condition: "visible",
selector: { text: "What's new", loose: true },
},
steps: [{ kind: "tap", selector: { text: "Skip", loose: true } }],
},
{ kind: "echo", message: "after block" },
],
});
const result = await run("dismiss");
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"when:pass",
"tap:pass",
"echo:pass",
]);
expect(result.steps[0].reason).toMatch(/condition met \(visible text="What's new"\)/);
expect(result.taps).toHaveLength(1);
expect(result.ok).toBe(true);
});
it("skips the whole block — one line per authored step — when the condition is unmet", async () => {
currentTree = () =>
screen([n({ label: "Home", frame: { x: 0, y: 0, width: 1, height: 0.1 } })]);
await writeFlow("clean-run", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: {
kind: "ui",
condition: "visible",
selector: { text: "What's new", loose: true },
},
steps: [
{ kind: "tap", selector: { text: "Skip", loose: true } },
{
kind: "when",
condition: { kind: "platform", platform: "ios" },
steps: [{ kind: "echo", message: "nested" }],
},
],
},
{ kind: "echo", message: "after block" },
],
});
const result = await run("clean-run");
// The block marker + every guarded step (nested when expanded) skip; the
// flow continues and stays green.
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"when:skip",
"tap:skip",
"when:skip",
"echo:skip",
"echo:pass",
]);
expect(result.steps[0].reason).toMatch(/condition not met .*block skipped \(2 steps\)/);
expect(result.steps[1].reason).toBe("when block skipped");
// The skipped nested echo keeps its message so the renderer marks it
// skipped rather than printing it as if it had run.
expect(result.steps[3].message).toBe("nested");
expect(result.taps).toHaveLength(0);
expect(result.ok).toBe(true);
});
it("stamps nesting depth identically whether a block enters or skips", async () => {
// Depth is display metadata for the renderers' indentation. Two invariants:
// top-level steps omit the field entirely (a flat flow's report stays
// byte-identical to the pre-depth shape), and a skipped block reports the
// same depths as an entered one, so runs stay comparable run-to-run.
await writeFlow("depths", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: {
kind: "ui",
condition: "visible",
selector: { text: "What's new", loose: true },
},
steps: [
{ kind: "tap", selector: { text: "Skip", loose: true } },
{
kind: "when",
condition: { kind: "platform", platform: "ios" },
steps: [{ kind: "echo", message: "nested" }],
},
],
},
{ kind: "echo", message: "after block" },
],
});
currentTree = () =>
screen([
n({ label: "What's new", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.2 } }),
n({ label: "Skip", frame: { x: 0.4, y: 0.8, width: 0.2, height: 0.1 } }),
]);
const entered = await run("depths");
expect(entered.steps.map((s) => `${s.kind}:${s.status}:${s.depth ?? 0}`)).toEqual([
"when:pass:0",
"tap:pass:1",
"when:pass:1",
"echo:pass:2",
"echo:pass:0",
]);
// Top level omits the field, not depth: 0.
expect(entered.steps[0].depth).toBeUndefined();
expect(entered.steps[4].depth).toBeUndefined();
currentTree = () =>
screen([n({ label: "Home", frame: { x: 0, y: 0, width: 1, height: 0.1 } })]);
const skipped = await run("depths");
expect(skipped.steps.map((s) => `${s.kind}:${s.status}:${s.depth ?? 0}`)).toEqual([
"when:skip:0",
"tap:skip:1",
"when:skip:1",
"echo:skip:2",
"echo:pass:0",
]);
expect(skipped.steps.map((s) => `${s.kind}:${s.depth ?? 0}`)).toEqual(
entered.steps.map((s) => `${s.kind}:${s.depth ?? 0}`)
);
// The skip path, too, omits the field at top level rather than emitting 0
// (the ?? 0 maps above cannot tell the two apart).
expect(skipped.steps[0].depth).toBeUndefined();
expect(skipped.steps[4].depth).toBeUndefined();
});
it("stamps depth on a run: inside a when block — expanded entered, one line skipped", async () => {
await writeFlow("dismiss", {
executionPrerequisite: "",
steps: [{ kind: "tap", selector: { text: "Skip", loose: true } }],
});
await writeFlow("run-in-when", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: {
kind: "ui",
condition: "visible",
selector: { text: "What's new", loose: true },
},
steps: [{ kind: "run", flow: "dismiss.yaml" }],
},
{ kind: "echo", message: "after block" },
],
});
currentTree = () =>
screen([
n({ label: "What's new", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.2 } }),
n({ label: "Skip", frame: { x: 0.4, y: 0.8, width: 0.2, height: 0.1 } }),
]);
const entered = await run("run-in-when");
// when marker at top level, run marker one deep, the fragment's steps two.
expect(entered.steps.map((s) => `${s.kind}:${s.status}:${s.depth ?? 0}`)).toEqual([
"when:pass:0",
"run:pass:1",
"tap:pass:2",
"echo:pass:0",
]);
// Attribution rides along at every depth: the marker belongs to the
// enclosing flow, the run: line and the fragment's expanded tap to the
// fragment's stem.
expect(entered.steps.map((s) => s.flow)).toEqual([
"run-in-when",
"dismiss",
"dismiss",
"run-in-when",
]);
currentTree = () =>
screen([n({ label: "Home", frame: { x: 0, y: 0, width: 1, height: 0.1 } })]);
const skipped = await run("run-in-when");
// A skipped block's run: line stays single (the fragment is never loaded),
// at the block's child depth via reportBlockSkipped.
expect(skipped.steps.map((s) => `${s.kind}:${s.status}:${s.depth ?? 0}`)).toEqual([
"when:skip:0",
"run:skip:1",
"echo:pass:0",
]);
// The block-skip path must attribute the never-loaded run: line to its
// target stem — identical to the entered marker above — not to the
// enclosing flow, or the CLI would drop/mislabel its `[fragment]` suffix.
expect(skipped.steps.map((s) => s.flow)).toEqual(["run-in-when", "dismiss", "run-in-when"]);
expect(skipped.steps[1]).toMatchObject({ flow: "dismiss", target: "dismiss.yaml" });
});
it("treats a failure inside an entered block as a real failure (hard stop)", async () => {
currentTree = () =>
screen([n({ label: "What's new", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.2 } })]);
await writeFlow("bad-dismiss", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: {
kind: "ui",
condition: "visible",
selector: { text: "What's new", loose: true },
},
// The asserted element never exists — the step must FAIL, not skip.
// An assert fails at the short grace; a tap on a missing element
// would burn the full action retry budget for the same hard stop.
steps: [
{
kind: "assert",
condition: "visible",
selector: { text: "No such button", loose: true },
},
],
},
{ kind: "echo", message: "never reached" },
],
});
const result = await run("bad-dismiss");
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"when:pass",
"assert:fail",
"echo:skip",
]);
// The trailing top-level echo is skipped by the hard stop but keeps its
// message, so the renderer can mark it skipped instead of dropping the
// line (or, worse, printing it as if it had run).
expect(result.steps[2].message).toBe("never reached");
expect(result.ok).toBe(false);
});
it("expands a when block skipped by a hard stop — one line per authored step, no abort", async () => {
// The hard-stop test above places an echo after the failure; here a when
// BLOCK sits there instead, so the stopped-run branch must expand the
// block's authored steps rather than collapse it to one line. And a hard
// stop is not a cancellation: `aborted` stays unset and the skip lines
// carry no "run aborted" reason.
currentTree = () =>
screen([n({ label: "Home", frame: { x: 0, y: 0, width: 1, height: 0.1 } })]);
await writeFlow("hard-stop-when", {
executionPrerequisite: "",
steps: [
{
kind: "assert",
condition: "visible",
selector: { text: "No such button", loose: true },
},
{
kind: "when",
condition: {
kind: "ui",
condition: "visible",
selector: { text: "What's new", loose: true },
},
steps: [
{ kind: "tap", selector: { text: "Skip", loose: true } },
{ kind: "echo", message: "inside" },
],
},
],
});
const result = await run("hard-stop-when");
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"assert:fail",
"when:skip",
"tap:skip",
"echo:skip",
]);
// The hard-stop expansion keeps the block's depths: marker at top level
// (field omitted), authored steps one deeper — same as an executed block.
expect(result.steps.map((s) => s.depth)).toEqual([undefined, undefined, 1, 1]);
expect(result.steps[1].reason).toBeUndefined();
expect(result.steps[3].message).toBe("inside");
expect(result.taps).toHaveLength(0);
expect(result.ok).toBe(false);
expect(result.aborted).toBeUndefined();
});
it("evaluates a platform guard statically against the resolved device", async () => {
await writeFlow("per-platform", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: { kind: "platform", platform: "ios" },
steps: [{ kind: "echo", message: "ios only" }],
},
{
kind: "when",
condition: { kind: "platform", platform: "android" },
steps: [{ kind: "tool", name: "button", args: { button: "back" } }],
},
],
});
const result = await run("per-platform"); // DEVICE is an iOS UDID
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"when:pass",
"echo:pass",
"when:skip",
"tool:skip",
]);
expect(result.steps[2].reason).toMatch(/platform android/);
expect(result.ok).toBe(true);
});
it("folds ios-remote to ios: a platform guard matches on a remote simulator", async () => {
// A `remote:`-prefixed udid resolves as platform "ios-remote" (shape-based,
// see classifyDevice), a spelling the parser deliberately rejects in
// guards — execWhenStep folds it to "ios" at evaluation time. Without the
// fold no guard could ever match on a remote sim: every
// `when: { platform: ios }` block would silently skip there while the run
// stays green — the same silent-no-op class the indeterminate-guard
// handling exists to prevent. This test pins the fold at runtime.
currentTree = () =>
screen([n({ label: "Skip", frame: { x: 0.4, y: 0.8, width: 0.2, height: 0.1 } })]);
await writeFlow("remote-sim", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: { kind: "platform", platform: "ios" },
steps: [{ kind: "tap", selector: { text: "Skip", loose: true } }],
},
{
kind: "when",
condition: { kind: "platform", platform: "android" },
steps: [{ kind: "tool", name: "button", args: { button: "back" } }],
},
],
});
const result = await run("remote-sim", `remote:${DEVICE}`);
// The run really targeted the remote-prefixed id (platform "ios-remote"),
// not a local iOS udid — so entering the ios block below IS the fold.
expect(result.device).toBe(`remote:${DEVICE}`);
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"when:pass",
"tap:pass",
"when:skip",
"tool:skip",
]);
expect(result.steps[0].reason).toMatch(/condition met \(platform ios\)/);
expect(result.steps[2].reason).toMatch(/platform android/);
expect(result.taps).toHaveLength(1);
expect(result.ok).toBe(true);
});
it("matches a platform guard positively on an android device and skips the ios block", async () => {
// The inverse orientation of the iOS-udid test above: an android serial
// (any id that is not a UUID / remote: / chromium-cdp- / amazon- shape
// resolves as android) must ENTER the android block. Pins that guard
// matching works from a non-iOS device too, not only as
// ios-matches / android-skips seen from an iOS udid.
currentTree = () =>
screen([n({ label: "Allow", frame: { x: 0.4, y: 0.8, width: 0.2, height: 0.1 } })]);
await writeFlow("android-run", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: { kind: "platform", platform: "android" },
steps: [{ kind: "tap", selector: { text: "Allow", loose: true } }],
},
{
kind: "when",
condition: { kind: "platform", platform: "ios" },
steps: [{ kind: "echo", message: "ios only" }],
},
],
});
const result = await run("android-run", "emulator-5554");
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"when:pass",
"tap:pass",
"when:skip",
"echo:skip",
]);
expect(result.steps[0].reason).toMatch(/condition met \(platform android\)/);
expect(result.steps[2].reason).toMatch(/platform ios/);
expect(result.taps).toHaveLength(1);
expect(result.ok).toBe(true);
});
it("matches a platform guard positively on a vega device", async () => {
// Vega resolves by the amazon- serial prefix. Touch directives are
// rejected upfront on vega (it is remote-driven), so the guarded step is
// an echo — the guard entering at all is what's under test.
await writeFlow("vega-run", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: { kind: "platform", platform: "vega" },
steps: [{ kind: "echo", message: "vega only" }],
},
{
kind: "when",
condition: { kind: "platform", platform: "ios" },
steps: [{ kind: "echo", message: "ios only" }],
},
],
});
const result = await run("vega-run", "amazon-4a27df03c9777152");
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"when:pass",
"echo:pass",
"when:skip",
"echo:skip",
]);
expect(result.steps[0].reason).toMatch(/condition met \(platform vega\)/);
expect(result.ok).toBe(true);
});
it("errors the when step when the guard cannot be evaluated (unreadable tree)", async () => {
currentTree = () => {
throw new Error("native devtools disconnected");
};
await writeFlow("blind", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: {
kind: "ui",
condition: "visible",
selector: { text: "What's new", loose: true },
},
steps: [{ kind: "tap", selector: { text: "Skip", loose: true } }],
},
{ kind: "echo", message: "never reached" },
],
});
const result = await run("blind");
// "Could not evaluate" is not "condition false" — a broken tree source
// must not silently turn a guarded dismissal into a green no-op. The
// guarded steps still expand: one skip line per authored step, same
// report shape as an unmet guard.
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"when:error",
"tap:skip",
"echo:skip",
]);
expect(result.steps[0].reason).toMatch(/could not evaluate when guard/i);
expect(result.steps[1].reason).toBe("when guard errored");
expect(result.ok).toBe(false);
});
it("expands a nested when block when the guard errors", async () => {
currentTree = () => {
throw new Error("native devtools disconnected");
};
await writeFlow("blind-nested", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: {
kind: "ui",
condition: "visible",
selector: { text: "What's new", loose: true },
},
steps: [
{ kind: "tap", selector: { text: "Skip", loose: true } },
{
kind: "when",
condition: { kind: "platform", platform: "ios" },
steps: [{ kind: "echo", message: "nested" }],
},
],
},
{ kind: "echo", message: "never reached" },
],
});
const result = await run("blind-nested");
// An errored guard reports the same shape as an unmet one — the block
// marker errors, then one skip line per authored step with the nested
// when's subtree expanded.
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"when:error",
"tap:skip",
"when:skip",
"echo:skip",
"echo:skip",
]);
// The errored-guard expansion keeps the entered-block depths: marker and
// trailing echo at top level (field omitted), authored steps one deeper.
expect(result.steps.map((s) => s.depth)).toEqual([undefined, 1, 1, 2, undefined]);
expect(result.steps[2].reason).toBe("when guard errored");
expect(result.errored).toBe(1);
expect(result.skipped).toBe(2); // tap + nested when marker; echo is narration
expect(result.ok).toBe(false);
});
it("errors — not skips — when every read in the window is blind (empty tree + hint)", async () => {
// A detached tree source (e.g. Vega toolkit) surfaces as SUCCESSFUL
// fetches returning an empty tree with a degraded-read hint. That is not
// evidence the condition is false, for any condition kind.
currentTree = () => screen([]);
currentHint = "automation toolkit not attached — relaunch the app";
await writeFlow("degraded", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: {
kind: "ui",
condition: "visible",
selector: { text: "Got it", loose: true },
},
steps: [{ kind: "tap", selector: { text: "Got it", loose: true } }],
},
{ kind: "echo", message: "never reached" },
],
});
const result = await run("degraded");
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"when:error",
"tap:skip",
"echo:skip",
]);
expect(result.steps[0].reason).toMatch(/could not evaluate when guard/i);
expect(result.taps).toHaveLength(0);
expect(result.ok).toBe(false);
});
it("errors a hidden guard that ends on blind reads after the element matched", async () => {
// First read: trusted, element visible. Every later read: empty tree,
// blind because the selector had matched. Gone-ness can't be confirmed —
// unknown must not be treated as "condition false".
let reads = 0;
currentTree = () =>
reads++ === 0
? screen([n({ label: "Spinner", frame: { x: 0.4, y: 0.4, width: 0.2, height: 0.2 } })])
: screen([]);
await writeFlow("spinner-gone", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: {
kind: "ui",
condition: "hidden",
selector: { text: "Spinner", loose: true },
},
steps: [{ kind: "echo", message: "cleanup" }],
},
{ kind: "echo", message: "never reached" },
],
});
const result = await run("spinner-gone");
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"when:error",
"echo:skip",
"echo:skip",
]);
expect(result.steps[0].reason).toMatch(/could not confirm the element is hidden/i);
expect(result.ok).toBe(false);
});
it("errors a hidden guard whose later reads throw after the element matched", async () => {
// The same evidence gap as the blind-read case, surfaced as a THROW: read
// 1 is trusted and sees the spinner, then the tree source disconnects and
// every later fetch rejects. The visible match left over from read 1 must
// not stand in as current evidence and turn the guard into a determinate
// "condition not met" skip — gone-ness is unconfirmable, so error.
let reads = 0;
currentTree = () => {
if (reads++ === 0) {
return screen([
n({ label: "Spinner", frame: { x: 0.4, y: 0.4, width: 0.2, height: 0.2 } }),
]);
}
throw new Error("native devtools disconnected");
};
await writeFlow("spinner-dark", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: {
kind: "ui",
condition: "hidden",
selector: { text: "Spinner", loose: true },
},
steps: [{ kind: "echo", message: "cleanup" }],
},
{ kind: "echo", message: "never reached" },
],
});
const result = await run("spinner-dark");
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"when:error",
"echo:skip",
"echo:skip",
]);
expect(result.steps[0].reason).toMatch(/could not confirm the element is hidden/i);
expect(result.steps[0].reason).toMatch(/native devtools disconnected/);
expect(result.ok).toBe(false);
});
it("keeps a clean skip when trusted reads showed a non-hidden condition false and only the final polls throw", async () => {
// The trailing-blip tolerance for every condition except `hidden`:
// trusted reads showed "What's new" absent until ~one poll before the
// 1s guard deadline, so a disconnect on the trailing polls is a blip,
// not doubt — the skip stays clean and the run green. (Appending the
// failed-read note is an assert/await report feature; the when skip
// reason carries only the guard label.)
let firstReadAt: number | undefined;
currentTree = () => {
firstReadAt ??= Date.now();
if (Date.now() - firstReadAt >= 950) throw new Error("native devtools disconnected");
return screen([n({ label: "Home", frame: { x: 0, y: 0, width: 1, height: 0.1 } })]);
};
await writeFlow("blip-skip", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: {
kind: "ui",
condition: "visible",
selector: { text: "What's new", loose: true },
},
steps: [{ kind: "tap", selector: { text: "Skip", loose: true } }],
},
{ kind: "echo", message: "after block" },
],
});
const result = await run("blip-skip");
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"when:skip",
"tap:skip",
"echo:pass",
]);
expect(result.steps[0].reason).toMatch(/condition not met/);
expect(result.taps).toHaveLength(0);
expect(result.ok).toBe(true);
});
it("errors — not skips — a guard whose reads go dark for the tail of the window", async () => {
// The unbounded dark tail: one trusted read shows "What's new" absent,
// then the tree source dies for the REST of the 1s window. That early
// read is the expected starting state of a wait, not evidence about the
// deadline — a determinate "condition not met" skip here would let a
// dying tree source turn a guarded dismissal into a silent green no-op
// while the dialog may well be on screen. Unknown is not false: error.
let reads = 0;
currentTree = () => {
if (reads++ === 0) {
return screen([n({ label: "Home", frame: { x: 0, y: 0, width: 1, height: 0.1 } })]);
}
throw new Error("native devtools disconnected");
};
await writeFlow("dark-tail-guard", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: {
kind: "ui",
condition: "visible",
selector: { text: "What's new", loose: true },
},
steps: [{ kind: "tap", selector: { text: "Skip", loose: true } }],
},
{ kind: "echo", message: "never reached" },
],
});
const result = await run("dark-tail-guard");
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"when:error",
"tap:skip",
"echo:skip",
]);
expect(result.steps[0].reason).toMatch(/could not evaluate when guard/i);
expect(result.steps[0].reason).toMatch(/unreadable for the final \d+ms/i);
expect(result.steps[0].reason).toMatch(/native devtools disconnected/);
expect(result.taps).toHaveLength(0);
expect(result.ok).toBe(false);
});
it("streams every when-related report line to the live progress consumer", async () => {
// All when reports go through pushReport — the progress stream and the
// final report must contain the same lines.
currentTree = () =>
screen([n({ label: "Home", frame: { x: 0, y: 0, width: 1, height: 0.1 } })]);
await writeFlow("stream", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: {
kind: "ui",
condition: "visible",
selector: { text: "What's new", loose: true },
},
steps: [
{ kind: "tap", selector: { text: "Skip", loose: true } },
{ kind: "echo", message: "inside" },
],
},
{ kind: "echo", message: "after block" },
],
});
const events: unknown[] = [];
const tool = createRunFlowTool(mockRegistry([]));
const ctx = {
emitProgress: (e: unknown) => {
events.push(e);
},
} as unknown as Parameters<typeof tool.execute>[2];
const result = asRun(
await tool.execute({}, { name: "stream", project_root: tmpDir, device: DEVICE }, ctx)
);
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"when:skip",
"tap:skip",
"echo:skip",
"echo:pass",
]);
expect(events).toEqual(result.steps);
});
});
describe("when: as tap-if-present", () => {
const coachMark = (): Parameters<typeof writeFlow>[1] => ({
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: { kind: "ui", condition: "visible", selector: { text: "Got it", loose: true } },
steps: [{ kind: "tap", selector: { text: "Got it", loose: true } }],
},
{ kind: "echo", message: "after" },
],
});
it("taps when the target is visible", async () => {
currentTree = () =>
screen([n({ label: "Got it", frame: { x: 0.4, y: 0.8, width: 0.2, height: 0.1 } })]);
await writeFlow("coach-mark", coachMark());
const result = await run("coach-mark");
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"when:pass",
"tap:pass",
"echo:pass",
]);
expect(result.taps).toHaveLength(1);
expect(result.ok).toBe(true);
});
it("skips — and keeps the run green — when the target is absent", async () => {
currentTree = () =>
screen([n({ label: "Home", frame: { x: 0, y: 0, width: 1, height: 0.1 } })]);
await writeFlow("no-coach-mark", coachMark());
const result = await run("no-coach-mark");
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"when:skip",
"tap:skip",
"echo:pass",
]);
expect(result.taps).toHaveLength(0);
expect(result.ok).toBe(true);
});
});
describe("when: run cancellation", () => {
// Same abort injection as flow-abort.test.ts: trip an AbortController from
// inside the mocked tree fetch, landing the cancellation deterministically
// inside a poll (no timer races).
it("skips the block with the abort reason — not a determinate skip — when the guard probe is cancelled", async () => {
const controller = new AbortController();
// The guard's target is absent and the cancellation lands inside the
// probe's first tree read. ABORTED_OUTCOME carries no `indeterminate`, so
// without execWhenStep's dedicated probe.aborted branch the cancelled
// probe would fall through to met=false and claim the determinate
// "condition not met (...) — block skipped" for a screen it never
// finished reading — the assertions below pin the abort reason instead.
currentTree = () => {
controller.abort();
return screen([n({ label: "Home", frame: { x: 0, y: 0, width: 1, height: 0.1 } })]);
};
await writeFlow("cancelled-guard", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: {
kind: "ui",
condition: "visible",
selector: { text: "What's new", loose: true },
},
steps: [
{ kind: "tap", selector: { text: "Skip", loose: true } },
{ kind: "echo", message: "inside" },
],
},
{ kind: "echo", message: "after block" },
],
});
const result = await run("cancelled-guard", DEVICE, controller.signal);
// The block marker + every authored step skip with the uniform abort
// reason, and the guarded tap never dispatches.
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"when:skip",
"tap:skip",
"echo:skip",
"echo:skip",
]);
expect(result.steps[0].reason).toBe("run aborted");
expect(result.steps[0].reason).not.toMatch(/condition not met/);
expect(result.steps[1].reason).toBe("run aborted");
expect(result.taps).toHaveLength(0);
expect(result.ok).toBe(false);
expect(result.aborted).toBe(true);
});
it("expands a when block — nested block included — when the run was cancelled before it", async () => {
const controller = new AbortController();
// The tap's target never appears; the run is cancelled on the third tree
// read, while the tap's auto-wait is still polling. The when block after
// it then hits the pre-step abort guard: one skip line for the marker AND
// one per authored step, with the nested when's subtree expanded.
let reads = 0;
currentTree = () => {
reads++;
if (reads >= 3) controller.abort();
return screen([n({ label: "Home", frame: { x: 0, y: 0, width: 1, height: 0.1 } })]);
};
await writeFlow("aborted-before-when", {
executionPrerequisite: "",
steps: [
{ kind: "tap", selector: { text: "Checkout", loose: true } },
{
kind: "when",
condition: {
kind: "ui",
condition: "visible",
selector: { text: "What's new", loose: true },
},
steps: [
{ kind: "tap", selector: { text: "Skip", loose: true } },
{
kind: "when",
condition: { kind: "platform", platform: "ios" },
steps: [{ kind: "echo", message: "nested" }],
},
],
},
],
});
const result = await run("aborted-before-when", DEVICE, controller.signal);
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"tap:skip",
"when:skip",
"tap:skip",
"when:skip",
"echo:skip",
]);
expect(result.steps.map((s) => s.reason)).toEqual(Array(5).fill("run aborted"));
// Abort skips keep the block's depths — top-level markers omit the field,
// authored steps sit one deeper — same shape as a clean run.
expect(result.steps.map((s) => s.depth)).toEqual([undefined, undefined, 1, 1, 2]);
// The skipped nested echo keeps its message, matching reportBlockSkipped.
expect(result.steps[4].message).toBe("nested");
expect(result.taps).toHaveLength(0);
expect(result.ok).toBe(false);
expect(result.aborted).toBe(true);
});
it("leaves the aborted field unset on a clean green run", async () => {
// Control for the cancellation cases: summarize spreads the flag in only
// when the signal tripped (`...(aborted ? { aborted: true } : {})`), so a
// completed run must not carry the key at all.
currentTree = () =>
screen([n({ label: "Got it", frame: { x: 0.4, y: 0.8, width: 0.2, height: 0.1 } })]);
await writeFlow("green-run", {
executionPrerequisite: "",
steps: [
{
kind: "when",
condition: {
kind: "ui",
condition: "visible",
selector: { text: "Got it", loose: true },
},
steps: [{ kind: "tap", selector: { text: "Got it", loose: true } }],
},
{ kind: "echo", message: "after" },
],
});
const result = await run("green-run");
expect(result.ok).toBe(true);
expect(result.aborted).toBeUndefined();
expect("aborted" in result).toBe(false);
});
it("pluralizes the skip reason exactly as the recording summary spells the same block", async () => {
// The `(N step/N steps)` tail is spelled twice — here in the skip reason
// and in flow-step-definitions' when summary. This couples them: an edit
// to either spelling that desyncs the two fails this, not a user-facing
// drift nobody pinned. BOTH counts run, because a plural-only block leaves
// the singular arm free to be hardcoded away on either side.
const block = (n: number) => ({
kind: "when" as const,
condition: {
kind: "ui" as const,
condition: "visible" as const,
selector: { text: "What's new", loose: true },
},
steps: Array.from({ length: n }, (_, i) => ({
kind: "echo" as const,
message: `step ${i}`,
})),
});
currentTree = () => screen([]);
for (const [n, tail] of [
[1, "(1 step)"],
[2, "(2 steps)"],
] as const) {
await writeFlow(`plural-${n}`, { executionPrerequisite: "", steps: [block(n)] });
const result = await run(`plural-${n}`);
expect(summarizeStep(block(n), 1).match(/\([^()]*\)$/)?.[0]).toBe(tail);
// Whole reason, not a suffix: a compare that only looks at the end passes
// on anything appended after the tail.
expect(result.steps[0].reason).toBe(
`condition not met (${describeWhenCondition(block(n).condition)}) — block skipped ${tail}`
);
}
});
});