@aarwitz/tapp 0.17.0 → 0.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -418,9 +418,24 @@ export async function buildAndroidApp({ projectDir, gradleProjectDir, moduleDir,
418
418
  const wrapper = path.join(gradleRoot, process.platform === "win32" ? "gradlew.bat" : "gradlew");
419
419
  const command = fs.existsSync(wrapper) ? (process.platform === "win32" ? wrapper : "bash") : "gradle";
420
420
  const args = fs.existsSync(wrapper) && process.platform !== "win32" ? [wrapper, task, "--no-daemon"] : [task, "--no-daemon"];
421
- const build = await runCommand(command, args, { cwd: gradleRoot, timeoutMs: 25 * 60 * 1000 });
421
+ const { resolveJavaRuntime } = await import("./environment-preflight.js");
422
+ const java = resolveJavaRuntime();
423
+ if (!java) return { error:"Android source builds need a working Java runtime. Install JDK 17 or set JAVA_HOME; an already-built APK can still be tested directly." };
424
+ const { resolveAndroidSdkRoot } = await import("./android-driver.js");
425
+ const androidSdkRoot = resolveAndroidSdkRoot();
426
+ if (!androidSdkRoot) return { error:"Android source builds need an Android SDK root. Install SDK platform-tools or set ANDROID_SDK_ROOT/ANDROID_HOME; an already-built APK can still be tested directly." };
427
+ const build = await runCommand(command, args, {
428
+ cwd:gradleRoot,
429
+ timeoutMs:25 * 60 * 1000,
430
+ env:{
431
+ JAVA_HOME:java.javaHome,
432
+ ANDROID_HOME:androidSdkRoot,
433
+ ANDROID_SDK_ROOT:androidSdkRoot,
434
+ PATH:`${path.dirname(java.javaPath)}${path.delimiter}${process.env.PATH || ""}`,
435
+ },
436
+ });
422
437
  if (build.code !== 0) {
423
- const errors = `${build.stdout}\n${build.stderr}`.split("\n").filter((line) => /(?:error|failure|exception)/i.test(line)).slice(-10);
438
+ const errors = `${build.stdout}\n${build.stderr}`.split("\n").filter((line) => /(?:error|failure|exception|sdk location|could not|not found)/i.test(line)).slice(-10);
424
439
  return { error: `Android build failed (${task})${build.timedOut ? " — timed out" : ""}`, details: { errors, tail: (build.stderr || build.stdout || "").slice(-1800) } };
425
440
  }
426
441
  const apkPath = androidApkCandidates(moduleRoot)[0];
@@ -428,6 +443,34 @@ export async function buildAndroidApp({ projectDir, gradleProjectDir, moduleDir,
428
443
  return { apkPath, task, gradleProjectDir: gradleRoot, moduleDir: moduleRoot };
429
444
  }
430
445
 
446
+ /** Resolve one reviewed Android target from the repository model and build its installable APK. */
447
+ export async function prepareAndroidInteractiveTarget({ projectDir = process.cwd(), target = "", onStatus = () => {}, build = buildAndroidApp } = {}) {
448
+ let root;
449
+ try { root = fs.realpathSync(path.resolve(projectDir)); }
450
+ catch { return { error:`Repository directory not found: ${projectDir}` }; }
451
+ const modelPath = existingProjectArtifactPath(root, "application-model.json");
452
+ if (!fs.existsSync(modelPath)) return { error:"No application model found — run `npx -y @aarwitz/tapp@latest init . --explore --platform android` first." };
453
+ let model;
454
+ try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
455
+ catch (error) { return { error:`Application model is unreadable: ${error.message || String(error)}` }; }
456
+ const { selectApplicationTarget } = await import("./ci-setup.js");
457
+ let selected;
458
+ try { selected = selectApplicationTarget(model, { platform:"android", target, useDefault:true }); }
459
+ catch (error) { return { error:error.message || String(error), details:error.details || {} }; }
460
+ const appId = String(selected.runtime?.applicationId || "").trim();
461
+ if (!appId) return { error:`The Android target '${selected.name}' has no confirmed application id — rerun init or provide --app-id.` };
462
+ const task = selected.build?.task || "assembleDebug";
463
+ onStatus(`Building the Android APK for ${selected.name} (${task})…`);
464
+ const built = await build({
465
+ projectDir:root,
466
+ gradleProjectDir:path.resolve(root, selected.build?.projectDir || "."),
467
+ moduleDir:path.resolve(root, selected.sourcePath || "."),
468
+ task,
469
+ });
470
+ if (built.error) return built;
471
+ return { ...built, appId, selectedTarget:selected };
472
+ }
473
+
431
474
  export async function installAppOnBootedSim(appPath, { cleanInstall = true } = {}) {
432
475
  const bid = await runCommand("/usr/libexec/PlistBuddy", ["-c", "Print CFBundleIdentifier", path.join(appPath, "Info.plist")], { timeoutMs: 30_000 });
433
476
  const bundleId = (bid.stdout || "").trim();
@@ -557,9 +600,59 @@ function treeSnapshot() {
557
600
  screenTitle: t ? t.screenTitle ?? null : null,
558
601
  elementCount: t ? (t.elements || []).length : 0,
559
602
  elements: t ? t.elements || [] : [],
603
+ ...(t?.url ? { url:t.url } : {}),
604
+ };
605
+ }
606
+
607
+ /** Keep the semantic/actionable accessibility surface an agent needs, without sending hundreds
608
+ * of empty native hierarchy containers on every turn. The complete tree remains in the active
609
+ * session for selector resolution; this is only the public MCP projection. */
610
+ export function agentFacingElements(elements, limit = 160) {
611
+ const result = [];
612
+ const seen = new Set();
613
+ for (const element of elements || []) {
614
+ const semanticValues = [
615
+ element?.id, element?.identifier, element?.label, element?.text,
616
+ element?.description, element?.placeholder, element?.value,
617
+ ].map((value) => String(value ?? "").trim()).filter(Boolean);
618
+ const roleAndType = `${element?.role || ""} ${element?.type || ""}`.toLowerCase();
619
+ // Unlabelled input/button controls are still actionable by coordinate. Empty windows,
620
+ // applications, groups, images and generic containers are implementation noise.
621
+ const actionableWithoutText = /button|link|textfield|securetext|textarea|edittext|switch|checkbox/.test(roleAndType);
622
+ if (!semanticValues.length && !actionableWithoutText) continue;
623
+ const frame = element?.frame || {};
624
+ const key = JSON.stringify([
625
+ roleAndType, ...semanticValues,
626
+ element?.x ?? frame.x ?? null, element?.y ?? frame.y ?? null,
627
+ element?.w ?? frame.width ?? null, element?.h ?? frame.height ?? null,
628
+ ]);
629
+ if (seen.has(key)) continue;
630
+ seen.add(key);
631
+ result.push(element);
632
+ if (result.length >= Math.max(1, limit)) break;
633
+ }
634
+ return result;
635
+ }
636
+
637
+ function agentScreenProjection(screen) {
638
+ const all = screen?.elements || [];
639
+ const elements = agentFacingElements(all);
640
+ return {
641
+ screenTitle:screen?.screenTitle ?? null,
642
+ elementCount:elements.length,
643
+ totalElementCount:all.length,
644
+ elementsOmitted:Math.max(0, all.length - elements.length),
645
+ elements,
646
+ ...(screen?.url ? { url:screen.url } : {}),
560
647
  };
561
648
  }
562
649
 
650
+ function focusMetadata(result) {
651
+ if (!result) return null;
652
+ const { elements:_elements, elementCount:_elementCount, screenTitle:_screenTitle, url:_url, ...metadata } = result;
653
+ return metadata;
654
+ }
655
+
563
656
  async function startSession(bundleId, extraEnv = {}) {
564
657
  if (activeSession && !activeSession.ended) {
565
658
  return { error: "A session is already active; call tapp_session_end first.", screen: treeSnapshot() };
@@ -578,7 +671,7 @@ async function startSession(bundleId, extraEnv = {}) {
578
671
  env: { ...process.env, ...extraEnv, OCQA_SESSION_CMD_PATH: cmdPath, OCQA_SESSION_RESULT_PATH: resultPath, OCQA_SESSION_TIMEOUT: "7200" },
579
672
  });
580
673
  activeSession = {
581
- proc, bundleId, seq: 0, cmdPath, resultPath, latestTree: null, treeVersion: 0, buffer: "", ready: false, ended: false,
674
+ platform:"ios", proc, bundleId, seq: 0, cmdPath, resultPath, latestTree: null, treeVersion: 0, buffer: "", ready: false, ended: false,
582
675
  // Always-on recorder: each act appends a Flow step; tapp_flow_save snapshots it to a file.
583
676
  recording: [],
584
677
  creds: { email: extraEnv.OCQA_TEST_EMAIL || "", password: extraEnv.OCQA_TEST_PASSWORD || "" },
@@ -636,24 +729,26 @@ async function webSessionSnapshot(page) {
636
729
  const rect = element.getBoundingClientRect();
637
730
  return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
638
731
  };
639
- const controls = [...document.querySelectorAll("button,a[href],input,textarea,select,[role=button],[role=tab],[role=checkbox],[role=switch]")]
732
+ const controls = [...document.querySelectorAll("button,a[href],input,textarea,select,[role=button],[role=tab],[role=checkbox],[role=switch],[role=status],[role=alert],h2,h3,p,li")]
640
733
  .filter((element) => element instanceof HTMLElement && visible(element))
641
734
  .slice(0, 250)
642
735
  .map((element) => {
643
736
  const rect = element.getBoundingClientRect();
644
737
  const label = String(element.getAttribute("aria-label") || element.labels?.[0]?.textContent || element.textContent || element.getAttribute("placeholder") || element.getAttribute("name") || element.id || "").replace(/\s+/g, " ").trim().slice(0, 160);
738
+ const interactive = element.matches("button,a[href],input,textarea,select,[role=button],[role=tab],[role=checkbox],[role=switch]");
645
739
  return {
646
740
  id: element.getAttribute("data-testid") || element.id || element.getAttribute("name") || "",
647
741
  label,
648
742
  type: element.getAttribute("role") || element.tagName.toLowerCase(),
649
- role: element.getAttribute("role") || (element.matches("button,[role=button]") ? "button" : element.matches("a") ? "link" : element.matches("input,textarea,select") ? "input" : "other"),
743
+ role: element.getAttribute("role") || (element.matches("button,[role=button]") ? "button" : element.matches("a") ? "link" : element.matches("input,textarea,select") ? "input" : "text"),
650
744
  enabled: !(element.disabled || element.getAttribute("aria-disabled") === "true"),
651
- hittable: true,
745
+ hittable: interactive,
652
746
  clickable: element.matches("button,a,[role=button],[role=tab],[role=checkbox],[role=switch]") && !(element.disabled || element.getAttribute("aria-disabled") === "true"),
653
747
  secure: element instanceof HTMLInputElement && element.type === "password",
654
748
  frame: { x:Math.round(rect.x), y:Math.round(rect.y), width:Math.round(rect.width), height:Math.round(rect.height) },
655
749
  };
656
- });
750
+ })
751
+ .filter((element) => element.label);
657
752
  const heading = document.querySelector("h1,[role=heading]")?.textContent?.replace(/\s+/g, " ").trim();
658
753
  return { screenTitle:heading || document.title || location.pathname || "Web application", elements:controls, url:location.href };
659
754
  });
@@ -710,6 +805,32 @@ async function startWebSession(url, { testEmail = "", testPassword = "" } = {})
710
805
  }
711
806
  }
712
807
 
808
+ /**
809
+ * Start a persistent web session from an owned repository target. The managed runtime belongs to
810
+ * the session and is stopped by endSession(), so MCP/CLI callers cannot strand a development
811
+ * server when focus succeeds, fails, or the client explicitly ends the session.
812
+ */
813
+ export async function startManagedWebInteractiveSession({
814
+ projectDir = process.cwd(), requestedTarget = "", timeout,
815
+ testEmail = "", testPassword = "", onStatus = () => {},
816
+ } = {}) {
817
+ let root;
818
+ try { root = fs.realpathSync(path.resolve(projectDir)); }
819
+ catch { return { error:`Repository directory not found: ${projectDir}` }; }
820
+ const runtime = await startManagedWebTarget({ root, requestedTarget, timeout, onStatus });
821
+ if (runtime.error) return runtime;
822
+ const session = await startWebSession(runtime.url, { testEmail, testPassword });
823
+ if (session.error) {
824
+ await stopManagedWebTarget(runtime);
825
+ return session;
826
+ }
827
+ if (activeSession?.platform === "web") activeSession.managedRuntime = runtime;
828
+ return {
829
+ ...session,
830
+ managedRuntime:{ url:runtime.url, logPath:runtime.logPath, start:runtime.start },
831
+ };
832
+ }
833
+
713
834
  /** Turn a typed value into a shareable token: known creds become $TEST_EMAIL / $TEST_PASSWORD. */
714
835
  function templateValue(text) {
715
836
  const c = (activeSession && activeSession.creds) || {};
@@ -858,6 +979,7 @@ async function sessionAct(cmd) {
858
979
  }
859
980
  if (activeSession.platform === "android") {
860
981
  const s = activeSession;
982
+ const beforeAction = s.latestTree;
861
983
  let status = "ok";
862
984
  let detail = null;
863
985
  let typedInto = null;
@@ -889,21 +1011,21 @@ async function sessionAct(cmd) {
889
1011
  status = "not_found"; detail = "Could not identify email and password fields";
890
1012
  } else {
891
1013
  const er = await s.driver.type(emailField.id || emailField.label, cmd.email || s.creds.email || "", s.latestTree);
892
- s.latestTree = await s.driver.settle();
1014
+ s.latestTree = await s.driver.settle(2200, s.latestTree);
893
1015
  const pr = await s.driver.type(passwordField.id || passwordField.label, cmd.password || s.creds.password || "", s.latestTree);
894
- s.latestTree = await s.driver.settle();
1016
+ s.latestTree = await s.driver.settle(2200, s.latestTree);
895
1017
  const submit = s.latestTree.elements.find((e) => e.clickable && /sign in|log in|login|continue/i.test(`${e.text} ${e.label} ${e.id}`));
896
1018
  if (er.status !== "ok" || pr.status !== "ok" || !submit) {
897
1019
  status = "not_found"; detail = "Could not fill or submit the login form";
898
1020
  } else {
899
1021
  const before = s.latestTree.screenTitle;
900
1022
  await s.driver.tap(submit.id || submit.description || submit.text, s.latestTree);
901
- s.latestTree = await s.driver.settle();
1023
+ s.latestTree = await s.driver.settle(2200, s.latestTree);
902
1024
  if (s.latestTree.screenTitle === before) { status = "still_on_login"; detail = "Submit left the app on the login screen"; }
903
1025
  }
904
1026
  }
905
1027
  }
906
- if (!["wait", "tree", "screenshot", "login"].includes(cmd.action)) s.latestTree = await s.driver.settle();
1028
+ if (!["wait", "tree", "screenshot", "login"].includes(cmd.action)) s.latestTree = await s.driver.settle(2200, beforeAction);
907
1029
  } catch (error) {
908
1030
  status = "error"; detail = error.message || String(error);
909
1031
  }
@@ -948,6 +1070,7 @@ async function endSession() {
948
1070
  if (s.platform === "web") {
949
1071
  activeSession = null;
950
1072
  await s.browser.close().catch(() => {});
1073
+ if (s.managedRuntime) await stopManagedWebTarget(s.managedRuntime).catch(() => {});
951
1074
  return { ok:true };
952
1075
  }
953
1076
  if (s.platform === "android") {
@@ -1026,6 +1149,79 @@ export {
1026
1149
  endSession as endInteractiveSession,
1027
1150
  };
1028
1151
 
1152
+ function elementMatchesSemanticTarget(element, value) {
1153
+ const target = String(value || "").trim().toLowerCase();
1154
+ if (!target) return false;
1155
+ return [element?.id, element?.identifier, element?.label, element?.text, element?.description]
1156
+ .map((item) => String(item || "").trim().toLowerCase())
1157
+ .some((item) => item === target || item.includes(target) || target.includes(item));
1158
+ }
1159
+
1160
+ /** Locate a requested surface from owned source, reconcile it with the runtime-observed UI Map,
1161
+ * and execute the shortest replayable route in the active session. Source may identify intent but
1162
+ * never authorizes a tap: only observed/validated map edges are executed. */
1163
+ export async function focusInteractiveSession({ projectDir = process.cwd(), query, platform = "", mapPath = "" } = {}) {
1164
+ const { locateFocusedTarget } = await import("./focused-navigation.js");
1165
+ const sessionPlatform = activeSession?.platform || platform || "";
1166
+ const location = locateFocusedTarget({
1167
+ projectDir, query, platform:sessionPlatform || platform, mapPath,
1168
+ currentScreen:activeSession?.latestTree?.screenTitle || "",
1169
+ });
1170
+ if (!activeSession || activeSession.ended) return { ...location, executed:false, execution:{ status:"not-started", reason:"No active session; start the target app, then focus it." } };
1171
+ if (location.navigation?.status !== "replayable") return { ...location, executed:false, execution:{ status:"blocked", reason:location.navigation?.reason || "No observed route" }, ...treeSnapshot() };
1172
+
1173
+ const executedSteps = [];
1174
+ if (location.navigation.mode === "direct-web-route") {
1175
+ if (activeSession.platform !== "web") return { ...location, executed:false, execution:{ status:"blocked", reason:"A web route cannot be used in a native session" }, ...treeSnapshot() };
1176
+ try {
1177
+ const current = new URL(activeSession.page.url());
1178
+ const destination = new URL(location.navigation.route, current.origin);
1179
+ if (destination.origin !== current.origin) throw new Error("Observed route left the active app origin");
1180
+ await activeSession.page.goto(destination.href, { waitUntil:"domcontentloaded", timeout:15_000 });
1181
+ await activeSession.page.waitForLoadState("networkidle", { timeout:3_000 }).catch(() => {});
1182
+ activeSession.latestTree = await webSessionSnapshot(activeSession.page);
1183
+ activeSession.treeVersion += 1;
1184
+ executedSteps.push({ action:"open", target:location.navigation.route, status:"ok", screenTitle:activeSession.latestTree.screenTitle });
1185
+ } catch (error) {
1186
+ return { ...location, executed:true, execution:{ status:"failed", steps:executedSteps, reason:error.message || String(error) }, ...treeSnapshot() };
1187
+ }
1188
+ } else {
1189
+ for (const step of location.navigation.steps || []) {
1190
+ const action = step.action?.type === "back" ? "back" : "tap";
1191
+ const candidates = [
1192
+ ...(step.action?.selectors || [])
1193
+ .sort((left, right) => {
1194
+ const order = ["testId", "accessibilityId", "resourceId", "cssId", "label"];
1195
+ const rank = (kind) => { const index = order.indexOf(kind); return index < 0 ? order.length : index; };
1196
+ return rank(left.kind) - rank(right.kind);
1197
+ })
1198
+ .map((selector) => selector.value),
1199
+ step.action?.target,
1200
+ ].filter(Boolean);
1201
+ const target = candidates.find((candidate) => (activeSession.latestTree?.elements || []).some((element) => elementMatchesSemanticTarget(element, candidate))) || candidates[0] || "";
1202
+ const result = await sessionAct(action === "back" ? { action } : { action, id:target });
1203
+ executedSteps.push({ action, target, status:result.status, screenTitle:result.screenTitle, durationMs:result.durationMs });
1204
+ if (result.error || result.status !== "ok") return {
1205
+ ...location, executed:true,
1206
+ execution:{ status:"failed", steps:executedSteps, reason:result.error || result.detail || `Observed route step '${target}' did not succeed` },
1207
+ ...treeSnapshot(),
1208
+ };
1209
+ }
1210
+ }
1211
+ const targetControls = location.target?.matchedControls || [];
1212
+ const reachedByTitle = location.target?.name && String(treeSnapshot().screenTitle || "").toLowerCase() === String(location.target.name).toLowerCase();
1213
+ const reachedByControl = targetControls.some((control) => (activeSession.latestTree?.elements || []).some((element) =>
1214
+ [control.id, control.label, control.semanticKey, ...(control.selectors || []).map((selector) => selector.value)].some((value) => elementMatchesSemanticTarget(element, value))
1215
+ ));
1216
+ const reached = reachedByTitle || reachedByControl;
1217
+ return {
1218
+ ...location, executed:true,
1219
+ execution:{ status:reached ? "reached" : "route-completed-unconfirmed", steps:executedSteps,
1220
+ ...(!reached ? { reason:"The observed route completed, but the requested title/control was not visible in the final tree." } : {}) },
1221
+ ...treeSnapshot(),
1222
+ };
1223
+ }
1224
+
1029
1225
  export async function captureInteractiveSessionFrame(maxWidth = 900) {
1030
1226
  if (!activeSession || activeSession.ended) return { error:"No active interactive session" };
1031
1227
  if (activeSession.platform === "android") {
@@ -1333,6 +1529,14 @@ export function explorationEnvFromArgs(args) {
1333
1529
  if (args.prExplorationTarget && typeof args.prExplorationTarget === "object" && !Array.isArray(args.prExplorationTarget)) {
1334
1530
  env.OCQA_PR_TARGET_JSON = JSON.stringify(args.prExplorationTarget);
1335
1531
  }
1532
+ // Local visual clients can reveal their preview at the same foreground/settled boundary used by
1533
+ // native recording. Keep this host handshake in the OS temp directory; it is not app evidence or
1534
+ // a caller-directed repository write.
1535
+ if (isNonEmptyString(args.visualReadyPath)) {
1536
+ const readyPath = path.resolve(args.visualReadyPath.trim());
1537
+ const tempRoot = path.resolve(os.tmpdir());
1538
+ if (readyPath.startsWith(`${tempRoot}${path.sep}`)) env.OCQA_VISUAL_READY_PATH = readyPath;
1539
+ }
1336
1540
  // Explicit login replay: a recorded sequence run before exploration, for custom login UIs the
1337
1541
  // heuristic preamble can't parse — the #1 reason a real app stays invisible. Steps are
1338
1542
  // {action: type|tap|wait, target, value?, timeoutMs?}; $TEST_EMAIL/$TEST_PASSWORD substituted
@@ -2193,6 +2397,7 @@ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
2193
2397
  text:
2194
2398
  `${goal}.${target} Use the connected Tapp tools on the real UI surface. ` +
2195
2399
  "Use the smallest operation that satisfies the request; initialize/explore the source repo only for a general repository test. " +
2400
+ "For a named screen/control, pass the exact request as session focus or call tapp_focus so Tapp uses source + its observed UI Map instead of wandering. " +
2196
2401
  "If Tapp returns multiple target choices, ask me to select one instead of guessing. " +
2197
2402
  "Read visual evidence before describing it. Report findings, coverage, authority, inconclusive state, and checked/not-checked scope; " +
2198
2403
  "never turn exploration into a score or ship verdict. Do not edit the app unless I ask for a fix.",
@@ -2380,6 +2585,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2380
2585
  testPassword: { type: "string", description: "Password for the login preamble" },
2381
2586
  interactive: { type: "boolean", description: "Host-with-a-human only (e.g. the VS Code extension): pause at input screens and wait for values via interactiveResponsePath. Plain agents: omit." },
2382
2587
  interactiveResponsePath: { type: "string", description: "File path the prompting host answers on (requests appear at <path>.request)" },
2588
+ visualReadyPath: { type: "string", description: "Local-client temp path written once the iOS target is foregrounded and settled, so previews exclude build/install footage" },
2383
2589
  inputOverrides: {
2384
2590
  type: "object",
2385
2591
  additionalProperties: { type: "string" },
@@ -2828,10 +3034,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2828
3034
  name: "tapp_session_start",
2829
3035
  title: "Start interactive session",
2830
3036
  description:
2831
- "Start a PERSISTENT interactive session against an installed iOS or Android app. The app " +
3037
+ "Start a PERSISTENT interactive session against an installed iOS/Android app, a web URL, or an " +
3038
+ "unambiguous managed web target in the MCP workspace. The app " +
2832
3039
  "launches once and stays up, so you can drive a Playwright-style tap → inspect loop without a cold " +
2833
3040
  "launch per action. Returns the initial screen {screenTitle, elements[]}. Drive it with " +
2834
- "tapp_session_act and finish with tapp_session_end. Only one session at a time. Starts from a " +
3041
+ "tapp_focus for any named destination (source + shortest observed route), then tapp_session_act only for remaining actions; finish with tapp_session_end. " +
3042
+ "For a focused user request, ALWAYS pass it in `focus` so the session reaches that surface before returning. Only one session at a time. Starts from a " +
2835
3043
  "fresh launch. Use appLaunchArgs/appLaunchEnv for apps that need a backend override or login bypass. " +
2836
3044
  "When you reach a screen with input fields and don't have values for them, ASK THE USER what to type " +
2837
3045
  "(offer defaults/skip) before typing — the session does not prompt on its own.",
@@ -2841,6 +3049,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2841
3049
  authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
2842
3050
  appBundleId: { type: "string", description: "Bundle id of the installed app to drive" },
2843
3051
  androidAppId: { type: "string", description: "Android application id to drive (alternative to appBundleId)" },
3052
+ url: { type: "string", description: "Owned http(s) web app URL to drive (alternative to appBundleId/androidAppId); omit all three target identifiers to build/start an unambiguous owned web target from projectDir" },
3053
+ target: { type: "string", description: "Optional managed-web target name/path when projectDir contains multiple browser applications" },
2844
3054
  apkPath: { type: "string", description: "Android APK to install before starting" },
2845
3055
  androidSerial: { type: "string", description: "Android adb device serial" },
2846
3056
  clearData: { type: "boolean", default: true, description: "Android: clear app data before launch" },
@@ -2848,6 +3058,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2848
3058
  testPassword: { type: "string", description: "Password available to the app/harness" },
2849
3059
  appLaunchArgs: { type: "array", items: { type: "string" }, description: "Launch arguments, e.g. [\"--uitesting\"]" },
2850
3060
  appLaunchEnv: { type: "object", additionalProperties: { type: "string" }, description: "Launch environment, e.g. {\"UI_TEST_BACKEND\": \"staging\"}" },
3061
+ focus: { type: "string", description: "Exact focused UI goal/control/screen to locate from repository source and reach through the shortest observed UI Map route before returning" },
3062
+ projectDir: { type: "string", description: "Repository root for source-connected focus; defaults to the MCP workspace" },
3063
+ mapPath: { type: "string", description: "Optional repository-relative UI Map for focus; defaults to .tapp/ui-map.json" },
3064
+ },
3065
+ },
3066
+ },
3067
+ {
3068
+ name: "tapp_focus",
3069
+ title: "Locate and reach a requested UI surface",
3070
+ description:
3071
+ "The FAST source-connected path for focused tasks. Locate a requested screen/control from owned repository source, reconcile it with Tapp's runtime-observed UI Map, and—when a session is active—execute the shortest replayable route in one call. Use before step-by-step tapping when the user names a destination such as 'Save storefront settings'. Source identifies intent; Tapp never invents navigation from source, and only observed/validated UI Map edges are executed. Without an active session this returns the grounded location/route plan without acting.",
3072
+ inputSchema: {
3073
+ type: "object",
3074
+ required: ["query"],
3075
+ properties: {
3076
+ authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
3077
+ query: { type: "string", description: "User's requested screen, control, or focused UI task" },
3078
+ projectDir: { type: "string", description: "Repository root; defaults to the MCP workspace" },
3079
+ platform: { type: "string", enum: ["ios", "android", "web"], description: "Optional without an active session; an active session is authoritative" },
3080
+ mapPath: { type: "string", description: "Optional repository-relative UI Map; defaults to .tapp/ui-map.json" },
2851
3081
  },
2852
3082
  },
2853
3083
  },
@@ -3691,15 +3921,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3691
3921
  (args.url || parsedFlow.url || /^https?:\/\//i.test(parsedFlow.app || "")) ? "web" : "ios")
3692
3922
  ).toLowerCase();
3693
3923
 
3694
- const flowLog = path.join(os.tmpdir(), `mcp-flow-${Date.now()}.log`);
3695
- const runEnv = { ...process.env, FLOW_LOG: flowLog };
3924
+ const flowToken = Date.now();
3925
+ const flowLog = path.join(os.tmpdir(), `mcp-flow-${flowToken}.log`);
3926
+ const evidenceDir = path.join(capturesDir, `flow-${platform}-${flowToken}`);
3927
+ const runEnv = { ...process.env, FLOW_LOG: flowLog, TAPP_FLOW_EVIDENCE_DIR: evidenceDir };
3696
3928
  if (isNonEmptyString(args.testEmail)) runEnv.OCQA_TEST_EMAIL = args.testEmail.trim();
3697
3929
  if (isNonEmptyString(args.testPassword)) runEnv.OCQA_TEST_PASSWORD = args.testPassword.trim();
3698
3930
  let run = { stdout: "", stderr: "", code: 0 };
3699
3931
  if (platform === "web") {
3700
3932
  try {
3701
3933
  const { runWebFlow } = await import("./web-flow.js");
3702
- const evidenceDir = path.join(capturesDir, `flow-web-${Date.now()}`);
3703
3934
  const result = await runWebFlow({
3704
3935
  flow: parsedFlow,
3705
3936
  url: isNonEmptyString(args.url) ? args.url.trim() : undefined,
@@ -3714,10 +3945,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3714
3945
  const cmdArgs = [path.join(scriptsDir, "run-flow.sh"), flowFile];
3715
3946
  if (isNonEmptyString(args.appBundleId)) cmdArgs.push(args.appBundleId.trim());
3716
3947
  run = await runCommand("bash", cmdArgs, { cwd: repoRoot, timeoutMs: 10 * 60 * 1000, env: runEnv });
3948
+ if (fs.existsSync(evidenceDir) && fs.readdirSync(evidenceDir).length > 0) run.evidenceDir = evidenceDir;
3717
3949
  } else if (platform === "android") {
3718
3950
  try {
3719
3951
  const { runAndroidFlow } = await import("./android-flow.js");
3720
- const evidenceDir = path.join(capturesDir, `flow-android-${Date.now()}`);
3721
3952
  const result = await runAndroidFlow({
3722
3953
  flow: parsedFlow,
3723
3954
  appId: isNonEmptyString(args.androidAppId)
@@ -3744,7 +3975,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3744
3975
  try { structured = JSON.parse(jsonRes.stdout.trim()); } catch { /* fall through */ }
3745
3976
  const textRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "report", flowLog], { cwd: repoRoot });
3746
3977
  const text = (textRes.stdout || "").trim() || run.stdout;
3747
- return richResult(text, { ...(structured || { raw: run.stdout }), platform, evidenceDir: run.evidenceDir });
3978
+ const retainedEvidence = run.evidenceDir && fs.existsSync(run.evidenceDir) && fs.readdirSync(run.evidenceDir).length > 0
3979
+ ? run.evidenceDir
3980
+ : undefined;
3981
+ const evidenceText = retainedEvidence
3982
+ ? `\n\nEvidence: \`${retainedEvidence}\``
3983
+ : "\n\n⚠️ Evidence unavailable — the platform runner did not write any artifacts for this Flow run.";
3984
+ return richResult(text + evidenceText, { ...(structured || { raw: run.stdout }), platform, ...(retainedEvidence ? { evidenceDir: retainedEvidence } : {}) });
3748
3985
  }
3749
3986
 
3750
3987
  if (name === "tapp_scenario_run") {
@@ -3935,7 +4172,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3935
4172
  }
3936
4173
  const maxWidth = Math.max(200, Math.min(1400, asInteger(args.maxWidth, 700)));
3937
4174
  const r = await openApp(String(args.appBundleId).trim(), explorationEnvFromArgs(args), maxWidth);
3938
- if (r.error) return errorResult(r.error);
4175
+ if (r.error) return errorResult(r.error, r.details || {});
3939
4176
  const content = [];
3940
4177
  content.push({ type: "text", text: `🚀 Launched \`${String(args.appBundleId).trim()}\`\n\n` + formatScreen(r.screenTitle, r.elements) });
3941
4178
  if (r.img && !r.img.error) content.push({ type: "image", data: r.img.data, mimeType: r.img.mimeType });
@@ -4035,17 +4272,75 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4035
4272
  if (unauthorized) return unauthorized;
4036
4273
  const ios = isNonEmptyString(args.appBundleId);
4037
4274
  const android = isNonEmptyString(args.androidAppId);
4038
- if (ios === android) return errorResult("Provide exactly one of appBundleId or androidAppId");
4039
- const target = ios ? args.appBundleId.trim() : args.androidAppId.trim();
4275
+ const web = isNonEmptyString(args.url);
4276
+ if ([ios, android, web].filter(Boolean).length > 1) return errorResult("Provide at most one of appBundleId, androidAppId, or url");
4277
+ const managedWeb = !ios && !android && !web;
4278
+ let target = ios ? args.appBundleId.trim() : android ? args.androidAppId.trim() : web ? args.url.trim() : "managed web target";
4279
+ let focusProjectDir = workspaceRoot;
4280
+ if (isNonEmptyString(args.focus) || managedWeb) {
4281
+ try { focusProjectDir = fs.realpathSync(path.resolve(workspaceRoot, isNonEmptyString(args.projectDir) ? args.projectDir.trim() : ".")); }
4282
+ catch { return errorResult("projectDir must be an existing directory inside the workspace"); }
4283
+ if (!isInsideDir(workspaceRoot, focusProjectDir) || !fs.statSync(focusProjectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
4284
+ }
4040
4285
  const r = ios
4041
4286
  ? await startSession(target, explorationEnvFromArgs(args))
4042
- : await startAndroidSession(target, { serial: args.androidSerial, apkPath: args.apkPath, clearData: args.clearData !== false, testEmail: args.testEmail, testPassword: args.testPassword });
4043
- if (r.error) return errorResult(r.error);
4044
- return richResult(
4045
- `🎬 Session started — \`${target}\` (${ios ? "iOS" : "Android"})\n\n` + formatScreen(r.screenTitle, r.elements) +
4046
- `\n\nDrive it with \`tapp_session_act\` (tap · type · swipe · back · wait · tree · screenshot).`,
4047
- r
4048
- );
4287
+ : android
4288
+ ? await startAndroidSession(target, { serial: args.androidSerial, apkPath: args.apkPath, clearData: args.clearData !== false, testEmail: args.testEmail, testPassword: args.testPassword })
4289
+ : web
4290
+ ? await startWebSession(target, { testEmail:args.testEmail, testPassword:args.testPassword })
4291
+ : await startManagedWebInteractiveSession({
4292
+ projectDir:focusProjectDir,
4293
+ requestedTarget:isNonEmptyString(args.target) ? args.target.trim() : "",
4294
+ testEmail:args.testEmail,
4295
+ testPassword:args.testPassword,
4296
+ });
4297
+ if (r.error) return errorResult(r.error, r.details || {});
4298
+ if (managedWeb) target = r.url || r.managedRuntime?.url || target;
4299
+ let focused = null;
4300
+ if (isNonEmptyString(args.focus)) {
4301
+ try {
4302
+ focused = await focusInteractiveSession({ projectDir:focusProjectDir, query:args.focus.trim(), platform:ios ? "ios" : android ? "android" : "web", mapPath:isNonEmptyString(args.mapPath) ? args.mapPath.trim() : "" });
4303
+ } catch (error) {
4304
+ await endSession();
4305
+ return errorResult("Could not focus the requested UI surface", { detail:error.message || String(error) });
4306
+ }
4307
+ }
4308
+ const platformLabel = ios ? "iOS" : android ? "Android" : "web";
4309
+ const screen = agentScreenProjection(focused || r);
4310
+ const text = focused
4311
+ ? `🎬 Session started — \`${target}\` (${platformLabel})\n\n${(await import("./focused-navigation.js")).focusedTargetSummary(focused)}\n\n${formatScreen(screen.screenTitle, screen.elements)}`
4312
+ : `🎬 Session started — \`${target}\` (${platformLabel})\n\n${formatScreen(screen.screenTitle, screen.elements)}\n\nDrive it with \`tapp_focus\` for a named destination, or \`tapp_session_act\` for one action.`;
4313
+ // Return the final focused screen once. Previously this duplicated the complete native tree
4314
+ // inside `focus` and at top level, and retained the pre-focus web URL at top level.
4315
+ const structured = focused
4316
+ ? { ok:true, platform:ios ? "ios" : android ? "android" : "web", ...screen, focus:focusMetadata(focused) }
4317
+ : { ...r, ...screen };
4318
+ const result = richResult(text, structured);
4319
+ if (focused?.execution?.status === "failed") result.isError = true;
4320
+ return result;
4321
+ }
4322
+
4323
+ if (name === "tapp_focus") {
4324
+ const unauthorized = ensureAuthorized(args);
4325
+ if (unauthorized) return unauthorized;
4326
+ if (!isNonEmptyString(args.query)) return errorResult("query is required");
4327
+ let projectDir;
4328
+ try { projectDir = fs.realpathSync(path.resolve(workspaceRoot, isNonEmptyString(args.projectDir) ? args.projectDir.trim() : ".")); }
4329
+ catch { return errorResult("projectDir must be an existing directory inside the workspace"); }
4330
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
4331
+ try {
4332
+ const result = await focusInteractiveSession({
4333
+ projectDir, query:args.query.trim(), platform:isNonEmptyString(args.platform) ? args.platform.trim() : "",
4334
+ mapPath:isNonEmptyString(args.mapPath) ? args.mapPath.trim() : "",
4335
+ });
4336
+ const { focusedTargetSummary } = await import("./focused-navigation.js");
4337
+ const execution = result.execution?.status === "reached"
4338
+ ? `\n\n⚡ Reached in ${(result.execution.steps || []).length} route action(s).\n\n${formatScreen(result.screenTitle, agentFacingElements(result.elements))}`
4339
+ : result.execution?.status === "failed" ? `\n\n⚠️ Route execution stopped: ${result.execution.reason}` : "";
4340
+ const response = richResult(focusedTargetSummary(result) + execution, { ...result, ...agentScreenProjection(result) });
4341
+ if (result.execution?.status === "failed") response.isError = true;
4342
+ return response;
4343
+ } catch (error) { return errorResult("Could not focus the requested UI surface", { detail:error.message || String(error) }); }
4049
4344
  }
4050
4345
 
4051
4346
  if (name === "tapp_session_act") {
@@ -4081,7 +4376,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4081
4376
  const detailNote = !ok && r.detail ? ` — ${r.detail}` : "";
4082
4377
  const head = `${did} — ${ok ? "ok" : `⚠️ ${r.status}${detailNote}`} → now on **${r.screenTitle || "Unknown"}**`;
4083
4378
  const rec = typeof r.recordedSteps === "number" ? `\n\n🔴 Recording — ${r.recordedSteps} step(s). \`tapp_flow_save\` to keep it as a test.` : "";
4084
- const result = richResult(head + "\n\n" + formatScreen(r.screenTitle, r.elements) + rec, r);
4379
+ const screen = agentScreenProjection(r);
4380
+ const result = richResult(head + "\n\n" + formatScreen(screen.screenTitle, screen.elements) + rec, { ...r, ...screen });
4085
4381
  if (!ok) result.isError = true;
4086
4382
  return result;
4087
4383
  }
@@ -92,12 +92,12 @@ function replayStepForEdge(edge, nodes, platform) {
92
92
  // root to one UI Map node. This is execution infrastructure, not a claim that
93
93
  // every observed edge is safely replayable: actor/precondition-dependent,
94
94
  // dynamic, proposed, and unsupported actions are excluded before BFS.
95
- export function replayableUiMapNavigation(map, targetNodeId, platform, { maxSteps = 8 } = {}) {
95
+ export function replayableUiMapNavigation(map, targetNodeId, platform, { maxSteps = 8, startNodeId = "" } = {}) {
96
96
  const limit = Math.max(0, Math.min(12, Number(maxSteps) || 0));
97
97
  const nodes = new Map((map?.nodes || []).map((node) => [node.id, node]));
98
98
  const target = nodes.get(targetNodeId);
99
99
  if (!target) return { status: "blocked", reason: `UI Map target node is missing: ${targetNodeId}` };
100
- const rootId = map?.app?.navigationRoots?.[platform] || map?.app?.entryNodes?.[platform] || "";
100
+ const rootId = startNodeId || map?.app?.navigationRoots?.[platform] || map?.app?.entryNodes?.[platform] || "";
101
101
  if (!rootId || !nodes.has(rootId)) return { status: "blocked", reason: `No observed ${platform} navigation root exists in the UI Map` };
102
102
  if (rootId === targetNodeId) return {
103
103
  status: "replayable", mode: "ui-map-path", provenance: "observed-ui-map",
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "mcpName": "io.github.aarwitz/tapp",
5
- "description": "Agent-driven app testing for iOS, Android, and web—real screens, replayable flows, and deterministic CI gates.",
5
+ "description": "Let coding agents verify UI changes on real iOS, Android, and web surfaces, then enforce reviewed proof in deterministic CI.",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "bin": {
@@ -89,7 +89,7 @@
89
89
  "mobile"
90
90
  ],
91
91
  "scripts": {
92
- "test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/presentation-contract.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js vscode-extension/test/bridge.test.js",
92
+ "test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/focused-navigation.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/presentation-contract.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js vscode-extension/test/bridge.test.js",
93
93
  "test:browser-journey": "node --test tests/browser-journey.test.js",
94
94
  "test:browser-native": "TAPP_RUN_NATIVE_BROWSER=1 node --test tests/browser-native-journey.test.js"
95
95
  }