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