@aarwitz/tapp 0.17.18 → 0.17.19

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.18",
4
+ "version": "0.17.19",
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.18",
27
+ "@aarwitz/tapp@0.17.19",
28
28
  "mcp"
29
29
  ],
30
30
  "cwd": "${CLAUDE_PROJECT_DIR}"
package/AGENTS.md CHANGED
@@ -24,6 +24,11 @@ npx -y @aarwitz/tapp@latest explore app.apk --platform android --app-id com.acme
24
24
  npx -y @aarwitz/tapp@latest flow run .tapp/flows/smoke.yml # committed, keyless E2E replay
25
25
  ```
26
26
 
27
+ On iOS, `tree` only uses an installed app: it never builds, uninstalls, or replaces it. With
28
+ no target it uses the repository's recorded iOS target or the sole installed app; ambiguity
29
+ requires a choice. Pass a bundle id to inspect a particular sandbox build. To replace a build,
30
+ run `tapp build` explicitly first.
31
+
27
32
  If repository onboarding detects multiple application targets, target detection is deterministic but
28
33
  the choice is the user's. Explicit `init --explore` asks even when the model has a saved default; a
29
34
  later bare `explore` may consume that default. In a human TTY, Tapp displays a numbered selector and
@@ -994,6 +994,9 @@ class ExplorerTests: XCTestCase {
994
994
  // Per-flow wait default (field issue #20): a splash that prefetches for ~8s makes the
995
995
  // fixed 6s wait_for fail on a healthy app. Steps may still override individually.
996
996
  let flowDefaultTimeoutMs = (flow["timeoutMs"] as? Int) ?? (flow["timeout"] as? Int) ?? 6000
997
+ // XCTest runs teardown blocks even when a step aborts unexpectedly. Keep the final
998
+ // visible frame inspectable for both successful and failing replays (public issue #26).
999
+ addTeardownBlock { [self] in recordFlowScreenshot(name: "flow-final") }
997
1000
 
998
1001
  // Variable substitution: $TEST_EMAIL/$TEST_PASSWORD from creds, plus any OCQA_FLOW_VARS.
999
1002
  var vars: [String: String] = ["TEST_EMAIL": resolve("OCQA_TEST_EMAIL", fallback: "test@example.com"),
@@ -1121,17 +1124,7 @@ class ExplorerTests: XCTestCase {
1121
1124
  // Web and Android flows write flow-failure-N.png next to the log; iOS buried the
1122
1125
  // evidence in the .xcresult, so seeing the failing screen needed a second run
1123
1126
  // (field issue #8). Write the same file, and keep the XCTest attachment too.
1124
- let failureShot = app.screenshot()
1125
- let failureAttachment = XCTAttachment(screenshot: failureShot)
1126
- failureAttachment.name = "flow_failure_\(idx)"
1127
- failureAttachment.lifetime = .keepAlways
1128
- add(failureAttachment)
1129
- let evidenceDir = resolve("OCQA_FLOW_EVIDENCE_DIR")
1130
- if !evidenceDir.isEmpty {
1131
- let destination = URL(fileURLWithPath: evidenceDir).appendingPathComponent("flow-failure-\(idx).png")
1132
- try? FileManager.default.createDirectory(at: URL(fileURLWithPath: evidenceDir), withIntermediateDirectories: true)
1133
- try? failureShot.pngRepresentation.write(to: destination)
1134
- }
1127
+ recordFlowScreenshot(name: "flow-failure-\(idx)")
1135
1128
  // A failed step surfaces as a finding, with the same shape QA findings use.
1136
1129
  let screen = detectTitle(readUITree(app)) ?? "Unknown"
1137
1130
  print("OCQA_ISSUE:{\"type\":\"flow_assertion_failed\",\"severity\":\"high\",\"title\":\"\(escapeJSON("Step \(idx) (\(action)) failed: \(detail)"))\",\"screen\":\"\(escapeJSON(screen))\",\"step\":\(idx)}")
@@ -1141,13 +1134,29 @@ class ExplorerTests: XCTestCase {
1141
1134
  waitForAnimationsToSettle()
1142
1135
  }
1143
1136
 
1144
- let shot = app.screenshot()
1145
- let att = XCTAttachment(screenshot: shot); att.name = "flow_final"; att.lifetime = .keepAlways; add(att)
1146
1137
  let contractResult = contractName.isEmpty ? "" : ",\"contract\":\"\(escapeJSON(contractName))\",\"criticality\":\"\(escapeJSON(contractCriticality))\""
1147
1138
  print("OCQA_FLOW_RESULT:{\"passed\":\(failed == 0),\"name\":\"\(escapeJSON(flow["name"] as? String ?? "flow"))\",\"kind\":\"\(escapeJSON(evidenceKind))\"\(contractResult),\"total\":\(steps.count),\"executed\":\(executed),\"failed\":\(failed)}")
1148
1139
  if failed > 0 { XCTFail("Flow had \(failed) failed step(s)") }
1149
1140
  }
1150
1141
 
1142
+ private func recordFlowScreenshot(name: String) {
1143
+ // Screen capture also works after an app crash and includes system sheets covering it.
1144
+ let shot = XCUIScreen.main.screenshot()
1145
+ let attachment = XCTAttachment(screenshot: shot)
1146
+ attachment.name = name.replacingOccurrences(of: "-", with: "_")
1147
+ attachment.lifetime = .keepAlways
1148
+ add(attachment)
1149
+ let evidenceDir = resolve("OCQA_FLOW_EVIDENCE_DIR")
1150
+ guard !evidenceDir.isEmpty else { return }
1151
+ do {
1152
+ let directory = URL(fileURLWithPath: evidenceDir)
1153
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
1154
+ try shot.pngRepresentation.write(to: directory.appendingPathComponent("\(name).png"), options: .atomic)
1155
+ } catch {
1156
+ print("OCQA_EVIDENCE_WARNING:Could not write \(name).png: \(error.localizedDescription); screenshot retained in result.xcresult")
1157
+ }
1158
+ }
1159
+
1151
1160
  /// Accepts both sugar (`{ tap: "Sign In" }`) and explicit (`{ action: "tap", target: "Sign In" }`).
1152
1161
  private func normalizeFlowStep(_ raw: [String: Any]) -> (String, [String: Any]) {
1153
1162
  if let action = raw["action"] as? String { return (action.lowercased(), raw) }
@@ -1569,9 +1578,20 @@ class ExplorerTests: XCTestCase {
1569
1578
  return
1570
1579
  }
1571
1580
 
1572
- // Trigger the interruption monitor on any pending system alerts
1573
- app.tap()
1574
- Thread.sleep(forTimeInterval: 0.3)
1581
+ // Dismiss an observed system alert directly. A blind app.tap() here used to press
1582
+ // whichever app control occupied the center BEFORE the first screen was recorded,
1583
+ // so the explorer could then flag its own second tap as unresponsive (issue #27).
1584
+ let startupAlert = XCUIApplication(bundleIdentifier: "com.apple.springboard").alerts.firstMatch
1585
+ if startupAlert.exists {
1586
+ for label in ["Allow While Using App", "Allow Once", "OK", "Allow", "Don't Allow"] {
1587
+ let button = startupAlert.buttons[label]
1588
+ if button.exists && button.isHittable {
1589
+ button.tap()
1590
+ waitForAnimationsToSettle()
1591
+ break
1592
+ }
1593
+ }
1594
+ }
1575
1595
 
1576
1596
  // Record the TRUE initial screen (e.g. a "Get Started" welcome sheet) before
1577
1597
  // navigateToRootScreen() below auto-dismisses it. Without this, grounding (AI-generate,
@@ -2677,7 +2697,8 @@ class ExplorerTests: XCTestCase {
2677
2697
  dismissKeyboardIfNeeded()
2678
2698
  }
2679
2699
 
2680
- let preContentSig = contentSignature(elements)
2700
+ // Compare with the screen immediately before the action, after keyboard dismissal.
2701
+ let preContentSig = contentSignature(readUITree(app))
2681
2702
  let actionDesc = performSmartAction(
2682
2703
  on: target,
2683
2704
  in: app,
@@ -2798,8 +2819,8 @@ class ExplorerTests: XCTestCase {
2798
2819
  // value change) is likely a dead control — exactly the "clicking a button does
2799
2820
  // nothing" case. We compare a content signature (labels + values) so controls that
2800
2821
  // only change a value (counters, toggles) are never falsely flagged, exclude
2801
- // selection/value controls, and require a second confirming read to rule out a
2802
- // merely-delayed update. NATIVE buttons only (rawValue:9): web buttons/links live
2822
+ // selection/value controls, and observe unchanged controls for a bounded window
2823
+ // to allow asynchronous updates. NATIVE buttons only (rawValue:9): web buttons/links live
2803
2824
  // inside a WKWebView, whose dynamic DOM changes aren't reliably reflected in the
2804
2825
  // accessibility tree, so we can't measure their responsiveness this way.
2805
2826
  let isButton = target.type.contains("rawValue: 9")
@@ -2816,12 +2837,11 @@ class ExplorerTests: XCTestCase {
2816
2837
  && !isInsideSelectionContainer(target)
2817
2838
  && !isSystemHandoffControl(humanLabel)
2818
2839
  && app.state == .runningForeground && !reportedIssueKeys.contains(noOpKey) {
2819
- let post1 = readUITree(app)
2820
- if !post1.isEmpty, contentSignature(post1) == preContentSig {
2821
- // Confirm it's genuinely inert, not just a delayed update.
2822
- Thread.sleep(forTimeInterval: 0.6)
2823
- let post2 = readUITree(app)
2824
- if app.state == .runningForeground, !post2.isEmpty, contentSignature(post2) == preContentSig {
2840
+ let remaining = timeoutSeconds - (Date().timeIntervalSince(startTime) - totalWaitSeconds)
2841
+ // A shortened observation is inconclusive, never evidence of a dead control.
2842
+ if remaining >= 8.0 {
2843
+ let response = observeControlResponse(previousContent: preContentSig)
2844
+ if response == .unchanged {
2825
2845
  reportedIssueKeys.insert(noOpKey)
2826
2846
  // A dead NAVIGATION control (Back/Close/Done/Cancel) is worse than a dead
2827
2847
  // feature button: it strands the user on the screen (and strands this
@@ -2832,7 +2852,7 @@ class ExplorerTests: XCTestCase {
2832
2852
  let issueTitle = isNavControl
2833
2853
  ? "Navigation control does nothing: '\(humanLabel)' — users may be stuck on this screen"
2834
2854
  : "Control may be unresponsive: '\(humanLabel)'"
2835
- let issueDesc = "Tapping '\(humanLabel)' on '\(titleStr)' produced no visible change (no navigation, content, or state change)."
2855
+ let issueDesc = "Tapping '\(humanLabel)' on '\(titleStr)' produced no observable navigation, content, selection, or enabled-state change within 8 seconds."
2836
2856
  issues.append((type: "unresponsive_element", severity: sev, title: issueTitle, desc: issueDesc))
2837
2857
  print("OCQA_ISSUE:{\"type\":\"unresponsive_element\",\"severity\":\"\(sev)\",\"title\":\"\(escapeJSON(issueTitle))\",\"screen\":\"\(escapedTitle)\",\"control\":\"\(escapeJSON(humanLabel))\",\"step\":\(actionCount)}")
2838
2858
  }
@@ -3326,7 +3346,28 @@ class ExplorerTests: XCTestCase {
3326
3346
  /// text). Used to tell a genuinely dead button ("nothing happened") from one that only changed
3327
3347
  /// a value.
3328
3348
  private func contentSignature(_ elements: [SimpleElement]) -> String {
3329
- elements.map { "\($0.type)|\($0.identifier)|\($0.label)|\($0.value)" }.sorted().joined(separator: "~")
3349
+ elements.map { "\($0.type)|\($0.identifier)|\($0.label)|\($0.value)|\($0.isSelected)|\($0.isEnabled)" }.sorted().joined(separator: "~")
3350
+ }
3351
+
3352
+ private enum ControlResponse { case changed, unchanged, unobserved }
3353
+
3354
+ /// Stable element counts only establish that animations settled; they say nothing about a
3355
+ /// pending network response. Exit immediately on a semantic change, but require the full
3356
+ /// observation window before reporting a no-op. Missing trees and ongoing loading cannot
3357
+ /// establish that a control is inert.
3358
+ private func observeControlResponse(previousContent: String, timeout: TimeInterval = 8.0) -> ControlResponse {
3359
+ guard !previousContent.isEmpty else { return .unobserved }
3360
+ let deadline = Date().addingTimeInterval(timeout)
3361
+ while true {
3362
+ guard app.state == .runningForeground else { return .unobserved }
3363
+ let current = readUITree(app)
3364
+ guard !current.isEmpty else { return .unobserved }
3365
+ if contentSignature(current) != previousContent { return .changed }
3366
+ if Date() >= deadline {
3367
+ return hasIndeterminateLoadingIndicator() ? .unobserved : .unchanged
3368
+ }
3369
+ Thread.sleep(forTimeInterval: min(0.25, max(0, deadline.timeIntervalSinceNow)))
3370
+ }
3330
3371
  }
3331
3372
 
3332
3373
  /// Machine identifiers sometimes leak into a11y titles — mangled runtime type names
package/README.md CHANGED
@@ -163,8 +163,8 @@ configuration and existing-file collisions, and never commits, pushes, enables b
163
163
  or creates GitHub resources. Review and pin the generated Tapp release reference to its immutable
164
164
  commit SHA before production.
165
165
 
166
- Every verb takes whatever you have: nothing (auto-detects the repo you're in, or the app
167
- already on the simulator), a repo directory, a `path/to/App.app`, or a bundle id:
166
+ `open` and `explore` accept a repo directory, a `path/to/App.app`, a bundle id, or no target
167
+ (auto-detecting the repository or installed app):
168
168
 
169
169
  ```bash
170
170
  npx -y @aarwitz/tapp@latest open [target] # launch the app → screen summary + screenshot file
@@ -174,6 +174,14 @@ npx -y @aarwitz/tapp@latest apps # what's installed on the simulator
174
174
  npx -y @aarwitz/tapp@latest build [dir] # just build + install (scheme auto-detected)
175
175
  ```
176
176
 
177
+ On iOS, `tree` inspects an installed app and never builds, uninstalls, or replaces it. With no
178
+ target, it uses the repository's recorded iOS target or the sole installed app. If the choice
179
+ is ambiguous, it lists the installed apps for you to select. Pass a bundle id to inspect a
180
+ specific sandbox build; run `tapp build` explicitly when you intend to replace it.
181
+
182
+ iOS Flow evidence includes `flow-final.png` on passing and failing replays, plus
183
+ `flow-failure-N.png` at a failed step, alongside the log, JSON report, and XCTest result bundle.
184
+
177
185
  Web: `npx -y @aarwitz/tapp@latest explore http://localhost:3000` *(one-time setup:
178
186
  `npm i -g playwright && npx playwright install chromium`)*. Add `--watch` to open Tapp's controlled,
179
187
  isolated Chromium window and follow its clicks with an on-page pointer/action label. Tapp hides that
package/bin/tapp.js CHANGED
@@ -222,7 +222,7 @@ function printEngineError(r) {
222
222
  console.error(" Available targets:");
223
223
  for (const choice of choices) {
224
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)}` : ""}`);
225
+ console.error(` ${choice.platform ? `${choice.platform} · ` : ""}${choice.name || choice.id}${choice.sourcePath ? ` (${choice.sourcePath})` : ""}${choice.command ? ` — ${choice.command}` : selector ? ` — use --target ${JSON.stringify(selector)}` : ""}`);
226
226
  }
227
227
  }
228
228
  if (r.details?.remediation) console.error(` Next: ${r.details.remediation}`);
@@ -250,8 +250,28 @@ async function promptForInitTarget(details) {
250
250
 
251
251
  // Turn whatever the user gave us (nothing / repo dir / .app / bundle id) into an installed
252
252
  // bundle id, narrating build/install progress on stderr.
253
- async function resolveTargetOrExit(engine, input) {
254
- const resolved = await engine.resolveAppTarget(input || "", { onStatus: (s) => console.error(`⏳ ${s}`) });
253
+ async function resolveTargetOrExit(engine, input, { inspect = false } = {}) {
254
+ let resolved = inspect
255
+ ? await engine.resolveInstalledAppTarget(input || "")
256
+ : await engine.resolveAppTarget(input || "", { onStatus: (s) => console.error(`⏳ ${s}`) });
257
+ if (inspect && resolved.details?.choices?.length && process.stdin.isTTY && process.stderr.isTTY && !process.env.CI) {
258
+ const { createInterface } = await import("node:readline/promises");
259
+ const terminal = createInterface({ input: process.stdin, output: process.stderr });
260
+ const choices = resolved.details.choices;
261
+ console.error(`\n${resolved.error}`);
262
+ choices.forEach((choice, index) => console.error(` ${index + 1}) ${choice.name} (${choice.bundleId})`));
263
+ try {
264
+ while (true) {
265
+ const answer = String(await terminal.question(`Select 1-${choices.length} (or q to cancel): `)).trim();
266
+ if (/^(q|quit|cancel)$/i.test(answer)) break;
267
+ const selected = Number(answer);
268
+ if (Number.isInteger(selected) && selected >= 1 && selected <= choices.length) {
269
+ resolved = await engine.resolveInstalledAppTarget(choices[selected - 1].bundleId);
270
+ break;
271
+ }
272
+ }
273
+ } finally { terminal.close(); }
274
+ }
255
275
  if (resolved.error) {
256
276
  printEngineError(resolved);
257
277
  process.exit(1);
@@ -266,11 +286,11 @@ function safeCommandUsage(verb) {
266
286
  focus: "tapp focus \"SCREEN OR CONTROL\" [target] [--platform ios|android|web] [--project-dir REPO] [--target NAME|PATH] [--map FILE] [--out FILE]",
267
287
  init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--watch] [--dry-run]",
268
288
  open: "tapp open [target] [--platform ios|android|web] [--out FILE] [--tap TEXT] [--wait-for TEXT]\n Web: [--device \"iPhone 13\"] [--viewport 390x844] [--full-page]",
269
- tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]\n Web: [--device \"iPhone 13\"] [--viewport 390x844]",
289
+ tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]\n iOS: inspects an installed app; never builds or installs. Use tapp build explicitly first if needed.\n Web: [--device \"iPhone 13\"] [--viewport 390x844]",
270
290
  shot: "tapp shot [--out FILE]",
271
291
  apps: "tapp apps",
272
292
  build: "tapp build [repo] [--scheme NAME] [--configuration NAME]",
273
- flow: "tapp flow example\ntapp flow steps [--json]\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--actor NAME] [--email VALUE] [--password VALUE] [--device \"iPhone 13\"] [--viewport 390x844]\n Exit codes: 0 replay passed · 1 replay failed · 2 infrastructure/usage error",
293
+ flow: "tapp flow example\ntapp flow steps [--json]\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--actor NAME] [--email VALUE] [--password VALUE] [--device \"iPhone 13\"] [--viewport 390x844]\n iOS: [--launch-arg ARG] [--launch-env JSON]\n Exit codes: 0 replay passed · 1 replay failed · 2 infrastructure/usage error",
274
294
  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]",
275
295
  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]\n Exit codes: 0 contract held · 1 contract failed · 2 infrastructure/usage error",
276
296
  scenario: "tapp scenario validate FILE [--project-dir DIR]\ntapp scenario run FILE --platform web --url URL [--project-dir DIR]\n Exit codes: 0 scenario passed · 1 scenario failed · 2 infrastructure/usage error",
@@ -962,7 +982,7 @@ switch (command) {
962
982
  console.error(`❌ ${sim.error}`);
963
983
  process.exit(1);
964
984
  }
965
- const bundleId = await resolveTargetOrExit(engine, positionals[0]);
985
+ const bundleId = await resolveTargetOrExit(engine, positionals[0], { inspect: true });
966
986
  const r = await engine.captureUiTree(bundleId);
967
987
  if (r.error) {
968
988
  console.error(`❌ ${r.error}`);
@@ -1230,6 +1250,11 @@ switch (command) {
1230
1250
  const evidenceDir = path.join(tappHome, "captures", `flow-${platform}-${token}`);
1231
1251
  fs.mkdirSync(path.dirname(flowLog), { recursive: true });
1232
1252
  const env = { ...process.env, FLOW_LOG: flowLog, TAPP_FLOW_EVIDENCE_DIR: evidenceDir };
1253
+ if (platform === "ios") {
1254
+ const launch = iosLaunchOptions(flags, rest);
1255
+ if (launch.appLaunchArgs) env.OCQA_APP_LAUNCH_ARGS_JSON = JSON.stringify(launch.appLaunchArgs);
1256
+ if (launch.appLaunchEnv) env.OCQA_APP_LAUNCH_ENV_JSON = JSON.stringify(launch.appLaunchEnv);
1257
+ }
1233
1258
  if (typeof flags.email === "string") env.OCQA_TEST_EMAIL = flags.email;
1234
1259
  if (typeof flags.password === "string") env.OCQA_TEST_PASSWORD = flags.password;
1235
1260
  if (typeof flags.device === "string") env.TAPP_WEB_DEVICE = flags.device;
@@ -137,7 +137,9 @@ function runCommand(command, args = [], options = {}) {
137
137
  const timeoutMessage = timedOut ? `\nProcess timed out after ${timeoutMs}ms` : "";
138
138
  resolve({
139
139
  code: timedOut ? 124 : (code ?? 1),
140
- stdout: clampOutput(stdout),
140
+ // Structured inventories are parsed internally; diagnostic truncation would corrupt
141
+ // their JSON/plist before the caller sees it (large hosted simulator inventories).
142
+ stdout: options.rawStdout ? stdout : clampOutput(stdout),
141
143
  stderr: clampOutput(`${stderr}${timeoutMessage}`.trim()),
142
144
  timedOut,
143
145
  });
@@ -147,7 +149,7 @@ function runCommand(command, args = [], options = {}) {
147
149
  clearTimeout(timer);
148
150
  resolve({
149
151
  code: 1,
150
- stdout: clampOutput(stdout),
152
+ stdout: options.rawStdout ? stdout : clampOutput(stdout),
151
153
  stderr: clampOutput(`${stderr}\n${error.message}`.trim()),
152
154
  timedOut: false,
153
155
  });
@@ -205,7 +207,7 @@ function summarizeCapture(runPath) {
205
207
 
206
208
 
207
209
  async function listSimulators() {
208
- const res = await runCommand("xcrun", ["simctl", "list", "devices", "-j"]);
210
+ const res = await runCommand("xcrun", ["simctl", "list", "devices", "-j"], { rawStdout: true });
209
211
  if (res.code !== 0) return { error: res.stderr || "simctl failed", simulators: [] };
210
212
  let data;
211
213
  try {
@@ -280,12 +282,12 @@ function notInstalledError(bundleId, booted) {
280
282
  // and the tapp_build MCP tool.
281
283
 
282
284
  export async function listInstalledUserApps() {
283
- const r = await runCommand("xcrun", ["simctl", "listapps", "booted"], { timeoutMs: 30_000 });
285
+ const r = await runCommand("xcrun", ["simctl", "listapps", "booted"], { timeoutMs: 30_000, rawStdout: true });
284
286
  if (r.code !== 0) return { error: "Could not list installed apps", details: { stderr: r.stderr } };
285
287
  // simctl emits an old-style plist; plutil converts it.
286
288
  const tmp = path.join(os.tmpdir(), `tapp-apps-${Date.now().toString(36)}.plist`);
287
289
  fs.writeFileSync(tmp, r.stdout);
288
- const conv = await runCommand("plutil", ["-convert", "json", "-o", "-", tmp], { timeoutMs: 30_000 });
290
+ const conv = await runCommand("plutil", ["-convert", "json", "-o", "-", tmp], { timeoutMs: 30_000, rawStdout: true });
289
291
  try { fs.rmSync(tmp, { force: true }); } catch {}
290
292
  let data;
291
293
  try {
@@ -502,6 +504,65 @@ export async function installAppOnBootedSim(appPath, { cleanInstall = true, onSt
502
504
  return { bundleId };
503
505
  }
504
506
 
507
+ // Inspection must never enter the build/install resolver. Even a clean install of the same
508
+ // bundle id can replace a staging binary and destroy its data (public issue #25).
509
+ export async function resolveInstalledAppTarget(input, { cwd = process.cwd(), listApps = listInstalledUserApps, isInstalled = appInstalledOnBootedSim } = {}) {
510
+ const target = String(input || "").trim();
511
+ let bundleId = "";
512
+ let dir = cwd;
513
+ if (target) {
514
+ const candidate = path.resolve(cwd, target);
515
+ if (!target.includes("/") && target.includes(".") && !fs.existsSync(candidate)) {
516
+ // Explicit ids may name system apps too; the user-app picker intentionally omits them.
517
+ if (!await isInstalled(target)) return { error: `${target} is not installed on the booted simulator. Install the intended build first; tree will not replace it.` };
518
+ return { bundleId: target, via: "using the installed app", targetResolution: { kind: "installed-application", bundleId: target } };
519
+ }
520
+ else if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory() && !/\.(app|xcodeproj|xcworkspace)$/.test(candidate)) dir = candidate;
521
+ else return { error: "tree inspects an installed app. Pass its bundle id, or run npx -y @aarwitz/tapp@latest build explicitly before inspecting it." };
522
+ }
523
+ const listed = await listApps();
524
+ if (listed.error) return listed;
525
+ const apps = listed.apps;
526
+ if (!bundleId) {
527
+ const modelPath = existingProjectArtifactPath(dir, "application-model.json");
528
+ if (fs.existsSync(modelPath)) {
529
+ let model;
530
+ try {
531
+ model = JSON.parse(fs.readFileSync(modelPath, "utf8"));
532
+ if (model.kind !== "tapp-application-model" || !Array.isArray(model.targets)) throw new Error("invalid application model");
533
+ } catch (error) {
534
+ return { error: `Cannot read ${modelPath}: ${error.message}. Pass an installed bundle id explicitly.` };
535
+ }
536
+ const targets = model.targets.filter((item) => item.platform === "ios");
537
+ const selected = targets.find((item) => item.id === model.application?.defaultTargetId)
538
+ || (targets.length === 1 ? targets[0] : null);
539
+ bundleId = String(selected?.runtime?.bundleId || "").trim();
540
+ // A repository with multiple/unconfirmed targets must not silently fall back to an
541
+ // unrelated app just because it is the only one installed on this simulator.
542
+ if (!bundleId) return installedAppChoices(apps, "The repository has no unambiguous installed iOS target. Pass the app's bundle id.");
543
+ } else if (target) {
544
+ return installedAppChoices(apps, "This repository has no recorded iOS bundle id. Pass the installed app's bundle id; tree does not build it.");
545
+ } else if (apps.length === 1) {
546
+ bundleId = apps[0].bundleId;
547
+ }
548
+ }
549
+ if (bundleId) {
550
+ if (!apps.some((item) => item.bundleId === bundleId)) return { error: `${bundleId} is not installed on the booted simulator. Install the intended build first; tree will not replace it.` };
551
+ return { bundleId, via: "using the installed app", targetResolution: { kind: "installed-application", bundleId } };
552
+ }
553
+ return installedAppChoices(apps, apps.length ? "Choose the installed app to inspect." : "No user app is installed. Install the intended build first; tree does not build or install apps.");
554
+ }
555
+
556
+ function installedAppChoices(apps, error) {
557
+ return {
558
+ error,
559
+ details: {
560
+ reason: apps.length ? "target-selection-required" : "app-not-installed",
561
+ choices: apps.map((app) => ({ platform: "ios", name: app.name, bundleId: app.bundleId, selector: app.bundleId, command: `npx -y @aarwitz/tapp@latest tree ${app.bundleId}` })),
562
+ },
563
+ };
564
+ }
565
+
505
566
  export async function resolveAppTarget(input, { cwd = process.cwd(), onStatus = () => {}, scheme = "", configuration = "Debug" } = {}) {
506
567
  const t = (input || "").trim();
507
568
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.18",
3
+ "version": "0.17.19",
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",
@@ -98,6 +98,7 @@ TEST_RUNNER_OCQA_CONFIG_PATH="$CFG" xcodebuild test-without-building \
98
98
  [ -n "$RESPONDER_PID" ] && { kill "$RESPONDER_PID" 2>/dev/null; wait "$RESPONDER_PID" 2>/dev/null; }
99
99
 
100
100
  cp "$LOG" "$EVIDENCE_DIR/flow.log"
101
+ grep '^OCQA_EVIDENCE_WARNING:' "$LOG" >&2 || true
101
102
  python3 "$ROOT/scripts/flow_lib.py" report --json "$LOG" > "$EVIDENCE_DIR/flow-report.json"
102
103
  echo ""
103
104
  python3 "$ROOT/scripts/flow_lib.py" report "$LOG"