Files
explore/test/node/binary-explorer.test.mjs
devin-ai-integration[bot]andbrent e47399440f Store opened iPhone Duo screenshots upright in explore:binary/session (#51)
Co-authored-by: brent <[email protected]>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 12:18:17 -07:00

1210 lines
92 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: "[email protected]", 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: "[email protected]" }), "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"));
});