@aarwitz/tapp 0.17.0-rc.8 → 0.17.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/.claude-plugin/marketplace.json +34 -0
- package/.claude-plugin/plugin.json +33 -0
- package/AGENTS.md +34 -14
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +11 -0
- package/README.md +126 -77
- package/bin/tapp.js +187 -63
- package/docs/BROWSER-PRODUCT.md +1 -1
- package/docs/application-model.md +12 -3
- package/mcp-server/src/android-explorer.js +3 -1
- package/mcp-server/src/android-flow.js +18 -1
- package/mcp-server/src/application-model.js +83 -13
- package/mcp-server/src/ci-report.js +2 -2
- package/mcp-server/src/ci-setup.js +4 -4
- package/mcp-server/src/environment-preflight.js +43 -0
- package/mcp-server/src/html-report.js +3 -2
- package/mcp-server/src/index.js +326 -110
- package/mcp-server/src/pr-selection.js +2 -2
- package/mcp-server/src/product-operations.js +111 -6
- package/mcp-server/src/report.js +17 -4
- package/mcp-server/src/web-explorer.js +123 -10
- package/mcp-server/src/web-flow.js +17 -1
- package/package.json +6 -4
- package/scripts/ci-gate.sh +42 -0
- package/scripts/flow_lib.py +1 -1
- package/scripts/quick-capture.sh +2 -2
- package/skills/tapp/SKILL.md +75 -0
- package/skills/tapp/agents/openai.yaml +4 -0
- package/skills/tapp/references/commands.md +102 -0
package/bin/tapp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// Tapp CLI — agent-driven app testing on real app surfaces.
|
|
3
3
|
//
|
|
4
4
|
// Zero-config verbs (the same engine the MCP tools use, exported by mcp-server/src/index.js):
|
|
5
5
|
// tapp explore <bundleId|appId|url> Autonomous exploration → findings + evidence (observation)
|
|
@@ -28,8 +28,8 @@ 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
|
-
//
|
|
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
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
|
|
@@ -53,6 +53,19 @@ function bad(label, detail = "") {
|
|
|
53
53
|
console.log(` ❌ ${label}${detail ? ` — ${detail}` : ""}`);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
let lastProgressLine = "";
|
|
57
|
+
function writeProgress(line) {
|
|
58
|
+
const text = String(line || "").trimEnd();
|
|
59
|
+
if (process.stderr.isTTY) process.stderr.write(`\r${text} `);
|
|
60
|
+
else if (text && text !== lastProgressLine) console.error(text);
|
|
61
|
+
lastProgressLine = text;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function finishProgress() {
|
|
65
|
+
if (process.stderr.isTTY && lastProgressLine) process.stderr.write("\n");
|
|
66
|
+
lastProgressLine = "";
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
function bootedSims() {
|
|
57
70
|
const r = run("xcrun", ["simctl", "list", "devices", "booted", "-j"]);
|
|
58
71
|
if (r.code !== 0) return [];
|
|
@@ -155,7 +168,7 @@ const engineImport = () => import(path.join(packageRoot, "mcp-server", "src", "i
|
|
|
155
168
|
|
|
156
169
|
function requireMacFor(what) {
|
|
157
170
|
if (process.platform === "darwin") return;
|
|
158
|
-
console.error(`❌ ${what} requires macOS (Xcode + iOS simulator). The web beta runs anywhere: tapp explore https://localhost:3000`);
|
|
171
|
+
console.error(`❌ ${what} requires macOS (Xcode + iOS simulator). The web beta runs anywhere: npx -y @aarwitz/tapp@latest explore https://localhost:3000`);
|
|
159
172
|
process.exit(1);
|
|
160
173
|
}
|
|
161
174
|
|
|
@@ -206,6 +219,26 @@ function printEngineError(r) {
|
|
|
206
219
|
}
|
|
207
220
|
}
|
|
208
221
|
|
|
222
|
+
async function promptForInitTarget(details) {
|
|
223
|
+
const choices = Array.isArray(details?.choices) ? details.choices : [];
|
|
224
|
+
if (!choices.length || !process.stdin.isTTY || !process.stderr.isTTY || process.env.CI) return null;
|
|
225
|
+
const { createInterface } = await import("node:readline/promises");
|
|
226
|
+
const terminal = createInterface({ input: process.stdin, output: process.stderr });
|
|
227
|
+
console.error("\nTapp found multiple application targets. Which one should it explore?");
|
|
228
|
+
choices.forEach((choice, index) => console.error(` ${index + 1}) ${choice.platform} · ${choice.name} (${choice.sourcePath})`));
|
|
229
|
+
try {
|
|
230
|
+
while (true) {
|
|
231
|
+
const answer = String(await terminal.question(`Select 1-${choices.length} (or q to cancel): `)).trim();
|
|
232
|
+
if (/^(?:q|quit|cancel)$/i.test(answer)) return null;
|
|
233
|
+
const selected = Number(answer);
|
|
234
|
+
if (Number.isInteger(selected) && selected >= 1 && selected <= choices.length) return choices[selected - 1];
|
|
235
|
+
console.error(`Enter a number from 1 to ${choices.length}, or q to cancel.`);
|
|
236
|
+
}
|
|
237
|
+
} finally {
|
|
238
|
+
terminal.close();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
209
242
|
// Turn whatever the user gave us (nothing / repo dir / .app / bundle id) into an installed
|
|
210
243
|
// bundle id, narrating build/install progress on stderr.
|
|
211
244
|
async function resolveTargetOrExit(engine, input) {
|
|
@@ -220,14 +253,14 @@ async function resolveTargetOrExit(engine, input) {
|
|
|
220
253
|
|
|
221
254
|
function safeCommandUsage(verb) {
|
|
222
255
|
const usage = {
|
|
223
|
-
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]",
|
|
224
|
-
init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--dry-run]",
|
|
256
|
+
explore: "tapp explore [target] [--platform ios|android|web] [--actions N] [--timeout SEC] [--email VALUE] [--password VALUE] [--baseline FILE] [--json FILE]\n Web: [--watch] opens Tapp's controlled browser and shows its actions\n iOS launch configuration: [--launch-arg VALUE ...] [--launch-env '{\"KEY\":\"VALUE\"}']\n Android: [--app-id ID] [--apk FILE] [--serial ID] [--keep-data]",
|
|
257
|
+
init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--watch] [--dry-run]",
|
|
225
258
|
open: "tapp open [target] [--platform ios|android|web] [--out FILE] [--tap TEXT] [--wait-for TEXT]",
|
|
226
259
|
tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]",
|
|
227
260
|
shot: "tapp shot [--out FILE]",
|
|
228
261
|
apps: "tapp apps",
|
|
229
262
|
build: "tapp build [repo] [--scheme NAME] [--configuration NAME]",
|
|
230
|
-
flow: "tapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--email VALUE] [--password VALUE]",
|
|
263
|
+
flow: "tapp flow example\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--email VALUE] [--password VALUE]",
|
|
231
264
|
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]",
|
|
232
265
|
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]",
|
|
233
266
|
scenario: "tapp scenario validate FILE [--project-dir DIR]\ntapp scenario run FILE --platform web --url URL [--project-dir DIR]",
|
|
@@ -249,13 +282,18 @@ function safeCommandUsage(verb) {
|
|
|
249
282
|
// Safe help: `--help`/`-h` on ANY verb prints the command reference and does NOTHING else — never
|
|
250
283
|
// builds, launches, writes, or opens (ADR-0005 manual-testing requirement). `ci` keeps its own
|
|
251
284
|
// richer `--help` (a safe usage print in ci-gate.sh); help/version don't need interception.
|
|
285
|
+
// A bare `tapp --help` puts the flag in `command`, not `rest`; normalize it before the
|
|
286
|
+
// per-verb interception so the root help path receives the same no-write guarantee.
|
|
287
|
+
if (["--help", "-h"].includes(command)) {
|
|
288
|
+
command = "help";
|
|
289
|
+
rest = [];
|
|
290
|
+
}
|
|
252
291
|
const safeHelpRequested = (rest.includes("--help") || rest.includes("-h"))
|
|
253
292
|
&& !["help", "version", "--version", "-v"].includes(command)
|
|
254
293
|
&& (command !== "ci" || rest[0] === "install");
|
|
255
294
|
if (safeHelpRequested) {
|
|
256
|
-
console.log(`Usage:\n ${safeCommandUsage(command).replaceAll("\n", "\n ")}\n\nℹ️ --help never builds, launches, writes, or opens.
|
|
257
|
-
|
|
258
|
-
rest = [];
|
|
295
|
+
console.log(`Usage:\n ${safeCommandUsage(command).replaceAll("\n", "\n ")}\n\nℹ️ --help never builds, launches, writes, or opens. Run \`tapp --help\` for the full command reference.`);
|
|
296
|
+
process.exit(0);
|
|
259
297
|
}
|
|
260
298
|
|
|
261
299
|
// Create TAPP_HOME only for commands that actually use it — never for help/version/--help.
|
|
@@ -296,7 +334,7 @@ switch (command) {
|
|
|
296
334
|
}
|
|
297
335
|
const platform = typeof flags.platform === "string" ? flags.platform.toLowerCase()
|
|
298
336
|
: typeof flags.url === "string" ? "web"
|
|
299
|
-
: typeof flags["app-id"] === "string" || typeof flags.apk === "string" ? "android" : "
|
|
337
|
+
: typeof flags["app-id"] === "string" || typeof flags.apk === "string" ? "android" : "";
|
|
300
338
|
if (explore && platform === "ios") requireMacFor("iOS init exploration");
|
|
301
339
|
const actions = flags.actions === undefined ? 40 : Number(flags.actions);
|
|
302
340
|
const timeout = flags.timeout === undefined ? 600 : Number(flags.timeout);
|
|
@@ -306,51 +344,85 @@ switch (command) {
|
|
|
306
344
|
}
|
|
307
345
|
const engine = explore ? await engineImport() : null;
|
|
308
346
|
const { initializeProductProject } = await import(path.join(packageRoot, "mcp-server", "src", "product-operations.js"));
|
|
347
|
+
const initOptions = {
|
|
348
|
+
projectDir,
|
|
349
|
+
mode: flags["dry-run"] === true ? "inspect" : explore ? "explore" : flags.refresh === true ? "refresh" : "write",
|
|
350
|
+
ownedUrl: typeof flags.url === "string" ? flags.url : "",
|
|
351
|
+
platform: typeof flags.platform === "string" ? flags.platform.toLowerCase() : explore ? platform : "",
|
|
352
|
+
target: typeof flags.target === "string" ? flags.target : "",
|
|
353
|
+
bundleId: typeof flags["bundle-id"] === "string" ? flags["bundle-id"] : "",
|
|
354
|
+
appId: typeof flags["app-id"] === "string" ? flags["app-id"] : "",
|
|
355
|
+
apkPath: typeof flags.apk === "string" ? path.resolve(flags.apk) : undefined,
|
|
356
|
+
serial: typeof flags.serial === "string" ? flags.serial : undefined,
|
|
357
|
+
maxActions: actions,
|
|
358
|
+
timeout,
|
|
359
|
+
testEmail: typeof flags.email === "string" ? flags.email : undefined,
|
|
360
|
+
testPassword: typeof flags.password === "string" ? flags.password : undefined,
|
|
361
|
+
watch: flags.watch === true,
|
|
362
|
+
runExploration: engine?.runInitExploration,
|
|
363
|
+
onProgress: (progress) => {
|
|
364
|
+
const activePlatform = progress.platform || platform;
|
|
365
|
+
writeProgress(`🔍 Import exploration… ${progress.action}/${progress.max || actions} actions · ${progress.states} ${activePlatform === "web" ? "pages reached" : activePlatform === "ios" ? "structural states observed" : "screens reached"}`);
|
|
366
|
+
},
|
|
367
|
+
onStatus: (status) => console.error(`⏳ ${status}`),
|
|
368
|
+
outDir,
|
|
369
|
+
maxContracts,
|
|
370
|
+
};
|
|
309
371
|
let result;
|
|
372
|
+
let failure = null;
|
|
310
373
|
try {
|
|
311
|
-
result = await initializeProductProject(
|
|
312
|
-
projectDir,
|
|
313
|
-
mode: flags["dry-run"] === true ? "inspect" : explore ? "explore" : flags.refresh === true ? "refresh" : "write",
|
|
314
|
-
ownedUrl: typeof flags.url === "string" ? flags.url : "",
|
|
315
|
-
platform: typeof flags.platform === "string" ? flags.platform.toLowerCase() : explore ? platform : "",
|
|
316
|
-
target: typeof flags.target === "string" ? flags.target : projectDir,
|
|
317
|
-
bundleId: typeof flags["bundle-id"] === "string" ? flags["bundle-id"] : "",
|
|
318
|
-
appId: typeof flags["app-id"] === "string" ? flags["app-id"] : "",
|
|
319
|
-
apkPath: typeof flags.apk === "string" ? path.resolve(flags.apk) : undefined,
|
|
320
|
-
serial: typeof flags.serial === "string" ? flags.serial : undefined,
|
|
321
|
-
maxActions: actions,
|
|
322
|
-
timeout,
|
|
323
|
-
testEmail: typeof flags.email === "string" ? flags.email : undefined,
|
|
324
|
-
testPassword: typeof flags.password === "string" ? flags.password : undefined,
|
|
325
|
-
runExploration: engine?.runInitExploration,
|
|
326
|
-
onProgress: (progress) => process.stderr.write(`\r🔍 Import exploration… ${progress.action}/${progress.max || actions} actions · ${progress.states} ${platform === "web" ? "pages reached" : platform === "ios" ? "structural states observed" : "screens reached"} `),
|
|
327
|
-
onStatus: (status) => console.error(`⏳ ${status}`),
|
|
328
|
-
outDir,
|
|
329
|
-
maxContracts,
|
|
330
|
-
});
|
|
374
|
+
result = await initializeProductProject(initOptions);
|
|
331
375
|
} catch (error) {
|
|
332
|
-
|
|
333
|
-
|
|
376
|
+
failure = error;
|
|
377
|
+
const choice = explore && error.details?.reason === "target-selection-required"
|
|
378
|
+
? await promptForInitTarget(error.details)
|
|
379
|
+
: null;
|
|
380
|
+
if (choice) {
|
|
381
|
+
if (choice.platform === "ios") requireMacFor("iOS init exploration");
|
|
382
|
+
console.error(`🎯 Exploring ${choice.platform}:${choice.name} (${choice.sourcePath})`);
|
|
383
|
+
try {
|
|
384
|
+
result = await initializeProductProject({ ...initOptions, platform: choice.platform, target: choice.selector });
|
|
385
|
+
failure = null;
|
|
386
|
+
} catch (retryError) {
|
|
387
|
+
failure = retryError;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (failure) {
|
|
392
|
+
if (explore) finishProgress();
|
|
393
|
+
printEngineError({ error: `Could not initialize repository: ${failure.message || String(failure)}`, details: failure.details || {} });
|
|
334
394
|
process.exit(2);
|
|
335
395
|
}
|
|
336
|
-
if (explore)
|
|
396
|
+
if (explore) finishProgress();
|
|
337
397
|
const built = { model: result.model, plan: result.plan };
|
|
338
398
|
const written = result.written;
|
|
339
399
|
const exploration = result.exploration;
|
|
340
400
|
if (typeof flags["json-out"] === "string") {
|
|
341
401
|
const out = path.resolve(flags["json-out"]);
|
|
342
402
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
343
|
-
fs.writeFileSync(out, JSON.stringify({
|
|
344
|
-
|
|
345
|
-
|
|
403
|
+
fs.writeFileSync(out, JSON.stringify({
|
|
404
|
+
model: built.model,
|
|
405
|
+
plan: written?.plan || built.plan,
|
|
406
|
+
...(exploration ? { exploration } : {}),
|
|
407
|
+
...(result.selectedTarget ? { selectedTarget: result.selectedTarget } : {}),
|
|
408
|
+
requirementScope: result.requirementScope,
|
|
409
|
+
}, null, 2) + "\n");
|
|
410
|
+
}
|
|
411
|
+
const activeRequirements = result.requirementScope?.active || built.model.requirements;
|
|
412
|
+
const deferredRequirements = result.requirementScope?.deferred || [];
|
|
413
|
+
const blocking = activeRequirements.filter((item) => item.severity === "blocking");
|
|
414
|
+
const deferredBlocking = deferredRequirements.filter((item) => item.severity === "blocking");
|
|
346
415
|
const pending = (written?.plan || built.plan).items.filter((item) => item.decision === "pending");
|
|
347
416
|
console.log(`🧭 Tapp init — ${built.model.application.name}`);
|
|
348
417
|
console.log(` targets: ${built.model.targets.length ? built.model.targets.map((target) => `${target.platform}:${target.name}`).join(", ") : "none"}`);
|
|
349
418
|
console.log(` UI Map: ${built.model.uiMap.status} · ${built.model.uiMap.nodeCount} states · ${built.model.uiMap.edgeCount} transitions`);
|
|
350
419
|
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"}`);
|
|
351
420
|
if (exploration?.managedRuntime) console.log(` Managed web runtime: built/started ${exploration.target} for exploration and stopped it afterward · log: ${exploration.runtime.logPath}`);
|
|
352
|
-
|
|
353
|
-
|
|
421
|
+
const selectedLabel = result.selectedTarget ? ` for ${result.selectedTarget.platform}:${result.selectedTarget.name}` : "";
|
|
422
|
+
const deferredLabel = deferredBlocking.length ? ` · ${deferredBlocking.length} setup gap(s) on unselected target(s)` : "";
|
|
423
|
+
console.log(` release plan: ${(written?.plan || built.plan).items.length} item(s) · ${pending.length} pending review · ${blocking.length} blocking requirement(s)${selectedLabel}${deferredLabel}`);
|
|
424
|
+
for (const requirement of activeRequirements) console.log(` ${requirement.severity === "blocking" ? "❌" : "⚠️"} ${requirement.message} Next: ${requirement.remediation}`);
|
|
425
|
+
for (const requirement of deferredRequirements) console.log(` ℹ️ Unselected ${requirement.targetPlatform}:${requirement.targetName} setup gap: ${requirement.message} Next: ${requirement.remediation}`);
|
|
354
426
|
if (written) console.log(` model: ${written.modelPath}\n plan: ${written.planPath}`);
|
|
355
427
|
else console.log(" dry run: repository files were not changed");
|
|
356
428
|
break;
|
|
@@ -405,7 +477,10 @@ switch (command) {
|
|
|
405
477
|
testPassword: typeof flags.password === "string" ? flags.password : undefined,
|
|
406
478
|
startWebTarget: engine.startManagedWebTarget,
|
|
407
479
|
stopWebTarget: engine.stopManagedWebTarget,
|
|
408
|
-
|
|
480
|
+
// Contract execution is emitted in full on stdout below. Keep build/runtime/replay
|
|
481
|
+
// status live on stderr, but do not echo the execution transcript there as well — an
|
|
482
|
+
// interactive terminal merges the streams and would otherwise show every result twice.
|
|
483
|
+
onProgress: (entry) => { if (entry.text && entry.phase !== "execute") console.error(`⏳ ${entry.text}`); },
|
|
409
484
|
});
|
|
410
485
|
} catch (error) {
|
|
411
486
|
console.error(`❌ Could not validate contract drafts: ${error.message || String(error)}`);
|
|
@@ -484,7 +559,7 @@ switch (command) {
|
|
|
484
559
|
const modelPlatform = typeof flags.platform === "string" ? flags.platform.toLowerCase() : "";
|
|
485
560
|
if (modelPlatform === "ios") requireMacFor("iOS testing");
|
|
486
561
|
const onProgress = (p) =>
|
|
487
|
-
|
|
562
|
+
writeProgress(`🔍 Exploring… ${p.action}/${p.max || flags.actions || 60} actions · ${p.states} states observed`);
|
|
488
563
|
const r = await engine.runExploreTarget({
|
|
489
564
|
projectDir: process.cwd(),
|
|
490
565
|
platform: modelPlatform,
|
|
@@ -495,11 +570,12 @@ switch (command) {
|
|
|
495
570
|
testPassword: flags.password,
|
|
496
571
|
...launchOptions,
|
|
497
572
|
baselineFindings,
|
|
573
|
+
watch: flags.watch === true,
|
|
498
574
|
surface: "cli",
|
|
499
575
|
onProgress,
|
|
500
576
|
onStatus: (t) => console.error(`ℹ️ ${t}`),
|
|
501
577
|
});
|
|
502
|
-
|
|
578
|
+
finishProgress();
|
|
503
579
|
if (r.error) { printEngineError(r); process.exit(1); }
|
|
504
580
|
console.log(r.text);
|
|
505
581
|
if (flags.json && typeof flags.json === "string") {
|
|
@@ -515,19 +591,23 @@ switch (command) {
|
|
|
515
591
|
process.exit(2);
|
|
516
592
|
}
|
|
517
593
|
if (platform === "ios") requireMacFor("iOS testing");
|
|
594
|
+
if (flags.watch === true && platform !== "web") {
|
|
595
|
+
console.error("❌ --watch is currently available for web exploration only");
|
|
596
|
+
process.exit(2);
|
|
597
|
+
}
|
|
518
598
|
if (platform !== "ios" && Object.keys(launchOptions).length) {
|
|
519
599
|
console.error("❌ --launch-arg and --launch-env apply only to iOS targets");
|
|
520
600
|
process.exit(2);
|
|
521
601
|
}
|
|
522
602
|
if (platform === "web" && !/^https?:\/\//i.test(target)) {
|
|
523
|
-
console.error("❌ Web
|
|
603
|
+
console.error("❌ Web exploration needs an http(s) URL");
|
|
524
604
|
process.exit(2);
|
|
525
605
|
}
|
|
526
606
|
const bundleId = platform === "ios" ? await resolveTargetOrExit(engine, target) : null;
|
|
527
607
|
const android = platform === "android" ? androidTarget(flags, target) : null;
|
|
528
608
|
const progressMetric = platform === "web" ? "pages reached" : platform === "ios" ? "structural states observed" : "screens reached";
|
|
529
609
|
const onProgress = (p) =>
|
|
530
|
-
|
|
610
|
+
writeProgress(`🔍 Exploring… ${p.action}/${p.max || flags.actions || 60} actions · ${p.states} ${progressMetric}`);
|
|
531
611
|
const r = platform === "web"
|
|
532
612
|
? await engine.runQaWeb({
|
|
533
613
|
url: target,
|
|
@@ -536,6 +616,7 @@ switch (command) {
|
|
|
536
616
|
testEmail: flags.email,
|
|
537
617
|
testPassword: flags.password,
|
|
538
618
|
baselineFindings,
|
|
619
|
+
watch: flags.watch === true,
|
|
539
620
|
surface: "cli",
|
|
540
621
|
onProgress,
|
|
541
622
|
})
|
|
@@ -559,7 +640,7 @@ switch (command) {
|
|
|
559
640
|
surface: "cli",
|
|
560
641
|
onProgress,
|
|
561
642
|
});
|
|
562
|
-
|
|
643
|
+
finishProgress();
|
|
563
644
|
if (r.error) {
|
|
564
645
|
printEngineError(r);
|
|
565
646
|
process.exit(1);
|
|
@@ -770,8 +851,22 @@ switch (command) {
|
|
|
770
851
|
printEngineError(inst);
|
|
771
852
|
process.exit(1);
|
|
772
853
|
}
|
|
854
|
+
let modelRefresh = null;
|
|
855
|
+
try {
|
|
856
|
+
const { persistIosBuildValidation } = await import(path.join(packageRoot, "mcp-server", "src", "application-model.js"));
|
|
857
|
+
modelRefresh = await persistIosBuildValidation({
|
|
858
|
+
projectDir: dir,
|
|
859
|
+
bundleId: inst.bundleId,
|
|
860
|
+
container: built.container,
|
|
861
|
+
scheme: built.scheme,
|
|
862
|
+
configuration: built.configuration,
|
|
863
|
+
});
|
|
864
|
+
} catch (error) {
|
|
865
|
+
console.error(`⚠️ Build succeeded, but Tapp could not refresh the existing application model: ${error.message || String(error)}`);
|
|
866
|
+
}
|
|
773
867
|
console.log(`🔨 Built ${path.basename(built.appPath)} (scheme ${built.scheme}) — installed as ${inst.bundleId}`);
|
|
774
|
-
console.log(
|
|
868
|
+
if (modelRefresh) console.log(` application model refreshed: ${modelRefresh.modelPath}`);
|
|
869
|
+
console.log(`\nNext: npx -y @aarwitz/tapp@latest explore ${inst.bundleId}`);
|
|
775
870
|
break;
|
|
776
871
|
}
|
|
777
872
|
|
|
@@ -854,8 +949,12 @@ switch (command) {
|
|
|
854
949
|
const { flags, positionals } = parseVerbArgs(rest);
|
|
855
950
|
const verb = positionals[0] || "run";
|
|
856
951
|
const flowPath = positionals[1] || (verb === "run" || verb === "validate" ? "" : verb);
|
|
952
|
+
if (verb === "example") {
|
|
953
|
+
console.log(`# Tapp Flow — deterministic, keyless replay\nname: sign-in-smoke\nplatform: web\nurl: https://example.test/login\nsteps:\n - login:\n email: $TEST_EMAIL\n password: $TEST_PASSWORD\n - wait_for: Dashboard\n - assert_screen: Dashboard\n`);
|
|
954
|
+
break;
|
|
955
|
+
}
|
|
857
956
|
if (!["run", "validate"].includes(verb) || !flowPath) {
|
|
858
|
-
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>");
|
|
957
|
+
console.error("usage: tapp flow example\n tapp flow run <flow.yml> [--platform ios|android|web] [--url URL] [--app-id ID] [--apk FILE] [--serial ID]\n tapp flow validate <flow.yml>");
|
|
859
958
|
process.exit(2);
|
|
860
959
|
}
|
|
861
960
|
const absolute = path.resolve(flowPath);
|
|
@@ -985,7 +1084,7 @@ switch (command) {
|
|
|
985
1084
|
try {
|
|
986
1085
|
const adopted = adoptPrCoverageProposal({ projectDir, prPlanPath, item: flags.item, releasePlanPath: typeof flags["release-plan"] === "string" ? flags["release-plan"] : undefined });
|
|
987
1086
|
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`);
|
|
988
|
-
console.log(` plan: ${adopted.path}\n next: tapp plan review ${adopted.path} --approve ${adopted.item.id}`);
|
|
1087
|
+
console.log(` plan: ${adopted.path}\n next: npx -y @aarwitz/tapp@latest plan review ${adopted.path} --approve ${adopted.item.id}`);
|
|
989
1088
|
} catch (error) { console.error(`❌ Could not adopt PR coverage proposal: ${error.message || String(error)}`); process.exit(2); }
|
|
990
1089
|
break;
|
|
991
1090
|
}
|
|
@@ -1145,6 +1244,13 @@ switch (command) {
|
|
|
1145
1244
|
const python = run("python3", ["--version"]);
|
|
1146
1245
|
python.code === 0 ? ok("python3", `${python.stdout} (used by Flows)`) : bad("python3", "not found — Flow replay needs python3 + pyyaml (everything else works)");
|
|
1147
1246
|
|
|
1247
|
+
const { storagePreflight } = await import(path.join(packageRoot, "mcp-server", "src", "environment-preflight.js"));
|
|
1248
|
+
const storage = storagePreflight(tappHome);
|
|
1249
|
+
if (storage.level === "blocked") { bad("Disk space", storage.message); healthy = false; }
|
|
1250
|
+
else if (storage.level === "warning") console.log(` ⚠️ Disk space — ${storage.message}`);
|
|
1251
|
+
else if (storage.level === "ok") ok("Disk space", storage.message);
|
|
1252
|
+
else console.log(` ⬜ Disk space — ${storage.message || "could not be checked"}`);
|
|
1253
|
+
|
|
1148
1254
|
console.log("\n Platforms:");
|
|
1149
1255
|
if (process.platform === "darwin") {
|
|
1150
1256
|
const xcode = run("xcode-select", ["-p"]);
|
|
@@ -1154,7 +1260,7 @@ switch (command) {
|
|
|
1154
1260
|
const booted = bootedSims();
|
|
1155
1261
|
ok("iOS", `${ver || "Xcode"}; ${booted.length ? `${booted[0].name} booted` : "no simulator booted yet"}`);
|
|
1156
1262
|
const xctestrun = harnessXctestrun();
|
|
1157
|
-
xctestrun ? ok("iOS harness cache", xctestrun) : console.log(" ⬜ iOS harness cache — builds on first use (or: tapp install)");
|
|
1263
|
+
xctestrun ? ok("iOS harness cache", xctestrun) : console.log(" ⬜ iOS harness cache — builds on first use (or: npx -y @aarwitz/tapp@latest install)");
|
|
1158
1264
|
} else {
|
|
1159
1265
|
console.log(" ⬜ iOS — unavailable (install Xcode + simulator runtime)");
|
|
1160
1266
|
}
|
|
@@ -1173,16 +1279,22 @@ switch (command) {
|
|
|
1173
1279
|
}
|
|
1174
1280
|
|
|
1175
1281
|
try {
|
|
1176
|
-
await import("playwright");
|
|
1177
|
-
|
|
1282
|
+
const { chromium } = await import("playwright");
|
|
1283
|
+
let executable = "";
|
|
1284
|
+
try { executable = chromium.executablePath(); } catch { /* report the missing browser below */ }
|
|
1285
|
+
if (executable && fs.existsSync(executable)) {
|
|
1286
|
+
ok("Web", `Playwright + Chromium (${executable})`);
|
|
1287
|
+
} else {
|
|
1288
|
+
console.log(" ⬜ Web — Playwright installed; Chromium browser missing (run: npx playwright install chromium)");
|
|
1289
|
+
}
|
|
1178
1290
|
} catch {
|
|
1179
1291
|
console.log(" ⬜ Web — install Playwright in the app workspace: npm install -D playwright && npx playwright install chromium");
|
|
1180
1292
|
}
|
|
1181
1293
|
|
|
1182
1294
|
console.log(`\n Home: ${tappHome}`);
|
|
1183
1295
|
console.log(healthy
|
|
1184
|
-
? "\nReady. Start with:\n npx -y @aarwitz/tapp open [target]\n npx -y @aarwitz/tapp explore [target]"
|
|
1185
|
-
: "\nFix the ❌ items above, then re-run: tapp doctor");
|
|
1296
|
+
? "\nReady. Start with:\n npx -y @aarwitz/tapp@latest open [target]\n npx -y @aarwitz/tapp@latest explore [target]"
|
|
1297
|
+
: "\nFix the ❌ items above, then re-run: npx -y @aarwitz/tapp@latest doctor");
|
|
1186
1298
|
process.exit(healthy ? 0 : 1);
|
|
1187
1299
|
}
|
|
1188
1300
|
|
|
@@ -1257,7 +1369,13 @@ switch (command) {
|
|
|
1257
1369
|
console.log(`✅ Actor '${name}' configured — ${result.actor.session} session · ${result.actor.provisioning} provisioning`);
|
|
1258
1370
|
console.log(` ${result.path}`);
|
|
1259
1371
|
console.log(` bindings: ${Object.entries(result.actor.credentials).map(([key, binding]) => `${key}=$${binding.env}`).join(", ") || "none"}`);
|
|
1260
|
-
|
|
1372
|
+
const { refreshExistingInitArtifacts } = await import(path.join(packageRoot, "mcp-server", "src", "application-model.js"));
|
|
1373
|
+
let refreshed = null;
|
|
1374
|
+
let refreshWarning = "";
|
|
1375
|
+
try { refreshed = await refreshExistingInitArtifacts({ projectDir }); }
|
|
1376
|
+
catch (error) { refreshWarning = error.message || String(error); }
|
|
1377
|
+
console.log(` No credential values were accepted or written.${refreshed ? ` Application model refreshed: ${refreshed.modelPath}` : " Run npx -y @aarwitz/tapp@latest init when you are ready to create the application model."}`);
|
|
1378
|
+
if (refreshWarning) console.error(`⚠️ Actor was saved, but Tapp could not refresh the existing application model: ${refreshWarning}`);
|
|
1261
1379
|
} catch (error) { console.error(`❌ Actor not configured: ${error.message || String(error)}`); process.exit(2); }
|
|
1262
1380
|
break;
|
|
1263
1381
|
}
|
|
@@ -1272,7 +1390,7 @@ switch (command) {
|
|
|
1272
1390
|
const projectDir = fs.realpathSync(path.resolve(positionals[1] || (typeof flags["project-dir"] === "string" ? flags["project-dir"] : process.cwd())));
|
|
1273
1391
|
const modelPath = typeof flags.model === "string" ? path.resolve(projectDir, flags.model) : existingProjectArtifactPath(projectDir, "application-model.json");
|
|
1274
1392
|
if (!modelPath.startsWith(projectDir + path.sep) || !fs.existsSync(modelPath)) {
|
|
1275
|
-
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.`);
|
|
1393
|
+
console.error(`❌ Application model not found inside the repository: ${modelPath}\n Run npx -y @aarwitz/tapp@latest init --explore, review/generate/validate/promote the plan, then create the baseline.`);
|
|
1276
1394
|
process.exit(2);
|
|
1277
1395
|
}
|
|
1278
1396
|
let model;
|
|
@@ -1379,7 +1497,7 @@ switch (command) {
|
|
|
1379
1497
|
catch { console.error(`❌ Repository directory not found: ${positionals[0] || flags["project-dir"] || process.cwd()}`); process.exit(2); }
|
|
1380
1498
|
const modelPath = typeof flags.model === "string" ? path.resolve(projectDir, flags.model) : existingProjectArtifactPath(projectDir, "application-model.json");
|
|
1381
1499
|
if (!modelPath.startsWith(projectDir + path.sep) || !fs.existsSync(modelPath)) {
|
|
1382
|
-
console.error(`❌ Application model not found inside the repository: ${modelPath}\n Run tapp init --explore first.`);
|
|
1500
|
+
console.error(`❌ Application model not found inside the repository: ${modelPath}\n Run npx -y @aarwitz/tapp@latest init --explore first.`);
|
|
1383
1501
|
process.exit(2);
|
|
1384
1502
|
}
|
|
1385
1503
|
const actionRef = typeof flags["action-ref"] === "string" ? flags["action-ref"] : `aarwitz/tapp@v${pkg.version}`;
|
|
@@ -1496,17 +1614,17 @@ switch (command) {
|
|
|
1496
1614
|
}
|
|
1497
1615
|
|
|
1498
1616
|
default: {
|
|
1499
|
-
console.log(`tapp v${pkg.version} —
|
|
1617
|
+
console.log(`tapp v${pkg.version} — agent-driven app testing for iOS, Android, and web.
|
|
1500
1618
|
|
|
1501
|
-
Core —
|
|
1619
|
+
Core — inspect, explore, gate (no Tapp account or server required):
|
|
1502
1620
|
tapp explore [target] Autonomous exploration → findings + evidence (an observation, NOT a
|
|
1503
|
-
release decision — run 'tapp ci' to gate a merge)
|
|
1504
|
-
(--platform ios|android|web · --
|
|
1621
|
+
release decision — run 'npx -y @aarwitz/tapp@latest ci' to gate a merge)
|
|
1622
|
+
(web: --watch · all: --platform ios|android|web · --actions N)
|
|
1505
1623
|
tapp contract run FILE Replay a business-level release contract — the guarantees that must hold
|
|
1506
1624
|
tapp ci ... Merge-blocking release gate — explore + suites + baseline → pass/fail/inconclusive
|
|
1507
1625
|
(see: tapp ci --help)
|
|
1508
1626
|
|
|
1509
|
-
Primitives — an agent's eyes and hands
|
|
1627
|
+
Primitives — an agent's eyes and hands:
|
|
1510
1628
|
tapp open [target] Launch the app → screen summary + screenshot saved to a file
|
|
1511
1629
|
(web: --tap TEXT · --wait-for TEXT · --out FILE)
|
|
1512
1630
|
tapp tree [target] Accessibility tree of the current screen (--json for every element)
|
|
@@ -1522,6 +1640,7 @@ Repository & release:
|
|
|
1522
1640
|
tapp actor list [repo] Inspect named actors, sessions, provisioning, and secret env bindings
|
|
1523
1641
|
|
|
1524
1642
|
Advanced — deterministic suites, lifecycle & compilers:
|
|
1643
|
+
tapp flow example Print a complete starter Flow YAML (no target or MCP required)
|
|
1525
1644
|
tapp flow run FILE Replay a committed deterministic Flow (no AI/API key)
|
|
1526
1645
|
tapp flow validate FILE Validate a Flow without launching a target
|
|
1527
1646
|
tapp task validate FILE Validate a reusable deterministic Task (+ optional UI Map grounding)
|
|
@@ -1561,10 +1680,15 @@ Setup:
|
|
|
1561
1680
|
tapp doctor Check Xcode / simulators / toolchain
|
|
1562
1681
|
tapp mcp Start the MCP server on stdio (adds inline screenshots + interactive sessions)
|
|
1563
1682
|
|
|
1683
|
+
Agent Skill (recommended — so a short “Use Tapp to test this app” prompt is enough):
|
|
1684
|
+
Any supported agent: npx -y skills add aarwitz/tapp --skill tapp
|
|
1685
|
+
Claude skill + MCP: claude plugin marketplace add aarwitz/tapp
|
|
1686
|
+
claude plugin install tapp@tapp
|
|
1687
|
+
|
|
1564
1688
|
MCP hookup (optional — for inline screenshots and the tap/type/inspect session loop):
|
|
1565
|
-
Claude Code: claude mcp add tapp -- npx -y @aarwitz/tapp mcp
|
|
1689
|
+
Claude Code: claude mcp add tapp -- npx -y @aarwitz/tapp@latest mcp
|
|
1566
1690
|
Cursor/VS Code (mcp.json):
|
|
1567
|
-
{ "servers": { "tapp": { "type": "stdio", "command": "npx", "args": ["-y", "@aarwitz/tapp", "mcp"] } } }
|
|
1691
|
+
{ "servers": { "tapp": { "type": "stdio", "command": "npx", "args": ["-y", "@aarwitz/tapp@latest", "mcp"] } } }
|
|
1568
1692
|
|
|
1569
1693
|
Then ask your agent things like:
|
|
1570
1694
|
"Explore com.mycompany.app and show me what breaks"
|
package/docs/BROWSER-PRODUCT.md
CHANGED
|
@@ -16,6 +16,15 @@ resolution actually builds and installs the detected Xcode container, the model
|
|
|
16
16
|
scheme as runtime-observed validation and removes the corresponding confirmation blocker. Merely
|
|
17
17
|
supplying a bundle id or prebuilt `.app` does not prove repository build configuration.
|
|
18
18
|
|
|
19
|
+
A repository with more than one detected application target is never resolved by detection order
|
|
20
|
+
during explicit initialization, even when a prior default exists. Bare `tapp init . --explore`
|
|
21
|
+
prompts in a human TTY; non-interactive CLI/MCP callers receive
|
|
22
|
+
exact `--platform`/`--target` commands before any build or write, and MCP also carries them as
|
|
23
|
+
structured `target-selection-required` choices. The selected run records that target as the default
|
|
24
|
+
for later bare `tapp explore`, but the application model retains the repository's other detected
|
|
25
|
+
targets and their unmet coverage. The selected init run reports other-target setup gaps as deferred
|
|
26
|
+
information rather than presenting them as failures of the target that was actually explored.
|
|
27
|
+
|
|
19
28
|
## First inspection
|
|
20
29
|
|
|
21
30
|
```bash
|
|
@@ -49,8 +58,8 @@ tapp init . --refresh --explore --platform web --url http://127.0.0.1:3000
|
|
|
49
58
|
|
|
50
59
|
MCP clients use `tapp_init` with `operation: inspect|write|refresh|explore`. `inspect` is the safe
|
|
51
60
|
default. `explore` writes real evidence, so the CLI rejects `--explore --dry-run`; the CLI also
|
|
52
|
-
|
|
53
|
-
runtime and are never written into the model, map, or plan.
|
|
61
|
+
refreshes existing model/plan artifacts through the same decision-preserving semantics. Credentials
|
|
62
|
+
are passed only to the runtime and are never written into the model, map, or plan.
|
|
54
63
|
|
|
55
64
|
Successful repository-driven iOS build validation is portable and durable. The application model
|
|
56
65
|
stores the repository-relative container, scheme, configuration, bundle id, and a
|
|
@@ -227,7 +236,7 @@ tapp baseline create . --platform web
|
|
|
227
236
|
tapp baseline create . --platform web --from /path/to/tapp-report.json
|
|
228
237
|
|
|
229
238
|
# Generate one target-aware job per model target plus a machine-readable manifest.
|
|
230
|
-
tapp ci install . --action-ref aarwitz/tapp@v0.
|
|
239
|
+
tapp ci install . --action-ref aarwitz/tapp@v0.17.0
|
|
231
240
|
```
|
|
232
241
|
|
|
233
242
|
Baseline creation rejects non-gate JSON, missing or mismatched target identity, platform mismatch,
|
|
@@ -74,6 +74,7 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
|
|
|
74
74
|
const visited = new Map();
|
|
75
75
|
let issues = 0;
|
|
76
76
|
let actions = 0;
|
|
77
|
+
let loginTried = false;
|
|
77
78
|
const crashExitBaseline = typeof d.latestCrashExitInfo === "function" ? await d.latestCrashExitInfo() : null;
|
|
78
79
|
let snap = await d.launch({ clearData });
|
|
79
80
|
let crashReported = false;
|
|
@@ -210,6 +211,7 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
|
|
|
210
211
|
if (candidate) {
|
|
211
212
|
const target = controlLabel(candidate);
|
|
212
213
|
const loginSubmit = inputs.some((input) => input.secure) && isAndroidAuthSubmit(candidate);
|
|
214
|
+
if (loginSubmit) loginTried = true;
|
|
213
215
|
tried.add(`${hash}|tap|${target}`);
|
|
214
216
|
const before = hash;
|
|
215
217
|
const r = await d.tap(target, snap);
|
|
@@ -260,7 +262,7 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
|
|
|
260
262
|
}
|
|
261
263
|
|
|
262
264
|
const timedOut = Date.now() >= deadline;
|
|
263
|
-
emit("COMPLETE", { actions, states: visited.size, issues, screens: [...new Set(visited.values())].join(","), outcome: timedOut ? "timeout" : "complete", timedOut, ...(timedOut ? { timeoutSeconds: timeoutSec } : {}) });
|
|
265
|
+
emit("COMPLETE", { actions, states: visited.size, issues, screens: [...new Set(visited.values())].join(","), outcome: timedOut ? "timeout" : "complete", timedOut, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried, ...(timedOut ? { timeoutSeconds: timeoutSec } : {}) });
|
|
264
266
|
onProgress({ action: actions, max: maxActions, states: visited.size });
|
|
265
267
|
return { markersPath, outDir, actions, states: visited.size, issues, timedOut, seedTargets: normalizedTargets };
|
|
266
268
|
}
|
|
@@ -43,6 +43,23 @@ export async function runAndroidFlow({ flow, appId, apkPath, serial, logPath, sc
|
|
|
43
43
|
} else if (action === "type") {
|
|
44
44
|
const r = await d.type(target, value, snap);
|
|
45
45
|
if (r.status !== "ok") throw new Error(r.detail || `no field ‘${target}’ to type into`);
|
|
46
|
+
} else if (action === "login") {
|
|
47
|
+
const fields = snap.elements.filter((element) => /EditText/i.test(element.type));
|
|
48
|
+
const emailField = fields.find((element) => /email|user/i.test(`${element.id} ${element.label}`)) || fields.find((element) => !element.secure);
|
|
49
|
+
const passwordField = fields.find((element) => element.secure || /password|passcode/i.test(`${element.id} ${element.label}`));
|
|
50
|
+
if (!emailField || !passwordField) throw new Error("could not identify email and password fields");
|
|
51
|
+
const emailValue = substituteFlowValue(raw.params.email || "$TEST_EMAIL", vars);
|
|
52
|
+
const passwordValue = substituteFlowValue(raw.params.password || "$TEST_PASSWORD", vars);
|
|
53
|
+
const emailResult = await d.type(emailField.id || emailField.label, emailValue, snap);
|
|
54
|
+
if (emailResult.status !== "ok") throw new Error(emailResult.detail || "could not fill the email field");
|
|
55
|
+
snap = await d.settle();
|
|
56
|
+
const passwordResult = await d.type(passwordField.id || passwordField.label, passwordValue, snap);
|
|
57
|
+
if (passwordResult.status !== "ok") throw new Error(passwordResult.detail || "could not fill the password field");
|
|
58
|
+
snap = await d.settle();
|
|
59
|
+
const submit = snap.elements.find((element) => element.clickable && /sign in|log in|login|continue|submit/i.test(`${element.text} ${element.label} ${element.id}`));
|
|
60
|
+
if (!submit) throw new Error("could not identify a sign-in control");
|
|
61
|
+
const submitResult = await d.tap(submit.id || submit.description || submit.text, snap);
|
|
62
|
+
if (submitResult.status !== "ok") throw new Error(submitResult.detail || "could not submit the login form");
|
|
46
63
|
} else if (action === "swipe") {
|
|
47
64
|
await d.swipe(target || "up");
|
|
48
65
|
} else if (action === "back") {
|
|
@@ -81,7 +98,7 @@ export async function runAndroidFlow({ flow, appId, apkPath, serial, logPath, sc
|
|
|
81
98
|
detail = error.message || String(error);
|
|
82
99
|
if (screenshotDir) await d.screenshot(path.join(screenshotDir, `flow-failure-${i + 1}.png`)).catch(() => {});
|
|
83
100
|
}
|
|
84
|
-
log.step({ index: i + 1, action, target: target || value, status, detail, task: raw.task });
|
|
101
|
+
log.step({ index: i + 1, action, target: action === "login" ? "sign-in form" : target || value, status, detail, task: raw.task });
|
|
85
102
|
if (status === "fail" && !flow.continueOnFailure) break;
|
|
86
103
|
}
|
|
87
104
|
if (screenshotDir) await d.screenshot(path.join(screenshotDir, "flow-final.png")).catch(() => {});
|