@aarwitz/tapp 0.17.6 → 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 +19 -2
- package/mcp-server/src/html-report.js +1 -0
- package/mcp-server/src/index.js +4 -2
- package/mcp-server/src/report.js +15 -2
- package/mcp-server/src/web-explorer.js +7 -2
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tapp",
|
|
3
3
|
"description": "Give Claude hands and eyes on iOS, Android, and web apps, with exploration, replayable flows, evidence, and deterministic CI gates.",
|
|
4
|
-
"version": "0.17.
|
|
4
|
+
"version": "0.17.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
|
|
@@ -178,6 +178,7 @@ function loadBaseline(baselinePath) {
|
|
|
178
178
|
actionsPerformed: parsed.actionsPerformed || 0,
|
|
179
179
|
platform: parsed.baselineIdentity?.platform || parsed.platform || null,
|
|
180
180
|
targetKey: parsed.baselineIdentity?.targetId || parsed.targetKey || null,
|
|
181
|
+
capture: parsed.capture || null,
|
|
181
182
|
};
|
|
182
183
|
}
|
|
183
184
|
|
|
@@ -399,6 +400,10 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
|
|
|
399
400
|
lines.push(`### Since baseline — ${regFailed ? "🔴 regression gate FAILED" : "🟢 regression gate passed"}`);
|
|
400
401
|
lines.push(`+${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved` +
|
|
401
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
|
+
}
|
|
402
407
|
for (const f of regression.newFindings) {
|
|
403
408
|
lines.push(`- NEW ${SEV_ICON[f.severity] || ""} ${f.severity}: ${f.title} (${f.screen ?? "—"})`);
|
|
404
409
|
}
|
|
@@ -491,7 +496,11 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
|
|
|
491
496
|
lines.push(`**Gate (${gate.policy}): ${badge}**${gate.reasons.length ? " — " + gate.reasons.join("; ") : ""}${ignoredNote}`);
|
|
492
497
|
// The gate is only authoritative about what it actually ran — record the scope explicitly.
|
|
493
498
|
const rev = gate.revision?.sha ? `${String(gate.revision.sha).slice(0, 12)}${gate.revision.dirty ? "-dirty" : ""}` : "unknown";
|
|
494
|
-
|
|
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}_`);
|
|
495
504
|
if (Array.isArray(gate.checked) && gate.checked.length) lines.push(`_Checked: ${gate.checked.join(" · ")}_`);
|
|
496
505
|
if (Array.isArray(gate.notChecked) && gate.notChecked.length) lines.push(`_Not checked: ${gate.notChecked.join(" · ")}_`);
|
|
497
506
|
return lines.join("\n");
|
|
@@ -550,6 +559,12 @@ if (collapsed.length) {
|
|
|
550
559
|
report.headline = `${collapsed.length} screen(s) regressed vs. baseline (content collapsed or became unreachable).`;
|
|
551
560
|
}
|
|
552
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
|
+
}
|
|
553
568
|
const runs = args.flowLogs.map(parseFlowLog);
|
|
554
569
|
const contracts = runs.filter((run) => run.kind === "release-contract");
|
|
555
570
|
const flows = runs.filter((run) => !["scenario", "release-contract"].includes(run.kind));
|
|
@@ -572,7 +587,9 @@ try {
|
|
|
572
587
|
const decision = evaluateGate({ report, regression, flows, scenarios, contracts, prPlan, baseline, failOn: args.failOn });
|
|
573
588
|
const gate = {
|
|
574
589
|
...decision,
|
|
575
|
-
|
|
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,
|
|
576
593
|
revision: gitRevision(args.projectDir),
|
|
577
594
|
checked: report.checkedFor,
|
|
578
595
|
notChecked: report.notChecked,
|
|
@@ -140,6 +140,7 @@ ${r.conditionsNotReached?.length ? `<div><h2>Conditions not reached</h2><ul>${sc
|
|
|
140
140
|
<h1>${esc(observationBadge(r))} <span class="dim">· ${esc(observationSummary(r))}</span></h1>
|
|
141
141
|
<div class="meta">${esc(label)} · evidence page (observation, not a release decision)</div>
|
|
142
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>` : ""}
|
|
143
144
|
<div class="headline">${esc(r.headline)}</div>
|
|
144
145
|
${r.credentialWarning ? `<div class="warning">${esc(r.credentialWarning)}</div>` : ""}
|
|
145
146
|
${scopeHtml}
|
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`,
|
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;
|
|
@@ -401,6 +408,12 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
401
408
|
...(action.reason ? { reason: action.reason } : {}),
|
|
402
409
|
})),
|
|
403
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,
|
|
404
417
|
uiMap: null,
|
|
405
418
|
comparison: null,
|
|
406
419
|
checkedFor,
|
|
@@ -548,7 +561,7 @@ export const GATE_EXIT = { pass: 0, fail: 1, error: 2, inconclusive: 3 };
|
|
|
548
561
|
// finding at or above that severity, and the CLI defaults web targets to `medium` — a 404 in the
|
|
549
562
|
// nav is the release blocker on a website, and a field-tested green PASS over six deterministic
|
|
550
563
|
// findings was exactly the dishonest verdict this product refuses to render.
|
|
551
|
-
export const GATE_POLICY_VERSION = "
|
|
564
|
+
export const GATE_POLICY_VERSION = "4";
|
|
552
565
|
|
|
553
566
|
// Pure gate evaluator: frozen evidence + policy → a GateRun decision. Extracted verbatim from the
|
|
554
567
|
// former inline logic in ci-report.js so the `[char]` characterization tests keep passing — the
|
|
@@ -576,7 +576,12 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
576
576
|
|
|
577
577
|
const { chromium, devices } = await loadPlaywright();
|
|
578
578
|
const browser = await chromium.launch(webBrowserLaunchOptions(process.env, { watch }));
|
|
579
|
-
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);
|
|
580
585
|
await installWebListenerTracking(context);
|
|
581
586
|
if (watch) await installWebWatchUi(context);
|
|
582
587
|
const page = await context.newPage();
|
|
@@ -1040,7 +1045,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
1040
1045
|
await auditPage.waitForTimeout(400); // let client-rendered shells paint their copy
|
|
1041
1046
|
const text = await auditPage.evaluate(() => (document.body && document.body.innerText || "").slice(0, 120000)).catch(() => "");
|
|
1042
1047
|
const phrase = webUnavailableShellPhrase(text);
|
|
1043
|
-
if (phrase) issue("outbound_unavailable", "
|
|
1048
|
+
if (phrase) issue("outbound_unavailable", "medium", `Outbound link returns 200 but shows "${phrase}": ${href.slice(0, 100)}`, meta.screen, href, meta.sourceUrl);
|
|
1044
1049
|
}
|
|
1045
1050
|
await auditPage.close().catch(() => {});
|
|
1046
1051
|
}
|
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",
|