mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
b1cadd7cd6
Fixes #453 ## The bug For an app that can never be injected (an Apple built-in app such as `com.apple.Preferences`), `native-devtools-status` kept reporting `requiresRestart: true` / `nextLaunchWillBeInjected: true`, and the native-* feature tools (native-describe-screen, native-find-views, etc.) kept returning "restart-app then retry". Restarting never changes the state, so an autonomous agent loops `restart-app → retry` forever. There was no terminal "this app cannot be injected" signal. ## Why these apps can't inject Apple system/built-in apps (bundle ids under `com.apple.`) are platform binaries with library validation enabled; the simulator ignores `DYLD_INSERT_LIBRARIES` for them, so the Argent native devtools dylib can never load. This is a reliable, well-understood signal on the simulator. Third-party apps the user installs carry no such restriction and inject fine. So the fix treats bundle ids starting with `com.apple.` as non-injectable, via a general predicate rather than hardcoding the single bundle id from the issue. ## The fix - New pure predicate `isInjectableBundleId(bundleId)` in `blueprints/native-devtools.ts` returning `false` for `com.apple.*` bundle ids (case-sensitive prefix, matching how bundle ids are compared), with a doc comment explaining the library-validation rationale. - New failure code `NATIVE_DEVTOOLS_NOT_INJECTABLE` in `@argent/registry`. - `precheckNativeDevtools(api, udid, bundleId)` (the 3-arg overload) now throws a terminal `FailureError` with that code, *before* the `requiresAppRestart → restart_required` branch, when the bundle id is non-injectable. Because every native-* feature tool awaits this precheck and propagates the throw, all 7 tools now surface a hard, terminal error instead of an unbounded restart→retry instruction — no change needed in the individual tool files. The 2-arg overload (no bundleId), used by `native-devtools-status`, is unchanged and never throws (guarded by `bundleId !== undefined`). - `native-devtools-status` now *reports* the state instead of throwing: it adds an `injectable: boolean` field. For a non-injectable app it returns `injectable: false, requiresRestart: false, nextLaunchWillBeInjected: false` (with `envSetup`/`appRunning`/`connected` still measured), giving an agent that checks status first a terminal signal. The tool description documents the new field and that `injectable: false` is terminal (do not restart/retry; use the standard describe / screenshot / view-at-point tools). ## Verification - `npm run build` (root tsc): clean. - `vitest run` on `native-devtools-status.test.ts`: 13/13 passing. Added coverage for the predicate, the status terminal state for a `com.apple.*` app, and that the 3-arg precheck throws `NATIVE_DEVTOOLS_NOT_INJECTABLE` while the 2-arg overload does not throw for the same api. - Telemetry `sanitize.test.ts` (iterates every registered failure code): 49/49 passing — the new code is accepted. - Prettier + eslint (`--max-warnings 0`) clean on all touched files. Device E2E on an iOS simulator with `com.apple.Preferences` (confirming `native-devtools-status` returns `injectable: false` and the native-* tools return the terminal error rather than looping restart-app) is pending and will be run separately. --- ### Device verification (iOS 18.5 simulator, com.apple.Preferences) Reproduced before/after on a real simulator with a dev tool-server: **Before (origin/main):** `native-devtools-status` for `com.apple.Preferences` → `requiresRestart:true, nextLaunchWillBeInjected:true`; `native-describe-screen` → `restart_required`; after `restart-app` the status is identical (the infinite loop). **After (this branch):** - `native-devtools-status` → `{envSetup:true, appRunning:true, connected:false, requiresRestart:false, nextLaunchWillBeInjected:false, injectable:false}` — terminal. - `native-describe-screen` / `native-find-views` → terminal error: "com.apple.Preferences is an Apple system app ... can never be injected ... use the standard describe / screenshot / view-at-point tools instead." (no more restart→retry loop). **Regression (injectable third-party app `xyz.blueskyweb.app`):** `native-devtools-status` → `injectable:true` with the normal `requiresRestart:true`; `native-describe-screen` → the normal `restart_required` (not the terminal error). The predicate does not false-positive on normal apps. --- ### Review follow-ups (@hubgan on-device review) - **Recovery guidance no longer recommends `view-at-point`.** Both view-at-point tools run this same 3-arg precheck and re-throw `NATIVE_DEVTOOLS_NOT_INJECTABLE` for a `com.apple.*` bundle, so recommending them dead-ended the agent. All three surfaces (the precheck throw, the `describe` iOS fallback hint, and the `native-devtools-status` description) now share one verbatim "do not fall back to the native-* tools" warning (`NON_INJECTABLE_NATIVE_WARNING`) and recommend only `describe` and `screenshot`. (52b888ce) - **Propagation tests for all six feature tools.** Added tests that drive each 3-arg feature tool's `execute()` with a `com.apple.*` bundle and assert the throw propagates out as `NATIVE_DEVTOOLS_NOT_INJECTABLE`, so a future try/catch inside a tool can't silently regress the terminal behavior (proven non-vacuous via a swallow-the-throw mutation). (52b888ce) - **Degraded sim + system app keeps the re-boot hint.** When the ax-service is degraded, `describe` now returns the `boot-device force=true` guidance instead of the terminal screenshot hint - a proper re-boot may let the ax-service read the system app's full tree. `should_restart` is still never set, so the restart loop stays removed. (5ace8be3) --- ### Second review round (@hubgan) + follow-up hardening - **Terminal check hoisted above env init in the precheck.** Injectability is a static property of the bundle id, so the `NATIVE_DEVTOOLS_NOT_INJECTABLE` throw now fires before the `givenUp`/`ensureEnvReady` plumbing - a broken env can no longer mask the terminal signal behind `init_failed`'s re-boot guidance, and no env work is spent on a system app. Regression tests cover both masked paths. (ca7093c6) - **The same ordering applied to the other two surfaces.** `native-devtools-status` computes the injectable gate before the precheck (a given-up sim no longer hides `injectable: false` behind `init_failed`), and `describe`'s gate moved ahead of native-devtools service resolution (a downed ios-remote tunnel or dispose race no longer swallows the terminal hint). (bef6dfcb) - **Unreachable-sim fallback.** If the status tool's `isAppRunning` probe fails on a non-injectable app (shut-down sim), it falls back to the precheck so a broken sim still surfaces structured `init_failed` - whose re-boot guidance IS corrective there - instead of a raw subprocess error; a healthy env rethrows the probe failure. (722b432a) - **Warning text scoped to the tools it is true of.** `NON_INJECTABLE_NATIVE_WARNING` now names the six injection-based feature tools instead of sweeping in all `native-*` tools - the `native-profiler-*` tools never inject and keep working on system apps. (bef6dfcb) - **launch-app / restart-app boundary pinned.** New tests drive both tools (local iOS and ios-remote dispatch, all four call sites) with `com.apple.Preferences` and the real precheck, asserting the terminal throw never reaches them - proven non-vacuous by a 3-arg mutation failing exactly the mutated sites. (bef6dfcb,722b432a)
572 lines
22 KiB
TypeScript
572 lines
22 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { AXServiceApi, AXDescribeResponse } from "../src/blueprints/ax-service";
|
|
import type { NativeDevtoolsApi } from "../src/blueprints/native-devtools";
|
|
import { NON_INJECTABLE_NATIVE_WARNING } from "../src/blueprints/native-devtools";
|
|
import { createDescribeTool } from "../src/tools/describe";
|
|
import { __primeDepCacheForTests, __resetDepCacheForTests } from "../src/utils/check-deps";
|
|
import { isTvOsSimulator } from "../src/utils/ios-devices";
|
|
|
|
// describeIos probes the runtime kind to short-circuit tvOS. Mock it so these
|
|
// unit tests stay hermetic (no real `simctl`) and default to the iOS path; the
|
|
// dedicated tvOS test below overrides it per-call.
|
|
vi.mock("../src/utils/ios-devices", () => ({
|
|
isTvOsSimulator: vi.fn(async () => false),
|
|
}));
|
|
const mockIsTvOsSimulator = vi.mocked(isTvOsSimulator);
|
|
|
|
// The describe tool no longer surfaces the JSON tree — `result.description`
|
|
// is the text rendering produced by format-tree.ts. `elementLineCount` counts
|
|
// the per-element lines (everything indented under a section header), which
|
|
// is what the old `tree.children.length` was effectively measuring once you
|
|
// ignore the root AXGroup wrapper.
|
|
function elementLineCount(description: string): number {
|
|
return description.split("\n").filter((l) => /^ {2}AX/.test(l)).length;
|
|
}
|
|
|
|
function makeAXServiceApi(
|
|
response: AXDescribeResponse,
|
|
options?: { degraded?: boolean }
|
|
): AXServiceApi {
|
|
return {
|
|
degraded: options?.degraded ?? false,
|
|
describe: async () => response,
|
|
alertCheck: async () => response.alertVisible,
|
|
ping: async () => true,
|
|
};
|
|
}
|
|
|
|
function makeNativeDevtoolsApi(options: {
|
|
connectedBundleIds?: string[];
|
|
requiresRestart?: boolean;
|
|
describeScreenResult?: unknown;
|
|
}): NativeDevtoolsApi {
|
|
const connected = new Set(options.connectedBundleIds ?? []);
|
|
return {
|
|
isEnvSetup: () => true,
|
|
socketPath: "/tmp/test.sock",
|
|
ensureEnvReady: async () => {},
|
|
reverifyEnv: async () => {},
|
|
getInitFailure: () => null,
|
|
isConnected: (bundleId) => connected.has(bundleId),
|
|
isAppRunning: async () => true,
|
|
listConnectedBundleIds: () => [...connected],
|
|
requiresAppRestart: async () => options.requiresRestart ?? false,
|
|
activateNetworkInspection: () => {},
|
|
getNetworkLog: () => [],
|
|
clearNetworkLog: () => {},
|
|
getAppState: async (bundleId) => ({
|
|
bundleId,
|
|
applicationState: "active",
|
|
foregroundActiveSceneCount: 1,
|
|
foregroundInactiveSceneCount: 0,
|
|
backgroundSceneCount: 0,
|
|
unattachedSceneCount: 0,
|
|
isFrontmostCandidate: true,
|
|
}),
|
|
detectFrontmostBundleId: async () => [...connected][0] ?? null,
|
|
queryViewHierarchy: async () =>
|
|
options.describeScreenResult ?? {
|
|
screenFrame: { x: 0, y: 0, width: 440, height: 956 },
|
|
elements: [],
|
|
},
|
|
} as NativeDevtoolsApi;
|
|
}
|
|
|
|
function makeMockRegistry(options: {
|
|
axService?: AXServiceApi;
|
|
nativeDevtools?: NativeDevtoolsApi;
|
|
}) {
|
|
return {
|
|
resolveService: vi.fn(async (urn: string) => {
|
|
if (urn.startsWith("AXService:")) {
|
|
if (options.axService) return options.axService;
|
|
throw new Error("ax-service not available");
|
|
}
|
|
if (urn.startsWith("NativeDevtools:")) {
|
|
if (options.nativeDevtools) return options.nativeDevtools;
|
|
throw new Error("native-devtools not available");
|
|
}
|
|
throw new Error(`unknown service: ${urn}`);
|
|
}),
|
|
} as any;
|
|
}
|
|
|
|
describe("describe tool", () => {
|
|
beforeEach(() => {
|
|
// `describe` dispatches by udid shape (classifyDevice). The tests pass
|
|
// iOS-shape udids that route to the iOS branch, whose `requires:["xcrun"]`
|
|
// would shell out to probe PATH on Linux CI without xcrun. Prime the dep
|
|
// cache so neither branch probes — handlers run with mock services.
|
|
__resetDepCacheForTests();
|
|
__primeDepCacheForTests(["xcrun", "adb"]);
|
|
mockIsTvOsSimulator.mockResolvedValue(false);
|
|
});
|
|
|
|
it("returns elements from ax-service daemon", async () => {
|
|
const axApi = makeAXServiceApi({
|
|
alertVisible: false,
|
|
screenFrame: { width: 440, height: 956 },
|
|
elements: [
|
|
{
|
|
label: "General",
|
|
frame: { x: 0.045, y: 0.337, width: 0.909, height: 0.046 },
|
|
traits: ["button"],
|
|
},
|
|
],
|
|
});
|
|
|
|
const registry = makeMockRegistry({ axService: axApi });
|
|
const tool = createDescribeTool(registry);
|
|
|
|
const result = await tool.execute({}, { udid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" });
|
|
expect(result.source).toBe("ax-service");
|
|
expect(result.description).toContain("ROOT AXGroup");
|
|
expect(result.description).toMatch(/AXButton\s+"General"/);
|
|
});
|
|
|
|
it("returns dialog elements when alertVisible is true", async () => {
|
|
const axApi = makeAXServiceApi({
|
|
alertVisible: true,
|
|
screenFrame: { width: 440, height: 956 },
|
|
elements: [
|
|
{
|
|
label: "Allow Once",
|
|
frame: { x: 0.1, y: 0.5, width: 0.8, height: 0.05 },
|
|
traits: ["button"],
|
|
},
|
|
{
|
|
label: "Don\u2019t Allow",
|
|
frame: { x: 0.1, y: 0.56, width: 0.8, height: 0.05 },
|
|
traits: ["button"],
|
|
},
|
|
],
|
|
});
|
|
|
|
const registry = makeMockRegistry({ axService: axApi });
|
|
const tool = createDescribeTool(registry);
|
|
|
|
const result = await tool.execute({}, { udid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" });
|
|
expect(result.source).toBe("ax-service");
|
|
expect(elementLineCount(result.description)).toBe(2);
|
|
expect(result.description).toMatch(/AXButton\s+"Allow Once"/);
|
|
expect(result.description).toMatch(/AXButton\s+"Don\u2019t Allow"/);
|
|
});
|
|
|
|
it("returns empty root when no elements and no native fallback", async () => {
|
|
const axApi = makeAXServiceApi({
|
|
alertVisible: false,
|
|
elements: [],
|
|
});
|
|
|
|
const registry = makeMockRegistry({ axService: axApi });
|
|
const tool = createDescribeTool(registry);
|
|
|
|
const result = await tool.execute({}, { udid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" });
|
|
expect(result.source).toBe("ax-service");
|
|
expect(result.description).toContain("ROOT AXGroup");
|
|
expect(elementLineCount(result.description)).toBe(0);
|
|
});
|
|
|
|
it("uses bundleId for native-devtools fallback when AX returns empty", async () => {
|
|
const axApi = makeAXServiceApi({
|
|
alertVisible: false,
|
|
elements: [],
|
|
});
|
|
|
|
// An injectable (non-Apple) app: the native fallback queries it by the
|
|
// provided bundleId. (Apple system apps are gated off the native path — see
|
|
// the non-injectable test below.)
|
|
const nativeApi = makeNativeDevtoolsApi({
|
|
connectedBundleIds: ["com.example.settings"],
|
|
describeScreenResult: {
|
|
screenFrame: { x: 0, y: 0, width: 440, height: 956 },
|
|
elements: [
|
|
{
|
|
frame: { x: 20, y: 150, width: 400, height: 44 },
|
|
tapPoint: { x: 220, y: 172 },
|
|
normalizedFrame: { x: 0.045, y: 0.157, width: 0.909, height: 0.046 },
|
|
normalizedTapPoint: { x: 0.5, y: 0.18 },
|
|
traits: ["button"],
|
|
label: "General",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const registry = makeMockRegistry({ axService: axApi, nativeDevtools: nativeApi });
|
|
const tool = createDescribeTool(registry);
|
|
|
|
const result = await tool.execute(
|
|
{},
|
|
{ udid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", bundleId: "com.example.settings" }
|
|
);
|
|
expect(result.source).toBe("native-devtools");
|
|
expect(result.description).toMatch(/AXButton\s+"General"/);
|
|
});
|
|
|
|
it("falls back to native-devtools with auto-target when AX returns empty", async () => {
|
|
const axApi = makeAXServiceApi({
|
|
alertVisible: false,
|
|
elements: [],
|
|
});
|
|
|
|
const nativeApi = makeNativeDevtoolsApi({
|
|
connectedBundleIds: ["com.example.app"],
|
|
describeScreenResult: {
|
|
screenFrame: { x: 0, y: 0, width: 440, height: 956 },
|
|
elements: [
|
|
{
|
|
frame: { x: 10, y: 100, width: 420, height: 40 },
|
|
tapPoint: { x: 220, y: 120 },
|
|
normalizedFrame: { x: 0.023, y: 0.105, width: 0.955, height: 0.042 },
|
|
normalizedTapPoint: { x: 0.5, y: 0.126 },
|
|
traits: ["staticText"],
|
|
label: "Hello World",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const registry = makeMockRegistry({ axService: axApi, nativeDevtools: nativeApi });
|
|
const tool = createDescribeTool(registry);
|
|
|
|
const result = await tool.execute({}, { udid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" });
|
|
expect(result.source).toBe("native-devtools");
|
|
expect(result.description).toContain('"Hello World"');
|
|
expect(result.should_restart).toBeUndefined();
|
|
});
|
|
|
|
it("returns should_restart when native-devtools app requires restart", async () => {
|
|
const axApi = makeAXServiceApi({
|
|
alertVisible: false,
|
|
elements: [],
|
|
});
|
|
|
|
const nativeApi = makeNativeDevtoolsApi({
|
|
connectedBundleIds: ["com.example.app"],
|
|
requiresRestart: true,
|
|
});
|
|
|
|
const registry = makeMockRegistry({ axService: axApi, nativeDevtools: nativeApi });
|
|
const tool = createDescribeTool(registry);
|
|
|
|
const result = await tool.execute(
|
|
{},
|
|
{ udid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", bundleId: "com.example.app" }
|
|
);
|
|
expect(result.source).toBe("ax-service");
|
|
expect(result.should_restart).toBe(true);
|
|
expect(elementLineCount(result.description)).toBe(0);
|
|
});
|
|
|
|
it("does NOT return should_restart for a non-injectable Apple system app (no restart loop)", async () => {
|
|
// com.apple.* apps can never load the injected dylib, so requiresAppRestart
|
|
// is always true for them in an unmocked run. Without an injectability gate,
|
|
// describe returns should_restart:true → the agent restarts the system app →
|
|
// AX is still empty → describe again → unbounded loop. The fallback must
|
|
// instead return the (empty) AX result with a screenshot hint.
|
|
const axApi = makeAXServiceApi({ alertVisible: false, elements: [] });
|
|
const nativeApi = makeNativeDevtoolsApi({
|
|
connectedBundleIds: [],
|
|
requiresRestart: true, // real behavior: a com.apple.* app never connects
|
|
});
|
|
const registry = makeMockRegistry({ axService: axApi, nativeDevtools: nativeApi });
|
|
const tool = createDescribeTool(registry);
|
|
|
|
const result = await tool.execute(
|
|
{},
|
|
{ udid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", bundleId: "com.apple.Preferences" }
|
|
);
|
|
expect(result.source).toBe("ax-service");
|
|
expect(result.should_restart).toBeUndefined();
|
|
expect(elementLineCount(result.description)).toBe(0);
|
|
expect(result.hint).toMatch(/system app/i);
|
|
// Reached only after describe's own AX path returned empty, so this hint
|
|
// leads with `screenshot` rather than re-recommending `describe`. It still
|
|
// carries the same native-* dead-end warning verbatim as the precheck throw
|
|
// and the native-devtools-status description.
|
|
expect(result.hint).toMatch(/`screenshot`/);
|
|
expect(result.hint).toContain(NON_INJECTABLE_NATIVE_WARNING);
|
|
});
|
|
|
|
it("keeps the degraded re-boot hint for a com.apple.* app when the ax-service is degraded", async () => {
|
|
// When the sim was not booted through argent, the ax-service is degraded and
|
|
// returns an empty tree, so describe reaches the non-injectable branch. Here
|
|
// the empty tree is a fixable sim-config problem, not proof the system app is
|
|
// undescribable: a proper `boot-device force=true` may let the ax-service
|
|
// read this app's full tree. So the terminal "use screenshot" hint must NOT
|
|
// clobber the re-boot guidance — otherwise the agent never learns its sim is
|
|
// degraded (which affects every describe call). should_restart still stays
|
|
// unset, so the restart loop remains broken.
|
|
const axApi = makeAXServiceApi({ alertVisible: false, elements: [] }, { degraded: true });
|
|
const nativeApi = makeNativeDevtoolsApi({
|
|
connectedBundleIds: [],
|
|
requiresRestart: true,
|
|
});
|
|
const registry = makeMockRegistry({ axService: axApi, nativeDevtools: nativeApi });
|
|
const tool = createDescribeTool(registry);
|
|
|
|
const result = await tool.execute(
|
|
{},
|
|
{ udid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", bundleId: "com.apple.Preferences" }
|
|
);
|
|
expect(result.source).toBe("ax-service");
|
|
expect(result.should_restart).toBeUndefined();
|
|
expect(elementLineCount(result.description)).toBe(0);
|
|
// The degraded re-boot guidance wins over the terminal screenshot hint.
|
|
expect(result.hint).toMatch(/boot-device/i);
|
|
expect(result.hint).not.toContain(NON_INJECTABLE_NATIVE_WARNING);
|
|
});
|
|
|
|
it("returns the terminal hint for an explicit system app even when native-devtools is unavailable", async () => {
|
|
// Injectability of an explicit bundleId is static, so the terminal hint
|
|
// must not depend on the native-devtools service resolving (a downed
|
|
// ios-remote tunnel or a dispose race would otherwise swallow it into the
|
|
// generic catch and return no guidance at all).
|
|
const axApi = makeAXServiceApi({ alertVisible: false, elements: [] });
|
|
// No native devtools service provided — resolveService throws for it.
|
|
const registry = makeMockRegistry({ axService: axApi });
|
|
const tool = createDescribeTool(registry);
|
|
|
|
const result = await tool.execute(
|
|
{},
|
|
{ udid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", bundleId: "com.apple.Preferences" }
|
|
);
|
|
expect(result.source).toBe("ax-service");
|
|
expect(result.should_restart).toBeUndefined();
|
|
expect(result.hint).toContain(NON_INJECTABLE_NATIVE_WARNING);
|
|
});
|
|
|
|
it("returns the real AX tree for a non-injectable system app when AX is non-empty (early return, before the gate)", async () => {
|
|
// The common case for a com.apple.* app: its accessibility tree is NON-empty
|
|
// (Settings et al. expose a rich AX tree). describe must return that real
|
|
// tree via the `tree.children.length > 0` early return, which is reached
|
|
// BEFORE the injectability gate — the gate only guards the empty-tree native
|
|
// fallback. If the gate were ever hoisted above the early return it would
|
|
// silently replace a real system-app tree with the terminal screenshot hint;
|
|
// the other non-injectable tests use an empty tree and would not catch that,
|
|
// so this test is the guard for the populated-tree path.
|
|
const axApi = makeAXServiceApi({
|
|
alertVisible: false,
|
|
screenFrame: { width: 440, height: 956 },
|
|
elements: [
|
|
{
|
|
label: "General",
|
|
frame: { x: 0.045, y: 0.337, width: 0.909, height: 0.046 },
|
|
traits: ["button"],
|
|
},
|
|
],
|
|
});
|
|
// requiresRestart:true mirrors a real com.apple.* app (it never connects);
|
|
// it must stay irrelevant here because the non-empty tree returns before the
|
|
// native fallback that would ever consult it.
|
|
const nativeApi = makeNativeDevtoolsApi({
|
|
connectedBundleIds: [],
|
|
requiresRestart: true,
|
|
});
|
|
const registry = makeMockRegistry({ axService: axApi, nativeDevtools: nativeApi });
|
|
const tool = createDescribeTool(registry);
|
|
|
|
const result = await tool.execute(
|
|
{},
|
|
{ udid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", bundleId: "com.apple.Preferences" }
|
|
);
|
|
expect(result.source).toBe("ax-service");
|
|
expect(result.should_restart).toBeUndefined();
|
|
expect(result.description).toMatch(/AXButton\s+"General"/);
|
|
expect(elementLineCount(result.description)).toBe(1);
|
|
// The real tree must be returned untouched, with no terminal non-injectable
|
|
// hint clobbering it — `hint` is the field the screenshot guidance lands in.
|
|
expect(result.hint).toBeUndefined();
|
|
});
|
|
|
|
it("returns empty AX result when native-devtools is unavailable", async () => {
|
|
const axApi = makeAXServiceApi({
|
|
alertVisible: false,
|
|
elements: [],
|
|
});
|
|
|
|
// No native devtools service provided — resolveService will throw
|
|
const registry = makeMockRegistry({ axService: axApi });
|
|
const tool = createDescribeTool(registry);
|
|
|
|
const result = await tool.execute({}, { udid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" });
|
|
expect(result.source).toBe("ax-service");
|
|
expect(elementLineCount(result.description)).toBe(0);
|
|
expect(result.should_restart).toBeUndefined();
|
|
});
|
|
|
|
it("returns degraded result with hint when ax-service is unavailable", async () => {
|
|
const registry = makeMockRegistry({});
|
|
const tool = createDescribeTool(registry);
|
|
|
|
const result = await tool.execute({}, { udid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" });
|
|
expect(result.source).toBe("ax-service");
|
|
expect(result.hint).toMatch(/boot-device/);
|
|
expect(elementLineCount(result.description)).toBe(0);
|
|
});
|
|
|
|
it("returns multiple elements with correct roles", async () => {
|
|
const axApi = makeAXServiceApi({
|
|
alertVisible: false,
|
|
screenFrame: { width: 440, height: 956 },
|
|
elements: [
|
|
{
|
|
label: "Search",
|
|
frame: { x: 0.05, y: 0.16, width: 0.9, height: 0.04 },
|
|
traits: ["searchField"],
|
|
value: "Search",
|
|
},
|
|
{
|
|
label: "General",
|
|
frame: { x: 0.05, y: 0.34, width: 0.9, height: 0.05 },
|
|
traits: ["button", "staticText"],
|
|
},
|
|
{
|
|
label: "Accessibility",
|
|
frame: { x: 0.05, y: 0.4, width: 0.9, height: 0.05 },
|
|
traits: ["button", "staticText"],
|
|
},
|
|
],
|
|
});
|
|
|
|
const registry = makeMockRegistry({ axService: axApi });
|
|
const tool = createDescribeTool(registry);
|
|
|
|
const result = await tool.execute({}, { udid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" });
|
|
expect(result.source).toBe("ax-service");
|
|
expect(elementLineCount(result.description)).toBe(3);
|
|
// value is dropped when it duplicates label — see format-tree.ts hasContent comment
|
|
expect(result.description).toMatch(/AXTextField\s+"Search"\s+\(/);
|
|
expect(result.description).not.toMatch(/value="Search"/);
|
|
expect(result.description).toMatch(/AXButton\s+"General"/);
|
|
expect(result.description).toMatch(/AXButton\s+"Accessibility"/);
|
|
});
|
|
|
|
it("resolves ax-service with the correct URN", async () => {
|
|
const axApi = makeAXServiceApi({
|
|
alertVisible: false,
|
|
screenFrame: { width: 440, height: 956 },
|
|
elements: [
|
|
{
|
|
label: "Item",
|
|
frame: { x: 0.05, y: 0.3, width: 0.9, height: 0.05 },
|
|
traits: ["staticText"],
|
|
},
|
|
],
|
|
});
|
|
|
|
const registry = makeMockRegistry({ axService: axApi });
|
|
const tool = createDescribeTool(registry);
|
|
|
|
await tool.execute({}, { udid: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" });
|
|
expect(registry.resolveService).toHaveBeenCalledWith(
|
|
"AXService:BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB",
|
|
{
|
|
device: { id: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", platform: "ios", kind: "simulator" },
|
|
transport: "unix",
|
|
}
|
|
);
|
|
});
|
|
|
|
it("includes hint when ax-service is degraded (sim booted outside argent)", async () => {
|
|
const axApi = makeAXServiceApi(
|
|
{
|
|
alertVisible: false,
|
|
screenFrame: { width: 440, height: 956 },
|
|
elements: [
|
|
{
|
|
label: "General",
|
|
frame: { x: 0.045, y: 0.337, width: 0.909, height: 0.046 },
|
|
traits: ["button"],
|
|
},
|
|
],
|
|
},
|
|
{ degraded: true }
|
|
);
|
|
|
|
const registry = makeMockRegistry({ axService: axApi });
|
|
const tool = createDescribeTool(registry);
|
|
|
|
const result = await tool.execute({}, { udid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" });
|
|
expect(result.source).toBe("ax-service");
|
|
expect(result.hint).toMatch(/boot-device/);
|
|
expect(result.hint).toMatch(/system dialogs/i);
|
|
});
|
|
|
|
it("omits hint when ax-service is not degraded", async () => {
|
|
const axApi = makeAXServiceApi({
|
|
alertVisible: false,
|
|
screenFrame: { width: 440, height: 956 },
|
|
elements: [
|
|
{
|
|
label: "General",
|
|
frame: { x: 0.045, y: 0.337, width: 0.909, height: 0.046 },
|
|
traits: ["button"],
|
|
},
|
|
],
|
|
});
|
|
|
|
const registry = makeMockRegistry({ axService: axApi });
|
|
const tool = createDescribeTool(registry);
|
|
|
|
const result = await tool.execute({}, { udid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" });
|
|
expect(result.hint).toBeUndefined();
|
|
});
|
|
|
|
it("returns empty AX result when native queryViewHierarchy returns an error", async () => {
|
|
const axApi = makeAXServiceApi({
|
|
alertVisible: false,
|
|
elements: [],
|
|
});
|
|
|
|
const nativeApi = makeNativeDevtoolsApi({
|
|
connectedBundleIds: ["com.example.app"],
|
|
describeScreenResult: { error: "view hierarchy unavailable" },
|
|
});
|
|
|
|
const registry = makeMockRegistry({ axService: axApi, nativeDevtools: nativeApi });
|
|
const tool = createDescribeTool(registry);
|
|
|
|
const result = await tool.execute(
|
|
{},
|
|
{ udid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", bundleId: "com.example.app" }
|
|
);
|
|
expect(result.source).toBe("ax-service");
|
|
expect(elementLineCount(result.description)).toBe(0);
|
|
});
|
|
|
|
it("routes a tvOS target to the focus-driven view instead of the iOS ax-service", async () => {
|
|
mockIsTvOsSimulator.mockResolvedValue(true);
|
|
// The TV focus backend answers; the iOS ax-service must never be resolved
|
|
// for an Apple TV device.
|
|
const tvApi = {
|
|
describe: vi.fn().mockResolvedValue({
|
|
bundleId: "com.example.tv",
|
|
focused: { label: "Home", isFocused: true },
|
|
focusable: [{ label: "Home", isFocused: true }, { label: "Search" }],
|
|
}),
|
|
recycleAx: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
const registry = {
|
|
resolveService: vi.fn(async (urn: string) => {
|
|
if (urn.startsWith("TvControl:")) return tvApi;
|
|
throw new Error(`ax-service must not be resolved for tvOS: ${urn}`);
|
|
}),
|
|
} as any;
|
|
const tool = createDescribeTool(registry);
|
|
|
|
const result = await tool.execute({}, { udid: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD" });
|
|
|
|
expect(result.source).toBe("tv-focus");
|
|
expect(result.description).toContain("Focused: Home");
|
|
expect(result.description).toContain("Focusable (2):");
|
|
expect(result.hint).toBeUndefined();
|
|
// Resolved the TV control service, never the iOS ax-service.
|
|
expect(registry.resolveService).toHaveBeenCalledWith(
|
|
"TvControl:DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD",
|
|
expect.anything()
|
|
);
|
|
});
|
|
});
|