mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
23cd32073a
## 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>
151 lines
5.2 KiB
TypeScript
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.");
|
|
});
|
|
});
|
|
});
|