@aarwitz/tapp 0.17.0-rc.9 → 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/marketplace.json +34 -0
- package/.claude-plugin/plugin.json +33 -0
- package/AGENTS.md +44 -15
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +65 -7
- package/README.md +141 -81
- package/bin/tapp.js +253 -64
- package/docs/BROWSER-PRODUCT.md +1 -1
- package/docs/application-model.md +12 -3
- package/docs/scenarios.md +1 -1
- package/mcp-server/src/android-driver.js +13 -2
- package/mcp-server/src/android-explorer.js +3 -1
- package/mcp-server/src/android-flow.js +18 -1
- package/mcp-server/src/application-model.js +83 -13
- package/mcp-server/src/ci-report.js +2 -2
- package/mcp-server/src/ci-setup.js +4 -4
- package/mcp-server/src/environment-preflight.js +43 -0
- package/mcp-server/src/focused-navigation.js +271 -0
- package/mcp-server/src/html-report.js +3 -2
- package/mcp-server/src/index.js +565 -134
- package/mcp-server/src/pr-selection.js +2 -2
- package/mcp-server/src/product-operations.js +111 -6
- package/mcp-server/src/report.js +17 -4
- package/mcp-server/src/ui-map.js +2 -2
- package/mcp-server/src/web-explorer.js +123 -10
- package/mcp-server/src/web-flow.js +17 -1
- package/package.json +6 -4
- package/scripts/ci-gate.sh +42 -0
- package/scripts/flow_lib.py +1 -1
- package/scripts/quick-capture.sh +44 -13
- package/scripts/run-flow.sh +12 -1
- package/skills/tapp/SKILL.md +95 -0
- package/skills/tapp/agents/openai.yaml +4 -0
- package/skills/tapp/references/commands.md +105 -0
package/mcp-server/src/index.js
CHANGED
|
@@ -9,6 +9,8 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
9
9
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
10
|
import {
|
|
11
11
|
CallToolRequestSchema,
|
|
12
|
+
GetPromptRequestSchema,
|
|
13
|
+
ListPromptsRequestSchema,
|
|
12
14
|
ListToolsRequestSchema,
|
|
13
15
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
14
16
|
|
|
@@ -17,7 +19,15 @@ import { existingProjectArtifactPath, projectArtifactDirectory } from "./project
|
|
|
17
19
|
|
|
18
20
|
const __filename = fileURLToPath(import.meta.url);
|
|
19
21
|
const __dirname = path.dirname(__filename);
|
|
22
|
+
// `repoRoot` is the installed Tapp package root: scripts and bundled harness assets live here.
|
|
23
|
+
// Repository-facing MCP operations must use `workspaceRoot` instead. In an npm/Claude-plugin
|
|
24
|
+
// installation those are different directories, even though source-repo tests historically made
|
|
25
|
+
// them look identical.
|
|
20
26
|
const repoRoot = path.resolve(__dirname, "../..");
|
|
27
|
+
const workspaceRoot = (() => {
|
|
28
|
+
try { return fs.realpathSync(process.cwd()); }
|
|
29
|
+
catch { return path.resolve(process.cwd()); }
|
|
30
|
+
})();
|
|
21
31
|
const scriptsDir = path.join(repoRoot, "scripts");
|
|
22
32
|
// TAPP_HOME (set by the `tapp` CLI when installed) redirects writable output to a user directory.
|
|
23
33
|
// The old alias remains a read-only fallback; unset repository development stays local.
|
|
@@ -35,8 +45,13 @@ function clampOutput(value, maxChars = MAX_OUTPUT_CHARS) {
|
|
|
35
45
|
return value;
|
|
36
46
|
}
|
|
37
47
|
|
|
38
|
-
|
|
39
|
-
|
|
48
|
+
// Compiler/build diagnostics are normally emitted at the end. Preserve both ends so a long
|
|
49
|
+
// dependency build cannot truncate away the one actionable error the user needs.
|
|
50
|
+
const marker = "\n...[output truncated]...\n";
|
|
51
|
+
const retained = Math.max(0, maxChars - marker.length);
|
|
52
|
+
const head = Math.ceil(retained / 2);
|
|
53
|
+
const tail = Math.floor(retained / 2);
|
|
54
|
+
return `${value.slice(0, head)}${marker}${value.slice(value.length - tail)}`;
|
|
40
55
|
}
|
|
41
56
|
|
|
42
57
|
function asBoolean(value, fallback = false) {
|
|
@@ -320,6 +335,9 @@ export function findXcodeContainer(startDir) {
|
|
|
320
335
|
}
|
|
321
336
|
|
|
322
337
|
export async function buildAppForSim({ dir, container, scheme, configuration = "Debug" } = {}) {
|
|
338
|
+
const { storagePreflight } = await import("./environment-preflight.js");
|
|
339
|
+
const storage = storagePreflight(path.join(tappHome || os.tmpdir(), "app-builds"));
|
|
340
|
+
if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
|
|
323
341
|
const target = container || findXcodeContainer(dir || process.cwd());
|
|
324
342
|
if (!target) return { error: `No Xcode project or workspace found under ${dir || process.cwd()}` };
|
|
325
343
|
const isWorkspace = target.endsWith(".xcworkspace");
|
|
@@ -539,9 +557,59 @@ function treeSnapshot() {
|
|
|
539
557
|
screenTitle: t ? t.screenTitle ?? null : null,
|
|
540
558
|
elementCount: t ? (t.elements || []).length : 0,
|
|
541
559
|
elements: t ? t.elements || [] : [],
|
|
560
|
+
...(t?.url ? { url:t.url } : {}),
|
|
561
|
+
};
|
|
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 } : {}),
|
|
542
604
|
};
|
|
543
605
|
}
|
|
544
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
|
+
|
|
545
613
|
async function startSession(bundleId, extraEnv = {}) {
|
|
546
614
|
if (activeSession && !activeSession.ended) {
|
|
547
615
|
return { error: "A session is already active; call tapp_session_end first.", screen: treeSnapshot() };
|
|
@@ -560,7 +628,7 @@ async function startSession(bundleId, extraEnv = {}) {
|
|
|
560
628
|
env: { ...process.env, ...extraEnv, OCQA_SESSION_CMD_PATH: cmdPath, OCQA_SESSION_RESULT_PATH: resultPath, OCQA_SESSION_TIMEOUT: "7200" },
|
|
561
629
|
});
|
|
562
630
|
activeSession = {
|
|
563
|
-
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,
|
|
564
632
|
// Always-on recorder: each act appends a Flow step; tapp_flow_save snapshots it to a file.
|
|
565
633
|
recording: [],
|
|
566
634
|
creds: { email: extraEnv.OCQA_TEST_EMAIL || "", password: extraEnv.OCQA_TEST_PASSWORD || "" },
|
|
@@ -618,24 +686,26 @@ async function webSessionSnapshot(page) {
|
|
|
618
686
|
const rect = element.getBoundingClientRect();
|
|
619
687
|
return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
|
|
620
688
|
};
|
|
621
|
-
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")]
|
|
622
690
|
.filter((element) => element instanceof HTMLElement && visible(element))
|
|
623
691
|
.slice(0, 250)
|
|
624
692
|
.map((element) => {
|
|
625
693
|
const rect = element.getBoundingClientRect();
|
|
626
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]");
|
|
627
696
|
return {
|
|
628
697
|
id: element.getAttribute("data-testid") || element.id || element.getAttribute("name") || "",
|
|
629
698
|
label,
|
|
630
699
|
type: element.getAttribute("role") || element.tagName.toLowerCase(),
|
|
631
|
-
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"),
|
|
632
701
|
enabled: !(element.disabled || element.getAttribute("aria-disabled") === "true"),
|
|
633
|
-
hittable:
|
|
702
|
+
hittable: interactive,
|
|
634
703
|
clickable: element.matches("button,a,[role=button],[role=tab],[role=checkbox],[role=switch]") && !(element.disabled || element.getAttribute("aria-disabled") === "true"),
|
|
635
704
|
secure: element instanceof HTMLInputElement && element.type === "password",
|
|
636
705
|
frame: { x:Math.round(rect.x), y:Math.round(rect.y), width:Math.round(rect.width), height:Math.round(rect.height) },
|
|
637
706
|
};
|
|
638
|
-
})
|
|
707
|
+
})
|
|
708
|
+
.filter((element) => element.label);
|
|
639
709
|
const heading = document.querySelector("h1,[role=heading]")?.textContent?.replace(/\s+/g, " ").trim();
|
|
640
710
|
return { screenTitle:heading || document.title || location.pathname || "Web application", elements:controls, url:location.href };
|
|
641
711
|
});
|
|
@@ -700,6 +770,28 @@ function templateValue(text) {
|
|
|
700
770
|
return text;
|
|
701
771
|
}
|
|
702
772
|
|
|
773
|
+
export function isStableFlowCheckpoint(value) {
|
|
774
|
+
const text = String(value || "").replace(/\s+/g, " ").trim();
|
|
775
|
+
if (!text || /^(loading|fetching|please wait|preparing|connecting|syncing|signing in)(?:[.…!]*|\s.*)$/i.test(text)) return false;
|
|
776
|
+
if (/^(?:mon|tues?|wed(?:nes)?|thu(?:rs)?|fri|sat(?:ur)?|sun)(?:day)?\b/i.test(text)) return false;
|
|
777
|
+
if (/^(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+\d{1,2}(?:,\s+\d{4})?$/i.test(text)) return false;
|
|
778
|
+
if (/^\d{4}-\d{2}-\d{2}(?:[ T].*)?$/.test(text)) return false;
|
|
779
|
+
return true;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
export function semanticTargetAtPoint(elements, x, y) {
|
|
783
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return "";
|
|
784
|
+
return (elements || [])
|
|
785
|
+
.filter((element) => {
|
|
786
|
+
const frame = element.frame || {};
|
|
787
|
+
return element.hittable !== false && Number.isFinite(frame.x) && Number.isFinite(frame.y) && Number.isFinite(frame.width) && Number.isFinite(frame.height)
|
|
788
|
+
&& x >= frame.x && y >= frame.y && x <= frame.x + frame.width && y <= frame.y + frame.height
|
|
789
|
+
&& String(element.id || element.identifier || element.label || "").trim();
|
|
790
|
+
})
|
|
791
|
+
.sort((a, b) => (a.frame.width * a.frame.height) - (b.frame.width * b.frame.height))
|
|
792
|
+
.map((element) => String(element.id || element.identifier || element.label || "").trim())[0] || "";
|
|
793
|
+
}
|
|
794
|
+
|
|
703
795
|
/** Append a Flow step for an act (record-by-doing). Inserts wait_for on screen change for
|
|
704
796
|
* deterministic replay. Inspection acts (tree/screenshot/wait) are not recorded. */
|
|
705
797
|
function recordStep(cmd, result) {
|
|
@@ -710,7 +802,7 @@ function recordStep(cmd, result) {
|
|
|
710
802
|
case "tap": {
|
|
711
803
|
const target = cmd.id || cmd.label || (typeof cmd.x === "number" ? `${cmd.x},${cmd.y}` : "");
|
|
712
804
|
if (target) activeSession.recording.push({ tap: target });
|
|
713
|
-
if (changed) activeSession.recording.push({ wait_for: newScreen });
|
|
805
|
+
if (changed && isStableFlowCheckpoint(newScreen)) activeSession.recording.push({ wait_for: newScreen });
|
|
714
806
|
break;
|
|
715
807
|
}
|
|
716
808
|
case "type": {
|
|
@@ -719,12 +811,16 @@ function recordStep(cmd, result) {
|
|
|
719
811
|
activeSession.recording.push({ type: step });
|
|
720
812
|
break;
|
|
721
813
|
}
|
|
814
|
+
case "login":
|
|
815
|
+
activeSession.recording.push({ login: { email: "$TEST_EMAIL", password: "$TEST_PASSWORD" } });
|
|
816
|
+
if (changed && isStableFlowCheckpoint(newScreen)) activeSession.recording.push({ wait_for: newScreen });
|
|
817
|
+
break;
|
|
722
818
|
case "swipe":
|
|
723
819
|
activeSession.recording.push({ swipe: cmd.direction || "up" });
|
|
724
820
|
break;
|
|
725
821
|
case "back":
|
|
726
822
|
activeSession.recording.push({ back: true });
|
|
727
|
-
if (changed) activeSession.recording.push({ wait_for: newScreen });
|
|
823
|
+
if (changed && isStableFlowCheckpoint(newScreen)) activeSession.recording.push({ wait_for: newScreen });
|
|
728
824
|
break;
|
|
729
825
|
default:
|
|
730
826
|
break; // tree / screenshot / wait are inspection, not test steps
|
|
@@ -733,7 +829,17 @@ function recordStep(cmd, result) {
|
|
|
733
829
|
}
|
|
734
830
|
|
|
735
831
|
async function sessionAct(cmd) {
|
|
736
|
-
|
|
832
|
+
const startedAt = Date.now();
|
|
833
|
+
const done = (result) => ({ ...result, durationMs: Date.now() - startedAt });
|
|
834
|
+
if (!activeSession || activeSession.ended) return done({ error: "No active session. Call tapp_session_start first." });
|
|
835
|
+
let coordinateResolvedTarget = "";
|
|
836
|
+
if (cmd.action === "tap" && !cmd.id && Number.isFinite(cmd.x) && Number.isFinite(cmd.y)) {
|
|
837
|
+
coordinateResolvedTarget = semanticTargetAtPoint(activeSession.latestTree?.elements, cmd.x, cmd.y);
|
|
838
|
+
if (coordinateResolvedTarget) {
|
|
839
|
+
const { x: _x, y: _y, ...semanticCommand } = cmd;
|
|
840
|
+
cmd = { ...semanticCommand, id: coordinateResolvedTarget };
|
|
841
|
+
}
|
|
842
|
+
}
|
|
737
843
|
if (activeSession.platform === "web") {
|
|
738
844
|
const session = activeSession;
|
|
739
845
|
let status = "ok";
|
|
@@ -741,9 +847,13 @@ async function sessionAct(cmd) {
|
|
|
741
847
|
let typedInto = null;
|
|
742
848
|
try {
|
|
743
849
|
if (cmd.action === "tap") {
|
|
744
|
-
const
|
|
745
|
-
if (!
|
|
746
|
-
else
|
|
850
|
+
const target = cmd.id || cmd.label || "";
|
|
851
|
+
if (!target && Number.isFinite(cmd.x) && Number.isFinite(cmd.y)) await session.page.mouse.click(cmd.x, cmd.y);
|
|
852
|
+
else {
|
|
853
|
+
const locator = await firstVisibleWebLocator(session.page, target);
|
|
854
|
+
if (!locator) { status = "not_found"; detail = "No visible web control matched the semantic target"; }
|
|
855
|
+
else await locator.click({ timeout:10_000 });
|
|
856
|
+
}
|
|
747
857
|
} else if (cmd.action === "type") {
|
|
748
858
|
const locator = await firstVisibleWebLocator(session.page, cmd.id || cmd.label || "", { input:true });
|
|
749
859
|
if (!locator) { status = "not_found"; detail = "No visible web field matched the semantic target"; }
|
|
@@ -756,6 +866,28 @@ async function sessionAct(cmd) {
|
|
|
756
866
|
if (!locator) await session.page.waitForTimeout(120);
|
|
757
867
|
}
|
|
758
868
|
if (!locator) { status = "timeout"; detail = `Timed out waiting for ${cmd.id || cmd.text || "target"}`; }
|
|
869
|
+
} else if (cmd.action === "login") {
|
|
870
|
+
const emailValue = cmd.email || session.creds.email || "";
|
|
871
|
+
const passwordValue = cmd.password || session.creds.password || "";
|
|
872
|
+
if (!emailValue || !passwordValue) { status = "missing_credentials"; detail = "Email and password are required"; }
|
|
873
|
+
else {
|
|
874
|
+
const email = await firstVisibleWebLocator(session.page, "Email", { input:true })
|
|
875
|
+
|| session.page.locator("input[type=email], input[name*=mail i], input[name*=user i], input[id*=mail i], input[id*=user i]").first();
|
|
876
|
+
const password = session.page.locator("input[type=password]").first();
|
|
877
|
+
if (!(await email.isVisible().catch(() => false)) || !(await password.isVisible().catch(() => false))) {
|
|
878
|
+
status = "not_found"; detail = "Could not identify email and password fields";
|
|
879
|
+
} else {
|
|
880
|
+
await email.fill(emailValue);
|
|
881
|
+
await password.fill(passwordValue);
|
|
882
|
+
const submit = session.page.getByRole("button", { name:/sign in|log in|login|continue|submit/i }).first();
|
|
883
|
+
if (!(await submit.isVisible().catch(() => false))) { status = "not_found"; detail = "Could not identify a sign-in control"; }
|
|
884
|
+
else {
|
|
885
|
+
await submit.click();
|
|
886
|
+
await session.page.waitForTimeout(500);
|
|
887
|
+
if (await password.isVisible().catch(() => false)) { status = "still_on_login"; detail = "Submit left the app on the login screen"; }
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
759
891
|
} else if (cmd.action === "back") {
|
|
760
892
|
await session.page.goBack({ waitUntil:"domcontentloaded", timeout:10_000 }).catch(() => {});
|
|
761
893
|
} else if (cmd.action === "swipe") {
|
|
@@ -774,10 +906,11 @@ async function sessionAct(cmd) {
|
|
|
774
906
|
}
|
|
775
907
|
const snapshot = treeSnapshot();
|
|
776
908
|
if (status === "ok") recordStep(cmd, snapshot);
|
|
777
|
-
return { status, typedInto, detail, ...snapshot, recordedSteps:session.recording.length, url:session.latestTree?.url || "" };
|
|
909
|
+
return done({ status, typedInto, detail, ...snapshot, recordedSteps:session.recording.length, url:session.latestTree?.url || "", ...(coordinateResolvedTarget ? { coordinateResolvedTarget } : {}) });
|
|
778
910
|
}
|
|
779
911
|
if (activeSession.platform === "android") {
|
|
780
912
|
const s = activeSession;
|
|
913
|
+
const beforeAction = s.latestTree;
|
|
781
914
|
let status = "ok";
|
|
782
915
|
let detail = null;
|
|
783
916
|
let typedInto = null;
|
|
@@ -809,28 +942,28 @@ async function sessionAct(cmd) {
|
|
|
809
942
|
status = "not_found"; detail = "Could not identify email and password fields";
|
|
810
943
|
} else {
|
|
811
944
|
const er = await s.driver.type(emailField.id || emailField.label, cmd.email || s.creds.email || "", s.latestTree);
|
|
812
|
-
s.latestTree = await s.driver.settle();
|
|
945
|
+
s.latestTree = await s.driver.settle(2200, s.latestTree);
|
|
813
946
|
const pr = await s.driver.type(passwordField.id || passwordField.label, cmd.password || s.creds.password || "", s.latestTree);
|
|
814
|
-
s.latestTree = await s.driver.settle();
|
|
947
|
+
s.latestTree = await s.driver.settle(2200, s.latestTree);
|
|
815
948
|
const submit = s.latestTree.elements.find((e) => e.clickable && /sign in|log in|login|continue/i.test(`${e.text} ${e.label} ${e.id}`));
|
|
816
949
|
if (er.status !== "ok" || pr.status !== "ok" || !submit) {
|
|
817
950
|
status = "not_found"; detail = "Could not fill or submit the login form";
|
|
818
951
|
} else {
|
|
819
952
|
const before = s.latestTree.screenTitle;
|
|
820
953
|
await s.driver.tap(submit.id || submit.description || submit.text, s.latestTree);
|
|
821
|
-
s.latestTree = await s.driver.settle();
|
|
954
|
+
s.latestTree = await s.driver.settle(2200, s.latestTree);
|
|
822
955
|
if (s.latestTree.screenTitle === before) { status = "still_on_login"; detail = "Submit left the app on the login screen"; }
|
|
823
956
|
}
|
|
824
957
|
}
|
|
825
958
|
}
|
|
826
|
-
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);
|
|
827
960
|
} catch (error) {
|
|
828
961
|
status = "error"; detail = error.message || String(error);
|
|
829
962
|
}
|
|
830
963
|
s.treeVersion += 1;
|
|
831
964
|
const snap = treeSnapshot();
|
|
832
965
|
if (status === "ok") recordStep(cmd, snap);
|
|
833
|
-
return { status, typedInto, detail, ...snap, recordedSteps: s.recording.length };
|
|
966
|
+
return done({ status, typedInto, detail, ...snap, recordedSteps: s.recording.length, ...(coordinateResolvedTarget ? { coordinateResolvedTarget } : {}) });
|
|
834
967
|
}
|
|
835
968
|
activeSession.seq += 1;
|
|
836
969
|
const seq = activeSession.seq;
|
|
@@ -859,7 +992,7 @@ async function sessionAct(cmd) {
|
|
|
859
992
|
while (activeSession.treeVersion === beforeVer && Date.now() < td && !activeSession.ended) await sleep(150);
|
|
860
993
|
const snap = treeSnapshot();
|
|
861
994
|
if (status === "ok") recordStep(cmd, snap); // record only successful acts
|
|
862
|
-
return { status, typedInto, detail, ...snap, recordedSteps: activeSession ? activeSession.recording.length : 0 };
|
|
995
|
+
return done({ status, typedInto, detail, ...snap, recordedSteps: activeSession ? activeSession.recording.length : 0, ...(coordinateResolvedTarget ? { coordinateResolvedTarget } : {}) });
|
|
863
996
|
}
|
|
864
997
|
|
|
865
998
|
async function endSession() {
|
|
@@ -904,7 +1037,7 @@ export async function saveInteractiveSessionFlow({ projectDir, name, addFinalAss
|
|
|
904
1037
|
if (!projectDir || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error("A valid repository root is required to save a Flow");
|
|
905
1038
|
const steps = [...(activeSession.recording || [])];
|
|
906
1039
|
if (steps.length === 0) throw new Error("Nothing recorded yet — perform some live-session actions first.");
|
|
907
|
-
if (addFinalAssertion && activeSession.lastScreen) {
|
|
1040
|
+
if (addFinalAssertion && activeSession.lastScreen && isStableFlowCheckpoint(activeSession.lastScreen)) {
|
|
908
1041
|
const last = steps[steps.length - 1] || {};
|
|
909
1042
|
if (!("assert_screen" in last)) steps.push({ assert_screen: activeSession.lastScreen });
|
|
910
1043
|
}
|
|
@@ -946,6 +1079,79 @@ export {
|
|
|
946
1079
|
endSession as endInteractiveSession,
|
|
947
1080
|
};
|
|
948
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
|
+
|
|
949
1155
|
export async function captureInteractiveSessionFrame(maxWidth = 900) {
|
|
950
1156
|
if (!activeSession || activeSession.ended) return { error:"No active interactive session" };
|
|
951
1157
|
if (activeSession.platform === "android") {
|
|
@@ -1229,6 +1435,7 @@ export function explorationEnvFromArgs(args) {
|
|
|
1229
1435
|
const env = {};
|
|
1230
1436
|
if (isNonEmptyString(args.testEmail)) env.OCQA_TEST_EMAIL = args.testEmail;
|
|
1231
1437
|
if (isNonEmptyString(args.testPassword)) env.OCQA_TEST_PASSWORD = args.testPassword;
|
|
1438
|
+
if (isNonEmptyString(args.testEmail) || isNonEmptyString(args.testPassword)) env.OCQA_CREDENTIALS_EXPLICIT = "1";
|
|
1232
1439
|
if (Array.isArray(args.appLaunchArgs)) {
|
|
1233
1440
|
const a = args.appLaunchArgs.filter((s) => typeof s === "string" && s.length > 0);
|
|
1234
1441
|
if (a.length) env.OCQA_APP_LAUNCH_ARGS_JSON = JSON.stringify(a);
|
|
@@ -1252,6 +1459,14 @@ export function explorationEnvFromArgs(args) {
|
|
|
1252
1459
|
if (args.prExplorationTarget && typeof args.prExplorationTarget === "object" && !Array.isArray(args.prExplorationTarget)) {
|
|
1253
1460
|
env.OCQA_PR_TARGET_JSON = JSON.stringify(args.prExplorationTarget);
|
|
1254
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
|
+
}
|
|
1255
1470
|
// Explicit login replay: a recorded sequence run before exploration, for custom login UIs the
|
|
1256
1471
|
// heuristic preamble can't parse — the #1 reason a real app stays invisible. Steps are
|
|
1257
1472
|
// {action: type|tap|wait, target, value?, timeoutMs?}; $TEST_EMAIL/$TEST_PASSWORD substituted
|
|
@@ -1320,9 +1535,9 @@ function fmtDuration(ms) {
|
|
|
1320
1535
|
export function qaNextSteps(report, surface = "mcp") {
|
|
1321
1536
|
if (surface === "cli") {
|
|
1322
1537
|
const next = [];
|
|
1323
|
-
if (report?.findings?.length) next.push("inspect the evidence with `tapp report latest`");
|
|
1324
|
-
next.push("re-run with `--baseline <report.json>` to compare a fix (
|
|
1325
|
-
next.push("replay a committed journey with `tapp flow run <file>`");
|
|
1538
|
+
if (report?.findings?.length) next.push("inspect the evidence with `npx -y @aarwitz/tapp@latest report latest`");
|
|
1539
|
+
next.push("save this run with `--json <report.json>`, then re-run with `--baseline <report.json>` to compare a fix (`tapp ci` gates it)");
|
|
1540
|
+
next.push("replay a committed journey with `npx -y @aarwitz/tapp@latest flow run <file>`");
|
|
1326
1541
|
return next;
|
|
1327
1542
|
}
|
|
1328
1543
|
const next = [];
|
|
@@ -1344,6 +1559,7 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
|
|
|
1344
1559
|
L.push(`### 🔭 Exploration complete — ${badge} · ${observationSummary(report)}${bundleId ? `\n\`${bundleId}\`` : ""}`);
|
|
1345
1560
|
L.push("");
|
|
1346
1561
|
L.push(report.headline);
|
|
1562
|
+
if (report.credentialWarning) L.push("", `> ⚠️ ${report.credentialWarning}`);
|
|
1347
1563
|
L.push("");
|
|
1348
1564
|
L.push(`**Coverage** — ${report.screensExplored} screens · ${report.actionsPerformed} actions${timedOut ? " · ⏱️ hit time limit" : ""}`);
|
|
1349
1565
|
if (report.platform === "web") {
|
|
@@ -1358,7 +1574,7 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
|
|
|
1358
1574
|
L.push("");
|
|
1359
1575
|
L.push("**Findings**");
|
|
1360
1576
|
for (const f of report.findings.slice(0, 12)) {
|
|
1361
|
-
L.push(`- ${SEV[f.severity] || "•"} \`${f.severity}\` ${f.title}${f.screen ? ` — on *${f.screen}*` : ""}`);
|
|
1577
|
+
L.push(`- ${SEV[f.severity] || "•"} \`${f.severity}\` ${f.title}${f.screen ? ` — on *${f.screen}*` : ""}${f.url ? ` — ${f.url}` : ""}`);
|
|
1362
1578
|
if (f.aiAnalysis) L.push(` - why: ${String(f.aiAnalysis).slice(0, 200)}`);
|
|
1363
1579
|
if (f.suggestedFix) L.push(` - fix: ${String(f.suggestedFix).slice(0, 200)}`);
|
|
1364
1580
|
}
|
|
@@ -1375,7 +1591,7 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
|
|
|
1375
1591
|
// The merge decision is the gate's job (tapp ci), not exploration's (ADR-0005).
|
|
1376
1592
|
L.push("");
|
|
1377
1593
|
L.push(
|
|
1378
|
-
`**Since last run** — +${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved (comparison only — run \`tapp ci\` to gate)`
|
|
1594
|
+
`**Since last run** — +${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved (comparison only — run \`npx -y @aarwitz/tapp@latest ci\` to gate)`
|
|
1379
1595
|
);
|
|
1380
1596
|
}
|
|
1381
1597
|
if (inputHint) {
|
|
@@ -1458,7 +1674,10 @@ export function formatScreen(screenTitle, elements) {
|
|
|
1458
1674
|
// `tapp` CLI verbs in bin/tapp.js — same pattern as report.js. Keep orchestration HERE so
|
|
1459
1675
|
// the surfaces can't drift.)
|
|
1460
1676
|
|
|
1461
|
-
export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], surface = "mcp", onProgress = () => {} }) {
|
|
1677
|
+
export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], watch = false, surface = "mcp", onProgress = () => {} }) {
|
|
1678
|
+
const { storagePreflight } = await import("./environment-preflight.js");
|
|
1679
|
+
const storage = storagePreflight(capturesDir);
|
|
1680
|
+
if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
|
|
1462
1681
|
const actions = Math.max(1, Math.min(1000, asInteger(maxActions, 60)));
|
|
1463
1682
|
const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
|
|
1464
1683
|
const id = "web-" + new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/^(\d{8})/, "$1-");
|
|
@@ -1475,6 +1694,7 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
|
|
|
1475
1694
|
testPassword: isNonEmptyString(testPassword) ? testPassword.trim() : "",
|
|
1476
1695
|
seedRoutes,
|
|
1477
1696
|
seedTargets,
|
|
1697
|
+
watch: watch === true,
|
|
1478
1698
|
onProgress,
|
|
1479
1699
|
});
|
|
1480
1700
|
} catch (err) {
|
|
@@ -1500,6 +1720,9 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
|
|
|
1500
1720
|
}
|
|
1501
1721
|
|
|
1502
1722
|
export async function runQaAndroid({ appId, apkPath, serial, maxActions, timeout, testEmail, testPassword, baselineFindings, clearData = true, seedTargets = [], surface = "mcp", onProgress = () => {} }) {
|
|
1723
|
+
const { storagePreflight } = await import("./environment-preflight.js");
|
|
1724
|
+
const storage = storagePreflight(capturesDir);
|
|
1725
|
+
if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
|
|
1503
1726
|
const actions = Math.max(1, Math.min(1000, asInteger(maxActions, 60)));
|
|
1504
1727
|
const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
|
|
1505
1728
|
const id = "android-" + new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/^(\d{8})/, "$1-");
|
|
@@ -1543,6 +1766,9 @@ export async function runQaAndroid({ appId, apkPath, serial, maxActions, timeout
|
|
|
1543
1766
|
}
|
|
1544
1767
|
|
|
1545
1768
|
export async function runQaIos({ bundleId, maxActions, timeout, args = {}, surface = "mcp", onProgress = () => {} }) {
|
|
1769
|
+
const { storagePreflight } = await import("./environment-preflight.js");
|
|
1770
|
+
const storage = storagePreflight(capturesDir);
|
|
1771
|
+
if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
|
|
1546
1772
|
const captureScript = path.join(scriptsDir, "quick-capture.sh");
|
|
1547
1773
|
if (!fs.existsSync(captureScript)) return { error: "Capture script not found", details: { captureScript } };
|
|
1548
1774
|
|
|
@@ -1635,6 +1861,7 @@ export async function runInitExploration({
|
|
|
1635
1861
|
timeout,
|
|
1636
1862
|
testEmail,
|
|
1637
1863
|
testPassword,
|
|
1864
|
+
watch = false,
|
|
1638
1865
|
onProgress = () => {},
|
|
1639
1866
|
onStatus = () => {},
|
|
1640
1867
|
} = {}) {
|
|
@@ -1650,17 +1877,18 @@ export async function runInitExploration({
|
|
|
1650
1877
|
let targetResolution = null;
|
|
1651
1878
|
let qa;
|
|
1652
1879
|
let managedRuntime = null;
|
|
1880
|
+
if (watch && selected !== "web") return { error: "Watch mode is currently available for web exploration only." };
|
|
1653
1881
|
if (selected === "web") {
|
|
1654
1882
|
if (/^https?:\/\//i.test(String(url))) {
|
|
1655
1883
|
resolvedTarget = String(url).trim();
|
|
1656
|
-
qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, onProgress });
|
|
1884
|
+
qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, watch, onProgress });
|
|
1657
1885
|
} else {
|
|
1658
1886
|
const started = await startManagedWebTarget({ root, requestedTarget: target, timeout, onStatus });
|
|
1659
1887
|
if (started.error) return started;
|
|
1660
1888
|
managedRuntime = started;
|
|
1661
1889
|
resolvedTarget = started.url;
|
|
1662
1890
|
try {
|
|
1663
|
-
qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, onProgress });
|
|
1891
|
+
qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, watch, onProgress });
|
|
1664
1892
|
} finally {
|
|
1665
1893
|
await stopManagedWebTarget(started);
|
|
1666
1894
|
}
|
|
@@ -1772,6 +2000,7 @@ export async function runExploreTarget({
|
|
|
1772
2000
|
appLaunchArgs,
|
|
1773
2001
|
appLaunchEnv,
|
|
1774
2002
|
baselineFindings,
|
|
2003
|
+
watch = false,
|
|
1775
2004
|
surface = "cli",
|
|
1776
2005
|
onProgress = () => {},
|
|
1777
2006
|
onStatus = () => {},
|
|
@@ -1782,7 +2011,7 @@ export async function runExploreTarget({
|
|
|
1782
2011
|
|
|
1783
2012
|
const modelPath = existingProjectArtifactPath(root, "application-model.json");
|
|
1784
2013
|
if (!fs.existsSync(modelPath)) {
|
|
1785
|
-
return { error: "No application model found — run `tapp init` first, or pass an explicit target (a bundle id, a path/to/App.app, a repo dir, --app-id/--apk, or an http(s) URL)." };
|
|
2014
|
+
return { error: "No application model found — call `tapp_init` or run `npx -y @aarwitz/tapp@latest init` first, or pass an explicit target (a bundle id, a path/to/App.app, a repo dir, --app-id/--apk, or an http(s) URL)." };
|
|
1786
2015
|
}
|
|
1787
2016
|
let model;
|
|
1788
2017
|
try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
|
|
@@ -1794,6 +2023,8 @@ export async function runExploreTarget({
|
|
|
1794
2023
|
catch (error) { return { error: error.message || String(error) }; }
|
|
1795
2024
|
const selectedPlatform = selected.platform;
|
|
1796
2025
|
|
|
2026
|
+
if (watch && selectedPlatform !== "web") return { error: "Watch mode is currently available for web exploration only." };
|
|
2027
|
+
|
|
1797
2028
|
if (selectedPlatform !== "ios" && ((Array.isArray(appLaunchArgs) && appLaunchArgs.length) || (appLaunchEnv && Object.keys(appLaunchEnv).length))) {
|
|
1798
2029
|
return { error: "appLaunchArgs/appLaunchEnv apply only to iOS targets." };
|
|
1799
2030
|
}
|
|
@@ -1802,14 +2033,14 @@ export async function runExploreTarget({
|
|
|
1802
2033
|
const ownedUrl = String(selected.runtime?.ownedUrl || "").trim();
|
|
1803
2034
|
if (/^https?:\/\//i.test(ownedUrl)) {
|
|
1804
2035
|
onStatus(`Exploring the owned URL from the application model: ${ownedUrl}`);
|
|
1805
|
-
return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
|
|
2036
|
+
return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, watch, surface, onProgress });
|
|
1806
2037
|
}
|
|
1807
2038
|
// Tapp-managed: build/start the repo's web target, wait for readiness, and ALWAYS stop it.
|
|
1808
2039
|
onStatus(`Preparing the managed web runtime for ${selected.name}…`);
|
|
1809
2040
|
const started = await startManagedWebTarget({ root, requestedTarget: selected.sourcePath || selected.name || "", timeout, onStatus });
|
|
1810
2041
|
if (started.error) return started;
|
|
1811
2042
|
try {
|
|
1812
|
-
return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
|
|
2043
|
+
return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, watch, surface, onProgress });
|
|
1813
2044
|
} finally {
|
|
1814
2045
|
await stopManagedWebTarget(started);
|
|
1815
2046
|
onStatus("Stopped the managed web runtime.");
|
|
@@ -1818,7 +2049,7 @@ export async function runExploreTarget({
|
|
|
1818
2049
|
|
|
1819
2050
|
if (selectedPlatform === "android") {
|
|
1820
2051
|
const appId = String(selected.runtime?.applicationId || "").trim();
|
|
1821
|
-
if (!appId) return { error: `The Android target '${selected.name}' has no confirmed application id — confirm it and rerun \`tapp init\`, or pass --app-id.` };
|
|
2052
|
+
if (!appId) return { error: `The Android target '${selected.name}' has no confirmed application id — confirm it and call \`tapp_init\` or rerun \`npx -y @aarwitz/tapp@latest init\`, or pass --app-id.` };
|
|
1822
2053
|
const task = selected.build?.task || "assembleDebug";
|
|
1823
2054
|
onStatus(`Building the Android APK (${task})…`);
|
|
1824
2055
|
const built = await buildAndroidApp({
|
|
@@ -1856,6 +2087,22 @@ function openLocalPort() {
|
|
|
1856
2087
|
});
|
|
1857
2088
|
}
|
|
1858
2089
|
|
|
2090
|
+
function localPortAvailable(port) {
|
|
2091
|
+
return new Promise((resolve) => {
|
|
2092
|
+
const server = net.createServer();
|
|
2093
|
+
server.unref();
|
|
2094
|
+
server.once("error", () => resolve(false));
|
|
2095
|
+
server.listen(port, "127.0.0.1", () => server.close(() => resolve(true)));
|
|
2096
|
+
});
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
export function managedWebDefaultPort(dependencies = {}) {
|
|
2100
|
+
if (dependencies.vite) return 5173;
|
|
2101
|
+
if (dependencies.next) return 3000;
|
|
2102
|
+
if (dependencies["react-scripts"]) return 3000;
|
|
2103
|
+
return 0;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
1859
2106
|
function managedInstallSpec(command) {
|
|
1860
2107
|
const known = {
|
|
1861
2108
|
"npm ci": ["npm", ["ci"]],
|
|
@@ -1940,7 +2187,9 @@ export async function startManagedWebTarget({ root, requestedTarget = "", timeou
|
|
|
1940
2187
|
const pkg = (() => { try { return JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf8")); } catch { return {}; } })();
|
|
1941
2188
|
const dependencies = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
1942
2189
|
const declaredPort = startMatch ? declaredPortFromStartScript(pkg.scripts?.[startMatch[1]]) : 0;
|
|
1943
|
-
const
|
|
2190
|
+
const frameworkPort = declaredPort ? 0 : managedWebDefaultPort(dependencies);
|
|
2191
|
+
const port = declaredPort || (frameworkPort && await localPortAvailable(frameworkPort) ? frameworkPort : await openLocalPort());
|
|
2192
|
+
const portBasis = declaredPort ? "repository-declared" : port === frameworkPort ? "framework-default" : "available-ephemeral";
|
|
1944
2193
|
let command = "npm";
|
|
1945
2194
|
let startArgs;
|
|
1946
2195
|
let startDir = projectDir;
|
|
@@ -1967,13 +2216,13 @@ export async function startManagedWebTarget({ root, requestedTarget = "", timeou
|
|
|
1967
2216
|
const append = (chunk) => fs.appendFileSync(logPath, String(chunk));
|
|
1968
2217
|
child.stdout.on("data", append);
|
|
1969
2218
|
child.stderr.on("data", append);
|
|
1970
|
-
onStatus(`Managed web runtime: ${command === process.execPath ? "Tapp static server" : `npm ${startArgs.join(" ")}`} → http://127.0.0.1:${port}`);
|
|
2219
|
+
onStatus(`Managed web runtime: ${command === process.execPath ? "Tapp static server" : `npm ${startArgs.join(" ")}`} → http://127.0.0.1:${port} (${portBasis} port)`);
|
|
1971
2220
|
const ready = await waitForOwnedUrl(`http://127.0.0.1:${port}`, child, Math.min(budgetMs, 60_000), logPath);
|
|
1972
2221
|
if (ready.error) {
|
|
1973
2222
|
await stopManagedWebTarget({ child, detached: process.platform !== "win32" });
|
|
1974
2223
|
return ready;
|
|
1975
2224
|
}
|
|
1976
|
-
return { child, detached: process.platform !== "win32", url: `http://127.0.0.1:${port}`, logPath, install, build, start: { command, args: startArgs, cwd: startDir } };
|
|
2225
|
+
return { child, detached: process.platform !== "win32", url: `http://127.0.0.1:${port}`, logPath, install, build, start: { command, args: startArgs, cwd: startDir, portBasis } };
|
|
1977
2226
|
}
|
|
1978
2227
|
|
|
1979
2228
|
export async function stopManagedWebTarget(runtime) {
|
|
@@ -2032,11 +2281,62 @@ const server = new Server(
|
|
|
2032
2281
|
},
|
|
2033
2282
|
{
|
|
2034
2283
|
capabilities: {
|
|
2284
|
+
prompts: {},
|
|
2035
2285
|
tools: {},
|
|
2036
2286
|
},
|
|
2037
2287
|
}
|
|
2038
2288
|
);
|
|
2039
2289
|
|
|
2290
|
+
const testAppPrompt = {
|
|
2291
|
+
name: "test-app",
|
|
2292
|
+
title: "Test this app with Tapp",
|
|
2293
|
+
description: "Use Tapp's real app surfaces to inspect, drive, or explore this repository and report evidence honestly.",
|
|
2294
|
+
arguments: [
|
|
2295
|
+
{
|
|
2296
|
+
name: "goal",
|
|
2297
|
+
description: "What to verify, such as finding bugs or exercising checkout",
|
|
2298
|
+
required: false,
|
|
2299
|
+
},
|
|
2300
|
+
{
|
|
2301
|
+
name: "target",
|
|
2302
|
+
description: "Optional repo target, bundle/app id, APK path, or owned URL",
|
|
2303
|
+
required: false,
|
|
2304
|
+
},
|
|
2305
|
+
],
|
|
2306
|
+
};
|
|
2307
|
+
|
|
2308
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [testAppPrompt] }));
|
|
2309
|
+
|
|
2310
|
+
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
2311
|
+
if (request.params.name !== testAppPrompt.name) {
|
|
2312
|
+
throw new Error(`Unknown prompt: ${request.params.name}`);
|
|
2313
|
+
}
|
|
2314
|
+
const goal = isNonEmptyString(request.params.arguments?.goal)
|
|
2315
|
+
? request.params.arguments.goal.trim()
|
|
2316
|
+
: "Test the app and find important bugs";
|
|
2317
|
+
const target = isNonEmptyString(request.params.arguments?.target)
|
|
2318
|
+
? ` Use this target: ${request.params.arguments.target.trim()}.`
|
|
2319
|
+
: "";
|
|
2320
|
+
return {
|
|
2321
|
+
description: testAppPrompt.description,
|
|
2322
|
+
messages: [
|
|
2323
|
+
{
|
|
2324
|
+
role: "user",
|
|
2325
|
+
content: {
|
|
2326
|
+
type: "text",
|
|
2327
|
+
text:
|
|
2328
|
+
`${goal}.${target} Use the connected Tapp tools on the real UI surface. ` +
|
|
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. " +
|
|
2331
|
+
"If Tapp returns multiple target choices, ask me to select one instead of guessing. " +
|
|
2332
|
+
"Read visual evidence before describing it. Report findings, coverage, authority, inconclusive state, and checked/not-checked scope; " +
|
|
2333
|
+
"never turn exploration into a score or ship verdict. Do not edit the app unless I ask for a fix.",
|
|
2334
|
+
},
|
|
2335
|
+
},
|
|
2336
|
+
],
|
|
2337
|
+
};
|
|
2338
|
+
});
|
|
2339
|
+
|
|
2040
2340
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
2041
2341
|
tools: [
|
|
2042
2342
|
{
|
|
@@ -2208,12 +2508,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2208
2508
|
androidSerial: { type: "string", description: "Android: optional adb device serial; defaults to the first authorized device." },
|
|
2209
2509
|
clearData: { type: "boolean", default: true, description: "Android: clear app data before launch for a repeatable starting state." },
|
|
2210
2510
|
url: { type: "string", description: "Web (beta): URL of the app to explore in a real browser (same-origin only; your own app/staging). Provide exactly one of appBundleId | url." },
|
|
2511
|
+
watch: { type: "boolean", default: false, description: "Web only: open Tapp's controlled Chromium window and show a cursor/HUD for each exploration action. Evidence screenshots exclude the overlay." },
|
|
2211
2512
|
maxActions: { type: "integer", minimum: 1, maximum: 1000, default: 60, description: "Exploration action budget" },
|
|
2212
2513
|
timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600, description: "Max wall-clock seconds" },
|
|
2213
2514
|
testEmail: { type: "string", description: "Email for the login preamble, if the app has a sign-in" },
|
|
2214
2515
|
testPassword: { type: "string", description: "Password for the login preamble" },
|
|
2215
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." },
|
|
2216
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" },
|
|
2217
2519
|
inputOverrides: {
|
|
2218
2520
|
type: "object",
|
|
2219
2521
|
additionalProperties: { type: "string" },
|
|
@@ -2250,7 +2552,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2250
2552
|
"Findings from a previous run (pass back the `findings` array a prior tapp_explore returned). " +
|
|
2251
2553
|
"When provided, the result adds a `regression` COMPARISON {counts:{new,persisting,resolved}, " +
|
|
2252
2554
|
"newFindings, resolved} vs. that baseline — an observation, not a gate signal. To gate a merge, " +
|
|
2253
|
-
"run `tapp ci` (or the GitHub Action): it applies the deterministic policy and returns the " +
|
|
2555
|
+
"run `npx -y @aarwitz/tapp@latest ci` (or the GitHub Action): it applies the deterministic policy and returns the " +
|
|
2254
2556
|
"pass/fail/inconclusive outcome.",
|
|
2255
2557
|
},
|
|
2256
2558
|
},
|
|
@@ -2278,6 +2580,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2278
2580
|
timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600 },
|
|
2279
2581
|
testEmail: { type: "string", description: "Explore: actor/login email; never persisted in the model" },
|
|
2280
2582
|
testPassword: { type: "string", description: "Explore: actor/login password; never persisted in the model" },
|
|
2583
|
+
watch: { type: "boolean", default: false, description: "Web explore only: show the controlled browser and Tapp's actions" },
|
|
2281
2584
|
maxContracts: { type: "integer", minimum: 1, maximum: 50, default: 15 },
|
|
2282
2585
|
outDir: { type: "string", description: "Repo-relative artifact directory; default .tapp" },
|
|
2283
2586
|
},
|
|
@@ -2661,10 +2964,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2661
2964
|
name: "tapp_session_start",
|
|
2662
2965
|
title: "Start interactive session",
|
|
2663
2966
|
description:
|
|
2664
|
-
"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 " +
|
|
2665
2968
|
"launches once and stays up, so you can drive a Playwright-style tap → inspect loop without a cold " +
|
|
2666
2969
|
"launch per action. Returns the initial screen {screenTitle, elements[]}. Drive it with " +
|
|
2667
|
-
"
|
|
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 " +
|
|
2668
2972
|
"fresh launch. Use appLaunchArgs/appLaunchEnv for apps that need a backend override or login bypass. " +
|
|
2669
2973
|
"When you reach a screen with input fields and don't have values for them, ASK THE USER what to type " +
|
|
2670
2974
|
"(offer defaults/skip) before typing — the session does not prompt on its own.",
|
|
@@ -2674,6 +2978,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2674
2978
|
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2675
2979
|
appBundleId: { type: "string", description: "Bundle id of the installed app to drive" },
|
|
2676
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)" },
|
|
2677
2982
|
apkPath: { type: "string", description: "Android APK to install before starting" },
|
|
2678
2983
|
androidSerial: { type: "string", description: "Android adb device serial" },
|
|
2679
2984
|
clearData: { type: "boolean", default: true, description: "Android: clear app data before launch" },
|
|
@@ -2681,6 +2986,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2681
2986
|
testPassword: { type: "string", description: "Password available to the app/harness" },
|
|
2682
2987
|
appLaunchArgs: { type: "array", items: { type: "string" }, description: "Launch arguments, e.g. [\"--uitesting\"]" },
|
|
2683
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" },
|
|
2684
3009
|
},
|
|
2685
3010
|
},
|
|
2686
3011
|
},
|
|
@@ -2731,45 +3056,62 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2731
3056
|
const { name, arguments: args = {} } = request.params;
|
|
2732
3057
|
|
|
2733
3058
|
if (name === "tapp_health") {
|
|
2734
|
-
const
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
3059
|
+
const coreChecks = [];
|
|
3060
|
+
coreChecks.push({
|
|
3061
|
+
check: "workspace",
|
|
3062
|
+
ok: fs.existsSync(workspaceRoot) && fs.statSync(workspaceRoot).isDirectory(),
|
|
3063
|
+
value: workspaceRoot,
|
|
3064
|
+
required: true,
|
|
2740
3065
|
});
|
|
2741
3066
|
|
|
2742
3067
|
const nodeVersion = await runCommand("node", ["-v"]);
|
|
2743
|
-
|
|
3068
|
+
coreChecks.push({
|
|
2744
3069
|
check: "node",
|
|
2745
3070
|
ok: nodeVersion.code === 0,
|
|
2746
3071
|
value: nodeVersion.stdout.trim() || nodeVersion.stderr.trim(),
|
|
3072
|
+
required: true,
|
|
2747
3073
|
});
|
|
2748
3074
|
|
|
2749
3075
|
const xcodebuildVersion = await runCommand("xcodebuild", ["-version"]);
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
value: (xcodebuildVersion.stdout || xcodebuildVersion.stderr).trim().split("\n")[0] || "not found",
|
|
2754
|
-
});
|
|
2755
|
-
|
|
2756
|
-
const simctl = await runCommand("xcrun", ["simctl", "list", "devices", "booted"]);
|
|
2757
|
-
checks.push({
|
|
2758
|
-
check: "bootedSimulator",
|
|
2759
|
-
ok: simctl.code === 0,
|
|
2760
|
-
value: (simctl.stdout || simctl.stderr).trim(),
|
|
2761
|
-
});
|
|
2762
|
-
|
|
2763
|
-
const allOk = checks.every((c) => c.ok);
|
|
3076
|
+
const simctl = xcodebuildVersion.code === 0
|
|
3077
|
+
? await runCommand("xcrun", ["simctl", "list", "devices", "booted"])
|
|
3078
|
+
: { code: 1, stdout: "", stderr: "Xcode not found" };
|
|
2764
3079
|
const bootedLine = (simctl.stdout || "").split("\n").find((l) => /\(Booted\)/.test(l));
|
|
2765
3080
|
const bootedName = bootedLine ? bootedLine.trim().replace(/\s*\(.*$/, "") : null;
|
|
2766
|
-
const
|
|
3081
|
+
const adb = await runCommand("adb", ["devices"]);
|
|
3082
|
+
const androidDevice = adb.code === 0
|
|
3083
|
+
? (adb.stdout || "").split("\n").find((line) => /\tdevice\s*$/.test(line))
|
|
3084
|
+
: null;
|
|
3085
|
+
let web = { ok: false, value: "Playwright or Chromium not installed" };
|
|
3086
|
+
try {
|
|
3087
|
+
const { chromium } = await import("playwright");
|
|
3088
|
+
const executable = chromium.executablePath();
|
|
3089
|
+
web = { ok: fs.existsSync(executable), value: fs.existsSync(executable) ? executable : "Chromium not installed" };
|
|
3090
|
+
} catch { /* optional dependency may be intentionally absent */ }
|
|
3091
|
+
const platformChecks = [
|
|
3092
|
+
{
|
|
3093
|
+
check: "iOS",
|
|
3094
|
+
ok: xcodebuildVersion.code === 0 && !!bootedName,
|
|
3095
|
+
value: bootedName ? `${bootedName} booted` : xcodebuildVersion.code === 0 ? "Xcode available; no simulator booted" : "Xcode not found",
|
|
3096
|
+
required: false,
|
|
3097
|
+
},
|
|
3098
|
+
{
|
|
3099
|
+
check: "Android",
|
|
3100
|
+
ok: !!androidDevice,
|
|
3101
|
+
value: androidDevice ? `${androidDevice.split("\t")[0]} connected` : adb.code === 0 ? "adb available; no authorized device" : "adb not found",
|
|
3102
|
+
required: false,
|
|
3103
|
+
},
|
|
3104
|
+
{ check: "web", ok: web.ok, value: web.value, required: false },
|
|
3105
|
+
];
|
|
3106
|
+
const checks = [...coreChecks, ...platformChecks];
|
|
3107
|
+
const ready = coreChecks.every((check) => check.ok) && platformChecks.some((check) => check.ok);
|
|
3108
|
+
const L = [`### ${ready ? "🩺 Tapp ready" : "⚠️ Tapp needs a platform runtime"}`, ""];
|
|
2767
3109
|
for (const c of checks) {
|
|
2768
|
-
|
|
3110
|
+
const icon = c.ok ? "✅" : c.required ? "❌" : "⚪️";
|
|
3111
|
+
L.push(`- ${icon} **${c.check}** — ${String(c.value).split("\n")[0] || "—"}`);
|
|
2769
3112
|
}
|
|
2770
|
-
L.push("");
|
|
2771
|
-
L.
|
|
2772
|
-
return richResult(L.join("\n"), { ok: allOk, checks });
|
|
3113
|
+
if (!ready) L.push("", "Run `npx -y @aarwitz/tapp@latest doctor` in the application repository for exact remediation.");
|
|
3114
|
+
return richResult(L.join("\n"), { ok: ready, workspaceRoot, checks, platforms: Object.fromEntries(platformChecks.map((check) => [check.check.toLowerCase(), check.ok])) });
|
|
2773
3115
|
}
|
|
2774
3116
|
|
|
2775
3117
|
if (name === "tapp_build") {
|
|
@@ -2788,11 +3130,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2788
3130
|
if (inst.error) return errorResult(inst.error);
|
|
2789
3131
|
bundleId = inst.bundleId;
|
|
2790
3132
|
}
|
|
3133
|
+
let modelRefresh = null;
|
|
3134
|
+
let modelRefreshWarning = "";
|
|
3135
|
+
if (bundleId) {
|
|
3136
|
+
try {
|
|
3137
|
+
const { persistIosBuildValidation } = await import("./application-model.js");
|
|
3138
|
+
modelRefresh = await persistIosBuildValidation({ projectDir: dir, bundleId, container: built.container, scheme: built.scheme, configuration: built.configuration });
|
|
3139
|
+
} catch (error) {
|
|
3140
|
+
modelRefreshWarning = error.message || String(error);
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
2791
3143
|
const text =
|
|
2792
3144
|
`🔨 Built **${path.basename(built.appPath)}** (scheme \`${built.scheme}\`) in ${fmtDuration(Date.now() - startedAt)}` +
|
|
2793
3145
|
(bundleId ? ` — installed on the simulator as \`${bundleId}\`` : "") +
|
|
2794
3146
|
`\n\nNext: \`tapp_explore\` with \`appBundleId: "${bundleId || "<install it first>"}"\`.`;
|
|
2795
|
-
return richResult(text, { ok: true, appPath: built.appPath, scheme: built.scheme, container: built.container, bundleId });
|
|
3147
|
+
return richResult(text + (modelRefresh ? `\nApplication model refreshed: \`${modelRefresh.modelPath}\`.` : modelRefreshWarning ? `\n⚠️ Application model refresh failed: ${modelRefreshWarning}` : ""), { ok: true, appPath: built.appPath, scheme: built.scheme, container: built.container, bundleId, modelRefreshed: !!modelRefresh, ...(modelRefreshWarning ? { modelRefreshWarning } : {}) });
|
|
2796
3148
|
}
|
|
2797
3149
|
|
|
2798
3150
|
if (name === "tapp_capture") {
|
|
@@ -2966,6 +3318,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2966
3318
|
if (targets !== 1) {
|
|
2967
3319
|
return errorResult("Provide exactly one of appBundleId (iOS), androidAppId (Android), or url (web beta)");
|
|
2968
3320
|
}
|
|
3321
|
+
if (args.watch === true && !wantsWeb) return errorResult("watch is currently available for web exploration only");
|
|
2969
3322
|
|
|
2970
3323
|
// Both branches call the shared engine (runQaWeb/runQaIos) — the handler only adds
|
|
2971
3324
|
// MCP concerns: auth, arg validation, and progress notifications.
|
|
@@ -2987,6 +3340,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2987
3340
|
testEmail: args.testEmail,
|
|
2988
3341
|
testPassword: args.testPassword,
|
|
2989
3342
|
baselineFindings: args.baselineFindings,
|
|
3343
|
+
watch: args.watch === true,
|
|
2990
3344
|
onProgress: notifyProgress("pages reached"),
|
|
2991
3345
|
});
|
|
2992
3346
|
if (r.error) return errorResult(r.error, r.details || {});
|
|
@@ -3031,8 +3385,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3031
3385
|
if (unauthorized) return unauthorized;
|
|
3032
3386
|
const operation = String(args.operation || "inspect").toLowerCase();
|
|
3033
3387
|
if (!["inspect", "write", "refresh", "explore"].includes(operation)) return errorResult("operation must be inspect|write|refresh|explore");
|
|
3034
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3035
|
-
if (!isInsideDir(
|
|
3388
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3389
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3036
3390
|
const maxContracts = asInteger(args.maxContracts, 15);
|
|
3037
3391
|
if (maxContracts < 1 || maxContracts > 50) return errorResult("maxContracts must be between 1 and 50");
|
|
3038
3392
|
const { initializeProductProject } = await import("./product-operations.js");
|
|
@@ -3042,32 +3396,42 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3042
3396
|
if (!isInsideDir(projectDir, resolvedOut)) return errorResult("outDir must be inside projectDir");
|
|
3043
3397
|
const selectedPlatform = isNonEmptyString(args.platform) ? args.platform.trim().toLowerCase()
|
|
3044
3398
|
: isNonEmptyString(args.url) ? "web"
|
|
3045
|
-
: isNonEmptyString(args.androidAppId) || isNonEmptyString(args.apkPath) ? "android" : "
|
|
3399
|
+
: isNonEmptyString(args.androidAppId) || isNonEmptyString(args.apkPath) ? "android" : "";
|
|
3046
3400
|
const progressToken = request.params && request.params._meta ? request.params._meta.progressToken : undefined;
|
|
3047
3401
|
const budget = Math.max(1, Math.min(1000, asInteger(args.maxActions, 40)));
|
|
3048
3402
|
const result = await initializeProductProject({
|
|
3049
3403
|
projectDir, mode: operation, outDir,
|
|
3050
3404
|
ownedUrl: isNonEmptyString(args.url) ? args.url.trim() : "",
|
|
3051
3405
|
platform: isNonEmptyString(args.platform) ? args.platform.trim().toLowerCase() : operation === "explore" ? selectedPlatform : "",
|
|
3052
|
-
target: isNonEmptyString(args.target) ? args.target.trim() :
|
|
3406
|
+
target: isNonEmptyString(args.target) ? args.target.trim() : "",
|
|
3053
3407
|
bundleId: isNonEmptyString(args.appBundleId) ? args.appBundleId.trim() : "",
|
|
3054
3408
|
appId: isNonEmptyString(args.androidAppId) ? args.androidAppId.trim() : "",
|
|
3055
3409
|
apkPath: isNonEmptyString(args.apkPath) ? path.resolve(projectDir, args.apkPath.trim()) : undefined,
|
|
3056
3410
|
serial: isNonEmptyString(args.androidSerial) ? args.androidSerial.trim() : undefined,
|
|
3057
3411
|
maxActions: args.maxActions, timeout: args.timeout, maxContracts,
|
|
3058
3412
|
testEmail: args.testEmail, testPassword: args.testPassword,
|
|
3413
|
+
watch: args.watch === true,
|
|
3059
3414
|
runExploration: runInitExploration,
|
|
3060
3415
|
onProgress: (progress) => {
|
|
3061
3416
|
if (progressToken === undefined) return;
|
|
3062
3417
|
server.notification({ method: "notifications/progress", params: { progressToken, progress: progress.action || 0, total: progress.max || budget, message: `Import exploration · ${progress.states} state(s) reached` } }).catch(() => {});
|
|
3063
3418
|
},
|
|
3064
3419
|
});
|
|
3065
|
-
const { model, plan, written, exploration } = result;
|
|
3066
|
-
const blocking = model.requirements.filter((item) => item.severity === "blocking");
|
|
3420
|
+
const { model, plan, written, exploration, selectedTarget, requirementScope } = result;
|
|
3421
|
+
const blocking = (requirementScope?.active || model.requirements).filter((item) => item.severity === "blocking");
|
|
3422
|
+
const deferredBlocking = (requirementScope?.deferred || []).filter((item) => item.severity === "blocking");
|
|
3067
3423
|
const pending = plan.items.filter((item) => item.decision === "pending");
|
|
3068
|
-
const
|
|
3069
|
-
|
|
3070
|
-
|
|
3424
|
+
const selectedLabel = selectedTarget ? ` for ${selectedTarget.platform}:${selectedTarget.name}` : "";
|
|
3425
|
+
const deferredLabel = deferredBlocking.length ? ` · ${deferredBlocking.length} setup gap(s) on unselected target(s)` : "";
|
|
3426
|
+
const summary = `🧭 Tapp init — ${model.application.name} · ${model.targets.length} target(s) · UI Map ${model.uiMap.status} (${model.uiMap.nodeCount} states/${model.uiMap.edgeCount} transitions) · ${plan.items.length} plan item(s), ${pending.length} pending · ${blocking.length} blocking requirement(s)${selectedLabel}${deferredLabel}${exploration ? ` · real ${exploration.platform} exploration: ${(exploration.findings || []).length} finding(s)${exploration.inconclusive ? " (inconclusive)" : ""}` : ""}`;
|
|
3427
|
+
return richResult(summary, { model, plan, selectedTarget, requirementScope, written: written ? { modelPath: written.modelPath, planPath: written.planPath } : null, exploration });
|
|
3428
|
+
} catch (error) {
|
|
3429
|
+
const detail = error.message || String(error);
|
|
3430
|
+
const message = error.details?.reason === "target-selection-required"
|
|
3431
|
+
? detail
|
|
3432
|
+
: "Could not initialize Tapp repository artifacts";
|
|
3433
|
+
return errorResult(message, { detail, ...(error.details || {}) });
|
|
3434
|
+
}
|
|
3071
3435
|
}
|
|
3072
3436
|
|
|
3073
3437
|
if (name === "tapp_actor_config") {
|
|
@@ -3078,8 +3442,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3078
3442
|
const allowedArguments = new Set(["authToken", "operation", "projectDir", "name", "role", "session", "provisioning", "credentialBindings", "replace"]);
|
|
3079
3443
|
const unexpectedArguments = Object.keys(args).filter((key) => !allowedArguments.has(key));
|
|
3080
3444
|
if (unexpectedArguments.length) return errorResult("Unsupported actor configuration fields; credential values are never accepted", { fields: unexpectedArguments });
|
|
3081
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3082
|
-
if (!isInsideDir(
|
|
3445
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3446
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3083
3447
|
const { configureActor, readProjectConfig } = await import("./project-config.js");
|
|
3084
3448
|
if (operation === "read") {
|
|
3085
3449
|
const loaded = readProjectConfig(projectDir);
|
|
@@ -3099,7 +3463,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3099
3463
|
credentials,
|
|
3100
3464
|
replace: asBoolean(args.replace),
|
|
3101
3465
|
});
|
|
3102
|
-
|
|
3466
|
+
const { refreshExistingInitArtifacts } = await import("./application-model.js");
|
|
3467
|
+
let refreshed = null;
|
|
3468
|
+
let refreshWarning = "";
|
|
3469
|
+
try { refreshed = await refreshExistingInitArtifacts({ projectDir }); }
|
|
3470
|
+
catch (error) { refreshWarning = error.message || String(error); }
|
|
3471
|
+
return richResult(
|
|
3472
|
+
`✅ Actor '${args.name.trim()}' configured with ${Object.keys(result.actor.credentials).length} environment binding(s); no credential values were accepted or written${refreshed ? " · application model refreshed" : ""}${refreshWarning ? `\n⚠️ Application model refresh failed: ${refreshWarning}` : ""}`,
|
|
3473
|
+
{ path: result.path, actor: result.actor, modelRefreshed: !!refreshed, ...(refreshed ? { modelPath: refreshed.modelPath, planPath: refreshed.planPath } : {}), ...(refreshWarning ? { refreshWarning } : {}) },
|
|
3474
|
+
);
|
|
3103
3475
|
} catch (error) { return errorResult("Actor not configured", { detail: error.message || String(error) }); }
|
|
3104
3476
|
}
|
|
3105
3477
|
|
|
@@ -3108,8 +3480,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3108
3480
|
if (unauthorized) return unauthorized;
|
|
3109
3481
|
const operation = String(args.operation || "read").toLowerCase();
|
|
3110
3482
|
if (!["read", "review", "generate", "validate", "promote"].includes(operation)) return errorResult("operation must be read|review|generate|validate|promote");
|
|
3111
|
-
const planPath = isNonEmptyString(args.planPath) ? path.resolve(
|
|
3112
|
-
if (!isInsideDir(
|
|
3483
|
+
const planPath = isNonEmptyString(args.planPath) ? path.resolve(workspaceRoot, args.planPath.trim()) : existingProjectArtifactPath(workspaceRoot, "release-plan.json");
|
|
3484
|
+
if (!isInsideDir(workspaceRoot, planPath)) return errorResult("planPath must be inside the workspace");
|
|
3113
3485
|
if (!fs.existsSync(planPath)) return errorResult("Release plan not found", { planPath });
|
|
3114
3486
|
let plan;
|
|
3115
3487
|
try { plan = JSON.parse(fs.readFileSync(planPath, "utf8")); }
|
|
@@ -3119,11 +3491,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3119
3491
|
if (![...decisions.approve, ...decisions.reject, ...decisions.defer].length) return errorResult("review requires at least one approve, reject, or defer item");
|
|
3120
3492
|
const { reviewProductPlan } = await import("./product-operations.js");
|
|
3121
3493
|
try {
|
|
3122
|
-
plan = reviewProductPlan({ projectDir:
|
|
3494
|
+
plan = reviewProductPlan({ projectDir: workspaceRoot, planPath, ...decisions }).plan;
|
|
3123
3495
|
} catch (error) { return errorResult("Could not review release plan", { detail: error.message || String(error) }); }
|
|
3124
3496
|
} else if (operation === "generate") {
|
|
3125
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3126
|
-
if (!isInsideDir(
|
|
3497
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3498
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3127
3499
|
const { generateProductPlan } = await import("./product-operations.js");
|
|
3128
3500
|
try {
|
|
3129
3501
|
const result = await generateProductPlan({ projectDir, planPath });
|
|
@@ -3131,8 +3503,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3131
3503
|
return richResult(`🧩 Proposal drafts — ${result.generatedTasks.length} UI-Map-grounded Task(s) · ${result.generated.length} compile-checked/untrusted contract(s) · ${result.blocked.length} blocked; deterministic real-surface replay remains required`, { plan, planPath, generatedTasks: result.generatedTasks, generated: result.generated, blocked: result.blocked });
|
|
3132
3504
|
} catch (error) { return errorResult("Could not generate contract drafts", { detail: error.message || String(error) }); }
|
|
3133
3505
|
} else if (operation === "validate") {
|
|
3134
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3135
|
-
if (!isInsideDir(
|
|
3506
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3507
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3136
3508
|
let apkPath = "";
|
|
3137
3509
|
if (isNonEmptyString(args.apkPath)) {
|
|
3138
3510
|
apkPath = path.resolve(projectDir, args.apkPath.trim());
|
|
@@ -3164,8 +3536,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3164
3536
|
return richResult(`🔎 Generated contract validation passed — ${result.results.length}/${result.results.length} on ${result.platform}`, { ...result, planPath });
|
|
3165
3537
|
} catch (error) { return errorResult("Generated contract validation failed", { detail: error.message || String(error), plan, planPath }); }
|
|
3166
3538
|
} else if (operation === "promote") {
|
|
3167
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3168
|
-
if (!isInsideDir(
|
|
3539
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3540
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3169
3541
|
const { promoteProductPlan } = await import("./product-operations.js");
|
|
3170
3542
|
try {
|
|
3171
3543
|
const result = await promoteProductPlan({ projectDir, planPath, items: Array.isArray(args.items) ? args.items : [] });
|
|
@@ -3181,9 +3553,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3181
3553
|
if (unauthorized) return unauthorized;
|
|
3182
3554
|
const operation = String(args.operation || "inspect").toLowerCase();
|
|
3183
3555
|
if (!["inspect", "install", "baseline"].includes(operation)) return errorResult("operation must be inspect|install|baseline");
|
|
3184
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3185
|
-
if (!isInsideDir(
|
|
3186
|
-
const modelPath = isNonEmptyString(args.modelPath) ? path.resolve(
|
|
3556
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3557
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3558
|
+
const modelPath = isNonEmptyString(args.modelPath) ? path.resolve(workspaceRoot, args.modelPath.trim()) : existingProjectArtifactPath(projectDir, "application-model.json");
|
|
3187
3559
|
if (!isInsideDir(projectDir, modelPath) || !fs.existsSync(modelPath)) return errorResult("Application model not found inside projectDir; run tapp_init first", { modelPath });
|
|
3188
3560
|
let model;
|
|
3189
3561
|
try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
|
|
@@ -3191,8 +3563,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3191
3563
|
const { createProductBaseline, installProductCi, prepareProductCi } = await import("./product-operations.js");
|
|
3192
3564
|
if (operation === "baseline") {
|
|
3193
3565
|
if (!isNonEmptyString(args.reportPath)) return errorResult("baseline requires reportPath from a successful portable gate");
|
|
3194
|
-
const reportPath = path.resolve(
|
|
3195
|
-
if (!isInsideDir(
|
|
3566
|
+
const reportPath = path.resolve(workspaceRoot, args.reportPath.trim());
|
|
3567
|
+
if (!isInsideDir(workspaceRoot, reportPath) || !fs.existsSync(reportPath)) return errorResult("reportPath must be an existing JSON file inside the workspace");
|
|
3196
3568
|
let report;
|
|
3197
3569
|
try { report = JSON.parse(fs.readFileSync(reportPath, "utf8")); }
|
|
3198
3570
|
catch (error) { return errorResult("Gate report is invalid JSON", { detail: error.message || String(error) }); }
|
|
@@ -3217,8 +3589,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3217
3589
|
if (unauthorized) return unauthorized;
|
|
3218
3590
|
const operation = String(args.operation || "read").toLowerCase();
|
|
3219
3591
|
const resolveRepoFile = (value, fallback = "") => {
|
|
3220
|
-
const resolved = path.resolve(
|
|
3221
|
-
return isInsideDir(
|
|
3592
|
+
const resolved = path.resolve(workspaceRoot, isNonEmptyString(value) ? value.trim() : fallback);
|
|
3593
|
+
return isInsideDir(workspaceRoot, resolved) ? resolved : null;
|
|
3222
3594
|
};
|
|
3223
3595
|
const capture = isNonEmptyString(args.captureId) ? listCaptureRuns(200).find((run) => run.id === args.captureId.trim()) : null;
|
|
3224
3596
|
if (isNonEmptyString(args.captureId) && !capture) return errorResult("Capture not found", { captureId: args.captureId });
|
|
@@ -3238,18 +3610,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3238
3610
|
const markersPath = capture ? path.join(capture.path, "ocqa-markers.txt") : resolveRepoFile(args.markersPath);
|
|
3239
3611
|
if (!markersPath) return errorResult("markersPath must be inside the repo, or provide captureId");
|
|
3240
3612
|
if (!fs.existsSync(markersPath)) return errorResult("OCQA markers not found", { markersPath });
|
|
3241
|
-
const outPath = isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(
|
|
3613
|
+
const outPath = isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(workspaceRoot, "ui-map.json");
|
|
3242
3614
|
if (!outPath) return errorResult("mapPath must be inside the repo");
|
|
3243
3615
|
try {
|
|
3244
3616
|
const observed = buildUiMapFromMarkers({ markersPath, platform: args.platform || "ios", target: args.target || "", runId: capture?.id || "" });
|
|
3245
3617
|
const map = fs.existsSync(outPath) && args.replace !== true ? mergeUiMaps(JSON.parse(fs.readFileSync(outPath, "utf8")), observed) : observed;
|
|
3246
3618
|
writeUiMap(outPath, map);
|
|
3247
3619
|
const controls = map.nodes.reduce((total, node) => total + node.controls.length, 0);
|
|
3248
|
-
return richResult(`🗺️ UI Map updated — ${map.nodes.length} states · ${map.edges.length} transitions · ${controls} semantic controls\n${path.relative(
|
|
3620
|
+
return richResult(`🗺️ UI Map updated — ${map.nodes.length} states · ${map.edges.length} transitions · ${controls} semantic controls\n${path.relative(workspaceRoot, outPath)}`, { map, path: outPath });
|
|
3249
3621
|
} catch (error) { return errorResult("Could not build UI Map", { detail: error.message || String(error) }); }
|
|
3250
3622
|
}
|
|
3251
3623
|
if (operation !== "read") return errorResult("operation must be read|build|diff");
|
|
3252
|
-
const mapPath = capture ? path.join(capture.path, "ui-map.json") : isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(
|
|
3624
|
+
const mapPath = capture ? path.join(capture.path, "ui-map.json") : isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(workspaceRoot, "ui-map.json");
|
|
3253
3625
|
if (!mapPath) return errorResult("mapPath must be inside the repo");
|
|
3254
3626
|
if (!fs.existsSync(mapPath)) return errorResult("UI Map not found; run QA or operation=build first", { mapPath });
|
|
3255
3627
|
try {
|
|
@@ -3266,8 +3638,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3266
3638
|
if (unauthorized) return unauthorized;
|
|
3267
3639
|
const operation = String(args.operation || "validate").toLowerCase();
|
|
3268
3640
|
if (!["read", "validate", "compile"].includes(operation)) return errorResult("operation must be read|validate|compile");
|
|
3269
|
-
const taskPath = isNonEmptyString(args.taskPath) ? path.resolve(
|
|
3270
|
-
if (!taskPath || !isInsideDir(
|
|
3641
|
+
const taskPath = isNonEmptyString(args.taskPath) ? path.resolve(workspaceRoot, args.taskPath.trim()) : null;
|
|
3642
|
+
if (!taskPath || !isInsideDir(workspaceRoot, taskPath)) return errorResult("taskPath must be inside the workspace");
|
|
3271
3643
|
if (!fs.existsSync(taskPath)) return errorResult("Task file not found", { taskPath: args.taskPath });
|
|
3272
3644
|
const { applyTaskCoverage, compileTaskSteps, loadTaskFile, loadTaskRegistry, validateTaskAgainstUiMap } = await import("./task-runtime.js");
|
|
3273
3645
|
let task;
|
|
@@ -3278,8 +3650,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3278
3650
|
let groundingMap = null;
|
|
3279
3651
|
let groundingMapPath = null;
|
|
3280
3652
|
if (isNonEmptyString(args.mapPath)) {
|
|
3281
|
-
const mapPath = path.resolve(
|
|
3282
|
-
if (!isInsideDir(
|
|
3653
|
+
const mapPath = path.resolve(workspaceRoot, args.mapPath.trim());
|
|
3654
|
+
if (!isInsideDir(workspaceRoot, mapPath)) return errorResult("mapPath must be inside the workspace");
|
|
3283
3655
|
if (!fs.existsSync(mapPath)) return errorResult("UI Map not found", { mapPath: args.mapPath });
|
|
3284
3656
|
groundingMapPath = mapPath;
|
|
3285
3657
|
try {
|
|
@@ -3309,12 +3681,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3309
3681
|
const flow = { name: `Task: ${task.name}`, kind: "flow", platform: args.platform || "", vars: compiled.vars, steps: compiled.steps, taskPlan: compiled.plan };
|
|
3310
3682
|
let outPath = null;
|
|
3311
3683
|
if (isNonEmptyString(args.outPath)) {
|
|
3312
|
-
outPath = path.resolve(
|
|
3313
|
-
if (!isInsideDir(
|
|
3684
|
+
outPath = path.resolve(workspaceRoot, args.outPath.trim());
|
|
3685
|
+
if (!isInsideDir(workspaceRoot, outPath)) return errorResult("outPath must be inside the workspace");
|
|
3314
3686
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
3315
3687
|
fs.writeFileSync(outPath, JSON.stringify(flow, null, 2) + "\n");
|
|
3316
3688
|
}
|
|
3317
|
-
return richResult(`🧩 Compiled ${task.name} into ${flow.steps.length} deterministic Flow steps${outPath ? `\n${path.relative(
|
|
3689
|
+
return richResult(`🧩 Compiled ${task.name} into ${flow.steps.length} deterministic Flow steps${outPath ? `\n${path.relative(workspaceRoot, outPath)}` : ""}`, { flow, grounding, path: outPath });
|
|
3318
3690
|
}
|
|
3319
3691
|
|
|
3320
3692
|
if (name === "tapp_release_contract") {
|
|
@@ -3322,8 +3694,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3322
3694
|
if (unauthorized) return unauthorized;
|
|
3323
3695
|
const operation = String(args.operation || "validate").toLowerCase();
|
|
3324
3696
|
if (!["read", "validate", "compile", "run"].includes(operation)) return errorResult("operation must be read|validate|compile|run");
|
|
3325
|
-
const contractPath = isNonEmptyString(args.contractPath) ? path.resolve(
|
|
3326
|
-
if (!contractPath || !isInsideDir(
|
|
3697
|
+
const contractPath = isNonEmptyString(args.contractPath) ? path.resolve(workspaceRoot, args.contractPath.trim()) : null;
|
|
3698
|
+
if (!contractPath || !isInsideDir(workspaceRoot, contractPath)) return errorResult("contractPath must be inside the workspace");
|
|
3327
3699
|
const {
|
|
3328
3700
|
applyReleaseContractCoverage,
|
|
3329
3701
|
compileReleaseContract,
|
|
@@ -3338,8 +3710,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3338
3710
|
let groundingMap = null;
|
|
3339
3711
|
let groundingMapPath = null;
|
|
3340
3712
|
if (isNonEmptyString(args.mapPath)) {
|
|
3341
|
-
groundingMapPath = path.resolve(
|
|
3342
|
-
if (!isInsideDir(
|
|
3713
|
+
groundingMapPath = path.resolve(workspaceRoot, args.mapPath.trim());
|
|
3714
|
+
if (!isInsideDir(workspaceRoot, groundingMapPath)) return errorResult("mapPath must be inside the workspace");
|
|
3343
3715
|
if (!fs.existsSync(groundingMapPath)) return errorResult("UI Map not found", { mapPath: args.mapPath });
|
|
3344
3716
|
try {
|
|
3345
3717
|
groundingMap = JSON.parse(fs.readFileSync(groundingMapPath, "utf8"));
|
|
@@ -3360,8 +3732,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3360
3732
|
catch (error) { return errorResult("Could not compile Release Contract", { detail: error.message || String(error) }); }
|
|
3361
3733
|
let outPath = null;
|
|
3362
3734
|
if (isNonEmptyString(args.outPath)) {
|
|
3363
|
-
outPath = path.resolve(
|
|
3364
|
-
if (!isInsideDir(
|
|
3735
|
+
outPath = path.resolve(workspaceRoot, args.outPath.trim());
|
|
3736
|
+
if (!isInsideDir(workspaceRoot, outPath)) return errorResult("outPath must be inside the workspace");
|
|
3365
3737
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
3366
3738
|
fs.writeFileSync(outPath, JSON.stringify(execution, null, 2) + "\n");
|
|
3367
3739
|
}
|
|
@@ -3408,12 +3780,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3408
3780
|
if (unauthorized) return unauthorized;
|
|
3409
3781
|
const operation = isNonEmptyString(args.operation) ? args.operation.trim().toLowerCase() : "plan";
|
|
3410
3782
|
if (!['plan', 'adopt'].includes(operation)) return errorResult("operation must be plan|adopt");
|
|
3411
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3412
|
-
if (!isInsideDir(
|
|
3783
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3784
|
+
if (!isInsideDir(workspaceRoot, projectDir)) return errorResult("projectDir must be inside the workspace");
|
|
3413
3785
|
if (operation === "adopt") {
|
|
3414
3786
|
if (!isNonEmptyString(args.prPlanPath) || !isNonEmptyString(args.item)) return errorResult("adopt requires prPlanPath and item");
|
|
3415
3787
|
const prPlanPath = path.resolve(projectDir, args.prPlanPath.trim());
|
|
3416
|
-
if (!isInsideDir(
|
|
3788
|
+
if (!isInsideDir(workspaceRoot, prPlanPath)) return errorResult("prPlanPath must be inside the workspace");
|
|
3417
3789
|
const { adoptPrCoverageProposal } = await import("./pr-selection.js");
|
|
3418
3790
|
try {
|
|
3419
3791
|
const adopted = adoptPrCoverageProposal({
|
|
@@ -3457,8 +3829,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3457
3829
|
fs.writeFileSync(flowFile, JSON.stringify(args.flow));
|
|
3458
3830
|
parsedFlow = args.flow;
|
|
3459
3831
|
} else if (isNonEmptyString(args.flowPath)) {
|
|
3460
|
-
const p = path.resolve(
|
|
3461
|
-
if (!isInsideDir(
|
|
3832
|
+
const p = path.resolve(workspaceRoot, args.flowPath.trim());
|
|
3833
|
+
if (!isInsideDir(workspaceRoot, p)) return errorResult("flowPath must be inside the workspace");
|
|
3462
3834
|
if (!fs.existsSync(p)) return errorResult("Flow file not found", { flowPath: args.flowPath });
|
|
3463
3835
|
flowFile = p;
|
|
3464
3836
|
} else {
|
|
@@ -3477,15 +3849,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3477
3849
|
(args.url || parsedFlow.url || /^https?:\/\//i.test(parsedFlow.app || "")) ? "web" : "ios")
|
|
3478
3850
|
).toLowerCase();
|
|
3479
3851
|
|
|
3480
|
-
const
|
|
3481
|
-
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 };
|
|
3482
3856
|
if (isNonEmptyString(args.testEmail)) runEnv.OCQA_TEST_EMAIL = args.testEmail.trim();
|
|
3483
3857
|
if (isNonEmptyString(args.testPassword)) runEnv.OCQA_TEST_PASSWORD = args.testPassword.trim();
|
|
3484
3858
|
let run = { stdout: "", stderr: "", code: 0 };
|
|
3485
3859
|
if (platform === "web") {
|
|
3486
3860
|
try {
|
|
3487
3861
|
const { runWebFlow } = await import("./web-flow.js");
|
|
3488
|
-
const evidenceDir = path.join(capturesDir, `flow-web-${Date.now()}`);
|
|
3489
3862
|
const result = await runWebFlow({
|
|
3490
3863
|
flow: parsedFlow,
|
|
3491
3864
|
url: isNonEmptyString(args.url) ? args.url.trim() : undefined,
|
|
@@ -3500,10 +3873,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3500
3873
|
const cmdArgs = [path.join(scriptsDir, "run-flow.sh"), flowFile];
|
|
3501
3874
|
if (isNonEmptyString(args.appBundleId)) cmdArgs.push(args.appBundleId.trim());
|
|
3502
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;
|
|
3503
3877
|
} else if (platform === "android") {
|
|
3504
3878
|
try {
|
|
3505
3879
|
const { runAndroidFlow } = await import("./android-flow.js");
|
|
3506
|
-
const evidenceDir = path.join(capturesDir, `flow-android-${Date.now()}`);
|
|
3507
3880
|
const result = await runAndroidFlow({
|
|
3508
3881
|
flow: parsedFlow,
|
|
3509
3882
|
appId: isNonEmptyString(args.androidAppId)
|
|
@@ -3530,7 +3903,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3530
3903
|
try { structured = JSON.parse(jsonRes.stdout.trim()); } catch { /* fall through */ }
|
|
3531
3904
|
const textRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "report", flowLog], { cwd: repoRoot });
|
|
3532
3905
|
const text = (textRes.stdout || "").trim() || run.stdout;
|
|
3533
|
-
|
|
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 } : {}) });
|
|
3534
3913
|
}
|
|
3535
3914
|
|
|
3536
3915
|
if (name === "tapp_scenario_run") {
|
|
@@ -3540,8 +3919,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3540
3919
|
if (args.scenario && typeof args.scenario === "object") {
|
|
3541
3920
|
scenario = args.scenario;
|
|
3542
3921
|
} else if (isNonEmptyString(args.scenarioPath)) {
|
|
3543
|
-
const scenarioFile = path.resolve(
|
|
3544
|
-
if (!isInsideDir(
|
|
3922
|
+
const scenarioFile = path.resolve(workspaceRoot, args.scenarioPath.trim());
|
|
3923
|
+
if (!isInsideDir(workspaceRoot, scenarioFile)) return errorResult("scenarioPath must be inside the workspace");
|
|
3545
3924
|
if (!fs.existsSync(scenarioFile)) return errorResult("Scenario file not found", { scenarioPath: args.scenarioPath });
|
|
3546
3925
|
try {
|
|
3547
3926
|
const { loadScenarioFile } = await import("./scenario-runtime.js");
|
|
@@ -3611,7 +3990,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3611
3990
|
const ungrounded = ungroundedScreens(parsed.steps, grounding);
|
|
3612
3991
|
const flow = { name: args.name || parsed.name, app: bundleId, steps: parsed.steps };
|
|
3613
3992
|
const slug = flow.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "generated-flow";
|
|
3614
|
-
const dir = path.join(
|
|
3993
|
+
const dir = path.join(workspaceRoot, ".tapp", "proposals", "flows");
|
|
3615
3994
|
fs.mkdirSync(dir, { recursive: true });
|
|
3616
3995
|
const outPath = path.join(dir, `${slug}.yml`);
|
|
3617
3996
|
const promotedPath = path.join(".tapp", "flows", `${slug}.yml`);
|
|
@@ -3619,7 +3998,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3619
3998
|
const yaml = (yamlRes.stdout || "").trim();
|
|
3620
3999
|
if (!yaml) return errorResult("Failed to render flow YAML", { stderr: yamlRes.stderr });
|
|
3621
4000
|
fs.writeFileSync(outPath, yaml + "\n");
|
|
3622
|
-
const rel = path.relative(
|
|
4001
|
+
const rel = path.relative(workspaceRoot, outPath);
|
|
3623
4002
|
|
|
3624
4003
|
const L = [`🤖 Generated a flow **proposal** **${flow.name}** from your goal → \`${rel}\``];
|
|
3625
4004
|
L.push(`⚠️ This is an **untrusted draft**, not a committed test. Grounded in ${grounding.screens.length} observed screen(s). ${ungrounded.length ? `⚠️ references unobserved: ${ungrounded.join(", ")} — review before relying on it.` : "All referenced screens were observed."}`);
|
|
@@ -3821,17 +4200,66 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3821
4200
|
if (unauthorized) return unauthorized;
|
|
3822
4201
|
const ios = isNonEmptyString(args.appBundleId);
|
|
3823
4202
|
const android = isNonEmptyString(args.androidAppId);
|
|
3824
|
-
|
|
3825
|
-
|
|
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
|
+
}
|
|
3826
4212
|
const r = ios
|
|
3827
4213
|
? await startSession(target, explorationEnvFromArgs(args))
|
|
3828
|
-
:
|
|
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 });
|
|
3829
4217
|
if (r.error) return errorResult(r.error);
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
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) }); }
|
|
3835
4263
|
}
|
|
3836
4264
|
|
|
3837
4265
|
if (name === "tapp_session_act") {
|
|
@@ -3867,14 +4295,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3867
4295
|
const detailNote = !ok && r.detail ? ` — ${r.detail}` : "";
|
|
3868
4296
|
const head = `${did} — ${ok ? "ok" : `⚠️ ${r.status}${detailNote}`} → now on **${r.screenTitle || "Unknown"}**`;
|
|
3869
4297
|
const rec = typeof r.recordedSteps === "number" ? `\n\n🔴 Recording — ${r.recordedSteps} step(s). \`tapp_flow_save\` to keep it as a test.` : "";
|
|
3870
|
-
|
|
4298
|
+
const screen = agentScreenProjection(r);
|
|
4299
|
+
const result = richResult(head + "\n\n" + formatScreen(screen.screenTitle, screen.elements) + rec, { ...r, ...screen });
|
|
4300
|
+
if (!ok) result.isError = true;
|
|
4301
|
+
return result;
|
|
3871
4302
|
}
|
|
3872
4303
|
|
|
3873
4304
|
if (name === "tapp_flow_save") {
|
|
3874
4305
|
const unauthorized = ensureAuthorized(args);
|
|
3875
4306
|
if (unauthorized) return unauthorized;
|
|
3876
4307
|
try {
|
|
3877
|
-
const saved = await saveInteractiveSessionFlow({ projectDir:
|
|
4308
|
+
const saved = await saveInteractiveSessionFlow({ projectDir:workspaceRoot, name:args.name, addFinalAssertion:args.addFinalAssertion !== false, replace:args.replace === true });
|
|
3878
4309
|
const text = `💾 Saved flow **${saved.flow.name}** → \`${saved.path}\` (${saved.flow.steps.length} steps)\n\n\`\`\`yaml\n${saved.yaml}\n\`\`\`\n\nReplay it anytime: \`tapp_flow_run\` with \`flowPath: "${saved.path}"\`.`;
|
|
3879
4310
|
return richResult(text, { path:saved.path, flow:saved.flow });
|
|
3880
4311
|
} catch (error) {
|