@aarwitz/tapp 0.16.5 → 0.17.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +26 -21
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +62 -22
- package/README.md +64 -67
- package/bin/tapp.js +168 -45
- 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 +271 -0
- package/docs/scenarios.md +95 -0
- package/mcp-server/src/android-driver.js +15 -2
- package/mcp-server/src/android-explorer.js +54 -12
- package/mcp-server/src/application-model.js +9 -8
- 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 +20 -6
- package/mcp-server/src/index.js +182 -67
- 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 +2 -2
- 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 +9 -8
- 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") {
|
|
@@ -984,7 +984,10 @@ async function runExploreStreaming(bundleId, actions, timeout, env, onProgress)
|
|
|
984
984
|
const before = new Set(listCaptureRuns(80).map((r) => r.id));
|
|
985
985
|
const proc = spawn("bash", cmdArgs, { cwd: repoRoot, env: { ...process.env, ...env } });
|
|
986
986
|
proc.stdout.on("data", () => {});
|
|
987
|
-
|
|
987
|
+
let captureStderr = "";
|
|
988
|
+
proc.stderr.on("data", (chunk) => {
|
|
989
|
+
captureStderr = (captureStderr + chunk.toString("utf8")).slice(-8_000);
|
|
990
|
+
});
|
|
988
991
|
const closed = new Promise((res) => proc.on("close", (code) => res(code ?? 1)));
|
|
989
992
|
|
|
990
993
|
let captureDir = null;
|
|
@@ -1037,7 +1040,18 @@ async function runExploreStreaming(bundleId, actions, timeout, env, onProgress)
|
|
|
1037
1040
|
await closed;
|
|
1038
1041
|
const created = listCaptureRuns(80).find((r) => !before.has(r.id))
|
|
1039
1042
|
|| (captureDir ? { id: path.basename(captureDir), path: captureDir, relativePath: path.relative(repoRoot, captureDir) } : null);
|
|
1040
|
-
return { created, timedOut };
|
|
1043
|
+
return { created, timedOut, captureStderr };
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
export function recordingUnavailableReason(stderr = "") {
|
|
1047
|
+
const text = String(stderr);
|
|
1048
|
+
if (/resource busy|host recording is already in progress/i.test(text)) {
|
|
1049
|
+
return "the simulator recorder is busy with another host recording";
|
|
1050
|
+
}
|
|
1051
|
+
if (/could not start simulator video recording/i.test(text)) {
|
|
1052
|
+
return "the simulator could not start video recording";
|
|
1053
|
+
}
|
|
1054
|
+
return "the simulator did not produce a recording";
|
|
1041
1055
|
}
|
|
1042
1056
|
|
|
1043
1057
|
// Grab the booted simulator's current screen and return it downscaled + JPEG-compressed so the
|
|
@@ -1066,7 +1080,7 @@ export async function captureScreenshotImage(maxWidth) {
|
|
|
1066
1080
|
// change data-handling behavior. A subscription token is an explicit tapp choice, and
|
|
1067
1081
|
// explicitly-invoked AI tools (tapp_flow_generate, assert_ai) carry their own consent.
|
|
1068
1082
|
export function remoteAiOptedIn(env = process.env) {
|
|
1069
|
-
if ((env.TAPP_SUBSCRIPTION_TOKEN ||
|
|
1083
|
+
if ((env.TAPP_SUBSCRIPTION_TOKEN || "").trim()) return true;
|
|
1070
1084
|
return ["1", "true", "yes"].includes(String(env.TAPP_ENABLE_REMOTE_AI || "").trim().toLowerCase());
|
|
1071
1085
|
}
|
|
1072
1086
|
|
|
@@ -1078,9 +1092,9 @@ export function isInsideDir(root, p) {
|
|
|
1078
1092
|
}
|
|
1079
1093
|
|
|
1080
1094
|
function resolveModelBackend() {
|
|
1081
|
-
const token = (process.env.TAPP_SUBSCRIPTION_TOKEN ||
|
|
1095
|
+
const token = (process.env.TAPP_SUBSCRIPTION_TOKEN || "").trim();
|
|
1082
1096
|
if (token) {
|
|
1083
|
-
const base = (process.env.TAPP_PROXY_URL ||
|
|
1097
|
+
const base = (process.env.TAPP_PROXY_URL || "http://localhost:8787").replace(/\/$/, "");
|
|
1084
1098
|
const url = base.endsWith("/v1/messages") ? base : base + "/v1/messages";
|
|
1085
1099
|
return { url, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" } };
|
|
1086
1100
|
}
|
|
@@ -1092,7 +1106,7 @@ function resolveModelBackend() {
|
|
|
1092
1106
|
}
|
|
1093
1107
|
|
|
1094
1108
|
async function callModel(backend, { system, userText, model, maxTokens = 1500 }) {
|
|
1095
|
-
const body = JSON.stringify({ model: model || process.env.TAPP_FLOW_MODEL ||
|
|
1109
|
+
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
1110
|
const res = await fetch(backend.url, { method: "POST", headers: backend.headers, body });
|
|
1097
1111
|
if (!res.ok) return { error: `model HTTP ${res.status}: ${(await res.text()).slice(0, 300)}` };
|
|
1098
1112
|
const data = await res.json();
|
|
@@ -1307,7 +1321,7 @@ export function qaNextSteps(report, surface = "mcp") {
|
|
|
1307
1321
|
if (surface === "cli") {
|
|
1308
1322
|
const next = [];
|
|
1309
1323
|
if (report?.findings?.length) next.push("inspect the evidence with `tapp report latest`");
|
|
1310
|
-
next.push("re-run with `--baseline <report.json>` to
|
|
1324
|
+
next.push("re-run with `--baseline <report.json>` to compare a fix (then `tapp ci` to gate it)");
|
|
1311
1325
|
next.push("replay a committed journey with `tapp flow run <file>`");
|
|
1312
1326
|
return next;
|
|
1313
1327
|
}
|
|
@@ -1318,25 +1332,27 @@ export function qaNextSteps(report, surface = "mcp") {
|
|
|
1318
1332
|
return next;
|
|
1319
1333
|
}
|
|
1320
1334
|
|
|
1321
|
-
function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured, reportHtml, recording, uiMap, surface = "mcp" } = {}) {
|
|
1335
|
+
function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured, reportHtml, recording, recordingWarning, uiMap, surface = "mcp" } = {}) {
|
|
1322
1336
|
const c = report.findingCounts || {};
|
|
1323
|
-
const badge =
|
|
1337
|
+
const badge = observationBadge(report);
|
|
1324
1338
|
const sevBits = ["critical", "high", "medium", "low"]
|
|
1325
1339
|
.map((k) => (c[k] ? `${SEV[k]} ${c[k]} ${k}` : null))
|
|
1326
1340
|
.filter(Boolean)
|
|
1327
1341
|
.join(", ");
|
|
1328
1342
|
const L = [];
|
|
1329
|
-
|
|
1343
|
+
// Exploration observes; it does not render a ship verdict. The release decision lives in the gate.
|
|
1344
|
+
L.push(`### 🔭 Exploration complete — ${badge} · ${observationSummary(report)}${bundleId ? `\n\`${bundleId}\`` : ""}`);
|
|
1330
1345
|
L.push("");
|
|
1331
1346
|
L.push(report.headline);
|
|
1332
1347
|
L.push("");
|
|
1333
1348
|
L.push(`**Coverage** — ${report.screensExplored} screens · ${report.actionsPerformed} actions${timedOut ? " · ⏱️ hit time limit" : ""}`);
|
|
1334
1349
|
if (report.platform === "web") {
|
|
1335
|
-
L.push(`**
|
|
1350
|
+
L.push(`**Deterministic basis** — ${report.deterministicFindingCounts?.total || 0} deterministic finding(s); ${report.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory`);
|
|
1336
1351
|
}
|
|
1337
1352
|
if (uiMap) L.push(`**UI Map** — ${uiMap.nodeCount} states · ${uiMap.edgeCount} transitions · ${uiMap.controlCount} semantic controls · ${uiMap.path}`);
|
|
1338
1353
|
if (reportHtml) L.push(`**Evidence** — 📄 ${reportHtml} (screenshots of every screen + findings, shareable)`);
|
|
1339
1354
|
if (recording) L.push(`**Recording** — 🎬 ${recording} (full exploration, embedded in the evidence page)`);
|
|
1355
|
+
else if (recordingWarning) L.push(`**Recording** — ⚠️ unavailable: ${recordingWarning}; screenshots were still captured`);
|
|
1340
1356
|
L.push(`**Issues** — ${c.total ? `${c.total}${sevBits ? ` (${sevBits})` : ""}` : "none found ✨"}`);
|
|
1341
1357
|
if (Array.isArray(report.findings) && report.findings.length) {
|
|
1342
1358
|
L.push("");
|
|
@@ -1355,17 +1371,18 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
|
|
|
1355
1371
|
}
|
|
1356
1372
|
}
|
|
1357
1373
|
if (regression && regression.counts) {
|
|
1358
|
-
|
|
1374
|
+
// Exploration reports the comparison (new/persisting/resolved) only — never a gate pass/fail.
|
|
1375
|
+
// The merge decision is the gate's job (tapp ci), not exploration's (ADR-0005).
|
|
1359
1376
|
L.push("");
|
|
1360
1377
|
L.push(
|
|
1361
|
-
`**Since last run** — +${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved
|
|
1378
|
+
`**Since last run** — +${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved (comparison only — run \`tapp ci\` to gate)`
|
|
1362
1379
|
);
|
|
1363
1380
|
}
|
|
1364
1381
|
if (inputHint) {
|
|
1365
1382
|
L.push("");
|
|
1366
1383
|
L.push(`> ℹ️ ${inputHint}`);
|
|
1367
1384
|
}
|
|
1368
|
-
// The
|
|
1385
|
+
// The observation's scope: enumerate exactly what ran and what remains unchecked.
|
|
1369
1386
|
if (Array.isArray(report.checkedFor) && report.checkedFor.length) {
|
|
1370
1387
|
L.push("");
|
|
1371
1388
|
L.push(`> ✅ Checked: ${report.checkedFor.join(" · ")}`);
|
|
@@ -1379,11 +1396,10 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
|
|
|
1379
1396
|
const next = qaNextSteps(report, surface);
|
|
1380
1397
|
L.push("");
|
|
1381
1398
|
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.
|
|
1399
|
+
// The gate hook belongs at the moment the user thinks "I want these checks on every PR".
|
|
1384
1400
|
if ((report.findings && report.findings.length) || regression) {
|
|
1385
1401
|
L.push("");
|
|
1386
|
-
L.push("> 🚦 Teams:
|
|
1402
|
+
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
1403
|
}
|
|
1388
1404
|
return L.join("\n");
|
|
1389
1405
|
}
|
|
@@ -1464,7 +1480,7 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
|
|
|
1464
1480
|
} catch (err) {
|
|
1465
1481
|
return { error: String(err.message || err) };
|
|
1466
1482
|
}
|
|
1467
|
-
const report = buildQaReport(webResult.markersPath, { platform: "web" });
|
|
1483
|
+
const report = buildQaReport(webResult.markersPath, { platform: "web", target: url.trim() });
|
|
1468
1484
|
if (!report) return { error: "Web exploration produced no markers", details: { capture: { id, path: outDir } } };
|
|
1469
1485
|
const backend = remoteAiOptedIn() ? resolveModelBackend() : null;
|
|
1470
1486
|
if (backend && report.findings.length) {
|
|
@@ -1507,7 +1523,7 @@ export async function runQaAndroid({ appId, apkPath, serial, maxActions, timeout
|
|
|
1507
1523
|
} catch (error) {
|
|
1508
1524
|
return { error: error.message || String(error), details: { capture: { id, path: outDir } } };
|
|
1509
1525
|
}
|
|
1510
|
-
const report = buildQaReport(androidResult.markersPath, { platform: "android" });
|
|
1526
|
+
const report = buildQaReport(androidResult.markersPath, { platform: "android", target: appId.trim() });
|
|
1511
1527
|
if (!report) return { error: "Android exploration produced no markers", details: { capture: { id, path: outDir } } };
|
|
1512
1528
|
const backend = remoteAiOptedIn() ? resolveModelBackend() : null;
|
|
1513
1529
|
if (backend && report.findings.length) {
|
|
@@ -1539,10 +1555,10 @@ export async function runQaIos({ bundleId, maxActions, timeout, args = {}, surfa
|
|
|
1539
1555
|
const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
|
|
1540
1556
|
const env = explorationEnvFromArgs(args);
|
|
1541
1557
|
|
|
1542
|
-
const { created, timedOut } = await runExploreStreaming(bundleId, actions, timeoutSec, env, onProgress);
|
|
1558
|
+
const { created, timedOut, captureStderr } = await runExploreStreaming(bundleId, actions, timeoutSec, env, onProgress);
|
|
1543
1559
|
if (!created) return { error: "Exploration produced no capture run", details: { timedOut } };
|
|
1544
1560
|
|
|
1545
|
-
const report = buildQaReport(path.join(created.path, "ocqa-markers.txt"));
|
|
1561
|
+
const report = buildQaReport(path.join(created.path, "ocqa-markers.txt"), { platform: "ios", target: bundleId });
|
|
1546
1562
|
if (!report) {
|
|
1547
1563
|
return {
|
|
1548
1564
|
error: "No markers parsed from exploration (the app may not have launched)",
|
|
@@ -1572,13 +1588,14 @@ export async function runQaIos({ bundleId, maxActions, timeout, args = {}, surfa
|
|
|
1572
1588
|
// Cross-run regression vs. a caller-supplied baseline (the CI gate).
|
|
1573
1589
|
const regression = computeRegression(report.findings, args.baselineFindings);
|
|
1574
1590
|
const uiMap = await writeRunUiMap({ markersPath: path.join(created.path, "ocqa-markers.txt"), platform: "ios", target: bundleId, runId: created.id, outDir: created.path });
|
|
1591
|
+
const recording =
|
|
1592
|
+
["exploration.webm", "exploration.mov"].map((f) => path.join(created.path, f)).find((p) => fs.existsSync(p)) || null;
|
|
1593
|
+
const recordingWarning = recording ? null : recordingUnavailableReason(captureStderr);
|
|
1575
1594
|
let reportHtml = null;
|
|
1576
1595
|
try {
|
|
1577
1596
|
const { writeHtmlReport } = await import("./html-report.js");
|
|
1578
|
-
reportHtml = writeHtmlReport(created.path, { report, label: bundleId });
|
|
1597
|
+
reportHtml = writeHtmlReport(created.path, { report, label: bundleId, recordingWarning });
|
|
1579
1598
|
} 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
1599
|
const structured = {
|
|
1583
1600
|
...report,
|
|
1584
1601
|
regression,
|
|
@@ -1586,11 +1603,12 @@ export async function runQaIos({ bundleId, maxActions, timeout, args = {}, surfa
|
|
|
1586
1603
|
inputHint,
|
|
1587
1604
|
reportHtml,
|
|
1588
1605
|
recording,
|
|
1606
|
+
recordingWarning,
|
|
1589
1607
|
capture: { id: created.id, path: created.path, relativePath: created.relativePath },
|
|
1590
1608
|
timedOut,
|
|
1591
1609
|
autoBooted: sim.autoBooted || false,
|
|
1592
1610
|
};
|
|
1593
|
-
const text = formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured: !!backend, reportHtml, recording, uiMap: uiMap.error ? null : uiMap, surface });
|
|
1611
|
+
const text = formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured: !!backend, reportHtml, recording, recordingWarning, uiMap: uiMap.error ? null : uiMap, surface });
|
|
1594
1612
|
return { structured, text };
|
|
1595
1613
|
}
|
|
1596
1614
|
|
|
@@ -1690,8 +1708,8 @@ export async function runInitExploration({
|
|
|
1690
1708
|
merged.provenance.lastRun = {
|
|
1691
1709
|
id: qa.structured.capture?.id || observed.provenance?.runIds?.at(-1) || "",
|
|
1692
1710
|
platform: selected,
|
|
1693
|
-
verdict: qa.structured.verdict,
|
|
1694
1711
|
inconclusive: qa.structured.inconclusive === true,
|
|
1712
|
+
findingCount: qa.structured.findingCounts?.total ?? 0,
|
|
1695
1713
|
statesExplored: Number(qa.structured.screensExplored || merged.nodes.length),
|
|
1696
1714
|
actionsPerformed: Number(qa.structured.actionsPerformed || 0),
|
|
1697
1715
|
observedAt: observed.provenance?.lastObservedAt || new Date().toISOString(),
|
|
@@ -1707,8 +1725,8 @@ export async function runInitExploration({
|
|
|
1707
1725
|
resolution: targetResolution,
|
|
1708
1726
|
evidence: {
|
|
1709
1727
|
captureId: qa.structured.capture?.id || "",
|
|
1710
|
-
verdict: qa.structured.verdict,
|
|
1711
1728
|
inconclusive: qa.structured.inconclusive === true,
|
|
1729
|
+
findingCount: qa.structured.findingCounts?.total ?? 0,
|
|
1712
1730
|
observedAt: observed.provenance?.lastObservedAt || new Date().toISOString(),
|
|
1713
1731
|
},
|
|
1714
1732
|
},
|
|
@@ -1721,7 +1739,6 @@ export async function runInitExploration({
|
|
|
1721
1739
|
controlCount: merged.nodes.reduce((total, node) => total + node.controls.length, 0),
|
|
1722
1740
|
},
|
|
1723
1741
|
mapDiff: previous ? diffUiMaps(previous, observed, { comparableFullSweep: false }) : null,
|
|
1724
|
-
verdict: qa.structured.verdict,
|
|
1725
1742
|
inconclusive: qa.structured.inconclusive === true,
|
|
1726
1743
|
findings: qa.structured.findings || [],
|
|
1727
1744
|
capture: qa.structured.capture,
|
|
@@ -1735,6 +1752,97 @@ export async function runInitExploration({
|
|
|
1735
1752
|
}
|
|
1736
1753
|
}
|
|
1737
1754
|
|
|
1755
|
+
/**
|
|
1756
|
+
* Source-preparing bare explore (ADR-0005 §5): a `tapp explore` with no explicit target in a
|
|
1757
|
+
* repository with an application model. Selects the model's default target and prepares it from
|
|
1758
|
+
* source before exploring — managed web is built/started/waited-for and always stopped again,
|
|
1759
|
+
* iOS is built + installed on the simulator, Android is built to an APK + installed — then runs
|
|
1760
|
+
* the ordinary exploration engine. Returns the same `{ text, structured, error }` shape as the
|
|
1761
|
+
* direct runQa* entrypoints, so the CLI/MCP surfaces print it unchanged. This is an OBSERVATION:
|
|
1762
|
+
* no verdict, no UI-map write (that is `runInitExploration`'s job); the gate still judges.
|
|
1763
|
+
*/
|
|
1764
|
+
export async function runExploreTarget({
|
|
1765
|
+
projectDir,
|
|
1766
|
+
platform = "",
|
|
1767
|
+
target = "",
|
|
1768
|
+
maxActions,
|
|
1769
|
+
timeout,
|
|
1770
|
+
testEmail,
|
|
1771
|
+
testPassword,
|
|
1772
|
+
appLaunchArgs,
|
|
1773
|
+
appLaunchEnv,
|
|
1774
|
+
baselineFindings,
|
|
1775
|
+
surface = "cli",
|
|
1776
|
+
onProgress = () => {},
|
|
1777
|
+
onStatus = () => {},
|
|
1778
|
+
} = {}) {
|
|
1779
|
+
let root;
|
|
1780
|
+
try { root = fs.realpathSync(path.resolve(projectDir || process.cwd())); }
|
|
1781
|
+
catch { return { error: `Repository directory not found: ${projectDir || process.cwd()}` }; }
|
|
1782
|
+
|
|
1783
|
+
const modelPath = existingProjectArtifactPath(root, "application-model.json");
|
|
1784
|
+
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)." };
|
|
1786
|
+
}
|
|
1787
|
+
let model;
|
|
1788
|
+
try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
|
|
1789
|
+
catch (error) { return { error: `Application model is unreadable: ${error.message || String(error)}` }; }
|
|
1790
|
+
|
|
1791
|
+
const { selectApplicationTarget } = await import("./ci-setup.js");
|
|
1792
|
+
let selected;
|
|
1793
|
+
try { selected = selectApplicationTarget(model, { platform, target, useDefault: true }); }
|
|
1794
|
+
catch (error) { return { error: error.message || String(error) }; }
|
|
1795
|
+
const selectedPlatform = selected.platform;
|
|
1796
|
+
|
|
1797
|
+
if (selectedPlatform !== "ios" && ((Array.isArray(appLaunchArgs) && appLaunchArgs.length) || (appLaunchEnv && Object.keys(appLaunchEnv).length))) {
|
|
1798
|
+
return { error: "appLaunchArgs/appLaunchEnv apply only to iOS targets." };
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
if (selectedPlatform === "web") {
|
|
1802
|
+
const ownedUrl = String(selected.runtime?.ownedUrl || "").trim();
|
|
1803
|
+
if (/^https?:\/\//i.test(ownedUrl)) {
|
|
1804
|
+
onStatus(`Exploring the owned URL from the application model: ${ownedUrl}`);
|
|
1805
|
+
return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
|
|
1806
|
+
}
|
|
1807
|
+
// Tapp-managed: build/start the repo's web target, wait for readiness, and ALWAYS stop it.
|
|
1808
|
+
onStatus(`Preparing the managed web runtime for ${selected.name}…`);
|
|
1809
|
+
const started = await startManagedWebTarget({ root, requestedTarget: selected.sourcePath || selected.name || "", timeout, onStatus });
|
|
1810
|
+
if (started.error) return started;
|
|
1811
|
+
try {
|
|
1812
|
+
return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
|
|
1813
|
+
} finally {
|
|
1814
|
+
await stopManagedWebTarget(started);
|
|
1815
|
+
onStatus("Stopped the managed web runtime.");
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
if (selectedPlatform === "android") {
|
|
1820
|
+
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.` };
|
|
1822
|
+
const task = selected.build?.task || "assembleDebug";
|
|
1823
|
+
onStatus(`Building the Android APK (${task})…`);
|
|
1824
|
+
const built = await buildAndroidApp({
|
|
1825
|
+
projectDir: root,
|
|
1826
|
+
gradleProjectDir: path.resolve(root, selected.build?.projectDir || "."),
|
|
1827
|
+
moduleDir: path.resolve(root, selected.sourcePath || "."),
|
|
1828
|
+
task,
|
|
1829
|
+
});
|
|
1830
|
+
if (built.error) return built;
|
|
1831
|
+
onStatus(`Installing and exploring ${appId}…`);
|
|
1832
|
+
return runQaAndroid({ appId, apkPath: built.apkPath, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// iOS
|
|
1836
|
+
if (process.platform !== "darwin") return { error: "iOS exploration requires macOS with Xcode." };
|
|
1837
|
+
const scheme = selected.build?.scheme || selected.build?.proposedScheme || "";
|
|
1838
|
+
const configuration = selected.build?.configuration || "Debug";
|
|
1839
|
+
onStatus(`Building and installing the iOS app${scheme ? ` (scheme ${scheme})` : ""}…`);
|
|
1840
|
+
const resolved = await resolveAppTarget(root, { cwd: root, onStatus, scheme, configuration });
|
|
1841
|
+
if (resolved.error) return resolved;
|
|
1842
|
+
if (resolved.via) onStatus(`Target ${resolved.bundleId} — ${resolved.via}`);
|
|
1843
|
+
return runQaIos({ bundleId: resolved.bundleId, maxActions, timeout, args: { testEmail, testPassword, baselineFindings, appLaunchArgs, appLaunchEnv }, surface, onProgress });
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1738
1846
|
function openLocalPort() {
|
|
1739
1847
|
return new Promise((resolve, reject) => {
|
|
1740
1848
|
const server = net.createServer();
|
|
@@ -1946,7 +2054,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1946
2054
|
description:
|
|
1947
2055
|
"Build the user's iOS app for the simulator from an Xcode project/workspace (auto-detects the " +
|
|
1948
2056
|
"container and scheme under projectDir, default cwd), install it on the booted simulator, and " +
|
|
1949
|
-
"return the bundle id. Use before
|
|
2057
|
+
"return the bundle id. Use before tapp_explore / tapp_open_app when the app isn't installed yet — " +
|
|
1950
2058
|
"no bundle id needed up front.",
|
|
1951
2059
|
inputSchema: {
|
|
1952
2060
|
type: "object",
|
|
@@ -2068,22 +2176,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2068
2176
|
},
|
|
2069
2177
|
},
|
|
2070
2178
|
{
|
|
2071
|
-
name: "
|
|
2072
|
-
title: "
|
|
2179
|
+
name: "tapp_explore",
|
|
2180
|
+
title: "Explore (autonomous)",
|
|
2073
2181
|
description:
|
|
2074
|
-
"
|
|
2075
|
-
"(url — beta, requires Playwright installed) and return a structured " +
|
|
2076
|
-
"
|
|
2182
|
+
"Autonomously explore iOS (appBundleId), Android (androidAppId), OR a web app " +
|
|
2183
|
+
"(url — beta, requires Playwright installed) and return a structured EXPLORATION OBSERVATION. " +
|
|
2184
|
+
"Exploration OBSERVES — it surfaces findings + coverage + evidence; it does NOT render a ship " +
|
|
2185
|
+
"verdict or score. To gate a merge, run the deterministic gate (contracts + baseline via CI). " +
|
|
2186
|
+
"Use when the user wants to find bugs / observe what breaks — this " +
|
|
2077
2187
|
"runs for MINUTES exploring the whole app. Do NOT use it just to view, screenshot, or reach a specific " +
|
|
2078
2188
|
"screen — use tapp_open_app (launch + screenshot) or a session for that. Tapp explores the app " +
|
|
2079
2189
|
"like a tester (taps, types, navigates, scrolls) and detects real issues — crashes, dead buttons, failed sign-ins, error screens, " +
|
|
2080
2190
|
"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 / " +
|
|
2191
|
+
"Returns {kind:'tapp-exploration-run', headline, inconclusive, screensExplored, " +
|
|
2192
|
+
"actionsPerformed, findingCounts, findings:[{type,severity,category,authority,title,screen}]} — NO " +
|
|
2193
|
+
"verdict/releaseScore. Web separates deterministic findings from advisory sampled control probes. " +
|
|
2194
|
+
"There is a coverage floor: if the app barely explored (crash on launch / sign-in wall) it reports " +
|
|
2195
|
+
"`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
2196
|
"tapp_boot_simulator first). For web, only point it at an app/environment you own — it CLICKS things. " +
|
|
2088
2197
|
"Tapp explores autonomously and does NOT pause to prompt for input — " +
|
|
2089
2198
|
"it fills forms with safe defaults. The result includes `inputFieldsEncountered` (and `inputHint`): if " +
|
|
@@ -2138,10 +2247,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2138
2247
|
type: "array",
|
|
2139
2248
|
items: { type: "object" },
|
|
2140
2249
|
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
|
-
"
|
|
2250
|
+
"Findings from a previous run (pass back the `findings` array a prior tapp_explore returned). " +
|
|
2251
|
+
"When provided, the result adds a `regression` COMPARISON {counts:{new,persisting,resolved}, " +
|
|
2252
|
+
"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 " +
|
|
2254
|
+
"pass/fail/inconclusive outcome.",
|
|
2145
2255
|
},
|
|
2146
2256
|
},
|
|
2147
2257
|
},
|
|
@@ -2356,7 +2466,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2356
2466
|
description:
|
|
2357
2467
|
"Replay a deterministic, authored end-to-end test (a Flow) against iOS (XCUITest), Android " +
|
|
2358
2468
|
"(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
|
|
2469
|
+
"(see docs/flows-architecture.md). Unlike tapp_explore (autonomous exploration), a Flow does EXACTLY " +
|
|
2360
2470
|
"what you specify, the same way every time — use it for regression tests and verifying a fix. Steps: " +
|
|
2361
2471
|
"{tap: X} · {type: {field: F, value: V}} · {swipe: up} · {back} · {wait_for: SCREEN}. Assertions " +
|
|
2362
2472
|
"(deterministic): {assert_screen: X} · {assert_exists: X} · {assert_absent: X} · {assert_text: {of, contains}}. " +
|
|
@@ -2411,8 +2521,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2411
2521
|
"Write a deterministic E2E Flow from a natural-language goal (e.g. 'sign in and open Settings'), " +
|
|
2412
2522
|
"GROUNDED in the app's real screens so it can't invent steps. Tapp explores the app to build a " +
|
|
2413
2523
|
"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
|
-
"
|
|
2524
|
+
"screens/controls that were actually observed. Saves it as an UNTRUSTED PROPOSAL under " +
|
|
2525
|
+
".tapp/proposals/flows/<name>.yml (never directly into .tapp/flows/) and returns the YAML for " +
|
|
2526
|
+
"review (optionally runs it). Review → replay against the real app → explicitly PROMOTE (move to " +
|
|
2527
|
+
".tapp/flows/) before CI depends on it. Needs a model backend (Tapp subscription token or " +
|
|
2416
2528
|
"ANTHROPIC_API_KEY). Use this to bootstrap a test you then refine; use tapp_flow_run to replay it.",
|
|
2417
2529
|
inputSchema: {
|
|
2418
2530
|
type: "object",
|
|
@@ -2487,7 +2599,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2487
2599
|
description:
|
|
2488
2600
|
"Launch an installed iOS or Android app and return a SCREENSHOT of the screen it lands on " +
|
|
2489
2601
|
"(plus the accessibility tree) — with NO exploration. This is the fast way (seconds) to just SEE a " +
|
|
2490
|
-
"screen. Use this — NOT
|
|
2602
|
+
"screen. Use this — NOT tapp_explore — whenever the user wants to view or screenshot a screen. Pass " +
|
|
2491
2603
|
"appLaunchArgs like [\"--uitesting\"] to bypass login and land on the home screen, and appLaunchEnv for " +
|
|
2492
2604
|
"a backend override. The app is launched fresh and closed afterward. (To screenshot a screen reached by " +
|
|
2493
2605
|
"real login or several taps, use a session instead and call tapp_screenshot along the way.)",
|
|
@@ -2529,7 +2641,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2529
2641
|
name: "tapp_install_app",
|
|
2530
2642
|
title: "Install app on sim",
|
|
2531
2643
|
description:
|
|
2532
|
-
"Build a target iOS app for the booted simulator and install it, so it's ready for
|
|
2644
|
+
"Build a target iOS app for the booted simulator and install it, so it's ready for tapp_explore or " +
|
|
2533
2645
|
"a session. Provide the Xcode project OR workspace path + scheme. Best-effort — apps with CocoaPods/" +
|
|
2534
2646
|
"signing quirks may still need their normal build. Returns {ok, installed, simulator}.",
|
|
2535
2647
|
inputSchema: {
|
|
@@ -2679,7 +2791,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2679
2791
|
const text =
|
|
2680
2792
|
`🔨 Built **${path.basename(built.appPath)}** (scheme \`${built.scheme}\`) in ${fmtDuration(Date.now() - startedAt)}` +
|
|
2681
2793
|
(bundleId ? ` — installed on the simulator as \`${bundleId}\`` : "") +
|
|
2682
|
-
`\n\nNext: \`
|
|
2794
|
+
`\n\nNext: \`tapp_explore\` with \`appBundleId: "${bundleId || "<install it first>"}"\`.`;
|
|
2683
2795
|
return richResult(text, { ok: true, appPath: built.appPath, scheme: built.scheme, container: built.container, bundleId });
|
|
2684
2796
|
}
|
|
2685
2797
|
|
|
@@ -2845,7 +2957,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2845
2957
|
return richResult(L.join("\n"), summary);
|
|
2846
2958
|
}
|
|
2847
2959
|
|
|
2848
|
-
if (name === "tapp_run_qa") {
|
|
2960
|
+
if (name === "tapp_explore" || name === "tapp_run_qa") { // tapp_run_qa: deprecated alias
|
|
2849
2961
|
const unauthorized = ensureAuthorized(args);
|
|
2850
2962
|
if (unauthorized) return unauthorized;
|
|
2851
2963
|
const wantsWeb = isNonEmptyString(args.url);
|
|
@@ -2859,12 +2971,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2859
2971
|
// MCP concerns: auth, arg validation, and progress notifications.
|
|
2860
2972
|
const progressToken = request.params && request.params._meta ? request.params._meta.progressToken : undefined;
|
|
2861
2973
|
const budget = Math.max(1, Math.min(1000, asInteger(args.maxActions, 60)));
|
|
2862
|
-
const notifyProgress = (
|
|
2974
|
+
const notifyProgress = (metric) => (p) => {
|
|
2863
2975
|
if (progressToken === undefined) return;
|
|
2864
2976
|
const total = p.max || budget;
|
|
2865
2977
|
server.notification({
|
|
2866
2978
|
method: "notifications/progress",
|
|
2867
|
-
params: { progressToken, progress: p.action || 0, total, message: `🔍 Exploring… ${p.action}/${total} actions · ${p.states} ${
|
|
2979
|
+
params: { progressToken, progress: p.action || 0, total, message: `🔍 Exploring… ${p.action}/${total} actions · ${p.states} ${metric}` },
|
|
2868
2980
|
}).catch(() => {});
|
|
2869
2981
|
};
|
|
2870
2982
|
if (wantsWeb) {
|
|
@@ -2875,7 +2987,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2875
2987
|
testEmail: args.testEmail,
|
|
2876
2988
|
testPassword: args.testPassword,
|
|
2877
2989
|
baselineFindings: args.baselineFindings,
|
|
2878
|
-
onProgress: notifyProgress("pages"),
|
|
2990
|
+
onProgress: notifyProgress("pages reached"),
|
|
2879
2991
|
});
|
|
2880
2992
|
if (r.error) return errorResult(r.error, r.details || {});
|
|
2881
2993
|
return richResult(r.text, r.structured);
|
|
@@ -2892,14 +3004,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2892
3004
|
testPassword: args.testPassword,
|
|
2893
3005
|
baselineFindings: args.baselineFindings,
|
|
2894
3006
|
clearData: args.clearData !== false,
|
|
2895
|
-
onProgress: notifyProgress("screens"),
|
|
3007
|
+
onProgress: notifyProgress("screens reached"),
|
|
2896
3008
|
});
|
|
2897
3009
|
if (r.error) return errorResult(r.error, r.details || {});
|
|
2898
3010
|
return richResult(r.text, r.structured);
|
|
2899
3011
|
}
|
|
2900
3012
|
|
|
2901
3013
|
let lastProgress = null;
|
|
2902
|
-
const iosProgress = notifyProgress("
|
|
3014
|
+
const iosProgress = notifyProgress("structural states observed");
|
|
2903
3015
|
const r = await runQaIos({
|
|
2904
3016
|
bundleId: String(args.appBundleId).trim(),
|
|
2905
3017
|
maxActions: args.maxActions,
|
|
@@ -2953,7 +3065,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2953
3065
|
const { model, plan, written, exploration } = result;
|
|
2954
3066
|
const blocking = model.requirements.filter((item) => item.severity === "blocking");
|
|
2955
3067
|
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.
|
|
3068
|
+
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
3069
|
return richResult(summary, { model, plan, written: written ? { modelPath: written.modelPath, planPath: written.planPath } : null, exploration });
|
|
2958
3070
|
} catch (error) { return errorResult("Could not initialize Tapp repository artifacts", { detail: error.message || String(error) }); }
|
|
2959
3071
|
}
|
|
@@ -3493,21 +3605,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3493
3605
|
const parsed = parseGeneratedFlow(mres.text);
|
|
3494
3606
|
if (!parsed) return errorResult("Model did not return a valid Flow", { raw: mres.text.slice(0, 500) });
|
|
3495
3607
|
|
|
3496
|
-
// 3) Ground-check + write
|
|
3608
|
+
// 3) Ground-check + write as an UNTRUSTED PROPOSAL (ADR-0005 decision 8: AI-generated work lands
|
|
3609
|
+
// as a draft under .tapp/proposals/, requires real-target replay + explicit human promotion,
|
|
3610
|
+
// and only then enters .tapp/flows/ where CI depends on it — never a direct write).
|
|
3497
3611
|
const ungrounded = ungroundedScreens(parsed.steps, grounding);
|
|
3498
3612
|
const flow = { name: args.name || parsed.name, app: bundleId, steps: parsed.steps };
|
|
3499
3613
|
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");
|
|
3614
|
+
const dir = path.join(repoRoot, ".tapp", "proposals", "flows");
|
|
3501
3615
|
fs.mkdirSync(dir, { recursive: true });
|
|
3502
3616
|
const outPath = path.join(dir, `${slug}.yml`);
|
|
3617
|
+
const promotedPath = path.join(".tapp", "flows", `${slug}.yml`);
|
|
3503
3618
|
const yamlRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "to-yaml", JSON.stringify(flow)], { cwd: repoRoot });
|
|
3504
3619
|
const yaml = (yamlRes.stdout || "").trim();
|
|
3505
3620
|
if (!yaml) return errorResult("Failed to render flow YAML", { stderr: yamlRes.stderr });
|
|
3506
3621
|
fs.writeFileSync(outPath, yaml + "\n");
|
|
3507
3622
|
const rel = path.relative(repoRoot, outPath);
|
|
3508
3623
|
|
|
3509
|
-
const L = [`🤖 Generated flow **${flow.name}** from your goal → \`${rel}\``];
|
|
3510
|
-
L.push(
|
|
3624
|
+
const L = [`🤖 Generated a flow **proposal** **${flow.name}** from your goal → \`${rel}\``];
|
|
3625
|
+
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."}`);
|
|
3626
|
+
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
3627
|
L.push("", "```yaml", yaml, "```");
|
|
3512
3628
|
|
|
3513
3629
|
// 4) Optionally replay it now.
|
|
@@ -3522,9 +3638,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3522
3638
|
L.push("", "---", "", (rep.stdout || "").trim());
|
|
3523
3639
|
}
|
|
3524
3640
|
} else {
|
|
3525
|
-
L.push("", `Replay
|
|
3641
|
+
L.push("", `Replay the proposal: \`tapp_flow_run\` with \`flowPath: "${rel}"\`. After it passes, promote it to \`.tapp/flows/\`.`);
|
|
3526
3642
|
}
|
|
3527
|
-
return richResult(L.join("\n"), { path: rel, flow, groundedScreens: grounding.screens.length, ungrounded });
|
|
3643
|
+
return richResult(L.join("\n"), { path: rel, proposal: true, promotePath: promotedPath, flow, groundedScreens: grounding.screens.length, ungrounded });
|
|
3528
3644
|
}
|
|
3529
3645
|
|
|
3530
3646
|
if (name === "tapp_ui_tree") {
|
|
@@ -3592,7 +3708,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3592
3708
|
if (isNonEmptyString(args.apkPath)) await driver.install(path.resolve(args.apkPath));
|
|
3593
3709
|
const snap = await driver.launch({ clearData: args.clearData === true });
|
|
3594
3710
|
const data = await driver.screenshot();
|
|
3595
|
-
await driver.forceStop().catch(() => {});
|
|
3596
3711
|
return {
|
|
3597
3712
|
content: [
|
|
3598
3713
|
{ 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"));
|
|
@@ -13,7 +13,7 @@ import { compileReleaseContract, loadReleaseContractFile } from "./release-contr
|
|
|
13
13
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
14
14
|
|
|
15
15
|
function executionHome() {
|
|
16
|
-
return process.env.TAPP_HOME ||
|
|
16
|
+
return process.env.TAPP_HOME || path.join(os.homedir(), ".tapp");
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
function atomicJson(destination, value) {
|
|
@@ -65,7 +65,7 @@ function artifactPaths(root, outDir = ".tapp") {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
function productRunRoot(root) {
|
|
68
|
-
const home = process.env.TAPP_HOME ||
|
|
68
|
+
const home = process.env.TAPP_HOME || path.join(os.homedir(), ".tapp");
|
|
69
69
|
const identity = crypto.createHash("sha256").update(root).digest("hex").slice(0, 16);
|
|
70
70
|
return path.join(home, "product-runs", identity);
|
|
71
71
|
}
|
|
@@ -84,7 +84,7 @@ function listProductRuns(root) {
|
|
|
84
84
|
id: entry.name,
|
|
85
85
|
createdAt: fs.statSync(dir).birthtime.toISOString(),
|
|
86
86
|
status: report ? "completed" : "incomplete",
|
|
87
|
-
|
|
87
|
+
outcome: report?.gate?.outcome || null,
|
|
88
88
|
gate: report?.gate || null,
|
|
89
89
|
reportPath: fs.existsSync(reportPath) ? reportPath : null,
|
|
90
90
|
markdownPath: fs.existsSync(markdownPath) ? markdownPath : null,
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { TAPP_DIRECTORY, projectArtifactDirectory } from "./project-paths.js";
|
|
4
4
|
|
|
5
5
|
export const PROJECT_CONFIG_RELATIVE_PATH = `${TAPP_DIRECTORY}/project.json`;
|
|
6
|
-
export const LEGACY_PROJECT_CONFIG_RELATIVE_PATH = `${LEGACY_TAPP_DIRECTORY}/project.json`;
|
|
7
6
|
|
|
8
7
|
const ACTOR_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
9
8
|
const ENV_NAME = /^[A-Z_][A-Z0-9_]{0,127}$/;
|