@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
package/bin/tapp.js
ADDED
|
@@ -0,0 +1,1382 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tapp CLI — ship with proof.
|
|
3
|
+
//
|
|
4
|
+
// Zero-config verbs (the same engine the MCP tools use, exported by mcp-server/src/index.js):
|
|
5
|
+
// tapp qa <bundleId|appId|url> Autonomous QA → verdict + findings + evidence
|
|
6
|
+
// tapp open <bundleId> Launch app → screen summary + screenshot file
|
|
7
|
+
// tapp tree <bundleId> Accessibility tree of the current screen
|
|
8
|
+
// tapp shot Screenshot the booted simulator
|
|
9
|
+
// tapp report [captureId] Open the HTML evidence page
|
|
10
|
+
// tapp app [repo] Open the browser release-contract workspace
|
|
11
|
+
// tapp ci ... Merge-blocking release gate (passthrough to ci-gate.sh)
|
|
12
|
+
//
|
|
13
|
+
// tapp mcp Start the MCP server on stdio (inline screenshots + interactive sessions)
|
|
14
|
+
// tapp install Prebuild the exploration harness for the booted simulator
|
|
15
|
+
// tapp doctor Check the toolchain (Xcode, simctl, node, harness cache)
|
|
16
|
+
//
|
|
17
|
+
// All writable output (captures, harness build cache) goes to ~/.tapp (override
|
|
18
|
+
// with TAPP_HOME). The package directory itself is never written to.
|
|
19
|
+
// (Internally exported as AUTOTAP_HOME — the env name the bundled scripts read.)
|
|
20
|
+
|
|
21
|
+
import { spawnSync, spawn } from "node:child_process";
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const packageRoot = path.resolve(__dirname, "..");
|
|
29
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
|
|
30
|
+
|
|
31
|
+
// Redirect all writable output away from the (possibly read-only) package dir.
|
|
32
|
+
if (!process.env.AUTOTAP_HOME) {
|
|
33
|
+
process.env.AUTOTAP_HOME = process.env.TAPP_HOME || path.join(os.homedir(), ".tapp");
|
|
34
|
+
}
|
|
35
|
+
fs.mkdirSync(process.env.AUTOTAP_HOME, { recursive: true });
|
|
36
|
+
|
|
37
|
+
const [, , command = "help", ...rest] = process.argv;
|
|
38
|
+
|
|
39
|
+
function run(cmd, args, opts = {}) {
|
|
40
|
+
const result = spawnSync(cmd, args, { encoding: "utf8", ...opts });
|
|
41
|
+
return {
|
|
42
|
+
code: result.status ?? 1,
|
|
43
|
+
stdout: (result.stdout || "").trim(),
|
|
44
|
+
stderr: (result.stderr || "").trim(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ok(label, detail = "") {
|
|
49
|
+
console.log(` ✅ ${label}${detail ? ` — ${detail}` : ""}`);
|
|
50
|
+
}
|
|
51
|
+
function bad(label, detail = "") {
|
|
52
|
+
console.log(` ❌ ${label}${detail ? ` — ${detail}` : ""}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function bootedSims() {
|
|
56
|
+
const r = run("xcrun", ["simctl", "list", "devices", "booted", "-j"]);
|
|
57
|
+
if (r.code !== 0) return [];
|
|
58
|
+
try {
|
|
59
|
+
const d = JSON.parse(r.stdout);
|
|
60
|
+
return Object.values(d.devices || {})
|
|
61
|
+
.flat()
|
|
62
|
+
.filter((x) => x.state === "Booted");
|
|
63
|
+
} catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function bootBestSimulator(preferredName = "iPhone 16 Pro") {
|
|
69
|
+
const r = run("xcrun", ["simctl", "list", "devices", "available", "-j"]);
|
|
70
|
+
if (r.code !== 0) return null;
|
|
71
|
+
let candidates = [];
|
|
72
|
+
try {
|
|
73
|
+
const d = JSON.parse(r.stdout);
|
|
74
|
+
// Newest runtime first, iPhones only, preferred name wins.
|
|
75
|
+
candidates = Object.entries(d.devices || {})
|
|
76
|
+
.sort(([a], [b]) => b.localeCompare(a))
|
|
77
|
+
.flatMap(([, devices]) => devices)
|
|
78
|
+
.filter((x) => (x.isAvailable ?? true) && x.name.startsWith("iPhone"));
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const pick = candidates.find((x) => x.name === preferredName) || candidates[0];
|
|
83
|
+
if (!pick) return null;
|
|
84
|
+
console.log(`Booting ${pick.name} (${pick.udid})…`);
|
|
85
|
+
run("xcrun", ["simctl", "boot", pick.udid]);
|
|
86
|
+
const status = run("xcrun", ["simctl", "bootstatus", pick.udid, "-b"]);
|
|
87
|
+
return status.code === 0 ? pick : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function harnessXctestrun() {
|
|
91
|
+
const dir = path.join(process.env.AUTOTAP_HOME, "harness-derived", "Build", "Products");
|
|
92
|
+
try {
|
|
93
|
+
const found = fs.readdirSync(dir).find((f) => f.endsWith(".xctestrun"));
|
|
94
|
+
return found ? path.join(dir, found) : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Flags/positionals for the zero-config verbs (qa/open/tree/shot). `--key value` or bare `--key`.
|
|
101
|
+
function parseVerbArgs(argv) {
|
|
102
|
+
const flags = {};
|
|
103
|
+
const positionals = [];
|
|
104
|
+
for (let i = 0; i < argv.length; i++) {
|
|
105
|
+
const a = argv[i];
|
|
106
|
+
if (a.startsWith("--")) {
|
|
107
|
+
const key = a.slice(2);
|
|
108
|
+
const next = argv[i + 1];
|
|
109
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
110
|
+
flags[key] = next;
|
|
111
|
+
i++;
|
|
112
|
+
} else {
|
|
113
|
+
flags[key] = true;
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
positionals.push(a);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { flags, positionals };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function repeatedFlagValues(argv, name) {
|
|
123
|
+
const values = [];
|
|
124
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
125
|
+
if (argv[index] !== `--${name}`) continue;
|
|
126
|
+
const value = argv[index + 1];
|
|
127
|
+
if (value !== undefined && !value.startsWith("--")) values.push(value);
|
|
128
|
+
}
|
|
129
|
+
return values;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const engineImport = () => import(path.join(packageRoot, "mcp-server", "src", "index.js"));
|
|
133
|
+
|
|
134
|
+
function requireMacFor(what) {
|
|
135
|
+
if (process.platform === "darwin") return;
|
|
136
|
+
console.error(`❌ ${what} requires macOS (Xcode + iOS simulator). The web beta runs anywhere: tapp qa https://localhost:3000`);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function ensureIOSHarness() {
|
|
141
|
+
const result = spawnSync("bash", [path.join(packageRoot, "scripts", "quick-capture.sh"), "build-harness"], {
|
|
142
|
+
stdio: "inherit",
|
|
143
|
+
env: process.env,
|
|
144
|
+
});
|
|
145
|
+
if ((result.status ?? 1) !== 0) {
|
|
146
|
+
console.error("❌ Could not prepare the iOS test harness.");
|
|
147
|
+
process.exit(result.status ?? 1);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function requestedPlatform(flags, target = "") {
|
|
152
|
+
if (typeof flags.platform === "string") return flags.platform.toLowerCase();
|
|
153
|
+
if (/^https?:\/\//i.test(target)) return "web";
|
|
154
|
+
if (/\.apk$/i.test(target)) return "android";
|
|
155
|
+
return "ios";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function androidTarget(flags, target = "") {
|
|
159
|
+
const targetIsApk = /\.apk$/i.test(target);
|
|
160
|
+
const apkPath = targetIsApk ? path.resolve(target) : typeof flags.apk === "string" ? path.resolve(flags.apk) : "";
|
|
161
|
+
const appId = typeof flags["app-id"] === "string" ? flags["app-id"] : targetIsApk ? "" : target;
|
|
162
|
+
if (!appId) {
|
|
163
|
+
console.error("❌ Android needs an application id: --app-id com.example.app (an APK alone does not reliably identify the launch target)");
|
|
164
|
+
process.exit(2);
|
|
165
|
+
}
|
|
166
|
+
if (apkPath && !fs.existsSync(apkPath)) {
|
|
167
|
+
console.error(`❌ APK not found: ${apkPath}`);
|
|
168
|
+
process.exit(2);
|
|
169
|
+
}
|
|
170
|
+
return { appId, apkPath: apkPath || undefined, serial: typeof flags.serial === "string" ? flags.serial : undefined };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function saveShot(img, outFlag, name) {
|
|
174
|
+
const out = outFlag || path.join(process.env.AUTOTAP_HOME, "shots", name);
|
|
175
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
176
|
+
fs.writeFileSync(out, Buffer.from(img.data, "base64"));
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function printEngineError(r) {
|
|
181
|
+
console.error(`❌ ${r.error}`);
|
|
182
|
+
if (r.details && Array.isArray(r.details.errors) && r.details.errors.length) {
|
|
183
|
+
console.error(r.details.errors.map((e) => " " + e.trim()).join("\n"));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Turn whatever the user gave us (nothing / repo dir / .app / bundle id) into an installed
|
|
188
|
+
// bundle id, narrating build/install progress on stderr.
|
|
189
|
+
async function resolveTargetOrExit(engine, input) {
|
|
190
|
+
const resolved = await engine.resolveAppTarget(input || "", { onStatus: (s) => console.error(`⏳ ${s}`) });
|
|
191
|
+
if (resolved.error) {
|
|
192
|
+
printEngineError(resolved);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
if (resolved.via) console.error(`🎯 Target: ${resolved.bundleId} — ${resolved.via}`);
|
|
196
|
+
return resolved.bundleId;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
switch (command) {
|
|
200
|
+
case "mcp": {
|
|
201
|
+
// Agents spawn `tapp mcp`; the engine module is import-safe, so start explicitly.
|
|
202
|
+
const { startMcpServer } = await engineImport();
|
|
203
|
+
await startMcpServer();
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
case "init": {
|
|
208
|
+
const { flags, positionals } = parseVerbArgs(rest);
|
|
209
|
+
const projectDir = path.resolve(positionals[0] || process.cwd());
|
|
210
|
+
if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) {
|
|
211
|
+
console.error(`❌ Repository directory not found: ${projectDir}`);
|
|
212
|
+
process.exit(2);
|
|
213
|
+
}
|
|
214
|
+
const maxContracts = flags["max-contracts"] === undefined ? 15 : Number(flags["max-contracts"]);
|
|
215
|
+
if (!Number.isInteger(maxContracts) || maxContracts < 1 || maxContracts > 50) {
|
|
216
|
+
console.error("❌ --max-contracts must be an integer from 1 to 50");
|
|
217
|
+
process.exit(2);
|
|
218
|
+
}
|
|
219
|
+
const explore = flags.explore === true;
|
|
220
|
+
if (explore && flags["dry-run"] === true) {
|
|
221
|
+
console.error("❌ --explore writes grounded UI Map evidence and cannot be combined with --dry-run");
|
|
222
|
+
process.exit(2);
|
|
223
|
+
}
|
|
224
|
+
const outDir = typeof flags["out-dir"] === "string" ? flags["out-dir"] : ".autotap";
|
|
225
|
+
const artifactDir = path.resolve(projectDir, outDir);
|
|
226
|
+
if (!artifactDir.startsWith(projectDir + path.sep) && artifactDir !== projectDir) {
|
|
227
|
+
console.error("❌ --out-dir must remain inside the repository");
|
|
228
|
+
process.exit(2);
|
|
229
|
+
}
|
|
230
|
+
const platform = typeof flags.platform === "string" ? flags.platform.toLowerCase()
|
|
231
|
+
: typeof flags.url === "string" ? "web"
|
|
232
|
+
: typeof flags["app-id"] === "string" || typeof flags.apk === "string" ? "android" : "ios";
|
|
233
|
+
if (explore && platform === "ios") requireMacFor("iOS init exploration");
|
|
234
|
+
const actions = flags.actions === undefined ? 40 : Number(flags.actions);
|
|
235
|
+
const timeout = flags.timeout === undefined ? 600 : Number(flags.timeout);
|
|
236
|
+
if (!Number.isInteger(actions) || actions < 1 || !Number.isInteger(timeout) || timeout < 1) {
|
|
237
|
+
console.error("❌ --actions and --timeout must be positive integers");
|
|
238
|
+
process.exit(2);
|
|
239
|
+
}
|
|
240
|
+
const engine = explore ? await engineImport() : null;
|
|
241
|
+
const { initializeProductProject } = await import(path.join(packageRoot, "mcp-server", "src", "product-operations.js"));
|
|
242
|
+
let result;
|
|
243
|
+
try {
|
|
244
|
+
result = await initializeProductProject({
|
|
245
|
+
projectDir,
|
|
246
|
+
mode: flags["dry-run"] === true ? "inspect" : explore ? "explore" : flags.refresh === true ? "refresh" : "write",
|
|
247
|
+
ownedUrl: typeof flags.url === "string" ? flags.url : "",
|
|
248
|
+
platform: typeof flags.platform === "string" ? flags.platform.toLowerCase() : explore ? platform : "",
|
|
249
|
+
target: typeof flags.target === "string" ? flags.target : projectDir,
|
|
250
|
+
bundleId: typeof flags["bundle-id"] === "string" ? flags["bundle-id"] : "",
|
|
251
|
+
appId: typeof flags["app-id"] === "string" ? flags["app-id"] : "",
|
|
252
|
+
apkPath: typeof flags.apk === "string" ? path.resolve(flags.apk) : undefined,
|
|
253
|
+
serial: typeof flags.serial === "string" ? flags.serial : undefined,
|
|
254
|
+
maxActions: actions,
|
|
255
|
+
timeout,
|
|
256
|
+
testEmail: typeof flags.email === "string" ? flags.email : undefined,
|
|
257
|
+
testPassword: typeof flags.password === "string" ? flags.password : undefined,
|
|
258
|
+
runExploration: engine?.runInitExploration,
|
|
259
|
+
onProgress: (progress) => process.stderr.write(`\r🔍 Import exploration… ${progress.action}/${progress.max || actions} actions · ${progress.states} ${platform === "web" ? "pages" : "screens"} reached `),
|
|
260
|
+
onStatus: (status) => console.error(`⏳ ${status}`),
|
|
261
|
+
outDir,
|
|
262
|
+
maxContracts,
|
|
263
|
+
});
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (explore) process.stderr.write("\n");
|
|
266
|
+
console.error(`❌ Could not initialize repository: ${error.message || String(error)}`);
|
|
267
|
+
process.exit(2);
|
|
268
|
+
}
|
|
269
|
+
if (explore) process.stderr.write("\n");
|
|
270
|
+
const built = { model: result.model, plan: result.plan };
|
|
271
|
+
const written = result.written;
|
|
272
|
+
const exploration = result.exploration;
|
|
273
|
+
if (typeof flags["json-out"] === "string") {
|
|
274
|
+
const out = path.resolve(flags["json-out"]);
|
|
275
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
276
|
+
fs.writeFileSync(out, JSON.stringify({ model: built.model, plan: written?.plan || built.plan, ...(exploration ? { exploration } : {}) }, null, 2) + "\n");
|
|
277
|
+
}
|
|
278
|
+
const blocking = built.model.requirements.filter((item) => item.severity === "blocking");
|
|
279
|
+
const pending = (written?.plan || built.plan).items.filter((item) => item.decision === "pending");
|
|
280
|
+
console.log(`🧭 Tapp init — ${built.model.application.name}`);
|
|
281
|
+
console.log(` targets: ${built.model.targets.length ? built.model.targets.map((target) => `${target.platform}:${target.name}`).join(", ") : "none"}`);
|
|
282
|
+
console.log(` UI Map: ${built.model.uiMap.status} · ${built.model.uiMap.nodeCount} states · ${built.model.uiMap.edgeCount} transitions`);
|
|
283
|
+
if (exploration) console.log(` Exploration: ${exploration.verdict}${exploration.inconclusive ? " (inconclusive)" : ""} · ${exploration.uiMap.nodeCount} states · evidence: ${exploration.reportHtml || exploration.capture?.path || "capture recorded"}`);
|
|
284
|
+
if (exploration?.managedRuntime) console.log(` Managed web runtime: built/started ${exploration.target} for exploration and stopped it afterward · log: ${exploration.runtime.logPath}`);
|
|
285
|
+
console.log(` release plan: ${(written?.plan || built.plan).items.length} item(s) · ${pending.length} pending review · ${blocking.length} blocking requirement(s)`);
|
|
286
|
+
for (const requirement of built.model.requirements) console.log(` ${requirement.severity === "blocking" ? "❌" : "⚠️"} ${requirement.message} Next: ${requirement.remediation}`);
|
|
287
|
+
if (written) console.log(` model: ${written.modelPath}\n plan: ${written.planPath}`);
|
|
288
|
+
else console.log(" dry run: repository files were not changed");
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
case "plan": {
|
|
293
|
+
const { flags, positionals } = parseVerbArgs(rest);
|
|
294
|
+
const verb = positionals[0] || "show";
|
|
295
|
+
const planPath = path.resolve(positionals[1] || ".autotap/release-plan.json");
|
|
296
|
+
if (!fs.existsSync(planPath)) {
|
|
297
|
+
console.error(`❌ Release plan not found: ${planPath}`);
|
|
298
|
+
process.exit(2);
|
|
299
|
+
}
|
|
300
|
+
let plan;
|
|
301
|
+
try { plan = JSON.parse(fs.readFileSync(planPath, "utf8")); }
|
|
302
|
+
catch (error) { console.error(`❌ Invalid release plan: ${error.message}`); process.exit(2); }
|
|
303
|
+
if (verb === "show") {
|
|
304
|
+
console.log(`📋 ${plan.application?.name || "Tapp"} release plan — ${plan.status}`);
|
|
305
|
+
for (const item of plan.items || []) console.log(` ${item.decision === "approved" || item.decision === "accepted" ? "✅" : item.decision === "rejected" ? "❌" : "⏳"} ${item.name} · ${item.criticality} · ${item.decision} · ${item.origin}`);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
if (verb === "generate") {
|
|
309
|
+
const projectDir = path.resolve(typeof flags["project-dir"] === "string" ? flags["project-dir"] : process.cwd());
|
|
310
|
+
const { generateProductPlan } = await import(path.join(packageRoot, "mcp-server", "src", "product-operations.js"));
|
|
311
|
+
let generated;
|
|
312
|
+
try { generated = await generateProductPlan({ projectDir, planPath }); }
|
|
313
|
+
catch (error) { console.error(`❌ Could not generate contract drafts: ${error.message || String(error)}`); process.exit(2); }
|
|
314
|
+
console.log(`🧩 Contract drafts — ${generated.generated.length} compile-checked/untrusted · ${generated.blocked.length} blocked · ${generated.generatedTasks.length} grounded Task draft(s)`);
|
|
315
|
+
for (const task of generated.generatedTasks) console.log(` 🧭 ${task.name} → ${task.path} (${task.platforms.join(", ")}); real replay still required`);
|
|
316
|
+
for (const item of generated.generated) console.log(` ✅ ${item.name} → ${item.path} (${item.staticValidation.map((entry) => `${entry.platform}:${entry.deterministicSteps}`).join(", ")}); real replay still required`);
|
|
317
|
+
for (const item of generated.blocked) console.log(` ⚠️ ${item.name}: ${item.reason}`);
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
if (verb === "validate") {
|
|
321
|
+
const projectDir = path.resolve(typeof flags["project-dir"] === "string" ? flags["project-dir"] : process.cwd());
|
|
322
|
+
const { validateProductPlan } = await import(path.join(packageRoot, "mcp-server", "src", "product-operations.js"));
|
|
323
|
+
const engine = await engineImport();
|
|
324
|
+
let validation;
|
|
325
|
+
try {
|
|
326
|
+
validation = await validateProductPlan({
|
|
327
|
+
projectDir, planPath,
|
|
328
|
+
items: typeof flags.item === "string" ? flags.item.split(",").map((item) => item.trim()).filter(Boolean) : [],
|
|
329
|
+
platform: typeof flags.platform === "string" ? flags.platform.toLowerCase() : "",
|
|
330
|
+
url: typeof flags.url === "string" ? flags.url : "",
|
|
331
|
+
target: typeof flags.target === "string" ? flags.target : "",
|
|
332
|
+
bundleId: typeof flags["bundle-id"] === "string" ? flags["bundle-id"] : "",
|
|
333
|
+
appId: typeof flags["app-id"] === "string" ? flags["app-id"] : "",
|
|
334
|
+
apkPath: typeof flags.apk === "string" ? path.resolve(flags.apk) : "",
|
|
335
|
+
serial: typeof flags.serial === "string" ? flags.serial : "",
|
|
336
|
+
timeout: flags.timeout,
|
|
337
|
+
testEmail: typeof flags.email === "string" ? flags.email : undefined,
|
|
338
|
+
testPassword: typeof flags.password === "string" ? flags.password : undefined,
|
|
339
|
+
startWebTarget: engine.startManagedWebTarget,
|
|
340
|
+
stopWebTarget: engine.stopManagedWebTarget,
|
|
341
|
+
onProgress: (entry) => { if (entry.text) console.error(`⏳ ${entry.text}`); },
|
|
342
|
+
});
|
|
343
|
+
} catch (error) {
|
|
344
|
+
console.error(`❌ Could not validate contract drafts: ${error.message || String(error)}`);
|
|
345
|
+
process.exit(2);
|
|
346
|
+
}
|
|
347
|
+
for (const item of validation.results) {
|
|
348
|
+
if (item.execution.stdout) process.stdout.write(item.execution.stdout + "\n");
|
|
349
|
+
if (item.execution.stderr) process.stderr.write(item.execution.stderr + "\n");
|
|
350
|
+
}
|
|
351
|
+
const failed = validation.results.filter((item) => !item.passed).length;
|
|
352
|
+
console.log(`🔎 Draft validation — ${validation.results.length - failed} passed · ${failed} failed on ${validation.platform}; trust requires every declared platform`);
|
|
353
|
+
if (failed) process.exit(1);
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
if (verb === "promote") {
|
|
357
|
+
const projectDir = path.resolve(typeof flags["project-dir"] === "string" ? flags["project-dir"] : process.cwd());
|
|
358
|
+
const ids = typeof flags.item === "string" ? flags.item.split(",").map((item) => item.trim()).filter(Boolean) : [];
|
|
359
|
+
const { promoteProductPlan } = await import(path.join(packageRoot, "mcp-server", "src", "product-operations.js"));
|
|
360
|
+
let promoted;
|
|
361
|
+
try { promoted = await promoteProductPlan({ projectDir, planPath, items: ids }); }
|
|
362
|
+
catch (error) { console.error(`❌ Could not promote validated proposals: ${error.message || String(error)}`); process.exit(2); }
|
|
363
|
+
console.log(`📦 Promoted validated proposals — ${promoted.promotedTasks.length} Task(s) · ${promoted.promotedContracts.length} release contract(s) · UI Map coverage updated`);
|
|
364
|
+
for (const task of promoted.promotedTasks) console.log(` 🧭 ${task.name} → ${task.path}`);
|
|
365
|
+
for (const contract of promoted.promotedContracts) console.log(` ✅ ${contract.name} → ${contract.path}`);
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
if (verb !== "review") {
|
|
369
|
+
console.error("usage: tapp plan show [.autotap/release-plan.json]\n tapp plan review [.autotap/release-plan.json] --approve name[,name] --reject name[,name] --defer name[,name]\n tapp plan generate [.autotap/release-plan.json] [--project-dir DIR]\n tapp plan validate [.autotap/release-plan.json] --project-dir DIR --platform web [--url URL] [--target NAME|PATH]\n tapp plan promote [.autotap/release-plan.json] --project-dir DIR [--item name[,name]]");
|
|
370
|
+
process.exit(2);
|
|
371
|
+
}
|
|
372
|
+
const list = (value) => typeof value === "string" ? value.split(",").map((item) => item.trim()).filter(Boolean) : [];
|
|
373
|
+
const decisions = { approve: list(flags.approve), reject: list(flags.reject), defer: list(flags.defer) };
|
|
374
|
+
if (!Object.values(decisions).some((items) => items.length)) {
|
|
375
|
+
console.error("❌ Provide at least one --approve, --reject, or --defer decision");
|
|
376
|
+
process.exit(2);
|
|
377
|
+
}
|
|
378
|
+
const { reviewProductPlan } = await import(path.join(packageRoot, "mcp-server", "src", "product-operations.js"));
|
|
379
|
+
const reviewProjectDir = typeof flags["project-dir"] === "string" ? path.resolve(flags["project-dir"])
|
|
380
|
+
: path.basename(path.dirname(planPath)) === ".autotap" ? path.dirname(path.dirname(planPath)) : path.dirname(planPath);
|
|
381
|
+
try { plan = reviewProductPlan({ projectDir: reviewProjectDir, planPath, ...decisions }).plan; }
|
|
382
|
+
catch (error) { console.error(`❌ Could not review release plan: ${error.message || String(error)}`); process.exit(2); }
|
|
383
|
+
console.log(`✅ Release plan updated — ${plan.items.filter((item) => ["approved", "accepted"].includes(item.decision)).length} accepted/approved · ${plan.items.filter((item) => item.decision === "rejected").length} rejected · ${plan.items.filter((item) => item.decision === "pending").length} pending`);
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---- Zero-config verbs: the same engine the MCP tools use (exported by index.js),
|
|
388
|
+
// invokable by any agent or human with no server setup at all.
|
|
389
|
+
|
|
390
|
+
case "qa": {
|
|
391
|
+
const { flags, positionals } = parseVerbArgs(rest);
|
|
392
|
+
const target = positionals[0] || "";
|
|
393
|
+
let baselineFindings;
|
|
394
|
+
if (flags.baseline) {
|
|
395
|
+
try {
|
|
396
|
+
const parsed = JSON.parse(fs.readFileSync(flags.baseline, "utf8"));
|
|
397
|
+
baselineFindings = Array.isArray(parsed) ? parsed : parsed.findings;
|
|
398
|
+
} catch (e) {
|
|
399
|
+
console.error(`❌ Could not read baseline ${flags.baseline}: ${e.message}`);
|
|
400
|
+
process.exit(2);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const engine = await engineImport();
|
|
404
|
+
const platform = requestedPlatform(flags, target);
|
|
405
|
+
if (!["ios", "android", "web"].includes(platform)) {
|
|
406
|
+
console.error("❌ --platform must be ios|android|web");
|
|
407
|
+
process.exit(2);
|
|
408
|
+
}
|
|
409
|
+
if (platform === "ios") requireMacFor("iOS testing");
|
|
410
|
+
if (platform === "web" && !/^https?:\/\//i.test(target)) {
|
|
411
|
+
console.error("❌ Web QA needs an http(s) URL");
|
|
412
|
+
process.exit(2);
|
|
413
|
+
}
|
|
414
|
+
const bundleId = platform === "ios" ? await resolveTargetOrExit(engine, target) : null;
|
|
415
|
+
const android = platform === "android" ? androidTarget(flags, target) : null;
|
|
416
|
+
const unit = platform === "web" ? "pages" : "screens";
|
|
417
|
+
const onProgress = (p) =>
|
|
418
|
+
process.stderr.write(`\r🔍 Exploring… ${p.action}/${p.max || flags.actions || 60} actions · ${p.states} ${unit} reached `);
|
|
419
|
+
const r = platform === "web"
|
|
420
|
+
? await engine.runQaWeb({
|
|
421
|
+
url: target,
|
|
422
|
+
maxActions: flags.actions,
|
|
423
|
+
timeout: flags.timeout,
|
|
424
|
+
testEmail: flags.email,
|
|
425
|
+
testPassword: flags.password,
|
|
426
|
+
baselineFindings,
|
|
427
|
+
onProgress,
|
|
428
|
+
})
|
|
429
|
+
: platform === "android"
|
|
430
|
+
? await engine.runQaAndroid({
|
|
431
|
+
...android,
|
|
432
|
+
maxActions: flags.actions,
|
|
433
|
+
timeout: flags.timeout,
|
|
434
|
+
testEmail: flags.email,
|
|
435
|
+
testPassword: flags.password,
|
|
436
|
+
baselineFindings,
|
|
437
|
+
clearData: flags["keep-data"] !== true,
|
|
438
|
+
onProgress,
|
|
439
|
+
})
|
|
440
|
+
: await engine.runQaIos({
|
|
441
|
+
bundleId,
|
|
442
|
+
maxActions: flags.actions,
|
|
443
|
+
timeout: flags.timeout,
|
|
444
|
+
args: { testEmail: flags.email, testPassword: flags.password, baselineFindings },
|
|
445
|
+
onProgress,
|
|
446
|
+
});
|
|
447
|
+
process.stderr.write("\n");
|
|
448
|
+
if (r.error) {
|
|
449
|
+
printEngineError(r);
|
|
450
|
+
process.exit(1);
|
|
451
|
+
}
|
|
452
|
+
console.log(r.text);
|
|
453
|
+
if (flags.json && typeof flags.json === "string") {
|
|
454
|
+
fs.writeFileSync(flags.json, JSON.stringify(r.structured, null, 2));
|
|
455
|
+
console.log(`\n📄 Full report JSON: ${flags.json} (pass as --baseline next run to diff regressions)`);
|
|
456
|
+
}
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
case "open": {
|
|
461
|
+
const { flags, positionals } = parseVerbArgs(rest);
|
|
462
|
+
const engine = await engineImport();
|
|
463
|
+
const platform = requestedPlatform(flags, positionals[0] || "");
|
|
464
|
+
if (platform === "android") {
|
|
465
|
+
const target = androidTarget(flags, positionals[0] || "");
|
|
466
|
+
const { AndroidDriver } = await import(path.join(packageRoot, "mcp-server", "src", "android-driver.js"));
|
|
467
|
+
const driver = new AndroidDriver(target);
|
|
468
|
+
await driver.ensureDevice();
|
|
469
|
+
if (target.apkPath) await driver.install(target.apkPath);
|
|
470
|
+
const snap = await driver.launch({ clearData: flags["clear-data"] === true });
|
|
471
|
+
const data = await driver.screenshot();
|
|
472
|
+
await driver.forceStop();
|
|
473
|
+
console.log(`🚀 Launched \`${target.appId}\` (Android)\n`);
|
|
474
|
+
console.log(engine.formatScreen(snap.screenTitle, snap.elements));
|
|
475
|
+
const out = typeof flags.out === "string" ? flags.out : path.join(process.env.AUTOTAP_HOME, "shots", `${target.appId}-${Date.now()}.png`);
|
|
476
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
477
|
+
fs.writeFileSync(out, data);
|
|
478
|
+
console.log(`\n📸 Screenshot: ${out}`);
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
requireMacFor("tapp open");
|
|
482
|
+
const sim = await engine.ensureBootedSim({ autoBoot: true });
|
|
483
|
+
if (sim.error) {
|
|
484
|
+
console.error(`❌ ${sim.error}`);
|
|
485
|
+
process.exit(1);
|
|
486
|
+
}
|
|
487
|
+
if (sim.autoBooted) console.error(`📱 Booted ${sim.booted.name}`);
|
|
488
|
+
const bundleId = await resolveTargetOrExit(engine, positionals[0]);
|
|
489
|
+
const r = await engine.openApp(bundleId, {}, 1000);
|
|
490
|
+
if (r.error) {
|
|
491
|
+
console.error(`❌ ${r.error}`);
|
|
492
|
+
process.exit(1);
|
|
493
|
+
}
|
|
494
|
+
console.log(`🚀 Launched \`${bundleId}\`\n`);
|
|
495
|
+
console.log(engine.formatScreen(r.screenTitle, r.elements));
|
|
496
|
+
if (r.img && !r.img.error) {
|
|
497
|
+
const out = saveShot(r.img, typeof flags.out === "string" ? flags.out : null, `${bundleId}-${Date.now()}.jpg`);
|
|
498
|
+
console.log(`\n📸 Screenshot: ${out}`);
|
|
499
|
+
}
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
case "tree": {
|
|
504
|
+
const { flags, positionals } = parseVerbArgs(rest);
|
|
505
|
+
const engine = await engineImport();
|
|
506
|
+
const platform = requestedPlatform(flags, positionals[0] || "");
|
|
507
|
+
if (platform === "android") {
|
|
508
|
+
const target = androidTarget(flags, positionals[0] || "");
|
|
509
|
+
const { AndroidDriver } = await import(path.join(packageRoot, "mcp-server", "src", "android-driver.js"));
|
|
510
|
+
const driver = new AndroidDriver(target);
|
|
511
|
+
await driver.ensureDevice();
|
|
512
|
+
const snap = await driver.snapshot();
|
|
513
|
+
if (flags.json) console.log(JSON.stringify({ screenTitle: snap.screenTitle, elements: snap.elements }, null, 2));
|
|
514
|
+
else console.log(engine.formatScreen(snap.screenTitle, snap.elements));
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
requireMacFor("tapp tree");
|
|
518
|
+
const sim = await engine.ensureBootedSim({ autoBoot: true });
|
|
519
|
+
if (sim.error) {
|
|
520
|
+
console.error(`❌ ${sim.error}`);
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
const bundleId = await resolveTargetOrExit(engine, positionals[0]);
|
|
524
|
+
const r = await engine.captureUiTree(bundleId);
|
|
525
|
+
if (r.error) {
|
|
526
|
+
console.error(`❌ ${r.error}`);
|
|
527
|
+
process.exit(1);
|
|
528
|
+
}
|
|
529
|
+
if (flags.json) {
|
|
530
|
+
console.log(JSON.stringify({ screenTitle: r.screenTitle, elements: r.elements }, null, 2));
|
|
531
|
+
} else {
|
|
532
|
+
console.log(engine.formatScreen(r.screenTitle, r.elements));
|
|
533
|
+
console.log("\n(full element list: tapp tree " + bundleId + " --json)");
|
|
534
|
+
}
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
case "shot":
|
|
539
|
+
case "screenshot": {
|
|
540
|
+
requireMacFor("tapp shot");
|
|
541
|
+
const { flags } = parseVerbArgs(rest);
|
|
542
|
+
const engine = await engineImport();
|
|
543
|
+
const img = await engine.captureScreenshotImage(flags.width ? Number(flags.width) : 1000);
|
|
544
|
+
if (img.error) {
|
|
545
|
+
console.error(`❌ ${img.error}`);
|
|
546
|
+
process.exit(1);
|
|
547
|
+
}
|
|
548
|
+
const out = saveShot(img, typeof flags.out === "string" ? flags.out : null, `shot-${Date.now()}.jpg`);
|
|
549
|
+
console.log(`📸 ${out} (${Math.round(img.bytes / 1024)}KB)`);
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
case "apps": {
|
|
554
|
+
requireMacFor("tapp apps");
|
|
555
|
+
const engine = await engineImport();
|
|
556
|
+
const sim = await engine.ensureBootedSim({ autoBoot: true });
|
|
557
|
+
if (sim.error) {
|
|
558
|
+
console.error(`❌ ${sim.error}`);
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
561
|
+
const la = await engine.listInstalledUserApps();
|
|
562
|
+
if (la.error) {
|
|
563
|
+
printEngineError(la);
|
|
564
|
+
process.exit(1);
|
|
565
|
+
}
|
|
566
|
+
if (!la.apps.length) {
|
|
567
|
+
console.log("No user apps installed on the booted simulator. Install one: tapp build (from your app repo), or xcrun simctl install booted path/to/App.app");
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
console.log("📱 Installed on the booted simulator:\n");
|
|
571
|
+
for (const a of la.apps) console.log(` ${a.bundleId} (${a.name})`);
|
|
572
|
+
console.log(`\nTest one: tapp qa <bundleId>`);
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
case "build": {
|
|
577
|
+
requireMacFor("tapp build");
|
|
578
|
+
const { flags, positionals } = parseVerbArgs(rest);
|
|
579
|
+
const engine = await engineImport();
|
|
580
|
+
const dir = positionals[0] ? path.resolve(positionals[0]) : process.cwd();
|
|
581
|
+
console.error("⏳ Building for the simulator (a first build can take a few minutes)…");
|
|
582
|
+
const built = await engine.buildAppForSim({
|
|
583
|
+
dir,
|
|
584
|
+
scheme: typeof flags.scheme === "string" ? flags.scheme : undefined,
|
|
585
|
+
configuration: typeof flags.configuration === "string" ? flags.configuration : "Debug",
|
|
586
|
+
});
|
|
587
|
+
if (built.error) {
|
|
588
|
+
printEngineError(built);
|
|
589
|
+
process.exit(1);
|
|
590
|
+
}
|
|
591
|
+
const sim = await engine.ensureBootedSim({ autoBoot: true });
|
|
592
|
+
if (sim.error) {
|
|
593
|
+
console.error(`❌ ${sim.error}`);
|
|
594
|
+
process.exit(1);
|
|
595
|
+
}
|
|
596
|
+
const inst = await engine.installAppOnBootedSim(built.appPath);
|
|
597
|
+
if (inst.error) {
|
|
598
|
+
printEngineError(inst);
|
|
599
|
+
process.exit(1);
|
|
600
|
+
}
|
|
601
|
+
console.log(`🔨 Built ${path.basename(built.appPath)} (scheme ${built.scheme}) — installed as ${inst.bundleId}`);
|
|
602
|
+
console.log(`\nNext: tapp qa ${inst.bundleId}`);
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
case "task": {
|
|
607
|
+
const { flags, positionals } = parseVerbArgs(rest);
|
|
608
|
+
const verb = positionals[0] || "validate";
|
|
609
|
+
const taskPath = positionals[1] ? path.resolve(positionals[1]) : "";
|
|
610
|
+
if (!["validate", "compile", "run"].includes(verb) || !taskPath) {
|
|
611
|
+
console.error("usage: tapp task validate <task.yml> [--platform ios|android|web] [--map .autotap/ui-map.json]\n tapp task compile <task.yml> --platform PLATFORM [--inputs '{\"name\":\"value\"}'] [--out compiled.json]\n tapp task run <task.yml> --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID] [--inputs JSON]");
|
|
612
|
+
process.exit(2);
|
|
613
|
+
}
|
|
614
|
+
if (!fs.existsSync(taskPath)) { console.error(`❌ Task not found: ${taskPath}`); process.exit(2); }
|
|
615
|
+
const { applyTaskCoverage, compileTaskSteps, loadTaskFile, loadTaskRegistry, validateTaskAgainstUiMap } = await import(path.join(packageRoot, "mcp-server", "src", "task-runtime.js"));
|
|
616
|
+
let task;
|
|
617
|
+
try { task = loadTaskFile(taskPath); }
|
|
618
|
+
catch (error) { console.error(`❌ ${error.message}`); process.exit(2); }
|
|
619
|
+
const platform = typeof flags.platform === "string" ? flags.platform.toLowerCase()
|
|
620
|
+
: verb === "run" ? (typeof flags.url === "string" ? "web" : typeof flags["app-id"] === "string" || typeof flags.apk === "string" ? "android" : "ios") : "";
|
|
621
|
+
if (platform && !["ios", "android", "web"].includes(platform)) { console.error("❌ --platform must be ios|android|web"); process.exit(2); }
|
|
622
|
+
let grounding = { errors: [], warnings: [] };
|
|
623
|
+
let groundingMap = null;
|
|
624
|
+
let groundingMapPath = null;
|
|
625
|
+
if (typeof flags.map === "string") {
|
|
626
|
+
groundingMapPath = path.resolve(flags.map);
|
|
627
|
+
if (!fs.existsSync(groundingMapPath)) { console.error(`❌ UI Map not found: ${groundingMapPath}`); process.exit(2); }
|
|
628
|
+
groundingMap = JSON.parse(fs.readFileSync(groundingMapPath, "utf8"));
|
|
629
|
+
grounding = validateTaskAgainstUiMap(task, groundingMap, platform);
|
|
630
|
+
}
|
|
631
|
+
if (grounding.errors.length) { console.error(`❌ Task is not grounded: ${grounding.errors.join("; ")}`); process.exit(2); }
|
|
632
|
+
if (flags["update-map"] === true) {
|
|
633
|
+
if (!groundingMap || !groundingMapPath) { console.error("❌ --update-map requires --map <ui-map.json>"); process.exit(2); }
|
|
634
|
+
fs.writeFileSync(groundingMapPath, JSON.stringify(applyTaskCoverage(groundingMap, task), null, 2) + "\n");
|
|
635
|
+
}
|
|
636
|
+
if (verb === "validate") {
|
|
637
|
+
console.log(`✅ Valid Task — ${task.name} v${task.version}`);
|
|
638
|
+
console.log(` inputs: ${Object.keys(task.inputs || {}).join(", ") || "none"} · outputs: ${Object.keys(task.outputs || {}).join(", ") || "none"}`);
|
|
639
|
+
console.log(` coverage: ${(task.coverage?.nodes || []).length} states · ${(task.coverage?.edges || []).length} transitions`);
|
|
640
|
+
for (const warning of grounding.warnings) console.log(` ⚠️ ${warning}`);
|
|
641
|
+
if (flags["update-map"] === true) console.log(` map coverage updated: ${groundingMapPath}`);
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
let inputs = {};
|
|
645
|
+
if (typeof flags.inputs === "string") {
|
|
646
|
+
try { inputs = JSON.parse(flags.inputs); }
|
|
647
|
+
catch { console.error("❌ --inputs must be a JSON object"); process.exit(2); }
|
|
648
|
+
if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) { console.error("❌ --inputs must be a JSON object"); process.exit(2); }
|
|
649
|
+
}
|
|
650
|
+
let registry;
|
|
651
|
+
try {
|
|
652
|
+
registry = loadTaskRegistry({ sourcePath: taskPath });
|
|
653
|
+
if (!registry.has(task.name)) registry.set(task.name, task);
|
|
654
|
+
} catch (error) { console.error(`❌ ${error.message}`); process.exit(2); }
|
|
655
|
+
const vars = {};
|
|
656
|
+
const plan = [];
|
|
657
|
+
let compiled;
|
|
658
|
+
try { compiled = compileTaskSteps({ steps: [{ task: task.name, with: inputs }], registry, platform, flowVars: vars, plan }); }
|
|
659
|
+
catch (error) { console.error(`❌ Could not compile Task: ${error.message}`); process.exit(2); }
|
|
660
|
+
const flow = {
|
|
661
|
+
name: `Task: ${task.name}`, kind: "flow", platform,
|
|
662
|
+
app: typeof flags["bundle-id"] === "string" ? flags["bundle-id"] : typeof flags["app-id"] === "string" ? flags["app-id"] : task.app || "",
|
|
663
|
+
url: typeof flags.url === "string" ? flags.url : task.url || "",
|
|
664
|
+
reset: task.reset || "launch", vars: compiled.vars, steps: compiled.steps, taskPlan: compiled.plan,
|
|
665
|
+
};
|
|
666
|
+
const out = path.resolve(typeof flags.out === "string" ? flags.out : path.join(process.env.AUTOTAP_HOME, "tasks", `${task.name}-${process.pid}-${Date.now()}.json`));
|
|
667
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
668
|
+
fs.writeFileSync(out, JSON.stringify(flow, null, 2) + "\n");
|
|
669
|
+
if (verb === "compile") {
|
|
670
|
+
console.log(`✅ Compiled Task '${task.name}' → ${flow.steps.length} deterministic Flow steps\n${out}`);
|
|
671
|
+
break;
|
|
672
|
+
}
|
|
673
|
+
const runArgs = [path.join(packageRoot, "bin", "tapp.js"), "flow", "run", out, "--platform", platform];
|
|
674
|
+
for (const key of ["url", "bundle-id", "app-id", "apk", "serial", "email", "password"]) {
|
|
675
|
+
if (typeof flags[key] === "string") runArgs.push(`--${key}`, flags[key]);
|
|
676
|
+
}
|
|
677
|
+
const result = spawnSync(process.execPath, runArgs, { stdio: "inherit", env: process.env });
|
|
678
|
+
process.exit(result.status ?? 1);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
case "flow": {
|
|
682
|
+
const { flags, positionals } = parseVerbArgs(rest);
|
|
683
|
+
const verb = positionals[0] || "run";
|
|
684
|
+
const flowPath = positionals[1] || (verb === "run" || verb === "validate" ? "" : verb);
|
|
685
|
+
if (!["run", "validate"].includes(verb) || !flowPath) {
|
|
686
|
+
console.error("usage: tapp flow run <flow.yml> [--platform ios|android|web] [--url URL] [--app-id ID] [--apk FILE] [--serial ID]\n tapp flow validate <flow.yml>");
|
|
687
|
+
process.exit(2);
|
|
688
|
+
}
|
|
689
|
+
const absolute = path.resolve(flowPath);
|
|
690
|
+
if (!fs.existsSync(absolute)) {
|
|
691
|
+
console.error(`❌ Flow not found: ${absolute}`);
|
|
692
|
+
process.exit(2);
|
|
693
|
+
}
|
|
694
|
+
const { loadFlowFile } = await import(path.join(packageRoot, "mcp-server", "src", "flow-runtime.js"));
|
|
695
|
+
let flow;
|
|
696
|
+
try { flow = loadFlowFile(absolute); } catch (error) {
|
|
697
|
+
console.error(`❌ Invalid Flow: ${error.message}`);
|
|
698
|
+
process.exit(2);
|
|
699
|
+
}
|
|
700
|
+
if (!Array.isArray(flow.steps) || flow.steps.length === 0) {
|
|
701
|
+
console.error("❌ Invalid Flow: steps must be a non-empty array");
|
|
702
|
+
process.exit(2);
|
|
703
|
+
}
|
|
704
|
+
const platform = String(flags.platform || flow.platform || (flow.url || /^https?:\/\//i.test(flow.app || "") ? "web" : "ios")).toLowerCase();
|
|
705
|
+
if (!["ios", "android", "web"].includes(platform)) {
|
|
706
|
+
console.error(`❌ Unsupported Flow platform: ${platform}`);
|
|
707
|
+
process.exit(2);
|
|
708
|
+
}
|
|
709
|
+
if (verb === "validate") {
|
|
710
|
+
const taskCount = Array.isArray(flow.taskPlan) ? flow.taskPlan.length : 0;
|
|
711
|
+
console.log(`✅ Valid ${platform} Flow — ${flow.name} (${flow.steps.length} deterministic steps${taskCount ? ` compiled from ${taskCount} Task call${taskCount === 1 ? "" : "s"}` : ""})`);
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
714
|
+
const token = `${process.pid}-${Date.now()}`;
|
|
715
|
+
const flowLog = path.join(process.env.AUTOTAP_HOME, "flows", `${token}.log`);
|
|
716
|
+
const evidenceDir = path.join(process.env.AUTOTAP_HOME, "captures", `flow-${platform}-${token}`);
|
|
717
|
+
fs.mkdirSync(path.dirname(flowLog), { recursive: true });
|
|
718
|
+
const env = { ...process.env, FLOW_LOG: flowLog, TAPP_FLOW_EVIDENCE_DIR: evidenceDir };
|
|
719
|
+
if (typeof flags.email === "string") env.OCQA_TEST_EMAIL = flags.email;
|
|
720
|
+
if (typeof flags.password === "string") env.OCQA_TEST_PASSWORD = flags.password;
|
|
721
|
+
let invocation;
|
|
722
|
+
if (platform === "web") {
|
|
723
|
+
const url = typeof flags.url === "string" ? flags.url : flow.url || flow.app;
|
|
724
|
+
if (!url) { console.error("❌ Web Flow needs `url:` or --url"); process.exit(2); }
|
|
725
|
+
invocation = [process.execPath, [path.join(packageRoot, "scripts", "run-web-flow.js"), absolute, url]];
|
|
726
|
+
} else if (platform === "android") {
|
|
727
|
+
const target = androidTarget(flags, typeof flags["app-id"] === "string" ? flags["app-id"] : flow.app || "");
|
|
728
|
+
invocation = [process.execPath, [path.join(packageRoot, "scripts", "run-android-flow.js"), absolute, target.appId, target.apkPath || "", target.serial || ""]];
|
|
729
|
+
} else {
|
|
730
|
+
requireMacFor("iOS Flow replay");
|
|
731
|
+
ensureIOSHarness();
|
|
732
|
+
invocation = ["bash", [path.join(packageRoot, "scripts", "run-flow.sh"), absolute, typeof flags["bundle-id"] === "string" ? flags["bundle-id"] : flow.app || ""]];
|
|
733
|
+
}
|
|
734
|
+
const result = spawnSync(invocation[0], invocation[1], { stdio: "inherit", env });
|
|
735
|
+
console.log(`\nEvidence: ${evidenceDir}`);
|
|
736
|
+
process.exit(result.status ?? 1);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
case "contract": {
|
|
740
|
+
const { flags, positionals } = parseVerbArgs(rest);
|
|
741
|
+
const verb = positionals[0] || "validate";
|
|
742
|
+
const contractPath = positionals[1] ? path.resolve(positionals[1]) : "";
|
|
743
|
+
if (!["validate", "compile", "run"].includes(verb) || !contractPath) {
|
|
744
|
+
console.error("usage: tapp contract validate <name.contract.ts> [--platform ios|android|web] [--map .autotap/ui-map.json]\n tapp contract compile <name.contract.ts> --platform PLATFORM [--out compiled.json]\n tapp contract run <name.contract.ts> --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID]");
|
|
745
|
+
process.exit(2);
|
|
746
|
+
}
|
|
747
|
+
const {
|
|
748
|
+
applyReleaseContractCoverage,
|
|
749
|
+
compileReleaseContract,
|
|
750
|
+
loadReleaseContractFile,
|
|
751
|
+
validateReleaseContractAgainstUiMap,
|
|
752
|
+
} = await import(path.join(packageRoot, "mcp-server", "src", "release-contract.js"));
|
|
753
|
+
let contract;
|
|
754
|
+
try { contract = await loadReleaseContractFile(contractPath); }
|
|
755
|
+
catch (error) { console.error(`❌ ${error.message}`); process.exit(2); }
|
|
756
|
+
const platform = String(flags.platform || (contract.platforms.length === 1 ? contract.platforms[0] : "")).toLowerCase();
|
|
757
|
+
if (platform && !["ios", "android", "web"].includes(platform)) { console.error("❌ --platform must be ios|android|web"); process.exit(2); }
|
|
758
|
+
let grounding = { errors: [], warnings: [] };
|
|
759
|
+
let groundingMap = null;
|
|
760
|
+
let groundingMapPath = "";
|
|
761
|
+
if (typeof flags.map === "string") {
|
|
762
|
+
groundingMapPath = path.resolve(flags.map);
|
|
763
|
+
if (!fs.existsSync(groundingMapPath)) { console.error(`❌ UI Map not found: ${groundingMapPath}`); process.exit(2); }
|
|
764
|
+
groundingMap = JSON.parse(fs.readFileSync(groundingMapPath, "utf8"));
|
|
765
|
+
grounding = validateReleaseContractAgainstUiMap(contract, groundingMap);
|
|
766
|
+
}
|
|
767
|
+
if (grounding.errors.length) { console.error(`❌ Release Contract is not grounded: ${grounding.errors.join("; ")}`); process.exit(2); }
|
|
768
|
+
if (flags["update-map"] === true) {
|
|
769
|
+
if (!groundingMapPath) { console.error("❌ --update-map requires --map <ui-map.json>"); process.exit(2); }
|
|
770
|
+
fs.writeFileSync(groundingMapPath, JSON.stringify(applyReleaseContractCoverage(groundingMap, contract), null, 2) + "\n");
|
|
771
|
+
}
|
|
772
|
+
if (verb === "validate") {
|
|
773
|
+
console.log(`✅ Valid Release Contract — ${contract.title} (${contract.criticality}, ${Object.keys(contract.actors).length} actor${Object.keys(contract.actors).length === 1 ? "" : "s"})`);
|
|
774
|
+
console.log(` platforms: ${contract.platforms.join(", ")} · ${contract.steps.length} business steps · ${contract.businessValue}`);
|
|
775
|
+
for (const warning of grounding.warnings) console.log(` ⚠️ ${warning}`);
|
|
776
|
+
if (flags["update-map"] === true) console.log(` map coverage updated: ${groundingMapPath}`);
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
let compiled;
|
|
780
|
+
try { compiled = compileReleaseContract(contract, { platform, sourcePath: contractPath }); }
|
|
781
|
+
catch (error) { console.error(`❌ Could not compile Release Contract: ${error.message}`); process.exit(2); }
|
|
782
|
+
const out = path.resolve(typeof flags.out === "string" ? flags.out : path.join(process.env.AUTOTAP_HOME, "contracts", `${contract.name}-${process.pid}-${Date.now()}.json`));
|
|
783
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
784
|
+
fs.writeFileSync(out, JSON.stringify(compiled, null, 2) + "\n");
|
|
785
|
+
if (verb === "compile") {
|
|
786
|
+
console.log(`✅ Compiled Release Contract '${contract.name}' → ${compiled.steps.length} deterministic steps (${compiled.kind})\n${out}`);
|
|
787
|
+
break;
|
|
788
|
+
}
|
|
789
|
+
const runArgs = [path.join(packageRoot, "bin", "tapp.js"), compiled.kind === "scenario" ? "scenario" : "flow", "run", out];
|
|
790
|
+
for (const key of ["url", "bundle-id", "app-id", "apk", "serial", "email", "password"]) {
|
|
791
|
+
if (typeof flags[key] === "string") runArgs.push(`--${key}`, flags[key]);
|
|
792
|
+
}
|
|
793
|
+
if (compiled.kind !== "scenario") runArgs.push("--platform", platform);
|
|
794
|
+
const result = spawnSync(process.execPath, runArgs, { stdio: "inherit", env: process.env });
|
|
795
|
+
process.exit(result.status ?? 1);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
case "pr": {
|
|
799
|
+
const { flags, positionals } = parseVerbArgs(rest);
|
|
800
|
+
const verb = positionals[0] || "plan";
|
|
801
|
+
if (!['plan', 'adopt'].includes(verb)) {
|
|
802
|
+
console.error("usage: tapp pr plan --base BASE [--head HEAD] [--project-dir DIR] [--platform PLATFORM] [--map PATH] [--json-out PATH]\n tapp pr plan --changed-files 'src/a.ts,src/b.ts' [options]\n tapp pr plan --changed-files-file /path/to/files.json [options]\n tapp pr adopt <executed-pr-plan.json> --item ID [--project-dir DIR]");
|
|
803
|
+
process.exit(2);
|
|
804
|
+
}
|
|
805
|
+
const projectDir = path.resolve(typeof flags["project-dir"] === "string" ? flags["project-dir"] : process.cwd());
|
|
806
|
+
if (verb === "adopt") {
|
|
807
|
+
const prPlanPath = positionals[1];
|
|
808
|
+
if (!prPlanPath || typeof flags.item !== "string") {
|
|
809
|
+
console.error("usage: tapp pr adopt <executed-pr-plan.json> --item ID [--project-dir DIR] [--release-plan PATH]");
|
|
810
|
+
process.exit(2);
|
|
811
|
+
}
|
|
812
|
+
const { adoptPrCoverageProposal } = await import(path.join(packageRoot, "mcp-server", "src", "pr-selection.js"));
|
|
813
|
+
try {
|
|
814
|
+
const adopted = adoptPrCoverageProposal({ projectDir, prPlanPath, item: flags.item, releasePlanPath: typeof flags["release-plan"] === "string" ? flags["release-plan"] : undefined });
|
|
815
|
+
console.log(`📥 ${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`);
|
|
816
|
+
console.log(` plan: ${adopted.path}\n next: tapp plan review ${adopted.path} --approve ${adopted.item.id}`);
|
|
817
|
+
} catch (error) { console.error(`❌ Could not adopt PR coverage proposal: ${error.message || String(error)}`); process.exit(2); }
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
const { buildPrContractPlan, changedFilesFromGit, changedSymbolEvidenceFromGit, parseChangedFiles, readChangedFilesFile } = await import(path.join(packageRoot, "mcp-server", "src", "pr-selection.js"));
|
|
821
|
+
let changedFiles = [];
|
|
822
|
+
let changedSymbolEvidence = [];
|
|
823
|
+
if (typeof flags["changed-files"] === "string") {
|
|
824
|
+
try { changedFiles = parseChangedFiles(flags["changed-files"]); }
|
|
825
|
+
catch { console.error("❌ --changed-files must be a comma list or JSON string array"); process.exit(2); }
|
|
826
|
+
} else if (typeof flags["changed-files-file"] === "string") {
|
|
827
|
+
try { changedFiles = readChangedFilesFile(flags["changed-files-file"]); }
|
|
828
|
+
catch (error) { console.error(`❌ ${error.message || String(error)}`); process.exit(2); }
|
|
829
|
+
} else if (typeof flags.base === "string") {
|
|
830
|
+
const head = typeof flags.head === "string" ? flags.head : "HEAD";
|
|
831
|
+
try {
|
|
832
|
+
changedFiles = changedFilesFromGit({ projectDir, base: flags.base, head });
|
|
833
|
+
changedSymbolEvidence = changedSymbolEvidenceFromGit({ projectDir, base: flags.base, head });
|
|
834
|
+
}
|
|
835
|
+
catch (error) { console.error(`❌ ${error.message || String(error)}`); process.exit(2); }
|
|
836
|
+
} else {
|
|
837
|
+
console.error("❌ Provide --base <ref>, --changed-files <list>, or --changed-files-file <path>");
|
|
838
|
+
process.exit(2);
|
|
839
|
+
}
|
|
840
|
+
if (!Array.isArray(changedFiles)) { console.error("❌ --changed-files JSON must be an array"); process.exit(2); }
|
|
841
|
+
let plan;
|
|
842
|
+
try {
|
|
843
|
+
plan = await buildPrContractPlan({
|
|
844
|
+
projectDir,
|
|
845
|
+
changedFiles,
|
|
846
|
+
changedSymbolEvidence,
|
|
847
|
+
platform: typeof flags.platform === "string" ? flags.platform.toLowerCase() : "",
|
|
848
|
+
mapPath: typeof flags.map === "string" ? flags.map : "",
|
|
849
|
+
});
|
|
850
|
+
} catch (error) { console.error(`❌ Could not build PR plan: ${error.message || String(error)}`); process.exit(2); }
|
|
851
|
+
if (typeof flags["json-out"] === "string") fs.writeFileSync(path.resolve(flags["json-out"]), JSON.stringify(plan, null, 2) + "\n");
|
|
852
|
+
console.log(`📋 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)`);
|
|
853
|
+
for (const contract of plan.selected) console.log(` ✅ ${contract.name} (${contract.criticality}) — ${contract.reasons.map((reason) => reason.type).join(", ")}`);
|
|
854
|
+
for (const target of plan.explorationTargets) {
|
|
855
|
+
const navigation = target.navigation || {};
|
|
856
|
+
const detail = navigation.route
|
|
857
|
+
? `${navigation.status} ${navigation.route}`
|
|
858
|
+
: navigation.mode === "ui-map-path" && navigation.status === "replayable"
|
|
859
|
+
? `replayable via ${(navigation.steps || []).length} observed UI Map edge(s)`
|
|
860
|
+
: `${navigation.status || "blocked"}: ${navigation.reason || "no replayable navigation reference"}`;
|
|
861
|
+
console.log(` 🔎 ${target.node.name} — ${detail}`);
|
|
862
|
+
}
|
|
863
|
+
for (const file of plan.uncoveredChangedFiles) console.log(` ⚠️ uncovered: ${file}`);
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
case "scenario": {
|
|
868
|
+
const { flags, positionals } = parseVerbArgs(rest);
|
|
869
|
+
const verb = positionals[0] || "run";
|
|
870
|
+
const scenarioPath = positionals[1] || "";
|
|
871
|
+
if (!["run", "validate"].includes(verb) || !scenarioPath) {
|
|
872
|
+
console.error("usage: tapp scenario run <scenario.yml> [--url URL]\n tapp scenario validate <scenario.yml>");
|
|
873
|
+
process.exit(2);
|
|
874
|
+
}
|
|
875
|
+
const absolute = path.resolve(scenarioPath);
|
|
876
|
+
if (!fs.existsSync(absolute)) {
|
|
877
|
+
console.error(`❌ Scenario not found: ${absolute}`);
|
|
878
|
+
process.exit(2);
|
|
879
|
+
}
|
|
880
|
+
const { loadScenarioFile, validateScenario } = await import(path.join(packageRoot, "mcp-server", "src", "scenario-runtime.js"));
|
|
881
|
+
let scenario;
|
|
882
|
+
try { scenario = loadScenarioFile(absolute); } catch (error) {
|
|
883
|
+
console.error(`❌ Invalid Scenario: ${error.message}`);
|
|
884
|
+
process.exit(2);
|
|
885
|
+
}
|
|
886
|
+
const errors = validateScenario(scenario);
|
|
887
|
+
if (errors.length) {
|
|
888
|
+
console.error(`❌ Invalid Scenario: ${errors.join("; ")}`);
|
|
889
|
+
process.exit(2);
|
|
890
|
+
}
|
|
891
|
+
if (verb === "validate") {
|
|
892
|
+
console.log(`✅ Valid web Scenario — ${scenario.name} (${Object.keys(scenario.actors).length} actors, ${scenario.steps.length} journey steps)`);
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
const token = `${process.pid}-${Date.now()}`;
|
|
896
|
+
const flowLog = path.join(process.env.AUTOTAP_HOME, "scenarios", `${token}.log`);
|
|
897
|
+
const evidenceDir = path.join(process.env.AUTOTAP_HOME, "captures", `scenario-web-${token}`);
|
|
898
|
+
const env = { ...process.env, FLOW_LOG: flowLog, TAPP_FLOW_EVIDENCE_DIR: evidenceDir };
|
|
899
|
+
const url = typeof flags.url === "string" ? flags.url : scenario.url || scenario.app || "";
|
|
900
|
+
const result = spawnSync(process.execPath, [path.join(packageRoot, "scripts", "run-web-scenario.js"), absolute, url], { stdio: "inherit", env });
|
|
901
|
+
if (fs.existsSync(flowLog)) spawnSync("python3", [path.join(packageRoot, "scripts", "flow_lib.py"), "report", flowLog], { stdio: "inherit" });
|
|
902
|
+
console.log(`\nEvidence: ${evidenceDir}`);
|
|
903
|
+
process.exit(result.status ?? 1);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
case "map": {
|
|
907
|
+
const { flags, positionals } = parseVerbArgs(rest);
|
|
908
|
+
const verb = positionals[0] || "inspect";
|
|
909
|
+
const { buildUiMapFromMarkers, diffUiMaps, mergeUiMaps, validateUiMap, writeUiMap } = await import(path.join(packageRoot, "mcp-server", "src", "ui-map.js"));
|
|
910
|
+
if (verb === "build") {
|
|
911
|
+
const markersPath = positionals[1] ? path.resolve(positionals[1]) : "";
|
|
912
|
+
if (!markersPath || !fs.existsSync(markersPath)) {
|
|
913
|
+
console.error(`❌ Markers not found: ${markersPath || "provide <ocqa-markers.txt>"}`);
|
|
914
|
+
process.exit(2);
|
|
915
|
+
}
|
|
916
|
+
const platform = String(flags.platform || "ios").toLowerCase();
|
|
917
|
+
if (!["ios", "android", "web"].includes(platform)) {
|
|
918
|
+
console.error("❌ --platform must be ios|android|web");
|
|
919
|
+
process.exit(2);
|
|
920
|
+
}
|
|
921
|
+
const out = path.resolve(typeof flags.out === "string" ? flags.out : path.join(".autotap", "ui-map.json"));
|
|
922
|
+
const observed = buildUiMapFromMarkers({
|
|
923
|
+
markersPath,
|
|
924
|
+
platform,
|
|
925
|
+
target: typeof flags.target === "string" ? flags.target : "",
|
|
926
|
+
runId: typeof flags["run-id"] === "string" ? flags["run-id"] : path.basename(path.dirname(markersPath)),
|
|
927
|
+
});
|
|
928
|
+
let map = observed;
|
|
929
|
+
if (fs.existsSync(out) && flags.replace !== true) {
|
|
930
|
+
try { map = mergeUiMaps(JSON.parse(fs.readFileSync(out, "utf8")), observed); }
|
|
931
|
+
catch (error) { console.error(`❌ Could not merge existing UI Map: ${error.message}`); process.exit(2); }
|
|
932
|
+
}
|
|
933
|
+
writeUiMap(out, map);
|
|
934
|
+
const controls = map.nodes.reduce((total, node) => total + node.controls.length, 0);
|
|
935
|
+
console.log(`✅ UI Map updated — ${map.nodes.length} states · ${map.edges.length} transitions · ${controls} controls\n${out}`);
|
|
936
|
+
break;
|
|
937
|
+
}
|
|
938
|
+
if (verb === "inspect") {
|
|
939
|
+
const mapPath = path.resolve(positionals[1] || path.join(".autotap", "ui-map.json"));
|
|
940
|
+
if (!fs.existsSync(mapPath)) { console.error(`❌ UI Map not found: ${mapPath}`); process.exit(2); }
|
|
941
|
+
const map = JSON.parse(fs.readFileSync(mapPath, "utf8"));
|
|
942
|
+
const errors = validateUiMap(map);
|
|
943
|
+
if (errors.length) { console.error(`❌ Invalid UI Map: ${errors.join("; ")}`); process.exit(2); }
|
|
944
|
+
const controls = map.nodes.reduce((total, node) => total + node.controls.length, 0);
|
|
945
|
+
console.log(`🗺️ UI Map v${map.schemaVersion} — ${map.nodes.length} states · ${map.edges.length} transitions · ${controls} controls`);
|
|
946
|
+
for (const node of map.nodes) console.log(`- ${node.name} · ${node.controls.length} controls · ${node.platforms.join("/")} · ${node.status}`);
|
|
947
|
+
break;
|
|
948
|
+
}
|
|
949
|
+
if (verb === "diff") {
|
|
950
|
+
const beforePath = positionals[1] ? path.resolve(positionals[1]) : "";
|
|
951
|
+
const afterPath = positionals[2] ? path.resolve(positionals[2]) : "";
|
|
952
|
+
if (!beforePath || !afterPath || !fs.existsSync(beforePath) || !fs.existsSync(afterPath)) {
|
|
953
|
+
console.error("usage: tapp map diff <before.json> <after.json> [--comparable] [--json out.json]");
|
|
954
|
+
process.exit(2);
|
|
955
|
+
}
|
|
956
|
+
const diff = diffUiMaps(JSON.parse(fs.readFileSync(beforePath, "utf8")), JSON.parse(fs.readFileSync(afterPath, "utf8")), { comparableFullSweep: flags.comparable === true });
|
|
957
|
+
if (typeof flags.json === "string") fs.writeFileSync(path.resolve(flags.json), JSON.stringify(diff, null, 2) + "\n");
|
|
958
|
+
console.log(`🗺️ UI Map diff — +${diff.addedNodes.length} states · ${diff.notObservedNodes.length} not observed · +${diff.addedEdges.length} transitions · ${diff.notObservedEdges.length} transitions not observed`);
|
|
959
|
+
if (!diff.comparableFullSweep && (diff.notObservedNodes.length || diff.notObservedEdges.length)) console.log("ℹ️ Absence is not labeled a regression because the runs were not declared comparable full sweeps.");
|
|
960
|
+
process.exit(diff.lostReachability.length || diff.lostTransitions.length ? 1 : 0);
|
|
961
|
+
}
|
|
962
|
+
console.error("usage: tapp map build <ocqa-markers.txt> [--platform ios|android|web] [--out .autotap/ui-map.json]\n tapp map inspect [ui-map.json]\n tapp map diff <before.json> <after.json> [--comparable]");
|
|
963
|
+
process.exit(2);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
case "doctor": {
|
|
967
|
+
console.log(`tapp v${pkg.version} — doctor\n`);
|
|
968
|
+
let healthy = true;
|
|
969
|
+
|
|
970
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
971
|
+
major >= 18 ? ok("Node", `v${process.versions.node}`) : (bad("Node", `v${process.versions.node} (need >= 18)`), (healthy = false));
|
|
972
|
+
|
|
973
|
+
const python = run("python3", ["--version"]);
|
|
974
|
+
python.code === 0 ? ok("python3", `${python.stdout} (used by Flows)`) : bad("python3", "not found — Flow replay needs python3 + pyyaml (everything else works)");
|
|
975
|
+
|
|
976
|
+
console.log("\n Platforms:");
|
|
977
|
+
if (process.platform === "darwin") {
|
|
978
|
+
const xcode = run("xcode-select", ["-p"]);
|
|
979
|
+
const simctl = run("xcrun", ["simctl", "help"]);
|
|
980
|
+
if (xcode.code === 0 && simctl.code === 0) {
|
|
981
|
+
const ver = run("xcodebuild", ["-version"]).stdout.split("\n")[0];
|
|
982
|
+
const booted = bootedSims();
|
|
983
|
+
ok("iOS", `${ver || "Xcode"}; ${booted.length ? `${booted[0].name} booted` : "no simulator booted yet"}`);
|
|
984
|
+
const xctestrun = harnessXctestrun();
|
|
985
|
+
xctestrun ? ok("iOS harness cache", xctestrun) : console.log(" ⬜ iOS harness cache — builds on first use (or: tapp install)");
|
|
986
|
+
} else {
|
|
987
|
+
console.log(" ⬜ iOS — unavailable (install Xcode + simulator runtime)");
|
|
988
|
+
}
|
|
989
|
+
} else {
|
|
990
|
+
console.log(` ⬜ iOS — requires macOS (this host: ${process.platform})`);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const { resolveAdbPath } = await import(path.join(packageRoot, "mcp-server", "src", "android-driver.js"));
|
|
994
|
+
const adbPath = resolveAdbPath();
|
|
995
|
+
const adb = adbPath ? run(adbPath, ["devices"]) : { code: 1, stdout: "" };
|
|
996
|
+
if (adb.code === 0) {
|
|
997
|
+
const devices = adb.stdout.split(/\r?\n/).slice(1).filter((line) => /\sdevice(?:\s|$)/.test(line));
|
|
998
|
+
ok("Android", devices.length ? `${devices.length} connected emulator/device` : "adb available; no device connected");
|
|
999
|
+
} else {
|
|
1000
|
+
console.log(" ⬜ Android — adb not found (install Android SDK platform-tools)");
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
try {
|
|
1004
|
+
await import("playwright");
|
|
1005
|
+
ok("Web", "Playwright installed");
|
|
1006
|
+
} catch {
|
|
1007
|
+
console.log(" ⬜ Web — install Playwright in the app workspace: npm install -D playwright && npx playwright install chromium");
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
console.log(`\n Home: ${process.env.AUTOTAP_HOME}`);
|
|
1011
|
+
console.log(healthy ? "\nReady. Add to your agent: claude mcp add tapp -- npx -y @aarwitz/tapp mcp" : "\nFix the ❌ items above, then re-run: tapp doctor");
|
|
1012
|
+
process.exit(healthy ? 0 : 1);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
case "install": {
|
|
1016
|
+
console.log("Preparing the iOS exploration harness…");
|
|
1017
|
+
let booted = bootedSims();
|
|
1018
|
+
if (!booted.length) {
|
|
1019
|
+
const sim = bootBestSimulator();
|
|
1020
|
+
if (!sim) {
|
|
1021
|
+
console.error("❌ No iOS simulator available. Install one via Xcode → Settings → Platforms.");
|
|
1022
|
+
process.exit(1);
|
|
1023
|
+
}
|
|
1024
|
+
booted = [sim];
|
|
1025
|
+
}
|
|
1026
|
+
const r = spawnSync("bash", [path.join(packageRoot, "scripts", "quick-capture.sh"), "build-harness"], {
|
|
1027
|
+
stdio: "inherit",
|
|
1028
|
+
});
|
|
1029
|
+
process.exit(r.status ?? 1);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
case "actor": {
|
|
1033
|
+
const { flags, positionals } = parseVerbArgs(rest);
|
|
1034
|
+
const verb = positionals[0] || "list";
|
|
1035
|
+
if (!["list", "set"].includes(verb)) {
|
|
1036
|
+
console.error("usage: tapp actor list [repo] [--json]\n tapp actor set NAME [repo] [--role ROLE] [--session default|isolated] [--provisioning existing|seeded|api|unknown] [--credential name=ENV_NAME] [--email-env ENV_NAME] [--password-env ENV_NAME] [--replace]");
|
|
1037
|
+
process.exit(2);
|
|
1038
|
+
}
|
|
1039
|
+
const projectValue = verb === "set" ? positionals[2] : positionals[1];
|
|
1040
|
+
let projectDir;
|
|
1041
|
+
try { projectDir = fs.realpathSync(path.resolve(projectValue || process.cwd())); }
|
|
1042
|
+
catch { console.error(`❌ Repository directory not found: ${projectValue || process.cwd()}`); process.exit(2); }
|
|
1043
|
+
const { configureActor, readProjectConfig } = await import(path.join(packageRoot, "mcp-server", "src", "project-config.js"));
|
|
1044
|
+
if (verb === "list") {
|
|
1045
|
+
const loaded = readProjectConfig(projectDir);
|
|
1046
|
+
if (loaded.errors.length) { console.error(`❌ Invalid ${loaded.relativePath}: ${loaded.errors.join("; ")}`); process.exit(2); }
|
|
1047
|
+
if (flags.json === true) console.log(JSON.stringify({ path: loaded.path, exists: loaded.exists, actors: loaded.config.actors || {} }, null, 2));
|
|
1048
|
+
else {
|
|
1049
|
+
const actors = Object.entries(loaded.config.actors || {});
|
|
1050
|
+
console.log(`👥 Tapp actors — ${actors.length} configured · ${loaded.relativePath}`);
|
|
1051
|
+
for (const [name, actor] of actors) console.log(` ${name} · role ${actor.role || "unspecified"} · ${actor.session || "default"} session · ${actor.provisioning || "existing"} · credentials ${Object.entries(actor.credentials || {}).map(([key, binding]) => `${key}=$${binding.env}`).join(", ") || "none"}`);
|
|
1052
|
+
}
|
|
1053
|
+
break;
|
|
1054
|
+
}
|
|
1055
|
+
const name = positionals[1] || "";
|
|
1056
|
+
const allowed = new Set(["role", "session", "provisioning", "credential", "email-env", "password-env", "replace"]);
|
|
1057
|
+
const suppliedFlags = rest.filter((value) => value.startsWith("--")).map((value) => value.slice(2));
|
|
1058
|
+
const unsupported = suppliedFlags.filter((value) => !allowed.has(value));
|
|
1059
|
+
if (unsupported.length) {
|
|
1060
|
+
console.error(`❌ Unsupported actor flag(s): ${[...new Set(unsupported)].join(", ")}. Credential values are never accepted; bind names with --credential name=ENV_NAME.`);
|
|
1061
|
+
process.exit(2);
|
|
1062
|
+
}
|
|
1063
|
+
const credentials = {};
|
|
1064
|
+
const bindings = [
|
|
1065
|
+
...repeatedFlagValues(rest, "credential"),
|
|
1066
|
+
...(typeof flags["email-env"] === "string" ? [`email=${flags["email-env"]}`] : []),
|
|
1067
|
+
...(typeof flags["password-env"] === "string" ? [`password=${flags["password-env"]}`] : []),
|
|
1068
|
+
];
|
|
1069
|
+
for (const value of bindings) {
|
|
1070
|
+
const match = /^([a-z][A-Za-z0-9_-]{0,63})=([A-Z_][A-Z0-9_]{0,127})$/.exec(value);
|
|
1071
|
+
if (!match) { console.error(`❌ Invalid credential binding '${value}'; expected name=UPPERCASE_ENV_NAME`); process.exit(2); }
|
|
1072
|
+
credentials[match[1]] = { env: match[2] };
|
|
1073
|
+
}
|
|
1074
|
+
try {
|
|
1075
|
+
const result = configureActor(projectDir, {
|
|
1076
|
+
name,
|
|
1077
|
+
role: typeof flags.role === "string" ? flags.role : "",
|
|
1078
|
+
session: typeof flags.session === "string" ? flags.session : "default",
|
|
1079
|
+
provisioning: typeof flags.provisioning === "string" ? flags.provisioning : "existing",
|
|
1080
|
+
credentials,
|
|
1081
|
+
replace: flags.replace === true,
|
|
1082
|
+
});
|
|
1083
|
+
console.log(`✅ Actor '${name}' configured — ${result.actor.session} session · ${result.actor.provisioning} provisioning`);
|
|
1084
|
+
console.log(` ${result.path}`);
|
|
1085
|
+
console.log(` bindings: ${Object.entries(result.actor.credentials).map(([key, binding]) => `${key}=$${binding.env}`).join(", ") || "none"}`);
|
|
1086
|
+
console.log(" No credential values were accepted or written. Rerun tapp init --refresh to update the application model.");
|
|
1087
|
+
} catch (error) { console.error(`❌ Actor not configured: ${error.message || String(error)}`); process.exit(2); }
|
|
1088
|
+
break;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
case "baseline": {
|
|
1092
|
+
const { flags, positionals } = parseVerbArgs(rest);
|
|
1093
|
+
const verb = positionals[0] || "create";
|
|
1094
|
+
if (verb !== "create") {
|
|
1095
|
+
console.error("usage: tapp baseline create [repo] [--platform ios|android|web] [--target ID|NAME|PATH] [--from gate-report.json] [--replace]\n Without --from, Tapp builds/starts the selected target, runs the full portable gate, and saves only a successful conclusive report.");
|
|
1096
|
+
process.exit(2);
|
|
1097
|
+
}
|
|
1098
|
+
const projectDir = fs.realpathSync(path.resolve(positionals[1] || (typeof flags["project-dir"] === "string" ? flags["project-dir"] : process.cwd())));
|
|
1099
|
+
const modelPath = path.resolve(projectDir, typeof flags.model === "string" ? flags.model : path.join(".autotap", "application-model.json"));
|
|
1100
|
+
if (!modelPath.startsWith(projectDir + path.sep) || !fs.existsSync(modelPath)) {
|
|
1101
|
+
console.error(`❌ Application model not found inside the repository: ${modelPath}\n Run tapp init --explore, review/generate/validate/promote the plan, then create the baseline.`);
|
|
1102
|
+
process.exit(2);
|
|
1103
|
+
}
|
|
1104
|
+
let model;
|
|
1105
|
+
try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
|
|
1106
|
+
catch (error) { console.error(`❌ Invalid application model: ${error.message}`); process.exit(2); }
|
|
1107
|
+
const { selectApplicationTarget } = await import(path.join(packageRoot, "mcp-server", "src", "ci-setup.js"));
|
|
1108
|
+
const { createProductBaseline, prepareProductTarget, runProductGate } = await import(path.join(packageRoot, "mcp-server", "src", "product-operations.js"));
|
|
1109
|
+
let selectedTarget;
|
|
1110
|
+
try {
|
|
1111
|
+
selectedTarget = selectApplicationTarget(model, {
|
|
1112
|
+
platform: typeof flags.platform === "string" ? flags.platform.toLowerCase() : "",
|
|
1113
|
+
target: typeof flags.target === "string" ? flags.target : "",
|
|
1114
|
+
});
|
|
1115
|
+
} catch (error) { console.error(`❌ ${error.message}`); process.exit(2); }
|
|
1116
|
+
|
|
1117
|
+
let reportPath = typeof flags.from === "string" ? path.resolve(flags.from) : "";
|
|
1118
|
+
if (!reportPath) {
|
|
1119
|
+
const actions = flags.actions === undefined ? 40 : Number(flags.actions);
|
|
1120
|
+
const timeout = flags.timeout === undefined ? 600 : Number(flags.timeout);
|
|
1121
|
+
if (!Number.isInteger(actions) || actions < 1 || !Number.isInteger(timeout) || timeout < 1) {
|
|
1122
|
+
console.error("❌ --actions and --timeout must be positive integers");
|
|
1123
|
+
process.exit(2);
|
|
1124
|
+
}
|
|
1125
|
+
if (selectedTarget.platform === "ios") requireMacFor("iOS baseline creation");
|
|
1126
|
+
const engine = await engineImport();
|
|
1127
|
+
let prepared;
|
|
1128
|
+
try {
|
|
1129
|
+
prepared = await prepareProductTarget({
|
|
1130
|
+
projectDir, platform:selectedTarget.platform, target:selectedTarget.id,
|
|
1131
|
+
appPath:typeof flags.app === "string" ? path.resolve(flags.app) : "",
|
|
1132
|
+
apkPath:typeof flags.apk === "string" ? path.resolve(flags.apk) : "",
|
|
1133
|
+
bundleId:typeof flags["bundle-id"] === "string" ? flags["bundle-id"] : "",
|
|
1134
|
+
appId:typeof flags["app-id"] === "string" ? flags["app-id"] : "",
|
|
1135
|
+
scheme:typeof flags.scheme === "string" ? flags.scheme : "",
|
|
1136
|
+
configuration:typeof flags.configuration === "string" ? flags.configuration : "",
|
|
1137
|
+
buildIos:engine.buildAppForSim,
|
|
1138
|
+
buildAndroid:engine.buildAndroidApp,
|
|
1139
|
+
onProgress:(entry) => { if (entry.text) console.error(`⏳ ${entry.text}`); },
|
|
1140
|
+
});
|
|
1141
|
+
} catch (error) { console.error(`❌ ${error.message || String(error)}`); process.exit(2); }
|
|
1142
|
+
const { appPath = "", bundleId = "", appId = "", apkPath = "" } = prepared.runtime;
|
|
1143
|
+
console.error(`🧪 Establishing a conclusive ${selectedTarget.platform}:${selectedTarget.name} baseline through the ordinary release gate…`);
|
|
1144
|
+
let gate;
|
|
1145
|
+
try {
|
|
1146
|
+
gate = await runProductGate({
|
|
1147
|
+
projectDir,
|
|
1148
|
+
platform: selectedTarget.platform,
|
|
1149
|
+
target: selectedTarget.id,
|
|
1150
|
+
url: typeof flags.url === "string" ? flags.url : prepared.runtime.url || "",
|
|
1151
|
+
appPath,
|
|
1152
|
+
bundleId,
|
|
1153
|
+
appId,
|
|
1154
|
+
apkPath,
|
|
1155
|
+
serial: typeof flags.serial === "string" ? flags.serial : "",
|
|
1156
|
+
device: typeof flags.device === "string" ? flags.device : "",
|
|
1157
|
+
flows: typeof flags.flows === "string" ? flags.flows : "",
|
|
1158
|
+
scenarios: typeof flags.scenarios === "string" ? flags.scenarios : "",
|
|
1159
|
+
contracts: typeof flags.contracts === "string" ? flags.contracts : "",
|
|
1160
|
+
actions,
|
|
1161
|
+
timeout,
|
|
1162
|
+
testEmail: typeof flags.email === "string" ? flags.email : undefined,
|
|
1163
|
+
testPassword: typeof flags.password === "string" ? flags.password : undefined,
|
|
1164
|
+
onProgress: (entry) => { if (entry.text) console.error(`⏳ ${entry.text}`); },
|
|
1165
|
+
});
|
|
1166
|
+
} catch (error) {
|
|
1167
|
+
console.error(`❌ The release gate could not run; no baseline was written: ${error.message || String(error)}`);
|
|
1168
|
+
process.exit(2);
|
|
1169
|
+
}
|
|
1170
|
+
reportPath = gate.reportPath;
|
|
1171
|
+
if (!gate.passed) {
|
|
1172
|
+
console.error(`❌ The gate did not pass; no baseline was written.${fs.existsSync(reportPath) ? ` Review the retained report: ${reportPath}` : ""}`);
|
|
1173
|
+
process.exit(gate.code || 1);
|
|
1174
|
+
}
|
|
1175
|
+
if (typeof flags["report-out"] === "string") {
|
|
1176
|
+
const exported = path.resolve(flags["report-out"]);
|
|
1177
|
+
fs.mkdirSync(path.dirname(exported), { recursive: true });
|
|
1178
|
+
fs.copyFileSync(gate.reportPath, exported);
|
|
1179
|
+
if (gate.markdownPath && fs.existsSync(gate.markdownPath)) fs.copyFileSync(gate.markdownPath, exported.replace(/\.json$/i, "") + ".md");
|
|
1180
|
+
reportPath = exported;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
if (!fs.existsSync(reportPath)) { console.error(`❌ Gate report not found: ${reportPath}`); process.exit(2); }
|
|
1184
|
+
try {
|
|
1185
|
+
const written = createProductBaseline({
|
|
1186
|
+
projectDir,
|
|
1187
|
+
reportPath,
|
|
1188
|
+
platform: selectedTarget.platform,
|
|
1189
|
+
target: selectedTarget.id,
|
|
1190
|
+
baselinePath: typeof flags.out === "string" ? flags.out : "",
|
|
1191
|
+
replace: flags.replace === true,
|
|
1192
|
+
});
|
|
1193
|
+
console.log(`✅ Conclusive baseline established — ${selectedTarget.platform}:${selectedTarget.name}`);
|
|
1194
|
+
console.log(` ${written.validation.screensExplored} states · ${written.validation.actionsPerformed} actions · ${written.validation.suite.contracts} contracts · verdict ${written.validation.verdict}`);
|
|
1195
|
+
console.log(` baseline: ${written.path}\n source gate report: ${reportPath}`);
|
|
1196
|
+
} catch (error) { console.error(`❌ Baseline not written: ${error.message}`); process.exit(2); }
|
|
1197
|
+
break;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
case "ci": {
|
|
1201
|
+
if (rest[0] === "install") {
|
|
1202
|
+
const { flags, positionals } = parseVerbArgs(rest.slice(1));
|
|
1203
|
+
let projectDir;
|
|
1204
|
+
try { projectDir = fs.realpathSync(path.resolve(positionals[0] || (typeof flags["project-dir"] === "string" ? flags["project-dir"] : process.cwd()))); }
|
|
1205
|
+
catch { console.error(`❌ Repository directory not found: ${positionals[0] || flags["project-dir"] || process.cwd()}`); process.exit(2); }
|
|
1206
|
+
const modelPath = path.resolve(projectDir, typeof flags.model === "string" ? flags.model : path.join(".autotap", "application-model.json"));
|
|
1207
|
+
if (!modelPath.startsWith(projectDir + path.sep) || !fs.existsSync(modelPath)) {
|
|
1208
|
+
console.error(`❌ Application model not found inside the repository: ${modelPath}\n Run tapp init --explore first.`);
|
|
1209
|
+
process.exit(2);
|
|
1210
|
+
}
|
|
1211
|
+
const actionRef = typeof flags["action-ref"] === "string" ? flags["action-ref"] : `aarwitz/tapp@v${pkg.version}`;
|
|
1212
|
+
const { installProductCi, prepareProductCi } = await import(path.join(packageRoot, "mcp-server", "src", "product-operations.js"));
|
|
1213
|
+
let rendered;
|
|
1214
|
+
try {
|
|
1215
|
+
rendered = prepareProductCi({
|
|
1216
|
+
projectDir,
|
|
1217
|
+
modelPath,
|
|
1218
|
+
actionRef,
|
|
1219
|
+
defaultBranch: typeof flags["default-branch"] === "string" ? flags["default-branch"] : "main",
|
|
1220
|
+
});
|
|
1221
|
+
} catch (error) { console.error(`❌ Could not generate CI installation: ${error.message}`); process.exit(2); }
|
|
1222
|
+
if (typeof flags["json-out"] === "string") {
|
|
1223
|
+
const output = path.resolve(flags["json-out"]);
|
|
1224
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
1225
|
+
fs.writeFileSync(output, JSON.stringify(rendered.manifest, null, 2) + "\n");
|
|
1226
|
+
}
|
|
1227
|
+
if (rendered.manifest.unresolved.length && flags["allow-unresolved"] !== true) {
|
|
1228
|
+
console.error("❌ CI workflow was not installed because target configuration remains unresolved:");
|
|
1229
|
+
for (const item of rendered.manifest.unresolved) console.error(` - ${item.platform}:${item.targetId} — ${item.message}`);
|
|
1230
|
+
console.error(" Resolve these in the application model and rerun init, or use --allow-unresolved only to inspect a non-working draft.");
|
|
1231
|
+
process.exit(2);
|
|
1232
|
+
}
|
|
1233
|
+
if (flags["dry-run"] === true) {
|
|
1234
|
+
console.log(rendered.workflow);
|
|
1235
|
+
console.error(`🧩 CI dry run — ${rendered.manifest.targets.length} target job(s) · ${rendered.manifest.unresolved.length} unresolved · no files written`);
|
|
1236
|
+
break;
|
|
1237
|
+
}
|
|
1238
|
+
try {
|
|
1239
|
+
const written = installProductCi({
|
|
1240
|
+
projectDir,
|
|
1241
|
+
modelPath,
|
|
1242
|
+
actionRef,
|
|
1243
|
+
defaultBranch: typeof flags["default-branch"] === "string" ? flags["default-branch"] : "main",
|
|
1244
|
+
workflowPath: typeof flags.out === "string" ? flags.out : ".github/workflows/tapp.yml",
|
|
1245
|
+
manifestPath: typeof flags.manifest === "string" ? flags.manifest : ".autotap/ci.json",
|
|
1246
|
+
replace: flags.replace === true,
|
|
1247
|
+
allowUnresolved: flags["allow-unresolved"] === true,
|
|
1248
|
+
});
|
|
1249
|
+
console.log(`✅ Reviewable CI gate installed — ${written.manifest.targets.length} target job(s)`);
|
|
1250
|
+
for (const target of written.manifest.targets) console.log(` ${target.platform}:${target.name} · ${target.contracts.length} contract(s) · baseline ${target.baseline || "automatic after first conclusive default-branch run"}`);
|
|
1251
|
+
console.log(` workflow: ${written.workflowPath}\n manifest: ${written.manifestPath}`);
|
|
1252
|
+
if (!written.manifest.actionRefImmutable) console.log(` ⚠️ Action ref ${written.manifest.actionRef} is a release tag, not a commit SHA; resolve and pin that tag's 40-character SHA before production.`);
|
|
1253
|
+
console.log(" Tapp did not commit, push, enable branch protection, or create GitHub resources.");
|
|
1254
|
+
} catch (error) { console.error(`❌ CI installation not written: ${error.message}`); process.exit(2); }
|
|
1255
|
+
break;
|
|
1256
|
+
}
|
|
1257
|
+
const r = spawnSync("bash", [path.join(packageRoot, "scripts", "ci-gate.sh"), ...rest], {
|
|
1258
|
+
stdio: "inherit",
|
|
1259
|
+
});
|
|
1260
|
+
process.exit(r.status ?? 1);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
case "app":
|
|
1264
|
+
case "studio": {
|
|
1265
|
+
const { flags, positionals } = parseVerbArgs(rest);
|
|
1266
|
+
let projectDir;
|
|
1267
|
+
if (positionals[0]) {
|
|
1268
|
+
try { projectDir = fs.realpathSync(path.resolve(positionals[0])); }
|
|
1269
|
+
catch { console.error(`❌ Repository directory not found: ${positionals[0]}`); process.exit(2); }
|
|
1270
|
+
}
|
|
1271
|
+
const port = flags.port === undefined ? 0 : Number(flags.port);
|
|
1272
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) { console.error("❌ --port must be 0..65535"); process.exit(2); }
|
|
1273
|
+
const { startBrowserProduct } = await import(path.join(packageRoot, "mcp-server", "src", "browser-product.js"));
|
|
1274
|
+
const product = await startBrowserProduct({ projectDir, port, launch: flags.open !== false && flags["no-open"] !== true });
|
|
1275
|
+
console.log(`Tapp · ${product.root || "choose a local folder or GitHub repository in the browser"}`);
|
|
1276
|
+
console.log(`Open: ${product.launchUrl}`);
|
|
1277
|
+
console.log("Local-only session; press Ctrl-C to stop.");
|
|
1278
|
+
const shutdown = async () => { try { await product.close(); } finally { process.exit(0); } };
|
|
1279
|
+
process.once("SIGINT", shutdown);
|
|
1280
|
+
process.once("SIGTERM", shutdown);
|
|
1281
|
+
await new Promise(() => {});
|
|
1282
|
+
break;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
case "report": {
|
|
1286
|
+
// Regenerate + open the HTML evidence page for a capture (default: the latest).
|
|
1287
|
+
const capturesDir = path.join(process.env.AUTOTAP_HOME, "captures");
|
|
1288
|
+
const repoCaptures = path.join(packageRoot, "captures");
|
|
1289
|
+
const roots = [capturesDir, repoCaptures].filter((d) => fs.existsSync(d));
|
|
1290
|
+
const runs = roots
|
|
1291
|
+
.flatMap((root) => fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => path.join(root, e.name)))
|
|
1292
|
+
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
1293
|
+
const wanted = rest[0] && rest[0] !== "latest" ? runs.find((r) => path.basename(r) === rest[0]) : runs[0];
|
|
1294
|
+
if (!wanted) {
|
|
1295
|
+
bad("No captures found", rest[0] ? `no capture named "${rest[0]}"` : "run a QA exploration first");
|
|
1296
|
+
process.exit(1);
|
|
1297
|
+
}
|
|
1298
|
+
const { writeHtmlReport } = await import(path.join(packageRoot, "mcp-server", "src", "html-report.js"));
|
|
1299
|
+
const out = writeHtmlReport(wanted, { label: path.basename(wanted) });
|
|
1300
|
+
if (!out) {
|
|
1301
|
+
bad("Capture has no markers", wanted);
|
|
1302
|
+
process.exit(1);
|
|
1303
|
+
}
|
|
1304
|
+
ok("Evidence report", out);
|
|
1305
|
+
spawnSync("open", [out], { stdio: "ignore" });
|
|
1306
|
+
break;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
case "version":
|
|
1310
|
+
case "--version":
|
|
1311
|
+
case "-v": {
|
|
1312
|
+
console.log(pkg.version);
|
|
1313
|
+
break;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
default: {
|
|
1317
|
+
console.log(`tapp v${pkg.version} — ship with proof. Autonomous QA and deterministic Flows for iOS, Android, and web.
|
|
1318
|
+
|
|
1319
|
+
Zero-config verbs (agents and humans can just run these — no server, no setup):
|
|
1320
|
+
tapp app [repo] Open the browser onboarding, contract-review, and release-evidence workspace
|
|
1321
|
+
(loopback-only; --no-open · --port PORT)
|
|
1322
|
+
tapp init [repo] Detect targets and write the application model + reviewable release plan
|
|
1323
|
+
(--explore builds/starts or connects, grounds the UI Map, then tears down)
|
|
1324
|
+
(--url URL · --platform PLATFORM · --dry-run · --refresh)
|
|
1325
|
+
tapp actor list [repo] Inspect named actors, sessions, provisioning, and secret env bindings
|
|
1326
|
+
tapp actor set NAME Configure an actor using environment-variable names only (never values)
|
|
1327
|
+
tapp plan show [FILE] Inspect the proposed/accepted release-contract plan
|
|
1328
|
+
tapp plan review [FILE] Explicitly approve, reject, or defer proposed plan items
|
|
1329
|
+
tapp plan generate [FILE] Generate compile-checked, untrusted contract drafts from approved Tasks
|
|
1330
|
+
tapp plan validate [FILE] Replay drafts on a real target; trust only after all platforms pass
|
|
1331
|
+
tapp plan promote [FILE] Move fully validated drafts into reviewed Tasks/contracts + map coverage
|
|
1332
|
+
tapp qa [target] Autonomous QA → verdict + findings + evidence
|
|
1333
|
+
(--platform ios|android|web · --app-id ID · --apk FILE · --actions N)
|
|
1334
|
+
tapp open [target] Launch the app → screen summary + screenshot saved to a file
|
|
1335
|
+
tapp tree [target] Accessibility tree of the current screen (--json for every element)
|
|
1336
|
+
tapp flow run FILE Replay a committed deterministic Flow (no AI/API key)
|
|
1337
|
+
tapp flow validate FILE Validate a Flow without launching a target
|
|
1338
|
+
tapp task validate FILE Validate a reusable deterministic Task (+ optional UI Map grounding)
|
|
1339
|
+
tapp task compile FILE Compile one Task to the shared keyless Flow execution contract
|
|
1340
|
+
tapp task run FILE Replay a Task directly on iOS, Android, or web
|
|
1341
|
+
tapp contract validate FILE Validate a business-level TypeScript release contract
|
|
1342
|
+
tapp contract compile FILE Compile a contract to the shared deterministic executor
|
|
1343
|
+
tapp contract run FILE Replay a release contract without AI or a coding agent
|
|
1344
|
+
tapp pr plan --base REF Select critical + diff-relevant contracts and report uncovered changes
|
|
1345
|
+
tapp pr adopt PLAN --item ID Explicitly add an observed PR coverage proposal to the release plan
|
|
1346
|
+
tapp scenario run FILE Replay an isolated multi-actor system Scenario (web)
|
|
1347
|
+
tapp scenario validate FILE Validate actors, lifecycle, and deterministic steps
|
|
1348
|
+
tapp map build MARKERS Build/merge the persistent platform-neutral UI Map
|
|
1349
|
+
tapp map inspect [FILE] Inspect states, controls, platforms, and map validity
|
|
1350
|
+
tapp map diff A B Diff observed UI structure without false reachability claims
|
|
1351
|
+
tapp baseline create [repo] Run/import a conclusive full gate and save a target-scoped baseline
|
|
1352
|
+
tapp shot Screenshot the booted simulator → file path (--out file.jpg)
|
|
1353
|
+
tapp build [dir] Build the iOS app in a repo for the simulator + install it (--scheme S)
|
|
1354
|
+
tapp apps List apps installed on the booted simulator (with bundle ids)
|
|
1355
|
+
tapp report [captureId] Open the HTML evidence page for a capture (default: latest)
|
|
1356
|
+
tapp ci ... Merge-blocking release gate — explore + flows + baseline diff (see: tapp ci --help)
|
|
1357
|
+
tapp ci install [repo] Generate a reviewable target-aware GitHub workflow + CI manifest
|
|
1358
|
+
|
|
1359
|
+
[target] is whatever you have — nothing (finds + builds the Xcode project in the current
|
|
1360
|
+
dir, or falls back to the app on the simulator), a repo dir, a path/to/App.app, a bundle
|
|
1361
|
+
id, an Android app id/APK (--platform android --app-id ...), or an http(s) URL.
|
|
1362
|
+
For iOS you never need to know a bundle id up front.
|
|
1363
|
+
|
|
1364
|
+
Setup:
|
|
1365
|
+
tapp install Prebuild the exploration harness (~2 min; otherwise builds on first use)
|
|
1366
|
+
tapp doctor Check Xcode / simulators / toolchain
|
|
1367
|
+
tapp mcp Start the MCP server on stdio (adds inline screenshots + interactive sessions)
|
|
1368
|
+
|
|
1369
|
+
MCP hookup (optional — for inline screenshots and the tap/type/inspect session loop):
|
|
1370
|
+
Claude Code: claude mcp add tapp -- npx -y @aarwitz/tapp mcp
|
|
1371
|
+
Cursor/VS Code (mcp.json):
|
|
1372
|
+
{ "servers": { "tapp": { "type": "stdio", "command": "npx", "args": ["-y", "@aarwitz/tapp", "mcp"] } } }
|
|
1373
|
+
|
|
1374
|
+
Then ask your agent things like:
|
|
1375
|
+
"Run tapp qa on com.mycompany.app — is it ship-ready?"
|
|
1376
|
+
"Open the settings screen and show me the screenshot"
|
|
1377
|
+
"Drive the login flow and record it as a replayable test"
|
|
1378
|
+
|
|
1379
|
+
Docs: ${pkg.homepage}`);
|
|
1380
|
+
break;
|
|
1381
|
+
}
|
|
1382
|
+
}
|