@aarwitz/tapp 0.17.0-rc.1 → 0.17.0-rc.10
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/AGENTS.md +9 -0
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +100 -31
- package/README.md +6 -0
- package/bin/tapp.js +166 -40
- package/docs/application-model.md +9 -2
- package/mcp-server/src/android-driver.js +88 -3
- package/mcp-server/src/android-explorer.js +83 -14
- package/mcp-server/src/application-model.js +7 -4
- package/mcp-server/src/ci-report.js +3 -3
- package/mcp-server/src/html-report.js +37 -3
- package/mcp-server/src/index.js +59 -27
- package/mcp-server/src/pr-selection.js +4 -3
- package/mcp-server/src/product-operations.js +94 -5
- package/mcp-server/src/report.js +64 -15
- package/mcp-server/src/web-explorer.js +1 -1
- package/package.json +2 -2
- package/scripts/ci-gate.sh +8 -6
- package/scripts/flow_ai_judge.py +1 -1
- package/scripts/platform-gate.js +11 -5
- package/scripts/quick-capture.sh +71 -37
package/AGENTS.md
CHANGED
|
@@ -21,6 +21,15 @@ npx -y @aarwitz/tapp explore app.apk --platform android --app-id com.acme.app
|
|
|
21
21
|
npx -y @aarwitz/tapp flow run .tapp/flows/smoke.yml # committed, keyless E2E replay
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
If repository onboarding detects multiple application targets, target detection is deterministic but
|
|
25
|
+
the choice is the user's. In a human TTY, Tapp displays a numbered selector and continues in the same
|
|
26
|
+
command. A non-interactive CLI prints the exact choices and exits before building. MCP returns
|
|
27
|
+
`reason: "target-selection-required"` with structured `choices[]` (`platform`, `name`, `sourcePath`,
|
|
28
|
+
`selector`, and exact `command`). **Do not pick one yourself.** Present those choices to the user
|
|
29
|
+
with the client's native multiple-choice question UI when available, then rerun using the selected
|
|
30
|
+
`--platform` and `--target`. Plain chat can list the same choices when the client has no question
|
|
31
|
+
widget.
|
|
32
|
+
|
|
24
33
|
For focused web evidence, `open` and `tree` accept one semantic interaction plus an async content
|
|
25
34
|
wait: `tapp open https://example.com --tap "Not now" --wait-for "Dashboard"`. Tapp waits for the
|
|
26
35
|
page to stabilize before capturing it and warns honestly if the bounded wait ends while it is still
|
|
@@ -946,6 +946,7 @@ class ExplorerTests: XCTestCase {
|
|
|
946
946
|
var elementScreenPresence: [String: Set<String>] = [:]
|
|
947
947
|
var totalDistinctStates = 0
|
|
948
948
|
var recentStateHashes: [String] = []
|
|
949
|
+
var recentScreenTitles: [String] = []
|
|
949
950
|
var actionsSinceNewState = 0
|
|
950
951
|
var sameScreenStreak = 0
|
|
951
952
|
var screenTextEntryCount: [String: Int] = [:]
|
|
@@ -1238,6 +1239,15 @@ class ExplorerTests: XCTestCase {
|
|
|
1238
1239
|
let initialControlsJson = mapControlsJSON(initialElements)
|
|
1239
1240
|
print("OCQA_STATE:{\"screen\":\"\(escapeJSON(initialTitle))\",\"hash\":\"\(computeHash(initialElements))\",\"elements\":\(initialElements.count),\"action\":0,\"role\":\"\(escapeJSON(initialRole))\",\"summary\":\"\(escapeJSON(initialSummary))\",\"settled\":\(isScreenSettled() ? "true" : "false"),\"atext\":[\(initialAtext)],\"inputs\":[\(initialInputJson)],\"controls\":[\(initialControlsJson)]}")
|
|
1240
1241
|
|
|
1242
|
+
// The launch surface is real evidence even when root normalization immediately dismisses
|
|
1243
|
+
// it. Attach it before the first action so the HTML report's "every screen explored" claim
|
|
1244
|
+
// includes onboarding/login sheets rather than beginning at the post-dismiss destination.
|
|
1245
|
+
let initialScreenshot = app.screenshot()
|
|
1246
|
+
let initialAttachment = XCTAttachment(screenshot: initialScreenshot)
|
|
1247
|
+
initialAttachment.name = "state_0_\(initialTitle.replacingOccurrences(of: " ", with: "_"))"
|
|
1248
|
+
initialAttachment.lifetime = .keepAlways
|
|
1249
|
+
add(initialAttachment)
|
|
1250
|
+
|
|
1241
1251
|
navigateToRootScreen(actionCount: &actionCount)
|
|
1242
1252
|
|
|
1243
1253
|
// The first state is the true customer launch surface. Directed replay begins after
|
|
@@ -1386,8 +1396,10 @@ class ExplorerTests: XCTestCase {
|
|
|
1386
1396
|
}
|
|
1387
1397
|
|
|
1388
1398
|
recentStateHashes.append(stateHash)
|
|
1399
|
+
recentScreenTitles.append(titleStr)
|
|
1389
1400
|
if recentStateHashes.count > 12 {
|
|
1390
1401
|
recentStateHashes.removeFirst(recentStateHashes.count - 12)
|
|
1402
|
+
recentScreenTitles.removeFirst(recentScreenTitles.count - 12)
|
|
1391
1403
|
}
|
|
1392
1404
|
|
|
1393
1405
|
if previousStateHash == stateHash {
|
|
@@ -1474,10 +1486,19 @@ class ExplorerTests: XCTestCase {
|
|
|
1474
1486
|
// is the fuller a11y text inventory that grounds the vision reviewer (see visionTextInventory).
|
|
1475
1487
|
let escapedTitle = escapeJSON(titleStr)
|
|
1476
1488
|
let settled = isScreenSettled()
|
|
1477
|
-
let
|
|
1489
|
+
let visibleTextInventory = visionTextInventory(elements)
|
|
1490
|
+
let atextJson = visibleTextInventory.map { "\"\(escapeJSON($0))\"" }.joined(separator: ",")
|
|
1478
1491
|
let controlsJson = mapControlsJSON(elements)
|
|
1479
1492
|
print("OCQA_STATE:{\"screen\":\"\(escapedTitle)\",\"hash\":\"\(stateHash)\",\"elements\":\(elements.count),\"action\":\(actionCount),\"role\":\"\(escapeJSON(screenRole))\",\"summary\":\"\(escapeJSON(screenSummary))\",\"settled\":\(settled ? "true" : "false"),\"atext\":[\(atextJson)],\"inputs\":[\(inputJsonArray)],\"controls\":[\(controlsJson)]}")
|
|
1480
1493
|
|
|
1494
|
+
// Signing out after a successful login is a completed auth cycle, not a navigation
|
|
1495
|
+
// trap and not lost-form-state. Stop cleanly instead of probing the root login screen
|
|
1496
|
+
// for an impossible back path or expecting credentials to persist after logout.
|
|
1497
|
+
if authSucceeded && detectedInputs.contains(where: { $0.secure }) {
|
|
1498
|
+
print("OCQA_STATE:auth_cycle_complete screen=\(escapedTitle) step=\(actionCount)")
|
|
1499
|
+
break
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1481
1502
|
// ---- Persistence probe: on a fresh RE-ARRIVAL at a screen, fields we previously
|
|
1482
1503
|
// typed into (and verified visible in the a11y value) should still hold their value.
|
|
1483
1504
|
// An empty field here means the entered state was silently lost on navigation —
|
|
@@ -1497,6 +1518,8 @@ class ExplorerTests: XCTestCase {
|
|
|
1497
1518
|
if current.isEmpty || current == ph {
|
|
1498
1519
|
reportedPersistenceKeys.insert(memKey)
|
|
1499
1520
|
let t = "Entered value did not persist: '\(fieldKey)' on \(titleStr)"
|
|
1521
|
+
issues.append((type: "state_persistence", severity: "medium", title: t,
|
|
1522
|
+
desc: "Typed '\(typed)' into this field earlier in the run; after navigating away and returning, the field is empty — entered state was silently lost."))
|
|
1500
1523
|
print("OCQA_ISSUE:{\"type\":\"state_persistence\",\"severity\":\"medium\",\"title\":\"\(escapeJSON(t))\",\"screen\":\"\(escapedTitle)\",\"control\":\"\(escapeJSON(fieldKey))\",\"step\":\(actionCount),\"desc\":\"Typed '\(escapeJSON(typed))' into this field earlier in the run; after navigating away and returning, the field is empty — entered state was silently lost.\"}")
|
|
1501
1524
|
}
|
|
1502
1525
|
}
|
|
@@ -1591,14 +1614,14 @@ class ExplorerTests: XCTestCase {
|
|
|
1591
1614
|
// content-feed app: post detail + replies-loading spinner flagged app_hang HIGH).
|
|
1592
1615
|
let visibleTextCount = elements.filter { isStaticTextType($0.type) && normalizeVisibleText($0.label).count >= 3 }.count
|
|
1593
1616
|
if screenVisitCount[titleStr] ?? 0 <= 1, visibleTextCount <= 4,
|
|
1594
|
-
|
|
1617
|
+
hasIndeterminateLoadingIndicator() {
|
|
1595
1618
|
let loadingKey = "loading:\(titleStr)"
|
|
1596
1619
|
if !reportedIssueKeys.contains(loadingKey) {
|
|
1597
1620
|
var resolved = false
|
|
1598
1621
|
let deadline = Date().addingTimeInterval(8.0)
|
|
1599
1622
|
while Date() < deadline {
|
|
1600
1623
|
Thread.sleep(forTimeInterval: 1.0)
|
|
1601
|
-
if !(
|
|
1624
|
+
if !hasIndeterminateLoadingIndicator() {
|
|
1602
1625
|
resolved = true
|
|
1603
1626
|
break
|
|
1604
1627
|
}
|
|
@@ -1658,7 +1681,13 @@ class ExplorerTests: XCTestCase {
|
|
|
1658
1681
|
|
|
1659
1682
|
// ---- Blank-screen detection ----
|
|
1660
1683
|
// Distinguish between "no a11y labels / custom UI" vs genuinely empty.
|
|
1661
|
-
|
|
1684
|
+
// A system Back/Close affordance is navigation chrome, not screen content. Treat a
|
|
1685
|
+
// destination whose only usable control is that chrome as blank too; otherwise an
|
|
1686
|
+
// EmptyView pushed by NavigationStack looks like a healthy one-control screen.
|
|
1687
|
+
let contentInteractables = interactable.filter {
|
|
1688
|
+
!isNavBackButton($0) && !isLikelyGlobalNavigation($0, screenBounds: screenBounds)
|
|
1689
|
+
}
|
|
1690
|
+
if visibleTextInventory.isEmpty && contentInteractables.isEmpty {
|
|
1662
1691
|
let blankKey = "blank:\(titleStr)"
|
|
1663
1692
|
let blankCount = (actionCounts[blankKey] ?? 0) + 1
|
|
1664
1693
|
actionCounts[blankKey] = blankCount
|
|
@@ -1690,16 +1719,26 @@ class ExplorerTests: XCTestCase {
|
|
|
1690
1719
|
}
|
|
1691
1720
|
|
|
1692
1721
|
// ---- Navigation-loop detection ----
|
|
1693
|
-
//
|
|
1722
|
+
// A cycle must actually move across distinct states. Four identical reads satisfy the
|
|
1723
|
+
// arithmetic shape A,A,A,A of the old period-2 check, which mislabeled ordinary
|
|
1724
|
+
// scroll/probe recovery on a stable screen as a navigation loop.
|
|
1694
1725
|
if recentStateHashes.count >= 6 {
|
|
1695
1726
|
let recent = recentStateHashes
|
|
1727
|
+
// Distinct structural hashes are not enough: a list and its detail rows can share
|
|
1728
|
+
// one navigation title and alternate A/B while the explorer intentionally samples
|
|
1729
|
+
// different rows. Calling that a navigation loop is a false positive. Require the
|
|
1730
|
+
// cycle to cross distinct user-visible screen titles as well.
|
|
1696
1731
|
let hasLoop2 = recent.count >= 4 &&
|
|
1697
1732
|
recent[recent.count - 1] == recent[recent.count - 3] &&
|
|
1698
|
-
recent[recent.count - 2] == recent[recent.count - 4]
|
|
1733
|
+
recent[recent.count - 2] == recent[recent.count - 4] &&
|
|
1734
|
+
Set(recent.suffix(2)).count == 2 &&
|
|
1735
|
+
Set(recentScreenTitles.suffix(2)).count == 2
|
|
1699
1736
|
let hasLoop3 = recent.count >= 6 &&
|
|
1700
1737
|
recent[recent.count - 1] == recent[recent.count - 4] &&
|
|
1701
1738
|
recent[recent.count - 2] == recent[recent.count - 5] &&
|
|
1702
|
-
recent[recent.count - 3] == recent[recent.count - 6]
|
|
1739
|
+
recent[recent.count - 3] == recent[recent.count - 6] &&
|
|
1740
|
+
Set(recent.suffix(3)).count == 3 &&
|
|
1741
|
+
Set(recentScreenTitles.suffix(3)).count == 3
|
|
1703
1742
|
if (hasLoop2 || hasLoop3) && !(authSucceeded && detectedInputs.contains { $0.secure }) {
|
|
1704
1743
|
let loopKey = "nav_loop:\(titleStr)"
|
|
1705
1744
|
if actionCounts[loopKey] == nil {
|
|
@@ -1711,17 +1750,10 @@ class ExplorerTests: XCTestCase {
|
|
|
1711
1750
|
}
|
|
1712
1751
|
}
|
|
1713
1752
|
|
|
1714
|
-
//
|
|
1715
|
-
//
|
|
1716
|
-
//
|
|
1717
|
-
|
|
1718
|
-
let unrespKey = "unresponsive:\(titleStr)"
|
|
1719
|
-
if actionCounts[unrespKey] == nil {
|
|
1720
|
-
issues.append((type: "unresponsive_element", severity: "medium", title: "Unresponsive UI on \(titleStr)", desc: "Actions are not changing app state — possible frozen or broken screen"))
|
|
1721
|
-
print("OCQA_ISSUE:{\"type\":\"unresponsive_element\",\"severity\":\"medium\",\"title\":\"Unresponsive UI\",\"screen\":\"\(escapedTitle)\",\"repeated_state_count\":\(repeatedStateCount),\"step\":\(actionCount)}")
|
|
1722
|
-
actionCounts[unrespKey] = 1
|
|
1723
|
-
}
|
|
1724
|
-
}
|
|
1753
|
+
// Do not infer an unresponsive app merely from an unchanged state streak: recovery
|
|
1754
|
+
// gestures (scroll, carousel probe, center probe) are expected to be no-ops on many
|
|
1755
|
+
// healthy screens. Labeled controls have a stronger detector below: a direct tap plus
|
|
1756
|
+
// two delayed, content-signature reads. Hangs have their own time-based detector.
|
|
1725
1757
|
|
|
1726
1758
|
if interactable.count < 3 {
|
|
1727
1759
|
print("OCQA_STATE:low_interactable screen=\(escapedTitle) total=\(elements.count) interactable=\(interactable.count) global=\(globalNavElements.count) nonGlobal=\(nonGlobalCandidates.count)")
|
|
@@ -1757,10 +1789,10 @@ class ExplorerTests: XCTestCase {
|
|
|
1757
1789
|
break
|
|
1758
1790
|
}
|
|
1759
1791
|
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1792
|
+
// Exhausting Tapp's untried candidate pool is not itself a user-visible dead end:
|
|
1793
|
+
// leaf screens commonly have only a working Back control that was already mapped.
|
|
1794
|
+
// Recover first; only the stronger navigation-trap path below emits a finding when
|
|
1795
|
+
// every real back/dismiss route fails.
|
|
1764
1796
|
// tryGoBack does swipe-down as its last resort (sheet dismiss)
|
|
1765
1797
|
let preBackTitle = titleStr
|
|
1766
1798
|
let backWorked = tryGoBack()
|
|
@@ -1772,6 +1804,7 @@ class ExplorerTests: XCTestCase {
|
|
|
1772
1804
|
print("OCQA_ACTION:{\"type\":\"back\",\"reason\":\"dead_end_escape\",\"from\":\"\(escapedTitle)\",\"to\":\"\(escapeJSON(postTitle))\",\"step\":\(actionCount),\"screen\":\"\(escapedTitle)\",\"narrative\":\"\(escapeJSON(recoveryNarrative("back_dead_end", screen: titleStr, to: postTitle)))\"}")
|
|
1773
1805
|
continue
|
|
1774
1806
|
}
|
|
1807
|
+
if actionCount >= maxActions { break }
|
|
1775
1808
|
// Swipe right (back gesture) as another option
|
|
1776
1809
|
let swipeStart = app.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.5))
|
|
1777
1810
|
let swipeEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
|
|
@@ -2047,6 +2080,7 @@ class ExplorerTests: XCTestCase {
|
|
|
2047
2080
|
}
|
|
2048
2081
|
// Back didn't change screens — fall through to global nav
|
|
2049
2082
|
print("OCQA_ACTION:{\"type\":\"back\",\"reason\":\"screen_exhausted_failed\",\"screen\":\"\(escapedTitle)\",\"step\":\(actionCount),\"narrative\":\"\(escapeJSON(recoveryNarrative("back_failed", screen: titleStr)))\"}")
|
|
2083
|
+
if actionCount >= maxActions { break }
|
|
2050
2084
|
// Stuck on this screen — use global navigation (tab bar) to reach unexplored areas
|
|
2051
2085
|
let globalNav = interactable
|
|
2052
2086
|
.filter { isLikelyGlobalNavigation($0, screenBounds: screenBounds) }
|
|
@@ -2247,12 +2281,21 @@ class ExplorerTests: XCTestCase {
|
|
|
2247
2281
|
// the crash itself goes unreported. app.state is non-throwing even when the app is dead.
|
|
2248
2282
|
// (Found on a real app: a login submit terminated the app; the run limped on but emitted
|
|
2249
2283
|
// no crash finding.) Try one relaunch to distinguish a hard crash from a transient exit.
|
|
2250
|
-
|
|
2251
|
-
|
|
2284
|
+
let stateAfterAction = app.state
|
|
2285
|
+
if stateAfterAction != .runningForeground {
|
|
2286
|
+
print("OCQA_STATE:app_left_foreground step=\(actionCount) state=\(stateAfterAction.rawValue)")
|
|
2287
|
+
let crashKey = "crash:\(titleStr)|\(key)"
|
|
2288
|
+
// A terminated process is already proof of an in-run crash. Relaunching it may
|
|
2289
|
+
// succeed, but that must not erase the user-visible failure that just happened.
|
|
2290
|
+
if stateAfterAction == .notRunning && !reportedIssueKeys.contains(crashKey) {
|
|
2291
|
+
reportedIssueKeys.insert(crashKey)
|
|
2292
|
+
issues.append((type: "crash", severity: "critical", title: "App crashed after \(actionType) on \(titleStr)",
|
|
2293
|
+
desc: "The app process terminated after \(actionType) '\(targetName)' on '\(titleStr)'."))
|
|
2294
|
+
print("OCQA_ISSUE:{\"type\":\"crash\",\"severity\":\"critical\",\"title\":\"\(escapeJSON("App crashed after \(actionType) on \(titleStr)"))\",\"screen\":\"\(escapedTitle)\",\"control\":\"\(escapedTarget)\",\"step\":\(actionCount)}")
|
|
2295
|
+
}
|
|
2252
2296
|
app.activate()
|
|
2253
2297
|
Thread.sleep(forTimeInterval: 3.0)
|
|
2254
2298
|
if app.state != .runningForeground {
|
|
2255
|
-
let crashKey = "crash:\(titleStr)|\(key)"
|
|
2256
2299
|
if !reportedIssueKeys.contains(crashKey) {
|
|
2257
2300
|
reportedIssueKeys.insert(crashKey)
|
|
2258
2301
|
issues.append((type: "crash", severity: "critical", title: "App crashed after \(actionType) on \(titleStr)",
|
|
@@ -2356,16 +2399,28 @@ class ExplorerTests: XCTestCase {
|
|
|
2356
2399
|
|
|
2357
2400
|
// ---- App left foreground / crash detection ----
|
|
2358
2401
|
// Check both .exists and .state — external links may cause either to fail
|
|
2359
|
-
let
|
|
2402
|
+
let stateAfterDelayedChecks = app.state
|
|
2403
|
+
let appInForeground = stateAfterDelayedChecks == .runningForeground
|
|
2360
2404
|
if !appInForeground || !app.exists {
|
|
2361
|
-
print("OCQA_STATE:app_left_foreground step=\(actionCount) state=\(
|
|
2405
|
+
print("OCQA_STATE:app_left_foreground step=\(actionCount) state=\(stateAfterDelayedChecks.rawValue)")
|
|
2406
|
+
let crashKey = "crash:\(titleStr)|\(key)"
|
|
2407
|
+
if stateAfterDelayedChecks == .notRunning && !reportedIssueKeys.contains(crashKey) {
|
|
2408
|
+
reportedIssueKeys.insert(crashKey)
|
|
2409
|
+
issues.append((type: "crash", severity: "critical",
|
|
2410
|
+
title: "App crashed after \(actionType) on \(titleStr)",
|
|
2411
|
+
desc: "The app process terminated after: \(actionDesc)"))
|
|
2412
|
+
print("OCQA_ISSUE:{\"type\":\"crash\",\"severity\":\"critical\",\"title\":\"\(escapeJSON("App crashed after \(actionType) on \(titleStr)"))\",\"screen\":\"\(escapedTitle)\",\"control\":\"\(escapedTarget)\",\"step\":\(actionCount)}")
|
|
2413
|
+
}
|
|
2362
2414
|
app.activate()
|
|
2363
2415
|
Thread.sleep(forTimeInterval: 3.0)
|
|
2364
2416
|
if app.state != .runningForeground {
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2417
|
+
if !reportedIssueKeys.contains(crashKey) {
|
|
2418
|
+
reportedIssueKeys.insert(crashKey)
|
|
2419
|
+
issues.append((type: "crash", severity: "critical",
|
|
2420
|
+
title: "App not recoverable",
|
|
2421
|
+
desc: "App left foreground after: \(actionDesc)"))
|
|
2422
|
+
print("OCQA_ISSUE:{\"type\":\"crash\",\"severity\":\"critical\",\"title\":\"App not recoverable\",\"action\":\"\(escapedTarget)\",\"step\":\(actionCount)}")
|
|
2423
|
+
}
|
|
2369
2424
|
break
|
|
2370
2425
|
}
|
|
2371
2426
|
print("OCQA_STATE:app_reactivated step=\(actionCount)")
|
|
@@ -3679,6 +3734,20 @@ class ExplorerTests: XCTestCase {
|
|
|
3679
3734
|
return kb.exists ? kb.frame : .zero
|
|
3680
3735
|
}
|
|
3681
3736
|
|
|
3737
|
+
/// Activity indicators are inherently indeterminate. `ProgressIndicator`, however, is also the
|
|
3738
|
+
/// XCTest type for legitimate determinate progress bars (loyalty points, upload percentage,
|
|
3739
|
+
/// onboarding completion). Only value-less/loading-valued progress indicators are hang signals.
|
|
3740
|
+
private func hasIndeterminateLoadingIndicator() -> Bool {
|
|
3741
|
+
if app.activityIndicators.allElementsBoundByIndex.contains(where: { $0.exists && $0.frame.width > 0 && $0.frame.height > 0 }) {
|
|
3742
|
+
return true
|
|
3743
|
+
}
|
|
3744
|
+
return app.progressIndicators.allElementsBoundByIndex.contains { indicator in
|
|
3745
|
+
guard indicator.exists, indicator.frame.width > 0, indicator.frame.height > 0 else { return false }
|
|
3746
|
+
let value = (indicator.value as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
3747
|
+
return value.isEmpty || value == "in progress" || value == "loading"
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
|
|
3682
3751
|
/// True when the screen is in a "settled" resting state — no on-screen keyboard and no open
|
|
3683
3752
|
/// transient overlay (menu / dropdown / popover / sheet / picker wheel). A screenshot taken while
|
|
3684
3753
|
/// one of these is up is inherently ambiguous to a visual reviewer (the keyboard "covers" the
|
package/README.md
CHANGED
|
@@ -80,6 +80,12 @@ npx -y @aarwitz/tapp baseline create . --platform web
|
|
|
80
80
|
npx -y @aarwitz/tapp ci install .
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
+
On a fresh repository containing multiple apps (for example, iOS plus web), bare
|
|
84
|
+
`tapp init . --explore` does not guess from detection order. A human terminal gets a numbered
|
|
85
|
+
selector; a non-interactive CLI prints exact target-selection commands, while MCP also returns
|
|
86
|
+
structured choices. Neither builds or writes before the choice. After you choose one, the model
|
|
87
|
+
retains every detected target and records the choice as the default for the next bare `tapp explore`.
|
|
88
|
+
|
|
83
89
|
The baseline command writes only after exploration and every selected deterministic suite pass
|
|
84
90
|
conclusively. It stores `.tapp/baselines/<platform>/<target-id>.json`; the generated workflow
|
|
85
91
|
uses that exact target identity so two apps on the same platform never share a baseline. `ci
|
package/bin/tapp.js
CHANGED
|
@@ -28,8 +28,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
28
28
|
const packageRoot = path.resolve(__dirname, "..");
|
|
29
29
|
const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
|
|
30
30
|
|
|
31
|
-
// Redirect all writable output away from the (possibly read-only) package dir.
|
|
32
|
-
//
|
|
31
|
+
// Redirect all writable output away from the (possibly read-only) package dir. TAPP_HOME and
|
|
32
|
+
// ~/.tapp are the only current runtime locations; retired environment/path aliases are ignored.
|
|
33
33
|
const tappHome = (process.env.TAPP_HOME || path.join(os.homedir(), ".tapp")).trim();
|
|
34
34
|
process.env.TAPP_HOME = tappHome;
|
|
35
35
|
// TAPP_HOME is created lazily (just before the switch) so `--help`, `help`, and `version` never
|
|
@@ -130,6 +130,27 @@ function repeatedFlagValues(argv, name) {
|
|
|
130
130
|
return values;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
function iosLaunchOptions(flags, argv) {
|
|
134
|
+
const appLaunchArgs = repeatedFlagValues(argv, "launch-arg");
|
|
135
|
+
let appLaunchEnv;
|
|
136
|
+
if (typeof flags["launch-env"] === "string") {
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(flags["launch-env"]);
|
|
139
|
+
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object" || Object.values(parsed).some((value) => typeof value !== "string")) {
|
|
140
|
+
throw new Error("expected a JSON object with string values");
|
|
141
|
+
}
|
|
142
|
+
appLaunchEnv = parsed;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
console.error(`❌ --launch-env must be a JSON object with string values: ${error.message}`);
|
|
145
|
+
process.exit(2);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
...(appLaunchArgs.length ? { appLaunchArgs } : {}),
|
|
150
|
+
...(appLaunchEnv ? { appLaunchEnv } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
133
154
|
const engineImport = () => import(path.join(packageRoot, "mcp-server", "src", "index.js"));
|
|
134
155
|
|
|
135
156
|
function requireMacFor(what) {
|
|
@@ -185,6 +206,26 @@ function printEngineError(r) {
|
|
|
185
206
|
}
|
|
186
207
|
}
|
|
187
208
|
|
|
209
|
+
async function promptForInitTarget(details) {
|
|
210
|
+
const choices = Array.isArray(details?.choices) ? details.choices : [];
|
|
211
|
+
if (!choices.length || !process.stdin.isTTY || !process.stderr.isTTY || process.env.CI) return null;
|
|
212
|
+
const { createInterface } = await import("node:readline/promises");
|
|
213
|
+
const terminal = createInterface({ input: process.stdin, output: process.stderr });
|
|
214
|
+
console.error("\nTapp found multiple application targets. Which one should it explore?");
|
|
215
|
+
choices.forEach((choice, index) => console.error(` ${index + 1}) ${choice.platform} · ${choice.name} (${choice.sourcePath})`));
|
|
216
|
+
try {
|
|
217
|
+
while (true) {
|
|
218
|
+
const answer = String(await terminal.question(`Select 1-${choices.length} (or q to cancel): `)).trim();
|
|
219
|
+
if (/^(?:q|quit|cancel)$/i.test(answer)) return null;
|
|
220
|
+
const selected = Number(answer);
|
|
221
|
+
if (Number.isInteger(selected) && selected >= 1 && selected <= choices.length) return choices[selected - 1];
|
|
222
|
+
console.error(`Enter a number from 1 to ${choices.length}, or q to cancel.`);
|
|
223
|
+
}
|
|
224
|
+
} finally {
|
|
225
|
+
terminal.close();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
188
229
|
// Turn whatever the user gave us (nothing / repo dir / .app / bundle id) into an installed
|
|
189
230
|
// bundle id, narrating build/install progress on stderr.
|
|
190
231
|
async function resolveTargetOrExit(engine, input) {
|
|
@@ -197,11 +238,48 @@ async function resolveTargetOrExit(engine, input) {
|
|
|
197
238
|
return resolved.bundleId;
|
|
198
239
|
}
|
|
199
240
|
|
|
241
|
+
function safeCommandUsage(verb) {
|
|
242
|
+
const usage = {
|
|
243
|
+
explore: "tapp explore [target] [--platform ios|android|web] [--actions N] [--timeout SEC] [--email VALUE] [--password VALUE] [--baseline FILE] [--json FILE]\n iOS launch configuration: [--launch-arg VALUE ...] [--launch-env '{\"KEY\":\"VALUE\"}']\n Android: [--app-id ID] [--apk FILE] [--serial ID] [--keep-data]",
|
|
244
|
+
init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--dry-run]",
|
|
245
|
+
open: "tapp open [target] [--platform ios|android|web] [--out FILE] [--tap TEXT] [--wait-for TEXT]",
|
|
246
|
+
tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]",
|
|
247
|
+
shot: "tapp shot [--out FILE]",
|
|
248
|
+
apps: "tapp apps",
|
|
249
|
+
build: "tapp build [repo] [--scheme NAME] [--configuration NAME]",
|
|
250
|
+
flow: "tapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--email VALUE] [--password VALUE]",
|
|
251
|
+
task: "tapp task validate FILE [--platform PLATFORM] [--map FILE]\ntapp task compile FILE --platform PLATFORM [--inputs JSON] [--out FILE]\ntapp task run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID] [--inputs JSON]",
|
|
252
|
+
contract: "tapp contract validate FILE [--platform PLATFORM] [--map FILE]\ntapp contract compile FILE --platform PLATFORM [--out FILE]\ntapp contract run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID]",
|
|
253
|
+
scenario: "tapp scenario validate FILE [--project-dir DIR]\ntapp scenario run FILE --platform web --url URL [--project-dir DIR]",
|
|
254
|
+
map: "tapp map build MARKERS [--platform PLATFORM] [--out FILE] [--replace]\ntapp map inspect [FILE]\ntapp map diff BEFORE AFTER [--comparable]",
|
|
255
|
+
pr: "tapp pr plan [--base REF|--changed-files FILE] [--head REF] [--platform PLATFORM] [--out FILE]\ntapp pr gate PLAN [gate target/options]\ntapp pr adopt PLAN --item ID [--project-dir DIR]",
|
|
256
|
+
plan: "tapp plan show [FILE]\ntapp plan review [FILE] --approve NAME[,NAME] --reject NAME[,NAME] --defer NAME[,NAME]\ntapp plan generate|validate|promote [FILE] [options]",
|
|
257
|
+
baseline: "tapp baseline create [repo] [--platform PLATFORM] [--target NAME] [--from GATE.json] [--replace]",
|
|
258
|
+
ci: "tapp ci ...\ntapp ci install [repo] [--out FILE] [--manifest FILE] [--dry-run] [--replace]",
|
|
259
|
+
actor: "tapp actor set NAME --email-env ENV --password-env ENV [--project-dir DIR]\ntapp actor list [repo]",
|
|
260
|
+
app: "tapp app [repo] [--no-open] [--port PORT]",
|
|
261
|
+
report: "tapp report [captureId|latest]",
|
|
262
|
+
doctor: "tapp doctor",
|
|
263
|
+
install: "tapp install",
|
|
264
|
+
mcp: "tapp mcp",
|
|
265
|
+
};
|
|
266
|
+
return usage[verb] || `tapp ${verb}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
200
269
|
// Safe help: `--help`/`-h` on ANY verb prints the command reference and does NOTHING else — never
|
|
201
270
|
// builds, launches, writes, or opens (ADR-0005 manual-testing requirement). `ci` keeps its own
|
|
202
271
|
// richer `--help` (a safe usage print in ci-gate.sh); help/version don't need interception.
|
|
203
|
-
|
|
204
|
-
|
|
272
|
+
// A bare `tapp --help` puts the flag in `command`, not `rest`; normalize it before the
|
|
273
|
+
// per-verb interception so the root help path receives the same no-write guarantee.
|
|
274
|
+
if (["--help", "-h"].includes(command)) {
|
|
275
|
+
command = "help";
|
|
276
|
+
rest = [];
|
|
277
|
+
}
|
|
278
|
+
const safeHelpRequested = (rest.includes("--help") || rest.includes("-h"))
|
|
279
|
+
&& !["help", "version", "--version", "-v"].includes(command)
|
|
280
|
+
&& (command !== "ci" || rest[0] === "install");
|
|
281
|
+
if (safeHelpRequested) {
|
|
282
|
+
console.log(`Usage:\n ${safeCommandUsage(command).replaceAll("\n", "\n ")}\n\nℹ️ --help never builds, launches, writes, or opens. Full command reference:\n`);
|
|
205
283
|
command = "help";
|
|
206
284
|
rest = [];
|
|
207
285
|
}
|
|
@@ -244,7 +322,7 @@ switch (command) {
|
|
|
244
322
|
}
|
|
245
323
|
const platform = typeof flags.platform === "string" ? flags.platform.toLowerCase()
|
|
246
324
|
: typeof flags.url === "string" ? "web"
|
|
247
|
-
: typeof flags["app-id"] === "string" || typeof flags.apk === "string" ? "android" : "
|
|
325
|
+
: typeof flags["app-id"] === "string" || typeof flags.apk === "string" ? "android" : "";
|
|
248
326
|
if (explore && platform === "ios") requireMacFor("iOS init exploration");
|
|
249
327
|
const actions = flags.actions === undefined ? 40 : Number(flags.actions);
|
|
250
328
|
const timeout = flags.timeout === undefined ? 600 : Number(flags.timeout);
|
|
@@ -254,31 +332,52 @@ switch (command) {
|
|
|
254
332
|
}
|
|
255
333
|
const engine = explore ? await engineImport() : null;
|
|
256
334
|
const { initializeProductProject } = await import(path.join(packageRoot, "mcp-server", "src", "product-operations.js"));
|
|
335
|
+
const initOptions = {
|
|
336
|
+
projectDir,
|
|
337
|
+
mode: flags["dry-run"] === true ? "inspect" : explore ? "explore" : flags.refresh === true ? "refresh" : "write",
|
|
338
|
+
ownedUrl: typeof flags.url === "string" ? flags.url : "",
|
|
339
|
+
platform: typeof flags.platform === "string" ? flags.platform.toLowerCase() : explore ? platform : "",
|
|
340
|
+
target: typeof flags.target === "string" ? flags.target : "",
|
|
341
|
+
bundleId: typeof flags["bundle-id"] === "string" ? flags["bundle-id"] : "",
|
|
342
|
+
appId: typeof flags["app-id"] === "string" ? flags["app-id"] : "",
|
|
343
|
+
apkPath: typeof flags.apk === "string" ? path.resolve(flags.apk) : undefined,
|
|
344
|
+
serial: typeof flags.serial === "string" ? flags.serial : undefined,
|
|
345
|
+
maxActions: actions,
|
|
346
|
+
timeout,
|
|
347
|
+
testEmail: typeof flags.email === "string" ? flags.email : undefined,
|
|
348
|
+
testPassword: typeof flags.password === "string" ? flags.password : undefined,
|
|
349
|
+
runExploration: engine?.runInitExploration,
|
|
350
|
+
onProgress: (progress) => {
|
|
351
|
+
const activePlatform = progress.platform || platform;
|
|
352
|
+
process.stderr.write(`\r🔍 Import exploration… ${progress.action}/${progress.max || actions} actions · ${progress.states} ${activePlatform === "web" ? "pages reached" : activePlatform === "ios" ? "structural states observed" : "screens reached"} `);
|
|
353
|
+
},
|
|
354
|
+
onStatus: (status) => console.error(`⏳ ${status}`),
|
|
355
|
+
outDir,
|
|
356
|
+
maxContracts,
|
|
357
|
+
};
|
|
257
358
|
let result;
|
|
359
|
+
let failure = null;
|
|
258
360
|
try {
|
|
259
|
-
result = await initializeProductProject(
|
|
260
|
-
projectDir,
|
|
261
|
-
mode: flags["dry-run"] === true ? "inspect" : explore ? "explore" : flags.refresh === true ? "refresh" : "write",
|
|
262
|
-
ownedUrl: typeof flags.url === "string" ? flags.url : "",
|
|
263
|
-
platform: typeof flags.platform === "string" ? flags.platform.toLowerCase() : explore ? platform : "",
|
|
264
|
-
target: typeof flags.target === "string" ? flags.target : projectDir,
|
|
265
|
-
bundleId: typeof flags["bundle-id"] === "string" ? flags["bundle-id"] : "",
|
|
266
|
-
appId: typeof flags["app-id"] === "string" ? flags["app-id"] : "",
|
|
267
|
-
apkPath: typeof flags.apk === "string" ? path.resolve(flags.apk) : undefined,
|
|
268
|
-
serial: typeof flags.serial === "string" ? flags.serial : undefined,
|
|
269
|
-
maxActions: actions,
|
|
270
|
-
timeout,
|
|
271
|
-
testEmail: typeof flags.email === "string" ? flags.email : undefined,
|
|
272
|
-
testPassword: typeof flags.password === "string" ? flags.password : undefined,
|
|
273
|
-
runExploration: engine?.runInitExploration,
|
|
274
|
-
onProgress: (progress) => process.stderr.write(`\r🔍 Import exploration… ${progress.action}/${progress.max || actions} actions · ${progress.states} ${platform === "web" ? "pages" : "screens"} reached `),
|
|
275
|
-
onStatus: (status) => console.error(`⏳ ${status}`),
|
|
276
|
-
outDir,
|
|
277
|
-
maxContracts,
|
|
278
|
-
});
|
|
361
|
+
result = await initializeProductProject(initOptions);
|
|
279
362
|
} catch (error) {
|
|
363
|
+
failure = error;
|
|
364
|
+
const choice = explore && error.details?.reason === "target-selection-required"
|
|
365
|
+
? await promptForInitTarget(error.details)
|
|
366
|
+
: null;
|
|
367
|
+
if (choice) {
|
|
368
|
+
if (choice.platform === "ios") requireMacFor("iOS init exploration");
|
|
369
|
+
console.error(`🎯 Exploring ${choice.platform}:${choice.name} (${choice.sourcePath})`);
|
|
370
|
+
try {
|
|
371
|
+
result = await initializeProductProject({ ...initOptions, platform: choice.platform, target: choice.selector });
|
|
372
|
+
failure = null;
|
|
373
|
+
} catch (retryError) {
|
|
374
|
+
failure = retryError;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (failure) {
|
|
280
379
|
if (explore) process.stderr.write("\n");
|
|
281
|
-
|
|
380
|
+
printEngineError({ error: `Could not initialize repository: ${failure.message || String(failure)}`, details: failure.details || {} });
|
|
282
381
|
process.exit(2);
|
|
283
382
|
}
|
|
284
383
|
if (explore) process.stderr.write("\n");
|
|
@@ -353,7 +452,10 @@ switch (command) {
|
|
|
353
452
|
testPassword: typeof flags.password === "string" ? flags.password : undefined,
|
|
354
453
|
startWebTarget: engine.startManagedWebTarget,
|
|
355
454
|
stopWebTarget: engine.stopManagedWebTarget,
|
|
356
|
-
|
|
455
|
+
// Contract execution is emitted in full on stdout below. Keep build/runtime/replay
|
|
456
|
+
// status live on stderr, but do not echo the execution transcript there as well — an
|
|
457
|
+
// interactive terminal merges the streams and would otherwise show every result twice.
|
|
458
|
+
onProgress: (entry) => { if (entry.text && entry.phase !== "execute") console.error(`⏳ ${entry.text}`); },
|
|
357
459
|
});
|
|
358
460
|
} catch (error) {
|
|
359
461
|
console.error(`❌ Could not validate contract drafts: ${error.message || String(error)}`);
|
|
@@ -408,6 +510,7 @@ switch (command) {
|
|
|
408
510
|
// hidden deprecated alias.
|
|
409
511
|
if (command === "qa") console.error("note: 'qa' is now 'explore' — 'qa' still works for now.\n");
|
|
410
512
|
const { flags, positionals } = parseVerbArgs(rest);
|
|
513
|
+
const launchOptions = iosLaunchOptions(flags, rest);
|
|
411
514
|
let target = positionals[0] || "";
|
|
412
515
|
let baselineFindings;
|
|
413
516
|
if (flags.baseline) {
|
|
@@ -431,7 +534,7 @@ switch (command) {
|
|
|
431
534
|
const modelPlatform = typeof flags.platform === "string" ? flags.platform.toLowerCase() : "";
|
|
432
535
|
if (modelPlatform === "ios") requireMacFor("iOS testing");
|
|
433
536
|
const onProgress = (p) =>
|
|
434
|
-
process.stderr.write(`\r🔍 Exploring… ${p.action}/${p.max || flags.actions || 60} actions · ${p.states}
|
|
537
|
+
process.stderr.write(`\r🔍 Exploring… ${p.action}/${p.max || flags.actions || 60} actions · ${p.states} states observed `);
|
|
435
538
|
const r = await engine.runExploreTarget({
|
|
436
539
|
projectDir: process.cwd(),
|
|
437
540
|
platform: modelPlatform,
|
|
@@ -440,6 +543,7 @@ switch (command) {
|
|
|
440
543
|
timeout: flags.timeout,
|
|
441
544
|
testEmail: flags.email,
|
|
442
545
|
testPassword: flags.password,
|
|
546
|
+
...launchOptions,
|
|
443
547
|
baselineFindings,
|
|
444
548
|
surface: "cli",
|
|
445
549
|
onProgress,
|
|
@@ -461,15 +565,19 @@ switch (command) {
|
|
|
461
565
|
process.exit(2);
|
|
462
566
|
}
|
|
463
567
|
if (platform === "ios") requireMacFor("iOS testing");
|
|
568
|
+
if (platform !== "ios" && Object.keys(launchOptions).length) {
|
|
569
|
+
console.error("❌ --launch-arg and --launch-env apply only to iOS targets");
|
|
570
|
+
process.exit(2);
|
|
571
|
+
}
|
|
464
572
|
if (platform === "web" && !/^https?:\/\//i.test(target)) {
|
|
465
573
|
console.error("❌ Web QA needs an http(s) URL");
|
|
466
574
|
process.exit(2);
|
|
467
575
|
}
|
|
468
576
|
const bundleId = platform === "ios" ? await resolveTargetOrExit(engine, target) : null;
|
|
469
577
|
const android = platform === "android" ? androidTarget(flags, target) : null;
|
|
470
|
-
const
|
|
578
|
+
const progressMetric = platform === "web" ? "pages reached" : platform === "ios" ? "structural states observed" : "screens reached";
|
|
471
579
|
const onProgress = (p) =>
|
|
472
|
-
process.stderr.write(`\r🔍 Exploring… ${p.action}/${p.max || flags.actions || 60} actions · ${p.states} ${
|
|
580
|
+
process.stderr.write(`\r🔍 Exploring… ${p.action}/${p.max || flags.actions || 60} actions · ${p.states} ${progressMetric} `);
|
|
473
581
|
const r = platform === "web"
|
|
474
582
|
? await engine.runQaWeb({
|
|
475
583
|
url: target,
|
|
@@ -497,7 +605,7 @@ switch (command) {
|
|
|
497
605
|
bundleId,
|
|
498
606
|
maxActions: flags.actions,
|
|
499
607
|
timeout: flags.timeout,
|
|
500
|
-
args: { testEmail: flags.email, testPassword: flags.password, baselineFindings },
|
|
608
|
+
args: { testEmail: flags.email, testPassword: flags.password, baselineFindings, ...launchOptions },
|
|
501
609
|
surface: "cli",
|
|
502
610
|
onProgress,
|
|
503
611
|
});
|
|
@@ -555,7 +663,6 @@ switch (command) {
|
|
|
555
663
|
if (target.apkPath) await driver.install(target.apkPath);
|
|
556
664
|
const snap = await driver.launch({ clearData: flags["clear-data"] === true });
|
|
557
665
|
const data = await driver.screenshot();
|
|
558
|
-
await driver.forceStop();
|
|
559
666
|
console.log(`🚀 Launched \`${target.appId}\` (Android)\n`);
|
|
560
667
|
console.log(engine.formatScreen(snap.screenTitle, snap.elements));
|
|
561
668
|
const out = typeof flags.out === "string" ? flags.out : path.join(tappHome, "shots", `${target.appId}-${Date.now()}.png`);
|
|
@@ -615,12 +722,17 @@ switch (command) {
|
|
|
615
722
|
break;
|
|
616
723
|
}
|
|
617
724
|
if (platform === "android") {
|
|
618
|
-
const
|
|
725
|
+
const input = positionals[0] || "";
|
|
726
|
+
const hasTarget = !!(input || flags["app-id"] || flags.apk);
|
|
727
|
+
const target = hasTarget
|
|
728
|
+
? androidTarget(flags, input)
|
|
729
|
+
: { serial: typeof flags.serial === "string" ? flags.serial : undefined };
|
|
619
730
|
const { AndroidDriver } = await import(path.join(packageRoot, "mcp-server", "src", "android-driver.js"));
|
|
620
731
|
const driver = new AndroidDriver(target);
|
|
621
732
|
await driver.ensureDevice();
|
|
622
|
-
|
|
623
|
-
|
|
733
|
+
if (target.apkPath) await driver.install(target.apkPath);
|
|
734
|
+
const snap = target.appId ? await driver.launch() : await driver.snapshot();
|
|
735
|
+
if (flags.json) console.log(JSON.stringify({ platform: "android", appId: target.appId || null, activity: snap.activity, screenTitle: snap.screenTitle, elements: snap.elements }, null, 2));
|
|
624
736
|
else console.log(engine.formatScreen(snap.screenTitle, snap.elements));
|
|
625
737
|
break;
|
|
626
738
|
}
|
|
@@ -1111,8 +1223,14 @@ switch (command) {
|
|
|
1111
1223
|
}
|
|
1112
1224
|
|
|
1113
1225
|
try {
|
|
1114
|
-
await import("playwright");
|
|
1115
|
-
|
|
1226
|
+
const { chromium } = await import("playwright");
|
|
1227
|
+
let executable = "";
|
|
1228
|
+
try { executable = chromium.executablePath(); } catch { /* report the missing browser below */ }
|
|
1229
|
+
if (executable && fs.existsSync(executable)) {
|
|
1230
|
+
ok("Web", `Playwright + Chromium (${executable})`);
|
|
1231
|
+
} else {
|
|
1232
|
+
console.log(" ⬜ Web — Playwright installed; Chromium browser missing (run: npx playwright install chromium)");
|
|
1233
|
+
}
|
|
1116
1234
|
} catch {
|
|
1117
1235
|
console.log(" ⬜ Web — install Playwright in the app workspace: npm install -D playwright && npx playwright install chromium");
|
|
1118
1236
|
}
|
|
@@ -1402,9 +1520,15 @@ switch (command) {
|
|
|
1402
1520
|
const runs = roots
|
|
1403
1521
|
.flatMap((root) => fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => path.join(root, e.name)))
|
|
1404
1522
|
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
1405
|
-
const
|
|
1523
|
+
const hasMarkers = (dir) => fs.existsSync(path.join(dir, "ocqa-markers.txt"));
|
|
1524
|
+
// `latest` (default) resolves to the newest *exploration* capture — the captures directory is
|
|
1525
|
+
// also full of flow-*/scenario-* evidence dirs with no ocqa-markers.txt, and picking the newest
|
|
1526
|
+
// of those made `tapp report` fail even though valid exploration captures existed. An explicitly
|
|
1527
|
+
// named capture is honored as-is so a non-exploration capture still gets a clear "no markers".
|
|
1528
|
+
const explicit = rest[0] && rest[0] !== "latest";
|
|
1529
|
+
const wanted = explicit ? runs.find((r) => path.basename(r) === rest[0]) : runs.find(hasMarkers);
|
|
1406
1530
|
if (!wanted) {
|
|
1407
|
-
bad("No captures found",
|
|
1531
|
+
bad("No captures found", explicit ? `no capture named "${rest[0]}"` : "run an exploration first (no capture with exploration markers was found)");
|
|
1408
1532
|
process.exit(1);
|
|
1409
1533
|
}
|
|
1410
1534
|
const { writeHtmlReport } = await import(path.join(packageRoot, "mcp-server", "src", "html-report.js"));
|
|
@@ -1414,7 +1538,9 @@ switch (command) {
|
|
|
1414
1538
|
process.exit(1);
|
|
1415
1539
|
}
|
|
1416
1540
|
ok("Evidence report", out);
|
|
1417
|
-
|
|
1541
|
+
// Only launch a browser from an interactive terminal — agents, scripts, and tests that invoke
|
|
1542
|
+
// `tapp report` non-interactively get the path without a surprise GUI window.
|
|
1543
|
+
if (process.stdout.isTTY) spawnSync("open", [out], { stdio: "ignore" });
|
|
1418
1544
|
break;
|
|
1419
1545
|
}
|
|
1420
1546
|
|