@aarwitz/tapp 0.17.13 → 0.17.16
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 +5 -0
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +81 -10
- package/bin/tapp.js +96 -6
- package/mcp-server/src/index.js +29 -8
- package/mcp-server/src/web-explorer.js +156 -0
- package/mcp-server/src/web-flow.js +62 -13
- package/package.json +1 -1
- package/scripts/run-web-flow.js +14 -3
- 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.16",
|
|
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.16",
|
|
28
28
|
"mcp"
|
|
29
29
|
],
|
|
30
30
|
"cwd": "${CLAUDE_PROJECT_DIR}"
|
package/AGENTS.md
CHANGED
|
@@ -133,6 +133,11 @@ 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
|
+
- `tapp audit <url>` is the read-only pass: it renders the page and reads the tree but never
|
|
137
|
+
clicks, so it is the one analysis safe to point at production. It finds controls that were
|
|
138
|
+
never alive — dead in-page anchors, `aria-controls` naming nothing, placeholder links, buttons
|
|
139
|
+
with no handler/form/link — which Flows structurally cannot find, because nobody writes a test
|
|
140
|
+
for a button they believe does nothing. Exit 1 when it finds any.
|
|
136
141
|
- Credential surfaces are catalogue-only without credentials, on every platform: with no
|
|
137
142
|
`testEmail`/`testPassword` supplied, exploration records a login/signup form's fields but does
|
|
138
143
|
not type into them, submit them, or open recovery/third-party-auth flows — a bare-app run may
|
|
@@ -231,6 +231,11 @@ class ExplorerTests: XCTestCase {
|
|
|
231
231
|
for query in queries {
|
|
232
232
|
let element = query[identifier]
|
|
233
233
|
if element.exists && element.isHittable {
|
|
234
|
+
if !waitForEnabled(element) {
|
|
235
|
+
print("OCQA_ACTION:{\"type\":\"tap\",\"identifier\":\"\(identifier)\",\"status\":\"disabled\"}")
|
|
236
|
+
Thread.sleep(forTimeInterval: 0.5)
|
|
237
|
+
return
|
|
238
|
+
}
|
|
234
239
|
element.tap()
|
|
235
240
|
print("OCQA_ACTION:{\"type\":\"tap\",\"identifier\":\"\(identifier)\"}")
|
|
236
241
|
Thread.sleep(forTimeInterval: 0.5)
|
|
@@ -240,8 +245,12 @@ class ExplorerTests: XCTestCase {
|
|
|
240
245
|
let predicate = NSPredicate(format: "label == %@", identifier)
|
|
241
246
|
let match = app.descendants(matching: .any).matching(predicate).firstMatch
|
|
242
247
|
if match.exists && match.isHittable {
|
|
243
|
-
match
|
|
244
|
-
|
|
248
|
+
if !waitForEnabled(match) {
|
|
249
|
+
print("OCQA_ACTION:{\"type\":\"tap\",\"label\":\"\(identifier)\",\"status\":\"disabled\"}")
|
|
250
|
+
} else {
|
|
251
|
+
match.tap()
|
|
252
|
+
print("OCQA_ACTION:{\"type\":\"tap\",\"label\":\"\(identifier)\"}")
|
|
253
|
+
}
|
|
245
254
|
} else {
|
|
246
255
|
print("OCQA_ACTION:{\"type\":\"tap\",\"identifier\":\"\(identifier)\",\"status\":\"not_found\"}")
|
|
247
256
|
}
|
|
@@ -434,10 +443,27 @@ class ExplorerTests: XCTestCase {
|
|
|
434
443
|
/// Returns "ok" if tapped, "not_hittable" if the element exists but couldn't be tapped (disabled,
|
|
435
444
|
/// or covered by the keyboard even after dismissing it), or "not_found" if nothing matched. The
|
|
436
445
|
/// distinction tells the caller whether to enter valid input first vs. that the target is absent.
|
|
446
|
+
/// A disabled control is still `exists` and still `isHittable`, so tapping one
|
|
447
|
+
/// succeeds and reports a normal action while nothing happens: the step passes and
|
|
448
|
+
/// the flow carries on against a screen that never advanced. Wait before calling it
|
|
449
|
+
/// disabled — a button guarded by form validation becomes enabled a moment after the
|
|
450
|
+
/// last field is filled, and failing on that would be its own kind of wrong answer.
|
|
451
|
+
private func waitForEnabled(_ element: XCUIElement, timeout: TimeInterval = 3) -> Bool {
|
|
452
|
+
if element.isEnabled { return true }
|
|
453
|
+
let deadline = Date().addingTimeInterval(timeout)
|
|
454
|
+
while Date() < deadline {
|
|
455
|
+
if element.isEnabled { return true }
|
|
456
|
+
Thread.sleep(forTimeInterval: 0.15)
|
|
457
|
+
}
|
|
458
|
+
return false
|
|
459
|
+
}
|
|
460
|
+
|
|
437
461
|
private func sessionTapById(_ identifier: String) -> String {
|
|
438
462
|
var existedButNotHittable = false
|
|
463
|
+
var existedButDisabled = false
|
|
439
464
|
func tryTap(_ el: XCUIElement) -> Bool {
|
|
440
465
|
guard el.exists else { return false }
|
|
466
|
+
if !waitForEnabled(el) { existedButDisabled = true; return false }
|
|
441
467
|
if el.isHittable { el.tap(); return true }
|
|
442
468
|
// Often covered by the keyboard (a submit button below filled fields) — dismiss + retry,
|
|
443
469
|
// like Playwright auto-scrolls a target into view.
|
|
@@ -472,6 +498,9 @@ class ExplorerTests: XCTestCase {
|
|
|
472
498
|
// label) — "Password" must reach the secureTextField or a later `type` lands in whatever
|
|
473
499
|
// field still has keyboard focus.
|
|
474
500
|
if let field = resolveFieldByHint(identifier), tryTap(field) { return "ok" }
|
|
501
|
+
// "disabled" outranks the others: the control was found and refused to act, which
|
|
502
|
+
// is a different answer from "it is not here" and needs a different fix.
|
|
503
|
+
if existedButDisabled { return "disabled" }
|
|
475
504
|
return existedButNotHittable ? "not_hittable" : "not_found"
|
|
476
505
|
}
|
|
477
506
|
|
|
@@ -827,8 +856,19 @@ class ExplorerTests: XCTestCase {
|
|
|
827
856
|
|
|
828
857
|
switch action {
|
|
829
858
|
case "tap":
|
|
830
|
-
|
|
831
|
-
|
|
859
|
+
let tapStatus = sessionTapById(target)
|
|
860
|
+
status = tapStatus == "ok" ? "pass" : "fail"
|
|
861
|
+
if status == "fail" {
|
|
862
|
+
// Name which of the three it was. "Disabled" in particular sends the
|
|
863
|
+
// author somewhere completely different from "not found": the control
|
|
864
|
+
// is right there, and something upstream in the flow has not satisfied
|
|
865
|
+
// whatever guards it.
|
|
866
|
+
switch tapStatus {
|
|
867
|
+
case "disabled": detail = "‘\(target)’ is on screen but disabled — an earlier step has not met whatever enables it"
|
|
868
|
+
case "not_hittable": detail = "‘\(target)’ exists but could not be reached, even after dismissing the keyboard and scrolling"
|
|
869
|
+
default: detail = "could not find ‘\(target)’ on this screen"
|
|
870
|
+
}
|
|
871
|
+
}
|
|
832
872
|
case "type":
|
|
833
873
|
status = sessionType(value, id: target.isEmpty ? nil : target) ? "pass" : "fail"
|
|
834
874
|
if status == "fail" { detail = lastTypeFailure.isEmpty ? "no field ‘\(target)’ to type into" : lastTypeFailure }
|
|
@@ -1389,6 +1429,10 @@ class ExplorerTests: XCTestCase {
|
|
|
1389
1429
|
// say "navigation-trap" or "frontier-drained" instead of blessing a 15/20 run "completed".
|
|
1390
1430
|
var explorationStopCause = "action-budget"
|
|
1391
1431
|
var leftAppObservations = 0
|
|
1432
|
+
// A loop the EXPLORER created by going back is its own traversal strategy, not an app
|
|
1433
|
+
// defect (field issue #9: "Personal Info ↔ back" filed as a finding). Remember when we
|
|
1434
|
+
// last navigated backwards so the loop detector can tell the two apart.
|
|
1435
|
+
var lastExplorerBackStep = -99
|
|
1392
1436
|
while actionCount < maxActions {
|
|
1393
1437
|
// Subtract time spent paused for interactive input so human typing never eats the budget.
|
|
1394
1438
|
if Date().timeIntervalSince(startTime) - totalWaitSeconds > timeoutSeconds {
|
|
@@ -1889,10 +1933,11 @@ class ExplorerTests: XCTestCase {
|
|
|
1889
1933
|
recent[recent.count - 3] == recent[recent.count - 6] &&
|
|
1890
1934
|
Set(recent.suffix(3)).count == 3 &&
|
|
1891
1935
|
Set(recentScreenTitles.suffix(3)).count == 3
|
|
1892
|
-
|
|
1936
|
+
let period = hasLoop2 ? 2 : 3
|
|
1937
|
+
let explorerDroveTheLoop = actionCount - lastExplorerBackStep <= period * 2
|
|
1938
|
+
if (hasLoop2 || hasLoop3) && !explorerDroveTheLoop && !(authSucceeded && detectedInputs.contains { $0.secure }) {
|
|
1893
1939
|
let loopKey = "nav_loop:\(titleStr)"
|
|
1894
1940
|
if actionCounts[loopKey] == nil {
|
|
1895
|
-
let period = hasLoop2 ? 2 : 3
|
|
1896
1941
|
issues.append((type: "navigation_loop", severity: "low", title: "Navigation loop detected (period \(period))", desc: "Exploration is cycling between the same \(period) screens"))
|
|
1897
1942
|
print("OCQA_ISSUE:{\"type\":\"navigation_loop\",\"severity\":\"low\",\"title\":\"Navigation loop\",\"screen\":\"\(escapedTitle)\",\"period\":\(period),\"step\":\(actionCount)}")
|
|
1898
1943
|
actionCounts[loopKey] = 1
|
|
@@ -1947,6 +1992,7 @@ class ExplorerTests: XCTestCase {
|
|
|
1947
1992
|
// tryGoBack does swipe-down as its last resort (sheet dismiss)
|
|
1948
1993
|
let preBackTitle = titleStr
|
|
1949
1994
|
let backWorked = tryGoBack()
|
|
1995
|
+
lastExplorerBackStep = actionCount
|
|
1950
1996
|
actionCount += 1
|
|
1951
1997
|
Thread.sleep(forTimeInterval: 0.3)
|
|
1952
1998
|
let postElements = readUITree(app)
|
|
@@ -2214,6 +2260,7 @@ class ExplorerTests: XCTestCase {
|
|
|
2214
2260
|
// Scrolling done — try to go back and verify the screen actually changed
|
|
2215
2261
|
let preBackTitle = titleStr
|
|
2216
2262
|
let backResult = tryGoBack()
|
|
2263
|
+
lastExplorerBackStep = actionCount
|
|
2217
2264
|
actionCount += 1
|
|
2218
2265
|
Thread.sleep(forTimeInterval: 0.3)
|
|
2219
2266
|
let postBackElements = readUITree(app)
|
|
@@ -2270,6 +2317,7 @@ class ExplorerTests: XCTestCase {
|
|
|
2270
2317
|
let swipeStart = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3))
|
|
2271
2318
|
let swipeEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9))
|
|
2272
2319
|
swipeStart.press(forDuration: 0.1, thenDragTo: swipeEnd)
|
|
2320
|
+
lastExplorerBackStep = actionCount
|
|
2273
2321
|
actionCount += 1
|
|
2274
2322
|
print("OCQA_ACTION:{\"type\":\"swipe_dismiss\",\"reason\":\"escape_stuck\",\"step\":\(actionCount),\"screen\":\"\(escapedTitle)\",\"narrative\":\"\(escapeJSON(recoveryNarrative("swipe_dismiss", screen: titleStr)))\"}")
|
|
2275
2323
|
Thread.sleep(forTimeInterval: 0.5)
|
|
@@ -4784,23 +4832,46 @@ class ExplorerTests: XCTestCase {
|
|
|
4784
4832
|
} while Date() < deadline
|
|
4785
4833
|
return focused()
|
|
4786
4834
|
}
|
|
4787
|
-
|
|
4835
|
+
// A field further down a form is usually UNDER the keyboard that the previous
|
|
4836
|
+
// field opened, so every tap below lands on a key instead and three attempts
|
|
4837
|
+
// report "never took keyboard focus" for a field that was simply unreachable.
|
|
4838
|
+
// sessionTapById already dismisses and scrolls for taps; typing needs the same
|
|
4839
|
+
// or no form taller than the keyboard can be filled at all.
|
|
4840
|
+
func keyboardCovers(_ el: XCUIElement) -> Bool {
|
|
4841
|
+
let keyboard = app.keyboards.firstMatch
|
|
4842
|
+
guard keyboard.exists else { return false }
|
|
4843
|
+
let f = el.frame, k = keyboard.frame
|
|
4844
|
+
return f.intersects(k) || f.minY >= k.minY
|
|
4845
|
+
}
|
|
4846
|
+
|
|
4847
|
+
for attempt in 0..<5 {
|
|
4788
4848
|
switch attempt {
|
|
4789
4849
|
case 0:
|
|
4790
4850
|
if element.isHittable { element.tap() } else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
|
|
4791
4851
|
case 1:
|
|
4792
4852
|
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
4793
|
-
|
|
4853
|
+
case 2:
|
|
4794
4854
|
// Frame may be stale by a few rows: tap just inside its top edge, which stays
|
|
4795
4855
|
// within the visible control when content shifted up.
|
|
4796
4856
|
element.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.2)).tap()
|
|
4857
|
+
case 3:
|
|
4858
|
+
// Get the keyboard out of the way and try where the field now sits.
|
|
4859
|
+
dismissKeyboardIfPresent()
|
|
4860
|
+
waitForAnimationsToSettle()
|
|
4861
|
+
if element.isHittable { element.tap() } else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
|
|
4862
|
+
default:
|
|
4863
|
+
// Still out of reach: bring it up the screen, then tap.
|
|
4864
|
+
dismissKeyboardIfPresent()
|
|
4865
|
+
app.swipeUp()
|
|
4866
|
+
Thread.sleep(forTimeInterval: 0.25)
|
|
4867
|
+
if element.isHittable { element.tap() } else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
|
|
4797
4868
|
}
|
|
4798
4869
|
if settleUntilFocused() {
|
|
4799
4870
|
if attempt > 0 { print("OCQA_STATE:type_focus_recovered attempt=\(attempt + 1)") }
|
|
4800
4871
|
return true
|
|
4801
4872
|
}
|
|
4802
4873
|
let f = element.frame
|
|
4803
|
-
print("OCQA_STATE:type_focus_miss attempt=\(attempt + 1) frame=\(Int(f.minX)),\(Int(f.minY)),\(Int(f.width))x\(Int(f.height)) keyboard=\(app.keyboards.count) focusedElsewhere=\(focusedField().map { fieldDesc($0) } ?? "none")")
|
|
4874
|
+
print("OCQA_STATE:type_focus_miss attempt=\(attempt + 1) frame=\(Int(f.minX)),\(Int(f.minY)),\(Int(f.width))x\(Int(f.height)) keyboard=\(app.keyboards.count) covered=\(keyboardCovers(element)) focusedElsewhere=\(focusedField().map { fieldDesc($0) } ?? "none")")
|
|
4804
4875
|
}
|
|
4805
4876
|
return false
|
|
4806
4877
|
}
|
|
@@ -4813,7 +4884,7 @@ class ExplorerTests: XCTestCase {
|
|
|
4813
4884
|
lastTypeFailure = ""
|
|
4814
4885
|
guard focusForTyping(element) else {
|
|
4815
4886
|
let f = element.frame
|
|
4816
|
-
lastTypeFailure = "‘\(fieldDesc(element))’ was found (frame \(Int(f.minX)),\(Int(f.minY)) \(Int(f.width))×\(Int(f.height))) but never took keyboard focus after
|
|
4887
|
+
lastTypeFailure = "‘\(fieldDesc(element))’ was found (frame \(Int(f.minX)),\(Int(f.minY)) \(Int(f.width))×\(Int(f.height))) but never took keyboard focus, including after dismissing the keyboard and scrolling it into view"
|
|
4817
4888
|
+ (app.keyboards.count == 0 ? " — no keyboard appeared" : "")
|
|
4818
4889
|
+ (focusedField().map { " — focus is on ‘\(fieldDesc($0))’" } ?? "")
|
|
4819
4890
|
print("OCQA_STATE:type_focus_failed detail=\(escapeJSON(lastTypeFailure))")
|
package/bin/tapp.js
CHANGED
|
@@ -283,6 +283,7 @@ function safeCommandUsage(verb) {
|
|
|
283
283
|
app: "tapp app [repo] [--no-open] [--port PORT]",
|
|
284
284
|
report: "tapp report [captureId|latest]",
|
|
285
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",
|
|
286
|
+
audit: "tapp audit <url> [--json] [--device NAME] [--viewport WxH]\n Read-only: renders the page and reads the tree, never clicks — safe to point at production.\n Exit codes: 0 no dead controls · 1 dead controls found · 2 usage/infrastructure",
|
|
286
287
|
doctor: "tapp doctor [--json]\n Exit codes: 0 environment healthy · 1 blocked (fix ❌ items)",
|
|
287
288
|
install: "tapp install",
|
|
288
289
|
mcp: "tapp mcp",
|
|
@@ -302,7 +303,7 @@ if (["--help", "-h"].includes(command)) {
|
|
|
302
303
|
const knownCommands = new Set([
|
|
303
304
|
"help", "version", "--version", "-v", "mcp", "init", "focus", "explore", "qa", "open",
|
|
304
305
|
"tree", "shot", "screenshot", "apps", "build", "flow", "task", "contract", "scenario", "map",
|
|
305
|
-
"pr", "plan", "baseline", "ci", "actor", "app", "studio", "report", "doctor", "install", "feedback",
|
|
306
|
+
"pr", "plan", "baseline", "ci", "actor", "app", "studio", "report", "doctor", "install", "feedback", "audit",
|
|
306
307
|
]);
|
|
307
308
|
if (!knownCommands.has(command)) {
|
|
308
309
|
console.error(`❌ Unknown command: ${command}`);
|
|
@@ -1150,9 +1151,48 @@ switch (command) {
|
|
|
1150
1151
|
break;
|
|
1151
1152
|
}
|
|
1152
1153
|
if (!["run", "validate"].includes(verb) || !flowPath) {
|
|
1153
|
-
console.error("usage: tapp flow example\n tapp flow steps [--json]\n tapp flow run <flow.yml> [--platform ios|android|web] [--actor NAME] [--email VALUE] [--password VALUE] [--url URL] [--app-id ID] [--apk FILE] [--serial ID]\n tapp flow validate <flow.yml>");
|
|
1154
|
+
console.error("usage: tapp flow example\n tapp flow steps [--json]\n tapp flow run <flow.yml|glob> [...more] [--platform ios|android|web] [--actor NAME] [--email VALUE] [--password VALUE] [--url URL] [--app-id ID] [--apk FILE] [--serial ID]\n tapp flow validate <flow.yml|glob> [...more]\n Several Flows (or a glob) run in sequence and print one line per Flow; exit 1 if any failed.");
|
|
1154
1155
|
process.exit(2);
|
|
1155
1156
|
}
|
|
1157
|
+
// Several Flows in one command (field issue #9): the shell usually expands the glob, but a
|
|
1158
|
+
// quoted pattern arrives whole. Everyone writes this loop by hand otherwise.
|
|
1159
|
+
const expandFlowPattern = (pattern) => {
|
|
1160
|
+
if (!/[*?]/.test(pattern)) return [pattern];
|
|
1161
|
+
const dir = path.dirname(pattern);
|
|
1162
|
+
const rx = new RegExp(`^${path.basename(pattern).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
|
|
1163
|
+
try {
|
|
1164
|
+
return fs.readdirSync(dir).filter((name) => rx.test(name)).sort().map((name) => path.join(dir, name));
|
|
1165
|
+
} catch { return []; }
|
|
1166
|
+
};
|
|
1167
|
+
const flowPaths = positionals.slice(1).flatMap(expandFlowPattern);
|
|
1168
|
+
if (flowPaths.length > 1) {
|
|
1169
|
+
const passThrough = Object.entries(flags).flatMap(([key, value]) =>
|
|
1170
|
+
value === true ? [`--${key}`] : typeof value === "string" ? [`--${key}`, value] : []);
|
|
1171
|
+
const rows = [];
|
|
1172
|
+
for (const candidate of flowPaths) {
|
|
1173
|
+
const name = path.basename(candidate).replace(/\.(ya?ml|json)$/i, "");
|
|
1174
|
+
process.stderr.write(`▶️ ${name}\n`);
|
|
1175
|
+
const child = spawnSync(process.execPath, [path.join(packageRoot, "bin", "tapp.js"), "flow", verb, candidate, ...passThrough], { encoding: "utf8", env: process.env });
|
|
1176
|
+
const output = `${child.stdout || ""}\n${child.stderr || ""}`;
|
|
1177
|
+
// Prefer the cause tapp already distilled; never the last line of a boxed error (#23).
|
|
1178
|
+
const lines = output.split("\n");
|
|
1179
|
+
const errorAt = lines.findIndex((line) => line.trim().startsWith("❌"));
|
|
1180
|
+
// A validation error puts its reasons on the bullet lines under the ❌ headline; a
|
|
1181
|
+
// headline alone ("Invalid web Flow — b-bad:") names no cause.
|
|
1182
|
+
const headline = errorAt >= 0
|
|
1183
|
+
? [lines[errorAt], ...(/[::]\s*$/.test(lines[errorAt]) ? [lines[errorAt + 1] || ""] : [])].map((line) => line.trim()).filter(Boolean).join(" ")
|
|
1184
|
+
: "";
|
|
1185
|
+
const cause = (output.match(/^\s*(?:\*\*)?First failure:.*$/m)?.[0] || headline || "")
|
|
1186
|
+
.replace(/\*\*/g, "").replace(/\s+/g, " ").trim().slice(0, 160);
|
|
1187
|
+
rows.push({ name, ok: (child.status ?? 1) === 0, cause });
|
|
1188
|
+
}
|
|
1189
|
+
const width = Math.max(...rows.map((row) => row.name.length));
|
|
1190
|
+
console.log("");
|
|
1191
|
+
for (const row of rows) console.log(`${row.name.padEnd(width)} ${row.ok ? "PASS" : "FAIL"}${row.ok || !row.cause ? "" : ` ${row.cause}`}`);
|
|
1192
|
+
const failed = rows.filter((row) => !row.ok).length;
|
|
1193
|
+
console.log(`\n${rows.length - failed}/${rows.length} ${verb === "validate" ? "valid" : "passed"}`);
|
|
1194
|
+
process.exit(failed ? 1 : 0);
|
|
1195
|
+
}
|
|
1156
1196
|
const absolute = path.resolve(flowPath);
|
|
1157
1197
|
if (!fs.existsSync(absolute)) {
|
|
1158
1198
|
console.error(`❌ Flow not found: ${absolute}`);
|
|
@@ -1463,6 +1503,38 @@ switch (command) {
|
|
|
1463
1503
|
process.exit(2);
|
|
1464
1504
|
}
|
|
1465
1505
|
|
|
1506
|
+
case "audit": {
|
|
1507
|
+
const { flags, positionals } = parseVerbArgs(rest);
|
|
1508
|
+
const url = positionals[0] || "";
|
|
1509
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
1510
|
+
console.error("usage: tapp audit <http(s) url> [--json] [--device NAME] [--viewport WxH]\n Read-only structural audit: never clicks, safe against production.");
|
|
1511
|
+
process.exit(2);
|
|
1512
|
+
}
|
|
1513
|
+
try {
|
|
1514
|
+
const { auditWebPage } = await import(path.join(packageRoot, "mcp-server", "src", "web-explorer.js"));
|
|
1515
|
+
const result = await auditWebPage({
|
|
1516
|
+
url,
|
|
1517
|
+
device: typeof flags.device === "string" ? flags.device : "",
|
|
1518
|
+
viewport: typeof flags.viewport === "string" ? flags.viewport : "",
|
|
1519
|
+
});
|
|
1520
|
+
if (flags.json === true) {
|
|
1521
|
+
console.log(JSON.stringify({ kind: "tapp-structural-audit", readOnly: true, ...result }, null, 2));
|
|
1522
|
+
} else {
|
|
1523
|
+
console.log(`🔎 Structural audit — ${result.url}\n ${result.controlsExamined} control(s) examined · nothing was clicked`);
|
|
1524
|
+
if (!result.findings.length) console.log("\n✅ No structurally dead controls found.");
|
|
1525
|
+
else {
|
|
1526
|
+
console.log("");
|
|
1527
|
+
for (const finding of result.findings) console.log(` ⚠️ ${finding.title}`);
|
|
1528
|
+
console.log(`\n${result.findings.length} dead control(s). These are controls no Flow would cover — nobody writes a test for a button they believe does nothing.`);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
process.exit(result.findings.length ? 1 : 0);
|
|
1532
|
+
} catch (error) {
|
|
1533
|
+
console.error(`❌ ${error.message || String(error)}`);
|
|
1534
|
+
process.exit(2);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1466
1538
|
case "doctor": {
|
|
1467
1539
|
const { flags: doctorFlags } = parseVerbArgs(rest);
|
|
1468
1540
|
const jsonMode = doctorFlags.json === true;
|
|
@@ -1541,14 +1613,29 @@ switch (command) {
|
|
|
1541
1613
|
|
|
1542
1614
|
try {
|
|
1543
1615
|
const { chromium } = await import("playwright");
|
|
1616
|
+
// Launch for real rather than stat the path executablePath() reports.
|
|
1617
|
+
// They are not the same question: web Flows launch headless, Playwright
|
|
1618
|
+
// may serve that from a separate binary, and executablePath() answers
|
|
1619
|
+
// for the full browser either way — so a stat can say Web is ready while
|
|
1620
|
+
// every launch fails on a binary nobody was asked to install. Whatever a
|
|
1621
|
+
// future Playwright resolves headless to, opening and closing one is the
|
|
1622
|
+
// only answer that stays true.
|
|
1544
1623
|
let executable = "";
|
|
1545
|
-
try { executable = chromium.executablePath(); } catch { /*
|
|
1546
|
-
|
|
1624
|
+
try { executable = chromium.executablePath(); } catch { /* the launch below is the real check */ }
|
|
1625
|
+
let launchError = "";
|
|
1626
|
+
try {
|
|
1627
|
+
const probe = await chromium.launch({ headless: true });
|
|
1628
|
+
await probe.close();
|
|
1629
|
+
} catch (error) {
|
|
1630
|
+
launchError = String(error?.message || error).split("\n").find((line) => line.trim()) || "";
|
|
1631
|
+
}
|
|
1632
|
+
if (!launchError) {
|
|
1547
1633
|
report.platforms.web = { available: true, chromium: executable };
|
|
1548
|
-
sayOk("Web", `Playwright + Chromium (${executable})`);
|
|
1634
|
+
sayOk("Web", `Playwright + Chromium${executable ? ` (${executable})` : ""}`);
|
|
1549
1635
|
} else {
|
|
1550
|
-
report.platforms.web = { available: false, reason: "Chromium browser missing (run: npx playwright install chromium)" };
|
|
1636
|
+
report.platforms.web = { available: false, reason: "Chromium browser missing (run: npx playwright install chromium)", detail: launchError };
|
|
1551
1637
|
say(" ⬜ Web — Playwright installed; Chromium browser missing (run: npx playwright install chromium)");
|
|
1638
|
+
say(` ${launchError}`);
|
|
1552
1639
|
}
|
|
1553
1640
|
} catch {
|
|
1554
1641
|
report.platforms.web = { available: false, reason: "Playwright not installed" };
|
|
@@ -1951,6 +2038,9 @@ Primitives — an agent's eyes and hands:
|
|
|
1951
2038
|
(web: --tap TEXT · --wait-for TEXT · --out FILE)
|
|
1952
2039
|
tapp tree [target] Accessibility tree of the current screen (--json for every element)
|
|
1953
2040
|
(web: --tap TEXT · --wait-for TEXT)
|
|
2041
|
+
tapp audit <url> Read-only structural audit — dead anchors, placeholder links, buttons
|
|
2042
|
+
with nothing behind them. Never clicks, so it is safe against
|
|
2043
|
+
production (exit 1 when dead controls are found)
|
|
1954
2044
|
|
|
1955
2045
|
Repository & release:
|
|
1956
2046
|
tapp init [repo] Detect targets and write the application model + reviewable release plan
|
package/mcp-server/src/index.js
CHANGED
|
@@ -471,10 +471,26 @@ export async function prepareAndroidInteractiveTarget({ projectDir = process.cwd
|
|
|
471
471
|
return { ...built, appId, selectedTarget:selected };
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
-
|
|
474
|
+
// Said before a build overwrites whatever is on the simulator. The clean install
|
|
475
|
+
// below wipes the old app's data with it, so a build someone deliberately
|
|
476
|
+
// configured — pointed at a local server, a staging key, a test account —
|
|
477
|
+
// disappears without being mentioned. Naming it is the difference between "my run
|
|
478
|
+
// is slow" and "my run is now testing something else entirely". Passing a bundle
|
|
479
|
+
// id instead of a project path uses what is installed and never builds.
|
|
480
|
+
export function replacingInstalledBuildNotice(bundleId) {
|
|
481
|
+
const id = String(bundleId || "").trim();
|
|
482
|
+
if (!id) return "";
|
|
483
|
+
return `Replacing the installed build of ${id} (its data is wiped too). To use what is already installed instead of building, pass the bundle id: ${id}`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export async function installAppOnBootedSim(appPath, { cleanInstall = true, onStatus = () => {} } = {}) {
|
|
475
487
|
const bid = await runCommand("/usr/libexec/PlistBuddy", ["-c", "Print CFBundleIdentifier", path.join(appPath, "Info.plist")], { timeoutMs: 30_000 });
|
|
476
488
|
const bundleId = (bid.stdout || "").trim();
|
|
477
489
|
if (!bundleId) return { error: `Could not read CFBundleIdentifier from ${appPath}/Info.plist — is this a simulator .app build?` };
|
|
490
|
+
if (cleanInstall) {
|
|
491
|
+
const existing = await runCommand("xcrun", ["simctl", "get_app_container", "booted", bundleId, "app"], { timeoutMs: 15_000 });
|
|
492
|
+
if (existing.code === 0 && (existing.stdout || "").trim()) onStatus(replacingInstalledBuildNotice(bundleId));
|
|
493
|
+
}
|
|
478
494
|
if (cleanInstall) {
|
|
479
495
|
// Clean install: stale keychain items from a previous install leave apps half-signed-in
|
|
480
496
|
// (Firebase Auth's "error accessing the keychain") — uninstall first for a fresh state.
|
|
@@ -496,7 +512,7 @@ export async function resolveAppTarget(input, { cwd = process.cwd(), onStatus =
|
|
|
496
512
|
const sim = await ensureBootedSim({ autoBoot: true });
|
|
497
513
|
if (sim.error) return { error: sim.error };
|
|
498
514
|
onStatus(`Installing ${path.basename(appPath)}…`);
|
|
499
|
-
const inst = await installAppOnBootedSim(appPath);
|
|
515
|
+
const inst = await installAppOnBootedSim(appPath, { onStatus });
|
|
500
516
|
if (inst.error) return inst;
|
|
501
517
|
return {
|
|
502
518
|
bundleId: inst.bundleId,
|
|
@@ -522,7 +538,7 @@ export async function resolveAppTarget(input, { cwd = process.cwd(), onStatus =
|
|
|
522
538
|
const built = await buildAppForSim({ container, scheme, configuration });
|
|
523
539
|
if (built.error) return built;
|
|
524
540
|
onStatus(`Built ${path.basename(built.appPath)} (scheme ${built.scheme}) — installing…`);
|
|
525
|
-
const inst = await installAppOnBootedSim(built.appPath);
|
|
541
|
+
const inst = await installAppOnBootedSim(built.appPath, { onStatus });
|
|
526
542
|
if (inst.error) return inst;
|
|
527
543
|
return {
|
|
528
544
|
bundleId: inst.bundleId,
|
|
@@ -917,7 +933,7 @@ const SESSION_ACT_ARGS = Object.freeze({
|
|
|
917
933
|
login: "{email?, password?} (defaults to the session's credentials)",
|
|
918
934
|
swipe: "{direction: up|down|left|right}",
|
|
919
935
|
back: "{}",
|
|
920
|
-
tree: "{verbose?: true}",
|
|
936
|
+
tree: "{verbose?: true} — verbose returns every element and the complete control list",
|
|
921
937
|
screenshot: "{label?}",
|
|
922
938
|
});
|
|
923
939
|
export function sessionActUsageError(cmd = {}) {
|
|
@@ -1769,13 +1785,15 @@ function elementBreakdown(elements) {
|
|
|
1769
1785
|
}
|
|
1770
1786
|
|
|
1771
1787
|
/** Scannable "Read screen X — N elements (...)" readout, plus the tappable/typeable controls. */
|
|
1772
|
-
export function formatScreen(screenTitle, elements) {
|
|
1788
|
+
export function formatScreen(screenTitle, elements, { full = false } = {}) {
|
|
1773
1789
|
const els = elements || [];
|
|
1774
1790
|
const interactable = els.filter((e) => e.isEnabled !== false && (String(e.type).includes("Button") || String(e.type).includes("Link") || String(e.type).includes("rawValue: 9") || String(e.type).includes("TextField") || String(e.type).includes("rawValue: 49") || String(e.type).includes("rawValue: 50") || String(e.type).includes("Cell") || String(e.type).includes("rawValue: 75")));
|
|
1775
1791
|
const allLabels = interactable
|
|
1776
1792
|
.map((e) => (e.label || e.identifier || "").trim())
|
|
1777
1793
|
.filter((s) => s && s.length <= 40 && !s.includes("."));
|
|
1778
|
-
|
|
1794
|
+
// A verbose/full readout must actually be full: the 8-item cap hid the fifth tab's label
|
|
1795
|
+
// and left tapping by coordinate as the only way to reach it (field issue #9).
|
|
1796
|
+
const labels = full ? allLabels : allLabels.slice(0, 8);
|
|
1779
1797
|
const L = [`🌳 Read screen **${screenTitle || "Unknown"}** — ${els.length} elements (${elementBreakdown(els)})`];
|
|
1780
1798
|
// A silently cut list reads as complete; say when it isn't.
|
|
1781
1799
|
if (labels.length) L.push("", "**Controls:** " + labels.map((l) => `\`${l}\``).join(" · ")
|
|
@@ -4523,8 +4541,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4523
4541
|
const detailNote = !ok && r.detail ? ` — ${r.detail}` : "";
|
|
4524
4542
|
const head = `${did} — ${ok ? "ok" : `⚠️ ${r.status}${detailNote}`} → now on **${r.screenTitle || "Unknown"}**`;
|
|
4525
4543
|
const rec = typeof r.recordedSteps === "number" ? `\n\n🔴 Recording — ${r.recordedSteps} step(s). \`tapp_flow_save\` to keep it as a test.` : "";
|
|
4526
|
-
|
|
4527
|
-
|
|
4544
|
+
// `verbose` is the documented spelling (SESSION_ACT_ARGS.tree); `full` is the original.
|
|
4545
|
+
// Accepting only `full` meant the documented flag silently did nothing (field issue #9).
|
|
4546
|
+
const fullTree = action === "tree" && (args.full === true || args.verbose === true || cmd.verbose === true || cmd.full === true);
|
|
4547
|
+
const screen = agentScreenProjection(r, { full: fullTree });
|
|
4548
|
+
const result = richResult(head + "\n\n" + formatScreen(screen.screenTitle, screen.elements, { full: fullTree }) + rec, { ...r, ...screen });
|
|
4528
4549
|
if (!ok) result.isError = true;
|
|
4529
4550
|
return result;
|
|
4530
4551
|
}
|
|
@@ -139,6 +139,11 @@ function cssAttrEscape(value) {
|
|
|
139
139
|
async function installWebListenerTracking(context) {
|
|
140
140
|
await context.addInitScript(() => {
|
|
141
141
|
const key = Symbol.for("tapp.clickListeners");
|
|
142
|
+
// Delegated handlers live on document/window, not on the control, so an
|
|
143
|
+
// element-only record says "nothing is listening" for a page where every
|
|
144
|
+
// button works. Keep their source text: it is the only way to tell which
|
|
145
|
+
// controls such a handler actually claims.
|
|
146
|
+
const delegatedKey = Symbol.for("tapp.delegatedClickHandlers");
|
|
142
147
|
const add = EventTarget.prototype.addEventListener;
|
|
143
148
|
const remove = EventTarget.prototype.removeEventListener;
|
|
144
149
|
EventTarget.prototype.addEventListener = function tappTrackedAdd(type, listener, options) {
|
|
@@ -146,6 +151,11 @@ async function installWebListenerTracking(context) {
|
|
|
146
151
|
if (!this[key]) Object.defineProperty(this, key, { value: new Set(), configurable: true });
|
|
147
152
|
this[key].add(listener);
|
|
148
153
|
}
|
|
154
|
+
if (type === "click" && listener && (this === document || this === window)) {
|
|
155
|
+
const target = this === window ? window : document;
|
|
156
|
+
if (!target[delegatedKey]) Object.defineProperty(target, delegatedKey, { value: new Set(), configurable: true });
|
|
157
|
+
try { target[delegatedKey].add(String(listener).slice(0, 4000)); } catch { /* exotic handler */ }
|
|
158
|
+
}
|
|
149
159
|
return add.call(this, type, listener, options);
|
|
150
160
|
};
|
|
151
161
|
EventTarget.prototype.removeEventListener = function tappTrackedRemove(type, listener, options) {
|
|
@@ -1175,3 +1185,149 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
1175
1185
|
}
|
|
1176
1186
|
return { markersPath, outDir, actions, screens: screenCount, seedRoutes: normalizedSeeds, seedTargets: normalizedTargets };
|
|
1177
1187
|
}
|
|
1188
|
+
|
|
1189
|
+
// ---- Read-only structural audit (field issue #24) -------------------------------------
|
|
1190
|
+
// Flows hold known-good behaviour fixed; they cannot find a control that was NEVER alive,
|
|
1191
|
+
// because nobody writes a flow for a button they believe does nothing. This pass renders the
|
|
1192
|
+
// page and reads the tree — it never clicks — so it is the one analysis that can be pointed
|
|
1193
|
+
// at production, which is where dead controls actually live.
|
|
1194
|
+
export function auditFindingsFromControls(controls = []) {
|
|
1195
|
+
const findings = [];
|
|
1196
|
+
for (const control of controls) {
|
|
1197
|
+
const where = control.label || control.identifier || control.selector || "(unlabelled)";
|
|
1198
|
+
if (control.kind === "dead_anchor") {
|
|
1199
|
+
findings.push({ type: "anchor_missing", severity: "medium",
|
|
1200
|
+
title: `In-page link “${where}” points at #${control.fragment}, which is not on the page`, target: control.href });
|
|
1201
|
+
} else if (control.kind === "placeholder_link") {
|
|
1202
|
+
findings.push({ type: "placeholder_link", severity: "medium",
|
|
1203
|
+
title: `Link “${where}” has no destination (href="${control.href}")`, target: control.href });
|
|
1204
|
+
} else if (control.kind === "dangling_aria_controls") {
|
|
1205
|
+
findings.push({ type: "dangling_aria_controls", severity: "medium",
|
|
1206
|
+
title: `“${where}” declares aria-controls="${control.controls}", but no such element exists`, target: control.controls });
|
|
1207
|
+
} else if (control.kind === "unwired_control") {
|
|
1208
|
+
findings.push({ type: "unwired_control", severity: "medium",
|
|
1209
|
+
title: `Button “${where}” has no click handler, form, or link behind it`, target: control.selector });
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return findings;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// Collected inside the page: every structurally dead control, WITHOUT interacting with any.
|
|
1216
|
+
export async function auditWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, device = "", viewport = "" }) {
|
|
1217
|
+
let target;
|
|
1218
|
+
try { target = new URL(url); }
|
|
1219
|
+
catch { throw new Error("Audit needs a valid http(s) URL"); }
|
|
1220
|
+
if (!/^https?:$/.test(target.protocol)) throw new Error("Audit needs a valid http(s) URL");
|
|
1221
|
+
const { chromium, devices } = await loadPlaywright();
|
|
1222
|
+
const browser = await chromium.launch(webBrowserLaunchOptions(process.env, {}));
|
|
1223
|
+
try {
|
|
1224
|
+
const context = await browser.newContext(webContextOptions({ device, viewport, devices }));
|
|
1225
|
+
await installWebListenerTracking(context);
|
|
1226
|
+
const page = await context.newPage();
|
|
1227
|
+
const bounded = Math.max(1000, Math.min(60_000, Number(timeoutMs) || NAV_TIMEOUT_MS));
|
|
1228
|
+
page.setDefaultTimeout(bounded);
|
|
1229
|
+
const response = await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: bounded });
|
|
1230
|
+
if (response && response.status() >= 400) throw new Error(`Could not open ${target.href}: HTTP ${response.status()}`);
|
|
1231
|
+
await waitForWebStability(page, { timeoutMs: Math.min(5_000, bounded) });
|
|
1232
|
+
const controls = await page.evaluate(() => {
|
|
1233
|
+
const key = Symbol.for("tapp.clickListeners");
|
|
1234
|
+
const visible = (el) => {
|
|
1235
|
+
const style = window.getComputedStyle(el);
|
|
1236
|
+
return style.visibility !== "hidden" && style.display !== "none" && el.getClientRects().length > 0;
|
|
1237
|
+
};
|
|
1238
|
+
const name = (el) => (el.getAttribute("aria-label") || el.textContent || el.getAttribute("value") || "").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
1239
|
+
const selectorFor = (el) => el.id ? `#${el.id}` : el.getAttribute("data-testid") ? `[data-testid="${el.getAttribute("data-testid")}"]` : el.tagName.toLowerCase();
|
|
1240
|
+
// Some controls are wired in CSS, not JavaScript: a menu that opens while
|
|
1241
|
+
// its trigger is hovered or focused has no listener to find, and calling
|
|
1242
|
+
// it dead is wrong. Collect the selectors that some rule reacts to — the
|
|
1243
|
+
// trigger side of any `X:hover Y` / `X:focus-within Y` rule whose
|
|
1244
|
+
// declarations change whether Y can be seen. A rule that only restyles
|
|
1245
|
+
// the trigger itself (`button:hover { background: … }`) is not behaviour
|
|
1246
|
+
// and is ignored, which is why the descendant part must be present.
|
|
1247
|
+
const REVEALS = /(^|;)\s*(display|visibility|opacity|height|max-height|transform|pointer-events|clip-path)\s*:/i;
|
|
1248
|
+
const revealTriggers = [];
|
|
1249
|
+
const collectTriggers = (rules) => {
|
|
1250
|
+
for (const rule of rules || []) {
|
|
1251
|
+
// A plain style rule also exposes .cssRules now that CSS nesting is
|
|
1252
|
+
// supported — an empty list, which is truthy. Recursing on that and
|
|
1253
|
+
// skipping the rule would walk straight past every selector there is.
|
|
1254
|
+
if (rule.cssRules && rule.cssRules.length) collectTriggers(rule.cssRules);
|
|
1255
|
+
const selector = rule.selectorText;
|
|
1256
|
+
if (!selector || !REVEALS.test(rule.style?.cssText || "")) continue;
|
|
1257
|
+
for (const part of selector.split(",")) {
|
|
1258
|
+
const match = part.match(/^(.*?):(?:hover|focus-within|focus-visible|focus)\b(.+)$/);
|
|
1259
|
+
if (!match) continue;
|
|
1260
|
+
const trigger = match[1].trim();
|
|
1261
|
+
if (trigger && match[2].trim()) revealTriggers.push(trigger);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
1265
|
+
for (const sheet of document.styleSheets) {
|
|
1266
|
+
try { collectTriggers(sheet.cssRules); } catch { /* cross-origin sheet */ }
|
|
1267
|
+
}
|
|
1268
|
+
const opensSomethingOnHoverOrFocus = (el) => revealTriggers.some((trigger) => {
|
|
1269
|
+
try { return el.matches(trigger) || Boolean(el.closest(trigger)); } catch { return false; }
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
// What a delegated handler on document/window claims. The dominant idiom is
|
|
1273
|
+
// `e.target.closest(SELECTOR)` / `.matches(SELECTOR)`, so pull those
|
|
1274
|
+
// selectors out and test controls against them. A handler we cannot read
|
|
1275
|
+
// this way (an outside-click closer, say) claims nothing and is ignored,
|
|
1276
|
+
// rather than excusing every unwired control on the page.
|
|
1277
|
+
const delegatedSelectors = [];
|
|
1278
|
+
for (const source of [
|
|
1279
|
+
...(document[Symbol.for("tapp.delegatedClickHandlers")] || []),
|
|
1280
|
+
...(window[Symbol.for("tapp.delegatedClickHandlers")] || []),
|
|
1281
|
+
]) {
|
|
1282
|
+
for (const m of String(source).matchAll(/\.(?:closest|matches)\(\s*["'`]([^"'`]+)["'`]/g)) {
|
|
1283
|
+
delegatedSelectors.push(m[1]);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
const claimedByDelegate = (el) => delegatedSelectors.some((selector) => {
|
|
1287
|
+
try { return el.matches(selector) || Boolean(el.closest(selector)); } catch { return false; }
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
const dead = [];
|
|
1291
|
+
for (const el of document.querySelectorAll("a[href], button, [role=button], [aria-controls]")) {
|
|
1292
|
+
if (!visible(el)) continue;
|
|
1293
|
+
const label = name(el);
|
|
1294
|
+
const href = el.getAttribute("href");
|
|
1295
|
+
const ariaControls = el.getAttribute("aria-controls");
|
|
1296
|
+
if (ariaControls && !document.getElementById(ariaControls)) {
|
|
1297
|
+
dead.push({ kind: "dangling_aria_controls", label, selector: selectorFor(el), controls: ariaControls });
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
if (href != null) {
|
|
1301
|
+
if (href === "#" || href.trim() === "" || /^javascript:\s*(void\(0\))?;?$/i.test(href)) {
|
|
1302
|
+
dead.push({ kind: "placeholder_link", label, selector: selectorFor(el), href });
|
|
1303
|
+
} else if (href.startsWith("#")) {
|
|
1304
|
+
const fragment = decodeURIComponent(href.slice(1));
|
|
1305
|
+
const found = fragment && (document.getElementById(fragment) || document.getElementsByName(fragment).length > 0);
|
|
1306
|
+
if (!found) dead.push({ kind: "dead_anchor", label, selector: selectorFor(el), href, fragment });
|
|
1307
|
+
}
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
// A button with nothing behind it: no listener on itself or an ancestor, no inline
|
|
1311
|
+
// handler, and not a submit inside a form.
|
|
1312
|
+
let wired = false;
|
|
1313
|
+
for (let node = el; node && node !== document.body; node = node.parentElement) {
|
|
1314
|
+
if ((node[key] && node[key].size > 0) || typeof node.onclick === "function" || node.hasAttribute("onclick")) { wired = true; break; }
|
|
1315
|
+
}
|
|
1316
|
+
if (!wired && el.matches("button[type=submit], input[type=submit]") && el.closest("form")) wired = true;
|
|
1317
|
+
if (!wired && el.closest("label")) wired = true; // a label drives its own control
|
|
1318
|
+
if (!wired && opensSomethingOnHoverOrFocus(el)) wired = true;
|
|
1319
|
+
if (!wired && claimedByDelegate(el)) wired = true;
|
|
1320
|
+
if (!wired) dead.push({ kind: "unwired_control", label, selector: selectorFor(el) });
|
|
1321
|
+
}
|
|
1322
|
+
return { dead, controlCount: document.querySelectorAll("a[href], button, [role=button]").length, title: document.title };
|
|
1323
|
+
});
|
|
1324
|
+
return {
|
|
1325
|
+
url: page.url(),
|
|
1326
|
+
title: controls.title,
|
|
1327
|
+
controlsExamined: controls.controlCount,
|
|
1328
|
+
findings: auditFindingsFromControls(controls.dead),
|
|
1329
|
+
};
|
|
1330
|
+
} finally {
|
|
1331
|
+
await browser.close().catch(() => {});
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
@@ -10,9 +10,13 @@ const DEFAULT_TIMEOUT = 6000;
|
|
|
10
10
|
// What WAS visible when a wait missed — points a failure report at the fix (renamed label,
|
|
11
11
|
// error state, wrong page) without a separate `tapp tree` run (field issue #20).
|
|
12
12
|
async function visibleLabelsHint(page, limit = 8) {
|
|
13
|
+
const collect = async (scope) => {
|
|
14
|
+
try { return String(await scope.locator("body").innerText({ timeout: 1000 })); } catch { return ""; }
|
|
15
|
+
};
|
|
13
16
|
try {
|
|
14
|
-
|
|
15
|
-
const
|
|
17
|
+
// Include same-origin frames: the text a reader can see may live inside an embedded modal.
|
|
18
|
+
const texts = [await collect(page), ...(await Promise.all(sameOriginFrames(page).map(collect)))];
|
|
19
|
+
const labels = [...new Set(texts.join("\n").split(/\n+/).map((l) => l.trim()).filter((l) => l.length >= 2 && l.length <= 60))].slice(0, limit);
|
|
16
20
|
return labels.length ? ` — visible: ${labels.join(" · ")}` : "";
|
|
17
21
|
} catch { return ""; }
|
|
18
22
|
}
|
|
@@ -31,19 +35,49 @@ function cssId(value) {
|
|
|
31
35
|
return "#" + String(value).replace(/([^a-zA-Z0-9_-])/g, "\\$1");
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
|
|
38
|
+
// A frame whose content the page's own origin may script. An `about:blank`/`srcdoc` frame
|
|
39
|
+
// inherits its parent's origin. Cross-origin frames (third-party widgets) stay out of scope:
|
|
40
|
+
// a Flow should not assert on content the page itself cannot read.
|
|
41
|
+
function sameOriginFrames(page) {
|
|
42
|
+
if (typeof page.frames !== "function") return []; // non-Playwright page (tests, fakes)
|
|
43
|
+
let origin;
|
|
44
|
+
try { origin = new URL(page.url()).origin; } catch { return []; }
|
|
45
|
+
const main = typeof page.mainFrame === "function" ? page.mainFrame() : null;
|
|
46
|
+
return page.frames().filter((frame) => {
|
|
47
|
+
if (frame === main) return false;
|
|
48
|
+
const url = frame.url() || "";
|
|
49
|
+
if (!url || url === "about:blank" || url.startsWith("about:srcdoc")) return true;
|
|
50
|
+
try { return new URL(url).origin === origin; } catch { return false; }
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function targetCandidates(scope, target) {
|
|
35
55
|
const exact = { exact: true };
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
return [
|
|
57
|
+
scope.getByTestId(target),
|
|
58
|
+
scope.locator(cssId(target)),
|
|
59
|
+
scope.getByLabel(target, exact),
|
|
60
|
+
scope.getByRole("button", { name: target, exact: true }),
|
|
61
|
+
scope.getByRole("link", { name: target, exact: true }),
|
|
62
|
+
scope.getByText(target, exact),
|
|
63
|
+
scope.getByLabel(target),
|
|
64
|
+
scope.getByText(target),
|
|
45
65
|
];
|
|
46
|
-
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function locateWebElement(page, target) {
|
|
69
|
+
const top = await firstVisible(targetCandidates(page, target));
|
|
70
|
+
if (top) return top;
|
|
71
|
+
// A modal rendered into a same-origin <iframe> is visibly on screen but invisible to a
|
|
72
|
+
// top-document-only search, so `wait_for` timed out on text the screenshot plainly showed
|
|
73
|
+
// (field issue #14). Top document first, then each same-origin frame in document order.
|
|
74
|
+
for (const frame of sameOriginFrames(page)) {
|
|
75
|
+
try {
|
|
76
|
+
const inFrame = await firstVisible(targetCandidates(frame, target));
|
|
77
|
+
if (inFrame) return inFrame;
|
|
78
|
+
} catch { /* a frame can detach mid-search — keep looking */ }
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
47
81
|
}
|
|
48
82
|
|
|
49
83
|
async function screenCandidates(page) {
|
|
@@ -125,6 +159,21 @@ export async function runWebRequestStep({ step, startUrl, vars = {}, timeout = D
|
|
|
125
159
|
return { action: "request", target: `${method} ${targetUrl.pathname}`, status: "pass", detail: "" };
|
|
126
160
|
}
|
|
127
161
|
|
|
162
|
+
// Playwright (and its install prompt) wrap guidance in box-drawing art spanning many lines.
|
|
163
|
+
// Consumers that keep ONE line of an error — a scoreboard row, a CI table — kept the box's
|
|
164
|
+
// bottom border `╚═══╝` and threw the cause away (field issue #23). Put the cause first and
|
|
165
|
+
// carry any actionable instruction with it, so the first AND last line are both useful.
|
|
166
|
+
export function distillErrorMessage(message) {
|
|
167
|
+
const lines = String(message || "").split("\n")
|
|
168
|
+
.map((line) => line.replace(/[╔╗╚╝═║│┌┐└┘─]/g, " ").trim())
|
|
169
|
+
.filter(Boolean);
|
|
170
|
+
if (!lines.length) return String(message || "").trim();
|
|
171
|
+
const headline = lines[0];
|
|
172
|
+
// A boxed Playwright prompt carries the fix as a bare command line inside the art.
|
|
173
|
+
const fix = lines.slice(1).find((line) => /^(npx|npm|pip3?|python3|yarn|pnpm) /.test(line));
|
|
174
|
+
return fix && !headline.includes(fix) ? `${headline} — fix: ${fix}` : headline;
|
|
175
|
+
}
|
|
176
|
+
|
|
128
177
|
// Playwright's timeout errors bury the actual cause ("<div id=…> intercepts pointer events",
|
|
129
178
|
// "element is not visible") in a multi-line retry log; single-line consumers kept only
|
|
130
179
|
// "Timeout 6000ms exceeded" and the report read like the element did not exist (field issue
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aarwitz/tapp",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.16",
|
|
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/run-web-flow.js
CHANGED
|
@@ -3,8 +3,9 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import { FlowLog, loadFlowFile } from "../mcp-server/src/flow-runtime.js";
|
|
8
|
+
import { distillErrorMessage, runWebFlow } from "../mcp-server/src/web-flow.js";
|
|
8
9
|
|
|
9
10
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
11
|
const flowPath = process.argv[2];
|
|
@@ -30,6 +31,16 @@ try {
|
|
|
30
31
|
process.stdout.write((report.stdout || "").trim() + "\n");
|
|
31
32
|
process.exit(result.passed ? 0 : 1);
|
|
32
33
|
} catch (error) {
|
|
33
|
-
|
|
34
|
+
// A run that dies before step 1 (no browser installed, bad url) still owes a structured
|
|
35
|
+
// report: without one the scoreboard shows an empty row and consumers keep whatever text
|
|
36
|
+
// line they can reach — for a boxed Playwright prompt, its bottom border (field issue #23).
|
|
37
|
+
const cause = distillErrorMessage(error?.message || String(error));
|
|
38
|
+
console.error(`❌ ${cause}`);
|
|
39
|
+
try {
|
|
40
|
+
fs.rmSync(logPath, { force: true });
|
|
41
|
+
const log = new FlowLog({ logPath, flow });
|
|
42
|
+
log.step({ index: 1, action: "harness", target: flow.name || "flow", status: "fail", detail: cause });
|
|
43
|
+
log.finish({ abortReason: cause });
|
|
44
|
+
} catch { /* the console line above is still the answer */ }
|
|
34
45
|
process.exit(2);
|
|
35
46
|
}
|
|
@@ -11,6 +11,7 @@ npx -y @aarwitz/tapp@latest explore [target]
|
|
|
11
11
|
npx -y @aarwitz/tapp@latest focus "Save storefront settings visible above keyboard" [target]
|
|
12
12
|
npx -y @aarwitz/tapp@latest open [target]
|
|
13
13
|
npx -y @aarwitz/tapp@latest tree [target] --json
|
|
14
|
+
npx -y @aarwitz/tapp@latest audit https://example.com --json # read-only: never clicks, safe against production
|
|
14
15
|
npx -y @aarwitz/tapp@latest shot
|
|
15
16
|
npx -y @aarwitz/tapp@latest report latest
|
|
16
17
|
npx -y @aarwitz/tapp@latest doctor
|