@aarwitz/tapp 0.17.12 → 0.17.14
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 +28 -4
- package/bin/tapp.js +96 -6
- package/mcp-server/src/html-report.js +5 -0
- package/mcp-server/src/index.js +10 -5
- package/mcp-server/src/report.js +4 -1
- package/mcp-server/src/web-explorer.js +172 -5
- 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.14",
|
|
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.14",
|
|
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
|
|
@@ -1136,8 +1136,24 @@ class ExplorerTests: XCTestCase {
|
|
|
1136
1136
|
// --- Login preamble: if credentials are provided and login fields are visible, log in first ---
|
|
1137
1137
|
if !ranExplicitLogin, !testEmail.isEmpty, !testPassword.isEmpty {
|
|
1138
1138
|
waitForUIStability(timeout: 2.0) // let app fully settle
|
|
1139
|
-
|
|
1140
|
-
|
|
1139
|
+
var allTextFields = app.textFields.allElementsBoundByIndex.filter { $0.exists && $0.frame.width > 0 }
|
|
1140
|
+
var allSecureFields = app.secureTextFields.allElementsBoundByIndex.filter { $0.exists && $0.frame.width > 0 }
|
|
1141
|
+
|
|
1142
|
+
if allSecureFields.count > 1 {
|
|
1143
|
+
let existingAccountLabels = ["Back to login", "Back to Login", "Already have an account?", "Sign in instead", "Log in instead"]
|
|
1144
|
+
for label in existingAccountLabels {
|
|
1145
|
+
let control = app.buttons[label].exists ? app.buttons[label] : app.staticTexts[label]
|
|
1146
|
+
if control.exists && control.isHittable {
|
|
1147
|
+
control.tap()
|
|
1148
|
+
print("OCQA_STATE:login_preamble_switched_from_signup")
|
|
1149
|
+
Thread.sleep(forTimeInterval: 0.8)
|
|
1150
|
+
waitForUIStability(timeout: 2.0)
|
|
1151
|
+
allTextFields = app.textFields.allElementsBoundByIndex.filter { $0.exists && $0.frame.width > 0 }
|
|
1152
|
+
allSecureFields = app.secureTextFields.allElementsBoundByIndex.filter { $0.exists && $0.frame.width > 0 }
|
|
1153
|
+
break
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1141
1157
|
print("OCQA_STATE:login_preamble_fields textFields=\(allTextFields.count) secureFields=\(allSecureFields.count)")
|
|
1142
1158
|
|
|
1143
1159
|
let emailField = allTextFields.first { f in
|
|
@@ -1373,6 +1389,10 @@ class ExplorerTests: XCTestCase {
|
|
|
1373
1389
|
// say "navigation-trap" or "frontier-drained" instead of blessing a 15/20 run "completed".
|
|
1374
1390
|
var explorationStopCause = "action-budget"
|
|
1375
1391
|
var leftAppObservations = 0
|
|
1392
|
+
// A loop the EXPLORER created by going back is its own traversal strategy, not an app
|
|
1393
|
+
// defect (field issue #9: "Personal Info ↔ back" filed as a finding). Remember when we
|
|
1394
|
+
// last navigated backwards so the loop detector can tell the two apart.
|
|
1395
|
+
var lastExplorerBackStep = -99
|
|
1376
1396
|
while actionCount < maxActions {
|
|
1377
1397
|
// Subtract time spent paused for interactive input so human typing never eats the budget.
|
|
1378
1398
|
if Date().timeIntervalSince(startTime) - totalWaitSeconds > timeoutSeconds {
|
|
@@ -1873,10 +1893,11 @@ class ExplorerTests: XCTestCase {
|
|
|
1873
1893
|
recent[recent.count - 3] == recent[recent.count - 6] &&
|
|
1874
1894
|
Set(recent.suffix(3)).count == 3 &&
|
|
1875
1895
|
Set(recentScreenTitles.suffix(3)).count == 3
|
|
1876
|
-
|
|
1896
|
+
let period = hasLoop2 ? 2 : 3
|
|
1897
|
+
let explorerDroveTheLoop = actionCount - lastExplorerBackStep <= period * 2
|
|
1898
|
+
if (hasLoop2 || hasLoop3) && !explorerDroveTheLoop && !(authSucceeded && detectedInputs.contains { $0.secure }) {
|
|
1877
1899
|
let loopKey = "nav_loop:\(titleStr)"
|
|
1878
1900
|
if actionCounts[loopKey] == nil {
|
|
1879
|
-
let period = hasLoop2 ? 2 : 3
|
|
1880
1901
|
issues.append((type: "navigation_loop", severity: "low", title: "Navigation loop detected (period \(period))", desc: "Exploration is cycling between the same \(period) screens"))
|
|
1881
1902
|
print("OCQA_ISSUE:{\"type\":\"navigation_loop\",\"severity\":\"low\",\"title\":\"Navigation loop\",\"screen\":\"\(escapedTitle)\",\"period\":\(period),\"step\":\(actionCount)}")
|
|
1882
1903
|
actionCounts[loopKey] = 1
|
|
@@ -1931,6 +1952,7 @@ class ExplorerTests: XCTestCase {
|
|
|
1931
1952
|
// tryGoBack does swipe-down as its last resort (sheet dismiss)
|
|
1932
1953
|
let preBackTitle = titleStr
|
|
1933
1954
|
let backWorked = tryGoBack()
|
|
1955
|
+
lastExplorerBackStep = actionCount
|
|
1934
1956
|
actionCount += 1
|
|
1935
1957
|
Thread.sleep(forTimeInterval: 0.3)
|
|
1936
1958
|
let postElements = readUITree(app)
|
|
@@ -2198,6 +2220,7 @@ class ExplorerTests: XCTestCase {
|
|
|
2198
2220
|
// Scrolling done — try to go back and verify the screen actually changed
|
|
2199
2221
|
let preBackTitle = titleStr
|
|
2200
2222
|
let backResult = tryGoBack()
|
|
2223
|
+
lastExplorerBackStep = actionCount
|
|
2201
2224
|
actionCount += 1
|
|
2202
2225
|
Thread.sleep(forTimeInterval: 0.3)
|
|
2203
2226
|
let postBackElements = readUITree(app)
|
|
@@ -2254,6 +2277,7 @@ class ExplorerTests: XCTestCase {
|
|
|
2254
2277
|
let swipeStart = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3))
|
|
2255
2278
|
let swipeEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9))
|
|
2256
2279
|
swipeStart.press(forDuration: 0.1, thenDragTo: swipeEnd)
|
|
2280
|
+
lastExplorerBackStep = actionCount
|
|
2257
2281
|
actionCount += 1
|
|
2258
2282
|
print("OCQA_ACTION:{\"type\":\"swipe_dismiss\",\"reason\":\"escape_stuck\",\"step\":\(actionCount),\"screen\":\"\(escapedTitle)\",\"narrative\":\"\(escapeJSON(recoveryNarrative("swipe_dismiss", screen: titleStr)))\"}")
|
|
2259
2283
|
Thread.sleep(forTimeInterval: 0.5)
|
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
|
|
@@ -84,6 +84,9 @@ export function writeHtmlReport(captureDir, { report, label = "", recordingWarni
|
|
|
84
84
|
${esc(f.title)}${f.screen ? ` <span class="dim">— on ${esc(f.screen)}</span>` : ""}${f.url ? ` <span class="dim">— ${esc(f.url)}</span>` : ""}
|
|
85
85
|
${f.aiAnalysis ? `<div class="ai">why: ${esc(f.aiAnalysis)}</div>` : ""}
|
|
86
86
|
${f.suggestedFix ? `<div class="ai">fix: ${esc(f.suggestedFix)}</div>` : ""}
|
|
87
|
+
${f.evidence && fs.existsSync(path.join(captureDir, f.evidence))
|
|
88
|
+
? `<a class="evidence" href="${esc(f.evidence)}"><img loading="lazy" src="${esc(f.evidence)}" alt="The specific control named above, scrolled into view"></a>`
|
|
89
|
+
: ""}
|
|
87
90
|
</li>`
|
|
88
91
|
)
|
|
89
92
|
.join("\n")
|
|
@@ -129,6 +132,8 @@ ${r.conditionsNotReached?.length ? `<div><h2>Conditions not reached</h2><ul>${sc
|
|
|
129
132
|
table.trace th, table.trace td { text-align: left; padding: 0.25rem 0.7rem 0.25rem 0; border-bottom: 1px solid #eceef1; }
|
|
130
133
|
table.trace th { color: #57606a; font-weight: 600; }
|
|
131
134
|
.ai { color: #57606a; font-size: 0.88rem; margin: 0.15rem 0 0 0.2rem; }
|
|
135
|
+
a.evidence { display: block; margin: 0.4rem 0 0 0.2rem; max-width: 360px; }
|
|
136
|
+
a.evidence img { width: 100%; border: 1px solid #d0d7de; border-radius: 6px; }
|
|
132
137
|
.dim { color: #57606a; }
|
|
133
138
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.8rem; }
|
|
134
139
|
figure { margin: 0; } figure img { width: 100%; border: 1px solid #d0d7de; border-radius: 6px; }
|
package/mcp-server/src/index.js
CHANGED
|
@@ -917,7 +917,7 @@ const SESSION_ACT_ARGS = Object.freeze({
|
|
|
917
917
|
login: "{email?, password?} (defaults to the session's credentials)",
|
|
918
918
|
swipe: "{direction: up|down|left|right}",
|
|
919
919
|
back: "{}",
|
|
920
|
-
tree: "{verbose?: true}",
|
|
920
|
+
tree: "{verbose?: true} — verbose returns every element and the complete control list",
|
|
921
921
|
screenshot: "{label?}",
|
|
922
922
|
});
|
|
923
923
|
export function sessionActUsageError(cmd = {}) {
|
|
@@ -1769,13 +1769,15 @@ function elementBreakdown(elements) {
|
|
|
1769
1769
|
}
|
|
1770
1770
|
|
|
1771
1771
|
/** Scannable "Read screen X — N elements (...)" readout, plus the tappable/typeable controls. */
|
|
1772
|
-
export function formatScreen(screenTitle, elements) {
|
|
1772
|
+
export function formatScreen(screenTitle, elements, { full = false } = {}) {
|
|
1773
1773
|
const els = elements || [];
|
|
1774
1774
|
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
1775
|
const allLabels = interactable
|
|
1776
1776
|
.map((e) => (e.label || e.identifier || "").trim())
|
|
1777
1777
|
.filter((s) => s && s.length <= 40 && !s.includes("."));
|
|
1778
|
-
|
|
1778
|
+
// A verbose/full readout must actually be full: the 8-item cap hid the fifth tab's label
|
|
1779
|
+
// and left tapping by coordinate as the only way to reach it (field issue #9).
|
|
1780
|
+
const labels = full ? allLabels : allLabels.slice(0, 8);
|
|
1779
1781
|
const L = [`🌳 Read screen **${screenTitle || "Unknown"}** — ${els.length} elements (${elementBreakdown(els)})`];
|
|
1780
1782
|
// A silently cut list reads as complete; say when it isn't.
|
|
1781
1783
|
if (labels.length) L.push("", "**Controls:** " + labels.map((l) => `\`${l}\``).join(" · ")
|
|
@@ -4523,8 +4525,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4523
4525
|
const detailNote = !ok && r.detail ? ` — ${r.detail}` : "";
|
|
4524
4526
|
const head = `${did} — ${ok ? "ok" : `⚠️ ${r.status}${detailNote}`} → now on **${r.screenTitle || "Unknown"}**`;
|
|
4525
4527
|
const rec = typeof r.recordedSteps === "number" ? `\n\n🔴 Recording — ${r.recordedSteps} step(s). \`tapp_flow_save\` to keep it as a test.` : "";
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
+
// `verbose` is the documented spelling (SESSION_ACT_ARGS.tree); `full` is the original.
|
|
4529
|
+
// Accepting only `full` meant the documented flag silently did nothing (field issue #9).
|
|
4530
|
+
const fullTree = action === "tree" && (args.full === true || args.verbose === true || cmd.verbose === true || cmd.full === true);
|
|
4531
|
+
const screen = agentScreenProjection(r, { full: fullTree });
|
|
4532
|
+
const result = richResult(head + "\n\n" + formatScreen(screen.screenTitle, screen.elements, { full: fullTree }) + rec, { ...r, ...screen });
|
|
4528
4533
|
if (!ok) result.isError = true;
|
|
4529
4534
|
return result;
|
|
4530
4535
|
}
|
package/mcp-server/src/report.js
CHANGED
|
@@ -175,7 +175,10 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
175
175
|
// same screen are two findings, and fixing one while breaking another is a regression.
|
|
176
176
|
const target = (typeof o.control === "string" && o.control) || (typeof o.target === "string" && o.target) || null;
|
|
177
177
|
const url = typeof o.url === "string" && o.url.trim() ? o.url.trim() : null;
|
|
178
|
-
|
|
178
|
+
// Element-scoped screenshot proving the specific control the finding names is actually
|
|
179
|
+
// visible — set only for web findings that name one (anchor_missing, placeholder_link).
|
|
180
|
+
const evidence = typeof o.evidence === "string" && o.evidence.trim() ? o.evidence.trim() : null;
|
|
181
|
+
rawIssues.push({ type: o.type, severity: sev, title: o.title, screen: o.screen || null, target, url, step: o.step ?? null, evidence });
|
|
179
182
|
} catch {
|
|
180
183
|
/* ignore malformed */
|
|
181
184
|
}
|
|
@@ -25,6 +25,13 @@ const NAV_TIMEOUT_MS = 15_000;
|
|
|
25
25
|
const BUTTONS_PER_PAGE = 4;
|
|
26
26
|
const OUTBOUND_LINK_LIMIT = 10;
|
|
27
27
|
const WATCH_ACTION_DELAY_MS = 350;
|
|
28
|
+
// A page's visible signature (waitForWebStability's basis) can go quiet before an async-injected
|
|
29
|
+
// widget (a third-party tour scheduler, a chat launcher, ...) has actually finished mounting its
|
|
30
|
+
// target element — nothing about that wait shows a spinner. One snapshot cannot tell "genuinely
|
|
31
|
+
// dead" from "hasn't finished loading"; give a real anchor-missing candidate a second look before
|
|
32
|
+
// treating it as a confirmed defect.
|
|
33
|
+
const ANCHOR_RECHECK_MS = 4_000;
|
|
34
|
+
const ANCHOR_RECHECK_INTERVAL_MS = 400;
|
|
28
35
|
const ERROR_TEXT_RE = /\b(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)\b/i;
|
|
29
36
|
const STANDALONE_ERROR_TEXT_RE = /^(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)(?:[.!:]|\s|$)/i;
|
|
30
37
|
|
|
@@ -82,6 +89,53 @@ export function webPageAppearsBlank({ textLen = 0, controlCount = 0, visualConte
|
|
|
82
89
|
return Number(textLen) === 0 && Number(controlCount) === 0 && Number(visualContentCount) === 0;
|
|
83
90
|
}
|
|
84
91
|
|
|
92
|
+
function webAnchorIdMissing(rawId) {
|
|
93
|
+
try {
|
|
94
|
+
const esc = window.CSS && CSS.escape ? CSS.escape(rawId) : rawId;
|
|
95
|
+
return !document.getElementById(rawId) && !document.querySelector(`a[name="${esc}"]`);
|
|
96
|
+
} catch {
|
|
97
|
+
return !document.getElementById(rawId);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A single DOM snapshot cannot distinguish "this anchor target will never exist" from "the
|
|
102
|
+
// script that creates it hasn't run yet". Poll for up to timeoutMs before accepting the miss —
|
|
103
|
+
// cheap when the target is genuinely absent (every poll agrees), and it only spends the extra
|
|
104
|
+
// time on pages that actually have a candidate anchor_missing finding.
|
|
105
|
+
export async function webAnchorStillMissing(page, anchor, { timeoutMs = ANCHOR_RECHECK_MS, intervalMs = ANCHOR_RECHECK_INTERVAL_MS } = {}) {
|
|
106
|
+
const id = decodeURIComponent(String(anchor || "").slice(1));
|
|
107
|
+
if (!id) return true;
|
|
108
|
+
const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
|
|
109
|
+
for (;;) {
|
|
110
|
+
const missing = await page.evaluate(webAnchorIdMissing, id).catch(() => true);
|
|
111
|
+
if (!missing) return false;
|
|
112
|
+
if (Date.now() >= deadline) return true;
|
|
113
|
+
await page.waitForTimeout(intervalMs);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Evidence for a finding that names a specific control is worthless if the screenshot never
|
|
118
|
+
// actually shows that control — a generic per-screen shot proves nothing about where the element
|
|
119
|
+
// is on the page. Scroll it into view and shoot it directly; fall back to a viewport shot (still
|
|
120
|
+
// scrolled to the element) if the element itself can't be screenshotted (zero-size, clipped).
|
|
121
|
+
export async function captureElementEvidence(page, locator, outDir, name) {
|
|
122
|
+
try {
|
|
123
|
+
const target = locator.first();
|
|
124
|
+
if ((await target.count()) === 0) return null;
|
|
125
|
+
await target.scrollIntoViewIfNeeded({ timeout: 2_000 });
|
|
126
|
+
await page.waitForTimeout(120);
|
|
127
|
+
const evidencePath = path.join(outDir, name);
|
|
128
|
+
await target.screenshot({ path: evidencePath }).catch(() => page.screenshot({ path: evidencePath }));
|
|
129
|
+
return path.basename(evidencePath);
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function cssAttrEscape(value) {
|
|
136
|
+
return String(value).replace(/["\\]/g, (c) => "\\" + c);
|
|
137
|
+
}
|
|
138
|
+
|
|
85
139
|
async function installWebListenerTracking(context) {
|
|
86
140
|
await context.addInitScript(() => {
|
|
87
141
|
const key = Symbol.for("tapp.clickListeners");
|
|
@@ -612,13 +666,13 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
612
666
|
|
|
613
667
|
const deadline = Date.now() + timeoutSec * 1000;
|
|
614
668
|
const issues = []; // emitted immediately; kept for counting only
|
|
615
|
-
const issue = (type, severity, title, screen, target, sourceUrl) => {
|
|
669
|
+
const issue = (type, severity, title, screen, target, sourceUrl, evidence) => {
|
|
616
670
|
issues.push(type);
|
|
617
671
|
// Findings belong to the page that CARRIED the defect. Async detectors default to the
|
|
618
672
|
// current page; the post-crawl outbound audit passes the link's source page explicitly so
|
|
619
673
|
// a bad footer link is never attributed to whatever page happened to be visited last.
|
|
620
674
|
const pageUrl = sourceUrl || page.url();
|
|
621
|
-
emit("ISSUE", { type, severity, title, screen, ...(target ? { target } : {}), ...(pageUrl && pageUrl !== "about:blank" ? { url: pageUrl } : {}) });
|
|
675
|
+
emit("ISSUE", { type, severity, title, screen, ...(target ? { target } : {}), ...(pageUrl && pageUrl !== "about:blank" ? { url: pageUrl } : {}), ...(evidence ? { evidence } : {}) });
|
|
622
676
|
};
|
|
623
677
|
|
|
624
678
|
// Async defect listeners: attribute to whatever screen is current when they fire.
|
|
@@ -805,14 +859,33 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
805
859
|
for (const finding of webPlaceholderLinkFindings(info.placeholderLinks)) {
|
|
806
860
|
if (placeholderLinksSeen.has(finding.target)) continue;
|
|
807
861
|
placeholderLinksSeen.add(finding.target);
|
|
808
|
-
|
|
862
|
+
// Only a labeled link's evidence can be located with any confidence — an unlabeled
|
|
863
|
+
// link's `target` is a synthetic fingerprint, not text a locator can find on the page.
|
|
864
|
+
const evidence = finding.target.startsWith("unlabeled:")
|
|
865
|
+
? null
|
|
866
|
+
: await captureElementEvidence(
|
|
867
|
+
page,
|
|
868
|
+
page.locator("a[href]").filter({ hasText: finding.target }),
|
|
869
|
+
outDir,
|
|
870
|
+
`evidence_${screenshotFor.size}_${slug(screen)}_${slug(finding.target)}.png`
|
|
871
|
+
);
|
|
872
|
+
issue(finding.type, finding.severity, finding.title, screen, finding.target, null, evidence);
|
|
809
873
|
}
|
|
810
|
-
// Anchor links pointing at ids that do not exist are deterministic dead navigation
|
|
874
|
+
// Anchor links pointing at ids that do not exist are deterministic dead navigation — but
|
|
875
|
+
// one snapshot can't tell "genuinely dead" from "the script that creates the target hasn't
|
|
876
|
+
// finished running yet" (an async-mounted widget shows no spinner while it loads).
|
|
811
877
|
for (const anchor of info.missingAnchors || []) {
|
|
812
878
|
const anchorTarget = `${key}${anchor}`;
|
|
813
879
|
if (missingAnchorsSeen.has(anchorTarget)) continue;
|
|
814
880
|
missingAnchorsSeen.add(anchorTarget);
|
|
815
|
-
|
|
881
|
+
if (!(await webAnchorStillMissing(page, anchor))) continue;
|
|
882
|
+
const evidence = await captureElementEvidence(
|
|
883
|
+
page,
|
|
884
|
+
page.locator(`a[href="${cssAttrEscape(anchor)}"]`),
|
|
885
|
+
outDir,
|
|
886
|
+
`evidence_${screenshotFor.size}_${slug(screen)}_${slug(anchor)}.png`
|
|
887
|
+
);
|
|
888
|
+
issue("anchor_missing", "medium", `Anchor link "${anchor}" has no matching element on the page`, screen, anchorTarget, null, evidence);
|
|
816
889
|
}
|
|
817
890
|
}
|
|
818
891
|
return { key, screen, info };
|
|
@@ -1102,3 +1175,97 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
1102
1175
|
}
|
|
1103
1176
|
return { markersPath, outDir, actions, screens: screenCount, seedRoutes: normalizedSeeds, seedTargets: normalizedTargets };
|
|
1104
1177
|
}
|
|
1178
|
+
|
|
1179
|
+
// ---- Read-only structural audit (field issue #24) -------------------------------------
|
|
1180
|
+
// Flows hold known-good behaviour fixed; they cannot find a control that was NEVER alive,
|
|
1181
|
+
// because nobody writes a flow for a button they believe does nothing. This pass renders the
|
|
1182
|
+
// page and reads the tree — it never clicks — so it is the one analysis that can be pointed
|
|
1183
|
+
// at production, which is where dead controls actually live.
|
|
1184
|
+
export function auditFindingsFromControls(controls = []) {
|
|
1185
|
+
const findings = [];
|
|
1186
|
+
for (const control of controls) {
|
|
1187
|
+
const where = control.label || control.identifier || control.selector || "(unlabelled)";
|
|
1188
|
+
if (control.kind === "dead_anchor") {
|
|
1189
|
+
findings.push({ type: "anchor_missing", severity: "medium",
|
|
1190
|
+
title: `In-page link “${where}” points at #${control.fragment}, which is not on the page`, target: control.href });
|
|
1191
|
+
} else if (control.kind === "placeholder_link") {
|
|
1192
|
+
findings.push({ type: "placeholder_link", severity: "medium",
|
|
1193
|
+
title: `Link “${where}” has no destination (href="${control.href}")`, target: control.href });
|
|
1194
|
+
} else if (control.kind === "dangling_aria_controls") {
|
|
1195
|
+
findings.push({ type: "dangling_aria_controls", severity: "medium",
|
|
1196
|
+
title: `“${where}” declares aria-controls="${control.controls}", but no such element exists`, target: control.controls });
|
|
1197
|
+
} else if (control.kind === "unwired_control") {
|
|
1198
|
+
findings.push({ type: "unwired_control", severity: "medium",
|
|
1199
|
+
title: `Button “${where}” has no click handler, form, or link behind it`, target: control.selector });
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
return findings;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// Collected inside the page: every structurally dead control, WITHOUT interacting with any.
|
|
1206
|
+
export async function auditWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, device = "", viewport = "" }) {
|
|
1207
|
+
let target;
|
|
1208
|
+
try { target = new URL(url); }
|
|
1209
|
+
catch { throw new Error("Audit needs a valid http(s) URL"); }
|
|
1210
|
+
if (!/^https?:$/.test(target.protocol)) throw new Error("Audit needs a valid http(s) URL");
|
|
1211
|
+
const { chromium, devices } = await loadPlaywright();
|
|
1212
|
+
const browser = await chromium.launch(webBrowserLaunchOptions(process.env, {}));
|
|
1213
|
+
try {
|
|
1214
|
+
const context = await browser.newContext(webContextOptions({ device, viewport, devices }));
|
|
1215
|
+
await installWebListenerTracking(context);
|
|
1216
|
+
const page = await context.newPage();
|
|
1217
|
+
const bounded = Math.max(1000, Math.min(60_000, Number(timeoutMs) || NAV_TIMEOUT_MS));
|
|
1218
|
+
page.setDefaultTimeout(bounded);
|
|
1219
|
+
const response = await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: bounded });
|
|
1220
|
+
if (response && response.status() >= 400) throw new Error(`Could not open ${target.href}: HTTP ${response.status()}`);
|
|
1221
|
+
await waitForWebStability(page, { timeoutMs: Math.min(5_000, bounded) });
|
|
1222
|
+
const controls = await page.evaluate(() => {
|
|
1223
|
+
const key = Symbol.for("tapp.clickListeners");
|
|
1224
|
+
const visible = (el) => {
|
|
1225
|
+
const style = window.getComputedStyle(el);
|
|
1226
|
+
return style.visibility !== "hidden" && style.display !== "none" && el.getClientRects().length > 0;
|
|
1227
|
+
};
|
|
1228
|
+
const name = (el) => (el.getAttribute("aria-label") || el.textContent || el.getAttribute("value") || "").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
1229
|
+
const selectorFor = (el) => el.id ? `#${el.id}` : el.getAttribute("data-testid") ? `[data-testid="${el.getAttribute("data-testid")}"]` : el.tagName.toLowerCase();
|
|
1230
|
+
const dead = [];
|
|
1231
|
+
for (const el of document.querySelectorAll("a[href], button, [role=button], [aria-controls]")) {
|
|
1232
|
+
if (!visible(el)) continue;
|
|
1233
|
+
const label = name(el);
|
|
1234
|
+
const href = el.getAttribute("href");
|
|
1235
|
+
const ariaControls = el.getAttribute("aria-controls");
|
|
1236
|
+
if (ariaControls && !document.getElementById(ariaControls)) {
|
|
1237
|
+
dead.push({ kind: "dangling_aria_controls", label, selector: selectorFor(el), controls: ariaControls });
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
if (href != null) {
|
|
1241
|
+
if (href === "#" || href.trim() === "" || /^javascript:\s*(void\(0\))?;?$/i.test(href)) {
|
|
1242
|
+
dead.push({ kind: "placeholder_link", label, selector: selectorFor(el), href });
|
|
1243
|
+
} else if (href.startsWith("#")) {
|
|
1244
|
+
const fragment = decodeURIComponent(href.slice(1));
|
|
1245
|
+
const found = fragment && (document.getElementById(fragment) || document.getElementsByName(fragment).length > 0);
|
|
1246
|
+
if (!found) dead.push({ kind: "dead_anchor", label, selector: selectorFor(el), href, fragment });
|
|
1247
|
+
}
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
// A button with nothing behind it: no listener on itself or an ancestor, no inline
|
|
1251
|
+
// handler, and not a submit inside a form.
|
|
1252
|
+
let wired = false;
|
|
1253
|
+
for (let node = el; node && node !== document.body; node = node.parentElement) {
|
|
1254
|
+
if ((node[key] && node[key].size > 0) || typeof node.onclick === "function" || node.hasAttribute("onclick")) { wired = true; break; }
|
|
1255
|
+
}
|
|
1256
|
+
if (!wired && el.matches("button[type=submit], input[type=submit]") && el.closest("form")) wired = true;
|
|
1257
|
+
if (!wired && el.closest("label")) wired = true; // a label drives its own control
|
|
1258
|
+
if (!wired) dead.push({ kind: "unwired_control", label, selector: selectorFor(el) });
|
|
1259
|
+
}
|
|
1260
|
+
return { dead, controlCount: document.querySelectorAll("a[href], button, [role=button]").length, title: document.title };
|
|
1261
|
+
});
|
|
1262
|
+
return {
|
|
1263
|
+
url: page.url(),
|
|
1264
|
+
title: controls.title,
|
|
1265
|
+
controlsExamined: controls.controlCount,
|
|
1266
|
+
findings: auditFindingsFromControls(controls.dead),
|
|
1267
|
+
};
|
|
1268
|
+
} finally {
|
|
1269
|
+
await browser.close().catch(() => {});
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
@@ -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.14",
|
|
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
|