mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
2e182f7971
## The problem Flow runs on CI iOS fail with `ViewInspector RPC timed out: Application.getState` at seemingly random steps - mid-flow after typing, on the first read after a fresh launch, in a probe flow 25 s after the original failure - while the app under test is provably healthy: spindumps taken at failure time show its main thread idle in the runloop, display links firing, and the injected devtools queue empty. The same flows pass locally. The chain: argent's iOS injection is simulator-wide (`launchctl setenv DYLD_INSERT_LIBRARIES` inside the simulator's launchd), so every process the simulator spawns afterwards loads the devtools dylib, not just the app under test. The dylib's constructor connects unconditionally (no UIKit or `com.apple.*` guard) and the tool-server registers whatever bundle id shakes hands, so Spotlight workers (`mdworker_shared`), background-launched system apps doing first-boot work (`MobileCal`), and bare daemons (nil bundle id, registered as `"unknown"`) all become connections indistinguishable from the app under test. Flow tree reads auto-resolved the target on every read: `queryFullHierarchyTree` passed `undefined` to `resolveNativeTargetApp`, which `Promise.all`s `Application.getState` over every connection. The dylib serves `getState` with a `dispatch_sync` onto the main thread, and a background-launched or suspended process never services its main queue, so its `getState` parks until the 5 s RPC timeout and the `Promise.all` rejects. One poisoned sibling connection fails every tree read in the run; the app under test is never even asked. That one mechanism explains everything the failures did. CI spindumps caught the poisoner red-handed (`MobileCal` parked in `HandleViewInspectorRequest -> CurrentApplicationState`) next to a healthy app. "Permanent wedge" vs "recovery" was just poisoner lifetime: a Spotlight worker exits and its connection drops with it, so reads recover; Calendar lingers and the whole job stays poisoned, fresh app launches and probe flows included. And it never reproduced locally because a warm simulator did its first-boot indexing and migrations long ago, while CI boots a pristine device and does exactly that work during the test window - which also made the failures correlate with typing and tapping purely by timing. ## The fix Pin the target instead of resolving it per read. `runLaunch` records the launched app on the run state once the tree-source gate passes, as `ActionEnv.treeTarget` - a `FlowTreeTarget` carrying the `bundleId`, whether the runner still vouches for it (`pinned`), and whether its probe has ever answered in this run (`probeAnswered`). `fetchFlowTree` threads that target down to `queryFullHierarchyTree`, the only platform source that consumes it. A pinned read talks to the pinned app and nobody else: it never calls `resolveNativeTargetApp` at all, and runs four gates of its own instead (`flow-ios-tree.ts`). A `com.apple.*` id is refused first, terminally and before anything is spent - a system app is never the app under test. Then `isConnected`, a map lookup, so a dead pin reports the dropped connection instead of driving env repair from 300 ms read polls, which could otherwise latch the device's process-wide give-up. Then a probe of the pinned app's own `getAppState`, and only that app's: a timeout before this pin has ever answered is ridden out as the cold start it usually is, one after an earlier answer is refused as an app that stopped servicing its main queue. Finally a foreground check on the answered state, as lenient over one app as auto-resolve is, so a flow that left the app fails with the crisp diagnosis instead of describing a screen that is not on screen. A poisoned sibling connection is never touched. The pin is only trusted while the runner can vouch for what is on screen, and it comes off in two tiers. The five `FOREGROUND_CHANGING_TOOLS` (`launch-app`, `restart-app`, `reinstall-app`, `open-url`, `button`) drop the target outright, because the launched app may no longer be on screen at all; `launch-app` and `restart-app` then re-set it from their own `bundleId` arg, unpinned, since they name the app they switched to - which is how a recorded flow switches apps, the recorder keeping them as raw `tool:` steps. Every other raw `tool:` step demotes the pin to an unpinned hint, its effect on the foreground being opaque to the runner; a launch attempt drops the target outright until it succeeds. Either tier also retires a proven tree-outage memo: a verdict proven against the pinned branch's four gates says nothing about the path the next read runs. An unpinned read is the pre-pin behavior with one arbiter added. `resolveNativeTargetApp` fans out over every connection, and only when that fan-out times out - the CI condition this change exists for - does the hint take the read, and only after passing the same terminal `com.apple.*` policy gate, the connections check, and a foreground-checked probe of its own. A resolution that answers always wins, including its deliberate "single app but backgrounded" refusal, so the arbiter never overrides a guard that fired: it only rides out a probe a stalled sibling made unanswerable. Nested `run:` fragments share the run state, so they inherit the pin, its demotion and its clearing. E2e flows begin with a `launch` by definition, so every selector read in a CI run happens pinned. Only iOS consumes the target; the other platforms' tree sources are per-device and never auto-resolve. The review rounds also surfaced that the `isInjectableBundleId` "library validation, the dylib can never load" rationale was disproven by this PR's own spindump evidence, so that text - and every other home of the same claim, `unreadableHierarchyReason` and `treeSourceGate` included - is rewritten as the policy it actually is (system apps are never the app under test; their observed connections are background processes whose reads hang or lie) with the gates' behavior unchanged. ## What this doesn't cover Auto-resolve's own fan-out is still all-or-nothing, so every read that reaches it without a hint the gates accept stays poisonable: top-level fragment runs, `describe` without an explicit bundle id, and the stretch after a raw `tool:` step whose hint is refused or cannot answer. Recording (`captureTapSelector`) now passes the session's last `launch` id as an unpinned hint, so it arbitrates the same timed-out fan-out, but it never pins - the recorder cannot vouch for the foreground app the way a `launch:` step can. Closing the rest means hardening auto-resolve itself (exclude connections that don't answer instead of failing the fan-out) and refusing the connections at the source (dylib constructor bail for non-UIKit and `com.apple.*` processes, plus a server-side handshake filter to cover already-shipped dylibs). Both are follow-ups. ## Tests Three files, and every behavior below is mutation-proven: disabling the seam, dropping the target from any single call site, removing an invalidation, or removing any one of the four gates each fails at least one dedicated test. The reviewers' repros were turned into tests rather than argued away. `flow-ios-tree-pinned-target.test.ts` works at the tree source. It reproduces the failure shape - a healthy app plus a poisoned `com.apple.mobilecal` sibling whose `getState` only times out - and proves a pinned read probes only the pinned app while an unpinned read still fails. It then covers the states auto-resolve could never reach: the terminal `com.apple.*` refusal, including its position ahead of the connections gate so a disconnected system app is not sent into a restart loop; a dead pin, where the dropped connection is reported and no env repair runs; both sides of the `probeAnswered` split; and the foreground guard over the states a real device produces (`inactive` under a permission dialog, `inactive` with no scenes, `background` still holding a foreground-inactive scene, and `background` with none). A second block covers the unpinned arbiter: the hint read on a timeout, refused when its own probe answers from the background, and rethrowing the fan-out timeout when the hint is disconnected, cannot answer, or when the resolution failed for any other reason. `flow-launch-pins-tree-target.test.ts` proves the runner wiring through the real `fetchFlowTree` seam: every read after a `launch` carries the pin across all five read paths (assert, tap, type, rotate, idle), retry polls included; fragments inherit it; a foreground-neutral `tool:` step demotes it and a foreground-changing one that names no app drops it outright; a later launch re-pins on a fresh target with the ride-out re-armed; and a launch-free run keeps the fallback. `flow-tool-step-tree-target-e2e.test.ts` drives `createRunFlowTool` with only `resolveService` stubbed, so the real `fetchFlowTree` and `queryFullHierarchyTree` run. It pins what the two tiers cost against a wedged sibling: the launched app still arbitrating across a foreground-neutral `tool:` step, the outage memo being retired when that step demotes the pin, the `com.apple.*` and foreground refusals staying in force through the demote, and a `hidden:` assert failing after the demote instead of going green against a backgrounded app.
667 lines
28 KiB
TypeScript
667 lines
28 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { FAILURE_CODES, FailureError, getFailureSignal } from "@argent/registry";
|
|
import type { DeviceInfo, Registry } from "@argent/registry";
|
|
import {
|
|
NON_INJECTABLE_NATIVE_WARNING,
|
|
type NativeAppState,
|
|
type NativeDevtoolsApi,
|
|
type NativeDevtoolsAppState,
|
|
} from "../../src/blueprints/native-devtools";
|
|
import type { FlowTreeTarget } from "../../src/tools/flows/flow-actions";
|
|
import { queryFullHierarchyTree } from "../../src/tools/flows/flow-ios-tree";
|
|
|
|
// Simulator-wide injection means background system processes also connect, and
|
|
// a suspended one never answers getState - auto-resolve Promise.alls getState
|
|
// over every connection, so one such sibling fails every read while the app
|
|
// under test is healthy. A pinned read probes ONLY the pinned app, never a
|
|
// sibling; an unpinned one (the second describe) still lets auto-resolve
|
|
// decide, consulting the target solely as a timeout arbiter.
|
|
|
|
const IOS_DEVICE = {
|
|
id: "00000000-0000-0000-0000-0000000000ab",
|
|
platform: "ios",
|
|
} as unknown as DeviceInfo;
|
|
|
|
const APP = "com.example.app";
|
|
const POISONER = "com.apple.mobilecal";
|
|
const OTHER = "com.example.other";
|
|
|
|
/**
|
|
* A pinned target. `probeAnswered` defaults to false - the state a fresh
|
|
* `launch` leaves behind, where a getState timeout is still ridden out as a
|
|
* cold-start stall.
|
|
*/
|
|
const pin = (bundleId: string, probeAnswered = false): FlowTreeTarget => ({
|
|
bundleId,
|
|
pinned: true,
|
|
probeAnswered,
|
|
});
|
|
const hint = (bundleId: string): FlowTreeTarget => ({
|
|
bundleId,
|
|
pinned: false,
|
|
probeAnswered: false,
|
|
});
|
|
|
|
/** What the wedged sibling's `Application.getState` really rejects with. */
|
|
function rpcTimeout(): FailureError {
|
|
return new FailureError("ViewInspector RPC timed out: Application.getState", {
|
|
error_code: FAILURE_CODES.NATIVE_DEVTOOLS_RPC_TIMEOUT,
|
|
failure_stage: "native_devtools_rpc_request",
|
|
failure_area: "tool_server",
|
|
error_kind: "timeout",
|
|
});
|
|
}
|
|
|
|
function appState(bundleId: string): NativeAppState {
|
|
return {
|
|
bundleId,
|
|
applicationState: "active",
|
|
foregroundActiveSceneCount: 1,
|
|
foregroundInactiveSceneCount: 0,
|
|
backgroundSceneCount: 0,
|
|
unattachedSceneCount: 0,
|
|
isFrontmostCandidate: true,
|
|
};
|
|
}
|
|
|
|
function windowSpanning() {
|
|
return {
|
|
className: "UIWindow",
|
|
frame: { x: 0, y: 0, width: 400, height: 800 },
|
|
windowFrame: { x: 0, y: 0, width: 400, height: 800 },
|
|
children: [
|
|
{
|
|
className: "RCTView",
|
|
identifier: "root",
|
|
windowFrame: { x: 0, y: 0, width: 400, height: 800 },
|
|
children: [],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The healthy app under test plus a poisoned sibling whose getState only ever
|
|
* times out; records state probes, hierarchy reads, and the env repair a miss
|
|
* path triggers (`appConnectionState` -> `reverifyEnv`).
|
|
*/
|
|
function poisonedApi(
|
|
connected: string[] = [APP, POISONER],
|
|
states: Record<string, NativeAppState> = {},
|
|
probeFailures: Record<string, Error> = {}
|
|
) {
|
|
const probed: string[] = [];
|
|
const queried: string[] = [];
|
|
const repaired: string[] = [];
|
|
const api = {
|
|
listConnectedBundleIds: () => [...connected],
|
|
isConnected: (id: string) => connected.includes(id),
|
|
getAppState: async (id: string) => {
|
|
probed.push(id);
|
|
if (probeFailures[id]) {
|
|
throw probeFailures[id];
|
|
}
|
|
if (id === POISONER) {
|
|
throw rpcTimeout();
|
|
}
|
|
return states[id] ?? appState(id);
|
|
},
|
|
appConnectionState: async (id: string): Promise<NativeDevtoolsAppState> => {
|
|
repaired.push(`appConnectionState:${id}`);
|
|
if (connected.includes(id)) return "connected";
|
|
// The real miss path awaits a full reverifyEnv before it diagnoses.
|
|
repaired.push("reverifyEnv");
|
|
return "not_running";
|
|
},
|
|
reverifyEnv: async () => {
|
|
repaired.push("reverifyEnv");
|
|
},
|
|
queryViewHierarchy: async (id: string) => {
|
|
queried.push(id);
|
|
return { windows: [windowSpanning()] };
|
|
},
|
|
} as unknown as NativeDevtoolsApi;
|
|
return { api, probed, queried, repaired };
|
|
}
|
|
|
|
function registryFor(api: NativeDevtoolsApi): Registry {
|
|
return {
|
|
resolveService: async () => api,
|
|
} as unknown as Registry;
|
|
}
|
|
|
|
describe("queryFullHierarchyTree - pinned target vs poisoned auto-resolve", () => {
|
|
it("a pinned read probes only the pinned app - the poisoned sibling is never touched", async () => {
|
|
const { api, probed, queried, repaired } = poisonedApi();
|
|
const target = pin(APP);
|
|
const { tree, source } = await queryFullHierarchyTree(registryFor(api), IOS_DEVICE, target);
|
|
expect(source).toBe("native-devtools");
|
|
expect(tree.children.length).toBeGreaterThan(0);
|
|
// A sibling probe would reintroduce the fan-out failure the pin avoids.
|
|
expect(probed).toEqual([APP]);
|
|
expect(queried).toEqual([APP]);
|
|
expect(repaired).toEqual([]);
|
|
expect(target.probeAnswered).toBe(true);
|
|
});
|
|
|
|
it("a pinned read of a still-answering backgrounded app refuses to describe an off-screen hierarchy", async () => {
|
|
// The narrow half of the backgrounded case: an app that still ANSWERS the
|
|
// probe, i.e. something keeps its main queue serviced past the switch away
|
|
// (a background mode, a `beginBackgroundTask`, the grace before iOS
|
|
// suspends it). The common half - an app that never answers - is covered by
|
|
// "a pinned read whose probe answered earlier refuses a later timeout".
|
|
const backgrounded: NativeAppState = {
|
|
bundleId: APP,
|
|
applicationState: "background",
|
|
foregroundActiveSceneCount: 0,
|
|
foregroundInactiveSceneCount: 0,
|
|
backgroundSceneCount: 1,
|
|
unattachedSceneCount: 0,
|
|
isFrontmostCandidate: false,
|
|
};
|
|
const { api, probed, queried, repaired } = poisonedApi([APP, POISONER], {
|
|
[APP]: backgrounded,
|
|
});
|
|
const err: unknown = await queryFullHierarchyTree(registryFor(api), IOS_DEVICE, pin(APP)).then(
|
|
() => {
|
|
throw new Error("expected the backgrounded-pin read to throw");
|
|
},
|
|
(e: unknown) => e
|
|
);
|
|
expect(err).toBeInstanceOf(Error);
|
|
const message = (err as Error).message;
|
|
expect(message).toContain(`${APP} (the launched app) has no foreground presence at all`);
|
|
expect(message).toContain("applicationState=background");
|
|
// The message must not advertise a refusal the guard does not deliver: it
|
|
// is as lenient as auto-resolve over one app, so it says so.
|
|
expect(message).toContain("Transitional states are NOT refused here");
|
|
expect(message).toContain("give the flow a `launch:` step for that app");
|
|
expect(message).toContain("demotes the pin and returns reads to frontmost auto-resolve");
|
|
expect(message).not.toContain("clears the pin");
|
|
expect(getFailureSignal(err)?.error_code).toBe(
|
|
FAILURE_CODES.NATIVE_TARGET_SINGLE_APP_NOT_FOREGROUND
|
|
);
|
|
expect(probed).toEqual([APP]);
|
|
expect(queried).toEqual([]);
|
|
expect(repaired).toEqual([]);
|
|
});
|
|
|
|
it("a pinned read under a system permission dialog still reads the app", async () => {
|
|
// What a system dialog (location, notifications, photos) over the app under
|
|
// test answers. The app IS what is on screen - the alert is drawn by
|
|
// another process in its own window - so the read must not be refused.
|
|
const underAlert: NativeAppState = {
|
|
...appState(APP),
|
|
applicationState: "inactive",
|
|
foregroundActiveSceneCount: 0,
|
|
foregroundInactiveSceneCount: 1,
|
|
isFrontmostCandidate: false,
|
|
};
|
|
const { api, probed, queried, repaired } = poisonedApi([APP, POISONER], { [APP]: underAlert });
|
|
const target = pin(APP);
|
|
const { tree, source } = await queryFullHierarchyTree(registryFor(api), IOS_DEVICE, target);
|
|
expect(source).toBe("native-devtools");
|
|
expect(tree.children.length).toBeGreaterThan(0);
|
|
// The guard let the read through, probing only the pinned app to decide.
|
|
expect(probed).toEqual([APP]);
|
|
expect(queried).toEqual([APP]);
|
|
expect(repaired).toEqual([]);
|
|
expect(target.probeAnswered).toBe(true);
|
|
});
|
|
|
|
it("a pinned read of an inactive app with no attached scene still reads it", async () => {
|
|
// `inactive` with every count at zero: no scene attached yet, or an
|
|
// injected framework old enough to omit the scene fields. Accepted because
|
|
// `chooseFrontmostConnectedApp` accepts it over the same one-element array
|
|
// - the pinned path matches auto-resolve rather than being stricter.
|
|
const inactive: NativeAppState = {
|
|
...appState(APP),
|
|
applicationState: "inactive",
|
|
foregroundActiveSceneCount: 0,
|
|
isFrontmostCandidate: false,
|
|
};
|
|
const { api, probed, queried, repaired } = poisonedApi([APP, POISONER], { [APP]: inactive });
|
|
const target = pin(APP);
|
|
const { tree, source } = await queryFullHierarchyTree(registryFor(api), IOS_DEVICE, target);
|
|
expect(source).toBe("native-devtools");
|
|
expect(tree.children.length).toBeGreaterThan(0);
|
|
expect(probed).toEqual([APP]);
|
|
expect(queried).toEqual([APP]);
|
|
expect(repaired).toEqual([]);
|
|
expect(target.probeAnswered).toBe(true);
|
|
});
|
|
|
|
it("a pinned read of a background app still holding a foreground-inactive scene reads it", async () => {
|
|
// `background` while a scene is still foreground-inactive - the tail of a
|
|
// transition, where the app's window is on screen even though the
|
|
// process-level state says otherwise. A lingering foreground scene outvotes
|
|
// `background`; only no foreground scene at all is refused.
|
|
const lingeringScene: NativeAppState = {
|
|
...appState(APP),
|
|
applicationState: "background",
|
|
foregroundActiveSceneCount: 0,
|
|
foregroundInactiveSceneCount: 1,
|
|
isFrontmostCandidate: false,
|
|
};
|
|
const { api, probed, queried, repaired } = poisonedApi([APP, POISONER], {
|
|
[APP]: lingeringScene,
|
|
});
|
|
const { tree } = await queryFullHierarchyTree(registryFor(api), IOS_DEVICE, pin(APP));
|
|
expect(tree.children.length).toBeGreaterThan(0);
|
|
expect(probed).toEqual([APP]);
|
|
expect(queried).toEqual([APP]);
|
|
expect(repaired).toEqual([]);
|
|
});
|
|
|
|
it("a pinned read rides out a frontmost probe the stalled main thread cannot answer, on the FIRST read", async () => {
|
|
// Application.getState hops onto the app's main thread, which a heavy cold
|
|
// start can pin past the RPC timeout. An unanswerable probe is not an
|
|
// answer of "backgrounded", so the read goes ahead - and the stall can only
|
|
// precede the pin's first ANSWERED probe.
|
|
const { api, probed, queried } = poisonedApi([APP, POISONER], {}, { [APP]: rpcTimeout() });
|
|
const target = pin(APP);
|
|
expect(target.probeAnswered).toBe(false);
|
|
const { tree } = await queryFullHierarchyTree(registryFor(api), IOS_DEVICE, target);
|
|
expect(tree.children.length).toBeGreaterThan(0);
|
|
expect(probed).toEqual([APP]);
|
|
expect(queried).toEqual([APP]);
|
|
// A ridden-out probe is not an answer, so it must not arm the refusal.
|
|
expect(target.probeAnswered).toBe(false);
|
|
});
|
|
|
|
it("a pinned read whose probe answered earlier refuses a later timeout instead of reading", async () => {
|
|
// Same RPC timeout, opposite verdict: this pin already had a probe answer,
|
|
// so the main queue WAS being serviced and has stopped. The hierarchy read
|
|
// would dispatch onto that same unserviced queue - not a second chance,
|
|
// only a second timeout.
|
|
const { api, probed, queried } = poisonedApi([APP, POISONER], {}, { [APP]: rpcTimeout() });
|
|
const err: unknown = await queryFullHierarchyTree(
|
|
registryFor(api),
|
|
IOS_DEVICE,
|
|
pin(APP, true)
|
|
).then(
|
|
() => {
|
|
throw new Error("expected the suspended-pin read to throw");
|
|
},
|
|
(e: unknown) => e
|
|
);
|
|
expect(err).toBeInstanceOf(Error);
|
|
const message = (err as Error).message;
|
|
expect(message).toContain(`${APP} (the launched app) stopped answering Application.getState`);
|
|
expect(message).toContain("an earlier one in this run answered");
|
|
// The escape hatch must be one that re-targets reads. A foreground-NEUTRAL
|
|
// `tool:` step is not: it demotes the pin back to a fan-out this same
|
|
// silent app sinks.
|
|
expect(message).toContain("give the flow a `launch:` step for that app");
|
|
expect(message).toContain(
|
|
"`tool: launch-app` or `tool: restart-app` naming that app works too"
|
|
);
|
|
expect(message).toContain("how a recorded flow switches apps");
|
|
expect(message).toContain("A foreground-NEUTRAL raw `tool:` step does not work here");
|
|
expect(message).not.toContain("clears the pin");
|
|
expect(message).not.toContain("returns reads to frontmost auto-resolve");
|
|
// The timeout's own classification: no app state was observed here.
|
|
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.NATIVE_DEVTOOLS_RPC_TIMEOUT);
|
|
expect(getFailureSignal(err)?.failure_stage).toBe("flow_tree_pinned_target");
|
|
expect((err as Error).cause).toBeInstanceOf(Error);
|
|
expect(probed).toEqual([APP]);
|
|
// The 15s getFullHierarchy on the same queue is never spent.
|
|
expect(queried).toEqual([]);
|
|
});
|
|
|
|
it("a pinned read propagates a frontmost probe failure that is not a timeout", async () => {
|
|
// Only a stall is ridden out; anything the probe actually reports is a
|
|
// failure the read must not paper over.
|
|
const { api, queried } = poisonedApi(
|
|
[APP, POISONER],
|
|
{},
|
|
{
|
|
[APP]: new FailureError("boom", {
|
|
error_code: FAILURE_CODES.NATIVE_DEVTOOLS_RPC_ERROR,
|
|
failure_stage: "native_devtools_rpc_response",
|
|
failure_area: "tool_server",
|
|
error_kind: "subprocess",
|
|
}),
|
|
}
|
|
);
|
|
await expect(queryFullHierarchyTree(registryFor(api), IOS_DEVICE, pin(APP))).rejects.toThrow(
|
|
/boom/
|
|
);
|
|
expect(queried).toEqual([]);
|
|
});
|
|
|
|
it("a pinned read of a dropped connection names the drop and runs no env repair", async () => {
|
|
// The app crashed or was killed after launch, and the socket close removed
|
|
// it from the connection set.
|
|
const { api, queried, repaired } = poisonedApi([POISONER]);
|
|
const err: unknown = await queryFullHierarchyTree(registryFor(api), IOS_DEVICE, pin(APP)).then(
|
|
() => {
|
|
throw new Error("expected the dropped-pin read to throw");
|
|
},
|
|
(e: unknown) => e
|
|
);
|
|
expect(err).toBeInstanceOf(Error);
|
|
const message = (err as Error).message;
|
|
expect(message).toContain(`${APP} lost its devtools connection after launch`);
|
|
// Not the stale-instrumentation diagnosis: the launch gate proved the
|
|
// instrumentation loaded.
|
|
expect(message).not.toMatch(/instrumentation loaded/);
|
|
// On iOS launch-app is `simctl launch`, which only foregrounds a live
|
|
// process - the next read fails identically. restart-app terminates first.
|
|
expect(message).toContain("restart it (restart-app, or a flow `launch` step)");
|
|
expect(message).not.toMatch(/relaunch it \(launch-app/);
|
|
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.NATIVE_DEVTOOLS_NOT_CONNECTED);
|
|
// A dead pin is re-read every 300ms poll, and appConnectionState's miss
|
|
// path would run a full reverifyEnv each time.
|
|
expect(repaired).toEqual([]);
|
|
expect(queried).toEqual([]);
|
|
});
|
|
|
|
it("a pinned read of an Apple system app is refused before any RPC is spent", async () => {
|
|
// A flow `launch:` can name a system app - nothing upstream refuses one -
|
|
// so the pin lands here, and the refusal must be terminal and free rather
|
|
// than a 5s probe plus a 15s hierarchy timeout surfacing as "RPC timed
|
|
// out".
|
|
const { api, probed, queried, repaired } = poisonedApi([POISONER]);
|
|
const err: unknown = await queryFullHierarchyTree(
|
|
registryFor(api),
|
|
IOS_DEVICE,
|
|
pin(POISONER)
|
|
).then(
|
|
() => {
|
|
throw new Error("expected the system-app pin to be refused");
|
|
},
|
|
(e: unknown) => e
|
|
);
|
|
expect(err).toBeInstanceOf(Error);
|
|
const message = (err as Error).message;
|
|
expect(message).toContain(`${POISONER} is an Apple system app`);
|
|
expect(message).toContain("never a valid flow target");
|
|
// Refusing here only helps if the message carries the coordinate remedy.
|
|
expect(message).toContain(
|
|
"`tap: { x: 0.5, y: 0.35 }` takes a point directly and reads no tree"
|
|
);
|
|
// This reader is writing flow YAML, where no native-* tool is a step.
|
|
expect(message).not.toContain(NON_INJECTABLE_NATIVE_WARNING);
|
|
expect(message).not.toMatch(/native-describe-screen|native-find-views/);
|
|
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.NATIVE_DEVTOOLS_NOT_INJECTABLE);
|
|
expect(getFailureSignal(err)?.failure_stage).toBe("flow_tree_pinned_target");
|
|
// No RPC was touched at all.
|
|
expect(probed).toEqual([]);
|
|
expect(queried).toEqual([]);
|
|
expect(repaired).toEqual([]);
|
|
});
|
|
|
|
it("refuses a system-app pin that is not connected, rather than sending the flow to restart it", async () => {
|
|
// The same pin with no connection behind it - treeSourceGate withholds its
|
|
// verdict for a com.apple.* id, so a `launch:` naming one passes even when
|
|
// nothing connected. This pins the ORDER of the two gates: check
|
|
// isConnected first and the author is told to restart the app, rebuilding
|
|
// the loop the policy gate exists to end.
|
|
const { api, probed, queried, repaired } = poisonedApi([APP]);
|
|
const err: unknown = await queryFullHierarchyTree(
|
|
registryFor(api),
|
|
IOS_DEVICE,
|
|
pin(POISONER)
|
|
).then(
|
|
() => {
|
|
throw new Error("expected the disconnected system-app pin to be refused");
|
|
},
|
|
(e: unknown) => e
|
|
);
|
|
expect(err).toBeInstanceOf(Error);
|
|
const message = (err as Error).message;
|
|
expect(message).toContain(`${POISONER} is an Apple system app`);
|
|
expect(message).toContain("no relaunch or retry changes this verdict");
|
|
expect(message).not.toMatch(/lost its devtools connection|restart it/);
|
|
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.NATIVE_DEVTOOLS_NOT_INJECTABLE);
|
|
expect(probed).toEqual([]);
|
|
expect(queried).toEqual([]);
|
|
expect(repaired).toEqual([]);
|
|
});
|
|
|
|
it("refuses a system-app pin in any casing, even while it is connected", async () => {
|
|
// Bundle ids are case-insensitive on iOS.
|
|
const RECASED = "COM.Apple.Preferences";
|
|
const { api, probed, queried } = poisonedApi([APP, RECASED]);
|
|
await expect(
|
|
queryFullHierarchyTree(registryFor(api), IOS_DEVICE, pin(RECASED))
|
|
).rejects.toThrow(/is an Apple system app/);
|
|
expect(probed).toEqual([]);
|
|
expect(queried).toEqual([]);
|
|
});
|
|
|
|
it("an unpinned read still fans out and is sunk by the poisoned sibling", async () => {
|
|
const { api, probed } = poisonedApi();
|
|
await expect(queryFullHierarchyTree(registryFor(api), IOS_DEVICE)).rejects.toThrow(
|
|
/RPC timed out/i
|
|
);
|
|
expect(probed).toContain(POISONER);
|
|
});
|
|
});
|
|
|
|
describe("queryFullHierarchyTree - an unpinned target arbitrates a timed-out fan-out", () => {
|
|
it("reads the hinted app when the fan-out times out and the hint is connected", async () => {
|
|
// The pin is gone (a `tool:` step ran), so the fan-out runs and the wedged
|
|
// sibling sinks it - but the launched app is still connected, so it decides
|
|
// the target.
|
|
const { api, probed, queried } = poisonedApi();
|
|
const { tree, source } = await queryFullHierarchyTree(registryFor(api), IOS_DEVICE, hint(APP));
|
|
expect(source).toBe("native-devtools");
|
|
expect(tree.children.length).toBeGreaterThan(0);
|
|
expect(probed).toContain(POISONER); // the fan-out really did run
|
|
expect(queried).toEqual([APP]);
|
|
});
|
|
|
|
it("refuses a hint that answers the arbiter's probe from the background", async () => {
|
|
// Without its own probe the arbiter would hand the read back to an app the
|
|
// pinned guard had just refused - its gates are all upstream of here.
|
|
const backgrounded: NativeAppState = {
|
|
...appState(APP),
|
|
applicationState: "background",
|
|
foregroundActiveSceneCount: 0,
|
|
backgroundSceneCount: 1,
|
|
isFrontmostCandidate: false,
|
|
};
|
|
const { api, probed, queried } = poisonedApi([APP, POISONER], { [APP]: backgrounded });
|
|
const target = hint(APP);
|
|
const err: unknown = await queryFullHierarchyTree(registryFor(api), IOS_DEVICE, target).then(
|
|
() => {
|
|
throw new Error("expected the backgrounded-hint read to be refused");
|
|
},
|
|
(e: unknown) => e
|
|
);
|
|
expect(err).toBeInstanceOf(Error);
|
|
const message = (err as Error).message;
|
|
expect(message).toContain(`${APP} (the launched app) has no foreground presence at all`);
|
|
expect(message).toContain("applicationState=background");
|
|
// The diagnosis names both halves: the timed-out fan-out, and a fallback
|
|
// that answered from the background.
|
|
expect(message).toContain("auto-resolve's probe of every connection timed out");
|
|
expect(message).toContain("give the flow a `launch:` step for that app");
|
|
// A state WAS observed here, unlike the silent-hint rethrow below.
|
|
expect(getFailureSignal(err)?.error_code).toBe(
|
|
FAILURE_CODES.NATIVE_TARGET_SINGLE_APP_NOT_FOREGROUND
|
|
);
|
|
expect(getFailureSignal(err)?.failure_stage).toBe("flow_tree_unpinned_hint");
|
|
expect((err as Error).cause).toBeInstanceOf(Error);
|
|
expect(probed).toContain(POISONER); // the fan-out really did run
|
|
// The backgrounded hint is never read.
|
|
expect(queried).toEqual([]);
|
|
// A demoted target never re-pins, so the discriminator stays unarmed.
|
|
expect(target.probeAnswered).toBe(false);
|
|
});
|
|
|
|
it("rethrows the fan-out timeout when the hint cannot answer the arbiter's probe", async () => {
|
|
// The hint's own main thread is silent - it may be the wedge itself - so
|
|
// there is nothing to vouch for and the fan-out's timeout stands.
|
|
const { api, probed, queried } = poisonedApi([APP, POISONER], {}, { [APP]: rpcTimeout() });
|
|
const err: unknown = await queryFullHierarchyTree(registryFor(api), IOS_DEVICE, hint(APP)).then(
|
|
() => {
|
|
throw new Error("expected the silent-hint read to rethrow the fan-out timeout");
|
|
},
|
|
(e: unknown) => e
|
|
);
|
|
expect(err).toBeInstanceOf(Error);
|
|
expect((err as Error).message).toMatch(/RPC timed out/i);
|
|
// The ORIGINAL fan-out error, not a new refusal: no foreground verdict is
|
|
// claimed for a state never observed.
|
|
expect(getFailureSignal(err)?.failure_stage).toBe("native_devtools_rpc_request");
|
|
expect(probed).toEqual([APP, POISONER, APP]); // the fan-out, then the arbiter's own probe
|
|
expect(queried).toEqual([]);
|
|
});
|
|
|
|
it("propagates an arbiter probe failure that is not a timeout", async () => {
|
|
// Only a stall lets the fan-out's timeout stand; anything the probe
|
|
// actually reports is a failure the read must not paper over.
|
|
const queried: string[] = [];
|
|
let hintProbes = 0;
|
|
const api = {
|
|
listConnectedBundleIds: () => [APP, POISONER],
|
|
isConnected: () => true,
|
|
getAppState: async (id: string) => {
|
|
if (id === POISONER) throw rpcTimeout();
|
|
// The first call is the fan-out's, sunk by the sibling; the second is
|
|
// the arbiter's own probe.
|
|
hintProbes += 1;
|
|
if (hintProbes > 1) {
|
|
throw new FailureError("boom", {
|
|
error_code: FAILURE_CODES.NATIVE_DEVTOOLS_RPC_ERROR,
|
|
failure_stage: "native_devtools_rpc_response",
|
|
failure_area: "tool_server",
|
|
error_kind: "subprocess",
|
|
});
|
|
}
|
|
return appState(id);
|
|
},
|
|
queryViewHierarchy: async (id: string) => {
|
|
queried.push(id);
|
|
return { windows: [windowSpanning()] };
|
|
},
|
|
} as unknown as NativeDevtoolsApi;
|
|
await expect(queryFullHierarchyTree(registryFor(api), IOS_DEVICE, hint(APP))).rejects.toThrow(
|
|
/boom/
|
|
);
|
|
expect(hintProbes).toBe(2);
|
|
expect(queried).toEqual([]);
|
|
});
|
|
|
|
it("refuses a com.apple.* hint with the terminal policy verdict instead of arbitrating toward it", async () => {
|
|
// A raw `tool:` step after `launch: com.apple.*` demotes the pin to this
|
|
// hint, and the system app's own wedged getState sinks the fan-out - so
|
|
// without a gate the arbiter re-targets the very id the pinned gate
|
|
// refuses.
|
|
const { api, probed, queried } = poisonedApi();
|
|
const err: unknown = await queryFullHierarchyTree(
|
|
registryFor(api),
|
|
IOS_DEVICE,
|
|
hint(POISONER)
|
|
).then(
|
|
() => {
|
|
throw new Error("expected the system-app hint to be refused");
|
|
},
|
|
(e: unknown) => e
|
|
);
|
|
expect(err).toBeInstanceOf(Error);
|
|
const message = (err as Error).message;
|
|
expect(message).toContain(`${POISONER} is an Apple system app`);
|
|
expect(message).toContain("never a valid flow target");
|
|
// Whether a `tool:` step ran must not soften the refusal.
|
|
expect(message).toContain(
|
|
"`tap: { x: 0.5, y: 0.35 }` takes a point directly and reads no tree"
|
|
);
|
|
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.NATIVE_DEVTOOLS_NOT_INJECTABLE);
|
|
// Its own stage: this one refuses after a fan-out the hint could not
|
|
// rescue, and the preserved cause carries it.
|
|
expect(getFailureSignal(err)?.failure_stage).toBe("flow_tree_unpinned_hint");
|
|
expect((err as Error).cause).toBeInstanceOf(Error);
|
|
expect(probed).toContain(POISONER); // the fan-out really did run
|
|
// The system app is never read.
|
|
expect(queried).toEqual([]);
|
|
});
|
|
|
|
it("refuses a disconnected com.apple.* hint before consulting the connections list", async () => {
|
|
// Ordering twin of the pinned case: check connections first and this state
|
|
// rethrows the raw fan-out timeout, hiding the terminal verdict behind an
|
|
// error that invites a retry.
|
|
const { api, queried } = poisonedApi([APP, OTHER], {}, { [OTHER]: rpcTimeout() });
|
|
const err: unknown = await queryFullHierarchyTree(
|
|
registryFor(api),
|
|
IOS_DEVICE,
|
|
hint(POISONER)
|
|
).then(
|
|
() => {
|
|
throw new Error("expected the disconnected system-app hint to be refused");
|
|
},
|
|
(e: unknown) => e
|
|
);
|
|
expect(err).toBeInstanceOf(Error);
|
|
expect((err as Error).message).toContain(`${POISONER} is an Apple system app`);
|
|
expect((err as Error).message).not.toMatch(/RPC timed out/i);
|
|
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.NATIVE_DEVTOOLS_NOT_INJECTABLE);
|
|
expect(queried).toEqual([]);
|
|
});
|
|
|
|
it("rethrows the timeout when the hinted app is no longer connected", async () => {
|
|
// The hint names an app that has since dropped its connection; targeting
|
|
// it would query a process that cannot answer.
|
|
const { api, queried } = poisonedApi([POISONER, OTHER]);
|
|
await expect(queryFullHierarchyTree(registryFor(api), IOS_DEVICE, hint(APP))).rejects.toThrow(
|
|
/RPC timed out/i
|
|
);
|
|
expect(queried).toEqual([]);
|
|
});
|
|
|
|
it("does not use the hint to bypass an answered backgrounded-app guard", async () => {
|
|
// Resolution ANSWERS here, so the hint must not override the guard.
|
|
const backgrounded: NativeAppState = {
|
|
...appState(APP),
|
|
applicationState: "background",
|
|
foregroundActiveSceneCount: 0,
|
|
backgroundSceneCount: 1,
|
|
isFrontmostCandidate: false,
|
|
};
|
|
const { api, queried } = poisonedApi([APP], { [APP]: backgrounded });
|
|
await expect(queryFullHierarchyTree(registryFor(api), IOS_DEVICE, hint(APP))).rejects.toThrow(
|
|
/not foreground-like/
|
|
);
|
|
expect(queried).toEqual([]);
|
|
});
|
|
|
|
it("rethrows a non-timeout resolution failure even with a connected hint", async () => {
|
|
// Only a stall is arbitrated; anything the fan-out actually reports is a
|
|
// failure the read must not paper over.
|
|
const { api, queried } = poisonedApi(
|
|
[APP, POISONER],
|
|
{},
|
|
{
|
|
[POISONER]: new FailureError("boom", {
|
|
error_code: FAILURE_CODES.NATIVE_DEVTOOLS_RPC_ERROR,
|
|
failure_stage: "native_devtools_rpc_response",
|
|
failure_area: "tool_server",
|
|
error_kind: "subprocess",
|
|
}),
|
|
}
|
|
);
|
|
await expect(queryFullHierarchyTree(registryFor(api), IOS_DEVICE, hint(APP))).rejects.toThrow(
|
|
/boom/
|
|
);
|
|
expect(queried).toEqual([]);
|
|
});
|
|
|
|
it("prefers the resolved frontmost app over a stale hint when resolution answers", async () => {
|
|
// Resolution answers with the frontmost app, and the hint - which a tool
|
|
// step may well have made stale - must not shadow it.
|
|
const backgroundedApp: NativeAppState = {
|
|
...appState(APP),
|
|
applicationState: "background",
|
|
foregroundActiveSceneCount: 0,
|
|
backgroundSceneCount: 1,
|
|
isFrontmostCandidate: false,
|
|
};
|
|
const { api, queried } = poisonedApi([APP, OTHER], { [APP]: backgroundedApp });
|
|
await queryFullHierarchyTree(registryFor(api), IOS_DEVICE, hint(APP));
|
|
expect(queried).toEqual([OTHER]);
|
|
});
|
|
});
|