import assert from "node:assert/strict"; import { mkdtemp, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { tsImport } from "tsx/esm/api"; const { runBlackBoxExploration, screenEvidenceKey, elementsOf, fieldName, systemDialogTitle } = await tsImport("../../src/binary/explorer.ts", import.meta.url); const { ExploreStore, validateMap } = await tsImport("../../src/map/store.ts", import.meta.url); const { CONTRACT } = await tsImport("../../src/types.ts", import.meta.url); const tsRun = await tsImport("../../src/binary/run.ts", import.meta.url); const { assertAppUrl, describeForeground, foreignNode, guardPositionals, leftForeground, retryWhileBusy } = tsRun; const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"); const SCREENS = { home: [{ type: "StaticText", label: "Welcome back, 12:31" }, { type: "Button", label: "Settings" }, { type: "Button", label: "Delete account" }, { type: "Button", label: "Share" }], settings: [{ type: "StaticText", label: "Settings" }, { type: "Switch", label: "Dark mode" }, { type: "Button", label: "Back" }], }; async function harness(script, options = {}) { const root = await mkdtemp(join(tmpdir(), "explore-explorer-")); const store = new ExploreStore(root); const now = new Date("2026-01-01T00:00:00Z").toISOString(); const map = { contract: CONTRACT, revision: 0, run: { id: "run-1", name: "Example", platform: "ios", controller: "agent-device", bundleId: "dev.example.app", status: "exploring", createdAt: now, updatedAt: now, build: { hash: "0".repeat(64), source: "local" }, environment: { deviceModel: "iPhone 17 Pro" }, limits: { maxScreens: 10, maxActions: 10, maxDurationMinutes: 5 } }, artifacts: [], screens: [], variants: [], observations: [], elements: [], actionAttempts: [], transitions: [], journeys: [], frontier: [], gaps: [] }; await store.initialize(map, false); let current = "home"; const acted = []; const calls = []; const session = { provider: "anthropic", model: "test", async preflight() {}, async run(request) { for (const [name, input] of script) { let result; try { result = await request.callTool(name, input, request.signal); } catch (error) { if (request.signal.aborted) return { stopReason: "aborted" }; throw error; } calls.push({ name, result: JSON.parse(result.content[0].text) }); if (request.signal.aborted) return { stopReason: "aborted" }; } return { stopReason: "done" }; } }; const result = await (options.explore ?? runBlackBoxExploration)({ session, store, map, app: { name: "Example", bundleId: "dev.example.app", platform: "ios" }, deadlineMs: Date.now() + 60_000, maxScreens: 10, maxActions: 10, maxTurns: 50, loadingSettleMs: 0, observe: async includeImage => ({ nodes: SCREENS[current], capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: `frame-${calls.length}`, png: PNG, imageSha256: `${calls.length}`.padStart(64, "a") } : {}) }), act: async action => { acted.push(action); if (action.action === "tap" && action.label === "Settings") current = "settings"; if (action.action === "back") current = "home"; }, ...options }); return { result, map, calls, acted, root, store }; } test("evidence keys ignore volatile text but not structure", () => { const key = screenEvidenceKey(SCREENS.home); assert.equal(key, screenEvidenceKey([{ type: "StaticText", label: "Welcome back, 09:05" }, ...SCREENS.home.slice(1)])); assert.notEqual(key, screenEvidenceKey(SCREENS.settings)); }); test("a scripted exploration records screens, a blocked destructive tap, a transition, and finishes", async () => { const frame = index => `frame-${index}`; const { result, map, calls, acted, root } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Home" }], ["act", { action: "tap", label: "Delete account" }], ["act", { action: "tap", label: "Settings" }], ["observe", { includeImage: true }], ["capture", { frameId: frame(4), title: "Settings" }], ["act", { action: "tap", label: "Dark mode" }], ["act", { action: "back" }], ["get_progress", {}], ["finish", { summary: "Two screens." }], ]); assert.equal(result.stopReason, "finished"); assert.equal(result.summary, "Two screens."); assert.deepEqual(map.screens.map(screen => screen.title), ["Home", "Settings"]); assert.equal(map.observations.filter(observation => observation.screenshotArtifactId).length, 2); assert.ok(map.observations.every(observation => observation.accessibilityArtifactId)); assert.ok(acted.every(action => action.label !== "Delete account"), "destructive tap never reached the device"); assert.equal(calls[2].result.blocked, true); assert.ok(map.actionAttempts.some(attempt => attempt.outcome === "blocked" && attempt.risk === "destructive")); assert.ok(map.elements.some(element => element.label === "Delete account" && element.risk === "destructive")); assert.ok(!map.frontier.some(item => item.label === "Delete account"), "destructive controls are never queued"); assert.equal(map.transitions.length, 3, "Settings, the Dark mode self-transition, and back"); assert.equal(calls[3].result.outcome, "transition"); assert.equal(calls[6].result.outcome, "same-screen"); const sameScreen = map.actionAttempts.find(attempt => attempt.outcome === "same-screen"); assert.ok(map.transitions.some(transition => transition.fromScreenId === transition.toScreenId && transition.attemptIds.includes(sameScreen.id)), "same-screen attempts belong to a self-transition"); assert.ok(map.frontier.some(item => item.label === "Settings" && item.status === "done")); for (const artifact of map.artifacts) await stat(join(root, artifact.path)); assert.equal(map.observations.filter(observation => observation.screenshotArtifactId).length, 2); // Every harness frame is the same PNG bytes, so the shots share one artifact: screenshots are hashed and deduplicated here. assert.equal(map.artifacts.filter(artifact => artifact.kind === "screenshot").length, 1); }); test("the action budget stops the run and leaves a coverage gap", async () => { const { result, map } = await harness([ ["observe", { includeImage: false }], ["act", { action: "tap", label: "Settings" }], ["act", { action: "back" }], ["act", { action: "tap", label: "Settings" }], ], { maxActions: 2 }); assert.equal(result.stopReason, "budget-actions"); assert.equal(result.actions, 2); assert.ok(map.gaps.some(gap => /actions budget/.test(gap.message))); assert.ok(map.frontier.some(item => item.status === "pending"), "unexplored controls stay pending for gap:list"); }); test("an action before any observation observes first, so the attempt has a source screen", async () => { const { map } = await harness([["act", { action: "tap", label: "Settings" }], ["finish", { summary: "Acted blind." }]]); assert.equal(map.actionAttempts.length, 1); assert.ok(map.actionAttempts[0].sourceScreenId && map.actionAttempts[0].sourceObservationId); assert.ok(map.screens.length >= 1); }); test("capture requires the latest observed frame", async () => { const { calls } = await harness([["observe", { includeImage: true }], ["capture", { frameId: "stale", title: "Nope" }], ["finish", { summary: "x" }]]); assert.equal(calls[1].result.code, "AGENT_FRAME_STALE"); }); test("controls that leave the app are blocked like destructive ones and never queued", async () => { const { map, calls, acted } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Home" }], ["act", { action: "tap", label: "Share" }], ["finish", { summary: "Done." }], ]); assert.equal(calls[2].result.blocked, true); assert.match(calls[2].result.reason, /opens another app/); assert.ok(acted.every(action => action.label !== "Share")); assert.ok(map.actionAttempts.some(attempt => attempt.outcome === "blocked" && attempt.risk === "external")); assert.ok(!map.frontier.some(item => item.label === "Share")); const { map: allowed, acted: allowedActs } = await harness([["observe", { includeImage: false }], ["act", { action: "tap", label: "Share" }], ["finish", { summary: "Done." }]], { allowDestructive: ["Share"] }); assert.ok(allowedActs.some(action => action.label === "Share"), "--allow-destructive lifts the block"); assert.ok(allowed.frontier.some(item => item.label === "Share")); }); test("an in-app Map tab is safe; a named maps app or directions leave the app", async () => { const { riskOf } = await tsImport("../../src/binary/explorer.ts", import.meta.url); for (const label of ["Map", "Maps", "Store map", "Map view"]) assert.equal(riskOf(label, []), "safe", label); for (const label of ["Open in Maps", "Apple Maps", "Google Maps", "Get directions"]) assert.equal(riskOf(label, []), "external", label); }); test("open is confined to the app's own URL schemes; system and store schemes never leave the app", () => { for (const url of ["https://example.com", "itms-apps://apps.apple.com/app/id1", "app-settings:", "prefs:root=General", "shortcuts://run", "market://details?id=x", "intent://scan#Intent;end", "tel:911", "x-apple-health://"]) { assert.throws(() => assertAppUrl(url), /AGENT_URL_NOT_ALLOWED|leave the app/, url); } assertAppUrl("euxy://settings"); assertAppUrl("euxy://settings", ["euxy", "exp+euxy"]); assert.throws(() => assertAppUrl("otherapp://x", ["euxy"]), /not a URL scheme this app registers/); assert.throws(() => assertAppUrl("not a url"), /Not a URL/); }); test("free text handed to agent-device sits behind a -- separator so it is never parsed as a flag", () => { assert.deepEqual(guardPositionals(["type", "--help", "--level", "full", "--json"]), ["type", "--level", "full", "--json", "--", "--help"]); assert.deepEqual(guardPositionals(["fill", "@e3", "-5 octaves", "--level", "full", "--json"]), ["fill", "--level", "full", "--json", "--", "@e3", "-5 octaves"]); // The controller appends --json after the caller's args; typed text must not swallow it (Rocket.Chat received "test.rocket.chat --json"). assert.deepEqual(guardPositionals(["fill", "@e3", "test.rocket.chat", "--json"]), ["fill", "--json", "--", "@e3", "test.rocket.chat"]); assert.deepEqual(guardPositionals(["type", "hello world", "--json"]), ["type", "--json", "--", "hello world"]); assert.deepEqual(guardPositionals(["press", "@e3", "--level", "full", "--json"]), ["press", "@e3", "--level", "full", "--json"]); }); test("explore:session picks the app in the foreground, else the only installed app, else asks for --bundle-id", async () => { const { resolveSessionApp } = await tsImport("../../src/binary/run.ts", import.meta.url); const connection = { sessionId: "s", daemonBaseUrl: "https://daemon.example", daemonAuthToken: "secret-token" }; const calls = []; const runner = ({ appstate, apps }) => request => { calls.push(request.args); assert.equal(request.environment.AGENT_DEVICE_DAEMON_AUTH_TOKEN, "secret-token"); if (request.args[0] === "appstate") { if (appstate instanceof Error) throw appstate; return { stdout: JSON.stringify({ success: true, data: appstate }) }; } return { stdout: JSON.stringify({ success: true, data: { apps } }) }; }; const ios = { platform: "ios", appName: "dev.brent.euxy", appBundleId: "dev.brent.euxy", source: "runner", state: "runningForeground", surface: "app" }; assert.deepEqual(resolveSessionApp(runner({ appstate: ios, apps: [] }), connection, "/tmp", "ios", "iPhone 17 Pro", "mine", {}), { bundleId: "dev.brent.euxy" }); assert.deepEqual(calls[0].slice(0, 3), ["appstate", "--session", "mine"]); assert.ok(calls[0].includes("--device") && calls[0].includes("iPhone 17 Pro")); // No agent-device session yet: only the installed list is consulted. calls.length = 0; assert.deepEqual(resolveSessionApp(runner({ apps: ["euxy (dev.brent.euxy)"] }), connection, "/tmp", "ios", "iPhone 17 Pro", undefined, {}), { bundleId: "dev.brent.euxy", name: "euxy" }); assert.deepEqual(calls.map(args => args[0]), ["apps"]); // Android foreground on the launcher is not an app; a single third-party package wins, system packages are ignored. const launcher = { platform: "android", appBundleId: "com.google.android.apps.nexuslauncher", state: "runningForeground", surface: "app" }; assert.deepEqual(resolveSessionApp(runner({ appstate: launcher, apps: ["com.android.settings", "dev.brent.euxy"] }), connection, "/tmp", "android", "", "mine", {}), { bundleId: "dev.brent.euxy" }); // A failing appstate falls through; several installed apps need --bundle-id, and the error lists them. assert.throws( () => resolveSessionApp(runner({ appstate: new Error("boom secret-token"), apps: ["A (dev.a)", "B (dev.b)"] }), connection, "/tmp", "ios", "iPhone 17 Pro", "mine", {}), error => error.code === "BUNDLE_ID_REQUIRED" && /dev\.a, dev\.b/.test(error.message) && !/secret-token/.test(error.message)); assert.throws(() => resolveSessionApp(runner({ apps: [] }), connection, "/tmp", "ios", "iPhone 17 Pro", undefined, {}), error => error.code === "BUNDLE_ID_REQUIRED"); }); test("decorative images, covered controls, and icons nested inside a control are not queued as controls of their own", async () => { const tabBar = [ { index: 0, type: "Application", label: "wtlfo" }, { index: 1, parentIndex: 0, type: "TabBar", label: "Tab Bar" }, { index: 2, parentIndex: 1, type: "Button", label: "Editor", hittable: true }, { index: 3, parentIndex: 2, type: "Image", label: "waveform", identifier: "waveform", hittable: true }, { index: 4, parentIndex: 1, type: "Button", label: "Learn", hittable: true }, { index: 5, parentIndex: 4, type: "Image", label: "bookmark", identifier: "book", hittable: true }, { index: 6, parentIndex: 1, type: "Cell", label: "Row", hittable: true }, { index: 7, parentIndex: 6, type: "Switch", label: "Enabled", hittable: true }, { index: 8, parentIndex: 0, type: "Image", label: "Hero", hittable: true, traits: ["button"] }, { index: 9, parentIndex: 0, type: "Image", label: "tempo", identifier: "metronome", hittable: true }, { index: 10, parentIndex: 0, type: "Button", label: "Covered", hittable: false }, { index: 11, parentIndex: 0, type: "Other", label: "About this app, What it does, ›", hittable: true }, { index: 12, parentIndex: 0, type: "Other", hittable: true }, { index: 13, parentIndex: 0, type: "Other", label: "Heading", hittable: false }, ]; const { map } = await harness([["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Tabs" }], ["finish", { summary: "Tabs." }]], { observe: async includeImage => ({ nodes: tabBar, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: "frame-0", png: PNG, imageSha256: "b".repeat(64) } : {}) }) }); assert.deepEqual(map.frontier.map(item => item.label).sort(), ["About this app, What it does, ›", "Editor", "Enabled", "Hero", "Learn", "Row"]); }); test("an app icon handed to the explorer becomes a screenshot artifact the run points at", async () => { const { map, store } = await harness([["finish", { summary: "Nothing." }]], { appIcon: PNG }); assert.ok(map.run.appIcon); assert.equal(map.run.appIcon.width, 1); assert.ok(map.artifacts.some(artifact => artifact.id === map.run.appIcon.id && artifact.path === map.run.appIcon.path)); assert.deepEqual(await validateMap(map, store, { checkArtifacts: true }), []); }); test("model-driven scroll and typing are recorded with the gesture and input the map schema requires", async () => { const { map, store } = await harness([ ["observe", { includeImage: false }], ["act", { action: "scroll", direction: "down" }], ["act", { action: "type", text: "hello" }], ["finish", { summary: "Scrolled and typed." }], ]); const kinds = map.actionAttempts.map(attempt => [attempt.action.kind, attempt.action.gesture?.direction, attempt.action.input?.value]); assert.deepEqual(kinds, [["swipe", "down", undefined], ["fill", undefined, "hello"]]); assert.deepEqual(await validateMap(map, store, { checkArtifacts: true }), []); }); test("a {{secret:NAME}} from the hints is typed on the device, while the model, the map, and the screenshots only hold the reference", async () => { // One module graph, so the explorer recognises the inputs' errors as its own CiError. const { createExplorationInputs } = await import("../../dist/config/exploration-inputs.js"); const { runBlackBoxExploration: explore } = await import("../../dist/binary/explorer.js"); const { readFile } = await import("node:fs/promises"); const secret = "hunter2-test-password"; const inputs = createExplorationInputs({ instructions: "Sign in with {{secret:APP_TEST_PASSWORD}}.", environment: { APP_TEST_PASSWORD: secret } }); let typed = ""; const device = []; const signIn = () => [{ type: "StaticText", label: "Sign in" }, { type: "TextField", label: "Password", value: typed }, { type: "Button", label: "Continue" }]; const { map, calls, root } = await harness([ ["observe", { includeImage: true }], ["act", { action: "fill", label: "Password", text: "{{secret:APP_TEST_PASSWORD}}" }], ["observe", { includeImage: true }], ["capture", { frameId: "frame-2", title: "Sign in filled", state: "filled" }], ["act", { action: "fill", label: "Password", text: "{{secret:OTHER_PASSWORD}}" }], ["finish", { summary: "Signed in." }], ], { explore, inputs, hints: "Sign in with {{secret:APP_TEST_PASSWORD}}.", observe: async includeImage => ({ nodes: signIn(), capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: `frame-${device.length + 1}`, png: PNG } : {}) }), act: async action => { device.push(action); if (action.action === "fill") typed = action.text; } }); assert.deepEqual(device.map(action => action.text), [secret], "only the declared reference reached the device, as its value"); assert.equal(calls[2].result.imageUnavailable.code, "CAPTURE_SENSITIVE_INPUT", "a frame showing the value keeps no picture"); assert.equal(calls[3].result.code, "CAPTURE_SENSITIVE_INPUT"); assert.equal(calls[4].result.code, "EXPLORATION_SECRET_UNDECLARED"); const attempt = map.actionAttempts.find(item => item.action.input); assert.deepEqual(attempt.action.input, { value: "{{secret:APP_TEST_PASSWORD}}", redacted: true }); assert.ok(!JSON.stringify(calls).includes(secret), "the model never sees the value"); assert.ok(!JSON.stringify(map).includes(secret)); for (const artifact of map.artifacts) assert.ok(!(await readFile(join(root, artifact.path), "utf8")).includes(secret), artifact.path); }); test("hint inputs are read before a session starts and name what to set", async () => { const { hintInputs } = await tsImport("../../src/binary/run.ts", import.meta.url); assert.deepEqual(hintInputs("Explore the settings", {}).names, []); assert.deepEqual(hintInputs("Use {{secret:APP_TEST_EMAIL}} and {{secret:APP_TEST_PASSWORD}}", { APP_TEST_EMAIL: "a@example.com", APP_TEST_PASSWORD: "pw" }).names, ["APP_TEST_EMAIL", "APP_TEST_PASSWORD"]); assert.throws(() => hintInputs("Use {{secret:APP_TEST_PASSWORD}}", {}), error => error.code === "EXPLORATION_SECRET_MISSING" && /export APP_TEST_PASSWORD=/.test(error.nextAction)); assert.throws(() => hintInputs("Use {{secret:EXPO_TOKEN}}", { EXPO_TOKEN: "t" }), { code: "EXPLORATION_SECRET_REFERENCE_INVALID" }); }); test("a screen the model reached but never captured is named after the path to it, not 'Screen N'", async () => { let current = "home", frames = 0; const { map } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Home" }], ["act", { action: "tap", label: "Settings" }], ["act", { action: "type", text: "hello" }], ["finish", { summary: "Peeked at settings." }], ], { act: async action => { if (action.action === "tap") current = "settings"; if (action.action === "type") current = "typing"; }, observe: async includeImage => ({ nodes: current === "typing" ? [...SCREENS.settings, { type: "TextField", label: "hello" }] : SCREENS[current], capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: `frame-${frames++}`, png: PNG, imageSha256: "c".repeat(64) } : {}) }) }); assert.deepEqual(map.screens.map(screen => screen.title), ["Home", "Home → Settings", "Home → Settings → typing"]); }); test("a capture titled like an existing screen folds into it as a state instead of a new screen", async () => { const { map } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Home" }], ["act", { action: "tap", label: "Settings" }], ["observe", { includeImage: true }], ["capture", { frameId: "frame-3", title: "home", state: "keyboard up" }], ["finish", { summary: "Done." }], ]); assert.deepEqual(map.screens.map(screen => screen.title), ["Home"]); // Home's capture, the shot taken on arriving at the unnamed screen, and the "keyboard up" capture that folded it. assert.equal(map.observations.filter(item => item.screenshotArtifactId).length, 3); }); test("a tap aimed at an identifier is recorded under the control's visible label", async () => { const home = [{ type: "StaticText", label: "Welcome back" }, { type: "Button", label: "Settings", identifier: "settingsBtn" }]; let current = "home"; const taps = []; const { map } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Home" }], ["act", { action: "tap", identifier: "settingsBtn" }], ["observe", { includeImage: true }], ["capture", { frameId: "frame-3", title: "Settings" }], ["finish", { summary: "Opened settings." }], ], { act: async action => { taps.push(action); if (action.action === "tap") current = "settings"; }, observe: async includeImage => ({ nodes: current === "home" ? home : SCREENS.settings, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: `frame-${current === "home" ? 0 : 3}`, png: PNG, imageSha256: "1".repeat(64) } : {}) }) }); assert.deepEqual(taps, [{ action: "tap", identifier: "settingsBtn" }]); assert.deepEqual(map.actionAttempts.map(attempt => [attempt.action.label, attempt.outcome]), [["Settings", "transition"]]); }); test("a coordinate tap is named after the control under it and completes that frontier item", async () => { const screen = [{ type: "Other", label: "Root", rect: { x: 0, y: 0, width: 390, height: 844 } }, { type: "Button", label: "Settings", rect: { x: 20, y: 100, width: 100, height: 44 } }]; const performed = []; let frames = 0; const { map } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Home" }], ["act", { action: "tap", frameId: "frame-0", x: 70 / 390, y: 122 / 844 }], ["observe", { includeImage: true }], ["capture", { frameId: "frame-1", title: "Settings" }], ["finish", { summary: "Done." }], ], { observe: async includeImage => ({ nodes: performed.length ? SCREENS.settings : screen, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: `frame-${frames}`, png: PNG, imageSha256: `${frames++}`.padStart(64, "a") } : {}) }), act: async action => { performed.push(action); } }); assert.equal(performed[0].action, "tap"); assert.ok("frameId" in performed[0], "the device still receives the coordinate tap"); const attempt = map.actionAttempts.find(item => item.action.kind === "press"); assert.equal(attempt.action.label, "Settings"); assert.ok(attempt.action.elementId, "the attempt points at the element under the tap"); assert.equal(attempt.outcome, "transition", attempt.error); assert.ok(map.frontier.some(item => item.label === "Settings" && item.status === "done")); }); test("a coordinate tap on nothing the tree knows that returns to the previous screen is recorded as Back", async () => { const performed = []; let frames = 0; const { map } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Home" }], ["act", { action: "tap", label: "Settings" }], ["observe", { includeImage: true }], ["capture", { frameId: "frame-1", title: "Settings" }], ["act", { action: "tap", frameId: "frame-1", x: 0.05, y: 0.1 }], ["finish", { summary: "Done." }], ], { observe: async includeImage => ({ nodes: performed.length === 1 ? SCREENS.settings.map(node => ({ ...node, rect: { x: 0, y: 200, width: 390, height: 44 } })) : SCREENS.home, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: `frame-${frames}`, png: PNG, imageSha256: `${frames++}`.padStart(64, "a") } : {}) }), act: async action => { performed.push(action); } }); const presses = map.actionAttempts.filter(item => item.action.kind === "press"); assert.deepEqual(presses.map(item => [item.action.label, item.outcome]), [["Settings", "transition"], ["Back", "transition"]]); assert.ok(!presses[1].action.elementId, "no element claimed the tap"); }); test("a screen seen for the first time gets its screenshot even when the model never captures it", async () => { const { map } = await harness([["observe", { includeImage: false }], ["act", { action: "tap", label: "Settings" }], ["finish", { summary: "Moved on." }]]); const settings = map.screens.find(screen => screen.id !== map.screens[0].id); assert.ok(settings, "the tap created a second screen"); const observations = map.observations.filter(observation => observation.screenId === settings.id); assert.equal(observations.length, 1); assert.ok(observations[0].screenshotArtifactId, "the new screen's first observation carries a screenshot"); assert.equal(map.observations.filter(observation => observation.screenshotArtifactId).length, 2, "the start screen is photographed on arrival too"); }); test("icon-font glyphs are stripped from labels, and a tap on such a control presses its centre", async () => { const nodes = [{ type: "Other", label: "root", rect: { x: 0, y: 0, width: 400, height: 800 } }, { type: "Other", hittable: true, label: "\ue01d, 排行榜", rect: { x: 100, y: 700, width: 200, height: 50 } }, { type: "Button", hittable: true, label: "\ue5c4" }]; const pressed = []; const { calls, map } = await harness([["observe", { includeImage: true }], ["act", { action: "tap", label: "排行榜" }], ["finish", { summary: "Glyphs." }]], { observe: async includeImage => ({ nodes, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: "frame-glyph", png: PNG } : {}) }), act: async action => { pressed.push(action); } }); assert.deepEqual(calls[0].result.nodes.map(node => node.label), ["root", "排行榜", undefined]); assert.deepEqual(map.elements.map(element => element.label), ["排行榜"]); assert.equal(pressed[0].action, "tap"); assert.ok("frameId" in pressed[0], "pressed by coordinates, not by the cleaned label the device does not know"); assert.ok(Math.abs(pressed[0].x - 0.5) < 0.01 && Math.abs(pressed[0].y - 725 / 800) < 0.01); assert.equal(map.actionAttempts[0].action.label, "排行榜"); }); test("evidence keys ignore a long, changing label on a role-less element", () => { const banner = text => [{ type: "Other", label: text, depth: 3 }, { type: "Button", label: "Home", depth: 2 }]; assert.equal(screenEvidenceKey(banner("boot award --mode console, SYSTEM READY, cat /var/log/award.log")), screenEvidenceKey(banner("boot award --mode console, SYSTEM READY, whoami and some more words"))); assert.notEqual(screenEvidenceKey(banner("Short label")), screenEvidenceKey(banner("Other label"))); }); test("a controller command refused while the runner winds down a previous one is retried, within a budget", () => { const busy = () => new Error("press: The iOS runner is still finishing a previous command that exceeded its execution watchdog."); let attempts = 0; const pauses = []; const result = retryWhileBusy(() => { attempts += 1; if (attempts < 3) throw busy(); return "ok"; }, ms => pauses.push(ms)); assert.equal(result, "ok"); assert.deepEqual(pauses, [5000, 5000]); let clock = 0; assert.throws(() => retryWhileBusy(() => { throw busy(); }, ms => { clock += ms; }, 12_000, 5_000, () => clock), /still finishing/); assert.equal(clock, 15_000, "three pauses, then the budget is spent"); assert.throws(() => retryWhileBusy(() => { throw new Error("main thread execution timed out"); }, () => assert.fail("no pause")), /timed out/); }); test("a launch-blocked reason names what appstate found in front, not just \"another app\"", () => { assert.equal(describeForeground({ appBundleId: "com.google.android.apps.nexuslauncher", state: "runningForeground", surface: "app" }), "com.google.android.apps.nexuslauncher is in front"); assert.equal(describeForeground({ surface: "home" }), "the device shows its home surface"); assert.equal(describeForeground({ appBundleId: "xyz.blueskyweb.app", surface: "home" }), "the device shows its home surface (appstate names xyz.blueskyweb.app)"); assert.equal(describeForeground({ state: "notRunning" }), "the snapshot shows another app"); }); test("the app is known to have left the foreground when appstate names another app or a non-app surface", () => { assert.equal(leftForeground({ appBundleId: "com.google.android.apps.nexuslauncher", state: "runningForeground", surface: "app" }, "xyz.blueskyweb.app"), true); assert.equal(leftForeground({ appBundleId: "xyz.blueskyweb.app", surface: "home" }, "xyz.blueskyweb.app"), true); assert.equal(leftForeground({ appBundleId: "xyz.blueskyweb.app", state: "runningForeground", surface: "app" }, "xyz.blueskyweb.app"), false); // YakiHonne asks for notifications on launch: the prompt is Android's permission controller, not another app. assert.equal(leftForeground({ appBundleId: "com.google.android.permissioncontroller", state: "runningForeground", surface: "app" }, "com.yakihonne.yakihonne"), false); assert.equal(leftForeground({ appName: "Bluesky (xyz.blueskyweb.app)", appBundleId: "Bluesky (xyz.blueskyweb.app)" }, "xyz.blueskyweb.app"), false); assert.equal(leftForeground({}, "xyz.blueskyweb.app"), false); }); test("an empty or volatile text node does not count toward screen identity", () => { const textView = { type: "android.widget.TextView", editable: false, clickable: false }; const drawer = [{ ...textView, label: "Notes", depth: 11 }, { ...textView, label: "Starred", depth: 11 }]; assert.equal(screenEvidenceKey(drawer), screenEvidenceKey([...drawer, { ...textView, label: "", depth: 12 }])); assert.equal(screenEvidenceKey(drawer), screenEvidenceKey([...drawer, { ...textView, label: "Synced 3 minutes ago", depth: 12 }])); assert.notEqual(screenEvidenceKey(drawer), screenEvidenceKey([...drawer, { type: "android.widget.Button", label: "", depth: 12 }])); }); test("every digit in a label is masked for screen identity, so a clock tick or a counter does not create a screen", () => { const screen = time => [{ type: "Other", label: "root" }, { type: "StaticText", label: `Updated ${time}` }, { type: "StaticText", label: "Connection" }]; assert.equal(screenEvidenceKey(screen("5:43")), screenEvidenceKey(screen("5:44"))); assert.equal(screenEvidenceKey(screen("12 of 30")), screenEvidenceKey(screen("13 of 31"))); }); test("Android's static TextViews are text, not fields; an editable one is", async () => { const nodes = [{ type: "android.view.ViewGroup", rect: { x: 0, y: 0, width: 400, height: 800 } }, { type: "android.widget.TextView", label: "Connection", editable: false, rect: { x: 10, y: 100, width: 200, height: 30 } }, { type: "android.widget.EditText", label: "Device name", editable: true, rect: { x: 10, y: 200, width: 200, height: 40 } }, { type: "android.widget.Button", label: "Save", rect: { x: 10, y: 300, width: 200, height: 40 } }]; const { map } = await harness([["observe", { includeImage: true }], ["finish", { summary: "Roles." }]], { observe: async includeImage => ({ nodes, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: "frame-roles", png: PNG } : {}) }) }); assert.deepEqual(map.elements.map(element => [element.label, element.role, element.actions]), [["Device name", "field", ["fill"]], ["Save", "button", ["press"]]]); }); test("a node another package drew is foreign to the app", () => { assert.equal(foreignNode({ type: "android.widget.TextView", label: "5:42", bundleId: "com.android.systemui" }, "dev.brent.euxy"), true); assert.equal(foreignNode({ type: "android.widget.TextView", label: "MIDI", bundleId: "dev.brent.euxy" }, "dev.brent.euxy"), false); assert.equal(foreignNode({ type: "StaticText", label: "MIDI" }, "dev.brent.euxy"), false); // A permission prompt's nodes are the screen while it is up, not the status bar. assert.equal(foreignNode({ type: "android.widget.Button", label: "Allow", bundleId: "com.google.android.permissioncontroller" }, "com.robosats"), false); assert.equal(foreignNode({ type: "android.widget.Button", label: "Allow", bundleId: "com.android.permissioncontroller" }, "com.robosats"), false); assert.equal(foreignNode({ type: "android.widget.TextView", label: "Gmail", bundleId: "com.google.android.apps.nexuslauncher" }, "com.robosats"), true); assert.equal(foreignNode({ type: "android.widget.TextView", label: '{"provider":"expo-router"}', identifier: "expo-explore-observer", bundleId: "dev.brent.euxy" }, "dev.brent.euxy"), true); }); test("a screenshot that is one flat colour is recognised as capture-protected", async () => { const { solidColour } = await tsImport("../../src/binary/run.ts", import.meta.url); const { PNG } = await import("pngjs"); const solid = new PNG({ width: 40, height: 60 }); solid.data.fill(0); for (let i = 3; i < solid.data.length; i += 4) solid.data[i] = 255; assert.equal(solidColour(PNG.sync.write(solid)), true); const mixed = new PNG({ width: 40, height: 60 }); mixed.data.fill(0); for (let i = 3; i < mixed.data.length; i += 4) mixed.data[i] = 255; mixed.data[4 * 97 * 3] = 200; assert.equal(solidColour(PNG.sync.write(mixed)), false); assert.equal(solidColour(Buffer.from("not a png")), false); }); test("an empty accessibility tree after an action is not a screen", async () => { let tapped = false; let observes = 0; let frames = 0; const { map, calls } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Home" }], ["act", { action: "tap", label: "Settings" }], ["observe", { includeImage: true }], ["finish", { summary: "Done." }], ], { observe: async includeImage => { observes += 1; return { nodes: tapped ? [] : SCREENS.home, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: `frame-${frames}`, png: PNG, imageSha256: `${frames++}`.padStart(64, "a") } : {}) }; }, act: async () => { tapped = true; } }); assert.equal(map.screens.length, 1, "no card was minted out of an empty tree"); assert.ok(map.screens.every(screen => !screen.id.startsWith("scr_e3b0c442")), "no screen keyed on the empty hash"); const attempt = map.actionAttempts.find(item => item.action.kind === "press"); assert.equal(attempt.outcome, "same-screen"); assert.ok(calls.some(call => call.result.emptyTree), "the model is told why nothing new was recorded"); assert.ok(observes >= 3, "the tree was read again before giving up"); }); test("a labelled Android ViewGroup (a Pressable) and a CheckedTextView are controls; their text children are not", () => { const row = (label, index) => ({ index, type: "android.view.ViewGroup", label, depth: 2, editable: false, enabled: true, parentIndex: 1 }); const nodes = [ { index: 0, type: "android.widget.FrameLayout", depth: 0, editable: false }, { index: 1, type: "android.widget.ScrollView", depth: 1, editable: false, parentIndex: 0 }, row("Networking", 2), { index: 3, type: "android.widget.TextView", label: "Networking", depth: 3, editable: false, parentIndex: 2 }, row("Privacy", 4), { index: 5, type: "android.widget.TextView", label: "Privacy", depth: 3, editable: false, parentIndex: 4 }, { index: 6, type: "android.widget.CheckedTextView", label: "Mainnet", identifier: "android:id/text1", depth: 2, editable: false, parentIndex: 1 }, { index: 7, type: "android.view.ViewGroup", depth: 2, editable: false, parentIndex: 1 }, ]; assert.deepEqual(elementsOf(nodes, []).map(element => [element.role, element.label]), [ ["other", "Networking"], ["other", "Privacy"], ["menu-item", "Mainnet"], ]); }); test("an identifier several controls share is not offered as a selector", () => { const option = (label, index) => ({ index, type: "android.widget.CheckedTextView", label, identifier: "android:id/text1", depth: 2, editable: false, parentIndex: 0 }); const nodes = [ { index: 0, type: "android.widget.ListView", identifier: "app:id/select_dialog_listview", depth: 1, editable: false }, option("Mainnet", 1), option("Testnet", 2), option("Mutinynet", 3), { index: 4, type: "android.widget.Button", label: "Cancel", identifier: "android:id/button2", depth: 2, editable: false, parentIndex: 0 }, ]; assert.deepEqual(elementsOf(nodes, []).map(element => [element.label, element.identifier]), [ ["Mainnet", undefined], ["Testnet", undefined], ["Mutinynet", undefined], ["Cancel", "android:id/button2"], ]); }); test("an Android Button with no text of its own is listed under its child TextView's caption", () => { const nodes = [ { index: 0, type: "android.widget.FrameLayout", depth: 0, editable: false }, { index: 1, type: "android.view.ViewGroup", depth: 1, editable: false, parentIndex: 0 }, { index: 2, type: "android.widget.TextView", label: "Welcome to DevHub", depth: 2, editable: false, parentIndex: 1 }, { index: 3, type: "android.widget.Button", depth: 2, editable: false, parentIndex: 1, rect: { x: 62, y: 996, width: 596, height: 103 } }, { index: 4, type: "android.widget.TextView", label: "Sign in with GitHub", depth: 3, editable: false, parentIndex: 3 }, { index: 5, type: "android.widget.Button", label: "Skip", depth: 2, editable: false, parentIndex: 1 }, { index: 6, type: "android.widget.TextView", label: "Skip for now", depth: 3, editable: false, parentIndex: 5 }, ]; assert.deepEqual(elementsOf(nodes, []).map(element => [element.role, element.label]), [ ["button", "Sign in with GitHub"], ["button", "Skip"], ]); }); test("the tree after a scroll is the same screen further down, not a new one", async () => { let scrolled = false; let frames = 0; const lower = [...SCREENS.home.filter(node => !/Settings/.test(String(node.label ?? ""))), { type: "Button", label: "Learn more", enabled: true, hittable: true, rect: { x: 10, y: 700, width: 200, height: 40 } }, { type: "StaticText", label: "Resources", rect: { x: 10, y: 660, width: 200, height: 30 } }]; const { map } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Sign in" }], ["act", { action: "scroll", direction: "down" }], ["observe", { includeImage: true }], ["finish", { summary: "Done." }], ], { observe: async includeImage => ({ nodes: scrolled ? lower : SCREENS.home, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: `frame-${frames}`, png: PNG, imageSha256: `${frames++}` } : {}) }), act: async () => { scrolled = true; } }); assert.equal(map.screens.length, 1, "scrolling did not mint a screen"); assert.equal(map.actionAttempts.find(item => item.action.kind === "swipe").outcome, "same-screen"); assert.ok(map.elements.some(element => element.screenId === map.screens[0].id && element.label === "Learn more"), "the rows the scroll revealed belong to the screen"); }); test("a list that has loaded more rows is the same screen", () => { const cell = depth => [{ type: "android.view.ViewGroup", depth }, { type: "android.view.ViewGroup", depth: depth + 1 }, { type: "android.widget.ImageView", depth: depth + 2 }]; const header = [{ type: "android.widget.TextView", label: "#photography", depth: 10 }]; const eight = [...header, ...Array.from({ length: 8 }, () => cell(20)).flat()]; const seventeen = [...header, ...Array.from({ length: 17 }, () => cell(20)).flat()]; assert.equal(screenEvidenceKey(eight), screenEvidenceKey(seventeen)); assert.notEqual(screenEvidenceKey(eight), screenEvidenceKey([{ type: "android.widget.TextView", label: "#food", depth: 10 }, ...cell(20)])); }); test("leaving the app with an empty tree on return does not mint a screen", async () => { let left = false; let frames = 0; const { map } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Home" }], ["act", { action: "back" }], ["observe", { includeImage: true }], ["finish", { summary: "Done." }], ], { observe: async includeImage => ({ nodes: left ? [] : SCREENS.home, capturedAt: new Date().toISOString(), ...(left ? { leftApp: true } : {}), ...(includeImage ? { frameId: `frame-${frames}`, png: PNG, imageSha256: `${frames++}` } : {}) }), act: async () => { left = true; } }); assert.equal(map.screens.length, 1, "no card was minted after leaving the app"); assert.equal(map.actionAttempts.find(item => item.action.kind === "back").outcome, "external"); }); test("a press agent-device refuses because the control's area belongs to its children is retried at its centre", async () => { const nodes = [{ type: "Other", label: "root", rect: { x: 0, y: 0, width: 400, height: 800 } }, { type: "android.view.ViewGroup", identifier: "HeaderMenu", label: "Menu", rect: { x: 0, y: 40, width: 100, height: 60 } }]; const pressed = []; const { calls, map } = await harness([["observe", { includeImage: true }], ["act", { action: "tap", label: "Menu" }], ["finish", { summary: "Covered." }]], { observe: async includeImage => ({ nodes, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: "frame-covered", png: PNG } : {}) }), act: async action => { pressed.push(action); if (!("frameId" in action)) throw new Error("press: Ref @e32 has no parent-owned touch point outside its interactive descendants"); } }); assert.equal(pressed.length, 2, "pressed by label, then by its centre"); assert.ok("frameId" in pressed[1] && Math.abs(pressed[1].x - 50 / 400) < 0.01 && Math.abs(pressed[1].y - 70 / 800) < 0.01); assert.equal(calls[1].result.ok, true); assert.equal(map.actionAttempts[0].outcome, "same-screen"); assert.equal(map.actionAttempts[0].error, undefined); assert.equal(map.actionAttempts[0].action.label, "Menu"); }); test("a press refused for a label that differs only by a typographic apostrophe is retried with the label agent-device names", async () => { const nodes = [{ type: "Other", label: "root", rect: { x: 0, y: 0, width: 400, height: 800 } }, { type: "android.widget.Button", label: "Don\u2019t allow", rect: { x: 0, y: 40, width: 100, height: 60 } }]; const pressed = []; const { calls, map } = await harness([["observe", { includeImage: true }], ["act", { action: "tap", label: "Don't allow" }], ["finish", { summary: "Done." }]], { observe: async includeImage => ({ nodes, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: "frame-prompt", png: PNG } : {}) }), act: async action => { pressed.push(action); if ("label" in action && action.label === "Don't allow") throw new Error('The requested UI control did not become available. The screen has a similar label: "Don\u2019t allow".'); } }); assert.equal(pressed.length, 2, "pressed by the typed label, then by the label agent-device named"); assert.equal(pressed[1].label, "Don\u2019t allow"); assert.equal(calls[1].result.ok, true); assert.equal(map.actionAttempts[0].error, undefined); }); test("a bare Android View with a roleDescription is that control; a labelled hittable View is a control too", () => { const nodes = [ { type: "android.view.View", label: "Feed", roleDescription: "Tab", hittable: true, enabled: true, rect: { x: 0, y: 0, width: 100, height: 40 } }, { type: "android.view.View", label: "Open player", hittable: true, enabled: true, rect: { x: 0, y: 50, width: 100, height: 40 } }, { type: "android.view.View", rect: { x: 0, y: 100, width: 100, height: 40 } }, ]; const elements = elementsOf(nodes, []); assert.deepEqual(elements.map(element => [element.label, element.role]), [["Feed", "tab"], ["Open player", "other"]]); }); test("a spinner or a busy control does not make a screen a different screen", () => { const idle = [{ type: "android.widget.Button", label: "Sign Up", depth: 5 }, { type: "android.widget.Button", label: "Login", depth: 5 }]; const busy = [{ type: "android.widget.Button", label: "busy", depth: 5 }, { type: "android.widget.Button", label: "Login", depth: 5 }]; assert.notEqual(screenEvidenceKey(idle), screenEvidenceKey(busy), "a renamed button is still different evidence"); const loaded = [{ type: "android.widget.TextView", label: "No follower activity yet", depth: 6 }, { type: "android.view.View", label: "Feed", roleDescription: "Tab", depth: 4 }]; const loading = [{ type: "android.widget.ProgressBar", depth: 6 }, { type: "android.view.View", label: "Feed", roleDescription: "Tab", depth: 4 }]; assert.equal(screenEvidenceKey(loading), screenEvidenceKey([{ type: "android.view.View", label: "Feed", roleDescription: "Tab", depth: 4 }]), "the spinner is not evidence"); assert.notEqual(screenEvidenceKey(loading), screenEvidenceKey(loaded)); }); test("a tree with a spinner is given a moment to load before it counts as a screen", async () => { const loading = [{ type: "android.widget.ProgressBar", rect: { x: 100, y: 300, width: 40, height: 40 } }, { type: "Button", label: "Home", hittable: true }]; const loaded = [{ type: "StaticText", label: "No follower activity yet" }, { type: "Button", label: "Home", hittable: true }, { type: "Button", label: "Global", hittable: true }]; let observed = 0; let current = SCREENS.home; const { map } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Home" }], ["act", { action: "tap", label: "Settings" }], ["observe", { includeImage: true }], ["finish", { summary: "Done." }], ], { loadingSettleMs: 1, observe: async includeImage => { observed += 1; const nodes = current === "pulse" ? (observed > 3 ? loaded : loading) : SCREENS.home; return { nodes, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: `frame-${observed}`, png: PNG, imageSha256: `${observed}`.padStart(64, "b") } : {}) }; }, act: async () => { current = "pulse"; } }); assert.equal(map.screens.length, 2, "the loading placeholder is not a screen of its own"); const pulse = map.screens.find(screen => screen.id !== map.screens[0].id); assert.ok(map.elements.some(element => element.screenId === pulse.id && element.label === "Global"), "the card has the loaded content"); }); test("a tree still loading is the screen whose evidence contains it, and the loaded tree that follows a loading-only screen is that screen", async () => { const welcome = [{ type: "Button", label: "Login", hittable: true, depth: 2 }, { type: "Button", label: "Sign Up", hittable: true, depth: 2 }, { type: "Button", label: "Skip for now", hittable: true, depth: 2 }]; const welcomeBusy = [{ type: "Button", label: "Login", hittable: true, depth: 2 }, { type: "Button", label: "busy", hittable: true, depth: 2 }, { type: "Button", label: "Skip for now", hittable: true, depth: 2 }]; const pulseLoading = [{ type: "Button", label: "Feed", hittable: true, depth: 3 }, { type: "android.widget.ProgressBar", depth: 4 }]; const pulseLoaded = [{ type: "Button", label: "Feed", hittable: true, depth: 4 }, { type: "StaticText", label: "No follower activity yet", depth: 5 }]; const trees = [welcome, welcomeBusy, pulseLoading, pulseLoaded]; let step = -1; const { map } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Welcome" }], ["act", { action: "back" }], ["observe", { includeImage: true }], ["act", { action: "tap", label: "Skip for now" }], ["observe", { includeImage: true }], ["act", { action: "tap", label: "Feed" }], ["observe", { includeImage: true }], ["finish", { summary: "Done." }], ], { observe: async includeImage => { step = Math.min(step + 1, trees.length - 1); const nodes = trees[step]; return { nodes, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: `frame-${step}`, png: PNG, imageSha256: `${step}`.padStart(64, "c") } : {}) }; } }); assert.equal(map.screens.length, 2, "busy Welcome joined Welcome; the loaded Pulse joined the loading Pulse"); assert.equal(map.actionAttempts[0].outcome, "same-screen"); assert.ok(map.elements.some(element => element.screenId === map.screens[1].id && element.label === "Feed")); }); test("an empty Android Button takes the caption that precedes it under the same parent (Expo Go)", () => { const nodes = [ { index: 0, type: "android.widget.LinearLayout", depth: 8, rect: { x: 0, y: 0, width: 400, height: 60 } }, { index: 1, parentIndex: 0, type: "android.widget.TextView", label: "OK", editable: false, depth: 9, rect: { x: 300, y: 10, width: 60, height: 40 } }, { index: 2, parentIndex: 0, type: "android.widget.Button", label: "", depth: 9, enabled: true, rect: { x: 290, y: 0, width: 80, height: 60 } }, ]; const elements = elementsOf(nodes, []); assert.deepEqual(elements.map(element => [element.label, element.role]), [["OK", "button"]]); }); test("a refused press whose label no extracted control carries is retried at the labelled node's centre", async () => { const nodes = [ { type: "Other", label: "root", rect: { x: 0, y: 0, width: 400, height: 800 } }, { type: "android.widget.TextView", label: "HELP", rect: { x: 300, y: 100, width: 80, height: 40 } }, ]; const pressed = []; const { map } = await harness( [["observe", { includeImage: true }], ["act", { action: "tap", label: "HELP" }], ["finish", { summary: "Covered." }]], { observe: async includeImage => ({ nodes, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: "frame-help", png: PNG } : {}) }), act: async action => { pressed.push(action); if (!("frameId" in action)) throw new Error("press: Ref @e21 has no parent-owned touch point outside its interactive descendants"); } }, ); assert.equal(pressed.length, 2); assert.ok("frameId" in pressed[1] && Math.abs(pressed[1].x - 340 / 400) < 0.01 && Math.abs(pressed[1].y - 120 / 800) < 0.01); assert.equal(map.actionAttempts[0].error, undefined); }); test("a text field's placeholder or typed text is not screen identity; the field is named by its accessibility label", () => { const home = (room, extra = {}) => [ { index: 0, type: "android.widget.TextView", label: "Enter room name", editable: false, depth: 3 }, { index: 1, type: "android.widget.EditText", label: room, value: room, placeholder: room, contentDescription: "Enter room name", editable: true, hintShowing: true, depth: 4, ...extra }, { index: 2, type: "android.widget.Button", label: "Join", depth: 4 }, ]; // Jitsi suggests a new random room name on every visit. assert.equal(screenEvidenceKey(home("LivelyAssistantsCountlessSweetly")), screenEvidenceKey(home("ConstitutionalWorkersGrowSelfishly"))); // Typing into the field does not make a new screen either. assert.equal(screenEvidenceKey(home("LivelyAssistantsCountlessSweetly")), screenEvidenceKey(home("standup", { placeholder: undefined, hintShowing: false }))); assert.notEqual(screenEvidenceKey(home("standup")), screenEvidenceKey(home("standup").slice(0, 2))); const [field] = elementsOf(home("LivelyAssistantsCountlessSweetly"), []).filter(element => element.role === "field"); assert.equal(field.label, "Enter room name"); // Without an accessibility label the static placeholder names the control, but still does not identify the screen. const bare = { type: "android.widget.EditText", label: "Eg: John Doe", value: "Eg: John Doe", placeholder: "Eg: John Doe", editable: true, hintShowing: true }; assert.equal(fieldName(bare), undefined); assert.equal(elementsOf([{ index: 0, ...bare }], [])[0].label, "Eg: John Doe"); assert.equal(fieldName({ type: "XCUIElementTypeTextField", label: "Email", value: "brent@expo.dev" }), "Email"); }); test("a control's caption (child or sibling text) names it before its testID, as react-native-paper's default testIDs need", () => { const paper = [ { index: 0, type: "android.view.ViewGroup", depth: 0 }, { index: 1, parentIndex: 0, type: "android.widget.Button", identifier: "icon-button", depth: 1 }, { index: 2, parentIndex: 1, type: "android.widget.TextView", label: "\u{f035c}", depth: 2, editable: false }, { index: 3, parentIndex: 0, type: "android.view.ViewGroup", depth: 1 }, { index: 4, parentIndex: 3, type: "android.widget.TextView", label: "Private key", identifier: "text-input-outlined-label-inactive", depth: 2, editable: false }, { index: 5, parentIndex: 3, type: "android.widget.EditText", label: " ", value: " ", identifier: "text-input-outlined", editable: true, depth: 2 }, { index: 6, parentIndex: 0, type: "android.widget.Button", identifier: "button", depth: 1 }, { index: 7, parentIndex: 6, type: "android.widget.TextView", label: "Access", identifier: "button-text", depth: 2, editable: false }, { index: 8, parentIndex: 0, type: "android.widget.Button", identifier: "button", depth: 1 }, { index: 9, parentIndex: 8, type: "android.widget.TextView", label: "Create keys", identifier: "button-text", depth: 2, editable: false }, ]; const labels = elementsOf(paper, []).map(element => `${element.role}:${element.label}`); assert.deepEqual(labels, ["button:icon-button", "field:Private key", "button:Access", "button:Create keys"]); }); test("a tree whose content sits one wrapper deeper is the same screen (RoboSats' welcome after back)", () => { const welcome = depth => [ { type: "android.widget.TextView", label: "A Simple and Private LN P2P Exchange", depth, editable: false }, { type: "android.widget.Button", label: "START", depth: depth + 1 }, { type: "android.widget.Button", label: "RECOVERY", depth: depth + 1 }, ]; assert.equal(screenEvidenceKey(welcome(12)), screenEvidenceKey(welcome(13))); assert.notEqual(screenEvidenceKey(welcome(12)), screenEvidenceKey(welcome(12).slice(0, 2))); }); test("a loading tree whose placeholder text differs from the loaded screen's still joins it (RoboSats' Robot Profile)", async () => { const chrome = [{ type: "android.widget.Button", label: "open menu drawer" }, { type: "android.widget.Button", label: "Robot Garage" }]; const loaded = [...chrome, { type: "android.widget.TextView", label: "No existing orders found", editable: false }]; const loading = [...chrome, { type: "android.widget.TextView", label: "Looking for orders!", editable: false }, { type: "android.widget.ProgressBar", label: "indeterminate" }]; let current = loaded; const { map } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Robot Profile" }], ["act", { action: "tap", label: "Robot Garage" }], ["observe", { includeImage: true }], ["finish", { summary: "done" }], ], { observe: async includeImage => ({ nodes: current, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: "frame-0", png: PNG, imageSha256: "b".repeat(64) } : {}) }), act: async () => { current = loading; }, }); assert.deepEqual(map.screens.map(screen => screen.title), ["Robot Profile"]); }); test("a permission prompt is one screen across Android's two asks, titled by its question", () => { const prompt = deny => [ { type: "android.widget.TextView", label: "Allow Robosats to send you notifications?", bundleId: "com.google.android.permissioncontroller", editable: false }, { type: "android.widget.Button", label: "Allow", identifier: "com.android.permissioncontroller:id/permission_allow_button", bundleId: "com.google.android.permissioncontroller" }, { type: "android.widget.Button", label: "Don’t allow", identifier: deny, bundleId: "com.google.android.permissioncontroller" }, ]; assert.equal( screenEvidenceKey(prompt("com.android.permissioncontroller:id/permission_deny_button")), screenEvidenceKey(prompt("com.android.permissioncontroller:id/permission_deny_and_dont_ask_again_button"))); assert.equal(systemDialogTitle(prompt("x")), "Allow Robosats to send you notifications?"); assert.equal(systemDialogTitle([{ type: "android.widget.TextView", label: "Welcome", editable: false }]), undefined); }); test("a hittable bare ViewGroup with no label is the pressable, named by the text inside; a layout wrapping controls is not", () => { const key = (index, parentIndex, digit) => [ { index, parentIndex, type: "android.view.ViewGroup", hittable: true, bundleId: "io.hexawallet.hexa2" }, { index: index + 1, parentIndex: index, type: "android.widget.TextView", label: digit, value: digit, editable: false, bundleId: "io.hexawallet.hexa2" }, ]; const nodes = [ { index: 0, type: "android.widget.ScrollView", hittable: true, bundleId: "io.hexawallet.hexa2" }, { index: 1, parentIndex: 0, type: "android.view.ViewGroup", hittable: true, bundleId: "io.hexawallet.hexa2" }, { index: 2, parentIndex: 1, type: "android.widget.TextView", label: "Please enter a passcode", editable: false, bundleId: "io.hexawallet.hexa2" }, ...key(3, 1, "1"), ...key(5, 1, "2"), { index: 7, parentIndex: 1, type: "android.view.ViewGroup", hittable: true, bundleId: "io.hexawallet.hexa2" }, { index: 8, parentIndex: 7, type: "android.widget.TextView", label: "\uf151", editable: false, bundleId: "io.hexawallet.hexa2" }, ]; const labels = elementsOf(nodes, []).map(element => `${element.role}:${element.label}`); assert.deepEqual(labels, ["other:1", "other:2"]); }); test("a glyph-only or letterless text node is not screen identity (passcode dots, a caret)", () => { const screen = indicator => [ { type: "android.widget.TextView", label: "Please enter a passcode", editable: false }, { type: "android.widget.TextView", label: indicator, editable: false }, { type: "android.view.ViewGroup", hittable: true }, { type: "android.widget.TextView", label: "1", editable: false }, ]; assert.equal(screenEvidenceKey(screen("|")), screenEvidenceKey(screen("\uf111"))); assert.equal(screenEvidenceKey(screen("|")), screenEvidenceKey([...screen("|"), { type: "android.widget.TextView", label: "\uf111\uf111", editable: false }])); assert.notEqual(screenEvidenceKey(screen("|")), screenEvidenceKey([...screen("|"), { type: "android.widget.TextView", label: "Proceed", editable: false }])); }); test("a press agent-device cannot find because the label was composed from several texts inside the pressable is retried at its centre", async () => { const nodes = [{ type: "Other", label: "root", rect: { x: 0, y: 0, width: 400, height: 800 } }, { index: 1, type: "android.view.ViewGroup", hittable: true, clickable: true, rect: { x: 0, y: 100, width: 400, height: 80 } }, { index: 2, parentIndex: 1, type: "android.widget.TextView", label: "Account Management", editable: false, rect: { x: 20, y: 110, width: 200, height: 30 } }, { index: 3, parentIndex: 1, type: "android.widget.TextView", label: "View and manage your accounts", editable: false, rect: { x: 20, y: 140, width: 300, height: 30 } }]; const pressed = []; const { calls, map } = await harness([["observe", { includeImage: true }], ["act", { action: "tap", label: "Account Management View and manage your accounts" }], ["finish", { summary: "Done." }]], { observe: async includeImage => ({ nodes, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: "frame-composed", png: PNG } : {}) }), act: async action => { pressed.push(action); if (!("frameId" in action)) throw new Error("The requested UI control did not become available and enabled within the setup budget."); } }); assert.equal(pressed.length, 2, "pressed by label, then by its centre"); assert.ok("frameId" in pressed[1] && Math.abs(pressed[1].x - 200 / 400) < 0.01 && Math.abs(pressed[1].y - 140 / 800) < 0.01); assert.equal(calls[1].result.ok, true); assert.equal(map.actionAttempts[0].error, undefined); }); test("the same controls with a field focused (keyboard up, text that no longer fits dropped) are the screen in its editing state", async () => { const field = focused => ({ type: "android.widget.EditText", label: "Enter wallet name", editable: true, focused, rect: { x: 20, y: 200, width: 360, height: 48 } }); const idle = [{ type: "android.widget.TextView", label: "New Wallet Creation", editable: false }, field(false), { type: "android.widget.TextView", label: "Note", editable: false }, { type: "android.widget.TextView", label: "Wallet name is used in the messages you send.", editable: false }]; const editing = [{ type: "android.widget.TextView", label: "New Wallet Creation", editable: false }, field(true)]; let typing = false; let frames = 0; const { map } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Name wallet" }], ["act", { action: "tap", label: "Enter wallet name" }], ["observe", { includeImage: true }], ["finish", { summary: "Done." }], ], { observe: async includeImage => ({ nodes: typing ? editing : idle, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: `frame-${frames}`, png: PNG, imageSha256: `${frames++}` } : {}) }), act: async () => { typing = true; } }); assert.equal(map.screens.length, 1, "focusing the field did not mint a screen"); assert.equal(map.actionAttempts[0].outcome, "same-screen"); }); test("a tree read while a text node was still missing, photographed identically to a screen already seen, is that screen (AudioScape's playlist title)", async () => { const tabs = [{ type: "android.view.View", label: "Home", hittable: true, editable: false }, { type: "android.view.View", label: "Library", hittable: true, editable: false }]; const titled = [{ type: "android.widget.TextView", label: "My Playlist", editable: false }, ...tabs]; const untitled = [...tabs]; const library = [{ type: "android.widget.TextView", label: "Library", editable: false }, { type: "android.view.View", label: "My Playlist", hittable: true, editable: false }, ...tabs]; const PLAYLIST = Buffer.concat([PNG, Buffer.from([1])]); let visits = 0; let frames = 0; let where = "library"; const trees = { library, titled, untitled }; const { map } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Library" }], ["act", { action: "tap", label: "My Playlist" }], ["observe", { includeImage: true }], ["act", { action: "back" }], ["observe", { includeImage: true }], ["act", { action: "tap", label: "My Playlist" }], ["observe", { includeImage: true }], ["finish", { summary: "Done." }], ], { observe: async includeImage => ({ nodes: trees[where], capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: `frame-${frames}`, png: where === "library" ? PNG : PLAYLIST, imageSha256: `${frames++}` } : {}) }), act: async action => { if (action.action === "back") where = "library"; else where = visits++ === 0 ? "titled" : "untitled"; } }); assert.equal(map.screens.length, 2, "the second read of the playlist did not mint a screen"); assert.equal(map.observations.filter(item => item.screenId === map.screens[1].id).length, 2, "both reads are observations of the one playlist screen"); assert.equal(map.actionAttempts.filter(item => item.outcome === "transition").length, 3); }); test("a coordinate tap the model describes is named by that description (MangaReader's header icons)", async () => { const home = [ { type: "android.widget.FrameLayout", label: "", hittable: true, rect: { x: 0, y: 0, width: 412, height: 915 } }, { type: "android.widget.Button", label: "\ue8b6", hittable: true, rect: { x: 340, y: 90, width: 48, height: 48 } }, { type: "android.widget.TextView", label: "漫画收藏为空~", editable: false, rect: { x: 100, y: 500, width: 200, height: 30 } }, ]; let frames = 0; const { map } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Home" }], ["act", { action: "tap", frameId: "frame-0", x: 364 / 412, y: 114 / 915, target: "search icon in the header" }], ["observe", { includeImage: true }], ["capture", { frameId: "frame-1", title: "Search" }], ["finish", { summary: "Done." }], ], { observe: async includeImage => ({ nodes: frames > 0 ? SCREENS.settings : home, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: `frame-${frames}`, png: PNG, imageSha256: `${frames++}`.padStart(64, "c") } : {}) }), act: async () => {} }); const attempt = map.actionAttempts.find(item => item.action.kind === "press"); assert.equal(attempt.action.label, "search icon in the header"); assert.equal(attempt.outcome, "transition", attempt.error); }); test("a coordinate tap with no description on an icon-font button is named by what it is and where it sits", async () => { const home = [ { type: "android.widget.FrameLayout", label: "", hittable: true, rect: { x: 0, y: 0, width: 412, height: 915 } }, { type: "android.widget.Button", label: "\ue8b6", hittable: true, rect: { x: 340, y: 90, width: 48, height: 48 } }, { type: "android.widget.TextView", label: "漫画收藏为空~", editable: false, rect: { x: 100, y: 500, width: 200, height: 30 } }, ]; let frames = 0; const { map } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Home" }], ["act", { action: "tap", frameId: "frame-0", x: 364 / 412, y: 114 / 915 }], ["observe", { includeImage: true }], ["capture", { frameId: "frame-1", title: "Search" }], ["finish", { summary: "Done." }], ], { observe: async includeImage => ({ nodes: frames > 0 ? SCREENS.settings : home, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: `frame-${frames}`, png: PNG, imageSha256: `${frames++}`.padStart(64, "d") } : {}) }), act: async () => {} }); const attempt = map.actionAttempts.find(item => item.action.kind === "press"); assert.equal(attempt.action.label, "icon button, top right"); }); test("with no --provider, model access falls through API keys and local logins to the first that passes preflight", async () => { const { preflightModelSession } = await tsImport("../../src/binary/run.ts", import.meta.url); const tried = []; const working = "anthropic/local-login"; const createSession = (config, { auth }) => ({ async preflight() { tried.push(`${config.provider}/${auth}`); if (`${config.provider}/${auth}` !== working) throw new Error(`${config.provider} ${auth} refused`); } }); const said = []; const model = await preflightModelSession({ createSession }, { OPENAI_API_KEY: "key" }, message => said.push(message)); assert.deepEqual(tried, ["openai/api-key", "anthropic/local-login"]); assert.equal(model.provider, "anthropic"); assert.equal(model.auth, "local-login"); assert.match(said.join("\n"), /openai API key unavailable \(openai api-key refused\)/); }); test("an explicit --provider is the only model access tried, and its preflight error is the one reported", async () => { const { preflightModelSession } = await tsImport("../../src/binary/run.ts", import.meta.url); const tried = []; const createSession = (config, { auth }) => ({ async preflight() { tried.push(`${config.provider}/${auth}`); throw new Error("refused"); } }); await assert.rejects(preflightModelSession({ provider: "openai", createSession }, { ANTHROPIC_API_KEY: "key" }, () => {}), /refused/); assert.deepEqual(tried, ["openai/local-login"]); }); test("when nothing passes preflight, the first failure is reported", async () => { const { preflightModelSession } = await tsImport("../../src/binary/run.ts", import.meta.url); const createSession = (config, { auth }) => ({ async preflight() { throw new Error(`${config.provider} ${auth} refused`); } }); await assert.rejects(preflightModelSession({ createSession }, {}, () => {}), /anthropic local-login refused/); }); test("a landscape frame on a portrait panel (an opened iPhone Duo) is stored upright", async () => { const { createRequire } = await import("node:module"); const { PNG: Png } = createRequire(import.meta.url)("pngjs"); const raw = new Png({ width: 1, height: 2 }); raw.data.set([255, 0, 0, 255, 0, 0, 255, 255]); const png = Png.sync.write(raw); const { map, root } = await harness([["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Home" }], ["finish", { summary: "One." }]], { observe: async includeImage => ({ nodes: SCREENS.home, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: "frame-0", png, width: 1, height: 2, landscape: true } : {}) }) }); const artifact = map.artifacts.find(item => item.kind === "screenshot"); assert.deepEqual([artifact.width, artifact.height], [2, 1]); const { readFile } = await import("node:fs/promises"); const stored = Png.sync.read(await readFile(join(root, artifact.path))); // The panel's top (red) is the UI's right-hand side once turned a quarter clockwise. assert.deepEqual([...stored.data], [0, 0, 255, 255, 255, 0, 0, 255]); }); /** A PNG of `width` × `height` RGBA pixels, each given as [r, g, b, a] row by row. */ async function pngOf(width, height, pixels) { const { createRequire } = await import("node:module"); const { PNG: Png } = createRequire(import.meta.url)("pngjs"); const image = new Png({ width, height }); image.data.set(pixels.flat()); return Png.sync.write(image); } test("an opened Duo's frame shows the same pixels as a screen already seen once turned, so it is that screen (AudioScape's playlist title, rotated)", async () => { const tabs = [{ type: "Button", label: "Home", hittable: true }, { type: "Button", label: "Library", hittable: true }]; const library = [{ type: "StaticText", label: "Library" }, { type: "Button", label: "My Playlist", hittable: true }, ...tabs]; const trees = { library, titled: [{ type: "StaticText", label: "My Playlist" }, ...tabs], untitled: [...tabs] }; const images = { library: await pngOf(1, 2, [[255, 0, 0, 255], [0, 0, 255, 255]]), playlist: await pngOf(1, 2, [[0, 255, 0, 255], [0, 0, 255, 255]]) }; let visits = 0; let frames = 0; let where = "library"; const { map } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Library" }], ["act", { action: "tap", label: "My Playlist" }], ["observe", { includeImage: true }], ["act", { action: "back" }], ["observe", { includeImage: true }], ["act", { action: "tap", label: "My Playlist" }], ["observe", { includeImage: true }], ["finish", { summary: "Done." }], ], { observe: async includeImage => ({ nodes: trees[where], capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: `frame-${frames++}`, png: Buffer.from(images[where === "library" ? "library" : "playlist"]), width: 1, height: 2, landscape: true } : {}) }), act: async action => { if (action.action === "back") where = "library"; else where = visits++ === 0 ? "titled" : "untitled"; } }); assert.equal(map.screens.length, 2, "the second read of the playlist did not mint a screen"); assert.equal(map.observations.filter(item => item.screenId === map.screens[1].id).length, 2, "both reads are observations of the one playlist screen"); assert.equal(map.artifacts.filter(item => item.kind === "screenshot").length, 2); }); test("a coordinate tap on an opened Duo is named by where it shows in the upright screenshot", async () => { // The inner panel's portrait axes: a button near the portrait top right is at the upright bottom right. const home = [ { type: "Application", label: "", rect: { x: 0, y: 0, width: 669, height: 951 } }, { type: "Button", label: "", hittable: true, rect: { x: 600, y: 40, width: 44, height: 44 } }, ]; let frames = 0; let tapped = false; // The look without an image carries no landscape flag; the screen's stored screenshot still says it was turned. const { map } = await harness([ ["observe", { includeImage: true }], ["capture", { frameId: "frame-0", title: "Home" }], ["observe", { includeImage: false }], ["act", { action: "tap", frameId: "frame-0", x: 622 / 669, y: 62 / 951 }], ["finish", { summary: "Done." }], ], { observe: async includeImage => ({ nodes: tapped ? SCREENS.settings : home, capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: `frame-${frames++}`, png: await pngOf(1, 2, [[255, 0, 0, 255], [0, 0, 255, 255]]), width: 1, height: 2, landscape: true } : {}) }), act: async () => { tapped = true; } }); assert.equal(map.actionAttempts.find(item => item.action.kind === "press").action.label, "icon button, bottom right"); assert.deepEqual(map.artifacts.find(item => item.kind === "screenshot").turned, { width: 669, height: 951 }); }); test("turnedClockwise puts the portrait top on the right and keeps the colour chunks", async () => { const { turnedClockwise, assertDecodedPng } = await tsImport("../../src/shared/png-evidence.ts", import.meta.url); const { createRequire } = await import("node:module"); const { PNG: Png } = createRequire(import.meta.url)("pngjs"); const { crc32 } = await import("node:zlib"); // 3 wide and 2 tall; each pixel's red is its index, so where it lands says where it came from. const plain = await pngOf(3, 2, [0, 1, 2, 3, 4, 5].map(index => [index * 40, 0, 0, 255])); const chunk = (type, data) => { const body = Buffer.concat([Buffer.from(type, "latin1"), data]), out = Buffer.alloc(body.length + 8); out.writeUInt32BE(data.length, 0); body.copy(out, 4); out.writeUInt32BE(crc32(body), body.length + 4); return out; }; const gamma = Buffer.alloc(4); gamma.writeUInt32BE(45455); const source = Buffer.concat([plain.subarray(0, 33), chunk("sRGB", Buffer.from([0])), chunk("gAMA", gamma), plain.subarray(33)]); const turned = turnedClockwise(source); assertDecodedPng(turned, "Turned"); const decoded = Png.sync.read(turned); assert.deepEqual([decoded.width, decoded.height], [2, 3]); // Source (x, y) lands at (height - 1 - y, x): the top row (0, 1, 2) runs down the right-hand column. const red = [...Array(6).keys()].map(index => decoded.data[index * 4] / 40); assert.deepEqual(red, [3, 0, 4, 1, 5, 2]); const types = []; for (let offset = 8; offset < turned.length; offset += 12 + turned.readUInt32BE(offset)) types.push(turned.toString("latin1", offset + 4, offset + 8)); assert.deepEqual(types.slice(0, 3), ["IHDR", "sRGB", "gAMA"]); assert.ok(turned.includes(gamma), "the gamma is the source's"); }); test("only an opened Duo's frame on its inner panel's portrait axes is turned upright", () => { const { duoScreenshotTurn } = tsRun; const root = (width, height) => ({ type: "Application", label: "Expo Go", rect: { x: 0, y: 0, width, height } }); const frame = (deviceModel, nodes, width, height, platform = "ios") => duoScreenshotTurn({ platform, deviceModel, nodes, width, height }); const cases = [ ["Duo open", frame("iPhone Duo", [root(669, 951)], 2007, 2853), "turn"], ["Duo half-open", frame("iPhone Duo", [root(669, 951), { type: "Button", label: "Home", rect: { x: 20, y: 40, width: 44, height: 44 } }], 2007, 2853), "turn"], ["Duo closed", frame("iPhone Duo", [root(466, 678)], 1398, 2034), "keep"], ["iPhone 17 Pro", frame("iPhone 17 Pro", [root(402, 874)], 1206, 2622), "keep"], ["an iPhone in landscape", frame("iPhone 17 Pro", [root(874, 402)], 2622, 1206), "keep"], ["iPad", frame("iPad Pro 13-inch (M5)", [root(1032, 1376)], 2064, 2752), "keep"], ["Android", frame("Pixel 9", [root(669, 951)], 2007, 2853, "android"), "keep"], ["an empty tree", frame("iPhone Duo", [], 2007, 2853), "keep"], ["a first node that isn't the Application", frame("iPhone Duo", [{ type: "Window", rect: { x: 0, y: 0, width: 300, height: 200 } }, root(669, 951)], 2007, 2853), "turn"], ["a root off by a fraction of a point", frame("iPhone Duo", [root(669.33, 951)], 2008, 2853), "turn"], ["an already-landscape image", frame("iPhone Duo", [root(669, 951)], 2853, 2007), "keep"], ["the inner panel on its own axes", frame("iPhone Duo", [root(951, 669)], 2853, 2007), "keep"], ["a Duo root on neither panel", frame("iPhone Duo", [root(700, 1000)], 2100, 3000), "unmatched"], ["a Duo root the image does not show", frame("iPhone Duo", [root(669, 951)], 2000, 2000), "unmatched"], ]; for (const [name, actual, expected] of cases) assert.equal(actual, expected, name); }); test("the agent API lists a turned screenshot's element bounds on its upright axes", async () => { const { agentApiReply } = await tsImport("../../src/report/agent-api.ts", import.meta.url); const sha = "a".repeat(64); const map = { screens: [{ id: "scr_home", title: "Home", observationIds: ["obs_1"] }], observations: [{ id: "obs_1", screenshotArtifactId: "art_1" }], artifacts: [{ id: "art_1", kind: "screenshot", sha256: sha, width: 2853, height: 2007, turned: { width: 669, height: 951 } }], elements: [{ id: "el_1", screenId: "scr_home", label: "Settings", role: "button", bounds: { x: 600, y: 40, width: 44, height: 30, unit: "points" } }], transitions: [], variants: [], gaps: [] }; const loaded = { kind: "exploration", file: "exploration.json", raw: new Uint8Array(), json: { map }, entries: new Map(), read: async () => undefined }; const reply = await agentApiReply({ pageUrl: "https://example.test/p/token", platforms: ["ios"], expiresAt: null }, loaded, "/screens/scr_home", new URLSearchParams(), "application/json"); const body = JSON.parse(await new Response(reply.body).text()); // Portrait (600, 40) on a 951-point-tall screen shows at (951 - 40 - 30, 600) upright, near the bottom right. assert.deepEqual(body.elements[0].bounds, { x: 881, y: 600, width: 30, height: 44, unit: "points" }); }); const { assertReplayFits, createReplaySession, parseReplayPlan, replaySetup } = await tsImport("../../src/binary/replay.ts", import.meta.url); function replayDevice(screens = SCREENS) { let screen = "home", frames = 0; const acted = []; return { acted, options: { observe: async includeImage => ({ nodes: screens[screen], capturedAt: new Date().toISOString(), ...(includeImage ? { frameId: `replay-${++frames}`, png: PNG, imageSha256: `${frames}`.padStart(64, "b") } : {}) }), act: async action => { acted.push(action); if (action.label === "Missing" || action.label === "Nowhere") throw new Error(`No element labelled ${action.label}`); if (action.action === "tap" && action.label === "Settings") screen = "settings"; if (action.action === "tap" && action.label === "Dark mode" && screens.settingsDark) screen = "settingsDark"; if (action.action === "back") screen = "home"; }, } }; } test("a replay plan takes the same steps on each run: platform-only steps are skipped, optional failures are passed over and listed", async () => { const plan = parseReplayPlan({ steps: [ { capture: "Home" }, { action: "tap", label: "Missing", optional: true }, { action: "tap", label: "Android only", platforms: ["android"] }, { action: "tap", label: "Settings" }, { capture: "Settings" }, { action: "tap", label: "Dark mode" }, { capture: "Settings", state: "dark" }, { action: "back" }, ] }); const device = replayDevice(); const { result, map } = await harness([], { session: createReplaySession(plan, "ios"), ...device.options }); assert.equal(result.stopReason, "finished"); assert.match(result.summary, /Every planned screen was captured\. Optional steps skipped because they failed: step 2 \(tap "Missing"\)\.$/); assert.deepEqual(map.screens.map(screen => screen.title), ["Home", "Settings"]); assert.ok(!device.acted.some(action => action.label === "Android only")); assert.ok(map.variants.some(variant => variant.name === "dark"), "the dark state is recorded as a variant"); }); test("a failed replay step stops the run, keeps what was captured, and names the screens it did not reach", async () => { const plan = parseReplayPlan({ steps: [ { capture: "Home" }, { action: "tap", label: "Settings" }, { capture: "Settings" }, { action: "back" }, { action: "tap", label: "Nowhere" }, { capture: "Never" }, { capture: "Android screen", platforms: ["android"] }, ] }); const device = replayDevice(); const { result, map } = await harness([], { session: createReplaySession(plan, "ios"), ...device.options }); assert.equal(result.stopReason, "blocked"); assert.deepEqual(map.screens.map(screen => screen.title), ["Home", "Settings"]); assert.match(result.summary, /Replay step 5 \(tap "Nowhere"\) failed: .*No element labelled Nowhere.*Not captured: Never$/); }); test("a blocked step names the screens still ahead before earlier misses, within stop_blocked's limit", async () => { const plan = parseReplayPlan({ steps: [ { capture: "Home" }, ...Array.from({ length: 20 }, (_, index) => ({ capture: `Earlier miss ${index} ${"x".repeat(60)}` })), { action: "tap", label: "Nowhere" }, { capture: "Ahead" }, ] }); const device = replayDevice(); const { result } = await harness([], { session: createReplaySession(plan, "ios"), ...device.options, maxTurns: 100 }); assert.equal(result.stopReason, "blocked"); assert.ok(result.summary.length <= 1000, `${result.summary.length} characters`); assert.match(result.summary, /Not captured: Ahead, Earlier miss 0 .* and \d+ more$/); }); test("a destructive or app-leaving tap in a replay plan is refused, as a model's would be", async () => { const plan = parseReplayPlan({ steps: [ { capture: "Home" }, { action: "tap", label: "Share", optional: true }, { action: "tap", label: "Delete account" }, { capture: "Gone" }, ] }); const device = replayDevice(); const { result, map } = await harness([], { session: createReplaySession(plan, "ios"), ...device.options }); assert.equal(result.stopReason, "blocked"); assert.match(result.summary, /^Replay step 3 \(tap "Delete account"\) failed: "Delete account" looks destructive.*Not captured: Gone$/); assert.deepEqual(device.acted, [], "neither tap reaches the device"); assert.deepEqual(map.actionAttempts.map(attempt => [attempt.action.label, attempt.outcome]), [["Share", "blocked"], ["Delete account", "blocked"]]); }); test("a replay plan is checked before any device starts: unknown, hostile and malformed fields are refused", () => { const refused = (step, pattern) => assert.throws(() => parseReplayPlan({ steps: [step] }), error => error.code === "REPLAY_PLAN_INVALID" && pattern.test(error.message), JSON.stringify(step)); assert.throws(() => parseReplayPlan({ steps: [] }), /no steps/); assert.throws(() => parseReplayPlan({ steps: [{ capture: "Home" }], step: [] }), /field "step" it does not take/); refused({ action: "tap" }, /exactly one of label or identifier/); refused({ action: "swipe" }, /needs a capture title or an action/); refused({ action: "constructor" }, /needs a capture title or an action/); refused({ capture: "Home", platforms: ["web"] }, /platforms must list ios and\/or android/); // A coordinate tap's fields would turn a label tap into a tap whose risk comes from `target`. refused({ action: "tap", label: "Delete account", frameId: "frame-1", x: 0.2, y: 0.15, target: "Next" }, /^Step 1 \(tap\) has a field "frameId" it does not take; a tap step takes action, label, identifier, platforms, optional\.$/); refused({ action: "tap", label: "Next", platform: ["ios"] }, /field "platform"/); refused({ action: "tap", label: "Next", optinal: true }, /field "optinal"/); refused({ capture: "Home", optional: true }, /\(capture\) has a field "optional"/); refused({ capture: "Home", action: "tap", label: "Next" }, /\(capture\) has a field "action"/); refused({ action: "back", label: "Back" }, /\(back\) has a field "label"/); refused({ action: "scroll", direction: ["up"] }, /scroll needs a direction/); refused({ action: "scroll", direction: "sideways" }, /scroll needs a direction/); refused({ action: "tap", label: "x".repeat(257) }, /label must be 1–256 characters without control characters/); refused({ action: "tap", identifier: "row\nnext" }, /identifier must be 1–256 characters/); refused({ action: "fill", label: "Name", text: "two\nlines" }, /fill needs text: one line/); refused({ action: "type", text: "x".repeat(2001) }, /type needs text/); refused({ action: "type", text: "{{secret:lowercase}}" }, /invalid secret reference/); refused({ action: "type", text: "{{secret:OPENAI_API_KEY}}" }, /cannot reference provider/); refused({ action: "wait", milliseconds: 15_001 }, /between 1 and 15000/); assert.deepEqual(parseReplayPlan({ steps: [{ capture: "Home" }] }).steps, [{ capture: "Home" }]); assert.deepEqual(parseReplayPlan({ steps: [{ action: "scroll", direction: "down", optional: true, platforms: ["ios"] }] }).steps, [{ action: "scroll", direction: "down", platforms: ["ios"], optional: true }]); }); test("a replay plan with no steps for the platform, or more than the budgets hold, is refused before any device starts", () => { const limits = { maxScreens: 40, maxActions: 150, maxTurns: 400 }; assert.throws(() => assertReplayFits(parseReplayPlan({ steps: [{ capture: "Home", platforms: ["android"] }, { action: "back", platforms: ["android"] }] }), "ios", limits), error => error.code === "REPLAY_PLAN_EMPTY" && /No step of the replay plan runs on ios/.test(error.message)); const plan = parseReplayPlan({ steps: [{ capture: "Home" }, { action: "tap", label: "Settings" }, { capture: "Settings" }, { action: "back" }, { action: "wait", milliseconds: 100 }] }); assert.doesNotThrow(() => assertReplayFits(plan, "ios", limits)); assert.throws(() => assertReplayFits(plan, "ios", { ...limits, maxScreens: 2, maxActions: 1 }), error => error.code === "REPLAY_PLAN_OVER_BUDGET" && /2 captures, which need a screen budget of at least 3 \(it is 2\) and 2 actions, more than the action budget of 1/.test(error.message) && /--max-screens 3 --max-actions 2/.test(error.nextAction)); assert.throws(() => assertReplayFits(plan, "ios", { ...limits, maxTurns: 8 }), /takes 9 tool calls, more than the 8/); }); test("a replay that runs out of budget names the planned screens it did not reach in the gap", async () => { const plan = parseReplayPlan({ steps: [{ capture: "Home" }, { action: "tap", label: "Settings" }, { capture: "Settings" }, { action: "back" }, { capture: "Later" }] }); const device = replayDevice(); const session = createReplaySession(plan, "ios"); const { result, map } = await harness([], { session, budgetNote: session.budgetNote, ...device.options, maxScreens: 2 }); assert.equal(result.stopReason, "budget-screens"); assert.match(map.gaps.at(-1).message, /^The exploration stopped at its screens budget with \d+ controls unexplored\. Not captured: Settings, Later\.$/); }); test("a capture of a screen the plan already captured under another title is reported and does not rename it", async () => { const plan = parseReplayPlan({ steps: [ { capture: "Home" }, { capture: "Start" }, { capture: "home" }, { action: "tap", label: "Settings" }, { action: "back" }, ] }); const device = replayDevice(); const { result, map } = await harness([], { session: createReplaySession(plan, "ios"), ...device.options }); assert.equal(result.stopReason, "finished"); assert.deepEqual(map.screens.map(screen => screen.title), ["Home", "Home → Settings"]); assert.match(result.summary, /Not captured: Start \(looked the same as Home\)\. Also reached, not in the plan: 1 unnamed screen\.$/); }); test("a planned state that the explorer merges into an earlier screen is neither reported as unplanned nor left in the map", async () => { // The README's own example: Dark mode changes the tree, and the capture titled Settings folds that new screen into Settings. const screens = { ...SCREENS, settingsDark: [...SCREENS.settings, { type: "Button", label: "Theme options" }] }; const plan = parseReplayPlan({ steps: [ { capture: "Home" }, { action: "tap", label: "Settings" }, { capture: "Settings" }, { action: "tap", label: "Dark mode" }, { capture: "Settings", state: "dark" }, ] }); const device = replayDevice(screens); const { result, map } = await harness([], { session: createReplaySession(plan, "ios"), ...device.options }); assert.equal(result.stopReason, "finished"); assert.deepEqual(map.screens.map(screen => screen.title), ["Home", "Settings"]); const settings = map.screens.find(screen => screen.title === "Settings"); assert.ok(map.variants.some(variant => variant.screenId === settings.id && variant.name === "dark")); assert.equal(result.summary, "Replayed 5 planned steps on ios. Every planned screen was captured."); }); test("a long replay summary is kept within finish's limit, the most useful names first", async () => { const titles = Array.from({ length: 60 }, (_, index) => `Planned screen ${String(index).padStart(2, "0")} ${"y".repeat(60)}`); const plan = parseReplayPlan({ steps: [{ capture: "Home" }, ...titles.map(title => ({ capture: title }))] }); const device = replayDevice(); const { result } = await harness([], { session: createReplaySession(plan, "ios"), ...device.options, maxTurns: 200 }); assert.equal(result.stopReason, "finished"); assert.ok(result.summary.length <= 4000, `${result.summary.length} characters`); assert.match(result.summary, /^Replayed 61 planned steps on ios\. Not captured: Planned screen 00 y+ \(looked the same as Home\), .* and \d+ more\.$/); }); test("a refused finish ends the replay with stop_blocked instead of a turn budget, and a refused stop is an error", async () => { const plan = parseReplayPlan({ steps: [{ action: "back" }] }); const calls = []; const request = refuse => ({ signal: new AbortController().signal, callTool: async (name, input) => { calls.push({ name, input }); const value = name === "get_progress" ? { captured: [] } : refuse.includes(name) ? { ok: false, code: "AGENT_ACTION_INVALID", message: `${name} refused` } : { ok: true }; return { content: [{ type: "text", text: JSON.stringify(value) }], ...(refuse.includes(name) ? { isError: true } : {}) }; } }); await createReplaySession(plan, "ios").run(request(["finish"])); assert.deepEqual(calls.map(call => call.name), ["act", "get_progress", "finish", "stop_blocked"]); assert.match(calls.at(-1).input.reason, /^The replay could not finish \(finish refused\)\. Replayed 1 planned steps on ios\./); await assert.rejects(createReplaySession(plan, "ios").run(request(["finish", "stop_blocked"])), { code: "REPLAY_STOP_REFUSED" }); }); test("a replay plan's {{secret:NAME}} inputs are read before any device starts and typed for the plan", async () => { const { runBinaryExploration, runSessionExploration } = await tsImport("../../src/binary/run.ts", import.meta.url); const { createExplorationInputs } = await tsImport("../../src/config/exploration-inputs.ts", import.meta.url); const plan = parseReplayPlan({ steps: [{ capture: "Home" }, { action: "fill", label: "Password", text: "{{secret:APP_TEST_PASSWORD}}" }] }); const output = await mkdtemp(join(tmpdir(), "explore-replay-")); for (const run of [runBinaryExploration, runSessionExploration]) { await assert.rejects(run({ archive: join(output, "missing.apk"), output, replay: plan, environment: {} }), error => error.code === "EXPLORATION_SECRET_MISSING" && /APP_TEST_PASSWORD/.test(error.message)); await assert.rejects(run({ archive: join(output, "missing.apk"), output, replay: plan, environment: {}, provider: "openai", hints: "Look around" }), error => error.code === "REPLAY_OPTIONS_IGNORED" && /--provider and --hints would do nothing/.test(error.message)); await assert.rejects(run({ archive: join(output, "missing.apk"), output, replay: { steps: [{ action: "tap", label: "Next", optinal: true }] }, environment: {} }), { code: "REPLAY_PLAN_INVALID" }); await assert.rejects(run({ archive: join(output, "missing.apk"), output, replay: parseReplayPlan({ steps: [{ action: "open", url: "https://example.com" }] }), environment: {} }), error => error.code === "REPLAY_PLAN_INVALID" && /^Step 1: https: URLs leave the app/.test(error.message)); } const device = replayDevice(); const inputs = createExplorationInputs({ setup: replaySetup(plan), environment: { APP_TEST_PASSWORD: "hunter2-test" } }); const { result, map } = await harness([], { session: createReplaySession(plan, "ios"), inputs, ...device.options }); assert.equal(result.stopReason, "finished"); assert.deepEqual(device.acted, [{ action: "fill", label: "Password", text: "hunter2-test" }]); assert.equal(map.actionAttempts[0].action.input.value, "{{secret:APP_TEST_PASSWORD}}"); assert.ok(!JSON.stringify(map).includes("hunter2-test")); });