Files
Jakub Piasecki 3d3b82a076 feat(flow): regex matching via matches in assertions and selectors (#503)
## What

- Add `matches` as a case-sensitive JavaScript regex comparator for
`await` and `assert` text conditions.
- Support `{ text: { matches: ... } }` in every flow selector slot,
combined with `id` and `role`.
- Preserve regex matchers through parsing, YAML serialization, recording
summaries, and run reports.

## Correctness

- Prevent empty-matchable patterns from matching unlabeled nodes or
empty assertion text.
- Reject unknown raw `await-ui-element` selector keys instead of
silently dropping constraints.
- Reject selector combinations YAML cannot round-trip without changing
their semantics.
- Document native label aggregation accurately and keep action ranking
consistent.

## Maintenance

- Share selector-field and regex-syntax validation across both matcher
parse paths.
- Compile selector regexes once per tree walk and full-match regexes
once per ranking pass.
- Make comparator serialization exhaustive, extract the duplicated flow
test harness, and scope slow-test timeouts narrowly.

## Verification

- Full tool-server suite: 2,588 tests across 263 files.
- Source and test typechecks, ESLint, Prettier, and skill-copy
validation pass.
2026-07-15 08:07:03 +02:00

84 lines
2.8 KiB
TypeScript

import { afterEach, beforeEach, 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 } from "../../src/tools/describe/contract";
import { createRunFlowTool, type FlowRunResult } from "../../src/tools/flows/flow-run";
import { serializeFlow } from "../../src/tools/flows/flow-utils";
const IOS_DEVICE = "00000000-0000-0000-0000-0000000000ab";
interface ToolCall {
tool: string;
args: Record<string, unknown>;
}
export function n(partial: Partial<DescribeNode> & { frame: DescribeNode["frame"] }): DescribeNode {
return { role: "AXOther", children: [], ...partial };
}
export function screen(children: DescribeNode[]): DescribeNode {
return n({ role: "AXWindow", frame: { x: 0, y: 0, width: 1, height: 1 }, children });
}
export function label(text: string, extra: Partial<DescribeNode> = {}): DescribeNode {
return n({
role: "AXStaticText",
label: text,
frame: { x: 0.1, y: 0.1, width: 0.5, height: 0.05 },
...extra,
});
}
function mockRegistry(calls?: ToolCall[]): 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;
}
function asRun(result: FlowRunResult | { notice: string }): FlowRunResult {
if (!("steps" in result)) throw new Error(`expected a run result, got notice: ${result.notice}`);
return result;
}
export function createFlowTestHarness(options: {
tempDirectoryPrefix: string;
reset?: () => void;
}) {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), options.tempDirectoryPrefix));
options.reset?.();
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
async function writeFlow(name: string, flow: 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(flow), "utf8");
}
async function execute(name: string, calls?: ToolCall[]): Promise<FlowRunResult> {
const tool = createRunFlowTool(mockRegistry(calls));
return asRun(await tool.execute({}, { name, project_root: tmpDir, device: IOS_DEVICE }));
}
const run = (name: string): Promise<FlowRunResult> => execute(name);
const runWithCalls = async (name: string): Promise<FlowRunResult & { calls: ToolCall[] }> => {
const calls: ToolCall[] = [];
return Object.assign(await execute(name, calls), { calls });
};
return { writeFlow, run, runWithCalls };
}