Files
2026-08-04 10:35:54 +02:00

193 lines
7.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach, afterEach } 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";
// The recorder must read the SAME tree source the runner resolves selectors
// against at replay (fetchFlowTree), not the trimmed agent-facing describe
// tree — mock it directly so each test controls exactly what capture sees.
let currentTreeData: () => DescribeTreeData;
vi.mock("../../src/tools/flows/flow-tree", () => ({
fetchFlowTree: vi.fn(async (): Promise<DescribeTreeData> => currentTreeData()),
}));
import { createFlowAddStepTool } from "../../src/tools/flows/flow-add-step";
import { flowStartRecordingTool } from "../../src/tools/flows/flow-start-recording";
import {
clearActiveFlow,
clearActiveProjectRoot,
parseFlow,
setActiveProjectRoot,
} from "../../src/tools/flows/flow-utils";
const DEVICE = "00000000-0000-0000-0000-0000000000AB"; // iOS UDID shape
const PREREQ = "App on home screen";
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: "AXGroup", frame: { x: 0, y: 0, width: 1, height: 1 }, children });
}
function setTree(children: DescribeNode[], source: DescribeTreeData["source"] = "native-devtools") {
currentTreeData = () => ({ tree: screen(children), source });
}
function mockRegistry(): Registry {
return {
invokeTool: vi.fn(async (id: string) => {
if (id === "gesture-tap") return { tapped: true };
throw new Error(`Tool "${id}" not found`);
}),
getTool: vi.fn(() => ({ inputSchema: { properties: { udid: {} } } })),
} as unknown as Registry;
}
async function recordTap(point: { x: number; y: number }) {
const tool = createFlowAddStepTool(mockRegistry());
return tool.execute(
{},
{ command: "gesture-tap", args: JSON.stringify({ udid: DEVICE, ...point }) }
);
}
async function recordedSteps() {
const content = await fs.readFile(path.join(tmpDir, ".argent", "flows", "rec.yaml"), "utf8");
return parseFlow(content).steps;
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-record-tap-"));
setActiveProjectRoot(tmpDir);
clearActiveFlow();
await flowStartRecordingTool.execute(
{},
{ name: "rec", project_root: tmpDir, executionPrerequisite: PREREQ }
);
});
afterEach(async () => {
clearActiveFlow();
clearActiveProjectRoot();
await fs.rm(tmpDir, { recursive: true, force: true });
});
describe("flow-add-step tap selector capture", () => {
it("documents that a kept coordinate still needs the replay hierarchy", () => {
expect(createFlowAddStepTool(mockRegistry()).description).toContain(
"Kept coordinates bypass selector resolution only: replay still requires the full flow hierarchy/devtools"
);
});
it("captures an identifier selector from the flow tree", async () => {
setTree([
n({
identifier: "add-to-cart",
label: "Add to cart",
frame: { x: 0.3, y: 0.5, width: 0.4, height: 0.06 },
}),
]);
const result = await recordTap({ x: 0.5, y: 0.52 });
expect(result.message).not.toContain("—");
expect(await recordedSteps()).toEqual([
{ kind: "tap", selector: { identifier: "add-to-cart" } },
]);
});
it("captures a strict text selector when the node has no identifier", async () => {
setTree([n({ label: "Add to cart", frame: { x: 0.3, y: 0.5, width: 0.4, height: 0.06 } })]);
await recordTap({ x: 0.5, y: 0.52 });
expect(await recordedSteps()).toEqual([{ kind: "tap", selector: { text: "Add to cart" } }]);
});
it("records a text selector for a labelled control that also exposes a value", async () => {
// The label+value join ("Volume 50%") exists on no single node — matchNode
// compares a text selector against label and value individually — so the
// derived selector must use the label alone and still pass the re-resolve
// check instead of degrading to coordinates.
setTree([
n({ label: "Volume", value: "50%", frame: { x: 0.2, y: 0.4, width: 0.6, height: 0.08 } }),
]);
const result = await recordTap({ x: 0.5, y: 0.44 });
expect(result.message).not.toContain("resolves to a different element");
expect(result.message).not.toContain("matches no element");
expect(await recordedSteps()).toEqual([{ kind: "tap", selector: { text: "Volume" } }]);
});
it("carries a recorded clickCount into the tap step's times", async () => {
// A recorded double-tap must not silently replay as a single tap.
setTree([n({ label: "Photo", frame: { x: 0.3, y: 0.5, width: 0.4, height: 0.06 } })]);
const tool = createFlowAddStepTool(mockRegistry());
await tool.execute(
{},
{
command: "gesture-tap",
args: JSON.stringify({ udid: DEVICE, x: 0.5, y: 0.52, clickCount: 2 }),
}
);
expect(await recordedSteps()).toEqual([{ kind: "tap", selector: { text: "Photo" }, times: 2 }]);
});
it("keeps coordinates when the selector would retarget to another element", async () => {
// Two "Add" labels: replay's selectorToFrame ranking (exact → smallest
// frame) elects the smaller node at the top, not the tapped one — so the
// selector must be rejected in favor of coordinates.
setTree([
n({ label: "Add", frame: { x: 0.1, y: 0.1, width: 0.1, height: 0.03 } }),
n({ label: "Add", frame: { x: 0.1, y: 0.5, width: 0.3, height: 0.05 } }),
]);
const result = await recordTap({ x: 0.2, y: 0.52 });
expect(result.message).toContain("resolves to a different element");
expect(await recordedSteps()).toEqual([{ kind: "tap", x: 0.2, y: 0.52 }]);
});
it("records the selector with a caveat when captured from the fallback tree source", async () => {
setTree(
[n({ label: "Settings", frame: { x: 0.3, y: 0.5, width: 0.4, height: 0.06 } })],
"ax-service"
);
const result = await recordTap({ x: 0.5, y: 0.52 });
expect(result.message).toContain("fallback ax-service tree");
expect(await recordedSteps()).toEqual([{ kind: "tap", selector: { text: "Settings" } }]);
});
it("keeps coordinates with a warning when the tree fetch fails", async () => {
currentTreeData = () => {
throw new Error("devtools gone");
};
const result = await recordTap({ x: 0.5, y: 0.52 });
expect(result.message).toContain("selector capture failed");
expect(result.message).toContain(
"kept coordinates, but replay still requires the full flow hierarchy/devtools"
);
expect(await recordedSteps()).toEqual([{ kind: "tap", x: 0.5, y: 0.52 }]);
});
it("does not persist a raw point that replay would reject", async () => {
setTree([]);
await expect(recordTap({ x: 1.5, y: 0.52 })).rejects.toThrow(/normalized 0–1 fractions/i);
expect(await recordedSteps()).toEqual([]);
});
});