@aarwitz/tapp 0.16.3 → 0.16.5
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 +13 -5
- package/README.md +25 -9
- package/mcp-server/src/ci-report.js +17 -9
- package/mcp-server/src/html-report.js +4 -4
- package/mcp-server/src/index.js +11 -7
- package/mcp-server/src/report.js +69 -15
- 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
|
|
|
@@ -82,10 +82,13 @@ Rules that prevent 90% of failures:
|
|
|
82
82
|
|
|
83
83
|
## Autonomous QA (`tapp_run_qa`)
|
|
84
84
|
|
|
85
|
-
Returns `{verdict, confidence, headline, screensExplored, actionsPerformed, findings[]}`.
|
|
85
|
+
Returns `{verdict, confidence, releaseScore, headline, screensExplored, actionsPerformed, findings[]}`.
|
|
86
|
+
Exploratory web runs set `confidence` and `releaseScore` to `null`; report their deterministic
|
|
87
|
+
finding counts, advisory sampled-probe counts, and coverage instead of inventing a scalar.
|
|
86
88
|
|
|
87
|
-
- `verdict`: `ready` | `caution` | `blocked`.
|
|
88
|
-
|
|
89
|
+
- `verdict`: `ready` | `caution` | `blocked`. Report it as-is; never soften a `blocked` or inflate a
|
|
90
|
+
`caution`. Judgment is deterministic for a given evidence trace, while adaptive exploration and
|
|
91
|
+
live target state can still change which evidence a run observes.
|
|
89
92
|
- `inconclusive: true` means the run couldn't see enough (crash on launch, login wall). That is
|
|
90
93
|
**not a pass** — tell the user what blocked exploration and what would unblock it.
|
|
91
94
|
- Login walls: pass `testEmail`/`testPassword` (auto-typed into login forms), `appLaunchArgs`
|
|
@@ -94,6 +97,11 @@ Returns `{verdict, confidence, headline, screensExplored, actionsPerformed, find
|
|
|
94
97
|
the user** for them rather than re-running blind.
|
|
95
98
|
- Diff two runs: pass the previous run's `findings` as `baselineFindings` → you get a
|
|
96
99
|
`regression` block (`new` / `persisting` / `resolved`, plus a CI `gate` signal).
|
|
100
|
+
- On web, report the exact verdict but preserve its scope: Tapp deterministically checks technical
|
|
101
|
+
behavior such as failed requests, missing assets, and placeholder links. Dead-control probes are
|
|
102
|
+
budget-capped advisory findings and do not drive the verdict. Tapp does
|
|
103
|
+
not validate marketing claims against APIs, API field privacy, brand consistency, or subjective
|
|
104
|
+
marketplace credibility unless an explicit reviewed test/contract or verifier covers them.
|
|
97
105
|
|
|
98
106
|
## Flows (deterministic E2E tests)
|
|
99
107
|
|
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"
|
|
@@ -168,7 +168,7 @@ Then ask your agent:
|
|
|
168
168
|
| 📸 | `tapp_screenshot` | Whatever's on the sim right now, as an inline image. |
|
|
169
169
|
| 🌳 | `tapp_ui_tree` | The accessibility tree of the current screen (ids, labels, hittability). |
|
|
170
170
|
| 🕹 | `tapp_session_start/act/end` | **Interactive driving** — the Playwright loop. App launches once; each act (tap/type/swipe/back/wait) returns the fresh tree. |
|
|
171
|
-
| 🧪 | `tapp_run_qa` | **Autonomous QA** — explores with no authored test, returns `{verdict, releaseScore, findings[]}
|
|
171
|
+
| 🧪 | `tapp_run_qa` | **Autonomous QA** — explores with no authored test, returns `{verdict, releaseScore, findings[]}` (`releaseScore` is `null` for exploratory web). Takes `appBundleId` (iOS), `androidAppId` (Android), or `url` (web). |
|
|
172
172
|
| 🧭 | `tapp_init` | **Repository import** — detect targets; optionally explore a real surface; persist the shared UI Map; construct the evidence-classified model and grounded release plan. |
|
|
173
173
|
| 👤 | `tapp_actor_config` | **Actor/session setup** — store roles, isolation/provisioning, and environment-variable names without accepting or persisting credential values. |
|
|
174
174
|
| ✅ | `tapp_release_plan` | **Release-plan lifecycle** — inspect, approve/reject/defer, generate, real-target validate, and explicitly promote proposed guarantees without silent test edits. |
|
|
@@ -193,7 +193,7 @@ deterministic per-platform navigation root used for bounded changed-surface repl
|
|
|
193
193
|
|
|
194
194
|
**Adaptive exploration, deterministic judgment.** Exploration is adaptive — two runs may
|
|
195
195
|
traverse different paths through your app. Judgment is deterministic: the same evidence
|
|
196
|
-
trace always produces the same findings
|
|
196
|
+
trace always produces the same findings and verdict — no LLM variability
|
|
197
197
|
in the decision loop. PR gating keys on the **regression diff**
|
|
198
198
|
(stable finding signatures vs. a baseline), so it reacts to what *changed*, not to
|
|
199
199
|
run-to-run path variance. For critical user journeys, committed **Tasks and Flows** provide the stable CI
|
|
@@ -201,22 +201,38 @@ suite: reusable semantic actions, exact assertions, condition-based waits, fresh
|
|
|
201
201
|
and evidence on failure. We call this *flake-resistant*, not magically flake-free—backend outages,
|
|
202
202
|
unstable test data, and poorly identified controls can still make any E2E test fail.
|
|
203
203
|
|
|
204
|
-
**
|
|
205
|
-
fixed
|
|
206
|
-
|
|
207
|
-
|
|
204
|
+
**Native has a heuristic release score; exploratory web does not.** The native 0–100 number comes
|
|
205
|
+
from fixed deductions and is not calibrated probability. Web reports deterministic findings,
|
|
206
|
+
advisory budget-capped control probes, and concrete coverage instead of compressing those unlike
|
|
207
|
+
signals into a scalar. Committed Flows, Tasks, contracts, and baseline regressions provide the web
|
|
208
|
+
merge decision.
|
|
208
209
|
|
|
209
210
|
`tapp_run_qa` explores like a user — accessibility surfaces on iOS/Android and a real browser on web —
|
|
210
211
|
and detects crashes, failed sign-ins, dead buttons, stuck loading screens, error surfaces,
|
|
211
212
|
navigation loops, and dead ends (plus, on web: uncaught JS exceptions, failed/5xx requests,
|
|
212
|
-
broken links and assets
|
|
213
|
+
broken links and assets, and visible placeholder links with no destination). The verdict is
|
|
214
|
+
**deterministic** (no LLM in the run loop) and **honest**:
|
|
213
215
|
|
|
214
216
|
- `blocked` — a release-blocking issue was found.
|
|
215
217
|
- `caution` — issues to review, or the run couldn't see enough.
|
|
216
|
-
- `ready` — genuinely explored with no blockers. **A shallow run
|
|
218
|
+
- `ready` — genuinely explored with no detected blockers in the checks that ran. **A shallow run
|
|
219
|
+
is never `ready`** — if the
|
|
217
220
|
app crashed on launch or a login wall blocked exploration, you get `inconclusive: true`,
|
|
218
221
|
not a false pass. Absence of findings is not a pass.
|
|
219
222
|
|
|
223
|
+
Web beta presents a `ready` result as **AUTOMATED CHECKS COMPLETE**, not “ship-ready,” and displays
|
|
224
|
+
no scalar score. Exhaustive checks on each exercised page drive the verdict; sampled control probes
|
|
225
|
+
remain visible findings but are advisory. The report
|
|
226
|
+
explicitly excludes content/claim accuracy, privacy and API data minimization, brand/SEO
|
|
227
|
+
consistency, and subjective visual credibility. Those require reviewed contracts, privacy review,
|
|
228
|
+
or human/vision judgment; an exploratory crawl must not imply they were validated.
|
|
229
|
+
|
|
230
|
+
For a business guarantee such as “every coach is insured,” use a deterministic app-owned verifier
|
|
231
|
+
endpoint that returns success only when the invariant holds, then require that status and the
|
|
232
|
+
customer-visible claim in a release contract. The current DSL does not yet read arbitrary JSON
|
|
233
|
+
response bodies or compare a cross-origin API payload directly with page copy; use a verifier or an
|
|
234
|
+
explicit CI preflight rather than assuming autonomous QA inferred the guarantee.
|
|
235
|
+
|
|
220
236
|
Apps behind a login? Pass `testEmail`/`testPassword` (typed into the login form automatically),
|
|
221
237
|
`appLaunchArgs` (e.g. `["--uitesting"]` if your app supports a bypass), or explicit `loginSteps`
|
|
222
238
|
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, qaScoreLabel, 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,16 +337,18 @@ 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("");
|
|
349
|
-
lines.push(
|
|
348
|
+
lines.push(`**${qaScoreLabel(report)}** · ${report.screensExplored} screens · ${report.actionsPerformed} actions · ${report.findingCounts.total} finding(s)`);
|
|
349
|
+
if (report.platform === "web") {
|
|
350
|
+
lines.push(`**Verdict basis:** ${report.verdictFindingCounts?.total || 0} deterministic finding(s); ${report.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory.`);
|
|
351
|
+
}
|
|
350
352
|
if (report.uiMap) lines.push(`**UI Map:** ${report.uiMap.nodeCount} states · ${report.uiMap.edgeCount} transitions · ${report.uiMap.controlCount} semantic controls`);
|
|
351
353
|
if (report.findings.length) {
|
|
352
354
|
lines.push("");
|
|
@@ -489,11 +491,17 @@ if (collapsed.length) {
|
|
|
489
491
|
report.findings.push(...collapsed);
|
|
490
492
|
report.findingCounts.high += collapsed.length;
|
|
491
493
|
report.findingCounts.total += collapsed.length;
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
if (
|
|
494
|
+
report.verdictFindingCounts.high += collapsed.length;
|
|
495
|
+
report.verdictFindingCounts.total += collapsed.length;
|
|
496
|
+
// Keep native scoring compatible. Exploratory web deliberately has no scalar; deterministic
|
|
497
|
+
// baseline regressions still raise its verdict directly.
|
|
498
|
+
if (Number.isFinite(report.confidence)) {
|
|
499
|
+
report.confidence = Math.max(0, report.confidence - collapsed.length * 10);
|
|
500
|
+
report.releaseScore = report.confidence;
|
|
501
|
+
}
|
|
502
|
+
if (report.verdict === "ready") {
|
|
503
|
+
report.verdict = Number.isFinite(report.confidence) && report.confidence < 50 ? "blocked" : "caution";
|
|
504
|
+
}
|
|
497
505
|
report.headline = `Proceed with caution — ${collapsed.length} screen(s) regressed vs. baseline (content collapsed or became unreachable).`;
|
|
498
506
|
}
|
|
499
507
|
const regression = computeRegression(report.findings, baseline?.findings ?? null);
|
|
@@ -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, qaScoreLabel, 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,8 +101,9 @@ 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">· ${esc(qaScoreLabel(r))}</span></h1>
|
|
106
105
|
<div class="meta">${esc(label)} · ${r.screensExplored} screens · ${r.actionsPerformed} actions · ${r.findingCounts.total} finding(s)</div>
|
|
106
|
+
${r.platform === "web" ? `<div class="meta">Verdict basis: ${r.verdictFindingCounts?.total || 0} deterministic finding(s); ${r.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory.</div>` : ""}
|
|
107
107
|
<div class="headline">${esc(r.headline)}</div>
|
|
108
108
|
<h2>Findings</h2>
|
|
109
109
|
<ul class="findings">
|
|
@@ -114,7 +114,7 @@ ${findingsHtml}
|
|
|
114
114
|
${shotsHtml || "<p class='dim'>No screenshots captured.</p>"}
|
|
115
115
|
</div>
|
|
116
116
|
${videoHtml}
|
|
117
|
-
<footer>Generated by <a href="https://github.com/aarwitz/tapp">Tapp</a> —
|
|
117
|
+
<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
118
|
</body>
|
|
119
119
|
</html>
|
|
120
120
|
`;
|
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, qaScoreLabel, 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,17 +1320,20 @@ 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)
|
|
1328
1327
|
.join(", ");
|
|
1329
1328
|
const L = [];
|
|
1330
|
-
L.push(`### 🧪 QA complete — ${badge} ·
|
|
1329
|
+
L.push(`### 🧪 QA complete — ${badge} · ${qaScoreLabel(report)}${bundleId ? `\n\`${bundleId}\`` : ""}`);
|
|
1331
1330
|
L.push("");
|
|
1332
1331
|
L.push(report.headline);
|
|
1333
1332
|
L.push("");
|
|
1334
1333
|
L.push(`**Coverage** — ${report.screensExplored} screens · ${report.actionsPerformed} actions${timedOut ? " · ⏱️ hit time limit" : ""}`);
|
|
1334
|
+
if (report.platform === "web") {
|
|
1335
|
+
L.push(`**Verdict basis** — ${report.verdictFindingCounts?.total || 0} deterministic finding(s); ${report.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory`);
|
|
1336
|
+
}
|
|
1335
1337
|
if (uiMap) L.push(`**UI Map** — ${uiMap.nodeCount} states · ${uiMap.edgeCount} transitions · ${uiMap.controlCount} semantic controls · ${uiMap.path}`);
|
|
1336
1338
|
if (reportHtml) L.push(`**Evidence** — 📄 ${reportHtml} (screenshots of every screen + findings, shareable)`);
|
|
1337
1339
|
if (recording) L.push(`**Recording** — 🎬 ${recording} (full exploration, embedded in the evidence page)`);
|
|
@@ -2071,13 +2073,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2071
2073
|
description:
|
|
2072
2074
|
"Run autonomous QA against iOS (appBundleId), Android (androidAppId), OR a web app " +
|
|
2073
2075
|
"(url — beta, requires Playwright installed) and return a structured " +
|
|
2074
|
-
"
|
|
2076
|
+
"QA verdict. Use ONLY when the user wants a QA assessment / to find bugs / a verdict — this " +
|
|
2075
2077
|
"runs for MINUTES exploring the whole app. Do NOT use it just to view, screenshot, or reach a specific " +
|
|
2076
2078
|
"screen — use tapp_open_app (launch + screenshot) or a session for that. Tapp explores the app " +
|
|
2077
2079
|
"like a tester (taps, types, navigates, scrolls) and detects real issues — crashes, dead buttons, failed sign-ins, error screens, " +
|
|
2078
2080
|
"stuck/hung screens; on web also uncaught JS exceptions, failed/5xx requests, broken links and assets. " +
|
|
2079
|
-
"Returns {verdict: ready|caution|blocked, confidence, headline, screensExplored, " +
|
|
2080
|
-
"actionsPerformed, findings:[{type,severity,category,title,screen}]}.
|
|
2081
|
+
"Returns {verdict: ready|caution|blocked, confidence, releaseScore, headline, screensExplored, " +
|
|
2082
|
+
"actionsPerformed, findings:[{type,severity,category,title,screen,evaluationTier}]}. Exploratory web " +
|
|
2083
|
+
"sets confidence/releaseScore to null and separates deterministic verdict findings from advisory " +
|
|
2084
|
+
"sampled control probes. The verdict has a coverage floor: " +
|
|
2081
2085
|
"if the app barely explored (crash on launch / sign-in wall) it returns 'caution' + inconclusive, never a " +
|
|
2082
2086
|
"false pass. For iOS the app must already be installed on a booted simulator (use tapp_list_simulators / " +
|
|
2083
2087
|
"tapp_boot_simulator first). For web, only point it at an app/environment you own — it CLICKS things. " +
|
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",
|
|
@@ -95,11 +96,28 @@ export const ISSUE_CATEGORY = {
|
|
|
95
96
|
explore_timeout: "performance_timeout",
|
|
96
97
|
};
|
|
97
98
|
export const CRITICAL_ISSUE_TYPES = new Set(["crash"]);
|
|
99
|
+
export const WEB_SAMPLED_ISSUE_TYPES = new Set(["unresponsive_element"]);
|
|
98
100
|
|
|
99
101
|
export function severityRank(s) {
|
|
100
102
|
return { critical: 0, high: 1, medium: 2, low: 3 }[s] ?? 4;
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
export function findingEvaluationTier(finding, platform = "ios") {
|
|
106
|
+
return platform === "web" && WEB_SAMPLED_ISSUE_TYPES.has(finding?.type) ? "sampled" : "deterministic";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function verdictBadge(report) {
|
|
110
|
+
if (report?.platform === "web" && report?.verdict === "ready") return "🔵 AUTOMATED CHECKS COMPLETE";
|
|
111
|
+
return { ready: "🟢 SHIP-READY", caution: "🟡 CAUTION", blocked: "🔴 BLOCKED" }[report?.verdict] || report?.verdict;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function qaScoreLabel(report) {
|
|
115
|
+
const score = report?.releaseScore ?? report?.confidence;
|
|
116
|
+
if (Number.isFinite(score)) return `release score ${score}/100`;
|
|
117
|
+
if (report?.platform === "web") return "exploratory web · no scalar score";
|
|
118
|
+
return "score unavailable";
|
|
119
|
+
}
|
|
120
|
+
|
|
103
121
|
// Turn a capture's OCQA markers into the same ship/no-ship report Tapp produces:
|
|
104
122
|
// deduped findings + a trustworthy verdict with a coverage floor (mirrors OrchestratorService).
|
|
105
123
|
export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
|
|
@@ -156,7 +174,9 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
|
|
|
156
174
|
// Chromium can also surface one 404 through both response and requestfailed listeners;
|
|
157
175
|
// keep the concrete missing-asset finding and discard that transport-level duplicate.
|
|
158
176
|
const normalizedIssues = rawIssues.map((issue) => {
|
|
159
|
-
if (platform !== "web"
|
|
177
|
+
if (platform !== "web") return issue;
|
|
178
|
+
if (issue.type === "placeholder_link" && issue.target) return { ...issue, screen: null };
|
|
179
|
+
if (!["missing_asset", "network_error"].includes(issue.type)) return issue;
|
|
160
180
|
const title = String(issue.title || "");
|
|
161
181
|
const match = issue.type === "missing_asset"
|
|
162
182
|
? title.match(/^404 asset:\s+(\S+)/i)
|
|
@@ -178,7 +198,11 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
|
|
|
178
198
|
const key = `${i.type}|${i.screen}|${i.target ?? ""}`;
|
|
179
199
|
if (seen.has(key)) continue;
|
|
180
200
|
seen.add(key);
|
|
181
|
-
findings.push({
|
|
201
|
+
findings.push({
|
|
202
|
+
...i,
|
|
203
|
+
category: ISSUE_CATEGORY[i.type] || i.type,
|
|
204
|
+
...(platform === "web" ? { evaluationTier: findingEvaluationTier(i, platform) } : {}),
|
|
205
|
+
});
|
|
182
206
|
}
|
|
183
207
|
findings.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
|
|
184
208
|
|
|
@@ -189,23 +213,33 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
|
|
|
189
213
|
const high = findings.filter((f) => f.severity === "high").length;
|
|
190
214
|
const med = findings.filter((f) => f.severity === "medium").length;
|
|
191
215
|
const low = findings.filter((f) => f.severity === "low").length;
|
|
216
|
+
const verdictFindings = platform === "web"
|
|
217
|
+
? findings.filter((finding) => finding.evaluationTier !== "sampled")
|
|
218
|
+
: findings;
|
|
219
|
+
const verdictCrit = verdictFindings.filter((f) => f.severity === "critical").length;
|
|
220
|
+
const verdictHigh = verdictFindings.filter((f) => f.severity === "high").length;
|
|
221
|
+
const verdictMed = verdictFindings.filter((f) => f.severity === "medium").length;
|
|
222
|
+
const verdictLow = verdictFindings.filter((f) => f.severity === "low").length;
|
|
223
|
+
const sampledFindings = platform === "web" ? findings.filter((finding) => finding.evaluationTier === "sampled") : [];
|
|
192
224
|
|
|
193
225
|
// Coverage floor: a verdict is only trustworthy if the app was actually exercised.
|
|
194
226
|
const inconclusive = screensExplored < 2 || actionsPerformed < 3;
|
|
195
|
-
let
|
|
196
|
-
if (inconclusive)
|
|
227
|
+
let riskScore = Math.max(0, Math.min(100, 100 - verdictCrit * 25 - verdictHigh * 10 - verdictMed * 3));
|
|
228
|
+
if (inconclusive) riskScore = Math.min(riskScore, 40);
|
|
197
229
|
|
|
198
230
|
let verdict;
|
|
199
|
-
if (
|
|
231
|
+
if (verdictCrit > 0) verdict = "blocked";
|
|
200
232
|
else if (inconclusive) verdict = "caution";
|
|
201
|
-
else if (
|
|
202
|
-
else if (
|
|
233
|
+
else if (riskScore < 50) verdict = "blocked";
|
|
234
|
+
else if (verdictHigh > 0 || riskScore < 80) verdict = "caution";
|
|
203
235
|
else verdict = "ready";
|
|
204
236
|
|
|
205
237
|
const headline = inconclusive
|
|
206
238
|
? `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
239
|
: verdict === "ready"
|
|
208
|
-
?
|
|
240
|
+
? platform === "web"
|
|
241
|
+
? "Automated web checks completed — no release-blocking deterministic findings in the exercised surfaces. Sampled control probes are advisory. This is not a content, privacy, brand, or business-claim review."
|
|
242
|
+
: "Ship-ready — no release-blocking issues found."
|
|
209
243
|
: verdict === "caution"
|
|
210
244
|
? `Proceed with caution — ${findings.length} issue(s) to review.`
|
|
211
245
|
: `Not ready — ${findings.length} issue(s): ${crit} critical, ${high} high, ${med} medium, ${low} low.`;
|
|
@@ -220,11 +254,14 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
|
|
|
220
254
|
if (platform === "web") {
|
|
221
255
|
checkedFor = [
|
|
222
256
|
"page errors (uncaught exceptions)", "failed/5xx requests", "broken links (404)",
|
|
223
|
-
"dead
|
|
257
|
+
"placeholder links with no destination", "sampled dead-button probes (advisory)", "error text on pages", "load timeouts",
|
|
224
258
|
];
|
|
225
259
|
notChecked = [
|
|
226
260
|
"app-specific business logic (cover with Flows: record or generate, then assert)",
|
|
227
|
-
"
|
|
261
|
+
"content and claim accuracy (including copy versus API data)",
|
|
262
|
+
"privacy or API data minimization",
|
|
263
|
+
"brand and SEO consistency",
|
|
264
|
+
"visual credibility or asset quality (vision review; needs an API key)",
|
|
228
265
|
"only the first few visible buttons per page are probed (web beta)",
|
|
229
266
|
"content & reachability regressions require a baseline",
|
|
230
267
|
];
|
|
@@ -259,11 +296,14 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
|
|
|
259
296
|
|
|
260
297
|
return {
|
|
261
298
|
verdict,
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
confidence,
|
|
266
|
-
releaseScore:
|
|
299
|
+
// Exploratory web QA deliberately has no scalar. Its verdict derives from deterministic
|
|
300
|
+
// checks on exercised pages; budget-capped control probes remain visible but advisory.
|
|
301
|
+
// Native keeps the legacy heuristic score until it has an equivalent tier split.
|
|
302
|
+
confidence: platform === "web" ? null : riskScore,
|
|
303
|
+
releaseScore: platform === "web" ? null : riskScore,
|
|
304
|
+
scoreUnavailableReason: platform === "web"
|
|
305
|
+
? "Exploratory web runs report deterministic findings, advisory sampled probes, and coverage instead of a scalar release score."
|
|
306
|
+
: null,
|
|
267
307
|
headline,
|
|
268
308
|
inconclusive,
|
|
269
309
|
checkedFor,
|
|
@@ -273,6 +313,20 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
|
|
|
273
313
|
screensExplored,
|
|
274
314
|
actionsPerformed,
|
|
275
315
|
findingCounts: { critical: crit, high, medium: med, low, total: findings.length },
|
|
316
|
+
verdictFindingCounts: {
|
|
317
|
+
critical: verdictCrit,
|
|
318
|
+
high: verdictHigh,
|
|
319
|
+
medium: verdictMed,
|
|
320
|
+
low: verdictLow,
|
|
321
|
+
total: verdictFindings.length,
|
|
322
|
+
},
|
|
323
|
+
sampledFindingCounts: {
|
|
324
|
+
critical: sampledFindings.filter((f) => f.severity === "critical").length,
|
|
325
|
+
high: sampledFindings.filter((f) => f.severity === "high").length,
|
|
326
|
+
medium: sampledFindings.filter((f) => f.severity === "medium").length,
|
|
327
|
+
low: sampledFindings.filter((f) => f.severity === "low").length,
|
|
328
|
+
total: sampledFindings.length,
|
|
329
|
+
},
|
|
276
330
|
findings,
|
|
277
331
|
screens: Array.from(screens),
|
|
278
332
|
screenElementCounts,
|
|
@@ -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