Files
software-mansion__argent/packages/tool-server/test/cross-platform-tool.test.ts
Juliusz Wajgelt 275f4c9968 fix(ios-remote): unblock native-devtools and keyboard on non-macOS hosts (#472)
## Problem

Argent can drive a **remote** iOS simulator (`ios-remote` / `remote:`
device ids) from a non-macOS host via the `sim-remote` CLI. Two tools
that were supposed to work in that setup failed because local-only
guards fired on the remote code path:

1. **native-devtools family** (`native-describe-screen`,
`native-find-views`, keyboard injection, …) threw `... requires a macOS
host` on Linux. Root cause: `requireDarwin()` in
`packages/native-devtools-ios/src/index.ts` gated **every** dylib/binary
accessor — including the **TCP variants** that exist specifically to be
*uploaded to the remote Mac orchestrator*, never executed locally.
`remoteIosHost` reaches those accessors (`setupNativeDevtoolsEnvRemote`
→ `tcpInjectionDylibs()`; `spawnAxDaemonRemote` →
`axServiceBinaryPathTcp()`) *after* the code has already committed to
the remote path, so the gate was redundant and wrong.

2. **`keyboard`** declared `appleRemote: { simulator: true }` capability
but its `dispatchByPlatform` had **no `iosRemote` branch**, so an
`ios-remote` device passed the capability check and then threw `declares
ios-remote capability but has no iosRemote branch` at dispatch. Its
local iOS handler also probes tvOS via local `xcrun`, which can't run
off-Mac anyway — but remote sims are always iOS.

## Change

- **native-devtools-ios**: remove `requireDarwin(...)` from the four
`*Tcp` accessors (`bootstrapDylibPathTcp`, `nativeDevtoolsDylibPathTcp`,
`keyboardPatchDylibPathTcp`, `axServiceBinaryPathTcp`); the existing
file-existence check stays, so a host missing the downloaded TCP
artifacts still gets a clear "not found" error. Local and tvOS accessors
remain darwin-gated (they're `simctl spawn`d on the local host, and
`ios-remote` is iOS-only).
- **keyboard**: add `makeIosRemoteImpl` that skips the tvOS probe and
types straight over `typeSimulatorServer` (which already routes
`ios-remote` through the sim-remote MoQ transport), and wire an
`iosRemote` branch into the dispatch — mirroring how `paste` already
works.

No other changes needed: `boot-device`'s remote path already skips the
darwin check, and the `native-*` tools already preflight the
`sim-remote` dependency for `ios-remote`. An audit of every iOS tool
handler confirmed these two were the complete set of
"advertised-but-blocked" remote services; the profiler / `await-*` tools
are intentionally local-only (they declare `apple`, not `appleRemote`,
and cleanly reject).

## Tests

- `native-devtools-ios`: inverted the two tests that asserted the TCP
accessors throw on Linux — they now assert the TCP dylibs/ax-service
**resolve** on a simulated Linux host, plus a case that a missing
artifact yields a plain not-found error rather than the macOS-host gate.
- `tool-server`: added two `dispatchByPlatform` regression tests —
correct `ios-remote` routing, and the "declares appleRemote but no
iosRemote branch" wiring error that the keyboard bug tripped.
- All package tests pass; both packages typecheck clean.

## Verification

On a non-macOS host with `sim-remote` configured against a remote Mac
sim:
1. Attach a `remote:<uuid>` simulator (`list-devices` shows it as
`ios-remote`).
2. Run `native-describe-screen` / `native-find-views` and the `keyboard`
tool — confirm they operate instead of throwing the macOS-host /
no-branch errors.
3. On macOS, confirm local (non-remote) native-devtools + keyboard still
work (removing the guard is a no-op on darwin).
2026-07-06 17:12:20 +02:00

185 lines
6.3 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import { dispatchByPlatform, type PlatformImpl } from "../src/utils/cross-platform-tool";
import { __resetDepCacheForTests, __primeDepCacheForTests } from "../src/utils/check-deps";
import { UnsupportedOperationError } from "../src/utils/capability";
import type { ToolCapability } from "@argent/registry";
const capabilityBoth: ToolCapability = {
apple: { simulator: true, device: true },
android: { emulator: true, device: true, unknown: true },
};
const capabilityIosOnly: ToolCapability = {
apple: { simulator: true, device: true },
};
const capabilityRemote: ToolCapability = {
apple: { simulator: true, device: true },
appleRemote: { simulator: true },
};
const iosUdid = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA";
const iosRemoteUdid = `remote:${iosUdid}`;
const androidUdid = "emulator-5554";
beforeEach(() => {
__resetDepCacheForTests();
});
describe("dispatchByPlatform", () => {
it("routes iOS UDIDs to the ios handler and Android serials to the android handler", async () => {
const iosHandler = vi.fn().mockResolvedValue("from-ios");
const androidHandler = vi.fn().mockResolvedValue("from-android");
const execute = dispatchByPlatform<
Record<string, never>,
Record<string, never>,
{ udid: string },
string
>({
toolId: "test",
capability: capabilityBoth,
ios: { handler: iosHandler },
android: { handler: androidHandler },
});
expect(await execute({}, { udid: iosUdid })).toBe("from-ios");
expect(iosHandler).toHaveBeenCalledOnce();
expect(androidHandler).not.toHaveBeenCalled();
expect(await execute({}, { udid: androidUdid })).toBe("from-android");
expect(androidHandler).toHaveBeenCalledOnce();
});
it("routes ios-remote UDIDs to the iosRemote handler, not the local ios handler", async () => {
const iosHandler = vi.fn().mockResolvedValue("from-ios");
const remoteHandler = vi.fn().mockResolvedValue("from-remote");
const execute = dispatchByPlatform<
Record<string, never>,
Record<string, never>,
{ udid: string },
string
>({
toolId: "test",
capability: capabilityRemote,
ios: { handler: iosHandler },
android: { handler: async () => "should-not-run" },
iosRemote: { handler: remoteHandler },
});
expect(await execute({}, { udid: iosRemoteUdid })).toBe("from-remote");
expect(remoteHandler).toHaveBeenCalledOnce();
expect(iosHandler).not.toHaveBeenCalled();
});
it("throws a wiring error when a device is ios-remote but no iosRemote branch is provided", async () => {
// Capability declares appleRemote, so assertSupported passes — but the tool
// forgot to wire the iosRemote branch. Surface a clear wiring error instead
// of silently falling through to the local (xcrun-based) ios handler. This
// is the exact gap the `keyboard` tool had.
const execute = dispatchByPlatform<
Record<string, never>,
Record<string, never>,
{ udid: string },
string
>({
toolId: "no-remote-branch",
capability: capabilityRemote,
ios: { handler: async () => "should-not-run" },
android: { handler: async () => "should-not-run" },
});
await expect(execute({}, { udid: iosRemoteUdid })).rejects.toThrow(
/declares ios-remote capability but has no iosRemote branch/
);
});
it("rejects with UnsupportedOperationError when capability does not declare the platform", async () => {
const execute = dispatchByPlatform<
Record<string, never>,
Record<string, never>,
{ udid: string },
string
>({
toolId: "ios-only-tool",
capability: capabilityIosOnly,
ios: { handler: async () => "ok" },
android: { handler: async () => "should-not-run" },
});
await expect(execute({}, { udid: androidUdid })).rejects.toBeInstanceOf(
UnsupportedOperationError
);
});
it("only checks the resolved branch's `requires` — iOS-only env doesn't trip the adb probe", async () => {
// Cache says xcrun is present; adb is NOT primed → would fail an adb probe.
__primeDepCacheForTests(["xcrun"]);
const iosHandler = vi.fn().mockResolvedValue("ios-ran");
const androidHandler = vi.fn().mockResolvedValue("android-ran");
const execute = dispatchByPlatform<
Record<string, never>,
Record<string, never>,
{ udid: string },
string
>({
toolId: "test",
capability: capabilityBoth,
ios: { requires: ["xcrun"], handler: iosHandler },
android: { requires: ["adb"], handler: androidHandler },
});
// iOS path: only xcrun is probed (already cached as available) — succeeds
// even though adb is not present.
expect(await execute({}, { udid: iosUdid })).toBe("ios-ran");
});
it("forwards services and InvokeToolOptions through to the handler", async () => {
const handler = vi.fn(async (services, params, _device, options) => {
return { sawSignal: !!options?.signal, sawService: services.foo };
});
const execute = dispatchByPlatform<{ foo: string }, { foo: string }, { udid: string }, unknown>(
{
toolId: "test",
capability: capabilityBoth,
ios: { handler },
android: { handler },
}
);
const controller = new AbortController();
const result = await execute({ foo: "bar" }, { udid: iosUdid }, { signal: controller.signal });
expect(result).toEqual({ sawSignal: true, sawService: "bar" });
});
it("does not call ensureDeps when `requires` is undefined or empty", async () => {
const handler = vi.fn().mockResolvedValue("ok");
const noRequires: PlatformImpl<Record<string, never>, { udid: string }, string> = { handler };
const emptyRequires: PlatformImpl<Record<string, never>, { udid: string }, string> = {
requires: [],
handler,
};
const a = dispatchByPlatform<
Record<string, never>,
Record<string, never>,
{ udid: string },
string
>({
toolId: "t",
capability: capabilityBoth,
ios: noRequires,
android: emptyRequires,
});
// No primed cache, no ensureDeps probe — these would fail if ensureDeps fired.
await expect(a({}, { udid: iosUdid })).resolves.toBe("ok");
await expect(a({}, { udid: androidUdid })).resolves.toBe("ok");
});
});