@aarwitz/tapp 0.16.3 → 0.16.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -2
- package/README.md +10 -3
- package/mcp-server/src/ci-report.js +2 -3
- package/mcp-server/src/html-report.js +3 -4
- package/mcp-server/src/index.js +2 -3
- package/mcp-server/src/report.js +17 -4
- package/mcp-server/src/web-explorer.js +166 -27
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -11,7 +11,7 @@ the app already on the simulator); it also accepts a repo dir, a `path/to/App.ap
|
|
|
11
11
|
bundle id, or (qa only) an http(s) URL. You never need to know a bundle id up front.
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
npx -y @aarwitz/tapp qa [target] # autonomous QA →
|
|
14
|
+
npx -y @aarwitz/tapp qa [target] # autonomous QA → scoped release verdict + findings (≈ tapp_run_qa)
|
|
15
15
|
npx -y @aarwitz/tapp open [target] # launch + screen summary + screenshot saved to a file (≈ tapp_open_app)
|
|
16
16
|
npx -y @aarwitz/tapp tree [target] # accessibility tree, --json for every element (≈ tapp_ui_tree)
|
|
17
17
|
npx -y @aarwitz/tapp shot # screenshot the booted sim → file path (≈ tapp_screenshot)
|
|
@@ -44,7 +44,7 @@ installs, returns the bundle id) → `tapp_run_qa {appBundleId}`.
|
|
|
44
44
|
|---|---|---|
|
|
45
45
|
| "Show me / screenshot a screen" | `tapp_open_app` (launch + screenshot + tree, ~15s) | `tapp_run_qa` (a full multi-minute QA exploration) |
|
|
46
46
|
| "Tap through / drive / fill a form / log in" | `tapp_session_start` → `session_act` loop | repeated `open_app` calls (cold relaunch each time) |
|
|
47
|
-
| "Is my app broken? Is it ship-ready? Find bugs" | `tapp_run_qa` — `appBundleId` for iOS, `androidAppId` for Android, `url` for owned web apps | a manual session (QA exploration is autonomous) |
|
|
47
|
+
| "Is my app broken? Is it ship-ready? Find bugs" | `tapp_run_qa` — `appBundleId` for iOS, `androidAppId` for Android, `url` for owned web apps; web `ready` means the disclosed automated checks passed, not that copy/privacy/brand claims were reviewed | a manual session (QA exploration is autonomous) |
|
|
48
48
|
| "Make this flow a repeatable test" | drive it in a session, then `tapp_flow_save`; replay with `tapp_flow_run` | re-driving it by hand every time |
|
|
49
49
|
| "What's on screen right now?" | `tapp_screenshot` / `tapp_ui_tree` | relaunching the app |
|
|
50
50
|
|
|
@@ -94,6 +94,10 @@ Returns `{verdict, confidence, headline, screensExplored, actionsPerformed, find
|
|
|
94
94
|
the user** for them rather than re-running blind.
|
|
95
95
|
- Diff two runs: pass the previous run's `findings` as `baselineFindings` → you get a
|
|
96
96
|
`regression` block (`new` / `persisting` / `resolved`, plus a CI `gate` signal).
|
|
97
|
+
- On web, report the exact verdict but preserve its scope: Tapp deterministically checks technical
|
|
98
|
+
behavior such as failed requests, missing assets, placeholder links, and inert controls. It does
|
|
99
|
+
not validate marketing claims against APIs, API field privacy, brand consistency, or subjective
|
|
100
|
+
marketplace credibility unless an explicit reviewed test/contract covers them.
|
|
97
101
|
|
|
98
102
|
## Flows (deterministic E2E tests)
|
|
99
103
|
|
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ Three platforms, one judgment layer:
|
|
|
26
26
|
does not link a Tapp SDK.
|
|
27
27
|
- **Web (beta)** — built *on* Playwright. Your agent already has browser hands; tapp adds the
|
|
28
28
|
autonomous exploration, the deterministic detectors (uncaught exceptions, failed requests,
|
|
29
|
-
dead buttons, broken links, error pages), and the same verdict.
|
|
29
|
+
dead buttons, broken links, placeholder `href="#"` links, error pages), and the same verdict.
|
|
30
30
|
|
|
31
31
|
```
|
|
32
32
|
you: "Add a logout button to the settings screen"
|
|
@@ -209,14 +209,21 @@ ranks runs, it doesn't promise odds.
|
|
|
209
209
|
`tapp_run_qa` explores like a user — accessibility surfaces on iOS/Android and a real browser on web —
|
|
210
210
|
and detects crashes, failed sign-ins, dead buttons, stuck loading screens, error surfaces,
|
|
211
211
|
navigation loops, and dead ends (plus, on web: uncaught JS exceptions, failed/5xx requests,
|
|
212
|
-
broken links and assets
|
|
212
|
+
broken links and assets, and visible placeholder links with no destination). The verdict is
|
|
213
|
+
**deterministic** (no LLM in the run loop) and **honest**:
|
|
213
214
|
|
|
214
215
|
- `blocked` — a release-blocking issue was found.
|
|
215
216
|
- `caution` — issues to review, or the run couldn't see enough.
|
|
216
|
-
- `ready` — genuinely explored with no blockers. **A shallow run
|
|
217
|
+
- `ready` — genuinely explored with no detected blockers in the checks that ran. **A shallow run
|
|
218
|
+
is never `ready`** — if the
|
|
217
219
|
app crashed on launch or a login wall blocked exploration, you get `inconclusive: true`,
|
|
218
220
|
not a false pass. Absence of findings is not a pass.
|
|
219
221
|
|
|
222
|
+
Web beta presents a `ready` result as **AUTOMATED CHECKS PASSED**, not “ship-ready.” Its report
|
|
223
|
+
explicitly excludes content/claim accuracy, privacy and API data minimization, brand/SEO
|
|
224
|
+
consistency, and subjective visual credibility. Those require reviewed contracts, privacy review,
|
|
225
|
+
or human/vision judgment; a green technical crawl must not imply they were validated.
|
|
226
|
+
|
|
220
227
|
Apps behind a login? Pass `testEmail`/`testPassword` (typed into the login form automatically),
|
|
221
228
|
`appLaunchArgs` (e.g. `["--uitesting"]` if your app supports a bypass), or explicit `loginSteps`
|
|
222
229
|
for custom login UIs.
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// any fail on any finding at all, or any flow failure. Strictest.
|
|
27
27
|
import fs from "fs";
|
|
28
28
|
import path from "node:path";
|
|
29
|
-
import { buildQaReport, computeRegression, computeContentCollapse, computeReachabilityLoss } from "./report.js";
|
|
29
|
+
import { buildQaReport, computeRegression, computeContentCollapse, computeReachabilityLoss, verdictBadge } from "./report.js";
|
|
30
30
|
import { writeHtmlReport } from "./html-report.js";
|
|
31
31
|
import { buildUiMapFromMarkers, writeUiMap } from "./ui-map.js";
|
|
32
32
|
import { proposeSelectorMaintenance, validateWebMaintenanceProposal } from "./maintenance-proposal.js";
|
|
@@ -337,12 +337,11 @@ function enrichPrPlan(plan, contracts, currentUiMap = null, markersPath = "", pr
|
|
|
337
337
|
return { ...plan, selected, explorationTargets, maintenanceCandidates, execution: counts };
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
-
const VERDICT_BADGE = { ready: "🟢 SHIP-READY", caution: "🟡 CAUTION", blocked: "🔴 BLOCKED" };
|
|
341
340
|
const SEV_ICON = { critical: "🟥", high: "🟧", medium: "🟨", low: "🟩" };
|
|
342
341
|
|
|
343
342
|
function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan, gate) {
|
|
344
343
|
const lines = [];
|
|
345
|
-
lines.push(`## tapp release check — ${
|
|
344
|
+
lines.push(`## tapp release check — ${verdictBadge(report)}`);
|
|
346
345
|
lines.push("");
|
|
347
346
|
lines.push(report.headline);
|
|
348
347
|
lines.push("");
|
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import fs from "fs";
|
|
10
10
|
import path from "path";
|
|
11
|
-
import { buildQaReport } from "./report.js";
|
|
11
|
+
import { buildQaReport, verdictBadge } from "./report.js";
|
|
12
12
|
|
|
13
|
-
const BADGE = { ready: "🟢 SHIP-READY", caution: "🟡 CAUTION", blocked: "🔴 BLOCKED" };
|
|
14
13
|
const SEV_COLOR = { critical: "#cf222e", high: "#bc4c00", medium: "#9a6700", low: "#57606a" };
|
|
15
14
|
|
|
16
15
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
@@ -102,7 +101,7 @@ export function writeHtmlReport(captureDir, { report, label = "" } = {}) {
|
|
|
102
101
|
</style>
|
|
103
102
|
</head>
|
|
104
103
|
<body>
|
|
105
|
-
<h1>${
|
|
104
|
+
<h1>${esc(verdictBadge(r))} <span class="dim">· release score ${r.releaseScore ?? r.confidence}/100</span></h1>
|
|
106
105
|
<div class="meta">${esc(label)} · ${r.screensExplored} screens · ${r.actionsPerformed} actions · ${r.findingCounts.total} finding(s)</div>
|
|
107
106
|
<div class="headline">${esc(r.headline)}</div>
|
|
108
107
|
<h2>Findings</h2>
|
|
@@ -114,7 +113,7 @@ ${findingsHtml}
|
|
|
114
113
|
${shotsHtml || "<p class='dim'>No screenshots captured.</p>"}
|
|
115
114
|
</div>
|
|
116
115
|
${videoHtml}
|
|
117
|
-
<footer>Generated by <a href="https://github.com/aarwitz/tapp">Tapp</a> —
|
|
116
|
+
<footer>Generated by <a href="https://github.com/aarwitz/tapp">Tapp</a> — deterministic automated checks with explicit coverage limits. Review unchecked product risks before release.</footer>
|
|
118
117
|
</body>
|
|
119
118
|
</html>
|
|
120
119
|
`;
|
package/mcp-server/src/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
ListToolsRequestSchema,
|
|
13
13
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
14
14
|
|
|
15
|
-
import { parseOcqaMarkers, buildQaReport, computeRegression } from "./report.js";
|
|
15
|
+
import { parseOcqaMarkers, buildQaReport, computeRegression, verdictBadge } from "./report.js";
|
|
16
16
|
import { existingProjectArtifactPath, projectArtifactDirectory } from "./project-paths.js";
|
|
17
17
|
|
|
18
18
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -1289,7 +1289,6 @@ function errorResult(message, details = {}) {
|
|
|
1289
1289
|
// programmatic use. This is what makes Tapp feel like a modern dev harness
|
|
1290
1290
|
// ("Explored 14 screens · 3 issues · ship: caution") rather than a wall of JSON.
|
|
1291
1291
|
const SEV = { critical: "🔴", high: "🟠", medium: "🟡", low: "⚪️" };
|
|
1292
|
-
const VERDICT_BADGE = { ready: "🟢 SHIP-READY", caution: "🟡 CAUTION", blocked: "🔴 BLOCKED" };
|
|
1293
1292
|
|
|
1294
1293
|
/** Result with a human-readable text block first and structured data attached for the agent. */
|
|
1295
1294
|
function richResult(text, structured) {
|
|
@@ -1321,7 +1320,7 @@ export function qaNextSteps(report, surface = "mcp") {
|
|
|
1321
1320
|
|
|
1322
1321
|
function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured, reportHtml, recording, uiMap, surface = "mcp" } = {}) {
|
|
1323
1322
|
const c = report.findingCounts || {};
|
|
1324
|
-
const badge =
|
|
1323
|
+
const badge = verdictBadge(report);
|
|
1325
1324
|
const sevBits = ["critical", "high", "medium", "low"]
|
|
1326
1325
|
.map((k) => (c[k] ? `${SEV[k]} ${c[k]} ${k}` : null))
|
|
1327
1326
|
.filter(Boolean)
|
package/mcp-server/src/report.js
CHANGED
|
@@ -86,6 +86,7 @@ export const ISSUE_CATEGORY = {
|
|
|
86
86
|
submit_failed: "unresponsive_element",
|
|
87
87
|
error_surface: "network_error_surface",
|
|
88
88
|
unresponsive_element: "unresponsive_element",
|
|
89
|
+
placeholder_link: "broken_link",
|
|
89
90
|
dead_end: "navigation_dead_end",
|
|
90
91
|
navigation_loop: "repeated_loop",
|
|
91
92
|
navigation_trap: "navigation_dead_end",
|
|
@@ -100,6 +101,11 @@ export function severityRank(s) {
|
|
|
100
101
|
return { critical: 0, high: 1, medium: 2, low: 3 }[s] ?? 4;
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
export function verdictBadge(report) {
|
|
105
|
+
if (report?.platform === "web" && report?.verdict === "ready") return "🟢 AUTOMATED CHECKS PASSED";
|
|
106
|
+
return { ready: "🟢 SHIP-READY", caution: "🟡 CAUTION", blocked: "🔴 BLOCKED" }[report?.verdict] || report?.verdict;
|
|
107
|
+
}
|
|
108
|
+
|
|
103
109
|
// Turn a capture's OCQA markers into the same ship/no-ship report Tapp produces:
|
|
104
110
|
// deduped findings + a trustworthy verdict with a coverage floor (mirrors OrchestratorService).
|
|
105
111
|
export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
|
|
@@ -156,7 +162,9 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
|
|
|
156
162
|
// Chromium can also surface one 404 through both response and requestfailed listeners;
|
|
157
163
|
// keep the concrete missing-asset finding and discard that transport-level duplicate.
|
|
158
164
|
const normalizedIssues = rawIssues.map((issue) => {
|
|
159
|
-
if (platform !== "web"
|
|
165
|
+
if (platform !== "web") return issue;
|
|
166
|
+
if (issue.type === "placeholder_link" && issue.target) return { ...issue, screen: null };
|
|
167
|
+
if (!["missing_asset", "network_error"].includes(issue.type)) return issue;
|
|
160
168
|
const title = String(issue.title || "");
|
|
161
169
|
const match = issue.type === "missing_asset"
|
|
162
170
|
? title.match(/^404 asset:\s+(\S+)/i)
|
|
@@ -205,7 +213,9 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
|
|
|
205
213
|
const headline = inconclusive
|
|
206
214
|
? `Inconclusive — only ${screensExplored} screen(s) / ${actionsPerformed} action(s) explored. The app may have crashed on launch, be stuck behind a sign-in wall, or otherwise prevent exploration. Absence of issues is NOT a pass.`
|
|
207
215
|
: verdict === "ready"
|
|
208
|
-
?
|
|
216
|
+
? platform === "web"
|
|
217
|
+
? "Automated web checks passed — no release-blocking technical issues found in the exercised surfaces. This is not a content, privacy, brand, or business-claim review."
|
|
218
|
+
: "Ship-ready — no release-blocking issues found."
|
|
209
219
|
: verdict === "caution"
|
|
210
220
|
? `Proceed with caution — ${findings.length} issue(s) to review.`
|
|
211
221
|
: `Not ready — ${findings.length} issue(s): ${crit} critical, ${high} high, ${med} medium, ${low} low.`;
|
|
@@ -220,11 +230,14 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
|
|
|
220
230
|
if (platform === "web") {
|
|
221
231
|
checkedFor = [
|
|
222
232
|
"page errors (uncaught exceptions)", "failed/5xx requests", "broken links (404)",
|
|
223
|
-
"dead buttons", "error text on pages", "load timeouts",
|
|
233
|
+
"placeholder links with no destination", "dead buttons", "error text on pages", "load timeouts",
|
|
224
234
|
];
|
|
225
235
|
notChecked = [
|
|
226
236
|
"app-specific business logic (cover with Flows: record or generate, then assert)",
|
|
227
|
-
"
|
|
237
|
+
"content and claim accuracy (including copy versus API data)",
|
|
238
|
+
"privacy or API data minimization",
|
|
239
|
+
"brand and SEO consistency",
|
|
240
|
+
"visual credibility or asset quality (vision review; needs an API key)",
|
|
228
241
|
"only the first few visible buttons per page are probed (web beta)",
|
|
229
242
|
"content & reachability regressions require a baseline",
|
|
230
243
|
];
|
|
@@ -40,6 +40,127 @@ export function webControlLabel({ text = "", value = "", ariaLabel = "", title =
|
|
|
40
40
|
.find(Boolean) || "button";
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
export function webPlaceholderLinkFindings(links = []) {
|
|
44
|
+
const findings = [];
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
for (const link of links || []) {
|
|
47
|
+
const rawHref = String(link?.rawHref || "").trim().toLowerCase();
|
|
48
|
+
const placeholder = rawHref === "#" || /^javascript:(?:void\(0\);?|;?)$/.test(rawHref);
|
|
49
|
+
if (!placeholder || link?.handlerHint) continue;
|
|
50
|
+
const label = String(link?.label || "").replace(/\s+/g, " ").trim().slice(0, 100);
|
|
51
|
+
const fingerprint = String(link?.fingerprint || "link").replace(/\s+/g, " ").trim().slice(0, 100) || "link";
|
|
52
|
+
const target = label || `unlabeled:${fingerprint}`;
|
|
53
|
+
if (seen.has(target)) continue;
|
|
54
|
+
seen.add(target);
|
|
55
|
+
findings.push({
|
|
56
|
+
type: "placeholder_link",
|
|
57
|
+
severity: label ? "medium" : "low",
|
|
58
|
+
title: label
|
|
59
|
+
? `Link "${label}" has no destination (${rawHref === "#" ? 'href="#"' : `href="${rawHref}"`})`
|
|
60
|
+
: `Unlabeled link has no destination (${rawHref === "#" ? 'href="#"' : `href="${rawHref}"`})`,
|
|
61
|
+
target,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return findings;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function webControlHadEffect({ wired = false, before = {}, after = {} } = {}) {
|
|
68
|
+
if (wired) return true;
|
|
69
|
+
return ["url", "title", "heading", "dialogs", "local"]
|
|
70
|
+
.some((key) => String(before?.[key] ?? "") !== String(after?.[key] ?? ""));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function shouldReportWebRequestFailure(errorText = "") {
|
|
74
|
+
// Chromium emits ERR_ABORTED when Tapp deliberately leaves a page while images/video are
|
|
75
|
+
// still loading. That is navigation lifecycle noise, not evidence that the resource is broken.
|
|
76
|
+
return !/\bnet::ERR_ABORTED\b/i.test(String(errorText));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function webPageAppearsBlank({ textLen = 0, controlCount = 0, visualContentCount = 0 } = {}) {
|
|
80
|
+
return Number(textLen) === 0 && Number(controlCount) === 0 && Number(visualContentCount) === 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function installWebListenerTracking(context) {
|
|
84
|
+
await context.addInitScript(() => {
|
|
85
|
+
const key = Symbol.for("tapp.clickListeners");
|
|
86
|
+
const add = EventTarget.prototype.addEventListener;
|
|
87
|
+
const remove = EventTarget.prototype.removeEventListener;
|
|
88
|
+
EventTarget.prototype.addEventListener = function tappTrackedAdd(type, listener, options) {
|
|
89
|
+
if (type === "click" && this instanceof Element && listener) {
|
|
90
|
+
if (!this[key]) Object.defineProperty(this, key, { value: new Set(), configurable: true });
|
|
91
|
+
this[key].add(listener);
|
|
92
|
+
}
|
|
93
|
+
return add.call(this, type, listener, options);
|
|
94
|
+
};
|
|
95
|
+
EventTarget.prototype.removeEventListener = function tappTrackedRemove(type, listener, options) {
|
|
96
|
+
if (type === "click" && this instanceof Element && this[key]) this[key].delete(listener);
|
|
97
|
+
return remove.call(this, type, listener, options);
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function captureWebControlState(page, locator) {
|
|
103
|
+
const global = await page.evaluate(() => {
|
|
104
|
+
const visible = (element) => {
|
|
105
|
+
const style = window.getComputedStyle(element);
|
|
106
|
+
return style.visibility !== "hidden" && style.display !== "none" && element.getClientRects().length > 0;
|
|
107
|
+
};
|
|
108
|
+
const dialogs = [...document.querySelectorAll("dialog[open], [role=dialog], [aria-modal=true]")]
|
|
109
|
+
.filter(visible)
|
|
110
|
+
.map((element) => (element.getAttribute("aria-label") || element.textContent || "dialog").replace(/\s+/g, " ").trim().slice(0, 160))
|
|
111
|
+
.sort();
|
|
112
|
+
return {
|
|
113
|
+
title: document.title,
|
|
114
|
+
heading: document.querySelector("h1")?.textContent?.replace(/\s+/g, " ").trim() || "",
|
|
115
|
+
dialogs: JSON.stringify(dialogs),
|
|
116
|
+
};
|
|
117
|
+
}).catch(() => ({ title: "", heading: "", dialogs: "" }));
|
|
118
|
+
|
|
119
|
+
const local = await locator.evaluate((element) => {
|
|
120
|
+
const key = Symbol.for("tapp.clickListeners");
|
|
121
|
+
const visible = (candidate) => {
|
|
122
|
+
const style = window.getComputedStyle(candidate);
|
|
123
|
+
return style.visibility !== "hidden" && style.display !== "none" && candidate.getClientRects().length > 0;
|
|
124
|
+
};
|
|
125
|
+
let wired = false;
|
|
126
|
+
for (let candidate = element; candidate && candidate !== document.body; candidate = candidate.parentElement) {
|
|
127
|
+
if ((candidate[key] && candidate[key].size > 0) || typeof candidate.onclick === "function" || candidate.hasAttribute("onclick")) {
|
|
128
|
+
wired = true;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!wired && element.matches("button[type=submit], input[type=submit]") && element.closest("form")) wired = true;
|
|
133
|
+
const describe = (root) => ({
|
|
134
|
+
tag: root.tagName,
|
|
135
|
+
className: typeof root.className === "string" ? root.className : "",
|
|
136
|
+
hidden: root.hidden,
|
|
137
|
+
open: root.hasAttribute("open"),
|
|
138
|
+
ariaExpanded: root.getAttribute("aria-expanded"),
|
|
139
|
+
ariaPressed: root.getAttribute("aria-pressed"),
|
|
140
|
+
ariaSelected: root.getAttribute("aria-selected"),
|
|
141
|
+
text: (root.textContent || "").replace(/\s+/g, " ").trim().slice(0, 300),
|
|
142
|
+
controls: [...root.querySelectorAll("button, a[href], input, textarea, select, [role=button]")]
|
|
143
|
+
.slice(0, 40)
|
|
144
|
+
.map((control) => ({
|
|
145
|
+
tag: control.tagName,
|
|
146
|
+
visible: visible(control),
|
|
147
|
+
disabled: !!control.disabled || control.getAttribute("aria-disabled") === "true",
|
|
148
|
+
checked: "checked" in control ? !!control.checked : null,
|
|
149
|
+
expanded: control.getAttribute("aria-expanded"),
|
|
150
|
+
pressed: control.getAttribute("aria-pressed"),
|
|
151
|
+
selected: control.getAttribute("aria-selected"),
|
|
152
|
+
label: (control.getAttribute("aria-label") || control.textContent || control.getAttribute("value") || "").replace(/\s+/g, " ").trim().slice(0, 80),
|
|
153
|
+
})),
|
|
154
|
+
});
|
|
155
|
+
const region = element.parentElement || element;
|
|
156
|
+
const controlledId = element.getAttribute("aria-controls");
|
|
157
|
+
const controlled = controlledId ? document.getElementById(controlledId) : null;
|
|
158
|
+
return { wired, signature: JSON.stringify([describe(region), controlled ? describe(controlled) : null]) };
|
|
159
|
+
}).catch(() => ({ wired: false, signature: "detached" }));
|
|
160
|
+
|
|
161
|
+
return { url: page.url(), ...global, local: local.signature, wired: local.wired };
|
|
162
|
+
}
|
|
163
|
+
|
|
43
164
|
// Wait for a page to stop presenting an explicit loading state and for its semantic
|
|
44
165
|
// surface to remain unchanged across a couple of samples. This is intentionally bounded:
|
|
45
166
|
// live counters and animation-heavy pages still return evidence, marked unsettled.
|
|
@@ -314,7 +435,9 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
314
435
|
|
|
315
436
|
const { chromium } = await loadPlaywright();
|
|
316
437
|
const browser = await chromium.launch(webBrowserLaunchOptions());
|
|
317
|
-
const
|
|
438
|
+
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
|
439
|
+
await installWebListenerTracking(context);
|
|
440
|
+
const page = await context.newPage();
|
|
318
441
|
page.setDefaultTimeout(NAV_TIMEOUT_MS);
|
|
319
442
|
|
|
320
443
|
const deadline = Date.now() + timeoutSec * 1000;
|
|
@@ -324,14 +447,9 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
324
447
|
emit("ISSUE", { type, severity, title, screen, ...(target ? { target } : {}) });
|
|
325
448
|
};
|
|
326
449
|
|
|
327
|
-
// Request counter: cheap "did that click cause network activity" signal for the
|
|
328
|
-
// dead-button check (a button that fires a request is not dead).
|
|
329
|
-
let requestCount = 0;
|
|
330
|
-
|
|
331
450
|
// Async defect listeners: attribute to whatever screen is current when they fire.
|
|
332
451
|
let currentScreen = start.pathname;
|
|
333
452
|
let lastActionTarget = "";
|
|
334
|
-
page.on("request", () => { requestCount += 1; });
|
|
335
453
|
page.on("pageerror", (err) => issue("js_exception", "high", `Uncaught JS exception: ${String(err.message || err).slice(0, 120)}`, currentScreen));
|
|
336
454
|
page.on("response", (res) => {
|
|
337
455
|
try {
|
|
@@ -347,7 +465,9 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
347
465
|
try {
|
|
348
466
|
const u = new URL(req.url());
|
|
349
467
|
if (u.origin !== start.origin) return;
|
|
350
|
-
|
|
468
|
+
const errorText = req.failure()?.errorText || "?";
|
|
469
|
+
if (!shouldReportWebRequestFailure(errorText)) return;
|
|
470
|
+
issue("network_error", "medium", `Request failed: ${u.pathname.slice(0, 80)} (${errorText})`, currentScreen, u.pathname);
|
|
351
471
|
} catch {}
|
|
352
472
|
});
|
|
353
473
|
|
|
@@ -363,6 +483,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
363
483
|
...normalizedSeeds.filter((target) => !targetRoutes.has(target)).map((target) => ({ target, action: `PR target ${target}`, fromScreen: null, prTarget: true })),
|
|
364
484
|
];
|
|
365
485
|
const screenshotFor = new Map();
|
|
486
|
+
const placeholderLinksSeen = new Set();
|
|
366
487
|
let actions = 0;
|
|
367
488
|
let screenCount = 0;
|
|
368
489
|
let lastScreen = null;
|
|
@@ -413,6 +534,11 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
413
534
|
title: document.title.trim(),
|
|
414
535
|
controlCount: document.querySelectorAll("a[href], button, [role=button], input, select, textarea").length,
|
|
415
536
|
textLen: (document.body?.innerText || "").trim().length,
|
|
537
|
+
visualContentCount: [...document.querySelectorAll("img, picture, video, canvas, svg, iframe, object, embed")]
|
|
538
|
+
.filter((el) => {
|
|
539
|
+
const style = window.getComputedStyle(el);
|
|
540
|
+
return style.visibility !== "hidden" && style.display !== "none" && el.getClientRects().length > 0;
|
|
541
|
+
}).length,
|
|
416
542
|
alertText: [...document.querySelectorAll("[role=alert], [aria-live=assertive]")]
|
|
417
543
|
.map((el) => el.textContent.trim()).filter(Boolean).join(" ").slice(0, 120),
|
|
418
544
|
errorCandidateTexts: [...document.querySelectorAll("h1, h2, h3, p, [data-error], [data-testid*=error i]")]
|
|
@@ -428,6 +554,25 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
428
554
|
.filter((el) => el.offsetParent !== null)
|
|
429
555
|
.map((el) => (el.textContent || "").trim())
|
|
430
556
|
.some((text) => /^(loading|fetching|please wait|preparing|connecting)(?:[.…!]*|\s.*)$/i.test(text)),
|
|
557
|
+
placeholderLinks: [...document.querySelectorAll("a[href]")]
|
|
558
|
+
.filter((el) => el.offsetParent !== null)
|
|
559
|
+
.map((el, index) => {
|
|
560
|
+
const listenerKey = Symbol.for("tapp.clickListeners");
|
|
561
|
+
const dataHandler = [...el.attributes]
|
|
562
|
+
.some((attribute) => /^data-(action|toggle|target|modal|waitlist)(?:-|$)/i.test(attribute.name));
|
|
563
|
+
const svgPath = el.querySelector("svg path")?.getAttribute("d") || "";
|
|
564
|
+
return {
|
|
565
|
+
rawHref: el.getAttribute("href") || "",
|
|
566
|
+
label: (el.getAttribute("aria-label") || el.getAttribute("title") || el.innerText || el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 100),
|
|
567
|
+
handlerHint: el.getAttribute("role") === "button"
|
|
568
|
+
|| el.hasAttribute("onclick")
|
|
569
|
+
|| typeof el.onclick === "function"
|
|
570
|
+
|| !!el[listenerKey]?.size
|
|
571
|
+
|| el.hasAttribute("aria-controls")
|
|
572
|
+
|| dataHandler,
|
|
573
|
+
fingerprint: el.id || el.getAttribute("data-testid") || svgPath.slice(0, 80) || `link-${index + 1}`,
|
|
574
|
+
};
|
|
575
|
+
}),
|
|
431
576
|
inputs,
|
|
432
577
|
controls,
|
|
433
578
|
};
|
|
@@ -461,11 +606,16 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
461
606
|
screenCount = screenshotFor.size;
|
|
462
607
|
await page.screenshot({ path: screenshotPath }).catch(() => {});
|
|
463
608
|
// Deterministic per-page detectors run once per distinct screen.
|
|
464
|
-
if (info
|
|
609
|
+
if (webPageAppearsBlank(info)) issue("blank_screen", "high", "Page rendered no visible content", screen);
|
|
465
610
|
else {
|
|
466
611
|
const errorText = webErrorSurfaceText({ alertText: info.alertText, candidateTexts: info.errorCandidateTexts });
|
|
467
612
|
if (errorText) issue("error_surface", "high", `Error shown: ${errorText.slice(0, 80)}`, screen);
|
|
468
613
|
}
|
|
614
|
+
for (const finding of webPlaceholderLinkFindings(info.placeholderLinks)) {
|
|
615
|
+
if (placeholderLinksSeen.has(finding.target)) continue;
|
|
616
|
+
placeholderLinksSeen.add(finding.target);
|
|
617
|
+
issue(finding.type, finding.severity, finding.title, screen, finding.target);
|
|
618
|
+
}
|
|
469
619
|
}
|
|
470
620
|
return { key, screen, info };
|
|
471
621
|
}
|
|
@@ -626,20 +776,10 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
626
776
|
id: await b.getAttribute("id").catch(() => ""),
|
|
627
777
|
}).slice(0, 40);
|
|
628
778
|
if (/log ?out|sign ?out|delete|remove/i.test(label)) continue; // don't destroy test state
|
|
629
|
-
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
|
|
633
|
-
await page
|
|
634
|
-
.evaluate(() => {
|
|
635
|
-
window.__tappMut = 0;
|
|
636
|
-
if (window.__tappMo) window.__tappMo.disconnect();
|
|
637
|
-
window.__tappMo = new MutationObserver((muts) => { window.__tappMut += muts.length; });
|
|
638
|
-
window.__tappMo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
|
|
639
|
-
})
|
|
640
|
-
.catch(() => {});
|
|
641
|
-
const dialogsBefore = await page.locator("dialog[open], [role=dialog], [aria-modal=true]").count().catch(() => 0);
|
|
642
|
-
const reqBefore = requestCount;
|
|
779
|
+
// Capture only durable, user-visible semantics around this control. A global
|
|
780
|
+
// MutationObserver is intentionally avoided: carousels, chat launchers, and live
|
|
781
|
+
// counters can mutate while an unrelated dead button is clicked, creating verdict jitter.
|
|
782
|
+
const beforeState = await captureWebControlState(page, b);
|
|
643
783
|
actions += 1;
|
|
644
784
|
lastActionTarget = label;
|
|
645
785
|
emit("ACTION", { type: "tap", target: label, screen: webActionScreen(ob), narrative: `Tapped "${label}"` });
|
|
@@ -653,16 +793,15 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
653
793
|
continue;
|
|
654
794
|
}
|
|
655
795
|
await waitForWebStability(page);
|
|
656
|
-
if (page.url() !==
|
|
796
|
+
if (page.url() !== beforeState.url) {
|
|
657
797
|
await observe();
|
|
658
798
|
await page.goBack({ waitUntil: "domcontentloaded" }).catch(() => {});
|
|
659
799
|
await waitForWebStability(page);
|
|
660
800
|
} else {
|
|
661
|
-
const
|
|
662
|
-
const
|
|
663
|
-
const hadEffect = mutations > 0 || dialogsAfter !== dialogsBefore || requestCount > reqBefore;
|
|
801
|
+
const afterState = await captureWebControlState(page, b);
|
|
802
|
+
const hadEffect = webControlHadEffect({ wired: beforeState.wired, before: beforeState, after: afterState });
|
|
664
803
|
if (!hadEffect) {
|
|
665
|
-
issue("unresponsive_element", "medium", `Button "${label}"
|
|
804
|
+
issue("unresponsive_element", "medium", `Button "${label}" has no wiring or observable effect`, ob.screen, label);
|
|
666
805
|
} else {
|
|
667
806
|
// Same-URL SPA transitions are real screens too; URL-only observation
|
|
668
807
|
// under-counted coverage and made healthy applications inconclusive.
|
package/package.json
CHANGED