@aarwitz/tapp 0.17.4 → 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/bin/tapp.js +13 -4
- 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 +25 -11
- package/mcp-server/src/report.js +56 -6
- package/mcp-server/src/web-explorer.js +165 -23
- package/mcp-server/src/web-flow.js +3 -3
- package/package.json +2 -2
- package/scripts/ci-gate.sh +44 -8
- package/scripts/platform-gate.js +11 -5
- package/scripts/run-web-flow.js +8 -1
- package/skills/tapp/references/commands.md +7 -0
|
@@ -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/bin/tapp.js
CHANGED
|
@@ -262,15 +262,15 @@ async function resolveTargetOrExit(engine, input) {
|
|
|
262
262
|
|
|
263
263
|
function safeCommandUsage(verb) {
|
|
264
264
|
const usage = {
|
|
265
|
-
explore: "tapp explore [target] [--platform ios|android|web] [--actions N] [--timeout SEC] [--email VALUE] [--password VALUE] [--baseline FILE] [--json FILE]\n Web: [--watch] opens Tapp's controlled browser and shows its actions\n iOS launch configuration: [--launch-arg VALUE ...] [--launch-env '{\"KEY\":\"VALUE\"}']\n Android: [--app-id ID] [--apk FILE] [--serial ID] [--keep-data]",
|
|
265
|
+
explore: "tapp explore [target] [--platform ios|android|web] [--actions N] [--timeout SEC] [--email VALUE] [--password VALUE] [--baseline FILE] [--json FILE]\n Web: [--watch] opens Tapp's controlled browser and shows its actions; [--device \"iPhone 13\"] [--viewport 390x844] render at a device profile or explicit size\n iOS launch configuration: [--launch-arg VALUE ...] [--launch-env '{\"KEY\":\"VALUE\"}']\n Android: [--app-id ID] [--apk FILE] [--serial ID] [--keep-data]",
|
|
266
266
|
focus: "tapp focus \"SCREEN OR CONTROL\" [target] [--platform ios|android|web] [--project-dir REPO] [--target NAME|PATH] [--map FILE] [--out FILE]",
|
|
267
267
|
init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--watch] [--dry-run]",
|
|
268
|
-
open: "tapp open [target] [--platform ios|android|web] [--out FILE] [--tap TEXT] [--wait-for TEXT]",
|
|
269
|
-
tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]",
|
|
268
|
+
open: "tapp open [target] [--platform ios|android|web] [--out FILE] [--tap TEXT] [--wait-for TEXT]\n Web: [--device \"iPhone 13\"] [--viewport 390x844] [--full-page]",
|
|
269
|
+
tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]\n Web: [--device \"iPhone 13\"] [--viewport 390x844]",
|
|
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]",
|
|
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]",
|
|
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
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
276
|
scenario: "tapp scenario validate FILE [--project-dir DIR]\ntapp scenario run FILE --platform web --url URL [--project-dir DIR]",
|
|
@@ -774,6 +774,8 @@ switch (command) {
|
|
|
774
774
|
testPassword: flags.password,
|
|
775
775
|
baselineFindings,
|
|
776
776
|
watch: flags.watch === true,
|
|
777
|
+
device: typeof flags.device === "string" ? flags.device : "",
|
|
778
|
+
viewport: typeof flags.viewport === "string" ? flags.viewport : "",
|
|
777
779
|
surface: "cli",
|
|
778
780
|
onProgress,
|
|
779
781
|
})
|
|
@@ -827,6 +829,9 @@ switch (command) {
|
|
|
827
829
|
timeoutMs: Number(flags.timeout) * 1000 || 15_000,
|
|
828
830
|
tapText: typeof flags.tap === "string" ? flags.tap : "",
|
|
829
831
|
waitForText: typeof flags["wait-for"] === "string" ? flags["wait-for"] : "",
|
|
832
|
+
device: typeof flags.device === "string" ? flags.device : "",
|
|
833
|
+
viewport: typeof flags.viewport === "string" ? flags.viewport : "",
|
|
834
|
+
fullPage: flags["full-page"] === true,
|
|
830
835
|
});
|
|
831
836
|
const out = typeof flags.out === "string" ? path.resolve(flags.out) : path.join(tappHome, "shots", `web-${Date.now()}.png`);
|
|
832
837
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
@@ -899,6 +904,8 @@ switch (command) {
|
|
|
899
904
|
screenshot: false,
|
|
900
905
|
tapText: typeof flags.tap === "string" ? flags.tap : "",
|
|
901
906
|
waitForText: typeof flags["wait-for"] === "string" ? flags["wait-for"] : "",
|
|
907
|
+
device: typeof flags.device === "string" ? flags.device : "",
|
|
908
|
+
viewport: typeof flags.viewport === "string" ? flags.viewport : "",
|
|
902
909
|
});
|
|
903
910
|
if (flags.json) console.log(JSON.stringify({ platform: "web", url: snap.url, screenTitle: snap.screenTitle, settled: snap.settled, elements: snap.elements }, null, 2));
|
|
904
911
|
else console.log(engine.formatScreen(snap.screenTitle, snap.elements));
|
|
@@ -1146,6 +1153,8 @@ switch (command) {
|
|
|
1146
1153
|
const env = { ...process.env, FLOW_LOG: flowLog, TAPP_FLOW_EVIDENCE_DIR: evidenceDir };
|
|
1147
1154
|
if (typeof flags.email === "string") env.OCQA_TEST_EMAIL = flags.email;
|
|
1148
1155
|
if (typeof flags.password === "string") env.OCQA_TEST_PASSWORD = flags.password;
|
|
1156
|
+
if (typeof flags.device === "string") env.TAPP_WEB_DEVICE = flags.device;
|
|
1157
|
+
if (typeof flags.viewport === "string") env.TAPP_WEB_VIEWPORT = flags.viewport;
|
|
1149
1158
|
if (typeof flags.actor === "string" && flags.actor) {
|
|
1150
1159
|
const { readProjectConfig } = await import(path.join(packageRoot, "mcp-server", "src", "project-config.js"));
|
|
1151
1160
|
const loaded = readProjectConfig(process.cwd());
|
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
|
@@ -778,16 +778,16 @@ async function firstVisibleWebLocator(page, target, { input = false } = {}) {
|
|
|
778
778
|
return await fallback.isVisible().catch(() => false) ? fallback : null;
|
|
779
779
|
}
|
|
780
780
|
|
|
781
|
-
async function startWebSession(url, { testEmail = "", testPassword = "" } = {}) {
|
|
781
|
+
async function startWebSession(url, { testEmail = "", testPassword = "", device = "", viewport = "" } = {}) {
|
|
782
782
|
if (activeSession && !activeSession.ended) return { error: "A session is already active; call tapp_session_end first.", screen:treeSnapshot() };
|
|
783
783
|
let browser;
|
|
784
784
|
try {
|
|
785
785
|
const parsed = new URL(String(url || ""));
|
|
786
786
|
if (!/^https?:$/.test(parsed.protocol)) return { error:"Web session URL must be http(s)" };
|
|
787
|
-
const { loadPlaywright } = await import("./web-explorer.js");
|
|
788
|
-
const { chromium } = await loadPlaywright();
|
|
787
|
+
const { loadPlaywright, webContextOptions } = await import("./web-explorer.js");
|
|
788
|
+
const { chromium, devices } = await loadPlaywright();
|
|
789
789
|
browser = await chromium.launch({ headless:true });
|
|
790
|
-
const context = await browser.newContext({ viewport
|
|
790
|
+
const context = await browser.newContext(webContextOptions({ device, viewport, devices }));
|
|
791
791
|
const page = await context.newPage();
|
|
792
792
|
await page.goto(parsed.href, { waitUntil:"domcontentloaded", timeout:30_000 });
|
|
793
793
|
await page.waitForLoadState("networkidle", { timeout:5_000 }).catch(() => {});
|
|
@@ -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
|
|
|
@@ -1754,7 +1756,7 @@ export function formatScreen(screenTitle, elements) {
|
|
|
1754
1756
|
// `tapp` CLI verbs in bin/tapp.js — same pattern as report.js. Keep orchestration HERE so
|
|
1755
1757
|
// the surfaces can't drift.)
|
|
1756
1758
|
|
|
1757
|
-
export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], watch = false, surface = "mcp", onProgress = () => {} }) {
|
|
1759
|
+
export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], watch = false, surface = "mcp", onProgress = () => {}, device = "", viewport = "" }) {
|
|
1758
1760
|
const { storagePreflight } = await import("./environment-preflight.js");
|
|
1759
1761
|
const storage = storagePreflight(capturesDir);
|
|
1760
1762
|
if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
|
|
@@ -1776,6 +1778,8 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
|
|
|
1776
1778
|
seedTargets,
|
|
1777
1779
|
watch: watch === true,
|
|
1778
1780
|
onProgress,
|
|
1781
|
+
device: isNonEmptyString(device) ? device.trim() : "",
|
|
1782
|
+
viewport: isNonEmptyString(viewport) ? viewport.trim() : "",
|
|
1779
1783
|
});
|
|
1780
1784
|
} catch (err) {
|
|
1781
1785
|
return { error: String(err.message || err) };
|
|
@@ -2602,6 +2606,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2602
2606
|
clearData: { type: "boolean", default: true, description: "Android: clear app data before launch for a repeatable starting state." },
|
|
2603
2607
|
url: { type: "string", description: "Web (beta): URL of the app to explore in a real browser (same-origin only; your own app/staging). Provide exactly one of appBundleId | url." },
|
|
2604
2608
|
watch: { type: "boolean", default: false, description: "Web only: open Tapp's controlled Chromium window and show a cursor/HUD for each exploration action. Evidence screenshots exclude the overlay." },
|
|
2609
|
+
device: { type: "string", description: "Web only: render as a Playwright device profile (e.g. \"iPhone 13\") — viewport, user agent, touch" },
|
|
2610
|
+
viewport: { type: "string", description: "Web only: explicit viewport WIDTHxHEIGHT (e.g. \"390x844\"); overrides the device profile's viewport" },
|
|
2605
2611
|
maxActions: { type: "integer", minimum: 1, maximum: 1000, default: 60, description: "Exploration action budget" },
|
|
2606
2612
|
timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600, description: "Max wall-clock seconds" },
|
|
2607
2613
|
testEmail: { type: "string", description: "Email for the login preamble, if the app has a sign-in" },
|
|
@@ -2890,6 +2896,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2890
2896
|
testEmail: { type: "string", description: "Value for $TEST_EMAIL" },
|
|
2891
2897
|
testPassword: { type: "string", description: "Value for $TEST_PASSWORD" },
|
|
2892
2898
|
actor: { type: "string", description: "Configured actor name; resolves $TEST_EMAIL/$TEST_PASSWORD from the actor's env-var bindings (explicit testEmail/testPassword win)" },
|
|
2899
|
+
device: { type: "string", description: "Web flows: replay in a Playwright device profile (e.g. \"iPhone 13\")" },
|
|
2900
|
+
viewport: { type: "string", description: "Web flows: explicit viewport WIDTHxHEIGHT (e.g. \"390x844\")" },
|
|
2893
2901
|
},
|
|
2894
2902
|
},
|
|
2895
2903
|
},
|
|
@@ -3078,6 +3086,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
3078
3086
|
appBundleId: { type: "string", description: "Bundle id of the installed app to drive" },
|
|
3079
3087
|
androidAppId: { type: "string", description: "Android application id to drive (alternative to appBundleId)" },
|
|
3080
3088
|
url: { type: "string", description: "Owned http(s) web app URL to drive (alternative to appBundleId/androidAppId); omit all three target identifiers to build/start an unambiguous owned web target from projectDir" },
|
|
3089
|
+
device: { type: "string", description: "Web sessions: render as a Playwright device profile (e.g. \"iPhone 13\")" },
|
|
3090
|
+
viewport: { type: "string", description: "Web sessions: explicit viewport WIDTHxHEIGHT (e.g. \"390x844\")" },
|
|
3081
3091
|
target: { type: "string", description: "Optional managed-web target name/path when projectDir contains multiple browser applications" },
|
|
3082
3092
|
apkPath: { type: "string", description: "Android APK to install before starting" },
|
|
3083
3093
|
androidSerial: { type: "string", description: "Android adb device serial" },
|
|
@@ -3444,6 +3454,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3444
3454
|
testPassword: args.testPassword,
|
|
3445
3455
|
baselineFindings: args.baselineFindings,
|
|
3446
3456
|
watch: args.watch === true,
|
|
3457
|
+
device: isNonEmptyString(args.device) ? args.device.trim() : "",
|
|
3458
|
+
viewport: isNonEmptyString(args.viewport) ? args.viewport.trim() : "",
|
|
3447
3459
|
onProgress: notifyProgress("pages reached"),
|
|
3448
3460
|
});
|
|
3449
3461
|
if (r.error) return errorResult(r.error, r.details || {});
|
|
@@ -3983,6 +3995,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3983
3995
|
url: isNonEmptyString(args.url) ? args.url.trim() : undefined,
|
|
3984
3996
|
logPath: flowLog,
|
|
3985
3997
|
screenshotDir: evidenceDir,
|
|
3998
|
+
device: isNonEmptyString(args.device) ? args.device.trim() : "",
|
|
3999
|
+
viewport: isNonEmptyString(args.viewport) ? args.viewport.trim() : "",
|
|
3986
4000
|
});
|
|
3987
4001
|
run = { ...run, code: result.passed ? 0 : 1, evidenceDir };
|
|
3988
4002
|
} catch (error) {
|
|
@@ -4334,7 +4348,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4334
4348
|
: android
|
|
4335
4349
|
? await startAndroidSession(target, { serial: args.androidSerial, apkPath: args.apkPath, clearData: args.clearData !== false, testEmail: args.testEmail, testPassword: args.testPassword })
|
|
4336
4350
|
: web
|
|
4337
|
-
? await startWebSession(target, { testEmail:args.testEmail, testPassword:args.testPassword })
|
|
4351
|
+
? await startWebSession(target, { testEmail:args.testEmail, testPassword:args.testPassword, device:isNonEmptyString(args.device) ? args.device.trim() : "", viewport:isNonEmptyString(args.viewport) ? args.viewport.trim() : "" })
|
|
4338
4352
|
: await startManagedWebInteractiveSession({
|
|
4339
4353
|
projectDir:focusProjectDir,
|
|
4340
4354
|
requestedTarget:isNonEmptyString(args.target) ? args.target.trim() : "",
|
package/mcp-server/src/report.js
CHANGED
|
@@ -72,6 +72,7 @@ export function parseOcqaMarkers(markersFilePath) {
|
|
|
72
72
|
)
|
|
73
73
|
),
|
|
74
74
|
complete,
|
|
75
|
+
actions,
|
|
75
76
|
recentActions: actions.slice(-5),
|
|
76
77
|
recentTransitions: transitions.slice(-5),
|
|
77
78
|
recentIssues: issues.slice(-5),
|
|
@@ -87,6 +88,10 @@ export const ISSUE_CATEGORY = {
|
|
|
87
88
|
error_surface: "network_error_surface",
|
|
88
89
|
unresponsive_element: "unresponsive_element",
|
|
89
90
|
placeholder_link: "broken_link",
|
|
91
|
+
anchor_missing: "broken_link",
|
|
92
|
+
unresolvable_host: "broken_link",
|
|
93
|
+
mailto_no_mx: "broken_link",
|
|
94
|
+
outbound_unavailable: "broken_link",
|
|
90
95
|
dead_end: "navigation_dead_end",
|
|
91
96
|
navigation_loop: "repeated_loop",
|
|
92
97
|
navigation_trap: "navigation_dead_end",
|
|
@@ -96,7 +101,7 @@ export const ISSUE_CATEGORY = {
|
|
|
96
101
|
explore_timeout: "performance_timeout",
|
|
97
102
|
};
|
|
98
103
|
export const CRITICAL_ISSUE_TYPES = new Set(["crash"]);
|
|
99
|
-
export const WEB_SAMPLED_ISSUE_TYPES = new Set(["unresponsive_element"]);
|
|
104
|
+
export const WEB_SAMPLED_ISSUE_TYPES = new Set(["unresponsive_element", "outbound_unavailable"]);
|
|
100
105
|
|
|
101
106
|
export function severityRank(s) {
|
|
102
107
|
return { critical: 0, high: 1, medium: 2, low: 3 }[s] ?? 4;
|
|
@@ -206,7 +211,9 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
206
211
|
// keep the concrete missing-asset finding and discard that transport-level duplicate.
|
|
207
212
|
const normalizedIssues = rawIssues.map((issue) => {
|
|
208
213
|
if (platform !== "web") return issue;
|
|
209
|
-
if (
|
|
214
|
+
if (["placeholder_link", "anchor_missing", "unresolvable_host", "mailto_no_mx", "outbound_unavailable"].includes(issue.type) && issue.target) {
|
|
215
|
+
return { ...issue, screen: null };
|
|
216
|
+
}
|
|
210
217
|
if (!["missing_asset", "network_error"].includes(issue.type)) return issue;
|
|
211
218
|
const title = String(issue.title || "");
|
|
212
219
|
const match = issue.type === "missing_asset"
|
|
@@ -279,9 +286,17 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
279
286
|
: screensExplored >= 2 && actionsPerformed >= 3;
|
|
280
287
|
const unexercisedLoginWall = anySecure && !loginAttempted && screensExplored <= 1;
|
|
281
288
|
const inconclusive = !coverageFloorMet || unexercisedLoginWall || timeBudgetExhausted;
|
|
289
|
+
// "completed" is reserved for a run that exhausted its action budget; a drained frontier is
|
|
290
|
+
// reported as its own honest reason so a 2-action sweep of a small surface never reads like a
|
|
291
|
+
// 40-action campaign. Drivers signal the real cause in COMPLETE.stop; captures from drivers
|
|
292
|
+
// that predate the field keep the old inference.
|
|
293
|
+
const driverStop = base.complete && typeof base.complete === "object" ? base.complete.stop : null;
|
|
282
294
|
const stopReason = unexercisedLoginWall ? (credentialsProvided ? "login-wall-credentials-unused" : "login-wall-no-credentials")
|
|
283
295
|
: timeBudgetExhausted ? "time-budget-exhausted"
|
|
284
|
-
: coverageFloorMet ? "
|
|
296
|
+
: !coverageFloorMet ? "coverage-floor-not-met"
|
|
297
|
+
: driverStop === "frontier-drained" ? "no-unexplored-in-scope-controls"
|
|
298
|
+
: driverStop === "probe-cap" ? "probe-cap-reached"
|
|
299
|
+
: "completed";
|
|
285
300
|
|
|
286
301
|
const headline = timeBudgetExhausted
|
|
287
302
|
? `Inconclusive — exploration reached its ${base.complete?.timeoutSeconds || "configured"}s time budget after ${actionsPerformed} action(s) across ${screensExplored} screen(s). Findings are partial; this is not an app performance finding. Increase --timeout or request fewer actions.`
|
|
@@ -301,10 +316,17 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
301
316
|
let checkedFor;
|
|
302
317
|
let notChecked;
|
|
303
318
|
if (platform === "web") {
|
|
319
|
+
const outbound = base.complete && typeof base.complete === "object" ? base.complete.outbound : null;
|
|
304
320
|
checkedFor = [
|
|
305
|
-
"page errors (uncaught exceptions)", "failed/5xx requests", "broken links (404)",
|
|
306
|
-
"placeholder links with no destination", "sampled dead-button probes (advisory)", "error text on pages", "load timeouts",
|
|
321
|
+
"page errors (uncaught exceptions)", "failed/5xx requests", "broken links (404, same-origin crawl)",
|
|
322
|
+
"placeholder links and anchors with no destination", "sampled dead-button probes (advisory)", "error text on pages", "load timeouts",
|
|
307
323
|
];
|
|
324
|
+
if (outbound && outbound.total) {
|
|
325
|
+
checkedFor.push(outbound.total > outbound.checked
|
|
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
|
+
}
|
|
329
|
+
if (outbound?.mailtos && !outbound.mailtoSkipped) checkedFor.push("mailto address domains (MX/A records)");
|
|
308
330
|
notChecked = [
|
|
309
331
|
"app-specific business logic (cover with Flows: record or generate, then assert)",
|
|
310
332
|
"content and claim accuracy (including copy versus API data)",
|
|
@@ -314,6 +336,11 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
314
336
|
"only the first few visible buttons per page are probed (web beta)",
|
|
315
337
|
"content & reachability regressions require a baseline",
|
|
316
338
|
];
|
|
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)");
|
|
341
|
+
if (inputFieldsEncountered.length && !loginAttempted) {
|
|
342
|
+
notChecked.push(`form submission (${inputFieldsEncountered.reduce((n, s) => n + s.fields.length, 0)} field(s) catalogued, none submitted)`);
|
|
343
|
+
}
|
|
317
344
|
} else if (platform === "android") {
|
|
318
345
|
checkedFor = [
|
|
319
346
|
"crashes / process exits", "dead controls", "error surfaces", "blank screens",
|
|
@@ -361,6 +388,18 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
361
388
|
headline,
|
|
362
389
|
inconclusive,
|
|
363
390
|
coverage: { screensExplored, actionsPerformed, screens: Array.from(screens) },
|
|
391
|
+
// Per-action evidence: what was done, to what, on which screen, at what offset — so an
|
|
392
|
+
// agent can distinguish "clicked Services, clicked FAQ" from "clicked the same button twice".
|
|
393
|
+
trace: (base.actions || [])
|
|
394
|
+
.filter((action) => action && typeof action === "object")
|
|
395
|
+
.map((action) => ({
|
|
396
|
+
...(typeof action.t === "number" ? { t: action.t } : {}),
|
|
397
|
+
type: action.type || "action",
|
|
398
|
+
target: action.target || "",
|
|
399
|
+
...(action.screen ? { screen: action.screen } : {}),
|
|
400
|
+
...(action.via ? { via: action.via } : {}),
|
|
401
|
+
...(action.reason ? { reason: action.reason } : {}),
|
|
402
|
+
})),
|
|
364
403
|
evidence: { markers: base.relativeMarkersFilePath },
|
|
365
404
|
uiMap: null,
|
|
366
405
|
comparison: null,
|
|
@@ -505,7 +544,11 @@ export function computeRegression(current, baseline) {
|
|
|
505
544
|
// the evidence." Exit codes are the CI contract; precedence is fail > inconclusive > pass.
|
|
506
545
|
export const GATE_EXIT = { pass: 0, fail: 1, error: 2, inconclusive: 3 };
|
|
507
546
|
// Bump when the gate's decision semantics change (NOT the npm version). Recorded on every GateRun.
|
|
508
|
-
|
|
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";
|
|
509
552
|
|
|
510
553
|
// Pure gate evaluator: frozen evidence + policy → a GateRun decision. Extracted verbatim from the
|
|
511
554
|
// former inline logic in ci-report.js so the `[char]` characterization tests keep passing — the
|
|
@@ -554,6 +597,13 @@ export function evaluateGate({ report, regression = null, flows = [], scenarios
|
|
|
554
597
|
if (report.findingCounts.total > 0) fail(`${report.findingCounts.total} finding(s) (fail-on: any)`);
|
|
555
598
|
// "any" is the strictest policy — an inconclusive run (evidence not obtained) must never pass it.
|
|
556
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)");
|
|
557
607
|
} else if (failOn === "absolute" || (failOn === "gate" && !regression)) {
|
|
558
608
|
if (findingsBlock(report.deterministicFindingCounts, { inconclusive: report.inconclusive })) fail("blocking deterministic finding(s)");
|
|
559
609
|
if (report.inconclusive) inconclusive("run was inconclusive (coverage floor not met)");
|
|
@@ -23,6 +23,7 @@ import { execFileSync } from "child_process";
|
|
|
23
23
|
const CLICK_SETTLE_MS = 700;
|
|
24
24
|
const NAV_TIMEOUT_MS = 15_000;
|
|
25
25
|
const BUTTONS_PER_PAGE = 4;
|
|
26
|
+
const OUTBOUND_LINK_LIMIT = 10;
|
|
26
27
|
const WATCH_ACTION_DELAY_MS = 350;
|
|
27
28
|
const ERROR_TEXT_RE = /\b(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)\b/i;
|
|
28
29
|
const STANDALONE_ERROR_TEXT_RE = /^(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)(?:[.!:]|\s|$)/i;
|
|
@@ -299,6 +300,42 @@ export function webBrowserLaunchOptions(environment = process.env, { watch = fal
|
|
|
299
300
|
};
|
|
300
301
|
}
|
|
301
302
|
|
|
303
|
+
export function parseWebViewport(value) {
|
|
304
|
+
if (!value) return null;
|
|
305
|
+
const match = /^(\d{2,5})[xX](\d{2,5})$/.exec(String(value).trim());
|
|
306
|
+
if (!match) throw new Error(`Invalid viewport '${value}'; expected WIDTHxHEIGHT, e.g. 390x844`);
|
|
307
|
+
return { width: Number(match[1]), height: Number(match[2]) };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// The one seam for every web browser context: an optional Playwright device profile (viewport,
|
|
311
|
+
// user agent, touch, scale factor) with an explicit WIDTHxHEIGHT override on top; the historical
|
|
312
|
+
// 1280×900 desktop default otherwise. `defaultBrowserType` is stripped because newContext
|
|
313
|
+
// rejects it — we always drive the profile through chromium.
|
|
314
|
+
export function webContextOptions({ device = "", viewport = "", devices = null } = {}) {
|
|
315
|
+
const name = String(device || "").trim();
|
|
316
|
+
let base = {};
|
|
317
|
+
if (name) {
|
|
318
|
+
const profile = devices ? devices[name] : null;
|
|
319
|
+
if (!profile) {
|
|
320
|
+
const head = name.toLowerCase().split(" ")[0];
|
|
321
|
+
const close = devices ? Object.keys(devices).filter((d) => d.toLowerCase().includes(head)).slice(0, 5) : [];
|
|
322
|
+
throw new Error(`Unknown Playwright device '${name}'${close.length ? `; close matches: ${close.join(", ")}` : ""}`);
|
|
323
|
+
}
|
|
324
|
+
const { defaultBrowserType: _ignored, ...rest } = profile;
|
|
325
|
+
base = rest;
|
|
326
|
+
}
|
|
327
|
+
const parsed = parseWebViewport(viewport);
|
|
328
|
+
return { ...base, viewport: parsed || base.viewport || { width: 1280, height: 900 } };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Conservative "the page answered 200 but is an unavailable shell" phrases (outbound links to
|
|
332
|
+
// social platforms that never 404). Precision over recall: only unmistakable copy matches.
|
|
333
|
+
export function webUnavailableShellPhrase(html) {
|
|
334
|
+
const text = String(html || "").slice(0, 120_000);
|
|
335
|
+
const match = /(this content isn'?t available|content (?:is )?(?:currently )?unavailable|this page isn'?t available|page (?:can'?t|cannot) be found|page not found|isn'?t available right now)/i.exec(text);
|
|
336
|
+
return match ? match[1] : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
302
339
|
// A headed Playwright browser does not move the host OS pointer when locator.click() runs. In
|
|
303
340
|
// explicit watch mode, draw a pointer inside the controlled page so a human can follow Tapp's
|
|
304
341
|
// real actions. The UI lives in a closed shadow root, ignores pointer events, and is hidden from
|
|
@@ -398,16 +435,16 @@ async function screenshotWithoutWebWatchUi(page, options, watch) {
|
|
|
398
435
|
// Focused one-screen inspection for the agent-facing `tapp open <url>` and `tapp tree <url>`
|
|
399
436
|
// commands. This deliberately does no exploration or judgment; it opens exactly one page,
|
|
400
437
|
// captures the visible semantic controls, and optionally takes one screenshot.
|
|
401
|
-
export async function inspectWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, screenshot = true, tapText = "", waitForText = "" }) {
|
|
438
|
+
export async function inspectWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, screenshot = true, tapText = "", waitForText = "", device = "", viewport = "", fullPage = false }) {
|
|
402
439
|
let target;
|
|
403
440
|
try { target = new URL(url); }
|
|
404
441
|
catch { throw new Error("Web inspection needs a valid http(s) URL"); }
|
|
405
442
|
if (!/^https?:$/.test(target.protocol)) throw new Error("Web inspection needs a valid http(s) URL");
|
|
406
443
|
|
|
407
|
-
const { chromium } = await loadPlaywright();
|
|
444
|
+
const { chromium, devices } = await loadPlaywright();
|
|
408
445
|
const browser = await chromium.launch(webBrowserLaunchOptions());
|
|
409
446
|
try {
|
|
410
|
-
const context = await browser.newContext({ viewport
|
|
447
|
+
const context = await browser.newContext(webContextOptions({ device, viewport, devices }));
|
|
411
448
|
const page = await context.newPage();
|
|
412
449
|
const boundedTimeout = Math.max(1000, Math.min(60_000, Number(timeoutMs) || NAV_TIMEOUT_MS));
|
|
413
450
|
page.setDefaultTimeout(boundedTimeout);
|
|
@@ -429,23 +466,26 @@ export async function inspectWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, screensh
|
|
|
429
466
|
}
|
|
430
467
|
const observed = await page.evaluate(() => {
|
|
431
468
|
const visible = (element) => element.offsetParent !== null;
|
|
432
|
-
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]")]
|
|
433
470
|
.filter((element) => element.type !== "hidden" && visible(element))
|
|
434
471
|
.slice(0, 80)
|
|
435
472
|
.map((element) => {
|
|
436
473
|
const tag = element.tagName.toLowerCase();
|
|
437
474
|
const field = ["input", "textarea", "select"].includes(tag);
|
|
438
475
|
const secure = element.type === "password";
|
|
439
|
-
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" : "");
|
|
440
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();
|
|
441
479
|
return {
|
|
442
|
-
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",
|
|
443
482
|
role,
|
|
444
483
|
label,
|
|
445
484
|
identifier: element.id || element.getAttribute("data-testid") || element.getAttribute("aria-label") || "",
|
|
446
485
|
isEnabled: !element.disabled && element.getAttribute("aria-disabled") !== "true",
|
|
447
486
|
hittable: true,
|
|
448
487
|
secure,
|
|
488
|
+
rect: { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) },
|
|
449
489
|
};
|
|
450
490
|
})
|
|
451
491
|
.filter((control) => control.label || control.identifier);
|
|
@@ -455,7 +495,7 @@ export async function inspectWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, screensh
|
|
|
455
495
|
controls,
|
|
456
496
|
};
|
|
457
497
|
});
|
|
458
|
-
const image = screenshot ? await page.screenshot({ type: "png" }) : null;
|
|
498
|
+
const image = screenshot ? await page.screenshot({ type: "png", fullPage: !!fullPage }) : null;
|
|
459
499
|
return {
|
|
460
500
|
url: page.url(),
|
|
461
501
|
screenTitle: webScreenTitle(observed, target.pathname || target.href),
|
|
@@ -524,17 +564,19 @@ export function normalizeWebSeedTargets(seedTargets = [], limit = 5) {
|
|
|
524
564
|
return result;
|
|
525
565
|
}
|
|
526
566
|
|
|
527
|
-
export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", seedRoutes = [], seedTargets = [], watch = false, onProgress }) {
|
|
567
|
+
export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", seedRoutes = [], seedTargets = [], watch = false, onProgress, device = "", viewport = "" }) {
|
|
528
568
|
const start = new URL(url);
|
|
529
569
|
if (!/^https?:$/.test(start.protocol)) throw new Error("url must be http(s)");
|
|
530
570
|
fs.mkdirSync(outDir, { recursive: true });
|
|
531
571
|
const markersPath = path.join(outDir, "ocqa-markers.txt");
|
|
532
572
|
const markersFd = fs.openSync(markersPath, "w");
|
|
533
573
|
const emit = (kind, payload) => fs.writeSync(markersFd, `OCQA_${kind}:${JSON.stringify(payload)}\n`);
|
|
574
|
+
const startedAtMs = Date.now();
|
|
575
|
+
const emitAction = (payload) => emit("ACTION", { t: Date.now() - startedAtMs, ...payload });
|
|
534
576
|
|
|
535
|
-
const { chromium } = await loadPlaywright();
|
|
577
|
+
const { chromium, devices } = await loadPlaywright();
|
|
536
578
|
const browser = await chromium.launch(webBrowserLaunchOptions(process.env, { watch }));
|
|
537
|
-
const context = await browser.newContext({ viewport
|
|
579
|
+
const context = await browser.newContext(webContextOptions({ device, viewport, devices }));
|
|
538
580
|
await installWebListenerTracking(context);
|
|
539
581
|
if (watch) await installWebWatchUi(context);
|
|
540
582
|
const page = await context.newPage();
|
|
@@ -542,9 +584,12 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
542
584
|
|
|
543
585
|
const deadline = Date.now() + timeoutSec * 1000;
|
|
544
586
|
const issues = []; // emitted immediately; kept for counting only
|
|
545
|
-
const issue = (type, severity, title, screen, target) => {
|
|
587
|
+
const issue = (type, severity, title, screen, target, sourceUrl) => {
|
|
546
588
|
issues.push(type);
|
|
547
|
-
|
|
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();
|
|
548
593
|
emit("ISSUE", { type, severity, title, screen, ...(target ? { target } : {}), ...(pageUrl && pageUrl !== "about:blank" ? { url: pageUrl } : {}) });
|
|
549
594
|
};
|
|
550
595
|
|
|
@@ -585,6 +630,11 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
585
630
|
];
|
|
586
631
|
const screenshotFor = new Map();
|
|
587
632
|
const placeholderLinksSeen = new Set();
|
|
633
|
+
const missingAnchorsSeen = new Set();
|
|
634
|
+
const outboundLinks = new Map(); // href → { label, screen }
|
|
635
|
+
const mailtoLinks = new Map(); // address → { screen }
|
|
636
|
+
const outbound = { total: 0, checked: 0, mailtos: 0 };
|
|
637
|
+
let probeCapHit = false;
|
|
588
638
|
let actions = 0;
|
|
589
639
|
let screenCount = 0;
|
|
590
640
|
let lastScreen = null;
|
|
@@ -607,12 +657,12 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
607
657
|
secure: el.type === "password",
|
|
608
658
|
}))
|
|
609
659
|
.filter((f) => f.label);
|
|
610
|
-
const controls = [...document.querySelectorAll("button, a[href], input, textarea, select, [role=button], [role=tab], [role=checkbox], [role=switch]")]
|
|
660
|
+
const controls = [...document.querySelectorAll("button, a[href], input, textarea, select, summary, [role=button], [role=tab], [role=checkbox], [role=switch]")]
|
|
611
661
|
.filter((el) => el.type !== "hidden" && el.offsetParent !== null)
|
|
612
662
|
.slice(0, 60)
|
|
613
663
|
.map((el) => {
|
|
614
664
|
const tag = el.tagName.toLowerCase();
|
|
615
|
-
const role = el.getAttribute("role") || (tag === "a" ? "link" : tag === "button" ? "button" : "");
|
|
665
|
+
const role = el.getAttribute("role") || (tag === "a" ? "link" : tag === "button" || tag === "summary" ? "button" : "");
|
|
616
666
|
const field = ["input", "textarea", "select"].includes(tag);
|
|
617
667
|
const secure = el.type === "password";
|
|
618
668
|
const label = (el.labels?.[0]?.textContent || el.getAttribute("aria-label") || el.textContent || el.placeholder || el.name || el.id || "").trim().slice(0, 120);
|
|
@@ -674,6 +724,18 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
674
724
|
fingerprint: el.id || el.getAttribute("data-testid") || svgPath.slice(0, 80) || `link-${index + 1}`,
|
|
675
725
|
};
|
|
676
726
|
}),
|
|
727
|
+
missingAnchors: [...document.querySelectorAll('a[href^="#"]')]
|
|
728
|
+
.filter((el) => el.offsetParent !== null)
|
|
729
|
+
.map((el) => el.getAttribute("href") || "")
|
|
730
|
+
.filter((href) => href.length > 1)
|
|
731
|
+
.filter((href) => {
|
|
732
|
+
const id = decodeURIComponent(href.slice(1));
|
|
733
|
+
try {
|
|
734
|
+
const esc = window.CSS && CSS.escape ? CSS.escape(id) : id;
|
|
735
|
+
return !document.getElementById(id) && !document.querySelector(`a[name="${esc}"]`);
|
|
736
|
+
} catch { return !document.getElementById(id); }
|
|
737
|
+
})
|
|
738
|
+
.slice(0, 20),
|
|
677
739
|
inputs,
|
|
678
740
|
controls,
|
|
679
741
|
};
|
|
@@ -717,6 +779,13 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
717
779
|
placeholderLinksSeen.add(finding.target);
|
|
718
780
|
issue(finding.type, finding.severity, finding.title, screen, finding.target);
|
|
719
781
|
}
|
|
782
|
+
// Anchor links pointing at ids that do not exist are deterministic dead navigation.
|
|
783
|
+
for (const anchor of info.missingAnchors || []) {
|
|
784
|
+
const anchorTarget = `${key}${anchor}`;
|
|
785
|
+
if (missingAnchorsSeen.has(anchorTarget)) continue;
|
|
786
|
+
missingAnchorsSeen.add(anchorTarget);
|
|
787
|
+
issue("anchor_missing", "medium", `Anchor link "${anchor}" has no matching element on the page`, screen, anchorTarget);
|
|
788
|
+
}
|
|
720
789
|
}
|
|
721
790
|
return { key, screen, info };
|
|
722
791
|
}
|
|
@@ -737,7 +806,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
737
806
|
if (watch) await showWebWatchAction(page, { locator: pw, action: "Type", target: "Password" });
|
|
738
807
|
await pw.fill(testPassword).catch(() => {});
|
|
739
808
|
lastActionTarget = "Sign in";
|
|
740
|
-
|
|
809
|
+
emitAction({ type: "login", target: "Sign in", screen, narrative: "Filled and submitted the sign-in form with the provided test credentials" });
|
|
741
810
|
actions += 1;
|
|
742
811
|
await submitWebLogin(page, watch
|
|
743
812
|
? (locator) => showWebWatchAction(page, { locator, action: "Click", target: "Sign in" })
|
|
@@ -784,7 +853,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
784
853
|
actions += 1;
|
|
785
854
|
lastActionTarget = webNavigationAction(entry.action, target);
|
|
786
855
|
pendingNavigation = entry.pathTarget ? { ...entry, prTarget: false } : entry;
|
|
787
|
-
|
|
856
|
+
emitAction({ type: "open", target, via: lastActionTarget, narrative: `Opened ${target}` });
|
|
788
857
|
// Attribute load-time events (pageerror, 404s) to the page being loaded, not the one
|
|
789
858
|
// we just left; observe() refines this to the page title once it settles.
|
|
790
859
|
currentScreen = target;
|
|
@@ -814,7 +883,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
814
883
|
const beforeScreen = ob.screen;
|
|
815
884
|
actions += 1;
|
|
816
885
|
lastActionTarget = action.target;
|
|
817
|
-
|
|
886
|
+
emitAction({ type: action.type, target: action.target, screen: beforeScreen, reason: "pr_ui_map_path", narrative: `Following observed UI Map path: ${action.type} ${action.target}` });
|
|
818
887
|
let acted = false;
|
|
819
888
|
if (action.type === "back") {
|
|
820
889
|
if (watch) await showWebWatchAction(page, { action: "Back", target: beforeScreen });
|
|
@@ -857,15 +926,27 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
857
926
|
emit("PR_TARGET", { targetId: entry.targetId, status: "observed", screen: ob.screen });
|
|
858
927
|
}
|
|
859
928
|
|
|
860
|
-
// Enqueue unvisited same-origin links (BFS keeps exploration order deterministic)
|
|
929
|
+
// Enqueue unvisited same-origin links (BFS keeps exploration order deterministic);
|
|
930
|
+
// collect outbound http(s) links and mailto addresses for the post-crawl audit.
|
|
861
931
|
const links = await page.$$eval("a[href]", (as) => as.map((a) => ({
|
|
862
932
|
href: a.href,
|
|
933
|
+
raw: a.getAttribute("href") || "",
|
|
863
934
|
label: (a.getAttribute("aria-label") || a.textContent || "").trim(),
|
|
864
935
|
}))).catch(() => []);
|
|
865
936
|
for (const link of links) {
|
|
866
937
|
try {
|
|
938
|
+
if (/^mailto:/i.test(link.raw)) {
|
|
939
|
+
const address = link.raw.replace(/^mailto:/i, "").split("?")[0].trim();
|
|
940
|
+
if (address.includes("@") && !mailtoLinks.has(address)) mailtoLinks.set(address, { screen: ob.screen, sourceUrl: start.origin + ob.key });
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
867
943
|
const u = new URL(link.href);
|
|
868
|
-
if (
|
|
944
|
+
if (!/^https?:$/.test(u.protocol)) continue;
|
|
945
|
+
if (u.origin !== start.origin) {
|
|
946
|
+
const clean = u.origin + u.pathname;
|
|
947
|
+
if (!outboundLinks.has(clean)) outboundLinks.set(clean, { label: link.label, screen: ob.screen, sourceUrl: start.origin + ob.key });
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
869
950
|
const key = u.pathname.replace(/\/+$/, "") + u.search || "/";
|
|
870
951
|
if (!visited.has(key) && !frontier.some((item) => item.target === key)) {
|
|
871
952
|
frontier.push({ target: key, action: webNavigationAction(link.label, key), fromScreen: ob.screen });
|
|
@@ -875,8 +956,10 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
875
956
|
|
|
876
957
|
// Bounded button pass: click, watch for effect, flag dead controls (the web analog
|
|
877
958
|
// of the iOS dead-button detector). Navigations are undone so BFS order holds.
|
|
878
|
-
const buttons = page.locator("button:visible, [role=button]:visible, input[type=submit]:visible");
|
|
879
|
-
const
|
|
959
|
+
const buttons = page.locator("button:visible, [role=button]:visible, input[type=submit]:visible, summary:visible");
|
|
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;
|
|
880
963
|
for (let i = 0; i < n && actions < maxActions && Date.now() < deadline; i++) {
|
|
881
964
|
const b = buttons.nth(i);
|
|
882
965
|
const label = webControlLabel({
|
|
@@ -893,7 +976,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
893
976
|
const beforeState = await captureWebControlState(page, b);
|
|
894
977
|
actions += 1;
|
|
895
978
|
lastActionTarget = label;
|
|
896
|
-
|
|
979
|
+
emitAction({ type: "tap", target: label, screen: webActionScreen(ob), narrative: `Tapped "${label}"` });
|
|
897
980
|
let clickSucceeded = false;
|
|
898
981
|
try {
|
|
899
982
|
if (watch) await showWebWatchAction(page, { locator: b, action: "Click", target: label });
|
|
@@ -925,8 +1008,67 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
925
1008
|
}
|
|
926
1009
|
progress();
|
|
927
1010
|
}
|
|
1011
|
+
|
|
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.
|
|
1016
|
+
outbound.total = outboundLinks.size;
|
|
1017
|
+
outbound.mailtos = mailtoLinks.size;
|
|
1018
|
+
if (outboundLinks.size) {
|
|
1019
|
+
const auditPage = await context.newPage();
|
|
1020
|
+
auditPage.setDefaultTimeout(8000);
|
|
1021
|
+
const targets = [...outboundLinks.entries()].slice(0, OUTBOUND_LINK_LIMIT);
|
|
1022
|
+
outbound.checked = targets.length;
|
|
1023
|
+
for (const [href, meta] of targets) {
|
|
1024
|
+
if (Date.now() >= deadline) break;
|
|
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);
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
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;
|
|
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);
|
|
1044
|
+
}
|
|
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
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
928
1063
|
} finally {
|
|
929
|
-
|
|
1064
|
+
const timedOut = Date.now() >= deadline;
|
|
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";
|
|
1071
|
+
emit("COMPLETE", { actions, screens: screenCount, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried, timedOut, stop, outbound });
|
|
930
1072
|
fs.closeSync(markersFd);
|
|
931
1073
|
await browser.close().catch(() => {});
|
|
932
1074
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { FlowLog, flowVariables, normalizeFlowStep, substituteFlowValue } from "./flow-runtime.js";
|
|
6
|
-
import { loadPlaywright } from "./web-explorer.js";
|
|
6
|
+
import { loadPlaywright, webContextOptions } from "./web-explorer.js";
|
|
7
7
|
|
|
8
8
|
const DEFAULT_TIMEOUT = 6000;
|
|
9
9
|
|
|
@@ -192,7 +192,7 @@ export async function executeWebFlowStep({ page, step, vars = {}, defaultTimeout
|
|
|
192
192
|
return { action, target: action === "login" ? "sign-in form" : target || value, status, detail, task: raw.task };
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
export async function runWebFlow({ flow, url, logPath, screenshotDir, playwright }) {
|
|
195
|
+
export async function runWebFlow({ flow, url, logPath, screenshotDir, playwright, device = "", viewport = "" }) {
|
|
196
196
|
const startUrl = url || flow.url || (/^https?:\/\//i.test(flow.app || "") ? flow.app : "");
|
|
197
197
|
if (!startUrl) throw new Error("Web Flow needs `url:` (or an http(s) `app:` value)");
|
|
198
198
|
if (logPath) fs.rmSync(logPath, { force: true });
|
|
@@ -202,7 +202,7 @@ export async function runWebFlow({ flow, url, logPath, screenshotDir, playwright
|
|
|
202
202
|
const log = new FlowLog({ logPath, flow: loggedFlow });
|
|
203
203
|
const pw = playwright || await loadPlaywright();
|
|
204
204
|
const browser = await pw.chromium.launch({ headless: true });
|
|
205
|
-
const context = await browser.newContext({ viewport
|
|
205
|
+
const context = await browser.newContext(webContextOptions({ device, viewport, devices: pw.devices }));
|
|
206
206
|
const page = await context.newPage();
|
|
207
207
|
const timeout = Number(flow.timeoutMs) || DEFAULT_TIMEOUT;
|
|
208
208
|
page.setDefaultTimeout(timeout);
|
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",
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
"mobile"
|
|
90
90
|
],
|
|
91
91
|
"scripts": {
|
|
92
|
-
"test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/focused-navigation.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/presentation-contract.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js vscode-extension/test/bridge.test.js",
|
|
92
|
+
"test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/focused-navigation.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-link-audit.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/presentation-contract.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js vscode-extension/test/bridge.test.js",
|
|
93
93
|
"test:browser-journey": "node --test tests/browser-journey.test.js",
|
|
94
94
|
"test:browser-native": "TAPP_RUN_NATIVE_BROWSER=1 node --test tests/browser-native-journey.test.js"
|
|
95
95
|
}
|
package/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
|
package/scripts/run-web-flow.js
CHANGED
|
@@ -18,7 +18,14 @@ const logPath = process.env.FLOW_LOG || path.join(os.tmpdir(), `tapp-web-flow-${
|
|
|
18
18
|
const screenshotDir = process.env.TAPP_FLOW_EVIDENCE_DIR || path.join(os.tmpdir(), `tapp-web-flow-${token}`);
|
|
19
19
|
|
|
20
20
|
try {
|
|
21
|
-
const result = await runWebFlow({
|
|
21
|
+
const result = await runWebFlow({
|
|
22
|
+
flow,
|
|
23
|
+
url: process.argv[3],
|
|
24
|
+
logPath,
|
|
25
|
+
screenshotDir,
|
|
26
|
+
device: process.env.TAPP_WEB_DEVICE || "",
|
|
27
|
+
viewport: process.env.TAPP_WEB_VIEWPORT || "",
|
|
28
|
+
});
|
|
22
29
|
const report = spawnSync("python3", [path.join(root, "scripts", "flow_lib.py"), "report", logPath], { encoding: "utf8" });
|
|
23
30
|
process.stdout.write((report.stdout || "").trim() + "\n");
|
|
24
31
|
process.exit(result.passed ? 0 : 1);
|
|
@@ -104,6 +104,13 @@ npx -y @aarwitz/tapp@latest flow run .tapp/flows/smoke.yml
|
|
|
104
104
|
npx -y @aarwitz/tapp@latest ci
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
Web commands (`open`, `tree`, `explore`, `flow run`) accept `--device "iPhone 13"` (any
|
|
108
|
+
Playwright device profile) or `--viewport 390x844` to render at mobile sizes, and `open`
|
|
109
|
+
accepts `--full-page` for a full-height screenshot — use these for "does it look right on a
|
|
110
|
+
phone" checks. Web explores also audit outbound links (DNS, HTTP status, unavailable-shell
|
|
111
|
+
heuristic) and `mailto:` domains, report an honest `stopReason` (`no-unexplored-in-scope-controls`
|
|
112
|
+
when a small surface is swept before the budget), and return a per-action `trace` in `--json`.
|
|
113
|
+
|
|
107
114
|
`tapp explore` observes. `tapp ci` applies versioned deterministic policy to evidence, selected
|
|
108
115
|
Flows/Scenarios/contracts, coverage, and any target-scoped baseline. Its outcomes are `pass`, `fail`,
|
|
109
116
|
or `inconclusive`; both `fail` and `inconclusive` block a merge.
|