@aarwitz/tapp 0.17.0-rc.8 ā 0.17.0
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 +34 -14
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +11 -0
- package/README.md +126 -77
- package/bin/tapp.js +187 -63
- package/docs/BROWSER-PRODUCT.md +1 -1
- package/docs/application-model.md +12 -3
- 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/html-report.js +3 -2
- package/mcp-server/src/index.js +326 -110
- 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/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 +2 -2
- package/skills/tapp/SKILL.md +75 -0
- package/skills/tapp/agents/openai.yaml +4 -0
- package/skills/tapp/references/commands.md +102 -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");
|
|
@@ -700,6 +718,28 @@ function templateValue(text) {
|
|
|
700
718
|
return text;
|
|
701
719
|
}
|
|
702
720
|
|
|
721
|
+
export function isStableFlowCheckpoint(value) {
|
|
722
|
+
const text = String(value || "").replace(/\s+/g, " ").trim();
|
|
723
|
+
if (!text || /^(loading|fetching|please wait|preparing|connecting|syncing|signing in)(?:[.ā¦!]*|\s.*)$/i.test(text)) return false;
|
|
724
|
+
if (/^(?:mon|tues?|wed(?:nes)?|thu(?:rs)?|fri|sat(?:ur)?|sun)(?:day)?\b/i.test(text)) return false;
|
|
725
|
+
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;
|
|
726
|
+
if (/^\d{4}-\d{2}-\d{2}(?:[ T].*)?$/.test(text)) return false;
|
|
727
|
+
return true;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
export function semanticTargetAtPoint(elements, x, y) {
|
|
731
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return "";
|
|
732
|
+
return (elements || [])
|
|
733
|
+
.filter((element) => {
|
|
734
|
+
const frame = element.frame || {};
|
|
735
|
+
return element.hittable !== false && Number.isFinite(frame.x) && Number.isFinite(frame.y) && Number.isFinite(frame.width) && Number.isFinite(frame.height)
|
|
736
|
+
&& x >= frame.x && y >= frame.y && x <= frame.x + frame.width && y <= frame.y + frame.height
|
|
737
|
+
&& String(element.id || element.identifier || element.label || "").trim();
|
|
738
|
+
})
|
|
739
|
+
.sort((a, b) => (a.frame.width * a.frame.height) - (b.frame.width * b.frame.height))
|
|
740
|
+
.map((element) => String(element.id || element.identifier || element.label || "").trim())[0] || "";
|
|
741
|
+
}
|
|
742
|
+
|
|
703
743
|
/** Append a Flow step for an act (record-by-doing). Inserts wait_for on screen change for
|
|
704
744
|
* deterministic replay. Inspection acts (tree/screenshot/wait) are not recorded. */
|
|
705
745
|
function recordStep(cmd, result) {
|
|
@@ -710,7 +750,7 @@ function recordStep(cmd, result) {
|
|
|
710
750
|
case "tap": {
|
|
711
751
|
const target = cmd.id || cmd.label || (typeof cmd.x === "number" ? `${cmd.x},${cmd.y}` : "");
|
|
712
752
|
if (target) activeSession.recording.push({ tap: target });
|
|
713
|
-
if (changed) activeSession.recording.push({ wait_for: newScreen });
|
|
753
|
+
if (changed && isStableFlowCheckpoint(newScreen)) activeSession.recording.push({ wait_for: newScreen });
|
|
714
754
|
break;
|
|
715
755
|
}
|
|
716
756
|
case "type": {
|
|
@@ -719,12 +759,16 @@ function recordStep(cmd, result) {
|
|
|
719
759
|
activeSession.recording.push({ type: step });
|
|
720
760
|
break;
|
|
721
761
|
}
|
|
762
|
+
case "login":
|
|
763
|
+
activeSession.recording.push({ login: { email: "$TEST_EMAIL", password: "$TEST_PASSWORD" } });
|
|
764
|
+
if (changed && isStableFlowCheckpoint(newScreen)) activeSession.recording.push({ wait_for: newScreen });
|
|
765
|
+
break;
|
|
722
766
|
case "swipe":
|
|
723
767
|
activeSession.recording.push({ swipe: cmd.direction || "up" });
|
|
724
768
|
break;
|
|
725
769
|
case "back":
|
|
726
770
|
activeSession.recording.push({ back: true });
|
|
727
|
-
if (changed) activeSession.recording.push({ wait_for: newScreen });
|
|
771
|
+
if (changed && isStableFlowCheckpoint(newScreen)) activeSession.recording.push({ wait_for: newScreen });
|
|
728
772
|
break;
|
|
729
773
|
default:
|
|
730
774
|
break; // tree / screenshot / wait are inspection, not test steps
|
|
@@ -733,7 +777,17 @@ function recordStep(cmd, result) {
|
|
|
733
777
|
}
|
|
734
778
|
|
|
735
779
|
async function sessionAct(cmd) {
|
|
736
|
-
|
|
780
|
+
const startedAt = Date.now();
|
|
781
|
+
const done = (result) => ({ ...result, durationMs: Date.now() - startedAt });
|
|
782
|
+
if (!activeSession || activeSession.ended) return done({ error: "No active session. Call tapp_session_start first." });
|
|
783
|
+
let coordinateResolvedTarget = "";
|
|
784
|
+
if (cmd.action === "tap" && !cmd.id && Number.isFinite(cmd.x) && Number.isFinite(cmd.y)) {
|
|
785
|
+
coordinateResolvedTarget = semanticTargetAtPoint(activeSession.latestTree?.elements, cmd.x, cmd.y);
|
|
786
|
+
if (coordinateResolvedTarget) {
|
|
787
|
+
const { x: _x, y: _y, ...semanticCommand } = cmd;
|
|
788
|
+
cmd = { ...semanticCommand, id: coordinateResolvedTarget };
|
|
789
|
+
}
|
|
790
|
+
}
|
|
737
791
|
if (activeSession.platform === "web") {
|
|
738
792
|
const session = activeSession;
|
|
739
793
|
let status = "ok";
|
|
@@ -741,9 +795,13 @@ async function sessionAct(cmd) {
|
|
|
741
795
|
let typedInto = null;
|
|
742
796
|
try {
|
|
743
797
|
if (cmd.action === "tap") {
|
|
744
|
-
const
|
|
745
|
-
if (!
|
|
746
|
-
else
|
|
798
|
+
const target = cmd.id || cmd.label || "";
|
|
799
|
+
if (!target && Number.isFinite(cmd.x) && Number.isFinite(cmd.y)) await session.page.mouse.click(cmd.x, cmd.y);
|
|
800
|
+
else {
|
|
801
|
+
const locator = await firstVisibleWebLocator(session.page, target);
|
|
802
|
+
if (!locator) { status = "not_found"; detail = "No visible web control matched the semantic target"; }
|
|
803
|
+
else await locator.click({ timeout:10_000 });
|
|
804
|
+
}
|
|
747
805
|
} else if (cmd.action === "type") {
|
|
748
806
|
const locator = await firstVisibleWebLocator(session.page, cmd.id || cmd.label || "", { input:true });
|
|
749
807
|
if (!locator) { status = "not_found"; detail = "No visible web field matched the semantic target"; }
|
|
@@ -756,6 +814,28 @@ async function sessionAct(cmd) {
|
|
|
756
814
|
if (!locator) await session.page.waitForTimeout(120);
|
|
757
815
|
}
|
|
758
816
|
if (!locator) { status = "timeout"; detail = `Timed out waiting for ${cmd.id || cmd.text || "target"}`; }
|
|
817
|
+
} else if (cmd.action === "login") {
|
|
818
|
+
const emailValue = cmd.email || session.creds.email || "";
|
|
819
|
+
const passwordValue = cmd.password || session.creds.password || "";
|
|
820
|
+
if (!emailValue || !passwordValue) { status = "missing_credentials"; detail = "Email and password are required"; }
|
|
821
|
+
else {
|
|
822
|
+
const email = await firstVisibleWebLocator(session.page, "Email", { input:true })
|
|
823
|
+
|| session.page.locator("input[type=email], input[name*=mail i], input[name*=user i], input[id*=mail i], input[id*=user i]").first();
|
|
824
|
+
const password = session.page.locator("input[type=password]").first();
|
|
825
|
+
if (!(await email.isVisible().catch(() => false)) || !(await password.isVisible().catch(() => false))) {
|
|
826
|
+
status = "not_found"; detail = "Could not identify email and password fields";
|
|
827
|
+
} else {
|
|
828
|
+
await email.fill(emailValue);
|
|
829
|
+
await password.fill(passwordValue);
|
|
830
|
+
const submit = session.page.getByRole("button", { name:/sign in|log in|login|continue|submit/i }).first();
|
|
831
|
+
if (!(await submit.isVisible().catch(() => false))) { status = "not_found"; detail = "Could not identify a sign-in control"; }
|
|
832
|
+
else {
|
|
833
|
+
await submit.click();
|
|
834
|
+
await session.page.waitForTimeout(500);
|
|
835
|
+
if (await password.isVisible().catch(() => false)) { status = "still_on_login"; detail = "Submit left the app on the login screen"; }
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
759
839
|
} else if (cmd.action === "back") {
|
|
760
840
|
await session.page.goBack({ waitUntil:"domcontentloaded", timeout:10_000 }).catch(() => {});
|
|
761
841
|
} else if (cmd.action === "swipe") {
|
|
@@ -774,7 +854,7 @@ async function sessionAct(cmd) {
|
|
|
774
854
|
}
|
|
775
855
|
const snapshot = treeSnapshot();
|
|
776
856
|
if (status === "ok") recordStep(cmd, snapshot);
|
|
777
|
-
return { status, typedInto, detail, ...snapshot, recordedSteps:session.recording.length, url:session.latestTree?.url || "" };
|
|
857
|
+
return done({ status, typedInto, detail, ...snapshot, recordedSteps:session.recording.length, url:session.latestTree?.url || "", ...(coordinateResolvedTarget ? { coordinateResolvedTarget } : {}) });
|
|
778
858
|
}
|
|
779
859
|
if (activeSession.platform === "android") {
|
|
780
860
|
const s = activeSession;
|
|
@@ -830,7 +910,7 @@ async function sessionAct(cmd) {
|
|
|
830
910
|
s.treeVersion += 1;
|
|
831
911
|
const snap = treeSnapshot();
|
|
832
912
|
if (status === "ok") recordStep(cmd, snap);
|
|
833
|
-
return { status, typedInto, detail, ...snap, recordedSteps: s.recording.length };
|
|
913
|
+
return done({ status, typedInto, detail, ...snap, recordedSteps: s.recording.length, ...(coordinateResolvedTarget ? { coordinateResolvedTarget } : {}) });
|
|
834
914
|
}
|
|
835
915
|
activeSession.seq += 1;
|
|
836
916
|
const seq = activeSession.seq;
|
|
@@ -859,7 +939,7 @@ async function sessionAct(cmd) {
|
|
|
859
939
|
while (activeSession.treeVersion === beforeVer && Date.now() < td && !activeSession.ended) await sleep(150);
|
|
860
940
|
const snap = treeSnapshot();
|
|
861
941
|
if (status === "ok") recordStep(cmd, snap); // record only successful acts
|
|
862
|
-
return { status, typedInto, detail, ...snap, recordedSteps: activeSession ? activeSession.recording.length : 0 };
|
|
942
|
+
return done({ status, typedInto, detail, ...snap, recordedSteps: activeSession ? activeSession.recording.length : 0, ...(coordinateResolvedTarget ? { coordinateResolvedTarget } : {}) });
|
|
863
943
|
}
|
|
864
944
|
|
|
865
945
|
async function endSession() {
|
|
@@ -904,7 +984,7 @@ export async function saveInteractiveSessionFlow({ projectDir, name, addFinalAss
|
|
|
904
984
|
if (!projectDir || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error("A valid repository root is required to save a Flow");
|
|
905
985
|
const steps = [...(activeSession.recording || [])];
|
|
906
986
|
if (steps.length === 0) throw new Error("Nothing recorded yet ā perform some live-session actions first.");
|
|
907
|
-
if (addFinalAssertion && activeSession.lastScreen) {
|
|
987
|
+
if (addFinalAssertion && activeSession.lastScreen && isStableFlowCheckpoint(activeSession.lastScreen)) {
|
|
908
988
|
const last = steps[steps.length - 1] || {};
|
|
909
989
|
if (!("assert_screen" in last)) steps.push({ assert_screen: activeSession.lastScreen });
|
|
910
990
|
}
|
|
@@ -1229,6 +1309,7 @@ export function explorationEnvFromArgs(args) {
|
|
|
1229
1309
|
const env = {};
|
|
1230
1310
|
if (isNonEmptyString(args.testEmail)) env.OCQA_TEST_EMAIL = args.testEmail;
|
|
1231
1311
|
if (isNonEmptyString(args.testPassword)) env.OCQA_TEST_PASSWORD = args.testPassword;
|
|
1312
|
+
if (isNonEmptyString(args.testEmail) || isNonEmptyString(args.testPassword)) env.OCQA_CREDENTIALS_EXPLICIT = "1";
|
|
1232
1313
|
if (Array.isArray(args.appLaunchArgs)) {
|
|
1233
1314
|
const a = args.appLaunchArgs.filter((s) => typeof s === "string" && s.length > 0);
|
|
1234
1315
|
if (a.length) env.OCQA_APP_LAUNCH_ARGS_JSON = JSON.stringify(a);
|
|
@@ -1320,9 +1401,9 @@ function fmtDuration(ms) {
|
|
|
1320
1401
|
export function qaNextSteps(report, surface = "mcp") {
|
|
1321
1402
|
if (surface === "cli") {
|
|
1322
1403
|
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>`");
|
|
1404
|
+
if (report?.findings?.length) next.push("inspect the evidence with `npx -y @aarwitz/tapp@latest report latest`");
|
|
1405
|
+
next.push("save this run with `--json <report.json>`, then re-run with `--baseline <report.json>` to compare a fix (`tapp ci` gates it)");
|
|
1406
|
+
next.push("replay a committed journey with `npx -y @aarwitz/tapp@latest flow run <file>`");
|
|
1326
1407
|
return next;
|
|
1327
1408
|
}
|
|
1328
1409
|
const next = [];
|
|
@@ -1344,6 +1425,7 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
|
|
|
1344
1425
|
L.push(`### š Exploration complete ā ${badge} Ā· ${observationSummary(report)}${bundleId ? `\n\`${bundleId}\`` : ""}`);
|
|
1345
1426
|
L.push("");
|
|
1346
1427
|
L.push(report.headline);
|
|
1428
|
+
if (report.credentialWarning) L.push("", `> ā ļø ${report.credentialWarning}`);
|
|
1347
1429
|
L.push("");
|
|
1348
1430
|
L.push(`**Coverage** ā ${report.screensExplored} screens Ā· ${report.actionsPerformed} actions${timedOut ? " Ā· ā±ļø hit time limit" : ""}`);
|
|
1349
1431
|
if (report.platform === "web") {
|
|
@@ -1358,7 +1440,7 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
|
|
|
1358
1440
|
L.push("");
|
|
1359
1441
|
L.push("**Findings**");
|
|
1360
1442
|
for (const f of report.findings.slice(0, 12)) {
|
|
1361
|
-
L.push(`- ${SEV[f.severity] || "ā¢"} \`${f.severity}\` ${f.title}${f.screen ? ` ā on *${f.screen}*` : ""}`);
|
|
1443
|
+
L.push(`- ${SEV[f.severity] || "ā¢"} \`${f.severity}\` ${f.title}${f.screen ? ` ā on *${f.screen}*` : ""}${f.url ? ` ā ${f.url}` : ""}`);
|
|
1362
1444
|
if (f.aiAnalysis) L.push(` - why: ${String(f.aiAnalysis).slice(0, 200)}`);
|
|
1363
1445
|
if (f.suggestedFix) L.push(` - fix: ${String(f.suggestedFix).slice(0, 200)}`);
|
|
1364
1446
|
}
|
|
@@ -1375,7 +1457,7 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
|
|
|
1375
1457
|
// The merge decision is the gate's job (tapp ci), not exploration's (ADR-0005).
|
|
1376
1458
|
L.push("");
|
|
1377
1459
|
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)`
|
|
1460
|
+
`**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
1461
|
);
|
|
1380
1462
|
}
|
|
1381
1463
|
if (inputHint) {
|
|
@@ -1458,7 +1540,10 @@ export function formatScreen(screenTitle, elements) {
|
|
|
1458
1540
|
// `tapp` CLI verbs in bin/tapp.js ā same pattern as report.js. Keep orchestration HERE so
|
|
1459
1541
|
// the surfaces can't drift.)
|
|
1460
1542
|
|
|
1461
|
-
export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], surface = "mcp", onProgress = () => {} }) {
|
|
1543
|
+
export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], watch = false, surface = "mcp", onProgress = () => {} }) {
|
|
1544
|
+
const { storagePreflight } = await import("./environment-preflight.js");
|
|
1545
|
+
const storage = storagePreflight(capturesDir);
|
|
1546
|
+
if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
|
|
1462
1547
|
const actions = Math.max(1, Math.min(1000, asInteger(maxActions, 60)));
|
|
1463
1548
|
const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
|
|
1464
1549
|
const id = "web-" + new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/^(\d{8})/, "$1-");
|
|
@@ -1475,6 +1560,7 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
|
|
|
1475
1560
|
testPassword: isNonEmptyString(testPassword) ? testPassword.trim() : "",
|
|
1476
1561
|
seedRoutes,
|
|
1477
1562
|
seedTargets,
|
|
1563
|
+
watch: watch === true,
|
|
1478
1564
|
onProgress,
|
|
1479
1565
|
});
|
|
1480
1566
|
} catch (err) {
|
|
@@ -1500,6 +1586,9 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
|
|
|
1500
1586
|
}
|
|
1501
1587
|
|
|
1502
1588
|
export async function runQaAndroid({ appId, apkPath, serial, maxActions, timeout, testEmail, testPassword, baselineFindings, clearData = true, seedTargets = [], surface = "mcp", onProgress = () => {} }) {
|
|
1589
|
+
const { storagePreflight } = await import("./environment-preflight.js");
|
|
1590
|
+
const storage = storagePreflight(capturesDir);
|
|
1591
|
+
if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
|
|
1503
1592
|
const actions = Math.max(1, Math.min(1000, asInteger(maxActions, 60)));
|
|
1504
1593
|
const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
|
|
1505
1594
|
const id = "android-" + new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/^(\d{8})/, "$1-");
|
|
@@ -1543,6 +1632,9 @@ export async function runQaAndroid({ appId, apkPath, serial, maxActions, timeout
|
|
|
1543
1632
|
}
|
|
1544
1633
|
|
|
1545
1634
|
export async function runQaIos({ bundleId, maxActions, timeout, args = {}, surface = "mcp", onProgress = () => {} }) {
|
|
1635
|
+
const { storagePreflight } = await import("./environment-preflight.js");
|
|
1636
|
+
const storage = storagePreflight(capturesDir);
|
|
1637
|
+
if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
|
|
1546
1638
|
const captureScript = path.join(scriptsDir, "quick-capture.sh");
|
|
1547
1639
|
if (!fs.existsSync(captureScript)) return { error: "Capture script not found", details: { captureScript } };
|
|
1548
1640
|
|
|
@@ -1635,6 +1727,7 @@ export async function runInitExploration({
|
|
|
1635
1727
|
timeout,
|
|
1636
1728
|
testEmail,
|
|
1637
1729
|
testPassword,
|
|
1730
|
+
watch = false,
|
|
1638
1731
|
onProgress = () => {},
|
|
1639
1732
|
onStatus = () => {},
|
|
1640
1733
|
} = {}) {
|
|
@@ -1650,17 +1743,18 @@ export async function runInitExploration({
|
|
|
1650
1743
|
let targetResolution = null;
|
|
1651
1744
|
let qa;
|
|
1652
1745
|
let managedRuntime = null;
|
|
1746
|
+
if (watch && selected !== "web") return { error: "Watch mode is currently available for web exploration only." };
|
|
1653
1747
|
if (selected === "web") {
|
|
1654
1748
|
if (/^https?:\/\//i.test(String(url))) {
|
|
1655
1749
|
resolvedTarget = String(url).trim();
|
|
1656
|
-
qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, onProgress });
|
|
1750
|
+
qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, watch, onProgress });
|
|
1657
1751
|
} else {
|
|
1658
1752
|
const started = await startManagedWebTarget({ root, requestedTarget: target, timeout, onStatus });
|
|
1659
1753
|
if (started.error) return started;
|
|
1660
1754
|
managedRuntime = started;
|
|
1661
1755
|
resolvedTarget = started.url;
|
|
1662
1756
|
try {
|
|
1663
|
-
qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, onProgress });
|
|
1757
|
+
qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, watch, onProgress });
|
|
1664
1758
|
} finally {
|
|
1665
1759
|
await stopManagedWebTarget(started);
|
|
1666
1760
|
}
|
|
@@ -1772,6 +1866,7 @@ export async function runExploreTarget({
|
|
|
1772
1866
|
appLaunchArgs,
|
|
1773
1867
|
appLaunchEnv,
|
|
1774
1868
|
baselineFindings,
|
|
1869
|
+
watch = false,
|
|
1775
1870
|
surface = "cli",
|
|
1776
1871
|
onProgress = () => {},
|
|
1777
1872
|
onStatus = () => {},
|
|
@@ -1782,7 +1877,7 @@ export async function runExploreTarget({
|
|
|
1782
1877
|
|
|
1783
1878
|
const modelPath = existingProjectArtifactPath(root, "application-model.json");
|
|
1784
1879
|
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)." };
|
|
1880
|
+
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
1881
|
}
|
|
1787
1882
|
let model;
|
|
1788
1883
|
try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
|
|
@@ -1794,6 +1889,8 @@ export async function runExploreTarget({
|
|
|
1794
1889
|
catch (error) { return { error: error.message || String(error) }; }
|
|
1795
1890
|
const selectedPlatform = selected.platform;
|
|
1796
1891
|
|
|
1892
|
+
if (watch && selectedPlatform !== "web") return { error: "Watch mode is currently available for web exploration only." };
|
|
1893
|
+
|
|
1797
1894
|
if (selectedPlatform !== "ios" && ((Array.isArray(appLaunchArgs) && appLaunchArgs.length) || (appLaunchEnv && Object.keys(appLaunchEnv).length))) {
|
|
1798
1895
|
return { error: "appLaunchArgs/appLaunchEnv apply only to iOS targets." };
|
|
1799
1896
|
}
|
|
@@ -1802,14 +1899,14 @@ export async function runExploreTarget({
|
|
|
1802
1899
|
const ownedUrl = String(selected.runtime?.ownedUrl || "").trim();
|
|
1803
1900
|
if (/^https?:\/\//i.test(ownedUrl)) {
|
|
1804
1901
|
onStatus(`Exploring the owned URL from the application model: ${ownedUrl}`);
|
|
1805
|
-
return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
|
|
1902
|
+
return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, watch, surface, onProgress });
|
|
1806
1903
|
}
|
|
1807
1904
|
// Tapp-managed: build/start the repo's web target, wait for readiness, and ALWAYS stop it.
|
|
1808
1905
|
onStatus(`Preparing the managed web runtime for ${selected.name}ā¦`);
|
|
1809
1906
|
const started = await startManagedWebTarget({ root, requestedTarget: selected.sourcePath || selected.name || "", timeout, onStatus });
|
|
1810
1907
|
if (started.error) return started;
|
|
1811
1908
|
try {
|
|
1812
|
-
return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
|
|
1909
|
+
return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, watch, surface, onProgress });
|
|
1813
1910
|
} finally {
|
|
1814
1911
|
await stopManagedWebTarget(started);
|
|
1815
1912
|
onStatus("Stopped the managed web runtime.");
|
|
@@ -1818,7 +1915,7 @@ export async function runExploreTarget({
|
|
|
1818
1915
|
|
|
1819
1916
|
if (selectedPlatform === "android") {
|
|
1820
1917
|
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.` };
|
|
1918
|
+
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
1919
|
const task = selected.build?.task || "assembleDebug";
|
|
1823
1920
|
onStatus(`Building the Android APK (${task})ā¦`);
|
|
1824
1921
|
const built = await buildAndroidApp({
|
|
@@ -1856,6 +1953,22 @@ function openLocalPort() {
|
|
|
1856
1953
|
});
|
|
1857
1954
|
}
|
|
1858
1955
|
|
|
1956
|
+
function localPortAvailable(port) {
|
|
1957
|
+
return new Promise((resolve) => {
|
|
1958
|
+
const server = net.createServer();
|
|
1959
|
+
server.unref();
|
|
1960
|
+
server.once("error", () => resolve(false));
|
|
1961
|
+
server.listen(port, "127.0.0.1", () => server.close(() => resolve(true)));
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
export function managedWebDefaultPort(dependencies = {}) {
|
|
1966
|
+
if (dependencies.vite) return 5173;
|
|
1967
|
+
if (dependencies.next) return 3000;
|
|
1968
|
+
if (dependencies["react-scripts"]) return 3000;
|
|
1969
|
+
return 0;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1859
1972
|
function managedInstallSpec(command) {
|
|
1860
1973
|
const known = {
|
|
1861
1974
|
"npm ci": ["npm", ["ci"]],
|
|
@@ -1940,7 +2053,9 @@ export async function startManagedWebTarget({ root, requestedTarget = "", timeou
|
|
|
1940
2053
|
const pkg = (() => { try { return JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf8")); } catch { return {}; } })();
|
|
1941
2054
|
const dependencies = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
1942
2055
|
const declaredPort = startMatch ? declaredPortFromStartScript(pkg.scripts?.[startMatch[1]]) : 0;
|
|
1943
|
-
const
|
|
2056
|
+
const frameworkPort = declaredPort ? 0 : managedWebDefaultPort(dependencies);
|
|
2057
|
+
const port = declaredPort || (frameworkPort && await localPortAvailable(frameworkPort) ? frameworkPort : await openLocalPort());
|
|
2058
|
+
const portBasis = declaredPort ? "repository-declared" : port === frameworkPort ? "framework-default" : "available-ephemeral";
|
|
1944
2059
|
let command = "npm";
|
|
1945
2060
|
let startArgs;
|
|
1946
2061
|
let startDir = projectDir;
|
|
@@ -1967,13 +2082,13 @@ export async function startManagedWebTarget({ root, requestedTarget = "", timeou
|
|
|
1967
2082
|
const append = (chunk) => fs.appendFileSync(logPath, String(chunk));
|
|
1968
2083
|
child.stdout.on("data", append);
|
|
1969
2084
|
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}`);
|
|
2085
|
+
onStatus(`Managed web runtime: ${command === process.execPath ? "Tapp static server" : `npm ${startArgs.join(" ")}`} ā http://127.0.0.1:${port} (${portBasis} port)`);
|
|
1971
2086
|
const ready = await waitForOwnedUrl(`http://127.0.0.1:${port}`, child, Math.min(budgetMs, 60_000), logPath);
|
|
1972
2087
|
if (ready.error) {
|
|
1973
2088
|
await stopManagedWebTarget({ child, detached: process.platform !== "win32" });
|
|
1974
2089
|
return ready;
|
|
1975
2090
|
}
|
|
1976
|
-
return { child, detached: process.platform !== "win32", url: `http://127.0.0.1:${port}`, logPath, install, build, start: { command, args: startArgs, cwd: startDir } };
|
|
2091
|
+
return { child, detached: process.platform !== "win32", url: `http://127.0.0.1:${port}`, logPath, install, build, start: { command, args: startArgs, cwd: startDir, portBasis } };
|
|
1977
2092
|
}
|
|
1978
2093
|
|
|
1979
2094
|
export async function stopManagedWebTarget(runtime) {
|
|
@@ -2032,11 +2147,61 @@ const server = new Server(
|
|
|
2032
2147
|
},
|
|
2033
2148
|
{
|
|
2034
2149
|
capabilities: {
|
|
2150
|
+
prompts: {},
|
|
2035
2151
|
tools: {},
|
|
2036
2152
|
},
|
|
2037
2153
|
}
|
|
2038
2154
|
);
|
|
2039
2155
|
|
|
2156
|
+
const testAppPrompt = {
|
|
2157
|
+
name: "test-app",
|
|
2158
|
+
title: "Test this app with Tapp",
|
|
2159
|
+
description: "Use Tapp's real app surfaces to inspect, drive, or explore this repository and report evidence honestly.",
|
|
2160
|
+
arguments: [
|
|
2161
|
+
{
|
|
2162
|
+
name: "goal",
|
|
2163
|
+
description: "What to verify, such as finding bugs or exercising checkout",
|
|
2164
|
+
required: false,
|
|
2165
|
+
},
|
|
2166
|
+
{
|
|
2167
|
+
name: "target",
|
|
2168
|
+
description: "Optional repo target, bundle/app id, APK path, or owned URL",
|
|
2169
|
+
required: false,
|
|
2170
|
+
},
|
|
2171
|
+
],
|
|
2172
|
+
};
|
|
2173
|
+
|
|
2174
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [testAppPrompt] }));
|
|
2175
|
+
|
|
2176
|
+
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
2177
|
+
if (request.params.name !== testAppPrompt.name) {
|
|
2178
|
+
throw new Error(`Unknown prompt: ${request.params.name}`);
|
|
2179
|
+
}
|
|
2180
|
+
const goal = isNonEmptyString(request.params.arguments?.goal)
|
|
2181
|
+
? request.params.arguments.goal.trim()
|
|
2182
|
+
: "Test the app and find important bugs";
|
|
2183
|
+
const target = isNonEmptyString(request.params.arguments?.target)
|
|
2184
|
+
? ` Use this target: ${request.params.arguments.target.trim()}.`
|
|
2185
|
+
: "";
|
|
2186
|
+
return {
|
|
2187
|
+
description: testAppPrompt.description,
|
|
2188
|
+
messages: [
|
|
2189
|
+
{
|
|
2190
|
+
role: "user",
|
|
2191
|
+
content: {
|
|
2192
|
+
type: "text",
|
|
2193
|
+
text:
|
|
2194
|
+
`${goal}.${target} Use the connected Tapp tools on the real UI surface. ` +
|
|
2195
|
+
"Use the smallest operation that satisfies the request; initialize/explore the source repo only for a general repository test. " +
|
|
2196
|
+
"If Tapp returns multiple target choices, ask me to select one instead of guessing. " +
|
|
2197
|
+
"Read visual evidence before describing it. Report findings, coverage, authority, inconclusive state, and checked/not-checked scope; " +
|
|
2198
|
+
"never turn exploration into a score or ship verdict. Do not edit the app unless I ask for a fix.",
|
|
2199
|
+
},
|
|
2200
|
+
},
|
|
2201
|
+
],
|
|
2202
|
+
};
|
|
2203
|
+
});
|
|
2204
|
+
|
|
2040
2205
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
2041
2206
|
tools: [
|
|
2042
2207
|
{
|
|
@@ -2208,6 +2373,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2208
2373
|
androidSerial: { type: "string", description: "Android: optional adb device serial; defaults to the first authorized device." },
|
|
2209
2374
|
clearData: { type: "boolean", default: true, description: "Android: clear app data before launch for a repeatable starting state." },
|
|
2210
2375
|
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." },
|
|
2376
|
+
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
2377
|
maxActions: { type: "integer", minimum: 1, maximum: 1000, default: 60, description: "Exploration action budget" },
|
|
2212
2378
|
timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600, description: "Max wall-clock seconds" },
|
|
2213
2379
|
testEmail: { type: "string", description: "Email for the login preamble, if the app has a sign-in" },
|
|
@@ -2250,7 +2416,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2250
2416
|
"Findings from a previous run (pass back the `findings` array a prior tapp_explore returned). " +
|
|
2251
2417
|
"When provided, the result adds a `regression` COMPARISON {counts:{new,persisting,resolved}, " +
|
|
2252
2418
|
"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 " +
|
|
2419
|
+
"run `npx -y @aarwitz/tapp@latest ci` (or the GitHub Action): it applies the deterministic policy and returns the " +
|
|
2254
2420
|
"pass/fail/inconclusive outcome.",
|
|
2255
2421
|
},
|
|
2256
2422
|
},
|
|
@@ -2278,6 +2444,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2278
2444
|
timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600 },
|
|
2279
2445
|
testEmail: { type: "string", description: "Explore: actor/login email; never persisted in the model" },
|
|
2280
2446
|
testPassword: { type: "string", description: "Explore: actor/login password; never persisted in the model" },
|
|
2447
|
+
watch: { type: "boolean", default: false, description: "Web explore only: show the controlled browser and Tapp's actions" },
|
|
2281
2448
|
maxContracts: { type: "integer", minimum: 1, maximum: 50, default: 15 },
|
|
2282
2449
|
outDir: { type: "string", description: "Repo-relative artifact directory; default .tapp" },
|
|
2283
2450
|
},
|
|
@@ -2731,45 +2898,62 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2731
2898
|
const { name, arguments: args = {} } = request.params;
|
|
2732
2899
|
|
|
2733
2900
|
if (name === "tapp_health") {
|
|
2734
|
-
const
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2901
|
+
const coreChecks = [];
|
|
2902
|
+
coreChecks.push({
|
|
2903
|
+
check: "workspace",
|
|
2904
|
+
ok: fs.existsSync(workspaceRoot) && fs.statSync(workspaceRoot).isDirectory(),
|
|
2905
|
+
value: workspaceRoot,
|
|
2906
|
+
required: true,
|
|
2740
2907
|
});
|
|
2741
2908
|
|
|
2742
2909
|
const nodeVersion = await runCommand("node", ["-v"]);
|
|
2743
|
-
|
|
2910
|
+
coreChecks.push({
|
|
2744
2911
|
check: "node",
|
|
2745
2912
|
ok: nodeVersion.code === 0,
|
|
2746
2913
|
value: nodeVersion.stdout.trim() || nodeVersion.stderr.trim(),
|
|
2914
|
+
required: true,
|
|
2747
2915
|
});
|
|
2748
2916
|
|
|
2749
2917
|
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);
|
|
2918
|
+
const simctl = xcodebuildVersion.code === 0
|
|
2919
|
+
? await runCommand("xcrun", ["simctl", "list", "devices", "booted"])
|
|
2920
|
+
: { code: 1, stdout: "", stderr: "Xcode not found" };
|
|
2764
2921
|
const bootedLine = (simctl.stdout || "").split("\n").find((l) => /\(Booted\)/.test(l));
|
|
2765
2922
|
const bootedName = bootedLine ? bootedLine.trim().replace(/\s*\(.*$/, "") : null;
|
|
2766
|
-
const
|
|
2923
|
+
const adb = await runCommand("adb", ["devices"]);
|
|
2924
|
+
const androidDevice = adb.code === 0
|
|
2925
|
+
? (adb.stdout || "").split("\n").find((line) => /\tdevice\s*$/.test(line))
|
|
2926
|
+
: null;
|
|
2927
|
+
let web = { ok: false, value: "Playwright or Chromium not installed" };
|
|
2928
|
+
try {
|
|
2929
|
+
const { chromium } = await import("playwright");
|
|
2930
|
+
const executable = chromium.executablePath();
|
|
2931
|
+
web = { ok: fs.existsSync(executable), value: fs.existsSync(executable) ? executable : "Chromium not installed" };
|
|
2932
|
+
} catch { /* optional dependency may be intentionally absent */ }
|
|
2933
|
+
const platformChecks = [
|
|
2934
|
+
{
|
|
2935
|
+
check: "iOS",
|
|
2936
|
+
ok: xcodebuildVersion.code === 0 && !!bootedName,
|
|
2937
|
+
value: bootedName ? `${bootedName} booted` : xcodebuildVersion.code === 0 ? "Xcode available; no simulator booted" : "Xcode not found",
|
|
2938
|
+
required: false,
|
|
2939
|
+
},
|
|
2940
|
+
{
|
|
2941
|
+
check: "Android",
|
|
2942
|
+
ok: !!androidDevice,
|
|
2943
|
+
value: androidDevice ? `${androidDevice.split("\t")[0]} connected` : adb.code === 0 ? "adb available; no authorized device" : "adb not found",
|
|
2944
|
+
required: false,
|
|
2945
|
+
},
|
|
2946
|
+
{ check: "web", ok: web.ok, value: web.value, required: false },
|
|
2947
|
+
];
|
|
2948
|
+
const checks = [...coreChecks, ...platformChecks];
|
|
2949
|
+
const ready = coreChecks.every((check) => check.ok) && platformChecks.some((check) => check.ok);
|
|
2950
|
+
const L = [`### ${ready ? "𩺠Tapp ready" : "ā ļø Tapp needs a platform runtime"}`, ""];
|
|
2767
2951
|
for (const c of checks) {
|
|
2768
|
-
|
|
2952
|
+
const icon = c.ok ? "ā
" : c.required ? "ā" : "āŖļø";
|
|
2953
|
+
L.push(`- ${icon} **${c.check}** ā ${String(c.value).split("\n")[0] || "ā"}`);
|
|
2769
2954
|
}
|
|
2770
|
-
L.push("");
|
|
2771
|
-
L.
|
|
2772
|
-
return richResult(L.join("\n"), { ok: allOk, checks });
|
|
2955
|
+
if (!ready) L.push("", "Run `npx -y @aarwitz/tapp@latest doctor` in the application repository for exact remediation.");
|
|
2956
|
+
return richResult(L.join("\n"), { ok: ready, workspaceRoot, checks, platforms: Object.fromEntries(platformChecks.map((check) => [check.check.toLowerCase(), check.ok])) });
|
|
2773
2957
|
}
|
|
2774
2958
|
|
|
2775
2959
|
if (name === "tapp_build") {
|
|
@@ -2788,11 +2972,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2788
2972
|
if (inst.error) return errorResult(inst.error);
|
|
2789
2973
|
bundleId = inst.bundleId;
|
|
2790
2974
|
}
|
|
2975
|
+
let modelRefresh = null;
|
|
2976
|
+
let modelRefreshWarning = "";
|
|
2977
|
+
if (bundleId) {
|
|
2978
|
+
try {
|
|
2979
|
+
const { persistIosBuildValidation } = await import("./application-model.js");
|
|
2980
|
+
modelRefresh = await persistIosBuildValidation({ projectDir: dir, bundleId, container: built.container, scheme: built.scheme, configuration: built.configuration });
|
|
2981
|
+
} catch (error) {
|
|
2982
|
+
modelRefreshWarning = error.message || String(error);
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2791
2985
|
const text =
|
|
2792
2986
|
`šØ Built **${path.basename(built.appPath)}** (scheme \`${built.scheme}\`) in ${fmtDuration(Date.now() - startedAt)}` +
|
|
2793
2987
|
(bundleId ? ` ā installed on the simulator as \`${bundleId}\`` : "") +
|
|
2794
2988
|
`\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 });
|
|
2989
|
+
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
2990
|
}
|
|
2797
2991
|
|
|
2798
2992
|
if (name === "tapp_capture") {
|
|
@@ -2966,6 +3160,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2966
3160
|
if (targets !== 1) {
|
|
2967
3161
|
return errorResult("Provide exactly one of appBundleId (iOS), androidAppId (Android), or url (web beta)");
|
|
2968
3162
|
}
|
|
3163
|
+
if (args.watch === true && !wantsWeb) return errorResult("watch is currently available for web exploration only");
|
|
2969
3164
|
|
|
2970
3165
|
// Both branches call the shared engine (runQaWeb/runQaIos) ā the handler only adds
|
|
2971
3166
|
// MCP concerns: auth, arg validation, and progress notifications.
|
|
@@ -2987,6 +3182,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2987
3182
|
testEmail: args.testEmail,
|
|
2988
3183
|
testPassword: args.testPassword,
|
|
2989
3184
|
baselineFindings: args.baselineFindings,
|
|
3185
|
+
watch: args.watch === true,
|
|
2990
3186
|
onProgress: notifyProgress("pages reached"),
|
|
2991
3187
|
});
|
|
2992
3188
|
if (r.error) return errorResult(r.error, r.details || {});
|
|
@@ -3031,8 +3227,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3031
3227
|
if (unauthorized) return unauthorized;
|
|
3032
3228
|
const operation = String(args.operation || "inspect").toLowerCase();
|
|
3033
3229
|
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(
|
|
3230
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3231
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3036
3232
|
const maxContracts = asInteger(args.maxContracts, 15);
|
|
3037
3233
|
if (maxContracts < 1 || maxContracts > 50) return errorResult("maxContracts must be between 1 and 50");
|
|
3038
3234
|
const { initializeProductProject } = await import("./product-operations.js");
|
|
@@ -3042,32 +3238,42 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3042
3238
|
if (!isInsideDir(projectDir, resolvedOut)) return errorResult("outDir must be inside projectDir");
|
|
3043
3239
|
const selectedPlatform = isNonEmptyString(args.platform) ? args.platform.trim().toLowerCase()
|
|
3044
3240
|
: isNonEmptyString(args.url) ? "web"
|
|
3045
|
-
: isNonEmptyString(args.androidAppId) || isNonEmptyString(args.apkPath) ? "android" : "
|
|
3241
|
+
: isNonEmptyString(args.androidAppId) || isNonEmptyString(args.apkPath) ? "android" : "";
|
|
3046
3242
|
const progressToken = request.params && request.params._meta ? request.params._meta.progressToken : undefined;
|
|
3047
3243
|
const budget = Math.max(1, Math.min(1000, asInteger(args.maxActions, 40)));
|
|
3048
3244
|
const result = await initializeProductProject({
|
|
3049
3245
|
projectDir, mode: operation, outDir,
|
|
3050
3246
|
ownedUrl: isNonEmptyString(args.url) ? args.url.trim() : "",
|
|
3051
3247
|
platform: isNonEmptyString(args.platform) ? args.platform.trim().toLowerCase() : operation === "explore" ? selectedPlatform : "",
|
|
3052
|
-
target: isNonEmptyString(args.target) ? args.target.trim() :
|
|
3248
|
+
target: isNonEmptyString(args.target) ? args.target.trim() : "",
|
|
3053
3249
|
bundleId: isNonEmptyString(args.appBundleId) ? args.appBundleId.trim() : "",
|
|
3054
3250
|
appId: isNonEmptyString(args.androidAppId) ? args.androidAppId.trim() : "",
|
|
3055
3251
|
apkPath: isNonEmptyString(args.apkPath) ? path.resolve(projectDir, args.apkPath.trim()) : undefined,
|
|
3056
3252
|
serial: isNonEmptyString(args.androidSerial) ? args.androidSerial.trim() : undefined,
|
|
3057
3253
|
maxActions: args.maxActions, timeout: args.timeout, maxContracts,
|
|
3058
3254
|
testEmail: args.testEmail, testPassword: args.testPassword,
|
|
3255
|
+
watch: args.watch === true,
|
|
3059
3256
|
runExploration: runInitExploration,
|
|
3060
3257
|
onProgress: (progress) => {
|
|
3061
3258
|
if (progressToken === undefined) return;
|
|
3062
3259
|
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
3260
|
},
|
|
3064
3261
|
});
|
|
3065
|
-
const { model, plan, written, exploration } = result;
|
|
3066
|
-
const blocking = model.requirements.filter((item) => item.severity === "blocking");
|
|
3262
|
+
const { model, plan, written, exploration, selectedTarget, requirementScope } = result;
|
|
3263
|
+
const blocking = (requirementScope?.active || model.requirements).filter((item) => item.severity === "blocking");
|
|
3264
|
+
const deferredBlocking = (requirementScope?.deferred || []).filter((item) => item.severity === "blocking");
|
|
3067
3265
|
const pending = plan.items.filter((item) => item.decision === "pending");
|
|
3068
|
-
const
|
|
3069
|
-
|
|
3070
|
-
|
|
3266
|
+
const selectedLabel = selectedTarget ? ` for ${selectedTarget.platform}:${selectedTarget.name}` : "";
|
|
3267
|
+
const deferredLabel = deferredBlocking.length ? ` Ā· ${deferredBlocking.length} setup gap(s) on unselected target(s)` : "";
|
|
3268
|
+
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)" : ""}` : ""}`;
|
|
3269
|
+
return richResult(summary, { model, plan, selectedTarget, requirementScope, written: written ? { modelPath: written.modelPath, planPath: written.planPath } : null, exploration });
|
|
3270
|
+
} catch (error) {
|
|
3271
|
+
const detail = error.message || String(error);
|
|
3272
|
+
const message = error.details?.reason === "target-selection-required"
|
|
3273
|
+
? detail
|
|
3274
|
+
: "Could not initialize Tapp repository artifacts";
|
|
3275
|
+
return errorResult(message, { detail, ...(error.details || {}) });
|
|
3276
|
+
}
|
|
3071
3277
|
}
|
|
3072
3278
|
|
|
3073
3279
|
if (name === "tapp_actor_config") {
|
|
@@ -3078,8 +3284,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3078
3284
|
const allowedArguments = new Set(["authToken", "operation", "projectDir", "name", "role", "session", "provisioning", "credentialBindings", "replace"]);
|
|
3079
3285
|
const unexpectedArguments = Object.keys(args).filter((key) => !allowedArguments.has(key));
|
|
3080
3286
|
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(
|
|
3287
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3288
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3083
3289
|
const { configureActor, readProjectConfig } = await import("./project-config.js");
|
|
3084
3290
|
if (operation === "read") {
|
|
3085
3291
|
const loaded = readProjectConfig(projectDir);
|
|
@@ -3099,7 +3305,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3099
3305
|
credentials,
|
|
3100
3306
|
replace: asBoolean(args.replace),
|
|
3101
3307
|
});
|
|
3102
|
-
|
|
3308
|
+
const { refreshExistingInitArtifacts } = await import("./application-model.js");
|
|
3309
|
+
let refreshed = null;
|
|
3310
|
+
let refreshWarning = "";
|
|
3311
|
+
try { refreshed = await refreshExistingInitArtifacts({ projectDir }); }
|
|
3312
|
+
catch (error) { refreshWarning = error.message || String(error); }
|
|
3313
|
+
return richResult(
|
|
3314
|
+
`ā
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}` : ""}`,
|
|
3315
|
+
{ path: result.path, actor: result.actor, modelRefreshed: !!refreshed, ...(refreshed ? { modelPath: refreshed.modelPath, planPath: refreshed.planPath } : {}), ...(refreshWarning ? { refreshWarning } : {}) },
|
|
3316
|
+
);
|
|
3103
3317
|
} catch (error) { return errorResult("Actor not configured", { detail: error.message || String(error) }); }
|
|
3104
3318
|
}
|
|
3105
3319
|
|
|
@@ -3108,8 +3322,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3108
3322
|
if (unauthorized) return unauthorized;
|
|
3109
3323
|
const operation = String(args.operation || "read").toLowerCase();
|
|
3110
3324
|
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(
|
|
3325
|
+
const planPath = isNonEmptyString(args.planPath) ? path.resolve(workspaceRoot, args.planPath.trim()) : existingProjectArtifactPath(workspaceRoot, "release-plan.json");
|
|
3326
|
+
if (!isInsideDir(workspaceRoot, planPath)) return errorResult("planPath must be inside the workspace");
|
|
3113
3327
|
if (!fs.existsSync(planPath)) return errorResult("Release plan not found", { planPath });
|
|
3114
3328
|
let plan;
|
|
3115
3329
|
try { plan = JSON.parse(fs.readFileSync(planPath, "utf8")); }
|
|
@@ -3119,11 +3333,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3119
3333
|
if (![...decisions.approve, ...decisions.reject, ...decisions.defer].length) return errorResult("review requires at least one approve, reject, or defer item");
|
|
3120
3334
|
const { reviewProductPlan } = await import("./product-operations.js");
|
|
3121
3335
|
try {
|
|
3122
|
-
plan = reviewProductPlan({ projectDir:
|
|
3336
|
+
plan = reviewProductPlan({ projectDir: workspaceRoot, planPath, ...decisions }).plan;
|
|
3123
3337
|
} catch (error) { return errorResult("Could not review release plan", { detail: error.message || String(error) }); }
|
|
3124
3338
|
} else if (operation === "generate") {
|
|
3125
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3126
|
-
if (!isInsideDir(
|
|
3339
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3340
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3127
3341
|
const { generateProductPlan } = await import("./product-operations.js");
|
|
3128
3342
|
try {
|
|
3129
3343
|
const result = await generateProductPlan({ projectDir, planPath });
|
|
@@ -3131,8 +3345,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3131
3345
|
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
3346
|
} catch (error) { return errorResult("Could not generate contract drafts", { detail: error.message || String(error) }); }
|
|
3133
3347
|
} else if (operation === "validate") {
|
|
3134
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3135
|
-
if (!isInsideDir(
|
|
3348
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3349
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3136
3350
|
let apkPath = "";
|
|
3137
3351
|
if (isNonEmptyString(args.apkPath)) {
|
|
3138
3352
|
apkPath = path.resolve(projectDir, args.apkPath.trim());
|
|
@@ -3164,8 +3378,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3164
3378
|
return richResult(`š Generated contract validation passed ā ${result.results.length}/${result.results.length} on ${result.platform}`, { ...result, planPath });
|
|
3165
3379
|
} catch (error) { return errorResult("Generated contract validation failed", { detail: error.message || String(error), plan, planPath }); }
|
|
3166
3380
|
} else if (operation === "promote") {
|
|
3167
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3168
|
-
if (!isInsideDir(
|
|
3381
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3382
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3169
3383
|
const { promoteProductPlan } = await import("./product-operations.js");
|
|
3170
3384
|
try {
|
|
3171
3385
|
const result = await promoteProductPlan({ projectDir, planPath, items: Array.isArray(args.items) ? args.items : [] });
|
|
@@ -3181,9 +3395,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3181
3395
|
if (unauthorized) return unauthorized;
|
|
3182
3396
|
const operation = String(args.operation || "inspect").toLowerCase();
|
|
3183
3397
|
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(
|
|
3398
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3399
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3400
|
+
const modelPath = isNonEmptyString(args.modelPath) ? path.resolve(workspaceRoot, args.modelPath.trim()) : existingProjectArtifactPath(projectDir, "application-model.json");
|
|
3187
3401
|
if (!isInsideDir(projectDir, modelPath) || !fs.existsSync(modelPath)) return errorResult("Application model not found inside projectDir; run tapp_init first", { modelPath });
|
|
3188
3402
|
let model;
|
|
3189
3403
|
try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
|
|
@@ -3191,8 +3405,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3191
3405
|
const { createProductBaseline, installProductCi, prepareProductCi } = await import("./product-operations.js");
|
|
3192
3406
|
if (operation === "baseline") {
|
|
3193
3407
|
if (!isNonEmptyString(args.reportPath)) return errorResult("baseline requires reportPath from a successful portable gate");
|
|
3194
|
-
const reportPath = path.resolve(
|
|
3195
|
-
if (!isInsideDir(
|
|
3408
|
+
const reportPath = path.resolve(workspaceRoot, args.reportPath.trim());
|
|
3409
|
+
if (!isInsideDir(workspaceRoot, reportPath) || !fs.existsSync(reportPath)) return errorResult("reportPath must be an existing JSON file inside the workspace");
|
|
3196
3410
|
let report;
|
|
3197
3411
|
try { report = JSON.parse(fs.readFileSync(reportPath, "utf8")); }
|
|
3198
3412
|
catch (error) { return errorResult("Gate report is invalid JSON", { detail: error.message || String(error) }); }
|
|
@@ -3217,8 +3431,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3217
3431
|
if (unauthorized) return unauthorized;
|
|
3218
3432
|
const operation = String(args.operation || "read").toLowerCase();
|
|
3219
3433
|
const resolveRepoFile = (value, fallback = "") => {
|
|
3220
|
-
const resolved = path.resolve(
|
|
3221
|
-
return isInsideDir(
|
|
3434
|
+
const resolved = path.resolve(workspaceRoot, isNonEmptyString(value) ? value.trim() : fallback);
|
|
3435
|
+
return isInsideDir(workspaceRoot, resolved) ? resolved : null;
|
|
3222
3436
|
};
|
|
3223
3437
|
const capture = isNonEmptyString(args.captureId) ? listCaptureRuns(200).find((run) => run.id === args.captureId.trim()) : null;
|
|
3224
3438
|
if (isNonEmptyString(args.captureId) && !capture) return errorResult("Capture not found", { captureId: args.captureId });
|
|
@@ -3238,18 +3452,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3238
3452
|
const markersPath = capture ? path.join(capture.path, "ocqa-markers.txt") : resolveRepoFile(args.markersPath);
|
|
3239
3453
|
if (!markersPath) return errorResult("markersPath must be inside the repo, or provide captureId");
|
|
3240
3454
|
if (!fs.existsSync(markersPath)) return errorResult("OCQA markers not found", { markersPath });
|
|
3241
|
-
const outPath = isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(
|
|
3455
|
+
const outPath = isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(workspaceRoot, "ui-map.json");
|
|
3242
3456
|
if (!outPath) return errorResult("mapPath must be inside the repo");
|
|
3243
3457
|
try {
|
|
3244
3458
|
const observed = buildUiMapFromMarkers({ markersPath, platform: args.platform || "ios", target: args.target || "", runId: capture?.id || "" });
|
|
3245
3459
|
const map = fs.existsSync(outPath) && args.replace !== true ? mergeUiMaps(JSON.parse(fs.readFileSync(outPath, "utf8")), observed) : observed;
|
|
3246
3460
|
writeUiMap(outPath, map);
|
|
3247
3461
|
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(
|
|
3462
|
+
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
3463
|
} catch (error) { return errorResult("Could not build UI Map", { detail: error.message || String(error) }); }
|
|
3250
3464
|
}
|
|
3251
3465
|
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(
|
|
3466
|
+
const mapPath = capture ? path.join(capture.path, "ui-map.json") : isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(workspaceRoot, "ui-map.json");
|
|
3253
3467
|
if (!mapPath) return errorResult("mapPath must be inside the repo");
|
|
3254
3468
|
if (!fs.existsSync(mapPath)) return errorResult("UI Map not found; run QA or operation=build first", { mapPath });
|
|
3255
3469
|
try {
|
|
@@ -3266,8 +3480,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3266
3480
|
if (unauthorized) return unauthorized;
|
|
3267
3481
|
const operation = String(args.operation || "validate").toLowerCase();
|
|
3268
3482
|
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(
|
|
3483
|
+
const taskPath = isNonEmptyString(args.taskPath) ? path.resolve(workspaceRoot, args.taskPath.trim()) : null;
|
|
3484
|
+
if (!taskPath || !isInsideDir(workspaceRoot, taskPath)) return errorResult("taskPath must be inside the workspace");
|
|
3271
3485
|
if (!fs.existsSync(taskPath)) return errorResult("Task file not found", { taskPath: args.taskPath });
|
|
3272
3486
|
const { applyTaskCoverage, compileTaskSteps, loadTaskFile, loadTaskRegistry, validateTaskAgainstUiMap } = await import("./task-runtime.js");
|
|
3273
3487
|
let task;
|
|
@@ -3278,8 +3492,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3278
3492
|
let groundingMap = null;
|
|
3279
3493
|
let groundingMapPath = null;
|
|
3280
3494
|
if (isNonEmptyString(args.mapPath)) {
|
|
3281
|
-
const mapPath = path.resolve(
|
|
3282
|
-
if (!isInsideDir(
|
|
3495
|
+
const mapPath = path.resolve(workspaceRoot, args.mapPath.trim());
|
|
3496
|
+
if (!isInsideDir(workspaceRoot, mapPath)) return errorResult("mapPath must be inside the workspace");
|
|
3283
3497
|
if (!fs.existsSync(mapPath)) return errorResult("UI Map not found", { mapPath: args.mapPath });
|
|
3284
3498
|
groundingMapPath = mapPath;
|
|
3285
3499
|
try {
|
|
@@ -3309,12 +3523,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3309
3523
|
const flow = { name: `Task: ${task.name}`, kind: "flow", platform: args.platform || "", vars: compiled.vars, steps: compiled.steps, taskPlan: compiled.plan };
|
|
3310
3524
|
let outPath = null;
|
|
3311
3525
|
if (isNonEmptyString(args.outPath)) {
|
|
3312
|
-
outPath = path.resolve(
|
|
3313
|
-
if (!isInsideDir(
|
|
3526
|
+
outPath = path.resolve(workspaceRoot, args.outPath.trim());
|
|
3527
|
+
if (!isInsideDir(workspaceRoot, outPath)) return errorResult("outPath must be inside the workspace");
|
|
3314
3528
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
3315
3529
|
fs.writeFileSync(outPath, JSON.stringify(flow, null, 2) + "\n");
|
|
3316
3530
|
}
|
|
3317
|
-
return richResult(`š§© Compiled ${task.name} into ${flow.steps.length} deterministic Flow steps${outPath ? `\n${path.relative(
|
|
3531
|
+
return richResult(`š§© Compiled ${task.name} into ${flow.steps.length} deterministic Flow steps${outPath ? `\n${path.relative(workspaceRoot, outPath)}` : ""}`, { flow, grounding, path: outPath });
|
|
3318
3532
|
}
|
|
3319
3533
|
|
|
3320
3534
|
if (name === "tapp_release_contract") {
|
|
@@ -3322,8 +3536,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3322
3536
|
if (unauthorized) return unauthorized;
|
|
3323
3537
|
const operation = String(args.operation || "validate").toLowerCase();
|
|
3324
3538
|
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(
|
|
3539
|
+
const contractPath = isNonEmptyString(args.contractPath) ? path.resolve(workspaceRoot, args.contractPath.trim()) : null;
|
|
3540
|
+
if (!contractPath || !isInsideDir(workspaceRoot, contractPath)) return errorResult("contractPath must be inside the workspace");
|
|
3327
3541
|
const {
|
|
3328
3542
|
applyReleaseContractCoverage,
|
|
3329
3543
|
compileReleaseContract,
|
|
@@ -3338,8 +3552,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3338
3552
|
let groundingMap = null;
|
|
3339
3553
|
let groundingMapPath = null;
|
|
3340
3554
|
if (isNonEmptyString(args.mapPath)) {
|
|
3341
|
-
groundingMapPath = path.resolve(
|
|
3342
|
-
if (!isInsideDir(
|
|
3555
|
+
groundingMapPath = path.resolve(workspaceRoot, args.mapPath.trim());
|
|
3556
|
+
if (!isInsideDir(workspaceRoot, groundingMapPath)) return errorResult("mapPath must be inside the workspace");
|
|
3343
3557
|
if (!fs.existsSync(groundingMapPath)) return errorResult("UI Map not found", { mapPath: args.mapPath });
|
|
3344
3558
|
try {
|
|
3345
3559
|
groundingMap = JSON.parse(fs.readFileSync(groundingMapPath, "utf8"));
|
|
@@ -3360,8 +3574,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3360
3574
|
catch (error) { return errorResult("Could not compile Release Contract", { detail: error.message || String(error) }); }
|
|
3361
3575
|
let outPath = null;
|
|
3362
3576
|
if (isNonEmptyString(args.outPath)) {
|
|
3363
|
-
outPath = path.resolve(
|
|
3364
|
-
if (!isInsideDir(
|
|
3577
|
+
outPath = path.resolve(workspaceRoot, args.outPath.trim());
|
|
3578
|
+
if (!isInsideDir(workspaceRoot, outPath)) return errorResult("outPath must be inside the workspace");
|
|
3365
3579
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
3366
3580
|
fs.writeFileSync(outPath, JSON.stringify(execution, null, 2) + "\n");
|
|
3367
3581
|
}
|
|
@@ -3408,12 +3622,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3408
3622
|
if (unauthorized) return unauthorized;
|
|
3409
3623
|
const operation = isNonEmptyString(args.operation) ? args.operation.trim().toLowerCase() : "plan";
|
|
3410
3624
|
if (!['plan', 'adopt'].includes(operation)) return errorResult("operation must be plan|adopt");
|
|
3411
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3412
|
-
if (!isInsideDir(
|
|
3625
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3626
|
+
if (!isInsideDir(workspaceRoot, projectDir)) return errorResult("projectDir must be inside the workspace");
|
|
3413
3627
|
if (operation === "adopt") {
|
|
3414
3628
|
if (!isNonEmptyString(args.prPlanPath) || !isNonEmptyString(args.item)) return errorResult("adopt requires prPlanPath and item");
|
|
3415
3629
|
const prPlanPath = path.resolve(projectDir, args.prPlanPath.trim());
|
|
3416
|
-
if (!isInsideDir(
|
|
3630
|
+
if (!isInsideDir(workspaceRoot, prPlanPath)) return errorResult("prPlanPath must be inside the workspace");
|
|
3417
3631
|
const { adoptPrCoverageProposal } = await import("./pr-selection.js");
|
|
3418
3632
|
try {
|
|
3419
3633
|
const adopted = adoptPrCoverageProposal({
|
|
@@ -3457,8 +3671,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3457
3671
|
fs.writeFileSync(flowFile, JSON.stringify(args.flow));
|
|
3458
3672
|
parsedFlow = args.flow;
|
|
3459
3673
|
} else if (isNonEmptyString(args.flowPath)) {
|
|
3460
|
-
const p = path.resolve(
|
|
3461
|
-
if (!isInsideDir(
|
|
3674
|
+
const p = path.resolve(workspaceRoot, args.flowPath.trim());
|
|
3675
|
+
if (!isInsideDir(workspaceRoot, p)) return errorResult("flowPath must be inside the workspace");
|
|
3462
3676
|
if (!fs.existsSync(p)) return errorResult("Flow file not found", { flowPath: args.flowPath });
|
|
3463
3677
|
flowFile = p;
|
|
3464
3678
|
} else {
|
|
@@ -3540,8 +3754,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3540
3754
|
if (args.scenario && typeof args.scenario === "object") {
|
|
3541
3755
|
scenario = args.scenario;
|
|
3542
3756
|
} else if (isNonEmptyString(args.scenarioPath)) {
|
|
3543
|
-
const scenarioFile = path.resolve(
|
|
3544
|
-
if (!isInsideDir(
|
|
3757
|
+
const scenarioFile = path.resolve(workspaceRoot, args.scenarioPath.trim());
|
|
3758
|
+
if (!isInsideDir(workspaceRoot, scenarioFile)) return errorResult("scenarioPath must be inside the workspace");
|
|
3545
3759
|
if (!fs.existsSync(scenarioFile)) return errorResult("Scenario file not found", { scenarioPath: args.scenarioPath });
|
|
3546
3760
|
try {
|
|
3547
3761
|
const { loadScenarioFile } = await import("./scenario-runtime.js");
|
|
@@ -3611,7 +3825,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3611
3825
|
const ungrounded = ungroundedScreens(parsed.steps, grounding);
|
|
3612
3826
|
const flow = { name: args.name || parsed.name, app: bundleId, steps: parsed.steps };
|
|
3613
3827
|
const slug = flow.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "generated-flow";
|
|
3614
|
-
const dir = path.join(
|
|
3828
|
+
const dir = path.join(workspaceRoot, ".tapp", "proposals", "flows");
|
|
3615
3829
|
fs.mkdirSync(dir, { recursive: true });
|
|
3616
3830
|
const outPath = path.join(dir, `${slug}.yml`);
|
|
3617
3831
|
const promotedPath = path.join(".tapp", "flows", `${slug}.yml`);
|
|
@@ -3619,7 +3833,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3619
3833
|
const yaml = (yamlRes.stdout || "").trim();
|
|
3620
3834
|
if (!yaml) return errorResult("Failed to render flow YAML", { stderr: yamlRes.stderr });
|
|
3621
3835
|
fs.writeFileSync(outPath, yaml + "\n");
|
|
3622
|
-
const rel = path.relative(
|
|
3836
|
+
const rel = path.relative(workspaceRoot, outPath);
|
|
3623
3837
|
|
|
3624
3838
|
const L = [`š¤ Generated a flow **proposal** **${flow.name}** from your goal ā \`${rel}\``];
|
|
3625
3839
|
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."}`);
|
|
@@ -3867,14 +4081,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3867
4081
|
const detailNote = !ok && r.detail ? ` ā ${r.detail}` : "";
|
|
3868
4082
|
const head = `${did} ā ${ok ? "ok" : `ā ļø ${r.status}${detailNote}`} ā now on **${r.screenTitle || "Unknown"}**`;
|
|
3869
4083
|
const rec = typeof r.recordedSteps === "number" ? `\n\nš“ Recording ā ${r.recordedSteps} step(s). \`tapp_flow_save\` to keep it as a test.` : "";
|
|
3870
|
-
|
|
4084
|
+
const result = richResult(head + "\n\n" + formatScreen(r.screenTitle, r.elements) + rec, r);
|
|
4085
|
+
if (!ok) result.isError = true;
|
|
4086
|
+
return result;
|
|
3871
4087
|
}
|
|
3872
4088
|
|
|
3873
4089
|
if (name === "tapp_flow_save") {
|
|
3874
4090
|
const unauthorized = ensureAuthorized(args);
|
|
3875
4091
|
if (unauthorized) return unauthorized;
|
|
3876
4092
|
try {
|
|
3877
|
-
const saved = await saveInteractiveSessionFlow({ projectDir:
|
|
4093
|
+
const saved = await saveInteractiveSessionFlow({ projectDir:workspaceRoot, name:args.name, addFinalAssertion:args.addFinalAssertion !== false, replace:args.replace === true });
|
|
3878
4094
|
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
4095
|
return richResult(text, { path:saved.path, flow:saved.flow });
|
|
3880
4096
|
} catch (error) {
|