@aarwitz/tapp 0.15.0

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.
Files changed (77) hide show
  1. package/AGENTS.md +123 -0
  2. package/Harness/OCQAHarness/AppDelegate.swift +21 -0
  3. package/Harness/OCQAHarness/Info.plist +26 -0
  4. package/Harness/OCQAHarness.xcodeproj/project.pbxproj +199 -0
  5. package/Harness/OCQAHarness.xcodeproj/xcshareddata/xcschemes/OCQAHarnessUITests.xcscheme +22 -0
  6. package/Harness/OCQAHarnessUITests/ExplorerTests.swift +4526 -0
  7. package/Harness/OCQAHarnessUITests/Info.plist +22 -0
  8. package/Harness/generate-harness-xcodeproj.rb +254 -0
  9. package/LICENSE +21 -0
  10. package/README.md +374 -0
  11. package/bin/tapp.js +1382 -0
  12. package/browser/app.css +227 -0
  13. package/browser/app.js +675 -0
  14. package/browser/index.html +195 -0
  15. package/browser/product-contract.js +25 -0
  16. package/browser/view-model.js +16 -0
  17. package/docs/BROWSER-PRODUCT.md +72 -0
  18. package/docs/PRODUCT-ENGINE.md +102 -0
  19. package/docs/application-model.md +276 -0
  20. package/docs/scenarios.md +95 -0
  21. package/mcp-server/src/android-driver.js +287 -0
  22. package/mcp-server/src/android-explorer.js +197 -0
  23. package/mcp-server/src/android-flow.js +89 -0
  24. package/mcp-server/src/application-model.js +1597 -0
  25. package/mcp-server/src/browser-product.js +659 -0
  26. package/mcp-server/src/browser-workspaces.js +234 -0
  27. package/mcp-server/src/ci-report.js +557 -0
  28. package/mcp-server/src/ci-setup.js +359 -0
  29. package/mcp-server/src/contract-authoring.js +10 -0
  30. package/mcp-server/src/enrich.js +57 -0
  31. package/mcp-server/src/flow-runtime.js +127 -0
  32. package/mcp-server/src/html-report.js +124 -0
  33. package/mcp-server/src/index.js +3775 -0
  34. package/mcp-server/src/maintenance-proposal.js +178 -0
  35. package/mcp-server/src/managed-operation.js +61 -0
  36. package/mcp-server/src/pr-selection.js +841 -0
  37. package/mcp-server/src/product-execution.js +155 -0
  38. package/mcp-server/src/product-operations.js +526 -0
  39. package/mcp-server/src/project-config.js +101 -0
  40. package/mcp-server/src/release-contract.d.ts +81 -0
  41. package/mcp-server/src/release-contract.js +226 -0
  42. package/mcp-server/src/report.js +363 -0
  43. package/mcp-server/src/scenario-runtime.js +139 -0
  44. package/mcp-server/src/static-server.js +44 -0
  45. package/mcp-server/src/task-runtime.js +266 -0
  46. package/mcp-server/src/ui-map.js +661 -0
  47. package/mcp-server/src/web-explorer.js +493 -0
  48. package/mcp-server/src/web-flow.js +238 -0
  49. package/package.json +82 -0
  50. package/scripts/android-corpus-e2e.sh +30 -0
  51. package/scripts/ci-gate.sh +323 -0
  52. package/scripts/cleanup-xcode.sh +157 -0
  53. package/scripts/compile-contract.js +27 -0
  54. package/scripts/compile-flow.js +18 -0
  55. package/scripts/corpus-apps.txt +9 -0
  56. package/scripts/corpus-sweep.sh +121 -0
  57. package/scripts/coverage-eval.sh +92 -0
  58. package/scripts/coverage_eval_parse.py +95 -0
  59. package/scripts/deploy-and-build.sh +99 -0
  60. package/scripts/flow-platform.js +18 -0
  61. package/scripts/flow_ai_judge.py +102 -0
  62. package/scripts/flow_lib.py +154 -0
  63. package/scripts/mutation-recall-desktop.sh +186 -0
  64. package/scripts/mutation-recall.sh +121 -0
  65. package/scripts/mutation_lib.py +128 -0
  66. package/scripts/mutation_operators.py +144 -0
  67. package/scripts/platform-gate.js +186 -0
  68. package/scripts/pr-plan.js +68 -0
  69. package/scripts/quick-capture.sh +419 -0
  70. package/scripts/run-android-flow.js +27 -0
  71. package/scripts/run-flow.sh +90 -0
  72. package/scripts/run-web-flow.js +28 -0
  73. package/scripts/run-web-scenario.js +23 -0
  74. package/scripts/validation-matrix.sh +146 -0
  75. package/scripts/vision-fp-eval.sh +206 -0
  76. package/scripts/vision_escalation_responder.py +147 -0
  77. package/scripts/vision_fp_probe.py +221 -0
@@ -0,0 +1,359 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ function inside(root, candidate) {
5
+ const value = path.relative(root, candidate);
6
+ return value === "" || (value !== ".." && !value.startsWith(`..${path.sep}`));
7
+ }
8
+
9
+ export function targetSlug(value) {
10
+ const slug = String(value || "target").trim().toLowerCase()
11
+ .replace(/[^a-z0-9._-]+/g, "-")
12
+ .replace(/^-+|-+$/g, "")
13
+ .slice(0, 100);
14
+ return slug || "target";
15
+ }
16
+
17
+ export function selectApplicationTarget(model, { platform = "", target = "" } = {}) {
18
+ if (model?.kind !== "tapp-application-model" || !Array.isArray(model.targets)) {
19
+ throw new Error("Expected a Tapp application model; run tapp init first");
20
+ }
21
+ const selectedPlatform = String(platform || "").toLowerCase();
22
+ let candidates = model.targets.filter((item) => !selectedPlatform || item.platform === selectedPlatform);
23
+ const requested = String(target || "").trim();
24
+ if (requested) {
25
+ const normalized = requested.replaceAll("\\", "/").replace(/^\.\//, "");
26
+ candidates = candidates.filter((item) => [item.id, item.name, item.sourcePath].some((value) => String(value || "").replaceAll("\\", "/") === normalized));
27
+ }
28
+ if (candidates.length !== 1) {
29
+ const summary = candidates.length ? candidates : model.targets.filter((item) => !selectedPlatform || item.platform === selectedPlatform);
30
+ throw new Error(candidates.length
31
+ ? `Multiple targets match; select one with --target (${summary.map((item) => `${item.platform}:${item.name}`).join(", ")})`
32
+ : `No application target matches${selectedPlatform ? ` platform '${selectedPlatform}'` : ""}${requested ? ` and '${requested}'` : ""}`);
33
+ }
34
+ return candidates[0];
35
+ }
36
+
37
+ export function baselinePathForTarget(projectDir, target) {
38
+ const root = fs.realpathSync(path.resolve(projectDir));
39
+ return path.join(root, ".autotap", "baselines", target.platform, `${targetSlug(target.id)}.json`);
40
+ }
41
+
42
+ export function validateBaselineReport(report, { platform, targetId } = {}) {
43
+ if (!report || typeof report !== "object" || Array.isArray(report)) throw new Error("Baseline source must be a full Tapp gate report JSON object");
44
+ if (!Array.isArray(report.findings) || !Array.isArray(report.screens)) throw new Error("Baseline source is missing Tapp QA findings/screens evidence");
45
+ if (report.platform !== platform) throw new Error(`Baseline platform '${report.platform || "unknown"}' does not match target platform '${platform}'`);
46
+ const reportTarget = String(report.targetKey || report.baselineIdentity?.targetId || "").trim();
47
+ if (!reportTarget) throw new Error("Baseline source is missing its targetKey; Tapp will not guess which same-platform application produced the evidence");
48
+ if (reportTarget !== targetId) throw new Error(`Baseline target '${reportTarget}' does not match application-model target '${targetId}'`);
49
+ if (report.inconclusive === true) throw new Error("An inconclusive run cannot become a trusted baseline");
50
+ if (report.verdict === "blocked") throw new Error("A blocked run cannot become a trusted baseline");
51
+ if (!report.gate || report.gate.failed !== false) throw new Error("Baseline creation requires a successful portable gate report (gate.failed must be false)");
52
+ for (const collection of ["flows", "scenarios", "contracts"]) {
53
+ const failed = (report[collection] || []).filter((item) => item.passed !== true);
54
+ if (failed.length) throw new Error(`Baseline source contains ${failed.length} failed ${collection}`);
55
+ }
56
+ return {
57
+ schemaVersion: 1,
58
+ platform,
59
+ targetId,
60
+ conclusive: true,
61
+ verdict: report.verdict,
62
+ screensExplored: Number(report.screensExplored || report.screens.length),
63
+ actionsPerformed: Number(report.actionsPerformed || 0),
64
+ suite: {
65
+ flows: (report.flows || []).length,
66
+ scenarios: (report.scenarios || []).length,
67
+ contracts: (report.contracts || []).length,
68
+ },
69
+ };
70
+ }
71
+
72
+ function captureEvidenceReference(value, suffix = "") {
73
+ const normalized = String(value || "").replaceAll("\\", "/");
74
+ const match = normalized.match(/(?:^|\/)captures\/([^/]+)(?:\/|$)/);
75
+ return match ? `tapp-capture:${match[1]}${suffix ? `/${suffix}` : ""}` : "";
76
+ }
77
+
78
+ function portableBaselineReport(report) {
79
+ const artifact = JSON.parse(JSON.stringify(report));
80
+ const markersEvidence = captureEvidenceReference(artifact.relativeMarkersFilePath, "ocqa-markers.txt");
81
+ if (markersEvidence) artifact.markersEvidence = markersEvidence;
82
+ delete artifact.relativeMarkersFilePath;
83
+
84
+ if (artifact.uiMap && typeof artifact.uiMap === "object" && !Array.isArray(artifact.uiMap)) {
85
+ const evidence = captureEvidenceReference(artifact.uiMap.path, "ui-map.json");
86
+ artifact.uiMap = { ...artifact.uiMap, ...(evidence ? { evidence } : {}) };
87
+ delete artifact.uiMap.path;
88
+ }
89
+ if (artifact.capture && typeof artifact.capture === "object" && !Array.isArray(artifact.capture)) {
90
+ const id = String(artifact.capture.id || "").trim();
91
+ const evidence = id ? `tapp-capture:${id}` : captureEvidenceReference(artifact.capture.path || artifact.capture.relativePath);
92
+ artifact.capture = { ...(id ? { id } : {}), ...(evidence ? { evidence } : {}) };
93
+ }
94
+ for (const field of ["reportHtml", "recording"]) {
95
+ if (!artifact[field]) continue;
96
+ const evidence = captureEvidenceReference(artifact[field], path.basename(String(artifact[field])));
97
+ if (evidence) artifact[`${field}Evidence`] = evidence;
98
+ delete artifact[field];
99
+ }
100
+ return artifact;
101
+ }
102
+
103
+ export function writeTargetBaseline({ projectDir, target, report, sourceReport = "", outPath = "", replace = false } = {}) {
104
+ const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
105
+ if (!target?.id || !["ios", "android", "web"].includes(target.platform)) throw new Error("A concrete application-model target is required");
106
+ const validation = validateBaselineReport(report, { platform: target.platform, targetId: target.id });
107
+ const destination = path.resolve(outPath || baselinePathForTarget(root, target));
108
+ if (!inside(root, destination)) throw new Error("Baseline output must remain inside the repository");
109
+ if (fs.existsSync(destination) && !replace) throw new Error(`Baseline already exists at ${path.relative(root, destination)}; inspect it or pass --replace after a reviewed conclusive run`);
110
+ const source = sourceReport ? path.resolve(sourceReport) : "";
111
+ const sourceLabel = source && inside(root, source) ? path.relative(root, source).replaceAll(path.sep, "/") : source ? path.basename(source) : "generated gate report";
112
+ const artifact = {
113
+ ...portableBaselineReport(report),
114
+ baselineIdentity: {
115
+ ...validation,
116
+ createdAt: new Date().toISOString(),
117
+ sourceReport: sourceLabel,
118
+ policy: "platform-and-target-specific; replace only from a reviewed successful conclusive gate",
119
+ },
120
+ };
121
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
122
+ const temporary = `${destination}.tmp-${process.pid}-${Date.now()}`;
123
+ fs.writeFileSync(temporary, JSON.stringify(artifact, null, 2) + "\n");
124
+ fs.renameSync(temporary, destination);
125
+ return { path: destination, relativePath: path.relative(root, destination).replaceAll(path.sep, "/"), artifact, validation };
126
+ }
127
+
128
+ function yamlString(value) {
129
+ return JSON.stringify(String(value));
130
+ }
131
+
132
+ function posix(value) {
133
+ return String(value || "").replaceAll(path.sep, "/").replace(/^\.\//, "");
134
+ }
135
+
136
+ function suiteDirectories(root, target, kind) {
137
+ const candidates = [path.join(root, ".autotap", kind)];
138
+ if (target.sourcePath && target.sourcePath !== ".") candidates.push(path.join(root, target.sourcePath, ".autotap", kind));
139
+ return [...new Set(candidates)].filter((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isDirectory())
140
+ .map((candidate) => `${posix(path.relative(root, candidate))}/*.yml`);
141
+ }
142
+
143
+ function contractsForTarget(model, target) {
144
+ const source = posix(target.sourcePath || ".");
145
+ return (model.artifacts?.contracts || [])
146
+ .filter((contract) => (contract.platforms || []).includes(target.platform))
147
+ .filter((contract) => {
148
+ const scope = posix(contract.scope || ".");
149
+ return scope === "." || source === "." || source === scope || source.startsWith(`${scope}/`) || scope.startsWith(`${source}/`);
150
+ });
151
+ }
152
+
153
+ function contractPaths(model, target) {
154
+ return contractsForTarget(model, target).map((contract) => posix(contract.path)).sort();
155
+ }
156
+
157
+ function credentialConfiguration(model, targetContracts = []) {
158
+ const hasContractActorMetadata = targetContracts.some((contract) => Array.isArray(contract.actors));
159
+ const actors = hasContractActorMetadata
160
+ ? [...targetContracts.flatMap((contract) => contract.actors || []).reduce((byName, actor) => {
161
+ const prior = byName.get(actor.name) || { name: actor.name, session: actor.session || "default", credentialRequirements: [], credentialBindings: {} };
162
+ for (const requirement of actor.credentialRequirements || []) if (!prior.credentialRequirements.includes(requirement)) prior.credentialRequirements.push(requirement);
163
+ for (const [key, value] of Object.entries(actor.credentialBindings || {})) prior.credentialBindings[key] ||= value;
164
+ if (actor.session === "default") prior.session = "default";
165
+ byName.set(actor.name, prior);
166
+ return byName;
167
+ }, new Map()).values()]
168
+ : (() => {
169
+ const contractNames = new Set(targetContracts.map((contract) => contract.name));
170
+ return (model.actors || []).filter((actor) => actor.configured === true || !(actor.contracts || []).length || actor.contracts.some((name) => contractNames.has(name)));
171
+ })();
172
+ const requirements = new Set(actors.flatMap((actor) => actor.credentialRequirements || []));
173
+ const primary = actors.find((actor) => actor.session === "default") || actors[0] || {};
174
+ const bindings = new Set(actors.flatMap((actor) => Object.values(actor.credentialBindings || {})));
175
+ if (!bindings.size) {
176
+ if (requirements.has("email")) bindings.add("TAPP_TEST_EMAIL");
177
+ if (requirements.has("password")) bindings.add("TAPP_TEST_PASSWORD");
178
+ }
179
+ for (const name of bindings) if (!/^[A-Z_][A-Z0-9_]{0,127}$/.test(String(name))) throw new Error(`Actor credential binding '${name}' is not a safe environment-variable name; rerun tapp init from valid .autotap/project.json`);
180
+ const inputs = {};
181
+ const primaryEmail = primary.credentialBindings?.email || (!primary.credentialBindings && requirements.has("email") ? "TAPP_TEST_EMAIL" : "");
182
+ const primaryPassword = primary.credentialBindings?.password || (!primary.credentialBindings && requirements.has("password") ? "TAPP_TEST_PASSWORD" : "");
183
+ if (primaryEmail) inputs["test-email"] = `\${{ secrets.${primaryEmail} }}`;
184
+ if (primaryPassword) inputs["test-password"] = `\${{ secrets.${primaryPassword} }}`;
185
+ const secrets = [...bindings].sort();
186
+ const environment = Object.fromEntries(secrets.map((name) => [name, `\${{ secrets.${name} }}`]));
187
+ return { inputs, environment, secrets };
188
+ }
189
+
190
+ function targetInputs(root, model, target) {
191
+ const inputs = { platform: target.platform, "target-key": target.id };
192
+ const unresolved = [];
193
+ if (target.platform === "ios") {
194
+ if (!target.build?.container) unresolved.push("Xcode project/workspace is unknown");
195
+ if (!target.build?.proposedScheme || target.status !== "configured") unresolved.push("shared iOS scheme has not been validated");
196
+ if (target.build?.container) inputs.project = posix(target.build.container);
197
+ if (target.build?.proposedScheme) inputs.scheme = target.build.proposedScheme;
198
+ inputs.configuration = target.build?.configuration || "Debug";
199
+ } else if (target.platform === "android") {
200
+ if (!target.runtime?.applicationId) unresolved.push("Android application id is unknown");
201
+ const androidProject = posix(target.build?.projectDir || ".");
202
+ const androidProjectPath = path.resolve(root, androidProject);
203
+ const androidProjectRelative = path.relative(root, androidProjectPath);
204
+ if (path.isAbsolute(androidProjectRelative) || androidProjectRelative === ".." || androidProjectRelative.startsWith(`..${path.sep}`)) unresolved.push("Android Gradle project resolves outside the repository");
205
+ else if (!fs.existsSync(path.join(androidProjectPath, "gradlew"))) unresolved.push(`Gradle wrapper is missing from ${androidProject}; commit gradlew or rerun tapp init from the Gradle repository root`);
206
+ inputs["android-project"] = androidProject;
207
+ inputs["android-task"] = target.build?.task || "assembleDebug";
208
+ if (target.runtime?.applicationId) inputs["android-app-id"] = target.runtime.applicationId;
209
+ inputs["android-serial"] = "emulator-5554";
210
+ } else {
211
+ if (target.build?.dependencyStatus === "missing-lockfile") unresolved.push("browser dependency lockfile is missing");
212
+ if (target.runtime?.management === "customer-managed" && target.runtime?.ownedUrl) inputs.url = target.runtime.ownedUrl;
213
+ else if (target.runtime?.management === "tapp-managed") inputs["web-target"] = target.id;
214
+ else unresolved.push("browser runtime has neither an owned URL nor a deterministic managed start path");
215
+ }
216
+ const flows = suiteDirectories(root, target, "flows");
217
+ const scenarios = target.platform === "web" ? suiteDirectories(root, target, "scenarios") : [];
218
+ const targetContracts = contractsForTarget(model, target);
219
+ const contracts = targetContracts.map((contract) => posix(contract.path)).sort();
220
+ if (flows.length) inputs.flows = flows.join(" ");
221
+ if (scenarios.length) inputs.scenarios = scenarios.join(" ");
222
+ if (contracts.length) inputs.contracts = contracts.join(" ");
223
+ const baselinePath = baselinePathForTarget(root, target);
224
+ if (fs.existsSync(baselinePath)) inputs.baseline = posix(path.relative(root, baselinePath));
225
+ const credentials = credentialConfiguration(model, targetContracts);
226
+ Object.assign(inputs, credentials.inputs);
227
+ return { inputs, environment: credentials.environment, requiredSecrets: credentials.secrets, unresolved, baselinePath: fs.existsSync(baselinePath) ? posix(path.relative(root, baselinePath)) : null, contracts };
228
+ }
229
+
230
+ function jobId(target, occupied) {
231
+ const base = targetSlug(`tapp-${target.platform}-${target.name}`).replaceAll("-", "_");
232
+ let value = base;
233
+ let suffix = 2;
234
+ while (occupied.has(value)) value = `${base}_${suffix++}`;
235
+ occupied.add(value);
236
+ return value;
237
+ }
238
+
239
+ const CHECKOUT_SHA = "11d5960a326750d5838078e36cf38b85af677262";
240
+ const SETUP_JAVA_SHA = "0f481fcb613427c0f801b606911222b5b6f3083a";
241
+
242
+ export function renderGithubWorkflow({ projectDir, model, actionRef, defaultBranch = "main" } = {}) {
243
+ const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
244
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+$/.test(String(actionRef || ""))) throw new Error("--action-ref must be owner/repository@release-tag-or-sha");
245
+ if (!/^[A-Za-z0-9._\/-]+$/.test(defaultBranch) || defaultBranch.startsWith("/")) throw new Error("--default-branch contains unsupported characters");
246
+ if (model?.kind !== "tapp-application-model" || !Array.isArray(model.targets) || !model.targets.length) throw new Error("Application model has no targets; run tapp init first");
247
+ const occupied = new Set();
248
+ const jobs = [];
249
+ const manifestTargets = [];
250
+ for (const target of model.targets) {
251
+ const id = jobId(target, occupied);
252
+ const configured = targetInputs(root, model, target);
253
+ manifestTargets.push({ id: target.id, name: target.name, platform: target.platform, job: id, baseline: configured.baselinePath, contracts: configured.contracts, requiredSecrets: configured.requiredSecrets, unresolved: configured.unresolved, inputs: configured.inputs });
254
+ const lines = [];
255
+ lines.push(` ${id}:`);
256
+ lines.push(` name: ${yamlString(`Tapp · ${target.platform} · ${target.name}`)}`);
257
+ lines.push(` runs-on: ${target.platform === "ios" ? "macos-15" : "ubuntu-24.04"}`);
258
+ lines.push(` timeout-minutes: ${target.platform === "ios" ? 45 : target.platform === "android" ? 40 : 25}`);
259
+ lines.push(" steps:");
260
+ lines.push(` - uses: actions/checkout@${CHECKOUT_SHA} # v4`);
261
+ if (target.platform === "android") {
262
+ lines.push(` - uses: actions/setup-java@${SETUP_JAVA_SHA} # v5.5.0`);
263
+ lines.push(" with:");
264
+ lines.push(" distribution: temurin");
265
+ lines.push(" java-version: \"17\"");
266
+ lines.push(" cache: gradle");
267
+ lines.push(" - name: Start Android API 35 emulator");
268
+ lines.push(" shell: bash");
269
+ lines.push(" run: |");
270
+ lines.push(" set -euo pipefail");
271
+ lines.push(" yes | sdkmanager --licenses >/dev/null || true");
272
+ lines.push(' sdkmanager "platform-tools" "emulator" "platforms;android-35" "system-images;android-35;google_apis;x86_64"');
273
+ lines.push(' echo no | avdmanager create avd --force --name tapp-ci --package "system-images;android-35;google_apis;x86_64"');
274
+ lines.push(" sudo chmod 666 /dev/kvm");
275
+ lines.push(' nohup "$ANDROID_HOME/emulator/emulator" -avd tapp-ci -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect >"$RUNNER_TEMP/tapp-emulator.log" 2>&1 &');
276
+ lines.push(" adb wait-for-device");
277
+ lines.push(" for attempt in $(seq 1 120); do");
278
+ lines.push(' [[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d \'\\r\')" == "1" ]] && exit 0');
279
+ lines.push(" sleep 2");
280
+ lines.push(" done");
281
+ lines.push(' cat "$RUNNER_TEMP/tapp-emulator.log"');
282
+ lines.push(" exit 1");
283
+ }
284
+ lines.push(" - name: Tapp release gate");
285
+ lines.push(` uses: ${actionRef}`);
286
+ lines.push(" with:");
287
+ for (const [key, value] of Object.entries(configured.inputs)) lines.push(` ${key}: ${yamlString(value)}`);
288
+ if (Object.keys(configured.environment).length) {
289
+ lines.push(" env:");
290
+ for (const [key, value] of Object.entries(configured.environment)) lines.push(` ${key}: ${yamlString(value)}`);
291
+ }
292
+ jobs.push(lines.join("\n"));
293
+ }
294
+ const workflow = [
295
+ "# Generated by `tapp ci install` from .autotap/application-model.json.",
296
+ "# Review this patch. Tapp never overwrites it silently.",
297
+ "name: Tapp release gate",
298
+ "",
299
+ "on:",
300
+ " pull_request:",
301
+ " push:",
302
+ ` branches: [${yamlString(defaultBranch)}]`,
303
+ "",
304
+ "permissions:",
305
+ " actions: read",
306
+ " contents: read",
307
+ " pull-requests: write",
308
+ "",
309
+ "concurrency:",
310
+ " group: tapp-${{ github.workflow }}-${{ github.ref }}",
311
+ " cancel-in-progress: true",
312
+ "",
313
+ "jobs:",
314
+ jobs.join("\n\n"),
315
+ "",
316
+ ].join("\n");
317
+ const unresolved = manifestTargets.flatMap((target) => target.unresolved.map((message) => ({ targetId: target.id, platform: target.platform, message })));
318
+ const targetRequirementSuffixes = new Set(["scheme", "application-id", "owned-url", "dependency-lock"]);
319
+ for (const requirement of (model.requirements || []).filter((item) => item.severity === "blocking")) {
320
+ const target = model.targets.find((candidate) => String(requirement.id || "").startsWith(`${candidate.id}:`));
321
+ const suffix = target ? String(requirement.id).slice(target.id.length + 1) : "";
322
+ if (target && targetRequirementSuffixes.has(suffix)) continue;
323
+ const message = `${requirement.message}${requirement.remediation ? ` Next: ${requirement.remediation}` : ""}`;
324
+ if (!unresolved.some((item) => item.message === message)) unresolved.push({ targetId: target?.id || "application-model", platform: target?.platform || "repository", message });
325
+ }
326
+ const requiredSecrets = [...new Set(manifestTargets.flatMap((target) => target.requiredSecrets))].sort();
327
+ const manifest = {
328
+ schemaVersion: 1,
329
+ kind: "tapp-ci-installation",
330
+ status: unresolved.length ? "requires-configuration" : "ready-for-review",
331
+ workflowPath: ".github/workflows/tapp.yml",
332
+ actionRef,
333
+ actionRefImmutable: /@[a-f0-9]{40}$/.test(actionRef),
334
+ defaultBranch,
335
+ targets: manifestTargets,
336
+ unresolved,
337
+ policy: { criticalContracts: "every PR", diffRelevantContracts: "every PR", autonomousExploration: "bounded every PR", baseline: "platform-and-target-specific", aiRequired: false },
338
+ security: { customerValuesInterpolatedIntoShellSource: false, secrets: requiredSecrets, thirdPartyActionsPinned: true },
339
+ generatedAt: new Date().toISOString(),
340
+ };
341
+ return { workflow, manifest };
342
+ }
343
+
344
+ export function writeCiInstallation({ projectDir, workflow, manifest, workflowPath = ".github/workflows/tapp.yml", manifestPath = ".autotap/ci.json", replace = false } = {}) {
345
+ const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
346
+ const destinations = [path.resolve(root, workflowPath), path.resolve(root, manifestPath)];
347
+ for (const destination of destinations) if (!inside(root, destination)) throw new Error("CI installation outputs must remain inside the repository");
348
+ const existing = destinations.filter((destination) => fs.existsSync(destination));
349
+ if (existing.length && !replace) throw new Error(`CI installation never overwrites existing files: ${existing.map((item) => posix(path.relative(root, item))).join(", ")}`);
350
+ for (const destination of destinations) fs.mkdirSync(path.dirname(destination), { recursive: true });
351
+ const renderedManifest = { ...manifest, workflowPath: posix(path.relative(root, destinations[0])) };
352
+ const values = [workflow.endsWith("\n") ? workflow : workflow + "\n", JSON.stringify(renderedManifest, null, 2) + "\n"];
353
+ for (let index = 0; index < destinations.length; index += 1) {
354
+ const temporary = `${destinations[index]}.tmp-${process.pid}-${Date.now()}-${index}`;
355
+ fs.writeFileSync(temporary, values[index]);
356
+ fs.renameSync(temporary, destinations[index]);
357
+ }
358
+ return { workflowPath: destinations[0], manifestPath: destinations[1], manifest: renderedManifest };
359
+ }
@@ -0,0 +1,10 @@
1
+ // Runtime half of the TypeScript authoring API. TypeScript supplies the
2
+ // repository-side type checking; this function keeps the authored value plain,
3
+ // serializable, and deterministic for Tapp's compiler.
4
+ export function defineContract(contract) {
5
+ return {
6
+ ...contract,
7
+ kind: "release-contract",
8
+ version: contract?.version ?? 1,
9
+ };
10
+ }
@@ -0,0 +1,57 @@
1
+ // Post-run finding enrichment — the node port of the desktop app's FindingEnriching seam
2
+ // (AISeams.swift), with the same contract: post-run only, deduped, capped, concurrent,
3
+ // additive/best-effort (a failure leaves the heuristic finding untouched), and it NEVER
4
+ // changes severity/verdict/gate. Attaches `aiAnalysis` (root cause) + `suggestedFix` per
5
+ // finding when a model backend is configured; without one the caller skips it entirely.
6
+
7
+ const ENRICH_CAP = 5; // findings are pre-sorted by severity, so this enriches the worst
8
+ const TIMEOUT_MS = 20_000;
9
+
10
+ function parseEnrichment(text) {
11
+ if (!text) return null;
12
+ let t = String(text).trim();
13
+ const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/);
14
+ if (fence) t = fence[1].trim();
15
+ const brace = t.indexOf("{");
16
+ if (brace > 0) t = t.slice(brace);
17
+ try {
18
+ const o = JSON.parse(t);
19
+ const rootCause = String(o.rootCause || o.root_cause || o.analysis || "").trim();
20
+ const suggestedFix = String(o.suggestedFix || o.suggested_fix || o.fix || "").trim();
21
+ if (!rootCause && !suggestedFix) return null;
22
+ return { rootCause, suggestedFix };
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export async function enrichFindings(findings, { backend, callModel, screens = [], appLabel = "" }) {
29
+ const targets = findings.slice(0, ENRICH_CAP);
30
+ const system =
31
+ "You are a senior mobile/web QA engineer. Given one defect found by autonomous UI exploration, " +
32
+ "reply with ONLY a JSON object {\"rootCause\": \"...\", \"suggestedFix\": \"...\"} — one concrete, " +
33
+ "app-specific sentence each. No markdown, no prose around the JSON.";
34
+ await Promise.all(
35
+ targets.map(async (f) => {
36
+ const userText =
37
+ `App under test: ${appLabel || "unknown"}\n` +
38
+ `Screens observed: ${screens.slice(0, 15).join(", ") || "n/a"}\n` +
39
+ `Defect: type=${f.type} severity=${f.severity} screen=${f.screen ?? "?"}\n` +
40
+ `Title: ${f.title}`;
41
+ try {
42
+ const res = await Promise.race([
43
+ callModel(backend, { system, userText, model: process.env.AUTOTAP_FINDING_MODEL || "claude-haiku-4-5-20251001", maxTokens: 300 }),
44
+ new Promise((r) => setTimeout(() => r({ error: "timeout" }), TIMEOUT_MS)),
45
+ ]);
46
+ const parsed = res && !res.error ? parseEnrichment(res.text) : null;
47
+ if (parsed) {
48
+ if (parsed.rootCause) f.aiAnalysis = parsed.rootCause;
49
+ if (parsed.suggestedFix) f.suggestedFix = parsed.suggestedFix;
50
+ }
51
+ } catch {
52
+ /* best-effort: heuristic finding stands */
53
+ }
54
+ })
55
+ );
56
+ return findings;
57
+ }
@@ -0,0 +1,127 @@
1
+ // Platform-neutral pieces of Tapp's committed Flow runtime. Drivers own how an
2
+ // action reaches a real surface; normalization, variable substitution, marker
3
+ // output, and fail-fast semantics stay identical on every platform.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { spawnSync } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
8
+ import { compileFlowTasksFromRepository } from "./task-runtime.js";
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const packageRoot = path.resolve(__dirname, "../..");
12
+
13
+ export function normalizeFlowStep(raw) {
14
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { action: "noop", target: "", value: "", params: {} };
15
+ if (typeof raw.action === "string") {
16
+ return {
17
+ action: raw.action.toLowerCase(),
18
+ target: String(raw.target ?? raw.field ?? ""),
19
+ value: String(raw.value ?? ""),
20
+ params: raw,
21
+ ...(raw.__tappTask?.name ? { task: raw.__tappTask.name } : {}),
22
+ };
23
+ }
24
+ const entry = Object.entries(raw)[0];
25
+ if (!entry) return { action: "noop", target: "", value: "", params: {} };
26
+ const [key, body] = entry;
27
+ if (body && typeof body === "object" && !Array.isArray(body)) {
28
+ return {
29
+ action: key.toLowerCase(),
30
+ target: String(body.target ?? body.field ?? body.of ?? ""),
31
+ value: String(body.value ?? body.contains ?? ""),
32
+ params: body,
33
+ ...(raw.__tappTask?.name ? { task: raw.__tappTask.name } : {}),
34
+ };
35
+ }
36
+ return { action: key.toLowerCase(), target: body === true ? "" : String(body ?? ""), value: body === true ? "" : String(body ?? ""), params: {}, ...(raw.__tappTask?.name ? { task: raw.__tappTask.name } : {}) };
37
+ }
38
+
39
+ export function flowVariables(flow, overrides = {}) {
40
+ return {
41
+ TEST_EMAIL: process.env.OCQA_TEST_EMAIL || "test@example.com",
42
+ TEST_PASSWORD: process.env.OCQA_TEST_PASSWORD || "TestPass123!",
43
+ ...(flow?.vars || {}),
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ // Flows created before cross-platform support did not carry `platform`; those
49
+ // repository-native files drove XCUITest and remain iOS-only. Treating an
50
+ // absent platform as "all" can execute an iOS recording against an unrelated
51
+ // web/Android target in a monorepo. Browser URLs are the only safe legacy
52
+ // exception because they identify their runtime unambiguously.
53
+ export function inferFlowPlatform(flow = {}) {
54
+ const explicit = String(flow.platform || "").trim().toLowerCase();
55
+ if (explicit) return explicit;
56
+ const target = String(flow.url || flow.app || "").trim();
57
+ return /^https?:\/\//i.test(target) ? "web" : "ios";
58
+ }
59
+
60
+ export function substituteFlowValue(value, vars) {
61
+ let out = String(value ?? "");
62
+ // Task outputs can intentionally point at another variable (for example an
63
+ // authenticated email output sourced from $TEST_EMAIL). Resolve a small,
64
+ // bounded chain while preserving unknown placeholders.
65
+ for (let pass = 0; pass < 5; pass += 1) {
66
+ const before = out;
67
+ for (const [key, replacement] of Object.entries(vars || {})) {
68
+ out = out.replaceAll(`$${key}`, String(replacement));
69
+ }
70
+ if (out === before) break;
71
+ }
72
+ return out;
73
+ }
74
+
75
+ export function loadFlowFile(flowPath, options = {}) {
76
+ const helper = path.join(packageRoot, "scripts", "flow_lib.py");
77
+ const parsed = spawnSync("python3", [helper, "to-json", flowPath], { encoding: "utf8" });
78
+ if (parsed.status !== 0) {
79
+ throw new Error((parsed.stderr || parsed.stdout || "Could not parse Flow").trim());
80
+ }
81
+ return compileFlowTasksFromRepository({ flow: JSON.parse(parsed.stdout), sourcePath: flowPath, ...options });
82
+ }
83
+
84
+ export class FlowLog {
85
+ constructor({ logPath, flow }) {
86
+ this.logPath = logPath;
87
+ this.flow = flow;
88
+ this.lines = [];
89
+ this.failed = 0;
90
+ this.executed = 0;
91
+ this.contract = flow.releaseContract?.name || "";
92
+ this.criticality = flow.releaseContract?.criticality || "";
93
+ this.kind = this.contract ? "release-contract" : flow.kind || "flow";
94
+ this.emit(`OCQA_FLOW_RESULT:started total=${flow.steps.length} name=${flow.name || "flow"} kind=${this.kind}${this.contract ? ` contract=${this.contract}` : ""}`);
95
+ }
96
+
97
+ emit(line) {
98
+ this.lines.push(line);
99
+ if (this.logPath) {
100
+ fs.mkdirSync(path.dirname(this.logPath), { recursive: true });
101
+ fs.appendFileSync(this.logPath, line + "\n");
102
+ }
103
+ }
104
+
105
+ step({ index, action, target, status, detail = "", actor = "", task = "" }) {
106
+ this.executed += 1;
107
+ if (status === "fail") this.failed += 1;
108
+ this.emit(`OCQA_FLOW_STEP:${JSON.stringify({ index, action, target, assert: action.startsWith("assert_"), status, detail, ...(actor ? { actor } : {}), ...(task ? { task } : {}), ...(this.contract ? { contract: this.contract } : {}) })}`);
109
+ if (status === "fail") {
110
+ this.emit(`OCQA_ISSUE:${JSON.stringify({
111
+ type: "flow_assertion_failed",
112
+ severity: "high",
113
+ title: `Step ${index} (${action}) failed: ${detail}`,
114
+ screen: actor ? `Scenario actor: ${actor}` : "Flow",
115
+ ...(actor ? { actor } : {}),
116
+ step: index,
117
+ })}`);
118
+ }
119
+ }
120
+
121
+ finish() {
122
+ const passed = this.failed === 0 && this.executed > 0;
123
+ // Keep `passed` first for marker consumers that stream-match the payload.
124
+ this.emit(`OCQA_FLOW_RESULT:${JSON.stringify({ passed, name: this.flow.name || "flow", kind: this.kind, ...(this.contract ? { contract: this.contract, criticality: this.criticality } : {}), total: this.flow.steps.length, executed: this.executed, failed: this.failed })}`);
125
+ return { passed, name: this.flow.name || "flow", kind: this.kind, ...(this.contract ? { contract: this.contract, criticality: this.criticality } : {}), total: this.flow.steps.length, executed: this.executed, failed: this.failed, logPath: this.logPath, lines: this.lines };
126
+ }
127
+ }