@aarwitz/tapp 0.17.5 → 0.17.7
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/bin/tapp.js +52 -27
- package/docs/scenarios.md +1 -1
- package/mcp-server/src/ci-report.js +34 -6
- package/mcp-server/src/html-report.js +8 -0
- package/mcp-server/src/index.js +11 -7
- package/mcp-server/src/report.js +33 -8
- package/mcp-server/src/web-explorer.js +68 -45
- 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.7",
|
|
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.7",
|
|
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.7 # 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.7 # or pin the reviewed release commit SHA
|
|
402
402
|
with:
|
|
403
403
|
platform: android
|
|
404
404
|
android-app-id: com.acme.app
|
package/bin/tapp.js
CHANGED
|
@@ -270,10 +270,10 @@ function safeCommandUsage(verb) {
|
|
|
270
270
|
shot: "tapp shot [--out FILE]",
|
|
271
271
|
apps: "tapp apps",
|
|
272
272
|
build: "tapp build [repo] [--scheme NAME] [--configuration NAME]",
|
|
273
|
-
flow: "tapp flow example\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--actor NAME] [--email VALUE] [--password VALUE] [--device \"iPhone 13\"] [--viewport 390x844]",
|
|
273
|
+
flow: "tapp flow example\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--actor NAME] [--email VALUE] [--password VALUE] [--device \"iPhone 13\"] [--viewport 390x844]\n Exit codes: 0 replay passed · 1 replay failed · 2 infrastructure/usage error",
|
|
274
274
|
task: "tapp task validate FILE [--platform PLATFORM] [--map FILE]\ntapp task compile FILE --platform PLATFORM [--inputs JSON] [--out FILE]\ntapp task run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID] [--inputs JSON]",
|
|
275
|
-
contract: "tapp contract validate FILE [--platform PLATFORM] [--map FILE]\ntapp contract compile FILE --platform PLATFORM [--out FILE]\ntapp contract run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID]",
|
|
276
|
-
scenario: "tapp scenario validate FILE [--project-dir DIR]\ntapp scenario run FILE --platform web --url URL [--project-dir DIR]",
|
|
275
|
+
contract: "tapp contract validate FILE [--platform PLATFORM] [--map FILE]\ntapp contract compile FILE --platform PLATFORM [--out FILE]\ntapp contract run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID]\n Exit codes: 0 contract held · 1 contract failed · 2 infrastructure/usage error",
|
|
276
|
+
scenario: "tapp scenario validate FILE [--project-dir DIR]\ntapp scenario run FILE --platform web --url URL [--project-dir DIR]\n Exit codes: 0 scenario passed · 1 scenario failed · 2 infrastructure/usage error",
|
|
277
277
|
map: "tapp map build MARKERS [--platform PLATFORM] [--out FILE] [--replace]\ntapp map inspect [FILE]\ntapp map diff BEFORE AFTER [--comparable]",
|
|
278
278
|
pr: "tapp pr plan [--base REF|--changed-files FILE] [--head REF] [--platform PLATFORM] [--out FILE]\ntapp pr gate PLAN [gate target/options]\ntapp pr adopt PLAN --item ID [--project-dir DIR]",
|
|
279
279
|
plan: "tapp plan show [FILE]\ntapp plan review [FILE] --approve NAME[,NAME] --reject NAME[,NAME] --defer NAME[,NAME]\ntapp plan generate|validate|promote [FILE] [options]",
|
|
@@ -282,7 +282,7 @@ function safeCommandUsage(verb) {
|
|
|
282
282
|
actor: "tapp actor set NAME --email-env ENV --password-env ENV [--replace] [--project-dir DIR]\ntapp actor list [repo]",
|
|
283
283
|
app: "tapp app [repo] [--no-open] [--port PORT]",
|
|
284
284
|
report: "tapp report [captureId|latest]",
|
|
285
|
-
doctor: "tapp doctor",
|
|
285
|
+
doctor: "tapp doctor [--json]\n Exit codes: 0 environment healthy · 1 blocked (fix ❌ items)",
|
|
286
286
|
install: "tapp install",
|
|
287
287
|
mcp: "tapp mcp",
|
|
288
288
|
};
|
|
@@ -1425,37 +1425,51 @@ switch (command) {
|
|
|
1425
1425
|
}
|
|
1426
1426
|
|
|
1427
1427
|
case "doctor": {
|
|
1428
|
-
|
|
1428
|
+
const { flags: doctorFlags } = parseVerbArgs(rest);
|
|
1429
|
+
const jsonMode = doctorFlags.json === true;
|
|
1430
|
+
const report = { version: pkg.version, healthy: true, node: {}, python3: {}, disk: {},
|
|
1431
|
+
platforms: { ios: {}, android: {}, web: {} }, home: tappHome };
|
|
1432
|
+
const say = (line) => { if (!jsonMode) console.log(line); };
|
|
1433
|
+
const sayOk = (label, detail = "") => { if (!jsonMode) ok(label, detail); };
|
|
1434
|
+
const sayBad = (label, detail = "") => { if (!jsonMode) bad(label, detail); };
|
|
1435
|
+
say(`tapp v${pkg.version} — doctor\n`);
|
|
1429
1436
|
let healthy = true;
|
|
1430
1437
|
|
|
1431
1438
|
const major = Number(process.versions.node.split(".")[0]);
|
|
1432
|
-
major >= 18
|
|
1439
|
+
report.node = { ok: major >= 18, version: process.versions.node };
|
|
1440
|
+
major >= 18 ? sayOk("Node", `v${process.versions.node}`) : (sayBad("Node", `v${process.versions.node} (need >= 18)`), (healthy = false));
|
|
1433
1441
|
|
|
1434
1442
|
const python = run("python3", ["--version"]);
|
|
1435
|
-
|
|
1443
|
+
report.python3 = { ok: python.code === 0, version: python.code === 0 ? python.stdout : null };
|
|
1444
|
+
python.code === 0 ? sayOk("python3", `${python.stdout} (used by Flows)`) : sayBad("python3", "not found — Flow replay needs python3 + pyyaml (everything else works)");
|
|
1436
1445
|
|
|
1437
1446
|
const { storagePreflight } = await import(path.join(packageRoot, "mcp-server", "src", "environment-preflight.js"));
|
|
1438
1447
|
const storage = storagePreflight(tappHome);
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
else if (storage.level === "
|
|
1442
|
-
else
|
|
1448
|
+
report.disk = { level: storage.level, message: storage.message || null };
|
|
1449
|
+
if (storage.level === "blocked") { sayBad("Disk space", storage.message); healthy = false; }
|
|
1450
|
+
else if (storage.level === "warning") say(` ⚠️ Disk space — ${storage.message}`);
|
|
1451
|
+
else if (storage.level === "ok") sayOk("Disk space", storage.message);
|
|
1452
|
+
else say(` ⬜ Disk space — ${storage.message || "could not be checked"}`);
|
|
1443
1453
|
|
|
1444
|
-
|
|
1454
|
+
say("\n Platforms:");
|
|
1445
1455
|
if (process.platform === "darwin") {
|
|
1446
1456
|
const xcode = run("xcode-select", ["-p"]);
|
|
1447
1457
|
const simctl = run("xcrun", ["simctl", "help"]);
|
|
1448
1458
|
if (xcode.code === 0 && simctl.code === 0) {
|
|
1449
1459
|
const ver = run("xcodebuild", ["-version"]).stdout.split("\n")[0];
|
|
1450
1460
|
const booted = bootedSims();
|
|
1451
|
-
ok("iOS", `${ver || "Xcode"}; ${booted.length ? `${booted[0].name} booted` : "no simulator booted yet"}`);
|
|
1452
1461
|
const xctestrun = harnessXctestrun();
|
|
1453
|
-
|
|
1462
|
+
report.platforms.ios = { available: true, xcode: ver || "Xcode",
|
|
1463
|
+
bootedSimulator: booted.length ? booted[0].name : null, harnessCache: Boolean(xctestrun) };
|
|
1464
|
+
sayOk("iOS", `${ver || "Xcode"}; ${booted.length ? `${booted[0].name} booted` : "no simulator booted yet"}`);
|
|
1465
|
+
xctestrun ? sayOk("iOS harness cache", xctestrun) : say(" ⬜ iOS harness cache — builds on first use (or: npx -y @aarwitz/tapp@latest install)");
|
|
1454
1466
|
} else {
|
|
1455
|
-
|
|
1467
|
+
report.platforms.ios = { available: false, reason: "install Xcode + simulator runtime" };
|
|
1468
|
+
say(" ⬜ iOS — unavailable (install Xcode + simulator runtime)");
|
|
1456
1469
|
}
|
|
1457
1470
|
} else {
|
|
1458
|
-
|
|
1471
|
+
report.platforms.ios = { available: false, reason: `requires macOS (this host: ${process.platform})` };
|
|
1472
|
+
say(` ⬜ iOS — requires macOS (this host: ${process.platform})`);
|
|
1459
1473
|
}
|
|
1460
1474
|
|
|
1461
1475
|
const { resolveAdbPath, resolveAndroidSdkRoot } = await import(path.join(packageRoot, "mcp-server", "src", "android-driver.js"));
|
|
@@ -1463,17 +1477,20 @@ switch (command) {
|
|
|
1463
1477
|
const adb = adbPath ? run(adbPath, ["devices"]) : { code: 1, stdout: "" };
|
|
1464
1478
|
if (adb.code === 0) {
|
|
1465
1479
|
const devices = adb.stdout.split(/\r?\n/).slice(1).filter((line) => /\sdevice(?:\s|$)/.test(line));
|
|
1466
|
-
|
|
1480
|
+
report.platforms.android = { adb: true, devicesConnected: devices.length };
|
|
1481
|
+
sayOk("Android", devices.length ? `${devices.length} connected emulator/device` : "adb available; no device connected");
|
|
1467
1482
|
} else {
|
|
1468
|
-
|
|
1483
|
+
report.platforms.android = { adb: false, devicesConnected: 0 };
|
|
1484
|
+
say(" ⬜ Android — adb not found (install Android SDK platform-tools)");
|
|
1469
1485
|
}
|
|
1470
1486
|
const { resolveJavaRuntime } = await import(path.join(packageRoot, "mcp-server", "src", "environment-preflight.js"));
|
|
1471
1487
|
const java = resolveJavaRuntime();
|
|
1472
1488
|
const androidSdkRoot = resolveAndroidSdkRoot();
|
|
1473
|
-
|
|
1489
|
+
report.platforms.android.sourceBuilds = Boolean(java && androidSdkRoot);
|
|
1490
|
+
if (java && androidSdkRoot) sayOk("Android source builds", `${java.version || java.javaHome}; SDK ${androidSdkRoot}`);
|
|
1474
1491
|
else {
|
|
1475
1492
|
const missing = [!java ? "JDK 17" : "", !androidSdkRoot ? "Android SDK root" : ""].filter(Boolean).join(" and ");
|
|
1476
|
-
|
|
1493
|
+
say(` ⬜ Android source builds — install/configure ${missing} (prebuilt APK testing still works)`);
|
|
1477
1494
|
}
|
|
1478
1495
|
|
|
1479
1496
|
try {
|
|
@@ -1481,18 +1498,26 @@ switch (command) {
|
|
|
1481
1498
|
let executable = "";
|
|
1482
1499
|
try { executable = chromium.executablePath(); } catch { /* report the missing browser below */ }
|
|
1483
1500
|
if (executable && fs.existsSync(executable)) {
|
|
1484
|
-
|
|
1501
|
+
report.platforms.web = { available: true, chromium: executable };
|
|
1502
|
+
sayOk("Web", `Playwright + Chromium (${executable})`);
|
|
1485
1503
|
} else {
|
|
1486
|
-
|
|
1504
|
+
report.platforms.web = { available: false, reason: "Chromium browser missing (run: npx playwright install chromium)" };
|
|
1505
|
+
say(" ⬜ Web — Playwright installed; Chromium browser missing (run: npx playwright install chromium)");
|
|
1487
1506
|
}
|
|
1488
1507
|
} catch {
|
|
1489
|
-
|
|
1508
|
+
report.platforms.web = { available: false, reason: "Playwright not installed" };
|
|
1509
|
+
say(" ⬜ Web — install Playwright in the app workspace: npm install -D playwright && npx playwright install chromium");
|
|
1490
1510
|
}
|
|
1491
1511
|
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1512
|
+
report.healthy = healthy;
|
|
1513
|
+
if (jsonMode) {
|
|
1514
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1515
|
+
} else {
|
|
1516
|
+
console.log(`\n Home: ${tappHome}`);
|
|
1517
|
+
console.log(healthy
|
|
1518
|
+
? "\nReady. Start with:\n npx -y @aarwitz/tapp@latest open [target]\n npx -y @aarwitz/tapp@latest explore [target]"
|
|
1519
|
+
: "\nFix the ❌ items above, then re-run: npx -y @aarwitz/tapp@latest doctor");
|
|
1520
|
+
}
|
|
1496
1521
|
process.exit(healthy ? 0 : 1);
|
|
1497
1522
|
}
|
|
1498
1523
|
|
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.7 # 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;
|
|
@@ -175,6 +178,7 @@ function loadBaseline(baselinePath) {
|
|
|
175
178
|
actionsPerformed: parsed.actionsPerformed || 0,
|
|
176
179
|
platform: parsed.baselineIdentity?.platform || parsed.platform || null,
|
|
177
180
|
targetKey: parsed.baselineIdentity?.targetId || parsed.targetKey || null,
|
|
181
|
+
capture: parsed.capture || null,
|
|
178
182
|
};
|
|
179
183
|
}
|
|
180
184
|
|
|
@@ -396,6 +400,10 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
|
|
|
396
400
|
lines.push(`### Since baseline — ${regFailed ? "🔴 regression gate FAILED" : "🟢 regression gate passed"}`);
|
|
397
401
|
lines.push(`+${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved` +
|
|
398
402
|
(regFailed ? ` — **${newCritical} new critical, ${newHigh} new high**` : ""));
|
|
403
|
+
if (regression.captureMismatch) {
|
|
404
|
+
const describe = (c) => [c?.device, c?.viewport ? `${c.viewport.width}x${c.viewport.height}` : null, c?.deviceScaleFactor ? `@${c.deviceScaleFactor}x` : null].filter(Boolean).join(" ") || "unknown";
|
|
405
|
+
lines.push(`⚠️ **Capture mismatch**: baseline was captured at ${describe(regression.captureMismatch.baseline)}, this run at ${describe(regression.captureMismatch.current)}. "Resolved" findings may reflect layout differences at the new size, not fixes — re-baseline at the same device/viewport to compare honestly.`);
|
|
406
|
+
}
|
|
399
407
|
for (const f of regression.newFindings) {
|
|
400
408
|
lines.push(`- NEW ${SEV_ICON[f.severity] || ""} ${f.severity}: ${f.title} (${f.screen ?? "—"})`);
|
|
401
409
|
}
|
|
@@ -477,10 +485,22 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
|
|
|
477
485
|
}
|
|
478
486
|
lines.push("");
|
|
479
487
|
const badge = GATE_BADGE[gate.outcome] || (gate.failed ? "🔴 FAIL" : "🟢 PASS");
|
|
480
|
-
|
|
488
|
+
// A PASS must state what it chose to ignore — a green banner over known findings without
|
|
489
|
+
// saying so is exactly the dishonest verdict this product refuses to render.
|
|
490
|
+
const ignored = gate.outcome === "pass" && report.deterministicFindingCounts
|
|
491
|
+
? ["high", "medium", "low"].map((sev) => [sev, report.deterministicFindingCounts[sev] || 0]).filter(([, n]) => n > 0)
|
|
492
|
+
: [];
|
|
493
|
+
const ignoredNote = ignored.length
|
|
494
|
+
? ` — ${ignored.reduce((n, [, c]) => n + c, 0)} deterministic finding(s) below the fail threshold (${ignored.map(([sev, n]) => `${n} ${sev}`).join(", ")})`
|
|
495
|
+
: "";
|
|
496
|
+
lines.push(`**Gate (${gate.policy}): ${badge}**${gate.reasons.length ? " — " + gate.reasons.join("; ") : ""}${ignoredNote}`);
|
|
481
497
|
// The gate is only authoritative about what it actually ran — record the scope explicitly.
|
|
482
498
|
const rev = gate.revision?.sha ? `${String(gate.revision.sha).slice(0, 12)}${gate.revision.dirty ? "-dirty" : ""}` : "unknown";
|
|
483
|
-
|
|
499
|
+
const captured = report.capture
|
|
500
|
+
? ` · capture: ${[report.capture.device, report.capture.viewport ? `${report.capture.viewport.width}x${report.capture.viewport.height}` : null, report.capture.deviceScaleFactor ? `@${report.capture.deviceScaleFactor}x` : null].filter(Boolean).join(" ")}`
|
|
501
|
+
: "";
|
|
502
|
+
const stableId = report.targetKey && report.targetKey !== gate.target ? ` (${report.targetKey})` : "";
|
|
503
|
+
lines.push(`_target: ${gate.target || "—"}${stableId} · revision: ${rev} · policy: ${gate.policy} v${gate.policyVersion || "?"}${captured}_`);
|
|
484
504
|
if (Array.isArray(gate.checked) && gate.checked.length) lines.push(`_Checked: ${gate.checked.join(" · ")}_`);
|
|
485
505
|
if (Array.isArray(gate.notChecked) && gate.notChecked.length) lines.push(`_Not checked: ${gate.notChecked.join(" · ")}_`);
|
|
486
506
|
return lines.join("\n");
|
|
@@ -539,6 +559,12 @@ if (collapsed.length) {
|
|
|
539
559
|
report.headline = `${collapsed.length} screen(s) regressed vs. baseline (content collapsed or became unreachable).`;
|
|
540
560
|
}
|
|
541
561
|
const regression = computeRegression(report.findings, baseline?.findings ?? null);
|
|
562
|
+
// A baseline captured at a different device/viewport is a layout comparison, not a regression
|
|
563
|
+
// signal: a phone run legitimately hides desktop nav links, so its "resolved" list lies. Keep
|
|
564
|
+
// the diff (new findings still gate) but stamp the mismatch so every consumer can see it.
|
|
565
|
+
if (regression && baseline?.capture && report.capture && JSON.stringify(baseline.capture) !== JSON.stringify(report.capture)) {
|
|
566
|
+
regression.captureMismatch = { baseline: baseline.capture, current: report.capture };
|
|
567
|
+
}
|
|
542
568
|
const runs = args.flowLogs.map(parseFlowLog);
|
|
543
569
|
const contracts = runs.filter((run) => run.kind === "release-contract");
|
|
544
570
|
const flows = runs.filter((run) => !["scenario", "release-contract"].includes(run.kind));
|
|
@@ -561,7 +587,9 @@ try {
|
|
|
561
587
|
const decision = evaluateGate({ report, regression, flows, scenarios, contracts, prPlan, baseline, failOn: args.failOn });
|
|
562
588
|
const gate = {
|
|
563
589
|
...decision,
|
|
564
|
-
|
|
590
|
+
// Display identity is the URL/bundle the run exercised; the stable application-model id
|
|
591
|
+
// stays on report.targetKey for baseline matching (a hash is not a target name).
|
|
592
|
+
target: report.target || args.targetKey || null,
|
|
565
593
|
revision: gitRevision(args.projectDir),
|
|
566
594
|
checked: report.checkedFor,
|
|
567
595
|
notChecked: report.notChecked,
|
|
@@ -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; }
|
|
@@ -137,6 +140,7 @@ ${r.conditionsNotReached?.length ? `<div><h2>Conditions not reached</h2><ul>${sc
|
|
|
137
140
|
<h1>${esc(observationBadge(r))} <span class="dim">· ${esc(observationSummary(r))}</span></h1>
|
|
138
141
|
<div class="meta">${esc(label)} · evidence page (observation, not a release decision)</div>
|
|
139
142
|
${r.platform === "web" ? `<div class="meta">Deterministic basis: ${r.deterministicFindingCounts?.total || 0} deterministic finding(s); ${r.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory.</div>` : ""}
|
|
143
|
+
${r.capture ? `<div class="meta">Captured at ${esc([r.capture.device, r.capture.viewport ? `${r.capture.viewport.width}×${r.capture.viewport.height}` : null, r.capture.deviceScaleFactor ? `@${r.capture.deviceScaleFactor}x` : null].filter(Boolean).join(" "))} — every screenshot on this page shares these conditions.</div>` : ""}
|
|
140
144
|
<div class="headline">${esc(r.headline)}</div>
|
|
141
145
|
${r.credentialWarning ? `<div class="warning">${esc(r.credentialWarning)}</div>` : ""}
|
|
142
146
|
${scopeHtml}
|
|
@@ -144,6 +148,10 @@ ${scopeHtml}
|
|
|
144
148
|
<ul class="findings">
|
|
145
149
|
${findingsHtml}
|
|
146
150
|
</ul>
|
|
151
|
+
${Array.isArray(r.trace) && r.trace.length ? `<h2>Action trace — what was done, in order</h2>
|
|
152
|
+
<table class="trace"><thead><tr><th>t</th><th>action</th><th>target</th><th>screen</th></tr></thead><tbody>
|
|
153
|
+
${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")}
|
|
154
|
+
</tbody></table>` : ""}
|
|
147
155
|
<h2>Evidence — every screen explored</h2>
|
|
148
156
|
<div class="grid">
|
|
149
157
|
${shotsHtml || "<p class='dim'>No screenshots captured.</p>"}
|
package/mcp-server/src/index.js
CHANGED
|
@@ -1721,9 +1721,10 @@ async function writeRunUiMap({ markersPath, platform, target, runId, outDir }) {
|
|
|
1721
1721
|
/** One-line "3 buttons · 2 fields · 8 text" breakdown of an accessibility element list. */
|
|
1722
1722
|
function elementBreakdown(elements) {
|
|
1723
1723
|
const has = (e, ...pats) => pats.some((p) => String(e.type || "").includes(p));
|
|
1724
|
-
let buttons = 0, fields = 0, texts = 0, cells = 0, other = 0;
|
|
1724
|
+
let buttons = 0, links = 0, fields = 0, texts = 0, cells = 0, other = 0;
|
|
1725
1725
|
for (const e of elements || []) {
|
|
1726
|
-
if (has(e, "
|
|
1726
|
+
if (has(e, "Link", "rawValue: 39")) links++;
|
|
1727
|
+
else if (has(e, "Button", "rawValue: 9")) buttons++;
|
|
1727
1728
|
else if (has(e, "TextField", "rawValue: 49", "rawValue: 50", "SecureTextField")) fields++;
|
|
1728
1729
|
else if (has(e, "StaticText", "rawValue: 48")) texts++;
|
|
1729
1730
|
else if (has(e, "Cell", "rawValue: 75")) cells++;
|
|
@@ -1731,6 +1732,7 @@ function elementBreakdown(elements) {
|
|
|
1731
1732
|
}
|
|
1732
1733
|
return [
|
|
1733
1734
|
buttons && `${buttons} button${buttons > 1 ? "s" : ""}`,
|
|
1735
|
+
links && `${links} link${links > 1 ? "s" : ""}`,
|
|
1734
1736
|
fields && `${fields} field${fields > 1 ? "s" : ""}`,
|
|
1735
1737
|
cells && `${cells} cell${cells > 1 ? "s" : ""}`,
|
|
1736
1738
|
texts && `${texts} text`,
|
|
@@ -1740,13 +1742,15 @@ function elementBreakdown(elements) {
|
|
|
1740
1742
|
/** Scannable "Read screen X — N elements (...)" readout, plus the tappable/typeable controls. */
|
|
1741
1743
|
export function formatScreen(screenTitle, elements) {
|
|
1742
1744
|
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
|
|
1745
|
+
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")));
|
|
1746
|
+
const allLabels = interactable
|
|
1745
1747
|
.map((e) => (e.label || e.identifier || "").trim())
|
|
1746
|
-
.filter((s) => s && s.length <= 40 && !s.includes("."))
|
|
1747
|
-
|
|
1748
|
+
.filter((s) => s && s.length <= 40 && !s.includes("."));
|
|
1749
|
+
const labels = allLabels.slice(0, 8);
|
|
1748
1750
|
const L = [`🌳 Read screen **${screenTitle || "Unknown"}** — ${els.length} elements (${elementBreakdown(els)})`];
|
|
1749
|
-
|
|
1751
|
+
// A silently cut list reads as complete; say when it isn't.
|
|
1752
|
+
if (labels.length) L.push("", "**Controls:** " + labels.map((l) => `\`${l}\``).join(" · ")
|
|
1753
|
+
+ (allLabels.length > labels.length ? ` · … and ${allLabels.length - labels.length} more` : ""));
|
|
1750
1754
|
return L.join("\n");
|
|
1751
1755
|
}
|
|
1752
1756
|
|
package/mcp-server/src/report.js
CHANGED
|
@@ -30,6 +30,7 @@ export function parseOcqaMarkers(markersFilePath) {
|
|
|
30
30
|
const transitions = [];
|
|
31
31
|
const issues = [];
|
|
32
32
|
let complete = null;
|
|
33
|
+
let context = null;
|
|
33
34
|
|
|
34
35
|
for (const line of lines) {
|
|
35
36
|
if (!line.startsWith("OCQA_")) continue;
|
|
@@ -57,6 +58,7 @@ export function parseOcqaMarkers(markersFilePath) {
|
|
|
57
58
|
if (category === "TRANSITION") transitions.push(parsed);
|
|
58
59
|
if (category === "ISSUE") issues.push(parsed);
|
|
59
60
|
if (category === "COMPLETE") complete = parsed;
|
|
61
|
+
if (category === "CONTEXT") context = parsed;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
return {
|
|
@@ -72,6 +74,7 @@ export function parseOcqaMarkers(markersFilePath) {
|
|
|
72
74
|
)
|
|
73
75
|
),
|
|
74
76
|
complete,
|
|
77
|
+
context,
|
|
75
78
|
actions,
|
|
76
79
|
recentActions: actions.slice(-5),
|
|
77
80
|
recentTransitions: transitions.slice(-5),
|
|
@@ -101,7 +104,11 @@ export const ISSUE_CATEGORY = {
|
|
|
101
104
|
explore_timeout: "performance_timeout",
|
|
102
105
|
};
|
|
103
106
|
export const CRITICAL_ISSUE_TYPES = new Set(["crash"]);
|
|
104
|
-
|
|
107
|
+
// outbound_unavailable graduated to the deterministic tier in policy v4: since 0.17.6 the
|
|
108
|
+
// outbound audit renders each link in the live browser, so the unavailable-shell phrase match
|
|
109
|
+
// is a stable observation of the page, not a sampled probe — and a dead social link is exactly
|
|
110
|
+
// the broken-link class the web fail-on default exists to block.
|
|
111
|
+
export const WEB_SAMPLED_ISSUE_TYPES = new Set(["unresponsive_element"]);
|
|
105
112
|
|
|
106
113
|
export function severityRank(s) {
|
|
107
114
|
return { critical: 0, high: 1, medium: 2, low: 3 }[s] ?? 4;
|
|
@@ -295,6 +302,7 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
295
302
|
: timeBudgetExhausted ? "time-budget-exhausted"
|
|
296
303
|
: !coverageFloorMet ? "coverage-floor-not-met"
|
|
297
304
|
: driverStop === "frontier-drained" ? "no-unexplored-in-scope-controls"
|
|
305
|
+
: driverStop === "probe-cap" ? "probe-cap-reached"
|
|
298
306
|
: "completed";
|
|
299
307
|
|
|
300
308
|
const headline = timeBudgetExhausted
|
|
@@ -320,12 +328,12 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
320
328
|
"page errors (uncaught exceptions)", "failed/5xx requests", "broken links (404, same-origin crawl)",
|
|
321
329
|
"placeholder links and anchors with no destination", "sampled dead-button probes (advisory)", "error text on pages", "load timeouts",
|
|
322
330
|
];
|
|
323
|
-
if (outbound &&
|
|
331
|
+
if (outbound && outbound.total) {
|
|
324
332
|
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)");
|
|
333
|
+
? `outbound link reachability — browser-rendered · DNS · HTTP · unavailable-shell heuristic (first ${outbound.checked} of ${outbound.total})`
|
|
334
|
+
: "outbound link reachability (browser-rendered · DNS · HTTP · unavailable-shell heuristic)");
|
|
328
335
|
}
|
|
336
|
+
if (outbound?.mailtos && !outbound.mailtoSkipped) checkedFor.push("mailto address domains (MX/A records)");
|
|
329
337
|
notChecked = [
|
|
330
338
|
"app-specific business logic (cover with Flows: record or generate, then assert)",
|
|
331
339
|
"content and claim accuracy (including copy versus API data)",
|
|
@@ -335,8 +343,8 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
335
343
|
"only the first few visible buttons per page are probed (web beta)",
|
|
336
344
|
"content & reachability regressions require a baseline",
|
|
337
345
|
];
|
|
338
|
-
if (outbound?.
|
|
339
|
-
|
|
346
|
+
if (outbound?.mailtoSkipped === "egress-policy") notChecked.push("mailto address domains (MX lookups are skipped by the public-egress policy)");
|
|
347
|
+
if (!outbound || (!outbound.total && !outbound.mailtos)) conditionsNotReached.push("outbound links (none encountered this run)");
|
|
340
348
|
if (inputFieldsEncountered.length && !loginAttempted) {
|
|
341
349
|
notChecked.push(`form submission (${inputFieldsEncountered.reduce((n, s) => n + s.fields.length, 0)} field(s) catalogued, none submitted)`);
|
|
342
350
|
}
|
|
@@ -400,6 +408,12 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
400
408
|
...(action.reason ? { reason: action.reason } : {}),
|
|
401
409
|
})),
|
|
402
410
|
evidence: { markers: base.relativeMarkersFilePath },
|
|
411
|
+
// Capture conditions shared by every screenshot/marker in this run (web: from the driver's
|
|
412
|
+
// CONTEXT marker). A baseline diff across different captures is a layout comparison, not a
|
|
413
|
+
// regression signal — consumers must be able to see that.
|
|
414
|
+
capture: base.context && typeof base.context === "object"
|
|
415
|
+
? { device: base.context.device || null, viewport: base.context.viewport || null, deviceScaleFactor: base.context.deviceScaleFactor ?? null }
|
|
416
|
+
: null,
|
|
403
417
|
uiMap: null,
|
|
404
418
|
comparison: null,
|
|
405
419
|
checkedFor,
|
|
@@ -543,7 +557,11 @@ export function computeRegression(current, baseline) {
|
|
|
543
557
|
// the evidence." Exit codes are the CI contract; precedence is fail > inconclusive > pass.
|
|
544
558
|
export const GATE_EXIT = { pass: 0, fail: 1, error: 2, inconclusive: 3 };
|
|
545
559
|
// Bump when the gate's decision semantics change (NOT the npm version). Recorded on every GateRun.
|
|
546
|
-
|
|
560
|
+
// v3: `failOn` gained severity thresholds ("high" | "medium") that block on any deterministic-tier
|
|
561
|
+
// finding at or above that severity, and the CLI defaults web targets to `medium` — a 404 in the
|
|
562
|
+
// nav is the release blocker on a website, and a field-tested green PASS over six deterministic
|
|
563
|
+
// findings was exactly the dishonest verdict this product refuses to render.
|
|
564
|
+
export const GATE_POLICY_VERSION = "4";
|
|
547
565
|
|
|
548
566
|
// Pure gate evaluator: frozen evidence + policy → a GateRun decision. Extracted verbatim from the
|
|
549
567
|
// former inline logic in ci-report.js so the `[char]` characterization tests keep passing — the
|
|
@@ -592,6 +610,13 @@ export function evaluateGate({ report, regression = null, flows = [], scenarios
|
|
|
592
610
|
if (report.findingCounts.total > 0) fail(`${report.findingCounts.total} finding(s) (fail-on: any)`);
|
|
593
611
|
// "any" is the strictest policy — an inconclusive run (evidence not obtained) must never pass it.
|
|
594
612
|
if (report.inconclusive) inconclusive("run was inconclusive (coverage floor not met)");
|
|
613
|
+
} else if (failOn === "medium" || failOn === "high") {
|
|
614
|
+
// Severity thresholds are absolute over the deterministic tier: anything at or above the
|
|
615
|
+
// requested severity blocks, baseline or not. Sampled/advisory findings never participate.
|
|
616
|
+
const counts = report.deterministicFindingCounts || {};
|
|
617
|
+
const blocking = (counts.critical || 0) + (counts.high || 0) + (failOn === "medium" ? counts.medium || 0 : 0);
|
|
618
|
+
if (blocking > 0) fail(`${blocking} deterministic finding(s) at or above ${failOn} severity (fail-on: ${failOn})`);
|
|
619
|
+
if (report.inconclusive) inconclusive("run was inconclusive (coverage floor not met)");
|
|
595
620
|
} else if (failOn === "absolute" || (failOn === "gate" && !regression)) {
|
|
596
621
|
if (findingsBlock(report.deterministicFindingCounts, { inconclusive: report.inconclusive })) fail("blocking deterministic finding(s)");
|
|
597
622
|
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);
|
|
@@ -573,7 +576,12 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
573
576
|
|
|
574
577
|
const { chromium, devices } = await loadPlaywright();
|
|
575
578
|
const browser = await chromium.launch(webBrowserLaunchOptions(process.env, { watch }));
|
|
576
|
-
const
|
|
579
|
+
const captureProfile = webContextOptions({ device, viewport, devices });
|
|
580
|
+
// Evidence is only comparable when the capture conditions are on record: a desktop run and a
|
|
581
|
+
// phone run legitimately disagree about which nav links exist, and a baseline diff across
|
|
582
|
+
// them must be able to say so instead of reporting "resolved".
|
|
583
|
+
emit("CONTEXT", { ...(String(device || "").trim() ? { device: String(device).trim() } : {}), viewport: captureProfile.viewport, deviceScaleFactor: captureProfile.deviceScaleFactor ?? 1 });
|
|
584
|
+
const context = await browser.newContext(captureProfile);
|
|
577
585
|
await installWebListenerTracking(context);
|
|
578
586
|
if (watch) await installWebWatchUi(context);
|
|
579
587
|
const page = await context.newPage();
|
|
@@ -581,9 +589,12 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
581
589
|
|
|
582
590
|
const deadline = Date.now() + timeoutSec * 1000;
|
|
583
591
|
const issues = []; // emitted immediately; kept for counting only
|
|
584
|
-
const issue = (type, severity, title, screen, target) => {
|
|
592
|
+
const issue = (type, severity, title, screen, target, sourceUrl) => {
|
|
585
593
|
issues.push(type);
|
|
586
|
-
|
|
594
|
+
// Findings belong to the page that CARRIED the defect. Async detectors default to the
|
|
595
|
+
// current page; the post-crawl outbound audit passes the link's source page explicitly so
|
|
596
|
+
// a bad footer link is never attributed to whatever page happened to be visited last.
|
|
597
|
+
const pageUrl = sourceUrl || page.url();
|
|
587
598
|
emit("ISSUE", { type, severity, title, screen, ...(target ? { target } : {}), ...(pageUrl && pageUrl !== "about:blank" ? { url: pageUrl } : {}) });
|
|
588
599
|
};
|
|
589
600
|
|
|
@@ -628,6 +639,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
628
639
|
const outboundLinks = new Map(); // href → { label, screen }
|
|
629
640
|
const mailtoLinks = new Map(); // address → { screen }
|
|
630
641
|
const outbound = { total: 0, checked: 0, mailtos: 0 };
|
|
642
|
+
let probeCapHit = false;
|
|
631
643
|
let actions = 0;
|
|
632
644
|
let screenCount = 0;
|
|
633
645
|
let lastScreen = null;
|
|
@@ -930,14 +942,14 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
930
942
|
try {
|
|
931
943
|
if (/^mailto:/i.test(link.raw)) {
|
|
932
944
|
const address = link.raw.replace(/^mailto:/i, "").split("?")[0].trim();
|
|
933
|
-
if (address.includes("@") && !mailtoLinks.has(address)) mailtoLinks.set(address, { screen: ob.screen });
|
|
945
|
+
if (address.includes("@") && !mailtoLinks.has(address)) mailtoLinks.set(address, { screen: ob.screen, sourceUrl: start.origin + ob.key });
|
|
934
946
|
continue;
|
|
935
947
|
}
|
|
936
948
|
const u = new URL(link.href);
|
|
937
949
|
if (!/^https?:$/.test(u.protocol)) continue;
|
|
938
950
|
if (u.origin !== start.origin) {
|
|
939
951
|
const clean = u.origin + u.pathname;
|
|
940
|
-
if (!outboundLinks.has(clean)) outboundLinks.set(clean, { label: link.label, screen: ob.screen });
|
|
952
|
+
if (!outboundLinks.has(clean)) outboundLinks.set(clean, { label: link.label, screen: ob.screen, sourceUrl: start.origin + ob.key });
|
|
941
953
|
continue;
|
|
942
954
|
}
|
|
943
955
|
const key = u.pathname.replace(/\/+$/, "") + u.search || "/";
|
|
@@ -950,7 +962,9 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
950
962
|
// Bounded button pass: click, watch for effect, flag dead controls (the web analog
|
|
951
963
|
// of the iOS dead-button detector). Navigations are undone so BFS order holds.
|
|
952
964
|
const buttons = page.locator("button:visible, [role=button]:visible, input[type=submit]:visible, summary:visible");
|
|
953
|
-
const
|
|
965
|
+
const buttonTotal = await buttons.count().catch(() => 0);
|
|
966
|
+
const n = Math.min(buttonTotal, BUTTONS_PER_PAGE);
|
|
967
|
+
if (buttonTotal > BUTTONS_PER_PAGE) probeCapHit = true;
|
|
954
968
|
for (let i = 0; i < n && actions < maxActions && Date.now() < deadline; i++) {
|
|
955
969
|
const b = buttons.nth(i);
|
|
956
970
|
const label = webControlLabel({
|
|
@@ -1000,56 +1014,65 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
1000
1014
|
progress();
|
|
1001
1015
|
}
|
|
1002
1016
|
|
|
1003
|
-
// Post-crawl outbound audit
|
|
1004
|
-
//
|
|
1005
|
-
//
|
|
1017
|
+
// Post-crawl outbound audit. Link checks run through the LIVE browser context: JS-rendered
|
|
1018
|
+
// "unavailable" shells (Facebook et al.) are only visible to a real renderer, and browser
|
|
1019
|
+
// navigation honors the egress proxy policy, so these checks never bypass it. Only the
|
|
1020
|
+
// mailto MX lookups use node:dns and are therefore skipped under the enforced-egress policy.
|
|
1006
1021
|
outbound.total = outboundLinks.size;
|
|
1007
1022
|
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
|
-
};
|
|
1023
|
+
if (outboundLinks.size) {
|
|
1024
|
+
const auditPage = await context.newPage();
|
|
1025
|
+
auditPage.setDefaultTimeout(8000);
|
|
1019
1026
|
const targets = [...outboundLinks.entries()].slice(0, OUTBOUND_LINK_LIMIT);
|
|
1020
1027
|
outbound.checked = targets.length;
|
|
1021
1028
|
for (const [href, meta] of targets) {
|
|
1022
1029
|
if (Date.now() >= deadline) break;
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1025
|
-
|
|
1030
|
+
const nav = await auditPage.goto(href, { waitUntil: "domcontentloaded", timeout: 8000 })
|
|
1031
|
+
.catch((error) => ({ navError: String(error && error.message || error) }));
|
|
1032
|
+
if (nav && nav.navError) {
|
|
1033
|
+
const dnsFailure = /ERR_NAME_NOT_RESOLVED/i.test(nav.navError);
|
|
1034
|
+
issue("unresolvable_host", "medium",
|
|
1035
|
+
dnsFailure
|
|
1036
|
+
? `Outbound link host does not resolve: ${new URL(href).hostname}`
|
|
1037
|
+
: `Outbound link unreachable: ${href.slice(0, 100)} (${nav.navError.slice(0, 60)})`,
|
|
1038
|
+
meta.screen, href, meta.sourceUrl);
|
|
1026
1039
|
continue;
|
|
1027
1040
|
}
|
|
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);
|
|
1041
|
+
if (nav && typeof nav.status === "function" && nav.status() >= 400) {
|
|
1042
|
+
issue("broken_link", "medium", `Outbound link returns HTTP ${nav.status()}: ${href.slice(0, 100)}`, meta.screen, href, meta.sourceUrl);
|
|
1043
|
+
continue;
|
|
1038
1044
|
}
|
|
1045
|
+
await auditPage.waitForTimeout(400); // let client-rendered shells paint their copy
|
|
1046
|
+
const text = await auditPage.evaluate(() => (document.body && document.body.innerText || "").slice(0, 120000)).catch(() => "");
|
|
1047
|
+
const phrase = webUnavailableShellPhrase(text);
|
|
1048
|
+
if (phrase) issue("outbound_unavailable", "medium", `Outbound link returns 200 but shows "${phrase}": ${href.slice(0, 100)}`, meta.screen, href, meta.sourceUrl);
|
|
1039
1049
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1050
|
+
await auditPage.close().catch(() => {});
|
|
1051
|
+
}
|
|
1052
|
+
if (mailtoLinks.size) {
|
|
1053
|
+
if (process.env.TAPP_ENFORCE_PUBLIC_EGRESS === "1") {
|
|
1054
|
+
outbound.mailtoSkipped = "egress-policy"; // node:dns would bypass the enforced proxy
|
|
1055
|
+
} else {
|
|
1056
|
+
const dns = await import("node:dns/promises");
|
|
1057
|
+
for (const [address, meta] of mailtoLinks) {
|
|
1058
|
+
if (Date.now() >= deadline) break;
|
|
1059
|
+
const domain = (address.split("@")[1] || "").toLowerCase();
|
|
1060
|
+
if (!domain) continue;
|
|
1061
|
+
// RFC 5321 implicit-MX: a domain with no MX but an A/AAAA record can still receive.
|
|
1062
|
+
const deliverable = await dns.resolveMx(domain).then((records) => records.length > 0).catch(() => false)
|
|
1063
|
+
|| await dns.lookup(domain).then(() => true).catch(() => false);
|
|
1064
|
+
if (!deliverable) issue("mailto_no_mx", "medium", `mailto: domain cannot receive email (no MX or address record): ${address}`, meta.screen, address, meta.sourceUrl);
|
|
1065
|
+
}
|
|
1048
1066
|
}
|
|
1049
1067
|
}
|
|
1050
1068
|
} finally {
|
|
1051
1069
|
const timedOut = Date.now() >= deadline;
|
|
1052
|
-
|
|
1070
|
+
// A drained frontier with a truncating probe cap is NOT "nothing left" — twin controls may
|
|
1071
|
+
// sit untapped past the per-page cap, and the stop reason must not claim otherwise.
|
|
1072
|
+
const stop = timedOut ? "time-budget"
|
|
1073
|
+
: actions >= maxActions ? "action-budget"
|
|
1074
|
+
: probeCapHit ? "probe-cap"
|
|
1075
|
+
: "frontier-drained";
|
|
1053
1076
|
emit("COMPLETE", { actions, screens: screenCount, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried, timedOut, stop, outbound });
|
|
1054
1077
|
fs.closeSync(markersFd);
|
|
1055
1078
|
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.7",
|
|
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
|