@aarwitz/tapp 0.17.7 → 0.17.9

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.7",
4
+ "version": "0.17.9",
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.7",
27
+ "@aarwitz/tapp@0.17.9",
28
28
  "mcp"
29
29
  ],
30
30
  "cwd": "${CLAUDE_PROJECT_DIR}"
package/AGENTS.md CHANGED
@@ -133,6 +133,10 @@ advisory). Report the finding counts and coverage; do not invent a scalar or a s
133
133
  (e.g. `["--uitesting"]` if the app has a test bypass), and/or `appLaunchEnv` (e.g. a staging
134
134
  backend URL). If the result shows `inputFieldsEncountered` and you have no credentials, **ask
135
135
  the user** for them rather than re-running blind.
136
+ - Credential surfaces are catalogue-only without credentials, on every platform: with no
137
+ `testEmail`/`testPassword` supplied, exploration records a login/signup form's fields but does
138
+ not type into them, submit them, or open recovery/third-party-auth flows — a bare-app run may
139
+ be pointed at production. Supplying credentials is the explicit opt-in that enables sign-in.
136
140
  - Diff two runs: pass the previous run's `findings` as `baselineFindings` → you get a
137
141
  `regression` **comparison** (`new` / `persisting` / `resolved`) — an observation, not a gate. To gate
138
142
  a merge on regressions, run the CI gate (`tapp ci` / the GitHub Action).
@@ -1327,10 +1327,16 @@ class ExplorerTests: XCTestCase {
1327
1327
  beelineToTarget(target: targetScreen, route: route, actionCount: &actionCount, maxActions: maxActions)
1328
1328
  }
1329
1329
 
1330
+ // Honest stop accounting: "action-budget" only survives when the while-condition itself
1331
+ // ends the loop; every break records what actually stopped exploration so the report can
1332
+ // say "navigation-trap" or "frontier-drained" instead of blessing a 15/20 run "completed".
1333
+ var explorationStopCause = "action-budget"
1334
+ var leftAppObservations = 0
1330
1335
  while actionCount < maxActions {
1331
1336
  // Subtract time spent paused for interactive input so human typing never eats the budget.
1332
1337
  if Date().timeIntervalSince(startTime) - totalWaitSeconds > timeoutSeconds {
1333
1338
  print("OCQA_STATE:timeout_reached")
1339
+ explorationStopCause = "time-budget"
1334
1340
  break
1335
1341
  }
1336
1342
 
@@ -1374,6 +1380,32 @@ class ExplorerTests: XCTestCase {
1374
1380
  }
1375
1381
  }
1376
1382
 
1383
+ // ---- Left-app handoff: an observation, not a defect ----
1384
+ // A tapped control can legitimately hand the user to a system surface or another
1385
+ // app (mail composer, Settings, App Store, share sheet). Without this check the
1386
+ // next tree read sees a backgrounded app, titles the screen "Unknown", and the
1387
+ // hang/blank detectors file false HIGH findings (field report № 6 #26). Record
1388
+ // the handoff against the action that caused it, come back, and keep exploring.
1389
+ if app.state != .runningForeground {
1390
+ leftAppObservations += 1
1391
+ let causeAction = pendingTransitionFrom?.actionKey ?? "previous action"
1392
+ let causeScreen = pendingTransitionFrom?.title ?? "Unknown"
1393
+ print("OCQA_ACTION:{\"type\":\"left_app\",\"target\":\"\(escapeJSON(causeAction))\",\"screen\":\"\(escapeJSON(causeScreen))\",\"step\":\(actionCount),\"narrative\":\"\(escapeJSON("'\(causeAction)' handed off to a system surface or another app — returning to the app under test."))\"}")
1394
+ pendingTransitionFrom = nil
1395
+ app.activate()
1396
+ _ = app.wait(for: .runningForeground, timeout: 5)
1397
+ Thread.sleep(forTimeInterval: 1.0)
1398
+ if app.state == .runningForeground {
1399
+ emitProgress(action: actionCount, maxActions: maxActions, states: visitedStates.count)
1400
+ continue
1401
+ }
1402
+ if leftAppObservations >= 3 {
1403
+ explorationStopCause = "left-app-unrecovered"
1404
+ break
1405
+ }
1406
+ continue
1407
+ }
1408
+
1377
1409
  var elements = readUITree(app)
1378
1410
  if elements.isEmpty {
1379
1411
  // App may have gone to background, crashed, or be unresponsive.
@@ -1399,6 +1431,7 @@ class ExplorerTests: XCTestCase {
1399
1431
  print("OCQA_ISSUE:{\"type\":\"crash\",\"severity\":\"critical\",\"title\":\"\(escapeJSON("App crashed during \(where_)"))\",\"screen\":\"\(escapeJSON(where_))\",\"step\":\(actionCount)}")
1400
1432
  }
1401
1433
  }
1434
+ explorationStopCause = "app-crashed"
1402
1435
  break
1403
1436
  }
1404
1437
  }
@@ -1528,6 +1561,7 @@ class ExplorerTests: XCTestCase {
1528
1561
  emitProgress(action: actionCount, maxActions: maxActions, states: visitedStates.count)
1529
1562
  continue
1530
1563
  }
1564
+ explorationStopCause = "stuck-no-progress"
1531
1565
  break
1532
1566
  }
1533
1567
 
@@ -1554,6 +1588,7 @@ class ExplorerTests: XCTestCase {
1554
1588
  // for an impossible back path or expecting credentials to persist after logout.
1555
1589
  if authSucceeded && detectedInputs.contains(where: { $0.secure }) {
1556
1590
  print("OCQA_STATE:auth_cycle_complete screen=\(escapedTitle) step=\(actionCount)")
1591
+ explorationStopCause = "auth-cycle-complete"
1557
1592
  break
1558
1593
  }
1559
1594
 
@@ -1844,6 +1879,7 @@ class ExplorerTests: XCTestCase {
1844
1879
  // All tab positions tried — truly stuck
1845
1880
  print("OCQA_STATE:truly_stuck_dead_end screen=\(escapedTitle) step=\(actionCount)")
1846
1881
  emitNavigationTrap(titleStr: titleStr, escapedTitle: escapedTitle, step: actionCount, reported: &reportedIssueKeys, issues: &issues)
1882
+ explorationStopCause = "navigation-trap"
1847
1883
  break
1848
1884
  }
1849
1885
 
@@ -2190,6 +2226,7 @@ class ExplorerTests: XCTestCase {
2190
2226
  // Truly stuck — break
2191
2227
  print("OCQA_STATE:truly_stuck screen=\(escapedTitle) step=\(actionCount)")
2192
2228
  emitNavigationTrap(titleStr: titleStr, escapedTitle: escapedTitle, step: actionCount, reported: &reportedIssueKeys, issues: &issues)
2229
+ explorationStopCause = "navigation-trap"
2193
2230
  break
2194
2231
  }
2195
2232
  }
@@ -2201,6 +2238,23 @@ class ExplorerTests: XCTestCase {
2201
2238
  persistentThreshold: persistentThreshold,
2202
2239
  screenBounds: screenBounds)
2203
2240
 
2241
+ // ---- Credential-surface policy: catalogue, don't exercise (parity with web). ----
2242
+ // Without explicitly supplied credentials — or interactive overrides for this screen —
2243
+ // a login/signup surface is evidence, not a playground: typing sample values and
2244
+ // tapping Sign In / Forgot Password fires real requests against whatever backend the
2245
+ // build points at (production, for a bare-app run). Fields are still catalogued into
2246
+ // STATE, the report says sign-in was not checked, and navigation away stays allowed.
2247
+ let credentialFlowLocked = (screenRole == "login" || screenRole == "signup" || detectedInputs.contains { $0.secure })
2248
+ && (testEmail.isEmpty || testPassword.isEmpty)
2249
+ && !detectedInputs.contains(where: { !hasNoOverride(key: $0.key, screen: titleStr, in: inputOverrides) })
2250
+ if credentialFlowLocked {
2251
+ if !reportedIssueKeys.contains("credlock:\(titleStr)") {
2252
+ reportedIssueKeys.insert("credlock:\(titleStr)")
2253
+ print("OCQA_STATE:credential_form_catalogued screen=\(escapedTitle) fields=\(detectedInputs.count)")
2254
+ }
2255
+ sorted = sorted.filter { !isTextField($0.type) && !isCredentialFlowControl($0) }
2256
+ }
2257
+
2204
2258
  // ---- Form-completion steering ----
2205
2259
  // A half-filled form is one tap from progress (submit) or oblivion (Close/Cancel
2206
2260
  // discards everything typed). While the screen has unfilled text fields, fill the
@@ -2214,8 +2268,8 @@ class ExplorerTests: XCTestCase {
2214
2268
  // screen or it abandons a form whose remaining field the pool has dropped (observed:
2215
2269
  // signup's Confirm Password missing from the pool while hittable in the tree).
2216
2270
  // Termination is per-field via typedFieldKeys, so this can never loop.
2217
- let treeHasFields = elements.contains(where: { isTextField($0.type) })
2218
- let unfilledFields = elements.filter {
2271
+ let treeHasFields = !credentialFlowLocked && elements.contains(where: { isTextField($0.type) })
2272
+ let unfilledFields = credentialFlowLocked ? [SimpleElement]() : elements.filter {
2219
2273
  isTextField($0.type) && $0.isEnabled && $0.isHittable
2220
2274
  && fieldLooksUnfilled($0, screen: titleStr, typedKeys: typedFieldKeys)
2221
2275
  }
@@ -2287,7 +2341,7 @@ class ExplorerTests: XCTestCase {
2287
2341
  if !numericCells.isEmpty { sorted = others + numericCells }
2288
2342
  }
2289
2343
 
2290
- guard let target = sorted.first else { break }
2344
+ guard let target = sorted.first else { explorationStopCause = "frontier-drained"; break }
2291
2345
 
2292
2346
  if !isTextField(target.type) {
2293
2347
  dismissKeyboardIfNeeded()
@@ -2360,6 +2414,7 @@ class ExplorerTests: XCTestCase {
2360
2414
  desc: "The app terminated after \(actionType) '\(targetName)' on '\(titleStr)' and did not recover on relaunch."))
2361
2415
  print("OCQA_ISSUE:{\"type\":\"crash\",\"severity\":\"critical\",\"title\":\"\(escapeJSON("App crashed after \(actionType) on \(titleStr)"))\",\"screen\":\"\(escapedTitle)\",\"control\":\"\(escapedTarget)\",\"step\":\(actionCount)}")
2362
2416
  }
2417
+ explorationStopCause = "app-crashed"
2363
2418
  break
2364
2419
  }
2365
2420
  print("OCQA_STATE:app_reactivated step=\(actionCount)")
@@ -2479,6 +2534,7 @@ class ExplorerTests: XCTestCase {
2479
2534
  desc: "App left foreground after: \(actionDesc)"))
2480
2535
  print("OCQA_ISSUE:{\"type\":\"crash\",\"severity\":\"critical\",\"title\":\"App not recoverable\",\"action\":\"\(escapedTarget)\",\"step\":\(actionCount)}")
2481
2536
  }
2537
+ explorationStopCause = "app-crashed"
2482
2538
  break
2483
2539
  }
2484
2540
  print("OCQA_STATE:app_reactivated step=\(actionCount)")
@@ -2529,7 +2585,7 @@ class ExplorerTests: XCTestCase {
2529
2585
  let uniqueScreens = screenTitles.values
2530
2586
  let screenList = Array(Set(uniqueScreens)).sorted().joined(separator: ",")
2531
2587
  didEmitComplete = true
2532
- print("OCQA_COMPLETE:{\"actions\":\(actionCount),\"states\":\(visitedStates.count),\"issues\":\(issues.count),\"screens\":\"\(screenList)\"}")
2588
+ print("OCQA_COMPLETE:{\"actions\":\(actionCount),\"states\":\(visitedStates.count),\"issues\":\(issues.count),\"screens\":\"\(screenList)\",\"stop\":\"\(explorationStopCause)\",\"timedOut\":\(explorationStopCause == "time-budget" ? "true" : "false")}")
2533
2589
 
2534
2590
  let finalScreenshot = app.screenshot()
2535
2591
  let finalAttachment = XCTAttachment(screenshot: finalScreenshot)
@@ -3380,6 +3436,19 @@ class ExplorerTests: XCTestCase {
3380
3436
 
3381
3437
  /// Dismiss-style controls (Close/Cancel/X) that discard a form in progress. Deprioritized —
3382
3438
  /// never excluded — while unfilled fields remain, so they stay available as an escape hatch.
3439
+ /// Any control that advances a credential flow: submitting, account recovery, account
3440
+ /// creation, or third-party auth. Used by the credential-surface lock — none of these may
3441
+ /// fire when no credentials were supplied, because each one sends real traffic to the
3442
+ /// app's live backend. Navigation controls (tabs, Skip, Continue as guest) do not match.
3443
+ private func isCredentialFlowControl(_ element: SimpleElement) -> Bool {
3444
+ if isLikelySubmitControl(element) { return true }
3445
+ let text = (element.label + " " + element.identifier).lowercased()
3446
+ let flowTokens = ["forgot", "reset password", "recover", "with apple", "with google",
3447
+ "with facebook", "create account", "sign up", "verification code",
3448
+ "magic link", "resend"]
3449
+ return flowTokens.contains(where: { text.contains($0) })
3450
+ }
3451
+
3383
3452
  private func isDismissControl(_ element: SimpleElement) -> Bool {
3384
3453
  let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
3385
3454
  let id = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
package/README.md CHANGED
@@ -29,10 +29,11 @@ Supported targets:
29
29
  |---|---|
30
30
  | iOS | Simulator on macOS with Xcode; driven through XCUITest and accessibility |
31
31
  | Android | Connected emulator or device with `adb`; driven through UIAutomator |
32
- | Web (beta) | Owned browser app in Playwright Chromium |
32
+ | Web | Owned browser app in Playwright Chromium |
33
+ | Windows desktop | WPF, WinForms, and WinUI apps through Windows UI Automation — early runs are set up through the pilot |
33
34
 
34
- Windows can host Android and web testing when their prerequisites are installed. Windows desktop
35
- UI applications such as WinForms, WPF, and WinUI are not currently Tapp targets.
35
+ Windows hosts Android and web testing when their prerequisites are installed, and is the home of
36
+ the Windows desktop target.
36
37
 
37
38
  ## Give Tapp to your coding agent
38
39
 
@@ -173,7 +174,7 @@ npx -y @aarwitz/tapp@latest apps # what's installed on the simulator
173
174
  npx -y @aarwitz/tapp@latest build [dir] # just build + install (scheme auto-detected)
174
175
  ```
175
176
 
176
- Web (beta): `npx -y @aarwitz/tapp@latest explore http://localhost:3000` *(one-time setup:
177
+ Web: `npx -y @aarwitz/tapp@latest explore http://localhost:3000` *(one-time setup:
177
178
  `npm i -g playwright && npx playwright install chromium`)*. Add `--watch` to open Tapp's controlled,
178
179
  isolated Chromium window and follow its clicks with an on-page pointer/action label. Tapp hides that
179
180
  watch UI from saved evidence screenshots and does not automate your personal/default browser profile.
@@ -348,7 +349,7 @@ jobs:
348
349
  timeout-minutes: 45
349
350
  steps:
350
351
  - uses: actions/checkout@v4
351
- - uses: aarwitz/tapp@v0.17.7 # or pin the reviewed release commit SHA
352
+ - uses: aarwitz/tapp@v0.17.9 # or pin the reviewed release commit SHA
352
353
  with:
353
354
  project: MyApp.xcodeproj # or MyApp.xcworkspace
354
355
  scheme: MyApp
@@ -398,7 +399,7 @@ Android CI runs on Linux with an emulator/device already connected. The Action c
398
399
  or accept a prebuilt one:
399
400
 
400
401
  ```yaml
401
- - uses: aarwitz/tapp@v0.17.7 # or pin the reviewed release commit SHA
402
+ - uses: aarwitz/tapp@v0.17.9 # or pin the reviewed release commit SHA
402
403
  with:
403
404
  platform: android
404
405
  android-app-id: com.acme.app
@@ -437,7 +438,7 @@ Every driver speaks one protocol: structured `OCQA_*` markers (state, actions, i
437
438
  transitions) that the engine parses into trees, screenshots, findings, coverage, and the gate outcome.
438
439
  On **iOS**, a generic **XCUITest harness** attaches to any app by bundle id — no SDK or app code
439
440
  changes — and acts through the accessibility tree. On **Android**, ADB + UIAutomator provide the
440
- same black-box driver contract. On **web** (beta), a deterministic **Playwright crawler** does the
441
+ same black-box driver contract. On **web**, a deterministic **Playwright crawler** does the
441
442
  same in a real browser. Same detectors' spirit,
442
443
  same dedup, same regression gate, same honest `pass`/`fail`/`inconclusive` outcome. Core exploration,
443
444
  evidence collection, and gate evaluation run entirely locally — no telemetry, nothing phones home. Optional AI
@@ -457,6 +458,15 @@ only an optional authoring/enrichment layer (`tapp_flow_generate`, `assert_ai`,
457
458
  The first tool call builds the harness once (~2 min, cached in `~/.tapp`; rebuilt automatically
458
459
  if you switch simulators). All captures land in `~/.tapp/captures/`.
459
460
 
461
+ ## Feedback
462
+
463
+ Tapp accepts feedback the way agents already work: as GitHub issues on
464
+ [aarwitz/tapp](https://github.com/aarwitz/tapp/issues). `tapp feedback "short title" --body "…"`
465
+ (or the `tapp_feedback` MCP tool) drafts an issue with the version, platform availability, and latest
466
+ capture id filled in and home paths and tokens redacted; `--submit` files it with your authenticated
467
+ `gh`. Drafts are the default because issues are public — an agent should show you the draft first.
468
+ Nothing is uploaded automatically; captures stay on your machine.
469
+
460
470
  ## License
461
471
 
462
472
  [MIT](./LICENSE)
package/bin/tapp.js CHANGED
@@ -282,6 +282,7 @@ function safeCommandUsage(verb) {
282
282
  actor: "tapp actor set NAME --email-env ENV --password-env ENV [--replace] [--project-dir DIR]\ntapp actor list [repo]",
283
283
  app: "tapp app [repo] [--no-open] [--port PORT]",
284
284
  report: "tapp report [captureId|latest]",
285
+ feedback: "tapp feedback \"short title\" [--body TEXT|--body-file FILE] [--type bug|idea|question] [--capture ID|latest|none] [--as human] [--submit] [--json]\n Drafts a public GitHub issue on aarwitz/tapp about tapp itself; --submit files it with the authenticated gh CLI.\n Exit codes: 0 drafted or filed · 1 gh submission failed · 2 usage error",
285
286
  doctor: "tapp doctor [--json]\n Exit codes: 0 environment healthy · 1 blocked (fix ❌ items)",
286
287
  install: "tapp install",
287
288
  mcp: "tapp mcp",
@@ -301,7 +302,7 @@ if (["--help", "-h"].includes(command)) {
301
302
  const knownCommands = new Set([
302
303
  "help", "version", "--version", "-v", "mcp", "init", "focus", "explore", "qa", "open",
303
304
  "tree", "shot", "screenshot", "apps", "build", "flow", "task", "contract", "scenario", "map",
304
- "pr", "plan", "baseline", "ci", "actor", "app", "studio", "report", "doctor", "install",
305
+ "pr", "plan", "baseline", "ci", "actor", "app", "studio", "report", "doctor", "install", "feedback",
305
306
  ]);
306
307
  if (!knownCommands.has(command)) {
307
308
  console.error(`❌ Unknown command: ${command}`);
@@ -695,15 +696,28 @@ switch (command) {
695
696
  const launchOptions = iosLaunchOptions(flags, rest);
696
697
  let target = positionals[0] || "";
697
698
  let baselineFindings;
699
+ let baselineCaptureContext = null;
698
700
  if (flags.baseline) {
699
701
  try {
700
702
  const parsed = JSON.parse(fs.readFileSync(flags.baseline, "utf8"));
701
703
  baselineFindings = Array.isArray(parsed) ? parsed : parsed.findings;
704
+ // Accept the 0.17.7 gate JSON's stamp-shaped `capture` too (explore JSON never
705
+ // stamped that key — there it is the evidence-folder record).
706
+ if (!Array.isArray(parsed)) baselineCaptureContext = parsed.captureContext || (parsed.capture?.viewport ? parsed.capture : null);
702
707
  } catch (e) {
703
708
  console.error(`❌ Could not read baseline ${flags.baseline}: ${e.message}`);
704
709
  process.exit(2);
705
710
  }
706
711
  }
712
+ // The same honesty rule the CI gate applies: a baseline captured at another device/viewport
713
+ // makes "resolved" a layout diff, not a fix — say so on the command the JSON hint points at.
714
+ const applyCaptureMismatch = (structured) => {
715
+ if (!structured?.regression || !baselineCaptureContext || !structured.captureContext) return;
716
+ if (JSON.stringify(baselineCaptureContext) === JSON.stringify(structured.captureContext)) return;
717
+ structured.regression.captureMismatch = { baseline: baselineCaptureContext, current: structured.captureContext };
718
+ const describe = (c) => [c?.device, c?.viewport ? `${c.viewport.width}x${c.viewport.height}` : null, c?.deviceScaleFactor ? `@${c.deviceScaleFactor}x` : null].filter(Boolean).join(" ") || "unknown";
719
+ console.error(`⚠️ Capture mismatch: the baseline was captured at ${describe(baselineCaptureContext)}, this run at ${describe(structured.captureContext)}. "Resolved" findings may reflect layout differences, not fixes — re-baseline at the same device/viewport to compare honestly.`);
720
+ };
707
721
  const engine = await engineImport();
708
722
  // Source-preparing bare explore (ADR-0005 §5): no explicit target + a repo application model →
709
723
  // drive the model's default target end to end. Managed web is built/started/waited-for and
@@ -734,6 +748,7 @@ switch (command) {
734
748
  });
735
749
  finishProgress();
736
750
  if (r.error) { printEngineError(r); process.exit(1); }
751
+ applyCaptureMismatch(r.structured);
737
752
  console.log(r.text);
738
753
  if (flags.json && typeof flags.json === "string") {
739
754
  fs.writeFileSync(flags.json, JSON.stringify(r.structured, null, 2));
@@ -804,6 +819,7 @@ switch (command) {
804
819
  printEngineError(r);
805
820
  process.exit(1);
806
821
  }
822
+ applyCaptureMismatch(r.structured);
807
823
  console.log(r.text);
808
824
  if (flags.json && typeof flags.json === "string") {
809
825
  fs.writeFileSync(flags.json, JSON.stringify(r.structured, null, 2));
@@ -1829,6 +1845,58 @@ switch (command) {
1829
1845
  break;
1830
1846
  }
1831
1847
 
1848
+ case "feedback": {
1849
+ const { flags, positionals } = parseVerbArgs(rest);
1850
+ const usage = 'Usage: tapp feedback "short title" [--body TEXT|--body-file FILE] [--type bug|idea|question] [--capture ID|latest|none] [--as human] [--submit] [--json]';
1851
+ const title = positionals.join(" ").trim();
1852
+ if (!title) { console.error(`❌ ${usage}`); process.exit(2); }
1853
+ const fb = await import(path.join(packageRoot, "mcp-server", "src", "feedback.js"));
1854
+ const type = typeof flags.type === "string" ? flags.type : "bug";
1855
+ if (!fb.FEEDBACK_TYPES.includes(type)) { console.error(`❌ --type must be one of ${fb.FEEDBACK_TYPES.join(", ")}\n${usage}`); process.exit(2); }
1856
+ let body = typeof flags.body === "string" ? flags.body : "";
1857
+ if (typeof flags["body-file"] === "string") {
1858
+ try { body = fs.readFileSync(flags["body-file"], "utf8"); } catch (error) { console.error(`❌ Could not read --body-file: ${error.message}`); process.exit(2); }
1859
+ }
1860
+ const captureFlag = typeof flags.capture === "string" ? flags.capture : "latest";
1861
+ const captureId = captureFlag === "none" ? null : captureFlag === "latest" ? fb.latestCaptureId(tappHome) : captureFlag;
1862
+ // Platform availability comes from doctor --json so the issue carries facts, not guesses.
1863
+ let doctor = null;
1864
+ try {
1865
+ const d = run(process.execPath, [path.join(packageRoot, "bin", "tapp.js"), "doctor", "--json"], { env: { ...process.env, TAPP_HOME: tappHome } });
1866
+ doctor = JSON.parse(d.stdout);
1867
+ } catch { /* feedback still works without the environment summary */ }
1868
+ let issue;
1869
+ try {
1870
+ issue = fb.composeFeedback({ title, body, type, version: pkg.version, doctor, captureId, filedBy: flags.as === "human" ? "human" : "agent" });
1871
+ } catch (error) { console.error(`❌ ${error.message}\n${usage}`); process.exit(2); }
1872
+ const url = fb.feedbackIssueUrl(issue);
1873
+ let submitted = false, issueUrl = null;
1874
+ if (flags.submit === true) {
1875
+ const status = fb.ghStatus();
1876
+ if (!status.available) {
1877
+ if (flags.json === true) console.log(JSON.stringify({ ...issue, url, submitted: false, error: "gh not authenticated" }, null, 2));
1878
+ else console.error(`❌ The GitHub CLI is not authenticated on this machine, so nothing was filed.\n Open this prefilled issue instead (it is public):\n ${url}`);
1879
+ process.exit(1);
1880
+ }
1881
+ const result = fb.submitFeedbackViaGh(issue);
1882
+ if (!result.ok) {
1883
+ if (flags.json === true) console.log(JSON.stringify({ ...issue, url, submitted: false, error: result.detail }, null, 2));
1884
+ else console.error(`❌ gh issue create failed: ${result.detail}\n Prefilled issue link: ${url}`);
1885
+ process.exit(1);
1886
+ }
1887
+ submitted = true; issueUrl = result.url;
1888
+ }
1889
+ if (flags.json === true) {
1890
+ console.log(JSON.stringify({ ...issue, url, submitted, issueUrl }, null, 2));
1891
+ break;
1892
+ }
1893
+ if (submitted) { ok("Filed feedback", issueUrl); break; }
1894
+ console.log(`📝 Feedback draft (not filed — issues on ${fb.FEEDBACK_REPO} are public; confirm with the user first):\n`);
1895
+ console.log(`Title: ${issue.title}\nLabels: ${issue.labels.join(", ")}\n\n${issue.body}`);
1896
+ console.log(`File it: tapp feedback ${JSON.stringify(issue.title)} … --submit (uses your authenticated gh)\nOr open: ${url}`);
1897
+ break;
1898
+ }
1899
+
1832
1900
  case "version":
1833
1901
  case "--version":
1834
1902
  case "-v": {
@@ -1859,6 +1927,8 @@ Repository & release:
1859
1927
  (--explore grounds the UI Map · --url URL · --platform · --dry-run · --refresh)
1860
1928
  tapp baseline create [repo] Run/import a conclusive full gate and save a target-scoped baseline
1861
1929
  tapp report [captureId] Open the HTML evidence page for a capture (default: latest)
1930
+ tapp feedback "title" Send a bug, idea, or question to the tapp maintainers as a GitHub issue
1931
+ (drafts by default · --submit files it · issues are public)
1862
1932
  tapp ci install [repo] Generate a reviewable target-aware GitHub workflow + CI manifest
1863
1933
  tapp actor set NAME Configure an actor using environment-variable names only (never values)
1864
1934
  tapp actor list [repo] Inspect named actors, sessions, provisioning, and secret env bindings
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.7 # or pin the reviewed release commit SHA
77
+ - uses: aarwitz/tapp@v0.17.9 # or pin the reviewed release commit SHA
78
78
  with:
79
79
  platform: web
80
80
  url: http://127.0.0.1:4180
@@ -913,7 +913,7 @@ function invalidatedGeneration(generation) {
913
913
  };
914
914
  }
915
915
 
916
- function mergePlanDecisions(next, prior, { invalidateValidation = false } = {}) {
916
+ export function mergePlanDecisions(next, prior, { invalidateValidation = false } = {}) {
917
917
  if (!prior || prior.schemaVersion !== 1 || prior.kind !== "tapp-release-plan") return next;
918
918
  const priorItems = new Map((prior.items || []).map((item) => [item.id, item]));
919
919
  const priorByNameScope = new Map();
@@ -178,7 +178,9 @@ function loadBaseline(baselinePath) {
178
178
  actionsPerformed: parsed.actionsPerformed || 0,
179
179
  platform: parsed.baselineIdentity?.platform || parsed.platform || null,
180
180
  targetKey: parsed.baselineIdentity?.targetId || parsed.targetKey || null,
181
- capture: parsed.capture || null,
181
+ // 0.17.7 gate JSONs briefly published the stamp as `capture`; explore JSONs use that key
182
+ // for the evidence-folder record. Accept the old key only when it is actually a stamp.
183
+ captureContext: parsed.captureContext || (parsed.capture?.viewport ? parsed.capture : null),
182
184
  };
183
185
  }
184
186
 
@@ -496,8 +498,8 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
496
498
  lines.push(`**Gate (${gate.policy}): ${badge}**${gate.reasons.length ? " — " + gate.reasons.join("; ") : ""}${ignoredNote}`);
497
499
  // The gate is only authoritative about what it actually ran — record the scope explicitly.
498
500
  const rev = gate.revision?.sha ? `${String(gate.revision.sha).slice(0, 12)}${gate.revision.dirty ? "-dirty" : ""}` : "unknown";
499
- const captured = report.capture
500
- ? ` · capture: ${[report.capture.device, report.capture.viewport ? `${report.capture.viewport.width}x${report.capture.viewport.height}` : null, report.capture.deviceScaleFactor ? `@${report.capture.deviceScaleFactor}x` : null].filter(Boolean).join(" ")}`
501
+ const captured = report.captureContext
502
+ ? ` · capture: ${[report.captureContext.device, report.captureContext.viewport ? `${report.captureContext.viewport.width}x${report.captureContext.viewport.height}` : null, report.captureContext.deviceScaleFactor ? `@${report.captureContext.deviceScaleFactor}x` : null].filter(Boolean).join(" ")}`
501
503
  : "";
502
504
  const stableId = report.targetKey && report.targetKey !== gate.target ? ` (${report.targetKey})` : "";
503
505
  lines.push(`_target: ${gate.target || "—"}${stableId} · revision: ${rev} · policy: ${gate.policy} v${gate.policyVersion || "?"}${captured}_`);
@@ -562,8 +564,8 @@ const regression = computeRegression(report.findings, baseline?.findings ?? null
562
564
  // A baseline captured at a different device/viewport is a layout comparison, not a regression
563
565
  // signal: a phone run legitimately hides desktop nav links, so its "resolved" list lies. Keep
564
566
  // the diff (new findings still gate) but stamp the mismatch so every consumer can see it.
565
- if (regression && baseline?.capture && report.capture && JSON.stringify(baseline.capture) !== JSON.stringify(report.capture)) {
566
- regression.captureMismatch = { baseline: baseline.capture, current: report.capture };
567
+ if (regression && baseline?.captureContext && report.captureContext && JSON.stringify(baseline.captureContext) !== JSON.stringify(report.captureContext)) {
568
+ regression.captureMismatch = { baseline: baseline.captureContext, current: report.captureContext };
567
569
  }
568
570
  const runs = args.flowLogs.map(parseFlowLog);
569
571
  const contracts = runs.filter((run) => run.kind === "release-contract");
@@ -0,0 +1,97 @@
1
+ // Feedback to the tapp maintainers, the agent-native way: a GitHub issue on aarwitz/tapp.
2
+ //
3
+ // Shared by `tapp feedback` (CLI) and `tapp_feedback` (MCP). Both default to a DRAFT — the
4
+ // composed issue plus a prefilled github.com/.../issues/new URL — and only file the issue when
5
+ // explicitly asked (`--submit` / `submit: true`), because issues are public and an agent must not
6
+ // publish on a user's behalf without consent. Filing uses the machine's authenticated `gh` CLI;
7
+ // nothing is uploaded: captures stay local and only the capture id is referenced.
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { spawnSync } from "node:child_process";
12
+
13
+ export const FEEDBACK_REPO = "aarwitz/tapp";
14
+ export const FEEDBACK_TYPES = ["bug", "idea", "question"];
15
+ const TYPE_LABEL = { bug: "bug", idea: "idea", question: "question" };
16
+
17
+ /** Strip home directories and token-shaped secrets before anything leaves the machine. */
18
+ export function redactText(text, home = os.homedir()) {
19
+ let s = String(text ?? "");
20
+ if (home && home.length > 1) s = s.split(home).join("~");
21
+ s = s
22
+ .replace(/\/Users\/[^/\s"']+/g, "~")
23
+ .replace(/\/home\/[^/\s"']+/g, "~")
24
+ .replace(/[A-Za-z]:\\Users\\[^\\\s"']+/g, "~")
25
+ .replace(/\b(sk-ant-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[A-Z0-9]{16})\b/g, "[redacted]");
26
+ return s;
27
+ }
28
+
29
+ /** Newest capture directory name under TAPP_HOME, or null. Only the id is ever shared. */
30
+ export function latestCaptureId(tappHome) {
31
+ const dir = path.join(tappHome, "captures");
32
+ if (!fs.existsSync(dir)) return null;
33
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
34
+ .filter((e) => e.isDirectory())
35
+ .map((e) => ({ name: e.name, mtime: fs.statSync(path.join(dir, e.name)).mtimeMs }))
36
+ .sort((a, b) => b.mtime - a.mtime);
37
+ return entries[0]?.name ?? null;
38
+ }
39
+
40
+ /** One line of platform availability from `tapp doctor --json` output. */
41
+ export function summarizeDoctor(doctor) {
42
+ if (!doctor || typeof doctor !== "object") return null;
43
+ const p = doctor.platforms || {};
44
+ const parts = [];
45
+ if (p.ios) {
46
+ const detail = [p.ios.xcode, p.ios.bootedSimulator ? `${p.ios.bootedSimulator} booted` : ""].filter(Boolean).join(", ");
47
+ parts.push(`iOS ${p.ios.available ? "✅" : "⬜"}${detail ? ` (${detail})` : ""}`);
48
+ }
49
+ if (p.android) parts.push(`Android ${p.android.adb ? "✅" : "⬜"}${p.android.devicesConnected ? ` (${p.android.devicesConnected} device)` : ""}`);
50
+ if (p.web) parts.push(`web ${p.web.available ? "✅" : "⬜"}`);
51
+ return parts.join(" · ") || null;
52
+ }
53
+
54
+ /** Build the issue: redacted title/body plus an automatic, path-free context footer. */
55
+ export function composeFeedback({
56
+ title, body = "", type = "bug", version = "unknown", node = process.version,
57
+ platform = `${process.platform} ${process.arch}`, doctor = null, captureId = null, filedBy = "agent",
58
+ }) {
59
+ const cleanTitle = redactText(title).trim();
60
+ if (!cleanTitle) throw new Error("feedback needs a short title");
61
+ if (!FEEDBACK_TYPES.includes(type)) throw new Error(`type must be one of: ${FEEDBACK_TYPES.join(", ")}`);
62
+ const context = [`- tapp ${version} · node ${node} · ${platform}`];
63
+ const platforms = summarizeDoctor(doctor);
64
+ if (platforms) context.push(`- platforms: ${platforms}`);
65
+ if (captureId) context.push(`- capture: \`${redactText(captureId)}\` (kept locally — nothing is uploaded; evidence can be shared privately on request)`);
66
+ const description = redactText(body).trim() || "_(no description provided)_";
67
+ const byline = filedBy === "agent" ? ", filed by a coding agent on the user's behalf" : "";
68
+ const full = `${description}\n\n---\n_Filed with \`tapp feedback\` (${type}${byline}). This issue is public._\n${context.join("\n")}\n`;
69
+ const labels = ["feedback", TYPE_LABEL[type]];
70
+ if (filedBy === "agent") labels.push("agent-filed");
71
+ return { title: cleanTitle.slice(0, 200), body: full, labels };
72
+ }
73
+
74
+ /** Prefilled "new issue" link — works for anyone with a GitHub account, no CLI needed. */
75
+ export function feedbackIssueUrl({ title, body, labels }) {
76
+ const url = new URL(`https://github.com/${FEEDBACK_REPO}/issues/new`);
77
+ url.searchParams.set("title", title);
78
+ url.searchParams.set("body", body);
79
+ url.searchParams.set("labels", labels.join(","));
80
+ return url.toString();
81
+ }
82
+
83
+ export function ghStatus(ghBin = process.env.TAPP_GH_BIN || "gh") {
84
+ const r = spawnSync(ghBin, ["auth", "status"], { encoding: "utf8" });
85
+ return { available: r.status === 0, detail: (r.stderr || r.stdout || "").trim() };
86
+ }
87
+
88
+ export function submitFeedbackViaGh(issue, ghBin = process.env.TAPP_GH_BIN || "gh") {
89
+ const r = spawnSync(ghBin, [
90
+ "issue", "create", "--repo", FEEDBACK_REPO,
91
+ "--title", issue.title, "--body-file", "-", "--label", issue.labels.join(","),
92
+ ], { encoding: "utf8", input: issue.body });
93
+ const out = (r.stdout || "").trim();
94
+ const err = (r.stderr || "").trim();
95
+ const url = (out.match(/https:\/\/github\.com\/\S+/) || [])[0] || null;
96
+ return { ok: r.status === 0 && Boolean(url), url, detail: r.status === 0 ? out : (err || out) };
97
+ }
@@ -140,7 +140,7 @@ ${r.conditionsNotReached?.length ? `<div><h2>Conditions not reached</h2><ul>${sc
140
140
  <h1>${esc(observationBadge(r))} <span class="dim">· ${esc(observationSummary(r))}</span></h1>
141
141
  <div class="meta">${esc(label)} · evidence page (observation, not a release decision)</div>
142
142
  ${r.platform === "web" ? `<div class="meta">Deterministic basis: ${r.deterministicFindingCounts?.total || 0} deterministic finding(s); ${r.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory.</div>` : ""}
143
- ${r.capture ? `<div class="meta">Captured at ${esc([r.capture.device, r.capture.viewport ? `${r.capture.viewport.width}×${r.capture.viewport.height}` : null, r.capture.deviceScaleFactor ? `@${r.capture.deviceScaleFactor}x` : null].filter(Boolean).join(" "))} — every screenshot on this page shares these conditions.</div>` : ""}
143
+ ${r.captureContext ? `<div class="meta">Captured at ${esc([r.captureContext.device, r.captureContext.viewport ? `${r.captureContext.viewport.width}×${r.captureContext.viewport.height}` : null, r.captureContext.deviceScaleFactor ? `@${r.captureContext.deviceScaleFactor}x` : null].filter(Boolean).join(" "))} — every screenshot on this page shares these conditions.</div>` : ""}
144
144
  <div class="headline">${esc(r.headline)}</div>
145
145
  ${r.credentialWarning ? `<div class="warning">${esc(r.credentialWarning)}</div>` : ""}
146
146
  ${scopeHtml}
@@ -2438,6 +2438,27 @@ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
2438
2438
 
2439
2439
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
2440
2440
  tools: [
2441
+ {
2442
+ name: "tapp_feedback",
2443
+ title: "Send feedback to the tapp maintainers",
2444
+ description:
2445
+ "Draft a public GitHub issue on aarwitz/tapp about tapp itself — a bug in tapp, an idea, or a " +
2446
+ "question (not a finding about the app under test). Adds the tapp version, platform availability, " +
2447
+ "and the latest capture id automatically and redacts home paths and tokens. Default is a DRAFT: " +
2448
+ "returns the composed issue plus a prefilled github.com URL the user can open. Pass submit:true only " +
2449
+ "after the user has agreed to file a public issue; filing uses this machine's authenticated GitHub CLI.",
2450
+ inputSchema: {
2451
+ type: "object",
2452
+ properties: {
2453
+ title: { type: "string", description: "Short, specific title" },
2454
+ body: { type: "string", description: "What happened, what you expected, the exact command or tool call. No secrets or private source." },
2455
+ type: { type: "string", enum: ["bug", "idea", "question"], description: "Default bug" },
2456
+ capture: { type: "string", description: "Capture id to reference, 'latest' (default) or 'none'" },
2457
+ submit: { type: "boolean", description: "File the issue now with gh (needs the user's consent). Default false = draft only." },
2458
+ },
2459
+ required: ["title"],
2460
+ },
2461
+ },
2441
2462
  {
2442
2463
  name: "tapp_health",
2443
2464
  title: "Check Tapp readiness",
@@ -3170,6 +3191,40 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3170
3191
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
3171
3192
  const { name, arguments: args = {} } = request.params;
3172
3193
 
3194
+ if (name === "tapp_feedback") {
3195
+ const fb = await import("./feedback.js");
3196
+ const { fileURLToPath } = await import("node:url");
3197
+ const packageRootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
3198
+ const title = String(args.title || "").trim();
3199
+ if (!title) return errorResult("tapp_feedback needs a short title", {});
3200
+ const type = typeof args.type === "string" ? args.type : "bug";
3201
+ if (!fb.FEEDBACK_TYPES.includes(type)) return errorResult(`type must be one of ${fb.FEEDBACK_TYPES.join(", ")}`, {});
3202
+ const tappHomeDir = (process.env.TAPP_HOME || path.join(os.homedir(), ".tapp")).trim();
3203
+ const captureArg = typeof args.capture === "string" ? args.capture : "latest";
3204
+ const captureId = captureArg === "none" ? null : captureArg === "latest" ? fb.latestCaptureId(tappHomeDir) : captureArg;
3205
+ let doctor = null;
3206
+ try {
3207
+ const d = await runCommand(process.execPath, [path.join(packageRootDir, "bin", "tapp.js"), "doctor", "--json"], { timeoutMs: 60_000, env: { TAPP_HOME: tappHomeDir } });
3208
+ doctor = JSON.parse(d.stdout);
3209
+ } catch { /* optional context */ }
3210
+ const version = JSON.parse(fs.readFileSync(path.join(packageRootDir, "package.json"), "utf8")).version;
3211
+ let issue;
3212
+ try { issue = fb.composeFeedback({ title, body: String(args.body || ""), type, version, doctor, captureId, filedBy: "agent" }); }
3213
+ catch (error) { return errorResult(error.message, {}); }
3214
+ const url = fb.feedbackIssueUrl(issue);
3215
+ if (args.submit === true) {
3216
+ const status = fb.ghStatus();
3217
+ if (!status.available) return errorResult("The GitHub CLI is not authenticated on this machine, so nothing was filed. Give the user the prefilled link instead.", { url, ...issue, submitted: false });
3218
+ const result = fb.submitFeedbackViaGh(issue);
3219
+ if (!result.ok) return errorResult(`gh issue create failed: ${result.detail}`, { url, ...issue, submitted: false });
3220
+ return richResult(`✅ Filed feedback: ${result.url}`, { submitted: true, issueUrl: result.url, ...issue });
3221
+ }
3222
+ return richResult(
3223
+ `📝 Feedback drafted, not filed. Issues on ${fb.FEEDBACK_REPO} are public — confirm with the user, then call tapp_feedback again with submit: true, or give them this prefilled link:\n${url}\n\nTitle: ${issue.title}\nLabels: ${issue.labels.join(", ")}\n\n${issue.body}`,
3224
+ { submitted: false, url, ...issue },
3225
+ );
3226
+ }
3227
+
3173
3228
  if (name === "tapp_health") {
3174
3229
  const coreChecks = [];
3175
3230
  coreChecks.push({
@@ -10,6 +10,7 @@ import {
10
10
  buildInitArtifacts,
11
11
  generateApprovedContractProposals,
12
12
  mergeGeneratedTaskProposalValidation,
13
+ mergePlanDecisions,
13
14
  promoteValidatedProposals,
14
15
  recordContractProposalValidation,
15
16
  recordGeneratedTaskProposalValidation,
@@ -411,6 +412,13 @@ export async function initializeProductProject({
411
412
  maxContracts: Number(maxContracts),
412
413
  });
413
414
  let written = null;
415
+ // Dry-run must preview the SAME plan a real refresh would produce: merge reviewed
416
+ // decisions from the existing plan (pure, no writes). Returning the raw regenerated
417
+ // plan told users their approvals would be lost on refresh (field report № 6 #25).
418
+ let previewPlan = built.plan;
419
+ if (mode === "inspect" && fs.existsSync(paths.plan)) {
420
+ previewPlan = mergePlanDecisions(built.plan, readJson(paths.plan));
421
+ }
414
422
  if (mode !== "inspect") {
415
423
  const existing = fs.existsSync(paths.model) || fs.existsSync(paths.plan);
416
424
  written = writeInitArtifacts({
@@ -422,7 +430,7 @@ export async function initializeProductProject({
422
430
  });
423
431
  }
424
432
  const requirementScope = scopeProductRequirements(built.model, { selectedTargetId: selectedTarget?.id || "" });
425
- return { operation: "initialize", mode, model: built.model, plan: written?.plan || built.plan, exploration, selectedTarget, requirementScope, written, project: readProductProject({ projectDir: root, outDir }) };
433
+ return { operation: "initialize", mode, model: built.model, plan: written?.plan || previewPlan, exploration, selectedTarget, requirementScope, written, project: readProductProject({ projectDir: root, outDir }) };
426
434
  }
427
435
 
428
436
  function resolvePlan(root, outDir, planPath = "") {
@@ -303,6 +303,9 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
303
303
  : !coverageFloorMet ? "coverage-floor-not-met"
304
304
  : driverStop === "frontier-drained" ? "no-unexplored-in-scope-controls"
305
305
  : driverStop === "probe-cap" ? "probe-cap-reached"
306
+ // Any other driver-signalled cause (navigation-trap, app-crashed, stuck-no-progress …)
307
+ // passes through verbatim: "completed" is ONLY the exhausted action budget.
308
+ : driverStop && driverStop !== "action-budget" && driverStop !== "time-budget" ? String(driverStop)
306
309
  : "completed";
307
310
 
308
311
  const headline = timeBudgetExhausted
@@ -410,8 +413,10 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
410
413
  evidence: { markers: base.relativeMarkersFilePath },
411
414
  // Capture conditions shared by every screenshot/marker in this run (web: from the driver's
412
415
  // CONTEXT marker). A baseline diff across different captures is a layout comparison, not a
413
- // regression signal — consumers must be able to see that.
414
- capture: base.context && typeof base.context === "object"
416
+ // regression signal — consumers must be able to see that. Named captureContext because
417
+ // exploration consumers already publish `capture` as the evidence-folder record (field
418
+ // report № 7): the stamp must survive in the file people pass as --baseline.
419
+ captureContext: base.context && typeof base.context === "object"
415
420
  ? { device: base.context.device || null, viewport: base.context.viewport || null, deviceScaleFactor: base.context.deviceScaleFactor ?? null }
416
421
  : null,
417
422
  uiMap: null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.7",
3
+ "version": "0.17.9",
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",
@@ -34,7 +34,9 @@
34
34
  # # blocks; see ci-report.js)
35
35
  # [--json-out <file.json>] # write the full report (use as the next baseline)
36
36
  # [--md-out <file.md>] # write the rendered markdown report (for a PR comment)
37
- # [--device <name>] # simulator device to boot if none is (default "iPhone 16 Pro")
37
+ # [--device <name>] # ios: simulator to boot (default "iPhone 16 Pro")
38
+ # # web: Playwright device profile, same as `tapp explore`
39
+ # [--viewport WxH] # web only: explicit viewport, e.g. 390x844
38
40
  # [--serial <adb-serial>] # Android emulator/device (default: first connected device)
39
41
  #
40
42
  # The app must be a SIMULATOR build (xcodebuild ... -destination 'generic/platform=iOS Simulator').
@@ -47,7 +49,7 @@ usage() {
47
49
 
48
50
  PLATFORM="ios" APP_PATH="" BUNDLE_ID="" APK_PATH="" APP_ID="" URL="" WEB_TARGET="" TARGET_KEY="" SERIAL="" ACTIONS=40 TIMEOUT=600 FLOWS="" SCENARIOS="" CONTRACTS="" PROJECT_DIR="" BASELINE="" FAIL_ON="gate" JSON_OUT="" MD_OUT="" DEVICE="iPhone 16 Pro" PR_BASE="" PR_HEAD="HEAD" CHANGED_FILES_FILE="" PR_PLAN_OUT=""
49
51
  IOS_PR_TARGET_JSON=""
50
- FLOWS_EXPLICIT=false SCENARIOS_EXPLICIT=false CONTRACTS_EXPLICIT=false PLATFORM_EXPLICIT=false FAIL_ON_EXPLICIT=false
52
+ FLOWS_EXPLICIT=false SCENARIOS_EXPLICIT=false CONTRACTS_EXPLICIT=false PLATFORM_EXPLICIT=false FAIL_ON_EXPLICIT=false DEVICE_EXPLICIT=false VIEWPORT=""
51
53
  while [[ $# -gt 0 ]]; do
52
54
  case "$1" in
53
55
  --platform) PLATFORM="$2"; PLATFORM_EXPLICIT=true; shift 2 ;;
@@ -73,12 +75,14 @@ while [[ $# -gt 0 ]]; do
73
75
  --fail-on) FAIL_ON="$2"; FAIL_ON_EXPLICIT=true; shift 2 ;;
74
76
  --json-out) JSON_OUT="$2"; shift 2 ;;
75
77
  --md-out) MD_OUT="$2"; shift 2 ;;
76
- --device) DEVICE="$2"; shift 2 ;;
78
+ --device) DEVICE="$2"; DEVICE_EXPLICIT=true; shift 2 ;;
79
+ --viewport) VIEWPORT="$2"; shift 2 ;;
77
80
  --help|-h) usage; exit 0 ;;
78
81
  *) echo "Unknown argument: $1" >&2; exit 2 ;;
79
82
  esac
80
83
  done
81
84
  [[ "$PLATFORM" == "ios" || "$PLATFORM" == "android" || "$PLATFORM" == "web" ]] || { echo "❌ --platform must be ios|android|web" >&2; exit 2; }
85
+ [[ "$PLATFORM" == "ios" && -n "$VIEWPORT" ]] && { echo "❌ --viewport applies to web gates only; on ios --device selects the simulator" >&2; exit 2; }
82
86
  [[ "$ACTIONS" =~ ^[1-9][0-9]*$ ]] || { echo "❌ --actions must be a positive integer" >&2; exit 2; }
83
87
  [[ "$TIMEOUT" =~ ^[1-9][0-9]*$ ]] || { echo "❌ --timeout must be a positive integer" >&2; exit 2; }
84
88
  [[ "$FAIL_ON" == "gate" || "$FAIL_ON" == "absolute" || "$FAIL_ON" == "any" || "$FAIL_ON" == "high" || "$FAIL_ON" == "medium" ]] || { echo "❌ --fail-on must be gate|absolute|any|high|medium" >&2; exit 2; }
@@ -283,6 +287,16 @@ if [[ "$PLATFORM" != "ios" ]]; then
283
287
  [[ -n "$URL" ]] && PLATFORM_ARGS+=(--url "$URL")
284
288
  [[ -n "$PROJECT_DIR" ]] && PLATFORM_ARGS+=(--project-dir "$PROJECT_DIR")
285
289
  [[ -n "$WEB_TARGET" ]] && PLATFORM_ARGS+=(--web-target "$WEB_TARGET")
290
+ # On a web gate --device/--viewport carry the SAME meaning as `tapp explore` (a Playwright
291
+ # rendering profile) — one flag must not silently mean "simulator" here and get dropped.
292
+ if [[ "$PLATFORM" == "web" ]]; then
293
+ [[ "$DEVICE_EXPLICIT" == "true" ]] && PLATFORM_ARGS+=(--device "$DEVICE")
294
+ [[ -n "$VIEWPORT" ]] && PLATFORM_ARGS+=(--viewport "$VIEWPORT")
295
+ elif [[ "$PLATFORM" == "android" ]]; then
296
+ if [[ "$DEVICE_EXPLICIT" == "true" || -n "$VIEWPORT" ]]; then
297
+ echo "❌ --device/--viewport have no meaning on an android gate (use --serial to pick a device)" >&2; exit 2
298
+ fi
299
+ fi
286
300
  [[ -n "$TARGET_KEY" ]] && PLATFORM_ARGS+=(--target-key "$TARGET_KEY")
287
301
  [[ -n "$APP_ID" ]] && PLATFORM_ARGS+=(--app-id "$APP_ID")
288
302
  [[ -n "$APK_PATH" ]] && PLATFORM_ARGS+=(--apk "$APK_PATH")
@@ -27,6 +27,8 @@ for (let i = 2; i < process.argv.length; i += 1) {
27
27
  else if (key === "--project-dir") args.projectDir = value;
28
28
  else if (key === "--web-target") args.webTarget = value;
29
29
  else if (key === "--target-key") args.targetKey = value;
30
+ else if (key === "--device") args.device = value;
31
+ else if (key === "--viewport") args.viewport = value;
30
32
  else if (key === "--app-id") args.appId = value;
31
33
  else if (key === "--apk") args.apk = value;
32
34
  else if (key === "--serial") args.serial = value;
@@ -126,7 +128,7 @@ try {
126
128
  exitCode = 2;
127
129
  } else {
128
130
  const qa = args.platform === "web"
129
- ? await runQaWeb({ url: args.url, maxActions: args.actions, timeout: args.timeout, testEmail: process.env.OCQA_TEST_EMAIL, testPassword: process.env.OCQA_TEST_PASSWORD, seedTargets: prExplorationTargets })
131
+ ? await runQaWeb({ url: args.url, maxActions: args.actions, timeout: args.timeout, testEmail: process.env.OCQA_TEST_EMAIL, testPassword: process.env.OCQA_TEST_PASSWORD, seedTargets: prExplorationTargets, device: args.device || "", viewport: args.viewport || "" })
130
132
  : await runQaAndroid({ appId: args.appId, apkPath: args.apk, serial: args.serial, maxActions: args.actions, timeout: args.timeout,
131
133
  testEmail: process.env.OCQA_TEST_EMAIL, testPassword: process.env.OCQA_TEST_PASSWORD, seedTargets: prExplorationTargets });
132
134
  if (qa.error) {
@@ -183,8 +183,8 @@ run_harness_test() {
183
183
  "OCQA_BUNDLE_ID": "$bundle_id",
184
184
  "OCQA_MAX_ACTIONS": "$max_actions",
185
185
  "OCQA_TIMEOUT_SECONDS": "$timeout_secs",
186
- "OCQA_TEST_EMAIL": "${OCQA_TEST_EMAIL:-qa@example.com}",
187
- "OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-Tapp123!}"$interactive_line$overrides_line$launch_args_line$launch_env_line$login_steps_line$pr_target_line$visual_ready_line$recording_started_line
186
+ "OCQA_TEST_EMAIL": "${OCQA_TEST_EMAIL:-}",
187
+ "OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-}"$interactive_line$overrides_line$launch_args_line$launch_env_line$login_steps_line$pr_target_line$visual_ready_line$recording_started_line
188
188
  }
189
189
  CONF
190
190
 
@@ -447,8 +447,8 @@ OCQA_COMPLETE:{\"actions\":0,\"states\":0,\"issues\":1,\"screens\":\"\",\"outcom
447
447
  "OCQA_SESSION_CMD_PATH": "$SESS_CMD",
448
448
  "OCQA_SESSION_RESULT_PATH": "$SESS_RES",
449
449
  "OCQA_SESSION_TIMEOUT": "$SESS_TIMEOUT",
450
- "OCQA_TEST_EMAIL": "${OCQA_TEST_EMAIL:-qa@example.com}",
451
- "OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-Tapp123!}"$sess_args_line$sess_env_line
450
+ "OCQA_TEST_EMAIL": "${OCQA_TEST_EMAIL:-}",
451
+ "OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-}"$sess_args_line$sess_env_line
452
452
  }
453
453
  CONF
454
454
  xctestrun=$(find "$HARNESS_DERIVED/Build/Products" -name "*.xctestrun" 2>/dev/null | head -1)
@@ -92,7 +92,7 @@ After the focused fast path, read returned `elements[]` before any remaining act
92
92
  ids or visible labels, check `hittable`, tap a field before typing, and wait for navigation or async
93
93
  content. Use coordinates only as a last resort. End the session when finished.
94
94
 
95
- Do not edit the app merely because testing found a defect unless the user also asked for a fix. State
96
- what the evidence proves and what remains untested.
95
+ Do not edit the app merely because testing found a defect unless the user also asked for a fix. State what the evidence proves and what remains untested.
96
+ If tapp itself misbehaves, draft feedback with `npx -y @aarwitz/tapp@latest feedback "title"` or `tapp_feedback`; it is public — draft first, `--submit` only with the user's OK.
97
97
 
98
98
  Read [references/commands.md](references/commands.md) only for exact CLI/MCP syntax, Flow replay, credentials, or platform prerequisites.
@@ -14,6 +14,7 @@ npx -y @aarwitz/tapp@latest tree [target] --json
14
14
  npx -y @aarwitz/tapp@latest shot
15
15
  npx -y @aarwitz/tapp@latest report latest
16
16
  npx -y @aarwitz/tapp@latest doctor
17
+ npx -y @aarwitz/tapp@latest feedback "short title" --body "what happened" --type bug|idea|question # draft; add --submit to file (public)
17
18
  ```
18
19
 
19
20
  Platform examples: