@aarwitz/tapp 0.17.6 → 0.17.8

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.6",
4
+ "version": "0.17.8",
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.6",
27
+ "@aarwitz/tapp@0.17.8",
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.6 # or pin the reviewed release commit SHA
352
+ - uses: aarwitz/tapp@v0.17.8 # 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.6 # or pin the reviewed release commit SHA
402
+ - uses: aarwitz/tapp@v0.17.8 # 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
package/bin/tapp.js CHANGED
@@ -270,10 +270,10 @@ function safeCommandUsage(verb) {
270
270
  shot: "tapp shot [--out FILE]",
271
271
  apps: "tapp apps",
272
272
  build: "tapp build [repo] [--scheme NAME] [--configuration NAME]",
273
- flow: "tapp flow example\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--actor NAME] [--email VALUE] [--password VALUE] [--device \"iPhone 13\"] [--viewport 390x844]",
273
+ flow: "tapp flow example\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",
274
274
  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
- 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]",
276
- scenario: "tapp scenario validate FILE [--project-dir DIR]\ntapp scenario run FILE --platform web --url URL [--project-dir DIR]",
275
+ 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
+ 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",
277
277
  map: "tapp map build MARKERS [--platform PLATFORM] [--out FILE] [--replace]\ntapp map inspect [FILE]\ntapp map diff BEFORE AFTER [--comparable]",
278
278
  pr: "tapp pr plan [--base REF|--changed-files FILE] [--head REF] [--platform PLATFORM] [--out FILE]\ntapp pr gate PLAN [gate target/options]\ntapp pr adopt PLAN --item ID [--project-dir DIR]",
279
279
  plan: "tapp plan show [FILE]\ntapp plan review [FILE] --approve NAME[,NAME] --reject NAME[,NAME] --defer NAME[,NAME]\ntapp plan generate|validate|promote [FILE] [options]",
@@ -282,7 +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
- doctor: "tapp doctor",
285
+ doctor: "tapp doctor [--json]\n Exit codes: 0 environment healthy · 1 blocked (fix ❌ items)",
286
286
  install: "tapp install",
287
287
  mcp: "tapp mcp",
288
288
  };
@@ -1425,37 +1425,51 @@ switch (command) {
1425
1425
  }
1426
1426
 
1427
1427
  case "doctor": {
1428
- console.log(`tapp v${pkg.version} doctor\n`);
1428
+ const { flags: doctorFlags } = parseVerbArgs(rest);
1429
+ const jsonMode = doctorFlags.json === true;
1430
+ const report = { version: pkg.version, healthy: true, node: {}, python3: {}, disk: {},
1431
+ platforms: { ios: {}, android: {}, web: {} }, home: tappHome };
1432
+ const say = (line) => { if (!jsonMode) console.log(line); };
1433
+ const sayOk = (label, detail = "") => { if (!jsonMode) ok(label, detail); };
1434
+ const sayBad = (label, detail = "") => { if (!jsonMode) bad(label, detail); };
1435
+ say(`tapp v${pkg.version} — doctor\n`);
1429
1436
  let healthy = true;
1430
1437
 
1431
1438
  const major = Number(process.versions.node.split(".")[0]);
1432
- major >= 18 ? ok("Node", `v${process.versions.node}`) : (bad("Node", `v${process.versions.node} (need >= 18)`), (healthy = false));
1439
+ report.node = { ok: major >= 18, version: process.versions.node };
1440
+ major >= 18 ? sayOk("Node", `v${process.versions.node}`) : (sayBad("Node", `v${process.versions.node} (need >= 18)`), (healthy = false));
1433
1441
 
1434
1442
  const python = run("python3", ["--version"]);
1435
- python.code === 0 ? ok("python3", `${python.stdout} (used by Flows)`) : bad("python3", "not found Flow replay needs python3 + pyyaml (everything else works)");
1443
+ report.python3 = { ok: python.code === 0, version: python.code === 0 ? python.stdout : null };
1444
+ python.code === 0 ? sayOk("python3", `${python.stdout} (used by Flows)`) : sayBad("python3", "not found — Flow replay needs python3 + pyyaml (everything else works)");
1436
1445
 
1437
1446
  const { storagePreflight } = await import(path.join(packageRoot, "mcp-server", "src", "environment-preflight.js"));
1438
1447
  const storage = storagePreflight(tappHome);
1439
- if (storage.level === "blocked") { bad("Disk space", storage.message); healthy = false; }
1440
- else if (storage.level === "warning") console.log(` ⚠️ Disk space — ${storage.message}`);
1441
- else if (storage.level === "ok") ok("Disk space", storage.message);
1442
- else console.log(` ⬜ Disk space — ${storage.message || "could not be checked"}`);
1448
+ report.disk = { level: storage.level, message: storage.message || null };
1449
+ if (storage.level === "blocked") { sayBad("Disk space", storage.message); healthy = false; }
1450
+ else if (storage.level === "warning") say(` ⚠️ Disk space — ${storage.message}`);
1451
+ else if (storage.level === "ok") sayOk("Disk space", storage.message);
1452
+ else say(` ⬜ Disk space — ${storage.message || "could not be checked"}`);
1443
1453
 
1444
- console.log("\n Platforms:");
1454
+ say("\n Platforms:");
1445
1455
  if (process.platform === "darwin") {
1446
1456
  const xcode = run("xcode-select", ["-p"]);
1447
1457
  const simctl = run("xcrun", ["simctl", "help"]);
1448
1458
  if (xcode.code === 0 && simctl.code === 0) {
1449
1459
  const ver = run("xcodebuild", ["-version"]).stdout.split("\n")[0];
1450
1460
  const booted = bootedSims();
1451
- ok("iOS", `${ver || "Xcode"}; ${booted.length ? `${booted[0].name} booted` : "no simulator booted yet"}`);
1452
1461
  const xctestrun = harnessXctestrun();
1453
- xctestrun ? ok("iOS harness cache", xctestrun) : console.log(" ⬜ iOS harness cache — builds on first use (or: npx -y @aarwitz/tapp@latest install)");
1462
+ report.platforms.ios = { available: true, xcode: ver || "Xcode",
1463
+ bootedSimulator: booted.length ? booted[0].name : null, harnessCache: Boolean(xctestrun) };
1464
+ sayOk("iOS", `${ver || "Xcode"}; ${booted.length ? `${booted[0].name} booted` : "no simulator booted yet"}`);
1465
+ xctestrun ? sayOk("iOS harness cache", xctestrun) : say(" ⬜ iOS harness cache — builds on first use (or: npx -y @aarwitz/tapp@latest install)");
1454
1466
  } else {
1455
- console.log(" ⬜ iOS unavailable (install Xcode + simulator runtime)");
1467
+ report.platforms.ios = { available: false, reason: "install Xcode + simulator runtime" };
1468
+ say(" ⬜ iOS — unavailable (install Xcode + simulator runtime)");
1456
1469
  }
1457
1470
  } else {
1458
- console.log(` ⬜ iOS requires macOS (this host: ${process.platform})`);
1471
+ report.platforms.ios = { available: false, reason: `requires macOS (this host: ${process.platform})` };
1472
+ say(` ⬜ iOS — requires macOS (this host: ${process.platform})`);
1459
1473
  }
1460
1474
 
1461
1475
  const { resolveAdbPath, resolveAndroidSdkRoot } = await import(path.join(packageRoot, "mcp-server", "src", "android-driver.js"));
@@ -1463,17 +1477,20 @@ switch (command) {
1463
1477
  const adb = adbPath ? run(adbPath, ["devices"]) : { code: 1, stdout: "" };
1464
1478
  if (adb.code === 0) {
1465
1479
  const devices = adb.stdout.split(/\r?\n/).slice(1).filter((line) => /\sdevice(?:\s|$)/.test(line));
1466
- ok("Android", devices.length ? `${devices.length} connected emulator/device` : "adb available; no device connected");
1480
+ report.platforms.android = { adb: true, devicesConnected: devices.length };
1481
+ sayOk("Android", devices.length ? `${devices.length} connected emulator/device` : "adb available; no device connected");
1467
1482
  } else {
1468
- console.log(" ⬜ Android adb not found (install Android SDK platform-tools)");
1483
+ report.platforms.android = { adb: false, devicesConnected: 0 };
1484
+ say(" ⬜ Android — adb not found (install Android SDK platform-tools)");
1469
1485
  }
1470
1486
  const { resolveJavaRuntime } = await import(path.join(packageRoot, "mcp-server", "src", "environment-preflight.js"));
1471
1487
  const java = resolveJavaRuntime();
1472
1488
  const androidSdkRoot = resolveAndroidSdkRoot();
1473
- if (java && androidSdkRoot) ok("Android source builds", `${java.version || java.javaHome}; SDK ${androidSdkRoot}`);
1489
+ report.platforms.android.sourceBuilds = Boolean(java && androidSdkRoot);
1490
+ if (java && androidSdkRoot) sayOk("Android source builds", `${java.version || java.javaHome}; SDK ${androidSdkRoot}`);
1474
1491
  else {
1475
1492
  const missing = [!java ? "JDK 17" : "", !androidSdkRoot ? "Android SDK root" : ""].filter(Boolean).join(" and ");
1476
- console.log(` ⬜ Android source builds — install/configure ${missing} (prebuilt APK testing still works)`);
1493
+ say(` ⬜ Android source builds — install/configure ${missing} (prebuilt APK testing still works)`);
1477
1494
  }
1478
1495
 
1479
1496
  try {
@@ -1481,18 +1498,26 @@ switch (command) {
1481
1498
  let executable = "";
1482
1499
  try { executable = chromium.executablePath(); } catch { /* report the missing browser below */ }
1483
1500
  if (executable && fs.existsSync(executable)) {
1484
- ok("Web", `Playwright + Chromium (${executable})`);
1501
+ report.platforms.web = { available: true, chromium: executable };
1502
+ sayOk("Web", `Playwright + Chromium (${executable})`);
1485
1503
  } else {
1486
- console.log(" ⬜ Web Playwright installed; Chromium browser missing (run: npx playwright install chromium)");
1504
+ report.platforms.web = { available: false, reason: "Chromium browser missing (run: npx playwright install chromium)" };
1505
+ say(" ⬜ Web — Playwright installed; Chromium browser missing (run: npx playwright install chromium)");
1487
1506
  }
1488
1507
  } catch {
1489
- console.log(" ⬜ Web install Playwright in the app workspace: npm install -D playwright && npx playwright install chromium");
1508
+ report.platforms.web = { available: false, reason: "Playwright not installed" };
1509
+ say(" ⬜ Web — install Playwright in the app workspace: npm install -D playwright && npx playwright install chromium");
1490
1510
  }
1491
1511
 
1492
- console.log(`\n Home: ${tappHome}`);
1493
- console.log(healthy
1494
- ? "\nReady. Start with:\n npx -y @aarwitz/tapp@latest open [target]\n npx -y @aarwitz/tapp@latest explore [target]"
1495
- : "\nFix the ❌ items above, then re-run: npx -y @aarwitz/tapp@latest doctor");
1512
+ report.healthy = healthy;
1513
+ if (jsonMode) {
1514
+ console.log(JSON.stringify(report, null, 2));
1515
+ } else {
1516
+ console.log(`\n Home: ${tappHome}`);
1517
+ console.log(healthy
1518
+ ? "\nReady. Start with:\n npx -y @aarwitz/tapp@latest open [target]\n npx -y @aarwitz/tapp@latest explore [target]"
1519
+ : "\nFix the ❌ items above, then re-run: npx -y @aarwitz/tapp@latest doctor");
1520
+ }
1496
1521
  process.exit(healthy ? 0 : 1);
1497
1522
  }
1498
1523
 
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.6 # or pin the reviewed release commit SHA
77
+ - uses: aarwitz/tapp@v0.17.8 # 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,6 +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
+ // 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),
181
184
  };
182
185
  }
183
186
 
@@ -399,6 +402,10 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
399
402
  lines.push(`### Since baseline — ${regFailed ? "🔴 regression gate FAILED" : "🟢 regression gate passed"}`);
400
403
  lines.push(`+${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved` +
401
404
  (regFailed ? ` — **${newCritical} new critical, ${newHigh} new high**` : ""));
405
+ if (regression.captureMismatch) {
406
+ 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";
407
+ lines.push(`⚠️ **Capture mismatch**: baseline was captured at ${describe(regression.captureMismatch.baseline)}, this run at ${describe(regression.captureMismatch.current)}. "Resolved" findings may reflect layout differences at the new size, not fixes — re-baseline at the same device/viewport to compare honestly.`);
408
+ }
402
409
  for (const f of regression.newFindings) {
403
410
  lines.push(`- NEW ${SEV_ICON[f.severity] || ""} ${f.severity}: ${f.title} (${f.screen ?? "—"})`);
404
411
  }
@@ -491,7 +498,11 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
491
498
  lines.push(`**Gate (${gate.policy}): ${badge}**${gate.reasons.length ? " — " + gate.reasons.join("; ") : ""}${ignoredNote}`);
492
499
  // The gate is only authoritative about what it actually ran — record the scope explicitly.
493
500
  const rev = gate.revision?.sha ? `${String(gate.revision.sha).slice(0, 12)}${gate.revision.dirty ? "-dirty" : ""}` : "unknown";
494
- lines.push(`_target: ${gate.target || "—"} · revision: ${rev} · policy: ${gate.policy} v${gate.policyVersion || "?"}_`);
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(" ")}`
503
+ : "";
504
+ const stableId = report.targetKey && report.targetKey !== gate.target ? ` (${report.targetKey})` : "";
505
+ lines.push(`_target: ${gate.target || "—"}${stableId} · revision: ${rev} · policy: ${gate.policy} v${gate.policyVersion || "?"}${captured}_`);
495
506
  if (Array.isArray(gate.checked) && gate.checked.length) lines.push(`_Checked: ${gate.checked.join(" · ")}_`);
496
507
  if (Array.isArray(gate.notChecked) && gate.notChecked.length) lines.push(`_Not checked: ${gate.notChecked.join(" · ")}_`);
497
508
  return lines.join("\n");
@@ -550,6 +561,12 @@ if (collapsed.length) {
550
561
  report.headline = `${collapsed.length} screen(s) regressed vs. baseline (content collapsed or became unreachable).`;
551
562
  }
552
563
  const regression = computeRegression(report.findings, baseline?.findings ?? null);
564
+ // A baseline captured at a different device/viewport is a layout comparison, not a regression
565
+ // signal: a phone run legitimately hides desktop nav links, so its "resolved" list lies. Keep
566
+ // the diff (new findings still gate) but stamp the mismatch so every consumer can see it.
567
+ if (regression && baseline?.captureContext && report.captureContext && JSON.stringify(baseline.captureContext) !== JSON.stringify(report.captureContext)) {
568
+ regression.captureMismatch = { baseline: baseline.captureContext, current: report.captureContext };
569
+ }
553
570
  const runs = args.flowLogs.map(parseFlowLog);
554
571
  const contracts = runs.filter((run) => run.kind === "release-contract");
555
572
  const flows = runs.filter((run) => !["scenario", "release-contract"].includes(run.kind));
@@ -572,7 +589,9 @@ try {
572
589
  const decision = evaluateGate({ report, regression, flows, scenarios, contracts, prPlan, baseline, failOn: args.failOn });
573
590
  const gate = {
574
591
  ...decision,
575
- target: args.targetKey || report.target || null,
592
+ // Display identity is the URL/bundle the run exercised; the stable application-model id
593
+ // stays on report.targetKey for baseline matching (a hash is not a target name).
594
+ target: report.target || args.targetKey || null,
576
595
  revision: gitRevision(args.projectDir),
577
596
  checked: report.checkedFor,
578
597
  notChecked: report.notChecked,
@@ -140,6 +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.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>` : ""}
143
144
  <div class="headline">${esc(r.headline)}</div>
144
145
  ${r.credentialWarning ? `<div class="warning">${esc(r.credentialWarning)}</div>` : ""}
145
146
  ${scopeHtml}
@@ -1721,9 +1721,10 @@ async function writeRunUiMap({ markersPath, platform, target, runId, outDir }) {
1721
1721
  /** One-line "3 buttons · 2 fields · 8 text" breakdown of an accessibility element list. */
1722
1722
  function elementBreakdown(elements) {
1723
1723
  const has = (e, ...pats) => pats.some((p) => String(e.type || "").includes(p));
1724
- let buttons = 0, fields = 0, texts = 0, cells = 0, other = 0;
1724
+ let buttons = 0, links = 0, fields = 0, texts = 0, cells = 0, other = 0;
1725
1725
  for (const e of elements || []) {
1726
- if (has(e, "Button", "rawValue: 9", "Link", "rawValue: 39")) buttons++;
1726
+ if (has(e, "Link", "rawValue: 39")) links++;
1727
+ else if (has(e, "Button", "rawValue: 9")) buttons++;
1727
1728
  else if (has(e, "TextField", "rawValue: 49", "rawValue: 50", "SecureTextField")) fields++;
1728
1729
  else if (has(e, "StaticText", "rawValue: 48")) texts++;
1729
1730
  else if (has(e, "Cell", "rawValue: 75")) cells++;
@@ -1731,6 +1732,7 @@ function elementBreakdown(elements) {
1731
1732
  }
1732
1733
  return [
1733
1734
  buttons && `${buttons} button${buttons > 1 ? "s" : ""}`,
1735
+ links && `${links} link${links > 1 ? "s" : ""}`,
1734
1736
  fields && `${fields} field${fields > 1 ? "s" : ""}`,
1735
1737
  cells && `${cells} cell${cells > 1 ? "s" : ""}`,
1736
1738
  texts && `${texts} text`,
@@ -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 = "") {
@@ -30,6 +30,7 @@ export function parseOcqaMarkers(markersFilePath) {
30
30
  const transitions = [];
31
31
  const issues = [];
32
32
  let complete = null;
33
+ let context = null;
33
34
 
34
35
  for (const line of lines) {
35
36
  if (!line.startsWith("OCQA_")) continue;
@@ -57,6 +58,7 @@ export function parseOcqaMarkers(markersFilePath) {
57
58
  if (category === "TRANSITION") transitions.push(parsed);
58
59
  if (category === "ISSUE") issues.push(parsed);
59
60
  if (category === "COMPLETE") complete = parsed;
61
+ if (category === "CONTEXT") context = parsed;
60
62
  }
61
63
 
62
64
  return {
@@ -72,6 +74,7 @@ export function parseOcqaMarkers(markersFilePath) {
72
74
  )
73
75
  ),
74
76
  complete,
77
+ context,
75
78
  actions,
76
79
  recentActions: actions.slice(-5),
77
80
  recentTransitions: transitions.slice(-5),
@@ -101,7 +104,11 @@ export const ISSUE_CATEGORY = {
101
104
  explore_timeout: "performance_timeout",
102
105
  };
103
106
  export const CRITICAL_ISSUE_TYPES = new Set(["crash"]);
104
- export const WEB_SAMPLED_ISSUE_TYPES = new Set(["unresponsive_element", "outbound_unavailable"]);
107
+ // outbound_unavailable graduated to the deterministic tier in policy v4: since 0.17.6 the
108
+ // outbound audit renders each link in the live browser, so the unavailable-shell phrase match
109
+ // is a stable observation of the page, not a sampled probe — and a dead social link is exactly
110
+ // the broken-link class the web fail-on default exists to block.
111
+ export const WEB_SAMPLED_ISSUE_TYPES = new Set(["unresponsive_element"]);
105
112
 
106
113
  export function severityRank(s) {
107
114
  return { critical: 0, high: 1, medium: 2, low: 3 }[s] ?? 4;
@@ -296,6 +303,9 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
296
303
  : !coverageFloorMet ? "coverage-floor-not-met"
297
304
  : driverStop === "frontier-drained" ? "no-unexplored-in-scope-controls"
298
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)
299
309
  : "completed";
300
310
 
301
311
  const headline = timeBudgetExhausted
@@ -401,6 +411,14 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
401
411
  ...(action.reason ? { reason: action.reason } : {}),
402
412
  })),
403
413
  evidence: { markers: base.relativeMarkersFilePath },
414
+ // Capture conditions shared by every screenshot/marker in this run (web: from the driver's
415
+ // CONTEXT marker). A baseline diff across different captures is a layout comparison, not a
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"
420
+ ? { device: base.context.device || null, viewport: base.context.viewport || null, deviceScaleFactor: base.context.deviceScaleFactor ?? null }
421
+ : null,
404
422
  uiMap: null,
405
423
  comparison: null,
406
424
  checkedFor,
@@ -548,7 +566,7 @@ export const GATE_EXIT = { pass: 0, fail: 1, error: 2, inconclusive: 3 };
548
566
  // finding at or above that severity, and the CLI defaults web targets to `medium` — a 404 in the
549
567
  // nav is the release blocker on a website, and a field-tested green PASS over six deterministic
550
568
  // findings was exactly the dishonest verdict this product refuses to render.
551
- export const GATE_POLICY_VERSION = "3";
569
+ export const GATE_POLICY_VERSION = "4";
552
570
 
553
571
  // Pure gate evaluator: frozen evidence + policy → a GateRun decision. Extracted verbatim from the
554
572
  // former inline logic in ci-report.js so the `[char]` characterization tests keep passing — the
@@ -576,7 +576,12 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
576
576
 
577
577
  const { chromium, devices } = await loadPlaywright();
578
578
  const browser = await chromium.launch(webBrowserLaunchOptions(process.env, { watch }));
579
- const context = await browser.newContext(webContextOptions({ device, viewport, devices }));
579
+ const captureProfile = webContextOptions({ device, viewport, devices });
580
+ // Evidence is only comparable when the capture conditions are on record: a desktop run and a
581
+ // phone run legitimately disagree about which nav links exist, and a baseline diff across
582
+ // them must be able to say so instead of reporting "resolved".
583
+ emit("CONTEXT", { ...(String(device || "").trim() ? { device: String(device).trim() } : {}), viewport: captureProfile.viewport, deviceScaleFactor: captureProfile.deviceScaleFactor ?? 1 });
584
+ const context = await browser.newContext(captureProfile);
580
585
  await installWebListenerTracking(context);
581
586
  if (watch) await installWebWatchUi(context);
582
587
  const page = await context.newPage();
@@ -1040,7 +1045,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
1040
1045
  await auditPage.waitForTimeout(400); // let client-rendered shells paint their copy
1041
1046
  const text = await auditPage.evaluate(() => (document.body && document.body.innerText || "").slice(0, 120000)).catch(() => "");
1042
1047
  const phrase = webUnavailableShellPhrase(text);
1043
- if (phrase) issue("outbound_unavailable", "low", `Outbound link returns 200 but shows "${phrase}": ${href.slice(0, 100)}`, meta.screen, href, meta.sourceUrl);
1048
+ if (phrase) issue("outbound_unavailable", "medium", `Outbound link returns 200 but shows "${phrase}": ${href.slice(0, 100)}`, meta.screen, href, meta.sourceUrl);
1044
1049
  }
1045
1050
  await auditPage.close().catch(() => {});
1046
1051
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.6",
3
+ "version": "0.17.8",
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) {