mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
d4a712c401
`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.
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import * as fs from "node:fs/promises";
|
||
import * as os from "node:os";
|
||
import * as path from "node:path";
|
||
import type { Registry } from "@argent/registry";
|
||
import type { DescribeNode, DescribeTreeData } from "../../src/tools/describe/contract";
|
||
|
||
// Serve the flow tree directly: flows resolve selectors against the platform's
|
||
// full-hierarchy source and hard-fail rather than degrade to the AX tree, so
|
||
// these unit tests stub the tree fetch itself.
|
||
let currentTree: () => DescribeNode;
|
||
vi.mock("../../src/tools/flows/flow-tree", 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);
|
||
});
|