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.
357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import type { Registry } from "@argent/registry";
|
|
import type { DescribeNode, DescribeTreeData } from "../../src/tools/describe/contract";
|
|
|
|
// Serve the flow tree directly (flows hard-fail rather than degrade to the AX
|
|
// tree). The mock scripts the reads to shape evidence gaps: a trusted read
|
|
// followed by blank trees or throws — the shapes where waitForCondition must
|
|
// distinguish "condition false" from "could not look" (blind-read guard for
|
|
// `hidden`, dark-tail rule for every condition).
|
|
let currentFetch: () => DescribeTreeData;
|
|
vi.mock("../../src/tools/flows/flow-tree", () => ({
|
|
fetchFlowTree: vi.fn(async (): Promise<DescribeTreeData> => currentFetch()),
|
|
}));
|
|
|
|
import { createRunFlowTool, type FlowRunResult } from "../../src/tools/flows/flow-run";
|
|
import { serializeFlow } from "../../src/tools/flows/flow-utils";
|
|
|
|
const DEVICE = "00000000-0000-0000-0000-0000000000ab"; // iOS UDID shape
|
|
let tmpDir: string;
|
|
|
|
function n(partial: Partial<DescribeNode> & { frame: DescribeNode["frame"] }): DescribeNode {
|
|
return { role: "AXOther", children: [], ...partial };
|
|
}
|
|
|
|
function screen(children: DescribeNode[]): DescribeNode {
|
|
return n({ role: "AXWindow", frame: { x: 0, y: 0, width: 1, height: 1 }, children });
|
|
}
|
|
|
|
function mockRegistry(): Registry {
|
|
return {
|
|
invokeTool: vi.fn(async (id: string) => {
|
|
if (id === "list-devices") return { devices: [] };
|
|
return { ok: true };
|
|
}),
|
|
getTool: vi.fn(() => undefined),
|
|
} as unknown as Registry;
|
|
}
|
|
|
|
async function writeFlow(name: string, yaml: Parameters<typeof serializeFlow>[0]): Promise<void> {
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
await fs.writeFile(path.join(dir, `${name}.yaml`), serializeFlow(yaml), "utf8");
|
|
}
|
|
|
|
function asRun(r: FlowRunResult | { notice: string }): FlowRunResult {
|
|
if (!("steps" in r)) throw new Error(`expected a run result, got notice: ${r.notice}`);
|
|
return r;
|
|
}
|
|
|
|
async function run(name: string): Promise<FlowRunResult> {
|
|
return asRun(
|
|
await createRunFlowTool(mockRegistry()).execute(
|
|
{},
|
|
{ name, project_root: tmpDir, device: DEVICE }
|
|
)
|
|
);
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-hidden-"));
|
|
});
|
|
afterEach(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("hidden timeout diagnostics", () => {
|
|
it("does not claim the element was still visible when the final reads were blank", async () => {
|
|
// Read 1: the spinner is visible (a trusted read — everMatched flips on).
|
|
// Every later read is an empty tree, which the blind-read guard refuses to
|
|
// trust for `hidden` once the selector has matched. The timeout reason must
|
|
// say the check could not be confirmed — not that an element the last reads
|
|
// never saw was "still visible".
|
|
let reads = 0;
|
|
currentFetch = () => {
|
|
reads++;
|
|
return {
|
|
tree:
|
|
reads === 1
|
|
? screen([
|
|
n({ identifier: "spinner", frame: { x: 0.4, y: 0.4, width: 0.2, height: 0.2 } }),
|
|
])
|
|
: screen([]),
|
|
source: "native-devtools",
|
|
};
|
|
};
|
|
|
|
await writeFlow("blank-hidden", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "assert", condition: "hidden", selector: { identifier: "spinner" } }],
|
|
});
|
|
|
|
const result = await run("blank-hidden");
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.steps[0].status).toBe("fail");
|
|
expect(result.steps[0].reason).toMatch(/could not confirm/);
|
|
expect(result.steps[0].reason).not.toMatch(/still visible/);
|
|
});
|
|
|
|
it("does not claim the element was still visible when the final reads threw", async () => {
|
|
// Same evidence gap as the blank-tree case, surfaced as a THROW: read 1
|
|
// is trusted and sees the spinner, then the tree source disconnects and
|
|
// every later fetch rejects. The stale read-1 match must not stand in as
|
|
// current evidence — the failure must say the check could not be
|
|
// confirmed (and why), not that the element was "still visible".
|
|
let reads = 0;
|
|
currentFetch = () => {
|
|
if (reads++ === 0) {
|
|
return {
|
|
tree: screen([
|
|
n({ identifier: "spinner", frame: { x: 0.4, y: 0.4, width: 0.2, height: 0.2 } }),
|
|
]),
|
|
source: "native-devtools",
|
|
};
|
|
}
|
|
throw new Error("native devtools disconnected");
|
|
};
|
|
|
|
await writeFlow("dark-hidden", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "assert", condition: "hidden", selector: { identifier: "spinner" } }],
|
|
});
|
|
|
|
const result = await run("dark-hidden");
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.steps[0].status).toBe("fail");
|
|
expect(result.steps[0].reason).toMatch(/could not confirm/);
|
|
expect(result.steps[0].reason).toMatch(/native devtools disconnected/);
|
|
expect(result.steps[0].reason).not.toMatch(/still visible/);
|
|
});
|
|
|
|
it("does not pass a hidden assert when the element was NEVER seen and the tree source is dark", async () => {
|
|
// The runner's half of the no-windows fix: a `hidden` assert whose element
|
|
// is NEVER seen, so `everMatched` never flips and the blind-read guard's
|
|
// everMatched-only backstop can't catch it — the only defense is the tree
|
|
// source refusing the read. The rejecting fetch is SCRIPTED here (this
|
|
// file mocks fetchFlowTree wholesale; the message just mirrors
|
|
// flow-ios-tree's no-windows guard, which flow-ios-tree-no-windows.test.ts
|
|
// exercises at the unit level and flow-hidden-no-windows-e2e.test.ts
|
|
// end-to-end). What this case locks in is the caller's contract with that
|
|
// upstream throw: when every fetch rejects, the assert fails with the
|
|
// outage — the /no window attached/ check pins the fetch error's text
|
|
// landing in the step reason — instead of treating an unreadable screen as
|
|
// a no-match that satisfies `hidden`. The scripted bundle id is a system
|
|
// app because this flow has no `launch:` step, so its reads are unpinned -
|
|
// the one path on which auto-resolve can still hand a connected
|
|
// `com.apple.*` process to the read.
|
|
currentFetch = () => {
|
|
throw new Error(
|
|
"getFullHierarchy returned no windows for com.apple.Preferences - it has no window attached to read"
|
|
);
|
|
};
|
|
|
|
await writeFlow("never-seen-hidden", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "assert", condition: "hidden", selector: { identifier: "General" } }],
|
|
});
|
|
|
|
const result = await run("never-seen-hidden");
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.steps[0].status).toBe("fail");
|
|
expect(result.steps[0].reason).toMatch(/could not read the UI tree/);
|
|
expect(result.steps[0].reason).toMatch(/no window attached to read/);
|
|
// Must NOT read as a confirmed-hidden pass.
|
|
expect(result.steps[0].reason).not.toMatch(/still visible/);
|
|
});
|
|
|
|
it("still reports a genuinely visible element as still visible", async () => {
|
|
currentFetch = () => ({
|
|
tree: screen([
|
|
n({ identifier: "spinner", frame: { x: 0.4, y: 0.4, width: 0.2, height: 0.2 } }),
|
|
]),
|
|
source: "native-devtools",
|
|
});
|
|
|
|
await writeFlow("stuck-spinner", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "assert", condition: "hidden", selector: { identifier: "spinner" } }],
|
|
});
|
|
|
|
const result = await run("stuck-spinner");
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.steps[0].status).toBe("fail");
|
|
expect(result.steps[0].reason).toMatch(/still visible/);
|
|
});
|
|
|
|
it("reports still visible when a mid-window throw is followed by a trusted read", async () => {
|
|
// A blip that RECOVERS: read 2 throws, but every later read is trusted
|
|
// and still shows the spinner. The final read is honest evidence, so the
|
|
// determinate "still visible" verdict stands — indeterminacy is only for
|
|
// windows whose last look at the screen was blind or failed.
|
|
let reads = 0;
|
|
currentFetch = () => {
|
|
if (reads++ === 1) throw new Error("native devtools disconnected");
|
|
return {
|
|
tree: screen([
|
|
n({ identifier: "spinner", frame: { x: 0.4, y: 0.4, width: 0.2, height: 0.2 } }),
|
|
]),
|
|
source: "native-devtools",
|
|
};
|
|
};
|
|
|
|
await writeFlow("blip-spinner", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "assert", condition: "hidden", selector: { identifier: "spinner" } }],
|
|
});
|
|
|
|
const result = await run("blip-spinner");
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.steps[0].status).toBe("fail");
|
|
expect(result.steps[0].reason).toMatch(/still visible/);
|
|
});
|
|
});
|
|
|
|
describe("dark-tail diagnostics (non-hidden conditions)", () => {
|
|
it("assert exists: reads going dark after one trusted read report indeterminate, not a stale verdict", async () => {
|
|
// Read 1: trusted, "Done" absent — the expected STARTING state of a
|
|
// wait, not evidence about the deadline. Reads 2+: the tree source dies
|
|
// for the rest of the window. A determinate "no element matched" here
|
|
// would narrate a screen nobody saw at the deadline and drop the fetch
|
|
// error entirely — the verdict must say the screen was unreadable, and
|
|
// say why.
|
|
let reads = 0;
|
|
currentFetch = () => {
|
|
if (reads++ === 0) {
|
|
return {
|
|
tree: screen([n({ label: "Home", frame: { x: 0, y: 0, width: 1, height: 0.1 } })]),
|
|
source: "native-devtools",
|
|
};
|
|
}
|
|
throw new Error("native devtools disconnected");
|
|
};
|
|
|
|
await writeFlow("dark-exists", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "assert", condition: "exists", selector: { text: "Done" } }],
|
|
});
|
|
|
|
const result = await run("dark-exists");
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.steps[0].status).toBe("fail");
|
|
expect(result.steps[0].reason).toMatch(/unreadable for the final \d+ms/i);
|
|
expect(result.steps[0].reason).toMatch(/native devtools disconnected/);
|
|
expect(result.steps[0].reason).not.toMatch(/no element matched/);
|
|
});
|
|
|
|
it("await exists: the same dark tail under an await window surfaces the fetch error", async () => {
|
|
let reads = 0;
|
|
currentFetch = () => {
|
|
if (reads++ === 0) {
|
|
return {
|
|
tree: screen([n({ label: "Home", frame: { x: 0, y: 0, width: 1, height: 0.1 } })]),
|
|
source: "native-devtools",
|
|
};
|
|
}
|
|
throw new Error("native devtools disconnected");
|
|
};
|
|
|
|
await writeFlow("dark-await", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "await", condition: "exists", selector: { text: "Done" }, timeout: 1000 }],
|
|
});
|
|
|
|
const result = await run("dark-await");
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.steps[0].status).toBe("fail");
|
|
expect(result.steps[0].reason).toMatch(/unreadable for the final \d+ms/i);
|
|
expect(result.steps[0].reason).toMatch(/native devtools disconnected/);
|
|
expect(result.steps[0].reason).not.toMatch(/no element matched/);
|
|
});
|
|
|
|
it("assert text: does not quote stale element text from before the reads went dark", async () => {
|
|
// Read 1 sees the banner saying "Loading"; then the source dies. Quoting
|
|
// `its text was "Loading"` at the deadline would present a first-poll
|
|
// snapshot as the state of a screen that was unreadable for essentially
|
|
// the whole window.
|
|
let reads = 0;
|
|
currentFetch = () => {
|
|
if (reads++ === 0) {
|
|
return {
|
|
tree: screen([
|
|
n({
|
|
identifier: "banner",
|
|
label: "Loading",
|
|
frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 },
|
|
}),
|
|
]),
|
|
source: "native-devtools",
|
|
};
|
|
}
|
|
throw new Error("native devtools disconnected");
|
|
};
|
|
|
|
await writeFlow("dark-text", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{
|
|
kind: "assert",
|
|
condition: "text",
|
|
selector: { identifier: "banner" },
|
|
expectedText: "Done",
|
|
textMatch: "contains",
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await run("dark-text");
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.steps[0].status).toBe("fail");
|
|
expect(result.steps[0].reason).toMatch(/unreadable for the final \d+ms/i);
|
|
expect(result.steps[0].reason).toMatch(/native devtools disconnected/);
|
|
expect(result.steps[0].reason).not.toMatch(/Loading/);
|
|
});
|
|
|
|
it("keeps the determinate verdict — with the error appended — when only the final polls throw", async () => {
|
|
// The deliberate trailing tolerance: trusted reads showed "Done" absent
|
|
// until ~one poll before the 1s assert deadline, so a fetch error on the
|
|
// trailing polls is a blip, not doubt — the determinate reason stands.
|
|
// The failed final read is appended rather than silently dropped (main
|
|
// surfaced `could not read the UI tree: <err>` here; losing it was a
|
|
// report-quality regression).
|
|
let firstReadAt: number | undefined;
|
|
currentFetch = () => {
|
|
firstReadAt ??= Date.now();
|
|
if (Date.now() - firstReadAt >= 950) throw new Error("native devtools disconnected");
|
|
return {
|
|
tree: screen([n({ label: "Home", frame: { x: 0, y: 0, width: 1, height: 0.1 } })]),
|
|
source: "native-devtools",
|
|
};
|
|
};
|
|
|
|
await writeFlow("blip-exists", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "assert", condition: "exists", selector: { text: "Done" } }],
|
|
});
|
|
|
|
const result = await run("blip-exists");
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.steps[0].status).toBe("fail");
|
|
expect(result.steps[0].reason).toMatch(/no element matched/);
|
|
expect(result.steps[0].reason).toMatch(
|
|
/final poll could not read the UI tree: native devtools disconnected/
|
|
);
|
|
});
|
|
});
|