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)
552 lines
21 KiB
TypeScript
552 lines
21 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { FailureError, FAILURE_CODES, getFailureSignal } from "@argent/registry";
|
|
import {
|
|
isInjectableBundleId,
|
|
NON_INJECTABLE_NATIVE_WARNING,
|
|
NON_INJECTABLE_RECOVERY,
|
|
precheckNativeDevtools,
|
|
MAX_NATIVE_DEVTOOLS_INIT_ATTEMPTS,
|
|
type NativeDevtoolsApi,
|
|
type NativeDevtoolsInitFailure,
|
|
} from "../src/blueprints/native-devtools";
|
|
// Both tools gate `execute()` behind `ensureDeps(["xcrun"])`, but the
|
|
// restart-guidance / init_failed logic under test never shells out to xcrun.
|
|
// The real probe makes this pass only on a host with Xcode (dev macOS) and
|
|
// fail on the Linux CI runner with `missing: [xcrun]`. Stub the gate to a
|
|
// no-op so the test exercises the logic it's about, not the host's toolchain.
|
|
// Keep the rest of the module (DependencyMissingError, cache reset) intact.
|
|
vi.mock("../src/utils/check-deps", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../src/utils/check-deps")>();
|
|
return {
|
|
...actual,
|
|
ensureDeps: vi.fn(async () => {}),
|
|
ensureDep: vi.fn(async () => {}),
|
|
};
|
|
});
|
|
|
|
import { nativeDevtoolsStatusTool } from "../src/tools/native-devtools/native-devtools-status";
|
|
import { nativeDescribeScreenTool } from "../src/tools/native-devtools/native-describe-screen";
|
|
import { nativeFindViewsTool } from "../src/tools/native-devtools/native-find-views";
|
|
import { nativeFullHierarchyTool } from "../src/tools/native-devtools/native-full-hierarchy";
|
|
import { nativeNetworkLogsTool } from "../src/tools/native-devtools/native-network-logs";
|
|
import { nativeViewAtPointTool } from "../src/tools/native-devtools/native-view-at-point";
|
|
import { nativeUserInteractableViewAtPointTool } from "../src/tools/native-devtools/native-user-interactable-view-at-point";
|
|
|
|
function makeNativeApi(options: {
|
|
envSetup?: boolean;
|
|
connected?: boolean;
|
|
appRunning?: boolean;
|
|
initFailure?: NativeDevtoolsInitFailure | null;
|
|
}): {
|
|
api: NativeDevtoolsApi;
|
|
ensureEnvReady: ReturnType<typeof vi.fn>;
|
|
reverifyEnv: ReturnType<typeof vi.fn>;
|
|
} {
|
|
let envSetup = options.envSetup ?? false;
|
|
const ensureEnvReady = vi.fn(async () => {
|
|
envSetup = true;
|
|
});
|
|
const reverifyEnv = vi.fn(async () => {
|
|
envSetup = true;
|
|
});
|
|
|
|
return {
|
|
api: {
|
|
isEnvSetup: () => envSetup,
|
|
socketPath: "/tmp/mock.sock",
|
|
ensureEnvReady,
|
|
reverifyEnv,
|
|
getInitFailure: () => options.initFailure ?? null,
|
|
isConnected: () => options.connected ?? false,
|
|
isAppRunning: async () => options.appRunning ?? false,
|
|
listConnectedBundleIds: () => [],
|
|
requiresAppRestart: async () => {
|
|
throw new Error("native-devtools-status should compute restart guidance directly");
|
|
},
|
|
activateNetworkInspection: () => {},
|
|
getNetworkLog: () => [],
|
|
clearNetworkLog: () => {},
|
|
getAppState: async () => {
|
|
throw new Error("not implemented");
|
|
},
|
|
detectFrontmostBundleId: async () => null,
|
|
queryViewHierarchy: async () => ({}),
|
|
},
|
|
ensureEnvReady,
|
|
reverifyEnv,
|
|
};
|
|
}
|
|
|
|
describe("native-devtools-status tool", () => {
|
|
it("reports a running uninjected app as needing restart", async () => {
|
|
const { api, ensureEnvReady } = makeNativeApi({ appRunning: true, connected: false });
|
|
|
|
await expect(
|
|
nativeDevtoolsStatusTool.execute(
|
|
{ nativeDevtools: api },
|
|
{ udid: "11111111-1111-1111-1111-111111111111", bundleId: "com.example.app" }
|
|
)
|
|
).resolves.toEqual({
|
|
envSetup: true,
|
|
appRunning: true,
|
|
connected: false,
|
|
requiresRestart: true,
|
|
nextLaunchWillBeInjected: true,
|
|
injectable: true,
|
|
});
|
|
|
|
expect(ensureEnvReady).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("re-applies the env when the app is not connected (repairs a stale latch after a sim reboot)", async () => {
|
|
// envSetup:false models the cleared launchd state after an out-of-band
|
|
// reboot; reverifyEnv must run and bring it back to true.
|
|
const { api, reverifyEnv } = makeNativeApi({
|
|
appRunning: true,
|
|
connected: false,
|
|
envSetup: false,
|
|
});
|
|
|
|
await expect(
|
|
nativeDevtoolsStatusTool.execute(
|
|
{ nativeDevtools: api },
|
|
{ udid: "11111111-1111-1111-1111-111111111111", bundleId: "com.example.app" }
|
|
)
|
|
).resolves.toEqual({
|
|
envSetup: true,
|
|
appRunning: true,
|
|
connected: false,
|
|
requiresRestart: true,
|
|
nextLaunchWillBeInjected: true,
|
|
injectable: true,
|
|
});
|
|
|
|
expect(reverifyEnv).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("does not re-apply the env when the app is already connected", async () => {
|
|
const { api, reverifyEnv } = makeNativeApi({ appRunning: true, connected: true });
|
|
|
|
await expect(
|
|
nativeDevtoolsStatusTool.execute(
|
|
{ nativeDevtools: api },
|
|
{ udid: "11111111-1111-1111-1111-111111111111", bundleId: "com.example.app" }
|
|
)
|
|
).resolves.toEqual({
|
|
envSetup: true,
|
|
appRunning: true,
|
|
connected: true,
|
|
requiresRestart: false,
|
|
nextLaunchWillBeInjected: true,
|
|
injectable: true,
|
|
});
|
|
|
|
expect(reverifyEnv).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports a stopped app as launch-ready without requiring restart", async () => {
|
|
const { api } = makeNativeApi({ appRunning: false, connected: false });
|
|
|
|
await expect(
|
|
nativeDevtoolsStatusTool.execute(
|
|
{ nativeDevtools: api },
|
|
{ udid: "11111111-1111-1111-1111-111111111111", bundleId: "com.example.app" }
|
|
)
|
|
).resolves.toEqual({
|
|
envSetup: true,
|
|
appRunning: false,
|
|
connected: false,
|
|
requiresRestart: false,
|
|
nextLaunchWillBeInjected: true,
|
|
injectable: true,
|
|
});
|
|
});
|
|
|
|
it("reports a com.apple.* system app as a terminal, non-injectable state", async () => {
|
|
// Apple system apps can never load the dylib. Even with the app running and
|
|
// env set up, status must report injectable:false and neither require a
|
|
// restart nor promise the next launch will be injected — otherwise an agent
|
|
// loops restart-app → retry forever.
|
|
const { api, reverifyEnv } = makeNativeApi({
|
|
appRunning: true,
|
|
connected: false,
|
|
envSetup: true,
|
|
});
|
|
|
|
await expect(
|
|
nativeDevtoolsStatusTool.execute(
|
|
{ nativeDevtools: api },
|
|
{ udid: "11111111-1111-1111-1111-111111111111", bundleId: "com.apple.Preferences" }
|
|
)
|
|
).resolves.toEqual({
|
|
envSetup: true,
|
|
appRunning: true,
|
|
connected: false,
|
|
requiresRestart: false,
|
|
nextLaunchWillBeInjected: false,
|
|
injectable: false,
|
|
});
|
|
|
|
// A non-injectable app is terminal — there is nothing to repair, so the
|
|
// stale-latch reverify path must not run.
|
|
expect(reverifyEnv).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports the terminal non-injectable state even when env init has given up", async () => {
|
|
// The precheck's init_failed block must not mask the statically-knowable
|
|
// terminal signal: its "re-boot the simulator" guidance can never make a
|
|
// system app injectable. Mirrors the same ordering inside
|
|
// precheckNativeDevtools (terminal case before the env plumbing).
|
|
const { api, ensureEnvReady } = makeNativeApi({
|
|
appRunning: true,
|
|
initFailure: {
|
|
attempts: MAX_NATIVE_DEVTOOLS_INIT_ATTEMPTS,
|
|
lastError: "ensureEnv timeout",
|
|
givenUp: true,
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
nativeDevtoolsStatusTool.execute(
|
|
{ nativeDevtools: api },
|
|
{ udid: "11111111-1111-1111-1111-111111111111", bundleId: "com.apple.Preferences" }
|
|
)
|
|
).resolves.toEqual({
|
|
envSetup: false,
|
|
appRunning: true,
|
|
connected: false,
|
|
requiresRestart: false,
|
|
nextLaunchWillBeInjected: false,
|
|
injectable: false,
|
|
});
|
|
|
|
// No env work is spent on an app that can never inject.
|
|
expect(ensureEnvReady).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to init_failed when the sim cannot even be probed (dead sim, broken env)", async () => {
|
|
// The terminal branch probes isAppRunning (a simctl spawn); on a shut-down
|
|
// or unreachable sim that rejects — exactly the sims where env init fails
|
|
// too. A raw subprocess throw here would be unstructured; the precheck's
|
|
// init_failed guidance (re-boot the simulator) IS corrective for a dead
|
|
// sim, so it must win when the env is broken.
|
|
const { api } = makeNativeApi({
|
|
initFailure: {
|
|
attempts: MAX_NATIVE_DEVTOOLS_INIT_ATTEMPTS,
|
|
lastError: "ensureEnv timeout",
|
|
givenUp: true,
|
|
},
|
|
});
|
|
api.isAppRunning = async () => {
|
|
throw new Error("simctl spawn failed: current state: Shutdown");
|
|
};
|
|
|
|
await expect(
|
|
nativeDevtoolsStatusTool.execute(
|
|
{ nativeDevtools: api },
|
|
{ udid: "11111111-1111-1111-1111-111111111111", bundleId: "com.apple.Preferences" }
|
|
)
|
|
).resolves.toMatchObject({ status: "init_failed" });
|
|
});
|
|
|
|
it("rethrows the probe failure for a non-injectable app when the env is healthy", async () => {
|
|
// With a healthy env there is no init_failed to fall back to — a transient
|
|
// isAppRunning failure must surface, not be swallowed into a made-up state.
|
|
const { api } = makeNativeApi({ envSetup: true });
|
|
api.isAppRunning = async () => {
|
|
throw new Error("transient simctl failure");
|
|
};
|
|
|
|
await expect(
|
|
nativeDevtoolsStatusTool.execute(
|
|
{ nativeDevtools: api },
|
|
{ udid: "11111111-1111-1111-1111-111111111111", bundleId: "com.apple.Preferences" }
|
|
)
|
|
).rejects.toThrow("transient simctl failure");
|
|
});
|
|
});
|
|
|
|
describe("isInjectableBundleId", () => {
|
|
it("treats com.apple.* system apps as non-injectable", () => {
|
|
expect(isInjectableBundleId("com.apple.Preferences")).toBe(false);
|
|
expect(isInjectableBundleId("com.apple.mobilesafari")).toBe(false);
|
|
// Matched case-insensitively: iOS treats bundle ids case-insensitively and
|
|
// Apple owns the com.apple namespace in every casing, so a stray mixed-case
|
|
// id must not slip through as injectable and drop the agent into a restart loop.
|
|
expect(isInjectableBundleId("com.Apple.Preferences")).toBe(false);
|
|
expect(isInjectableBundleId("COM.APPLE.PREFERENCES")).toBe(false);
|
|
});
|
|
|
|
it("treats third-party apps as injectable", () => {
|
|
expect(isInjectableBundleId("com.example.MyApp")).toBe(true);
|
|
expect(isInjectableBundleId("com.latekvo.pokemon")).toBe(true);
|
|
// Prefix match is exact — a lookalike that only contains the substring is
|
|
// still injectable.
|
|
expect(isInjectableBundleId("com.appleseed.App")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("precheckNativeDevtools — non-injectable terminal error", () => {
|
|
const UDID = "33333333-3333-3333-3333-333333333333";
|
|
|
|
it("throws NATIVE_DEVTOOLS_NOT_INJECTABLE for a com.apple.* bundle (3-arg)", async () => {
|
|
const { api } = makeNativeApi({ appRunning: true, connected: false });
|
|
|
|
await expect(precheckNativeDevtools(api, UDID, "com.apple.Preferences")).rejects.toBeInstanceOf(
|
|
FailureError
|
|
);
|
|
|
|
try {
|
|
await precheckNativeDevtools(api, UDID, "com.apple.Preferences");
|
|
throw new Error("expected precheckNativeDevtools to throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(FailureError);
|
|
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.NATIVE_DEVTOOLS_NOT_INJECTABLE);
|
|
}
|
|
});
|
|
|
|
it("does not throw for the same api via the 2-arg overload (status / launch-app / restart-app path)", async () => {
|
|
const { api } = makeNativeApi({ appRunning: true, connected: false });
|
|
|
|
await expect(precheckNativeDevtools(api, UDID)).resolves.toBeNull();
|
|
});
|
|
|
|
it("throws the terminal error even when env init has given up", async () => {
|
|
// Injectability is a static property of the bundle id — a broken env must
|
|
// not mask the terminal signal behind init_failed's "re-boot the simulator"
|
|
// guidance, which can never make a system app injectable.
|
|
const { api } = makeNativeApi({
|
|
initFailure: {
|
|
attempts: MAX_NATIVE_DEVTOOLS_INIT_ATTEMPTS,
|
|
lastError: "ensureEnv timeout",
|
|
givenUp: true,
|
|
},
|
|
});
|
|
|
|
try {
|
|
await precheckNativeDevtools(api, UDID, "com.apple.Preferences");
|
|
throw new Error("expected precheckNativeDevtools to throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(FailureError);
|
|
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.NATIVE_DEVTOOLS_NOT_INJECTABLE);
|
|
}
|
|
});
|
|
|
|
it("fires before any env work — ensureEnvReady never runs for a non-injectable bundle", async () => {
|
|
// Same ordering guarantee from the other side: no env-setup work is spent
|
|
// on an app that can never load the dylib, and a transiently failing
|
|
// ensureEnvReady cannot swallow the terminal signal.
|
|
const { api } = makeNativeApi({
|
|
initFailure: { attempts: 1, lastError: "first attempt failed", givenUp: false },
|
|
});
|
|
const ensureEnvReady = vi.fn(async () => {
|
|
throw new Error("transient ensureEnv failure");
|
|
});
|
|
api.ensureEnvReady = ensureEnvReady;
|
|
|
|
try {
|
|
await precheckNativeDevtools(api, UDID, "com.apple.Preferences");
|
|
throw new Error("expected precheckNativeDevtools to throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(FailureError);
|
|
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.NATIVE_DEVTOOLS_NOT_INJECTABLE);
|
|
}
|
|
expect(ensureEnvReady).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("non-injectable recovery guidance is consistent and points only at working tools", () => {
|
|
const UDID = "55555555-5555-5555-5555-555555555555";
|
|
|
|
it("recommends describe and screenshot but only warns the agent OFF the native-* tools", () => {
|
|
// The recovery must send the agent to tools that actually work on a system
|
|
// app; the view-at-point tools re-run this same precheck and re-throw, so
|
|
// recommending them dead-ends. describe/screenshot are recommended, and the
|
|
// view-at-point tools appear only inside the "do not fall back" warning.
|
|
expect(NON_INJECTABLE_RECOVERY).toMatch(/`describe`/);
|
|
expect(NON_INJECTABLE_RECOVERY).toMatch(/`screenshot`/);
|
|
expect(NON_INJECTABLE_RECOVERY).toContain(NON_INJECTABLE_NATIVE_WARNING);
|
|
expect(NON_INJECTABLE_NATIVE_WARNING).toMatch(
|
|
/Do not fall back to the native-devtools feature tools/
|
|
);
|
|
expect(NON_INJECTABLE_NATIVE_WARNING).toContain("native-view-at-point");
|
|
// The recommendation clause itself never names a native-* tool outside the
|
|
// warning, so nothing points the agent back at a dead-end.
|
|
const recommendationOnly = NON_INJECTABLE_RECOVERY.replace(NON_INJECTABLE_NATIVE_WARNING, "");
|
|
expect(recommendationOnly).not.toContain("native-");
|
|
});
|
|
|
|
it("the precheck throw and the status description share the recovery guidance verbatim", async () => {
|
|
// The precheck throw, the status description, and the describe fallback hint
|
|
// used to recommend different tool sets. They now share the dead-end warning
|
|
// verbatim, so no surface can drift into recommending a native-* tool. This
|
|
// test covers the two pre-describe surfaces, which additionally share the
|
|
// full describe/screenshot recommendation; the third surface (the describe
|
|
// fallback hint) is asserted in describe-tool.test.ts.
|
|
expect(nativeDevtoolsStatusTool.description).toContain(NON_INJECTABLE_RECOVERY);
|
|
|
|
let message = "";
|
|
try {
|
|
await precheckNativeDevtools(makeNativeApi({}).api, UDID, "com.apple.Preferences");
|
|
} catch (err) {
|
|
message = (err as Error).message;
|
|
}
|
|
expect(message).toContain(NON_INJECTABLE_RECOVERY);
|
|
expect(message).toContain(NON_INJECTABLE_NATIVE_WARNING);
|
|
});
|
|
});
|
|
|
|
describe("native-* feature tools — the non-injectable throw propagates out of execute()", () => {
|
|
// The NATIVE_DEVTOOLS_NOT_INJECTABLE guard lives only in the shared precheck;
|
|
// every 3-arg feature tool relies on that throw propagating straight out of
|
|
// its execute() (none wraps the precheck in a try/catch). The precheck-level
|
|
// unit above proves the precheck throws, but not that each tool surfaces it —
|
|
// a later refactor that swallowed the throw inside a tool would leave that
|
|
// unit green while regressing the terminal behavior. Assert it at every tool
|
|
// boundary so that regression can't slip through.
|
|
const U = "44444444-4444-4444-4444-444444444444";
|
|
const SYSTEM_APP = "com.apple.Preferences";
|
|
// The non-injectable throw fires in the precheck before appRunning/connected/
|
|
// requiresAppRestart are ever consulted, so the mock's device state is inert
|
|
// here — default it so nothing reads as if the restart logic were exercised.
|
|
const mkApi = () => makeNativeApi({}).api;
|
|
|
|
async function expectNotInjectableThrow(run: () => Promise<unknown>): Promise<void> {
|
|
let caught: unknown;
|
|
try {
|
|
await run();
|
|
} catch (err) {
|
|
caught = err;
|
|
}
|
|
expect(caught).toBeInstanceOf(FailureError);
|
|
expect(getFailureSignal(caught)?.error_code).toBe(FAILURE_CODES.NATIVE_DEVTOOLS_NOT_INJECTABLE);
|
|
}
|
|
|
|
it("native-describe-screen surfaces NATIVE_DEVTOOLS_NOT_INJECTABLE", () =>
|
|
expectNotInjectableThrow(() =>
|
|
nativeDescribeScreenTool.execute(
|
|
{ nativeDevtools: mkApi() },
|
|
{ udid: U, bundleId: SYSTEM_APP }
|
|
)
|
|
));
|
|
|
|
it("native-find-views surfaces NATIVE_DEVTOOLS_NOT_INJECTABLE", () =>
|
|
expectNotInjectableThrow(() =>
|
|
nativeFindViewsTool.execute({ nativeDevtools: mkApi() }, { udid: U, bundleId: SYSTEM_APP })
|
|
));
|
|
|
|
it("native-full-hierarchy surfaces NATIVE_DEVTOOLS_NOT_INJECTABLE", () =>
|
|
expectNotInjectableThrow(() =>
|
|
nativeFullHierarchyTool.execute(
|
|
{ nativeDevtools: mkApi() },
|
|
{ udid: U, bundleId: SYSTEM_APP }
|
|
)
|
|
));
|
|
|
|
it("native-network-logs surfaces NATIVE_DEVTOOLS_NOT_INJECTABLE", () =>
|
|
expectNotInjectableThrow(() =>
|
|
nativeNetworkLogsTool.execute(
|
|
{ nativeDevtools: mkApi() },
|
|
{ udid: U, bundleId: SYSTEM_APP, limit: 50, clear: false }
|
|
)
|
|
));
|
|
|
|
it("native-view-at-point surfaces NATIVE_DEVTOOLS_NOT_INJECTABLE", () =>
|
|
expectNotInjectableThrow(() =>
|
|
nativeViewAtPointTool.execute(
|
|
{ nativeDevtools: mkApi() },
|
|
{ udid: U, bundleId: SYSTEM_APP, x: 0, y: 0 }
|
|
)
|
|
));
|
|
|
|
it("native-user-interactable-view-at-point surfaces NATIVE_DEVTOOLS_NOT_INJECTABLE", () =>
|
|
expectNotInjectableThrow(() =>
|
|
nativeUserInteractableViewAtPointTool.execute(
|
|
{ nativeDevtools: mkApi() },
|
|
{ udid: U, bundleId: SYSTEM_APP, x: 0, y: 0 }
|
|
)
|
|
));
|
|
});
|
|
|
|
describe("native-devtools tools — init_failed precheck", () => {
|
|
const FAILED_UDID = "22222222-2222-2222-2222-222222222222";
|
|
|
|
it("native-describe-screen returns init_failed when the api reports givenUp", async () => {
|
|
const { api } = makeNativeApi({
|
|
initFailure: {
|
|
attempts: MAX_NATIVE_DEVTOOLS_INIT_ATTEMPTS,
|
|
lastError: "ensureEnv timeout",
|
|
givenUp: true,
|
|
},
|
|
});
|
|
|
|
const result = await nativeDescribeScreenTool.execute(
|
|
{ nativeDevtools: api },
|
|
{ udid: FAILED_UDID, bundleId: "com.example.app" }
|
|
);
|
|
expect(result).toMatchObject({
|
|
status: "init_failed",
|
|
attempts: MAX_NATIVE_DEVTOOLS_INIT_ATTEMPTS,
|
|
});
|
|
if (result.status === "init_failed") {
|
|
expect(result.message).toContain(FAILED_UDID);
|
|
expect(result.message).toContain("ensureEnv timeout");
|
|
}
|
|
});
|
|
|
|
it("native-describe-screen proceeds normally below the cap", async () => {
|
|
const { api } = makeNativeApi({
|
|
initFailure: {
|
|
attempts: MAX_NATIVE_DEVTOOLS_INIT_ATTEMPTS - 1,
|
|
lastError: "transient",
|
|
givenUp: false,
|
|
},
|
|
});
|
|
api.requiresAppRestart = async () => true;
|
|
|
|
const result = await nativeDescribeScreenTool.execute(
|
|
{ nativeDevtools: api },
|
|
{ udid: FAILED_UDID, bundleId: "com.example.app" }
|
|
);
|
|
expect(result).toMatchObject({ status: "restart_required" });
|
|
});
|
|
|
|
it("native-devtools-status returns init_failed when the api reports givenUp", async () => {
|
|
const { api } = makeNativeApi({
|
|
initFailure: {
|
|
attempts: MAX_NATIVE_DEVTOOLS_INIT_ATTEMPTS,
|
|
lastError: "simctl spawn timed out",
|
|
givenUp: true,
|
|
},
|
|
});
|
|
|
|
const result = await nativeDevtoolsStatusTool.execute(
|
|
{ nativeDevtools: api },
|
|
{ udid: FAILED_UDID, bundleId: "com.example.app" }
|
|
);
|
|
expect(result).toMatchObject({ status: "init_failed" });
|
|
});
|
|
|
|
it("converts a transient ensureEnvReady throw into init_failed (fail-fast)", async () => {
|
|
const { api } = makeNativeApi({
|
|
initFailure: {
|
|
attempts: 1,
|
|
lastError: "first attempt failed",
|
|
givenUp: false,
|
|
},
|
|
});
|
|
api.ensureEnvReady = async () => {
|
|
throw new Error("transient ensureEnv failure");
|
|
};
|
|
|
|
const result = await nativeDescribeScreenTool.execute(
|
|
{ nativeDevtools: api },
|
|
{ udid: FAILED_UDID, bundleId: "com.example.app" }
|
|
);
|
|
expect(result).toMatchObject({ status: "init_failed", attempts: 1 });
|
|
if (result.status === "init_failed") {
|
|
expect(result.message).toContain(FAILED_UDID);
|
|
}
|
|
});
|
|
});
|