@aarwitz/tapp 0.17.5 โ 0.17.6
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/README.md +2 -2
- package/docs/scenarios.md +1 -1
- package/mcp-server/src/ci-report.js +15 -4
- package/mcp-server/src/html-report.js +7 -0
- package/mcp-server/src/index.js +7 -5
- package/mcp-server/src/report.js +19 -7
- package/mcp-server/src/web-explorer.js +62 -44
- package/package.json +1 -1
- package/scripts/ci-gate.sh +44 -8
- package/scripts/platform-gate.js +11 -5
|
@@ -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.6",
|
|
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.6",
|
|
28
28
|
"mcp"
|
|
29
29
|
],
|
|
30
30
|
"cwd": "${CLAUDE_PROJECT_DIR}"
|
package/README.md
CHANGED
|
@@ -348,7 +348,7 @@ jobs:
|
|
|
348
348
|
timeout-minutes: 45
|
|
349
349
|
steps:
|
|
350
350
|
- uses: actions/checkout@v4
|
|
351
|
-
- uses: aarwitz/tapp@v0.17.
|
|
351
|
+
- uses: aarwitz/tapp@v0.17.6 # or pin the reviewed release commit SHA
|
|
352
352
|
with:
|
|
353
353
|
project: MyApp.xcodeproj # or MyApp.xcworkspace
|
|
354
354
|
scheme: MyApp
|
|
@@ -398,7 +398,7 @@ Android CI runs on Linux with an emulator/device already connected. The Action c
|
|
|
398
398
|
or accept a prebuilt one:
|
|
399
399
|
|
|
400
400
|
```yaml
|
|
401
|
-
- uses: aarwitz/tapp@v0.17.
|
|
401
|
+
- uses: aarwitz/tapp@v0.17.6 # or pin the reviewed release commit SHA
|
|
402
402
|
with:
|
|
403
403
|
platform: android
|
|
404
404
|
android-app-id: com.acme.app
|
package/docs/scenarios.md
CHANGED
|
@@ -74,7 +74,7 @@ tapp ci --platform web --url http://127.0.0.1:4180 \
|
|
|
74
74
|
GitHub Action:
|
|
75
75
|
|
|
76
76
|
```yaml
|
|
77
|
-
- uses: aarwitz/tapp@v0.17.
|
|
77
|
+
- uses: aarwitz/tapp@v0.17.6 # or pin the reviewed release commit SHA
|
|
78
78
|
with:
|
|
79
79
|
platform: web
|
|
80
80
|
url: http://127.0.0.1:4180
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// [--pr-plan <plan.json>] # selected PR contract execution manifest
|
|
17
17
|
// [--project-dir <repo> --maintenance-url <url>]
|
|
18
18
|
// # optional disposable web patch replay
|
|
19
|
-
// [--fail-on <gate|absolute|any>]
|
|
19
|
+
// [--fail-on <gate|absolute|any|high|medium>] # default: gate (web CLI defaults to medium)
|
|
20
20
|
//
|
|
21
21
|
// Gate policy (--fail-on):
|
|
22
22
|
// gate fail when the run introduced NEW high/critical findings vs. the baseline
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
// absolute fail on any current-run deterministic findings-block (critical / risk threshold) or an
|
|
26
26
|
// inconclusive run, or any failed suite โ no baseline needed.
|
|
27
27
|
// any fail on any finding at all, or any suite failure. Strictest.
|
|
28
|
+
// high fail on any deterministic finding at high/critical severity (absolute; no baseline).
|
|
29
|
+
// medium fail on any deterministic finding at medium severity or above. The `tapp ci` CLI
|
|
30
|
+
// defaults web targets to this: on a website, a broken link IS the release blocker.
|
|
28
31
|
import fs from "fs";
|
|
29
32
|
import path from "node:path";
|
|
30
33
|
import { execSync } from "node:child_process";
|
|
@@ -61,8 +64,8 @@ function parseArgs(argv) {
|
|
|
61
64
|
console.error("Required: --markers <ocqa-markers.txt>");
|
|
62
65
|
process.exit(2);
|
|
63
66
|
}
|
|
64
|
-
if (!["gate", "absolute", "any"].includes(args.failOn)) {
|
|
65
|
-
console.error(`--fail-on must be gate|absolute|any, got: ${args.failOn}`);
|
|
67
|
+
if (!["gate", "absolute", "any", "high", "medium"].includes(args.failOn)) {
|
|
68
|
+
console.error(`--fail-on must be gate|absolute|any|high|medium, got: ${args.failOn}`);
|
|
66
69
|
process.exit(2);
|
|
67
70
|
}
|
|
68
71
|
return args;
|
|
@@ -477,7 +480,15 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
|
|
|
477
480
|
}
|
|
478
481
|
lines.push("");
|
|
479
482
|
const badge = GATE_BADGE[gate.outcome] || (gate.failed ? "๐ด FAIL" : "๐ข PASS");
|
|
480
|
-
|
|
483
|
+
// A PASS must state what it chose to ignore โ a green banner over known findings without
|
|
484
|
+
// saying so is exactly the dishonest verdict this product refuses to render.
|
|
485
|
+
const ignored = gate.outcome === "pass" && report.deterministicFindingCounts
|
|
486
|
+
? ["high", "medium", "low"].map((sev) => [sev, report.deterministicFindingCounts[sev] || 0]).filter(([, n]) => n > 0)
|
|
487
|
+
: [];
|
|
488
|
+
const ignoredNote = ignored.length
|
|
489
|
+
? ` โ ${ignored.reduce((n, [, c]) => n + c, 0)} deterministic finding(s) below the fail threshold (${ignored.map(([sev, n]) => `${n} ${sev}`).join(", ")})`
|
|
490
|
+
: "";
|
|
491
|
+
lines.push(`**Gate (${gate.policy}): ${badge}**${gate.reasons.length ? " โ " + gate.reasons.join("; ") : ""}${ignoredNote}`);
|
|
481
492
|
// The gate is only authoritative about what it actually ran โ record the scope explicitly.
|
|
482
493
|
const rev = gate.revision?.sha ? `${String(gate.revision.sha).slice(0, 12)}${gate.revision.dirty ? "-dirty" : ""}` : "unknown";
|
|
483
494
|
lines.push(`_target: ${gate.target || "โ"} ยท revision: ${rev} ยท policy: ${gate.policy} v${gate.policyVersion || "?"}_`);
|
|
@@ -125,6 +125,9 @@ ${r.conditionsNotReached?.length ? `<div><h2>Conditions not reached</h2><ul>${sc
|
|
|
125
125
|
.scope ul { margin-top: 0; padding-left: 1.2rem; }
|
|
126
126
|
.sev { color: #fff; border-radius: 4px; padding: 0.05rem 0.45rem; font-size: 0.78rem; font-weight: 600; margin-right: 0.4rem; }
|
|
127
127
|
ul.findings { padding-left: 1.1rem; } ul.findings li { margin-bottom: 0.6rem; }
|
|
128
|
+
table.trace { border-collapse: collapse; font-size: 0.88rem; width: 100%; }
|
|
129
|
+
table.trace th, table.trace td { text-align: left; padding: 0.25rem 0.7rem 0.25rem 0; border-bottom: 1px solid #eceef1; }
|
|
130
|
+
table.trace th { color: #57606a; font-weight: 600; }
|
|
128
131
|
.ai { color: #57606a; font-size: 0.88rem; margin: 0.15rem 0 0 0.2rem; }
|
|
129
132
|
.dim { color: #57606a; }
|
|
130
133
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.8rem; }
|
|
@@ -144,6 +147,10 @@ ${scopeHtml}
|
|
|
144
147
|
<ul class="findings">
|
|
145
148
|
${findingsHtml}
|
|
146
149
|
</ul>
|
|
150
|
+
${Array.isArray(r.trace) && r.trace.length ? `<h2>Action trace โ what was done, in order</h2>
|
|
151
|
+
<table class="trace"><thead><tr><th>t</th><th>action</th><th>target</th><th>screen</th></tr></thead><tbody>
|
|
152
|
+
${r.trace.map((a) => `<tr><td>${typeof a.t === "number" ? (a.t / 1000).toFixed(1) + "s" : "โ"}</td><td>${esc(a.type || "")}</td><td>${esc(a.target || "")}</td><td>${esc(a.screen || "")}</td></tr>`).join("\n")}
|
|
153
|
+
</tbody></table>` : ""}
|
|
147
154
|
<h2>Evidence โ every screen explored</h2>
|
|
148
155
|
<div class="grid">
|
|
149
156
|
${shotsHtml || "<p class='dim'>No screenshots captured.</p>"}
|
package/mcp-server/src/index.js
CHANGED
|
@@ -1740,13 +1740,15 @@ function elementBreakdown(elements) {
|
|
|
1740
1740
|
/** Scannable "Read screen X โ N elements (...)" readout, plus the tappable/typeable controls. */
|
|
1741
1741
|
export function formatScreen(screenTitle, elements) {
|
|
1742
1742
|
const els = elements || [];
|
|
1743
|
-
const interactable = els.filter((e) => e.isEnabled !== false && (String(e.type).includes("Button") || String(e.type).includes("rawValue: 9") || String(e.type).includes("TextField") || String(e.type).includes("rawValue: 49") || String(e.type).includes("rawValue: 50") || String(e.type).includes("Cell") || String(e.type).includes("rawValue: 75")));
|
|
1744
|
-
const
|
|
1743
|
+
const interactable = els.filter((e) => e.isEnabled !== false && (String(e.type).includes("Button") || String(e.type).includes("Link") || String(e.type).includes("rawValue: 9") || String(e.type).includes("TextField") || String(e.type).includes("rawValue: 49") || String(e.type).includes("rawValue: 50") || String(e.type).includes("Cell") || String(e.type).includes("rawValue: 75")));
|
|
1744
|
+
const allLabels = interactable
|
|
1745
1745
|
.map((e) => (e.label || e.identifier || "").trim())
|
|
1746
|
-
.filter((s) => s && s.length <= 40 && !s.includes("."))
|
|
1747
|
-
|
|
1746
|
+
.filter((s) => s && s.length <= 40 && !s.includes("."));
|
|
1747
|
+
const labels = allLabels.slice(0, 8);
|
|
1748
1748
|
const L = [`๐ณ Read screen **${screenTitle || "Unknown"}** โ ${els.length} elements (${elementBreakdown(els)})`];
|
|
1749
|
-
|
|
1749
|
+
// A silently cut list reads as complete; say when it isn't.
|
|
1750
|
+
if (labels.length) L.push("", "**Controls:** " + labels.map((l) => `\`${l}\``).join(" ยท ")
|
|
1751
|
+
+ (allLabels.length > labels.length ? ` ยท โฆ and ${allLabels.length - labels.length} more` : ""));
|
|
1750
1752
|
return L.join("\n");
|
|
1751
1753
|
}
|
|
1752
1754
|
|
package/mcp-server/src/report.js
CHANGED
|
@@ -295,6 +295,7 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
295
295
|
: timeBudgetExhausted ? "time-budget-exhausted"
|
|
296
296
|
: !coverageFloorMet ? "coverage-floor-not-met"
|
|
297
297
|
: driverStop === "frontier-drained" ? "no-unexplored-in-scope-controls"
|
|
298
|
+
: driverStop === "probe-cap" ? "probe-cap-reached"
|
|
298
299
|
: "completed";
|
|
299
300
|
|
|
300
301
|
const headline = timeBudgetExhausted
|
|
@@ -320,12 +321,12 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
320
321
|
"page errors (uncaught exceptions)", "failed/5xx requests", "broken links (404, same-origin crawl)",
|
|
321
322
|
"placeholder links and anchors with no destination", "sampled dead-button probes (advisory)", "error text on pages", "load timeouts",
|
|
322
323
|
];
|
|
323
|
-
if (outbound &&
|
|
324
|
+
if (outbound && outbound.total) {
|
|
324
325
|
checkedFor.push(outbound.total > outbound.checked
|
|
325
|
-
? `outbound link reachability โ DNS ยท HTTP ยท unavailable-shell heuristic (first ${outbound.checked} of ${outbound.total})`
|
|
326
|
-
: "outbound link reachability (DNS ยท HTTP ยท unavailable-shell heuristic)");
|
|
327
|
-
if (outbound.mailtos) checkedFor.push("mailto address domains (MX/A records)");
|
|
326
|
+
? `outbound link reachability โ browser-rendered ยท DNS ยท HTTP ยท unavailable-shell heuristic (first ${outbound.checked} of ${outbound.total})`
|
|
327
|
+
: "outbound link reachability (browser-rendered ยท DNS ยท HTTP ยท unavailable-shell heuristic)");
|
|
328
328
|
}
|
|
329
|
+
if (outbound?.mailtos && !outbound.mailtoSkipped) checkedFor.push("mailto address domains (MX/A records)");
|
|
329
330
|
notChecked = [
|
|
330
331
|
"app-specific business logic (cover with Flows: record or generate, then assert)",
|
|
331
332
|
"content and claim accuracy (including copy versus API data)",
|
|
@@ -335,8 +336,8 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
335
336
|
"only the first few visible buttons per page are probed (web beta)",
|
|
336
337
|
"content & reachability regressions require a baseline",
|
|
337
338
|
];
|
|
338
|
-
if (outbound?.
|
|
339
|
-
|
|
339
|
+
if (outbound?.mailtoSkipped === "egress-policy") notChecked.push("mailto address domains (MX lookups are skipped by the public-egress policy)");
|
|
340
|
+
if (!outbound || (!outbound.total && !outbound.mailtos)) conditionsNotReached.push("outbound links (none encountered this run)");
|
|
340
341
|
if (inputFieldsEncountered.length && !loginAttempted) {
|
|
341
342
|
notChecked.push(`form submission (${inputFieldsEncountered.reduce((n, s) => n + s.fields.length, 0)} field(s) catalogued, none submitted)`);
|
|
342
343
|
}
|
|
@@ -543,7 +544,11 @@ export function computeRegression(current, baseline) {
|
|
|
543
544
|
// the evidence." Exit codes are the CI contract; precedence is fail > inconclusive > pass.
|
|
544
545
|
export const GATE_EXIT = { pass: 0, fail: 1, error: 2, inconclusive: 3 };
|
|
545
546
|
// Bump when the gate's decision semantics change (NOT the npm version). Recorded on every GateRun.
|
|
546
|
-
|
|
547
|
+
// v3: `failOn` gained severity thresholds ("high" | "medium") that block on any deterministic-tier
|
|
548
|
+
// finding at or above that severity, and the CLI defaults web targets to `medium` โ a 404 in the
|
|
549
|
+
// nav is the release blocker on a website, and a field-tested green PASS over six deterministic
|
|
550
|
+
// findings was exactly the dishonest verdict this product refuses to render.
|
|
551
|
+
export const GATE_POLICY_VERSION = "3";
|
|
547
552
|
|
|
548
553
|
// Pure gate evaluator: frozen evidence + policy โ a GateRun decision. Extracted verbatim from the
|
|
549
554
|
// former inline logic in ci-report.js so the `[char]` characterization tests keep passing โ the
|
|
@@ -592,6 +597,13 @@ export function evaluateGate({ report, regression = null, flows = [], scenarios
|
|
|
592
597
|
if (report.findingCounts.total > 0) fail(`${report.findingCounts.total} finding(s) (fail-on: any)`);
|
|
593
598
|
// "any" is the strictest policy โ an inconclusive run (evidence not obtained) must never pass it.
|
|
594
599
|
if (report.inconclusive) inconclusive("run was inconclusive (coverage floor not met)");
|
|
600
|
+
} else if (failOn === "medium" || failOn === "high") {
|
|
601
|
+
// Severity thresholds are absolute over the deterministic tier: anything at or above the
|
|
602
|
+
// requested severity blocks, baseline or not. Sampled/advisory findings never participate.
|
|
603
|
+
const counts = report.deterministicFindingCounts || {};
|
|
604
|
+
const blocking = (counts.critical || 0) + (counts.high || 0) + (failOn === "medium" ? counts.medium || 0 : 0);
|
|
605
|
+
if (blocking > 0) fail(`${blocking} deterministic finding(s) at or above ${failOn} severity (fail-on: ${failOn})`);
|
|
606
|
+
if (report.inconclusive) inconclusive("run was inconclusive (coverage floor not met)");
|
|
595
607
|
} else if (failOn === "absolute" || (failOn === "gate" && !regression)) {
|
|
596
608
|
if (findingsBlock(report.deterministicFindingCounts, { inconclusive: report.inconclusive })) fail("blocking deterministic finding(s)");
|
|
597
609
|
if (report.inconclusive) inconclusive("run was inconclusive (coverage floor not met)");
|
|
@@ -466,23 +466,26 @@ export async function inspectWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, screensh
|
|
|
466
466
|
}
|
|
467
467
|
const observed = await page.evaluate(() => {
|
|
468
468
|
const visible = (element) => element.offsetParent !== null;
|
|
469
|
-
const controls = [...document.querySelectorAll("button, a[href], input, textarea, select, [role=button], [role=tab], [role=checkbox], [role=switch]")]
|
|
469
|
+
const controls = [...document.querySelectorAll("button, a[href], input, textarea, select, summary, [role=button], [role=tab], [role=checkbox], [role=switch]")]
|
|
470
470
|
.filter((element) => element.type !== "hidden" && visible(element))
|
|
471
471
|
.slice(0, 80)
|
|
472
472
|
.map((element) => {
|
|
473
473
|
const tag = element.tagName.toLowerCase();
|
|
474
474
|
const field = ["input", "textarea", "select"].includes(tag);
|
|
475
475
|
const secure = element.type === "password";
|
|
476
|
-
const role = element.getAttribute("role") || (tag === "a" ? "link" : tag === "button" ? "button" : "");
|
|
476
|
+
const role = element.getAttribute("role") || (tag === "a" ? "link" : tag === "button" || tag === "summary" ? "button" : "");
|
|
477
477
|
const label = (element.labels?.[0]?.textContent || element.getAttribute("aria-label") || element.textContent || element.placeholder || element.name || element.id || "").trim().slice(0, 120);
|
|
478
|
+
const box = element.getBoundingClientRect();
|
|
478
479
|
return {
|
|
479
|
-
type
|
|
480
|
+
// `type` stays faithful so an agent follows links and presses buttons, not vice versa.
|
|
481
|
+
type: field ? (secure ? "SecureTextField" : "TextField") : tag === "a" ? "Link" : "Button",
|
|
480
482
|
role,
|
|
481
483
|
label,
|
|
482
484
|
identifier: element.id || element.getAttribute("data-testid") || element.getAttribute("aria-label") || "",
|
|
483
485
|
isEnabled: !element.disabled && element.getAttribute("aria-disabled") !== "true",
|
|
484
486
|
hittable: true,
|
|
485
487
|
secure,
|
|
488
|
+
rect: { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) },
|
|
486
489
|
};
|
|
487
490
|
})
|
|
488
491
|
.filter((control) => control.label || control.identifier);
|
|
@@ -581,9 +584,12 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
581
584
|
|
|
582
585
|
const deadline = Date.now() + timeoutSec * 1000;
|
|
583
586
|
const issues = []; // emitted immediately; kept for counting only
|
|
584
|
-
const issue = (type, severity, title, screen, target) => {
|
|
587
|
+
const issue = (type, severity, title, screen, target, sourceUrl) => {
|
|
585
588
|
issues.push(type);
|
|
586
|
-
|
|
589
|
+
// Findings belong to the page that CARRIED the defect. Async detectors default to the
|
|
590
|
+
// current page; the post-crawl outbound audit passes the link's source page explicitly so
|
|
591
|
+
// a bad footer link is never attributed to whatever page happened to be visited last.
|
|
592
|
+
const pageUrl = sourceUrl || page.url();
|
|
587
593
|
emit("ISSUE", { type, severity, title, screen, ...(target ? { target } : {}), ...(pageUrl && pageUrl !== "about:blank" ? { url: pageUrl } : {}) });
|
|
588
594
|
};
|
|
589
595
|
|
|
@@ -628,6 +634,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
628
634
|
const outboundLinks = new Map(); // href โ { label, screen }
|
|
629
635
|
const mailtoLinks = new Map(); // address โ { screen }
|
|
630
636
|
const outbound = { total: 0, checked: 0, mailtos: 0 };
|
|
637
|
+
let probeCapHit = false;
|
|
631
638
|
let actions = 0;
|
|
632
639
|
let screenCount = 0;
|
|
633
640
|
let lastScreen = null;
|
|
@@ -930,14 +937,14 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
930
937
|
try {
|
|
931
938
|
if (/^mailto:/i.test(link.raw)) {
|
|
932
939
|
const address = link.raw.replace(/^mailto:/i, "").split("?")[0].trim();
|
|
933
|
-
if (address.includes("@") && !mailtoLinks.has(address)) mailtoLinks.set(address, { screen: ob.screen });
|
|
940
|
+
if (address.includes("@") && !mailtoLinks.has(address)) mailtoLinks.set(address, { screen: ob.screen, sourceUrl: start.origin + ob.key });
|
|
934
941
|
continue;
|
|
935
942
|
}
|
|
936
943
|
const u = new URL(link.href);
|
|
937
944
|
if (!/^https?:$/.test(u.protocol)) continue;
|
|
938
945
|
if (u.origin !== start.origin) {
|
|
939
946
|
const clean = u.origin + u.pathname;
|
|
940
|
-
if (!outboundLinks.has(clean)) outboundLinks.set(clean, { label: link.label, screen: ob.screen });
|
|
947
|
+
if (!outboundLinks.has(clean)) outboundLinks.set(clean, { label: link.label, screen: ob.screen, sourceUrl: start.origin + ob.key });
|
|
941
948
|
continue;
|
|
942
949
|
}
|
|
943
950
|
const key = u.pathname.replace(/\/+$/, "") + u.search || "/";
|
|
@@ -950,7 +957,9 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
950
957
|
// Bounded button pass: click, watch for effect, flag dead controls (the web analog
|
|
951
958
|
// of the iOS dead-button detector). Navigations are undone so BFS order holds.
|
|
952
959
|
const buttons = page.locator("button:visible, [role=button]:visible, input[type=submit]:visible, summary:visible");
|
|
953
|
-
const
|
|
960
|
+
const buttonTotal = await buttons.count().catch(() => 0);
|
|
961
|
+
const n = Math.min(buttonTotal, BUTTONS_PER_PAGE);
|
|
962
|
+
if (buttonTotal > BUTTONS_PER_PAGE) probeCapHit = true;
|
|
954
963
|
for (let i = 0; i < n && actions < maxActions && Date.now() < deadline; i++) {
|
|
955
964
|
const b = buttons.nth(i);
|
|
956
965
|
const label = webControlLabel({
|
|
@@ -1000,56 +1009,65 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
1000
1009
|
progress();
|
|
1001
1010
|
}
|
|
1002
1011
|
|
|
1003
|
-
// Post-crawl outbound audit
|
|
1004
|
-
//
|
|
1005
|
-
//
|
|
1012
|
+
// Post-crawl outbound audit. Link checks run through the LIVE browser context: JS-rendered
|
|
1013
|
+
// "unavailable" shells (Facebook et al.) are only visible to a real renderer, and browser
|
|
1014
|
+
// navigation honors the egress proxy policy, so these checks never bypass it. Only the
|
|
1015
|
+
// mailto MX lookups use node:dns and are therefore skipped under the enforced-egress policy.
|
|
1006
1016
|
outbound.total = outboundLinks.size;
|
|
1007
1017
|
outbound.mailtos = mailtoLinks.size;
|
|
1008
|
-
if (
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
const dns = await import("node:dns/promises");
|
|
1012
|
-
const hostResolvable = new Map();
|
|
1013
|
-
const resolves = async (host) => {
|
|
1014
|
-
if (!hostResolvable.has(host)) {
|
|
1015
|
-
hostResolvable.set(host, await dns.lookup(host).then(() => true).catch(() => false));
|
|
1016
|
-
}
|
|
1017
|
-
return hostResolvable.get(host);
|
|
1018
|
-
};
|
|
1018
|
+
if (outboundLinks.size) {
|
|
1019
|
+
const auditPage = await context.newPage();
|
|
1020
|
+
auditPage.setDefaultTimeout(8000);
|
|
1019
1021
|
const targets = [...outboundLinks.entries()].slice(0, OUTBOUND_LINK_LIMIT);
|
|
1020
1022
|
outbound.checked = targets.length;
|
|
1021
1023
|
for (const [href, meta] of targets) {
|
|
1022
1024
|
if (Date.now() >= deadline) break;
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1025
|
-
|
|
1025
|
+
const nav = await auditPage.goto(href, { waitUntil: "domcontentloaded", timeout: 8000 })
|
|
1026
|
+
.catch((error) => ({ navError: String(error && error.message || error) }));
|
|
1027
|
+
if (nav && nav.navError) {
|
|
1028
|
+
const dnsFailure = /ERR_NAME_NOT_RESOLVED/i.test(nav.navError);
|
|
1029
|
+
issue("unresolvable_host", "medium",
|
|
1030
|
+
dnsFailure
|
|
1031
|
+
? `Outbound link host does not resolve: ${new URL(href).hostname}`
|
|
1032
|
+
: `Outbound link unreachable: ${href.slice(0, 100)} (${nav.navError.slice(0, 60)})`,
|
|
1033
|
+
meta.screen, href, meta.sourceUrl);
|
|
1026
1034
|
continue;
|
|
1027
1035
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
issue("broken_link", "medium", `Outbound link returns HTTP ${res.status}: ${href.slice(0, 100)}`, meta.screen, href);
|
|
1032
|
-
} else {
|
|
1033
|
-
const phrase = webUnavailableShellPhrase(await res.text().catch(() => ""));
|
|
1034
|
-
if (phrase) issue("outbound_unavailable", "low", `Outbound link returns 200 but shows "${phrase}": ${href.slice(0, 100)}`, meta.screen, href);
|
|
1035
|
-
}
|
|
1036
|
-
} catch (error) {
|
|
1037
|
-
issue("unresolvable_host", "medium", `Outbound link unreachable: ${href.slice(0, 100)}`, meta.screen, href);
|
|
1036
|
+
if (nav && typeof nav.status === "function" && nav.status() >= 400) {
|
|
1037
|
+
issue("broken_link", "medium", `Outbound link returns HTTP ${nav.status()}: ${href.slice(0, 100)}`, meta.screen, href, meta.sourceUrl);
|
|
1038
|
+
continue;
|
|
1038
1039
|
}
|
|
1040
|
+
await auditPage.waitForTimeout(400); // let client-rendered shells paint their copy
|
|
1041
|
+
const text = await auditPage.evaluate(() => (document.body && document.body.innerText || "").slice(0, 120000)).catch(() => "");
|
|
1042
|
+
const phrase = webUnavailableShellPhrase(text);
|
|
1043
|
+
if (phrase) issue("outbound_unavailable", "low", `Outbound link returns 200 but shows "${phrase}": ${href.slice(0, 100)}`, meta.screen, href, meta.sourceUrl);
|
|
1039
1044
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1045
|
+
await auditPage.close().catch(() => {});
|
|
1046
|
+
}
|
|
1047
|
+
if (mailtoLinks.size) {
|
|
1048
|
+
if (process.env.TAPP_ENFORCE_PUBLIC_EGRESS === "1") {
|
|
1049
|
+
outbound.mailtoSkipped = "egress-policy"; // node:dns would bypass the enforced proxy
|
|
1050
|
+
} else {
|
|
1051
|
+
const dns = await import("node:dns/promises");
|
|
1052
|
+
for (const [address, meta] of mailtoLinks) {
|
|
1053
|
+
if (Date.now() >= deadline) break;
|
|
1054
|
+
const domain = (address.split("@")[1] || "").toLowerCase();
|
|
1055
|
+
if (!domain) continue;
|
|
1056
|
+
// RFC 5321 implicit-MX: a domain with no MX but an A/AAAA record can still receive.
|
|
1057
|
+
const deliverable = await dns.resolveMx(domain).then((records) => records.length > 0).catch(() => false)
|
|
1058
|
+
|| await dns.lookup(domain).then(() => true).catch(() => false);
|
|
1059
|
+
if (!deliverable) issue("mailto_no_mx", "medium", `mailto: domain cannot receive email (no MX or address record): ${address}`, meta.screen, address, meta.sourceUrl);
|
|
1060
|
+
}
|
|
1048
1061
|
}
|
|
1049
1062
|
}
|
|
1050
1063
|
} finally {
|
|
1051
1064
|
const timedOut = Date.now() >= deadline;
|
|
1052
|
-
|
|
1065
|
+
// A drained frontier with a truncating probe cap is NOT "nothing left" โ twin controls may
|
|
1066
|
+
// sit untapped past the per-page cap, and the stop reason must not claim otherwise.
|
|
1067
|
+
const stop = timedOut ? "time-budget"
|
|
1068
|
+
: actions >= maxActions ? "action-budget"
|
|
1069
|
+
: probeCapHit ? "probe-cap"
|
|
1070
|
+
: "frontier-drained";
|
|
1053
1071
|
emit("COMPLETE", { actions, screens: screenCount, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried, timedOut, stop, outbound });
|
|
1054
1072
|
fs.closeSync(markersFd);
|
|
1055
1073
|
await browser.close().catch(() => {});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aarwitz/tapp",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.6",
|
|
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",
|
package/scripts/ci-gate.sh
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
# for GitHub Actions; equally usable from any other CI or locally.
|
|
11
11
|
#
|
|
12
12
|
# Usage:
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# tapp ci [--platform ios] --app <path/to/App.app> [--bundle-id <com.example.app>]
|
|
14
|
+
# tapp ci --platform android --apk <path/to/app.apk> --app-id <com.example.app> [--serial <adb-serial>]
|
|
15
|
+
# tapp ci --platform web [--url <http(s)://owned-app>]
|
|
16
|
+
# tapp ci # in an initialized repo: reads .tapp/application-model.json for platform/target
|
|
16
17
|
# # omit --url with --project-dir to detect/build/start/stop one owned web target
|
|
17
18
|
# # bundle id is detected from the .app when omitted
|
|
18
19
|
# [--actions N] # exploration budget (default 40)
|
|
@@ -27,7 +28,10 @@
|
|
|
27
28
|
# [--pr-plan-out <file.json>] # persist the reviewable selection plan
|
|
28
29
|
# [--baseline <file.json>] # prior report to diff against (skipped if absent)
|
|
29
30
|
# [--target-key <stable-id>] # isolates target-specific baselines in monorepos
|
|
30
|
-
# [--fail-on gate|absolute|any]
|
|
31
|
+
# [--fail-on gate|absolute|any|high|medium]
|
|
32
|
+
# # gate policy (default: gate; web targets default
|
|
33
|
+
# # to medium โ any deterministic medium+ finding
|
|
34
|
+
# # blocks; see ci-report.js)
|
|
31
35
|
# [--json-out <file.json>] # write the full report (use as the next baseline)
|
|
32
36
|
# [--md-out <file.md>] # write the rendered markdown report (for a PR comment)
|
|
33
37
|
# [--device <name>] # simulator device to boot if none is (default "iPhone 16 Pro")
|
|
@@ -43,10 +47,10 @@ usage() {
|
|
|
43
47
|
|
|
44
48
|
PLATFORM="ios" APP_PATH="" BUNDLE_ID="" APK_PATH="" APP_ID="" URL="" WEB_TARGET="" TARGET_KEY="" SERIAL="" ACTIONS=40 TIMEOUT=600 FLOWS="" SCENARIOS="" CONTRACTS="" PROJECT_DIR="" BASELINE="" FAIL_ON="gate" JSON_OUT="" MD_OUT="" DEVICE="iPhone 16 Pro" PR_BASE="" PR_HEAD="HEAD" CHANGED_FILES_FILE="" PR_PLAN_OUT=""
|
|
45
49
|
IOS_PR_TARGET_JSON=""
|
|
46
|
-
FLOWS_EXPLICIT=false SCENARIOS_EXPLICIT=false CONTRACTS_EXPLICIT=false
|
|
50
|
+
FLOWS_EXPLICIT=false SCENARIOS_EXPLICIT=false CONTRACTS_EXPLICIT=false PLATFORM_EXPLICIT=false FAIL_ON_EXPLICIT=false
|
|
47
51
|
while [[ $# -gt 0 ]]; do
|
|
48
52
|
case "$1" in
|
|
49
|
-
--platform) PLATFORM="$2"; shift 2 ;;
|
|
53
|
+
--platform) PLATFORM="$2"; PLATFORM_EXPLICIT=true; shift 2 ;;
|
|
50
54
|
--app) APP_PATH="$2"; shift 2 ;;
|
|
51
55
|
--bundle-id) BUNDLE_ID="$2"; shift 2 ;;
|
|
52
56
|
--apk) APK_PATH="$2"; shift 2 ;;
|
|
@@ -66,7 +70,7 @@ while [[ $# -gt 0 ]]; do
|
|
|
66
70
|
--changed-files-file) CHANGED_FILES_FILE="$2"; shift 2 ;;
|
|
67
71
|
--pr-plan-out) PR_PLAN_OUT="$2"; shift 2 ;;
|
|
68
72
|
--baseline) BASELINE="$2"; shift 2 ;;
|
|
69
|
-
--fail-on) FAIL_ON="$2"; shift 2 ;;
|
|
73
|
+
--fail-on) FAIL_ON="$2"; FAIL_ON_EXPLICIT=true; shift 2 ;;
|
|
70
74
|
--json-out) JSON_OUT="$2"; shift 2 ;;
|
|
71
75
|
--md-out) MD_OUT="$2"; shift 2 ;;
|
|
72
76
|
--device) DEVICE="$2"; shift 2 ;;
|
|
@@ -77,16 +81,48 @@ done
|
|
|
77
81
|
[[ "$PLATFORM" == "ios" || "$PLATFORM" == "android" || "$PLATFORM" == "web" ]] || { echo "โ --platform must be ios|android|web" >&2; exit 2; }
|
|
78
82
|
[[ "$ACTIONS" =~ ^[1-9][0-9]*$ ]] || { echo "โ --actions must be a positive integer" >&2; exit 2; }
|
|
79
83
|
[[ "$TIMEOUT" =~ ^[1-9][0-9]*$ ]] || { echo "โ --timeout must be a positive integer" >&2; exit 2; }
|
|
80
|
-
[[ "$FAIL_ON" == "gate" || "$FAIL_ON" == "absolute" || "$FAIL_ON" == "any" ]] || { echo "โ --fail-on must be gate|absolute|any" >&2; exit 2; }
|
|
84
|
+
[[ "$FAIL_ON" == "gate" || "$FAIL_ON" == "absolute" || "$FAIL_ON" == "any" || "$FAIL_ON" == "high" || "$FAIL_ON" == "medium" ]] || { echo "โ --fail-on must be gate|absolute|any|high|medium" >&2; exit 2; }
|
|
81
85
|
if [[ -n "$PROJECT_DIR" ]]; then
|
|
82
86
|
[[ -d "$PROJECT_DIR" ]] || { echo "โ Project directory not found: $PROJECT_DIR" >&2; exit 2; }
|
|
83
87
|
PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)"
|
|
84
88
|
fi
|
|
89
|
+
# A repository-connected gate should connect to the repository it is run from: when no
|
|
90
|
+
# --project-dir was given but the working directory is an initialized Tapp repo, use it.
|
|
91
|
+
if [[ -z "$PROJECT_DIR" && -f "$(pwd)/.tapp/application-model.json" ]]; then
|
|
92
|
+
PROJECT_DIR="$(pwd)"
|
|
93
|
+
echo "Using repository artifacts from $PROJECT_DIR/.tapp"
|
|
94
|
+
fi
|
|
85
95
|
TAPP_PROJECT_ARTIFACTS=""
|
|
86
96
|
if [[ -n "$PROJECT_DIR" ]]; then
|
|
87
97
|
[[ -d "$PROJECT_DIR/.tapp" ]] && TAPP_PROJECT_ARTIFACTS="$PROJECT_DIR/.tapp"
|
|
88
98
|
fi
|
|
89
99
|
|
|
100
|
+
# When --platform was not given, derive it from the application model instead of assuming iOS โ
|
|
101
|
+
# but only when the model is unambiguous (exactly one platform across its targets).
|
|
102
|
+
if [[ "$PLATFORM_EXPLICIT" == "false" && -n "$TAPP_PROJECT_ARTIFACTS" && -f "$TAPP_PROJECT_ARTIFACTS/application-model.json" ]]; then
|
|
103
|
+
MODEL_PLATFORM="$(node - "$TAPP_PROJECT_ARTIFACTS/application-model.json" <<'NODE'
|
|
104
|
+
const fs = require("fs");
|
|
105
|
+
try {
|
|
106
|
+
const model = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
|
107
|
+
const platforms = [...new Set((model.targets || []).map((t) => t.platform).filter(Boolean))];
|
|
108
|
+
if (platforms.length === 1) process.stdout.write(platforms[0]);
|
|
109
|
+
} catch {}
|
|
110
|
+
NODE
|
|
111
|
+
)"
|
|
112
|
+
if [[ -n "$MODEL_PLATFORM" && "$MODEL_PLATFORM" != "$PLATFORM" ]]; then
|
|
113
|
+
PLATFORM="$MODEL_PLATFORM"
|
|
114
|
+
echo "Platform derived from the application model: $PLATFORM (pass --platform to override)"
|
|
115
|
+
fi
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
# Web policy default (gate policy v3): on a website, a deterministic broken link IS the release
|
|
119
|
+
# blocker โ default web targets to fail-on medium. Pass --fail-on gate to restore baseline-diff
|
|
120
|
+
# semantics for a web target.
|
|
121
|
+
if [[ "$FAIL_ON_EXPLICIT" == "false" && "$PLATFORM" == "web" ]]; then
|
|
122
|
+
FAIL_ON="medium"
|
|
123
|
+
echo "Gate policy: fail-on medium (web default; any deterministic medium+ finding blocks โ override with --fail-on)"
|
|
124
|
+
fi
|
|
125
|
+
|
|
90
126
|
# A repository-connected gate must retain the stable application-model target identity in its
|
|
91
127
|
# report. Otherwise its first passing report cannot become a target-scoped baseline, even though
|
|
92
128
|
# Tapp already knows exactly which application it built and exercised. Explicit --target-key still
|
package/scripts/platform-gate.js
CHANGED
|
@@ -42,12 +42,18 @@ for (let i = 2; i < process.argv.length; i += 1) {
|
|
|
42
42
|
process.exit(2);
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (
|
|
45
|
+
// Usage problems are part of the public outcome contract (exit 2), never an uncaught stack trace.
|
|
46
|
+
const usageError = (message) => { console.error(`โ ${message}`); process.exit(2); };
|
|
47
|
+
if (!["web", "android"].includes(args.platform)) usageError("--platform must be web|android");
|
|
48
|
+
if (args.platform === "web" && !args.url && !args.projectDir) usageError("The web gate needs a target: pass --url <http(s)://owned-app>, or --project-dir <repo> to build/start the repository's own web target.");
|
|
49
|
+
if (args.platform === "android" && !args.appId) usageError("The Android gate requires --app-id");
|
|
48
50
|
if (args.projectDir) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
try {
|
|
52
|
+
args.projectDir = fs.realpathSync(path.resolve(args.projectDir));
|
|
53
|
+
if (!fs.statSync(args.projectDir).isDirectory()) usageError(`Project directory not found: ${args.projectDir}`);
|
|
54
|
+
} catch {
|
|
55
|
+
usageError(`Project directory not found: ${args.projectDir}`);
|
|
56
|
+
}
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
// Parse and platform-filter before launching a browser/device so a typo cannot
|