@aarwitz/tapp 0.16.5 → 0.17.0-rc.10
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/AGENTS.md +35 -21
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +100 -31
- package/README.md +70 -67
- package/bin/tapp.js +265 -74
- package/browser/app.js +12 -6
- package/docs/BROWSER-PRODUCT.md +75 -0
- package/docs/PRODUCT-ENGINE.md +107 -0
- package/docs/application-model.md +278 -0
- package/docs/scenarios.md +95 -0
- package/mcp-server/src/android-driver.js +88 -3
- package/mcp-server/src/android-explorer.js +83 -14
- package/mcp-server/src/application-model.js +13 -9
- package/mcp-server/src/browser-product.js +1 -1
- package/mcp-server/src/ci-report.js +84 -62
- package/mcp-server/src/ci-setup.js +35 -5
- package/mcp-server/src/enrich.js +1 -1
- package/mcp-server/src/html-report.js +41 -7
- package/mcp-server/src/index.js +198 -72
- package/mcp-server/src/pr-selection.js +4 -3
- package/mcp-server/src/product-execution.js +1 -1
- package/mcp-server/src/product-operations.js +96 -7
- package/mcp-server/src/project-config.js +1 -2
- package/mcp-server/src/project-paths.js +5 -17
- package/mcp-server/src/release-contract.js +3 -3
- package/mcp-server/src/report.js +185 -51
- package/mcp-server/src/task-runtime.js +1 -1
- package/mcp-server/src/web-explorer.js +1 -1
- package/package.json +2 -2
- package/scripts/ci-gate.sh +11 -9
- package/scripts/flow_ai_judge.py +1 -1
- package/scripts/platform-gate.js +11 -5
- package/scripts/quick-capture.sh +72 -38
- package/scripts/run-flow.sh +1 -1
|
@@ -15,7 +15,7 @@ export function targetSlug(value) {
|
|
|
15
15
|
return slug || "target";
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export function selectApplicationTarget(model, { platform = "", target = "" } = {}) {
|
|
18
|
+
export function selectApplicationTarget(model, { platform = "", target = "", useDefault = false } = {}) {
|
|
19
19
|
if (model?.kind !== "tapp-application-model" || !Array.isArray(model.targets)) {
|
|
20
20
|
throw new Error("Expected a Tapp application model; run tapp init first");
|
|
21
21
|
}
|
|
@@ -26,6 +26,14 @@ export function selectApplicationTarget(model, { platform = "", target = "" } =
|
|
|
26
26
|
const normalized = requested.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
27
27
|
candidates = candidates.filter((item) => [item.id, item.name, item.sourcePath].some((value) => String(value || "").replaceAll("\\", "/") === normalized));
|
|
28
28
|
}
|
|
29
|
+
// Target-resolution ladder (ADR-0005 §5): explicit narrowing (above) → exactly one candidate →
|
|
30
|
+
// the model's recorded default target (opt-in: `explore` uses it, but the gate/baseline stay
|
|
31
|
+
// strict so CI never silently picks a target) → otherwise list the choices and stop.
|
|
32
|
+
if (candidates.length > 1 && useDefault && !requested) {
|
|
33
|
+
const def = String(model.application?.defaultTargetId || "").trim();
|
|
34
|
+
const chosen = def && candidates.find((item) => item.id === def);
|
|
35
|
+
if (chosen) return chosen;
|
|
36
|
+
}
|
|
29
37
|
if (candidates.length !== 1) {
|
|
30
38
|
const summary = candidates.length ? candidates : model.targets.filter((item) => !selectedPlatform || item.platform === selectedPlatform);
|
|
31
39
|
throw new Error(candidates.length
|
|
@@ -35,6 +43,22 @@ export function selectApplicationTarget(model, { platform = "", target = "" } =
|
|
|
35
43
|
return candidates[0];
|
|
36
44
|
}
|
|
37
45
|
|
|
46
|
+
// For a bare `tapp explore` in a repo: if the model's default target is a web target with a recorded
|
|
47
|
+
// owned URL, return that URL so exploration hits it directly (the `init --url X` → `explore` path).
|
|
48
|
+
// Targets that must be built/started from source need the prepare pipeline (wired separately), so
|
|
49
|
+
// this returns null and the caller falls back to its normal target resolution.
|
|
50
|
+
export function defaultWebExploreUrl(model) {
|
|
51
|
+
let target;
|
|
52
|
+
try {
|
|
53
|
+
target = selectApplicationTarget(model, { useDefault: true });
|
|
54
|
+
} catch {
|
|
55
|
+
return null; // no model, or ambiguous with no recorded default
|
|
56
|
+
}
|
|
57
|
+
if (target?.platform !== "web") return null;
|
|
58
|
+
const url = String(target.runtime?.ownedUrl || "");
|
|
59
|
+
return /^https?:\/\//i.test(url) ? url : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
export function baselinePathForTarget(projectDir, target) {
|
|
39
63
|
const root = fs.realpathSync(path.resolve(projectDir));
|
|
40
64
|
return path.join(root, ".tapp", "baselines", target.platform, `${targetSlug(target.id)}.json`);
|
|
@@ -52,9 +76,15 @@ export function validateBaselineReport(report, { platform, targetId } = {}) {
|
|
|
52
76
|
const reportTarget = String(report.targetKey || report.baselineIdentity?.targetId || "").trim();
|
|
53
77
|
if (!reportTarget) throw new Error("Baseline source is missing its targetKey; Tapp will not guess which same-platform application produced the evidence");
|
|
54
78
|
if (reportTarget !== targetId) throw new Error(`Baseline target '${reportTarget}' does not match application-model target '${targetId}'`);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
79
|
+
// A trusted baseline must be a clean PASS. The gate outcome is the single authoritative signal
|
|
80
|
+
// (ADR-0005) — reject fail, inconclusive, error, or a missing/unknown outcome, naming which.
|
|
81
|
+
const outcome = report.gate?.outcome;
|
|
82
|
+
if (outcome !== "pass") {
|
|
83
|
+
const why = outcome === "inconclusive" ? "An inconclusive run"
|
|
84
|
+
: outcome === "fail" ? "A failing run"
|
|
85
|
+
: `A non-passing run (${outcome || "no gate outcome"})`;
|
|
86
|
+
throw new Error(`${why} cannot become a trusted baseline; only a passing gate run can`);
|
|
87
|
+
}
|
|
58
88
|
for (const collection of ["flows", "scenarios", "contracts"]) {
|
|
59
89
|
const failed = (report[collection] || []).filter((item) => item.passed !== true);
|
|
60
90
|
if (failed.length) throw new Error(`Baseline source contains ${failed.length} failed ${collection}`);
|
|
@@ -64,7 +94,7 @@ export function validateBaselineReport(report, { platform, targetId } = {}) {
|
|
|
64
94
|
platform,
|
|
65
95
|
targetId,
|
|
66
96
|
conclusive: true,
|
|
67
|
-
|
|
97
|
+
outcome: report.gate?.outcome ?? null,
|
|
68
98
|
screensExplored: Number(report.screensExplored || report.screens.length),
|
|
69
99
|
actionsPerformed: Number(report.actionsPerformed || 0),
|
|
70
100
|
suite: {
|
package/mcp-server/src/enrich.js
CHANGED
|
@@ -40,7 +40,7 @@ export async function enrichFindings(findings, { backend, callModel, screens = [
|
|
|
40
40
|
`Title: ${f.title}`;
|
|
41
41
|
try {
|
|
42
42
|
const res = await Promise.race([
|
|
43
|
-
callModel(backend, { system, userText, model: process.env.TAPP_FINDING_MODEL ||
|
|
43
|
+
callModel(backend, { system, userText, model: process.env.TAPP_FINDING_MODEL || "claude-haiku-4-5-20251001", maxTokens: 300 }),
|
|
44
44
|
new Promise((r) => setTimeout(() => r({ error: "timeout" }), TIMEOUT_MS)),
|
|
45
45
|
]);
|
|
46
46
|
const parsed = res && !res.error ? parseEnrichment(res.text) : null;
|
|
@@ -8,12 +8,28 @@
|
|
|
8
8
|
|
|
9
9
|
import fs from "fs";
|
|
10
10
|
import path from "path";
|
|
11
|
-
import { buildQaReport,
|
|
11
|
+
import { buildQaReport, observationBadge, observationSummary } from "./report.js";
|
|
12
12
|
|
|
13
13
|
const SEV_COLOR = { critical: "#cf222e", high: "#bc4c00", medium: "#9a6700", low: "#57606a" };
|
|
14
14
|
|
|
15
15
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
16
16
|
|
|
17
|
+
const VALID_PLATFORMS = new Set(["web", "android", "ios"]);
|
|
18
|
+
|
|
19
|
+
// Recover a capture's platform when no in-memory report is available. Prefer the run's persisted
|
|
20
|
+
// metadata (ui-map.json → app.platforms) — it is authoritative and survives a renamed folder — and
|
|
21
|
+
// fall back to the capture-id prefix (web-*/android-*, ios unprefixed) only for legacy captures that
|
|
22
|
+
// predate the map. Defaults to ios if nothing is resolvable, matching buildQaReport's own default.
|
|
23
|
+
export function capturePlatform(captureDir) {
|
|
24
|
+
try {
|
|
25
|
+
const map = JSON.parse(fs.readFileSync(path.join(captureDir, "ui-map.json"), "utf8"));
|
|
26
|
+
const fromMap = (map?.app?.platforms || []).find((p) => VALID_PLATFORMS.has(p));
|
|
27
|
+
if (fromMap) return fromMap;
|
|
28
|
+
} catch { /* no map, unreadable, or no valid platform — fall back to the id prefix */ }
|
|
29
|
+
const base = path.basename(captureDir);
|
|
30
|
+
return base.startsWith("web-") ? "web" : base.startsWith("android-") ? "android" : "ios";
|
|
31
|
+
}
|
|
32
|
+
|
|
17
33
|
// Two capture layouts exist: web runs write state_*.png at the capture root; iOS runs
|
|
18
34
|
// export XCUITest attachments into screenshots/ as UUID files with a manifest carrying
|
|
19
35
|
// the human-readable state_N_<Screen> names.
|
|
@@ -49,8 +65,12 @@ function collectShots(captureDir) {
|
|
|
49
65
|
}
|
|
50
66
|
}
|
|
51
67
|
|
|
52
|
-
export function writeHtmlReport(captureDir, { report, label = "" } = {}) {
|
|
53
|
-
|
|
68
|
+
export function writeHtmlReport(captureDir, { report, label = "", recordingWarning = "" } = {}) {
|
|
69
|
+
// Rebuild path (e.g. `tapp report`): no report object is passed, so buildQaReport would default to
|
|
70
|
+
// native — rendering a web/android page with the wrong "Checked / Not checked" scope, overclaiming
|
|
71
|
+
// native checks it never ran. Recover the run's real platform (metadata first, id prefix as a
|
|
72
|
+
// legacy fallback). When a report object IS passed (explore/init), it is already correct.
|
|
73
|
+
const r = report || buildQaReport(path.join(captureDir, "ocqa-markers.txt"), { platform: capturePlatform(captureDir) });
|
|
54
74
|
if (!r) return null;
|
|
55
75
|
|
|
56
76
|
const shots = collectShots(captureDir);
|
|
@@ -78,7 +98,16 @@ export function writeHtmlReport(captureDir, { report, label = "" } = {}) {
|
|
|
78
98
|
|
|
79
99
|
const videoHtml = video
|
|
80
100
|
? `<h2>Recording — the full exploration</h2>\n<video controls preload="metadata" style="width:100%;border:1px solid #d0d7de;border-radius:8px" src="${esc(video)}"></video>`
|
|
81
|
-
:
|
|
101
|
+
: recordingWarning
|
|
102
|
+
? `<h2>Recording</h2>\n<p class="warning">Unavailable: ${esc(recordingWarning)}. Screenshots were still captured.</p>`
|
|
103
|
+
: "";
|
|
104
|
+
|
|
105
|
+
const scopeList = (items) => (items || []).map((item) => `<li>${esc(item)}</li>`).join("\n");
|
|
106
|
+
const scopeHtml = `<section class="scope">
|
|
107
|
+
<div><h2>Checked this run</h2><ul>${scopeList(r.checkedFor) || "<li class='dim'>No automated checks completed.</li>"}</ul></div>
|
|
108
|
+
<div><h2>Not checked this run</h2><ul>${scopeList(r.notChecked) || "<li class='dim'>No additional limitations recorded.</li>"}</ul></div>
|
|
109
|
+
${r.conditionsNotReached?.length ? `<div><h2>Conditions not reached</h2><ul>${scopeList(r.conditionsNotReached)}</ul></div>` : ""}
|
|
110
|
+
</section>`;
|
|
82
111
|
|
|
83
112
|
const html = `<!doctype html>
|
|
84
113
|
<html lang="en">
|
|
@@ -90,6 +119,10 @@ export function writeHtmlReport(captureDir, { report, label = "" } = {}) {
|
|
|
90
119
|
h1 { font-size: 1.6rem; margin-bottom: 0.2rem; }
|
|
91
120
|
.meta { color: #57606a; margin-bottom: 1.2rem; }
|
|
92
121
|
.headline { background: #f6f8fa; border-radius: 8px; padding: 0.9rem 1.1rem; margin: 1rem 0; }
|
|
122
|
+
.warning { background: #fff8c5; border: 1px solid #d4a72c; border-radius: 8px; padding: 0.75rem 1rem; }
|
|
123
|
+
.scope { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0.8rem 1.5rem; }
|
|
124
|
+
.scope h2 { font-size: 1.05rem; margin-bottom: 0.35rem; }
|
|
125
|
+
.scope ul { margin-top: 0; padding-left: 1.2rem; }
|
|
93
126
|
.sev { color: #fff; border-radius: 4px; padding: 0.05rem 0.45rem; font-size: 0.78rem; font-weight: 600; margin-right: 0.4rem; }
|
|
94
127
|
ul.findings { padding-left: 1.1rem; } ul.findings li { margin-bottom: 0.6rem; }
|
|
95
128
|
.ai { color: #57606a; font-size: 0.88rem; margin: 0.15rem 0 0 0.2rem; }
|
|
@@ -101,10 +134,11 @@ export function writeHtmlReport(captureDir, { report, label = "" } = {}) {
|
|
|
101
134
|
</style>
|
|
102
135
|
</head>
|
|
103
136
|
<body>
|
|
104
|
-
<h1>${esc(
|
|
105
|
-
<div class="meta">${esc(label)} ·
|
|
106
|
-
${r.platform === "web" ? `<div class="meta">
|
|
137
|
+
<h1>${esc(observationBadge(r))} <span class="dim">· ${esc(observationSummary(r))}</span></h1>
|
|
138
|
+
<div class="meta">${esc(label)} · evidence page (observation, not a release decision)</div>
|
|
139
|
+
${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>` : ""}
|
|
107
140
|
<div class="headline">${esc(r.headline)}</div>
|
|
141
|
+
${scopeHtml}
|
|
108
142
|
<h2>Findings</h2>
|
|
109
143
|
<ul class="findings">
|
|
110
144
|
${findingsHtml}
|