@aarwitz/tapp 0.16.5 → 0.17.0-rc.10
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 +35 -21
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +100 -31
- package/README.md +70 -67
- package/bin/tapp.js +265 -74
- package/browser/app.js +12 -6
- package/docs/BROWSER-PRODUCT.md +75 -0
- package/docs/PRODUCT-ENGINE.md +107 -0
- package/docs/application-model.md +278 -0
- package/docs/scenarios.md +95 -0
- package/mcp-server/src/android-driver.js +88 -3
- package/mcp-server/src/android-explorer.js +83 -14
- package/mcp-server/src/application-model.js +13 -9
- package/mcp-server/src/browser-product.js +1 -1
- package/mcp-server/src/ci-report.js +84 -62
- package/mcp-server/src/ci-setup.js +35 -5
- package/mcp-server/src/enrich.js +1 -1
- package/mcp-server/src/html-report.js +41 -7
- package/mcp-server/src/index.js +198 -72
- package/mcp-server/src/pr-selection.js +4 -3
- package/mcp-server/src/product-execution.js +1 -1
- package/mcp-server/src/product-operations.js +96 -7
- package/mcp-server/src/project-config.js +1 -2
- package/mcp-server/src/project-paths.js +5 -17
- package/mcp-server/src/release-contract.js +3 -3
- package/mcp-server/src/report.js +185 -51
- package/mcp-server/src/task-runtime.js +1 -1
- package/mcp-server/src/web-explorer.js +1 -1
- package/package.json +2 -2
- package/scripts/ci-gate.sh +11 -9
- package/scripts/flow_ai_judge.py +1 -1
- package/scripts/platform-gate.js +11 -5
- package/scripts/quick-capture.sh +72 -38
- package/scripts/run-flow.sh +1 -1
package/bin/tapp.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// tapp CLI — ship with proof.
|
|
3
3
|
//
|
|
4
4
|
// Zero-config verbs (the same engine the MCP tools use, exported by mcp-server/src/index.js):
|
|
5
|
-
// tapp
|
|
5
|
+
// tapp explore <bundleId|appId|url> Autonomous exploration → findings + evidence (observation)
|
|
6
6
|
// tapp open <bundleId> Launch app → screen summary + screenshot file
|
|
7
7
|
// tapp tree <bundleId> Accessibility tree of the current screen
|
|
8
8
|
// tapp shot Screenshot the booted simulator
|
|
@@ -28,13 +28,14 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
28
28
|
const packageRoot = path.resolve(__dirname, "..");
|
|
29
29
|
const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
|
|
30
30
|
|
|
31
|
-
// Redirect all writable output away from the (possibly read-only) package dir.
|
|
32
|
-
//
|
|
33
|
-
const tappHome = (process.env.TAPP_HOME ||
|
|
31
|
+
// Redirect all writable output away from the (possibly read-only) package dir. TAPP_HOME and
|
|
32
|
+
// ~/.tapp are the only current runtime locations; retired environment/path aliases are ignored.
|
|
33
|
+
const tappHome = (process.env.TAPP_HOME || path.join(os.homedir(), ".tapp")).trim();
|
|
34
34
|
process.env.TAPP_HOME = tappHome;
|
|
35
|
-
|
|
35
|
+
// TAPP_HOME is created lazily (just before the switch) so `--help`, `help`, and `version` never
|
|
36
|
+
// write anything — not even the home directory.
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
let [, , command = "help", ...rest] = process.argv;
|
|
38
39
|
|
|
39
40
|
function run(cmd, args, opts = {}) {
|
|
40
41
|
const result = spawnSync(cmd, args, { encoding: "utf8", ...opts });
|
|
@@ -129,11 +130,32 @@ function repeatedFlagValues(argv, name) {
|
|
|
129
130
|
return values;
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
function iosLaunchOptions(flags, argv) {
|
|
134
|
+
const appLaunchArgs = repeatedFlagValues(argv, "launch-arg");
|
|
135
|
+
let appLaunchEnv;
|
|
136
|
+
if (typeof flags["launch-env"] === "string") {
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(flags["launch-env"]);
|
|
139
|
+
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object" || Object.values(parsed).some((value) => typeof value !== "string")) {
|
|
140
|
+
throw new Error("expected a JSON object with string values");
|
|
141
|
+
}
|
|
142
|
+
appLaunchEnv = parsed;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
console.error(`❌ --launch-env must be a JSON object with string values: ${error.message}`);
|
|
145
|
+
process.exit(2);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
...(appLaunchArgs.length ? { appLaunchArgs } : {}),
|
|
150
|
+
...(appLaunchEnv ? { appLaunchEnv } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
132
154
|
const engineImport = () => import(path.join(packageRoot, "mcp-server", "src", "index.js"));
|
|
133
155
|
|
|
134
156
|
function requireMacFor(what) {
|
|
135
157
|
if (process.platform === "darwin") return;
|
|
136
|
-
console.error(`❌ ${what} requires macOS (Xcode + iOS simulator). The web beta runs anywhere: tapp
|
|
158
|
+
console.error(`❌ ${what} requires macOS (Xcode + iOS simulator). The web beta runs anywhere: tapp explore https://localhost:3000`);
|
|
137
159
|
process.exit(1);
|
|
138
160
|
}
|
|
139
161
|
|
|
@@ -184,6 +206,26 @@ function printEngineError(r) {
|
|
|
184
206
|
}
|
|
185
207
|
}
|
|
186
208
|
|
|
209
|
+
async function promptForInitTarget(details) {
|
|
210
|
+
const choices = Array.isArray(details?.choices) ? details.choices : [];
|
|
211
|
+
if (!choices.length || !process.stdin.isTTY || !process.stderr.isTTY || process.env.CI) return null;
|
|
212
|
+
const { createInterface } = await import("node:readline/promises");
|
|
213
|
+
const terminal = createInterface({ input: process.stdin, output: process.stderr });
|
|
214
|
+
console.error("\nTapp found multiple application targets. Which one should it explore?");
|
|
215
|
+
choices.forEach((choice, index) => console.error(` ${index + 1}) ${choice.platform} · ${choice.name} (${choice.sourcePath})`));
|
|
216
|
+
try {
|
|
217
|
+
while (true) {
|
|
218
|
+
const answer = String(await terminal.question(`Select 1-${choices.length} (or q to cancel): `)).trim();
|
|
219
|
+
if (/^(?:q|quit|cancel)$/i.test(answer)) return null;
|
|
220
|
+
const selected = Number(answer);
|
|
221
|
+
if (Number.isInteger(selected) && selected >= 1 && selected <= choices.length) return choices[selected - 1];
|
|
222
|
+
console.error(`Enter a number from 1 to ${choices.length}, or q to cancel.`);
|
|
223
|
+
}
|
|
224
|
+
} finally {
|
|
225
|
+
terminal.close();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
187
229
|
// Turn whatever the user gave us (nothing / repo dir / .app / bundle id) into an installed
|
|
188
230
|
// bundle id, narrating build/install progress on stderr.
|
|
189
231
|
async function resolveTargetOrExit(engine, input) {
|
|
@@ -196,6 +238,57 @@ async function resolveTargetOrExit(engine, input) {
|
|
|
196
238
|
return resolved.bundleId;
|
|
197
239
|
}
|
|
198
240
|
|
|
241
|
+
function safeCommandUsage(verb) {
|
|
242
|
+
const usage = {
|
|
243
|
+
explore: "tapp explore [target] [--platform ios|android|web] [--actions N] [--timeout SEC] [--email VALUE] [--password VALUE] [--baseline FILE] [--json FILE]\n iOS launch configuration: [--launch-arg VALUE ...] [--launch-env '{\"KEY\":\"VALUE\"}']\n Android: [--app-id ID] [--apk FILE] [--serial ID] [--keep-data]",
|
|
244
|
+
init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--dry-run]",
|
|
245
|
+
open: "tapp open [target] [--platform ios|android|web] [--out FILE] [--tap TEXT] [--wait-for TEXT]",
|
|
246
|
+
tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]",
|
|
247
|
+
shot: "tapp shot [--out FILE]",
|
|
248
|
+
apps: "tapp apps",
|
|
249
|
+
build: "tapp build [repo] [--scheme NAME] [--configuration NAME]",
|
|
250
|
+
flow: "tapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--email VALUE] [--password VALUE]",
|
|
251
|
+
task: "tapp task validate FILE [--platform PLATFORM] [--map FILE]\ntapp task compile FILE --platform PLATFORM [--inputs JSON] [--out FILE]\ntapp task run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID] [--inputs JSON]",
|
|
252
|
+
contract: "tapp contract validate FILE [--platform PLATFORM] [--map FILE]\ntapp contract compile FILE --platform PLATFORM [--out FILE]\ntapp contract run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID]",
|
|
253
|
+
scenario: "tapp scenario validate FILE [--project-dir DIR]\ntapp scenario run FILE --platform web --url URL [--project-dir DIR]",
|
|
254
|
+
map: "tapp map build MARKERS [--platform PLATFORM] [--out FILE] [--replace]\ntapp map inspect [FILE]\ntapp map diff BEFORE AFTER [--comparable]",
|
|
255
|
+
pr: "tapp pr plan [--base REF|--changed-files FILE] [--head REF] [--platform PLATFORM] [--out FILE]\ntapp pr gate PLAN [gate target/options]\ntapp pr adopt PLAN --item ID [--project-dir DIR]",
|
|
256
|
+
plan: "tapp plan show [FILE]\ntapp plan review [FILE] --approve NAME[,NAME] --reject NAME[,NAME] --defer NAME[,NAME]\ntapp plan generate|validate|promote [FILE] [options]",
|
|
257
|
+
baseline: "tapp baseline create [repo] [--platform PLATFORM] [--target NAME] [--from GATE.json] [--replace]",
|
|
258
|
+
ci: "tapp ci ...\ntapp ci install [repo] [--out FILE] [--manifest FILE] [--dry-run] [--replace]",
|
|
259
|
+
actor: "tapp actor set NAME --email-env ENV --password-env ENV [--project-dir DIR]\ntapp actor list [repo]",
|
|
260
|
+
app: "tapp app [repo] [--no-open] [--port PORT]",
|
|
261
|
+
report: "tapp report [captureId|latest]",
|
|
262
|
+
doctor: "tapp doctor",
|
|
263
|
+
install: "tapp install",
|
|
264
|
+
mcp: "tapp mcp",
|
|
265
|
+
};
|
|
266
|
+
return usage[verb] || `tapp ${verb}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Safe help: `--help`/`-h` on ANY verb prints the command reference and does NOTHING else — never
|
|
270
|
+
// builds, launches, writes, or opens (ADR-0005 manual-testing requirement). `ci` keeps its own
|
|
271
|
+
// richer `--help` (a safe usage print in ci-gate.sh); help/version don't need interception.
|
|
272
|
+
// A bare `tapp --help` puts the flag in `command`, not `rest`; normalize it before the
|
|
273
|
+
// per-verb interception so the root help path receives the same no-write guarantee.
|
|
274
|
+
if (["--help", "-h"].includes(command)) {
|
|
275
|
+
command = "help";
|
|
276
|
+
rest = [];
|
|
277
|
+
}
|
|
278
|
+
const safeHelpRequested = (rest.includes("--help") || rest.includes("-h"))
|
|
279
|
+
&& !["help", "version", "--version", "-v"].includes(command)
|
|
280
|
+
&& (command !== "ci" || rest[0] === "install");
|
|
281
|
+
if (safeHelpRequested) {
|
|
282
|
+
console.log(`Usage:\n ${safeCommandUsage(command).replaceAll("\n", "\n ")}\n\nℹ️ --help never builds, launches, writes, or opens. Full command reference:\n`);
|
|
283
|
+
command = "help";
|
|
284
|
+
rest = [];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Create TAPP_HOME only for commands that actually use it — never for help/version/--help.
|
|
288
|
+
if (!["help", "version", "--version", "-v"].includes(command)) {
|
|
289
|
+
fs.mkdirSync(tappHome, { recursive: true });
|
|
290
|
+
}
|
|
291
|
+
|
|
199
292
|
switch (command) {
|
|
200
293
|
case "mcp": {
|
|
201
294
|
// Agents spawn `tapp mcp`; the engine module is import-safe, so start explicitly.
|
|
@@ -229,7 +322,7 @@ switch (command) {
|
|
|
229
322
|
}
|
|
230
323
|
const platform = typeof flags.platform === "string" ? flags.platform.toLowerCase()
|
|
231
324
|
: typeof flags.url === "string" ? "web"
|
|
232
|
-
: typeof flags["app-id"] === "string" || typeof flags.apk === "string" ? "android" : "
|
|
325
|
+
: typeof flags["app-id"] === "string" || typeof flags.apk === "string" ? "android" : "";
|
|
233
326
|
if (explore && platform === "ios") requireMacFor("iOS init exploration");
|
|
234
327
|
const actions = flags.actions === undefined ? 40 : Number(flags.actions);
|
|
235
328
|
const timeout = flags.timeout === undefined ? 600 : Number(flags.timeout);
|
|
@@ -239,31 +332,52 @@ switch (command) {
|
|
|
239
332
|
}
|
|
240
333
|
const engine = explore ? await engineImport() : null;
|
|
241
334
|
const { initializeProductProject } = await import(path.join(packageRoot, "mcp-server", "src", "product-operations.js"));
|
|
335
|
+
const initOptions = {
|
|
336
|
+
projectDir,
|
|
337
|
+
mode: flags["dry-run"] === true ? "inspect" : explore ? "explore" : flags.refresh === true ? "refresh" : "write",
|
|
338
|
+
ownedUrl: typeof flags.url === "string" ? flags.url : "",
|
|
339
|
+
platform: typeof flags.platform === "string" ? flags.platform.toLowerCase() : explore ? platform : "",
|
|
340
|
+
target: typeof flags.target === "string" ? flags.target : "",
|
|
341
|
+
bundleId: typeof flags["bundle-id"] === "string" ? flags["bundle-id"] : "",
|
|
342
|
+
appId: typeof flags["app-id"] === "string" ? flags["app-id"] : "",
|
|
343
|
+
apkPath: typeof flags.apk === "string" ? path.resolve(flags.apk) : undefined,
|
|
344
|
+
serial: typeof flags.serial === "string" ? flags.serial : undefined,
|
|
345
|
+
maxActions: actions,
|
|
346
|
+
timeout,
|
|
347
|
+
testEmail: typeof flags.email === "string" ? flags.email : undefined,
|
|
348
|
+
testPassword: typeof flags.password === "string" ? flags.password : undefined,
|
|
349
|
+
runExploration: engine?.runInitExploration,
|
|
350
|
+
onProgress: (progress) => {
|
|
351
|
+
const activePlatform = progress.platform || platform;
|
|
352
|
+
process.stderr.write(`\r🔍 Import exploration… ${progress.action}/${progress.max || actions} actions · ${progress.states} ${activePlatform === "web" ? "pages reached" : activePlatform === "ios" ? "structural states observed" : "screens reached"} `);
|
|
353
|
+
},
|
|
354
|
+
onStatus: (status) => console.error(`⏳ ${status}`),
|
|
355
|
+
outDir,
|
|
356
|
+
maxContracts,
|
|
357
|
+
};
|
|
242
358
|
let result;
|
|
359
|
+
let failure = null;
|
|
243
360
|
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
|
-
});
|
|
361
|
+
result = await initializeProductProject(initOptions);
|
|
264
362
|
} catch (error) {
|
|
363
|
+
failure = error;
|
|
364
|
+
const choice = explore && error.details?.reason === "target-selection-required"
|
|
365
|
+
? await promptForInitTarget(error.details)
|
|
366
|
+
: null;
|
|
367
|
+
if (choice) {
|
|
368
|
+
if (choice.platform === "ios") requireMacFor("iOS init exploration");
|
|
369
|
+
console.error(`🎯 Exploring ${choice.platform}:${choice.name} (${choice.sourcePath})`);
|
|
370
|
+
try {
|
|
371
|
+
result = await initializeProductProject({ ...initOptions, platform: choice.platform, target: choice.selector });
|
|
372
|
+
failure = null;
|
|
373
|
+
} catch (retryError) {
|
|
374
|
+
failure = retryError;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (failure) {
|
|
265
379
|
if (explore) process.stderr.write("\n");
|
|
266
|
-
|
|
380
|
+
printEngineError({ error: `Could not initialize repository: ${failure.message || String(failure)}`, details: failure.details || {} });
|
|
267
381
|
process.exit(2);
|
|
268
382
|
}
|
|
269
383
|
if (explore) process.stderr.write("\n");
|
|
@@ -280,7 +394,7 @@ switch (command) {
|
|
|
280
394
|
console.log(`🧭 Tapp init — ${built.model.application.name}`);
|
|
281
395
|
console.log(` targets: ${built.model.targets.length ? built.model.targets.map((target) => `${target.platform}:${target.name}`).join(", ") : "none"}`);
|
|
282
396
|
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.
|
|
397
|
+
if (exploration) console.log(` Exploration: ${(exploration.findings || []).length} finding(s)${exploration.inconclusive ? " (inconclusive)" : ""} · ${exploration.uiMap.nodeCount} states · evidence: ${exploration.reportHtml || exploration.capture?.path || "capture recorded"}`);
|
|
284
398
|
if (exploration?.managedRuntime) console.log(` Managed web runtime: built/started ${exploration.target} for exploration and stopped it afterward · log: ${exploration.runtime.logPath}`);
|
|
285
399
|
console.log(` release plan: ${(written?.plan || built.plan).items.length} item(s) · ${pending.length} pending review · ${blocking.length} blocking requirement(s)`);
|
|
286
400
|
for (const requirement of built.model.requirements) console.log(` ${requirement.severity === "blocking" ? "❌" : "⚠️"} ${requirement.message} Next: ${requirement.remediation}`);
|
|
@@ -338,7 +452,10 @@ switch (command) {
|
|
|
338
452
|
testPassword: typeof flags.password === "string" ? flags.password : undefined,
|
|
339
453
|
startWebTarget: engine.startManagedWebTarget,
|
|
340
454
|
stopWebTarget: engine.stopManagedWebTarget,
|
|
341
|
-
|
|
455
|
+
// Contract execution is emitted in full on stdout below. Keep build/runtime/replay
|
|
456
|
+
// status live on stderr, but do not echo the execution transcript there as well — an
|
|
457
|
+
// interactive terminal merges the streams and would otherwise show every result twice.
|
|
458
|
+
onProgress: (entry) => { if (entry.text && entry.phase !== "execute") console.error(`⏳ ${entry.text}`); },
|
|
342
459
|
});
|
|
343
460
|
} catch (error) {
|
|
344
461
|
console.error(`❌ Could not validate contract drafts: ${error.message || String(error)}`);
|
|
@@ -387,9 +504,14 @@ switch (command) {
|
|
|
387
504
|
// ---- Zero-config verbs: the same engine the MCP tools use (exported by index.js),
|
|
388
505
|
// invokable by any agent or human with no server setup at all.
|
|
389
506
|
|
|
507
|
+
case "explore":
|
|
390
508
|
case "qa": {
|
|
509
|
+
// `explore` is the canonical verb (ADR-0005: exploration observes; the gate judges). `qa` is a
|
|
510
|
+
// hidden deprecated alias.
|
|
511
|
+
if (command === "qa") console.error("note: 'qa' is now 'explore' — 'qa' still works for now.\n");
|
|
391
512
|
const { flags, positionals } = parseVerbArgs(rest);
|
|
392
|
-
const
|
|
513
|
+
const launchOptions = iosLaunchOptions(flags, rest);
|
|
514
|
+
let target = positionals[0] || "";
|
|
393
515
|
let baselineFindings;
|
|
394
516
|
if (flags.baseline) {
|
|
395
517
|
try {
|
|
@@ -401,21 +523,61 @@ switch (command) {
|
|
|
401
523
|
}
|
|
402
524
|
}
|
|
403
525
|
const engine = await engineImport();
|
|
526
|
+
// Source-preparing bare explore (ADR-0005 §5): no explicit target + a repo application model →
|
|
527
|
+
// drive the model's default target end to end. Managed web is built/started/waited-for and
|
|
528
|
+
// always stopped; iOS is built + installed on the simulator; Android is built to an APK +
|
|
529
|
+
// installed. `--platform`/`--target` narrow which model target is chosen. With no model we fall
|
|
530
|
+
// through to the ordinary target resolution below, so nothing regresses.
|
|
531
|
+
if (!target && !flags["app-id"] && !flags.apk) {
|
|
532
|
+
const modelPath = existingProjectArtifactPath(process.cwd(), "application-model.json");
|
|
533
|
+
if (modelPath && fs.existsSync(modelPath)) {
|
|
534
|
+
const modelPlatform = typeof flags.platform === "string" ? flags.platform.toLowerCase() : "";
|
|
535
|
+
if (modelPlatform === "ios") requireMacFor("iOS testing");
|
|
536
|
+
const onProgress = (p) =>
|
|
537
|
+
process.stderr.write(`\r🔍 Exploring… ${p.action}/${p.max || flags.actions || 60} actions · ${p.states} states observed `);
|
|
538
|
+
const r = await engine.runExploreTarget({
|
|
539
|
+
projectDir: process.cwd(),
|
|
540
|
+
platform: modelPlatform,
|
|
541
|
+
target: typeof flags.target === "string" ? flags.target : "",
|
|
542
|
+
maxActions: flags.actions,
|
|
543
|
+
timeout: flags.timeout,
|
|
544
|
+
testEmail: flags.email,
|
|
545
|
+
testPassword: flags.password,
|
|
546
|
+
...launchOptions,
|
|
547
|
+
baselineFindings,
|
|
548
|
+
surface: "cli",
|
|
549
|
+
onProgress,
|
|
550
|
+
onStatus: (t) => console.error(`ℹ️ ${t}`),
|
|
551
|
+
});
|
|
552
|
+
process.stderr.write("\n");
|
|
553
|
+
if (r.error) { printEngineError(r); process.exit(1); }
|
|
554
|
+
console.log(r.text);
|
|
555
|
+
if (flags.json && typeof flags.json === "string") {
|
|
556
|
+
fs.writeFileSync(flags.json, JSON.stringify(r.structured, null, 2));
|
|
557
|
+
console.log(`\n📄 Full report JSON: ${flags.json} (pass as --baseline next run to diff regressions)`);
|
|
558
|
+
}
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
404
562
|
const platform = requestedPlatform(flags, target);
|
|
405
563
|
if (!["ios", "android", "web"].includes(platform)) {
|
|
406
564
|
console.error("❌ --platform must be ios|android|web");
|
|
407
565
|
process.exit(2);
|
|
408
566
|
}
|
|
409
567
|
if (platform === "ios") requireMacFor("iOS testing");
|
|
568
|
+
if (platform !== "ios" && Object.keys(launchOptions).length) {
|
|
569
|
+
console.error("❌ --launch-arg and --launch-env apply only to iOS targets");
|
|
570
|
+
process.exit(2);
|
|
571
|
+
}
|
|
410
572
|
if (platform === "web" && !/^https?:\/\//i.test(target)) {
|
|
411
573
|
console.error("❌ Web QA needs an http(s) URL");
|
|
412
574
|
process.exit(2);
|
|
413
575
|
}
|
|
414
576
|
const bundleId = platform === "ios" ? await resolveTargetOrExit(engine, target) : null;
|
|
415
577
|
const android = platform === "android" ? androidTarget(flags, target) : null;
|
|
416
|
-
const
|
|
578
|
+
const progressMetric = platform === "web" ? "pages reached" : platform === "ios" ? "structural states observed" : "screens reached";
|
|
417
579
|
const onProgress = (p) =>
|
|
418
|
-
process.stderr.write(`\r🔍 Exploring… ${p.action}/${p.max || flags.actions || 60} actions · ${p.states} ${
|
|
580
|
+
process.stderr.write(`\r🔍 Exploring… ${p.action}/${p.max || flags.actions || 60} actions · ${p.states} ${progressMetric} `);
|
|
419
581
|
const r = platform === "web"
|
|
420
582
|
? await engine.runQaWeb({
|
|
421
583
|
url: target,
|
|
@@ -443,7 +605,7 @@ switch (command) {
|
|
|
443
605
|
bundleId,
|
|
444
606
|
maxActions: flags.actions,
|
|
445
607
|
timeout: flags.timeout,
|
|
446
|
-
args: { testEmail: flags.email, testPassword: flags.password, baselineFindings },
|
|
608
|
+
args: { testEmail: flags.email, testPassword: flags.password, baselineFindings, ...launchOptions },
|
|
447
609
|
surface: "cli",
|
|
448
610
|
onProgress,
|
|
449
611
|
});
|
|
@@ -501,7 +663,6 @@ switch (command) {
|
|
|
501
663
|
if (target.apkPath) await driver.install(target.apkPath);
|
|
502
664
|
const snap = await driver.launch({ clearData: flags["clear-data"] === true });
|
|
503
665
|
const data = await driver.screenshot();
|
|
504
|
-
await driver.forceStop();
|
|
505
666
|
console.log(`🚀 Launched \`${target.appId}\` (Android)\n`);
|
|
506
667
|
console.log(engine.formatScreen(snap.screenTitle, snap.elements));
|
|
507
668
|
const out = typeof flags.out === "string" ? flags.out : path.join(tappHome, "shots", `${target.appId}-${Date.now()}.png`);
|
|
@@ -561,12 +722,17 @@ switch (command) {
|
|
|
561
722
|
break;
|
|
562
723
|
}
|
|
563
724
|
if (platform === "android") {
|
|
564
|
-
const
|
|
725
|
+
const input = positionals[0] || "";
|
|
726
|
+
const hasTarget = !!(input || flags["app-id"] || flags.apk);
|
|
727
|
+
const target = hasTarget
|
|
728
|
+
? androidTarget(flags, input)
|
|
729
|
+
: { serial: typeof flags.serial === "string" ? flags.serial : undefined };
|
|
565
730
|
const { AndroidDriver } = await import(path.join(packageRoot, "mcp-server", "src", "android-driver.js"));
|
|
566
731
|
const driver = new AndroidDriver(target);
|
|
567
732
|
await driver.ensureDevice();
|
|
568
|
-
|
|
569
|
-
|
|
733
|
+
if (target.apkPath) await driver.install(target.apkPath);
|
|
734
|
+
const snap = target.appId ? await driver.launch() : await driver.snapshot();
|
|
735
|
+
if (flags.json) console.log(JSON.stringify({ platform: "android", appId: target.appId || null, activity: snap.activity, screenTitle: snap.screenTitle, elements: snap.elements }, null, 2));
|
|
570
736
|
else console.log(engine.formatScreen(snap.screenTitle, snap.elements));
|
|
571
737
|
break;
|
|
572
738
|
}
|
|
@@ -625,7 +791,7 @@ switch (command) {
|
|
|
625
791
|
}
|
|
626
792
|
console.log("📱 Installed on the booted simulator:\n");
|
|
627
793
|
for (const a of la.apps) console.log(` ${a.bundleId} (${a.name})`);
|
|
628
|
-
console.log(`\nTest one: tapp
|
|
794
|
+
console.log(`\nTest one: tapp explore <bundleId>`);
|
|
629
795
|
break;
|
|
630
796
|
}
|
|
631
797
|
|
|
@@ -655,7 +821,7 @@ switch (command) {
|
|
|
655
821
|
process.exit(1);
|
|
656
822
|
}
|
|
657
823
|
console.log(`🔨 Built ${path.basename(built.appPath)} (scheme ${built.scheme}) — installed as ${inst.bundleId}`);
|
|
658
|
-
console.log(`\nNext: tapp
|
|
824
|
+
console.log(`\nNext: tapp explore ${inst.bundleId}`);
|
|
659
825
|
break;
|
|
660
826
|
}
|
|
661
827
|
|
|
@@ -1057,15 +1223,21 @@ switch (command) {
|
|
|
1057
1223
|
}
|
|
1058
1224
|
|
|
1059
1225
|
try {
|
|
1060
|
-
await import("playwright");
|
|
1061
|
-
|
|
1226
|
+
const { chromium } = await import("playwright");
|
|
1227
|
+
let executable = "";
|
|
1228
|
+
try { executable = chromium.executablePath(); } catch { /* report the missing browser below */ }
|
|
1229
|
+
if (executable && fs.existsSync(executable)) {
|
|
1230
|
+
ok("Web", `Playwright + Chromium (${executable})`);
|
|
1231
|
+
} else {
|
|
1232
|
+
console.log(" ⬜ Web — Playwright installed; Chromium browser missing (run: npx playwright install chromium)");
|
|
1233
|
+
}
|
|
1062
1234
|
} catch {
|
|
1063
1235
|
console.log(" ⬜ Web — install Playwright in the app workspace: npm install -D playwright && npx playwright install chromium");
|
|
1064
1236
|
}
|
|
1065
1237
|
|
|
1066
1238
|
console.log(`\n Home: ${tappHome}`);
|
|
1067
1239
|
console.log(healthy
|
|
1068
|
-
? "\nReady. Start with:\n npx -y @aarwitz/tapp open [target]\n npx -y @aarwitz/tapp
|
|
1240
|
+
? "\nReady. Start with:\n npx -y @aarwitz/tapp open [target]\n npx -y @aarwitz/tapp explore [target]"
|
|
1069
1241
|
: "\nFix the ❌ items above, then re-run: tapp doctor");
|
|
1070
1242
|
process.exit(healthy ? 0 : 1);
|
|
1071
1243
|
}
|
|
@@ -1249,7 +1421,7 @@ switch (command) {
|
|
|
1249
1421
|
replace: flags.replace === true,
|
|
1250
1422
|
});
|
|
1251
1423
|
console.log(`✅ Conclusive baseline established — ${selectedTarget.platform}:${selectedTarget.name}`);
|
|
1252
|
-
console.log(` ${written.validation.screensExplored} states · ${written.validation.actionsPerformed} actions · ${written.validation.suite.contracts} contracts ·
|
|
1424
|
+
console.log(` ${written.validation.screensExplored} states · ${written.validation.actionsPerformed} actions · ${written.validation.suite.contracts} contracts · outcome ${written.validation.outcome}`);
|
|
1253
1425
|
console.log(` baseline: ${written.path}\n source gate report: ${reportPath}`);
|
|
1254
1426
|
} catch (error) { console.error(`❌ Baseline not written: ${error.message}`); process.exit(2); }
|
|
1255
1427
|
break;
|
|
@@ -1348,9 +1520,15 @@ switch (command) {
|
|
|
1348
1520
|
const runs = roots
|
|
1349
1521
|
.flatMap((root) => fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => path.join(root, e.name)))
|
|
1350
1522
|
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
1351
|
-
const
|
|
1523
|
+
const hasMarkers = (dir) => fs.existsSync(path.join(dir, "ocqa-markers.txt"));
|
|
1524
|
+
// `latest` (default) resolves to the newest *exploration* capture — the captures directory is
|
|
1525
|
+
// also full of flow-*/scenario-* evidence dirs with no ocqa-markers.txt, and picking the newest
|
|
1526
|
+
// of those made `tapp report` fail even though valid exploration captures existed. An explicitly
|
|
1527
|
+
// named capture is honored as-is so a non-exploration capture still gets a clear "no markers".
|
|
1528
|
+
const explicit = rest[0] && rest[0] !== "latest";
|
|
1529
|
+
const wanted = explicit ? runs.find((r) => path.basename(r) === rest[0]) : runs.find(hasMarkers);
|
|
1352
1530
|
if (!wanted) {
|
|
1353
|
-
bad("No captures found",
|
|
1531
|
+
bad("No captures found", explicit ? `no capture named "${rest[0]}"` : "run an exploration first (no capture with exploration markers was found)");
|
|
1354
1532
|
process.exit(1);
|
|
1355
1533
|
}
|
|
1356
1534
|
const { writeHtmlReport } = await import(path.join(packageRoot, "mcp-server", "src", "html-report.js"));
|
|
@@ -1360,7 +1538,9 @@ switch (command) {
|
|
|
1360
1538
|
process.exit(1);
|
|
1361
1539
|
}
|
|
1362
1540
|
ok("Evidence report", out);
|
|
1363
|
-
|
|
1541
|
+
// Only launch a browser from an interactive terminal — agents, scripts, and tests that invoke
|
|
1542
|
+
// `tapp report` non-interactively get the path without a surprise GUI window.
|
|
1543
|
+
if (process.stdout.isTTY) spawnSync("open", [out], { stdio: "ignore" });
|
|
1364
1544
|
break;
|
|
1365
1545
|
}
|
|
1366
1546
|
|
|
@@ -1372,15 +1552,32 @@ switch (command) {
|
|
|
1372
1552
|
}
|
|
1373
1553
|
|
|
1374
1554
|
default: {
|
|
1375
|
-
console.log(`tapp v${pkg.version} — ship with proof. Autonomous
|
|
1555
|
+
console.log(`tapp v${pkg.version} — ship with proof. Autonomous exploration and deterministic release gates for iOS, Android, and web.
|
|
1376
1556
|
|
|
1377
|
-
|
|
1557
|
+
Core — explore, prove, gate (agents and humans can just run these — no server, no setup):
|
|
1558
|
+
tapp explore [target] Autonomous exploration → findings + evidence (an observation, NOT a
|
|
1559
|
+
release decision — run 'tapp ci' to gate a merge)
|
|
1560
|
+
(--platform ios|android|web · --app-id ID · --apk FILE · --actions N)
|
|
1561
|
+
tapp contract run FILE Replay a business-level release contract — the guarantees that must hold
|
|
1562
|
+
tapp ci ... Merge-blocking release gate — explore + suites + baseline → pass/fail/inconclusive
|
|
1563
|
+
(see: tapp ci --help)
|
|
1564
|
+
|
|
1565
|
+
Primitives — an agent's eyes and hands (no setup):
|
|
1378
1566
|
tapp open [target] Launch the app → screen summary + screenshot saved to a file
|
|
1379
1567
|
(web: --tap TEXT · --wait-for TEXT · --out FILE)
|
|
1380
|
-
tapp qa [target] Autonomous QA → verdict + findings + evidence
|
|
1381
|
-
(--platform ios|android|web · --app-id ID · --apk FILE · --actions N)
|
|
1382
1568
|
tapp tree [target] Accessibility tree of the current screen (--json for every element)
|
|
1383
1569
|
(web: --tap TEXT · --wait-for TEXT)
|
|
1570
|
+
|
|
1571
|
+
Repository & release:
|
|
1572
|
+
tapp init [repo] Detect targets and write the application model + reviewable release plan
|
|
1573
|
+
(--explore grounds the UI Map · --url URL · --platform · --dry-run · --refresh)
|
|
1574
|
+
tapp baseline create [repo] Run/import a conclusive full gate and save a target-scoped baseline
|
|
1575
|
+
tapp report [captureId] Open the HTML evidence page for a capture (default: latest)
|
|
1576
|
+
tapp ci install [repo] Generate a reviewable target-aware GitHub workflow + CI manifest
|
|
1577
|
+
tapp actor set NAME Configure an actor using environment-variable names only (never values)
|
|
1578
|
+
tapp actor list [repo] Inspect named actors, sessions, provisioning, and secret env bindings
|
|
1579
|
+
|
|
1580
|
+
Advanced — deterministic suites, lifecycle & compilers:
|
|
1384
1581
|
tapp flow run FILE Replay a committed deterministic Flow (no AI/API key)
|
|
1385
1582
|
tapp flow validate FILE Validate a Flow without launching a target
|
|
1386
1583
|
tapp task validate FILE Validate a reusable deterministic Task (+ optional UI Map grounding)
|
|
@@ -1388,38 +1585,32 @@ Zero-config verbs (agents and humans can just run these — no server, no setup)
|
|
|
1388
1585
|
tapp task run FILE Replay a Task directly on iOS, Android, or web
|
|
1389
1586
|
tapp contract validate FILE Validate a business-level TypeScript release contract
|
|
1390
1587
|
tapp contract compile FILE Compile a contract to the shared deterministic executor
|
|
1391
|
-
tapp contract run FILE Replay a release contract without AI or a coding agent
|
|
1392
|
-
tapp pr plan --base REF Select critical + diff-relevant contracts and report uncovered changes
|
|
1393
|
-
tapp pr adopt PLAN --item ID Explicitly add an observed PR coverage proposal to the release plan
|
|
1394
1588
|
tapp scenario run FILE Replay an isolated multi-actor system Scenario (web)
|
|
1395
1589
|
tapp scenario validate FILE Validate actors, lifecycle, and deterministic steps
|
|
1590
|
+
tapp pr plan --base REF Select critical + diff-relevant contracts and report uncovered changes
|
|
1591
|
+
tapp pr adopt PLAN --item ID Explicitly add an observed PR coverage proposal to the release plan
|
|
1592
|
+
tapp plan show [FILE] Inspect the proposed/accepted release-contract plan
|
|
1593
|
+
tapp plan review [FILE] Explicitly approve, reject, or defer proposed plan items
|
|
1594
|
+
tapp plan generate [FILE] Generate compile-checked, untrusted contract drafts from approved Tasks
|
|
1595
|
+
tapp plan validate [FILE] Replay drafts on a real target; trust only after all platforms pass
|
|
1596
|
+
tapp plan promote [FILE] Move fully validated drafts into reviewed Tasks/contracts + map coverage
|
|
1396
1597
|
tapp map build MARKERS Build/merge the persistent platform-neutral UI Map
|
|
1397
1598
|
tapp map inspect [FILE] Inspect states, controls, platforms, and map validity
|
|
1398
1599
|
tapp map diff A B Diff observed UI structure without false reachability claims
|
|
1399
|
-
|
|
1600
|
+
|
|
1601
|
+
Simulator & workspace:
|
|
1400
1602
|
tapp shot Screenshot the booted simulator → file path (--out file.jpg)
|
|
1401
1603
|
tapp build [dir] Build the iOS app in a repo for the simulator + install it (--scheme S)
|
|
1402
1604
|
tapp apps List apps installed on the booted simulator (with bundle ids)
|
|
1403
|
-
tapp report [captureId] Open the HTML evidence page for a capture (default: latest)
|
|
1404
1605
|
tapp app [repo] Optional local browser workspace for repository onboarding and review
|
|
1405
1606
|
(loopback-only; --no-open · --port PORT)
|
|
1406
|
-
tapp init [repo] Detect targets and write the application model + reviewable release plan
|
|
1407
|
-
(--explore builds/starts or connects, grounds the UI Map, then tears down)
|
|
1408
|
-
(--url URL · --platform PLATFORM · --dry-run · --refresh)
|
|
1409
|
-
tapp actor list [repo] Inspect named actors, sessions, provisioning, and secret env bindings
|
|
1410
|
-
tapp actor set NAME Configure an actor using environment-variable names only (never values)
|
|
1411
|
-
tapp plan show [FILE] Inspect the proposed/accepted release-contract plan
|
|
1412
|
-
tapp plan review [FILE] Explicitly approve, reject, or defer proposed plan items
|
|
1413
|
-
tapp plan generate [FILE] Generate compile-checked, untrusted contract drafts from approved Tasks
|
|
1414
|
-
tapp plan validate [FILE] Replay drafts on a real target; trust only after all platforms pass
|
|
1415
|
-
tapp plan promote [FILE] Move fully validated drafts into reviewed Tasks/contracts + map coverage
|
|
1416
|
-
tapp ci ... Merge-blocking release gate — explore + flows + baseline diff (see: tapp ci --help)
|
|
1417
|
-
tapp ci install [repo] Generate a reviewable target-aware GitHub workflow + CI manifest
|
|
1418
1607
|
|
|
1419
|
-
[target] is whatever you have — nothing (
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1608
|
+
[target] is whatever you have — nothing (in an initialized repo, bare 'tapp explore' drives the
|
|
1609
|
+
application model's default target from source: managed web is built/started/stopped, iOS is
|
|
1610
|
+
built + installed, Android is built to an APK + installed; otherwise it finds + builds the Xcode
|
|
1611
|
+
project in the current dir, or falls back to the app on the simulator), a repo dir, a
|
|
1612
|
+
path/to/App.app, a bundle id, an Android app id/APK (--platform android --app-id ...), or an
|
|
1613
|
+
http(s) URL. For iOS you never need to know a bundle id up front.
|
|
1423
1614
|
|
|
1424
1615
|
Setup:
|
|
1425
1616
|
tapp install Prebuild the exploration harness (~2 min; otherwise builds on first use)
|
|
@@ -1432,7 +1623,7 @@ MCP hookup (optional — for inline screenshots and the tap/type/inspect session
|
|
|
1432
1623
|
{ "servers": { "tapp": { "type": "stdio", "command": "npx", "args": ["-y", "@aarwitz/tapp", "mcp"] } } }
|
|
1433
1624
|
|
|
1434
1625
|
Then ask your agent things like:
|
|
1435
|
-
"
|
|
1626
|
+
"Explore com.mycompany.app and show me what breaks"
|
|
1436
1627
|
"Open the settings screen and show me the screenshot"
|
|
1437
1628
|
"Drive the login flow and record it as a replayable test"
|
|
1438
1629
|
|
package/browser/app.js
CHANGED
|
@@ -243,12 +243,14 @@ function renderDecision(run) {
|
|
|
243
243
|
return;
|
|
244
244
|
}
|
|
245
245
|
const report = latest.report;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
246
|
+
// The decision reflects the GATE outcome (pass/fail/inconclusive), not a ship verdict — exploration
|
|
247
|
+
// only observes (ADR-0005). Without a gate, it's an observation, not a merge decision.
|
|
248
|
+
const outcome = report.gate?.outcome || (report.gate?.failed === true ? "fail" : report.inconclusive ? "inconclusive" : report.gate ? "pass" : null);
|
|
249
|
+
const cssClass = { pass: "ready", fail: "blocked", inconclusive: "caution" }[outcome] || "caution";
|
|
250
|
+
card.className = `decision-card ${cssClass}`;
|
|
251
|
+
$("#decision-title").textContent = outcome === "fail" ? "Do not merge" : outcome === "inconclusive" ? "Inconclusive" : outcome === "pass" ? "Ready to merge" : "Observed — not a release decision";
|
|
250
252
|
$("#decision-detail").textContent = report.gate?.reasons?.join(" · ") || report.headline || "Review the evidence below.";
|
|
251
|
-
$("#overview-evidence").innerHTML = `<div class="latest-run-line"><span class="verdict-dot ${esc(
|
|
253
|
+
$("#overview-evidence").innerHTML = `<div class="latest-run-line"><span class="verdict-dot ${esc(cssClass)}"></span><div><strong>${esc(report.headline || pretty(cssClass))}</strong><small>${esc(pretty(report.platform || "unknown"))} · ${compactDate(latest.createdAt)} · ${(report.contracts || []).filter((item) => item.passed).length}/${(report.contracts || []).length} contracts passed</small></div></div><p>${esc((report.gate?.reasons || ["No blocking release-gate reason reported."])[0])}</p>${reportLink(report)}`;
|
|
252
254
|
}
|
|
253
255
|
|
|
254
256
|
function renderEvidence(runs) {
|
|
@@ -284,7 +286,11 @@ function renderRuns(runs) {
|
|
|
284
286
|
$("#runs-list").innerHTML = runs.length ? runs.map((run) => {
|
|
285
287
|
const report = run.report;
|
|
286
288
|
const failed = report?.gate?.failed === true;
|
|
287
|
-
|
|
289
|
+
// Status reflects the gate outcome (pass/fail/inconclusive), mapped to the existing CSS classes.
|
|
290
|
+
const status = !report ? run.status
|
|
291
|
+
: report.gate?.outcome === "fail" || failed ? "blocked"
|
|
292
|
+
: report.gate?.outcome === "inconclusive" || report.inconclusive ? "inconclusive"
|
|
293
|
+
: report.gate?.outcome === "pass" ? "ready" : "completed";
|
|
288
294
|
return `<button class="run-row ${run.id === state.selectedRunId ? "selected" : ""}" data-run-id="${esc(run.id)}"><span class="run-status ${esc(status)}">${failed ? "×" : report ? "✓" : "…"}</span><span><strong>${esc(report?.headline || `Release run ${run.id.slice(-8)}`)}</strong><small>${compactDate(run.createdAt)} · ${esc(pretty(report?.platform || "unknown"))}</small></span><em>${esc(pretty(status))}</em></button>`;
|
|
289
295
|
}).join("") : '<div class="empty">No release runs yet.</div>';
|
|
290
296
|
const selected = runs.find((run) => run.id === state.selectedRunId) || runs[0];
|