mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
dbf4de1ab0
## Summary
Sets up the foundation for adding Android support without changing any
user-visible iOS behavior. iOS works exactly as before; Android handlers
are stubs that throw a clear \"not yet implemented\" message until
they're filled in.
### New primitives
- `Platform`, `DeviceKind`, `DeviceInfo`, `ToolCapability`,
`ToolDependency` (in `@argent/registry`)
- `classifyDevice(udid)` + `resolveDevice(udid)` (UUID shape → iOS, else
Android)
- `assertSupported(toolId, capability, device)` — throws
`UnsupportedOperationError`
- `ensureDep(s)` — host-binary preflight with install hints
- `dispatchByPlatform({ toolId, capability, ios, android })` — one-line
cross-platform `execute()`
### HTTP gating
`/tools/:name` now runs:
- Host-binary preflight (`requires`) → 424 with install hint
- Capability gate (`capability` + `udid`) → 400 with reason
### Per-tool directory layout
Every cross-platform tool now lives in its own directory:
\`\`\`
tools/gesture-tap/
index.ts ← ToolDefinition, capability, dispatchByPlatform
platforms/
ios.ts ← iOS impl (existing logic)
android.ts ← stub: throws \"not yet implemented\"
\`\`\`
Refactored 17 tools this way: gesture-{tap,swipe,custom,pinch,rotate},
screenshot, button, paste, keyboard, launch-app, restart-app,
reinstall-app, open-url, rotate, run-sequence, describe (with shared
contract + iOS adapters under platforms/).
### Tool call flow (iOS, today)
1. Agent calls \`gesture-tap { udid: \"<uuid>\", x, y }\`
2. HTTP zod-parses, then host-binary preflight, then capability gate
(passes — capability has \`apple\`)
3. Tool's \`execute()\` (built by \`dispatchByPlatform\`):
- \`resolveDevice(udid)\` → \`{ platform: \"ios\", kind: \"simulator\",
id }\`
- \`assertSupported\` → passes
- branches to \`tapIos(services, params, device)\`
4. iOS impl uses \`SimulatorServerApi\` exactly as before.
### Tool call flow (Android, future)
Same flow; the Android branch in \`dispatchByPlatform\` runs. Filling in
Android = three steps:
1. Implement the function in \`tools/<tool>/platforms/android.ts\`
2. Add \`android: { emulator: true, device: true, unknown: true }\` to
the tool's capability
3. Make \`services()\` platform-aware where the iOS service URN doesn't
apply (e.g. \`launch-app\` declares \`nativeDevtools\` for iOS only)
## Test plan
- [x] All 395 existing tests pass; 9 new tests for `device-info` +
`capability`
- [x] TypeScript clean across `@argent/registry` and
`@argent/tool-server`
- [ ] Manual smoke-test on a real iOS simulator (gesture-tap,
screenshot, describe, launch-app)
## Deferred to follow-ups
- **Per-tool capability declarations on iOS-only tools**
(`native-devtools/*`, `profiler/ios-profiler/*`, `network/*`,
`simulator/*`). They still work; the HTTP capability gate is a no-op
when no declaration exists. Adding them is mechanical and doesn't affect
iOS behavior.
- **`list-simulators` / `boot-simulator` rename to `list-devices` /
`boot-device`** — that's a behavior change, paired with the Android
impls.
- **Platform-aware `services()`** for tools whose iOS service has no
Android equivalent (e.g. `nativeDevtools` in `launch-app` /
`restart-app`). Needed when filling in Android impls.
108 lines
2.7 KiB
TypeScript
108 lines
2.7 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
describeNodeSchema,
|
|
getDescribeTapPoint,
|
|
parseDescribeResult,
|
|
} from "../src/tools/describe/contract";
|
|
|
|
const settingsDescribeSample = {
|
|
role: "AXGroup",
|
|
frame: { x: 0, y: 0, width: 1, height: 1 },
|
|
children: [
|
|
{
|
|
role: "AXHeading",
|
|
frame: {
|
|
x: 0.041025641025641144,
|
|
y: 0.12401263823064766,
|
|
width: 0.34102564102564104,
|
|
height: 0.0481832543443918,
|
|
},
|
|
label: "Settings",
|
|
children: [],
|
|
},
|
|
{
|
|
role: "AXGroup",
|
|
frame: { x: 0, y: 0, width: 1, height: 1 },
|
|
children: [
|
|
{
|
|
role: "AXButton",
|
|
frame: {
|
|
x: 0.041025641025641144,
|
|
y: 0.32977883096366506,
|
|
width: 0.9179487179487179,
|
|
height: 0.06161137440758294,
|
|
},
|
|
identifier: "com.apple.settings.general",
|
|
label: "General",
|
|
children: [],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "AXGroup",
|
|
frame: {
|
|
x: 0,
|
|
y: 0.8981042654028436,
|
|
width: 1,
|
|
height: 0.1018957345971564,
|
|
},
|
|
identifier: "Toolbar",
|
|
label: "Toolbar",
|
|
children: [],
|
|
},
|
|
],
|
|
};
|
|
|
|
describe("describe public contract", () => {
|
|
it("accepts the live AX describe payload shape", () => {
|
|
const result = parseDescribeResult(settingsDescribeSample);
|
|
|
|
expect(result.role).toBe("AXGroup");
|
|
expect(result.children).toHaveLength(3);
|
|
expect(result.children[0]?.role).toBe("AXHeading");
|
|
expect(result.children[1]?.children[0]?.identifier).toBe("com.apple.settings.general");
|
|
});
|
|
|
|
it("computes tap points from the normalized frame midpoint", () => {
|
|
const tap = getDescribeTapPoint({
|
|
x: 0.1,
|
|
y: 0.2,
|
|
width: 0.4,
|
|
height: 0.6,
|
|
});
|
|
|
|
expect(tap.x).toBeCloseTo(0.3);
|
|
expect(tap.y).toBeCloseTo(0.5);
|
|
});
|
|
|
|
it("rejects frames outside normalized [0,1] bounds", () => {
|
|
const invalid = {
|
|
role: "AXButton",
|
|
frame: { x: 1.2, y: 0.1, width: 0.2, height: 0.2 },
|
|
children: [],
|
|
};
|
|
|
|
expect(() => parseDescribeResult(invalid)).toThrow();
|
|
});
|
|
|
|
it("requires children on every node to preserve tree semantics", () => {
|
|
const invalid = {
|
|
role: "AXButton",
|
|
frame: { x: 0.1, y: 0.1, width: 0.2, height: 0.2 },
|
|
};
|
|
|
|
expect(() => parseDescribeResult(invalid)).toThrow();
|
|
});
|
|
|
|
it("allows additional non-contract fields without breaking compatibility", () => {
|
|
const result = describeNodeSchema.parse({
|
|
role: "AXButton",
|
|
frame: { x: 0.1, y: 0.1, width: 0.2, height: 0.2 },
|
|
children: [],
|
|
hint: "Extra metadata preserved by passthrough",
|
|
});
|
|
|
|
expect(result.role).toBe("AXButton");
|
|
});
|
|
});
|