@aarwitz/tapp 0.16.5 → 0.17.0-rc.10
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/AGENTS.md +35 -21
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +100 -31
- package/README.md +70 -67
- package/bin/tapp.js +265 -74
- package/browser/app.js +12 -6
- package/docs/BROWSER-PRODUCT.md +75 -0
- package/docs/PRODUCT-ENGINE.md +107 -0
- package/docs/application-model.md +278 -0
- package/docs/scenarios.md +95 -0
- package/mcp-server/src/android-driver.js +88 -3
- package/mcp-server/src/android-explorer.js +83 -14
- package/mcp-server/src/application-model.js +13 -9
- package/mcp-server/src/browser-product.js +1 -1
- package/mcp-server/src/ci-report.js +84 -62
- package/mcp-server/src/ci-setup.js +35 -5
- package/mcp-server/src/enrich.js +1 -1
- package/mcp-server/src/html-report.js +41 -7
- package/mcp-server/src/index.js +198 -72
- package/mcp-server/src/pr-selection.js +4 -3
- package/mcp-server/src/product-execution.js +1 -1
- package/mcp-server/src/product-operations.js +96 -7
- package/mcp-server/src/project-config.js +1 -2
- package/mcp-server/src/project-paths.js +5 -17
- package/mcp-server/src/release-contract.js +3 -3
- package/mcp-server/src/report.js +185 -51
- package/mcp-server/src/task-runtime.js +1 -1
- package/mcp-server/src/web-explorer.js +1 -1
- package/package.json +2 -2
- package/scripts/ci-gate.sh +11 -9
- package/scripts/flow_ai_judge.py +1 -1
- package/scripts/platform-gate.js +11 -5
- package/scripts/quick-capture.sh +72 -38
- package/scripts/run-flow.sh +1 -1
package/mcp-server/src/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
ListToolsRequestSchema,
|
|
13
13
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
14
14
|
|
|
15
|
-
import { parseOcqaMarkers, buildQaReport, computeRegression,
|
|
15
|
+
import { parseOcqaMarkers, buildQaReport, computeRegression, observationBadge, observationSummary } from "./report.js";
|
|
16
16
|
import { existingProjectArtifactPath, projectArtifactDirectory } from "./project-paths.js";
|
|
17
17
|
|
|
18
18
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -21,10 +21,10 @@ const repoRoot = path.resolve(__dirname, "../..");
|
|
|
21
21
|
const scriptsDir = path.join(repoRoot, "scripts");
|
|
22
22
|
// TAPP_HOME (set by the `tapp` CLI when installed) redirects writable output to a user directory.
|
|
23
23
|
// The old alias remains a read-only fallback; unset repository development stays local.
|
|
24
|
-
const tappHome = (process.env.TAPP_HOME ||
|
|
24
|
+
const tappHome = (process.env.TAPP_HOME || "").trim();
|
|
25
25
|
const capturesDir = tappHome ? path.join(tappHome, "captures") : path.join(repoRoot, "captures");
|
|
26
26
|
const MAX_OUTPUT_CHARS = 60_000;
|
|
27
|
-
const requiredAuthToken = (process.env.TAPP_MCP_TOKEN ||
|
|
27
|
+
const requiredAuthToken = (process.env.TAPP_MCP_TOKEN || "").trim();
|
|
28
28
|
|
|
29
29
|
function clampOutput(value, maxChars = MAX_OUTPUT_CHARS) {
|
|
30
30
|
if (typeof value !== "string") {
|
|
@@ -35,8 +35,13 @@ function clampOutput(value, maxChars = MAX_OUTPUT_CHARS) {
|
|
|
35
35
|
return value;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
// Compiler/build diagnostics are normally emitted at the end. Preserve both ends so a long
|
|
39
|
+
// dependency build cannot truncate away the one actionable error the user needs.
|
|
40
|
+
const marker = "\n...[output truncated]...\n";
|
|
41
|
+
const retained = Math.max(0, maxChars - marker.length);
|
|
42
|
+
const head = Math.ceil(retained / 2);
|
|
43
|
+
const tail = Math.floor(retained / 2);
|
|
44
|
+
return `${value.slice(0, head)}${marker}${value.slice(value.length - tail)}`;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
function asBoolean(value, fallback = false) {
|
|
@@ -984,7 +989,10 @@ async function runExploreStreaming(bundleId, actions, timeout, env, onProgress)
|
|
|
984
989
|
const before = new Set(listCaptureRuns(80).map((r) => r.id));
|
|
985
990
|
const proc = spawn("bash", cmdArgs, { cwd: repoRoot, env: { ...process.env, ...env } });
|
|
986
991
|
proc.stdout.on("data", () => {});
|
|
987
|
-
|
|
992
|
+
let captureStderr = "";
|
|
993
|
+
proc.stderr.on("data", (chunk) => {
|
|
994
|
+
captureStderr = (captureStderr + chunk.toString("utf8")).slice(-8_000);
|
|
995
|
+
});
|
|
988
996
|
const closed = new Promise((res) => proc.on("close", (code) => res(code ?? 1)));
|
|
989
997
|
|
|
990
998
|
let captureDir = null;
|
|
@@ -1037,7 +1045,18 @@ async function runExploreStreaming(bundleId, actions, timeout, env, onProgress)
|
|
|
1037
1045
|
await closed;
|
|
1038
1046
|
const created = listCaptureRuns(80).find((r) => !before.has(r.id))
|
|
1039
1047
|
|| (captureDir ? { id: path.basename(captureDir), path: captureDir, relativePath: path.relative(repoRoot, captureDir) } : null);
|
|
1040
|
-
return { created, timedOut };
|
|
1048
|
+
return { created, timedOut, captureStderr };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
export function recordingUnavailableReason(stderr = "") {
|
|
1052
|
+
const text = String(stderr);
|
|
1053
|
+
if (/resource busy|host recording is already in progress/i.test(text)) {
|
|
1054
|
+
return "the simulator recorder is busy with another host recording";
|
|
1055
|
+
}
|
|
1056
|
+
if (/could not start simulator video recording/i.test(text)) {
|
|
1057
|
+
return "the simulator could not start video recording";
|
|
1058
|
+
}
|
|
1059
|
+
return "the simulator did not produce a recording";
|
|
1041
1060
|
}
|
|
1042
1061
|
|
|
1043
1062
|
// Grab the booted simulator's current screen and return it downscaled + JPEG-compressed so the
|
|
@@ -1066,7 +1085,7 @@ export async function captureScreenshotImage(maxWidth) {
|
|
|
1066
1085
|
// change data-handling behavior. A subscription token is an explicit tapp choice, and
|
|
1067
1086
|
// explicitly-invoked AI tools (tapp_flow_generate, assert_ai) carry their own consent.
|
|
1068
1087
|
export function remoteAiOptedIn(env = process.env) {
|
|
1069
|
-
if ((env.TAPP_SUBSCRIPTION_TOKEN ||
|
|
1088
|
+
if ((env.TAPP_SUBSCRIPTION_TOKEN || "").trim()) return true;
|
|
1070
1089
|
return ["1", "true", "yes"].includes(String(env.TAPP_ENABLE_REMOTE_AI || "").trim().toLowerCase());
|
|
1071
1090
|
}
|
|
1072
1091
|
|
|
@@ -1078,9 +1097,9 @@ export function isInsideDir(root, p) {
|
|
|
1078
1097
|
}
|
|
1079
1098
|
|
|
1080
1099
|
function resolveModelBackend() {
|
|
1081
|
-
const token = (process.env.TAPP_SUBSCRIPTION_TOKEN ||
|
|
1100
|
+
const token = (process.env.TAPP_SUBSCRIPTION_TOKEN || "").trim();
|
|
1082
1101
|
if (token) {
|
|
1083
|
-
const base = (process.env.TAPP_PROXY_URL ||
|
|
1102
|
+
const base = (process.env.TAPP_PROXY_URL || "http://localhost:8787").replace(/\/$/, "");
|
|
1084
1103
|
const url = base.endsWith("/v1/messages") ? base : base + "/v1/messages";
|
|
1085
1104
|
return { url, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" } };
|
|
1086
1105
|
}
|
|
@@ -1092,7 +1111,7 @@ function resolveModelBackend() {
|
|
|
1092
1111
|
}
|
|
1093
1112
|
|
|
1094
1113
|
async function callModel(backend, { system, userText, model, maxTokens = 1500 }) {
|
|
1095
|
-
const body = JSON.stringify({ model: model || process.env.TAPP_FLOW_MODEL ||
|
|
1114
|
+
const body = JSON.stringify({ model: model || process.env.TAPP_FLOW_MODEL || "claude-sonnet-4-6", max_tokens: maxTokens, system, messages: [{ role: "user", content: userText }] });
|
|
1096
1115
|
const res = await fetch(backend.url, { method: "POST", headers: backend.headers, body });
|
|
1097
1116
|
if (!res.ok) return { error: `model HTTP ${res.status}: ${(await res.text()).slice(0, 300)}` };
|
|
1098
1117
|
const data = await res.json();
|
|
@@ -1307,7 +1326,7 @@ export function qaNextSteps(report, surface = "mcp") {
|
|
|
1307
1326
|
if (surface === "cli") {
|
|
1308
1327
|
const next = [];
|
|
1309
1328
|
if (report?.findings?.length) next.push("inspect the evidence with `tapp report latest`");
|
|
1310
|
-
next.push("re-run with `--baseline <report.json>` to
|
|
1329
|
+
next.push("re-run with `--baseline <report.json>` to compare a fix (then `tapp ci` to gate it)");
|
|
1311
1330
|
next.push("replay a committed journey with `tapp flow run <file>`");
|
|
1312
1331
|
return next;
|
|
1313
1332
|
}
|
|
@@ -1318,25 +1337,27 @@ export function qaNextSteps(report, surface = "mcp") {
|
|
|
1318
1337
|
return next;
|
|
1319
1338
|
}
|
|
1320
1339
|
|
|
1321
|
-
function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured, reportHtml, recording, uiMap, surface = "mcp" } = {}) {
|
|
1340
|
+
function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured, reportHtml, recording, recordingWarning, uiMap, surface = "mcp" } = {}) {
|
|
1322
1341
|
const c = report.findingCounts || {};
|
|
1323
|
-
const badge =
|
|
1342
|
+
const badge = observationBadge(report);
|
|
1324
1343
|
const sevBits = ["critical", "high", "medium", "low"]
|
|
1325
1344
|
.map((k) => (c[k] ? `${SEV[k]} ${c[k]} ${k}` : null))
|
|
1326
1345
|
.filter(Boolean)
|
|
1327
1346
|
.join(", ");
|
|
1328
1347
|
const L = [];
|
|
1329
|
-
|
|
1348
|
+
// Exploration observes; it does not render a ship verdict. The release decision lives in the gate.
|
|
1349
|
+
L.push(`### 🔭 Exploration complete — ${badge} · ${observationSummary(report)}${bundleId ? `\n\`${bundleId}\`` : ""}`);
|
|
1330
1350
|
L.push("");
|
|
1331
1351
|
L.push(report.headline);
|
|
1332
1352
|
L.push("");
|
|
1333
1353
|
L.push(`**Coverage** — ${report.screensExplored} screens · ${report.actionsPerformed} actions${timedOut ? " · ⏱️ hit time limit" : ""}`);
|
|
1334
1354
|
if (report.platform === "web") {
|
|
1335
|
-
L.push(`**
|
|
1355
|
+
L.push(`**Deterministic basis** — ${report.deterministicFindingCounts?.total || 0} deterministic finding(s); ${report.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory`);
|
|
1336
1356
|
}
|
|
1337
1357
|
if (uiMap) L.push(`**UI Map** — ${uiMap.nodeCount} states · ${uiMap.edgeCount} transitions · ${uiMap.controlCount} semantic controls · ${uiMap.path}`);
|
|
1338
1358
|
if (reportHtml) L.push(`**Evidence** — 📄 ${reportHtml} (screenshots of every screen + findings, shareable)`);
|
|
1339
1359
|
if (recording) L.push(`**Recording** — 🎬 ${recording} (full exploration, embedded in the evidence page)`);
|
|
1360
|
+
else if (recordingWarning) L.push(`**Recording** — ⚠️ unavailable: ${recordingWarning}; screenshots were still captured`);
|
|
1340
1361
|
L.push(`**Issues** — ${c.total ? `${c.total}${sevBits ? ` (${sevBits})` : ""}` : "none found ✨"}`);
|
|
1341
1362
|
if (Array.isArray(report.findings) && report.findings.length) {
|
|
1342
1363
|
L.push("");
|
|
@@ -1355,17 +1376,18 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
|
|
|
1355
1376
|
}
|
|
1356
1377
|
}
|
|
1357
1378
|
if (regression && regression.counts) {
|
|
1358
|
-
|
|
1379
|
+
// Exploration reports the comparison (new/persisting/resolved) only — never a gate pass/fail.
|
|
1380
|
+
// The merge decision is the gate's job (tapp ci), not exploration's (ADR-0005).
|
|
1359
1381
|
L.push("");
|
|
1360
1382
|
L.push(
|
|
1361
|
-
`**Since last run** — +${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved
|
|
1383
|
+
`**Since last run** — +${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved (comparison only — run \`tapp ci\` to gate)`
|
|
1362
1384
|
);
|
|
1363
1385
|
}
|
|
1364
1386
|
if (inputHint) {
|
|
1365
1387
|
L.push("");
|
|
1366
1388
|
L.push(`> ℹ️ ${inputHint}`);
|
|
1367
1389
|
}
|
|
1368
|
-
// The
|
|
1390
|
+
// The observation's scope: enumerate exactly what ran and what remains unchecked.
|
|
1369
1391
|
if (Array.isArray(report.checkedFor) && report.checkedFor.length) {
|
|
1370
1392
|
L.push("");
|
|
1371
1393
|
L.push(`> ✅ Checked: ${report.checkedFor.join(" · ")}`);
|
|
@@ -1379,11 +1401,10 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
|
|
|
1379
1401
|
const next = qaNextSteps(report, surface);
|
|
1380
1402
|
L.push("");
|
|
1381
1403
|
L.push(`**Next** — ${next.join(" · ")}`);
|
|
1382
|
-
// The gate hook belongs at the moment the user thinks "I want
|
|
1383
|
-
// i.e. right after a verdict that found something, or after they hand-diffed a baseline.
|
|
1404
|
+
// The gate hook belongs at the moment the user thinks "I want these checks on every PR".
|
|
1384
1405
|
if ((report.findings && report.findings.length) || regression) {
|
|
1385
1406
|
L.push("");
|
|
1386
|
-
L.push("> 🚦 Teams:
|
|
1407
|
+
L.push("> 🚦 Teams: run these checks plus reviewed release contracts as a merge gate on every PR — https://github.com/aarwitz/tapp#ci-gate");
|
|
1387
1408
|
}
|
|
1388
1409
|
return L.join("\n");
|
|
1389
1410
|
}
|
|
@@ -1464,7 +1485,7 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
|
|
|
1464
1485
|
} catch (err) {
|
|
1465
1486
|
return { error: String(err.message || err) };
|
|
1466
1487
|
}
|
|
1467
|
-
const report = buildQaReport(webResult.markersPath, { platform: "web" });
|
|
1488
|
+
const report = buildQaReport(webResult.markersPath, { platform: "web", target: url.trim() });
|
|
1468
1489
|
if (!report) return { error: "Web exploration produced no markers", details: { capture: { id, path: outDir } } };
|
|
1469
1490
|
const backend = remoteAiOptedIn() ? resolveModelBackend() : null;
|
|
1470
1491
|
if (backend && report.findings.length) {
|
|
@@ -1507,7 +1528,7 @@ export async function runQaAndroid({ appId, apkPath, serial, maxActions, timeout
|
|
|
1507
1528
|
} catch (error) {
|
|
1508
1529
|
return { error: error.message || String(error), details: { capture: { id, path: outDir } } };
|
|
1509
1530
|
}
|
|
1510
|
-
const report = buildQaReport(androidResult.markersPath, { platform: "android" });
|
|
1531
|
+
const report = buildQaReport(androidResult.markersPath, { platform: "android", target: appId.trim() });
|
|
1511
1532
|
if (!report) return { error: "Android exploration produced no markers", details: { capture: { id, path: outDir } } };
|
|
1512
1533
|
const backend = remoteAiOptedIn() ? resolveModelBackend() : null;
|
|
1513
1534
|
if (backend && report.findings.length) {
|
|
@@ -1539,10 +1560,10 @@ export async function runQaIos({ bundleId, maxActions, timeout, args = {}, surfa
|
|
|
1539
1560
|
const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
|
|
1540
1561
|
const env = explorationEnvFromArgs(args);
|
|
1541
1562
|
|
|
1542
|
-
const { created, timedOut } = await runExploreStreaming(bundleId, actions, timeoutSec, env, onProgress);
|
|
1563
|
+
const { created, timedOut, captureStderr } = await runExploreStreaming(bundleId, actions, timeoutSec, env, onProgress);
|
|
1543
1564
|
if (!created) return { error: "Exploration produced no capture run", details: { timedOut } };
|
|
1544
1565
|
|
|
1545
|
-
const report = buildQaReport(path.join(created.path, "ocqa-markers.txt"));
|
|
1566
|
+
const report = buildQaReport(path.join(created.path, "ocqa-markers.txt"), { platform: "ios", target: bundleId });
|
|
1546
1567
|
if (!report) {
|
|
1547
1568
|
return {
|
|
1548
1569
|
error: "No markers parsed from exploration (the app may not have launched)",
|
|
@@ -1572,13 +1593,14 @@ export async function runQaIos({ bundleId, maxActions, timeout, args = {}, surfa
|
|
|
1572
1593
|
// Cross-run regression vs. a caller-supplied baseline (the CI gate).
|
|
1573
1594
|
const regression = computeRegression(report.findings, args.baselineFindings);
|
|
1574
1595
|
const uiMap = await writeRunUiMap({ markersPath: path.join(created.path, "ocqa-markers.txt"), platform: "ios", target: bundleId, runId: created.id, outDir: created.path });
|
|
1596
|
+
const recording =
|
|
1597
|
+
["exploration.webm", "exploration.mov"].map((f) => path.join(created.path, f)).find((p) => fs.existsSync(p)) || null;
|
|
1598
|
+
const recordingWarning = recording ? null : recordingUnavailableReason(captureStderr);
|
|
1575
1599
|
let reportHtml = null;
|
|
1576
1600
|
try {
|
|
1577
1601
|
const { writeHtmlReport } = await import("./html-report.js");
|
|
1578
|
-
reportHtml = writeHtmlReport(created.path, { report, label: bundleId });
|
|
1602
|
+
reportHtml = writeHtmlReport(created.path, { report, label: bundleId, recordingWarning });
|
|
1579
1603
|
} catch { /* evidence page is best-effort */ }
|
|
1580
|
-
const recording =
|
|
1581
|
-
["exploration.webm", "exploration.mov"].map((f) => path.join(created.path, f)).find((p) => fs.existsSync(p)) || null;
|
|
1582
1604
|
const structured = {
|
|
1583
1605
|
...report,
|
|
1584
1606
|
regression,
|
|
@@ -1586,11 +1608,12 @@ export async function runQaIos({ bundleId, maxActions, timeout, args = {}, surfa
|
|
|
1586
1608
|
inputHint,
|
|
1587
1609
|
reportHtml,
|
|
1588
1610
|
recording,
|
|
1611
|
+
recordingWarning,
|
|
1589
1612
|
capture: { id: created.id, path: created.path, relativePath: created.relativePath },
|
|
1590
1613
|
timedOut,
|
|
1591
1614
|
autoBooted: sim.autoBooted || false,
|
|
1592
1615
|
};
|
|
1593
|
-
const text = formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured: !!backend, reportHtml, recording, uiMap: uiMap.error ? null : uiMap, surface });
|
|
1616
|
+
const text = formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured: !!backend, reportHtml, recording, recordingWarning, uiMap: uiMap.error ? null : uiMap, surface });
|
|
1594
1617
|
return { structured, text };
|
|
1595
1618
|
}
|
|
1596
1619
|
|
|
@@ -1690,8 +1713,8 @@ export async function runInitExploration({
|
|
|
1690
1713
|
merged.provenance.lastRun = {
|
|
1691
1714
|
id: qa.structured.capture?.id || observed.provenance?.runIds?.at(-1) || "",
|
|
1692
1715
|
platform: selected,
|
|
1693
|
-
verdict: qa.structured.verdict,
|
|
1694
1716
|
inconclusive: qa.structured.inconclusive === true,
|
|
1717
|
+
findingCount: qa.structured.findingCounts?.total ?? 0,
|
|
1695
1718
|
statesExplored: Number(qa.structured.screensExplored || merged.nodes.length),
|
|
1696
1719
|
actionsPerformed: Number(qa.structured.actionsPerformed || 0),
|
|
1697
1720
|
observedAt: observed.provenance?.lastObservedAt || new Date().toISOString(),
|
|
@@ -1707,8 +1730,8 @@ export async function runInitExploration({
|
|
|
1707
1730
|
resolution: targetResolution,
|
|
1708
1731
|
evidence: {
|
|
1709
1732
|
captureId: qa.structured.capture?.id || "",
|
|
1710
|
-
verdict: qa.structured.verdict,
|
|
1711
1733
|
inconclusive: qa.structured.inconclusive === true,
|
|
1734
|
+
findingCount: qa.structured.findingCounts?.total ?? 0,
|
|
1712
1735
|
observedAt: observed.provenance?.lastObservedAt || new Date().toISOString(),
|
|
1713
1736
|
},
|
|
1714
1737
|
},
|
|
@@ -1721,7 +1744,6 @@ export async function runInitExploration({
|
|
|
1721
1744
|
controlCount: merged.nodes.reduce((total, node) => total + node.controls.length, 0),
|
|
1722
1745
|
},
|
|
1723
1746
|
mapDiff: previous ? diffUiMaps(previous, observed, { comparableFullSweep: false }) : null,
|
|
1724
|
-
verdict: qa.structured.verdict,
|
|
1725
1747
|
inconclusive: qa.structured.inconclusive === true,
|
|
1726
1748
|
findings: qa.structured.findings || [],
|
|
1727
1749
|
capture: qa.structured.capture,
|
|
@@ -1735,6 +1757,97 @@ export async function runInitExploration({
|
|
|
1735
1757
|
}
|
|
1736
1758
|
}
|
|
1737
1759
|
|
|
1760
|
+
/**
|
|
1761
|
+
* Source-preparing bare explore (ADR-0005 §5): a `tapp explore` with no explicit target in a
|
|
1762
|
+
* repository with an application model. Selects the model's default target and prepares it from
|
|
1763
|
+
* source before exploring — managed web is built/started/waited-for and always stopped again,
|
|
1764
|
+
* iOS is built + installed on the simulator, Android is built to an APK + installed — then runs
|
|
1765
|
+
* the ordinary exploration engine. Returns the same `{ text, structured, error }` shape as the
|
|
1766
|
+
* direct runQa* entrypoints, so the CLI/MCP surfaces print it unchanged. This is an OBSERVATION:
|
|
1767
|
+
* no verdict, no UI-map write (that is `runInitExploration`'s job); the gate still judges.
|
|
1768
|
+
*/
|
|
1769
|
+
export async function runExploreTarget({
|
|
1770
|
+
projectDir,
|
|
1771
|
+
platform = "",
|
|
1772
|
+
target = "",
|
|
1773
|
+
maxActions,
|
|
1774
|
+
timeout,
|
|
1775
|
+
testEmail,
|
|
1776
|
+
testPassword,
|
|
1777
|
+
appLaunchArgs,
|
|
1778
|
+
appLaunchEnv,
|
|
1779
|
+
baselineFindings,
|
|
1780
|
+
surface = "cli",
|
|
1781
|
+
onProgress = () => {},
|
|
1782
|
+
onStatus = () => {},
|
|
1783
|
+
} = {}) {
|
|
1784
|
+
let root;
|
|
1785
|
+
try { root = fs.realpathSync(path.resolve(projectDir || process.cwd())); }
|
|
1786
|
+
catch { return { error: `Repository directory not found: ${projectDir || process.cwd()}` }; }
|
|
1787
|
+
|
|
1788
|
+
const modelPath = existingProjectArtifactPath(root, "application-model.json");
|
|
1789
|
+
if (!fs.existsSync(modelPath)) {
|
|
1790
|
+
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)." };
|
|
1791
|
+
}
|
|
1792
|
+
let model;
|
|
1793
|
+
try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
|
|
1794
|
+
catch (error) { return { error: `Application model is unreadable: ${error.message || String(error)}` }; }
|
|
1795
|
+
|
|
1796
|
+
const { selectApplicationTarget } = await import("./ci-setup.js");
|
|
1797
|
+
let selected;
|
|
1798
|
+
try { selected = selectApplicationTarget(model, { platform, target, useDefault: true }); }
|
|
1799
|
+
catch (error) { return { error: error.message || String(error) }; }
|
|
1800
|
+
const selectedPlatform = selected.platform;
|
|
1801
|
+
|
|
1802
|
+
if (selectedPlatform !== "ios" && ((Array.isArray(appLaunchArgs) && appLaunchArgs.length) || (appLaunchEnv && Object.keys(appLaunchEnv).length))) {
|
|
1803
|
+
return { error: "appLaunchArgs/appLaunchEnv apply only to iOS targets." };
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
if (selectedPlatform === "web") {
|
|
1807
|
+
const ownedUrl = String(selected.runtime?.ownedUrl || "").trim();
|
|
1808
|
+
if (/^https?:\/\//i.test(ownedUrl)) {
|
|
1809
|
+
onStatus(`Exploring the owned URL from the application model: ${ownedUrl}`);
|
|
1810
|
+
return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
|
|
1811
|
+
}
|
|
1812
|
+
// Tapp-managed: build/start the repo's web target, wait for readiness, and ALWAYS stop it.
|
|
1813
|
+
onStatus(`Preparing the managed web runtime for ${selected.name}…`);
|
|
1814
|
+
const started = await startManagedWebTarget({ root, requestedTarget: selected.sourcePath || selected.name || "", timeout, onStatus });
|
|
1815
|
+
if (started.error) return started;
|
|
1816
|
+
try {
|
|
1817
|
+
return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
|
|
1818
|
+
} finally {
|
|
1819
|
+
await stopManagedWebTarget(started);
|
|
1820
|
+
onStatus("Stopped the managed web runtime.");
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
if (selectedPlatform === "android") {
|
|
1825
|
+
const appId = String(selected.runtime?.applicationId || "").trim();
|
|
1826
|
+
if (!appId) return { error: `The Android target '${selected.name}' has no confirmed application id — confirm it and rerun \`tapp init\`, or pass --app-id.` };
|
|
1827
|
+
const task = selected.build?.task || "assembleDebug";
|
|
1828
|
+
onStatus(`Building the Android APK (${task})…`);
|
|
1829
|
+
const built = await buildAndroidApp({
|
|
1830
|
+
projectDir: root,
|
|
1831
|
+
gradleProjectDir: path.resolve(root, selected.build?.projectDir || "."),
|
|
1832
|
+
moduleDir: path.resolve(root, selected.sourcePath || "."),
|
|
1833
|
+
task,
|
|
1834
|
+
});
|
|
1835
|
+
if (built.error) return built;
|
|
1836
|
+
onStatus(`Installing and exploring ${appId}…`);
|
|
1837
|
+
return runQaAndroid({ appId, apkPath: built.apkPath, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// iOS
|
|
1841
|
+
if (process.platform !== "darwin") return { error: "iOS exploration requires macOS with Xcode." };
|
|
1842
|
+
const scheme = selected.build?.scheme || selected.build?.proposedScheme || "";
|
|
1843
|
+
const configuration = selected.build?.configuration || "Debug";
|
|
1844
|
+
onStatus(`Building and installing the iOS app${scheme ? ` (scheme ${scheme})` : ""}…`);
|
|
1845
|
+
const resolved = await resolveAppTarget(root, { cwd: root, onStatus, scheme, configuration });
|
|
1846
|
+
if (resolved.error) return resolved;
|
|
1847
|
+
if (resolved.via) onStatus(`Target ${resolved.bundleId} — ${resolved.via}`);
|
|
1848
|
+
return runQaIos({ bundleId: resolved.bundleId, maxActions, timeout, args: { testEmail, testPassword, baselineFindings, appLaunchArgs, appLaunchEnv }, surface, onProgress });
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1738
1851
|
function openLocalPort() {
|
|
1739
1852
|
return new Promise((resolve, reject) => {
|
|
1740
1853
|
const server = net.createServer();
|
|
@@ -1946,7 +2059,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1946
2059
|
description:
|
|
1947
2060
|
"Build the user's iOS app for the simulator from an Xcode project/workspace (auto-detects the " +
|
|
1948
2061
|
"container and scheme under projectDir, default cwd), install it on the booted simulator, and " +
|
|
1949
|
-
"return the bundle id. Use before
|
|
2062
|
+
"return the bundle id. Use before tapp_explore / tapp_open_app when the app isn't installed yet — " +
|
|
1950
2063
|
"no bundle id needed up front.",
|
|
1951
2064
|
inputSchema: {
|
|
1952
2065
|
type: "object",
|
|
@@ -2068,22 +2181,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2068
2181
|
},
|
|
2069
2182
|
},
|
|
2070
2183
|
{
|
|
2071
|
-
name: "
|
|
2072
|
-
title: "
|
|
2184
|
+
name: "tapp_explore",
|
|
2185
|
+
title: "Explore (autonomous)",
|
|
2073
2186
|
description:
|
|
2074
|
-
"
|
|
2075
|
-
"(url — beta, requires Playwright installed) and return a structured " +
|
|
2076
|
-
"
|
|
2187
|
+
"Autonomously explore iOS (appBundleId), Android (androidAppId), OR a web app " +
|
|
2188
|
+
"(url — beta, requires Playwright installed) and return a structured EXPLORATION OBSERVATION. " +
|
|
2189
|
+
"Exploration OBSERVES — it surfaces findings + coverage + evidence; it does NOT render a ship " +
|
|
2190
|
+
"verdict or score. To gate a merge, run the deterministic gate (contracts + baseline via CI). " +
|
|
2191
|
+
"Use when the user wants to find bugs / observe what breaks — this " +
|
|
2077
2192
|
"runs for MINUTES exploring the whole app. Do NOT use it just to view, screenshot, or reach a specific " +
|
|
2078
2193
|
"screen — use tapp_open_app (launch + screenshot) or a session for that. Tapp explores the app " +
|
|
2079
2194
|
"like a tester (taps, types, navigates, scrolls) and detects real issues — crashes, dead buttons, failed sign-ins, error screens, " +
|
|
2080
2195
|
"stuck/hung screens; on web also uncaught JS exceptions, failed/5xx requests, broken links and assets. " +
|
|
2081
|
-
"Returns {
|
|
2082
|
-
"actionsPerformed, findings:[{type,severity,category,title,screen
|
|
2083
|
-
"
|
|
2084
|
-
"
|
|
2085
|
-
"
|
|
2086
|
-
"false pass. For iOS the app must already be installed on a booted simulator (use tapp_list_simulators / " +
|
|
2196
|
+
"Returns {kind:'tapp-exploration-run', headline, inconclusive, screensExplored, " +
|
|
2197
|
+
"actionsPerformed, findingCounts, findings:[{type,severity,category,authority,title,screen}]} — NO " +
|
|
2198
|
+
"verdict/releaseScore. Web separates deterministic findings from advisory sampled control probes. " +
|
|
2199
|
+
"There is a coverage floor: if the app barely explored (crash on launch / sign-in wall) it reports " +
|
|
2200
|
+
"`inconclusive` — absence of findings is NEVER a pass. For iOS the app must already be installed on a booted simulator (use tapp_list_simulators / " +
|
|
2087
2201
|
"tapp_boot_simulator first). For web, only point it at an app/environment you own — it CLICKS things. " +
|
|
2088
2202
|
"Tapp explores autonomously and does NOT pause to prompt for input — " +
|
|
2089
2203
|
"it fills forms with safe defaults. The result includes `inputFieldsEncountered` (and `inputHint`): if " +
|
|
@@ -2138,10 +2252,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2138
2252
|
type: "array",
|
|
2139
2253
|
items: { type: "object" },
|
|
2140
2254
|
description:
|
|
2141
|
-
"Findings from a previous run (pass back the `findings` array a prior
|
|
2142
|
-
"When provided, the result adds `regression` {counts:{new,persisting,resolved},
|
|
2143
|
-
"
|
|
2144
|
-
"
|
|
2255
|
+
"Findings from a previous run (pass back the `findings` array a prior tapp_explore returned). " +
|
|
2256
|
+
"When provided, the result adds a `regression` COMPARISON {counts:{new,persisting,resolved}, " +
|
|
2257
|
+
"newFindings, resolved} vs. that baseline — an observation, not a gate signal. To gate a merge, " +
|
|
2258
|
+
"run `tapp ci` (or the GitHub Action): it applies the deterministic policy and returns the " +
|
|
2259
|
+
"pass/fail/inconclusive outcome.",
|
|
2145
2260
|
},
|
|
2146
2261
|
},
|
|
2147
2262
|
},
|
|
@@ -2356,7 +2471,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2356
2471
|
description:
|
|
2357
2472
|
"Replay a deterministic, authored end-to-end test (a Flow) against iOS (XCUITest), Android " +
|
|
2358
2473
|
"(ADB/UIAutomator), or web (Playwright), and return a scannable pass/fail report. A Flow is a list of steps + assertions " +
|
|
2359
|
-
"(see docs/flows-architecture.md). Unlike
|
|
2474
|
+
"(see docs/flows-architecture.md). Unlike tapp_explore (autonomous exploration), a Flow does EXACTLY " +
|
|
2360
2475
|
"what you specify, the same way every time — use it for regression tests and verifying a fix. Steps: " +
|
|
2361
2476
|
"{tap: X} · {type: {field: F, value: V}} · {swipe: up} · {back} · {wait_for: SCREEN}. Assertions " +
|
|
2362
2477
|
"(deterministic): {assert_screen: X} · {assert_exists: X} · {assert_absent: X} · {assert_text: {of, contains}}. " +
|
|
@@ -2411,8 +2526,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2411
2526
|
"Write a deterministic E2E Flow from a natural-language goal (e.g. 'sign in and open Settings'), " +
|
|
2412
2527
|
"GROUNDED in the app's real screens so it can't invent steps. Tapp explores the app to build a " +
|
|
2413
2528
|
"screen/control map (or reuses a recent run via captureId), then a model authors a Flow using only " +
|
|
2414
|
-
"screens/controls that were actually observed. Saves it
|
|
2415
|
-
"
|
|
2529
|
+
"screens/controls that were actually observed. Saves it as an UNTRUSTED PROPOSAL under " +
|
|
2530
|
+
".tapp/proposals/flows/<name>.yml (never directly into .tapp/flows/) and returns the YAML for " +
|
|
2531
|
+
"review (optionally runs it). Review → replay against the real app → explicitly PROMOTE (move to " +
|
|
2532
|
+
".tapp/flows/) before CI depends on it. Needs a model backend (Tapp subscription token or " +
|
|
2416
2533
|
"ANTHROPIC_API_KEY). Use this to bootstrap a test you then refine; use tapp_flow_run to replay it.",
|
|
2417
2534
|
inputSchema: {
|
|
2418
2535
|
type: "object",
|
|
@@ -2487,7 +2604,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2487
2604
|
description:
|
|
2488
2605
|
"Launch an installed iOS or Android app and return a SCREENSHOT of the screen it lands on " +
|
|
2489
2606
|
"(plus the accessibility tree) — with NO exploration. This is the fast way (seconds) to just SEE a " +
|
|
2490
|
-
"screen. Use this — NOT
|
|
2607
|
+
"screen. Use this — NOT tapp_explore — whenever the user wants to view or screenshot a screen. Pass " +
|
|
2491
2608
|
"appLaunchArgs like [\"--uitesting\"] to bypass login and land on the home screen, and appLaunchEnv for " +
|
|
2492
2609
|
"a backend override. The app is launched fresh and closed afterward. (To screenshot a screen reached by " +
|
|
2493
2610
|
"real login or several taps, use a session instead and call tapp_screenshot along the way.)",
|
|
@@ -2529,7 +2646,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2529
2646
|
name: "tapp_install_app",
|
|
2530
2647
|
title: "Install app on sim",
|
|
2531
2648
|
description:
|
|
2532
|
-
"Build a target iOS app for the booted simulator and install it, so it's ready for
|
|
2649
|
+
"Build a target iOS app for the booted simulator and install it, so it's ready for tapp_explore or " +
|
|
2533
2650
|
"a session. Provide the Xcode project OR workspace path + scheme. Best-effort — apps with CocoaPods/" +
|
|
2534
2651
|
"signing quirks may still need their normal build. Returns {ok, installed, simulator}.",
|
|
2535
2652
|
inputSchema: {
|
|
@@ -2679,7 +2796,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2679
2796
|
const text =
|
|
2680
2797
|
`🔨 Built **${path.basename(built.appPath)}** (scheme \`${built.scheme}\`) in ${fmtDuration(Date.now() - startedAt)}` +
|
|
2681
2798
|
(bundleId ? ` — installed on the simulator as \`${bundleId}\`` : "") +
|
|
2682
|
-
`\n\nNext: \`
|
|
2799
|
+
`\n\nNext: \`tapp_explore\` with \`appBundleId: "${bundleId || "<install it first>"}"\`.`;
|
|
2683
2800
|
return richResult(text, { ok: true, appPath: built.appPath, scheme: built.scheme, container: built.container, bundleId });
|
|
2684
2801
|
}
|
|
2685
2802
|
|
|
@@ -2845,7 +2962,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2845
2962
|
return richResult(L.join("\n"), summary);
|
|
2846
2963
|
}
|
|
2847
2964
|
|
|
2848
|
-
if (name === "tapp_run_qa") {
|
|
2965
|
+
if (name === "tapp_explore" || name === "tapp_run_qa") { // tapp_run_qa: deprecated alias
|
|
2849
2966
|
const unauthorized = ensureAuthorized(args);
|
|
2850
2967
|
if (unauthorized) return unauthorized;
|
|
2851
2968
|
const wantsWeb = isNonEmptyString(args.url);
|
|
@@ -2859,12 +2976,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2859
2976
|
// MCP concerns: auth, arg validation, and progress notifications.
|
|
2860
2977
|
const progressToken = request.params && request.params._meta ? request.params._meta.progressToken : undefined;
|
|
2861
2978
|
const budget = Math.max(1, Math.min(1000, asInteger(args.maxActions, 60)));
|
|
2862
|
-
const notifyProgress = (
|
|
2979
|
+
const notifyProgress = (metric) => (p) => {
|
|
2863
2980
|
if (progressToken === undefined) return;
|
|
2864
2981
|
const total = p.max || budget;
|
|
2865
2982
|
server.notification({
|
|
2866
2983
|
method: "notifications/progress",
|
|
2867
|
-
params: { progressToken, progress: p.action || 0, total, message: `🔍 Exploring… ${p.action}/${total} actions · ${p.states} ${
|
|
2984
|
+
params: { progressToken, progress: p.action || 0, total, message: `🔍 Exploring… ${p.action}/${total} actions · ${p.states} ${metric}` },
|
|
2868
2985
|
}).catch(() => {});
|
|
2869
2986
|
};
|
|
2870
2987
|
if (wantsWeb) {
|
|
@@ -2875,7 +2992,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2875
2992
|
testEmail: args.testEmail,
|
|
2876
2993
|
testPassword: args.testPassword,
|
|
2877
2994
|
baselineFindings: args.baselineFindings,
|
|
2878
|
-
onProgress: notifyProgress("pages"),
|
|
2995
|
+
onProgress: notifyProgress("pages reached"),
|
|
2879
2996
|
});
|
|
2880
2997
|
if (r.error) return errorResult(r.error, r.details || {});
|
|
2881
2998
|
return richResult(r.text, r.structured);
|
|
@@ -2892,14 +3009,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2892
3009
|
testPassword: args.testPassword,
|
|
2893
3010
|
baselineFindings: args.baselineFindings,
|
|
2894
3011
|
clearData: args.clearData !== false,
|
|
2895
|
-
onProgress: notifyProgress("screens"),
|
|
3012
|
+
onProgress: notifyProgress("screens reached"),
|
|
2896
3013
|
});
|
|
2897
3014
|
if (r.error) return errorResult(r.error, r.details || {});
|
|
2898
3015
|
return richResult(r.text, r.structured);
|
|
2899
3016
|
}
|
|
2900
3017
|
|
|
2901
3018
|
let lastProgress = null;
|
|
2902
|
-
const iosProgress = notifyProgress("
|
|
3019
|
+
const iosProgress = notifyProgress("structural states observed");
|
|
2903
3020
|
const r = await runQaIos({
|
|
2904
3021
|
bundleId: String(args.appBundleId).trim(),
|
|
2905
3022
|
maxActions: args.maxActions,
|
|
@@ -2930,14 +3047,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2930
3047
|
if (!isInsideDir(projectDir, resolvedOut)) return errorResult("outDir must be inside projectDir");
|
|
2931
3048
|
const selectedPlatform = isNonEmptyString(args.platform) ? args.platform.trim().toLowerCase()
|
|
2932
3049
|
: isNonEmptyString(args.url) ? "web"
|
|
2933
|
-
: isNonEmptyString(args.androidAppId) || isNonEmptyString(args.apkPath) ? "android" : "
|
|
3050
|
+
: isNonEmptyString(args.androidAppId) || isNonEmptyString(args.apkPath) ? "android" : "";
|
|
2934
3051
|
const progressToken = request.params && request.params._meta ? request.params._meta.progressToken : undefined;
|
|
2935
3052
|
const budget = Math.max(1, Math.min(1000, asInteger(args.maxActions, 40)));
|
|
2936
3053
|
const result = await initializeProductProject({
|
|
2937
3054
|
projectDir, mode: operation, outDir,
|
|
2938
3055
|
ownedUrl: isNonEmptyString(args.url) ? args.url.trim() : "",
|
|
2939
3056
|
platform: isNonEmptyString(args.platform) ? args.platform.trim().toLowerCase() : operation === "explore" ? selectedPlatform : "",
|
|
2940
|
-
target: isNonEmptyString(args.target) ? args.target.trim() :
|
|
3057
|
+
target: isNonEmptyString(args.target) ? args.target.trim() : "",
|
|
2941
3058
|
bundleId: isNonEmptyString(args.appBundleId) ? args.appBundleId.trim() : "",
|
|
2942
3059
|
appId: isNonEmptyString(args.androidAppId) ? args.androidAppId.trim() : "",
|
|
2943
3060
|
apkPath: isNonEmptyString(args.apkPath) ? path.resolve(projectDir, args.apkPath.trim()) : undefined,
|
|
@@ -2953,9 +3070,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2953
3070
|
const { model, plan, written, exploration } = result;
|
|
2954
3071
|
const blocking = model.requirements.filter((item) => item.severity === "blocking");
|
|
2955
3072
|
const pending = plan.items.filter((item) => item.decision === "pending");
|
|
2956
|
-
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)${exploration ? ` · real ${exploration.platform} exploration ${exploration.
|
|
3073
|
+
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)${exploration ? ` · real ${exploration.platform} exploration: ${(exploration.findings || []).length} finding(s)${exploration.inconclusive ? " (inconclusive)" : ""}` : ""}`;
|
|
2957
3074
|
return richResult(summary, { model, plan, written: written ? { modelPath: written.modelPath, planPath: written.planPath } : null, exploration });
|
|
2958
|
-
} catch (error) {
|
|
3075
|
+
} catch (error) {
|
|
3076
|
+
const detail = error.message || String(error);
|
|
3077
|
+
const message = error.details?.reason === "target-selection-required"
|
|
3078
|
+
? detail
|
|
3079
|
+
: "Could not initialize Tapp repository artifacts";
|
|
3080
|
+
return errorResult(message, { detail, ...(error.details || {}) });
|
|
3081
|
+
}
|
|
2959
3082
|
}
|
|
2960
3083
|
|
|
2961
3084
|
if (name === "tapp_actor_config") {
|
|
@@ -3493,21 +3616,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3493
3616
|
const parsed = parseGeneratedFlow(mres.text);
|
|
3494
3617
|
if (!parsed) return errorResult("Model did not return a valid Flow", { raw: mres.text.slice(0, 500) });
|
|
3495
3618
|
|
|
3496
|
-
// 3) Ground-check + write
|
|
3619
|
+
// 3) Ground-check + write as an UNTRUSTED PROPOSAL (ADR-0005 decision 8: AI-generated work lands
|
|
3620
|
+
// as a draft under .tapp/proposals/, requires real-target replay + explicit human promotion,
|
|
3621
|
+
// and only then enters .tapp/flows/ where CI depends on it — never a direct write).
|
|
3497
3622
|
const ungrounded = ungroundedScreens(parsed.steps, grounding);
|
|
3498
3623
|
const flow = { name: args.name || parsed.name, app: bundleId, steps: parsed.steps };
|
|
3499
3624
|
const slug = flow.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "generated-flow";
|
|
3500
|
-
const dir = path.join(repoRoot, ".tapp", "flows");
|
|
3625
|
+
const dir = path.join(repoRoot, ".tapp", "proposals", "flows");
|
|
3501
3626
|
fs.mkdirSync(dir, { recursive: true });
|
|
3502
3627
|
const outPath = path.join(dir, `${slug}.yml`);
|
|
3628
|
+
const promotedPath = path.join(".tapp", "flows", `${slug}.yml`);
|
|
3503
3629
|
const yamlRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "to-yaml", JSON.stringify(flow)], { cwd: repoRoot });
|
|
3504
3630
|
const yaml = (yamlRes.stdout || "").trim();
|
|
3505
3631
|
if (!yaml) return errorResult("Failed to render flow YAML", { stderr: yamlRes.stderr });
|
|
3506
3632
|
fs.writeFileSync(outPath, yaml + "\n");
|
|
3507
3633
|
const rel = path.relative(repoRoot, outPath);
|
|
3508
3634
|
|
|
3509
|
-
const L = [`🤖 Generated flow **${flow.name}** from your goal → \`${rel}\``];
|
|
3510
|
-
L.push(
|
|
3635
|
+
const L = [`🤖 Generated a flow **proposal** **${flow.name}** from your goal → \`${rel}\``];
|
|
3636
|
+
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."}`);
|
|
3637
|
+
L.push(`**Review → replay → promote:** review it, replay it against the real app, and only then promote it (move to \`${promotedPath}\`) so CI can depend on it.`);
|
|
3511
3638
|
L.push("", "```yaml", yaml, "```");
|
|
3512
3639
|
|
|
3513
3640
|
// 4) Optionally replay it now.
|
|
@@ -3522,9 +3649,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3522
3649
|
L.push("", "---", "", (rep.stdout || "").trim());
|
|
3523
3650
|
}
|
|
3524
3651
|
} else {
|
|
3525
|
-
L.push("", `Replay
|
|
3652
|
+
L.push("", `Replay the proposal: \`tapp_flow_run\` with \`flowPath: "${rel}"\`. After it passes, promote it to \`.tapp/flows/\`.`);
|
|
3526
3653
|
}
|
|
3527
|
-
return richResult(L.join("\n"), { path: rel, flow, groundedScreens: grounding.screens.length, ungrounded });
|
|
3654
|
+
return richResult(L.join("\n"), { path: rel, proposal: true, promotePath: promotedPath, flow, groundedScreens: grounding.screens.length, ungrounded });
|
|
3528
3655
|
}
|
|
3529
3656
|
|
|
3530
3657
|
if (name === "tapp_ui_tree") {
|
|
@@ -3592,7 +3719,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3592
3719
|
if (isNonEmptyString(args.apkPath)) await driver.install(path.resolve(args.apkPath));
|
|
3593
3720
|
const snap = await driver.launch({ clearData: args.clearData === true });
|
|
3594
3721
|
const data = await driver.screenshot();
|
|
3595
|
-
await driver.forceStop().catch(() => {});
|
|
3596
3722
|
return {
|
|
3597
3723
|
content: [
|
|
3598
3724
|
{ type: "text", text: `🚀 Launched \`${appId}\` (Android)\n\n` + formatScreen(snap.screenTitle, snap.elements) },
|
|
@@ -428,16 +428,17 @@ export function adoptPrCoverageProposal({ projectDir, prPlanPath, item, releaseP
|
|
|
428
428
|
if (!ground || !fs.existsSync(mapPath)) throw new Error("Coverage proposal requires the repository's persistent UI Map");
|
|
429
429
|
const map = JSON.parse(fs.readFileSync(mapPath, "utf8"));
|
|
430
430
|
const node = (map.nodes || []).find((candidate) => candidate.id === ground.id && candidate.status !== "proposed");
|
|
431
|
-
|
|
431
|
+
const refreshAdvice = "refresh the persistent UI Map with `tapp init --explore --refresh`, rerun `tapp pr gate`, then retry `tapp pr adopt`";
|
|
432
|
+
if (!node) throw new Error(`Coverage proposal UI Map node is stale or missing: ${ground.id}; ${refreshAdvice}`);
|
|
432
433
|
if (target.navigation?.route && !(node.routes || []).some((route) => route.platform === target.platform && route.path === target.navigation.route && route.replayable === true)) {
|
|
433
|
-
throw new Error(`Coverage proposal route is stale in the persistent UI Map: ${target.navigation.route}`);
|
|
434
|
+
throw new Error(`Coverage proposal route is stale in the persistent UI Map: ${target.navigation.route}; ${refreshAdvice}`);
|
|
434
435
|
}
|
|
435
436
|
if (target.navigation?.mode === "ui-map-path") {
|
|
436
437
|
const currentNavigation = replayableUiMapNavigation(map, node.id, target.platform);
|
|
437
438
|
const expectedEdges = (target.navigation.steps || []).map((step) => step.edgeId);
|
|
438
439
|
const currentEdges = (currentNavigation.steps || []).map((step) => step.edgeId);
|
|
439
440
|
if (currentNavigation.status !== "replayable" || JSON.stringify(expectedEdges) !== JSON.stringify(currentEdges)) {
|
|
440
|
-
throw new Error(`Coverage proposal UI Map path is stale for ${node.name}`);
|
|
441
|
+
throw new Error(`Coverage proposal UI Map path is stale for ${node.name}; ${refreshAdvice}`);
|
|
441
442
|
}
|
|
442
443
|
}
|
|
443
444
|
const releasePlan = JSON.parse(fs.readFileSync(targetPath, "utf8"));
|