@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.
- package/.claude-plugin/plugin.json +2 -2
- package/AGENTS.md +6 -5
- package/README.md +4 -3
- package/bin/tapp.js +109 -12
- package/docs/scenarios.md +1 -1
- package/mcp-server/src/android-driver.js +7 -0
- package/mcp-server/src/environment-preflight.js +35 -0
- package/mcp-server/src/index.js +91 -10
- package/package.json +1 -1
- package/skills/tapp/SKILL.md +8 -5
- package/skills/tapp/references/commands.md +9 -4
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
562
|
-
if (
|
|
563
|
-
|
|
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
|
|
568
|
-
|
|
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
|
-
|
|
571
|
-
|
|
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.
|
|
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
|
+
}
|
package/mcp-server/src/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
4205
|
-
const
|
|
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
|
-
:
|
|
4217
|
-
|
|
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.
|
|
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",
|
package/skills/tapp/SKILL.md
CHANGED
|
@@ -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
|
|
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.
|
|
52
|
-
|
|
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
|
|
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:
|
|
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
|
|
55
|
-
|
|
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
|
|