@aarwitz/tapp 0.17.12 → 0.17.13
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.
|
@@ -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.13",
|
|
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.13",
|
|
28
28
|
"mcp"
|
|
29
29
|
],
|
|
30
30
|
"cwd": "${CLAUDE_PROJECT_DIR}"
|
|
@@ -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
|
|
@@ -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/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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aarwitz/tapp",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.13",
|
|
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",
|