@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.
@@ -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
- if (report.inconclusive === true) throw new Error("An inconclusive run cannot become a trusted baseline");
56
- if (report.verdict === "blocked") throw new Error("A blocked run cannot become a trusted baseline");
57
- if (!report.gate || report.gate.failed !== false) throw new Error("Baseline creation requires a successful portable gate report (gate.failed must be false)");
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
- verdict: report.verdict,
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: {
@@ -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 || process.env.AUTOTAP_FINDING_MODEL || "claude-haiku-4-5-20251001", maxTokens: 300 }),
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, qaScoreLabel, verdictBadge } from "./report.js";
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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
- const r = report || buildQaReport(path.join(captureDir, "ocqa-markers.txt"));
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(verdictBadge(r))} <span class="dim">· ${esc(qaScoreLabel(r))}</span></h1>
105
- <div class="meta">${esc(label)} · ${r.screensExplored} screens · ${r.actionsPerformed} actions · ${r.findingCounts.total} finding(s)</div>
106
- ${r.platform === "web" ? `<div class="meta">Verdict basis: ${r.verdictFindingCounts?.total || 0} deterministic finding(s); ${r.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory.</div>` : ""}
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}