@aarwitz/tapp 0.17.21 → 0.17.22
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 +11 -5
- package/bin/tapp.js +63 -21
- package/mcp-server/src/index.js +62 -0
- package/mcp-server/src/web-audit.js +423 -0
- package/mcp-server/src/web-explorer.js +98 -114
- package/package.json +2 -2
- package/skills/tapp/SKILL.md +1 -1
|
@@ -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.22",
|
|
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.22",
|
|
28
28
|
"mcp"
|
|
29
29
|
],
|
|
30
30
|
"cwd": "${CLAUDE_PROJECT_DIR}"
|
package/AGENTS.md
CHANGED
|
@@ -138,11 +138,17 @@ advisory). Report the finding counts and coverage; do not invent a scalar or a s
|
|
|
138
138
|
(e.g. `["--uitesting"]` if the app has a test bypass), and/or `appLaunchEnv` (e.g. a staging
|
|
139
139
|
backend URL). If the result shows `inputFieldsEncountered` and you have no credentials, **ask
|
|
140
140
|
the user** for them rather than re-running blind.
|
|
141
|
-
- `tapp audit <url>` is the read-only pass: it renders the page and reads
|
|
142
|
-
clicks, so it is the one analysis safe to point at production
|
|
143
|
-
never alive
|
|
144
|
-
with no handler/form/link — which Flows
|
|
145
|
-
for a button they believe does nothing
|
|
141
|
+
- `tapp audit <url>` / `tapp_audit` is the read-only pass: it renders the page and reads it but never
|
|
142
|
+
clicks, types, submits, or hovers, so it is the one analysis safe to point at production or at a
|
|
143
|
+
site you do not own. It finds controls that were never alive (dead in-page anchors,
|
|
144
|
+
`aria-controls` naming nothing, placeholder links, buttons with no handler/form/link — which Flows
|
|
145
|
+
structurally cannot find, because nobody writes a test for a button they believe does nothing),
|
|
146
|
+
images that failed to load, same-origin assets answering 404, 5xx/failed requests and uncaught JS
|
|
147
|
+
exceptions during load, same-origin links answering 404/410/5xx, mixed content, and a page wider
|
|
148
|
+
than its viewport. `--pages N` crawls same-origin links (robots.txt honoured, `--delay` between
|
|
149
|
+
pages); several URLs or `--urls FILE` batch sites. It writes a capture in the explore layout
|
|
150
|
+
(screenshots, per-finding evidence, `report.html`). Exit 1 when it finds anything. No findings
|
|
151
|
+
means the page is served without structural defects — not that the product works.
|
|
146
152
|
- Credential surfaces are catalogue-only without credentials, on every platform: with no
|
|
147
153
|
`testEmail`/`testPassword` supplied, exploration records a login/signup form's fields but does
|
|
148
154
|
not type into them, submit them, or open recovery/third-party-auth flows — a bare-app run may
|
package/bin/tapp.js
CHANGED
|
@@ -287,7 +287,7 @@ function safeCommandUsage(verb) {
|
|
|
287
287
|
app: "tapp app [repo] [--no-open] [--port PORT]",
|
|
288
288
|
report: "tapp report [captureId|latest]",
|
|
289
289
|
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",
|
|
290
|
-
audit: "tapp audit <url> [--json] [--device NAME] [--viewport WxH]\n Read-only: renders
|
|
290
|
+
audit: "tapp audit <url> [<url>...] [--urls FILE] [--pages N] [--delay MS] [--links N] [--json [FILE]] [--no-capture] [--device NAME] [--viewport WxH]\n Read-only: renders each page and reads it, never clicks — safe to point at production or a site you do not own.\n Finds dead controls, broken images/assets/links, 5xx/failed requests, JS exceptions, mixed content, horizontal overflow.\n --pages N crawls same-origin links (robots.txt honoured, --delay between pages). Writes a capture with screenshots, evidence and report.html.\n Exit codes: 0 nothing broken · 1 defects found · 2 usage/infrastructure",
|
|
291
291
|
doctor: "tapp doctor [--json]\n Exit codes: 0 environment healthy · 1 blocked (fix ❌ items)",
|
|
292
292
|
install: "tapp install",
|
|
293
293
|
mcp: "tapp mcp",
|
|
@@ -1513,30 +1513,71 @@ switch (command) {
|
|
|
1513
1513
|
|
|
1514
1514
|
case "audit": {
|
|
1515
1515
|
const { flags, positionals } = parseVerbArgs(rest);
|
|
1516
|
-
const
|
|
1517
|
-
if (
|
|
1518
|
-
|
|
1516
|
+
const urls = [...positionals];
|
|
1517
|
+
if (typeof flags.urls === "string") {
|
|
1518
|
+
try {
|
|
1519
|
+
for (const line of fs.readFileSync(flags.urls, "utf8").split(/\r?\n/)) {
|
|
1520
|
+
const trimmed = line.trim();
|
|
1521
|
+
if (trimmed && !trimmed.startsWith("#")) urls.push(trimmed);
|
|
1522
|
+
}
|
|
1523
|
+
} catch (error) {
|
|
1524
|
+
console.error(`❌ Could not read --urls ${flags.urls}: ${error.message}`);
|
|
1525
|
+
process.exit(2);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
if (!urls.length || urls.some((u) => !/^https?:\/\//i.test(u))) {
|
|
1529
|
+
console.error(`usage: ${USAGE.audit}`);
|
|
1519
1530
|
process.exit(2);
|
|
1520
1531
|
}
|
|
1532
|
+
const jsonMode = flags.json === true || typeof flags.json === "string";
|
|
1533
|
+
const say = (line) => { if (!jsonMode || typeof flags.json === "string") console.log(line); };
|
|
1521
1534
|
try {
|
|
1522
|
-
const {
|
|
1523
|
-
const
|
|
1524
|
-
|
|
1535
|
+
const { auditWebSite } = await import(path.join(packageRoot, "mcp-server", "src", "web-explorer.js"));
|
|
1536
|
+
const { auditCaptureId } = await import(path.join(packageRoot, "mcp-server", "src", "web-audit.js"));
|
|
1537
|
+
const capturesDir = flags["no-capture"] === true ? "" : path.join(tappHome, "captures");
|
|
1538
|
+
const common = {
|
|
1539
|
+
pages: Math.max(1, Number(flags.pages) || 1),
|
|
1540
|
+
delayMs: flags.delay !== undefined ? Math.max(0, Number(flags.delay) || 0) : undefined,
|
|
1541
|
+
linkCheckLimit: flags.links !== undefined ? Math.max(0, Number(flags.links) || 0) : undefined,
|
|
1525
1542
|
device: typeof flags.device === "string" ? flags.device : "",
|
|
1526
1543
|
viewport: typeof flags.viewport === "string" ? flags.viewport : "",
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1544
|
+
capturesDir,
|
|
1545
|
+
};
|
|
1546
|
+
const sites = [];
|
|
1547
|
+
for (const [index, url] of urls.entries()) {
|
|
1548
|
+
say(`🔎 Read-only audit — ${url}${common.pages > 1 ? ` (up to ${common.pages} pages)` : ""}`);
|
|
1549
|
+
let site;
|
|
1550
|
+
try {
|
|
1551
|
+
site = await auditWebSite({ ...common, url, captureId: capturesDir ? `${auditCaptureId()}${urls.length > 1 ? `-${index + 1}` : ""}` : "",
|
|
1552
|
+
onPage: (page, n) => say(` ${n}. ${page.url} — ${page.controlsExamined} control(s), ${page.linksChecked.checked} link(s) checked, ${page.findings.length} finding(s)`) });
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
if (urls.length === 1) throw error;
|
|
1555
|
+
site = { kind: "tapp-structural-audit", readOnly: true, url, error: error.message || String(error), pagesAudited: 0, findingCounts: { critical: 0, high: 0, medium: 0, low: 0, total: 0 }, pages: [], capture: null };
|
|
1556
|
+
say(` ❌ ${site.error}`);
|
|
1537
1557
|
}
|
|
1558
|
+
sites.push(site);
|
|
1559
|
+
if (!jsonMode) {
|
|
1560
|
+
const findings = site.pages.flatMap((p) => p.findings);
|
|
1561
|
+
if (!site.error && !findings.length) console.log(" ✅ Nothing broken observed. Nothing was clicked — this is not a pass on behaviour.");
|
|
1562
|
+
for (const finding of findings) console.log(` ${finding.severity === "high" || finding.severity === "critical" ? "🔴" : finding.severity === "medium" ? "🟠" : "🟡"} [${finding.type}] ${finding.title}`);
|
|
1563
|
+
if (site.robotsBlocked?.length) console.log(` robots.txt kept ${site.robotsBlocked.length} page(s) out of the crawl`);
|
|
1564
|
+
if (site.capture) console.log(` 📁 ${site.capture.path}\n 📄 ${site.capture.report}`);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
const totalFindings = sites.reduce((n, site) => n + site.findingCounts.total, 0);
|
|
1568
|
+
const payload = urls.length === 1 ? sites[0] : {
|
|
1569
|
+
kind: "tapp-structural-audit", readOnly: true, sites,
|
|
1570
|
+
totals: { sites: sites.length, pagesAudited: sites.reduce((n, site) => n + site.pagesAudited, 0), findings: totalFindings, failed: sites.filter((site) => site.error).length },
|
|
1571
|
+
};
|
|
1572
|
+
if (typeof flags.json === "string") {
|
|
1573
|
+
fs.writeFileSync(flags.json, JSON.stringify(payload, null, 2));
|
|
1574
|
+
console.log(`\n📄 Audit JSON: ${flags.json}`);
|
|
1575
|
+
} else if (flags.json === true) {
|
|
1576
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1577
|
+
} else if (urls.length > 1) {
|
|
1578
|
+
console.log(`\n${totalFindings} finding(s) across ${sites.length} site(s).`);
|
|
1538
1579
|
}
|
|
1539
|
-
process.exit(
|
|
1580
|
+
process.exit(totalFindings ? 1 : sites.some((site) => site.error) ? 2 : 0);
|
|
1540
1581
|
} catch (error) {
|
|
1541
1582
|
console.error(`❌ ${error.message || String(error)}`);
|
|
1542
1583
|
process.exit(2);
|
|
@@ -2046,9 +2087,10 @@ Primitives — an agent's eyes and hands:
|
|
|
2046
2087
|
(web: --tap TEXT · --wait-for TEXT · --out FILE)
|
|
2047
2088
|
tapp tree [target] Accessibility tree of the current screen (--json for every element)
|
|
2048
2089
|
(web: --tap TEXT · --wait-for TEXT)
|
|
2049
|
-
tapp audit <url
|
|
2050
|
-
|
|
2051
|
-
production (exit 1
|
|
2090
|
+
tapp audit <url>... Read-only audit — dead controls, broken images/links/assets, failed
|
|
2091
|
+
requests, JS exceptions, mixed content, overflow. Never clicks, so it
|
|
2092
|
+
is safe against production or a prospect's site (exit 1 on defects)
|
|
2093
|
+
(--pages N crawls same-origin · --urls FILE batches · --json [FILE])
|
|
2052
2094
|
|
|
2053
2095
|
Repository & release:
|
|
2054
2096
|
tapp init [repo] Detect targets and write the application model + reviewable release plan
|
package/mcp-server/src/index.js
CHANGED
|
@@ -2809,6 +2809,34 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2809
2809
|
},
|
|
2810
2810
|
},
|
|
2811
2811
|
},
|
|
2812
|
+
{
|
|
2813
|
+
name: "tapp_audit",
|
|
2814
|
+
title: "Audit (read-only)",
|
|
2815
|
+
description:
|
|
2816
|
+
"Read-only audit of a web page or site: renders it and reads it, NEVER clicks, types, submits or hovers — " +
|
|
2817
|
+
"the one analysis safe to point at production or at a site you do not own. Finds controls that were never " +
|
|
2818
|
+
"alive (dead in-page anchors, placeholder links, aria-controls naming nothing, buttons with no handler/form/link), " +
|
|
2819
|
+
"images that failed to load, same-origin assets answering 404, 5xx/failed requests and uncaught JS exceptions " +
|
|
2820
|
+
"during load, same-origin links answering 404/410/5xx, mixed content, and a page wider than its viewport. " +
|
|
2821
|
+
"pages > 1 crawls same-origin links breadth-first with robots.txt honoured. Writes a capture (screenshots, " +
|
|
2822
|
+
"per-finding evidence, report.html) like an explore run. Returns {kind:'tapp-structural-audit', readOnly:true, " +
|
|
2823
|
+
"pagesAudited, findingCounts, pages:[{url,title,controlsExamined,linksChecked,findings}], checkedFor, notChecked, capture}. " +
|
|
2824
|
+
"An observation, never a verdict; absence of findings says the page is served without structural defects, not that the product works.",
|
|
2825
|
+
inputSchema: {
|
|
2826
|
+
type: "object",
|
|
2827
|
+
properties: {
|
|
2828
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2829
|
+
url: { type: "string", description: "http(s) URL of the page to audit (the crawl start page when pages > 1)" },
|
|
2830
|
+
pages: { type: "integer", default: 1, minimum: 1, maximum: 200, description: "How many same-origin pages to audit at most (1 = this page only)" },
|
|
2831
|
+
delayMs: { type: "integer", default: 1000, minimum: 0, description: "Pause between pages when crawling — be polite to sites you do not own" },
|
|
2832
|
+
linkCheckLimit: { type: "integer", default: 30, minimum: 0, description: "Same-origin links to HEAD-check per page" },
|
|
2833
|
+
device: { type: "string", description: "Playwright device profile, e.g. 'iPhone 13' — audit the mobile rendering" },
|
|
2834
|
+
viewport: { type: "string", description: "WIDTHxHEIGHT viewport override" },
|
|
2835
|
+
capture: { type: "boolean", default: true, description: "Write a capture directory (screenshots, evidence, report.html)" },
|
|
2836
|
+
},
|
|
2837
|
+
required: ["url"],
|
|
2838
|
+
},
|
|
2839
|
+
},
|
|
2812
2840
|
{
|
|
2813
2841
|
name: "tapp_init",
|
|
2814
2842
|
title: "Inspect or explore a repository and create the Tapp application model and release plan",
|
|
@@ -3609,6 +3637,40 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3609
3637
|
return richResult(L.join("\n"), summary);
|
|
3610
3638
|
}
|
|
3611
3639
|
|
|
3640
|
+
if (name === "tapp_audit") {
|
|
3641
|
+
const unauthorized = ensureAuthorized(args);
|
|
3642
|
+
if (unauthorized) return unauthorized;
|
|
3643
|
+
if (!isNonEmptyString(args.url)) return errorResult("url is required");
|
|
3644
|
+
try {
|
|
3645
|
+
const { auditWebSite } = await import("./web-audit.js");
|
|
3646
|
+
const site = await auditWebSite({
|
|
3647
|
+
url: args.url,
|
|
3648
|
+
pages: asInteger(args.pages, 1),
|
|
3649
|
+
delayMs: asInteger(args.delayMs, 1000),
|
|
3650
|
+
linkCheckLimit: asInteger(args.linkCheckLimit, 30),
|
|
3651
|
+
device: isNonEmptyString(args.device) ? args.device : "",
|
|
3652
|
+
viewport: isNonEmptyString(args.viewport) ? args.viewport : "",
|
|
3653
|
+
capturesDir: args.capture === false ? "" : capturesDir,
|
|
3654
|
+
});
|
|
3655
|
+
const findings = site.pages.flatMap((p) => p.findings);
|
|
3656
|
+
const c = site.findingCounts;
|
|
3657
|
+
const lines = [
|
|
3658
|
+
`🔎 Read-only audit of ${site.url} — ${site.pagesAudited} page(s), nothing clicked`,
|
|
3659
|
+
findings.length
|
|
3660
|
+
? `${findings.length} defect(s): ${c.critical} critical · ${c.high} high · ${c.medium} medium · ${c.low} low`
|
|
3661
|
+
: "✅ Nothing broken observed. Not a pass on behaviour — nothing was exercised.",
|
|
3662
|
+
...findings.slice(0, 25).map((f) => ` ${SEV[f.severity] || "·"} [${f.type}] ${f.title}${f.screen ? ` — on ${f.screen}` : ""}`),
|
|
3663
|
+
...(findings.length > 25 ? [` … ${findings.length - 25} more in structuredContent`] : []),
|
|
3664
|
+
...(site.robotsBlocked?.length ? [`robots.txt kept ${site.robotsBlocked.length} page(s) out of the crawl`] : []),
|
|
3665
|
+
...(site.capture ? [`📁 ${site.capture.path}`, `📄 ${site.capture.report}`] : []),
|
|
3666
|
+
"Next: to exercise the controls (clicks, forms) run tapp_explore — only on an environment you own.",
|
|
3667
|
+
];
|
|
3668
|
+
return richResult(lines.join("\n"), site);
|
|
3669
|
+
} catch (error) {
|
|
3670
|
+
return errorResult(error.message || String(error));
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3612
3674
|
if (name === "tapp_explore" || name === "tapp_run_qa") { // tapp_run_qa: deprecated alias
|
|
3613
3675
|
const unauthorized = ensureAuthorized(args);
|
|
3614
3676
|
if (unauthorized) return unauthorized;
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// Read-only web audit: render a page, read it, never click it.
|
|
2
|
+
//
|
|
3
|
+
// This is the one analysis safe to point at a site you do not own, which is what makes it the
|
|
4
|
+
// primitive behind the outreach motion (find a prospect's broken site, show them proof). Everything
|
|
5
|
+
// here is an observation of a page as served: structurally dead controls, assets that failed to
|
|
6
|
+
// load, links that answer 404, mixed content, a layout wider than its viewport. No form is
|
|
7
|
+
// submitted, no button pressed, no navigation beyond the pages the caller asked for.
|
|
8
|
+
//
|
|
9
|
+
// The capture it writes uses the same layout as an explore run (state_*.png at the root,
|
|
10
|
+
// ocqa-markers.txt, report.html) so every existing consumer of a capture — `tapp report`, the
|
|
11
|
+
// Studio, Clien's evidence videos — reads an audit without a second code path.
|
|
12
|
+
import fs from "fs";
|
|
13
|
+
import path from "path";
|
|
14
|
+
import { createRequire } from "module";
|
|
15
|
+
import {
|
|
16
|
+
NAV_TIMEOUT_MS, loadPlaywright, webBrowserLaunchOptions, webContextOptions, installWebListenerTracking,
|
|
17
|
+
waitForWebStability, captureElementEvidence, shouldReportWebRequestFailure, auditStructuralControls,
|
|
18
|
+
auditFindingsFromControls, slug,
|
|
19
|
+
} from "./web-explorer.js";
|
|
20
|
+
import { buildQaReport } from "./report.js";
|
|
21
|
+
import { writeHtmlReport } from "./html-report.js";
|
|
22
|
+
import { buildUiMapFromMarkers, writeUiMap } from "./ui-map.js";
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const VERSION = (() => { try { return require("../../package.json").version; } catch { return "0.0.0"; } })();
|
|
26
|
+
|
|
27
|
+
export const AUDIT_LINK_CHECK_LIMIT = 30;
|
|
28
|
+
export const AUDIT_CRAWL_DELAY_MS = 1000;
|
|
29
|
+
const LINK_CHECK_CONCURRENCY = 4;
|
|
30
|
+
const LINK_CHECK_TIMEOUT_MS = 10_000;
|
|
31
|
+
|
|
32
|
+
// What an audit does and does not look at. Spelled out so a report never borrows the exploration
|
|
33
|
+
// run's "checked for" list and claims clicks that never happened.
|
|
34
|
+
export function auditScope({ linksChecked = { total: 0, checked: 0 }, pages = 1 } = {}) {
|
|
35
|
+
const checkedFor = [
|
|
36
|
+
"controls that were never alive: dead in-page anchors, placeholder links, aria-controls naming nothing, buttons with no handler/form/link",
|
|
37
|
+
"images that failed to load",
|
|
38
|
+
"same-origin assets answering 404 and requests answering 5xx or failing during page load",
|
|
39
|
+
"uncaught JavaScript exceptions during page load",
|
|
40
|
+
"mixed content (http:// resources on an https:// page)",
|
|
41
|
+
"page wider than its viewport (horizontal scrolling) and a missing viewport meta tag",
|
|
42
|
+
];
|
|
43
|
+
if (linksChecked.total) {
|
|
44
|
+
checkedFor.push(linksChecked.total > linksChecked.checked
|
|
45
|
+
? `same-origin links answering 404/410/5xx (first ${linksChecked.checked} of ${linksChecked.total}, HEAD then GET)`
|
|
46
|
+
: "same-origin links answering 404/410/5xx (HEAD then GET)");
|
|
47
|
+
}
|
|
48
|
+
const notChecked = [
|
|
49
|
+
"anything that needs a click: nothing was pressed, typed, submitted, or hovered (run `tapp explore` on an environment you own for that)",
|
|
50
|
+
"off-site links (only same-origin links are fetched; outbound reachability is an explore check)",
|
|
51
|
+
"app-specific business logic, content accuracy, privacy, brand, visual quality",
|
|
52
|
+
pages > 1 ? `pages beyond the ${pages} audited` : "pages other than the one audited (pass --pages N to crawl same-origin links)",
|
|
53
|
+
];
|
|
54
|
+
return { checkedFor, notChecked };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// robots.txt, the polite minimum: honour Disallow for `*` and for our own agent, longest match wins,
|
|
58
|
+
// Allow beats Disallow at equal length. Unparseable or unreachable → everything allowed.
|
|
59
|
+
export function parseRobots(text = "") {
|
|
60
|
+
const groups = [];
|
|
61
|
+
let current = null;
|
|
62
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
63
|
+
const line = raw.replace(/#.*$/, "").trim();
|
|
64
|
+
if (!line) continue;
|
|
65
|
+
const m = line.match(/^([a-z-]+)\s*:\s*(.*)$/i);
|
|
66
|
+
if (!m) continue;
|
|
67
|
+
const field = m[1].toLowerCase();
|
|
68
|
+
const value = m[2].trim();
|
|
69
|
+
if (field === "user-agent") {
|
|
70
|
+
if (!current || current.rules.length) { current = { agents: [], rules: [] }; groups.push(current); }
|
|
71
|
+
current.agents.push(value.toLowerCase());
|
|
72
|
+
} else if ((field === "disallow" || field === "allow") && current) {
|
|
73
|
+
current.rules.push({ allow: field === "allow", prefix: value });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return groups;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function robotsAllows(groups, pathname, agent = "tapp") {
|
|
80
|
+
const mine = groups.filter((g) => g.agents.some((a) => a === agent || agent.startsWith(a)));
|
|
81
|
+
const applicable = mine.length ? mine : groups.filter((g) => g.agents.includes("*"));
|
|
82
|
+
let best = null;
|
|
83
|
+
for (const group of applicable) {
|
|
84
|
+
for (const rule of group.rules) {
|
|
85
|
+
if (!rule.prefix) { if (!rule.allow && !best) best = { allow: true, len: 0 }; continue; }
|
|
86
|
+
const re = new RegExp("^" + rule.prefix.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*").replace(/\\\$$/, "$"));
|
|
87
|
+
if (re.test(pathname) && (!best || rule.prefix.length > best.len || (rule.prefix.length === best.len && rule.allow))) {
|
|
88
|
+
best = { allow: rule.allow, len: rule.prefix.length };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return best ? best.allow : true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Page-level health, read from the DOM after load. Flagged elements are tagged like the structural
|
|
96
|
+
// scan does so the evidence shot is of the element the finding names.
|
|
97
|
+
async function auditPageHealth(page) {
|
|
98
|
+
return page.evaluate(() => {
|
|
99
|
+
const findings = [];
|
|
100
|
+
let n = 0;
|
|
101
|
+
const tag = (el) => { n += 1; el.setAttribute("data-tapp-audit-health", String(n)); return `[data-tapp-audit-health="${n}"]`; };
|
|
102
|
+
const visible = (el) => el.getClientRects().length > 0;
|
|
103
|
+
for (const img of document.images) {
|
|
104
|
+
const src = img.getAttribute("src") || "";
|
|
105
|
+
if (!src || src.startsWith("data:")) continue;
|
|
106
|
+
if (img.complete && img.naturalWidth === 0) {
|
|
107
|
+
findings.push({ kind: "broken_image", src: src.slice(0, 160), alt: (img.getAttribute("alt") || "").slice(0, 80), selector: visible(img) ? tag(img) : null });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (location.protocol === "https:") {
|
|
111
|
+
for (const el of document.querySelectorAll("img[src], script[src], iframe[src], video[src], audio[src], source[src], link[rel~=stylesheet][href]")) {
|
|
112
|
+
const value = el.getAttribute("src") || el.getAttribute("href") || "";
|
|
113
|
+
if (/^http:\/\//i.test(value)) findings.push({ kind: "mixed_content", tagName: el.tagName.toLowerCase(), src: value.slice(0, 160), selector: visible(el) ? tag(el) : null });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const doc = document.documentElement;
|
|
117
|
+
const overflow = Math.max(doc.scrollWidth, document.body ? document.body.scrollWidth : 0) - window.innerWidth;
|
|
118
|
+
if (overflow > 2) findings.push({ kind: "horizontal_overflow", overflow, viewport: window.innerWidth });
|
|
119
|
+
if (!document.querySelector('meta[name="viewport"]')) findings.push({ kind: "no_viewport_meta" });
|
|
120
|
+
const links = [];
|
|
121
|
+
for (const a of document.querySelectorAll("a[href]")) {
|
|
122
|
+
const href = a.getAttribute("href") || "";
|
|
123
|
+
if (!href || href.startsWith("#") || /^(mailto|tel|javascript|sms):/i.test(href)) continue;
|
|
124
|
+
let abs;
|
|
125
|
+
try { abs = new URL(href, location.href); } catch { continue; }
|
|
126
|
+
if (!/^https?:$/.test(abs.protocol)) continue;
|
|
127
|
+
abs.hash = "";
|
|
128
|
+
links.push({ href: abs.href, label: (a.getAttribute("aria-label") || a.textContent || "").replace(/\s+/g, " ").trim().slice(0, 80), sameOrigin: abs.origin === location.origin });
|
|
129
|
+
}
|
|
130
|
+
return { findings, links };
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function healthFindings(raw = []) {
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const h of raw) {
|
|
137
|
+
if (h.kind === "broken_image") {
|
|
138
|
+
out.push({ type: "broken_image", severity: "medium", title: `Image failed to load: ${h.src}${h.alt ? ` (alt “${h.alt}”)` : ""}`, target: h.src, selector: h.selector });
|
|
139
|
+
} else if (h.kind === "mixed_content") {
|
|
140
|
+
out.push({ type: "mixed_content", severity: "medium", title: `Mixed content: <${h.tagName}> loads ${h.src} over plain http on an https page (browsers block or warn)`, target: h.src, selector: h.selector });
|
|
141
|
+
} else if (h.kind === "horizontal_overflow") {
|
|
142
|
+
out.push({ type: "horizontal_overflow", severity: "low", title: `Page is ${h.overflow}px wider than the ${h.viewport}px viewport — it scrolls sideways on this screen size`, target: `overflow:${h.overflow}` });
|
|
143
|
+
} else if (h.kind === "no_viewport_meta") {
|
|
144
|
+
out.push({ type: "no_viewport_meta", severity: "low", title: "No <meta name=\"viewport\"> — phones render the page zoomed out at desktop width", target: "meta[name=viewport]" });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// HEAD each same-origin link (GET when HEAD is refused). A link the server cannot answer is
|
|
151
|
+
// reported low — a WAF or rate limit answers that way too — while 404/410 and 5xx are the server's
|
|
152
|
+
// own word.
|
|
153
|
+
async function checkLinks(context, links, { limit, pageUrl }) {
|
|
154
|
+
const unique = new Map();
|
|
155
|
+
for (const link of links) {
|
|
156
|
+
if (!link.sameOrigin || link.href === pageUrl) continue;
|
|
157
|
+
if (!unique.has(link.href)) unique.set(link.href, link.label);
|
|
158
|
+
}
|
|
159
|
+
const targets = [...unique.entries()].slice(0, Math.max(0, limit));
|
|
160
|
+
const findings = [];
|
|
161
|
+
let cursor = 0;
|
|
162
|
+
const worker = async () => {
|
|
163
|
+
while (cursor < targets.length) {
|
|
164
|
+
const [href, label] = targets[cursor++];
|
|
165
|
+
const where = label ? `“${label}”` : href;
|
|
166
|
+
const pathOf = (() => { try { return new URL(href).pathname; } catch { return href; } })();
|
|
167
|
+
try {
|
|
168
|
+
let res = await context.request.fetch(href, { method: "HEAD", maxRedirects: 5, timeout: LINK_CHECK_TIMEOUT_MS, failOnStatusCode: false });
|
|
169
|
+
if ([405, 501, 403].includes(res.status())) {
|
|
170
|
+
res = await context.request.fetch(href, { method: "GET", maxRedirects: 5, timeout: LINK_CHECK_TIMEOUT_MS, failOnStatusCode: false });
|
|
171
|
+
}
|
|
172
|
+
const status = res.status();
|
|
173
|
+
if (status === 404 || status === 410) findings.push({ type: "broken_link", severity: "medium", title: `Link ${where} → ${pathOf} answers ${status}`, target: href, url: href });
|
|
174
|
+
else if (status >= 500) findings.push({ type: "broken_link", severity: "high", title: `Link ${where} → ${pathOf} answers ${status}`, target: href, url: href });
|
|
175
|
+
} catch (error) {
|
|
176
|
+
findings.push({ type: "broken_link", severity: "low", title: `Link ${where} → ${pathOf} could not be fetched (${String(error?.message || error).split("\n")[0].slice(0, 80)})`, target: href, url: href });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
await Promise.all(Array.from({ length: Math.min(LINK_CHECK_CONCURRENCY, targets.length) }, worker));
|
|
181
|
+
findings.sort((a, b) => String(a.target).localeCompare(String(b.target)));
|
|
182
|
+
return { findings, total: unique.size, checked: targets.length };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// One page, inside an already-open context. Collects everything, then (if a capture is open) shoots
|
|
186
|
+
// the page and each named element and appends the markers.
|
|
187
|
+
async function auditOnePage(context, target, { timeoutMs, linkCheckLimit, capture }) {
|
|
188
|
+
const page = await context.newPage();
|
|
189
|
+
const bounded = Math.max(1000, Math.min(60_000, Number(timeoutMs) || NAV_TIMEOUT_MS));
|
|
190
|
+
page.setDefaultTimeout(bounded);
|
|
191
|
+
const origin = target.origin;
|
|
192
|
+
const loadFindings = [];
|
|
193
|
+
const seenLoad = new Set();
|
|
194
|
+
const loadIssue = (type, severity, title, key) => {
|
|
195
|
+
if (seenLoad.has(key)) return;
|
|
196
|
+
seenLoad.add(key);
|
|
197
|
+
loadFindings.push({ type, severity, title, target: key });
|
|
198
|
+
};
|
|
199
|
+
page.on("pageerror", (err) => loadIssue("js_exception", "high", `Uncaught JS exception: ${String(err.message || err).slice(0, 120)}`, `js:${String(err.message || err).slice(0, 120)}`));
|
|
200
|
+
page.on("response", (res) => {
|
|
201
|
+
try {
|
|
202
|
+
const u = new URL(res.url());
|
|
203
|
+
if (u.origin !== origin) return;
|
|
204
|
+
if (res.status() >= 500) loadIssue("network_error", "high", `${res.status()} from ${u.pathname.slice(0, 80)}`, u.pathname);
|
|
205
|
+
else if (res.status() === 404 && res.request().resourceType() !== "document") loadIssue("missing_asset", "medium", `404 asset: ${u.pathname.slice(0, 80)}`, u.pathname);
|
|
206
|
+
} catch { /* unparseable url */ }
|
|
207
|
+
});
|
|
208
|
+
page.on("requestfailed", (req) => {
|
|
209
|
+
try {
|
|
210
|
+
const u = new URL(req.url());
|
|
211
|
+
if (u.origin !== origin) return;
|
|
212
|
+
const errorText = req.failure()?.errorText || "?";
|
|
213
|
+
if (!shouldReportWebRequestFailure(errorText)) return;
|
|
214
|
+
loadIssue("network_error", "medium", `Request failed: ${u.pathname.slice(0, 80)} (${errorText})`, u.pathname);
|
|
215
|
+
} catch { /* unparseable url */ }
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const response = await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: bounded });
|
|
220
|
+
if (response && response.status() >= 400) throw new Error(`Could not open ${target.href}: HTTP ${response.status()}`);
|
|
221
|
+
await waitForWebStability(page, { timeoutMs: Math.min(5_000, bounded) });
|
|
222
|
+
|
|
223
|
+
const controls = await auditStructuralControls(page);
|
|
224
|
+
const health = await auditPageHealth(page);
|
|
225
|
+
const links = await checkLinks(context, health.links, { limit: linkCheckLimit, pageUrl: page.url() });
|
|
226
|
+
|
|
227
|
+
const structural = auditFindingsFromControls(controls.dead).map((f, i) => ({ ...f, selector: controls.dead[i].selector }));
|
|
228
|
+
const findings = [...structural, ...healthFindings(health.findings), ...loadFindings, ...links.findings];
|
|
229
|
+
const screen = target.pathname + target.search;
|
|
230
|
+
for (const f of findings) { f.screen = screen; if (!f.url) f.url = page.url(); }
|
|
231
|
+
|
|
232
|
+
if (capture) {
|
|
233
|
+
capture.states += 1;
|
|
234
|
+
const stateName = `state_${capture.states}_${slug(target.pathname === "/" ? (controls.title || "home") : target.pathname)}.png`;
|
|
235
|
+
await page.screenshot({ path: path.join(capture.dir, stateName), fullPage: true }).catch(() => page.screenshot({ path: path.join(capture.dir, stateName) }).catch(() => {}));
|
|
236
|
+
capture.emit("STATE", { screen, url: page.url(), elements: controls.controlCount, role: "page", controls: controls.dead.map((d) => d.label).filter(Boolean).slice(0, 40), inputs: [], settled: true, screenshot: stateName });
|
|
237
|
+
for (const f of findings) {
|
|
238
|
+
if (f.selector) {
|
|
239
|
+
capture.evidence += 1;
|
|
240
|
+
const name = `evidence_${capture.evidence}_${slug(f.type)}.png`;
|
|
241
|
+
const shot = await captureElementEvidence(page, page.locator(f.selector), capture.dir, name);
|
|
242
|
+
if (shot) f.evidence = shot;
|
|
243
|
+
}
|
|
244
|
+
capture.emit("ISSUE", { type: f.type, severity: f.severity, title: f.title, screen, ...(f.target ? { target: f.target } : {}), url: f.url, ...(f.evidence ? { evidence: f.evidence } : {}) });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
for (const f of findings) delete f.selector;
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
url: page.url(),
|
|
251
|
+
title: controls.title,
|
|
252
|
+
controlsExamined: controls.controlCount,
|
|
253
|
+
linksChecked: { total: links.total, checked: links.checked },
|
|
254
|
+
findings,
|
|
255
|
+
links: health.links,
|
|
256
|
+
};
|
|
257
|
+
} finally {
|
|
258
|
+
await page.close().catch(() => {});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function openCapture(capturesDir, id) {
|
|
263
|
+
const dir = path.join(capturesDir, id);
|
|
264
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
265
|
+
const markersFd = fs.openSync(path.join(dir, "ocqa-markers.txt"), "w");
|
|
266
|
+
return {
|
|
267
|
+
id, dir, states: 0, evidence: 0, markersFd,
|
|
268
|
+
emit(kind, payload) { fs.writeSync(markersFd, `OCQA_${kind}:${JSON.stringify(payload)}\n`); },
|
|
269
|
+
close() { try { fs.closeSync(markersFd); } catch { /* already closed */ } },
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function auditCaptureId(now = new Date()) {
|
|
274
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
275
|
+
return `web-audit-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function finalizeCapture(capture, { startUrl, pages, findings, robotsBlocked, linksChecked }) {
|
|
279
|
+
capture.emit("COMPLETE", { actions: 0, screens: pages.length, credentialsProvided: false, credentialsUsed: false, timedOut: false, stop: "audit-complete", readOnly: true, robotsBlocked });
|
|
280
|
+
capture.close();
|
|
281
|
+
const markers = path.join(capture.dir, "ocqa-markers.txt");
|
|
282
|
+
const report = buildQaReport(markers, { platform: "web", target: startUrl });
|
|
283
|
+
if (!report) return null;
|
|
284
|
+
// The exploration report's coverage floor and wording assume clicks; an audit has none by design.
|
|
285
|
+
const scope = auditScope({ linksChecked, pages: pages.length });
|
|
286
|
+
const counts = report.deterministicFindingCounts || report.findingCounts || { critical: 0, high: 0, medium: 0, low: 0 };
|
|
287
|
+
Object.assign(report, {
|
|
288
|
+
kind: "tapp-structural-audit",
|
|
289
|
+
readOnly: true,
|
|
290
|
+
inconclusive: false,
|
|
291
|
+
runStatus: "completed",
|
|
292
|
+
stopReason: "audit-complete",
|
|
293
|
+
headline: findings.length === 0
|
|
294
|
+
? `Read-only audit of ${pages.length} page(s) — nothing broken was observed. Nothing was clicked; this says the page is served without structural defects, not that the product works.`
|
|
295
|
+
: `${findings.length} defect(s) observed on ${pages.length} page(s) without clicking anything (${counts.critical} critical, ${counts.high} high, ${counts.medium} medium, ${counts.low} low).`,
|
|
296
|
+
checkedFor: scope.checkedFor,
|
|
297
|
+
notChecked: scope.notChecked,
|
|
298
|
+
conditionsNotReached: [],
|
|
299
|
+
});
|
|
300
|
+
fs.writeFileSync(path.join(capture.dir, "report.json"), JSON.stringify(report, null, 2));
|
|
301
|
+
// The capture-local UI map is what ties a page screenshot to the findings on that page for
|
|
302
|
+
// every consumer that reads explore captures (the Studio, Clien's evidence slides).
|
|
303
|
+
try { writeUiMap(path.join(capture.dir, "ui-map.json"), buildUiMapFromMarkers({ markersPath: markers, platform: "web", target: startUrl, runId: capture.id })); }
|
|
304
|
+
catch { /* the map is a convenience; the report and markers are the evidence */ }
|
|
305
|
+
writeHtmlReport(capture.dir, { report, label: "Read-only audit" });
|
|
306
|
+
return report;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Audit a site: the start page and, with pages > 1, same-origin links discovered on audited pages
|
|
310
|
+
// (breadth-first, robots.txt honoured, one request at a time with a pause between pages).
|
|
311
|
+
export async function auditWebSite({
|
|
312
|
+
url, pages = 1, timeoutMs = NAV_TIMEOUT_MS, device = "", viewport = "",
|
|
313
|
+
linkCheckLimit = AUDIT_LINK_CHECK_LIMIT, delayMs = AUDIT_CRAWL_DELAY_MS,
|
|
314
|
+
capturesDir = "", captureId = "", onPage = null,
|
|
315
|
+
}) {
|
|
316
|
+
let start;
|
|
317
|
+
try { start = new URL(url); }
|
|
318
|
+
catch { throw new Error("Audit needs a valid http(s) URL"); }
|
|
319
|
+
if (!/^https?:$/.test(start.protocol)) throw new Error("Audit needs a valid http(s) URL");
|
|
320
|
+
const maxPages = Math.max(1, Math.min(200, Number(pages) || 1));
|
|
321
|
+
|
|
322
|
+
const { chromium, devices } = await loadPlaywright();
|
|
323
|
+
const browser = await chromium.launch(webBrowserLaunchOptions(process.env, {}));
|
|
324
|
+
const capture = capturesDir ? openCapture(capturesDir, captureId || auditCaptureId()) : null;
|
|
325
|
+
try {
|
|
326
|
+
const contextOptions = webContextOptions({ device, viewport, devices });
|
|
327
|
+
// Identify ourselves to sites we do not own; the default UA is kept so rendering is unchanged.
|
|
328
|
+
const probe = await browser.newContext(contextOptions);
|
|
329
|
+
const defaultUa = contextOptions.userAgent || await (await probe.newPage()).evaluate(() => navigator.userAgent).catch(() => "");
|
|
330
|
+
await probe.close();
|
|
331
|
+
const context = await browser.newContext({ ...contextOptions, userAgent: `${defaultUa} tapp-audit/${VERSION} (+https://runtapp.com)`.trim() });
|
|
332
|
+
await installWebListenerTracking(context);
|
|
333
|
+
if (capture) capture.emit("CONTEXT", { ...(String(device || "").trim() ? { device: String(device).trim() } : {}), viewport: contextOptions.viewport, deviceScaleFactor: contextOptions.deviceScaleFactor ?? 1, mode: "audit", readOnly: true });
|
|
334
|
+
|
|
335
|
+
let robots = [];
|
|
336
|
+
if (maxPages > 1) {
|
|
337
|
+
try {
|
|
338
|
+
const res = await context.request.get(new URL("/robots.txt", start).href, { timeout: 5_000, failOnStatusCode: false });
|
|
339
|
+
if (res.ok()) robots = parseRobots(await res.text());
|
|
340
|
+
} catch { /* unreachable robots.txt: everything allowed */ }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const results = [];
|
|
344
|
+
const robotsBlocked = [];
|
|
345
|
+
const queued = new Set([start.href]);
|
|
346
|
+
const queue = [start];
|
|
347
|
+
let linksTotal = 0;
|
|
348
|
+
let linksChecked = 0;
|
|
349
|
+
while (queue.length && results.length < maxPages) {
|
|
350
|
+
const target = queue.shift();
|
|
351
|
+
if (results.length > 0) {
|
|
352
|
+
if (!robotsAllows(robots, target.pathname)) { robotsBlocked.push(target.href); continue; }
|
|
353
|
+
if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
|
|
354
|
+
}
|
|
355
|
+
let result;
|
|
356
|
+
try {
|
|
357
|
+
result = await auditOnePage(context, target, { timeoutMs, linkCheckLimit, capture });
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (results.length === 0) throw error;
|
|
360
|
+
result = { url: target.href, title: "", controlsExamined: 0, linksChecked: { total: 0, checked: 0 }, links: [],
|
|
361
|
+
findings: [{ type: "broken_link", severity: "medium", title: `Page ${target.pathname} did not open: ${String(error?.message || error).slice(0, 120)}`, target: target.href, url: target.href, screen: target.pathname }] };
|
|
362
|
+
}
|
|
363
|
+
linksTotal += result.linksChecked.total;
|
|
364
|
+
linksChecked += result.linksChecked.checked;
|
|
365
|
+
const { links, ...pageResult } = result;
|
|
366
|
+
results.push(pageResult);
|
|
367
|
+
if (typeof onPage === "function") onPage(pageResult, results.length);
|
|
368
|
+
// A link the HEAD check already found broken is reported; opening it would only repeat the finding.
|
|
369
|
+
const broken = new Set(pageResult.findings.filter((f) => f.type === "broken_link").map((f) => f.target));
|
|
370
|
+
for (const link of links) {
|
|
371
|
+
if (!link.sameOrigin || queued.has(link.href) || broken.has(link.href) || queued.size >= maxPages * 4) continue;
|
|
372
|
+
let next;
|
|
373
|
+
try { next = new URL(link.href); } catch { continue; }
|
|
374
|
+
if (/\.(pdf|zip|png|jpe?g|gif|svg|webp|mp4|mp3|css|js|xml|ico)$/i.test(next.pathname)) continue;
|
|
375
|
+
queued.add(next.href);
|
|
376
|
+
queue.push(next);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const findings = results.flatMap((r) => r.findings);
|
|
381
|
+
const bySeverity = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
382
|
+
for (const f of findings) if (f.severity in bySeverity) bySeverity[f.severity] += 1;
|
|
383
|
+
const scope = auditScope({ linksChecked: { total: linksTotal, checked: linksChecked }, pages: results.length });
|
|
384
|
+
const summary = {
|
|
385
|
+
kind: "tapp-structural-audit",
|
|
386
|
+
readOnly: true,
|
|
387
|
+
url: start.href,
|
|
388
|
+
pagesAudited: results.length,
|
|
389
|
+
pagesQueued: queue.length,
|
|
390
|
+
robotsBlocked,
|
|
391
|
+
findingCounts: { ...bySeverity, total: findings.length },
|
|
392
|
+
checkedFor: scope.checkedFor,
|
|
393
|
+
notChecked: scope.notChecked,
|
|
394
|
+
pages: results,
|
|
395
|
+
capture: null,
|
|
396
|
+
};
|
|
397
|
+
if (capture) {
|
|
398
|
+
finalizeCapture(capture, { startUrl: start.href, pages: results, findings, robotsBlocked, linksChecked: { total: linksTotal, checked: linksChecked } });
|
|
399
|
+
summary.capture = { id: capture.id, path: capture.dir, report: path.join(capture.dir, "report.html") };
|
|
400
|
+
fs.writeFileSync(path.join(capture.dir, "audit.json"), JSON.stringify(summary, null, 2));
|
|
401
|
+
}
|
|
402
|
+
return summary;
|
|
403
|
+
} finally {
|
|
404
|
+
if (capture) capture.close();
|
|
405
|
+
await browser.close().catch(() => {});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// One page. The shape existing callers and tests rely on, plus `capture` when a captures dir is given.
|
|
410
|
+
export async function auditWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, device = "", viewport = "", linkCheckLimit = AUDIT_LINK_CHECK_LIMIT, capturesDir = "", captureId = "" }) {
|
|
411
|
+
const site = await auditWebSite({ url, pages: 1, timeoutMs, device, viewport, linkCheckLimit, capturesDir, captureId });
|
|
412
|
+
const page = site.pages[0];
|
|
413
|
+
return {
|
|
414
|
+
url: page.url,
|
|
415
|
+
title: page.title,
|
|
416
|
+
controlsExamined: page.controlsExamined,
|
|
417
|
+
linksChecked: page.linksChecked,
|
|
418
|
+
findings: page.findings,
|
|
419
|
+
checkedFor: site.checkedFor,
|
|
420
|
+
notChecked: site.notChecked,
|
|
421
|
+
capture: site.capture,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
@@ -21,7 +21,7 @@ import { createRequire } from "module";
|
|
|
21
21
|
import { execFileSync } from "child_process";
|
|
22
22
|
|
|
23
23
|
const CLICK_SETTLE_MS = 700;
|
|
24
|
-
const NAV_TIMEOUT_MS = 15_000;
|
|
24
|
+
export 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;
|
|
@@ -136,7 +136,7 @@ function cssAttrEscape(value) {
|
|
|
136
136
|
return String(value).replace(/["\\]/g, (c) => "\\" + c);
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
async function installWebListenerTracking(context) {
|
|
139
|
+
export async function installWebListenerTracking(context) {
|
|
140
140
|
await context.addInitScript(() => {
|
|
141
141
|
const key = Symbol.for("tapp.clickListeners");
|
|
142
142
|
// Delegated handlers live on document/window, not on the control, so an
|
|
@@ -626,7 +626,7 @@ export function webScreenRole(screen, inputs = []) {
|
|
|
626
626
|
return "screen";
|
|
627
627
|
}
|
|
628
628
|
|
|
629
|
-
function slug(s) {
|
|
629
|
+
export function slug(s) {
|
|
630
630
|
return String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "page";
|
|
631
631
|
}
|
|
632
632
|
|
|
@@ -1212,122 +1212,106 @@ export function auditFindingsFromControls(controls = []) {
|
|
|
1212
1212
|
return findings;
|
|
1213
1213
|
}
|
|
1214
1214
|
|
|
1215
|
-
//
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
const
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
const collectTriggers = (rules) => {
|
|
1250
|
-
for (const rule of rules || []) {
|
|
1251
|
-
// A plain style rule also exposes .cssRules now that CSS nesting is
|
|
1252
|
-
// supported — an empty list, which is truthy. Recursing on that and
|
|
1253
|
-
// skipping the rule would walk straight past every selector there is.
|
|
1254
|
-
if (rule.cssRules && rule.cssRules.length) collectTriggers(rule.cssRules);
|
|
1255
|
-
const selector = rule.selectorText;
|
|
1256
|
-
if (!selector || !REVEALS.test(rule.style?.cssText || "")) continue;
|
|
1257
|
-
for (const part of selector.split(",")) {
|
|
1258
|
-
const match = part.match(/^(.*?):(?:hover|focus-within|focus-visible|focus)\b(.+)$/);
|
|
1259
|
-
if (!match) continue;
|
|
1260
|
-
const trigger = match[1].trim();
|
|
1261
|
-
if (trigger && match[2].trim()) revealTriggers.push(trigger);
|
|
1262
|
-
}
|
|
1215
|
+
// The structural scan, run inside the page: every control that was never alive, WITHOUT
|
|
1216
|
+
// interacting with any. Each flagged element is tagged with data-tapp-audit="<n>" so evidence can be
|
|
1217
|
+
// shot of exactly that element (a bare tag-name selector would photograph the first match instead).
|
|
1218
|
+
export async function auditStructuralControls(page) {
|
|
1219
|
+
return page.evaluate(() => {
|
|
1220
|
+
const key = Symbol.for("tapp.clickListeners");
|
|
1221
|
+
const visible = (el) => {
|
|
1222
|
+
const style = window.getComputedStyle(el);
|
|
1223
|
+
return style.visibility !== "hidden" && style.display !== "none" && el.getClientRects().length > 0;
|
|
1224
|
+
};
|
|
1225
|
+
const name = (el) => (el.getAttribute("aria-label") || el.textContent || el.getAttribute("value") || "").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
1226
|
+
const selectorFor = (el) => el.id ? `#${el.id}` : el.getAttribute("data-testid") ? `[data-testid="${el.getAttribute("data-testid")}"]` : el.tagName.toLowerCase();
|
|
1227
|
+
// Some controls are wired in CSS, not JavaScript: a menu that opens while
|
|
1228
|
+
// its trigger is hovered or focused has no listener to find, and calling
|
|
1229
|
+
// it dead is wrong. Collect the selectors that some rule reacts to — the
|
|
1230
|
+
// trigger side of any `X:hover Y` / `X:focus-within Y` rule whose
|
|
1231
|
+
// declarations change whether Y can be seen. A rule that only restyles
|
|
1232
|
+
// the trigger itself (`button:hover { background: … }`) is not behaviour
|
|
1233
|
+
// and is ignored, which is why the descendant part must be present.
|
|
1234
|
+
const REVEALS = /(^|;)\s*(display|visibility|opacity|height|max-height|transform|pointer-events|clip-path)\s*:/i;
|
|
1235
|
+
const revealTriggers = [];
|
|
1236
|
+
const collectTriggers = (rules) => {
|
|
1237
|
+
for (const rule of rules || []) {
|
|
1238
|
+
// A plain style rule also exposes .cssRules now that CSS nesting is
|
|
1239
|
+
// supported — an empty list, which is truthy. Recursing on that and
|
|
1240
|
+
// skipping the rule would walk straight past every selector there is.
|
|
1241
|
+
if (rule.cssRules && rule.cssRules.length) collectTriggers(rule.cssRules);
|
|
1242
|
+
const selector = rule.selectorText;
|
|
1243
|
+
if (!selector || !REVEALS.test(rule.style?.cssText || "")) continue;
|
|
1244
|
+
for (const part of selector.split(",")) {
|
|
1245
|
+
const match = part.match(/^(.*?):(?:hover|focus-within|focus-visible|focus)\b(.+)$/);
|
|
1246
|
+
if (!match) continue;
|
|
1247
|
+
const trigger = match[1].trim();
|
|
1248
|
+
if (trigger && match[2].trim()) revealTriggers.push(trigger);
|
|
1263
1249
|
}
|
|
1264
|
-
};
|
|
1265
|
-
for (const sheet of document.styleSheets) {
|
|
1266
|
-
try { collectTriggers(sheet.cssRules); } catch { /* cross-origin sheet */ }
|
|
1267
1250
|
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1251
|
+
};
|
|
1252
|
+
for (const sheet of document.styleSheets) {
|
|
1253
|
+
try { collectTriggers(sheet.cssRules); } catch { /* cross-origin sheet */ }
|
|
1254
|
+
}
|
|
1255
|
+
const opensSomethingOnHoverOrFocus = (el) => revealTriggers.some((trigger) => {
|
|
1256
|
+
try { return el.matches(trigger) || Boolean(el.closest(trigger)); } catch { return false; }
|
|
1257
|
+
});
|
|
1271
1258
|
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
}
|
|
1259
|
+
// What a delegated handler on document/window claims. The dominant idiom is
|
|
1260
|
+
// `e.target.closest(SELECTOR)` / `.matches(SELECTOR)`, so pull those
|
|
1261
|
+
// selectors out and test controls against them. A handler we cannot read
|
|
1262
|
+
// this way (an outside-click closer, say) claims nothing and is ignored,
|
|
1263
|
+
// rather than excusing every unwired control on the page.
|
|
1264
|
+
const delegatedSelectors = [];
|
|
1265
|
+
for (const source of [
|
|
1266
|
+
...(document[Symbol.for("tapp.delegatedClickHandlers")] || []),
|
|
1267
|
+
...(window[Symbol.for("tapp.delegatedClickHandlers")] || []),
|
|
1268
|
+
]) {
|
|
1269
|
+
for (const m of String(source).matchAll(/\.(?:closest|matches)\(\s*["'`]([^"'`]+)["'`]/g)) {
|
|
1270
|
+
delegatedSelectors.push(m[1]);
|
|
1285
1271
|
}
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1272
|
+
}
|
|
1273
|
+
const claimedByDelegate = (el) => delegatedSelectors.some((selector) => {
|
|
1274
|
+
try { return el.matches(selector) || Boolean(el.closest(selector)); } catch { return false; }
|
|
1275
|
+
});
|
|
1289
1276
|
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
}
|
|
1308
|
-
continue;
|
|
1309
|
-
}
|
|
1310
|
-
// A button with nothing behind it: no listener on itself or an ancestor, no inline
|
|
1311
|
-
// handler, and not a submit inside a form.
|
|
1312
|
-
let wired = false;
|
|
1313
|
-
for (let node = el; node && node !== document.body; node = node.parentElement) {
|
|
1314
|
-
if ((node[key] && node[key].size > 0) || typeof node.onclick === "function" || node.hasAttribute("onclick")) { wired = true; break; }
|
|
1277
|
+
const dead = [];
|
|
1278
|
+
for (const el of document.querySelectorAll("a[href], button, [role=button], [aria-controls]")) {
|
|
1279
|
+
if (!visible(el)) continue;
|
|
1280
|
+
const label = name(el);
|
|
1281
|
+
const href = el.getAttribute("href");
|
|
1282
|
+
const ariaControls = el.getAttribute("aria-controls");
|
|
1283
|
+
if (ariaControls && !document.getElementById(ariaControls)) {
|
|
1284
|
+
dead.push({ element: el, kind: "dangling_aria_controls", label, selector: selectorFor(el), controls: ariaControls });
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
if (href != null) {
|
|
1288
|
+
if (href === "#" || href.trim() === "" || /^javascript:\s*(void\(0\))?;?$/i.test(href)) {
|
|
1289
|
+
dead.push({ element: el, kind: "placeholder_link", label, selector: selectorFor(el), href });
|
|
1290
|
+
} else if (href.startsWith("#")) {
|
|
1291
|
+
const fragment = decodeURIComponent(href.slice(1));
|
|
1292
|
+
const found = fragment && (document.getElementById(fragment) || document.getElementsByName(fragment).length > 0);
|
|
1293
|
+
if (!found) dead.push({ element: el, kind: "dead_anchor", label, selector: selectorFor(el), href, fragment });
|
|
1315
1294
|
}
|
|
1316
|
-
|
|
1317
|
-
if (!wired && el.closest("label")) wired = true; // a label drives its own control
|
|
1318
|
-
if (!wired && opensSomethingOnHoverOrFocus(el)) wired = true;
|
|
1319
|
-
if (!wired && claimedByDelegate(el)) wired = true;
|
|
1320
|
-
if (!wired) dead.push({ kind: "unwired_control", label, selector: selectorFor(el) });
|
|
1295
|
+
continue;
|
|
1321
1296
|
}
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1297
|
+
// A button with nothing behind it: no listener on itself or an ancestor, no inline
|
|
1298
|
+
// handler, and not a submit inside a form.
|
|
1299
|
+
let wired = false;
|
|
1300
|
+
for (let node = el; node && node !== document.body; node = node.parentElement) {
|
|
1301
|
+
if ((node[key] && node[key].size > 0) || typeof node.onclick === "function" || node.hasAttribute("onclick")) { wired = true; break; }
|
|
1302
|
+
}
|
|
1303
|
+
if (!wired && el.matches("button[type=submit], input[type=submit]") && el.closest("form")) wired = true;
|
|
1304
|
+
if (!wired && el.closest("label")) wired = true; // a label drives its own control
|
|
1305
|
+
if (!wired && opensSomethingOnHoverOrFocus(el)) wired = true;
|
|
1306
|
+
if (!wired && claimedByDelegate(el)) wired = true;
|
|
1307
|
+
if (!wired) dead.push({ element: el, kind: "unwired_control", label, selector: selectorFor(el) });
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
for (let i = 0; i < dead.length; i += 1) {
|
|
1311
|
+
if (dead[i].element) { dead[i].element.setAttribute("data-tapp-audit", String(i + 1)); dead[i].selector = `[data-tapp-audit="${i + 1}"]`; delete dead[i].element; }
|
|
1312
|
+
}
|
|
1313
|
+
return { dead, controlCount: document.querySelectorAll("a[href], button, [role=button]").length, title: document.title };
|
|
1314
|
+
});
|
|
1333
1315
|
}
|
|
1316
|
+
|
|
1317
|
+
export { auditWebPage, auditWebSite } from "./web-audit.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aarwitz/tapp",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.22",
|
|
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",
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
"mobile"
|
|
90
90
|
],
|
|
91
91
|
"scripts": {
|
|
92
|
-
"test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/focused-navigation.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-link-audit.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/presentation-contract.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js tests/feedback-triage-0919.test.js vscode-extension/test/bridge.test.js",
|
|
92
|
+
"test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/focused-navigation.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-link-audit.test.js tests/web-audit.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/presentation-contract.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js tests/feedback-triage-0919.test.js vscode-extension/test/bridge.test.js",
|
|
93
93
|
"test:browser-journey": "node --test tests/browser-journey.test.js",
|
|
94
94
|
"test:browser-native": "TAPP_RUN_NATIVE_BROWSER=1 node --test tests/browser-native-journey.test.js"
|
|
95
95
|
}
|
package/skills/tapp/SKILL.md
CHANGED
|
@@ -43,7 +43,7 @@ than starting another broad exploration. If `.tapp/ui-map.json` does not exist y
|
|
|
43
43
|
with `init . --explore`; source alone can locate a surface but cannot authorize unobserved taps.
|
|
44
44
|
Targets may be a repository path, Xcode container, `.app`, iOS bundle id, APK plus Android app id,
|
|
45
45
|
or owned HTTP(S) URL. Never explore a third-party web property without authorization: exploration
|
|
46
|
-
clicks and types.
|
|
46
|
+
clicks and types. For production or a site you do not own use `audit` / `tapp_audit` — read-only.
|
|
47
47
|
|
|
48
48
|
## Navigate like a source-connected expert
|
|
49
49
|
|