mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
193 lines
7.0 KiB
TypeScript
193 lines
7.0 KiB
TypeScript
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([]);
|
||
});
|
||
});
|