@aarwitz/tapp 0.16.5 โ 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 -21
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +1 -1
- package/README.md +64 -67
- 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 +82 -60
- 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 -4
- package/mcp-server/src/index.js +140 -46
- 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 +123 -38
- 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,19 +1320,20 @@ 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" : ""}`);
|
|
1334
1335
|
if (report.platform === "web") {
|
|
1335
|
-
L.push(`**
|
|
1336
|
+
L.push(`**Deterministic basis** โ ${report.deterministicFindingCounts?.total || 0} deterministic finding(s); ${report.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory`);
|
|
1336
1337
|
}
|
|
1337
1338
|
if (uiMap) L.push(`**UI Map** โ ${uiMap.nodeCount} states ยท ${uiMap.edgeCount} transitions ยท ${uiMap.controlCount} semantic controls ยท ${uiMap.path}`);
|
|
1338
1339
|
if (reportHtml) L.push(`**Evidence** โ ๐ ${reportHtml} (screenshots of every screen + findings, shareable)`);
|
|
@@ -1355,10 +1356,11 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
|
|
|
1355
1356
|
}
|
|
1356
1357
|
}
|
|
1357
1358
|
if (regression && regression.counts) {
|
|
1358
|
-
|
|
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).
|
|
1359
1361
|
L.push("");
|
|
1360
1362
|
L.push(
|
|
1361
|
-
`**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)`
|
|
1362
1364
|
);
|
|
1363
1365
|
}
|
|
1364
1366
|
if (inputHint) {
|
|
@@ -1690,8 +1692,8 @@ export async function runInitExploration({
|
|
|
1690
1692
|
merged.provenance.lastRun = {
|
|
1691
1693
|
id: qa.structured.capture?.id || observed.provenance?.runIds?.at(-1) || "",
|
|
1692
1694
|
platform: selected,
|
|
1693
|
-
verdict: qa.structured.verdict,
|
|
1694
1695
|
inconclusive: qa.structured.inconclusive === true,
|
|
1696
|
+
findingCount: qa.structured.findingCounts?.total ?? 0,
|
|
1695
1697
|
statesExplored: Number(qa.structured.screensExplored || merged.nodes.length),
|
|
1696
1698
|
actionsPerformed: Number(qa.structured.actionsPerformed || 0),
|
|
1697
1699
|
observedAt: observed.provenance?.lastObservedAt || new Date().toISOString(),
|
|
@@ -1707,8 +1709,8 @@ export async function runInitExploration({
|
|
|
1707
1709
|
resolution: targetResolution,
|
|
1708
1710
|
evidence: {
|
|
1709
1711
|
captureId: qa.structured.capture?.id || "",
|
|
1710
|
-
verdict: qa.structured.verdict,
|
|
1711
1712
|
inconclusive: qa.structured.inconclusive === true,
|
|
1713
|
+
findingCount: qa.structured.findingCounts?.total ?? 0,
|
|
1712
1714
|
observedAt: observed.provenance?.lastObservedAt || new Date().toISOString(),
|
|
1713
1715
|
},
|
|
1714
1716
|
},
|
|
@@ -1721,7 +1723,6 @@ export async function runInitExploration({
|
|
|
1721
1723
|
controlCount: merged.nodes.reduce((total, node) => total + node.controls.length, 0),
|
|
1722
1724
|
},
|
|
1723
1725
|
mapDiff: previous ? diffUiMaps(previous, observed, { comparableFullSweep: false }) : null,
|
|
1724
|
-
verdict: qa.structured.verdict,
|
|
1725
1726
|
inconclusive: qa.structured.inconclusive === true,
|
|
1726
1727
|
findings: qa.structured.findings || [],
|
|
1727
1728
|
capture: qa.structured.capture,
|
|
@@ -1735,6 +1736,91 @@ export async function runInitExploration({
|
|
|
1735
1736
|
}
|
|
1736
1737
|
}
|
|
1737
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
|
+
|
|
1738
1824
|
function openLocalPort() {
|
|
1739
1825
|
return new Promise((resolve, reject) => {
|
|
1740
1826
|
const server = net.createServer();
|
|
@@ -1946,7 +2032,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1946
2032
|
description:
|
|
1947
2033
|
"Build the user's iOS app for the simulator from an Xcode project/workspace (auto-detects the " +
|
|
1948
2034
|
"container and scheme under projectDir, default cwd), install it on the booted simulator, and " +
|
|
1949
|
-
"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 โ " +
|
|
1950
2036
|
"no bundle id needed up front.",
|
|
1951
2037
|
inputSchema: {
|
|
1952
2038
|
type: "object",
|
|
@@ -2068,22 +2154,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2068
2154
|
},
|
|
2069
2155
|
},
|
|
2070
2156
|
{
|
|
2071
|
-
name: "
|
|
2072
|
-
title: "
|
|
2157
|
+
name: "tapp_explore",
|
|
2158
|
+
title: "Explore (autonomous)",
|
|
2073
2159
|
description:
|
|
2074
|
-
"
|
|
2075
|
-
"(url โ beta, requires Playwright installed) and return a structured " +
|
|
2076
|
-
"
|
|
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 " +
|
|
2077
2165
|
"runs for MINUTES exploring the whole app. Do NOT use it just to view, screenshot, or reach a specific " +
|
|
2078
2166
|
"screen โ use tapp_open_app (launch + screenshot) or a session for that. Tapp explores the app " +
|
|
2079
2167
|
"like a tester (taps, types, navigates, scrolls) and detects real issues โ crashes, dead buttons, failed sign-ins, error screens, " +
|
|
2080
2168
|
"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 / " +
|
|
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 / " +
|
|
2087
2174
|
"tapp_boot_simulator first). For web, only point it at an app/environment you own โ it CLICKS things. " +
|
|
2088
2175
|
"Tapp explores autonomously and does NOT pause to prompt for input โ " +
|
|
2089
2176
|
"it fills forms with safe defaults. The result includes `inputFieldsEncountered` (and `inputHint`): if " +
|
|
@@ -2138,10 +2225,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2138
2225
|
type: "array",
|
|
2139
2226
|
items: { type: "object" },
|
|
2140
2227
|
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
|
-
"
|
|
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.",
|
|
2145
2233
|
},
|
|
2146
2234
|
},
|
|
2147
2235
|
},
|
|
@@ -2356,7 +2444,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2356
2444
|
description:
|
|
2357
2445
|
"Replay a deterministic, authored end-to-end test (a Flow) against iOS (XCUITest), Android " +
|
|
2358
2446
|
"(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
|
|
2447
|
+
"(see docs/flows-architecture.md). Unlike tapp_explore (autonomous exploration), a Flow does EXACTLY " +
|
|
2360
2448
|
"what you specify, the same way every time โ use it for regression tests and verifying a fix. Steps: " +
|
|
2361
2449
|
"{tap: X} ยท {type: {field: F, value: V}} ยท {swipe: up} ยท {back} ยท {wait_for: SCREEN}. Assertions " +
|
|
2362
2450
|
"(deterministic): {assert_screen: X} ยท {assert_exists: X} ยท {assert_absent: X} ยท {assert_text: {of, contains}}. " +
|
|
@@ -2411,8 +2499,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2411
2499
|
"Write a deterministic E2E Flow from a natural-language goal (e.g. 'sign in and open Settings'), " +
|
|
2412
2500
|
"GROUNDED in the app's real screens so it can't invent steps. Tapp explores the app to build a " +
|
|
2413
2501
|
"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
|
-
"
|
|
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 " +
|
|
2416
2506
|
"ANTHROPIC_API_KEY). Use this to bootstrap a test you then refine; use tapp_flow_run to replay it.",
|
|
2417
2507
|
inputSchema: {
|
|
2418
2508
|
type: "object",
|
|
@@ -2487,7 +2577,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2487
2577
|
description:
|
|
2488
2578
|
"Launch an installed iOS or Android app and return a SCREENSHOT of the screen it lands on " +
|
|
2489
2579
|
"(plus the accessibility tree) โ with NO exploration. This is the fast way (seconds) to just SEE a " +
|
|
2490
|
-
"screen. Use this โ NOT
|
|
2580
|
+
"screen. Use this โ NOT tapp_explore โ whenever the user wants to view or screenshot a screen. Pass " +
|
|
2491
2581
|
"appLaunchArgs like [\"--uitesting\"] to bypass login and land on the home screen, and appLaunchEnv for " +
|
|
2492
2582
|
"a backend override. The app is launched fresh and closed afterward. (To screenshot a screen reached by " +
|
|
2493
2583
|
"real login or several taps, use a session instead and call tapp_screenshot along the way.)",
|
|
@@ -2529,7 +2619,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2529
2619
|
name: "tapp_install_app",
|
|
2530
2620
|
title: "Install app on sim",
|
|
2531
2621
|
description:
|
|
2532
|
-
"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 " +
|
|
2533
2623
|
"a session. Provide the Xcode project OR workspace path + scheme. Best-effort โ apps with CocoaPods/" +
|
|
2534
2624
|
"signing quirks may still need their normal build. Returns {ok, installed, simulator}.",
|
|
2535
2625
|
inputSchema: {
|
|
@@ -2679,7 +2769,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2679
2769
|
const text =
|
|
2680
2770
|
`๐จ Built **${path.basename(built.appPath)}** (scheme \`${built.scheme}\`) in ${fmtDuration(Date.now() - startedAt)}` +
|
|
2681
2771
|
(bundleId ? ` โ installed on the simulator as \`${bundleId}\`` : "") +
|
|
2682
|
-
`\n\nNext: \`
|
|
2772
|
+
`\n\nNext: \`tapp_explore\` with \`appBundleId: "${bundleId || "<install it first>"}"\`.`;
|
|
2683
2773
|
return richResult(text, { ok: true, appPath: built.appPath, scheme: built.scheme, container: built.container, bundleId });
|
|
2684
2774
|
}
|
|
2685
2775
|
|
|
@@ -2845,7 +2935,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2845
2935
|
return richResult(L.join("\n"), summary);
|
|
2846
2936
|
}
|
|
2847
2937
|
|
|
2848
|
-
if (name === "tapp_run_qa") {
|
|
2938
|
+
if (name === "tapp_explore" || name === "tapp_run_qa") { // tapp_run_qa: deprecated alias
|
|
2849
2939
|
const unauthorized = ensureAuthorized(args);
|
|
2850
2940
|
if (unauthorized) return unauthorized;
|
|
2851
2941
|
const wantsWeb = isNonEmptyString(args.url);
|
|
@@ -2953,7 +3043,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2953
3043
|
const { model, plan, written, exploration } = result;
|
|
2954
3044
|
const blocking = model.requirements.filter((item) => item.severity === "blocking");
|
|
2955
3045
|
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.
|
|
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)" : ""}` : ""}`;
|
|
2957
3047
|
return richResult(summary, { model, plan, written: written ? { modelPath: written.modelPath, planPath: written.planPath } : null, exploration });
|
|
2958
3048
|
} catch (error) { return errorResult("Could not initialize Tapp repository artifacts", { detail: error.message || String(error) }); }
|
|
2959
3049
|
}
|
|
@@ -3493,21 +3583,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3493
3583
|
const parsed = parseGeneratedFlow(mres.text);
|
|
3494
3584
|
if (!parsed) return errorResult("Model did not return a valid Flow", { raw: mres.text.slice(0, 500) });
|
|
3495
3585
|
|
|
3496
|
-
// 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).
|
|
3497
3589
|
const ungrounded = ungroundedScreens(parsed.steps, grounding);
|
|
3498
3590
|
const flow = { name: args.name || parsed.name, app: bundleId, steps: parsed.steps };
|
|
3499
3591
|
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");
|
|
3592
|
+
const dir = path.join(repoRoot, ".tapp", "proposals", "flows");
|
|
3501
3593
|
fs.mkdirSync(dir, { recursive: true });
|
|
3502
3594
|
const outPath = path.join(dir, `${slug}.yml`);
|
|
3595
|
+
const promotedPath = path.join(".tapp", "flows", `${slug}.yml`);
|
|
3503
3596
|
const yamlRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "to-yaml", JSON.stringify(flow)], { cwd: repoRoot });
|
|
3504
3597
|
const yaml = (yamlRes.stdout || "").trim();
|
|
3505
3598
|
if (!yaml) return errorResult("Failed to render flow YAML", { stderr: yamlRes.stderr });
|
|
3506
3599
|
fs.writeFileSync(outPath, yaml + "\n");
|
|
3507
3600
|
const rel = path.relative(repoRoot, outPath);
|
|
3508
3601
|
|
|
3509
|
-
const L = [`๐ค Generated flow **${flow.name}** from your goal โ \`${rel}\``];
|
|
3510
|
-
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.`);
|
|
3511
3605
|
L.push("", "```yaml", yaml, "```");
|
|
3512
3606
|
|
|
3513
3607
|
// 4) Optionally replay it now.
|
|
@@ -3522,9 +3616,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3522
3616
|
L.push("", "---", "", (rep.stdout || "").trim());
|
|
3523
3617
|
}
|
|
3524
3618
|
} else {
|
|
3525
|
-
L.push("", `Replay
|
|
3619
|
+
L.push("", `Replay the proposal: \`tapp_flow_run\` with \`flowPath: "${rel}"\`. After it passes, promote it to \`.tapp/flows/\`.`);
|
|
3526
3620
|
}
|
|
3527
|
-
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 });
|
|
3528
3622
|
}
|
|
3529
3623
|
|
|
3530
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 },
|