Files
software-mansion__argent/packages/tool-server/test/flows/flow-long-press.test.ts
Jakub Piasecki d4a712c401 refactor(gesture): rename the momentum-free flag to momentum (#732)
`settle: true` collided with the runner's other `settle` — waiting for
the tree to quiesce — which is the very thing a swipe step does after
every dispatch, whichever way the flag is set. One sentence of the flow
skill used both senses at once.

This names the physics instead: `momentum: false` is the momentum-free
gesture, and the natural fling is the default the prose already
described.

### Renamed

| surface | before | after |
| --- | --- | --- |
| `swipe` flow directive | `settle: true` | `momentum: false` |
| `gesture-swipe` param | `settle` | `momentum` |
| `gesture-drag` param | `settle` | `momentum` |

The directive passes the flag straight through to the tools, so all
three move together. `momentum: true` is the default and normalizes to
absent, mirroring what `settle: false` did — parse/serialize stay exact
inverses.

### Also

- Recording summary label `(settle)` → `(momentum-free)`
- Internal constants → `MOMENTUM_FREE_EASE_EXPONENT`,
`MOMENTUM_FREE_MIN_STEPS`
- `flow-run` tool description and `argent-create-flow` skill updated

### Testing

`tsc -b`, eslint (`--max-warnings 0`) and prettier clean; `test/flows` +
`test/tools` pass 671/671, including a new case covering the
legacy-spelling rejection.
2026-08-28 09:55:47 +02:00

324 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { Registry } from "@argent/registry";
import type { DescribeNode, DescribeTreeData } from "../../src/tools/describe/contract";
// Serve the flow tree directly: flows resolve selectors against the platform's
// full-hierarchy source and hard-fail rather than degrade to the AX tree, so
// these unit tests stub the tree fetch itself.
let currentTree: () => DescribeNode;
vi.mock("../../src/tools/flows/flow-tree", async (importOriginal) => ({
...(await importOriginal<typeof import("../../src/tools/flows/flow-tree")>()),
fetchFlowTree: vi.fn(
async (): Promise<DescribeTreeData> => ({
tree: currentTree(),
source: "native-devtools",
})
),
}));
import { createRunFlowTool, type FlowRunResult } from "../../src/tools/flows/flow-run";
import { serializeFlow, parseFlow } from "../../src/tools/flows/flow-utils";
import { gestureDragTool } from "../../src/tools/gesture-drag/index";
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 });
}
/** Registry that records every gesture tool invocation's args. */
function mockRegistry(calls: Array<{ tool: string; args: Record<string, unknown> }>): Registry {
return {
invokeTool: vi.fn(async (id: string, args: Record<string, unknown>) => {
if (id === "list-devices") return { devices: [] };
calls.push({ tool: id, args });
return { ok: true };
}),
getTool: vi.fn(() => ({ inputSchema: { properties: { udid: {} } } })),
} as unknown as Registry;
}
async function writeFlow(name: string, yaml: Parameters<typeof serializeFlow>[0]): Promise<void> {
const dir = path.join(tmpDir, ".argent", "flows");
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, `${name}.yaml`), serializeFlow(yaml), "utf8");
}
function asRun(r: FlowRunResult | { notice: string }): FlowRunResult {
if (!("steps" in r)) throw new Error(`expected a run result, got notice: ${r.notice}`);
return r;
}
async function run(
name: string
): Promise<FlowRunResult & { calls: Array<{ tool: string; args: Record<string, unknown> }> }> {
const calls: Array<{ tool: string; args: Record<string, unknown> }> = [];
const tool = createRunFlowTool(mockRegistry(calls));
const result = asRun(await tool.execute({}, { name, project_root: tmpDir, device: DEVICE }));
return Object.assign(result, { calls });
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-long-press-"));
currentTree = () => screen([]);
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
describe("long-press: parse/serialize", () => {
it("round-trips selector and point spellings", () => {
const flow = {
executionPrerequisite: "",
steps: [
{ kind: "long-press" as const, selector: { text: "Row 3", loose: true } },
{ kind: "long-press" as const, selector: { identifier: "row-3" } },
{ kind: "long-press" as const, selector: { text: "Row 3", loose: true }, duration: 1200 },
{ kind: "long-press" as const, x: 0.5, y: 0.6 },
{ kind: "long-press" as const, x: 0.5, y: 0.6, duration: 1200 },
// The exact ceiling — must round-trip clean.
{ kind: "long-press" as const, selector: { identifier: "row-3" }, duration: 10_000 },
],
};
expect(parseFlow(serializeFlow(flow)).steps).toEqual(flow.steps);
});
it.each([
["one millisecond over the ceiling", "10001"],
["a hold authored in the wrong unit", "20000"],
["a literal that clears parsePositiveMs's finite check", "1e21"],
])("rejects a duration of %s as longer than a hold can be dispatched", (_description, value) => {
// Unbounded, this parsed clean and then died inside the registry on Chromium
// alone, where a long-press IS a gesture-drag whose own durationMs stops at
// 10s. The bound belongs at parse, where the author is told on every platform.
expect(() => parseFlow(`steps:\n - long-press: { on: A, duration: ${value} }\n`)).toThrow(
/long-press\.duration is \S+ms - above the maximum long-press duration of 10000ms/i
);
});
it("rejects a programmatic long-press duration above the ceiling", () => {
expect(() =>
serializeFlow({
executionPrerequisite: "",
steps: [{ kind: "long-press", selector: { text: "Row 3", loose: true }, duration: 15_000 }],
})
).toThrow(
/Cannot serialize flow long-press\.duration: 15000ms - above the maximum long-press duration of 10000ms/
);
});
it("bounds duration at exactly what the Chromium dispatch accepts", () => {
// The two numbers are the same by construction, not by coincidence: this is
// the pairing that broke, so it is asserted rather than assumed.
const steps = parseFlow('steps:\n - long-press: { on: "Row", duration: 10000 }\n').steps;
const duration = (steps[0] as { duration: number }).duration;
expect(duration).toBe(10_000);
expect(
gestureDragTool.zodSchema!.safeParse({
udid: "chromium-cdp-9222",
fromX: 0.5,
fromY: 0.5,
toX: 0.5,
toY: 0.5,
durationMs: duration,
}).success
).toBe(true);
});
it.each([
["zero", 0],
["NaN", Number.NaN],
])("rejects a programmatic long-press duration that is %s", (_description, duration) => {
// A FlowStep built in code bypasses the YAML parser, so the serializer must
// apply the same positive-milliseconds guard the parser enforces.
expect(() =>
serializeFlow({
executionPrerequisite: "",
steps: [{ kind: "long-press", selector: { text: "Row 3", loose: true }, duration }],
})
).toThrow("Cannot serialize flow long-press.duration: needs a positive number of milliseconds");
});
it("parses the options form with the usual selector sugar", () => {
const steps = parseFlow(
"steps:\n" +
' - long-press: { on: "Row 3", duration: 1200 }\n' +
" - long-press: { on: { text: Row } }\n" // no options — canonicalizes away
).steps;
expect(steps).toEqual([
{ kind: "long-press", selector: { text: "Row 3", loose: true }, duration: 1200 },
{ kind: "long-press", selector: { text: "Row" } },
]);
});
it("parses a raw point bare and under `on`", () => {
expect(
parseFlow(
"steps:\n" +
" - long-press: { x: 0.5, y: 0.6 }\n" +
" - long-press: { on: { x: 0.5, y: 0.6 }, duration: 1200 }\n"
).steps
).toEqual([
{ kind: "long-press", x: 0.5, y: 0.6 },
{ kind: "long-press", x: 0.5, y: 0.6, duration: 1200 },
]);
});
it("rejects malformed bodies", () => {
expect(() => parseFlow('steps:\n - long-press: { text: "Row", duration: 900 }\n')).toThrow(
/options form takes a nested selector/i
);
expect(() => parseFlow("steps:\n - long-press: { on: A, foo: 1 }\n")).toThrow(
/accepts only \{ on, duration \}/i
);
expect(() => parseFlow("steps:\n - long-press: { duration: 900 }\n")).toThrow(
/needs a target/i
);
for (const bad of ["0", "-5", '"900"', ".inf"]) {
expect(() => parseFlow(`steps:\n - long-press: { on: A, duration: ${bad} }\n`)).toThrow(
/duration needs a positive number of milliseconds/i
);
}
});
it("rejects malformed coordinate targets", () => {
expect(() => parseFlow('steps:\n - long-press: { text: "Row", x: 0.5, y: 0.5 }\n')).toThrow(
/selector or x\/y coordinates, not both/i
);
expect(() => parseFlow("steps:\n - long-press: { x: 0.5 }\n")).toThrow(
/needs numeric x and y/i
);
expect(() => parseFlow("steps:\n - long-press: { x: 0.5, y: 0.5, z: 1 }\n")).toThrow(
/takes only \{ x, y \}/i
);
expect(() => parseFlow("steps:\n - long-press: { x: 0.5, y: 0.5, duration: 900 }\n")).toThrow(
/options form takes a nested point/i
);
expect(() =>
parseFlow("steps:\n - long-press: { on: { x: 1.5, y: 0.5 }, duration: 500 }\n")
).toThrow(/normalized 0–1 fractions/i);
});
});
describe("long-press: execution", () => {
it("dispatches one Down/delayed-Up gesture-custom train on touch platforms", async () => {
currentTree = () =>
screen([n({ label: "Row 3", frame: { x: 0.1, y: 0.4, width: 0.8, height: 0.1 } })]);
await writeFlow("press", {
executionPrerequisite: "",
steps: [{ kind: "long-press", selector: { text: "Row 3", loose: true }, duration: 1200 }],
});
const result = await run("press");
expect(result.ok).toBe(true);
expect(result.calls).toEqual([
{
tool: "gesture-custom",
args: {
udid: DEVICE,
events: [
{ type: "Down", x: 0.5, y: 0.45, delayMs: 0 },
{ type: "Up", x: 0.5, y: 0.45, delayMs: 1200 },
],
},
},
]);
});
it("holds for the 800ms default when no duration is given", async () => {
currentTree = () =>
screen([n({ label: "Row 3", frame: { x: 0.1, y: 0.4, width: 0.8, height: 0.1 } })]);
await writeFlow("press-default", {
executionPrerequisite: "",
steps: [{ kind: "long-press", selector: { text: "Row 3", loose: true } }],
});
const result = await run("press-default");
const events = result.calls[0]!.args.events as Array<{ type: string; delayMs: number }>;
expect(events[1]).toMatchObject({ type: "Up", delayMs: 800 });
});
it("maps to a mouse press-hold-release (gesture-drag, from == to) on chromium", async () => {
currentTree = () =>
screen([n({ label: "Row 3", frame: { x: 0.1, y: 0.4, width: 0.8, height: 0.1 } })]);
await writeFlow("press-chromium", {
executionPrerequisite: "",
steps: [{ kind: "long-press", selector: { text: "Row 3", loose: true }, duration: 900 }],
});
const calls: Array<{ tool: string; args: Record<string, unknown> }> = [];
const tool = createRunFlowTool(mockRegistry(calls));
const result = asRun(
await tool.execute(
{},
{ name: "press-chromium", project_root: tmpDir, device: "chromium-cdp-9222" }
)
);
expect(result.ok).toBe(true);
expect(calls).toEqual([
{
tool: "gesture-drag",
args: {
udid: "chromium-cdp-9222",
fromX: 0.5,
fromY: 0.45,
toX: 0.5,
toY: 0.45,
durationMs: 900,
},
},
]);
});
it("presses a raw point", async () => {
currentTree = () => screen([]);
await writeFlow("press-point", {
executionPrerequisite: "",
steps: [{ kind: "long-press", x: 0.3, y: 0.7, duration: 1000 }],
});
const result = await run("press-point");
expect(result.ok).toBe(true);
expect(result.steps[0]).toMatchObject({ target: "(0.3, 0.7)" });
expect(result.calls).toEqual([
{
tool: "gesture-custom",
args: {
udid: DEVICE,
events: [
{ type: "Down", x: 0.3, y: 0.7, delayMs: 0 },
{ type: "Up", x: 0.3, y: 0.7, delayMs: 1000 },
],
},
},
]);
});
it("fails with the scroll-to hint when the target never appears", async () => {
currentTree = () => screen([]);
await writeFlow("press-missing", {
executionPrerequisite: "",
steps: [{ kind: "long-press", selector: { text: "Row 3", loose: true } }],
});
const result = await run("press-missing");
expect(result.ok).toBe(false);
expect(result.steps[0]).toMatchObject({ kind: "long-press", status: "fail" });
expect(result.steps[0].reason).toMatch(/add a scroll-to step/i);
expect(result.calls).toHaveLength(0);
}, 15000);
});