@aarwitz/tapp 0.17.1 → 0.17.2

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tapp",
3
3
  "description": "Give Claude hands and eyes on iOS, Android, and web apps, with exploration, replayable flows, evidence, and deterministic CI gates.",
4
- "version": "0.17.1",
4
+ "version": "0.17.2",
5
5
  "author": {
6
6
  "name": "Aaron Horowitz",
7
7
  "url": "https://github.com/aarwitz"
@@ -24,7 +24,7 @@
24
24
  "command": "npx",
25
25
  "args": [
26
26
  "-y",
27
- "@aarwitz/tapp@0.17.1",
27
+ "@aarwitz/tapp@0.17.2",
28
28
  "mcp"
29
29
  ],
30
30
  "cwd": "${CLAUDE_PROJECT_DIR}"
package/AGENTS.md CHANGED
@@ -7,10 +7,9 @@ repository-connected deterministic gate.
7
7
  ## No MCP connected? Just run the CLI
8
8
 
9
9
  The core inspect, explore, replay, and gate capabilities work as plain commands — no Tapp account,
10
- server, or global install. `[target]` is
11
- optional: with nothing, tapp finds + builds the Xcode project in the cwd (or falls back to
12
- the app already on the simulator); it also accepts a repo dir, a `path/to/App.app`, a
13
- bundle id, or (web) an http(s) URL. You never need to know a bundle id up front.
10
+ server, or global install. `[target]` is optional: with a repository/model, Tapp selects and prepares
11
+ one conclusive web, iOS, or Android target; it also accepts a repo dir, `path/to/App.app`, bundle id,
12
+ APK plus app id, or an owned HTTP(S) URL. You never need to know an iOS bundle id up front.
14
13
 
15
14
  ```bash
16
15
  npx -y @aarwitz/tapp@latest explore [target] # autonomous exploration → findings + evidence (observation, not a gate; ≈ tapp_explore)
@@ -77,6 +76,7 @@ wander blindly or invent a route. URL-only targets correctly have no source adva
77
76
 
78
77
  ```
79
78
  tapp_session_start { appBundleId: "com.acme.app", focus: "Save storefront settings visible above keyboard", projectDir: "." }
79
+ tapp_session_start { focus: "Storefront Settings", projectDir: "." } → managed web target from the workspace
80
80
  tapp_focus { query: "Save storefront settings visible above keyboard" } → one-call shortest observed route
81
81
  tapp_session_act { action: "login", email: "qa@x.com", password: "…" } → atomic fill + submit + verify
82
82
  tapp_session_act { action: "tap", id: "Email" } → tap by a11y id OR visible label
@@ -153,7 +153,8 @@ without a coding agent, model, subscription, or API key. AI generation and `asse
153
153
  ## Setup facts (tell the user when relevant)
154
154
 
155
155
  - iOS runs locally on a Mac with Xcode + a simulator. Android needs `adb` and a connected
156
- emulator/device. Web needs Playwright + Chromium. `tapp doctor` reports each capability.
156
+ emulator/device; source builds also need JDK 17, while prebuilt APK testing does not. Web needs
157
+ Playwright + Chromium. `tapp doctor` reports each capability separately.
157
158
  - First tool call builds the test harness once (~2 min, cached in `~/.tapp`). `tapp install`
158
159
  prebuilds it. Switching simulators triggers an automatic rebuild.
159
160
  - The app under test must be **installed on the booted simulator** (`tapp_install_app` builds
package/README.md CHANGED
@@ -84,7 +84,8 @@ agent: "Done — and here it is working on the simulator: [screenshot]"
84
84
  ## npm CLI quickstart
85
85
 
86
86
  Requirements: **Node ≥ 18**. iOS needs **macOS + Xcode**; Android needs `adb` plus a connected
87
- emulator/device; web needs Playwright + Chromium.
87
+ emulator/device (and JDK 17 when Tapp builds source rather than installing an existing APK); web
88
+ needs Playwright + Chromium. `tapp doctor` reports these separately.
88
89
 
89
90
  From the app repository, ground Tapp once, then use the smallest operation for later checks:
90
91
 
@@ -337,7 +338,7 @@ jobs:
337
338
  timeout-minutes: 45
338
339
  steps:
339
340
  - uses: actions/checkout@v4
340
- - uses: aarwitz/tapp@v0.17.1 # or pin the reviewed release commit SHA
341
+ - uses: aarwitz/tapp@v0.17.2 # or pin the reviewed release commit SHA
341
342
  with:
342
343
  project: MyApp.xcodeproj # or MyApp.xcworkspace
343
344
  scheme: MyApp
@@ -387,7 +388,7 @@ Android CI runs on Linux with an emulator/device already connected. The Action c
387
388
  or accept a prebuilt one:
388
389
 
389
390
  ```yaml
390
- - uses: aarwitz/tapp@v0.17.1 # or pin the reviewed release commit SHA
391
+ - uses: aarwitz/tapp@v0.17.2 # or pin the reviewed release commit SHA
391
392
  with:
392
393
  platform: android
393
394
  android-app-id: com.acme.app
package/bin/tapp.js CHANGED
@@ -217,6 +217,15 @@ function printEngineError(r) {
217
217
  if (r.details && Array.isArray(r.details.errors) && r.details.errors.length) {
218
218
  console.error(r.details.errors.map((e) => " " + e.trim()).join("\n"));
219
219
  }
220
+ const choices = Array.isArray(r.details?.choices) ? r.details.choices : Array.isArray(r.details?.targets) ? r.details.targets : [];
221
+ if (choices.length) {
222
+ console.error(" Available targets:");
223
+ for (const choice of choices) {
224
+ const selector = choice.name || choice.id || choice.sourcePath;
225
+ console.error(` ${choice.platform ? `${choice.platform} · ` : ""}${choice.name || choice.id}${choice.sourcePath ? ` (${choice.sourcePath})` : ""}${selector ? ` — use --target ${JSON.stringify(selector)}` : ""}`);
226
+ }
227
+ }
228
+ if (r.details?.remediation) console.error(` Next: ${r.details.remediation}`);
220
229
  }
221
230
 
222
231
  async function promptForInitTarget(details) {
@@ -254,7 +263,7 @@ async function resolveTargetOrExit(engine, input) {
254
263
  function safeCommandUsage(verb) {
255
264
  const usage = {
256
265
  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
- focus: "tapp focus \"SCREEN OR CONTROL\" [target] [--platform ios|android|web] [--project-dir REPO] [--map FILE] [--out FILE]",
266
+ focus: "tapp focus \"SCREEN OR CONTROL\" [target] [--platform ios|android|web] [--project-dir REPO] [--target NAME|PATH] [--map FILE] [--out FILE]",
258
267
  init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--watch] [--dry-run]",
259
268
  open: "tapp open [target] [--platform ios|android|web] [--out FILE] [--tap TEXT] [--wait-for TEXT]",
260
269
  tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]",
@@ -544,33 +553,113 @@ switch (command) {
544
553
  const { flags, positionals } = parseVerbArgs(rest);
545
554
  const query = positionals[0] || "";
546
555
  if (!query) {
547
- console.error('usage: tapp focus "SCREEN OR CONTROL" [target] [--platform ios|android|web] [--project-dir REPO] [--map FILE] [--out FILE]');
556
+ console.error('usage: tapp focus "SCREEN OR CONTROL" [target] [--platform ios|android|web] [--project-dir REPO] [--target NAME|PATH] [--map FILE] [--out FILE]');
548
557
  process.exit(2);
549
558
  }
550
559
  let projectDir;
551
560
  try { projectDir = fs.realpathSync(path.resolve(typeof flags["project-dir"] === "string" ? flags["project-dir"] : process.cwd())); }
552
561
  catch { console.error("❌ --project-dir must be an existing repository directory"); process.exit(2); }
553
562
  const target = positionals[1] || (typeof flags.url === "string" ? flags.url : "");
554
- const platform = requestedPlatform(flags, target);
563
+ if (typeof flags["project-dir"] !== "string" && target && !/^https?:\/\//i.test(target)) {
564
+ try {
565
+ const targetPath = fs.realpathSync(path.resolve(target));
566
+ if (fs.statSync(targetPath).isDirectory()) projectDir = targetPath;
567
+ } catch { /* a bundle/application id is not a repository path */ }
568
+ }
569
+ let platform = requestedPlatform(flags, target);
570
+ let modeledTarget = null;
571
+ const directRuntimeTarget = /^https?:\/\//i.test(target) || /\.apk$/i.test(target)
572
+ || (target && !fs.existsSync(path.resolve(target)));
573
+ const modelPath = existingProjectArtifactPath(projectDir, "application-model.json");
574
+ if (!directRuntimeTarget && fs.existsSync(modelPath)) {
575
+ try {
576
+ const model = JSON.parse(fs.readFileSync(modelPath, "utf8"));
577
+ const { selectApplicationTarget } = await import(path.join(packageRoot, "mcp-server", "src", "ci-setup.js"));
578
+ modeledTarget = selectApplicationTarget(model, {
579
+ platform:typeof flags.platform === "string" ? flags.platform.toLowerCase() : "",
580
+ target:typeof flags.target === "string" ? flags.target.trim() : "",
581
+ useDefault:true,
582
+ });
583
+ platform = modeledTarget.platform;
584
+ } catch (error) {
585
+ let choices = [];
586
+ try {
587
+ const model = JSON.parse(fs.readFileSync(modelPath, "utf8"));
588
+ choices = (model.targets || []).filter((item) => typeof flags.platform !== "string" || item.platform === flags.platform.toLowerCase());
589
+ } catch { /* the primary parse/select error is printed below */ }
590
+ printEngineError({ error:error.message || String(error), details:{ targets:choices } });
591
+ process.exit(2);
592
+ }
593
+ }
555
594
  if (!["ios", "android", "web"].includes(platform)) { console.error("❌ --platform must be ios|android|web"); process.exit(2); }
556
595
  const engine = await engineImport();
557
596
  let started = null;
558
597
  try {
559
598
  if (platform === "ios") {
560
599
  requireMacFor("iOS focused navigation");
561
- const sim = await engine.ensureBootedSim({ autoBoot:true });
562
- if (sim.error) throw new Error(sim.error);
563
- const bundleId = await resolveTargetOrExit(engine, target || projectDir);
600
+ let bundleId;
601
+ if (modeledTarget) {
602
+ const resolved = await engine.resolveAppTarget(path.resolve(projectDir, modeledTarget.sourcePath || "."), {
603
+ cwd:projectDir,
604
+ scheme:modeledTarget.build?.scheme || modeledTarget.build?.proposedScheme || "",
605
+ configuration:modeledTarget.build?.configuration || "Debug",
606
+ onStatus:(status) => console.error(`⏳ ${status}`),
607
+ });
608
+ if (resolved.error) {
609
+ printEngineError(resolved);
610
+ process.exitCode = 1;
611
+ break;
612
+ }
613
+ bundleId = resolved.bundleId;
614
+ if (resolved.via) console.error(`🎯 Target: ${bundleId} — ${resolved.via}`);
615
+ } else {
616
+ const sim = await engine.ensureBootedSim({ autoBoot:true });
617
+ if (sim.error) throw new Error(sim.error);
618
+ bundleId = await resolveTargetOrExit(engine, target || projectDir);
619
+ }
564
620
  const launch = iosLaunchOptions(flags, rest);
565
621
  started = await engine.startIosInteractiveSession(bundleId, engine.explorationEnvFromArgs({ testEmail:flags.email, testPassword:flags.password, ...launch }));
566
622
  } else if (platform === "android") {
567
- const android = androidTarget(flags, target);
568
- started = await engine.startAndroidInteractiveSession(android.appId, { serial:android.serial, apkPath:android.apkPath, clearData:flags["keep-data"] !== true });
623
+ const explicitAndroid = typeof flags["app-id"] === "string" || /\.apk$/i.test(target)
624
+ || (target && !fs.existsSync(path.resolve(target)));
625
+ if (explicitAndroid) {
626
+ const android = androidTarget(flags, target);
627
+ started = await engine.startAndroidInteractiveSession(android.appId, { serial:android.serial, apkPath:android.apkPath, clearData:flags["keep-data"] !== true });
628
+ } else {
629
+ const prepared = await engine.prepareAndroidInteractiveTarget({
630
+ projectDir,
631
+ target:typeof flags.target === "string" ? flags.target : modeledTarget?.id || "",
632
+ onStatus:(status) => console.error(`⏳ ${status}`),
633
+ });
634
+ if (prepared.error) {
635
+ printEngineError(prepared);
636
+ process.exitCode = 1;
637
+ break;
638
+ }
639
+ console.error(`🎯 Target: ${prepared.appId} — built ${prepared.selectedTarget.name} → ${prepared.apkPath}`);
640
+ started = await engine.startAndroidInteractiveSession(prepared.appId, {
641
+ serial:typeof flags.serial === "string" ? flags.serial : undefined,
642
+ apkPath:prepared.apkPath,
643
+ clearData:flags["keep-data"] !== true,
644
+ });
645
+ }
569
646
  } else {
570
- if (!/^https?:\/\//i.test(target)) { console.error("❌ Web focus needs an http(s) target URL"); process.exit(2); }
571
- started = await engine.startWebInteractiveSession(target);
647
+ started = /^https?:\/\//i.test(target)
648
+ ? await engine.startWebInteractiveSession(target)
649
+ : await engine.startManagedWebInteractiveSession({
650
+ projectDir,
651
+ requestedTarget:typeof flags.target === "string" ? flags.target : modeledTarget?.sourcePath || modeledTarget?.name || target,
652
+ timeout:flags.timeout,
653
+ testEmail:typeof flags.email === "string" ? flags.email : "",
654
+ testPassword:typeof flags.password === "string" ? flags.password : "",
655
+ onStatus:(status) => console.error(`⏳ ${status}`),
656
+ });
657
+ }
658
+ if (started.error) {
659
+ printEngineError(started);
660
+ process.exitCode = 1;
661
+ break;
572
662
  }
573
- if (started.error) throw new Error(started.error);
574
663
  const focused = await engine.focusInteractiveSession({ projectDir, query, platform, mapPath:typeof flags.map === "string" ? flags.map : "" });
575
664
  const { focusedTargetSummary } = await import(path.join(packageRoot, "mcp-server", "src", "focused-navigation.js"));
576
665
  console.log(focusedTargetSummary(focused));
@@ -1338,7 +1427,7 @@ switch (command) {
1338
1427
  console.log(` ⬜ iOS — requires macOS (this host: ${process.platform})`);
1339
1428
  }
1340
1429
 
1341
- const { resolveAdbPath } = await import(path.join(packageRoot, "mcp-server", "src", "android-driver.js"));
1430
+ const { resolveAdbPath, resolveAndroidSdkRoot } = await import(path.join(packageRoot, "mcp-server", "src", "android-driver.js"));
1342
1431
  const adbPath = resolveAdbPath();
1343
1432
  const adb = adbPath ? run(adbPath, ["devices"]) : { code: 1, stdout: "" };
1344
1433
  if (adb.code === 0) {
@@ -1347,6 +1436,14 @@ switch (command) {
1347
1436
  } else {
1348
1437
  console.log(" ⬜ Android — adb not found (install Android SDK platform-tools)");
1349
1438
  }
1439
+ const { resolveJavaRuntime } = await import(path.join(packageRoot, "mcp-server", "src", "environment-preflight.js"));
1440
+ const java = resolveJavaRuntime();
1441
+ const androidSdkRoot = resolveAndroidSdkRoot();
1442
+ if (java && androidSdkRoot) ok("Android source builds", `${java.version || java.javaHome}; SDK ${androidSdkRoot}`);
1443
+ else {
1444
+ const missing = [!java ? "JDK 17" : "", !androidSdkRoot ? "Android SDK root" : ""].filter(Boolean).join(" and ");
1445
+ console.log(` ⬜ Android source builds — install/configure ${missing} (prebuilt APK testing still works)`);
1446
+ }
1350
1447
 
1351
1448
  try {
1352
1449
  const { chromium } = await import("playwright");
package/docs/scenarios.md CHANGED
@@ -74,7 +74,7 @@ tapp ci --platform web --url http://127.0.0.1:4180 \
74
74
  GitHub Action:
75
75
 
76
76
  ```yaml
77
- - uses: aarwitz/tapp@v0.17.1 # or pin the reviewed release commit SHA
77
+ - uses: aarwitz/tapp@v0.17.2 # or pin the reviewed release commit SHA
78
78
  with:
79
79
  platform: web
80
80
  url: http://127.0.0.1:4180
@@ -33,6 +33,13 @@ export function resolveAdbPath(env = process.env) {
33
33
  return executable("adb", env);
34
34
  }
35
35
 
36
+ /** Resolve the SDK root from the same evidence used to find adb, including common unlinked
37
+ * Homebrew installations. Gradle needs this even when device automation already found adb. */
38
+ export function resolveAndroidSdkRoot(env = process.env) {
39
+ const adbPath = resolveAdbPath(env);
40
+ return adbPath ? path.dirname(path.dirname(adbPath)) : null;
41
+ }
42
+
36
43
  function runFile(command, args, { encoding = "utf8", timeout = 30_000, maxBuffer = 16 * 1024 * 1024 } = {}) {
37
44
  return new Promise((resolve) => {
38
45
  execFile(command, args, { encoding, timeout, maxBuffer }, (error, stdout, stderr) => {
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
3
4
 
4
5
  export const STORAGE_BLOCK_BYTES = 256 * 1024 * 1024;
5
6
  export const STORAGE_WARN_BYTES = 5 * 1024 * 1024 * 1024;
@@ -41,3 +42,37 @@ export function storagePreflight(candidate, { statfs = fs.statfsSync, blockBytes
41
42
  return { ok: true, level: "unknown", path: checkedPath, freeBytes: null, message: `Free disk space could not be checked: ${error.message || String(error)}` };
42
43
  }
43
44
  }
45
+
46
+ /** Find a working Java runtime for repository-owned Android builds, including common unlinked
47
+ * Homebrew and Android Studio installations on macOS. APK/device testing itself does not need it. */
48
+ export function resolveJavaRuntime(env = process.env, { probe = spawnSync, minimumMajor = 17 } = {}) {
49
+ const executable = process.platform === "win32" ? "java.exe" : "java";
50
+ const homes = [
51
+ env.JAVA_HOME,
52
+ process.platform === "darwin" ? "/Applications/Android Studio.app/Contents/jbr/Contents/Home" : "",
53
+ process.platform === "darwin" ? "/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" : "",
54
+ process.platform === "darwin" ? "/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home" : "",
55
+ process.platform === "darwin" ? "/usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" : "",
56
+ process.platform === "darwin" ? "/usr/local/opt/openjdk/libexec/openjdk.jdk/Contents/Home" : "",
57
+ ].filter(Boolean);
58
+ const candidates = [
59
+ ...homes.map((home) => ({ javaHome:home, javaPath:path.join(home, "bin", executable) })),
60
+ ...String(env.PATH || "").split(path.delimiter).filter(Boolean).map((dir) => ({ javaHome:"", javaPath:path.join(dir, executable) })),
61
+ ];
62
+ const seen = new Set();
63
+ for (const candidate of candidates) {
64
+ if (seen.has(candidate.javaPath) || !fs.existsSync(candidate.javaPath)) continue;
65
+ seen.add(candidate.javaPath);
66
+ const checked = probe(candidate.javaPath, ["-version"], { encoding:"utf8" });
67
+ if ((checked.status ?? 1) !== 0) continue;
68
+ const version = String(checked.stderr || checked.stdout || "").split(/\r?\n/)[0].trim();
69
+ const matched = version.match(/version\s+"(\d+)(?:\.(\d+))?/i);
70
+ const major = matched ? Number(matched[1] === "1" ? matched[2] : matched[1]) : 0;
71
+ if (!major || major < minimumMajor) continue;
72
+ let javaPath = candidate.javaPath;
73
+ try { javaPath = fs.realpathSync(javaPath); } catch { /* preserve the discovered path */ }
74
+ const javaHome = candidate.javaHome || path.dirname(path.dirname(javaPath));
75
+ return { javaPath, javaHome, version, major };
76
+ }
77
+ return null;
78
+ }
@@ -418,9 +418,24 @@ export async function buildAndroidApp({ projectDir, gradleProjectDir, moduleDir,
418
418
  const wrapper = path.join(gradleRoot, process.platform === "win32" ? "gradlew.bat" : "gradlew");
419
419
  const command = fs.existsSync(wrapper) ? (process.platform === "win32" ? wrapper : "bash") : "gradle";
420
420
  const args = fs.existsSync(wrapper) && process.platform !== "win32" ? [wrapper, task, "--no-daemon"] : [task, "--no-daemon"];
421
- const build = await runCommand(command, args, { cwd: gradleRoot, timeoutMs: 25 * 60 * 1000 });
421
+ const { resolveJavaRuntime } = await import("./environment-preflight.js");
422
+ const java = resolveJavaRuntime();
423
+ if (!java) return { error:"Android source builds need a working Java runtime. Install JDK 17 or set JAVA_HOME; an already-built APK can still be tested directly." };
424
+ const { resolveAndroidSdkRoot } = await import("./android-driver.js");
425
+ const androidSdkRoot = resolveAndroidSdkRoot();
426
+ if (!androidSdkRoot) return { error:"Android source builds need an Android SDK root. Install SDK platform-tools or set ANDROID_SDK_ROOT/ANDROID_HOME; an already-built APK can still be tested directly." };
427
+ const build = await runCommand(command, args, {
428
+ cwd:gradleRoot,
429
+ timeoutMs:25 * 60 * 1000,
430
+ env:{
431
+ JAVA_HOME:java.javaHome,
432
+ ANDROID_HOME:androidSdkRoot,
433
+ ANDROID_SDK_ROOT:androidSdkRoot,
434
+ PATH:`${path.dirname(java.javaPath)}${path.delimiter}${process.env.PATH || ""}`,
435
+ },
436
+ });
422
437
  if (build.code !== 0) {
423
- const errors = `${build.stdout}\n${build.stderr}`.split("\n").filter((line) => /(?:error|failure|exception)/i.test(line)).slice(-10);
438
+ const errors = `${build.stdout}\n${build.stderr}`.split("\n").filter((line) => /(?:error|failure|exception|sdk location|could not|not found)/i.test(line)).slice(-10);
424
439
  return { error: `Android build failed (${task})${build.timedOut ? " — timed out" : ""}`, details: { errors, tail: (build.stderr || build.stdout || "").slice(-1800) } };
425
440
  }
426
441
  const apkPath = androidApkCandidates(moduleRoot)[0];
@@ -428,6 +443,34 @@ export async function buildAndroidApp({ projectDir, gradleProjectDir, moduleDir,
428
443
  return { apkPath, task, gradleProjectDir: gradleRoot, moduleDir: moduleRoot };
429
444
  }
430
445
 
446
+ /** Resolve one reviewed Android target from the repository model and build its installable APK. */
447
+ export async function prepareAndroidInteractiveTarget({ projectDir = process.cwd(), target = "", onStatus = () => {}, build = buildAndroidApp } = {}) {
448
+ let root;
449
+ try { root = fs.realpathSync(path.resolve(projectDir)); }
450
+ catch { return { error:`Repository directory not found: ${projectDir}` }; }
451
+ const modelPath = existingProjectArtifactPath(root, "application-model.json");
452
+ if (!fs.existsSync(modelPath)) return { error:"No application model found — run `npx -y @aarwitz/tapp@latest init . --explore --platform android` first." };
453
+ let model;
454
+ try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
455
+ catch (error) { return { error:`Application model is unreadable: ${error.message || String(error)}` }; }
456
+ const { selectApplicationTarget } = await import("./ci-setup.js");
457
+ let selected;
458
+ try { selected = selectApplicationTarget(model, { platform:"android", target, useDefault:true }); }
459
+ catch (error) { return { error:error.message || String(error), details:error.details || {} }; }
460
+ const appId = String(selected.runtime?.applicationId || "").trim();
461
+ if (!appId) return { error:`The Android target '${selected.name}' has no confirmed application id — rerun init or provide --app-id.` };
462
+ const task = selected.build?.task || "assembleDebug";
463
+ onStatus(`Building the Android APK for ${selected.name} (${task})…`);
464
+ const built = await build({
465
+ projectDir:root,
466
+ gradleProjectDir:path.resolve(root, selected.build?.projectDir || "."),
467
+ moduleDir:path.resolve(root, selected.sourcePath || "."),
468
+ task,
469
+ });
470
+ if (built.error) return built;
471
+ return { ...built, appId, selectedTarget:selected };
472
+ }
473
+
431
474
  export async function installAppOnBootedSim(appPath, { cleanInstall = true } = {}) {
432
475
  const bid = await runCommand("/usr/libexec/PlistBuddy", ["-c", "Print CFBundleIdentifier", path.join(appPath, "Info.plist")], { timeoutMs: 30_000 });
433
476
  const bundleId = (bid.stdout || "").trim();
@@ -762,6 +805,32 @@ async function startWebSession(url, { testEmail = "", testPassword = "" } = {})
762
805
  }
763
806
  }
764
807
 
808
+ /**
809
+ * Start a persistent web session from an owned repository target. The managed runtime belongs to
810
+ * the session and is stopped by endSession(), so MCP/CLI callers cannot strand a development
811
+ * server when focus succeeds, fails, or the client explicitly ends the session.
812
+ */
813
+ export async function startManagedWebInteractiveSession({
814
+ projectDir = process.cwd(), requestedTarget = "", timeout,
815
+ testEmail = "", testPassword = "", onStatus = () => {},
816
+ } = {}) {
817
+ let root;
818
+ try { root = fs.realpathSync(path.resolve(projectDir)); }
819
+ catch { return { error:`Repository directory not found: ${projectDir}` }; }
820
+ const runtime = await startManagedWebTarget({ root, requestedTarget, timeout, onStatus });
821
+ if (runtime.error) return runtime;
822
+ const session = await startWebSession(runtime.url, { testEmail, testPassword });
823
+ if (session.error) {
824
+ await stopManagedWebTarget(runtime);
825
+ return session;
826
+ }
827
+ if (activeSession?.platform === "web") activeSession.managedRuntime = runtime;
828
+ return {
829
+ ...session,
830
+ managedRuntime:{ url:runtime.url, logPath:runtime.logPath, start:runtime.start },
831
+ };
832
+ }
833
+
765
834
  /** Turn a typed value into a shareable token: known creds become $TEST_EMAIL / $TEST_PASSWORD. */
766
835
  function templateValue(text) {
767
836
  const c = (activeSession && activeSession.creds) || {};
@@ -1001,6 +1070,7 @@ async function endSession() {
1001
1070
  if (s.platform === "web") {
1002
1071
  activeSession = null;
1003
1072
  await s.browser.close().catch(() => {});
1073
+ if (s.managedRuntime) await stopManagedWebTarget(s.managedRuntime).catch(() => {});
1004
1074
  return { ok:true };
1005
1075
  }
1006
1076
  if (s.platform === "android") {
@@ -2964,7 +3034,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2964
3034
  name: "tapp_session_start",
2965
3035
  title: "Start interactive session",
2966
3036
  description:
2967
- "Start a PERSISTENT interactive session against an installed iOS/Android app or a web URL. The app " +
3037
+ "Start a PERSISTENT interactive session against an installed iOS/Android app, a web URL, or an " +
3038
+ "unambiguous managed web target in the MCP workspace. The app " +
2968
3039
  "launches once and stays up, so you can drive a Playwright-style tap → inspect loop without a cold " +
2969
3040
  "launch per action. Returns the initial screen {screenTitle, elements[]}. Drive it with " +
2970
3041
  "tapp_focus for any named destination (source + shortest observed route), then tapp_session_act only for remaining actions; finish with tapp_session_end. " +
@@ -2978,7 +3049,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2978
3049
  authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
2979
3050
  appBundleId: { type: "string", description: "Bundle id of the installed app to drive" },
2980
3051
  androidAppId: { type: "string", description: "Android application id to drive (alternative to appBundleId)" },
2981
- url: { type: "string", description: "Owned http(s) web app URL to drive (alternative to appBundleId/androidAppId)" },
3052
+ url: { type: "string", description: "Owned http(s) web app URL to drive (alternative to appBundleId/androidAppId); omit all three target identifiers to build/start an unambiguous owned web target from projectDir" },
3053
+ target: { type: "string", description: "Optional managed-web target name/path when projectDir contains multiple browser applications" },
2982
3054
  apkPath: { type: "string", description: "Android APK to install before starting" },
2983
3055
  androidSerial: { type: "string", description: "Android adb device serial" },
2984
3056
  clearData: { type: "boolean", default: true, description: "Android: clear app data before launch" },
@@ -4100,7 +4172,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4100
4172
  }
4101
4173
  const maxWidth = Math.max(200, Math.min(1400, asInteger(args.maxWidth, 700)));
4102
4174
  const r = await openApp(String(args.appBundleId).trim(), explorationEnvFromArgs(args), maxWidth);
4103
- if (r.error) return errorResult(r.error);
4175
+ if (r.error) return errorResult(r.error, r.details || {});
4104
4176
  const content = [];
4105
4177
  content.push({ type: "text", text: `🚀 Launched \`${String(args.appBundleId).trim()}\`\n\n` + formatScreen(r.screenTitle, r.elements) });
4106
4178
  if (r.img && !r.img.error) content.push({ type: "image", data: r.img.data, mimeType: r.img.mimeType });
@@ -4201,10 +4273,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4201
4273
  const ios = isNonEmptyString(args.appBundleId);
4202
4274
  const android = isNonEmptyString(args.androidAppId);
4203
4275
  const web = isNonEmptyString(args.url);
4204
- if ([ios, android, web].filter(Boolean).length !== 1) return errorResult("Provide exactly one of appBundleId, androidAppId, or url");
4205
- const target = ios ? args.appBundleId.trim() : android ? args.androidAppId.trim() : args.url.trim();
4276
+ if ([ios, android, web].filter(Boolean).length > 1) return errorResult("Provide at most one of appBundleId, androidAppId, or url");
4277
+ const managedWeb = !ios && !android && !web;
4278
+ let target = ios ? args.appBundleId.trim() : android ? args.androidAppId.trim() : web ? args.url.trim() : "managed web target";
4206
4279
  let focusProjectDir = workspaceRoot;
4207
- if (isNonEmptyString(args.focus)) {
4280
+ if (isNonEmptyString(args.focus) || managedWeb) {
4208
4281
  try { focusProjectDir = fs.realpathSync(path.resolve(workspaceRoot, isNonEmptyString(args.projectDir) ? args.projectDir.trim() : ".")); }
4209
4282
  catch { return errorResult("projectDir must be an existing directory inside the workspace"); }
4210
4283
  if (!isInsideDir(workspaceRoot, focusProjectDir) || !fs.statSync(focusProjectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
@@ -4213,8 +4286,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4213
4286
  ? await startSession(target, explorationEnvFromArgs(args))
4214
4287
  : android
4215
4288
  ? await startAndroidSession(target, { serial: args.androidSerial, apkPath: args.apkPath, clearData: args.clearData !== false, testEmail: args.testEmail, testPassword: args.testPassword })
4216
- : await startWebSession(target, { testEmail:args.testEmail, testPassword:args.testPassword });
4217
- if (r.error) return errorResult(r.error);
4289
+ : web
4290
+ ? await startWebSession(target, { testEmail:args.testEmail, testPassword:args.testPassword })
4291
+ : await startManagedWebInteractiveSession({
4292
+ projectDir:focusProjectDir,
4293
+ requestedTarget:isNonEmptyString(args.target) ? args.target.trim() : "",
4294
+ testEmail:args.testEmail,
4295
+ testPassword:args.testPassword,
4296
+ });
4297
+ if (r.error) return errorResult(r.error, r.details || {});
4298
+ if (managedWeb) target = r.url || r.managedRuntime?.url || target;
4218
4299
  let focused = null;
4219
4300
  if (isNonEmptyString(args.focus)) {
4220
4301
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
4
4
  "mcpName": "io.github.aarwitz/tapp",
5
5
  "description": "Let coding agents verify UI changes on real iOS, Android, and web surfaces, then enforce reviewed proof in deterministic CI.",
6
6
  "license": "MIT",
@@ -38,7 +38,9 @@ When the user asks for a general first test of a repository:
38
38
  `npx -y @aarwitz/tapp@latest doctor`, apply only the stated remediation that is in scope, and
39
39
  retry once.
40
40
 
41
- For a focused request, use the requested target directly rather than forcing repository onboarding.
41
+ For a focused request in an already-grounded repository, use the requested target directly rather
42
+ than starting another broad exploration. If `.tapp/ui-map.json` does not exist yet, ground it once
43
+ with `init . --explore`; source alone can locate a surface but cannot authorize unobserved taps.
42
44
  Targets may be a repository path, Xcode container, `.app`, iOS bundle id, APK plus Android app id,
43
45
  or owned HTTP(S) URL. Never explore a third-party web property without authorization: exploration
44
46
  clicks and types.
@@ -48,8 +50,10 @@ clicks and types.
48
50
  When the user names a screen, control, or UI condition, do not discover the app one screenshot at a
49
51
  time. Start from the repository source, then use Tapp's observed navigation evidence:
50
52
 
51
- 1. With MCP, pass the exact request as `focus` to `tapp_session_start`, or call `tapp_focus` in an
52
- active session. Without MCP, run `npx -y @aarwitz/tapp@latest focus "<exact request>" [target]`.
53
+ 1. In a source repository, run `npx -y @aarwitz/tapp@latest focus "<exact request>" [target]`; it
54
+ selects the reviewed model target and prepares web, iOS, or Android from source. In an active MCP
55
+ session call `tapp_focus`; for managed web, `tapp_session_start` can also take `projectDir` and
56
+ `focus` without retyping the URL.
53
57
  2. Tapp searches owned source, reconciles the likely surface with `.tapp/ui-map.json`, and executes
54
58
  the shortest runtime-observed route in one call. Read its final tree before visual assertions.
55
59
  3. If Tapp returns source evidence but no replayable route, inspect the cited file/line and relevant
@@ -91,5 +95,4 @@ content. Use coordinates only as a last resort. End the session when finished.
91
95
  Do not edit the app merely because testing found a defect unless the user also asked for a fix. State
92
96
  what the evidence proves and what remains untested.
93
97
 
94
- Read [references/commands.md](references/commands.md) only when exact CLI/MCP syntax, Flow replay,
95
- credentials, or platform prerequisites are needed.
98
+ Read [references/commands.md](references/commands.md) only for exact CLI/MCP syntax, Flow replay, credentials, or platform prerequisites.
@@ -27,7 +27,10 @@ npx -y @aarwitz/tapp@latest explore https://staging.example.com
27
27
  npx -y @aarwitz/tapp@latest explore MyApp.xcodeproj --platform ios
28
28
  npx -y @aarwitz/tapp@latest open com.example.MyApp --platform ios
29
29
 
30
- # Android: app id is required; APK is optional if already installed.
30
+ # Android source: select a modeled target; Tapp builds, installs, and focuses it.
31
+ npx -y @aarwitz/tapp@latest focus "About screen" . --target demoapp
32
+
33
+ # Android black-box entry: app id is required; APK is optional if already installed.
31
34
  npx -y @aarwitz/tapp@latest explore app-debug.apk --platform android --app-id com.example.app
32
35
  ```
33
36
 
@@ -51,8 +54,9 @@ npx -y @aarwitz/tapp@latest open https://example.com --tap "Not now" --wait-for
51
54
  - `tapp_ci_setup`: create a target-scoped baseline or reviewable CI installation.
52
55
 
53
56
  An iOS no-bundle-id MCP path is `tapp_build {projectDir:"."}` followed by `tapp_explore` with the
54
- returned `bundleId`. Android uses `androidAppId` and optional `apkPath`; web uses `url` or
55
- source-connected `tapp_init`.
57
+ returned `bundleId`. Android uses `androidAppId` and optional `apkPath`. Web can use an explicit
58
+ owned `url`, or `tapp_session_start {projectDir:".", focus:"..."}` can build/start one unambiguous
59
+ owned browser target and stop it with `tapp_session_end`.
56
60
 
57
61
  ## Interactive session loop
58
62
 
@@ -98,7 +102,8 @@ or `inconclusive`; both `fail` and `inconclusive` block a merge.
98
102
  ## Platform prerequisites
99
103
 
100
104
  - iOS: macOS, Xcode, and a booted simulator. First use builds a cached harness under `~/.tapp`.
101
- - Android: `adb` and a connected authorized emulator/device.
105
+ - Android: `adb` and a connected authorized emulator/device; JDK 17 for source builds (not for a
106
+ prebuilt APK).
102
107
  - Web: Playwright and Chromium. If Tapp reports the browser missing, run
103
108
  `npx playwright install chromium` and retry.
104
109