@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.
- package/.claude-plugin/plugin.json +2 -2
- package/AGENTS.md +16 -6
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +54 -7
- package/README.md +29 -17
- package/bin/tapp.js +170 -2
- package/docs/scenarios.md +1 -1
- package/mcp-server/src/android-driver.js +20 -2
- package/mcp-server/src/environment-preflight.js +35 -0
- package/mcp-server/src/focused-navigation.js +271 -0
- package/mcp-server/src/index.js +325 -29
- 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 +28 -5
- package/skills/tapp/references/commands.md +13 -5
package/mcp-server/src/index.js
CHANGED
|
@@ -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
|
|
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" : "
|
|
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:
|
|
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
|
|
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
|
-
"
|
|
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
|
|
3695
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
4039
|
-
|
|
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
|
-
:
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
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
|
|
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
|
}
|
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.2",
|
|
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
|
}
|