@aarwitz/tapp 0.17.7 → 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.7",
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.7",
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.7 # 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.7 # 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/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.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,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");
@@ -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}
@@ -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.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) {