@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.
- package/.claude-plugin/plugin.json +2 -2
- package/AGENTS.md +10 -1
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +54 -7
- package/README.md +27 -16
- package/bin/tapp.js +72 -1
- package/docs/scenarios.md +1 -1
- package/mcp-server/src/android-driver.js +13 -2
- package/mcp-server/src/focused-navigation.js +271 -0
- package/mcp-server/src/index.js +240 -25
- package/mcp-server/src/ui-map.js +2 -2
- package/package.json +3 -3
- package/scripts/quick-capture.sh +42 -11
- package/scripts/run-flow.sh +12 -1
- package/skills/tapp/SKILL.md +22 -2
- package/skills/tapp/references/commands.md +4 -1
package/mcp-server/src/index.js
CHANGED
|
@@ -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" : "
|
|
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:
|
|
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
|
|
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
|
-
"
|
|
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
|
|
3695
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
4039
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
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
|
|
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
|
}
|
package/mcp-server/src/ui-map.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"mcpName": "io.github.aarwitz/tapp",
|
|
5
|
-
"description": "
|
|
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
|
}
|
package/scripts/quick-capture.sh
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
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
|
|
package/scripts/run-flow.sh
CHANGED
|
@@ -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"
|
|
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 $?
|
package/skills/tapp/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
|
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
|
-
|
|
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
|
|