@aarwitz/tapp 0.17.0 → 0.17.1

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.
@@ -557,9 +557,59 @@ function treeSnapshot() {
557
557
  screenTitle: t ? t.screenTitle ?? null : null,
558
558
  elementCount: t ? (t.elements || []).length : 0,
559
559
  elements: t ? t.elements || [] : [],
560
+ ...(t?.url ? { url:t.url } : {}),
560
561
  };
561
562
  }
562
563
 
564
+ /** Keep the semantic/actionable accessibility surface an agent needs, without sending hundreds
565
+ * of empty native hierarchy containers on every turn. The complete tree remains in the active
566
+ * session for selector resolution; this is only the public MCP projection. */
567
+ export function agentFacingElements(elements, limit = 160) {
568
+ const result = [];
569
+ const seen = new Set();
570
+ for (const element of elements || []) {
571
+ const semanticValues = [
572
+ element?.id, element?.identifier, element?.label, element?.text,
573
+ element?.description, element?.placeholder, element?.value,
574
+ ].map((value) => String(value ?? "").trim()).filter(Boolean);
575
+ const roleAndType = `${element?.role || ""} ${element?.type || ""}`.toLowerCase();
576
+ // Unlabelled input/button controls are still actionable by coordinate. Empty windows,
577
+ // applications, groups, images and generic containers are implementation noise.
578
+ const actionableWithoutText = /button|link|textfield|securetext|textarea|edittext|switch|checkbox/.test(roleAndType);
579
+ if (!semanticValues.length && !actionableWithoutText) continue;
580
+ const frame = element?.frame || {};
581
+ const key = JSON.stringify([
582
+ roleAndType, ...semanticValues,
583
+ element?.x ?? frame.x ?? null, element?.y ?? frame.y ?? null,
584
+ element?.w ?? frame.width ?? null, element?.h ?? frame.height ?? null,
585
+ ]);
586
+ if (seen.has(key)) continue;
587
+ seen.add(key);
588
+ result.push(element);
589
+ if (result.length >= Math.max(1, limit)) break;
590
+ }
591
+ return result;
592
+ }
593
+
594
+ function agentScreenProjection(screen) {
595
+ const all = screen?.elements || [];
596
+ const elements = agentFacingElements(all);
597
+ return {
598
+ screenTitle:screen?.screenTitle ?? null,
599
+ elementCount:elements.length,
600
+ totalElementCount:all.length,
601
+ elementsOmitted:Math.max(0, all.length - elements.length),
602
+ elements,
603
+ ...(screen?.url ? { url:screen.url } : {}),
604
+ };
605
+ }
606
+
607
+ function focusMetadata(result) {
608
+ if (!result) return null;
609
+ const { elements:_elements, elementCount:_elementCount, screenTitle:_screenTitle, url:_url, ...metadata } = result;
610
+ return metadata;
611
+ }
612
+
563
613
  async function startSession(bundleId, extraEnv = {}) {
564
614
  if (activeSession && !activeSession.ended) {
565
615
  return { error: "A session is already active; call tapp_session_end first.", screen: treeSnapshot() };
@@ -578,7 +628,7 @@ async function startSession(bundleId, extraEnv = {}) {
578
628
  env: { ...process.env, ...extraEnv, OCQA_SESSION_CMD_PATH: cmdPath, OCQA_SESSION_RESULT_PATH: resultPath, OCQA_SESSION_TIMEOUT: "7200" },
579
629
  });
580
630
  activeSession = {
581
- proc, bundleId, seq: 0, cmdPath, resultPath, latestTree: null, treeVersion: 0, buffer: "", ready: false, ended: false,
631
+ platform:"ios", proc, bundleId, seq: 0, cmdPath, resultPath, latestTree: null, treeVersion: 0, buffer: "", ready: false, ended: false,
582
632
  // Always-on recorder: each act appends a Flow step; tapp_flow_save snapshots it to a file.
583
633
  recording: [],
584
634
  creds: { email: extraEnv.OCQA_TEST_EMAIL || "", password: extraEnv.OCQA_TEST_PASSWORD || "" },
@@ -636,24 +686,26 @@ async function webSessionSnapshot(page) {
636
686
  const rect = element.getBoundingClientRect();
637
687
  return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
638
688
  };
639
- const controls = [...document.querySelectorAll("button,a[href],input,textarea,select,[role=button],[role=tab],[role=checkbox],[role=switch]")]
689
+ 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
690
  .filter((element) => element instanceof HTMLElement && visible(element))
641
691
  .slice(0, 250)
642
692
  .map((element) => {
643
693
  const rect = element.getBoundingClientRect();
644
694
  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);
695
+ const interactive = element.matches("button,a[href],input,textarea,select,[role=button],[role=tab],[role=checkbox],[role=switch]");
645
696
  return {
646
697
  id: element.getAttribute("data-testid") || element.id || element.getAttribute("name") || "",
647
698
  label,
648
699
  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"),
700
+ role: element.getAttribute("role") || (element.matches("button,[role=button]") ? "button" : element.matches("a") ? "link" : element.matches("input,textarea,select") ? "input" : "text"),
650
701
  enabled: !(element.disabled || element.getAttribute("aria-disabled") === "true"),
651
- hittable: true,
702
+ hittable: interactive,
652
703
  clickable: element.matches("button,a,[role=button],[role=tab],[role=checkbox],[role=switch]") && !(element.disabled || element.getAttribute("aria-disabled") === "true"),
653
704
  secure: element instanceof HTMLInputElement && element.type === "password",
654
705
  frame: { x:Math.round(rect.x), y:Math.round(rect.y), width:Math.round(rect.width), height:Math.round(rect.height) },
655
706
  };
656
- });
707
+ })
708
+ .filter((element) => element.label);
657
709
  const heading = document.querySelector("h1,[role=heading]")?.textContent?.replace(/\s+/g, " ").trim();
658
710
  return { screenTitle:heading || document.title || location.pathname || "Web application", elements:controls, url:location.href };
659
711
  });
@@ -858,6 +910,7 @@ async function sessionAct(cmd) {
858
910
  }
859
911
  if (activeSession.platform === "android") {
860
912
  const s = activeSession;
913
+ const beforeAction = s.latestTree;
861
914
  let status = "ok";
862
915
  let detail = null;
863
916
  let typedInto = null;
@@ -889,21 +942,21 @@ async function sessionAct(cmd) {
889
942
  status = "not_found"; detail = "Could not identify email and password fields";
890
943
  } else {
891
944
  const er = await s.driver.type(emailField.id || emailField.label, cmd.email || s.creds.email || "", s.latestTree);
892
- s.latestTree = await s.driver.settle();
945
+ s.latestTree = await s.driver.settle(2200, s.latestTree);
893
946
  const pr = await s.driver.type(passwordField.id || passwordField.label, cmd.password || s.creds.password || "", s.latestTree);
894
- s.latestTree = await s.driver.settle();
947
+ s.latestTree = await s.driver.settle(2200, s.latestTree);
895
948
  const submit = s.latestTree.elements.find((e) => e.clickable && /sign in|log in|login|continue/i.test(`${e.text} ${e.label} ${e.id}`));
896
949
  if (er.status !== "ok" || pr.status !== "ok" || !submit) {
897
950
  status = "not_found"; detail = "Could not fill or submit the login form";
898
951
  } else {
899
952
  const before = s.latestTree.screenTitle;
900
953
  await s.driver.tap(submit.id || submit.description || submit.text, s.latestTree);
901
- s.latestTree = await s.driver.settle();
954
+ s.latestTree = await s.driver.settle(2200, s.latestTree);
902
955
  if (s.latestTree.screenTitle === before) { status = "still_on_login"; detail = "Submit left the app on the login screen"; }
903
956
  }
904
957
  }
905
958
  }
906
- if (!["wait", "tree", "screenshot", "login"].includes(cmd.action)) s.latestTree = await s.driver.settle();
959
+ if (!["wait", "tree", "screenshot", "login"].includes(cmd.action)) s.latestTree = await s.driver.settle(2200, beforeAction);
907
960
  } catch (error) {
908
961
  status = "error"; detail = error.message || String(error);
909
962
  }
@@ -1026,6 +1079,79 @@ export {
1026
1079
  endSession as endInteractiveSession,
1027
1080
  };
1028
1081
 
1082
+ function elementMatchesSemanticTarget(element, value) {
1083
+ const target = String(value || "").trim().toLowerCase();
1084
+ if (!target) return false;
1085
+ return [element?.id, element?.identifier, element?.label, element?.text, element?.description]
1086
+ .map((item) => String(item || "").trim().toLowerCase())
1087
+ .some((item) => item === target || item.includes(target) || target.includes(item));
1088
+ }
1089
+
1090
+ /** Locate a requested surface from owned source, reconcile it with the runtime-observed UI Map,
1091
+ * and execute the shortest replayable route in the active session. Source may identify intent but
1092
+ * never authorizes a tap: only observed/validated map edges are executed. */
1093
+ export async function focusInteractiveSession({ projectDir = process.cwd(), query, platform = "", mapPath = "" } = {}) {
1094
+ const { locateFocusedTarget } = await import("./focused-navigation.js");
1095
+ const sessionPlatform = activeSession?.platform || platform || "";
1096
+ const location = locateFocusedTarget({
1097
+ projectDir, query, platform:sessionPlatform || platform, mapPath,
1098
+ currentScreen:activeSession?.latestTree?.screenTitle || "",
1099
+ });
1100
+ if (!activeSession || activeSession.ended) return { ...location, executed:false, execution:{ status:"not-started", reason:"No active session; start the target app, then focus it." } };
1101
+ if (location.navigation?.status !== "replayable") return { ...location, executed:false, execution:{ status:"blocked", reason:location.navigation?.reason || "No observed route" }, ...treeSnapshot() };
1102
+
1103
+ const executedSteps = [];
1104
+ if (location.navigation.mode === "direct-web-route") {
1105
+ if (activeSession.platform !== "web") return { ...location, executed:false, execution:{ status:"blocked", reason:"A web route cannot be used in a native session" }, ...treeSnapshot() };
1106
+ try {
1107
+ const current = new URL(activeSession.page.url());
1108
+ const destination = new URL(location.navigation.route, current.origin);
1109
+ if (destination.origin !== current.origin) throw new Error("Observed route left the active app origin");
1110
+ await activeSession.page.goto(destination.href, { waitUntil:"domcontentloaded", timeout:15_000 });
1111
+ await activeSession.page.waitForLoadState("networkidle", { timeout:3_000 }).catch(() => {});
1112
+ activeSession.latestTree = await webSessionSnapshot(activeSession.page);
1113
+ activeSession.treeVersion += 1;
1114
+ executedSteps.push({ action:"open", target:location.navigation.route, status:"ok", screenTitle:activeSession.latestTree.screenTitle });
1115
+ } catch (error) {
1116
+ return { ...location, executed:true, execution:{ status:"failed", steps:executedSteps, reason:error.message || String(error) }, ...treeSnapshot() };
1117
+ }
1118
+ } else {
1119
+ for (const step of location.navigation.steps || []) {
1120
+ const action = step.action?.type === "back" ? "back" : "tap";
1121
+ const candidates = [
1122
+ ...(step.action?.selectors || [])
1123
+ .sort((left, right) => {
1124
+ const order = ["testId", "accessibilityId", "resourceId", "cssId", "label"];
1125
+ const rank = (kind) => { const index = order.indexOf(kind); return index < 0 ? order.length : index; };
1126
+ return rank(left.kind) - rank(right.kind);
1127
+ })
1128
+ .map((selector) => selector.value),
1129
+ step.action?.target,
1130
+ ].filter(Boolean);
1131
+ const target = candidates.find((candidate) => (activeSession.latestTree?.elements || []).some((element) => elementMatchesSemanticTarget(element, candidate))) || candidates[0] || "";
1132
+ const result = await sessionAct(action === "back" ? { action } : { action, id:target });
1133
+ executedSteps.push({ action, target, status:result.status, screenTitle:result.screenTitle, durationMs:result.durationMs });
1134
+ if (result.error || result.status !== "ok") return {
1135
+ ...location, executed:true,
1136
+ execution:{ status:"failed", steps:executedSteps, reason:result.error || result.detail || `Observed route step '${target}' did not succeed` },
1137
+ ...treeSnapshot(),
1138
+ };
1139
+ }
1140
+ }
1141
+ const targetControls = location.target?.matchedControls || [];
1142
+ const reachedByTitle = location.target?.name && String(treeSnapshot().screenTitle || "").toLowerCase() === String(location.target.name).toLowerCase();
1143
+ const reachedByControl = targetControls.some((control) => (activeSession.latestTree?.elements || []).some((element) =>
1144
+ [control.id, control.label, control.semanticKey, ...(control.selectors || []).map((selector) => selector.value)].some((value) => elementMatchesSemanticTarget(element, value))
1145
+ ));
1146
+ const reached = reachedByTitle || reachedByControl;
1147
+ return {
1148
+ ...location, executed:true,
1149
+ execution:{ status:reached ? "reached" : "route-completed-unconfirmed", steps:executedSteps,
1150
+ ...(!reached ? { reason:"The observed route completed, but the requested title/control was not visible in the final tree." } : {}) },
1151
+ ...treeSnapshot(),
1152
+ };
1153
+ }
1154
+
1029
1155
  export async function captureInteractiveSessionFrame(maxWidth = 900) {
1030
1156
  if (!activeSession || activeSession.ended) return { error:"No active interactive session" };
1031
1157
  if (activeSession.platform === "android") {
@@ -1333,6 +1459,14 @@ export function explorationEnvFromArgs(args) {
1333
1459
  if (args.prExplorationTarget && typeof args.prExplorationTarget === "object" && !Array.isArray(args.prExplorationTarget)) {
1334
1460
  env.OCQA_PR_TARGET_JSON = JSON.stringify(args.prExplorationTarget);
1335
1461
  }
1462
+ // Local visual clients can reveal their preview at the same foreground/settled boundary used by
1463
+ // native recording. Keep this host handshake in the OS temp directory; it is not app evidence or
1464
+ // a caller-directed repository write.
1465
+ if (isNonEmptyString(args.visualReadyPath)) {
1466
+ const readyPath = path.resolve(args.visualReadyPath.trim());
1467
+ const tempRoot = path.resolve(os.tmpdir());
1468
+ if (readyPath.startsWith(`${tempRoot}${path.sep}`)) env.OCQA_VISUAL_READY_PATH = readyPath;
1469
+ }
1336
1470
  // Explicit login replay: a recorded sequence run before exploration, for custom login UIs the
1337
1471
  // heuristic preamble can't parse — the #1 reason a real app stays invisible. Steps are
1338
1472
  // {action: type|tap|wait, target, value?, timeoutMs?}; $TEST_EMAIL/$TEST_PASSWORD substituted
@@ -2193,6 +2327,7 @@ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
2193
2327
  text:
2194
2328
  `${goal}.${target} Use the connected Tapp tools on the real UI surface. ` +
2195
2329
  "Use the smallest operation that satisfies the request; initialize/explore the source repo only for a general repository test. " +
2330
+ "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
2331
  "If Tapp returns multiple target choices, ask me to select one instead of guessing. " +
2197
2332
  "Read visual evidence before describing it. Report findings, coverage, authority, inconclusive state, and checked/not-checked scope; " +
2198
2333
  "never turn exploration into a score or ship verdict. Do not edit the app unless I ask for a fix.",
@@ -2380,6 +2515,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2380
2515
  testPassword: { type: "string", description: "Password for the login preamble" },
2381
2516
  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
2517
  interactiveResponsePath: { type: "string", description: "File path the prompting host answers on (requests appear at <path>.request)" },
2518
+ visualReadyPath: { type: "string", description: "Local-client temp path written once the iOS target is foregrounded and settled, so previews exclude build/install footage" },
2383
2519
  inputOverrides: {
2384
2520
  type: "object",
2385
2521
  additionalProperties: { type: "string" },
@@ -2828,10 +2964,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2828
2964
  name: "tapp_session_start",
2829
2965
  title: "Start interactive session",
2830
2966
  description:
2831
- "Start a PERSISTENT interactive session against an installed iOS or Android app. The app " +
2967
+ "Start a PERSISTENT interactive session against an installed iOS/Android app or a web URL. The app " +
2832
2968
  "launches once and stays up, so you can drive a Playwright-style tap → inspect loop without a cold " +
2833
2969
  "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 " +
2970
+ "tapp_focus for any named destination (source + shortest observed route), then tapp_session_act only for remaining actions; finish with tapp_session_end. " +
2971
+ "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
2972
  "fresh launch. Use appLaunchArgs/appLaunchEnv for apps that need a backend override or login bypass. " +
2836
2973
  "When you reach a screen with input fields and don't have values for them, ASK THE USER what to type " +
2837
2974
  "(offer defaults/skip) before typing — the session does not prompt on its own.",
@@ -2841,6 +2978,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2841
2978
  authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
2842
2979
  appBundleId: { type: "string", description: "Bundle id of the installed app to drive" },
2843
2980
  androidAppId: { type: "string", description: "Android application id to drive (alternative to appBundleId)" },
2981
+ url: { type: "string", description: "Owned http(s) web app URL to drive (alternative to appBundleId/androidAppId)" },
2844
2982
  apkPath: { type: "string", description: "Android APK to install before starting" },
2845
2983
  androidSerial: { type: "string", description: "Android adb device serial" },
2846
2984
  clearData: { type: "boolean", default: true, description: "Android: clear app data before launch" },
@@ -2848,6 +2986,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2848
2986
  testPassword: { type: "string", description: "Password available to the app/harness" },
2849
2987
  appLaunchArgs: { type: "array", items: { type: "string" }, description: "Launch arguments, e.g. [\"--uitesting\"]" },
2850
2988
  appLaunchEnv: { type: "object", additionalProperties: { type: "string" }, description: "Launch environment, e.g. {\"UI_TEST_BACKEND\": \"staging\"}" },
2989
+ 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" },
2990
+ projectDir: { type: "string", description: "Repository root for source-connected focus; defaults to the MCP workspace" },
2991
+ mapPath: { type: "string", description: "Optional repository-relative UI Map for focus; defaults to .tapp/ui-map.json" },
2992
+ },
2993
+ },
2994
+ },
2995
+ {
2996
+ name: "tapp_focus",
2997
+ title: "Locate and reach a requested UI surface",
2998
+ description:
2999
+ "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.",
3000
+ inputSchema: {
3001
+ type: "object",
3002
+ required: ["query"],
3003
+ properties: {
3004
+ authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
3005
+ query: { type: "string", description: "User's requested screen, control, or focused UI task" },
3006
+ projectDir: { type: "string", description: "Repository root; defaults to the MCP workspace" },
3007
+ platform: { type: "string", enum: ["ios", "android", "web"], description: "Optional without an active session; an active session is authoritative" },
3008
+ mapPath: { type: "string", description: "Optional repository-relative UI Map; defaults to .tapp/ui-map.json" },
2851
3009
  },
2852
3010
  },
2853
3011
  },
@@ -3691,15 +3849,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3691
3849
  (args.url || parsedFlow.url || /^https?:\/\//i.test(parsedFlow.app || "")) ? "web" : "ios")
3692
3850
  ).toLowerCase();
3693
3851
 
3694
- const flowLog = path.join(os.tmpdir(), `mcp-flow-${Date.now()}.log`);
3695
- const runEnv = { ...process.env, FLOW_LOG: flowLog };
3852
+ const flowToken = Date.now();
3853
+ const flowLog = path.join(os.tmpdir(), `mcp-flow-${flowToken}.log`);
3854
+ const evidenceDir = path.join(capturesDir, `flow-${platform}-${flowToken}`);
3855
+ const runEnv = { ...process.env, FLOW_LOG: flowLog, TAPP_FLOW_EVIDENCE_DIR: evidenceDir };
3696
3856
  if (isNonEmptyString(args.testEmail)) runEnv.OCQA_TEST_EMAIL = args.testEmail.trim();
3697
3857
  if (isNonEmptyString(args.testPassword)) runEnv.OCQA_TEST_PASSWORD = args.testPassword.trim();
3698
3858
  let run = { stdout: "", stderr: "", code: 0 };
3699
3859
  if (platform === "web") {
3700
3860
  try {
3701
3861
  const { runWebFlow } = await import("./web-flow.js");
3702
- const evidenceDir = path.join(capturesDir, `flow-web-${Date.now()}`);
3703
3862
  const result = await runWebFlow({
3704
3863
  flow: parsedFlow,
3705
3864
  url: isNonEmptyString(args.url) ? args.url.trim() : undefined,
@@ -3714,10 +3873,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3714
3873
  const cmdArgs = [path.join(scriptsDir, "run-flow.sh"), flowFile];
3715
3874
  if (isNonEmptyString(args.appBundleId)) cmdArgs.push(args.appBundleId.trim());
3716
3875
  run = await runCommand("bash", cmdArgs, { cwd: repoRoot, timeoutMs: 10 * 60 * 1000, env: runEnv });
3876
+ if (fs.existsSync(evidenceDir) && fs.readdirSync(evidenceDir).length > 0) run.evidenceDir = evidenceDir;
3717
3877
  } else if (platform === "android") {
3718
3878
  try {
3719
3879
  const { runAndroidFlow } = await import("./android-flow.js");
3720
- const evidenceDir = path.join(capturesDir, `flow-android-${Date.now()}`);
3721
3880
  const result = await runAndroidFlow({
3722
3881
  flow: parsedFlow,
3723
3882
  appId: isNonEmptyString(args.androidAppId)
@@ -3744,7 +3903,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3744
3903
  try { structured = JSON.parse(jsonRes.stdout.trim()); } catch { /* fall through */ }
3745
3904
  const textRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "report", flowLog], { cwd: repoRoot });
3746
3905
  const text = (textRes.stdout || "").trim() || run.stdout;
3747
- return richResult(text, { ...(structured || { raw: run.stdout }), platform, evidenceDir: run.evidenceDir });
3906
+ const retainedEvidence = run.evidenceDir && fs.existsSync(run.evidenceDir) && fs.readdirSync(run.evidenceDir).length > 0
3907
+ ? run.evidenceDir
3908
+ : undefined;
3909
+ const evidenceText = retainedEvidence
3910
+ ? `\n\nEvidence: \`${retainedEvidence}\``
3911
+ : "\n\n⚠️ Evidence unavailable — the platform runner did not write any artifacts for this Flow run.";
3912
+ return richResult(text + evidenceText, { ...(structured || { raw: run.stdout }), platform, ...(retainedEvidence ? { evidenceDir: retainedEvidence } : {}) });
3748
3913
  }
3749
3914
 
3750
3915
  if (name === "tapp_scenario_run") {
@@ -4035,17 +4200,66 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4035
4200
  if (unauthorized) return unauthorized;
4036
4201
  const ios = isNonEmptyString(args.appBundleId);
4037
4202
  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();
4203
+ const web = isNonEmptyString(args.url);
4204
+ if ([ios, android, web].filter(Boolean).length !== 1) return errorResult("Provide exactly one of appBundleId, androidAppId, or url");
4205
+ const target = ios ? args.appBundleId.trim() : android ? args.androidAppId.trim() : args.url.trim();
4206
+ let focusProjectDir = workspaceRoot;
4207
+ if (isNonEmptyString(args.focus)) {
4208
+ try { focusProjectDir = fs.realpathSync(path.resolve(workspaceRoot, isNonEmptyString(args.projectDir) ? args.projectDir.trim() : ".")); }
4209
+ catch { return errorResult("projectDir must be an existing directory inside the workspace"); }
4210
+ if (!isInsideDir(workspaceRoot, focusProjectDir) || !fs.statSync(focusProjectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
4211
+ }
4040
4212
  const r = ios
4041
4213
  ? 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 });
4214
+ : android
4215
+ ? await startAndroidSession(target, { serial: args.androidSerial, apkPath: args.apkPath, clearData: args.clearData !== false, testEmail: args.testEmail, testPassword: args.testPassword })
4216
+ : await startWebSession(target, { testEmail:args.testEmail, testPassword:args.testPassword });
4043
4217
  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
- );
4218
+ let focused = null;
4219
+ if (isNonEmptyString(args.focus)) {
4220
+ try {
4221
+ focused = await focusInteractiveSession({ projectDir:focusProjectDir, query:args.focus.trim(), platform:ios ? "ios" : android ? "android" : "web", mapPath:isNonEmptyString(args.mapPath) ? args.mapPath.trim() : "" });
4222
+ } catch (error) {
4223
+ await endSession();
4224
+ return errorResult("Could not focus the requested UI surface", { detail:error.message || String(error) });
4225
+ }
4226
+ }
4227
+ const platformLabel = ios ? "iOS" : android ? "Android" : "web";
4228
+ const screen = agentScreenProjection(focused || r);
4229
+ const text = focused
4230
+ ? `🎬 Session started — \`${target}\` (${platformLabel})\n\n${(await import("./focused-navigation.js")).focusedTargetSummary(focused)}\n\n${formatScreen(screen.screenTitle, screen.elements)}`
4231
+ : `🎬 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.`;
4232
+ // Return the final focused screen once. Previously this duplicated the complete native tree
4233
+ // inside `focus` and at top level, and retained the pre-focus web URL at top level.
4234
+ const structured = focused
4235
+ ? { ok:true, platform:ios ? "ios" : android ? "android" : "web", ...screen, focus:focusMetadata(focused) }
4236
+ : { ...r, ...screen };
4237
+ const result = richResult(text, structured);
4238
+ if (focused?.execution?.status === "failed") result.isError = true;
4239
+ return result;
4240
+ }
4241
+
4242
+ if (name === "tapp_focus") {
4243
+ const unauthorized = ensureAuthorized(args);
4244
+ if (unauthorized) return unauthorized;
4245
+ if (!isNonEmptyString(args.query)) return errorResult("query is required");
4246
+ let projectDir;
4247
+ try { projectDir = fs.realpathSync(path.resolve(workspaceRoot, isNonEmptyString(args.projectDir) ? args.projectDir.trim() : ".")); }
4248
+ catch { return errorResult("projectDir must be an existing directory inside the workspace"); }
4249
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
4250
+ try {
4251
+ const result = await focusInteractiveSession({
4252
+ projectDir, query:args.query.trim(), platform:isNonEmptyString(args.platform) ? args.platform.trim() : "",
4253
+ mapPath:isNonEmptyString(args.mapPath) ? args.mapPath.trim() : "",
4254
+ });
4255
+ const { focusedTargetSummary } = await import("./focused-navigation.js");
4256
+ const execution = result.execution?.status === "reached"
4257
+ ? `\n\n⚡ Reached in ${(result.execution.steps || []).length} route action(s).\n\n${formatScreen(result.screenTitle, agentFacingElements(result.elements))}`
4258
+ : result.execution?.status === "failed" ? `\n\n⚠️ Route execution stopped: ${result.execution.reason}` : "";
4259
+ const response = richResult(focusedTargetSummary(result) + execution, { ...result, ...agentScreenProjection(result) });
4260
+ if (result.execution?.status === "failed") response.isError = true;
4261
+ return response;
4262
+ } catch (error) { return errorResult("Could not focus the requested UI surface", { detail:error.message || String(error) }); }
4049
4263
  }
4050
4264
 
4051
4265
  if (name === "tapp_session_act") {
@@ -4081,7 +4295,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4081
4295
  const detailNote = !ok && r.detail ? ` — ${r.detail}` : "";
4082
4296
  const head = `${did} — ${ok ? "ok" : `⚠️ ${r.status}${detailNote}`} → now on **${r.screenTitle || "Unknown"}**`;
4083
4297
  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);
4298
+ const screen = agentScreenProjection(r);
4299
+ const result = richResult(head + "\n\n" + formatScreen(screen.screenTitle, screen.elements) + rec, { ...r, ...screen });
4085
4300
  if (!ok) result.isError = true;
4086
4301
  return result;
4087
4302
  }
@@ -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.1",
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
  }
@@ -163,13 +163,28 @@ run_harness_test() {
163
163
  \"OCQA_PR_TARGET\": ${OCQA_PR_TARGET_JSON}"
164
164
  fi
165
165
 
166
+ # Visual evidence must begin at the first settled target-app frame, never while Xcode is
167
+ # installing/launching the test runner or SpringBoard is selecting the app. The harness writes
168
+ # the ready file after the target is foregrounded + stable, then briefly waits for the host's
169
+ # recording-start acknowledgement so the first autonomous action cannot race ahead of video.
170
+ local visual_ready_line=""
171
+ if [[ -n "${OCQA_VISUAL_READY_PATH:-}" ]]; then
172
+ visual_ready_line=",
173
+ \"OCQA_VISUAL_READY_PATH\": \"${OCQA_VISUAL_READY_PATH}\""
174
+ fi
175
+ local recording_started_line=""
176
+ if [[ -n "${OCQA_RECORDING_STARTED_PATH:-}" ]]; then
177
+ recording_started_line=",
178
+ \"OCQA_RECORDING_STARTED_PATH\": \"${OCQA_RECORDING_STARTED_PATH}\""
179
+ fi
180
+
166
181
  cat > /tmp/ocqa-run-config.json << CONF
167
182
  {
168
183
  "OCQA_BUNDLE_ID": "$bundle_id",
169
184
  "OCQA_MAX_ACTIONS": "$max_actions",
170
185
  "OCQA_TIMEOUT_SECONDS": "$timeout_secs",
171
186
  "OCQA_TEST_EMAIL": "${OCQA_TEST_EMAIL:-qa@example.com}",
172
- "OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-Tapp123!}"$interactive_line$overrides_line$launch_args_line$launch_env_line$login_steps_line$pr_target_line
187
+ "OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-Tapp123!}"$interactive_line$overrides_line$launch_args_line$launch_env_line$login_steps_line$pr_target_line$visual_ready_line$recording_started_line
173
188
  }
174
189
  CONF
175
190
 
@@ -290,23 +305,38 @@ OCQA_COMPLETE:{\"actions\":0,\"states\":0,\"issues\":1,\"screens\":\"\",\"outcom
290
305
  echo "WARNING: Target process exited during launch preflight; recorded a crash instead of waiting for the exploration timeout." >&2
291
306
  else
292
307
 
293
- # Start video recording in background
308
+ # Prepare the foreground/settled handshake before starting the harness. Recording begins
309
+ # only after that handshake; this excludes build/install/SpringBoard footage without using a
310
+ # brittle fixed trim duration. A caller such as VS Code may provide its own ready path to
311
+ # reveal a preview at the same authoritative boundary.
294
312
  cleanup_stale_recorders "$UDID"
295
- if xcrun simctl io "$UDID" recordVideo --codec=h264 "$CAPTURE_DIR/exploration.mov" & then
296
- RECORD_PID=$!
297
- fi
298
- sleep 0.5
299
- if ! kill -0 "$RECORD_PID" 2>/dev/null; then
300
- echo "WARNING: Could not start simulator video recording. Continuing without video." >&2
301
- RECORD_PID=""
302
- fi
313
+ VISUAL_READY_PATH="${OCQA_VISUAL_READY_PATH:-$CAPTURE_DIR/visual-ready}"
314
+ RECORDING_STARTED_PATH="$CAPTURE_DIR/recording-started"
315
+ rm -f "$VISUAL_READY_PATH" "$RECORDING_STARTED_PATH"
316
+ export OCQA_VISUAL_READY_PATH="$VISUAL_READY_PATH"
317
+ export OCQA_RECORDING_STARTED_PATH="$RECORDING_STARTED_PATH"
303
318
 
304
319
  # Run exploration with watchdog timeout to avoid silent hangs.
305
320
  run_harness_test "testAutonomousExploration" "$SIM_NAME" "$APP_BUNDLE" "$MAX_ACTIONS" "$EXPLORE_TIMEOUT" > "$local_output_file" 2>&1 &
306
321
  HARNESS_PID=$!
307
322
 
308
323
  START_TS=$(date +%s)
324
+ RECORDING_ATTEMPTED=0
309
325
  while kill -0 "$HARNESS_PID" 2>/dev/null; do
326
+ if [[ "$RECORDING_ATTEMPTED" -eq 0 && -f "$VISUAL_READY_PATH" ]]; then
327
+ RECORDING_ATTEMPTED=1
328
+ if xcrun simctl io "$UDID" recordVideo --codec=h264 "$CAPTURE_DIR/exploration.mov" & then
329
+ RECORD_PID=$!
330
+ fi
331
+ sleep 0.25
332
+ if [[ -z "$RECORD_PID" ]] || ! kill -0 "$RECORD_PID" 2>/dev/null; then
333
+ echo "WARNING: Could not start simulator video recording. Continuing without video." >&2
334
+ RECORD_PID=""
335
+ fi
336
+ # Always release the bounded harness wait. The missing video remains explicit in the
337
+ # report; exploration itself must not hang merely because recording was unavailable.
338
+ : > "$RECORDING_STARTED_PATH"
339
+ fi
310
340
  NOW_TS=$(date +%s)
311
341
  ELAPSED=$((NOW_TS - START_TS))
312
342
  if [[ "$ELAPSED" -ge "$EXPLORE_TIMEOUT" ]]; then
@@ -316,7 +346,7 @@ OCQA_COMPLETE:{\"actions\":0,\"states\":0,\"issues\":1,\"screens\":\"\",\"outcom
316
346
  kill -KILL "$HARNESS_PID" 2>/dev/null || true
317
347
  break
318
348
  fi
319
- sleep 2
349
+ if [[ "$RECORDING_ATTEMPTED" -eq 0 ]]; then sleep 0.1; else sleep 1; fi
320
350
  done
321
351
 
322
352
  wait "$HARNESS_PID" 2>/dev/null || true
@@ -327,6 +357,7 @@ OCQA_COMPLETE:{\"actions\":0,\"states\":0,\"issues\":1,\"screens\":\"\",\"outcom
327
357
  kill -INT "$RECORD_PID" 2>/dev/null || true
328
358
  wait "$RECORD_PID" 2>/dev/null || true
329
359
  fi
360
+ rm -f "$RECORDING_STARTED_PATH"
330
361
  sleep 1
331
362
  fi
332
363
 
@@ -40,6 +40,14 @@ TOKEN="$(date +%s)"
40
40
  CFG="/tmp/ocqa-flow-$TOKEN.json"
41
41
  AI_RESP="/tmp/ocqa-flow-ai-$TOKEN.json"
42
42
  AI_DIR="/tmp/ocqa-flow-ai-$TOKEN"
43
+ EVIDENCE_DIR="${TAPP_FLOW_EVIDENCE_DIR:-/tmp/tapp-flow-ios-$TOKEN}"
44
+ RESULT_BUNDLE="$EVIDENCE_DIR/result.xcresult"
45
+ mkdir -p "$EVIDENCE_DIR"
46
+ case "$FLOW" in
47
+ *.json) FLOW_EVIDENCE_SOURCE="$EVIDENCE_DIR/flow-source.json" ;;
48
+ *) FLOW_EVIDENCE_SOURCE="$EVIDENCE_DIR/flow-source.yml" ;;
49
+ esac
50
+ cp "$FLOW" "$FLOW_EVIDENCE_SOURCE"
43
51
  python3 - "$CFG" "$APP" "$FLOW_JSON" "$AI_RESP" "$AI_DIR" <<'PY'
44
52
  import json, os, sys
45
53
  cfg, app, flow_json, ai_resp, ai_dir = sys.argv[1:6]
@@ -82,9 +90,12 @@ fi
82
90
 
83
91
  TEST_RUNNER_OCQA_CONFIG_PATH="$CFG" xcodebuild test-without-building \
84
92
  -xctestrun "$XCTR" -destination "platform=iOS Simulator,id=$UDID" \
85
- -only-testing:"OCQAHarnessUITests/ExplorerTests/testReplayFlow" > "$LOG" 2>&1
93
+ -only-testing:"OCQAHarnessUITests/ExplorerTests/testReplayFlow" \
94
+ -resultBundlePath "$RESULT_BUNDLE" > "$LOG" 2>&1
86
95
  [ -n "$RESPONDER_PID" ] && { kill "$RESPONDER_PID" 2>/dev/null; wait "$RESPONDER_PID" 2>/dev/null; }
87
96
 
97
+ cp "$LOG" "$EVIDENCE_DIR/flow.log"
98
+ python3 "$ROOT/scripts/flow_lib.py" report --json "$LOG" > "$EVIDENCE_DIR/flow-report.json"
88
99
  echo ""
89
100
  python3 "$ROOT/scripts/flow_lib.py" report "$LOG"
90
101
  exit $?
@@ -14,7 +14,8 @@ screen or journey works from source inspection alone.
14
14
  |---|---|
15
15
  | See or screenshot one screen | `open` / `tapp_open_app` |
16
16
  | Inspect controls on the current screen | `tree` / `tapp_ui_tree` |
17
- | Drive a specific journey | MCP session start act end |
17
+ | Reach a named screen/control | `focus` / `tapp_focus` (source + observed UI Map fast path) |
18
+ | Drive a specific journey | MCP session start → focus or act → end |
18
19
  | Find bugs autonomously | `explore` / `tapp_explore` |
19
20
  | Preserve a journey | record and save a Flow; replay it deterministically |
20
21
  | Decide whether a merge passes policy | `ci`; exploration never decides this |
@@ -42,6 +43,25 @@ Targets may be a repository path, Xcode container, `.app`, iOS bundle id, APK pl
42
43
  or owned HTTP(S) URL. Never explore a third-party web property without authorization: exploration
43
44
  clicks and types.
44
45
 
46
+ ## Navigate like a source-connected expert
47
+
48
+ When the user names a screen, control, or UI condition, do not discover the app one screenshot at a
49
+ time. Start from the repository source, then use Tapp's observed navigation evidence:
50
+
51
+ 1. With MCP, pass the exact request as `focus` to `tapp_session_start`, or call `tapp_focus` in an
52
+ active session. Without MCP, run `npx -y @aarwitz/tapp@latest focus "<exact request>" [target]`.
53
+ 2. Tapp searches owned source, reconciles the likely surface with `.tapp/ui-map.json`, and executes
54
+ the shortest runtime-observed route in one call. Read its final tree before visual assertions.
55
+ 3. If Tapp returns source evidence but no replayable route, inspect the cited file/line and relevant
56
+ router/navigation source. Do not wander blindly or invent a path; ground the map or drive only a
57
+ route supported by that source evidence.
58
+
59
+ A fresh repository needs one grounding exploration before `focus` can replay a route. Source can
60
+ locate an unobserved surface, but it never authorizes unobserved taps.
61
+
62
+ Source establishes intent and location; the real UI establishes behavior. A URL-only target has no
63
+ source advantage and correctly falls back to runtime observation.
64
+
45
65
  ## Observe honestly
46
66
 
47
67
  Exploration returns findings, coverage, evidence, and `inconclusive`; it does not return a score or
@@ -64,7 +84,7 @@ point the human to the report's exploration recording when available.
64
84
 
65
85
  ## Drive safely
66
86
 
67
- For an interactive MCP session, read returned `elements[]` before every action, target accessibility
87
+ After the focused fast path, read returned `elements[]` before any remaining action, target accessibility
68
88
  ids or visible labels, check `hittable`, tap a field before typing, and wait for navigation or async
69
89
  content. Use coordinates only as a last resort. End the session when finished.
70
90