Files
software-mansion__argent/packages/tool-server/test/native-devtools-status.test.ts
Mateusz Aliyev 6e9e45c9f0 feat: attach to devices offered by external providers (#735)
This pull request lets Argent drive a simulator or emulator another
process is already running, attaching to its simulator-server rather
than spawning a second one.

A provider writes a JSON descriptor to `~/.argent/providers/` listing
the devices it offers and the mechanisms Argent may use on each. They
appear in `list-devices` with an `ext:` id and work with the existing
tools. The file is re-read on every call, so a withdrawal or a narrowed
grant applies immediately.

`ios.additionalDeviceSets` (#600) already makes such a simulator
reachable by UDID. What changes is ownership. `boot-device` and
`stop-simulator-server` refuse these devices, anything the provider did
not grant is refused with a message naming it and Argent uses only the
endpoints its own simulator-server build serves. A grant binds to the
device rather than to one of its names, so the real udid or serial is
gated exactly like the `ext:` id. Android emulators are covered too and
a device visible to both a provider and `adb`/`simctl` is listed once.

The contract ships as `schemas/device-provider-v1.json`, validated by
`argent providers check`, and is documented in
`docs/reference/device-providers.mdx`. Includes unit tests and an e2e
phase where the harness acts as its own provider.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for discovering and connecting to externally managed
devices.
* Added `argent providers` commands for listing, validating, publishing,
withdrawing, and pruning providers.
* Added provider-aware simulator, debugger, profiler, native tools, and
device listings.
* Added capability controls, endpoint validation, revocation handling,
and provider-specific diagnostics.
  * Added automatic CLI discovery for provider integrations.
* **Bug Fixes**
  * Improved service recovery and paused-runtime error reporting.
  * Improved simulator keyboard and paste command reliability.
* **Documentation**
* Documented provider descriptors, capabilities, lifecycle rules, and
CLI usage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-04 22:02:57 +02:00

1540 lines
68 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { FailureError, FAILURE_CODES, getFailureSignal } from "@argent/registry";
import {
buildAppStateMessage,
isInjectableBundleId,
NON_INJECTABLE_NATIVE_WARNING,
NON_INJECTABLE_RECOVERY,
precheckNativeDevtools,
MAX_NATIVE_DEVTOOLS_INIT_ATTEMPTS,
type NativeDevtoolsApi,
type NativeDevtoolsAppState,
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 { flowLaunchGateReason } from "../src/tools/flows/flow-run";
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;
state?: NativeDevtoolsAppState;
}): {
api: NativeDevtoolsApi;
ensureEnvReady: ReturnType<typeof vi.fn>;
reverifyEnv: ReturnType<typeof vi.fn>;
isAppRunning: ReturnType<typeof vi.fn>;
} {
let envSetup = options.envSetup ?? false;
const ensureEnvReady = vi.fn(async () => {
envSetup = true;
});
const reverifyEnv = vi.fn(async () => {
envSetup = true;
});
const isAppRunning = vi.fn(async () => options.appRunning ?? false);
return {
api: {
isEnvSetup: () => envSetup,
socketPath: "/tmp/mock.sock",
ensureEnvReady,
reverifyEnv,
armsEnv: true,
withdrawEnv: async () => {},
getInitFailure: () => options.initFailure ?? null,
isConnected: () => options.connected ?? false,
isAppRunning,
listConnectedBundleIds: () => [],
appConnectionState: async () => {
if (options.connected) return "connected";
// Mirrors the real API: the unconnected path re-applies the launchd env
// before it judges the process, so callers can still pin that repair.
await reverifyEnv();
return options.state ?? (options.appRunning ? "stale_process" : "not_running");
},
activateNetworkInspection: () => {},
getNetworkLog: () => [],
clearNetworkLog: () => {},
getAppState: async () => {
throw new Error("not implemented");
},
detectFrontmostBundleId: async () => null,
queryViewHierarchy: async () => ({}),
},
ensureEnvReady,
reverifyEnv,
isAppRunning,
};
}
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,
state: "stale_process",
message: expect.stringContaining("restart-app") as string,
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,
state: "stale_process",
message: expect.stringContaining("restart-app") as string,
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,
state: "connected",
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,
state: "not_running",
message: expect.stringContaining("launch-app") as string,
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" });
});
// The twin of the injectable path's escalation, on the branch that runs no env
// work of its own: once `ensureEnvReady` latches, a failure recorded since is
// the only witness left that the sim is gone — and one sim state must not read
// as a dead sim on one bundle id and a raw `simctl spawn` throw on another.
it("surfaces a recorded env failure for a non-injectable app on a latched env", async () => {
const { api } = makeNativeApi({
envSetup: true,
initFailure: { attempts: 1, lastError: "Invalid device", givenUp: false },
});
api.isAppRunning = async () => {
throw new Error("simctl spawn failed: Invalid device");
};
await expect(
nativeDevtoolsStatusTool.execute(
{ nativeDevtools: api },
{ udid: "11111111-1111-1111-1111-111111111111", bundleId: "com.apple.Preferences" }
)
).resolves.toMatchObject({ status: "init_failed", attempts: 1 });
});
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/
// appConnectionState 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.appConnectionState = async () => "stale_process";
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);
}
});
});
// A status asserted for every unconnected app would keep "call restart-app"
// literally true forever, and an agent obeying it restarts indefinitely. Each
// measured state has to reach the agent as the action that actually applies.
describe("precheckNativeDevtools maps a measured state to its remedy", () => {
function precheckWith(state: NativeDevtoolsAppState) {
const { api } = makeNativeApi({ envSetup: true, appRunning: true, state });
return precheckNativeDevtools(api, "UDID", "com.example.app");
}
it("leads with restart-app for a process that predates the injection", async () => {
const result = await precheckWith("stale_process");
expect(result).toMatchObject({ status: "restart_required" });
const message = (result as { message: string }).message;
expect(message).toContain("restart-app");
// The escalation belongs to states we could not measure; naming it here puts
// "restart the tool-server" in front of an app that just needs a relaunch.
expect(message).not.toContain("argent server stop");
});
it("does not tell a stale_process it is uninjected — one route into it is injected", async () => {
// `stale_process` is reached both by a process carrying no argent injection
// and by an injected one older than this service's listener, so "not
// injected" is false on the second — which is where the `unregistered`
// remedy lands an agent: a tool-server restart rebinds the same socket path
// under a fresh `listeningSince`.
const result = await precheckWith("stale_process");
const message = (result as { message: string }).message;
expect(message).not.toContain("not injected");
expect(message).toContain("cannot reach");
expect(message).toContain("restart-app");
});
it("points a stopped app at launch-app rather than a restart", async () => {
const result = await precheckWith("not_running");
expect(result).toMatchObject({ status: "restart_required" });
expect((result as { message: string }).message).toContain("launch-app");
});
it("refuses to call an injected-but-unregistered process a restart_required", async () => {
// The loop this whole derivation exists to break: the process already
// launched under exactly the terms restart-app would recreate.
const result = await precheckWith("unregistered");
expect(result).toMatchObject({ status: "service_stale" });
const message = (result as { message: string }).message;
expect(message).toContain("argent server stop && argent server start --detach");
expect(message).toContain("Restarting the app cannot change that");
expect(message).not.toContain("restart-app");
});
it("refuses to call a still-connecting process a restart_required", async () => {
// The other self-perpetuating advice: exec starts the dial, so a relaunch
// discards the handshake AND resets the age this verdict reads. Not
// `indeterminate` either — the process WAS inspected.
const result = await precheckWith("connecting");
expect(result).toMatchObject({ status: "connect_pending" });
const message = (result as { message: string }).message;
expect(message).toContain("Wait a few seconds");
// The prohibition itself, not just the absence of the hyphenated tool name:
// "wait a few seconds, then relaunch the app and retry" satisfies every
// other assertion here while prescribing the one action that resets the age
// this verdict reads. The sibling `unregistered` case above pins its
// prohibition the same way.
//
// Matched with its colon, so it is the UNQUALIFIED form: this state is the
// one where not even a single relaunch is right — it discards a handshake
// that is already in flight — and "Do NOT restart the app more than once"
// permits exactly that while still containing the bare phrase.
expect(message).toContain("Do NOT restart the app:");
// And the reason, so the prohibition cannot be left standing without the
// fact that makes it true.
expect(message).toContain("a relaunch discards the one in progress");
expect(message).not.toContain("restart-app");
expect(message).not.toContain("could not be inspected");
});
it("keeps the loop warning for a process it could not inspect", async () => {
// ios-remote and an unreadable process table land here: restart-app is
// still the right first move, but nothing measured says it will work, so
// the way out of the loop has to travel with it.
const result = await precheckWith("indeterminate");
expect(result).toMatchObject({ status: "restart_required" });
const message = (result as { message: string }).message;
expect(message).toContain("restart-app");
expect(message).toContain("argent server stop && argent server start --detach");
expect(message).toContain("do not keep restarting the app");
});
// `argent server start` defaults to the foreground and never returns, so the
// bare pair hangs whoever runs it. Every surface prescribing the tool-server
// restart must pass --detach.
it("prescribes a tool-server restart that actually returns", async () => {
for (const state of ["unregistered", "indeterminate"] as const) {
const message = (await precheckWith(state)) as { message: string };
expect(message.message, state).toContain("argent server start --detach");
}
});
// Any expression that admits `unregistered` or `connecting` — including the
// obvious `appRunning && !connected` — leaves the boolean saying "restart the
// app" beside a message saying a restart cannot help, and the description
// tells agents to branch on the boolean.
it("never asks for a restart of an app a restart provably cannot fix", async () => {
for (const state of ["unregistered", "connecting"] as const) {
const { api } = makeNativeApi({ envSetup: true, appRunning: true, state });
const result = (await nativeDevtoolsStatusTool.execute(
{ nativeDevtools: api },
{ udid: "UDID", bundleId: "com.example.app" }
)) as { requiresRestart: boolean; state: string };
expect(result.requiresRestart, `${state} must not require a restart`).toBe(false);
expect(result.state).toBe(state);
}
});
// A sim that dies mid-call makes the measurement reject. Without the guard the
// raw subprocess error escapes execute() instead of the structured answer.
it("degrades a rejected measurement instead of letting it escape the tool", async () => {
const { api } = makeNativeApi({ envSetup: true, appRunning: true });
api.appConnectionState = async () => {
throw new Error("Invalid device: UDID");
};
const result = (await nativeDevtoolsStatusTool.execute(
{ nativeDevtools: api },
{ udid: "UDID", bundleId: "com.example.app" }
)) as { state?: string; message?: string };
expect(result.state).toBe("indeterminate");
expect(result.message).toContain("do not keep restarting the app");
});
// `connected` has to come off the same measurement as `state`, not a second
// `isConnected()` read: a connection landing between the two would pair
// `connected: true` with a state saying the service never registered it.
it("reads connected from the one measurement, not a second probe", async () => {
const { api } = makeNativeApi({ envSetup: true, appRunning: true, state: "unregistered" });
api.isConnected = () => true; // the map moved on after the measurement began
const result = (await nativeDevtoolsStatusTool.execute(
{ nativeDevtools: api },
{ udid: "UDID", bundleId: "com.example.app" }
)) as { connected: boolean; state: string };
expect(result.connected).toBe(false);
expect(result.state).toBe("unregistered");
});
// The precheck is the path all six native-* feature tools take. A rejection
// here (the env re-apply, on a sim that went away) must not reach the agent as
// a raw subprocess error — and must never be swallowed as "connected", which
// would let all six proceed to query a connection that does not exist.
it("degrades a rejected measurement rather than letting it escape or pass", async () => {
const { api } = makeNativeApi({ envSetup: true, appRunning: true });
api.appConnectionState = async () => {
throw new Error("Invalid device: UDID");
};
const result = await precheckNativeDevtools(api, "UDID", "com.example.app");
expect(result).not.toBeNull();
expect(result).toMatchObject({ status: "restart_required" });
expect((result as { message: string }).message).toContain("do not keep restarting the app");
});
// When that same rejection came with a recorded env failure, the sim itself is
// gone — which is init_failed's case, not a connection diagnosis.
it("reports a dead sim as init_failed rather than an unmeasured connection", async () => {
const { api } = makeNativeApi({ envSetup: true, appRunning: true });
api.getInitFailure = () => ({ attempts: 2, lastError: "Invalid device", givenUp: false });
api.appConnectionState = async () => {
throw new Error("Invalid device: UDID");
};
const result = await precheckNativeDevtools(api, "UDID", "com.example.app");
expect(result).toMatchObject({ status: "init_failed", attempts: 2 });
});
it("passes a connected app straight through", async () => {
await expect(precheckWith("connected")).resolves.toBeNull();
});
it("reads running-ness out of the state instead of a second device probe", async () => {
// `appConnectionState` already runs `launchctl list`, and re-verifies the
// env first — so a separate `isAppRunning` is both an extra round-trip and a
// snapshot taken seconds apart, which is how `appRunning: true` could land
// beside `state: "not_running"`. Only `indeterminate` may pay for a probe.
for (const state of [
"connected",
"not_running",
"stale_process",
"unregistered",
"connecting",
] as const) {
const { api, isAppRunning } = makeNativeApi({ envSetup: true, connected: false, state });
const result = (await nativeDevtoolsStatusTool.execute(
{ nativeDevtools: api },
{ udid: "UDID", bundleId: "com.example.app" }
)) as { appRunning: boolean; state: string };
expect(isAppRunning, `${state} must not re-probe the device`).not.toHaveBeenCalled();
expect(result.state).toBe(state);
expect(result.appRunning).toBe(state !== "not_running");
}
const { api, isAppRunning } = makeNativeApi({
envSetup: true,
connected: false,
appRunning: true,
state: "indeterminate",
});
const result = (await nativeDevtoolsStatusTool.execute(
{ nativeDevtools: api },
{ udid: "UDID", bundleId: "com.example.app" }
)) as { appRunning: boolean; requiresRestart: boolean };
expect(isAppRunning).toHaveBeenCalledTimes(1);
expect(result.appRunning).toBe(true);
expect(result.requiresRestart).toBe(true);
});
// Without this pin, collapsing the mapping back to a single status leaves
// every precheck-level test green while the agent silently gets restart-app
// guidance again.
it("carries service_stale out through every native-* tool", async () => {
const tools = [
nativeDescribeScreenTool,
nativeFindViewsTool,
nativeFullHierarchyTool,
nativeNetworkLogsTool,
nativeViewAtPointTool,
nativeUserInteractableViewAtPointTool,
];
for (const tool of tools) {
const { api } = makeNativeApi({
envSetup: true,
appRunning: true,
state: "unregistered",
});
const result = (await tool.execute(
{ nativeDevtools: api },
{
udid: "UDID",
bundleId: "com.example.app",
x: 1,
y: 1,
className: "UIView",
limit: 1,
clear: false,
}
)) as { status: string; message: string };
expect(result.status, `${tool.id} must report service_stale`).toBe("service_stale");
expect(result.message).toContain("argent server stop && argent server start --detach");
expect(result.message).not.toContain("restart-app");
}
});
// The booleans cannot say "one restart, then stop". `requiresRestart: true` is
// the whole of what an `indeterminate` app reports, and the only state a
// running app reaches on ios-remote — so without the prose the tool hands back
// the restart loop and nothing else.
it("hands back the loop escape for the states the booleans cannot express", async () => {
for (const state of ["indeterminate", "stale_process", "not_running"] as const) {
const { api } = makeNativeApi({ envSetup: true, appRunning: true, state });
const result = (await nativeDevtoolsStatusTool.execute(
{ nativeDevtools: api },
{ udid: "UDID", bundleId: "com.example.app" }
)) as { message?: string };
expect(result.message, `${state} must carry its remedy`).toBeDefined();
expect(result.message).toContain("com.example.app");
}
const { api: indeterminate } = makeNativeApi({
envSetup: true,
appRunning: true,
state: "indeterminate",
});
const escape = (await nativeDevtoolsStatusTool.execute(
{ nativeDevtools: indeterminate },
{ udid: "UDID", bundleId: "com.example.app" }
)) as { message?: string; requiresRestart: boolean };
expect(escape.requiresRestart).toBe(true);
expect(escape.message).toContain("do not keep restarting the app");
expect(escape.message).toContain("argent server stop && argent server start --detach");
// A connected app has no remedy to carry, so the field must not appear at
// all — an empty-string or stale message would read as a live problem.
const { api: healthy } = makeNativeApi({ envSetup: true, connected: true });
const ok = (await nativeDevtoolsStatusTool.execute(
{ nativeDevtools: healthy },
{ udid: "UDID", bundleId: "com.example.app" }
)) as Record<string, unknown>;
expect(ok).not.toHaveProperty("message");
});
// Telling an agent to restart something the same payload says is not running
// is the contradiction deriving both from one measurement prevents. Only
// `indeterminate` re-probes, so only it can produce the pair.
it("does not ask for a restart of an app the probe says is not running", async () => {
const { api } = makeNativeApi({
envSetup: true,
appRunning: false,
state: "indeterminate",
});
const result = (await nativeDevtoolsStatusTool.execute(
{ nativeDevtools: api },
{ udid: "UDID", bundleId: "com.example.app" }
)) as { appRunning: boolean; requiresRestart: boolean };
expect(result.appRunning).toBe(false);
expect(result.requiresRestart).toBe(false);
// `message` is the field the description tells agents to prefer, so it has
// to agree too: the `indeterminate` text opens "Call restart-app then
// retry", which contradicts `appRunning: false` outright.
const withMessage = result as unknown as { state: string; message: string };
expect(withMessage.state).toBe("not_running");
expect(withMessage.message).toContain("launch-app");
expect(withMessage.message).not.toContain("could not be inspected");
});
// The dead-sim escalation has to fire on the FIRST failure, not the third.
// Re-running the precheck would report nothing — `ensureEnvReady` succeeded at
// the top of the call and latches — so reading the failure `reverifyEnv` just
// recorded is what turns a shut-down simulator into structured guidance
// instead of a raw `Command failed: xcrun simctl spawn …`.
it("surfaces init_failed on a dead sim before the service has given up", async () => {
const { api } = makeNativeApi({ envSetup: true, state: "indeterminate" });
api.getInitFailure = () => ({ attempts: 1, lastError: "Invalid device", givenUp: false });
api.isAppRunning = vi.fn(async () => {
throw new Error("Invalid device: UDID");
});
const result = (await nativeDevtoolsStatusTool.execute(
{ nativeDevtools: api },
{ udid: "UDID", bundleId: "com.example.app" }
)) as { status?: string; message?: string; attempts?: number };
expect(result.status).toBe("init_failed");
expect(result.attempts).toBe(1);
expect(result.message).toContain("Invalid device");
});
// These are the two fields an agent reads to decide a launch is worth it, and
// `isEnvSetup()` is a latch that never clears — so on a simulator whose
// launchd env was wiped it keeps answering yes, and the agent relaunches into
// an uninjected process forever. A failure the measurement's re-apply recorded
// is the one thing that contradicts the latch.
it("stops promising an injected launch once the env re-apply has failed", async () => {
const { api } = makeNativeApi({ envSetup: true, appRunning: true, state: "stale_process" });
api.getInitFailure = () => ({ attempts: 1, lastError: "Invalid device", givenUp: false });
const result = (await nativeDevtoolsStatusTool.execute(
{ nativeDevtools: api },
{ udid: "UDID", bundleId: "com.example.app" }
)) as { envSetup: boolean; nextLaunchWillBeInjected: boolean };
expect(result.envSetup).toBe(false);
expect(result.nextLaunchWillBeInjected).toBe(false);
});
// The other half of that reading, and the reason it is conditional. The
// recorded failure only contradicts the latch where something re-applied the
// env to record it — `appConnectionState` does that for an app it finds
// UNCONNECTED. A connected one answers off the connections map first, so the
// record beside it is whatever an earlier call for another bundle left, and
// nothing re-tests it while this app stays connected: the reading would be
// wrong for as long as the app is up, against a process that is demonstrably
// injected.
it("keeps reporting the env a live connection proves was in place", async () => {
const { api, reverifyEnv } = makeNativeApi({ envSetup: true, connected: true });
api.getInitFailure = () => ({ attempts: 1, lastError: "Invalid device", givenUp: false });
const result = (await nativeDevtoolsStatusTool.execute(
{ nativeDevtools: api },
{ udid: "UDID", bundleId: "com.example.app" }
)) as { envSetup: boolean; connected: boolean; nextLaunchWillBeInjected: boolean };
// The premise: nothing on this path re-applied the env, so the failure it
// would have consulted is stale by construction.
expect(reverifyEnv).not.toHaveBeenCalled();
expect(result.connected).toBe(true);
expect(result.envSetup).toBe(true);
expect(result.nextLaunchWillBeInjected).toBe(true);
});
// Same rule on the terminal branch, which runs no env work at all — and a
// system app CAN be connected there (#453 saw one runtime refuse the dylib,
// an E2E run another accept it), so the same contradiction is reachable.
it("keeps reporting a working env for a connected non-injectable app", async () => {
const { api } = makeNativeApi({ envSetup: true, connected: true, appRunning: true });
api.getInitFailure = () => ({ attempts: 1, lastError: "Invalid device", givenUp: false });
const result = (await nativeDevtoolsStatusTool.execute(
{ nativeDevtools: api },
{ udid: "UDID", bundleId: "com.apple.Preferences" }
)) as { envSetup: boolean; connected: boolean; injectable: boolean };
expect(result.injectable).toBe(false);
expect(result.connected).toBe(true);
expect(result.envSetup).toBe(true);
});
// Same reading on the branch that runs no env work of its own: a system app
// on a sim whose env is broken must not report the env as in place either.
it("stops reporting a working env for a non-injectable app once it has failed", async () => {
const { api } = makeNativeApi({ envSetup: true, appRunning: true });
api.getInitFailure = () => ({ attempts: 1, lastError: "Invalid device", givenUp: false });
const result = (await nativeDevtoolsStatusTool.execute(
{ nativeDevtools: api },
{ udid: "UDID", bundleId: "com.apple.Preferences" }
)) as { envSetup: boolean };
expect(result.envSetup).toBe(false);
});
// A sim that dies mid-call is healthy at every earlier read: the entry
// precheck saw a working service and `getInitFailure` was still null on the
// way in. Hoisting the read out of the catch reports that stale null and lets
// the raw subprocess error out.
it("reads the recorded failure after the probe, not before it", async () => {
const { api } = makeNativeApi({ envSetup: true, state: "indeterminate" });
let died = false;
api.getInitFailure = () =>
died
? { attempts: MAX_NATIVE_DEVTOOLS_INIT_ATTEMPTS, lastError: "sim gone", givenUp: true }
: null;
api.isAppRunning = vi.fn(async () => {
died = true;
throw new Error("Invalid device: UDID");
});
const result = (await nativeDevtoolsStatusTool.execute(
{ nativeDevtools: api },
{ udid: "UDID", bundleId: "com.example.app" }
)) as { status?: string; message?: string };
expect(result.status).toBe("init_failed");
expect(result.message).toContain("sim gone");
});
// The other half of that branch: with no recorded failure the env is healthy,
// so the probe's own error is the honest answer. Degrading it would report a
// reading the tool never took.
it("lets the probe's error out when the env recorded no failure", async () => {
const { api } = makeNativeApi({ envSetup: true, state: "indeterminate" });
api.getInitFailure = () => null;
api.isAppRunning = vi.fn(async () => {
throw new Error("Invalid device: UDID");
});
await expect(
nativeDevtoolsStatusTool.execute(
{ nativeDevtools: api },
{ udid: "UDID", bundleId: "com.example.app" }
)
).rejects.toThrow("Invalid device: UDID");
});
});
// Descriptions are what an agent reads before ever calling, so one that names
// restart_required while staying silent about service_stale rebuilds the loop in
// the more prominent place, whatever the runtime message says.
describe("native-* tool descriptions document every precheck outcome", () => {
const tools = [
nativeDescribeScreenTool,
nativeFindViewsTool,
nativeFullHierarchyTool,
nativeNetworkLogsTool,
nativeViewAtPointTool,
nativeUserInteractableViewAtPointTool,
];
it("names service_stale and its tool-server remedy alongside restart_required", () => {
for (const tool of tools) {
expect(tool.description, `${tool.id} must mention restart_required`).toContain(
"restart_required"
);
expect(tool.description, `${tool.id} must mention service_stale`).toContain("service_stale");
expect(tool.description, `${tool.id} must mention connect_pending`).toContain(
"connect_pending"
);
// The fourth member of NativeDevtoolsPrecheckBlock, and the only status
// whose remedy is neither a restart nor a wait — an agent with no arm for
// it retries a simulator that needs re-booting.
expect(tool.description, `${tool.id} must mention init_failed`).toContain("init_failed");
expect(tool.description, `${tool.id} must name the tool-server remedy`).toContain(
"argent server stop && argent server start --detach"
);
}
});
// All six throw the identical terminal NATIVE_DEVTOOLS_NOT_INJECTABLE error
// for a com.apple.* bundle id, and it is the one outcome an agent can act on
// BEFORE spending a call, off `tools/list`. Half of them describing a terminal
// arm and half not is what makes an agent believe the silent ones might work.
it("names the terminal system-app rejection on every tool that performs it", () => {
for (const tool of tools) {
expect(tool.description, `${tool.id} must name the system-app rejection`).toMatch(
/Apple system app is rejected outright/
);
expect(tool.description, `${tool.id} must mark it terminal`).toMatch(/never retry it/);
}
});
/**
* The one `If state is <state>:` line out of the description.
*
* A whole-description `toContain` is not a pin on any of them: the guidance
* lines echo each other closely enough that deleting one outright leaves its
* assertions satisfied by a sibling — "do NOT restart the app again" appears
* verbatim in the `indeterminate` line too — and the field list and the
* `injectable: false` paragraph between them supply most of the remaining
* words, down to "describe" inside `native-describe-screen`.
*/
function guidanceLine(state: NativeDevtoolsAppState): string {
const prefix = `If state is ${state}:`;
const line = nativeDevtoolsStatusTool
.description!.split("\n")
.find((l) => l.startsWith(prefix));
expect(line, `description has no "${prefix}" line`).toBeTypeOf("string");
return line!;
}
it("tells native-devtools-status readers to wait out a connecting app", () => {
expect(guidanceLine("connecting")).toContain("do NOT restart the app");
});
it("tells native-devtools-status readers not to restart an unregistered app", () => {
expect(guidanceLine("unregistered")).toContain("do NOT restart the app again");
});
// Every remedy in the set is individually correct and together they close into
// a ring: the tool-server restart this state prescribes rebinds the listener,
// so the same never-dialing process reads `stale_process` (it now predates the
// listener) -> restart-app -> `connecting` -> `unregistered` again, unbounded.
// Nothing measured separates the first landing from the second, so both
// surfaces have to hand the reader the test — as `not_running` and
// `indeterminate` already do. Without it this PR lengthens the loop it fixes.
it("gives an unregistered app a way out of the remedy cycle on its second landing", () => {
const message = buildAppStateMessage("com.example.app", "unregistered");
const line = guidanceLine("unregistered");
// Pinned as each surface's own conditional clause rather than a shared
// /already|again/: the description line opens "If state is unregistered: do
// NOT restart the app again", so a loose pattern matches the wrapper the
// escape sits in and survives the escape being deleted from it.
expect(message).toContain("If you have already restarted the tool-server for this app");
expect(line).toContain("If it reads unregistered again after that restart");
for (const [name, text] of [
["message", message],
["description", line],
] as const) {
// Conditional, so "stop" is not the last word: a reader who has spent both
// remedies needs somewhere to go.
expect(text, `${name} names no terminal fallback`).toMatch(
/treat native devtools as unavailable/i
);
// A tool that reads the screen WITHOUT injection. Pointing back at a
// native-* tool re-enters the same precheck, which is the cycle again with
// an extra hop — and `native-describe-screen` contains "describe", so the
// tool name has to be matched on its own.
expect(text, `${name} names no injection-free reader`).toMatch(/\bscreenshot\b/);
expect(text, `${name} falls back to a tool behind the same precheck`).not.toMatch(
/native-(describe-screen|find-views|full-hierarchy|network-logs|view-at-point)/
);
}
});
// `indeterminate` gives the agent `requiresRestart: true` and nothing else, so
// the description is where "restart once, then stop" has to be stated. It is
// also the only unconnected state a running app reaches on a remote simulator.
it("bounds the restart advice for a process it could not inspect", () => {
// Over the whole description neither assertion says anything about this
// line: the prefix is all the first one needs, and the `unregistered` line
// supplies the command verbatim. So the bound this test is named for — the
// one stop-condition on the description side for the only unconnected state
// a running app reaches on ios-remote — could be deleted outright.
const line = guidanceLine("indeterminate");
expect(line).toContain("do NOT restart the app again");
expect(line).toContain("argent server stop && argent server start --detach");
});
// `indeterminate` sets `requiresRestart: true`, so it matches the boolean
// rule as well as its own `If state is` line — and an agent that acts on the
// earlier, more general rule never reaches the later one. That earlier rule
// is where the bound has to be stated, and on a remote simulator it is the
// ordinary path rather than a rare one: no app process can be inspected
// there, so `indeterminate` is the only unconnected state a running app can
// reach. Verbatim for the reason the stop-conditions below are: the failure
// is a softened or deleted bound, which any pattern over the opening clause
// reads straight past.
it("bounds the requiresRestart rule rather than leaving it open", () => {
const line = nativeDevtoolsStatusTool
.description!.split("\n")
.find((l) => l.startsWith("If requiresRestart is true:"));
expect(line, "description has no requiresRestart rule").toBeTypeOf("string");
expect(line!).toBe(
"If requiresRestart is true: call restart-app once, then proceed with the native feature. " +
"Read state before acting on a second such reading — indeterminate reaches this rule too, " +
"and its line below bounds it at that one restart."
);
});
// Rename a state in one place and the prose describes names the tool never
// emits — which no runtime assertion catches, because both sides still agree
// with themselves. Exhaustive by construction: a record fails to compile when
// a state joins the union unlisted, where a hand-written array silently omits.
//
// The value says where each state is documented. `provider_attached` occurs
// only on a provider's device, so the description (read by every agent) omits
// it and its `message` explains it when it happens.
const ALL_STATES: Record<NativeDevtoolsAppState, "description" | "message"> = {
connected: "description",
not_running: "description",
stale_process: "description",
unregistered: "description",
connecting: "description",
indeterminate: "description",
provider_attached: "message",
};
it("spells every emitted state exactly as the description names it", () => {
for (const [state, documentedIn] of Object.entries(ALL_STATES)) {
if (documentedIn !== "description") continue;
expect(nativeDevtoolsStatusTool.description, `description must name ${state}`).toContain(
`"${state}"`
);
}
});
it("keeps a provider-only state out of the description and explains it in message", () => {
for (const [state, documentedIn] of Object.entries(ALL_STATES)) {
if (documentedIn !== "message") continue;
expect(
nativeDevtoolsStatusTool.description,
`${state} occurs only on a provider's device, so the description must not name it`
).not.toContain(state);
/** What the agent gets instead, when the state occurs. */
const message = buildAppStateMessage(
"com.example.App",
state as Exclude<NativeDevtoolsAppState, "connected">
);
expect(message, `${state} has no message to stand in for the description`).toBeTypeOf(
"string"
);
expect(message.length).toBeGreaterThan(0);
}
});
// `argent server start` defaults to the foreground and never returns, so an
// agent that runs it as written is gone. Checked per occurrence, not per blob:
// a surface holding two satisfies a whole-blob `toContain` with one still
// bare.
it("never prescribes a tool-server restart the agent cannot return from", () => {
const BARE_START = /argent server start(?! --detach)/;
// A surface with no text at all would vacuously satisfy the check below.
const described = (name: string, text: string | undefined): [string, string] => {
expect(text, `${name} has no text to check`).toBeTypeOf("string");
return [name, text!];
};
const surfaces: [string, string][] = [
described("native-devtools-status description", nativeDevtoolsStatusTool.description),
...tools.map((t) => described(`${t.id} description`, t.description)),
...(Object.keys(ALL_STATES) as NativeDevtoolsAppState[])
.filter((s): s is Exclude<NativeDevtoolsAppState, "connected"> => s !== "connected")
.map((s): [string, string] => [`${s} message`, buildAppStateMessage("com.example.app", s)]),
];
for (const [name, text] of surfaces) {
expect(text, `${name} prescribes a foreground tool-server start`).not.toMatch(BARE_START);
}
// The regex only fires on text that mentions the command at all, so prove
// the surfaces that must mention it do — otherwise deleting the guidance
// outright passes as "no bare start".
expect(buildAppStateMessage("com.example.app", "unregistered")).toContain(
"argent server start"
);
expect(nativeDevtoolsStatusTool.description).toContain("argent server start");
});
// One agent reads the tool description AND the message the same call returns.
// The descriptions must be plain literals (extract-tools.mjs reads them
// statically), so the wording is copied eight ways and nothing but this pins
// the copies together — which is how six of them kept telling the reader to
// wait "a second or two" after the connect budget outgrew it and the
// other two were updated.
it("agrees with the message on how long a connecting app is worth waiting for", () => {
const surfaces: [string, string][] = [
["connecting message", buildAppStateMessage("com.example.app", "connecting")],
["native-devtools-status description", guidanceLine("connecting")],
...tools.map((t): [string, string] => [`${t.id} description`, t.description!]),
];
for (const [name, text] of surfaces) {
expect(text, `${name} has no wait guidance to check`).toMatch(/wait a few seconds/i);
// The wording the budget outgrew. Pinned as its own assertion so the
// failure names the drift rather than a missing phrase.
expect(text, `${name} still quotes the pre-budget wait`).not.toMatch(/second or two/i);
}
// The six carry their own phrasing of the prohibition, so they need their
// own pin — and it has to reject a qualified one for the same reason the
// message's does: the first relaunch is already the one that discards the
// handshake, so "do not restart it more than once" is wrong advice that
// still contains "do not restart it".
for (const tool of tools) {
expect(tool.description!, `${tool.id} qualifies the connecting prohibition`).toContain(
"do not restart it, wait"
);
}
});
// Same split surface, same hazard: round 2 gave `unregistered` a landing that
// terminates, and the six tool descriptions route `service_stale` — the status
// that state maps to — with their own copy of the remedy.
it("stops every service_stale surface from prescribing an unbounded tool-server restart", () => {
for (const tool of tools) {
const routing = tool
.description!.split("\n")
.find((l) => l.includes("If status is service_stale:"));
expect(routing, `${tool.id} does not route service_stale`).toBeTypeOf("string");
expect(
routing!,
`${tool.id} prescribes a tool-server restart with no second landing`
).toMatch(/if the same status comes back|stop restarting/i);
}
});
/**
* The remedy prose an agent obeys to decide whether to STOP, pinned verbatim.
*
* Every other assertion in this file matches an opening clause, and that has
* repeatedly proved to constrain nothing: the claim can be negated by a
* sentence appended after the part that matched ("go ahead and relaunch … keep
* relaunching until it connects"), qualified inside it ("do NOT restart the app
* MORE THAN ONCE"), or scoped away ("restart the tool-server AGAIN and keep
* retrying until it clears") — each of which reopens the unbounded loop this
* whole change exists to close, with every pattern still green.
*
* A pattern cannot fix that, because the defect is always in the words it did
* not look at. So these strings are duplicated here on purpose, exactly as
* NON_INJECTABLE_RECOVERY is: rewording one has to fail, be re-read, and be
* re-approved rather than absorbed. Edit both together.
*/
const CONNECTING_PROHIBITION =
"Do NOT restart the app: launching it is what starts the connection, so a relaunch " +
"discards the one in progress and returns you to this same state.";
const UNREGISTERED_ESCAPE =
"If you have already restarted the tool-server for this app and it reads this way again, " +
"stop: the process is loading argent's dylib but never dialing, which no further restart on " +
"either side fixes. Treat native devtools as unavailable — read the screen with describe or " +
"screenshot and drive it by coordinate.";
const INDETERMINATE_ESCALATION =
"If it is still not connected after that restart, the native-devtools service is stale rather " +
"than the app being uninjected — do not keep restarting the app; restart the tool-server " +
"(`argent server stop && argent server start --detach`) and retry.";
/**
* An instruction to retry without bound, in any of the ways these surfaces
* could phrase one.
*
* The pins below all constrain a SLICE of a surface — a named line, a clause
* between two markers, the final sentence — and every round of review has
* found the same defect in whatever slice was left over: a second guidance
* line for the same state, a clause appended past the last marker, a
* permitting sentence before the pinned tail. Pinning more slices only moves
* the gap, so this asks the one question that does not depend on where the
* text sits: does this surface, anywhere, tell an agent to keep going?
*
* A clause that FORBIDS one is the point of these messages, so it is the
* un-negated ones that fail.
*/
const UNBOUNDED_RETRY =
/keep [\w-]+ing|more than once|again and again|a couple more|until (it|connected)\b|ignore the stop/i;
// Deliberately not a bare `stop`: "ignore the stop-conditions above and keep
// calling restart-app" contains the word while saying the opposite, so the
// escape hatch would have covered for the negation it exists to allow.
const FORBIDDEN =
/\bdo not\b|\bdo NOT\b|\bnever\b|\bcannot\b|\bno further\b|\bat most\b|\bstop (restarting|:)/i;
/** Split on sentence and clause boundaries, so polarity is judged locally. */
function clausesOf(text: string): string[] {
return text
.split(/(?<=[.;:])\s+|\s+—\s+|\n/)
.map((c) => c.trim())
.filter(Boolean);
}
it("never tells an agent to keep retrying, on any surface that carries a remedy", () => {
const surfaces: [string, string][] = [
...(Object.keys(ALL_STATES) as NativeDevtoolsAppState[])
.filter((s): s is Exclude<NativeDevtoolsAppState, "connected"> => s !== "connected")
.flatMap((s): [string, string][] => [
[`${s} message`, buildAppStateMessage("com.example.app", s)],
// The flow gate rewrites every state for a reader who has just
// launched, so it is a second copy of the same remedies on a surface
// none of the verbatim pins reach.
[`${s} flow-gate reason`, flowLaunchGateReason("com.example.app", s)],
]),
["native-devtools-status description", nativeDevtoolsStatusTool.description!],
...tools.map((t): [string, string] => [`${t.id} description`, t.description!]),
];
for (const [name, text] of surfaces) {
for (const clause of clausesOf(text)) {
if (!UNBOUNDED_RETRY.test(clause)) continue;
expect(FORBIDDEN.test(clause), `${name} permits an unbounded retry: "${clause}"`).toBe(
true
);
}
}
});
it("gives each state exactly one guidance line to obey", () => {
// A verbatim `toBe` on the first matching line says nothing about a second
// one appended later, which an agent reads just as readily.
const lines = nativeDevtoolsStatusTool.description!.split("\n");
for (const state of Object.keys(ALL_STATES) as NativeDevtoolsAppState[]) {
const matches = lines.filter((l) => l.startsWith(`If state is ${state}:`));
expect(matches.length, `description routes ${state} ${matches.length} times`).toBeLessThan(2);
}
});
it("routes exactly the four precheck statuses on every native-* tool", () => {
// The clause pins slice up to the NEXT `If status is `, so the last slice is
// open-ended: a fifth clause is unconstrained on all six tools at once.
for (const tool of tools) {
const count = tool.description!.match(/If status is /g)?.length ?? 0;
expect(count, `${tool.id} routes ${count} statuses`).toBe(4);
}
});
it.each([
["connecting", CONNECTING_PROHIBITION],
["unregistered", UNREGISTERED_ESCAPE],
["indeterminate", INDETERMINATE_ESCALATION],
[
"not_running",
"If that launch fails rather than starting the app, the bundle id is not installed on this " +
"device — this state cannot tell the two apart; install it and no relaunch will be needed.",
],
["stale_process", "A fresh process picks up the current one: call restart-app then retry."],
] as const)("ends the %s message on its stop-condition, verbatim", (state, tail) => {
// `endsWith`, not `toContain`: the failure mode is a permitting sentence
// AFTER the stop-condition, which any containment check reads straight past.
const message = buildAppStateMessage("com.example.app", state);
expect(message.trimEnd().endsWith(tail), `${state} message does not end on its remedy`).toBe(
true
);
});
it.each([
[
"connecting",
"If state is connecting: do NOT restart the app — launching it is what starts the connection, so a relaunch discards the one in progress and returns this same state. Wait a few seconds and repeat this call.",
],
[
"unregistered",
"If state is unregistered: do NOT restart the app again — it already launched under the terms a restart would recreate. Restart the tool-server (`argent server stop && argent server start --detach`), then retry. If it reads unregistered again after that restart, stop: the process loads argent's dylib but never dials, and no further restart on either side changes it — treat native devtools as unavailable, then use `describe` or `screenshot` and drive by coordinate.",
],
[
"indeterminate",
"If state is indeterminate: the process could not be inspected, so restart-app is worth one attempt. If this call still reports it after that restart, do NOT restart the app again — the service is stale rather than the app uninjected, so restart the tool-server (`argent server stop && argent server start --detach`) and retry. Remote simulators can never inspect the process, so this is the only unconnected state a running app reaches there.",
],
] as const)("routes %s in the description exactly as written", (state, line) => {
expect(guidanceLine(state)).toBe(line);
});
it.each([
[
"connect_pending",
"If status is connect_pending: the app is injected and still connecting — do not restart it, wait a few seconds and retry.",
],
[
"service_stale",
"If status is service_stale: the app is already injected, so restarting it cannot help — restart the tool-server (`argent server stop && argent server start --detach`) and retry. If the same status comes back after that restart, stop restarting: follow the message, which names the terminal fallback.",
],
])("routes %s identically on all six native-* tools", (status, clause) => {
for (const tool of tools) {
const start = tool.description!.indexOf(`If status is ${status}:`);
expect(start, `${tool.id} does not route ${status}`).toBeGreaterThanOrEqual(0);
const rest = tool.description!.slice(start);
const next = rest.slice(1).search(/If status is /);
expect(rest.slice(0, next === -1 ? undefined : next + 1).trim(), tool.id).toBe(clause);
}
});
// Whether the dylib loads into an Apple system app is not settled — #453
// recorded `connected: false` on iOS 26.5, an E2E run `connected: true` on
// 18.5 — and one agent reads every surface below, so hedging one while its
// twin says "can never" is what misleads. The DECISION (terminal, do not
// retry) is unchanged; only the certainty claimed for it has to agree.
it("does not claim injection is impossible on any agent-facing surface", () => {
// Fails loudly on an absent description rather than letting the regex below
// pass over `undefined`, which would satisfy a negative match vacuously.
const described = (name: string, text: string | undefined): [string, string] => {
expect(text, `${name} has no text, so this check would be vacuous`).toBeTypeOf("string");
return [name, text!];
};
const surfaces: [string, string][] = [
described("native-devtools-status description", nativeDevtoolsStatusTool.description),
...tools.map((t) => described(`${t.id} description`, t.description)),
described("non-injectable recovery", NON_INJECTABLE_RECOVERY),
];
for (const [name, text] of surfaces) {
expect(text, `${name} asserts injection is impossible`).not.toMatch(
/can never (be injected|load|inject)/
);
}
// Softening the claim must not soften the behaviour, or the restart loop
// #453 reported comes straight back.
expect(nativeDevtoolsStatusTool.description).toMatch(/Do NOT restart\/retry/);
});
// `not_running` is the absence of a `UIKitApplication:<id>` row, which an
// uninstalled bundle id lacks too. Prescribing only launch-app makes it a loop
// of its own there: `simctl launch` refuses, and the state's single remedy is
// spent. Naming the second reading gives that agent somewhere to go.
it("admits that not_running cannot see whether the app is installed", () => {
const message = buildAppStateMessage("com.nonexistent.TotallyFake", "not_running");
expect(message).toContain("launch-app");
expect(message).toMatch(/not installed/);
// The claim has to be scoped to this state's evidence, not asserted as a
// measurement it never took.
expect(message).toMatch(/cannot tell the two apart/);
});
});