Files
software-mansion__argent/packages/tool-server/test/native-target-app.test.ts
filip131311 23cd32073a fix(native-devtools): re-apply DYLD env after out-of-band sim reboot (#346)
## Summary

After an iOS simulator loses its launchd `DYLD_INSERT_LIBRARIES` (any
reboot — out-of-band *or* an argent `boot-device`),
`native-devtools-status` keeps reporting `envSetup: true` /
`nextLaunchWillBeInjected: true` while the env is actually **empty**.
Freshly launched apps aren't injected and native feature tools
(`native-describe-screen`, `native-find-views`, …) fail with
`restart_required` — and `restart-app` couldn't break out of it.

## Root cause

`envSetup` is a **write-once latch**. `ensureEnvReady()` short-circuits
once it flips `true`, and nothing resets it, so the env is never
re-applied after a reboot clears it. Two paths relied on that latched
function and so never recovered:

1. `requiresAppRestart()` — written to recover from the reboot case, but
called the latched `ensureEnvReady()`.
2. `boot-device` — primes the native-devtools service after (re)booting,
but the simulator watcher usually initialized it earlier, so
`resolveService` returns the cached/latched instance and the wiped env
is never re-applied. A clean argent `boot-device` + `launch-app`
therefore produced an uninjected app and a spurious `restart_required`.

## Fix

- Split a latch-free `runEnsureEnv()` out of `ensureEnvReady()` and add
`reverifyEnv()` — re-applies the env even when the latch says it's set
(still honouring the give-up guard). `ensureEnv` is already idempotent,
so it's a cheap no-op when correct.
- `requiresAppRestart()` → uses `reverifyEnv()` (fixes the native
feature tools' recovery; precheck routes through it).
- `native-devtools-status` → calls `reverifyEnv()` when the app isn't
connected, so its flags reflect reality and a later launch is injected.
- `boot-device` → forces `reverifyEnv()` after the (re)boot, so a clean
argent boot injects on first launch with no restart.

The hot path (`ensureEnvReady`, app already connected) is unchanged — no
added latency on normal calls; the extra simctl round-trips only fire
when there's evidence the env is stale.

## Verified end-to-end on a real iOS 26.3 sim (patched tool-server)

- **Clean argent boot → launch → just works:** prime service,
`boot-device force`, `launch-app` → `connected:true`,
`requiresRestart:false`, `native-describe-screen` returns the full tree.
`DYLD_INSERT_LIBRARIES` is present immediately after `boot-device`
returns. ✅
- **Out-of-band reboot → `native-devtools-status`:** status call
self-heals the env; next launch is injected. ✅
- **Out-of-band reboot → launch → native tool → `restart-app`:** the
previously dead-end recovery now works. ✅
- **Before the fix (same hardware):** status claimed `envSetup:true`
with the env empty; launches uninjected; `restart-app` and `boot-device
force` did not recover.

## Tests

- New regression tests: `native-devtools-status` re-applies when
disconnected / does not when connected; `boot-device` forces a re-apply
after boot.
- Full `packages/tool-server` suite green (1155), typecheck + prettier
clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Radoniusz Cloudiusz <radoniusz@radoniusz.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:58:22 +02:00

151 lines
5.2 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
chooseFrontmostConnectedApp,
inspectConnectedNativeApps,
resolveNativeTargetApp,
} from "../src/utils/native-target-app";
import type { NativeAppState, NativeDevtoolsApi } from "../src/blueprints/native-devtools";
function makeAppState(bundleId: string, overrides: Partial<NativeAppState> = {}): NativeAppState {
return {
bundleId,
applicationState: "background",
foregroundActiveSceneCount: 0,
foregroundInactiveSceneCount: 0,
backgroundSceneCount: 1,
unattachedSceneCount: 0,
isFrontmostCandidate: false,
...overrides,
};
}
function makeApi(apps: NativeAppState[]): NativeDevtoolsApi {
const byBundleId = new Map(apps.map((app) => [app.bundleId, app]));
return {
isEnvSetup: () => true,
socketPath: "/tmp/mock.sock",
ensureEnvReady: async () => {},
reverifyEnv: async () => {},
getInitFailure: () => null,
isConnected: (bundleId) => byBundleId.has(bundleId),
isAppRunning: async (bundleId) => byBundleId.has(bundleId),
listConnectedBundleIds: () => [...byBundleId.keys()],
requiresAppRestart: async () => false,
activateNetworkInspection: () => {},
getNetworkLog: () => [],
clearNetworkLog: () => {},
getAppState: async (bundleId) => {
const app = byBundleId.get(bundleId);
if (!app) throw new Error(`unknown bundleId: ${bundleId}`);
return app;
},
detectFrontmostBundleId: async () => null,
queryViewHierarchy: async () => ({}),
};
}
describe("native-target-app", () => {
describe("chooseFrontmostConnectedApp", () => {
it("returns the unique strong candidate when one app is active", () => {
const result = chooseFrontmostConnectedApp([
makeAppState("com.example.a", { applicationState: "background" }),
makeAppState("com.example.b", { applicationState: "active" }),
]);
expect(result?.bundleId).toBe("com.example.b");
});
it("returns the unique weak candidate when no strong candidate exists", () => {
const result = chooseFrontmostConnectedApp([
makeAppState("com.example.a", { applicationState: "background" }),
makeAppState("com.example.b", {
applicationState: "inactive",
foregroundInactiveSceneCount: 1,
}),
]);
expect(result?.bundleId).toBe("com.example.b");
});
it("returns null when multiple strong candidates exist", () => {
const result = chooseFrontmostConnectedApp([
makeAppState("com.example.a", { applicationState: "active" }),
makeAppState("com.example.b", { foregroundActiveSceneCount: 1 }),
]);
expect(result).toBeNull();
});
});
describe("inspectConnectedNativeApps", () => {
it("returns connected apps sorted by bundle id", async () => {
const api = makeApi([makeAppState("com.example.z"), makeAppState("com.example.a")]);
const result = await inspectConnectedNativeApps(api);
expect(result.map((app) => app.bundleId)).toEqual(["com.example.a", "com.example.z"]);
});
});
describe("resolveNativeTargetApp", () => {
it("uses explicit bundleId when provided", async () => {
const api = makeApi([makeAppState("com.example.a")]);
await expect(resolveNativeTargetApp(api, "com.example.explicit")).resolves.toEqual({
bundleId: "com.example.explicit",
source: "explicit",
});
});
it("auto-selects the only connected app", async () => {
const api = makeApi([
makeAppState("com.example.only", {
applicationState: "active",
foregroundActiveSceneCount: 1,
}),
]);
await expect(resolveNativeTargetApp(api)).resolves.toEqual({
bundleId: "com.example.only",
source: "single_connected_foreground_like",
});
});
it("requires explicit bundleId when the only connected app is background-only", async () => {
const api = makeApi([makeAppState("com.example.background-only")]);
await expect(resolveNativeTargetApp(api)).rejects.toThrow(
"Provide bundleId explicitly if you still want to target this app."
);
});
it("auto-selects the unique frontmost connected app", async () => {
const api = makeApi([
makeAppState("com.example.background"),
makeAppState("com.example.front", { applicationState: "active" }),
]);
await expect(resolveNativeTargetApp(api)).resolves.toEqual({
bundleId: "com.example.front",
source: "frontmost_detected",
});
});
it("throws when no connected apps are available", async () => {
const api = makeApi([]);
await expect(resolveNativeTargetApp(api)).rejects.toThrow(
"No native-devtools-connected apps are available for auto-targeting. Launch or restart the app first, provide bundleId explicitly, or use screenshot to inspect visible Home/system UI."
);
});
it("throws when multiple connected apps exist and none is uniquely frontmost", async () => {
const api = makeApi([
makeAppState("com.example.a", { applicationState: "active" }),
makeAppState("com.example.b", { foregroundActiveSceneCount: 1 }),
]);
await expect(resolveNativeTargetApp(api)).rejects.toThrow("Provide bundleId explicitly.");
});
});
});