@aarwitz/tapp 0.17.21 → 0.17.23
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 +14 -5
- package/bin/tapp.js +63 -21
- package/mcp-server/src/index.js +76 -1
- package/mcp-server/src/web-audit.js +423 -0
- package/mcp-server/src/web-explorer.js +115 -117
- package/package.json +2 -2
- package/skills/tapp/SKILL.md +4 -4
- package/skills/tapp/references/commands.md +2 -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.23",
|
|
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.23",
|
|
28
28
|
"mcp"
|
|
29
29
|
],
|
|
30
30
|
"cwd": "${CLAUDE_PROJECT_DIR}"
|
package/AGENTS.md
CHANGED
|
@@ -68,6 +68,7 @@ installs, returns the bundle id) → `tapp_explore {appBundleId}`.
|
|
|
68
68
|
| "Find/reach this named screen or control" | `tapp_session_start` with `focus`, or `tapp_focus`; plain CLI: `tapp focus` | screenshot-by-screenshot wandering |
|
|
69
69
|
| "Tap through / drive / fill a form / log in" | `tapp_session_start` → `session_act` loop | repeated `open_app` calls (cold relaunch each time) |
|
|
70
70
|
| "Is my app broken? Find bugs" | `tapp_explore` — `appBundleId` for iOS, `androidAppId` for Android, `url` for owned web apps; returns an observation (findings + evidence), not a ship verdict — gate a merge with the CI gate (`tapp ci` CLI / the GitHub Action) + a contract | a manual session (exploration is autonomous) |
|
|
71
|
+
| "Is this live site / production / a prospect's site broken?" | `tapp_audit` (CLI `tapp audit <url> --pages N`) — read-only: dead controls, broken images/links/assets, failed requests, JS errors, mixed content, overflow; writes a capture with evidence | `tapp_explore` (it clicks, types and submits) |
|
|
71
72
|
| "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 |
|
|
72
73
|
| "What's on screen right now?" | `tapp_screenshot` / `tapp_ui_tree` | relaunching the app |
|
|
73
74
|
|
|
@@ -138,11 +139,17 @@ advisory). Report the finding counts and coverage; do not invent a scalar or a s
|
|
|
138
139
|
(e.g. `["--uitesting"]` if the app has a test bypass), and/or `appLaunchEnv` (e.g. a staging
|
|
139
140
|
backend URL). If the result shows `inputFieldsEncountered` and you have no credentials, **ask
|
|
140
141
|
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
|
|
142
|
+
- `tapp audit <url>` / `tapp_audit` is the read-only pass: it renders the page and reads it but never
|
|
143
|
+
clicks, types, submits, or hovers, so it is the one analysis safe to point at production or at a
|
|
144
|
+
site you do not own. It finds controls that were never alive (dead in-page anchors,
|
|
145
|
+
`aria-controls` naming nothing, placeholder links, buttons with no handler/form/link — which Flows
|
|
146
|
+
structurally cannot find, because nobody writes a test for a button they believe does nothing),
|
|
147
|
+
images that failed to load, same-origin assets answering 404, 5xx/failed requests and uncaught JS
|
|
148
|
+
exceptions during load, same-origin links answering 404/410/5xx, mixed content, and a page wider
|
|
149
|
+
than its viewport. `--pages N` crawls same-origin links (robots.txt honoured, `--delay` between
|
|
150
|
+
pages); several URLs or `--urls FILE` batch sites. It writes a capture in the explore layout
|
|
151
|
+
(screenshots, per-finding evidence, `report.html`). Exit 1 when it finds anything. No findings
|
|
152
|
+
means the page is served without structural defects — not that the product works.
|
|
146
153
|
- Credential surfaces are catalogue-only without credentials, on every platform: with no
|
|
147
154
|
`testEmail`/`testPassword` supplied, exploration records a login/signup form's fields but does
|
|
148
155
|
not type into them, submit them, or open recovery/third-party-auth flows — a bare-app run may
|
|
@@ -203,3 +210,5 @@ without a coding agent, model, subscription, or API key. AI generation and `asse
|
|
|
203
210
|
of retrying variations.
|
|
204
211
|
- When you show a screenshot as proof, say what it proves and what it doesn't ("login works;
|
|
205
212
|
I haven't verified checkout").
|
|
213
|
+
- A clean `tapp_audit` means the page is served without structural defects. It says nothing about
|
|
214
|
+
behaviour, because nothing was clicked; never report it as "the site works".
|
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
|
@@ -2512,6 +2512,19 @@ const server = new Server(
|
|
|
2512
2512
|
prompts: {},
|
|
2513
2513
|
tools: {},
|
|
2514
2514
|
},
|
|
2515
|
+
// Shown to the model by MCP clients at connect time: the routing rules that keep an agent from
|
|
2516
|
+
// reaching for the wrong tool, in the order the mistakes actually happen.
|
|
2517
|
+
instructions: [
|
|
2518
|
+
"Tapp gives you hands and eyes on real iOS, Android and web app surfaces. Pick the smallest operation:",
|
|
2519
|
+
"- see or screenshot one screen → tapp_open_app; what is on screen now → tapp_screenshot / tapp_ui_tree;",
|
|
2520
|
+
"- reach a named screen or drive a journey → tapp_session_start (+ focus) then session_act;",
|
|
2521
|
+
"- find bugs in an app or environment you own → tapp_explore (it clicks, types and submits; minutes);",
|
|
2522
|
+
"- check production or a site you do not own → tapp_audit only (read-only: never clicks);",
|
|
2523
|
+
"- a merge decision → the CI gate (tapp ci / GitHub Action), never exploration.",
|
|
2524
|
+
"Results are observations with findings, coverage and evidence paths — never a score or ship verdict.",
|
|
2525
|
+
"inconclusive:true is not a pass; say what blocked coverage. A clean audit says the page is served without structural defects, not that the product works.",
|
|
2526
|
+
"Open returned screenshot paths with your image tool before describing a screen. Never claim a flow works that you did not drive or see.",
|
|
2527
|
+
].join("\n"),
|
|
2515
2528
|
}
|
|
2516
2529
|
);
|
|
2517
2530
|
|
|
@@ -2742,7 +2755,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2742
2755
|
"verdict/releaseScore. Web separates deterministic findings from advisory sampled control probes. " +
|
|
2743
2756
|
"There is a coverage floor: if the app barely explored (crash on launch / sign-in wall) it reports " +
|
|
2744
2757
|
"`inconclusive` — absence of findings is NEVER a pass. For iOS the app must already be installed on a booted simulator (use tapp_list_simulators / " +
|
|
2745
|
-
"tapp_boot_simulator first). For web, only point it at an app/environment you own — it CLICKS things. " +
|
|
2758
|
+
"tapp_boot_simulator first). For web, only point it at an app/environment you own — it CLICKS things; for production or a site you do not own use tapp_audit (read-only). " +
|
|
2746
2759
|
"Tapp explores autonomously and does NOT pause to prompt for input — " +
|
|
2747
2760
|
"it fills forms with safe defaults. The result includes `inputFieldsEncountered` (and `inputHint`): if " +
|
|
2748
2761
|
"the app showed login/form fields and the user hasn't given you values, ASK THE USER what to enter (offer " +
|
|
@@ -2809,6 +2822,34 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2809
2822
|
},
|
|
2810
2823
|
},
|
|
2811
2824
|
},
|
|
2825
|
+
{
|
|
2826
|
+
name: "tapp_audit",
|
|
2827
|
+
title: "Audit (read-only)",
|
|
2828
|
+
description:
|
|
2829
|
+
"Read-only audit of a web page or site: renders it and reads it, NEVER clicks, types, submits or hovers — " +
|
|
2830
|
+
"the one analysis safe to point at production or at a site you do not own. Finds controls that were never " +
|
|
2831
|
+
"alive (dead in-page anchors, placeholder links, aria-controls naming nothing, buttons with no handler/form/link), " +
|
|
2832
|
+
"images that failed to load, same-origin assets answering 404, 5xx/failed requests and uncaught JS exceptions " +
|
|
2833
|
+
"during load, same-origin links answering 404/410/5xx, mixed content, and a page wider than its viewport. " +
|
|
2834
|
+
"pages > 1 crawls same-origin links breadth-first with robots.txt honoured. Writes a capture (screenshots, " +
|
|
2835
|
+
"per-finding evidence, report.html) like an explore run. Returns {kind:'tapp-structural-audit', readOnly:true, " +
|
|
2836
|
+
"pagesAudited, findingCounts, pages:[{url,title,controlsExamined,linksChecked,findings}], checkedFor, notChecked, capture}. " +
|
|
2837
|
+
"An observation, never a verdict; absence of findings says the page is served without structural defects, not that the product works.",
|
|
2838
|
+
inputSchema: {
|
|
2839
|
+
type: "object",
|
|
2840
|
+
properties: {
|
|
2841
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2842
|
+
url: { type: "string", description: "http(s) URL of the page to audit (the crawl start page when pages > 1)" },
|
|
2843
|
+
pages: { type: "integer", default: 1, minimum: 1, maximum: 200, description: "How many same-origin pages to audit at most (1 = this page only)" },
|
|
2844
|
+
delayMs: { type: "integer", default: 1000, minimum: 0, description: "Pause between pages when crawling — be polite to sites you do not own" },
|
|
2845
|
+
linkCheckLimit: { type: "integer", default: 30, minimum: 0, description: "Same-origin links to HEAD-check per page" },
|
|
2846
|
+
device: { type: "string", description: "Playwright device profile, e.g. 'iPhone 13' — audit the mobile rendering" },
|
|
2847
|
+
viewport: { type: "string", description: "WIDTHxHEIGHT viewport override" },
|
|
2848
|
+
capture: { type: "boolean", default: true, description: "Write a capture directory (screenshots, evidence, report.html)" },
|
|
2849
|
+
},
|
|
2850
|
+
required: ["url"],
|
|
2851
|
+
},
|
|
2852
|
+
},
|
|
2812
2853
|
{
|
|
2813
2854
|
name: "tapp_init",
|
|
2814
2855
|
title: "Inspect or explore a repository and create the Tapp application model and release plan",
|
|
@@ -3609,6 +3650,40 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3609
3650
|
return richResult(L.join("\n"), summary);
|
|
3610
3651
|
}
|
|
3611
3652
|
|
|
3653
|
+
if (name === "tapp_audit") {
|
|
3654
|
+
const unauthorized = ensureAuthorized(args);
|
|
3655
|
+
if (unauthorized) return unauthorized;
|
|
3656
|
+
if (!isNonEmptyString(args.url)) return errorResult("url is required");
|
|
3657
|
+
try {
|
|
3658
|
+
const { auditWebSite } = await import("./web-audit.js");
|
|
3659
|
+
const site = await auditWebSite({
|
|
3660
|
+
url: args.url,
|
|
3661
|
+
pages: asInteger(args.pages, 1),
|
|
3662
|
+
delayMs: asInteger(args.delayMs, 1000),
|
|
3663
|
+
linkCheckLimit: asInteger(args.linkCheckLimit, 30),
|
|
3664
|
+
device: isNonEmptyString(args.device) ? args.device : "",
|
|
3665
|
+
viewport: isNonEmptyString(args.viewport) ? args.viewport : "",
|
|
3666
|
+
capturesDir: args.capture === false ? "" : capturesDir,
|
|
3667
|
+
});
|
|
3668
|
+
const findings = site.pages.flatMap((p) => p.findings);
|
|
3669
|
+
const c = site.findingCounts;
|
|
3670
|
+
const lines = [
|
|
3671
|
+
`🔎 Read-only audit of ${site.url} — ${site.pagesAudited} page(s), nothing clicked`,
|
|
3672
|
+
findings.length
|
|
3673
|
+
? `${findings.length} defect(s): ${c.critical} critical · ${c.high} high · ${c.medium} medium · ${c.low} low`
|
|
3674
|
+
: "✅ Nothing broken observed. Not a pass on behaviour — nothing was exercised.",
|
|
3675
|
+
...findings.slice(0, 25).map((f) => ` ${SEV[f.severity] || "·"} [${f.type}] ${f.title}${f.screen ? ` — on ${f.screen}` : ""}`),
|
|
3676
|
+
...(findings.length > 25 ? [` … ${findings.length - 25} more in structuredContent`] : []),
|
|
3677
|
+
...(site.robotsBlocked?.length ? [`robots.txt kept ${site.robotsBlocked.length} page(s) out of the crawl`] : []),
|
|
3678
|
+
...(site.capture ? [`📁 ${site.capture.path}`, `📄 ${site.capture.report}`] : []),
|
|
3679
|
+
"Next: to exercise the controls (clicks, forms) run tapp_explore — only on an environment you own.",
|
|
3680
|
+
];
|
|
3681
|
+
return richResult(lines.join("\n"), site);
|
|
3682
|
+
} catch (error) {
|
|
3683
|
+
return errorResult(error.message || String(error));
|
|
3684
|
+
}
|
|
3685
|
+
}
|
|
3686
|
+
|
|
3612
3687
|
if (name === "tapp_explore" || name === "tapp_run_qa") { // tapp_run_qa: deprecated alias
|
|
3613
3688
|
const unauthorized = ensureAuthorized(args);
|
|
3614
3689
|
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
|
|
|
@@ -668,7 +668,9 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
668
668
|
// phone run legitimately disagree about which nav links exist, and a baseline diff across
|
|
669
669
|
// them must be able to say so instead of reporting "resolved".
|
|
670
670
|
emit("CONTEXT", { ...(String(device || "").trim() ? { device: String(device).trim() } : {}), viewport: captureProfile.viewport, deviceScaleFactor: captureProfile.deviceScaleFactor ?? 1 });
|
|
671
|
-
|
|
671
|
+
// Playwright records natively (no ffmpeg transcode needed, unlike simctl's .mov output) —
|
|
672
|
+
// written to a Playwright-chosen filename in outDir, finalized only on context.close().
|
|
673
|
+
const context = await browser.newContext({ ...captureProfile, recordVideo: { dir: outDir, size: captureProfile.viewport } });
|
|
672
674
|
await installWebListenerTracking(context);
|
|
673
675
|
if (watch) await installWebWatchUi(context);
|
|
674
676
|
const page = await context.newPage();
|
|
@@ -1127,7 +1129,10 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
1127
1129
|
outbound.total = outboundLinks.size;
|
|
1128
1130
|
outbound.mailtos = mailtoLinks.size;
|
|
1129
1131
|
if (outboundLinks.size) {
|
|
1130
|
-
|
|
1132
|
+
// A fresh, unrecorded context: outbound link checks navigate away to third-party sites and
|
|
1133
|
+
// must not inherit the exploration context's recordVideo (noise, not exploration evidence).
|
|
1134
|
+
const auditContext = await browser.newContext(captureProfile);
|
|
1135
|
+
const auditPage = await auditContext.newPage();
|
|
1131
1136
|
auditPage.setDefaultTimeout(8000);
|
|
1132
1137
|
const targets = [...outboundLinks.entries()].slice(0, OUTBOUND_LINK_LIMIT);
|
|
1133
1138
|
outbound.checked = targets.length;
|
|
@@ -1153,7 +1158,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
1153
1158
|
const phrase = webUnavailableShellPhrase(text);
|
|
1154
1159
|
if (phrase) issue("outbound_unavailable", "medium", `Outbound link returns 200 but shows "${phrase}": ${href.slice(0, 100)}`, meta.screen, href, meta.sourceUrl);
|
|
1155
1160
|
}
|
|
1156
|
-
await
|
|
1161
|
+
await auditContext.close().catch(() => {});
|
|
1157
1162
|
}
|
|
1158
1163
|
if (mailtoLinks.size) {
|
|
1159
1164
|
if (process.env.TAPP_ENFORCE_PUBLIC_EGRESS === "1") {
|
|
@@ -1181,6 +1186,15 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
1181
1186
|
: "frontier-drained";
|
|
1182
1187
|
emit("COMPLETE", { actions, screens: screenCount, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried, timedOut, stop, outbound });
|
|
1183
1188
|
fs.closeSync(markersFd);
|
|
1189
|
+
// Video finalizes on context.close(), before browser.close() tears down the recorder —
|
|
1190
|
+
// same "exploration.<ext>" name summarizeCapture() and the website replay already look for.
|
|
1191
|
+
const video = page.video();
|
|
1192
|
+
await context.close().catch(() => {});
|
|
1193
|
+
if (video) {
|
|
1194
|
+
await video.path()
|
|
1195
|
+
.then((p) => fs.renameSync(p, path.join(outDir, "exploration.webm")))
|
|
1196
|
+
.catch(() => {});
|
|
1197
|
+
}
|
|
1184
1198
|
await browser.close().catch(() => {});
|
|
1185
1199
|
}
|
|
1186
1200
|
return { markersPath, outDir, actions, screens: screenCount, seedRoutes: normalizedSeeds, seedTargets: normalizedTargets };
|
|
@@ -1212,122 +1226,106 @@ export function auditFindingsFromControls(controls = []) {
|
|
|
1212
1226
|
return findings;
|
|
1213
1227
|
}
|
|
1214
1228
|
|
|
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
|
-
}
|
|
1229
|
+
// The structural scan, run inside the page: every control that was never alive, WITHOUT
|
|
1230
|
+
// interacting with any. Each flagged element is tagged with data-tapp-audit="<n>" so evidence can be
|
|
1231
|
+
// shot of exactly that element (a bare tag-name selector would photograph the first match instead).
|
|
1232
|
+
export async function auditStructuralControls(page) {
|
|
1233
|
+
return page.evaluate(() => {
|
|
1234
|
+
const key = Symbol.for("tapp.clickListeners");
|
|
1235
|
+
const visible = (el) => {
|
|
1236
|
+
const style = window.getComputedStyle(el);
|
|
1237
|
+
return style.visibility !== "hidden" && style.display !== "none" && el.getClientRects().length > 0;
|
|
1238
|
+
};
|
|
1239
|
+
const name = (el) => (el.getAttribute("aria-label") || el.textContent || el.getAttribute("value") || "").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
1240
|
+
const selectorFor = (el) => el.id ? `#${el.id}` : el.getAttribute("data-testid") ? `[data-testid="${el.getAttribute("data-testid")}"]` : el.tagName.toLowerCase();
|
|
1241
|
+
// Some controls are wired in CSS, not JavaScript: a menu that opens while
|
|
1242
|
+
// its trigger is hovered or focused has no listener to find, and calling
|
|
1243
|
+
// it dead is wrong. Collect the selectors that some rule reacts to — the
|
|
1244
|
+
// trigger side of any `X:hover Y` / `X:focus-within Y` rule whose
|
|
1245
|
+
// declarations change whether Y can be seen. A rule that only restyles
|
|
1246
|
+
// the trigger itself (`button:hover { background: … }`) is not behaviour
|
|
1247
|
+
// and is ignored, which is why the descendant part must be present.
|
|
1248
|
+
const REVEALS = /(^|;)\s*(display|visibility|opacity|height|max-height|transform|pointer-events|clip-path)\s*:/i;
|
|
1249
|
+
const revealTriggers = [];
|
|
1250
|
+
const collectTriggers = (rules) => {
|
|
1251
|
+
for (const rule of rules || []) {
|
|
1252
|
+
// A plain style rule also exposes .cssRules now that CSS nesting is
|
|
1253
|
+
// supported — an empty list, which is truthy. Recursing on that and
|
|
1254
|
+
// skipping the rule would walk straight past every selector there is.
|
|
1255
|
+
if (rule.cssRules && rule.cssRules.length) collectTriggers(rule.cssRules);
|
|
1256
|
+
const selector = rule.selectorText;
|
|
1257
|
+
if (!selector || !REVEALS.test(rule.style?.cssText || "")) continue;
|
|
1258
|
+
for (const part of selector.split(",")) {
|
|
1259
|
+
const match = part.match(/^(.*?):(?:hover|focus-within|focus-visible|focus)\b(.+)$/);
|
|
1260
|
+
if (!match) continue;
|
|
1261
|
+
const trigger = match[1].trim();
|
|
1262
|
+
if (trigger && match[2].trim()) revealTriggers.push(trigger);
|
|
1263
1263
|
}
|
|
1264
|
-
};
|
|
1265
|
-
for (const sheet of document.styleSheets) {
|
|
1266
|
-
try { collectTriggers(sheet.cssRules); } catch { /* cross-origin sheet */ }
|
|
1267
1264
|
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1265
|
+
};
|
|
1266
|
+
for (const sheet of document.styleSheets) {
|
|
1267
|
+
try { collectTriggers(sheet.cssRules); } catch { /* cross-origin sheet */ }
|
|
1268
|
+
}
|
|
1269
|
+
const opensSomethingOnHoverOrFocus = (el) => revealTriggers.some((trigger) => {
|
|
1270
|
+
try { return el.matches(trigger) || Boolean(el.closest(trigger)); } catch { return false; }
|
|
1271
|
+
});
|
|
1271
1272
|
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
}
|
|
1273
|
+
// What a delegated handler on document/window claims. The dominant idiom is
|
|
1274
|
+
// `e.target.closest(SELECTOR)` / `.matches(SELECTOR)`, so pull those
|
|
1275
|
+
// selectors out and test controls against them. A handler we cannot read
|
|
1276
|
+
// this way (an outside-click closer, say) claims nothing and is ignored,
|
|
1277
|
+
// rather than excusing every unwired control on the page.
|
|
1278
|
+
const delegatedSelectors = [];
|
|
1279
|
+
for (const source of [
|
|
1280
|
+
...(document[Symbol.for("tapp.delegatedClickHandlers")] || []),
|
|
1281
|
+
...(window[Symbol.for("tapp.delegatedClickHandlers")] || []),
|
|
1282
|
+
]) {
|
|
1283
|
+
for (const m of String(source).matchAll(/\.(?:closest|matches)\(\s*["'`]([^"'`]+)["'`]/g)) {
|
|
1284
|
+
delegatedSelectors.push(m[1]);
|
|
1285
1285
|
}
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1286
|
+
}
|
|
1287
|
+
const claimedByDelegate = (el) => delegatedSelectors.some((selector) => {
|
|
1288
|
+
try { return el.matches(selector) || Boolean(el.closest(selector)); } catch { return false; }
|
|
1289
|
+
});
|
|
1289
1290
|
|
|
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; }
|
|
1291
|
+
const dead = [];
|
|
1292
|
+
for (const el of document.querySelectorAll("a[href], button, [role=button], [aria-controls]")) {
|
|
1293
|
+
if (!visible(el)) continue;
|
|
1294
|
+
const label = name(el);
|
|
1295
|
+
const href = el.getAttribute("href");
|
|
1296
|
+
const ariaControls = el.getAttribute("aria-controls");
|
|
1297
|
+
if (ariaControls && !document.getElementById(ariaControls)) {
|
|
1298
|
+
dead.push({ element: el, kind: "dangling_aria_controls", label, selector: selectorFor(el), controls: ariaControls });
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
if (href != null) {
|
|
1302
|
+
if (href === "#" || href.trim() === "" || /^javascript:\s*(void\(0\))?;?$/i.test(href)) {
|
|
1303
|
+
dead.push({ element: el, kind: "placeholder_link", label, selector: selectorFor(el), href });
|
|
1304
|
+
} else if (href.startsWith("#")) {
|
|
1305
|
+
const fragment = decodeURIComponent(href.slice(1));
|
|
1306
|
+
const found = fragment && (document.getElementById(fragment) || document.getElementsByName(fragment).length > 0);
|
|
1307
|
+
if (!found) dead.push({ element: el, kind: "dead_anchor", label, selector: selectorFor(el), href, fragment });
|
|
1315
1308
|
}
|
|
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) });
|
|
1309
|
+
continue;
|
|
1321
1310
|
}
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1311
|
+
// A button with nothing behind it: no listener on itself or an ancestor, no inline
|
|
1312
|
+
// handler, and not a submit inside a form.
|
|
1313
|
+
let wired = false;
|
|
1314
|
+
for (let node = el; node && node !== document.body; node = node.parentElement) {
|
|
1315
|
+
if ((node[key] && node[key].size > 0) || typeof node.onclick === "function" || node.hasAttribute("onclick")) { wired = true; break; }
|
|
1316
|
+
}
|
|
1317
|
+
if (!wired && el.matches("button[type=submit], input[type=submit]") && el.closest("form")) wired = true;
|
|
1318
|
+
if (!wired && el.closest("label")) wired = true; // a label drives its own control
|
|
1319
|
+
if (!wired && opensSomethingOnHoverOrFocus(el)) wired = true;
|
|
1320
|
+
if (!wired && claimedByDelegate(el)) wired = true;
|
|
1321
|
+
if (!wired) dead.push({ element: el, kind: "unwired_control", label, selector: selectorFor(el) });
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
for (let i = 0; i < dead.length; i += 1) {
|
|
1325
|
+
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; }
|
|
1326
|
+
}
|
|
1327
|
+
return { dead, controlCount: document.querySelectorAll("a[href], button, [role=button]").length, title: document.title };
|
|
1328
|
+
});
|
|
1333
1329
|
}
|
|
1330
|
+
|
|
1331
|
+
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.23",
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: tapp
|
|
3
|
-
description: Use Tapp to see, drive, explore, and verify real application surfaces on iOS simulators, Android emulators/devices, or the web. Use when a user asks an agent to test an app or UI change, find bugs, inspect or screenshot a screen, exercise a journey, create a replayable flow, gather release evidence,
|
|
3
|
+
description: Use Tapp to see, drive, explore, and verify real application surfaces on iOS simulators, Android emulators/devices, or the web. Use when a user asks an agent to test an app or UI change, find bugs, inspect or screenshot a screen, exercise a journey, create a replayable flow, gather release evidence, run the deterministic Tapp gate, or check a live website, production, or a site they do not own without touching it (read-only audit). Also use when the user mentions Tapp, @aarwitz/tapp, tapp_* tools, .tapp artifacts, or asks whether agent-authored UI actually works.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Tapp
|
|
@@ -16,7 +16,8 @@ screen or journey works from source inspection alone.
|
|
|
16
16
|
| Inspect controls on the current screen | `tree` / `tapp_ui_tree` |
|
|
17
17
|
| Reach a named screen/control | `focus` / `tapp_focus` (source + observed UI Map fast path) |
|
|
18
18
|
| Drive a specific journey | MCP session start → focus or act → end |
|
|
19
|
-
| Find bugs autonomously | `explore` / `tapp_explore` |
|
|
19
|
+
| Find bugs autonomously (owned app/environment; it clicks) | `explore` / `tapp_explore` |
|
|
20
|
+
| Check production or a site you do not own (never clicks) | `audit` / `tapp_audit` |
|
|
20
21
|
| Preserve a journey | record and save a Flow; replay it deterministically |
|
|
21
22
|
| Decide whether a merge passes policy | `ci`; exploration never decides this |
|
|
22
23
|
|
|
@@ -42,8 +43,7 @@ For a focused request in an already-grounded repository, use the requested targe
|
|
|
42
43
|
than starting another broad exploration. If `.tapp/ui-map.json` does not exist yet, ground it once
|
|
43
44
|
with `init . --explore`; source alone can locate a surface but cannot authorize unobserved taps.
|
|
44
45
|
Targets may be a repository path, Xcode container, `.app`, iOS bundle id, APK plus Android app id,
|
|
45
|
-
or owned HTTP(S) URL. Never explore a third-party web property
|
|
46
|
-
clicks and types.
|
|
46
|
+
or owned HTTP(S) URL. Never explore a third-party web property: exploration clicks and types.
|
|
47
47
|
|
|
48
48
|
## Navigate like a source-connected expert
|
|
49
49
|
|
|
@@ -11,7 +11,8 @@ npx -y @aarwitz/tapp@latest explore [target]
|
|
|
11
11
|
npx -y @aarwitz/tapp@latest focus "Save storefront settings visible above keyboard" [target]
|
|
12
12
|
npx -y @aarwitz/tapp@latest open [target]
|
|
13
13
|
npx -y @aarwitz/tapp@latest tree [target] --json
|
|
14
|
-
npx -y @aarwitz/tapp@latest audit https://example.com --json # read-only: never clicks
|
|
14
|
+
npx -y @aarwitz/tapp@latest audit https://example.com --pages 5 --json # read-only: never clicks; safe on production or third-party sites
|
|
15
|
+
# several URLs or --urls FILE batch sites · --device "iPhone 13" audits the mobile rendering · --json FILE writes the result · exit 1 = defects found
|
|
15
16
|
npx -y @aarwitz/tapp@latest shot
|
|
16
17
|
npx -y @aarwitz/tapp@latest report latest
|
|
17
18
|
npx -y @aarwitz/tapp@latest doctor
|