@aarwitz/tapp 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +123 -0
- package/Harness/OCQAHarness/AppDelegate.swift +21 -0
- package/Harness/OCQAHarness/Info.plist +26 -0
- package/Harness/OCQAHarness.xcodeproj/project.pbxproj +199 -0
- package/Harness/OCQAHarness.xcodeproj/xcshareddata/xcschemes/OCQAHarnessUITests.xcscheme +22 -0
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +4526 -0
- package/Harness/OCQAHarnessUITests/Info.plist +22 -0
- package/Harness/generate-harness-xcodeproj.rb +254 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/bin/tapp.js +1382 -0
- package/browser/app.css +227 -0
- package/browser/app.js +675 -0
- package/browser/index.html +195 -0
- package/browser/product-contract.js +25 -0
- package/browser/view-model.js +16 -0
- package/docs/BROWSER-PRODUCT.md +72 -0
- package/docs/PRODUCT-ENGINE.md +102 -0
- package/docs/application-model.md +276 -0
- package/docs/scenarios.md +95 -0
- package/mcp-server/src/android-driver.js +287 -0
- package/mcp-server/src/android-explorer.js +197 -0
- package/mcp-server/src/android-flow.js +89 -0
- package/mcp-server/src/application-model.js +1597 -0
- package/mcp-server/src/browser-product.js +659 -0
- package/mcp-server/src/browser-workspaces.js +234 -0
- package/mcp-server/src/ci-report.js +557 -0
- package/mcp-server/src/ci-setup.js +359 -0
- package/mcp-server/src/contract-authoring.js +10 -0
- package/mcp-server/src/enrich.js +57 -0
- package/mcp-server/src/flow-runtime.js +127 -0
- package/mcp-server/src/html-report.js +124 -0
- package/mcp-server/src/index.js +3775 -0
- package/mcp-server/src/maintenance-proposal.js +178 -0
- package/mcp-server/src/managed-operation.js +61 -0
- package/mcp-server/src/pr-selection.js +841 -0
- package/mcp-server/src/product-execution.js +155 -0
- package/mcp-server/src/product-operations.js +526 -0
- package/mcp-server/src/project-config.js +101 -0
- package/mcp-server/src/release-contract.d.ts +81 -0
- package/mcp-server/src/release-contract.js +226 -0
- package/mcp-server/src/report.js +363 -0
- package/mcp-server/src/scenario-runtime.js +139 -0
- package/mcp-server/src/static-server.js +44 -0
- package/mcp-server/src/task-runtime.js +266 -0
- package/mcp-server/src/ui-map.js +661 -0
- package/mcp-server/src/web-explorer.js +493 -0
- package/mcp-server/src/web-flow.js +238 -0
- package/package.json +82 -0
- package/scripts/android-corpus-e2e.sh +30 -0
- package/scripts/ci-gate.sh +323 -0
- package/scripts/cleanup-xcode.sh +157 -0
- package/scripts/compile-contract.js +27 -0
- package/scripts/compile-flow.js +18 -0
- package/scripts/corpus-apps.txt +9 -0
- package/scripts/corpus-sweep.sh +121 -0
- package/scripts/coverage-eval.sh +92 -0
- package/scripts/coverage_eval_parse.py +95 -0
- package/scripts/deploy-and-build.sh +99 -0
- package/scripts/flow-platform.js +18 -0
- package/scripts/flow_ai_judge.py +102 -0
- package/scripts/flow_lib.py +154 -0
- package/scripts/mutation-recall-desktop.sh +186 -0
- package/scripts/mutation-recall.sh +121 -0
- package/scripts/mutation_lib.py +128 -0
- package/scripts/mutation_operators.py +144 -0
- package/scripts/platform-gate.js +186 -0
- package/scripts/pr-plan.js +68 -0
- package/scripts/quick-capture.sh +419 -0
- package/scripts/run-android-flow.js +27 -0
- package/scripts/run-flow.sh +90 -0
- package/scripts/run-web-flow.js +28 -0
- package/scripts/run-web-scenario.js +23 -0
- package/scripts/validation-matrix.sh +146 -0
- package/scripts/vision-fp-eval.sh +206 -0
- package/scripts/vision_escalation_responder.py +147 -0
- package/scripts/vision_fp_probe.py +221 -0
|
@@ -0,0 +1,3775 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
9
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
|
+
import {
|
|
11
|
+
CallToolRequestSchema,
|
|
12
|
+
ListToolsRequestSchema,
|
|
13
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
14
|
+
|
|
15
|
+
import { parseOcqaMarkers, buildQaReport, computeRegression } from "./report.js";
|
|
16
|
+
|
|
17
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
18
|
+
const __dirname = path.dirname(__filename);
|
|
19
|
+
const repoRoot = path.resolve(__dirname, "../..");
|
|
20
|
+
const scriptsDir = path.join(repoRoot, "scripts");
|
|
21
|
+
// AUTOTAP_HOME (set by the `tapp` CLI when running as an installed npm package) redirects
|
|
22
|
+
// all writable output to a user dir; unset (repo dev flow) captures stay in the repo.
|
|
23
|
+
const autotapHome = (process.env.AUTOTAP_HOME || "").trim();
|
|
24
|
+
const capturesDir = autotapHome ? path.join(autotapHome, "captures") : path.join(repoRoot, "captures");
|
|
25
|
+
const MAX_OUTPUT_CHARS = 60_000;
|
|
26
|
+
const requiredAuthToken = (process.env.TAPP_MCP_TOKEN || process.env.AUTOTAP_MCP_TOKEN || "").trim();
|
|
27
|
+
|
|
28
|
+
function clampOutput(value, maxChars = MAX_OUTPUT_CHARS) {
|
|
29
|
+
if (typeof value !== "string") {
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (value.length <= maxChars) {
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const dropped = value.length - maxChars;
|
|
38
|
+
return `${value.slice(0, maxChars)}\n...[truncated ${dropped} chars]`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function asBoolean(value, fallback = false) {
|
|
42
|
+
return typeof value === "boolean" ? value : fallback;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function asInteger(value, fallback) {
|
|
46
|
+
if (Number.isInteger(value)) {
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (typeof value === "string" && /^-?\d+$/.test(value.trim())) {
|
|
51
|
+
return Number.parseInt(value, 10);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return fallback;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isNonEmptyString(value) {
|
|
58
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeCapturePath(inputPath) {
|
|
62
|
+
const resolved = path.resolve(inputPath);
|
|
63
|
+
const normalizedCapturesRoot = path.resolve(capturesDir);
|
|
64
|
+
const insideCaptures =
|
|
65
|
+
resolved === normalizedCapturesRoot || resolved.startsWith(`${normalizedCapturesRoot}${path.sep}`);
|
|
66
|
+
|
|
67
|
+
if (!insideCaptures) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return resolved;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isAuthRequired() {
|
|
75
|
+
return requiredAuthToken.length > 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function ensureAuthorized(args = {}) {
|
|
79
|
+
if (!isAuthRequired()) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const provided = typeof args.authToken === "string" ? args.authToken.trim() : "";
|
|
84
|
+
if (provided !== requiredAuthToken) {
|
|
85
|
+
return errorResult("Unauthorized", {
|
|
86
|
+
reason: "Provide valid authToken when AUTOTAP_MCP_TOKEN is set",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function runCommand(command, args = [], options = {}) {
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
const timeoutMs = Number.isInteger(options.timeoutMs) ? options.timeoutMs : 10 * 60 * 1000;
|
|
96
|
+
const child = spawn(command, args, {
|
|
97
|
+
cwd: options.cwd || repoRoot,
|
|
98
|
+
env: { ...process.env, ...(options.env || {}) },
|
|
99
|
+
shell: false,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
let stdout = "";
|
|
103
|
+
let stderr = "";
|
|
104
|
+
|
|
105
|
+
child.stdout.on("data", (chunk) => {
|
|
106
|
+
stdout += String(chunk);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
child.stderr.on("data", (chunk) => {
|
|
110
|
+
stderr += String(chunk);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
let timedOut = false;
|
|
114
|
+
const timer = setTimeout(() => {
|
|
115
|
+
timedOut = true;
|
|
116
|
+
child.kill("SIGTERM");
|
|
117
|
+
}, timeoutMs);
|
|
118
|
+
|
|
119
|
+
child.on("close", (code) => {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
const timeoutMessage = timedOut ? `\nProcess timed out after ${timeoutMs}ms` : "";
|
|
122
|
+
resolve({
|
|
123
|
+
code: timedOut ? 124 : (code ?? 1),
|
|
124
|
+
stdout: clampOutput(stdout),
|
|
125
|
+
stderr: clampOutput(`${stderr}${timeoutMessage}`.trim()),
|
|
126
|
+
timedOut,
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
child.on("error", (error) => {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
resolve({
|
|
133
|
+
code: 1,
|
|
134
|
+
stdout: clampOutput(stdout),
|
|
135
|
+
stderr: clampOutput(`${stderr}\n${error.message}`.trim()),
|
|
136
|
+
timedOut: false,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function listCaptureRuns(limit = 10) {
|
|
143
|
+
if (!fs.existsSync(capturesDir)) {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const entries = fs
|
|
148
|
+
.readdirSync(capturesDir, { withFileTypes: true })
|
|
149
|
+
.filter((d) => d.isDirectory())
|
|
150
|
+
.map((d) => {
|
|
151
|
+
const full = path.join(capturesDir, d.name);
|
|
152
|
+
const stat = fs.statSync(full);
|
|
153
|
+
return {
|
|
154
|
+
id: d.name,
|
|
155
|
+
path: full,
|
|
156
|
+
relativePath: path.relative(repoRoot, full),
|
|
157
|
+
modifiedAt: stat.mtime.toISOString(),
|
|
158
|
+
};
|
|
159
|
+
})
|
|
160
|
+
.sort((a, b) => (a.modifiedAt < b.modifiedAt ? 1 : -1));
|
|
161
|
+
|
|
162
|
+
return entries.slice(0, Math.max(1, limit));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function summarizeCapture(runPath) {
|
|
166
|
+
if (!fs.existsSync(runPath)) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const files = fs.readdirSync(runPath);
|
|
171
|
+
const screenshotsDir = files.includes("screenshots") ? path.join(runPath, "screenshots") : null;
|
|
172
|
+
const screenshotCount = screenshotsDir && fs.existsSync(screenshotsDir)
|
|
173
|
+
? fs.readdirSync(screenshotsDir).filter((f) => f.endsWith(".png") || f.endsWith(".jpg") || f.endsWith(".jpeg")).length
|
|
174
|
+
: 0;
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
path: runPath,
|
|
178
|
+
relativePath: path.relative(repoRoot, runPath),
|
|
179
|
+
hasMarkers: files.includes("ocqa-markers.txt"),
|
|
180
|
+
hasFullOutput: files.includes("full-output.txt"),
|
|
181
|
+
hasUITree: files.includes("uitree.json"),
|
|
182
|
+
videos: files.filter((f) => f.endsWith(".mov") || f.endsWith(".webm") || f.endsWith(".mp4")),
|
|
183
|
+
screenshotsDir,
|
|
184
|
+
screenshotCount,
|
|
185
|
+
files,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async function listSimulators() {
|
|
192
|
+
const res = await runCommand("xcrun", ["simctl", "list", "devices", "-j"]);
|
|
193
|
+
if (res.code !== 0) return { error: res.stderr || "simctl failed", simulators: [] };
|
|
194
|
+
let data;
|
|
195
|
+
try {
|
|
196
|
+
data = JSON.parse(res.stdout);
|
|
197
|
+
} catch {
|
|
198
|
+
return { error: "could not parse simctl JSON", simulators: [] };
|
|
199
|
+
}
|
|
200
|
+
const sims = [];
|
|
201
|
+
for (const [runtime, devices] of Object.entries(data.devices || {})) {
|
|
202
|
+
for (const d of devices || []) {
|
|
203
|
+
if (d.isAvailable === false) continue;
|
|
204
|
+
sims.push({
|
|
205
|
+
name: d.name,
|
|
206
|
+
udid: d.udid,
|
|
207
|
+
state: d.state,
|
|
208
|
+
booted: d.state === "Booted",
|
|
209
|
+
runtime: runtime.replace("com.apple.CoreSimulator.SimRuntime.", ""),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { simulators: sims, booted: sims.filter((s) => s.booted) };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Pre-flight for every iOS entry point: the #1 first-session failure is "no simulator
|
|
217
|
+
// booted", and the harness's raw failure text is unactionable. Long-running tools
|
|
218
|
+
// (run_qa) auto-boot the first available iPhone; fast tools return an instructive error
|
|
219
|
+
// the agent can act on instead of a shrug.
|
|
220
|
+
export async function ensureBootedSim({ autoBoot = false } = {}) {
|
|
221
|
+
const sims = await listSimulators();
|
|
222
|
+
if (sims.booted && sims.booted.length) return { booted: sims.booted[0] };
|
|
223
|
+
// A failed listing is NOT "no simulators" โ surface the real reason (was silently
|
|
224
|
+
// misdiagnosed as "No iOS simulators exist" when simctl errored in odd environments).
|
|
225
|
+
if (sims.error) {
|
|
226
|
+
return {
|
|
227
|
+
error:
|
|
228
|
+
`Could not query iOS simulators โ xcrun simctl failed: ${String(sims.error).slice(0, 300)}. ` +
|
|
229
|
+
`Is Xcode installed and healthy? Try in a terminal: xcrun simctl list devices`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
const candidate = (sims.simulators || []).find((s) => s.name.startsWith("iPhone")) || (sims.simulators || [])[0];
|
|
233
|
+
if (!candidate) {
|
|
234
|
+
return { error: "No iOS simulators exist on this Mac. Install a simulator runtime in Xcode (Settings โ Platforms), then retry." };
|
|
235
|
+
}
|
|
236
|
+
if (!autoBoot) {
|
|
237
|
+
return { error: `No simulator is booted. Call tapp_boot_simulator (e.g. udid "${candidate.udid}" โ ${candidate.name}) and retry.` };
|
|
238
|
+
}
|
|
239
|
+
await runCommand("xcrun", ["simctl", "boot", candidate.udid], { timeoutMs: 2 * 60 * 1000 });
|
|
240
|
+
const st = await runCommand("xcrun", ["simctl", "bootstatus", candidate.udid, "-b"], { timeoutMs: 3 * 60 * 1000 });
|
|
241
|
+
if (st.code !== 0) return { error: `Auto-boot of ${candidate.name} failed โ boot one manually with tapp_boot_simulator.` };
|
|
242
|
+
return { booted: candidate, autoBooted: true };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// The #1 real first-run failure: the bundle id isn't installed on the sim (typo, or the app was
|
|
246
|
+
// never installed). Without this pre-flight the harness reports a misleading "crashed at launch"
|
|
247
|
+
// and sessions die with an unactionable error โ check cheaply up front instead.
|
|
248
|
+
async function appInstalledOnBootedSim(bundleId) {
|
|
249
|
+
const r = await runCommand("xcrun", ["simctl", "get_app_container", "booted", bundleId, "app"], { timeoutMs: 15_000 });
|
|
250
|
+
return r.code === 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function notInstalledError(bundleId, booted) {
|
|
254
|
+
const name = booted && booted.name ? booted.name : "the booted simulator";
|
|
255
|
+
return (
|
|
256
|
+
`\`${bundleId}\` is not installed on ${name}. Install a simulator build first โ ` +
|
|
257
|
+
`tapp_install_app with the .app path (CLI: xcrun simctl install booted path/to/App.app) โ ` +
|
|
258
|
+
`or double-check the bundle id (xcrun simctl listapps booted).`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---- App target resolution: users have a repo, a built .app, or nothing โ not a bundle id.
|
|
263
|
+
// The ladder turns whatever they have into an installed bundle id. Shared by the CLI verbs
|
|
264
|
+
// and the tapp_build MCP tool.
|
|
265
|
+
|
|
266
|
+
export async function listInstalledUserApps() {
|
|
267
|
+
const r = await runCommand("xcrun", ["simctl", "listapps", "booted"], { timeoutMs: 30_000 });
|
|
268
|
+
if (r.code !== 0) return { error: "Could not list installed apps", details: { stderr: r.stderr } };
|
|
269
|
+
// simctl emits an old-style plist; plutil converts it.
|
|
270
|
+
const tmp = path.join(os.tmpdir(), `tapp-apps-${Date.now().toString(36)}.plist`);
|
|
271
|
+
fs.writeFileSync(tmp, r.stdout);
|
|
272
|
+
const conv = await runCommand("plutil", ["-convert", "json", "-o", "-", tmp], { timeoutMs: 30_000 });
|
|
273
|
+
try { fs.rmSync(tmp, { force: true }); } catch {}
|
|
274
|
+
let data;
|
|
275
|
+
try {
|
|
276
|
+
data = JSON.parse(conv.stdout);
|
|
277
|
+
} catch {
|
|
278
|
+
return { error: "Could not parse the installed-app list", details: { stderr: conv.stderr } };
|
|
279
|
+
}
|
|
280
|
+
const apps = Object.entries(data)
|
|
281
|
+
.filter(([bundleId, a]) => a && a.ApplicationType === "User" && !bundleId.endsWith(".xctrunner"))
|
|
282
|
+
.map(([bundleId, a]) => ({ bundleId, name: a.CFBundleDisplayName || a.CFBundleName || bundleId }));
|
|
283
|
+
return { apps };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Prefer a real .xcworkspace (CocoaPods layout) over a bare .xcodeproj; ignore the
|
|
287
|
+
// project.xcworkspace every .xcodeproj contains. Shallow search, dependency dirs skipped.
|
|
288
|
+
export function findXcodeContainer(startDir) {
|
|
289
|
+
// The target may BE the container ("build MyApp.xcodeproj" โ agents do this).
|
|
290
|
+
if (/\.(xcworkspace|xcodeproj)$/.test(startDir) && fs.existsSync(startDir)) return startDir;
|
|
291
|
+
const skip = new Set(["node_modules", "Pods", "DerivedData", "build", "Carthage", ".build", ".git"]);
|
|
292
|
+
const workspaces = [];
|
|
293
|
+
const projects = [];
|
|
294
|
+
const walk = (dir, depth) => {
|
|
295
|
+
let entries;
|
|
296
|
+
try {
|
|
297
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
298
|
+
} catch {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
for (const e of entries) {
|
|
302
|
+
if (!e.isDirectory()) continue;
|
|
303
|
+
const p = path.join(dir, e.name);
|
|
304
|
+
if (e.name.endsWith(".xcworkspace")) {
|
|
305
|
+
if (!dir.endsWith(".xcodeproj")) workspaces.push(p);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (e.name.endsWith(".xcodeproj")) {
|
|
309
|
+
projects.push(p);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (skip.has(e.name) || e.name.startsWith(".")) continue;
|
|
313
|
+
if (depth < 3) walk(p, depth + 1);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
walk(startDir, 0);
|
|
317
|
+
const shallowest = (arr) => arr.sort((a, b) => a.split(path.sep).length - b.split(path.sep).length)[0];
|
|
318
|
+
return workspaces.length ? shallowest(workspaces) : projects.length ? shallowest(projects) : null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function buildAppForSim({ dir, container, scheme, configuration = "Debug" } = {}) {
|
|
322
|
+
const target = container || findXcodeContainer(dir || process.cwd());
|
|
323
|
+
if (!target) return { error: `No Xcode project or workspace found under ${dir || process.cwd()}` };
|
|
324
|
+
const isWorkspace = target.endsWith(".xcworkspace");
|
|
325
|
+
let schemeName = isNonEmptyString(scheme) ? scheme.trim() : "";
|
|
326
|
+
if (!schemeName) {
|
|
327
|
+
const list = await runCommand("xcodebuild", ["-list", "-json", isWorkspace ? "-workspace" : "-project", target], { timeoutMs: 120_000 });
|
|
328
|
+
try {
|
|
329
|
+
const j = JSON.parse(list.stdout);
|
|
330
|
+
const schemes = (isWorkspace ? j.workspace && j.workspace.schemes : j.project && j.project.schemes) || [];
|
|
331
|
+
const base = path.basename(target).replace(/\.(xcworkspace|xcodeproj)$/, "");
|
|
332
|
+
schemeName = schemes.find((s) => s === base) || schemes.find((s) => !/tests?$/i.test(s)) || schemes[0];
|
|
333
|
+
} catch { /* fall through to the error below */ }
|
|
334
|
+
if (!schemeName) {
|
|
335
|
+
return {
|
|
336
|
+
error:
|
|
337
|
+
`Could not detect a scheme in ${path.basename(target)}. Pass one explicitly, and make sure it is ` +
|
|
338
|
+
`shared (Xcode: Product โ Scheme โ Manage Schemes โ check Shared).`,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const derived = path.join(autotapHome || os.tmpdir(), "app-builds", schemeName.replace(/[^a-zA-Z0-9]/g, "_"));
|
|
343
|
+
const build = await runCommand(
|
|
344
|
+
"xcodebuild",
|
|
345
|
+
[
|
|
346
|
+
"build",
|
|
347
|
+
isWorkspace ? "-workspace" : "-project", target,
|
|
348
|
+
"-scheme", schemeName,
|
|
349
|
+
"-configuration", configuration,
|
|
350
|
+
"-destination", "generic/platform=iOS Simulator",
|
|
351
|
+
"-derivedDataPath", derived,
|
|
352
|
+
],
|
|
353
|
+
{ cwd: path.dirname(target), timeoutMs: 25 * 60 * 1000 }
|
|
354
|
+
);
|
|
355
|
+
if (build.code !== 0) {
|
|
356
|
+
const errors = (build.stdout + "\n" + build.stderr).split("\n").filter((l) => /error:/i.test(l)).slice(0, 8);
|
|
357
|
+
return {
|
|
358
|
+
error: `Build failed (scheme ${schemeName})${build.timedOut ? " โ timed out" : ""}`,
|
|
359
|
+
details: { errors, tail: (build.stderr || build.stdout || "").slice(-1500) },
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
const productsDir = path.join(derived, "Build/Products", `${configuration}-iphonesimulator`);
|
|
363
|
+
let apps = [];
|
|
364
|
+
try {
|
|
365
|
+
// Exclude UI-test Runner bundles โ the classic wrong pick when a repo has test targets.
|
|
366
|
+
apps = fs.readdirSync(productsDir).filter((f) => f.endsWith(".app") && !f.endsWith("-Runner.app"));
|
|
367
|
+
} catch { /* handled below */ }
|
|
368
|
+
const appName = apps.find((f) => f.replace(/\.app$/, "") === schemeName) || apps[0];
|
|
369
|
+
if (!appName) return { error: "Built .app not found after the build", details: { productsDir } };
|
|
370
|
+
return { appPath: path.join(productsDir, appName), scheme: schemeName, container: target, configuration };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function androidApkCandidates(moduleDir) {
|
|
374
|
+
const output = path.join(moduleDir, "build", "outputs", "apk");
|
|
375
|
+
const candidates = [];
|
|
376
|
+
const visit = (dir) => {
|
|
377
|
+
let entries;
|
|
378
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
379
|
+
for (const entry of entries) {
|
|
380
|
+
const absolute = path.join(dir, entry.name);
|
|
381
|
+
if (entry.isDirectory()) visit(absolute);
|
|
382
|
+
else if (entry.isFile() && entry.name.endsWith(".apk") && !/androidTest|test/i.test(absolute)) candidates.push(absolute);
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
visit(output);
|
|
386
|
+
return candidates.sort((left, right) => {
|
|
387
|
+
const leftDebug = /debug/i.test(left) ? 1 : 0;
|
|
388
|
+
const rightDebug = /debug/i.test(right) ? 1 : 0;
|
|
389
|
+
if (leftDebug !== rightDebug) return rightDebug - leftDebug;
|
|
390
|
+
return fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs;
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export async function buildAndroidApp({ projectDir, gradleProjectDir, moduleDir, task = "assembleDebug" } = {}) {
|
|
395
|
+
const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
|
|
396
|
+
const gradleRoot = path.resolve(gradleProjectDir || root);
|
|
397
|
+
const moduleRoot = path.resolve(moduleDir || root);
|
|
398
|
+
if (!isInsideDir(root, gradleRoot) || !isInsideDir(root, moduleRoot)) return { error: "Android build paths must remain inside the repository" };
|
|
399
|
+
const wrapper = path.join(gradleRoot, process.platform === "win32" ? "gradlew.bat" : "gradlew");
|
|
400
|
+
const command = fs.existsSync(wrapper) ? (process.platform === "win32" ? wrapper : "bash") : "gradle";
|
|
401
|
+
const args = fs.existsSync(wrapper) && process.platform !== "win32" ? [wrapper, task, "--no-daemon"] : [task, "--no-daemon"];
|
|
402
|
+
const build = await runCommand(command, args, { cwd: gradleRoot, timeoutMs: 25 * 60 * 1000 });
|
|
403
|
+
if (build.code !== 0) {
|
|
404
|
+
const errors = `${build.stdout}\n${build.stderr}`.split("\n").filter((line) => /(?:error|failure|exception)/i.test(line)).slice(-10);
|
|
405
|
+
return { error: `Android build failed (${task})${build.timedOut ? " โ timed out" : ""}`, details: { errors, tail: (build.stderr || build.stdout || "").slice(-1800) } };
|
|
406
|
+
}
|
|
407
|
+
const apkPath = androidApkCandidates(moduleRoot)[0];
|
|
408
|
+
if (!apkPath) return { error: `Android build completed but no application APK was found under ${path.relative(root, moduleRoot) || "."}/build/outputs/apk` };
|
|
409
|
+
return { apkPath, task, gradleProjectDir: gradleRoot, moduleDir: moduleRoot };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export async function installAppOnBootedSim(appPath, { cleanInstall = true } = {}) {
|
|
413
|
+
const bid = await runCommand("/usr/libexec/PlistBuddy", ["-c", "Print CFBundleIdentifier", path.join(appPath, "Info.plist")], { timeoutMs: 30_000 });
|
|
414
|
+
const bundleId = (bid.stdout || "").trim();
|
|
415
|
+
if (!bundleId) return { error: `Could not read CFBundleIdentifier from ${appPath}/Info.plist โ is this a simulator .app build?` };
|
|
416
|
+
if (cleanInstall) {
|
|
417
|
+
// Clean install: stale keychain items from a previous install leave apps half-signed-in
|
|
418
|
+
// (Firebase Auth's "error accessing the keychain") โ uninstall first for a fresh state.
|
|
419
|
+
await runCommand("xcrun", ["simctl", "terminate", "booted", bundleId], { timeoutMs: 30_000 });
|
|
420
|
+
await runCommand("xcrun", ["simctl", "uninstall", "booted", bundleId], { timeoutMs: 60_000 });
|
|
421
|
+
}
|
|
422
|
+
const inst = await runCommand("xcrun", ["simctl", "install", "booted", appPath], { timeoutMs: 3 * 60 * 1000 });
|
|
423
|
+
if (inst.code !== 0) return { error: `Install failed: ${(inst.stderr || "").trim().slice(0, 300)}` };
|
|
424
|
+
return { bundleId };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export async function resolveAppTarget(input, { cwd = process.cwd(), onStatus = () => {}, scheme = "", configuration = "Debug" } = {}) {
|
|
428
|
+
const t = (input || "").trim();
|
|
429
|
+
|
|
430
|
+
// A built .app bundle โ install it, read the bundle id from Info.plist.
|
|
431
|
+
if (t.endsWith(".app")) {
|
|
432
|
+
const appPath = path.resolve(cwd, t);
|
|
433
|
+
if (!fs.existsSync(appPath)) return { error: `.app not found: ${appPath}` };
|
|
434
|
+
const sim = await ensureBootedSim({ autoBoot: true });
|
|
435
|
+
if (sim.error) return { error: sim.error };
|
|
436
|
+
onStatus(`Installing ${path.basename(appPath)}โฆ`);
|
|
437
|
+
const inst = await installAppOnBootedSim(appPath);
|
|
438
|
+
if (inst.error) return inst;
|
|
439
|
+
return {
|
|
440
|
+
bundleId: inst.bundleId,
|
|
441
|
+
via: `installed ${path.basename(appPath)}`,
|
|
442
|
+
targetResolution: { kind: "prebuilt-artifact-installed", bundleId: inst.bundleId },
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Looks like a bundle id (dots, not a path, not a local file) โ use as-is; the
|
|
447
|
+
// is-it-installed pre-flight downstream catches typos with an actionable message.
|
|
448
|
+
if (t && !t.includes("/") && t.includes(".") && !fs.existsSync(path.resolve(cwd, t))) {
|
|
449
|
+
return { bundleId: t, targetResolution: { kind: "application-id", bundleId: t } };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// A directory (or no argument at all) โ find the Xcode project, build, install.
|
|
453
|
+
const dir = t ? path.resolve(cwd, t) : cwd;
|
|
454
|
+
if (t && !fs.existsSync(dir)) return { error: `Not a bundle id, .app path, or directory: ${t}` };
|
|
455
|
+
const container = findXcodeContainer(dir);
|
|
456
|
+
if (container) {
|
|
457
|
+
const sim = await ensureBootedSim({ autoBoot: true });
|
|
458
|
+
if (sim.error) return { error: sim.error };
|
|
459
|
+
onStatus(`Found ${path.basename(container)} โ building for the simulator (a first build can take a few minutes)โฆ`);
|
|
460
|
+
const built = await buildAppForSim({ container, scheme, configuration });
|
|
461
|
+
if (built.error) return built;
|
|
462
|
+
onStatus(`Built ${path.basename(built.appPath)} (scheme ${built.scheme}) โ installingโฆ`);
|
|
463
|
+
const inst = await installAppOnBootedSim(built.appPath);
|
|
464
|
+
if (inst.error) return inst;
|
|
465
|
+
return {
|
|
466
|
+
bundleId: inst.bundleId,
|
|
467
|
+
via: `built ${path.basename(container)} โ installed ${path.basename(built.appPath)}`,
|
|
468
|
+
targetResolution: {
|
|
469
|
+
kind: "xcode-build-installed",
|
|
470
|
+
bundleId: inst.bundleId,
|
|
471
|
+
build: {
|
|
472
|
+
container: built.container,
|
|
473
|
+
scheme: built.scheme,
|
|
474
|
+
configuration: built.configuration,
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Nothing to build โ fall back to what's already on the simulator.
|
|
481
|
+
const sim = await ensureBootedSim({ autoBoot: true });
|
|
482
|
+
if (sim.error) return { error: sim.error };
|
|
483
|
+
const la = await listInstalledUserApps();
|
|
484
|
+
if (la.error) return la;
|
|
485
|
+
if (la.apps.length === 1) {
|
|
486
|
+
return {
|
|
487
|
+
bundleId: la.apps[0].bundleId,
|
|
488
|
+
via: `the only app installed on the simulator (${la.apps[0].name})`,
|
|
489
|
+
targetResolution: { kind: "installed-application", bundleId: la.apps[0].bundleId },
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
if (la.apps.length > 1) {
|
|
493
|
+
return {
|
|
494
|
+
error:
|
|
495
|
+
`No Xcode project found under ${dir}, and ${la.apps.length} apps are installed on the simulator โ say which one:\n` +
|
|
496
|
+
la.apps.map((a) => ` ${a.bundleId} (${a.name})`).join("\n"),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
error:
|
|
501
|
+
`Nothing to test: no Xcode project/workspace under ${dir} and no app installed on the simulator. ` +
|
|
502
|
+
`Run from your app repo, or pass a bundle id or a path to a simulator .app build.`,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ---- Persistent interactive session (Playwright-style tap/type/inspect loop) ----
|
|
507
|
+
// The harness `testInteractiveSession` launches the app ONCE and services commands from a file,
|
|
508
|
+
// emitting the fresh UI tree after each. The MCP server is a long-lived process, so it can hold the
|
|
509
|
+
// running session across tool calls.
|
|
510
|
+
let activeSession = null;
|
|
511
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
512
|
+
|
|
513
|
+
function consumeSessionStdout(chunk) {
|
|
514
|
+
if (!activeSession) return;
|
|
515
|
+
activeSession.buffer += chunk;
|
|
516
|
+
const START = "OCQA_UITREE_START";
|
|
517
|
+
const END = "OCQA_UITREE_END";
|
|
518
|
+
let s;
|
|
519
|
+
while ((s = activeSession.buffer.indexOf(START)) >= 0) {
|
|
520
|
+
const e = activeSession.buffer.indexOf(END, s);
|
|
521
|
+
if (e < 0) break;
|
|
522
|
+
const json = activeSession.buffer.slice(s + START.length, e).trim();
|
|
523
|
+
activeSession.buffer = activeSession.buffer.slice(e + END.length);
|
|
524
|
+
try {
|
|
525
|
+
activeSession.latestTree = JSON.parse(json);
|
|
526
|
+
activeSession.treeVersion += 1;
|
|
527
|
+
} catch {
|
|
528
|
+
/* partial/garbled tree โ ignore */
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (activeSession.buffer.includes("OCQA_SESSION:ready")) activeSession.ready = true;
|
|
532
|
+
if (activeSession.buffer.length > 200_000) activeSession.buffer = activeSession.buffer.slice(-50_000);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function treeSnapshot() {
|
|
536
|
+
const t = activeSession && activeSession.latestTree;
|
|
537
|
+
return {
|
|
538
|
+
screenTitle: t ? t.screenTitle ?? null : null,
|
|
539
|
+
elementCount: t ? (t.elements || []).length : 0,
|
|
540
|
+
elements: t ? t.elements || [] : [],
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function startSession(bundleId, extraEnv = {}) {
|
|
545
|
+
if (activeSession && !activeSession.ended) {
|
|
546
|
+
return { error: "A session is already active; call tapp_session_end first.", screen: treeSnapshot() };
|
|
547
|
+
}
|
|
548
|
+
const sim = await ensureBootedSim();
|
|
549
|
+
if (sim.error) return { error: sim.error };
|
|
550
|
+
if (!(await appInstalledOnBootedSim(bundleId))) return { error: notInstalledError(bundleId, sim.booted) };
|
|
551
|
+
const token = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
552
|
+
const cmdPath = `/tmp/ocqa-session-${token}-cmd.json`;
|
|
553
|
+
const resultPath = `/tmp/ocqa-session-${token}-res.json`;
|
|
554
|
+
for (const p of [cmdPath, resultPath]) { try { fs.rmSync(p, { force: true }); } catch {} }
|
|
555
|
+
|
|
556
|
+
const captureScript = path.join(scriptsDir, "quick-capture.sh");
|
|
557
|
+
const proc = spawn("bash", [captureScript, "session", bundleId], {
|
|
558
|
+
cwd: repoRoot,
|
|
559
|
+
env: { ...process.env, ...extraEnv, OCQA_SESSION_CMD_PATH: cmdPath, OCQA_SESSION_RESULT_PATH: resultPath, OCQA_SESSION_TIMEOUT: "7200" },
|
|
560
|
+
});
|
|
561
|
+
activeSession = {
|
|
562
|
+
proc, bundleId, seq: 0, cmdPath, resultPath, latestTree: null, treeVersion: 0, buffer: "", ready: false, ended: false,
|
|
563
|
+
// Always-on recorder: each act appends a Flow step; tapp_flow_save snapshots it to a file.
|
|
564
|
+
recording: [],
|
|
565
|
+
creds: { email: extraEnv.OCQA_TEST_EMAIL || "", password: extraEnv.OCQA_TEST_PASSWORD || "" },
|
|
566
|
+
lastScreen: null,
|
|
567
|
+
};
|
|
568
|
+
proc.stdout.on("data", (d) => consumeSessionStdout(String(d)));
|
|
569
|
+
// quick-capture writes harness build diagnostics to stderr. Preserve the bounded tail in the
|
|
570
|
+
// same session buffer so a remote/managed caller receives the actual Xcode failure instead of
|
|
571
|
+
// the generic "process exited" fallback.
|
|
572
|
+
proc.stderr.on("data", (d) => consumeSessionStdout(String(d)));
|
|
573
|
+
proc.on("close", () => { if (activeSession && activeSession.proc === proc) activeSession.ended = true; });
|
|
574
|
+
|
|
575
|
+
const deadline = Date.now() + 240_000; // build + launch can take a few minutes on first run
|
|
576
|
+
while (!activeSession.ready && Date.now() < deadline && !activeSession.ended) await sleep(300);
|
|
577
|
+
if (activeSession.ended) {
|
|
578
|
+
// Surface the real failure from the harness output instead of a shrug.
|
|
579
|
+
const errLine = ((activeSession.buffer || "").match(/error:\s*([^\n]+)/) || [])[1];
|
|
580
|
+
activeSession = null;
|
|
581
|
+
return { error: `Session process exited before it became ready${errLine ? ` โ ${errLine.trim()}` : " (build/launch failed?)."}` };
|
|
582
|
+
}
|
|
583
|
+
if (!activeSession.ready) { return { error: "Session did not become ready within the time limit." }; }
|
|
584
|
+
|
|
585
|
+
const td = Date.now() + 10_000;
|
|
586
|
+
while (!activeSession.latestTree && Date.now() < td) await sleep(200);
|
|
587
|
+
activeSession.lastScreen = treeSnapshot().screenTitle;
|
|
588
|
+
return { ok: true, ...treeSnapshot() };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async function startAndroidSession(appId, { serial, apkPath, clearData = true, testEmail = "", testPassword = "" } = {}) {
|
|
592
|
+
if (activeSession && !activeSession.ended) {
|
|
593
|
+
return { error: "A session is already active; call tapp_session_end first.", screen: treeSnapshot() };
|
|
594
|
+
}
|
|
595
|
+
try {
|
|
596
|
+
const { AndroidDriver } = await import("./android-driver.js");
|
|
597
|
+
const driver = new AndroidDriver({ appId, serial });
|
|
598
|
+
await driver.ensureDevice();
|
|
599
|
+
if (apkPath) await driver.install(path.resolve(apkPath));
|
|
600
|
+
const snap = await driver.launch({ clearData });
|
|
601
|
+
activeSession = {
|
|
602
|
+
platform: "android", driver, appId, bundleId: appId, latestTree: snap, treeVersion: 1,
|
|
603
|
+
recording: [], creds: { email: testEmail, password: testPassword }, lastScreen: snap.screenTitle,
|
|
604
|
+
ended: false,
|
|
605
|
+
};
|
|
606
|
+
return { ok: true, ...treeSnapshot() };
|
|
607
|
+
} catch (error) {
|
|
608
|
+
activeSession = null;
|
|
609
|
+
return { error: error.message || String(error) };
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function webSessionSnapshot(page) {
|
|
614
|
+
const snapshot = await page.evaluate(() => {
|
|
615
|
+
const visible = (element) => {
|
|
616
|
+
const style = getComputedStyle(element);
|
|
617
|
+
const rect = element.getBoundingClientRect();
|
|
618
|
+
return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
|
|
619
|
+
};
|
|
620
|
+
const controls = [...document.querySelectorAll("button,a[href],input,textarea,select,[role=button],[role=tab],[role=checkbox],[role=switch]")]
|
|
621
|
+
.filter((element) => element instanceof HTMLElement && visible(element))
|
|
622
|
+
.slice(0, 250)
|
|
623
|
+
.map((element) => {
|
|
624
|
+
const rect = element.getBoundingClientRect();
|
|
625
|
+
const label = String(element.getAttribute("aria-label") || element.labels?.[0]?.textContent || element.textContent || element.getAttribute("placeholder") || element.getAttribute("name") || element.id || "").replace(/\s+/g, " ").trim().slice(0, 160);
|
|
626
|
+
return {
|
|
627
|
+
id: element.getAttribute("data-testid") || element.id || element.getAttribute("name") || "",
|
|
628
|
+
label,
|
|
629
|
+
type: element.getAttribute("role") || element.tagName.toLowerCase(),
|
|
630
|
+
role: element.getAttribute("role") || (element.matches("button,[role=button]") ? "button" : element.matches("a") ? "link" : element.matches("input,textarea,select") ? "input" : "other"),
|
|
631
|
+
enabled: !(element.disabled || element.getAttribute("aria-disabled") === "true"),
|
|
632
|
+
hittable: true,
|
|
633
|
+
clickable: element.matches("button,a,[role=button],[role=tab],[role=checkbox],[role=switch]") && !(element.disabled || element.getAttribute("aria-disabled") === "true"),
|
|
634
|
+
secure: element instanceof HTMLInputElement && element.type === "password",
|
|
635
|
+
frame: { x:Math.round(rect.x), y:Math.round(rect.y), width:Math.round(rect.width), height:Math.round(rect.height) },
|
|
636
|
+
};
|
|
637
|
+
});
|
|
638
|
+
const heading = document.querySelector("h1,[role=heading]")?.textContent?.replace(/\s+/g, " ").trim();
|
|
639
|
+
return { screenTitle:heading || document.title || location.pathname || "Web application", elements:controls, url:location.href };
|
|
640
|
+
});
|
|
641
|
+
return snapshot;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function webAttributeSelector(attribute, value) {
|
|
645
|
+
return `[${attribute}="${String(value || "").replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"]`;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function firstVisibleWebLocator(page, target, { input = false } = {}) {
|
|
649
|
+
const value = String(target || "").trim();
|
|
650
|
+
if (!value) return null;
|
|
651
|
+
const candidates = [
|
|
652
|
+
page.locator(webAttributeSelector("data-testid", value)).first(),
|
|
653
|
+
page.locator(webAttributeSelector("id", value)).first(),
|
|
654
|
+
page.locator(webAttributeSelector("name", value)).first(),
|
|
655
|
+
page.getByLabel(value, { exact: true }).first(),
|
|
656
|
+
...(input ? [page.getByPlaceholder(value, { exact: true }).first()] : [
|
|
657
|
+
page.getByRole("button", { name:value, exact:true }).first(),
|
|
658
|
+
page.getByRole("link", { name:value, exact:true }).first(),
|
|
659
|
+
page.getByText(value, { exact:true }).first(),
|
|
660
|
+
]),
|
|
661
|
+
];
|
|
662
|
+
for (const locator of candidates) if (await locator.isVisible().catch(() => false)) return locator;
|
|
663
|
+
const fallback = page.getByText(value, { exact:false }).first();
|
|
664
|
+
return await fallback.isVisible().catch(() => false) ? fallback : null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function startWebSession(url, { testEmail = "", testPassword = "" } = {}) {
|
|
668
|
+
if (activeSession && !activeSession.ended) return { error: "A session is already active; call tapp_session_end first.", screen:treeSnapshot() };
|
|
669
|
+
let browser;
|
|
670
|
+
try {
|
|
671
|
+
const parsed = new URL(String(url || ""));
|
|
672
|
+
if (!/^https?:$/.test(parsed.protocol)) return { error:"Web session URL must be http(s)" };
|
|
673
|
+
const { loadPlaywright } = await import("./web-explorer.js");
|
|
674
|
+
const { chromium } = await loadPlaywright();
|
|
675
|
+
browser = await chromium.launch({ headless:true });
|
|
676
|
+
const context = await browser.newContext({ viewport:{ width:1280, height:900 } });
|
|
677
|
+
const page = await context.newPage();
|
|
678
|
+
await page.goto(parsed.href, { waitUntil:"domcontentloaded", timeout:30_000 });
|
|
679
|
+
await page.waitForLoadState("networkidle", { timeout:5_000 }).catch(() => {});
|
|
680
|
+
const snapshot = await webSessionSnapshot(page);
|
|
681
|
+
activeSession = {
|
|
682
|
+
platform:"web", browser, context, page, latestTree:snapshot, treeVersion:1, recording:[],
|
|
683
|
+
creds:{ email:testEmail, password:testPassword },
|
|
684
|
+
lastScreen:snapshot.screenTitle, ended:false,
|
|
685
|
+
};
|
|
686
|
+
return { ok:true, ...treeSnapshot(), url:snapshot.url };
|
|
687
|
+
} catch (error) {
|
|
688
|
+
await browser?.close().catch(() => {});
|
|
689
|
+
activeSession = null;
|
|
690
|
+
return { error:error.message || String(error) };
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Turn a typed value into a shareable token: known creds become $TEST_EMAIL / $TEST_PASSWORD. */
|
|
695
|
+
function templateValue(text) {
|
|
696
|
+
const c = (activeSession && activeSession.creds) || {};
|
|
697
|
+
if (c.email && text === c.email) return "$TEST_EMAIL";
|
|
698
|
+
if (c.password && text === c.password) return "$TEST_PASSWORD";
|
|
699
|
+
return text;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Append a Flow step for an act (record-by-doing). Inserts wait_for on screen change for
|
|
703
|
+
* deterministic replay. Inspection acts (tree/screenshot/wait) are not recorded. */
|
|
704
|
+
function recordStep(cmd, result) {
|
|
705
|
+
if (!activeSession || !activeSession.recording) return;
|
|
706
|
+
const newScreen = result && result.screenTitle;
|
|
707
|
+
const changed = newScreen && newScreen !== activeSession.lastScreen;
|
|
708
|
+
switch (cmd.action) {
|
|
709
|
+
case "tap": {
|
|
710
|
+
const target = cmd.id || cmd.label || (typeof cmd.x === "number" ? `${cmd.x},${cmd.y}` : "");
|
|
711
|
+
if (target) activeSession.recording.push({ tap: target });
|
|
712
|
+
if (changed) activeSession.recording.push({ wait_for: newScreen });
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
case "type": {
|
|
716
|
+
const step = { value: templateValue(cmd.text ?? "") };
|
|
717
|
+
if (cmd.id) step.field = cmd.id;
|
|
718
|
+
activeSession.recording.push({ type: step });
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
case "swipe":
|
|
722
|
+
activeSession.recording.push({ swipe: cmd.direction || "up" });
|
|
723
|
+
break;
|
|
724
|
+
case "back":
|
|
725
|
+
activeSession.recording.push({ back: true });
|
|
726
|
+
if (changed) activeSession.recording.push({ wait_for: newScreen });
|
|
727
|
+
break;
|
|
728
|
+
default:
|
|
729
|
+
break; // tree / screenshot / wait are inspection, not test steps
|
|
730
|
+
}
|
|
731
|
+
if (newScreen) activeSession.lastScreen = newScreen;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function sessionAct(cmd) {
|
|
735
|
+
if (!activeSession || activeSession.ended) return { error: "No active session. Call tapp_session_start first." };
|
|
736
|
+
if (activeSession.platform === "web") {
|
|
737
|
+
const session = activeSession;
|
|
738
|
+
let status = "ok";
|
|
739
|
+
let detail = null;
|
|
740
|
+
let typedInto = null;
|
|
741
|
+
try {
|
|
742
|
+
if (cmd.action === "tap") {
|
|
743
|
+
const locator = await firstVisibleWebLocator(session.page, cmd.id || cmd.label || "");
|
|
744
|
+
if (!locator) { status = "not_found"; detail = "No visible web control matched the semantic target"; }
|
|
745
|
+
else await locator.click({ timeout:10_000 });
|
|
746
|
+
} else if (cmd.action === "type") {
|
|
747
|
+
const locator = await firstVisibleWebLocator(session.page, cmd.id || cmd.label || "", { input:true });
|
|
748
|
+
if (!locator) { status = "not_found"; detail = "No visible web field matched the semantic target"; }
|
|
749
|
+
else { await locator.fill(String(cmd.text || "")); typedInto = cmd.id || cmd.label || null; }
|
|
750
|
+
} else if (cmd.action === "wait") {
|
|
751
|
+
const deadline = Date.now() + Math.max(100, Math.min(60_000, Number(cmd.timeoutMs) || 5000));
|
|
752
|
+
let locator = null;
|
|
753
|
+
while (!locator && Date.now() < deadline) {
|
|
754
|
+
locator = await firstVisibleWebLocator(session.page, cmd.id || cmd.text || "");
|
|
755
|
+
if (!locator) await session.page.waitForTimeout(120);
|
|
756
|
+
}
|
|
757
|
+
if (!locator) { status = "timeout"; detail = `Timed out waiting for ${cmd.id || cmd.text || "target"}`; }
|
|
758
|
+
} else if (cmd.action === "back") {
|
|
759
|
+
await session.page.goBack({ waitUntil:"domcontentloaded", timeout:10_000 }).catch(() => {});
|
|
760
|
+
} else if (cmd.action === "swipe") {
|
|
761
|
+
const amount = ["down", "right"].includes(cmd.direction) ? -650 : 650;
|
|
762
|
+
await session.page.mouse.wheel(cmd.direction === "left" || cmd.direction === "right" ? amount : 0, cmd.direction === "up" || cmd.direction === "down" ? amount : 650);
|
|
763
|
+
} else if (!['tree', 'screenshot'].includes(cmd.action)) {
|
|
764
|
+
status = "error"; detail = `Unsupported web session action '${cmd.action}'`;
|
|
765
|
+
}
|
|
766
|
+
await session.page.waitForTimeout(300);
|
|
767
|
+
session.latestTree = await webSessionSnapshot(session.page);
|
|
768
|
+
session.treeVersion += 1;
|
|
769
|
+
} catch (error) {
|
|
770
|
+
status = error.name === "TimeoutError" ? "timeout" : "error";
|
|
771
|
+
detail = error.message || String(error);
|
|
772
|
+
session.latestTree = await webSessionSnapshot(session.page).catch(() => session.latestTree);
|
|
773
|
+
}
|
|
774
|
+
const snapshot = treeSnapshot();
|
|
775
|
+
if (status === "ok") recordStep(cmd, snapshot);
|
|
776
|
+
return { status, typedInto, detail, ...snapshot, recordedSteps:session.recording.length, url:session.latestTree?.url || "" };
|
|
777
|
+
}
|
|
778
|
+
if (activeSession.platform === "android") {
|
|
779
|
+
const s = activeSession;
|
|
780
|
+
let status = "ok";
|
|
781
|
+
let detail = null;
|
|
782
|
+
let typedInto = null;
|
|
783
|
+
try {
|
|
784
|
+
if (cmd.action === "tap") {
|
|
785
|
+
if (typeof cmd.x === "number" && typeof cmd.y === "number") {
|
|
786
|
+
const r = await s.driver.adb(["shell", "input", "tap", String(Math.round(cmd.x)), String(Math.round(cmd.y))]);
|
|
787
|
+
status = r.code === 0 ? "ok" : "not_hittable";
|
|
788
|
+
} else {
|
|
789
|
+
const r = await s.driver.tap(cmd.id || cmd.label || "", s.latestTree);
|
|
790
|
+
status = r.status; detail = r.detail || null;
|
|
791
|
+
}
|
|
792
|
+
} else if (cmd.action === "type") {
|
|
793
|
+
const r = await s.driver.type(cmd.id || "", cmd.text || "", s.latestTree);
|
|
794
|
+
status = r.status; detail = r.detail || null; typedInto = cmd.id || r.element?.label || null;
|
|
795
|
+
} else if (cmd.action === "swipe") {
|
|
796
|
+
await s.driver.swipe(cmd.direction || "up");
|
|
797
|
+
} else if (cmd.action === "back") {
|
|
798
|
+
await s.driver.back();
|
|
799
|
+
} else if (cmd.action === "wait") {
|
|
800
|
+
const waited = await s.driver.waitFor(cmd.id || cmd.text || "", cmd.timeoutMs || 5000);
|
|
801
|
+
status = waited ? "ok" : "timeout";
|
|
802
|
+
if (waited) s.latestTree = waited;
|
|
803
|
+
} else if (cmd.action === "login") {
|
|
804
|
+
const fields = s.latestTree.elements.filter((e) => /EditText/i.test(e.type));
|
|
805
|
+
const emailField = fields.find((e) => /email|user/i.test(`${e.id} ${e.label}`)) || fields.find((e) => !e.secure);
|
|
806
|
+
const passwordField = fields.find((e) => e.secure || /password|passcode/i.test(`${e.id} ${e.label}`));
|
|
807
|
+
if (!emailField || !passwordField) {
|
|
808
|
+
status = "not_found"; detail = "Could not identify email and password fields";
|
|
809
|
+
} else {
|
|
810
|
+
const er = await s.driver.type(emailField.id || emailField.label, cmd.email || s.creds.email || "", s.latestTree);
|
|
811
|
+
s.latestTree = await s.driver.settle();
|
|
812
|
+
const pr = await s.driver.type(passwordField.id || passwordField.label, cmd.password || s.creds.password || "", s.latestTree);
|
|
813
|
+
s.latestTree = await s.driver.settle();
|
|
814
|
+
const submit = s.latestTree.elements.find((e) => e.clickable && /sign in|log in|login|continue/i.test(`${e.text} ${e.label} ${e.id}`));
|
|
815
|
+
if (er.status !== "ok" || pr.status !== "ok" || !submit) {
|
|
816
|
+
status = "not_found"; detail = "Could not fill or submit the login form";
|
|
817
|
+
} else {
|
|
818
|
+
const before = s.latestTree.screenTitle;
|
|
819
|
+
await s.driver.tap(submit.id || submit.description || submit.text, s.latestTree);
|
|
820
|
+
s.latestTree = await s.driver.settle();
|
|
821
|
+
if (s.latestTree.screenTitle === before) { status = "still_on_login"; detail = "Submit left the app on the login screen"; }
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
if (!["wait", "tree", "screenshot", "login"].includes(cmd.action)) s.latestTree = await s.driver.settle();
|
|
826
|
+
} catch (error) {
|
|
827
|
+
status = "error"; detail = error.message || String(error);
|
|
828
|
+
}
|
|
829
|
+
s.treeVersion += 1;
|
|
830
|
+
const snap = treeSnapshot();
|
|
831
|
+
if (status === "ok") recordStep(cmd, snap);
|
|
832
|
+
return { status, typedInto, detail, ...snap, recordedSteps: s.recording.length };
|
|
833
|
+
}
|
|
834
|
+
activeSession.seq += 1;
|
|
835
|
+
const seq = activeSession.seq;
|
|
836
|
+
const beforeVer = activeSession.treeVersion;
|
|
837
|
+
const tmp = activeSession.cmdPath + ".tmp";
|
|
838
|
+
fs.writeFileSync(tmp, JSON.stringify({ seq, ...cmd }));
|
|
839
|
+
fs.renameSync(tmp, activeSession.cmdPath); // atomic so the harness never reads a partial command
|
|
840
|
+
|
|
841
|
+
// A `wait` can block in the harness up to its own timeout โ give the ack poll enough headroom.
|
|
842
|
+
let status = "timeout";
|
|
843
|
+
let typedInto = null;
|
|
844
|
+
let detail = null;
|
|
845
|
+
// login runs a full fill+submit+verify sequence in the harness; wait can block up to its
|
|
846
|
+
// own timeout โ both need more ack headroom than a single tap.
|
|
847
|
+
const ackBudget = cmd.action === "wait" ? (cmd.timeoutMs || 5000) + 10_000 : cmd.action === "login" ? 180_000 : 60_000;
|
|
848
|
+
const deadline = Date.now() + ackBudget;
|
|
849
|
+
while (Date.now() < deadline && !activeSession.ended) {
|
|
850
|
+
await sleep(150);
|
|
851
|
+
try {
|
|
852
|
+
const res = JSON.parse(fs.readFileSync(activeSession.resultPath, "utf8"));
|
|
853
|
+
if (res.seq === seq) { status = res.status; typedInto = res.typedInto || null; detail = res.detail || null; break; }
|
|
854
|
+
} catch {}
|
|
855
|
+
}
|
|
856
|
+
// Give the post-action tree a moment to arrive.
|
|
857
|
+
const td = Date.now() + 5_000;
|
|
858
|
+
while (activeSession.treeVersion === beforeVer && Date.now() < td && !activeSession.ended) await sleep(150);
|
|
859
|
+
const snap = treeSnapshot();
|
|
860
|
+
if (status === "ok") recordStep(cmd, snap); // record only successful acts
|
|
861
|
+
return { status, typedInto, detail, ...snap, recordedSteps: activeSession ? activeSession.recording.length : 0 };
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async function endSession() {
|
|
865
|
+
if (!activeSession) return { ok: true, note: "no session" };
|
|
866
|
+
const s = activeSession;
|
|
867
|
+
if (s.platform === "web") {
|
|
868
|
+
activeSession = null;
|
|
869
|
+
await s.browser.close().catch(() => {});
|
|
870
|
+
return { ok:true };
|
|
871
|
+
}
|
|
872
|
+
if (s.platform === "android") {
|
|
873
|
+
await s.driver.forceStop().catch(() => {});
|
|
874
|
+
activeSession = null;
|
|
875
|
+
return { ok: true };
|
|
876
|
+
}
|
|
877
|
+
if (!s.ended) {
|
|
878
|
+
try {
|
|
879
|
+
s.seq += 1;
|
|
880
|
+
fs.writeFileSync(s.cmdPath, JSON.stringify({ seq: s.seq, action: "quit" }));
|
|
881
|
+
} catch {}
|
|
882
|
+
await sleep(800);
|
|
883
|
+
try { s.proc.kill("SIGTERM"); } catch {}
|
|
884
|
+
}
|
|
885
|
+
activeSession = null;
|
|
886
|
+
return { ok: true };
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Persist the recording owned by the shared interactive-session engine.
|
|
891
|
+
*
|
|
892
|
+
* The caller supplies the repository root deliberately: MCP uses the checkout
|
|
893
|
+
* that hosts this package, while browser/managed adapters use the customer's
|
|
894
|
+
* isolated workspace. Interface layers must not reproduce recorder or YAML
|
|
895
|
+
* semantics, and an existing human-authored Flow is never overwritten unless
|
|
896
|
+
* replacement was explicitly requested.
|
|
897
|
+
*/
|
|
898
|
+
export async function saveInteractiveSessionFlow({ projectDir, name, addFinalAssertion = true, replace = false, url = "" } = {}) {
|
|
899
|
+
if (!activeSession || activeSession.ended) throw new Error("No active session to save. Start one and drive it first.");
|
|
900
|
+
const flowName = String(name || "").trim();
|
|
901
|
+
if (!flowName) throw new Error("Flow name is required");
|
|
902
|
+
const root = path.resolve(String(projectDir || ""));
|
|
903
|
+
if (!projectDir || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error("A valid repository root is required to save a Flow");
|
|
904
|
+
const steps = [...(activeSession.recording || [])];
|
|
905
|
+
if (steps.length === 0) throw new Error("Nothing recorded yet โ perform some live-session actions first.");
|
|
906
|
+
if (addFinalAssertion && activeSession.lastScreen) {
|
|
907
|
+
const last = steps[steps.length - 1] || {};
|
|
908
|
+
if (!("assert_screen" in last)) steps.push({ assert_screen: activeSession.lastScreen });
|
|
909
|
+
}
|
|
910
|
+
const platform = activeSession.platform || "ios";
|
|
911
|
+
const flow = {
|
|
912
|
+
name: flowName,
|
|
913
|
+
...(platform !== "ios" ? { platform } : {}),
|
|
914
|
+
...(platform === "web"
|
|
915
|
+
? (String(url || "").trim() ? { url:String(url).trim() } : {})
|
|
916
|
+
: { app:activeSession.bundleId }),
|
|
917
|
+
...(platform === "android" ? { reset:"clear" } : {}),
|
|
918
|
+
steps,
|
|
919
|
+
};
|
|
920
|
+
const slug = flowName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "flow";
|
|
921
|
+
const dir = path.join(root, ".autotap", "flows");
|
|
922
|
+
const outPath = path.join(dir, `${slug}.yml`);
|
|
923
|
+
if (fs.existsSync(outPath) && !replace) {
|
|
924
|
+
const error = new Error(`Flow '${path.relative(root, outPath)}' already exists. Choose another name or explicitly replace it.`);
|
|
925
|
+
error.code = "TAPP_FLOW_EXISTS";
|
|
926
|
+
throw error;
|
|
927
|
+
}
|
|
928
|
+
const yamlResult = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "to-yaml", JSON.stringify(flow)], { cwd:root });
|
|
929
|
+
const yaml = String(yamlResult.stdout || "").trim();
|
|
930
|
+
if (!yaml || yamlResult.code !== 0) throw new Error(String(yamlResult.stderr || "Failed to render Flow YAML").trim());
|
|
931
|
+
fs.mkdirSync(dir, { recursive:true });
|
|
932
|
+
fs.writeFileSync(outPath, `${yaml}\n`, { flag:replace ? "w" : "wx" });
|
|
933
|
+
return { path:path.relative(root, outPath), flow, yaml, replaced:replace };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Structured interactive-session primitives for trusted local adapters such as the
|
|
937
|
+
// managed cloud runner. They preserve the same persistent harness, semantic selectors,
|
|
938
|
+
// action recording, and post-action UI tree used by MCP instead of reimplementing
|
|
939
|
+
// simulator control in an interface layer.
|
|
940
|
+
export {
|
|
941
|
+
startSession as startIosInteractiveSession,
|
|
942
|
+
startAndroidSession as startAndroidInteractiveSession,
|
|
943
|
+
startWebSession as startWebInteractiveSession,
|
|
944
|
+
sessionAct as actInteractiveSession,
|
|
945
|
+
endSession as endInteractiveSession,
|
|
946
|
+
};
|
|
947
|
+
|
|
948
|
+
export async function captureInteractiveSessionFrame(maxWidth = 900) {
|
|
949
|
+
if (!activeSession || activeSession.ended) return { error:"No active interactive session" };
|
|
950
|
+
if (activeSession.platform === "android") {
|
|
951
|
+
try {
|
|
952
|
+
const data = await activeSession.driver.screenshot();
|
|
953
|
+
return { data:data.toString("base64"), mimeType:"image/png", bytes:data.length };
|
|
954
|
+
} catch (error) { return { error:error.message || String(error) }; }
|
|
955
|
+
}
|
|
956
|
+
if (activeSession.platform === "web") {
|
|
957
|
+
try {
|
|
958
|
+
const data = await activeSession.page.screenshot({ type:"jpeg", quality:72, fullPage:false });
|
|
959
|
+
return { data:data.toString("base64"), mimeType:"image/jpeg", bytes:data.length };
|
|
960
|
+
} catch (error) { return { error:error.message || String(error) }; }
|
|
961
|
+
}
|
|
962
|
+
return captureScreenshotImage(maxWidth);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Fast "just show me a screen": launch the app fresh (optionally bypassing login), grab a screenshot
|
|
966
|
+
// while it's on screen, return the tree too, then close it. No exploration. Uses the session
|
|
967
|
+
// machinery only to keep the app alive long enough to photograph it.
|
|
968
|
+
export async function openApp(bundleId, extraEnv, maxWidth) {
|
|
969
|
+
const start = await startSession(bundleId, extraEnv);
|
|
970
|
+
if (start.error) return { error: start.error };
|
|
971
|
+
const img = await captureScreenshotImage(maxWidth);
|
|
972
|
+
const result = { screenTitle: start.screenTitle ?? null, elements: start.elements ?? [], img };
|
|
973
|
+
await endSession();
|
|
974
|
+
return result;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Run an autonomous exploration and stream OCQA_PROGRESS live by tailing the capture's
|
|
978
|
+
// harness-output.txt (which the explore mode writes to as the harness runs). onProgress is called
|
|
979
|
+
// with each {action,max,states} as it arrives. Resolves with the created capture once done.
|
|
980
|
+
async function runExploreStreaming(bundleId, actions, timeout, env, onProgress) {
|
|
981
|
+
const captureScript = path.join(scriptsDir, "quick-capture.sh");
|
|
982
|
+
const cmdArgs = [captureScript, "explore", bundleId, "--actions", String(actions), "--timeout", String(timeout)];
|
|
983
|
+
const before = new Set(listCaptureRuns(80).map((r) => r.id));
|
|
984
|
+
const proc = spawn("bash", cmdArgs, { cwd: repoRoot, env: { ...process.env, ...env } });
|
|
985
|
+
proc.stdout.on("data", () => {});
|
|
986
|
+
proc.stderr.on("data", () => {});
|
|
987
|
+
const closed = new Promise((res) => proc.on("close", (code) => res(code ?? 1)));
|
|
988
|
+
|
|
989
|
+
let captureDir = null;
|
|
990
|
+
let pos = 0;
|
|
991
|
+
let timedOut = false;
|
|
992
|
+
// Interactive runs pause for a human โ time spent waiting is excluded from the harness's own
|
|
993
|
+
// budget, so give the watchdog matching headroom.
|
|
994
|
+
const interactiveGrace = env.OCQA_INTERACTIVE_INPUT === "1" ? 900 : 0;
|
|
995
|
+
const hardDeadline = Date.now() + (timeout + 240 + interactiveGrace) * 1000;
|
|
996
|
+
const requestSidecar = env.OCQA_INPUT_RESPONSE_PATH ? env.OCQA_INPUT_RESPONSE_PATH + ".request" : null;
|
|
997
|
+
|
|
998
|
+
while (true) {
|
|
999
|
+
const which = await Promise.race([closed.then(() => "closed"), sleep(1200).then(() => "tick")]);
|
|
1000
|
+
if (!captureDir) {
|
|
1001
|
+
const c = listCaptureRuns(80).find((r) => !before.has(r.id));
|
|
1002
|
+
if (c) captureDir = c.path;
|
|
1003
|
+
}
|
|
1004
|
+
if (captureDir) {
|
|
1005
|
+
const hp = path.join(captureDir, "harness-output.txt");
|
|
1006
|
+
try {
|
|
1007
|
+
const size = fs.statSync(hp).size;
|
|
1008
|
+
if (size > pos) {
|
|
1009
|
+
const fd = fs.openSync(hp, "r");
|
|
1010
|
+
const buf = Buffer.alloc(size - pos);
|
|
1011
|
+
fs.readSync(fd, buf, 0, buf.length, pos);
|
|
1012
|
+
fs.closeSync(fd);
|
|
1013
|
+
pos = size;
|
|
1014
|
+
for (const line of buf.toString("utf8").split("\n")) {
|
|
1015
|
+
if (line.startsWith("OCQA_PROGRESS:")) {
|
|
1016
|
+
try { onProgress(JSON.parse(line.slice("OCQA_PROGRESS:".length))); } catch {}
|
|
1017
|
+
}
|
|
1018
|
+
// Surface harness pause requests to the host as a sidecar file next to the response
|
|
1019
|
+
// path โ prompting hosts (VS Code extension) poll it, answer the human, and write
|
|
1020
|
+
// the response file the harness itself is polling.
|
|
1021
|
+
if (requestSidecar && line.startsWith("OCQA_AWAIT_INPUT:")) {
|
|
1022
|
+
try { fs.writeFileSync(requestSidecar, line.slice("OCQA_AWAIT_INPUT:".length), { mode: 0o600 }); } catch {}
|
|
1023
|
+
}
|
|
1024
|
+
if (requestSidecar && line.startsWith("OCQA_INPUT_RESOLVED:")) {
|
|
1025
|
+
try { fs.rmSync(requestSidecar, { force: true }); } catch {}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
} catch {
|
|
1030
|
+
/* file not there yet */
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if (which === "closed") break;
|
|
1034
|
+
if (Date.now() > hardDeadline) { try { proc.kill("SIGTERM"); } catch {} timedOut = true; break; }
|
|
1035
|
+
}
|
|
1036
|
+
await closed;
|
|
1037
|
+
const created = listCaptureRuns(80).find((r) => !before.has(r.id))
|
|
1038
|
+
|| (captureDir ? { id: path.basename(captureDir), path: captureDir, relativePath: path.relative(repoRoot, captureDir) } : null);
|
|
1039
|
+
return { created, timedOut };
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Grab the booted simulator's current screen and return it downscaled + JPEG-compressed so the
|
|
1043
|
+
// payload stays small enough for an MCP client to render inline. Works standalone or mid-session
|
|
1044
|
+
// (it just photographs whatever is on the booted sim).
|
|
1045
|
+
export async function captureScreenshotImage(maxWidth) {
|
|
1046
|
+
const stamp = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
1047
|
+
const png = `/tmp/tapp-shot-${stamp}.png`;
|
|
1048
|
+
const jpg = `/tmp/tapp-shot-${stamp}.jpg`;
|
|
1049
|
+
const r = await runCommand("xcrun", ["simctl", "io", "booted", "screenshot", png], { timeoutMs: 30_000 });
|
|
1050
|
+
if (!fs.existsSync(png)) return { error: "Screenshot failed (is a simulator booted?)", stderr: r.stderr };
|
|
1051
|
+
await runCommand("sips", ["-Z", String(maxWidth), "-s", "format", "jpeg", "-s", "formatOptions", "60", png, "--out", jpg], { timeoutMs: 30_000 });
|
|
1052
|
+
const file = fs.existsSync(jpg) ? jpg : png;
|
|
1053
|
+
const data = fs.readFileSync(file).toString("base64");
|
|
1054
|
+
const mimeType = file === jpg ? "image/jpeg" : "image/png";
|
|
1055
|
+
const bytes = fs.statSync(file).size;
|
|
1056
|
+
for (const p of [png, jpg]) { try { fs.rmSync(p, { force: true }); } catch {} }
|
|
1057
|
+
return { data, mimeType, bytes };
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// ---- Model backend (subscription proxy / BYO key) โ mirrors Tapp/Services/ModelBackend.swift.
|
|
1061
|
+
// Used by AI-generate (tapp_flow_generate). Resolution: Tapp subscription token โ proxy;
|
|
1062
|
+
// else ANTHROPIC_API_KEY โ api.anthropic.com; else null (feature disabled).
|
|
1063
|
+
// Remote-AI opt-in for IMPLICIT model calls (post-run finding enrichment). A bare
|
|
1064
|
+
// ANTHROPIC_API_KEY is often ambient in dev shells โ its mere presence must never silently
|
|
1065
|
+
// change data-handling behavior. A subscription token is an explicit tapp choice, and
|
|
1066
|
+
// explicitly-invoked AI tools (tapp_flow_generate, assert_ai) carry their own consent.
|
|
1067
|
+
export function remoteAiOptedIn(env = process.env) {
|
|
1068
|
+
if ((env.AUTOTAP_SUBSCRIPTION_TOKEN || env.TAPP_SUBSCRIPTION_TOKEN || "").trim()) return true;
|
|
1069
|
+
return ["1", "true", "yes"].includes(String(env.TAPP_ENABLE_REMOTE_AI || "").trim().toLowerCase());
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Robust "is p inside root" โ a plain startsWith(root) accepts sibling dirs that share a
|
|
1073
|
+
// prefix (/repos/tapp vs /repos/tapp-malicious).
|
|
1074
|
+
export function isInsideDir(root, p) {
|
|
1075
|
+
const rel = path.relative(root, p);
|
|
1076
|
+
return rel === "" || (!rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel));
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function resolveModelBackend() {
|
|
1080
|
+
const token = (process.env.AUTOTAP_SUBSCRIPTION_TOKEN || "").trim();
|
|
1081
|
+
if (token) {
|
|
1082
|
+
const base = (process.env.AUTOTAP_PROXY_URL || "http://localhost:8787").replace(/\/$/, "");
|
|
1083
|
+
const url = base.endsWith("/v1/messages") ? base : base + "/v1/messages";
|
|
1084
|
+
return { url, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" } };
|
|
1085
|
+
}
|
|
1086
|
+
const key = (process.env.ANTHROPIC_API_KEY || "").trim();
|
|
1087
|
+
if (key) {
|
|
1088
|
+
return { url: "https://api.anthropic.com/v1/messages", headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json" } };
|
|
1089
|
+
}
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
async function callModel(backend, { system, userText, model, maxTokens = 1500 }) {
|
|
1094
|
+
const body = JSON.stringify({ model: model || process.env.AUTOTAP_FLOW_MODEL || "claude-sonnet-4-6", max_tokens: maxTokens, system, messages: [{ role: "user", content: userText }] });
|
|
1095
|
+
const res = await fetch(backend.url, { method: "POST", headers: backend.headers, body });
|
|
1096
|
+
if (!res.ok) return { error: `model HTTP ${res.status}: ${(await res.text()).slice(0, 300)}` };
|
|
1097
|
+
const data = await res.json();
|
|
1098
|
+
const text = (data.content || []).filter((b) => b.type === "text").map((b) => b.text).join("\n");
|
|
1099
|
+
return { text };
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Build a compact grounding map of the app from a harness markers file: the distinct screens with
|
|
1103
|
+
// their controls (from the OCQA_STATE `summary`) and the observed transitions. The model authors a
|
|
1104
|
+
// Flow using ONLY what appears here, so it can't invent screens/buttons.
|
|
1105
|
+
function buildAppGrounding(markersText) {
|
|
1106
|
+
const screens = new Map(); // title -> { role, summary }
|
|
1107
|
+
const transitions = [];
|
|
1108
|
+
let startScreen = null; // the first observed screen = the app's launch/entry point
|
|
1109
|
+
for (const line of markersText.split(/\r?\n/)) {
|
|
1110
|
+
const t = line.trim();
|
|
1111
|
+
if (t.startsWith("OCQA_STATE:{")) {
|
|
1112
|
+
try {
|
|
1113
|
+
const s = JSON.parse(t.slice("OCQA_STATE:".length));
|
|
1114
|
+
const title = (s.screen || "").trim();
|
|
1115
|
+
if (title && title !== "Unknown") {
|
|
1116
|
+
if (!startScreen) startScreen = title;
|
|
1117
|
+
if (!screens.has(title)) screens.set(title, { role: s.role || "", summary: s.summary || "" });
|
|
1118
|
+
}
|
|
1119
|
+
} catch {}
|
|
1120
|
+
} else if (t.startsWith("OCQA_TRANSITION_RESOLVED:{")) {
|
|
1121
|
+
try {
|
|
1122
|
+
const o = JSON.parse(t.slice("OCQA_TRANSITION_RESOLVED:".length));
|
|
1123
|
+
if (o.from && o.to) transitions.push({ from: o.from, to: o.to, via: o.action || "" });
|
|
1124
|
+
} catch {}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return { startScreen, screens: Array.from(screens.entries()).map(([title, v]) => ({ title, ...v })), transitions };
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Pull the clean, short control labels out of a describeScreen summary
|
|
1131
|
+
// ("โฆ Fields: Email, Password. Actions: Sign In, Sign Up.") โ these are the exact strings the
|
|
1132
|
+
// selector resolves, unlike the screen's long descriptive text.
|
|
1133
|
+
function controlsFromSummary(summary) {
|
|
1134
|
+
const grab = (label) => {
|
|
1135
|
+
const m = new RegExp(`${label}:\\s*([^.]+)\\.`).exec(summary || "");
|
|
1136
|
+
return m ? m[1].split(",").map((s) => s.trim()).filter((s) => s && s.length <= 40) : [];
|
|
1137
|
+
};
|
|
1138
|
+
return { actions: grab("Actions"), fields: grab("Fields") };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function renderGroundingForPrompt(g) {
|
|
1142
|
+
// Per screen: the exact short control labels the flow may tap/type into.
|
|
1143
|
+
const L = [];
|
|
1144
|
+
if (g.startScreen) L.push(`ENTRY POINT: the app launches on screen "${g.startScreen}". Your FIRST step acts on that screen.`, "");
|
|
1145
|
+
L.push("OBSERVED SCREENS โ tap targets MUST be an exact control listed for the screen you are on; screen names (wait_for/assert_screen) MUST be an exact title below:");
|
|
1146
|
+
for (const s of g.screens.slice(0, 40)) {
|
|
1147
|
+
const { actions, fields } = controlsFromSummary(s.summary);
|
|
1148
|
+
const parts = [];
|
|
1149
|
+
if (actions.length) parts.push(`tap: ${actions.map((a) => `"${a}"`).join(", ")}`);
|
|
1150
|
+
if (fields.length) parts.push(`fields: ${fields.map((f) => `"${f}"`).join(", ")}`);
|
|
1151
|
+
L.push(`- screen "${s.title}"${s.role ? ` [${s.role}]` : ""}${parts.length ? " โ " + parts.join("; ") : ""}`);
|
|
1152
|
+
}
|
|
1153
|
+
if (g.transitions.length) {
|
|
1154
|
+
L.push("", "KNOWN NAVIGATIONS (tapping the control moved between screens โ prefer these for navigation):");
|
|
1155
|
+
const seen = new Set();
|
|
1156
|
+
for (const tr of g.transitions) {
|
|
1157
|
+
const via = tr.via.replace(/^label:|^id:/, "");
|
|
1158
|
+
const k = `${tr.from}|${tr.to}|${via}`;
|
|
1159
|
+
if (seen.has(k) || via.length > 40) continue; seen.add(k);
|
|
1160
|
+
L.push(`- on "${tr.from}", tap "${via}" โ "${tr.to}"`);
|
|
1161
|
+
if (seen.size >= 40) break;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
return L.join("\n");
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const FLOW_AUTHOR_SYSTEM =
|
|
1168
|
+
"You author DETERMINISTIC end-to-end test Flows for an iOS app that Tapp will replay exactly. " +
|
|
1169
|
+
"You are given the app's REAL observed screens with the exact short control labels tappable on each, " +
|
|
1170
|
+
"the exact input field names, and the known navigations, plus a goal. Emit the SHORTEST Flow that " +
|
|
1171
|
+
"achieves the goal. HARD RULES: (1) a `tap` target must be VERBATIM one of the short control labels " +
|
|
1172
|
+
"listed for the screen you are currently on โ NEVER a screen's descriptive sentence or a made-up " +
|
|
1173
|
+
"label; (2) `wait_for` and `assert_screen` must be a VERBATIM screen title from the list; (3) after " +
|
|
1174
|
+
"any tap that navigates, add `wait_for: <destination>`; (4) `type` only into a listed field; use " +
|
|
1175
|
+
"`$TEST_EMAIL`/`$TEST_PASSWORD` for credentials. If the goal cannot be reached with the observed " +
|
|
1176
|
+
"controls, produce the closest partial flow and stop โ do not invent. Respond with ONLY JSON: " +
|
|
1177
|
+
'{"name":"<short name>","steps":[ {"tap":"X"}, {"wait_for":"Y"}, {"type":{"field":"F","value":"V"}}, {"assert_screen":"Z"}, {"assert_exists":"W"} ]}. ' +
|
|
1178
|
+
"No prose, no code fences.";
|
|
1179
|
+
|
|
1180
|
+
/** Parse the model's Flow JSON (tolerant of fences/prose). Returns { name, steps } or null. */
|
|
1181
|
+
function parseGeneratedFlow(text) {
|
|
1182
|
+
const s = text.indexOf("{"), e = text.lastIndexOf("}");
|
|
1183
|
+
if (s < 0 || e <= s) return null;
|
|
1184
|
+
let obj;
|
|
1185
|
+
try { obj = JSON.parse(text.slice(s, e + 1)); } catch { return null; }
|
|
1186
|
+
if (!Array.isArray(obj.steps) || obj.steps.length === 0) return null;
|
|
1187
|
+
return { name: typeof obj.name === "string" ? obj.name : "Generated flow", steps: obj.steps };
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** Flag steps that reference screens or tap targets not present in the grounding (hallucination
|
|
1191
|
+
* guard โ screen names must be observed titles; tap targets must be observed short controls). */
|
|
1192
|
+
function ungroundedScreens(steps, grounding) {
|
|
1193
|
+
const knownScreens = new Set(grounding.screens.map((s) => s.title.toLowerCase()));
|
|
1194
|
+
const knownControls = new Set();
|
|
1195
|
+
for (const s of grounding.screens) {
|
|
1196
|
+
const { actions, fields } = controlsFromSummary(s.summary);
|
|
1197
|
+
for (const a of [...actions, ...fields]) knownControls.add(a.toLowerCase());
|
|
1198
|
+
}
|
|
1199
|
+
for (const tr of grounding.transitions) knownControls.add(tr.via.replace(/^label:|^id:/, "").toLowerCase());
|
|
1200
|
+
const bad = [];
|
|
1201
|
+
for (const step of steps) {
|
|
1202
|
+
const screen = step.wait_for || step.assert_screen;
|
|
1203
|
+
if (typeof screen === "string" && screen && !knownScreens.has(screen.toLowerCase())) bad.push(screen);
|
|
1204
|
+
const tapT = step.tap;
|
|
1205
|
+
if (typeof tapT === "string" && tapT && knownControls.size && !knownControls.has(tapT.toLowerCase())) bad.push(`tap:${tapT}`);
|
|
1206
|
+
}
|
|
1207
|
+
return Array.from(new Set(bad));
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Build the harness environment shared by run_qa and session_start: test credentials, app launch
|
|
1211
|
+
// arguments / environment (e.g. UI_TEST_BACKEND, --uitesting / login bypass), and deterministic
|
|
1212
|
+
// field overrides. quick-capture.sh folds the *_JSON vars into the run config.
|
|
1213
|
+
export function explorationEnvFromArgs(args) {
|
|
1214
|
+
const env = {};
|
|
1215
|
+
if (isNonEmptyString(args.testEmail)) env.OCQA_TEST_EMAIL = args.testEmail;
|
|
1216
|
+
if (isNonEmptyString(args.testPassword)) env.OCQA_TEST_PASSWORD = args.testPassword;
|
|
1217
|
+
if (Array.isArray(args.appLaunchArgs)) {
|
|
1218
|
+
const a = args.appLaunchArgs.filter((s) => typeof s === "string" && s.length > 0);
|
|
1219
|
+
if (a.length) env.OCQA_APP_LAUNCH_ARGS_JSON = JSON.stringify(a);
|
|
1220
|
+
}
|
|
1221
|
+
if (args.appLaunchEnv && typeof args.appLaunchEnv === "object" && !Array.isArray(args.appLaunchEnv)) {
|
|
1222
|
+
const e = Object.fromEntries(Object.entries(args.appLaunchEnv).filter(([k, v]) => typeof k === "string" && typeof v === "string"));
|
|
1223
|
+
if (Object.keys(e).length) env.OCQA_APP_LAUNCH_ENV_JSON = JSON.stringify(e);
|
|
1224
|
+
}
|
|
1225
|
+
// Interactive mid-run input: the harness pauses at input screens (OCQA_AWAIT_INPUT) and
|
|
1226
|
+
// polls the response path โ only when the host can actually prompt a human.
|
|
1227
|
+
if (args.interactive === true && isNonEmptyString(args.interactiveResponsePath)) {
|
|
1228
|
+
env.OCQA_INTERACTIVE_INPUT = "1";
|
|
1229
|
+
env.OCQA_INPUT_RESPONSE_PATH = args.interactiveResponsePath.trim();
|
|
1230
|
+
}
|
|
1231
|
+
if (args.inputOverrides && typeof args.inputOverrides === "object" && !Array.isArray(args.inputOverrides)) {
|
|
1232
|
+
const entries = Object.entries(args.inputOverrides).filter(
|
|
1233
|
+
([k, v]) => typeof k === "string" && typeof v === "string" && k.trim() && v.length > 0
|
|
1234
|
+
);
|
|
1235
|
+
if (entries.length) env.OCQA_INPUT_OVERRIDES_JSON = JSON.stringify(Object.fromEntries(entries.map(([k, v]) => [k.trim(), v])));
|
|
1236
|
+
}
|
|
1237
|
+
if (args.prExplorationTarget && typeof args.prExplorationTarget === "object" && !Array.isArray(args.prExplorationTarget)) {
|
|
1238
|
+
env.OCQA_PR_TARGET_JSON = JSON.stringify(args.prExplorationTarget);
|
|
1239
|
+
}
|
|
1240
|
+
// Explicit login replay: a recorded sequence run before exploration, for custom login UIs the
|
|
1241
|
+
// heuristic preamble can't parse โ the #1 reason a real app stays invisible. Steps are
|
|
1242
|
+
// {action: type|tap|wait, target, value?, timeoutMs?}; $TEST_EMAIL/$TEST_PASSWORD substituted
|
|
1243
|
+
// harness-side. Accepts step objects, or "action:target[:value]" strings for convenience.
|
|
1244
|
+
if (Array.isArray(args.loginSteps)) {
|
|
1245
|
+
const steps = args.loginSteps
|
|
1246
|
+
.map((s) => {
|
|
1247
|
+
if (s && typeof s === "object" && isNonEmptyString(s.action) && isNonEmptyString(s.target)) {
|
|
1248
|
+
const step = { action: String(s.action).toLowerCase(), target: String(s.target) };
|
|
1249
|
+
if (s.action === "wait" && Number.isInteger(s.timeoutMs)) step.timeoutMs = s.timeoutMs;
|
|
1250
|
+
else if (isNonEmptyString(s.value)) step.value = s.value;
|
|
1251
|
+
return step;
|
|
1252
|
+
}
|
|
1253
|
+
if (typeof s === "string") {
|
|
1254
|
+
const [action, target, ...rest] = s.split(":");
|
|
1255
|
+
if (!action || !target) return null;
|
|
1256
|
+
const third = rest.join(":");
|
|
1257
|
+
const step = { action: action.trim().toLowerCase(), target: target.trim() };
|
|
1258
|
+
if (step.action === "wait" && /^\d+$/.test(third)) step.timeoutMs = parseInt(third, 10);
|
|
1259
|
+
else if (third) step.value = third;
|
|
1260
|
+
return step;
|
|
1261
|
+
}
|
|
1262
|
+
return null;
|
|
1263
|
+
})
|
|
1264
|
+
.filter(Boolean);
|
|
1265
|
+
if (steps.length) env.OCQA_LOGIN_STEPS_JSON = JSON.stringify(steps);
|
|
1266
|
+
}
|
|
1267
|
+
return env;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function toolResult(value) {
|
|
1271
|
+
return {
|
|
1272
|
+
content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }],
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function errorResult(message, details = {}) {
|
|
1277
|
+
return {
|
|
1278
|
+
isError: true,
|
|
1279
|
+
content: [{ type: "text", text: `โ ${message}` }],
|
|
1280
|
+
structuredContent: { error: message, ...details },
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// ---- Modern, scannable tool output -------------------------------------------------------------
|
|
1285
|
+
// Copilot / Cursor / Claude render the text of a tool result in-chat. Lead every result with a
|
|
1286
|
+
// one-line ACTION headline + a compact, human-scannable body (severity icons, action words, next
|
|
1287
|
+
// steps) instead of a raw JSON dump, and attach the full data via `structuredContent` for
|
|
1288
|
+
// programmatic use. This is what makes Tapp feel like a modern dev harness
|
|
1289
|
+
// ("Explored 14 screens ยท 3 issues ยท ship: caution") rather than a wall of JSON.
|
|
1290
|
+
const SEV = { critical: "๐ด", high: "๐ ", medium: "๐ก", low: "โช๏ธ" };
|
|
1291
|
+
const VERDICT_BADGE = { ready: "๐ข SHIP-READY", caution: "๐ก CAUTION", blocked: "๐ด BLOCKED" };
|
|
1292
|
+
|
|
1293
|
+
/** Result with a human-readable text block first and structured data attached for the agent. */
|
|
1294
|
+
function richResult(text, structured) {
|
|
1295
|
+
const out = { content: [{ type: "text", text: String(text).trimEnd() }] };
|
|
1296
|
+
if (structured !== undefined) out.structuredContent = structured;
|
|
1297
|
+
return out;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function fmtDuration(ms) {
|
|
1301
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
1302
|
+
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/** Format a QA report as a scannable release readout with next-step suggestions. */
|
|
1306
|
+
function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured, reportHtml, recording, uiMap } = {}) {
|
|
1307
|
+
const c = report.findingCounts || {};
|
|
1308
|
+
const badge = VERDICT_BADGE[report.verdict] || report.verdict;
|
|
1309
|
+
const sevBits = ["critical", "high", "medium", "low"]
|
|
1310
|
+
.map((k) => (c[k] ? `${SEV[k]} ${c[k]} ${k}` : null))
|
|
1311
|
+
.filter(Boolean)
|
|
1312
|
+
.join(", ");
|
|
1313
|
+
const L = [];
|
|
1314
|
+
L.push(`### ๐งช QA complete โ ${badge} ยท release score ${report.confidence}/100${bundleId ? `\n\`${bundleId}\`` : ""}`);
|
|
1315
|
+
L.push("");
|
|
1316
|
+
L.push(report.headline);
|
|
1317
|
+
L.push("");
|
|
1318
|
+
L.push(`**Coverage** โ ${report.screensExplored} screens ยท ${report.actionsPerformed} actions${timedOut ? " ยท โฑ๏ธ hit time limit" : ""}`);
|
|
1319
|
+
if (uiMap) L.push(`**UI Map** โ ${uiMap.nodeCount} states ยท ${uiMap.edgeCount} transitions ยท ${uiMap.controlCount} semantic controls ยท ${uiMap.path}`);
|
|
1320
|
+
if (reportHtml) L.push(`**Evidence** โ ๐ ${reportHtml} (screenshots of every screen + findings, shareable)`);
|
|
1321
|
+
if (recording) L.push(`**Recording** โ ๐ฌ ${recording} (full exploration, embedded in the evidence page)`);
|
|
1322
|
+
L.push(`**Issues** โ ${c.total ? `${c.total}${sevBits ? ` (${sevBits})` : ""}` : "none found โจ"}`);
|
|
1323
|
+
if (Array.isArray(report.findings) && report.findings.length) {
|
|
1324
|
+
L.push("");
|
|
1325
|
+
L.push("**Findings**");
|
|
1326
|
+
for (const f of report.findings.slice(0, 12)) {
|
|
1327
|
+
L.push(`- ${SEV[f.severity] || "โข"} \`${f.severity}\` ${f.title}${f.screen ? ` โ on *${f.screen}*` : ""}`);
|
|
1328
|
+
if (f.aiAnalysis) L.push(` - why: ${String(f.aiAnalysis).slice(0, 200)}`);
|
|
1329
|
+
if (f.suggestedFix) L.push(` - fix: ${String(f.suggestedFix).slice(0, 200)}`);
|
|
1330
|
+
}
|
|
1331
|
+
if (report.findings.length > 12) L.push(`- โฆand ${report.findings.length - 12} more`);
|
|
1332
|
+
// The "why?" itch is the AI-value moment โ say it exactly here, only when it's real
|
|
1333
|
+
// (a key genuinely unlocks root causes + fixes), and never on a clean run.
|
|
1334
|
+
if (!aiConfigured) {
|
|
1335
|
+
L.push("");
|
|
1336
|
+
L.push("> ๐ก Want a root cause + suggested fix for each finding? Set `ANTHROPIC_API_KEY` + `TAPP_ENABLE_REMOTE_AI=1` and re-run โ analysis appears inline (sends finding metadata to the model provider; see SECURITY.md).");
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
if (regression && regression.counts) {
|
|
1340
|
+
const g = regression.gate || {};
|
|
1341
|
+
L.push("");
|
|
1342
|
+
L.push(
|
|
1343
|
+
`**Since last run** โ +${regression.counts.new} new ยท ${regression.counts.persisting} persisting ยท ${regression.counts.resolved} resolved ยท gate ${g.failed ? "๐ด FAIL" : "๐ข PASS"}`
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
if (inputHint) {
|
|
1347
|
+
L.push("");
|
|
1348
|
+
L.push(`> โน๏ธ ${inputHint}`);
|
|
1349
|
+
}
|
|
1350
|
+
// The honesty label: a "ready" is a claim about exactly these classes, nothing more.
|
|
1351
|
+
if (Array.isArray(report.checkedFor) && report.checkedFor.length) {
|
|
1352
|
+
L.push("");
|
|
1353
|
+
L.push(`> โ
Checked: ${report.checkedFor.join(" ยท ")}`);
|
|
1354
|
+
if (Array.isArray(report.notChecked) && report.notChecked.length) {
|
|
1355
|
+
L.push(`> โฌ Not checked this run: ${report.notChecked.join(" ยท ")}`);
|
|
1356
|
+
if (Array.isArray(report.conditionsNotReached) && report.conditionsNotReached.length) {
|
|
1357
|
+
L.push(`> โป๏ธ Conditions never reached: ${report.conditionsNotReached.join(" ยท ")}`);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
const next = [];
|
|
1362
|
+
if (report.findings && report.findings.length) next.push("open a flagged screen with `tapp_open_app`");
|
|
1363
|
+
next.push("re-run with `baselineFindings` to gate a fix");
|
|
1364
|
+
next.push("drive it step-by-step via `tapp_session_start`");
|
|
1365
|
+
L.push("");
|
|
1366
|
+
L.push(`**Next** โ ${next.join(" ยท ")}`);
|
|
1367
|
+
// The gate hook belongs at the moment the user thinks "I want this on every PR" โ
|
|
1368
|
+
// i.e. right after a verdict that found something, or after they hand-diffed a baseline.
|
|
1369
|
+
if ((report.findings && report.findings.length) || regression) {
|
|
1370
|
+
L.push("");
|
|
1371
|
+
L.push("> ๐ฆ Teams: get this verdict on every PR automatically (evidence + regression gate) โ https://github.com/aarwitz/tapp#ci-gate");
|
|
1372
|
+
}
|
|
1373
|
+
return L.join("\n");
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
async function writeRunUiMap({ markersPath, platform, target, runId, outDir }) {
|
|
1377
|
+
try {
|
|
1378
|
+
const { buildUiMapFromMarkers, writeUiMap } = await import("./ui-map.js");
|
|
1379
|
+
const map = buildUiMapFromMarkers({ markersPath, platform, target, runId });
|
|
1380
|
+
const mapPath = writeUiMap(path.join(outDir, "ui-map.json"), map);
|
|
1381
|
+
return {
|
|
1382
|
+
schemaVersion: map.schemaVersion,
|
|
1383
|
+
path: mapPath,
|
|
1384
|
+
relativePath: path.relative(repoRoot, mapPath),
|
|
1385
|
+
nodeCount: map.nodes.length,
|
|
1386
|
+
edgeCount: map.edges.length,
|
|
1387
|
+
controlCount: map.nodes.reduce((total, node) => total + node.controls.length, 0),
|
|
1388
|
+
};
|
|
1389
|
+
} catch (error) {
|
|
1390
|
+
return { error: error.message || String(error) };
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/** One-line "3 buttons ยท 2 fields ยท 8 text" breakdown of an accessibility element list. */
|
|
1395
|
+
function elementBreakdown(elements) {
|
|
1396
|
+
const has = (e, ...pats) => pats.some((p) => String(e.type || "").includes(p));
|
|
1397
|
+
let buttons = 0, fields = 0, texts = 0, cells = 0, other = 0;
|
|
1398
|
+
for (const e of elements || []) {
|
|
1399
|
+
if (has(e, "Button", "rawValue: 9", "Link", "rawValue: 39")) buttons++;
|
|
1400
|
+
else if (has(e, "TextField", "rawValue: 49", "rawValue: 50", "SecureTextField")) fields++;
|
|
1401
|
+
else if (has(e, "StaticText", "rawValue: 48")) texts++;
|
|
1402
|
+
else if (has(e, "Cell", "rawValue: 75")) cells++;
|
|
1403
|
+
else other++;
|
|
1404
|
+
}
|
|
1405
|
+
return [
|
|
1406
|
+
buttons && `${buttons} button${buttons > 1 ? "s" : ""}`,
|
|
1407
|
+
fields && `${fields} field${fields > 1 ? "s" : ""}`,
|
|
1408
|
+
cells && `${cells} cell${cells > 1 ? "s" : ""}`,
|
|
1409
|
+
texts && `${texts} text`,
|
|
1410
|
+
].filter(Boolean).join(" ยท ") || `${(elements || []).length} elements`;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/** Scannable "Read screen X โ N elements (...)" readout, plus the tappable/typeable controls. */
|
|
1414
|
+
export function formatScreen(screenTitle, elements) {
|
|
1415
|
+
const els = elements || [];
|
|
1416
|
+
const interactable = els.filter((e) => e.isEnabled !== false && (String(e.type).includes("Button") || String(e.type).includes("rawValue: 9") || String(e.type).includes("TextField") || String(e.type).includes("rawValue: 49") || String(e.type).includes("rawValue: 50") || String(e.type).includes("Cell") || String(e.type).includes("rawValue: 75")));
|
|
1417
|
+
const labels = interactable
|
|
1418
|
+
.map((e) => (e.label || e.identifier || "").trim())
|
|
1419
|
+
.filter((s) => s && s.length <= 40 && !s.includes("."))
|
|
1420
|
+
.slice(0, 8);
|
|
1421
|
+
const L = [`๐ณ Read screen **${screenTitle || "Unknown"}** โ ${els.length} elements (${elementBreakdown(els)})`];
|
|
1422
|
+
if (labels.length) L.push("", "**Controls:** " + labels.map((l) => `\`${l}\``).join(" ยท "));
|
|
1423
|
+
return L.join("\n");
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// ---- Shared QA engine (one implementation, two consumers: the MCP tools below and the
|
|
1427
|
+
// `tapp` CLI verbs in bin/tapp.js โ same pattern as report.js. Keep orchestration HERE so
|
|
1428
|
+
// the surfaces can't drift.)
|
|
1429
|
+
|
|
1430
|
+
export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], onProgress = () => {} }) {
|
|
1431
|
+
const actions = Math.max(1, Math.min(1000, asInteger(maxActions, 60)));
|
|
1432
|
+
const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
|
|
1433
|
+
const id = "web-" + new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/^(\d{8})/, "$1-");
|
|
1434
|
+
const outDir = path.join(capturesDir, id);
|
|
1435
|
+
let webResult;
|
|
1436
|
+
try {
|
|
1437
|
+
const { exploreWeb } = await import("./web-explorer.js");
|
|
1438
|
+
webResult = await exploreWeb({
|
|
1439
|
+
url: url.trim(),
|
|
1440
|
+
maxActions: actions,
|
|
1441
|
+
timeoutSec,
|
|
1442
|
+
outDir,
|
|
1443
|
+
testEmail: isNonEmptyString(testEmail) ? testEmail.trim() : "",
|
|
1444
|
+
testPassword: isNonEmptyString(testPassword) ? testPassword.trim() : "",
|
|
1445
|
+
seedRoutes,
|
|
1446
|
+
seedTargets,
|
|
1447
|
+
onProgress,
|
|
1448
|
+
});
|
|
1449
|
+
} catch (err) {
|
|
1450
|
+
return { error: String(err.message || err) };
|
|
1451
|
+
}
|
|
1452
|
+
const report = buildQaReport(webResult.markersPath, { platform: "web" });
|
|
1453
|
+
if (!report) return { error: "Web exploration produced no markers", details: { capture: { id, path: outDir } } };
|
|
1454
|
+
const backend = remoteAiOptedIn() ? resolveModelBackend() : null;
|
|
1455
|
+
if (backend && report.findings.length) {
|
|
1456
|
+
const { enrichFindings } = await import("./enrich.js");
|
|
1457
|
+
await enrichFindings(report.findings, { backend, callModel, screens: report.screens, appLabel: url.trim() });
|
|
1458
|
+
}
|
|
1459
|
+
const regression = computeRegression(report.findings, baselineFindings);
|
|
1460
|
+
const uiMap = await writeRunUiMap({ markersPath: webResult.markersPath, platform: "web", target: url.trim(), runId: id, outDir });
|
|
1461
|
+
let reportHtml = null;
|
|
1462
|
+
try {
|
|
1463
|
+
const { writeHtmlReport } = await import("./html-report.js");
|
|
1464
|
+
reportHtml = writeHtmlReport(outDir, { report, label: url.trim() });
|
|
1465
|
+
} catch { /* evidence page is best-effort */ }
|
|
1466
|
+
const structured = { ...report, regression, platform: "web", uiMap, reportHtml, exploration: { seedRoutes: webResult.seedRoutes || [], targets: webResult.seedTargets || [] }, capture: { id, path: outDir, relativePath: path.relative(repoRoot, outDir) } };
|
|
1467
|
+
const text = formatQaReport(report, { regression, bundleId: url.trim(), aiConfigured: !!backend, reportHtml, uiMap: uiMap.error ? null : uiMap });
|
|
1468
|
+
return { structured, text };
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
export async function runQaAndroid({ appId, apkPath, serial, maxActions, timeout, testEmail, testPassword, baselineFindings, clearData = true, seedTargets = [], onProgress = () => {} }) {
|
|
1472
|
+
const actions = Math.max(1, Math.min(1000, asInteger(maxActions, 60)));
|
|
1473
|
+
const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
|
|
1474
|
+
const id = "android-" + new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/^(\d{8})/, "$1-");
|
|
1475
|
+
const outDir = path.join(capturesDir, id);
|
|
1476
|
+
let androidResult;
|
|
1477
|
+
try {
|
|
1478
|
+
const { exploreAndroid } = await import("./android-explorer.js");
|
|
1479
|
+
androidResult = await exploreAndroid({
|
|
1480
|
+
appId: appId.trim(),
|
|
1481
|
+
apkPath: isNonEmptyString(apkPath) ? path.resolve(apkPath) : undefined,
|
|
1482
|
+
serial: isNonEmptyString(serial) ? serial.trim() : undefined,
|
|
1483
|
+
maxActions: actions,
|
|
1484
|
+
timeoutSec,
|
|
1485
|
+
outDir,
|
|
1486
|
+
testEmail: isNonEmptyString(testEmail) ? testEmail.trim() : "",
|
|
1487
|
+
testPassword: isNonEmptyString(testPassword) ? testPassword : "",
|
|
1488
|
+
clearData,
|
|
1489
|
+
seedTargets,
|
|
1490
|
+
onProgress,
|
|
1491
|
+
});
|
|
1492
|
+
} catch (error) {
|
|
1493
|
+
return { error: error.message || String(error), details: { capture: { id, path: outDir } } };
|
|
1494
|
+
}
|
|
1495
|
+
const report = buildQaReport(androidResult.markersPath, { platform: "android" });
|
|
1496
|
+
if (!report) return { error: "Android exploration produced no markers", details: { capture: { id, path: outDir } } };
|
|
1497
|
+
const backend = remoteAiOptedIn() ? resolveModelBackend() : null;
|
|
1498
|
+
if (backend && report.findings.length) {
|
|
1499
|
+
const { enrichFindings } = await import("./enrich.js");
|
|
1500
|
+
await enrichFindings(report.findings, { backend, callModel, screens: report.screens, appLabel: appId.trim() });
|
|
1501
|
+
}
|
|
1502
|
+
const regression = computeRegression(report.findings, baselineFindings);
|
|
1503
|
+
const uiMap = await writeRunUiMap({ markersPath: androidResult.markersPath, platform: "android", target: appId.trim(), runId: id, outDir });
|
|
1504
|
+
let reportHtml = null;
|
|
1505
|
+
try {
|
|
1506
|
+
const { writeHtmlReport } = await import("./html-report.js");
|
|
1507
|
+
reportHtml = writeHtmlReport(outDir, { report, label: appId.trim() });
|
|
1508
|
+
} catch {}
|
|
1509
|
+
const structured = { ...report, regression, platform: "android", uiMap, reportHtml, exploration: { targets: androidResult.seedTargets || [] }, capture: { id, path: outDir, relativePath: path.relative(repoRoot, outDir) } };
|
|
1510
|
+
const text = formatQaReport(report, { regression, bundleId: appId.trim(), aiConfigured: !!backend, reportHtml, uiMap: uiMap.error ? null : uiMap });
|
|
1511
|
+
return { structured, text };
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
export async function runQaIos({ bundleId, maxActions, timeout, args = {}, onProgress = () => {} }) {
|
|
1515
|
+
const captureScript = path.join(scriptsDir, "quick-capture.sh");
|
|
1516
|
+
if (!fs.existsSync(captureScript)) return { error: "Capture script not found", details: { captureScript } };
|
|
1517
|
+
|
|
1518
|
+
// run_qa runs for minutes anyway โ auto-boot rather than bounce the user.
|
|
1519
|
+
const sim = await ensureBootedSim({ autoBoot: true });
|
|
1520
|
+
if (sim.error) return { error: sim.error };
|
|
1521
|
+
if (!(await appInstalledOnBootedSim(bundleId))) return { error: notInstalledError(bundleId, sim.booted) };
|
|
1522
|
+
|
|
1523
|
+
const actions = Math.max(1, Math.min(1000, asInteger(maxActions, 60)));
|
|
1524
|
+
const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
|
|
1525
|
+
const env = explorationEnvFromArgs(args);
|
|
1526
|
+
|
|
1527
|
+
const { created, timedOut } = await runExploreStreaming(bundleId, actions, timeoutSec, env, onProgress);
|
|
1528
|
+
if (!created) return { error: "Exploration produced no capture run", details: { timedOut } };
|
|
1529
|
+
|
|
1530
|
+
const report = buildQaReport(path.join(created.path, "ocqa-markers.txt"));
|
|
1531
|
+
if (!report) {
|
|
1532
|
+
return {
|
|
1533
|
+
error: "No markers parsed from exploration (the app may not have launched)",
|
|
1534
|
+
details: { capture: { id: created.id, relativePath: created.relativePath } },
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
// If the app showed input fields and the caller didn't supply values, tell the agent to ask the
|
|
1538
|
+
// user โ Tapp fills with safe defaults autonomously and does NOT pause to prompt (that's the
|
|
1539
|
+
// standalone app's behavior; here the agent does the asking).
|
|
1540
|
+
const gaveValues = isNonEmptyString(args.testEmail) || isNonEmptyString(args.testPassword) || (args.inputOverrides && Object.keys(args.inputOverrides).length > 0);
|
|
1541
|
+
let inputHint;
|
|
1542
|
+
if (report.inputFieldsEncountered.length > 0 && !gaveValues) {
|
|
1543
|
+
const screensList = report.inputFieldsEncountered.map((s) => s.screen).slice(0, 5).join(", ");
|
|
1544
|
+
inputHint =
|
|
1545
|
+
`This app showed input fields${report.loginEncountered ? " including a login" : ""} on: ${screensList}. ` +
|
|
1546
|
+
`I explored autonomously and filled them with safe placeholder values โ I did NOT pause to ask. ` +
|
|
1547
|
+
`If you want me to test with real values, tell me what to enter for these fields (or say "use defaults" / "skip"), ` +
|
|
1548
|
+
`and I'll re-run with testEmail/testPassword or inputOverrides โ or I can drive it step-by-step in an interactive session so you can supply values as we go.`;
|
|
1549
|
+
}
|
|
1550
|
+
// Post-run AI enrichment (additive, never changes the verdict) โ requires explicit
|
|
1551
|
+
// remote-AI opt-in; an ambient API key alone is not consent.
|
|
1552
|
+
const backend = remoteAiOptedIn() ? resolveModelBackend() : null;
|
|
1553
|
+
if (backend && report.findings.length) {
|
|
1554
|
+
const { enrichFindings } = await import("./enrich.js");
|
|
1555
|
+
await enrichFindings(report.findings, { backend, callModel, screens: report.screens, appLabel: bundleId });
|
|
1556
|
+
}
|
|
1557
|
+
// Cross-run regression vs. a caller-supplied baseline (the CI gate).
|
|
1558
|
+
const regression = computeRegression(report.findings, args.baselineFindings);
|
|
1559
|
+
const uiMap = await writeRunUiMap({ markersPath: path.join(created.path, "ocqa-markers.txt"), platform: "ios", target: bundleId, runId: created.id, outDir: created.path });
|
|
1560
|
+
let reportHtml = null;
|
|
1561
|
+
try {
|
|
1562
|
+
const { writeHtmlReport } = await import("./html-report.js");
|
|
1563
|
+
reportHtml = writeHtmlReport(created.path, { report, label: bundleId });
|
|
1564
|
+
} catch { /* evidence page is best-effort */ }
|
|
1565
|
+
const recording =
|
|
1566
|
+
["exploration.webm", "exploration.mov"].map((f) => path.join(created.path, f)).find((p) => fs.existsSync(p)) || null;
|
|
1567
|
+
const structured = {
|
|
1568
|
+
...report,
|
|
1569
|
+
regression,
|
|
1570
|
+
uiMap,
|
|
1571
|
+
inputHint,
|
|
1572
|
+
reportHtml,
|
|
1573
|
+
recording,
|
|
1574
|
+
capture: { id: created.id, path: created.path, relativePath: created.relativePath },
|
|
1575
|
+
timedOut,
|
|
1576
|
+
autoBooted: sim.autoBooted || false,
|
|
1577
|
+
};
|
|
1578
|
+
const text = formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured: !!backend, reportHtml, recording, uiMap: uiMap.error ? null : uiMap });
|
|
1579
|
+
return { structured, text };
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/**
|
|
1583
|
+
* First-run import bridge: exercise one real target through the ordinary QA
|
|
1584
|
+
* engine, then merge that run's capture-local UI Map into the repository map.
|
|
1585
|
+
* This is shared by CLI and MCP so `tapp init --explore` is not a second
|
|
1586
|
+
* crawler. It never invokes AI implicitly and never interprets a shallow-map
|
|
1587
|
+
* absence as a regression.
|
|
1588
|
+
*/
|
|
1589
|
+
export async function runInitExploration({
|
|
1590
|
+
projectDir,
|
|
1591
|
+
platform,
|
|
1592
|
+
outDir = ".autotap",
|
|
1593
|
+
url = "",
|
|
1594
|
+
target = "",
|
|
1595
|
+
bundleId = "",
|
|
1596
|
+
appId = "",
|
|
1597
|
+
apkPath,
|
|
1598
|
+
serial,
|
|
1599
|
+
scheme,
|
|
1600
|
+
configuration,
|
|
1601
|
+
maxActions,
|
|
1602
|
+
timeout,
|
|
1603
|
+
testEmail,
|
|
1604
|
+
testPassword,
|
|
1605
|
+
onProgress = () => {},
|
|
1606
|
+
onStatus = () => {},
|
|
1607
|
+
} = {}) {
|
|
1608
|
+
let root;
|
|
1609
|
+
try { root = fs.realpathSync(path.resolve(projectDir || process.cwd())); }
|
|
1610
|
+
catch { return { error: `Repository directory not found: ${projectDir || process.cwd()}` }; }
|
|
1611
|
+
const selected = String(platform || (url ? "web" : appId || apkPath ? "android" : "ios")).toLowerCase();
|
|
1612
|
+
if (!["ios", "android", "web"].includes(selected)) return { error: "platform must be ios|android|web" };
|
|
1613
|
+
const mapPath = path.resolve(root, outDir, "ui-map.json");
|
|
1614
|
+
if (!isInsideDir(root, mapPath)) return { error: "UI Map output must remain inside the repository" };
|
|
1615
|
+
|
|
1616
|
+
let resolvedTarget = "";
|
|
1617
|
+
let targetResolution = null;
|
|
1618
|
+
let qa;
|
|
1619
|
+
let managedRuntime = null;
|
|
1620
|
+
if (selected === "web") {
|
|
1621
|
+
if (/^https?:\/\//i.test(String(url))) {
|
|
1622
|
+
resolvedTarget = String(url).trim();
|
|
1623
|
+
qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, onProgress });
|
|
1624
|
+
} else {
|
|
1625
|
+
const started = await startManagedWebTarget({ root, requestedTarget: target, timeout, onStatus });
|
|
1626
|
+
if (started.error) return started;
|
|
1627
|
+
managedRuntime = started;
|
|
1628
|
+
resolvedTarget = started.url;
|
|
1629
|
+
try {
|
|
1630
|
+
qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, onProgress });
|
|
1631
|
+
} finally {
|
|
1632
|
+
await stopManagedWebTarget(started);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
} else if (selected === "android") {
|
|
1636
|
+
if (!String(appId || "").trim()) return { error: "Android init exploration requires --app-id" };
|
|
1637
|
+
resolvedTarget = String(appId).trim();
|
|
1638
|
+
qa = await runQaAndroid({ appId: resolvedTarget, apkPath, serial, maxActions, timeout, testEmail, testPassword, onProgress });
|
|
1639
|
+
} else {
|
|
1640
|
+
resolvedTarget = String(bundleId || "").trim();
|
|
1641
|
+
if (!resolvedTarget) {
|
|
1642
|
+
const resolved = await resolveAppTarget(String(target || root), { cwd: root, onStatus, scheme, configuration });
|
|
1643
|
+
if (resolved.error) return resolved;
|
|
1644
|
+
resolvedTarget = resolved.bundleId;
|
|
1645
|
+
targetResolution = resolved.targetResolution || null;
|
|
1646
|
+
if (resolved.via) onStatus(`Target ${resolvedTarget} โ ${resolved.via}`);
|
|
1647
|
+
}
|
|
1648
|
+
qa = await runQaIos({
|
|
1649
|
+
bundleId: resolvedTarget,
|
|
1650
|
+
maxActions,
|
|
1651
|
+
timeout,
|
|
1652
|
+
args: { testEmail, testPassword },
|
|
1653
|
+
onProgress,
|
|
1654
|
+
});
|
|
1655
|
+
}
|
|
1656
|
+
if (qa?.error) return qa;
|
|
1657
|
+
const observedPath = qa?.structured?.uiMap?.path;
|
|
1658
|
+
if (!observedPath || !fs.existsSync(observedPath)) {
|
|
1659
|
+
return { error: "Exploration completed without a readable UI Map", details: { capture: qa?.structured?.capture } };
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
try {
|
|
1663
|
+
const { diffUiMaps, mergeUiMaps, validateUiMap, writeUiMap } = await import("./ui-map.js");
|
|
1664
|
+
const observed = JSON.parse(fs.readFileSync(observedPath, "utf8"));
|
|
1665
|
+
const observedErrors = validateUiMap(observed);
|
|
1666
|
+
if (observedErrors.length) return { error: `Exploration UI Map is invalid: ${observedErrors.join("; ")}` };
|
|
1667
|
+
let previous = null;
|
|
1668
|
+
if (fs.existsSync(mapPath)) {
|
|
1669
|
+
previous = JSON.parse(fs.readFileSync(mapPath, "utf8"));
|
|
1670
|
+
const previousErrors = validateUiMap(previous);
|
|
1671
|
+
if (previousErrors.length) return { error: `Existing repository UI Map is invalid: ${previousErrors.join("; ")}` };
|
|
1672
|
+
}
|
|
1673
|
+
const merged = previous ? mergeUiMaps(previous, observed) : observed;
|
|
1674
|
+
merged.provenance ||= {};
|
|
1675
|
+
merged.provenance.lastRun = {
|
|
1676
|
+
id: qa.structured.capture?.id || observed.provenance?.runIds?.at(-1) || "",
|
|
1677
|
+
platform: selected,
|
|
1678
|
+
verdict: qa.structured.verdict,
|
|
1679
|
+
inconclusive: qa.structured.inconclusive === true,
|
|
1680
|
+
statesExplored: Number(qa.structured.screensExplored || merged.nodes.length),
|
|
1681
|
+
actionsPerformed: Number(qa.structured.actionsPerformed || 0),
|
|
1682
|
+
observedAt: observed.provenance?.lastObservedAt || new Date().toISOString(),
|
|
1683
|
+
};
|
|
1684
|
+
writeUiMap(mapPath, merged);
|
|
1685
|
+
return {
|
|
1686
|
+
platform: selected,
|
|
1687
|
+
target: resolvedTarget,
|
|
1688
|
+
...(targetResolution ? {
|
|
1689
|
+
targetValidation: {
|
|
1690
|
+
platform: selected,
|
|
1691
|
+
target: resolvedTarget,
|
|
1692
|
+
resolution: targetResolution,
|
|
1693
|
+
evidence: {
|
|
1694
|
+
captureId: qa.structured.capture?.id || "",
|
|
1695
|
+
verdict: qa.structured.verdict,
|
|
1696
|
+
inconclusive: qa.structured.inconclusive === true,
|
|
1697
|
+
observedAt: observed.provenance?.lastObservedAt || new Date().toISOString(),
|
|
1698
|
+
},
|
|
1699
|
+
},
|
|
1700
|
+
} : {}),
|
|
1701
|
+
uiMapPath: mapPath,
|
|
1702
|
+
uiMap: {
|
|
1703
|
+
schemaVersion: merged.schemaVersion,
|
|
1704
|
+
nodeCount: merged.nodes.length,
|
|
1705
|
+
edgeCount: merged.edges.length,
|
|
1706
|
+
controlCount: merged.nodes.reduce((total, node) => total + node.controls.length, 0),
|
|
1707
|
+
},
|
|
1708
|
+
mapDiff: previous ? diffUiMaps(previous, observed, { comparableFullSweep: false }) : null,
|
|
1709
|
+
verdict: qa.structured.verdict,
|
|
1710
|
+
inconclusive: qa.structured.inconclusive === true,
|
|
1711
|
+
findings: qa.structured.findings || [],
|
|
1712
|
+
capture: qa.structured.capture,
|
|
1713
|
+
reportHtml: qa.structured.reportHtml || null,
|
|
1714
|
+
managedRuntime: !!managedRuntime,
|
|
1715
|
+
...(managedRuntime ? { runtime: { logPath: managedRuntime.logPath, install: managedRuntime.install, build: managedRuntime.build, start: managedRuntime.start } } : {}),
|
|
1716
|
+
qa: qa.structured,
|
|
1717
|
+
};
|
|
1718
|
+
} catch (error) {
|
|
1719
|
+
return { error: `Could not ground repository UI Map: ${error.message || String(error)}` };
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
function openLocalPort() {
|
|
1724
|
+
return new Promise((resolve, reject) => {
|
|
1725
|
+
const server = net.createServer();
|
|
1726
|
+
server.unref();
|
|
1727
|
+
server.once("error", reject);
|
|
1728
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1729
|
+
const address = server.address();
|
|
1730
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
1731
|
+
server.close((error) => error ? reject(error) : resolve(port));
|
|
1732
|
+
});
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
function managedInstallSpec(command) {
|
|
1737
|
+
const known = {
|
|
1738
|
+
"npm ci": ["npm", ["ci"]],
|
|
1739
|
+
"corepack pnpm install --frozen-lockfile": ["corepack", ["pnpm", "install", "--frozen-lockfile"]],
|
|
1740
|
+
"corepack yarn install --immutable": ["corepack", ["yarn", "install", "--immutable"]],
|
|
1741
|
+
"bun install --frozen-lockfile": ["bun", ["install", "--frozen-lockfile"]],
|
|
1742
|
+
};
|
|
1743
|
+
return known[command] || null;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
function declaredPortFromStartScript(script) {
|
|
1747
|
+
const source = String(script || "");
|
|
1748
|
+
const matches = [
|
|
1749
|
+
/(?:^|[\s;&|])PORT\s*=\s*([0-9]{1,5})(?=\s|$)/i,
|
|
1750
|
+
/(?:--port|-p)(?:\s+|=)([0-9]{1,5})(?=\s|$)/i,
|
|
1751
|
+
/(?:^|\s)(?:python3?|python)\s+-m\s+http\.server\s+([0-9]{1,5})(?=\s|$)/i,
|
|
1752
|
+
];
|
|
1753
|
+
for (const pattern of matches) {
|
|
1754
|
+
const value = Number(pattern.exec(source)?.[1] || 0);
|
|
1755
|
+
if (Number.isInteger(value) && value > 0 && value <= 65535) return value;
|
|
1756
|
+
}
|
|
1757
|
+
return 0;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
async function runManagedBuildStep(label, command, args, cwd, timeoutMs, onStatus) {
|
|
1761
|
+
onStatus(`${label}: ${command} ${args.join(" ")}`);
|
|
1762
|
+
const result = await runCommand(command, args, { cwd, timeoutMs });
|
|
1763
|
+
if (result.code !== 0) return { error: `${label} failed`, details: { command, args, cwd, stdout: result.stdout, stderr: result.stderr, timedOut: result.timedOut } };
|
|
1764
|
+
return { label, command, args, cwd };
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
async function waitForOwnedUrl(url, child, timeoutMs, logPath) {
|
|
1768
|
+
const deadline = Date.now() + timeoutMs;
|
|
1769
|
+
let lastError = "";
|
|
1770
|
+
while (Date.now() < deadline) {
|
|
1771
|
+
if (child.exitCode !== null) return { error: `Managed web runtime exited before becoming ready`, details: { exitCode: child.exitCode, logPath } };
|
|
1772
|
+
const controller = new AbortController();
|
|
1773
|
+
const timer = setTimeout(() => controller.abort(), 1000);
|
|
1774
|
+
try {
|
|
1775
|
+
const response = await fetch(url, { redirect: "manual", signal: controller.signal });
|
|
1776
|
+
if (response.status < 500) return { ready: true };
|
|
1777
|
+
lastError = `HTTP ${response.status}`;
|
|
1778
|
+
} catch (error) { lastError = error.message || String(error); }
|
|
1779
|
+
finally { clearTimeout(timer); }
|
|
1780
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
1781
|
+
}
|
|
1782
|
+
return { error: `Managed web runtime did not become ready within ${Math.round(timeoutMs / 1000)}s`, details: { url, logPath, lastError } };
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
export async function startManagedWebTarget({ root, requestedTarget = "", timeout, onStatus = () => {} } = {}) {
|
|
1786
|
+
const { inspectApplicationRepository } = await import("./application-model.js");
|
|
1787
|
+
const inspected = await inspectApplicationRepository({ projectDir: root, platform: "web" });
|
|
1788
|
+
let targets = inspected.model.targets.filter((item) => item.platform === "web");
|
|
1789
|
+
const requested = String(requestedTarget || "").trim();
|
|
1790
|
+
let requestedPath = "";
|
|
1791
|
+
if (requested) {
|
|
1792
|
+
try { requestedPath = fs.realpathSync(path.resolve(root, requested)); }
|
|
1793
|
+
catch { requestedPath = path.resolve(root, requested); }
|
|
1794
|
+
}
|
|
1795
|
+
if (requested && requestedPath !== root) {
|
|
1796
|
+
targets = targets.filter((item) => item.name === requested || item.id === requested || path.resolve(root, item.sourcePath) === requestedPath);
|
|
1797
|
+
}
|
|
1798
|
+
if (targets.length !== 1) return { error: targets.length ? "Multiple browser targets were detected; select one with --target <name-or-path>" : "No runnable browser target was detected", details: { targets: inspected.model.targets.map((item) => ({ id: item.id, name: item.name, sourcePath: item.sourcePath })) } };
|
|
1799
|
+
const target = targets[0];
|
|
1800
|
+
if (target.build.dependencyStatus === "missing-lockfile") return { error: `${target.name} cannot be built reproducibly because its dependency lockfile is missing`, details: { remediation: "Commit the lockfile or provide an already-running owned --url." } };
|
|
1801
|
+
const projectDir = path.resolve(root, target.build.projectDir || target.sourcePath || ".");
|
|
1802
|
+
const budgetMs = Math.max(30, Math.min(3600, Number(timeout) || 600)) * 1000;
|
|
1803
|
+
let install = null;
|
|
1804
|
+
if (target.build.install) {
|
|
1805
|
+
const spec = managedInstallSpec(target.build.install);
|
|
1806
|
+
if (!spec) return { error: `Unsupported deterministic install command: ${target.build.install}` };
|
|
1807
|
+
const installDir = path.resolve(root, target.build.installProjectDir || target.build.projectDir || ".");
|
|
1808
|
+
install = await runManagedBuildStep("Install browser dependencies", spec[0], spec[1], installDir, budgetMs, onStatus);
|
|
1809
|
+
if (install.error) return install;
|
|
1810
|
+
}
|
|
1811
|
+
let build = null;
|
|
1812
|
+
if (target.build.build) {
|
|
1813
|
+
build = await runManagedBuildStep("Build browser target", "npm", ["run", "build"], projectDir, budgetMs, onStatus);
|
|
1814
|
+
if (build.error) return build;
|
|
1815
|
+
}
|
|
1816
|
+
const startMatch = String(target.build.start || "").match(/^npm run ([A-Za-z0-9:_-]+)$/);
|
|
1817
|
+
const pkg = (() => { try { return JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf8")); } catch { return {}; } })();
|
|
1818
|
+
const dependencies = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
1819
|
+
const declaredPort = startMatch ? declaredPortFromStartScript(pkg.scripts?.[startMatch[1]]) : 0;
|
|
1820
|
+
const port = declaredPort || await openLocalPort();
|
|
1821
|
+
let command = "npm";
|
|
1822
|
+
let startArgs;
|
|
1823
|
+
let startDir = projectDir;
|
|
1824
|
+
if (startMatch) {
|
|
1825
|
+
startArgs = ["run", startMatch[1]];
|
|
1826
|
+
if (dependencies.vite) startArgs.push("--", "--host", "127.0.0.1", "--port", String(port));
|
|
1827
|
+
else if (dependencies.next) startArgs.push("--", "--hostname", "127.0.0.1", "--port", String(port));
|
|
1828
|
+
} else {
|
|
1829
|
+
const candidates = build ? ["dist", "build", "out"].map((name) => path.join(projectDir, name)) : [];
|
|
1830
|
+
startDir = candidates.find((candidate) => fs.existsSync(path.join(candidate, "index.html"))) || projectDir;
|
|
1831
|
+
if (!fs.existsSync(path.join(startDir, "index.html"))) return { error: `${target.name} has no safely detected start script or static index`, details: { remediation: "Add a package start/dev/serve/preview script or provide an already-running owned --url." } };
|
|
1832
|
+
command = process.execPath;
|
|
1833
|
+
startArgs = [path.join(__dirname, "static-server.js"), startDir, String(port)];
|
|
1834
|
+
}
|
|
1835
|
+
const logDir = path.join(autotapHome || os.tmpdir(), "init-runtime");
|
|
1836
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
1837
|
+
const logPath = path.join(logDir, `web-${process.pid}-${Date.now()}.log`);
|
|
1838
|
+
const child = spawn(command, startArgs, {
|
|
1839
|
+
cwd: startDir,
|
|
1840
|
+
env: { ...process.env, PORT: String(port), HOST: "127.0.0.1", BROWSER: "none", CI: "1" },
|
|
1841
|
+
shell: false,
|
|
1842
|
+
detached: process.platform !== "win32",
|
|
1843
|
+
});
|
|
1844
|
+
const append = (chunk) => fs.appendFileSync(logPath, String(chunk));
|
|
1845
|
+
child.stdout.on("data", append);
|
|
1846
|
+
child.stderr.on("data", append);
|
|
1847
|
+
onStatus(`Managed web runtime: ${command === process.execPath ? "Tapp static server" : `npm ${startArgs.join(" ")}`} โ http://127.0.0.1:${port}`);
|
|
1848
|
+
const ready = await waitForOwnedUrl(`http://127.0.0.1:${port}`, child, Math.min(budgetMs, 60_000), logPath);
|
|
1849
|
+
if (ready.error) {
|
|
1850
|
+
await stopManagedWebTarget({ child, detached: process.platform !== "win32" });
|
|
1851
|
+
return ready;
|
|
1852
|
+
}
|
|
1853
|
+
return { child, detached: process.platform !== "win32", url: `http://127.0.0.1:${port}`, logPath, install, build, start: { command, args: startArgs, cwd: startDir } };
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
export async function stopManagedWebTarget(runtime) {
|
|
1857
|
+
const child = runtime?.child;
|
|
1858
|
+
if (!child) return;
|
|
1859
|
+
const signal = (value) => {
|
|
1860
|
+
try {
|
|
1861
|
+
if (runtime.detached && child.pid) process.kill(-child.pid, value);
|
|
1862
|
+
else if (child.exitCode === null) child.kill(value);
|
|
1863
|
+
} catch { /* already stopped */ }
|
|
1864
|
+
};
|
|
1865
|
+
const closed = new Promise((resolve) => child.once("close", resolve));
|
|
1866
|
+
signal("SIGTERM");
|
|
1867
|
+
await Promise.race([closed, new Promise((resolve) => setTimeout(resolve, 3000))]);
|
|
1868
|
+
signal("SIGKILL");
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
export async function captureUiTree(bundleId) {
|
|
1872
|
+
const captureScript = path.join(scriptsDir, "quick-capture.sh");
|
|
1873
|
+
const before = new Set(listCaptureRuns(50).map((r) => r.id));
|
|
1874
|
+
const result = await runCommand("bash", [captureScript, "tree", bundleId], {
|
|
1875
|
+
cwd: repoRoot,
|
|
1876
|
+
timeoutMs: 5 * 60 * 1000,
|
|
1877
|
+
});
|
|
1878
|
+
const created = listCaptureRuns(50).find((r) => !before.has(r.id));
|
|
1879
|
+
if (!created) return { error: "UI tree produced no capture", details: { stderr: result.stderr } };
|
|
1880
|
+
|
|
1881
|
+
const treePath = path.join(created.path, "uitree.json");
|
|
1882
|
+
if (!fs.existsSync(treePath) || fs.statSync(treePath).size === 0) {
|
|
1883
|
+
return {
|
|
1884
|
+
error: "No accessibility tree was produced (is the app installed + foregrounded?)",
|
|
1885
|
+
details: { capture: { id: created.id, relativePath: created.relativePath }, stderr: result.stderr },
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
let tree;
|
|
1889
|
+
try {
|
|
1890
|
+
tree = JSON.parse(fs.readFileSync(treePath, "utf8"));
|
|
1891
|
+
} catch {
|
|
1892
|
+
return { error: "uitree.json was not valid JSON", details: { treePath } };
|
|
1893
|
+
}
|
|
1894
|
+
return { screenTitle: tree.screenTitle ?? null, elements: tree.elements || [], capture: { id: created.id, relativePath: created.relativePath } };
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
const pkgVersion = (() => {
|
|
1898
|
+
try {
|
|
1899
|
+
return JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version || "0.0.0";
|
|
1900
|
+
} catch {
|
|
1901
|
+
return "0.0.0";
|
|
1902
|
+
}
|
|
1903
|
+
})();
|
|
1904
|
+
|
|
1905
|
+
const server = new Server(
|
|
1906
|
+
{
|
|
1907
|
+
name: "tapp",
|
|
1908
|
+
version: pkgVersion,
|
|
1909
|
+
},
|
|
1910
|
+
{
|
|
1911
|
+
capabilities: {
|
|
1912
|
+
tools: {},
|
|
1913
|
+
},
|
|
1914
|
+
}
|
|
1915
|
+
);
|
|
1916
|
+
|
|
1917
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1918
|
+
tools: [
|
|
1919
|
+
{
|
|
1920
|
+
name: "tapp_health",
|
|
1921
|
+
title: "Check Tapp readiness",
|
|
1922
|
+
description: "Check Tapp workspace and toolchain availability",
|
|
1923
|
+
inputSchema: {
|
|
1924
|
+
type: "object",
|
|
1925
|
+
properties: {},
|
|
1926
|
+
},
|
|
1927
|
+
},
|
|
1928
|
+
{
|
|
1929
|
+
name: "tapp_build",
|
|
1930
|
+
title: "Build the iOS app",
|
|
1931
|
+
description:
|
|
1932
|
+
"Build the user's iOS app for the simulator from an Xcode project/workspace (auto-detects the " +
|
|
1933
|
+
"container and scheme under projectDir, default cwd), install it on the booted simulator, and " +
|
|
1934
|
+
"return the bundle id. Use before tapp_run_qa / tapp_open_app when the app isn't installed yet โ " +
|
|
1935
|
+
"no bundle id needed up front.",
|
|
1936
|
+
inputSchema: {
|
|
1937
|
+
type: "object",
|
|
1938
|
+
properties: {
|
|
1939
|
+
authToken: {
|
|
1940
|
+
type: "string",
|
|
1941
|
+
description: "Required when AUTOTAP_MCP_TOKEN is set",
|
|
1942
|
+
},
|
|
1943
|
+
projectDir: { type: "string", description: "Repo/dir to search for the .xcworkspace/.xcodeproj (default: cwd)" },
|
|
1944
|
+
scheme: { type: "string", description: "Scheme to build (default: auto-detected)" },
|
|
1945
|
+
configuration: { type: "string", default: "Debug" },
|
|
1946
|
+
install: { type: "boolean", default: true, description: "Install on the booted simulator after building" },
|
|
1947
|
+
},
|
|
1948
|
+
},
|
|
1949
|
+
},
|
|
1950
|
+
{
|
|
1951
|
+
name: "tapp_capture",
|
|
1952
|
+
title: "Headless capture",
|
|
1953
|
+
description: "Run headless capture workflows using scripts/quick-capture.sh",
|
|
1954
|
+
inputSchema: {
|
|
1955
|
+
type: "object",
|
|
1956
|
+
properties: {
|
|
1957
|
+
authToken: {
|
|
1958
|
+
type: "string",
|
|
1959
|
+
description: "Required when AUTOTAP_MCP_TOKEN is set",
|
|
1960
|
+
},
|
|
1961
|
+
mode: {
|
|
1962
|
+
type: "string",
|
|
1963
|
+
enum: ["screenshot", "record", "explore", "tree"],
|
|
1964
|
+
description: "Capture mode",
|
|
1965
|
+
},
|
|
1966
|
+
appBundleId: {
|
|
1967
|
+
type: "string",
|
|
1968
|
+
description: "Bundle ID (required for explore/tree)",
|
|
1969
|
+
},
|
|
1970
|
+
actions: {
|
|
1971
|
+
type: "integer",
|
|
1972
|
+
minimum: 1,
|
|
1973
|
+
maximum: 1000,
|
|
1974
|
+
description: "Action limit for explore",
|
|
1975
|
+
},
|
|
1976
|
+
duration: {
|
|
1977
|
+
type: "integer",
|
|
1978
|
+
minimum: 1,
|
|
1979
|
+
maximum: 3600,
|
|
1980
|
+
description: "Record duration in seconds",
|
|
1981
|
+
},
|
|
1982
|
+
testEmail: {
|
|
1983
|
+
type: "string",
|
|
1984
|
+
description: "Optional OCQA_TEST_EMAIL override",
|
|
1985
|
+
},
|
|
1986
|
+
testPassword: {
|
|
1987
|
+
type: "string",
|
|
1988
|
+
description: "Optional OCQA_TEST_PASSWORD override",
|
|
1989
|
+
},
|
|
1990
|
+
inputOverrides: {
|
|
1991
|
+
type: "object",
|
|
1992
|
+
description:
|
|
1993
|
+
"Deterministic field values typed during explore. Map of field key -> value. " +
|
|
1994
|
+
"Keys: 'id:<identifier>', 'label:<label>', or scoped 'screen:<title>|id:<identifier>'. " +
|
|
1995
|
+
"Example: {\"id:email_field\": \"user@example.com\", \"id:zip\": \"90210\"}. " +
|
|
1996
|
+
"Values replace the default 'test' input and are typed exactly as given on every run.",
|
|
1997
|
+
additionalProperties: { type: "string" },
|
|
1998
|
+
},
|
|
1999
|
+
},
|
|
2000
|
+
required: ["mode"],
|
|
2001
|
+
},
|
|
2002
|
+
},
|
|
2003
|
+
{
|
|
2004
|
+
name: "tapp_parse_markers",
|
|
2005
|
+
title: "Parse capture markers",
|
|
2006
|
+
description: "Parse OCQA markers from a capture run into structured summary",
|
|
2007
|
+
inputSchema: {
|
|
2008
|
+
type: "object",
|
|
2009
|
+
properties: {
|
|
2010
|
+
runId: {
|
|
2011
|
+
type: "string",
|
|
2012
|
+
description: "Capture run directory name under captures/",
|
|
2013
|
+
},
|
|
2014
|
+
runPath: {
|
|
2015
|
+
type: "string",
|
|
2016
|
+
description: "Absolute capture path override (must remain under captures/)",
|
|
2017
|
+
},
|
|
2018
|
+
},
|
|
2019
|
+
},
|
|
2020
|
+
},
|
|
2021
|
+
{
|
|
2022
|
+
name: "tapp_list_captures",
|
|
2023
|
+
title: "List captures",
|
|
2024
|
+
description: "List recent capture runs from captures/",
|
|
2025
|
+
inputSchema: {
|
|
2026
|
+
type: "object",
|
|
2027
|
+
properties: {
|
|
2028
|
+
limit: {
|
|
2029
|
+
type: "integer",
|
|
2030
|
+
minimum: 1,
|
|
2031
|
+
maximum: 100,
|
|
2032
|
+
default: 10,
|
|
2033
|
+
},
|
|
2034
|
+
},
|
|
2035
|
+
},
|
|
2036
|
+
},
|
|
2037
|
+
{
|
|
2038
|
+
name: "tapp_capture_summary",
|
|
2039
|
+
title: "Capture summary",
|
|
2040
|
+
description: "Show summary metadata for a capture run",
|
|
2041
|
+
inputSchema: {
|
|
2042
|
+
type: "object",
|
|
2043
|
+
properties: {
|
|
2044
|
+
runId: {
|
|
2045
|
+
type: "string",
|
|
2046
|
+
description: "Capture run directory name under captures/",
|
|
2047
|
+
},
|
|
2048
|
+
runPath: {
|
|
2049
|
+
type: "string",
|
|
2050
|
+
description: "Absolute capture path override",
|
|
2051
|
+
},
|
|
2052
|
+
},
|
|
2053
|
+
},
|
|
2054
|
+
},
|
|
2055
|
+
{
|
|
2056
|
+
name: "tapp_run_qa",
|
|
2057
|
+
title: "Run autonomous QA",
|
|
2058
|
+
description:
|
|
2059
|
+
"Run autonomous QA against iOS (appBundleId), Android (androidAppId), OR a web app " +
|
|
2060
|
+
"(url โ beta, requires Playwright installed) and return a structured " +
|
|
2061
|
+
"ship/no-ship verdict. Use ONLY when the user wants a QA assessment / to find bugs / a verdict โ this " +
|
|
2062
|
+
"runs for MINUTES exploring the whole app. Do NOT use it just to view, screenshot, or reach a specific " +
|
|
2063
|
+
"screen โ use tapp_open_app (launch + screenshot) or a session for that. Tapp explores the app " +
|
|
2064
|
+
"like a tester (taps, types, navigates, scrolls) and detects real issues โ crashes, dead buttons, failed sign-ins, error screens, " +
|
|
2065
|
+
"stuck/hung screens; on web also uncaught JS exceptions, failed/5xx requests, broken links and assets. " +
|
|
2066
|
+
"Returns {verdict: ready|caution|blocked, confidence, headline, screensExplored, " +
|
|
2067
|
+
"actionsPerformed, findings:[{type,severity,category,title,screen}]}. The verdict has a coverage floor: " +
|
|
2068
|
+
"if the app barely explored (crash on launch / sign-in wall) it returns 'caution' + inconclusive, never a " +
|
|
2069
|
+
"false pass. For iOS the app must already be installed on a booted simulator (use tapp_list_simulators / " +
|
|
2070
|
+
"tapp_boot_simulator first). For web, only point it at an app/environment you own โ it CLICKS things. " +
|
|
2071
|
+
"Tapp explores autonomously and does NOT pause to prompt for input โ " +
|
|
2072
|
+
"it fills forms with safe defaults. The result includes `inputFieldsEncountered` (and `inputHint`): if " +
|
|
2073
|
+
"the app showed login/form fields and the user hasn't given you values, ASK THE USER what to enter (offer " +
|
|
2074
|
+
"to use defaults or skip), then re-run with testEmail/testPassword or inputOverrides for a real result.",
|
|
2075
|
+
inputSchema: {
|
|
2076
|
+
type: "object",
|
|
2077
|
+
properties: {
|
|
2078
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2079
|
+
appBundleId: { type: "string", description: "iOS: bundle id of the installed app to test, e.g. com.acme.app. Provide exactly one of appBundleId | url." },
|
|
2080
|
+
androidAppId: { type: "string", description: "Android: application id installed on a connected emulator/device, e.g. com.acme.app." },
|
|
2081
|
+
apkPath: { type: "string", description: "Android: optional APK to install before testing." },
|
|
2082
|
+
androidSerial: { type: "string", description: "Android: optional adb device serial; defaults to the first authorized device." },
|
|
2083
|
+
clearData: { type: "boolean", default: true, description: "Android: clear app data before launch for a repeatable starting state." },
|
|
2084
|
+
url: { type: "string", description: "Web (beta): URL of the app to explore in a real browser (same-origin only; your own app/staging). Provide exactly one of appBundleId | url." },
|
|
2085
|
+
maxActions: { type: "integer", minimum: 1, maximum: 1000, default: 60, description: "Exploration action budget" },
|
|
2086
|
+
timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600, description: "Max wall-clock seconds" },
|
|
2087
|
+
testEmail: { type: "string", description: "Email for the login preamble, if the app has a sign-in" },
|
|
2088
|
+
testPassword: { type: "string", description: "Password for the login preamble" },
|
|
2089
|
+
interactive: { type: "boolean", description: "Host-with-a-human only (e.g. the VS Code extension): pause at input screens and wait for values via interactiveResponsePath. Plain agents: omit." },
|
|
2090
|
+
interactiveResponsePath: { type: "string", description: "File path the prompting host answers on (requests appear at <path>.request)" },
|
|
2091
|
+
inputOverrides: {
|
|
2092
|
+
type: "object",
|
|
2093
|
+
additionalProperties: { type: "string" },
|
|
2094
|
+
description:
|
|
2095
|
+
"Deterministic field values typed during exploration. Map of field key -> value; keys are " +
|
|
2096
|
+
"'id:<identifier>', 'label:<label>', or scoped 'screen:<title>|id:<identifier>'. " +
|
|
2097
|
+
"Example: {\"id:email_field\": \"user@example.com\"}.",
|
|
2098
|
+
},
|
|
2099
|
+
appLaunchArgs: {
|
|
2100
|
+
type: "array",
|
|
2101
|
+
items: { type: "string" },
|
|
2102
|
+
description: "Launch arguments passed to the app, e.g. [\"--uitesting\"] to enable a login bypass.",
|
|
2103
|
+
},
|
|
2104
|
+
appLaunchEnv: {
|
|
2105
|
+
type: "object",
|
|
2106
|
+
additionalProperties: { type: "string" },
|
|
2107
|
+
description: "Launch environment for the app, e.g. {\"UI_TEST_BACKEND\": \"staging\"} to point it at a test backend.",
|
|
2108
|
+
},
|
|
2109
|
+
loginSteps: {
|
|
2110
|
+
type: "array",
|
|
2111
|
+
items: {},
|
|
2112
|
+
description:
|
|
2113
|
+
"Explicit login replay run BEFORE exploration, for custom login UIs the heuristic can't " +
|
|
2114
|
+
"parse (the #1 reason a real app stays invisible). Each step is {action:'type'|'tap'|'wait', " +
|
|
2115
|
+
"target:'<accessibility-id-or-label>', value?:'<text>', timeoutMs?:<for wait>} โ or a shorthand " +
|
|
2116
|
+
"string 'action:target[:value]'. $TEST_EMAIL/$TEST_PASSWORD are substituted from testEmail/" +
|
|
2117
|
+
"testPassword. Example: [{\"action\":\"type\",\"target\":\"email_field\",\"value\":\"$TEST_EMAIL\"}," +
|
|
2118
|
+
"{\"action\":\"type\",\"target\":\"password_field\",\"value\":\"$TEST_PASSWORD\"},{\"action\":\"tap\",\"target\":\"sign_in_button\"}].",
|
|
2119
|
+
},
|
|
2120
|
+
baselineFindings: {
|
|
2121
|
+
type: "array",
|
|
2122
|
+
items: { type: "object" },
|
|
2123
|
+
description:
|
|
2124
|
+
"Findings from a previous run (pass back the `findings` array a prior tapp_run_qa returned). " +
|
|
2125
|
+
"When provided, the result adds `regression` {counts:{new,persisting,resolved}, newFindings, resolved, " +
|
|
2126
|
+
"gate:{newHigh,newCritical,failed}} comparing this run to that baseline. For a CI gate: store the " +
|
|
2127
|
+
"baseline once, then fail the build when regression.gate.failed is true (new high/critical introduced).",
|
|
2128
|
+
},
|
|
2129
|
+
},
|
|
2130
|
+
},
|
|
2131
|
+
},
|
|
2132
|
+
{
|
|
2133
|
+
name: "tapp_init",
|
|
2134
|
+
title: "Inspect or explore a repository and create the Tapp application model and release plan",
|
|
2135
|
+
description:
|
|
2136
|
+
"Deterministically inspect repository targets, reviewed Tasks/contracts, actors, capabilities, entities, requirements, and the persistent UI Map. Returns an evidence-classified application model plus a compact grounded release-contract plan. `inspect` is read-only; `write` creates artifacts without overwriting; `refresh` updates source evidence; `explore` safely builds/starts a detected web target when url is omitted (or resolves native targets), runs the ordinary real-surface QA engine, tears managed runtimes down, merges its observed UI Map, then refreshes the model/plan while preserving explicit decisions and invalidating stale replay trust. Execution is deterministic/keyless and does not invoke AI unless remote AI was separately and explicitly enabled.",
|
|
2137
|
+
inputSchema: {
|
|
2138
|
+
type: "object",
|
|
2139
|
+
properties: {
|
|
2140
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2141
|
+
operation: { type: "string", enum: ["inspect", "write", "refresh", "explore"], default: "inspect" },
|
|
2142
|
+
projectDir: { type: "string", description: "Repo-relative project root; defaults to the MCP workspace root" },
|
|
2143
|
+
platform: { type: "string", enum: ["ios", "android", "web"], description: "Optional target filter" },
|
|
2144
|
+
url: { type: "string", description: "Owned web runtime URL when already running; omit during web explore to build/start one safely detected target" },
|
|
2145
|
+
target: { type: "string", description: "Explore target selector: web target name/path, or iOS repo directory, Xcode container, .app, or bundle id; defaults to projectDir" },
|
|
2146
|
+
appBundleId: { type: "string", description: "iOS explore: already-installed bundle id, avoiding a build" },
|
|
2147
|
+
androidAppId: { type: "string", description: "Android explore: required application id" },
|
|
2148
|
+
apkPath: { type: "string", description: "Android explore: optional repo-relative APK to install" },
|
|
2149
|
+
androidSerial: { type: "string", description: "Android explore: optional adb device serial" },
|
|
2150
|
+
maxActions: { type: "integer", minimum: 1, maximum: 1000, default: 40 },
|
|
2151
|
+
timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600 },
|
|
2152
|
+
testEmail: { type: "string", description: "Explore: actor/login email; never persisted in the model" },
|
|
2153
|
+
testPassword: { type: "string", description: "Explore: actor/login password; never persisted in the model" },
|
|
2154
|
+
maxContracts: { type: "integer", minimum: 1, maximum: 50, default: 15 },
|
|
2155
|
+
outDir: { type: "string", description: "Repo-relative artifact directory; default .autotap" },
|
|
2156
|
+
},
|
|
2157
|
+
},
|
|
2158
|
+
},
|
|
2159
|
+
{
|
|
2160
|
+
name: "tapp_actor_config",
|
|
2161
|
+
title: "Inspect or configure named test actors without storing credential values",
|
|
2162
|
+
description:
|
|
2163
|
+
"Manage the repository-native .autotap/project.json actor/session contract used by init, release-contract generation, and CI. `read` is inspect-only. `set` writes an explicit actor role, isolation/provisioning policy, and credential-name to environment-variable-name bindings. The tool never accepts, returns, or persists credential values and never overwrites an actor unless replace is explicit.",
|
|
2164
|
+
inputSchema: {
|
|
2165
|
+
type: "object",
|
|
2166
|
+
properties: {
|
|
2167
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2168
|
+
operation: { type: "string", enum: ["read", "set"], default: "read" },
|
|
2169
|
+
projectDir: { type: "string", description: "Repo-relative project root; defaults to the MCP workspace root" },
|
|
2170
|
+
name: { type: "string", description: "Set: stable actor name" },
|
|
2171
|
+
role: { type: "string", description: "Set: product role, such as member or admin" },
|
|
2172
|
+
session: { type: "string", enum: ["default", "isolated"], default: "default" },
|
|
2173
|
+
provisioning: { type: "string", enum: ["existing", "seeded", "api", "unknown"], default: "existing" },
|
|
2174
|
+
credentialBindings: { type: "object", additionalProperties: { type: "string", pattern: "^[A-Z_][A-Z0-9_]{0,127}$" }, description: "Set: credential names mapped to environment-variable names, for example {email:'ALICE_EMAIL'}; values/secrets are forbidden" },
|
|
2175
|
+
replace: { type: "boolean", default: false, description: "Explicitly replace an existing actor's non-secret configuration" },
|
|
2176
|
+
},
|
|
2177
|
+
},
|
|
2178
|
+
},
|
|
2179
|
+
{
|
|
2180
|
+
name: "tapp_release_plan",
|
|
2181
|
+
title: "Inspect or explicitly review a Tapp release plan",
|
|
2182
|
+
description:
|
|
2183
|
+
"Read the repository-native release plan, apply explicit approve/reject/defer decisions, generate grounded Task/contract drafts, deterministically validate drafts on a real target, or explicitly promote fully replay-validated drafts into reviewed repository-native artifacts. Review changes only decision metadata. Generation writes under .autotap/proposals, never overwrites, never invokes AI, and remains untrusted until real deterministic replay passes. Web validation can build/start/stop the detected managed target when url is omitted.",
|
|
2184
|
+
inputSchema: {
|
|
2185
|
+
type: "object",
|
|
2186
|
+
properties: {
|
|
2187
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2188
|
+
operation: { type: "string", enum: ["read", "review", "generate", "validate", "promote"], default: "read" },
|
|
2189
|
+
planPath: { type: "string", description: "Repo-relative plan path; default .autotap/release-plan.json" },
|
|
2190
|
+
projectDir: { type: "string", description: "Generate: repo-relative project root containing the scoped .autotap Task directories" },
|
|
2191
|
+
approve: { type: "array", items: { type: "string" }, description: "Plan item ids or names to approve" },
|
|
2192
|
+
reject: { type: "array", items: { type: "string" }, description: "Plan item ids or names to reject" },
|
|
2193
|
+
defer: { type: "array", items: { type: "string" }, description: "Plan item ids or names to defer" },
|
|
2194
|
+
items: { type: "array", items: { type: "string" }, description: "Validate/promote only these plan item ids or names; defaults to every matching draft" },
|
|
2195
|
+
platform: { type: "string", enum: ["ios", "android", "web"], description: "Validate: target platform; inferred when all selected drafts use one platform" },
|
|
2196
|
+
url: { type: "string", description: "Validate web: already-running owned URL; omit to use Tapp's managed target lifecycle" },
|
|
2197
|
+
target: { type: "string", description: "Validate managed web: target id, name, or source path when the repository has multiple browser targets" },
|
|
2198
|
+
appBundleId: { type: "string", description: "Validate iOS: installed application bundle id" },
|
|
2199
|
+
androidAppId: { type: "string", description: "Validate Android: installed application id" },
|
|
2200
|
+
apkPath: { type: "string", description: "Validate Android: optional repo-relative APK" },
|
|
2201
|
+
androidSerial: { type: "string", description: "Validate Android: optional adb serial" },
|
|
2202
|
+
timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600 },
|
|
2203
|
+
},
|
|
2204
|
+
},
|
|
2205
|
+
},
|
|
2206
|
+
{
|
|
2207
|
+
name: "tapp_ci_setup",
|
|
2208
|
+
title: "Create a target-scoped baseline or reviewable CI installation",
|
|
2209
|
+
description:
|
|
2210
|
+
"Complete the local release-contract onboarding loop from the shared application model. `inspect` renders a target-aware GitHub workflow and machine-readable CI manifest without writing; `install` writes both with collision protection; `baseline` imports an existing successful conclusive portable-gate report into a platform/target-specific repository baseline. No network resources, commits, pushes, branch protection, or AI are used.",
|
|
2211
|
+
inputSchema: {
|
|
2212
|
+
type: "object",
|
|
2213
|
+
properties: {
|
|
2214
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2215
|
+
operation: { type: "string", enum: ["inspect", "install", "baseline"], default: "inspect" },
|
|
2216
|
+
projectDir: { type: "string", description: "Repo-relative project root; defaults to the MCP workspace root" },
|
|
2217
|
+
modelPath: { type: "string", description: "Repo-relative application model path; defaults to <projectDir>/.autotap/application-model.json" },
|
|
2218
|
+
actionRef: { type: "string", description: "GitHub Action reference owner/repository@release-tag-or-sha; defaults to the current Tapp release tag" },
|
|
2219
|
+
defaultBranch: { type: "string", default: "main" },
|
|
2220
|
+
workflowPath: { type: "string", description: "Install: project-relative output; default .github/workflows/tapp.yml" },
|
|
2221
|
+
manifestPath: { type: "string", description: "Install: project-relative output; default .autotap/ci.json" },
|
|
2222
|
+
allowUnresolved: { type: "boolean", default: false, description: "Permit writing a draft whose manifest names unresolved target configuration" },
|
|
2223
|
+
replace: { type: "boolean", default: false, description: "Explicitly replace an existing generated workflow/manifest or target baseline" },
|
|
2224
|
+
reportPath: { type: "string", description: "Baseline: repo-relative successful conclusive portable-gate JSON report" },
|
|
2225
|
+
platform: { type: "string", enum: ["ios", "android", "web"], description: "Baseline: select a target platform" },
|
|
2226
|
+
target: { type: "string", description: "Baseline: application-model target id, name, or source path" },
|
|
2227
|
+
baselinePath: { type: "string", description: "Baseline: optional project-relative target baseline output" },
|
|
2228
|
+
},
|
|
2229
|
+
},
|
|
2230
|
+
},
|
|
2231
|
+
{
|
|
2232
|
+
name: "tapp_ui_map",
|
|
2233
|
+
title: "Build, inspect, or diff the Tapp UI Map",
|
|
2234
|
+
description:
|
|
2235
|
+
"Use Tapp's first-class platform-neutral UI Map: evidence-grounded screen states, semantic controls, transitions, platform variants, provenance, and task/contract coverage hooks. " +
|
|
2236
|
+
"QA runs create capture-local ui-map.json automatically. `read` returns one; `build` deterministically builds/merges a repository map from OCQA evidence; `diff` reports additions and absences without calling shallow-run absence a regression unless comparableFullSweep is explicitly true. No AI is used.",
|
|
2237
|
+
inputSchema: {
|
|
2238
|
+
type: "object",
|
|
2239
|
+
properties: {
|
|
2240
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2241
|
+
operation: { type: "string", enum: ["read", "build", "diff"], default: "read" },
|
|
2242
|
+
captureId: { type: "string", description: "Read/build from this Tapp capture's ocqa-markers.txt/ui-map.json" },
|
|
2243
|
+
mapPath: { type: "string", description: "Repo-relative UI Map path for read, or build output (default .autotap/ui-map.json)" },
|
|
2244
|
+
markersPath: { type: "string", description: "Repo-relative OCQA markers path for build when captureId is not supplied" },
|
|
2245
|
+
beforePath: { type: "string", description: "Repo-relative baseline UI Map for diff" },
|
|
2246
|
+
afterPath: { type: "string", description: "Repo-relative current UI Map for diff" },
|
|
2247
|
+
platform: { type: "string", enum: ["ios", "android", "web"], default: "ios" },
|
|
2248
|
+
target: { type: "string", description: "Bundle id, Android app id, or owned URL recorded as map provenance" },
|
|
2249
|
+
replace: { type: "boolean", default: false, description: "Build: replace rather than merge existing repository map" },
|
|
2250
|
+
comparableFullSweep: { type: "boolean", default: false, description: "Diff: only enable when both runs had comparable target/config/action budget; permits lost-reachability classification" },
|
|
2251
|
+
},
|
|
2252
|
+
},
|
|
2253
|
+
},
|
|
2254
|
+
{
|
|
2255
|
+
name: "tapp_task",
|
|
2256
|
+
title: "Inspect, validate, or compile a reusable deterministic Task",
|
|
2257
|
+
description:
|
|
2258
|
+
"Work with repository-native compositional Tasks in .autotap/tasks. Tasks define inputs, outputs, pre/postconditions, platform implementations, and the UI Map states/transitions they cover. " +
|
|
2259
|
+
"Validation is deterministic and can ground selectors/coverage against ui-map.json. Compilation expands a Task into the shared keyless Flow contract with reviewable Task provenance; pass that returned flow to tapp_flow_run to replay it. No AI or API key is used.",
|
|
2260
|
+
inputSchema: {
|
|
2261
|
+
type: "object",
|
|
2262
|
+
required: ["taskPath"],
|
|
2263
|
+
properties: {
|
|
2264
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2265
|
+
operation: { type: "string", enum: ["read", "validate", "compile"], default: "validate" },
|
|
2266
|
+
taskPath: { type: "string", description: "Repo-relative .autotap/tasks/*.yml|json file" },
|
|
2267
|
+
platform: { type: "string", enum: ["ios", "android", "web"], description: "Implementation to validate/compile" },
|
|
2268
|
+
inputs: { type: "object", additionalProperties: { type: "string" }, description: "Task inputs for compile. Secret inputs must be environment placeholders such as $TEST_PASSWORD, never plaintext." },
|
|
2269
|
+
mapPath: { type: "string", description: "Optional repo-relative UI Map v1 used to ground states, edges, and semantic controls" },
|
|
2270
|
+
updateMap: { type: "boolean", default: false, description: "Explicitly add the validated Task's coverage references to mapPath" },
|
|
2271
|
+
outPath: { type: "string", description: "Compile: optional repo-relative JSON output; omitted returns the compiled Flow without writing" },
|
|
2272
|
+
},
|
|
2273
|
+
},
|
|
2274
|
+
},
|
|
2275
|
+
{
|
|
2276
|
+
name: "tapp_release_contract",
|
|
2277
|
+
title: "Inspect, validate, compile, or run a release contract",
|
|
2278
|
+
description:
|
|
2279
|
+
"Work with repository-native TypeScript release contracts in .autotap/contracts. Contracts express business guarantees through reusable Tasks, named actors, exact/eventual expectations, criticality, policy, and UI Map coverage. " +
|
|
2280
|
+
"Compilation targets the same deterministic Flow/Scenario evidence contract; ordinary run is keyless and never invokes a model. Multi-actor isolated replay is currently web-only.",
|
|
2281
|
+
inputSchema: {
|
|
2282
|
+
type: "object",
|
|
2283
|
+
required: ["contractPath"],
|
|
2284
|
+
properties: {
|
|
2285
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2286
|
+
operation: { type: "string", enum: ["read", "validate", "compile", "run"], default: "validate" },
|
|
2287
|
+
contractPath: { type: "string", description: "Repo-relative .autotap/contracts/*.contract.ts file" },
|
|
2288
|
+
platform: { type: "string", enum: ["ios", "android", "web"], description: "Target platform; optional when the contract declares exactly one" },
|
|
2289
|
+
mapPath: { type: "string", description: "Optional repo-relative UI Map v1 for coverage grounding" },
|
|
2290
|
+
updateMap: { type: "boolean", default: false, description: "Explicitly add the reviewed contract coverage to mapPath" },
|
|
2291
|
+
outPath: { type: "string", description: "Compile: optional repo-relative deterministic JSON output" },
|
|
2292
|
+
url: { type: "string", description: "Run: web target URL override" },
|
|
2293
|
+
appBundleId: { type: "string", description: "Run: iOS bundle id override" },
|
|
2294
|
+
androidAppId: { type: "string", description: "Run: Android application id override" },
|
|
2295
|
+
apkPath: { type: "string", description: "Run: optional Android APK" },
|
|
2296
|
+
androidSerial: { type: "string", description: "Run: optional adb serial" },
|
|
2297
|
+
},
|
|
2298
|
+
},
|
|
2299
|
+
},
|
|
2300
|
+
{
|
|
2301
|
+
name: "tapp_pr_plan",
|
|
2302
|
+
title: "Plan PR coverage or explicitly adopt an observed coverage proposal",
|
|
2303
|
+
description:
|
|
2304
|
+
"Plan builds a deterministic reviewable PR plan from changed files, reviewed ownership, exact observed static-route evidence, UI Map coverage, reusable Tasks, and contract policy. It also emits bounded exploration targets for changed weakly covered UI states. Adopt is an explicit write: it appends one conclusively observed, review-only coverage proposal to the repository release plan, but generates or trusts nothing. No AI runs; plan is read-only and adopt never rewrites existing items.",
|
|
2305
|
+
inputSchema: {
|
|
2306
|
+
type: "object",
|
|
2307
|
+
properties: {
|
|
2308
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2309
|
+
operation: { type: "string", enum: ["plan", "adopt"], default: "plan" },
|
|
2310
|
+
changedFiles: {
|
|
2311
|
+
type: "array", minItems: 1,
|
|
2312
|
+
items: {
|
|
2313
|
+
anyOf: [
|
|
2314
|
+
{ type: "string" },
|
|
2315
|
+
{
|
|
2316
|
+
type: "object", required: ["filename"], additionalProperties: false,
|
|
2317
|
+
properties: {
|
|
2318
|
+
filename: { type: "string" },
|
|
2319
|
+
previous_filename: { type: "string" },
|
|
2320
|
+
patch: { type: "string", description: "Optional bounded unified patch from the PR provider; consumed locally and never copied into the plan" },
|
|
2321
|
+
},
|
|
2322
|
+
},
|
|
2323
|
+
],
|
|
2324
|
+
},
|
|
2325
|
+
description: "Repository-relative PR paths or provider change objects with optional bounded patch evidence for reviewed symbol ownership",
|
|
2326
|
+
},
|
|
2327
|
+
projectDir: { type: "string", description: "Repo-relative project root; defaults to the MCP workspace root" },
|
|
2328
|
+
platform: { type: "string", enum: ["ios", "android", "web"], description: "Optional platform filter" },
|
|
2329
|
+
mapPath: { type: "string", description: "Project-relative UI Map; defaults to .autotap/ui-map.json" },
|
|
2330
|
+
prPlanPath: { type: "string", description: "Adopt: project-relative executed PR plan containing conclusive exploration evidence" },
|
|
2331
|
+
item: { type: "string", description: "Adopt: stable exploration target id whose reviewable proposal should be appended" },
|
|
2332
|
+
releasePlanPath: { type: "string", description: "Adopt: project-relative target; defaults to .autotap/release-plan.json" },
|
|
2333
|
+
},
|
|
2334
|
+
},
|
|
2335
|
+
},
|
|
2336
|
+
{
|
|
2337
|
+
name: "tapp_flow_run",
|
|
2338
|
+
title: "Run a deterministic E2E flow",
|
|
2339
|
+
description:
|
|
2340
|
+
"Replay a deterministic, authored end-to-end test (a Flow) against iOS (XCUITest), Android " +
|
|
2341
|
+
"(ADB/UIAutomator), or web (Playwright), and return a scannable pass/fail report. A Flow is a list of steps + assertions " +
|
|
2342
|
+
"(see docs/flows-architecture.md). Unlike tapp_run_qa (autonomous exploration), a Flow does EXACTLY " +
|
|
2343
|
+
"what you specify, the same way every time โ use it for regression tests and verifying a fix. Steps: " +
|
|
2344
|
+
"{tap: X} ยท {type: {field: F, value: V}} ยท {swipe: up} ยท {back} ยท {wait_for: SCREEN}. Assertions " +
|
|
2345
|
+
"(deterministic): {assert_screen: X} ยท {assert_exists: X} ยท {assert_absent: X} ยท {assert_text: {of, contains}}. " +
|
|
2346
|
+
"Opt-in AI assertion: {assert_ai: '<claim about the current screen>'} (judged host-side; needs a key; " +
|
|
2347
|
+
"skipped otherwise). Pass a flow inline via `flow`, or a repo-relative `flowPath` to a .yml/.json. " +
|
|
2348
|
+
"A failed assertion fails the flow and is reported like a QA finding. $TEST_EMAIL/$TEST_PASSWORD and any " +
|
|
2349
|
+
"flow `vars` are substituted; pass testEmail/testPassword for real credential values.",
|
|
2350
|
+
inputSchema: {
|
|
2351
|
+
type: "object",
|
|
2352
|
+
properties: {
|
|
2353
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2354
|
+
flow: {
|
|
2355
|
+
type: "object",
|
|
2356
|
+
description:
|
|
2357
|
+
"Inline Flow: {name, app, steps:[...], vars?}. Example: {name:'login', app:'com.acme.app', steps:[{tap:'Sign In'}, {type:{field:'Email', value:'$TEST_EMAIL'}}, {tap:'Continue'}, {assert_screen:'Home'}]}",
|
|
2358
|
+
},
|
|
2359
|
+
flowPath: { type: "string", description: "Alternative to `flow`: repo-relative path to a .yml/.json Flow (e.g. .autotap/flows/login.yml)" },
|
|
2360
|
+
platform: { type: "string", enum: ["ios", "web", "android"], description: "Overrides Flow platform detection" },
|
|
2361
|
+
appBundleId: { type: "string", description: "iOS: overrides the Flow's `app:` field" },
|
|
2362
|
+
androidAppId: { type: "string", description: "Android: overrides the Flow's `app:` field" },
|
|
2363
|
+
url: { type: "string", description: "Web start URL. Overrides the Flow's `url:` field." },
|
|
2364
|
+
apkPath: { type: "string", description: "Android APK to install before replay." },
|
|
2365
|
+
androidSerial: { type: "string", description: "Android adb device serial." },
|
|
2366
|
+
testEmail: { type: "string", description: "Value for $TEST_EMAIL" },
|
|
2367
|
+
testPassword: { type: "string", description: "Value for $TEST_PASSWORD" },
|
|
2368
|
+
},
|
|
2369
|
+
},
|
|
2370
|
+
},
|
|
2371
|
+
{
|
|
2372
|
+
name: "tapp_scenario_run",
|
|
2373
|
+
title: "Run a deterministic multi-actor Scenario",
|
|
2374
|
+
description:
|
|
2375
|
+
"Replay a repository-native system test whose named actors run in isolated browser contexts against shared application state. " +
|
|
2376
|
+
"Scenarios use the same deterministic Flow actions/assertions plus explicit actors, shared variables, setup/teardown requests, " +
|
|
2377
|
+
"and polling timeouts for eventual consistency. No model or API key is used during replay. Every result is tagged with its actor. " +
|
|
2378
|
+
"Web actor isolation is implemented now; iOS and Android multi-actor replay is reported as unsupported rather than simulated.",
|
|
2379
|
+
inputSchema: {
|
|
2380
|
+
type: "object",
|
|
2381
|
+
properties: {
|
|
2382
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2383
|
+
scenario: { type: "object", description: "Inline Scenario with {kind:'scenario', platform:'web', actors, steps, setup?, teardown?}" },
|
|
2384
|
+
scenarioPath: { type: "string", description: "Repo-relative path to a .yml/.json Scenario" },
|
|
2385
|
+
url: { type: "string", description: "Override the Scenario's web URL" },
|
|
2386
|
+
variables: { type: "object", additionalProperties: { type: "string" }, description: "Explicit non-secret/shared variable overrides. Actor secrets may also be referenced by environment variable name in committed Scenario vars." },
|
|
2387
|
+
},
|
|
2388
|
+
},
|
|
2389
|
+
},
|
|
2390
|
+
{
|
|
2391
|
+
name: "tapp_flow_generate",
|
|
2392
|
+
title: "Generate a Flow from a goal (AI)",
|
|
2393
|
+
description:
|
|
2394
|
+
"Write a deterministic E2E Flow from a natural-language goal (e.g. 'sign in and open Settings'), " +
|
|
2395
|
+
"GROUNDED in the app's real screens so it can't invent steps. Tapp explores the app to build a " +
|
|
2396
|
+
"screen/control map (or reuses a recent run via captureId), then a model authors a Flow using only " +
|
|
2397
|
+
"screens/controls that were actually observed. Saves it to .autotap/flows/<name>.yml and returns the " +
|
|
2398
|
+
"YAML for review (optionally runs it). Needs a model backend (Tapp subscription token or " +
|
|
2399
|
+
"ANTHROPIC_API_KEY). Use this to bootstrap a test you then refine; use tapp_flow_run to replay it.",
|
|
2400
|
+
inputSchema: {
|
|
2401
|
+
type: "object",
|
|
2402
|
+
properties: {
|
|
2403
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2404
|
+
goal: { type: "string", description: "What the test should do, in plain English (e.g. 'sign in with test creds and reach the dashboard')" },
|
|
2405
|
+
appBundleId: { type: "string", description: "Bundle id of the installed app to author against" },
|
|
2406
|
+
captureId: { type: "string", description: "Reuse this capture's grounding instead of exploring (from a prior run_qa, faster)" },
|
|
2407
|
+
maxActions: { type: "integer", minimum: 5, maximum: 200, default: 35, description: "Exploration budget when building grounding" },
|
|
2408
|
+
run: { type: "boolean", default: false, description: "Also replay the generated flow and include the pass/fail result" },
|
|
2409
|
+
testEmail: { type: "string" },
|
|
2410
|
+
testPassword: { type: "string" },
|
|
2411
|
+
},
|
|
2412
|
+
required: ["goal", "appBundleId"],
|
|
2413
|
+
},
|
|
2414
|
+
},
|
|
2415
|
+
{
|
|
2416
|
+
name: "tapp_flow_save",
|
|
2417
|
+
title: "Save the session as a Flow",
|
|
2418
|
+
description:
|
|
2419
|
+
"Save what you've done in the CURRENT interactive session as a reusable, deterministic Flow " +
|
|
2420
|
+
"(record-by-doing). Every successful tapp_session_act (tap/type/swipe/back) is recorded; this " +
|
|
2421
|
+
"writes them to .autotap/flows/<name>.yml with wait_for steps auto-inserted on screen changes and a " +
|
|
2422
|
+
"final assert_screen checkpoint. Typed credentials are templated to $TEST_EMAIL/$TEST_PASSWORD so the " +
|
|
2423
|
+
"flow is shareable. The saved flow replays with tapp_flow_run. Do it once โ it's a test.",
|
|
2424
|
+
inputSchema: {
|
|
2425
|
+
type: "object",
|
|
2426
|
+
properties: {
|
|
2427
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2428
|
+
name: { type: "string", description: "Human name for the flow, e.g. 'Sign in and reach Home'" },
|
|
2429
|
+
addFinalAssertion: { type: "boolean", default: true, description: "Append assert_screen for the final screen as a checkpoint" },
|
|
2430
|
+
replace: { type: "boolean", default: false, description: "Explicitly replace a Flow with the same generated filename. Existing Flows are preserved by default." },
|
|
2431
|
+
},
|
|
2432
|
+
required: ["name"],
|
|
2433
|
+
},
|
|
2434
|
+
},
|
|
2435
|
+
{
|
|
2436
|
+
name: "tapp_ui_tree",
|
|
2437
|
+
title: "Inspect screen (a11y tree)",
|
|
2438
|
+
description:
|
|
2439
|
+
"Dump the accessibility (UI) tree of the current screen of an installed iOS or Android app โ " +
|
|
2440
|
+
"the inspection primitive (like Playwright's snapshot). Returns {screenTitle, elements:[{type,id,label," +
|
|
2441
|
+
"enabled,hittable,x,y,w,h}]}. Use it to see what's on screen before/after acting.",
|
|
2442
|
+
inputSchema: {
|
|
2443
|
+
type: "object",
|
|
2444
|
+
properties: {
|
|
2445
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2446
|
+
appBundleId: { type: "string", description: "iOS bundle id of the installed app" },
|
|
2447
|
+
androidAppId: { type: "string", description: "Android application id of the installed app" },
|
|
2448
|
+
androidSerial: { type: "string", description: "Android adb device serial" },
|
|
2449
|
+
},
|
|
2450
|
+
},
|
|
2451
|
+
},
|
|
2452
|
+
{
|
|
2453
|
+
name: "tapp_screenshot",
|
|
2454
|
+
title: "Screenshot current screen",
|
|
2455
|
+
description:
|
|
2456
|
+
"Return an inline image of whatever is CURRENTLY on the booted simulator. It does NOT launch or " +
|
|
2457
|
+
"navigate the app โ it just photographs the current screen (use it during a session, or after " +
|
|
2458
|
+
"tapp_open_app). To launch an app and screenshot the screen it opens on, use tapp_open_app instead.",
|
|
2459
|
+
inputSchema: {
|
|
2460
|
+
type: "object",
|
|
2461
|
+
properties: {
|
|
2462
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2463
|
+
maxWidth: { type: "integer", minimum: 200, maximum: 1400, default: 700, description: "Max image width in px (downscaled to keep payload small)" },
|
|
2464
|
+
},
|
|
2465
|
+
},
|
|
2466
|
+
},
|
|
2467
|
+
{
|
|
2468
|
+
name: "tapp_open_app",
|
|
2469
|
+
title: "Launch app + screenshot",
|
|
2470
|
+
description:
|
|
2471
|
+
"Launch an installed iOS or Android app and return a SCREENSHOT of the screen it lands on " +
|
|
2472
|
+
"(plus the accessibility tree) โ with NO exploration. This is the fast way (seconds) to just SEE a " +
|
|
2473
|
+
"screen. Use this โ NOT tapp_run_qa โ whenever the user wants to view or screenshot a screen. Pass " +
|
|
2474
|
+
"appLaunchArgs like [\"--uitesting\"] to bypass login and land on the home screen, and appLaunchEnv for " +
|
|
2475
|
+
"a backend override. The app is launched fresh and closed afterward. (To screenshot a screen reached by " +
|
|
2476
|
+
"real login or several taps, use a session instead and call tapp_screenshot along the way.)",
|
|
2477
|
+
inputSchema: {
|
|
2478
|
+
type: "object",
|
|
2479
|
+
properties: {
|
|
2480
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2481
|
+
appBundleId: { type: "string", description: "iOS bundle id of the installed app" },
|
|
2482
|
+
androidAppId: { type: "string", description: "Android application id of the installed app" },
|
|
2483
|
+
apkPath: { type: "string", description: "Android APK to install before launch" },
|
|
2484
|
+
androidSerial: { type: "string", description: "Android adb device serial" },
|
|
2485
|
+
clearData: { type: "boolean", default: false, description: "Android: clear app data before launch" },
|
|
2486
|
+
appLaunchArgs: { type: "array", items: { type: "string" }, description: "Launch args, e.g. [\"--uitesting\"] to bypass login" },
|
|
2487
|
+
appLaunchEnv: { type: "object", additionalProperties: { type: "string" }, description: "Launch env, e.g. {\"UI_TEST_BACKEND\": \"staging\"}" },
|
|
2488
|
+
maxWidth: { type: "integer", minimum: 200, maximum: 1400, default: 700, description: "Max screenshot width in px" },
|
|
2489
|
+
},
|
|
2490
|
+
},
|
|
2491
|
+
},
|
|
2492
|
+
{
|
|
2493
|
+
name: "tapp_list_simulators",
|
|
2494
|
+
title: "List simulators",
|
|
2495
|
+
description: "List available iOS simulators (name, udid, state, runtime, booted) so you can pick or boot one before running QA.",
|
|
2496
|
+
inputSchema: { type: "object", properties: {} },
|
|
2497
|
+
},
|
|
2498
|
+
{
|
|
2499
|
+
name: "tapp_boot_simulator",
|
|
2500
|
+
title: "Boot simulator",
|
|
2501
|
+
description: "Boot an iOS simulator by udid (preferred) or name so Tapp can run against it. No-op if already booted.",
|
|
2502
|
+
inputSchema: {
|
|
2503
|
+
type: "object",
|
|
2504
|
+
properties: {
|
|
2505
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2506
|
+
udid: { type: "string", description: "Simulator UDID (from tapp_list_simulators)" },
|
|
2507
|
+
name: { type: "string", description: "Simulator name, e.g. 'iPhone 16 Pro' (used if udid omitted)" },
|
|
2508
|
+
},
|
|
2509
|
+
},
|
|
2510
|
+
},
|
|
2511
|
+
{
|
|
2512
|
+
name: "tapp_install_app",
|
|
2513
|
+
title: "Install app on sim",
|
|
2514
|
+
description:
|
|
2515
|
+
"Build a target iOS app for the booted simulator and install it, so it's ready for tapp_run_qa or " +
|
|
2516
|
+
"a session. Provide the Xcode project OR workspace path + scheme. Best-effort โ apps with CocoaPods/" +
|
|
2517
|
+
"signing quirks may still need their normal build. Returns {ok, installed, simulator}.",
|
|
2518
|
+
inputSchema: {
|
|
2519
|
+
type: "object",
|
|
2520
|
+
properties: {
|
|
2521
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2522
|
+
project: { type: "string", description: "Absolute path to .xcodeproj (use this OR workspace)" },
|
|
2523
|
+
workspace: { type: "string", description: "Absolute path to .xcworkspace (use this OR project)" },
|
|
2524
|
+
scheme: { type: "string", description: "Scheme to build" },
|
|
2525
|
+
configuration: { type: "string", default: "Debug", description: "Build configuration (default Debug)" },
|
|
2526
|
+
cleanInstall: { type: "boolean", default: true, description: "Uninstall the app first (clears data + keychain session; avoids Firebase keychain errors). Set false to install over the existing app." },
|
|
2527
|
+
},
|
|
2528
|
+
required: ["scheme"],
|
|
2529
|
+
},
|
|
2530
|
+
},
|
|
2531
|
+
{
|
|
2532
|
+
name: "tapp_session_start",
|
|
2533
|
+
title: "Start interactive session",
|
|
2534
|
+
description:
|
|
2535
|
+
"Start a PERSISTENT interactive session against an installed iOS or Android app. The app " +
|
|
2536
|
+
"launches once and stays up, so you can drive a Playwright-style tap โ inspect loop without a cold " +
|
|
2537
|
+
"launch per action. Returns the initial screen {screenTitle, elements[]}. Drive it with " +
|
|
2538
|
+
"tapp_session_act and finish with tapp_session_end. Only one session at a time. Starts from a " +
|
|
2539
|
+
"fresh launch. Use appLaunchArgs/appLaunchEnv for apps that need a backend override or login bypass. " +
|
|
2540
|
+
"When you reach a screen with input fields and don't have values for them, ASK THE USER what to type " +
|
|
2541
|
+
"(offer defaults/skip) before typing โ the session does not prompt on its own.",
|
|
2542
|
+
inputSchema: {
|
|
2543
|
+
type: "object",
|
|
2544
|
+
properties: {
|
|
2545
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2546
|
+
appBundleId: { type: "string", description: "Bundle id of the installed app to drive" },
|
|
2547
|
+
androidAppId: { type: "string", description: "Android application id to drive (alternative to appBundleId)" },
|
|
2548
|
+
apkPath: { type: "string", description: "Android APK to install before starting" },
|
|
2549
|
+
androidSerial: { type: "string", description: "Android adb device serial" },
|
|
2550
|
+
clearData: { type: "boolean", default: true, description: "Android: clear app data before launch" },
|
|
2551
|
+
testEmail: { type: "string", description: "Email available to the app/harness, if it has a sign-in" },
|
|
2552
|
+
testPassword: { type: "string", description: "Password available to the app/harness" },
|
|
2553
|
+
appLaunchArgs: { type: "array", items: { type: "string" }, description: "Launch arguments, e.g. [\"--uitesting\"]" },
|
|
2554
|
+
appLaunchEnv: { type: "object", additionalProperties: { type: "string" }, description: "Launch environment, e.g. {\"UI_TEST_BACKEND\": \"staging\"}" },
|
|
2555
|
+
},
|
|
2556
|
+
},
|
|
2557
|
+
},
|
|
2558
|
+
{
|
|
2559
|
+
name: "tapp_session_act",
|
|
2560
|
+
title: "Session: tap/type/inspect",
|
|
2561
|
+
description:
|
|
2562
|
+
"Perform ONE action in the active interactive session and get the resulting screen back (the fresh " +
|
|
2563
|
+
"accessibility tree). Actions: 'login' (`email` + `password` โ fills the login form, submits, and " +
|
|
2564
|
+
"verifies IN ONE CALL; always prefer this over manual type/tap for sign-in: iOS wipes secure fields " +
|
|
2565
|
+
"on refocus, so step-by-step login flows lose the password), 'tap' (by `id` = accessibility identifier " +
|
|
2566
|
+
"or visible/partial label or placeholder, or by `x`/`y` coordinates), 'type' (`text`, optional `id` to " +
|
|
2567
|
+
"target a field โ always REPLACES the field's content), 'swipe' (`direction`), 'back', 'wait' (block " +
|
|
2568
|
+
"until an element with `id`/`text` appears, up to `timeoutMs`), 'tree' (re-inspect without acting), " +
|
|
2569
|
+
"'screenshot'. Returns {status, screenTitle, elements[]}; status 'not_found'/'timeout'/'still_on_login' " +
|
|
2570
|
+
"etc. with a `detail` explaining login failures.",
|
|
2571
|
+
inputSchema: {
|
|
2572
|
+
type: "object",
|
|
2573
|
+
properties: {
|
|
2574
|
+
authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" },
|
|
2575
|
+
action: { type: "string", enum: ["login", "tap", "type", "swipe", "back", "wait", "tree", "screenshot"] },
|
|
2576
|
+
email: { type: "string", description: "login: email/username to sign in with" },
|
|
2577
|
+
password: { type: "string", description: "login: password to sign in with" },
|
|
2578
|
+
id: { type: "string", description: "Element accessibility id or visible/partial label (for tap/type/wait)" },
|
|
2579
|
+
x: { type: "number", description: "Tap X coordinate (points), if not using id" },
|
|
2580
|
+
y: { type: "number", description: "Tap Y coordinate (points), if not using id" },
|
|
2581
|
+
text: { type: "string", description: "Text to type, or the label/text to wait for" },
|
|
2582
|
+
direction: { type: "string", enum: ["up", "down", "left", "right"], description: "Swipe direction" },
|
|
2583
|
+
timeoutMs: { type: "integer", minimum: 500, maximum: 60000, default: 5000, description: "For 'wait': how long to poll for the element" },
|
|
2584
|
+
label: { type: "string", description: "Optional screenshot label" },
|
|
2585
|
+
},
|
|
2586
|
+
required: ["action"],
|
|
2587
|
+
},
|
|
2588
|
+
},
|
|
2589
|
+
{
|
|
2590
|
+
name: "tapp_session_end",
|
|
2591
|
+
title: "End session",
|
|
2592
|
+
description: "End the active interactive session (quits the app + harness). Always call this when done.",
|
|
2593
|
+
inputSchema: {
|
|
2594
|
+
type: "object",
|
|
2595
|
+
properties: { authToken: { type: "string", description: "Required when AUTOTAP_MCP_TOKEN is set" } },
|
|
2596
|
+
},
|
|
2597
|
+
},
|
|
2598
|
+
],
|
|
2599
|
+
}));
|
|
2600
|
+
|
|
2601
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2602
|
+
const { name, arguments: args = {} } = request.params;
|
|
2603
|
+
|
|
2604
|
+
if (name === "tapp_health") {
|
|
2605
|
+
const checks = [];
|
|
2606
|
+
|
|
2607
|
+
checks.push({
|
|
2608
|
+
check: "repoRoot",
|
|
2609
|
+
ok: fs.existsSync(path.join(repoRoot, "Tapp.xcodeproj")),
|
|
2610
|
+
value: repoRoot,
|
|
2611
|
+
});
|
|
2612
|
+
|
|
2613
|
+
const nodeVersion = await runCommand("node", ["-v"]);
|
|
2614
|
+
checks.push({
|
|
2615
|
+
check: "node",
|
|
2616
|
+
ok: nodeVersion.code === 0,
|
|
2617
|
+
value: nodeVersion.stdout.trim() || nodeVersion.stderr.trim(),
|
|
2618
|
+
});
|
|
2619
|
+
|
|
2620
|
+
const xcodebuildVersion = await runCommand("xcodebuild", ["-version"]);
|
|
2621
|
+
checks.push({
|
|
2622
|
+
check: "xcodebuild",
|
|
2623
|
+
ok: xcodebuildVersion.code === 0,
|
|
2624
|
+
value: (xcodebuildVersion.stdout || xcodebuildVersion.stderr).trim().split("\n")[0] || "not found",
|
|
2625
|
+
});
|
|
2626
|
+
|
|
2627
|
+
const simctl = await runCommand("xcrun", ["simctl", "list", "devices", "booted"]);
|
|
2628
|
+
checks.push({
|
|
2629
|
+
check: "bootedSimulator",
|
|
2630
|
+
ok: simctl.code === 0,
|
|
2631
|
+
value: (simctl.stdout || simctl.stderr).trim(),
|
|
2632
|
+
});
|
|
2633
|
+
|
|
2634
|
+
const allOk = checks.every((c) => c.ok);
|
|
2635
|
+
const bootedLine = (simctl.stdout || "").split("\n").find((l) => /\(Booted\)/.test(l));
|
|
2636
|
+
const bootedName = bootedLine ? bootedLine.trim().replace(/\s*\(.*$/, "") : null;
|
|
2637
|
+
const L = [`### ${allOk ? "๐ฉบ Tapp ready" : "โ ๏ธ Tapp not fully ready"}`, ""];
|
|
2638
|
+
for (const c of checks) {
|
|
2639
|
+
L.push(`- ${c.ok ? "โ
" : "โ"} **${c.check}** โ ${String(c.value).split("\n")[0] || "โ"}`);
|
|
2640
|
+
}
|
|
2641
|
+
L.push("");
|
|
2642
|
+
L.push(bootedName ? `๐ฑ Simulator booted: **${bootedName}**` : "๐ฑ No simulator booted โ run `tapp_boot_simulator` first.");
|
|
2643
|
+
return richResult(L.join("\n"), { ok: allOk, checks });
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
if (name === "tapp_build") {
|
|
2647
|
+
const unauthorized = ensureAuthorized(args);
|
|
2648
|
+
if (unauthorized) return unauthorized;
|
|
2649
|
+
|
|
2650
|
+
const dir = isNonEmptyString(args.projectDir) ? path.resolve(args.projectDir.trim()) : process.cwd();
|
|
2651
|
+
const startedAt = Date.now();
|
|
2652
|
+
const built = await buildAppForSim({ dir, scheme: args.scheme, configuration: isNonEmptyString(args.configuration) ? args.configuration.trim() : "Debug" });
|
|
2653
|
+
if (built.error) return errorResult(built.error, built.details || {});
|
|
2654
|
+
let bundleId;
|
|
2655
|
+
if (args.install !== false) {
|
|
2656
|
+
const sim = await ensureBootedSim({ autoBoot: true });
|
|
2657
|
+
if (sim.error) return errorResult(sim.error);
|
|
2658
|
+
const inst = await installAppOnBootedSim(built.appPath);
|
|
2659
|
+
if (inst.error) return errorResult(inst.error);
|
|
2660
|
+
bundleId = inst.bundleId;
|
|
2661
|
+
}
|
|
2662
|
+
const text =
|
|
2663
|
+
`๐จ Built **${path.basename(built.appPath)}** (scheme \`${built.scheme}\`) in ${fmtDuration(Date.now() - startedAt)}` +
|
|
2664
|
+
(bundleId ? ` โ installed on the simulator as \`${bundleId}\`` : "") +
|
|
2665
|
+
`\n\nNext: \`tapp_run_qa\` with \`appBundleId: "${bundleId || "<install it first>"}"\`.`;
|
|
2666
|
+
return richResult(text, { ok: true, appPath: built.appPath, scheme: built.scheme, container: built.container, bundleId });
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
if (name === "tapp_capture") {
|
|
2670
|
+
const unauthorized = ensureAuthorized(args);
|
|
2671
|
+
if (unauthorized) return unauthorized;
|
|
2672
|
+
|
|
2673
|
+
const mode = typeof args.mode === "string" ? args.mode.trim() : "";
|
|
2674
|
+
const allowedModes = new Set(["screenshot", "record", "explore", "tree"]);
|
|
2675
|
+
if (!allowedModes.has(mode)) {
|
|
2676
|
+
return errorResult("Invalid mode", { allowedModes: Array.from(allowedModes), received: args.mode ?? null });
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
if ((mode === "explore" || mode === "tree") && !isNonEmptyString(args.appBundleId)) {
|
|
2680
|
+
return errorResult("appBundleId is required for explore/tree mode");
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
const captureScript = path.join(scriptsDir, "quick-capture.sh");
|
|
2684
|
+
if (!fs.existsSync(captureScript)) {
|
|
2685
|
+
return errorResult("Capture script not found", { captureScript });
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
const cmdArgs = [captureScript, mode];
|
|
2689
|
+
|
|
2690
|
+
if (mode === "explore" || mode === "tree") {
|
|
2691
|
+
cmdArgs.push(String(args.appBundleId).trim());
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
const actions = asInteger(args.actions, null);
|
|
2695
|
+
if (mode === "explore" && actions !== null) {
|
|
2696
|
+
if (actions < 1 || actions > 1000) {
|
|
2697
|
+
return errorResult("actions must be between 1 and 1000", { received: actions });
|
|
2698
|
+
}
|
|
2699
|
+
cmdArgs.push("--actions", String(actions));
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
const duration = asInteger(args.duration, null);
|
|
2703
|
+
if (mode === "record" && duration !== null) {
|
|
2704
|
+
if (duration < 1 || duration > 3600) {
|
|
2705
|
+
return errorResult("duration must be between 1 and 3600 seconds", { received: duration });
|
|
2706
|
+
}
|
|
2707
|
+
cmdArgs.push("--duration", String(duration));
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
const env = {};
|
|
2711
|
+
if (typeof args.testEmail === "string" && args.testEmail) {
|
|
2712
|
+
env.OCQA_TEST_EMAIL = args.testEmail;
|
|
2713
|
+
}
|
|
2714
|
+
if (typeof args.testPassword === "string" && args.testPassword) {
|
|
2715
|
+
env.OCQA_TEST_PASSWORD = args.testPassword;
|
|
2716
|
+
}
|
|
2717
|
+
if (args.inputOverrides && typeof args.inputOverrides === "object" && !Array.isArray(args.inputOverrides)) {
|
|
2718
|
+
const entries = Object.entries(args.inputOverrides).filter(
|
|
2719
|
+
([k, v]) => typeof k === "string" && typeof v === "string" && k.trim() && v.length > 0
|
|
2720
|
+
);
|
|
2721
|
+
if (entries.length > 0) {
|
|
2722
|
+
const sanitized = Object.fromEntries(entries.map(([k, v]) => [k.trim(), v]));
|
|
2723
|
+
env.OCQA_INPUT_OVERRIDES_JSON = JSON.stringify(sanitized);
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
const before = new Set(listCaptureRuns(50).map((r) => r.id));
|
|
2728
|
+
const result = await runCommand("bash", cmdArgs, {
|
|
2729
|
+
cwd: repoRoot,
|
|
2730
|
+
env,
|
|
2731
|
+
timeoutMs: mode === "explore" ? 30 * 60 * 1000 : 10 * 60 * 1000,
|
|
2732
|
+
});
|
|
2733
|
+
const after = listCaptureRuns(50);
|
|
2734
|
+
const created = after.find((r) => !before.has(r.id));
|
|
2735
|
+
|
|
2736
|
+
const ok = result.code === 0;
|
|
2737
|
+
const title = ok ? "๐ฆ Capture complete" : `โ Capture failed${result.timedOut ? " (timed out)" : ""}`;
|
|
2738
|
+
const out = [title];
|
|
2739
|
+
if (created) out.push(`\nRun: \`${created.relativePath}\``);
|
|
2740
|
+
if (!ok) {
|
|
2741
|
+
const tail = (result.stderr || result.stdout || "").trim();
|
|
2742
|
+
if (tail) out.push("\n```", tail.slice(-1200), "```");
|
|
2743
|
+
}
|
|
2744
|
+
return richResult(out.join("\n"), {
|
|
2745
|
+
code: result.code,
|
|
2746
|
+
ok,
|
|
2747
|
+
createdCapture: created || null,
|
|
2748
|
+
stdout: result.stdout,
|
|
2749
|
+
stderr: result.stderr,
|
|
2750
|
+
timedOut: result.timedOut,
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
if (name === "tapp_parse_markers") {
|
|
2755
|
+
let runPath = null;
|
|
2756
|
+
if (isNonEmptyString(args.runPath)) {
|
|
2757
|
+
runPath = normalizeCapturePath(args.runPath.trim());
|
|
2758
|
+
if (!runPath) {
|
|
2759
|
+
return errorResult("runPath must be inside captures/", { capturesDir });
|
|
2760
|
+
}
|
|
2761
|
+
} else if (isNonEmptyString(args.runId)) {
|
|
2762
|
+
runPath = normalizeCapturePath(path.join(capturesDir, args.runId.trim()));
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
if (!runPath) {
|
|
2766
|
+
return errorResult("Provide runId or runPath");
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
const markersFilePath = path.join(runPath, "ocqa-markers.txt");
|
|
2770
|
+
const parsed = parseOcqaMarkers(markersFilePath);
|
|
2771
|
+
if (!parsed) {
|
|
2772
|
+
return errorResult("Markers file not found", { markersFilePath });
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
const c = parsed.counts || {};
|
|
2776
|
+
const screens = Array.isArray(parsed.uniqueScreens) ? parsed.uniqueScreens : [];
|
|
2777
|
+
const L = [
|
|
2778
|
+
`๐งพ Parsed markers from \`${parsed.relativeMarkersFilePath || "ocqa-markers.txt"}\``,
|
|
2779
|
+
"",
|
|
2780
|
+
`States: **${c.STATE || 0}** ยท Actions: **${c.ACTION || 0}** ยท Issues: **${c.ISSUE || 0}** ยท Transitions: **${c.TRANSITION || 0}**`,
|
|
2781
|
+
`Screens: ${screens.length ? screens.slice(0, 8).join(", ") : "none"}${screens.length > 8 ? ` (+${screens.length - 8} more)` : ""}`,
|
|
2782
|
+
];
|
|
2783
|
+
return richResult(L.join("\n"), parsed);
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
if (name === "tapp_list_captures") {
|
|
2787
|
+
const limit = asInteger(args.limit, 10);
|
|
2788
|
+
if (limit < 1 || limit > 100) {
|
|
2789
|
+
return errorResult("limit must be between 1 and 100", { received: limit });
|
|
2790
|
+
}
|
|
2791
|
+
const captures = listCaptureRuns(limit);
|
|
2792
|
+
const L = [`๐๏ธ Found **${captures.length}** capture run${captures.length === 1 ? "" : "s"}`];
|
|
2793
|
+
if (captures.length) {
|
|
2794
|
+
L.push("");
|
|
2795
|
+
for (const c of captures.slice(0, 12)) {
|
|
2796
|
+
L.push(`- \`${c.id}\` ยท ${c.relativePath}`);
|
|
2797
|
+
}
|
|
2798
|
+
if (captures.length > 12) L.push(`- โฆand ${captures.length - 12} more`);
|
|
2799
|
+
}
|
|
2800
|
+
return richResult(L.join("\n"), { captures });
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
if (name === "tapp_capture_summary") {
|
|
2804
|
+
let runPath = null;
|
|
2805
|
+
if (isNonEmptyString(args.runPath)) {
|
|
2806
|
+
runPath = normalizeCapturePath(args.runPath.trim());
|
|
2807
|
+
if (!runPath) {
|
|
2808
|
+
return errorResult("runPath must be inside captures/", { capturesDir });
|
|
2809
|
+
}
|
|
2810
|
+
} else if (isNonEmptyString(args.runId)) {
|
|
2811
|
+
runPath = normalizeCapturePath(path.join(capturesDir, args.runId.trim()));
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
if (!runPath) {
|
|
2815
|
+
return errorResult("Provide runId or runPath");
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
const summary = summarizeCapture(runPath);
|
|
2819
|
+
if (!summary) {
|
|
2820
|
+
return errorResult("Capture not found", { runPath });
|
|
2821
|
+
}
|
|
2822
|
+
const L = [
|
|
2823
|
+
`๐ Capture summary โ \`${summary.relativePath}\``,
|
|
2824
|
+
"",
|
|
2825
|
+
`Screenshots: **${summary.screenshotCount}** ยท Videos: **${summary.videos.length}** ยท Markers: **${summary.hasMarkers ? "yes" : "no"}**`,
|
|
2826
|
+
];
|
|
2827
|
+
if (summary.videos.length) L.push(`Videos: ${summary.videos.join(", ")}`);
|
|
2828
|
+
return richResult(L.join("\n"), summary);
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
if (name === "tapp_run_qa") {
|
|
2832
|
+
const unauthorized = ensureAuthorized(args);
|
|
2833
|
+
if (unauthorized) return unauthorized;
|
|
2834
|
+
const wantsWeb = isNonEmptyString(args.url);
|
|
2835
|
+
const wantsAndroid = isNonEmptyString(args.androidAppId);
|
|
2836
|
+
const targets = [wantsWeb, wantsAndroid, isNonEmptyString(args.appBundleId)].filter(Boolean).length;
|
|
2837
|
+
if (targets !== 1) {
|
|
2838
|
+
return errorResult("Provide exactly one of appBundleId (iOS), androidAppId (Android), or url (web beta)");
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
// Both branches call the shared engine (runQaWeb/runQaIos) โ the handler only adds
|
|
2842
|
+
// MCP concerns: auth, arg validation, and progress notifications.
|
|
2843
|
+
const progressToken = request.params && request.params._meta ? request.params._meta.progressToken : undefined;
|
|
2844
|
+
const budget = Math.max(1, Math.min(1000, asInteger(args.maxActions, 60)));
|
|
2845
|
+
const notifyProgress = (unit) => (p) => {
|
|
2846
|
+
if (progressToken === undefined) return;
|
|
2847
|
+
const total = p.max || budget;
|
|
2848
|
+
server.notification({
|
|
2849
|
+
method: "notifications/progress",
|
|
2850
|
+
params: { progressToken, progress: p.action || 0, total, message: `๐ Exploringโฆ ${p.action}/${total} actions ยท ${p.states} ${unit} reached` },
|
|
2851
|
+
}).catch(() => {});
|
|
2852
|
+
};
|
|
2853
|
+
if (wantsWeb) {
|
|
2854
|
+
const r = await runQaWeb({
|
|
2855
|
+
url: args.url,
|
|
2856
|
+
maxActions: args.maxActions,
|
|
2857
|
+
timeout: args.timeout,
|
|
2858
|
+
testEmail: args.testEmail,
|
|
2859
|
+
testPassword: args.testPassword,
|
|
2860
|
+
baselineFindings: args.baselineFindings,
|
|
2861
|
+
onProgress: notifyProgress("pages"),
|
|
2862
|
+
});
|
|
2863
|
+
if (r.error) return errorResult(r.error, r.details || {});
|
|
2864
|
+
return richResult(r.text, r.structured);
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
if (wantsAndroid) {
|
|
2868
|
+
const r = await runQaAndroid({
|
|
2869
|
+
appId: args.androidAppId,
|
|
2870
|
+
apkPath: args.apkPath,
|
|
2871
|
+
serial: args.androidSerial,
|
|
2872
|
+
maxActions: args.maxActions,
|
|
2873
|
+
timeout: args.timeout,
|
|
2874
|
+
testEmail: args.testEmail,
|
|
2875
|
+
testPassword: args.testPassword,
|
|
2876
|
+
baselineFindings: args.baselineFindings,
|
|
2877
|
+
clearData: args.clearData !== false,
|
|
2878
|
+
onProgress: notifyProgress("screens"),
|
|
2879
|
+
});
|
|
2880
|
+
if (r.error) return errorResult(r.error, r.details || {});
|
|
2881
|
+
return richResult(r.text, r.structured);
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
let lastProgress = null;
|
|
2885
|
+
const iosProgress = notifyProgress("screens");
|
|
2886
|
+
const r = await runQaIos({
|
|
2887
|
+
bundleId: String(args.appBundleId).trim(),
|
|
2888
|
+
maxActions: args.maxActions,
|
|
2889
|
+
timeout: args.timeout,
|
|
2890
|
+
args,
|
|
2891
|
+
onProgress: (p) => {
|
|
2892
|
+
lastProgress = p;
|
|
2893
|
+
iosProgress(p);
|
|
2894
|
+
},
|
|
2895
|
+
});
|
|
2896
|
+
if (r.error) return errorResult(r.error, { ...(r.details || {}), lastProgress });
|
|
2897
|
+
return richResult(r.text, r.structured);
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
if (name === "tapp_init") {
|
|
2901
|
+
const unauthorized = ensureAuthorized(args);
|
|
2902
|
+
if (unauthorized) return unauthorized;
|
|
2903
|
+
const operation = String(args.operation || "inspect").toLowerCase();
|
|
2904
|
+
if (!["inspect", "write", "refresh", "explore"].includes(operation)) return errorResult("operation must be inspect|write|refresh|explore");
|
|
2905
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
|
|
2906
|
+
if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the repo");
|
|
2907
|
+
const maxContracts = asInteger(args.maxContracts, 15);
|
|
2908
|
+
if (maxContracts < 1 || maxContracts > 50) return errorResult("maxContracts must be between 1 and 50");
|
|
2909
|
+
const { initializeProductProject } = await import("./product-operations.js");
|
|
2910
|
+
try {
|
|
2911
|
+
const outDir = isNonEmptyString(args.outDir) ? args.outDir.trim() : ".autotap";
|
|
2912
|
+
const resolvedOut = path.resolve(projectDir, outDir);
|
|
2913
|
+
if (!isInsideDir(projectDir, resolvedOut)) return errorResult("outDir must be inside projectDir");
|
|
2914
|
+
const selectedPlatform = isNonEmptyString(args.platform) ? args.platform.trim().toLowerCase()
|
|
2915
|
+
: isNonEmptyString(args.url) ? "web"
|
|
2916
|
+
: isNonEmptyString(args.androidAppId) || isNonEmptyString(args.apkPath) ? "android" : "ios";
|
|
2917
|
+
const progressToken = request.params && request.params._meta ? request.params._meta.progressToken : undefined;
|
|
2918
|
+
const budget = Math.max(1, Math.min(1000, asInteger(args.maxActions, 40)));
|
|
2919
|
+
const result = await initializeProductProject({
|
|
2920
|
+
projectDir, mode: operation, outDir,
|
|
2921
|
+
ownedUrl: isNonEmptyString(args.url) ? args.url.trim() : "",
|
|
2922
|
+
platform: isNonEmptyString(args.platform) ? args.platform.trim().toLowerCase() : operation === "explore" ? selectedPlatform : "",
|
|
2923
|
+
target: isNonEmptyString(args.target) ? args.target.trim() : projectDir,
|
|
2924
|
+
bundleId: isNonEmptyString(args.appBundleId) ? args.appBundleId.trim() : "",
|
|
2925
|
+
appId: isNonEmptyString(args.androidAppId) ? args.androidAppId.trim() : "",
|
|
2926
|
+
apkPath: isNonEmptyString(args.apkPath) ? path.resolve(projectDir, args.apkPath.trim()) : undefined,
|
|
2927
|
+
serial: isNonEmptyString(args.androidSerial) ? args.androidSerial.trim() : undefined,
|
|
2928
|
+
maxActions: args.maxActions, timeout: args.timeout, maxContracts,
|
|
2929
|
+
testEmail: args.testEmail, testPassword: args.testPassword,
|
|
2930
|
+
runExploration: runInitExploration,
|
|
2931
|
+
onProgress: (progress) => {
|
|
2932
|
+
if (progressToken === undefined) return;
|
|
2933
|
+
server.notification({ method: "notifications/progress", params: { progressToken, progress: progress.action || 0, total: progress.max || budget, message: `Import exploration ยท ${progress.states} state(s) reached` } }).catch(() => {});
|
|
2934
|
+
},
|
|
2935
|
+
});
|
|
2936
|
+
const { model, plan, written, exploration } = result;
|
|
2937
|
+
const blocking = model.requirements.filter((item) => item.severity === "blocking");
|
|
2938
|
+
const pending = plan.items.filter((item) => item.decision === "pending");
|
|
2939
|
+
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.verdict}${exploration.inconclusive ? " (inconclusive)" : ""}` : ""}`;
|
|
2940
|
+
return richResult(summary, { model, plan, written: written ? { modelPath: written.modelPath, planPath: written.planPath } : null, exploration });
|
|
2941
|
+
} catch (error) { return errorResult("Could not initialize Tapp repository artifacts", { detail: error.message || String(error) }); }
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
if (name === "tapp_actor_config") {
|
|
2945
|
+
const unauthorized = ensureAuthorized(args);
|
|
2946
|
+
if (unauthorized) return unauthorized;
|
|
2947
|
+
const operation = String(args.operation || "read").toLowerCase();
|
|
2948
|
+
if (!["read", "set"].includes(operation)) return errorResult("operation must be read|set");
|
|
2949
|
+
const allowedArguments = new Set(["authToken", "operation", "projectDir", "name", "role", "session", "provisioning", "credentialBindings", "replace"]);
|
|
2950
|
+
const unexpectedArguments = Object.keys(args).filter((key) => !allowedArguments.has(key));
|
|
2951
|
+
if (unexpectedArguments.length) return errorResult("Unsupported actor configuration fields; credential values are never accepted", { fields: unexpectedArguments });
|
|
2952
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
|
|
2953
|
+
if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the repo");
|
|
2954
|
+
const { configureActor, readProjectConfig } = await import("./project-config.js");
|
|
2955
|
+
if (operation === "read") {
|
|
2956
|
+
const loaded = readProjectConfig(projectDir);
|
|
2957
|
+
if (loaded.errors.length) return errorResult("Project actor configuration is invalid", { path: loaded.path, errors: loaded.errors });
|
|
2958
|
+
return richResult(`๐ฅ Tapp actors โ ${Object.keys(loaded.config.actors || {}).length} configured ยท credential values are never returned`, { path: loaded.path, exists: loaded.exists, actors: loaded.config.actors || {}, lifecycle: loaded.config.lifecycle || {} });
|
|
2959
|
+
}
|
|
2960
|
+
if (!isNonEmptyString(args.name)) return errorResult("set requires name");
|
|
2961
|
+
const supplied = args.credentialBindings === undefined ? {} : args.credentialBindings;
|
|
2962
|
+
if (!supplied || typeof supplied !== "object" || Array.isArray(supplied)) return errorResult("credentialBindings must map credential names to environment-variable names");
|
|
2963
|
+
const credentials = Object.fromEntries(Object.entries(supplied).map(([key, env]) => [key, { env }]));
|
|
2964
|
+
try {
|
|
2965
|
+
const result = configureActor(projectDir, {
|
|
2966
|
+
name: args.name.trim(),
|
|
2967
|
+
role: isNonEmptyString(args.role) ? args.role.trim() : "",
|
|
2968
|
+
session: isNonEmptyString(args.session) ? args.session.trim() : "default",
|
|
2969
|
+
provisioning: isNonEmptyString(args.provisioning) ? args.provisioning.trim() : "existing",
|
|
2970
|
+
credentials,
|
|
2971
|
+
replace: asBoolean(args.replace),
|
|
2972
|
+
});
|
|
2973
|
+
return richResult(`โ
Actor '${args.name.trim()}' configured with ${Object.keys(result.actor.credentials).length} environment binding(s); no credential values were accepted or written`, { path: result.path, actor: result.actor, next: "Run tapp_init refresh to update the application model and release plan." });
|
|
2974
|
+
} catch (error) { return errorResult("Actor not configured", { detail: error.message || String(error) }); }
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
if (name === "tapp_release_plan") {
|
|
2978
|
+
const unauthorized = ensureAuthorized(args);
|
|
2979
|
+
if (unauthorized) return unauthorized;
|
|
2980
|
+
const operation = String(args.operation || "read").toLowerCase();
|
|
2981
|
+
if (!["read", "review", "generate", "validate", "promote"].includes(operation)) return errorResult("operation must be read|review|generate|validate|promote");
|
|
2982
|
+
const planPath = path.resolve(repoRoot, isNonEmptyString(args.planPath) ? args.planPath.trim() : ".autotap/release-plan.json");
|
|
2983
|
+
if (!isInsideDir(repoRoot, planPath)) return errorResult("planPath must be inside the repo");
|
|
2984
|
+
if (!fs.existsSync(planPath)) return errorResult("Release plan not found", { planPath });
|
|
2985
|
+
let plan;
|
|
2986
|
+
try { plan = JSON.parse(fs.readFileSync(planPath, "utf8")); }
|
|
2987
|
+
catch (error) { return errorResult("Release plan is invalid JSON", { detail: error.message || String(error) }); }
|
|
2988
|
+
if (operation === "review") {
|
|
2989
|
+
const decisions = { approve: args.approve || [], reject: args.reject || [], defer: args.defer || [] };
|
|
2990
|
+
if (![...decisions.approve, ...decisions.reject, ...decisions.defer].length) return errorResult("review requires at least one approve, reject, or defer item");
|
|
2991
|
+
const { reviewProductPlan } = await import("./product-operations.js");
|
|
2992
|
+
try {
|
|
2993
|
+
plan = reviewProductPlan({ projectDir: repoRoot, planPath, ...decisions }).plan;
|
|
2994
|
+
} catch (error) { return errorResult("Could not review release plan", { detail: error.message || String(error) }); }
|
|
2995
|
+
} else if (operation === "generate") {
|
|
2996
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
|
|
2997
|
+
if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the repo");
|
|
2998
|
+
const { generateProductPlan } = await import("./product-operations.js");
|
|
2999
|
+
try {
|
|
3000
|
+
const result = await generateProductPlan({ projectDir, planPath });
|
|
3001
|
+
plan = result.plan;
|
|
3002
|
+
return richResult(`๐งฉ Proposal drafts โ ${result.generatedTasks.length} UI-Map-grounded Task(s) ยท ${result.generated.length} compile-checked/untrusted contract(s) ยท ${result.blocked.length} blocked; deterministic real-surface replay remains required`, { plan, planPath, generatedTasks: result.generatedTasks, generated: result.generated, blocked: result.blocked });
|
|
3003
|
+
} catch (error) { return errorResult("Could not generate contract drafts", { detail: error.message || String(error) }); }
|
|
3004
|
+
} else if (operation === "validate") {
|
|
3005
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
|
|
3006
|
+
if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the repo");
|
|
3007
|
+
let apkPath = "";
|
|
3008
|
+
if (isNonEmptyString(args.apkPath)) {
|
|
3009
|
+
apkPath = path.resolve(projectDir, args.apkPath.trim());
|
|
3010
|
+
if (!isInsideDir(projectDir, apkPath)) return errorResult("apkPath must remain inside projectDir");
|
|
3011
|
+
}
|
|
3012
|
+
const { validateProductPlan } = await import("./product-operations.js");
|
|
3013
|
+
const progressToken = request.params && request.params._meta ? request.params._meta.progressToken : undefined;
|
|
3014
|
+
try {
|
|
3015
|
+
const result = await validateProductPlan({
|
|
3016
|
+
projectDir, planPath,
|
|
3017
|
+
items: Array.isArray(args.items) ? args.items : [],
|
|
3018
|
+
platform: isNonEmptyString(args.platform) ? args.platform.trim() : "",
|
|
3019
|
+
url: isNonEmptyString(args.url) ? args.url.trim() : "",
|
|
3020
|
+
target: isNonEmptyString(args.target) ? args.target.trim() : "",
|
|
3021
|
+
bundleId: isNonEmptyString(args.appBundleId) ? args.appBundleId.trim() : "",
|
|
3022
|
+
appId: isNonEmptyString(args.androidAppId) ? args.androidAppId.trim() : "",
|
|
3023
|
+
apkPath,
|
|
3024
|
+
serial: isNonEmptyString(args.androidSerial) ? args.androidSerial.trim() : "",
|
|
3025
|
+
timeout: asInteger(args.timeout, 600),
|
|
3026
|
+
startWebTarget: startManagedWebTarget,
|
|
3027
|
+
stopWebTarget: stopManagedWebTarget,
|
|
3028
|
+
onProgress: (entry) => {
|
|
3029
|
+
if (progressToken === undefined) return;
|
|
3030
|
+
server.notification({ method: "notifications/progress", params: { progressToken, progress: entry.current || 0, total: entry.total || 1, message: entry.text || entry.phase || "Validating release contract" } }).catch(() => {});
|
|
3031
|
+
},
|
|
3032
|
+
});
|
|
3033
|
+
plan = result.plan;
|
|
3034
|
+
if (!result.passed) return errorResult("Generated contract validation failed", { result, plan, planPath });
|
|
3035
|
+
return richResult(`๐ Generated contract validation passed โ ${result.results.length}/${result.results.length} on ${result.platform}`, { ...result, planPath });
|
|
3036
|
+
} catch (error) { return errorResult("Generated contract validation failed", { detail: error.message || String(error), plan, planPath }); }
|
|
3037
|
+
} else if (operation === "promote") {
|
|
3038
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
|
|
3039
|
+
if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the repo");
|
|
3040
|
+
const { promoteProductPlan } = await import("./product-operations.js");
|
|
3041
|
+
try {
|
|
3042
|
+
const result = await promoteProductPlan({ projectDir, planPath, items: Array.isArray(args.items) ? args.items : [] });
|
|
3043
|
+
plan = result.plan;
|
|
3044
|
+
return richResult(`๐ฆ Promoted ${result.promotedTasks.length} validated Task(s) and ${result.promotedContracts.length} release contract(s); canonical UI Map coverage updated`, { ...result, planPath });
|
|
3045
|
+
} catch (error) { return errorResult("Could not promote validated proposals", { detail: error.message || String(error) }); }
|
|
3046
|
+
}
|
|
3047
|
+
return richResult(`๐ ${plan.application?.name || "Tapp"} release plan โ ${plan.status} ยท ${(plan.items || []).length} item(s)`, { plan, planPath });
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
if (name === "tapp_ci_setup") {
|
|
3051
|
+
const unauthorized = ensureAuthorized(args);
|
|
3052
|
+
if (unauthorized) return unauthorized;
|
|
3053
|
+
const operation = String(args.operation || "inspect").toLowerCase();
|
|
3054
|
+
if (!["inspect", "install", "baseline"].includes(operation)) return errorResult("operation must be inspect|install|baseline");
|
|
3055
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
|
|
3056
|
+
if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the repo");
|
|
3057
|
+
const modelPath = isNonEmptyString(args.modelPath) ? path.resolve(repoRoot, args.modelPath.trim()) : path.join(projectDir, ".autotap", "application-model.json");
|
|
3058
|
+
if (!isInsideDir(projectDir, modelPath) || !fs.existsSync(modelPath)) return errorResult("Application model not found inside projectDir; run tapp_init first", { modelPath });
|
|
3059
|
+
let model;
|
|
3060
|
+
try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
|
|
3061
|
+
catch (error) { return errorResult("Application model is invalid JSON", { detail: error.message || String(error) }); }
|
|
3062
|
+
const { createProductBaseline, installProductCi, prepareProductCi } = await import("./product-operations.js");
|
|
3063
|
+
if (operation === "baseline") {
|
|
3064
|
+
if (!isNonEmptyString(args.reportPath)) return errorResult("baseline requires reportPath from a successful portable gate");
|
|
3065
|
+
const reportPath = path.resolve(repoRoot, args.reportPath.trim());
|
|
3066
|
+
if (!isInsideDir(repoRoot, reportPath) || !fs.existsSync(reportPath)) return errorResult("reportPath must be an existing JSON file inside the repo");
|
|
3067
|
+
let report;
|
|
3068
|
+
try { report = JSON.parse(fs.readFileSync(reportPath, "utf8")); }
|
|
3069
|
+
catch (error) { return errorResult("Gate report is invalid JSON", { detail: error.message || String(error) }); }
|
|
3070
|
+
try {
|
|
3071
|
+
const result = createProductBaseline({ projectDir, reportPath, platform: args.platform || "", target: args.target || "", baselinePath: isNonEmptyString(args.baselinePath) ? args.baselinePath.trim() : "", replace: asBoolean(args.replace) });
|
|
3072
|
+
return richResult(`โ
Conclusive baseline established โ ${result.selectedTarget.platform}:${result.selectedTarget.name} ยท ${result.validation.screensExplored} states ยท ${result.validation.suite.contracts} contract(s)`, result);
|
|
3073
|
+
} catch (error) { return errorResult("Baseline not written", { detail: error.message || String(error) }); }
|
|
3074
|
+
}
|
|
3075
|
+
try {
|
|
3076
|
+
const actionRef = isNonEmptyString(args.actionRef) ? args.actionRef.trim() : `aarwitz/tapp@v${pkgVersion}`;
|
|
3077
|
+
const defaultBranch = isNonEmptyString(args.defaultBranch) ? args.defaultBranch.trim() : "main";
|
|
3078
|
+
const rendered = prepareProductCi({ projectDir, modelPath, actionRef, defaultBranch });
|
|
3079
|
+
if (operation === "inspect") return richResult(`๐งฉ CI plan โ ${rendered.manifest.targets.length} target job(s) ยท ${rendered.manifest.unresolved.length} unresolved ยท read-only`, rendered);
|
|
3080
|
+
if (rendered.manifest.unresolved.length && !asBoolean(args.allowUnresolved)) return errorResult("CI workflow not installed because target configuration remains unresolved", { unresolved: rendered.manifest.unresolved, next: "Resolve the application model requirements or explicitly allow an inspect-only draft." });
|
|
3081
|
+
const result = installProductCi({ projectDir, modelPath, actionRef, defaultBranch, workflowPath: isNonEmptyString(args.workflowPath) ? args.workflowPath.trim() : ".github/workflows/tapp.yml", manifestPath: isNonEmptyString(args.manifestPath) ? args.manifestPath.trim() : ".autotap/ci.json", replace: asBoolean(args.replace), allowUnresolved: asBoolean(args.allowUnresolved) });
|
|
3082
|
+
return richResult(`โ
Reviewable CI gate installed โ ${result.manifest.targets.length} target job(s); no commit, push, branch protection, or GitHub resource was created`, result);
|
|
3083
|
+
} catch (error) { return errorResult("Could not prepare CI installation", { detail: error.message || String(error) }); }
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
if (name === "tapp_ui_map") {
|
|
3087
|
+
const unauthorized = ensureAuthorized(args);
|
|
3088
|
+
if (unauthorized) return unauthorized;
|
|
3089
|
+
const operation = String(args.operation || "read").toLowerCase();
|
|
3090
|
+
const resolveRepoFile = (value, fallback = "") => {
|
|
3091
|
+
const resolved = path.resolve(repoRoot, isNonEmptyString(value) ? value.trim() : fallback);
|
|
3092
|
+
return isInsideDir(repoRoot, resolved) ? resolved : null;
|
|
3093
|
+
};
|
|
3094
|
+
const capture = isNonEmptyString(args.captureId) ? listCaptureRuns(200).find((run) => run.id === args.captureId.trim()) : null;
|
|
3095
|
+
if (isNonEmptyString(args.captureId) && !capture) return errorResult("Capture not found", { captureId: args.captureId });
|
|
3096
|
+
const { buildUiMapFromMarkers, diffUiMaps, mergeUiMaps, validateUiMap, writeUiMap } = await import("./ui-map.js");
|
|
3097
|
+
if (operation === "diff") {
|
|
3098
|
+
const beforePath = resolveRepoFile(args.beforePath);
|
|
3099
|
+
const afterPath = resolveRepoFile(args.afterPath);
|
|
3100
|
+
if (!beforePath || !afterPath) return errorResult("beforePath and afterPath must be inside the repo");
|
|
3101
|
+
if (!fs.existsSync(beforePath) || !fs.existsSync(afterPath)) return errorResult("UI Map diff input not found", { beforePath, afterPath });
|
|
3102
|
+
try {
|
|
3103
|
+
const diff = diffUiMaps(JSON.parse(fs.readFileSync(beforePath, "utf8")), JSON.parse(fs.readFileSync(afterPath, "utf8")), { comparableFullSweep: args.comparableFullSweep === true });
|
|
3104
|
+
const text = `๐บ๏ธ UI Map diff โ +${diff.addedNodes.length} states ยท ${diff.notObservedNodes.length} not observed ยท +${diff.addedEdges.length} transitions ยท ${diff.notObservedEdges.length} transitions not observed${diff.comparableFullSweep ? ` ยท ${diff.lostReachability.length} lost` : "\nAbsence is not classified as lost reachability because comparableFullSweep was not enabled."}`;
|
|
3105
|
+
return richResult(text, diff);
|
|
3106
|
+
} catch (error) { return errorResult("Could not diff UI Maps", { detail: error.message || String(error) }); }
|
|
3107
|
+
}
|
|
3108
|
+
if (operation === "build") {
|
|
3109
|
+
const markersPath = capture ? path.join(capture.path, "ocqa-markers.txt") : resolveRepoFile(args.markersPath);
|
|
3110
|
+
if (!markersPath) return errorResult("markersPath must be inside the repo, or provide captureId");
|
|
3111
|
+
if (!fs.existsSync(markersPath)) return errorResult("OCQA markers not found", { markersPath });
|
|
3112
|
+
const outPath = resolveRepoFile(args.mapPath, path.join(".autotap", "ui-map.json"));
|
|
3113
|
+
if (!outPath) return errorResult("mapPath must be inside the repo");
|
|
3114
|
+
try {
|
|
3115
|
+
const observed = buildUiMapFromMarkers({ markersPath, platform: args.platform || "ios", target: args.target || "", runId: capture?.id || "" });
|
|
3116
|
+
const map = fs.existsSync(outPath) && args.replace !== true ? mergeUiMaps(JSON.parse(fs.readFileSync(outPath, "utf8")), observed) : observed;
|
|
3117
|
+
writeUiMap(outPath, map);
|
|
3118
|
+
const controls = map.nodes.reduce((total, node) => total + node.controls.length, 0);
|
|
3119
|
+
return richResult(`๐บ๏ธ UI Map updated โ ${map.nodes.length} states ยท ${map.edges.length} transitions ยท ${controls} semantic controls\n${path.relative(repoRoot, outPath)}`, { map, path: outPath });
|
|
3120
|
+
} catch (error) { return errorResult("Could not build UI Map", { detail: error.message || String(error) }); }
|
|
3121
|
+
}
|
|
3122
|
+
if (operation !== "read") return errorResult("operation must be read|build|diff");
|
|
3123
|
+
const mapPath = capture ? path.join(capture.path, "ui-map.json") : resolveRepoFile(args.mapPath, path.join(".autotap", "ui-map.json"));
|
|
3124
|
+
if (!mapPath) return errorResult("mapPath must be inside the repo");
|
|
3125
|
+
if (!fs.existsSync(mapPath)) return errorResult("UI Map not found; run QA or operation=build first", { mapPath });
|
|
3126
|
+
try {
|
|
3127
|
+
const map = JSON.parse(fs.readFileSync(mapPath, "utf8"));
|
|
3128
|
+
const errors = validateUiMap(map);
|
|
3129
|
+
if (errors.length) return errorResult("Invalid UI Map", { errors, mapPath });
|
|
3130
|
+
const controls = map.nodes.reduce((total, node) => total + node.controls.length, 0);
|
|
3131
|
+
return richResult(`๐บ๏ธ UI Map v${map.schemaVersion} โ ${map.nodes.length} states ยท ${map.edges.length} transitions ยท ${controls} semantic controls`, { map, path: mapPath });
|
|
3132
|
+
} catch (error) { return errorResult("Could not read UI Map", { detail: error.message || String(error) }); }
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
if (name === "tapp_task") {
|
|
3136
|
+
const unauthorized = ensureAuthorized(args);
|
|
3137
|
+
if (unauthorized) return unauthorized;
|
|
3138
|
+
const operation = String(args.operation || "validate").toLowerCase();
|
|
3139
|
+
if (!["read", "validate", "compile"].includes(operation)) return errorResult("operation must be read|validate|compile");
|
|
3140
|
+
const taskPath = isNonEmptyString(args.taskPath) ? path.resolve(repoRoot, args.taskPath.trim()) : null;
|
|
3141
|
+
if (!taskPath || !isInsideDir(repoRoot, taskPath)) return errorResult("taskPath must be inside the repo");
|
|
3142
|
+
if (!fs.existsSync(taskPath)) return errorResult("Task file not found", { taskPath: args.taskPath });
|
|
3143
|
+
const { applyTaskCoverage, compileTaskSteps, loadTaskFile, loadTaskRegistry, validateTaskAgainstUiMap } = await import("./task-runtime.js");
|
|
3144
|
+
let task;
|
|
3145
|
+
try { task = loadTaskFile(taskPath); }
|
|
3146
|
+
catch (error) { return errorResult("Invalid Task", { detail: error.message || String(error) }); }
|
|
3147
|
+
if (operation === "read") return richResult(`๐งฉ Task ${task.name} v${task.version}`, { task, path: taskPath });
|
|
3148
|
+
let grounding = { errors: [], warnings: [] };
|
|
3149
|
+
let groundingMap = null;
|
|
3150
|
+
let groundingMapPath = null;
|
|
3151
|
+
if (isNonEmptyString(args.mapPath)) {
|
|
3152
|
+
const mapPath = path.resolve(repoRoot, args.mapPath.trim());
|
|
3153
|
+
if (!isInsideDir(repoRoot, mapPath)) return errorResult("mapPath must be inside the repo");
|
|
3154
|
+
if (!fs.existsSync(mapPath)) return errorResult("UI Map not found", { mapPath: args.mapPath });
|
|
3155
|
+
groundingMapPath = mapPath;
|
|
3156
|
+
try {
|
|
3157
|
+
groundingMap = JSON.parse(fs.readFileSync(mapPath, "utf8"));
|
|
3158
|
+
grounding = validateTaskAgainstUiMap(task, groundingMap, args.platform || "");
|
|
3159
|
+
}
|
|
3160
|
+
catch (error) { return errorResult("Could not ground Task", { detail: error.message || String(error) }); }
|
|
3161
|
+
}
|
|
3162
|
+
if (grounding.errors.length) return errorResult("Task is not grounded", grounding);
|
|
3163
|
+
if (args.updateMap === true) {
|
|
3164
|
+
if (!groundingMap || !groundingMapPath) return errorResult("updateMap requires mapPath");
|
|
3165
|
+
fs.writeFileSync(groundingMapPath, JSON.stringify(applyTaskCoverage(groundingMap, task), null, 2) + "\n");
|
|
3166
|
+
}
|
|
3167
|
+
if (operation === "validate") {
|
|
3168
|
+
return richResult(`๐งฉ Valid Task โ ${task.name} v${task.version} ยท ${(task.coverage?.nodes || []).length} states ยท ${(task.coverage?.edges || []).length} transitions${grounding.warnings.length ? `\n${grounding.warnings.map((warning) => `โ ๏ธ ${warning}`).join("\n")}` : ""}`, { task, grounding, path: taskPath });
|
|
3169
|
+
}
|
|
3170
|
+
let registry;
|
|
3171
|
+
try {
|
|
3172
|
+
registry = loadTaskRegistry({ sourcePath: taskPath });
|
|
3173
|
+
if (!registry.has(task.name)) registry.set(task.name, task);
|
|
3174
|
+
} catch (error) { return errorResult("Could not load Task registry", { detail: error.message || String(error) }); }
|
|
3175
|
+
const vars = {};
|
|
3176
|
+
const plan = [];
|
|
3177
|
+
let compiled;
|
|
3178
|
+
try { compiled = compileTaskSteps({ steps: [{ task: task.name, with: args.inputs || {} }], registry, platform: args.platform || "", flowVars: vars, plan }); }
|
|
3179
|
+
catch (error) { return errorResult("Could not compile Task", { detail: error.message || String(error) }); }
|
|
3180
|
+
const flow = { name: `Task: ${task.name}`, kind: "flow", platform: args.platform || "", vars: compiled.vars, steps: compiled.steps, taskPlan: compiled.plan };
|
|
3181
|
+
let outPath = null;
|
|
3182
|
+
if (isNonEmptyString(args.outPath)) {
|
|
3183
|
+
outPath = path.resolve(repoRoot, args.outPath.trim());
|
|
3184
|
+
if (!isInsideDir(repoRoot, outPath)) return errorResult("outPath must be inside the repo");
|
|
3185
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
3186
|
+
fs.writeFileSync(outPath, JSON.stringify(flow, null, 2) + "\n");
|
|
3187
|
+
}
|
|
3188
|
+
return richResult(`๐งฉ Compiled ${task.name} into ${flow.steps.length} deterministic Flow steps${outPath ? `\n${path.relative(repoRoot, outPath)}` : ""}`, { flow, grounding, path: outPath });
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
if (name === "tapp_release_contract") {
|
|
3192
|
+
const unauthorized = ensureAuthorized(args);
|
|
3193
|
+
if (unauthorized) return unauthorized;
|
|
3194
|
+
const operation = String(args.operation || "validate").toLowerCase();
|
|
3195
|
+
if (!["read", "validate", "compile", "run"].includes(operation)) return errorResult("operation must be read|validate|compile|run");
|
|
3196
|
+
const contractPath = isNonEmptyString(args.contractPath) ? path.resolve(repoRoot, args.contractPath.trim()) : null;
|
|
3197
|
+
if (!contractPath || !isInsideDir(repoRoot, contractPath)) return errorResult("contractPath must be inside the repo");
|
|
3198
|
+
const {
|
|
3199
|
+
applyReleaseContractCoverage,
|
|
3200
|
+
compileReleaseContract,
|
|
3201
|
+
loadReleaseContractFile,
|
|
3202
|
+
validateReleaseContractAgainstUiMap,
|
|
3203
|
+
} = await import("./release-contract.js");
|
|
3204
|
+
let contract;
|
|
3205
|
+
try { contract = await loadReleaseContractFile(contractPath); }
|
|
3206
|
+
catch (error) { return errorResult("Invalid Release Contract", { detail: error.message || String(error) }); }
|
|
3207
|
+
if (operation === "read") return richResult(`๐ ${contract.title} โ ${contract.criticality}`, { contract, path: contractPath });
|
|
3208
|
+
let grounding = { errors: [], warnings: [] };
|
|
3209
|
+
let groundingMap = null;
|
|
3210
|
+
let groundingMapPath = null;
|
|
3211
|
+
if (isNonEmptyString(args.mapPath)) {
|
|
3212
|
+
groundingMapPath = path.resolve(repoRoot, args.mapPath.trim());
|
|
3213
|
+
if (!isInsideDir(repoRoot, groundingMapPath)) return errorResult("mapPath must be inside the repo");
|
|
3214
|
+
if (!fs.existsSync(groundingMapPath)) return errorResult("UI Map not found", { mapPath: args.mapPath });
|
|
3215
|
+
try {
|
|
3216
|
+
groundingMap = JSON.parse(fs.readFileSync(groundingMapPath, "utf8"));
|
|
3217
|
+
grounding = validateReleaseContractAgainstUiMap(contract, groundingMap);
|
|
3218
|
+
} catch (error) { return errorResult("Could not ground Release Contract", { detail: error.message || String(error) }); }
|
|
3219
|
+
}
|
|
3220
|
+
if (grounding.errors.length) return errorResult("Release Contract is not grounded", grounding);
|
|
3221
|
+
if (args.updateMap === true) {
|
|
3222
|
+
if (!groundingMapPath) return errorResult("updateMap requires mapPath");
|
|
3223
|
+
fs.writeFileSync(groundingMapPath, JSON.stringify(applyReleaseContractCoverage(groundingMap, contract), null, 2) + "\n");
|
|
3224
|
+
}
|
|
3225
|
+
if (operation === "validate") {
|
|
3226
|
+
return richResult(`๐ Valid Release Contract โ ${contract.title} ยท ${contract.criticality} ยท ${Object.keys(contract.actors).length} actor(s) ยท ${contract.steps.length} business steps${grounding.warnings.length ? `\n${grounding.warnings.map((warning) => `โ ๏ธ ${warning}`).join("\n")}` : ""}`, { contract, grounding, path: contractPath });
|
|
3227
|
+
}
|
|
3228
|
+
const platform = String(args.platform || (contract.platforms.length === 1 ? contract.platforms[0] : "")).toLowerCase();
|
|
3229
|
+
let execution;
|
|
3230
|
+
try { execution = compileReleaseContract(contract, { platform, sourcePath: contractPath }); }
|
|
3231
|
+
catch (error) { return errorResult("Could not compile Release Contract", { detail: error.message || String(error) }); }
|
|
3232
|
+
let outPath = null;
|
|
3233
|
+
if (isNonEmptyString(args.outPath)) {
|
|
3234
|
+
outPath = path.resolve(repoRoot, args.outPath.trim());
|
|
3235
|
+
if (!isInsideDir(repoRoot, outPath)) return errorResult("outPath must be inside the repo");
|
|
3236
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
3237
|
+
fs.writeFileSync(outPath, JSON.stringify(execution, null, 2) + "\n");
|
|
3238
|
+
}
|
|
3239
|
+
if (operation === "compile") {
|
|
3240
|
+
return richResult(`๐ Compiled ${contract.name} into ${execution.steps.length} deterministic ${execution.kind === "scenario" ? "Scenario" : "Flow"} steps`, { contract, execution, grounding, path: outPath });
|
|
3241
|
+
}
|
|
3242
|
+
const flowLog = path.join(os.tmpdir(), `mcp-contract-${Date.now()}.log`);
|
|
3243
|
+
const evidenceDir = path.join(capturesDir, `contract-${platform}-${Date.now()}`);
|
|
3244
|
+
try {
|
|
3245
|
+
if (execution.kind === "scenario") {
|
|
3246
|
+
const { runWebScenario } = await import("./scenario-runtime.js");
|
|
3247
|
+
await runWebScenario({ scenario: execution, url: isNonEmptyString(args.url) ? args.url.trim() : undefined, logPath: flowLog, screenshotDir: evidenceDir });
|
|
3248
|
+
} else if (platform === "web") {
|
|
3249
|
+
const { runWebFlow } = await import("./web-flow.js");
|
|
3250
|
+
await runWebFlow({ flow: execution, url: isNonEmptyString(args.url) ? args.url.trim() : undefined, logPath: flowLog, screenshotDir: evidenceDir });
|
|
3251
|
+
} else if (platform === "android") {
|
|
3252
|
+
const { runAndroidFlow } = await import("./android-flow.js");
|
|
3253
|
+
await runAndroidFlow({
|
|
3254
|
+
flow: execution,
|
|
3255
|
+
appId: isNonEmptyString(args.androidAppId) ? args.androidAppId.trim() : execution.app,
|
|
3256
|
+
apkPath: isNonEmptyString(args.apkPath) ? path.resolve(args.apkPath) : undefined,
|
|
3257
|
+
serial: isNonEmptyString(args.androidSerial) ? args.androidSerial.trim() : undefined,
|
|
3258
|
+
logPath: flowLog,
|
|
3259
|
+
screenshotDir: evidenceDir,
|
|
3260
|
+
});
|
|
3261
|
+
} else {
|
|
3262
|
+
const prepared = await runCommand("bash", [path.join(scriptsDir, "quick-capture.sh"), "build-harness"], { cwd: repoRoot, timeoutMs: 10 * 60 * 1000 });
|
|
3263
|
+
if (prepared.code !== 0) return errorResult("Could not prepare iOS harness", { stderr: prepared.stderr, stdout: prepared.stdout });
|
|
3264
|
+
const compiledPath = path.join(os.tmpdir(), `mcp-contract-${Date.now()}.json`);
|
|
3265
|
+
fs.writeFileSync(compiledPath, JSON.stringify({ ...execution, app: isNonEmptyString(args.appBundleId) ? args.appBundleId.trim() : execution.app }));
|
|
3266
|
+
const run = await runCommand("bash", [path.join(scriptsDir, "run-flow.sh"), compiledPath, isNonEmptyString(args.appBundleId) ? args.appBundleId.trim() : execution.app || ""], { cwd: repoRoot, timeoutMs: 10 * 60 * 1000, env: { ...process.env, FLOW_LOG: flowLog } });
|
|
3267
|
+
if (!fs.existsSync(flowLog)) return errorResult("iOS Release Contract produced no evidence", { stderr: run.stderr, stdout: run.stdout });
|
|
3268
|
+
}
|
|
3269
|
+
} catch (error) { return errorResult("Release Contract failed to start", { detail: error.message || String(error) }); }
|
|
3270
|
+
const jsonRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "report", "--json", flowLog], { cwd: repoRoot });
|
|
3271
|
+
const textRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "report", flowLog], { cwd: repoRoot });
|
|
3272
|
+
let structured = null;
|
|
3273
|
+
try { structured = JSON.parse(jsonRes.stdout.trim()); } catch {}
|
|
3274
|
+
return richResult((textRes.stdout || "").trim(), { ...(structured || {}), contract: contract.name, criticality: contract.criticality, platform, evidenceDir });
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
if (name === "tapp_pr_plan") {
|
|
3278
|
+
const unauthorized = ensureAuthorized(args);
|
|
3279
|
+
if (unauthorized) return unauthorized;
|
|
3280
|
+
const operation = isNonEmptyString(args.operation) ? args.operation.trim().toLowerCase() : "plan";
|
|
3281
|
+
if (!['plan', 'adopt'].includes(operation)) return errorResult("operation must be plan|adopt");
|
|
3282
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
|
|
3283
|
+
if (!isInsideDir(repoRoot, projectDir)) return errorResult("projectDir must be inside the repo");
|
|
3284
|
+
if (operation === "adopt") {
|
|
3285
|
+
if (!isNonEmptyString(args.prPlanPath) || !isNonEmptyString(args.item)) return errorResult("adopt requires prPlanPath and item");
|
|
3286
|
+
const prPlanPath = path.resolve(projectDir, args.prPlanPath.trim());
|
|
3287
|
+
if (!isInsideDir(repoRoot, prPlanPath)) return errorResult("prPlanPath must be inside the repo");
|
|
3288
|
+
const { adoptPrCoverageProposal } = await import("./pr-selection.js");
|
|
3289
|
+
try {
|
|
3290
|
+
const adopted = adoptPrCoverageProposal({
|
|
3291
|
+
projectDir,
|
|
3292
|
+
prPlanPath,
|
|
3293
|
+
item: args.item.trim(),
|
|
3294
|
+
releasePlanPath: isNonEmptyString(args.releasePlanPath) ? args.releasePlanPath.trim() : undefined,
|
|
3295
|
+
});
|
|
3296
|
+
return richResult(`๐ฅ ${adopted.mode === "reconciled-existing" ? "Reconciled PR evidence into" : "Adopted"} ${adopted.item.name}${adopted.mode === "reconciled-existing" ? ` while preserving decision '${adopted.item.decision}'` : " as a pending release-plan item"}; no Task or contract was generated or trusted`, { path: adopted.path, item: adopted.item, plan: adopted.plan, mode: adopted.mode });
|
|
3297
|
+
} catch (error) { return errorResult("Could not adopt PR coverage proposal", { detail: error.message || String(error) }); }
|
|
3298
|
+
}
|
|
3299
|
+
const validChange = (item) => isNonEmptyString(item) || (item && typeof item === "object" && !Array.isArray(item) && isNonEmptyString(item.filename) &&
|
|
3300
|
+
(item.previous_filename === undefined || isNonEmptyString(item.previous_filename)) && (item.patch === undefined || typeof item.patch === "string"));
|
|
3301
|
+
if (!Array.isArray(args.changedFiles) || !args.changedFiles.length || args.changedFiles.some((item) => !validChange(item))) {
|
|
3302
|
+
return errorResult("changedFiles must be a non-empty array of repository-relative paths or change objects");
|
|
3303
|
+
}
|
|
3304
|
+
const { buildPrContractPlan } = await import("./pr-selection.js");
|
|
3305
|
+
try {
|
|
3306
|
+
const plan = await buildPrContractPlan({
|
|
3307
|
+
projectDir,
|
|
3308
|
+
changedFiles: args.changedFiles,
|
|
3309
|
+
platform: isNonEmptyString(args.platform) ? args.platform.trim().toLowerCase() : "",
|
|
3310
|
+
mapPath: isNonEmptyString(args.mapPath) ? args.mapPath.trim() : "",
|
|
3311
|
+
});
|
|
3312
|
+
const summary = `๐ PR contract plan โ ${plan.selected.length} selected ยท ${plan.skipped.length} skipped ยท ${plan.explorationTargets.length} bounded exploration target(s) ยท ${plan.uncoveredChangedFiles.length} uncovered changed file(s)` +
|
|
3313
|
+
(plan.selected.length ? `\n${plan.selected.map((item) => `โ
${item.name} (${item.criticality}) โ ${item.reasons.map((reason) => reason.type).join(", ")}`).join("\n")}` : "") +
|
|
3314
|
+
(plan.uncoveredChangedFiles.length ? `\n${plan.uncoveredChangedFiles.map((file) => `โ ๏ธ uncovered: ${file}`).join("\n")}` : "");
|
|
3315
|
+
return richResult(summary, { plan });
|
|
3316
|
+
} catch (error) { return errorResult("Could not build PR contract plan", { detail: error.message || String(error) }); }
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
if (name === "tapp_flow_run") {
|
|
3320
|
+
const unauthorized = ensureAuthorized(args);
|
|
3321
|
+
if (unauthorized) return unauthorized;
|
|
3322
|
+
|
|
3323
|
+
// Resolve the flow file: inline `flow` object โ temp .json, else repo-relative `flowPath`.
|
|
3324
|
+
let flowFile;
|
|
3325
|
+
let parsedFlow;
|
|
3326
|
+
if (args.flow && typeof args.flow === "object") {
|
|
3327
|
+
flowFile = path.join(os.tmpdir(), `mcp-flow-${Date.now()}.json`);
|
|
3328
|
+
fs.writeFileSync(flowFile, JSON.stringify(args.flow));
|
|
3329
|
+
parsedFlow = args.flow;
|
|
3330
|
+
} else if (isNonEmptyString(args.flowPath)) {
|
|
3331
|
+
const p = path.resolve(repoRoot, args.flowPath.trim());
|
|
3332
|
+
if (!isInsideDir(repoRoot, p)) return errorResult("flowPath must be inside the repo");
|
|
3333
|
+
if (!fs.existsSync(p)) return errorResult("Flow file not found", { flowPath: args.flowPath });
|
|
3334
|
+
flowFile = p;
|
|
3335
|
+
} else {
|
|
3336
|
+
return errorResult("Provide `flow` (inline) or `flowPath`");
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
if (!parsedFlow) {
|
|
3340
|
+
const parsed = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "to-json", flowFile], { cwd: repoRoot });
|
|
3341
|
+
if (parsed.code !== 0) return errorResult("Could not parse Flow", { stderr: parsed.stderr });
|
|
3342
|
+
try { parsedFlow = JSON.parse(parsed.stdout); } catch { return errorResult("Flow parser returned invalid JSON"); }
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
const platform = String(
|
|
3346
|
+
args.platform || parsedFlow.platform ||
|
|
3347
|
+
(args.androidAppId ? "android" :
|
|
3348
|
+
(args.url || parsedFlow.url || /^https?:\/\//i.test(parsedFlow.app || "")) ? "web" : "ios")
|
|
3349
|
+
).toLowerCase();
|
|
3350
|
+
|
|
3351
|
+
const flowLog = path.join(os.tmpdir(), `mcp-flow-${Date.now()}.log`);
|
|
3352
|
+
const runEnv = { ...process.env, FLOW_LOG: flowLog };
|
|
3353
|
+
if (isNonEmptyString(args.testEmail)) runEnv.OCQA_TEST_EMAIL = args.testEmail.trim();
|
|
3354
|
+
if (isNonEmptyString(args.testPassword)) runEnv.OCQA_TEST_PASSWORD = args.testPassword.trim();
|
|
3355
|
+
let run = { stdout: "", stderr: "", code: 0 };
|
|
3356
|
+
if (platform === "web") {
|
|
3357
|
+
try {
|
|
3358
|
+
const { runWebFlow } = await import("./web-flow.js");
|
|
3359
|
+
const evidenceDir = path.join(capturesDir, `flow-web-${Date.now()}`);
|
|
3360
|
+
const result = await runWebFlow({
|
|
3361
|
+
flow: parsedFlow,
|
|
3362
|
+
url: isNonEmptyString(args.url) ? args.url.trim() : undefined,
|
|
3363
|
+
logPath: flowLog,
|
|
3364
|
+
screenshotDir: evidenceDir,
|
|
3365
|
+
});
|
|
3366
|
+
run = { ...run, code: result.passed ? 0 : 1, evidenceDir };
|
|
3367
|
+
} catch (error) {
|
|
3368
|
+
return errorResult("Web Flow failed to start", { detail: error.message || String(error) });
|
|
3369
|
+
}
|
|
3370
|
+
} else if (platform === "ios") {
|
|
3371
|
+
const cmdArgs = [path.join(scriptsDir, "run-flow.sh"), flowFile];
|
|
3372
|
+
if (isNonEmptyString(args.appBundleId)) cmdArgs.push(args.appBundleId.trim());
|
|
3373
|
+
run = await runCommand("bash", cmdArgs, { cwd: repoRoot, timeoutMs: 10 * 60 * 1000, env: runEnv });
|
|
3374
|
+
} else if (platform === "android") {
|
|
3375
|
+
try {
|
|
3376
|
+
const { runAndroidFlow } = await import("./android-flow.js");
|
|
3377
|
+
const evidenceDir = path.join(capturesDir, `flow-android-${Date.now()}`);
|
|
3378
|
+
const result = await runAndroidFlow({
|
|
3379
|
+
flow: parsedFlow,
|
|
3380
|
+
appId: isNonEmptyString(args.androidAppId)
|
|
3381
|
+
? args.androidAppId.trim()
|
|
3382
|
+
: isNonEmptyString(args.appBundleId) ? args.appBundleId.trim() : undefined,
|
|
3383
|
+
apkPath: isNonEmptyString(args.apkPath) ? path.resolve(args.apkPath) : undefined,
|
|
3384
|
+
serial: isNonEmptyString(args.androidSerial) ? args.androidSerial.trim() : undefined,
|
|
3385
|
+
logPath: flowLog,
|
|
3386
|
+
screenshotDir: evidenceDir,
|
|
3387
|
+
});
|
|
3388
|
+
run = { ...run, code: result.passed ? 0 : 1, evidenceDir };
|
|
3389
|
+
} catch (error) {
|
|
3390
|
+
return errorResult("Android Flow failed to start", { detail: error.message || String(error) });
|
|
3391
|
+
}
|
|
3392
|
+
} else {
|
|
3393
|
+
return errorResult("Unsupported Flow platform", { platform });
|
|
3394
|
+
}
|
|
3395
|
+
if (!fs.existsSync(flowLog)) {
|
|
3396
|
+
return errorResult("Flow run produced no log (harness build / launch failure?)", { stderr: run.stderr, stdout: run.stdout });
|
|
3397
|
+
}
|
|
3398
|
+
// Structured report from the harness markers, plus the scannable text the runner already renders.
|
|
3399
|
+
const jsonRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "report", "--json", flowLog], { cwd: repoRoot });
|
|
3400
|
+
let structured = null;
|
|
3401
|
+
try { structured = JSON.parse(jsonRes.stdout.trim()); } catch { /* fall through */ }
|
|
3402
|
+
const textRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "report", flowLog], { cwd: repoRoot });
|
|
3403
|
+
const text = (textRes.stdout || "").trim() || run.stdout;
|
|
3404
|
+
return richResult(text, { ...(structured || { raw: run.stdout }), platform, evidenceDir: run.evidenceDir });
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
if (name === "tapp_scenario_run") {
|
|
3408
|
+
const unauthorized = ensureAuthorized(args);
|
|
3409
|
+
if (unauthorized) return unauthorized;
|
|
3410
|
+
let scenario;
|
|
3411
|
+
if (args.scenario && typeof args.scenario === "object") {
|
|
3412
|
+
scenario = args.scenario;
|
|
3413
|
+
} else if (isNonEmptyString(args.scenarioPath)) {
|
|
3414
|
+
const scenarioFile = path.resolve(repoRoot, args.scenarioPath.trim());
|
|
3415
|
+
if (!isInsideDir(repoRoot, scenarioFile)) return errorResult("scenarioPath must be inside the repo");
|
|
3416
|
+
if (!fs.existsSync(scenarioFile)) return errorResult("Scenario file not found", { scenarioPath: args.scenarioPath });
|
|
3417
|
+
try {
|
|
3418
|
+
const { loadScenarioFile } = await import("./scenario-runtime.js");
|
|
3419
|
+
scenario = loadScenarioFile(scenarioFile);
|
|
3420
|
+
} catch (error) {
|
|
3421
|
+
return errorResult("Could not parse Scenario", { detail: error.message || String(error) });
|
|
3422
|
+
}
|
|
3423
|
+
} else {
|
|
3424
|
+
return errorResult("Provide `scenario` (inline) or `scenarioPath`");
|
|
3425
|
+
}
|
|
3426
|
+
const flowLog = path.join(os.tmpdir(), `mcp-scenario-${Date.now()}.log`);
|
|
3427
|
+
const evidenceDir = path.join(capturesDir, `scenario-web-${Date.now()}`);
|
|
3428
|
+
try {
|
|
3429
|
+
const { runWebScenario } = await import("./scenario-runtime.js");
|
|
3430
|
+
await runWebScenario({
|
|
3431
|
+
scenario,
|
|
3432
|
+
url: isNonEmptyString(args.url) ? args.url.trim() : undefined,
|
|
3433
|
+
variables: args.variables && typeof args.variables === "object" ? args.variables : {},
|
|
3434
|
+
logPath: flowLog,
|
|
3435
|
+
screenshotDir: evidenceDir,
|
|
3436
|
+
});
|
|
3437
|
+
} catch (error) {
|
|
3438
|
+
return errorResult("Scenario failed to start", { detail: error.message || String(error) });
|
|
3439
|
+
}
|
|
3440
|
+
const jsonRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "report", "--json", flowLog], { cwd: repoRoot });
|
|
3441
|
+
const textRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "report", flowLog], { cwd: repoRoot });
|
|
3442
|
+
let structured = null;
|
|
3443
|
+
try { structured = JSON.parse(jsonRes.stdout.trim()); } catch { /* raw evidence remains available */ }
|
|
3444
|
+
return richResult((textRes.stdout || "").trim(), { ...(structured || {}), platform: "web", evidenceDir });
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
if (name === "tapp_flow_generate") {
|
|
3448
|
+
const unauthorized = ensureAuthorized(args);
|
|
3449
|
+
if (unauthorized) return unauthorized;
|
|
3450
|
+
if (!isNonEmptyString(args.goal)) return errorResult("goal is required");
|
|
3451
|
+
if (!isNonEmptyString(args.appBundleId)) return errorResult("appBundleId is required");
|
|
3452
|
+
const backend = resolveModelBackend();
|
|
3453
|
+
if (!backend) return errorResult("AI-generate needs a model backend โ set an Tapp subscription token (AUTOTAP_SUBSCRIPTION_TOKEN) or ANTHROPIC_API_KEY.");
|
|
3454
|
+
const bundleId = args.appBundleId.trim();
|
|
3455
|
+
|
|
3456
|
+
// 1) Grounding: reuse a capture's markers, else explore the app to build a screen/control map.
|
|
3457
|
+
let markersText = "";
|
|
3458
|
+
if (isNonEmptyString(args.captureId)) {
|
|
3459
|
+
const p = normalizeCapturePath(path.join(capturesDir, args.captureId.trim()));
|
|
3460
|
+
const mf = p && path.join(p, "ocqa-markers.txt");
|
|
3461
|
+
if (mf && fs.existsSync(mf)) markersText = fs.readFileSync(mf, "utf8");
|
|
3462
|
+
else return errorResult("captureId has no markers", { captureId: args.captureId });
|
|
3463
|
+
} else {
|
|
3464
|
+
const actions = Math.max(5, Math.min(200, asInteger(args.maxActions, 35)));
|
|
3465
|
+
const { created } = await runExploreStreaming(bundleId, actions, 400, explorationEnvFromArgs(args), () => {});
|
|
3466
|
+
if (!created) return errorResult("Could not explore the app to build grounding (is it installed on a booted sim?)");
|
|
3467
|
+
markersText = fs.readFileSync(path.join(created.path, "ocqa-markers.txt"), "utf8");
|
|
3468
|
+
}
|
|
3469
|
+
const grounding = buildAppGrounding(markersText);
|
|
3470
|
+
if (grounding.screens.length === 0) return errorResult("No screens observed โ the app may not have launched or is behind a wall. Try running QA/login first.");
|
|
3471
|
+
|
|
3472
|
+
// 2) Author the flow from the goal, grounded in the observed screens.
|
|
3473
|
+
const userText = `${renderGroundingForPrompt(grounding)}\n\nGOAL: ${args.goal.trim()}\n\nEmit the Flow as JSON now.`;
|
|
3474
|
+
const mres = await callModel(backend, { system: FLOW_AUTHOR_SYSTEM, userText, maxTokens: 1500 });
|
|
3475
|
+
if (mres.error) return errorResult("Model call failed", { detail: mres.error });
|
|
3476
|
+
const parsed = parseGeneratedFlow(mres.text);
|
|
3477
|
+
if (!parsed) return errorResult("Model did not return a valid Flow", { raw: mres.text.slice(0, 500) });
|
|
3478
|
+
|
|
3479
|
+
// 3) Ground-check + write.
|
|
3480
|
+
const ungrounded = ungroundedScreens(parsed.steps, grounding);
|
|
3481
|
+
const flow = { name: args.name || parsed.name, app: bundleId, steps: parsed.steps };
|
|
3482
|
+
const slug = flow.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "generated-flow";
|
|
3483
|
+
const dir = path.join(repoRoot, ".autotap", "flows");
|
|
3484
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3485
|
+
const outPath = path.join(dir, `${slug}.yml`);
|
|
3486
|
+
const yamlRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "to-yaml", JSON.stringify(flow)], { cwd: repoRoot });
|
|
3487
|
+
const yaml = (yamlRes.stdout || "").trim();
|
|
3488
|
+
if (!yaml) return errorResult("Failed to render flow YAML", { stderr: yamlRes.stderr });
|
|
3489
|
+
fs.writeFileSync(outPath, yaml + "\n");
|
|
3490
|
+
const rel = path.relative(repoRoot, outPath);
|
|
3491
|
+
|
|
3492
|
+
const L = [`๐ค Generated flow **${flow.name}** from your goal โ \`${rel}\``];
|
|
3493
|
+
L.push(`Grounded in ${grounding.screens.length} observed screen(s). ${ungrounded.length ? `โ ๏ธ references unobserved: ${ungrounded.join(", ")} โ review before relying on it.` : "All referenced screens were observed."}`);
|
|
3494
|
+
L.push("", "```yaml", yaml, "```");
|
|
3495
|
+
|
|
3496
|
+
// 4) Optionally replay it now.
|
|
3497
|
+
if (args.run === true) {
|
|
3498
|
+
const flowLog = path.join(os.tmpdir(), `mcp-gen-${Date.now()}.log`);
|
|
3499
|
+
const runEnv = { ...process.env, FLOW_LOG: flowLog };
|
|
3500
|
+
if (isNonEmptyString(args.testEmail)) runEnv.OCQA_TEST_EMAIL = args.testEmail.trim();
|
|
3501
|
+
if (isNonEmptyString(args.testPassword)) runEnv.OCQA_TEST_PASSWORD = args.testPassword.trim();
|
|
3502
|
+
await runCommand("bash", [path.join(scriptsDir, "run-flow.sh"), outPath, bundleId], { cwd: repoRoot, timeoutMs: 10 * 60 * 1000, env: runEnv });
|
|
3503
|
+
if (fs.existsSync(flowLog)) {
|
|
3504
|
+
const rep = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "report", flowLog], { cwd: repoRoot });
|
|
3505
|
+
L.push("", "---", "", (rep.stdout || "").trim());
|
|
3506
|
+
}
|
|
3507
|
+
} else {
|
|
3508
|
+
L.push("", `Replay it: \`tapp_flow_run\` with \`flowPath: "${rel}"\`.`);
|
|
3509
|
+
}
|
|
3510
|
+
return richResult(L.join("\n"), { path: rel, flow, groundedScreens: grounding.screens.length, ungrounded });
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
if (name === "tapp_ui_tree") {
|
|
3514
|
+
const unauthorized = ensureAuthorized(args);
|
|
3515
|
+
if (unauthorized) return unauthorized;
|
|
3516
|
+
const wantsAndroid = isNonEmptyString(args.androidAppId);
|
|
3517
|
+
const wantsIos = isNonEmptyString(args.appBundleId);
|
|
3518
|
+
if (wantsAndroid && wantsIos) return errorResult("Provide appBundleId or androidAppId, not both");
|
|
3519
|
+
if (wantsAndroid) {
|
|
3520
|
+
try {
|
|
3521
|
+
const { AndroidDriver } = await import("./android-driver.js");
|
|
3522
|
+
const driver = new AndroidDriver({ appId: args.androidAppId.trim(), serial: args.androidSerial });
|
|
3523
|
+
await driver.ensureDevice();
|
|
3524
|
+
const snap = await driver.snapshot();
|
|
3525
|
+
return richResult(formatScreen(snap.screenTitle, snap.elements), { screenTitle: snap.screenTitle, elementCount: snap.elements.length, elements: snap.elements, platform: "android" });
|
|
3526
|
+
} catch (error) { return errorResult(error.message || String(error)); }
|
|
3527
|
+
}
|
|
3528
|
+
if (!isNonEmptyString(args.appBundleId)) return errorResult("appBundleId or androidAppId is required");
|
|
3529
|
+
|
|
3530
|
+
const r = await captureUiTree(String(args.appBundleId).trim());
|
|
3531
|
+
if (r.error) return errorResult(r.error, r.details || {});
|
|
3532
|
+
return richResult(formatScreen(r.screenTitle, r.elements), {
|
|
3533
|
+
screenTitle: r.screenTitle,
|
|
3534
|
+
elementCount: r.elements.length,
|
|
3535
|
+
elements: r.elements,
|
|
3536
|
+
capture: r.capture,
|
|
3537
|
+
});
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
if (name === "tapp_screenshot") {
|
|
3541
|
+
const unauthorized = ensureAuthorized(args);
|
|
3542
|
+
if (unauthorized) return unauthorized;
|
|
3543
|
+
if (activeSession?.platform === "android") {
|
|
3544
|
+
try {
|
|
3545
|
+
const data = await activeSession.driver.screenshot();
|
|
3546
|
+
return { content: [
|
|
3547
|
+
{ type: "text", text: `๐ธ Captured current Android screen โ image/png, ~${Math.round(data.length / 1024)}KB` },
|
|
3548
|
+
{ type: "image", data: data.toString("base64"), mimeType: "image/png" },
|
|
3549
|
+
] };
|
|
3550
|
+
} catch (error) { return errorResult(error.message || String(error)); }
|
|
3551
|
+
}
|
|
3552
|
+
const maxWidth = Math.max(200, Math.min(1400, asInteger(args.maxWidth, 700)));
|
|
3553
|
+
const img = await captureScreenshotImage(maxWidth);
|
|
3554
|
+
if (img.error) return errorResult(img.error, { stderr: img.stderr });
|
|
3555
|
+
return {
|
|
3556
|
+
content: [
|
|
3557
|
+
{ type: "text", text: `๐ธ Captured current screen โ ${img.mimeType}, ~${Math.round(img.bytes / 1024)}KB` },
|
|
3558
|
+
{ type: "image", data: img.data, mimeType: img.mimeType },
|
|
3559
|
+
],
|
|
3560
|
+
};
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
if (name === "tapp_open_app") {
|
|
3564
|
+
const unauthorized = ensureAuthorized(args);
|
|
3565
|
+
if (unauthorized) return unauthorized;
|
|
3566
|
+
const wantsAndroid = isNonEmptyString(args.androidAppId);
|
|
3567
|
+
const wantsIos = isNonEmptyString(args.appBundleId);
|
|
3568
|
+
if (wantsAndroid === wantsIos) return errorResult("Provide exactly one of appBundleId or androidAppId");
|
|
3569
|
+
if (wantsAndroid) {
|
|
3570
|
+
try {
|
|
3571
|
+
const { AndroidDriver } = await import("./android-driver.js");
|
|
3572
|
+
const appId = args.androidAppId.trim();
|
|
3573
|
+
const driver = new AndroidDriver({ appId, serial: args.androidSerial });
|
|
3574
|
+
await driver.ensureDevice();
|
|
3575
|
+
if (isNonEmptyString(args.apkPath)) await driver.install(path.resolve(args.apkPath));
|
|
3576
|
+
const snap = await driver.launch({ clearData: args.clearData === true });
|
|
3577
|
+
const data = await driver.screenshot();
|
|
3578
|
+
await driver.forceStop().catch(() => {});
|
|
3579
|
+
return {
|
|
3580
|
+
content: [
|
|
3581
|
+
{ type: "text", text: `๐ Launched \`${appId}\` (Android)\n\n` + formatScreen(snap.screenTitle, snap.elements) },
|
|
3582
|
+
{ type: "image", data: data.toString("base64"), mimeType: "image/png" },
|
|
3583
|
+
],
|
|
3584
|
+
structuredContent: { platform: "android", screenTitle: snap.screenTitle, elementCount: snap.elements.length, elements: snap.elements },
|
|
3585
|
+
};
|
|
3586
|
+
} catch (error) {
|
|
3587
|
+
return errorResult(error.message || String(error));
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
3590
|
+
const maxWidth = Math.max(200, Math.min(1400, asInteger(args.maxWidth, 700)));
|
|
3591
|
+
const r = await openApp(String(args.appBundleId).trim(), explorationEnvFromArgs(args), maxWidth);
|
|
3592
|
+
if (r.error) return errorResult(r.error);
|
|
3593
|
+
const content = [];
|
|
3594
|
+
content.push({ type: "text", text: `๐ Launched \`${String(args.appBundleId).trim()}\`\n\n` + formatScreen(r.screenTitle, r.elements) });
|
|
3595
|
+
if (r.img && !r.img.error) content.push({ type: "image", data: r.img.data, mimeType: r.img.mimeType });
|
|
3596
|
+
return { content, structuredContent: { screenTitle: r.screenTitle, elementCount: r.elements.length, elements: r.elements } };
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
if (name === "tapp_list_simulators") {
|
|
3600
|
+
const sims = await listSimulators();
|
|
3601
|
+
const list = sims.simulators || [];
|
|
3602
|
+
const booted = (sims.booted || []).map((s) => s.name);
|
|
3603
|
+
const L = [`### ๐ฑ ${list.length} simulator${list.length === 1 ? "" : "s"}${booted.length ? ` ยท ${booted.length} booted` : ""}`, ""];
|
|
3604
|
+
for (const s of list.slice(0, 20)) {
|
|
3605
|
+
L.push(`- ${s.booted ? "๐ข" : "โช๏ธ"} **${s.name}** โ ${s.runtime || "?"}${s.booted ? " ยท **booted**" : ""} \`${s.udid}\``);
|
|
3606
|
+
}
|
|
3607
|
+
if (!booted.length) L.push("", "No simulator booted โ `tapp_boot_simulator` to start one before QA.");
|
|
3608
|
+
return richResult(L.join("\n"), sims);
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
if (name === "tapp_boot_simulator") {
|
|
3612
|
+
const unauthorized = ensureAuthorized(args);
|
|
3613
|
+
if (unauthorized) return unauthorized;
|
|
3614
|
+
|
|
3615
|
+
const target = isNonEmptyString(args.udid) ? args.udid.trim() : isNonEmptyString(args.name) ? args.name.trim() : "";
|
|
3616
|
+
if (!target) return errorResult("Provide udid or name");
|
|
3617
|
+
|
|
3618
|
+
const res = await runCommand("xcrun", ["simctl", "boot", target], { timeoutMs: 2 * 60 * 1000 });
|
|
3619
|
+
const alreadyBooted = (res.stderr || "").includes("current state: Booted");
|
|
3620
|
+
const ok = res.code === 0 || alreadyBooted;
|
|
3621
|
+
if (ok) {
|
|
3622
|
+
await runCommand("xcrun", ["simctl", "bootstatus", target], { timeoutMs: 2 * 60 * 1000 });
|
|
3623
|
+
}
|
|
3624
|
+
if (ok) {
|
|
3625
|
+
const t = alreadyBooted ? "already booted" : "booted";
|
|
3626
|
+
return richResult(`๐ฑ Simulator ${t}: \`${target}\``, { ok, target, alreadyBooted, code: res.code });
|
|
3627
|
+
}
|
|
3628
|
+
return richResult(`โ Could not boot simulator \`${target}\``, { ok, target, alreadyBooted, code: res.code, stderr: res.stderr });
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
if (name === "tapp_install_app") {
|
|
3632
|
+
const unauthorized = ensureAuthorized(args);
|
|
3633
|
+
if (unauthorized) return unauthorized;
|
|
3634
|
+
const scheme = isNonEmptyString(args.scheme) ? args.scheme.trim() : "";
|
|
3635
|
+
if (!scheme) return errorResult("scheme is required");
|
|
3636
|
+
const project = isNonEmptyString(args.project) ? args.project.trim() : "";
|
|
3637
|
+
const workspace = isNonEmptyString(args.workspace) ? args.workspace.trim() : "";
|
|
3638
|
+
if (!project && !workspace) return errorResult("Provide project or workspace");
|
|
3639
|
+
const configuration = isNonEmptyString(args.configuration) ? args.configuration.trim() : "Debug";
|
|
3640
|
+
const sims = await listSimulators();
|
|
3641
|
+
const booted = (sims.booted || [])[0];
|
|
3642
|
+
if (!booted) return errorResult("No booted simulator. Call tapp_boot_simulator first.");
|
|
3643
|
+
const target = workspace || project;
|
|
3644
|
+
if (!fs.existsSync(target)) return errorResult("Project/workspace path not found", { target });
|
|
3645
|
+
|
|
3646
|
+
const derived = `/tmp/tapp-target-${scheme.replace(/[^a-zA-Z0-9]/g, "")}`;
|
|
3647
|
+
const buildArgs = [
|
|
3648
|
+
"build",
|
|
3649
|
+
workspace ? "-workspace" : "-project", target,
|
|
3650
|
+
"-scheme", scheme,
|
|
3651
|
+
"-configuration", configuration,
|
|
3652
|
+
"-destination", `platform=iOS Simulator,id=${booted.udid}`,
|
|
3653
|
+
"-derivedDataPath", derived,
|
|
3654
|
+
"-sdk", "iphonesimulator",
|
|
3655
|
+
];
|
|
3656
|
+
const build = await runCommand("xcodebuild", buildArgs, { cwd: path.dirname(target), timeoutMs: 25 * 60 * 1000 });
|
|
3657
|
+
if (build.code !== 0) return errorResult("Build failed", { stderr: (build.stderr || build.stdout || "").slice(-3000) });
|
|
3658
|
+
|
|
3659
|
+
const productsDir = path.join(derived, "Build/Products", `${configuration}-iphonesimulator`);
|
|
3660
|
+
const app = fs.existsSync(productsDir) ? fs.readdirSync(productsDir).find((f) => f.endsWith(".app")) : null;
|
|
3661
|
+
if (!app) return errorResult("Built .app not found after build", { productsDir });
|
|
3662
|
+
const appPath = path.join(productsDir, app);
|
|
3663
|
+
// Clean install by default: uninstall first so the app's data + keychain-backed session are
|
|
3664
|
+
// cleared. Installing OVER an existing app leaves stale keychain items that Firebase Auth (etc.)
|
|
3665
|
+
// can't access ("An error occurred when accessing the keychain") and starts in a half-signed-in
|
|
3666
|
+
// state โ a clean uninstall gives a fresh signed-out app. Opt out with cleanInstall:false.
|
|
3667
|
+
const cleanInstall = args.cleanInstall !== false;
|
|
3668
|
+
const bidRes = await runCommand("/usr/libexec/PlistBuddy", ["-c", "Print CFBundleIdentifier", path.join(appPath, "Info.plist")]);
|
|
3669
|
+
const bundleId = (bidRes.stdout || "").trim();
|
|
3670
|
+
if (cleanInstall && bundleId) {
|
|
3671
|
+
await runCommand("xcrun", ["simctl", "terminate", booted.udid, bundleId], { timeoutMs: 30_000 });
|
|
3672
|
+
await runCommand("xcrun", ["simctl", "uninstall", booted.udid, bundleId], { timeoutMs: 60_000 });
|
|
3673
|
+
}
|
|
3674
|
+
const inst = await runCommand("xcrun", ["simctl", "install", booted.udid, appPath], { timeoutMs: 3 * 60 * 1000 });
|
|
3675
|
+
if (inst.code !== 0) return errorResult("Install failed", { stderr: inst.stderr });
|
|
3676
|
+
const L = [
|
|
3677
|
+
`โ
Installed app on **${booted.name}**`,
|
|
3678
|
+
"",
|
|
3679
|
+
`Bundle: \`${bundleId || "(unknown)"}\``,
|
|
3680
|
+
`App: \`${appPath}\``,
|
|
3681
|
+
`DerivedData: \`${derived}\``,
|
|
3682
|
+
];
|
|
3683
|
+
if (cleanInstall) L.push("Mode: clean install");
|
|
3684
|
+
return richResult(L.join("\n"), { ok: true, installed: appPath, bundleId: bundleId || undefined, cleanInstall, simulator: booted.name, derivedDataPath: derived });
|
|
3685
|
+
}
|
|
3686
|
+
|
|
3687
|
+
if (name === "tapp_session_start") {
|
|
3688
|
+
const unauthorized = ensureAuthorized(args);
|
|
3689
|
+
if (unauthorized) return unauthorized;
|
|
3690
|
+
const ios = isNonEmptyString(args.appBundleId);
|
|
3691
|
+
const android = isNonEmptyString(args.androidAppId);
|
|
3692
|
+
if (ios === android) return errorResult("Provide exactly one of appBundleId or androidAppId");
|
|
3693
|
+
const target = ios ? args.appBundleId.trim() : args.androidAppId.trim();
|
|
3694
|
+
const r = ios
|
|
3695
|
+
? await startSession(target, explorationEnvFromArgs(args))
|
|
3696
|
+
: await startAndroidSession(target, { serial: args.androidSerial, apkPath: args.apkPath, clearData: args.clearData !== false, testEmail: args.testEmail, testPassword: args.testPassword });
|
|
3697
|
+
if (r.error) return errorResult(r.error);
|
|
3698
|
+
return richResult(
|
|
3699
|
+
`๐ฌ Session started โ \`${target}\` (${ios ? "iOS" : "Android"})\n\n` + formatScreen(r.screenTitle, r.elements) +
|
|
3700
|
+
`\n\nDrive it with \`tapp_session_act\` (tap ยท type ยท swipe ยท back ยท wait ยท tree ยท screenshot).`,
|
|
3701
|
+
r
|
|
3702
|
+
);
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
if (name === "tapp_session_act") {
|
|
3706
|
+
const unauthorized = ensureAuthorized(args);
|
|
3707
|
+
if (unauthorized) return unauthorized;
|
|
3708
|
+
const action = isNonEmptyString(args.action) ? args.action.trim().toLowerCase() : "";
|
|
3709
|
+
const allowed = new Set(["tap", "type", "swipe", "back", "wait", "tree", "screenshot", "login"]);
|
|
3710
|
+
if (!allowed.has(action)) return errorResult("Invalid action", { allowed: Array.from(allowed), received: args.action ?? null });
|
|
3711
|
+
const cmd = { action };
|
|
3712
|
+
if (isNonEmptyString(args.id)) cmd.id = args.id.trim();
|
|
3713
|
+
if (typeof args.x === "number") cmd.x = args.x;
|
|
3714
|
+
if (typeof args.y === "number") cmd.y = args.y;
|
|
3715
|
+
if (typeof args.text === "string") cmd.text = args.text;
|
|
3716
|
+
if (isNonEmptyString(args.direction)) cmd.direction = args.direction.trim();
|
|
3717
|
+
if (isNonEmptyString(args.label)) cmd.label = args.label.trim();
|
|
3718
|
+
if (action === "login") {
|
|
3719
|
+
if (isNonEmptyString(args.email)) cmd.email = args.email.trim();
|
|
3720
|
+
if (isNonEmptyString(args.password)) cmd.password = args.password;
|
|
3721
|
+
}
|
|
3722
|
+
if (action === "wait") cmd.timeoutMs = Math.max(500, Math.min(60_000, asInteger(args.timeoutMs, 5000)));
|
|
3723
|
+
const r = await sessionAct(cmd);
|
|
3724
|
+
if (r.error) return errorResult(r.error);
|
|
3725
|
+
// Action-word recap: what was done โ where we are now.
|
|
3726
|
+
const tgt = cmd.id || cmd.label || cmd.text || cmd.direction || "";
|
|
3727
|
+
const verb = { tap: "๐ Tapped", type: "โจ๏ธ Typed", swipe: "โ๏ธ Swiped", back: "โ๏ธ Went back", wait: "โณ Waited for", tree: "๐ณ Inspected", screenshot: "๐ธ Captured", login: "๐ Signed in" }[action] || action;
|
|
3728
|
+
const ok = r.status === "ok";
|
|
3729
|
+
// For `type`, say WHERE the text landed and never echo the text itself (it may be a password).
|
|
3730
|
+
const did = action === "type"
|
|
3731
|
+
? ok ? `โจ๏ธ Typed into \`${r.typedInto || cmd.id || "focused field"}\`` : `โจ๏ธ Type \`${cmd.id || "?"}\``
|
|
3732
|
+
: action === "login"
|
|
3733
|
+
? ok ? "๐ Signed in" : "๐ Sign-in"
|
|
3734
|
+
: tgt ? `${verb} \`${tgt}\`` : verb;
|
|
3735
|
+
const detailNote = !ok && r.detail ? ` โ ${r.detail}` : "";
|
|
3736
|
+
const head = `${did} โ ${ok ? "ok" : `โ ๏ธ ${r.status}${detailNote}`} โ now on **${r.screenTitle || "Unknown"}**`;
|
|
3737
|
+
const rec = typeof r.recordedSteps === "number" ? `\n\n๐ด Recording โ ${r.recordedSteps} step(s). \`tapp_flow_save\` to keep it as a test.` : "";
|
|
3738
|
+
return richResult(head + "\n\n" + formatScreen(r.screenTitle, r.elements) + rec, r);
|
|
3739
|
+
}
|
|
3740
|
+
|
|
3741
|
+
if (name === "tapp_flow_save") {
|
|
3742
|
+
const unauthorized = ensureAuthorized(args);
|
|
3743
|
+
if (unauthorized) return unauthorized;
|
|
3744
|
+
try {
|
|
3745
|
+
const saved = await saveInteractiveSessionFlow({ projectDir:repoRoot, name:args.name, addFinalAssertion:args.addFinalAssertion !== false, replace:args.replace === true });
|
|
3746
|
+
const text = `๐พ Saved flow **${saved.flow.name}** โ \`${saved.path}\` (${saved.flow.steps.length} steps)\n\n\`\`\`yaml\n${saved.yaml}\n\`\`\`\n\nReplay it anytime: \`tapp_flow_run\` with \`flowPath: "${saved.path}"\`.`;
|
|
3747
|
+
return richResult(text, { path:saved.path, flow:saved.flow });
|
|
3748
|
+
} catch (error) {
|
|
3749
|
+
return errorResult(error.message || String(error), error.code ? { code:error.code } : {});
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
3752
|
+
|
|
3753
|
+
if (name === "tapp_session_end") {
|
|
3754
|
+
const unauthorized = ensureAuthorized(args);
|
|
3755
|
+
if (unauthorized) return unauthorized;
|
|
3756
|
+
const recorded = activeSession && activeSession.recording ? activeSession.recording.length : 0;
|
|
3757
|
+
await endSession();
|
|
3758
|
+
const hint = recorded > 0 ? ` (${recorded} recorded step(s) discarded โ use tapp_flow_save before ending to keep them)` : "";
|
|
3759
|
+
return richResult(`๐ Session ended.${hint}`, { ok: true, recordedStepsDiscarded: recorded });
|
|
3760
|
+
}
|
|
3761
|
+
|
|
3762
|
+
return errorResult(`Unknown tool: ${name}`);
|
|
3763
|
+
});
|
|
3764
|
+
|
|
3765
|
+
export async function startMcpServer() {
|
|
3766
|
+
const transport = new StdioServerTransport();
|
|
3767
|
+
await server.connect(transport);
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
// Self-start only when executed directly (`node src/index.js`, `npm run start`/`dev`).
|
|
3771
|
+
// bin/tapp.js imports this module โ for `tapp mcp` it calls startMcpServer() explicitly,
|
|
3772
|
+
// while the CLI verbs (qa/open/tree/shot) use the exported engine without starting a server.
|
|
3773
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
3774
|
+
await startMcpServer();
|
|
3775
|
+
}
|