@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.
- package/.claude-plugin/plugin.json +2 -2
- package/AGENTS.md +4 -0
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +73 -4
- package/README.md +8 -7
- package/docs/scenarios.md +1 -1
- package/mcp-server/src/application-model.js +1 -1
- package/mcp-server/src/ci-report.js +7 -5
- package/mcp-server/src/html-report.js +1 -1
- package/mcp-server/src/product-operations.js +9 -1
- package/mcp-server/src/report.js +7 -2
- package/package.json +1 -1
- package/scripts/ci-gate.sh +17 -3
- package/scripts/platform-gate.js +3 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tapp",
|
|
3
3
|
"description": "Give Claude hands and eyes on iOS, Android, and web apps, with exploration, replayable flows, evidence, and deterministic CI gates.",
|
|
4
|
-
"version": "0.17.
|
|
4
|
+
"version": "0.17.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.
|
|
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
|
|
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
|
|
35
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
500
|
-
? ` · capture: ${[report.
|
|
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?.
|
|
566
|
-
regression.captureMismatch = { baseline: baseline.
|
|
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.
|
|
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 ||
|
|
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 = "") {
|
package/mcp-server/src/report.js
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|
package/scripts/ci-gate.sh
CHANGED
|
@@ -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
|
|
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")
|
package/scripts/platform-gate.js
CHANGED
|
@@ -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) {
|