@aarwitz/tapp 0.16.4 โ 0.17.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +26 -17
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +1 -1
- package/README.md +69 -63
- package/bin/tapp.js +102 -37
- 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/application-model.js +9 -8
- package/mcp-server/src/browser-product.js +1 -1
- package/mcp-server/src/ci-report.js +84 -53
- package/mcp-server/src/ci-setup.js +35 -5
- package/mcp-server/src/enrich.js +1 -1
- package/mcp-server/src/html-report.js +4 -3
- package/mcp-server/src/index.js +142 -43
- 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 +157 -31
- package/mcp-server/src/task-runtime.js +1 -1
- package/package.json +2 -2
- package/scripts/ci-gate.sh +3 -3
- package/scripts/quick-capture.sh +1 -1
- 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") {
|
|
@@ -1066,7 +1066,7 @@ export async function captureScreenshotImage(maxWidth) {
|
|
|
1066
1066
|
// change data-handling behavior. A subscription token is an explicit tapp choice, and
|
|
1067
1067
|
// explicitly-invoked AI tools (tapp_flow_generate, assert_ai) carry their own consent.
|
|
1068
1068
|
export function remoteAiOptedIn(env = process.env) {
|
|
1069
|
-
if ((env.TAPP_SUBSCRIPTION_TOKEN ||
|
|
1069
|
+
if ((env.TAPP_SUBSCRIPTION_TOKEN || "").trim()) return true;
|
|
1070
1070
|
return ["1", "true", "yes"].includes(String(env.TAPP_ENABLE_REMOTE_AI || "").trim().toLowerCase());
|
|
1071
1071
|
}
|
|
1072
1072
|
|
|
@@ -1078,9 +1078,9 @@ export function isInsideDir(root, p) {
|
|
|
1078
1078
|
}
|
|
1079
1079
|
|
|
1080
1080
|
function resolveModelBackend() {
|
|
1081
|
-
const token = (process.env.TAPP_SUBSCRIPTION_TOKEN ||
|
|
1081
|
+
const token = (process.env.TAPP_SUBSCRIPTION_TOKEN || "").trim();
|
|
1082
1082
|
if (token) {
|
|
1083
|
-
const base = (process.env.TAPP_PROXY_URL ||
|
|
1083
|
+
const base = (process.env.TAPP_PROXY_URL || "http://localhost:8787").replace(/\/$/, "");
|
|
1084
1084
|
const url = base.endsWith("/v1/messages") ? base : base + "/v1/messages";
|
|
1085
1085
|
return { url, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" } };
|
|
1086
1086
|
}
|
|
@@ -1092,7 +1092,7 @@ function resolveModelBackend() {
|
|
|
1092
1092
|
}
|
|
1093
1093
|
|
|
1094
1094
|
async function callModel(backend, { system, userText, model, maxTokens = 1500 }) {
|
|
1095
|
-
const body = JSON.stringify({ model: model || process.env.TAPP_FLOW_MODEL ||
|
|
1095
|
+
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
1096
|
const res = await fetch(backend.url, { method: "POST", headers: backend.headers, body });
|
|
1097
1097
|
if (!res.ok) return { error: `model HTTP ${res.status}: ${(await res.text()).slice(0, 300)}` };
|
|
1098
1098
|
const data = await res.json();
|
|
@@ -1307,7 +1307,7 @@ export function qaNextSteps(report, surface = "mcp") {
|
|
|
1307
1307
|
if (surface === "cli") {
|
|
1308
1308
|
const next = [];
|
|
1309
1309
|
if (report?.findings?.length) next.push("inspect the evidence with `tapp report latest`");
|
|
1310
|
-
next.push("re-run with `--baseline <report.json>` to
|
|
1310
|
+
next.push("re-run with `--baseline <report.json>` to compare a fix (then `tapp ci` to gate it)");
|
|
1311
1311
|
next.push("replay a committed journey with `tapp flow run <file>`");
|
|
1312
1312
|
return next;
|
|
1313
1313
|
}
|
|
@@ -1320,17 +1320,21 @@ export function qaNextSteps(report, surface = "mcp") {
|
|
|
1320
1320
|
|
|
1321
1321
|
function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured, reportHtml, recording, uiMap, surface = "mcp" } = {}) {
|
|
1322
1322
|
const c = report.findingCounts || {};
|
|
1323
|
-
const badge =
|
|
1323
|
+
const badge = observationBadge(report);
|
|
1324
1324
|
const sevBits = ["critical", "high", "medium", "low"]
|
|
1325
1325
|
.map((k) => (c[k] ? `${SEV[k]} ${c[k]} ${k}` : null))
|
|
1326
1326
|
.filter(Boolean)
|
|
1327
1327
|
.join(", ");
|
|
1328
1328
|
const L = [];
|
|
1329
|
-
|
|
1329
|
+
// Exploration observes; it does not render a ship verdict. The release decision lives in the gate.
|
|
1330
|
+
L.push(`### ๐ญ Exploration complete โ ${badge} ยท ${observationSummary(report)}${bundleId ? `\n\`${bundleId}\`` : ""}`);
|
|
1330
1331
|
L.push("");
|
|
1331
1332
|
L.push(report.headline);
|
|
1332
1333
|
L.push("");
|
|
1333
1334
|
L.push(`**Coverage** โ ${report.screensExplored} screens ยท ${report.actionsPerformed} actions${timedOut ? " ยท โฑ๏ธ hit time limit" : ""}`);
|
|
1335
|
+
if (report.platform === "web") {
|
|
1336
|
+
L.push(`**Deterministic basis** โ ${report.deterministicFindingCounts?.total || 0} deterministic finding(s); ${report.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory`);
|
|
1337
|
+
}
|
|
1334
1338
|
if (uiMap) L.push(`**UI Map** โ ${uiMap.nodeCount} states ยท ${uiMap.edgeCount} transitions ยท ${uiMap.controlCount} semantic controls ยท ${uiMap.path}`);
|
|
1335
1339
|
if (reportHtml) L.push(`**Evidence** โ ๐ ${reportHtml} (screenshots of every screen + findings, shareable)`);
|
|
1336
1340
|
if (recording) L.push(`**Recording** โ ๐ฌ ${recording} (full exploration, embedded in the evidence page)`);
|
|
@@ -1352,10 +1356,11 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
|
|
|
1352
1356
|
}
|
|
1353
1357
|
}
|
|
1354
1358
|
if (regression && regression.counts) {
|
|
1355
|
-
|
|
1359
|
+
// Exploration reports the comparison (new/persisting/resolved) only โ never a gate pass/fail.
|
|
1360
|
+
// The merge decision is the gate's job (tapp ci), not exploration's (ADR-0005).
|
|
1356
1361
|
L.push("");
|
|
1357
1362
|
L.push(
|
|
1358
|
-
`**Since last run** โ +${regression.counts.new} new ยท ${regression.counts.persisting} persisting ยท ${regression.counts.resolved} resolved
|
|
1363
|
+
`**Since last run** โ +${regression.counts.new} new ยท ${regression.counts.persisting} persisting ยท ${regression.counts.resolved} resolved (comparison only โ run \`tapp ci\` to gate)`
|
|
1359
1364
|
);
|
|
1360
1365
|
}
|
|
1361
1366
|
if (inputHint) {
|
|
@@ -1687,8 +1692,8 @@ export async function runInitExploration({
|
|
|
1687
1692
|
merged.provenance.lastRun = {
|
|
1688
1693
|
id: qa.structured.capture?.id || observed.provenance?.runIds?.at(-1) || "",
|
|
1689
1694
|
platform: selected,
|
|
1690
|
-
verdict: qa.structured.verdict,
|
|
1691
1695
|
inconclusive: qa.structured.inconclusive === true,
|
|
1696
|
+
findingCount: qa.structured.findingCounts?.total ?? 0,
|
|
1692
1697
|
statesExplored: Number(qa.structured.screensExplored || merged.nodes.length),
|
|
1693
1698
|
actionsPerformed: Number(qa.structured.actionsPerformed || 0),
|
|
1694
1699
|
observedAt: observed.provenance?.lastObservedAt || new Date().toISOString(),
|
|
@@ -1704,8 +1709,8 @@ export async function runInitExploration({
|
|
|
1704
1709
|
resolution: targetResolution,
|
|
1705
1710
|
evidence: {
|
|
1706
1711
|
captureId: qa.structured.capture?.id || "",
|
|
1707
|
-
verdict: qa.structured.verdict,
|
|
1708
1712
|
inconclusive: qa.structured.inconclusive === true,
|
|
1713
|
+
findingCount: qa.structured.findingCounts?.total ?? 0,
|
|
1709
1714
|
observedAt: observed.provenance?.lastObservedAt || new Date().toISOString(),
|
|
1710
1715
|
},
|
|
1711
1716
|
},
|
|
@@ -1718,7 +1723,6 @@ export async function runInitExploration({
|
|
|
1718
1723
|
controlCount: merged.nodes.reduce((total, node) => total + node.controls.length, 0),
|
|
1719
1724
|
},
|
|
1720
1725
|
mapDiff: previous ? diffUiMaps(previous, observed, { comparableFullSweep: false }) : null,
|
|
1721
|
-
verdict: qa.structured.verdict,
|
|
1722
1726
|
inconclusive: qa.structured.inconclusive === true,
|
|
1723
1727
|
findings: qa.structured.findings || [],
|
|
1724
1728
|
capture: qa.structured.capture,
|
|
@@ -1732,6 +1736,91 @@ export async function runInitExploration({
|
|
|
1732
1736
|
}
|
|
1733
1737
|
}
|
|
1734
1738
|
|
|
1739
|
+
/**
|
|
1740
|
+
* Source-preparing bare explore (ADR-0005 ยง5): a `tapp explore` with no explicit target in a
|
|
1741
|
+
* repository with an application model. Selects the model's default target and prepares it from
|
|
1742
|
+
* source before exploring โ managed web is built/started/waited-for and always stopped again,
|
|
1743
|
+
* iOS is built + installed on the simulator, Android is built to an APK + installed โ then runs
|
|
1744
|
+
* the ordinary exploration engine. Returns the same `{ text, structured, error }` shape as the
|
|
1745
|
+
* direct runQa* entrypoints, so the CLI/MCP surfaces print it unchanged. This is an OBSERVATION:
|
|
1746
|
+
* no verdict, no UI-map write (that is `runInitExploration`'s job); the gate still judges.
|
|
1747
|
+
*/
|
|
1748
|
+
export async function runExploreTarget({
|
|
1749
|
+
projectDir,
|
|
1750
|
+
platform = "",
|
|
1751
|
+
target = "",
|
|
1752
|
+
maxActions,
|
|
1753
|
+
timeout,
|
|
1754
|
+
testEmail,
|
|
1755
|
+
testPassword,
|
|
1756
|
+
baselineFindings,
|
|
1757
|
+
surface = "cli",
|
|
1758
|
+
onProgress = () => {},
|
|
1759
|
+
onStatus = () => {},
|
|
1760
|
+
} = {}) {
|
|
1761
|
+
let root;
|
|
1762
|
+
try { root = fs.realpathSync(path.resolve(projectDir || process.cwd())); }
|
|
1763
|
+
catch { return { error: `Repository directory not found: ${projectDir || process.cwd()}` }; }
|
|
1764
|
+
|
|
1765
|
+
const modelPath = existingProjectArtifactPath(root, "application-model.json");
|
|
1766
|
+
if (!fs.existsSync(modelPath)) {
|
|
1767
|
+
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)." };
|
|
1768
|
+
}
|
|
1769
|
+
let model;
|
|
1770
|
+
try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
|
|
1771
|
+
catch (error) { return { error: `Application model is unreadable: ${error.message || String(error)}` }; }
|
|
1772
|
+
|
|
1773
|
+
const { selectApplicationTarget } = await import("./ci-setup.js");
|
|
1774
|
+
let selected;
|
|
1775
|
+
try { selected = selectApplicationTarget(model, { platform, target, useDefault: true }); }
|
|
1776
|
+
catch (error) { return { error: error.message || String(error) }; }
|
|
1777
|
+
const selectedPlatform = selected.platform;
|
|
1778
|
+
|
|
1779
|
+
if (selectedPlatform === "web") {
|
|
1780
|
+
const ownedUrl = String(selected.runtime?.ownedUrl || "").trim();
|
|
1781
|
+
if (/^https?:\/\//i.test(ownedUrl)) {
|
|
1782
|
+
onStatus(`Exploring the owned URL from the application model: ${ownedUrl}`);
|
|
1783
|
+
return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
|
|
1784
|
+
}
|
|
1785
|
+
// Tapp-managed: build/start the repo's web target, wait for readiness, and ALWAYS stop it.
|
|
1786
|
+
onStatus(`Preparing the managed web runtime for ${selected.name}โฆ`);
|
|
1787
|
+
const started = await startManagedWebTarget({ root, requestedTarget: selected.sourcePath || selected.name || "", timeout, onStatus });
|
|
1788
|
+
if (started.error) return started;
|
|
1789
|
+
try {
|
|
1790
|
+
return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
|
|
1791
|
+
} finally {
|
|
1792
|
+
await stopManagedWebTarget(started);
|
|
1793
|
+
onStatus("Stopped the managed web runtime.");
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
if (selectedPlatform === "android") {
|
|
1798
|
+
const appId = String(selected.runtime?.applicationId || "").trim();
|
|
1799
|
+
if (!appId) return { error: `The Android target '${selected.name}' has no confirmed application id โ confirm it and rerun \`tapp init\`, or pass --app-id.` };
|
|
1800
|
+
const task = selected.build?.task || "assembleDebug";
|
|
1801
|
+
onStatus(`Building the Android APK (${task})โฆ`);
|
|
1802
|
+
const built = await buildAndroidApp({
|
|
1803
|
+
projectDir: root,
|
|
1804
|
+
gradleProjectDir: path.resolve(root, selected.build?.projectDir || "."),
|
|
1805
|
+
moduleDir: path.resolve(root, selected.sourcePath || "."),
|
|
1806
|
+
task,
|
|
1807
|
+
});
|
|
1808
|
+
if (built.error) return built;
|
|
1809
|
+
onStatus(`Installing and exploring ${appId}โฆ`);
|
|
1810
|
+
return runQaAndroid({ appId, apkPath: built.apkPath, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// iOS
|
|
1814
|
+
if (process.platform !== "darwin") return { error: "iOS exploration requires macOS with Xcode." };
|
|
1815
|
+
const scheme = selected.build?.scheme || selected.build?.proposedScheme || "";
|
|
1816
|
+
const configuration = selected.build?.configuration || "Debug";
|
|
1817
|
+
onStatus(`Building and installing the iOS app${scheme ? ` (scheme ${scheme})` : ""}โฆ`);
|
|
1818
|
+
const resolved = await resolveAppTarget(root, { cwd: root, onStatus, scheme, configuration });
|
|
1819
|
+
if (resolved.error) return resolved;
|
|
1820
|
+
if (resolved.via) onStatus(`Target ${resolved.bundleId} โ ${resolved.via}`);
|
|
1821
|
+
return runQaIos({ bundleId: resolved.bundleId, maxActions, timeout, args: { testEmail, testPassword, baselineFindings }, surface, onProgress });
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1735
1824
|
function openLocalPort() {
|
|
1736
1825
|
return new Promise((resolve, reject) => {
|
|
1737
1826
|
const server = net.createServer();
|
|
@@ -1943,7 +2032,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1943
2032
|
description:
|
|
1944
2033
|
"Build the user's iOS app for the simulator from an Xcode project/workspace (auto-detects the " +
|
|
1945
2034
|
"container and scheme under projectDir, default cwd), install it on the booted simulator, and " +
|
|
1946
|
-
"return the bundle id. Use before
|
|
2035
|
+
"return the bundle id. Use before tapp_explore / tapp_open_app when the app isn't installed yet โ " +
|
|
1947
2036
|
"no bundle id needed up front.",
|
|
1948
2037
|
inputSchema: {
|
|
1949
2038
|
type: "object",
|
|
@@ -2065,20 +2154,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2065
2154
|
},
|
|
2066
2155
|
},
|
|
2067
2156
|
{
|
|
2068
|
-
name: "
|
|
2069
|
-
title: "
|
|
2157
|
+
name: "tapp_explore",
|
|
2158
|
+
title: "Explore (autonomous)",
|
|
2070
2159
|
description:
|
|
2071
|
-
"
|
|
2072
|
-
"(url โ beta, requires Playwright installed) and return a structured " +
|
|
2073
|
-
"
|
|
2160
|
+
"Autonomously explore iOS (appBundleId), Android (androidAppId), OR a web app " +
|
|
2161
|
+
"(url โ beta, requires Playwright installed) and return a structured EXPLORATION OBSERVATION. " +
|
|
2162
|
+
"Exploration OBSERVES โ it surfaces findings + coverage + evidence; it does NOT render a ship " +
|
|
2163
|
+
"verdict or score. To gate a merge, run the deterministic gate (contracts + baseline via CI). " +
|
|
2164
|
+
"Use when the user wants to find bugs / observe what breaks โ this " +
|
|
2074
2165
|
"runs for MINUTES exploring the whole app. Do NOT use it just to view, screenshot, or reach a specific " +
|
|
2075
2166
|
"screen โ use tapp_open_app (launch + screenshot) or a session for that. Tapp explores the app " +
|
|
2076
2167
|
"like a tester (taps, types, navigates, scrolls) and detects real issues โ crashes, dead buttons, failed sign-ins, error screens, " +
|
|
2077
2168
|
"stuck/hung screens; on web also uncaught JS exceptions, failed/5xx requests, broken links and assets. " +
|
|
2078
|
-
"Returns {
|
|
2079
|
-
"actionsPerformed, findings:[{type,severity,category,title,screen}]}
|
|
2080
|
-
"
|
|
2081
|
-
"
|
|
2169
|
+
"Returns {kind:'tapp-exploration-run', headline, inconclusive, screensExplored, " +
|
|
2170
|
+
"actionsPerformed, findingCounts, findings:[{type,severity,category,authority,title,screen}]} โ NO " +
|
|
2171
|
+
"verdict/releaseScore. Web separates deterministic findings from advisory sampled control probes. " +
|
|
2172
|
+
"There is a coverage floor: if the app barely explored (crash on launch / sign-in wall) it reports " +
|
|
2173
|
+
"`inconclusive` โ absence of findings is NEVER a pass. For iOS the app must already be installed on a booted simulator (use tapp_list_simulators / " +
|
|
2082
2174
|
"tapp_boot_simulator first). For web, only point it at an app/environment you own โ it CLICKS things. " +
|
|
2083
2175
|
"Tapp explores autonomously and does NOT pause to prompt for input โ " +
|
|
2084
2176
|
"it fills forms with safe defaults. The result includes `inputFieldsEncountered` (and `inputHint`): if " +
|
|
@@ -2133,10 +2225,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2133
2225
|
type: "array",
|
|
2134
2226
|
items: { type: "object" },
|
|
2135
2227
|
description:
|
|
2136
|
-
"Findings from a previous run (pass back the `findings` array a prior
|
|
2137
|
-
"When provided, the result adds `regression` {counts:{new,persisting,resolved},
|
|
2138
|
-
"
|
|
2139
|
-
"
|
|
2228
|
+
"Findings from a previous run (pass back the `findings` array a prior tapp_explore returned). " +
|
|
2229
|
+
"When provided, the result adds a `regression` COMPARISON {counts:{new,persisting,resolved}, " +
|
|
2230
|
+
"newFindings, resolved} vs. that baseline โ an observation, not a gate signal. To gate a merge, " +
|
|
2231
|
+
"run `tapp ci` (or the GitHub Action): it applies the deterministic policy and returns the " +
|
|
2232
|
+
"pass/fail/inconclusive outcome.",
|
|
2140
2233
|
},
|
|
2141
2234
|
},
|
|
2142
2235
|
},
|
|
@@ -2351,7 +2444,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2351
2444
|
description:
|
|
2352
2445
|
"Replay a deterministic, authored end-to-end test (a Flow) against iOS (XCUITest), Android " +
|
|
2353
2446
|
"(ADB/UIAutomator), or web (Playwright), and return a scannable pass/fail report. A Flow is a list of steps + assertions " +
|
|
2354
|
-
"(see docs/flows-architecture.md). Unlike
|
|
2447
|
+
"(see docs/flows-architecture.md). Unlike tapp_explore (autonomous exploration), a Flow does EXACTLY " +
|
|
2355
2448
|
"what you specify, the same way every time โ use it for regression tests and verifying a fix. Steps: " +
|
|
2356
2449
|
"{tap: X} ยท {type: {field: F, value: V}} ยท {swipe: up} ยท {back} ยท {wait_for: SCREEN}. Assertions " +
|
|
2357
2450
|
"(deterministic): {assert_screen: X} ยท {assert_exists: X} ยท {assert_absent: X} ยท {assert_text: {of, contains}}. " +
|
|
@@ -2406,8 +2499,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2406
2499
|
"Write a deterministic E2E Flow from a natural-language goal (e.g. 'sign in and open Settings'), " +
|
|
2407
2500
|
"GROUNDED in the app's real screens so it can't invent steps. Tapp explores the app to build a " +
|
|
2408
2501
|
"screen/control map (or reuses a recent run via captureId), then a model authors a Flow using only " +
|
|
2409
|
-
"screens/controls that were actually observed. Saves it
|
|
2410
|
-
"
|
|
2502
|
+
"screens/controls that were actually observed. Saves it as an UNTRUSTED PROPOSAL under " +
|
|
2503
|
+
".tapp/proposals/flows/<name>.yml (never directly into .tapp/flows/) and returns the YAML for " +
|
|
2504
|
+
"review (optionally runs it). Review โ replay against the real app โ explicitly PROMOTE (move to " +
|
|
2505
|
+
".tapp/flows/) before CI depends on it. Needs a model backend (Tapp subscription token or " +
|
|
2411
2506
|
"ANTHROPIC_API_KEY). Use this to bootstrap a test you then refine; use tapp_flow_run to replay it.",
|
|
2412
2507
|
inputSchema: {
|
|
2413
2508
|
type: "object",
|
|
@@ -2482,7 +2577,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2482
2577
|
description:
|
|
2483
2578
|
"Launch an installed iOS or Android app and return a SCREENSHOT of the screen it lands on " +
|
|
2484
2579
|
"(plus the accessibility tree) โ with NO exploration. This is the fast way (seconds) to just SEE a " +
|
|
2485
|
-
"screen. Use this โ NOT
|
|
2580
|
+
"screen. Use this โ NOT tapp_explore โ whenever the user wants to view or screenshot a screen. Pass " +
|
|
2486
2581
|
"appLaunchArgs like [\"--uitesting\"] to bypass login and land on the home screen, and appLaunchEnv for " +
|
|
2487
2582
|
"a backend override. The app is launched fresh and closed afterward. (To screenshot a screen reached by " +
|
|
2488
2583
|
"real login or several taps, use a session instead and call tapp_screenshot along the way.)",
|
|
@@ -2524,7 +2619,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2524
2619
|
name: "tapp_install_app",
|
|
2525
2620
|
title: "Install app on sim",
|
|
2526
2621
|
description:
|
|
2527
|
-
"Build a target iOS app for the booted simulator and install it, so it's ready for
|
|
2622
|
+
"Build a target iOS app for the booted simulator and install it, so it's ready for tapp_explore or " +
|
|
2528
2623
|
"a session. Provide the Xcode project OR workspace path + scheme. Best-effort โ apps with CocoaPods/" +
|
|
2529
2624
|
"signing quirks may still need their normal build. Returns {ok, installed, simulator}.",
|
|
2530
2625
|
inputSchema: {
|
|
@@ -2674,7 +2769,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2674
2769
|
const text =
|
|
2675
2770
|
`๐จ Built **${path.basename(built.appPath)}** (scheme \`${built.scheme}\`) in ${fmtDuration(Date.now() - startedAt)}` +
|
|
2676
2771
|
(bundleId ? ` โ installed on the simulator as \`${bundleId}\`` : "") +
|
|
2677
|
-
`\n\nNext: \`
|
|
2772
|
+
`\n\nNext: \`tapp_explore\` with \`appBundleId: "${bundleId || "<install it first>"}"\`.`;
|
|
2678
2773
|
return richResult(text, { ok: true, appPath: built.appPath, scheme: built.scheme, container: built.container, bundleId });
|
|
2679
2774
|
}
|
|
2680
2775
|
|
|
@@ -2840,7 +2935,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2840
2935
|
return richResult(L.join("\n"), summary);
|
|
2841
2936
|
}
|
|
2842
2937
|
|
|
2843
|
-
if (name === "tapp_run_qa") {
|
|
2938
|
+
if (name === "tapp_explore" || name === "tapp_run_qa") { // tapp_run_qa: deprecated alias
|
|
2844
2939
|
const unauthorized = ensureAuthorized(args);
|
|
2845
2940
|
if (unauthorized) return unauthorized;
|
|
2846
2941
|
const wantsWeb = isNonEmptyString(args.url);
|
|
@@ -2948,7 +3043,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2948
3043
|
const { model, plan, written, exploration } = result;
|
|
2949
3044
|
const blocking = model.requirements.filter((item) => item.severity === "blocking");
|
|
2950
3045
|
const pending = plan.items.filter((item) => item.decision === "pending");
|
|
2951
|
-
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.
|
|
3046
|
+
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)" : ""}` : ""}`;
|
|
2952
3047
|
return richResult(summary, { model, plan, written: written ? { modelPath: written.modelPath, planPath: written.planPath } : null, exploration });
|
|
2953
3048
|
} catch (error) { return errorResult("Could not initialize Tapp repository artifacts", { detail: error.message || String(error) }); }
|
|
2954
3049
|
}
|
|
@@ -3488,21 +3583,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3488
3583
|
const parsed = parseGeneratedFlow(mres.text);
|
|
3489
3584
|
if (!parsed) return errorResult("Model did not return a valid Flow", { raw: mres.text.slice(0, 500) });
|
|
3490
3585
|
|
|
3491
|
-
// 3) Ground-check + write
|
|
3586
|
+
// 3) Ground-check + write as an UNTRUSTED PROPOSAL (ADR-0005 decision 8: AI-generated work lands
|
|
3587
|
+
// as a draft under .tapp/proposals/, requires real-target replay + explicit human promotion,
|
|
3588
|
+
// and only then enters .tapp/flows/ where CI depends on it โ never a direct write).
|
|
3492
3589
|
const ungrounded = ungroundedScreens(parsed.steps, grounding);
|
|
3493
3590
|
const flow = { name: args.name || parsed.name, app: bundleId, steps: parsed.steps };
|
|
3494
3591
|
const slug = flow.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "generated-flow";
|
|
3495
|
-
const dir = path.join(repoRoot, ".tapp", "flows");
|
|
3592
|
+
const dir = path.join(repoRoot, ".tapp", "proposals", "flows");
|
|
3496
3593
|
fs.mkdirSync(dir, { recursive: true });
|
|
3497
3594
|
const outPath = path.join(dir, `${slug}.yml`);
|
|
3595
|
+
const promotedPath = path.join(".tapp", "flows", `${slug}.yml`);
|
|
3498
3596
|
const yamlRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "to-yaml", JSON.stringify(flow)], { cwd: repoRoot });
|
|
3499
3597
|
const yaml = (yamlRes.stdout || "").trim();
|
|
3500
3598
|
if (!yaml) return errorResult("Failed to render flow YAML", { stderr: yamlRes.stderr });
|
|
3501
3599
|
fs.writeFileSync(outPath, yaml + "\n");
|
|
3502
3600
|
const rel = path.relative(repoRoot, outPath);
|
|
3503
3601
|
|
|
3504
|
-
const L = [`๐ค Generated flow **${flow.name}** from your goal โ \`${rel}\``];
|
|
3505
|
-
L.push(
|
|
3602
|
+
const L = [`๐ค Generated a flow **proposal** **${flow.name}** from your goal โ \`${rel}\``];
|
|
3603
|
+
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."}`);
|
|
3604
|
+
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.`);
|
|
3506
3605
|
L.push("", "```yaml", yaml, "```");
|
|
3507
3606
|
|
|
3508
3607
|
// 4) Optionally replay it now.
|
|
@@ -3517,9 +3616,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3517
3616
|
L.push("", "---", "", (rep.stdout || "").trim());
|
|
3518
3617
|
}
|
|
3519
3618
|
} else {
|
|
3520
|
-
L.push("", `Replay
|
|
3619
|
+
L.push("", `Replay the proposal: \`tapp_flow_run\` with \`flowPath: "${rel}"\`. After it passes, promote it to \`.tapp/flows/\`.`);
|
|
3521
3620
|
}
|
|
3522
|
-
return richResult(L.join("\n"), { path: rel, flow, groundedScreens: grounding.screens.length, ungrounded });
|
|
3621
|
+
return richResult(L.join("\n"), { path: rel, proposal: true, promotePath: promotedPath, flow, groundedScreens: grounding.screens.length, ungrounded });
|
|
3523
3622
|
}
|
|
3524
3623
|
|
|
3525
3624
|
if (name === "tapp_ui_tree") {
|
|
@@ -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}$/;
|
|
@@ -1,32 +1,20 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
1
|
import path from "node:path";
|
|
3
2
|
|
|
4
3
|
export const TAPP_DIRECTORY = ".tapp";
|
|
5
|
-
export const LEGACY_TAPP_DIRECTORY = ".autotap";
|
|
6
4
|
export const TAPP_CONFIG = ".tapp.yml";
|
|
7
|
-
export const LEGACY_TAPP_CONFIG = ".autotap.yml";
|
|
8
5
|
|
|
9
|
-
export function projectArtifactDirectory(
|
|
10
|
-
|
|
11
|
-
if (requested !== TAPP_DIRECTORY) return requested;
|
|
12
|
-
const canonical = path.join(root, TAPP_DIRECTORY);
|
|
13
|
-
const legacy = path.join(root, LEGACY_TAPP_DIRECTORY);
|
|
14
|
-
if (!fs.existsSync(canonical) && fs.existsSync(legacy)) return LEGACY_TAPP_DIRECTORY;
|
|
15
|
-
return TAPP_DIRECTORY;
|
|
6
|
+
export function projectArtifactDirectory(_projectDir, requested = TAPP_DIRECTORY) {
|
|
7
|
+
return requested;
|
|
16
8
|
}
|
|
17
9
|
|
|
18
10
|
export function projectArtifactPath(projectDir, ...parts) {
|
|
19
|
-
return path.join(path.resolve(projectDir),
|
|
11
|
+
return path.join(path.resolve(projectDir), TAPP_DIRECTORY, ...parts);
|
|
20
12
|
}
|
|
21
13
|
|
|
22
14
|
export function existingProjectArtifactPath(projectDir, ...parts) {
|
|
23
|
-
|
|
24
|
-
const canonical = path.join(root, TAPP_DIRECTORY, ...parts);
|
|
25
|
-
if (fs.existsSync(canonical)) return canonical;
|
|
26
|
-
const legacy = path.join(root, LEGACY_TAPP_DIRECTORY, ...parts);
|
|
27
|
-
return fs.existsSync(legacy) ? legacy : canonical;
|
|
15
|
+
return path.join(path.resolve(projectDir), TAPP_DIRECTORY, ...parts);
|
|
28
16
|
}
|
|
29
17
|
|
|
30
18
|
export function isProjectArtifactDirectory(name) {
|
|
31
|
-
return name === TAPP_DIRECTORY
|
|
19
|
+
return name === TAPP_DIRECTORY;
|
|
32
20
|
}
|
|
@@ -11,8 +11,8 @@ import { semanticUiKey } from "./ui-map.js";
|
|
|
11
11
|
|
|
12
12
|
const PLATFORMS = new Set(["ios", "android", "web"]);
|
|
13
13
|
const CRITICALITIES = new Set(["low", "medium", "high", "critical"]);
|
|
14
|
-
const CONTRACT_AUTHORING_SPECIFIERS = new Set(["@aarwitz/tapp/contracts"
|
|
15
|
-
const CONTRACT_AUTHORING_IMPORT = /(["'])
|
|
14
|
+
const CONTRACT_AUTHORING_SPECIFIERS = new Set(["@aarwitz/tapp/contracts"]);
|
|
15
|
+
const CONTRACT_AUTHORING_IMPORT = /(["'])@aarwitz\/tapp\/contracts\1/g;
|
|
16
16
|
const authoringUrl = pathToFileURL(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "contract-authoring.js")).href;
|
|
17
17
|
|
|
18
18
|
function expectationAction(expectation) {
|
|
@@ -86,7 +86,7 @@ function rewriteAuthoringImport(source, contractPath) {
|
|
|
86
86
|
}
|
|
87
87
|
const imports = [...source.matchAll(/(?:from\s*|import\s*)["']([^"']+)["']/g)].map((match) => match[1]);
|
|
88
88
|
const unsupported = imports.filter((specifier) => !CONTRACT_AUTHORING_SPECIFIERS.has(specifier));
|
|
89
|
-
if (unsupported.length) throw new Error(`Release contract imports are limited to @aarwitz/tapp/contracts (
|
|
89
|
+
if (unsupported.length) throw new Error(`Release contract imports are limited to @aarwitz/tapp/contracts (found ${unsupported.join(", ")})`);
|
|
90
90
|
const output = ts.transpileModule(source, {
|
|
91
91
|
fileName: contractPath,
|
|
92
92
|
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022, verbatimModuleSyntax: true },
|