@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.
- package/AGENTS.md +123 -0
- package/Harness/OCQAHarness/AppDelegate.swift +21 -0
- package/Harness/OCQAHarness/Info.plist +26 -0
- package/Harness/OCQAHarness.xcodeproj/project.pbxproj +199 -0
- package/Harness/OCQAHarness.xcodeproj/xcshareddata/xcschemes/OCQAHarnessUITests.xcscheme +22 -0
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +4526 -0
- package/Harness/OCQAHarnessUITests/Info.plist +22 -0
- package/Harness/generate-harness-xcodeproj.rb +254 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/bin/tapp.js +1382 -0
- package/browser/app.css +227 -0
- package/browser/app.js +675 -0
- package/browser/index.html +195 -0
- package/browser/product-contract.js +25 -0
- package/browser/view-model.js +16 -0
- package/docs/BROWSER-PRODUCT.md +72 -0
- package/docs/PRODUCT-ENGINE.md +102 -0
- package/docs/application-model.md +276 -0
- package/docs/scenarios.md +95 -0
- package/mcp-server/src/android-driver.js +287 -0
- package/mcp-server/src/android-explorer.js +197 -0
- package/mcp-server/src/android-flow.js +89 -0
- package/mcp-server/src/application-model.js +1597 -0
- package/mcp-server/src/browser-product.js +659 -0
- package/mcp-server/src/browser-workspaces.js +234 -0
- package/mcp-server/src/ci-report.js +557 -0
- package/mcp-server/src/ci-setup.js +359 -0
- package/mcp-server/src/contract-authoring.js +10 -0
- package/mcp-server/src/enrich.js +57 -0
- package/mcp-server/src/flow-runtime.js +127 -0
- package/mcp-server/src/html-report.js +124 -0
- package/mcp-server/src/index.js +3775 -0
- package/mcp-server/src/maintenance-proposal.js +178 -0
- package/mcp-server/src/managed-operation.js +61 -0
- package/mcp-server/src/pr-selection.js +841 -0
- package/mcp-server/src/product-execution.js +155 -0
- package/mcp-server/src/product-operations.js +526 -0
- package/mcp-server/src/project-config.js +101 -0
- package/mcp-server/src/release-contract.d.ts +81 -0
- package/mcp-server/src/release-contract.js +226 -0
- package/mcp-server/src/report.js +363 -0
- package/mcp-server/src/scenario-runtime.js +139 -0
- package/mcp-server/src/static-server.js +44 -0
- package/mcp-server/src/task-runtime.js +266 -0
- package/mcp-server/src/ui-map.js +661 -0
- package/mcp-server/src/web-explorer.js +493 -0
- package/mcp-server/src/web-flow.js +238 -0
- package/package.json +82 -0
- package/scripts/android-corpus-e2e.sh +30 -0
- package/scripts/ci-gate.sh +323 -0
- package/scripts/cleanup-xcode.sh +157 -0
- package/scripts/compile-contract.js +27 -0
- package/scripts/compile-flow.js +18 -0
- package/scripts/corpus-apps.txt +9 -0
- package/scripts/corpus-sweep.sh +121 -0
- package/scripts/coverage-eval.sh +92 -0
- package/scripts/coverage_eval_parse.py +95 -0
- package/scripts/deploy-and-build.sh +99 -0
- package/scripts/flow-platform.js +18 -0
- package/scripts/flow_ai_judge.py +102 -0
- package/scripts/flow_lib.py +154 -0
- package/scripts/mutation-recall-desktop.sh +186 -0
- package/scripts/mutation-recall.sh +121 -0
- package/scripts/mutation_lib.py +128 -0
- package/scripts/mutation_operators.py +144 -0
- package/scripts/platform-gate.js +186 -0
- package/scripts/pr-plan.js +68 -0
- package/scripts/quick-capture.sh +419 -0
- package/scripts/run-android-flow.js +27 -0
- package/scripts/run-flow.sh +90 -0
- package/scripts/run-web-flow.js +28 -0
- package/scripts/run-web-scenario.js +23 -0
- package/scripts/validation-matrix.sh +146 -0
- package/scripts/vision-fp-eval.sh +206 -0
- package/scripts/vision_escalation_responder.py +147 -0
- package/scripts/vision_fp_probe.py +221 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
// Local transport adapter for Tapp's shared customer product operations. The
|
|
2
|
+
// browser is a customer interface, not a web-only test driver. Repository
|
|
3
|
+
// sources are server-owned workspaces and every target operation resolves a
|
|
4
|
+
// canonical Application Model identity before it builds or runs anything.
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import http from "node:http";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { customerProductContract } from "../../browser/product-contract.js";
|
|
13
|
+
import { BrowserWorkspaceRegistry } from "./browser-workspaces.js";
|
|
14
|
+
import { selectApplicationTarget } from "./ci-setup.js";
|
|
15
|
+
import {
|
|
16
|
+
createProductBaseline,
|
|
17
|
+
generateProductPlan,
|
|
18
|
+
initializeProductProject,
|
|
19
|
+
installProductCi,
|
|
20
|
+
prepareProductCi,
|
|
21
|
+
prepareProductTarget,
|
|
22
|
+
promoteProductPlan,
|
|
23
|
+
readProductProject,
|
|
24
|
+
reviewProductPlan,
|
|
25
|
+
runProductGate,
|
|
26
|
+
validateProductPlan,
|
|
27
|
+
} from "./product-operations.js";
|
|
28
|
+
|
|
29
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
30
|
+
const assetRoot = path.join(packageRoot, "browser");
|
|
31
|
+
const JSON_BODY_LIMIT = 1024 * 1024;
|
|
32
|
+
const SESSION_COOKIE = "tapp_local_session";
|
|
33
|
+
|
|
34
|
+
const contentTypes = new Map([
|
|
35
|
+
[".css", "text/css; charset=utf-8"], [".html", "text/html; charset=utf-8"],
|
|
36
|
+
[".js", "text/javascript; charset=utf-8"], [".json", "application/json; charset=utf-8"],
|
|
37
|
+
[".png", "image/png"], [".jpg", "image/jpeg"], [".jpeg", "image/jpeg"],
|
|
38
|
+
[".webm", "video/webm"], [".mov", "video/quicktime"], [".svg", "image/svg+xml"],
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
function sameSecret(left, right) {
|
|
42
|
+
const a = Buffer.from(String(left || ""));
|
|
43
|
+
const b = Buffer.from(String(right || ""));
|
|
44
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function cookies(request) {
|
|
48
|
+
return Object.fromEntries(String(request.headers.cookie || "").split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
|
|
49
|
+
const at = part.indexOf("=");
|
|
50
|
+
return at < 0 ? [part, ""] : [part.slice(0, at), decodeURIComponent(part.slice(at + 1))];
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function json(response, status, value, headers = {}) {
|
|
55
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers });
|
|
56
|
+
response.end(JSON.stringify(value));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readJsonBody(request) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let length = 0;
|
|
63
|
+
request.on("data", (chunk) => {
|
|
64
|
+
length += chunk.length;
|
|
65
|
+
if (length > JSON_BODY_LIMIT) { reject(Object.assign(new Error("Request body exceeds 1 MiB"), { statusCode: 413 })); request.destroy(); return; }
|
|
66
|
+
chunks.push(chunk);
|
|
67
|
+
});
|
|
68
|
+
request.on("end", () => {
|
|
69
|
+
if (!chunks.length) { resolve({}); return; }
|
|
70
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
|
|
71
|
+
catch { reject(Object.assign(new Error("Request body must be valid JSON"), { statusCode: 400 })); }
|
|
72
|
+
});
|
|
73
|
+
request.on("error", reject);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function serveFile(response, file, { inline = true } = {}) {
|
|
78
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404); response.end("Not found"); return; }
|
|
79
|
+
const headers = {
|
|
80
|
+
"content-type": contentTypes.get(path.extname(file).toLowerCase()) || "application/octet-stream",
|
|
81
|
+
"cache-control": "no-store",
|
|
82
|
+
"x-content-type-options": "nosniff",
|
|
83
|
+
};
|
|
84
|
+
if (!inline) headers["content-disposition"] = `attachment; filename="${path.basename(file).replaceAll('"', "")}"`;
|
|
85
|
+
response.writeHead(200, headers);
|
|
86
|
+
fs.createReadStream(file).pipe(response);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function serveBrowserIndex(response, { apiBase = "", loginUrl = "" } = {}) {
|
|
90
|
+
const file = path.join(assetRoot, "index.html");
|
|
91
|
+
let html = fs.readFileSync(file, "utf8");
|
|
92
|
+
const encode = (value) => String(value || "").replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
|
93
|
+
html = html
|
|
94
|
+
.replace('<meta name="tapp-api-base" content="">', `<meta name="tapp-api-base" content="${encode(apiBase)}">`)
|
|
95
|
+
.replace('<meta name="tapp-login-url" content="">', `<meta name="tapp-login-url" content="${encode(loginUrl)}">`);
|
|
96
|
+
response.writeHead(200, {
|
|
97
|
+
"content-type":"text/html; charset=utf-8",
|
|
98
|
+
"content-length":Buffer.byteLength(html),
|
|
99
|
+
"cache-control":"no-store",
|
|
100
|
+
"x-content-type-options":"nosniff",
|
|
101
|
+
});
|
|
102
|
+
response.end(html);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function assetPath(pathname) {
|
|
106
|
+
const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/assets\//, "");
|
|
107
|
+
const candidate = path.resolve(assetRoot, relative);
|
|
108
|
+
return candidate.startsWith(assetRoot + path.sep) ? candidate : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function captureRoot() {
|
|
112
|
+
return path.join(process.env.AUTOTAP_HOME || process.env.TAPP_HOME || path.join(os.homedir(), ".tapp"), "captures");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function capturePath(captureId, relative = "report.html") {
|
|
116
|
+
if (!/^[A-Za-z0-9._-]{1,128}$/.test(captureId)) return null;
|
|
117
|
+
const root = path.resolve(captureRoot(), captureId);
|
|
118
|
+
const candidate = path.resolve(root, relative || "report.html");
|
|
119
|
+
return candidate === root || candidate.startsWith(root + path.sep) ? candidate : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function openBrowser(url) {
|
|
123
|
+
const invocation = process.platform === "darwin" ? ["open", [url]]
|
|
124
|
+
: process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
|
|
125
|
+
: ["xdg-open", [url]];
|
|
126
|
+
const child = spawn(invocation[0], invocation[1], { detached: true, stdio: "ignore", shell: false });
|
|
127
|
+
child.unref();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function publicJob(job, { hosted = false } = {}) {
|
|
131
|
+
return {
|
|
132
|
+
id: job.id, operation: job.operation, status: job.status, repositoryId: job.repositoryId || null,
|
|
133
|
+
createdAt: job.createdAt, completedAt: job.completedAt || null, progress: job.progress.slice(-80),
|
|
134
|
+
result: hosted ? hostedPayload(job.result) : job.result || null,
|
|
135
|
+
error: job.error || null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function normalizeInteractiveElements(elements = []) {
|
|
140
|
+
const actionableRoles = new Set(["button", "cell", "checkbox", "input", "link", "secureField", "switch", "tab", "textField", "textView"]);
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
const normalized = [];
|
|
143
|
+
for (const element of Array.isArray(elements) ? elements : []) {
|
|
144
|
+
const id = String(element.id || "").trim();
|
|
145
|
+
const label = String(element.label || element.text || element.description || "").trim();
|
|
146
|
+
const target = id || label;
|
|
147
|
+
const role = String(element.role || (element.secure ? "secureField" : "other"));
|
|
148
|
+
const type = String(element.type || element.class || "");
|
|
149
|
+
const input = element.secure === true || /input|textfield|edittext|textarea|secure/i.test(`${role} ${type}`);
|
|
150
|
+
const actionable = element.clickable === true || actionableRoles.has(role) || /XCUIElementType\(rawValue:\s*9\)/.test(type) || input;
|
|
151
|
+
const key = target.toLocaleLowerCase();
|
|
152
|
+
if (!target || !actionable || seen.has(key)) continue;
|
|
153
|
+
seen.add(key);
|
|
154
|
+
const hittable = element.hittable === true || element.clickable === true;
|
|
155
|
+
normalized.push({
|
|
156
|
+
id,
|
|
157
|
+
label,
|
|
158
|
+
type,
|
|
159
|
+
role:input && role === "other" ? "input" : role,
|
|
160
|
+
enabled:element.enabled !== false,
|
|
161
|
+
hittable,
|
|
162
|
+
clickable:!input && actionable && hittable,
|
|
163
|
+
secure:element.secure === true,
|
|
164
|
+
...(element.frame ? { frame:element.frame } : {}),
|
|
165
|
+
});
|
|
166
|
+
if (normalized.length >= 100) break;
|
|
167
|
+
}
|
|
168
|
+
return normalized;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function publicInteractiveResult(result, context = {}) {
|
|
172
|
+
return {
|
|
173
|
+
active: context.active !== false,
|
|
174
|
+
platform: context.platform || "",
|
|
175
|
+
targetId: context.targetId || "",
|
|
176
|
+
targetName: context.targetName || "",
|
|
177
|
+
screenTitle: result?.screenTitle || null,
|
|
178
|
+
status: result?.status || (result?.error ? "error" : "ok"),
|
|
179
|
+
detail: result?.detail || result?.error || null,
|
|
180
|
+
recordedSteps: Number(result?.recordedSteps || 0),
|
|
181
|
+
url: result?.url || context.url || "",
|
|
182
|
+
elements:normalizeInteractiveElements(result?.elements),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function disconnectedProject(repository = null) {
|
|
187
|
+
return {
|
|
188
|
+
kind: "tapp-product-project", schemaVersion: 1, connected: false, repository,
|
|
189
|
+
application: { name: "Connect a product", platforms: [], targetIds: [] },
|
|
190
|
+
targets: [], actors: [], capabilities: [], requirements: [], model: null, map: null, plan: null,
|
|
191
|
+
ci: null, baselines: [], flows: [], evidence: [],
|
|
192
|
+
state: { inspected: false, explored: false, reviewComplete: false, generated: false, validated: false, promoted: false, ciPrepared: false, baselineReady: false, blockingRequirements: 0 },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function projectSnapshot(registry) {
|
|
197
|
+
const repository = registry.current();
|
|
198
|
+
const root = registry.currentRoot();
|
|
199
|
+
if (!root) return disconnectedProject();
|
|
200
|
+
return { ...readProductProject({ projectDir: root }), connected: true, repository };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function selectedTarget(root, body) {
|
|
204
|
+
const project = readProductProject({ projectDir: root });
|
|
205
|
+
if (!project.model) throw new Error("Inspect the repository before selecting a target");
|
|
206
|
+
return selectApplicationTarget(project.model, { platform: String(body.platform || "").toLowerCase(), target: String(body.target || "") });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function runtimeRequest(body = {}) {
|
|
210
|
+
return {
|
|
211
|
+
maxActions: Number(body.maxActions || body.actions) || 40,
|
|
212
|
+
timeout: Number(body.timeout) || 600,
|
|
213
|
+
maxContracts: Number(body.maxContracts) || 15,
|
|
214
|
+
testEmail: typeof body.testEmail === "string" ? body.testEmail : undefined,
|
|
215
|
+
testPassword: typeof body.testPassword === "string" ? body.testPassword : undefined,
|
|
216
|
+
serial: typeof body.serial === "string" ? body.serial.trim() : "",
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function runnerSummary({ hosted = false } = {}) {
|
|
221
|
+
if (hosted) return [
|
|
222
|
+
{ id:"managed-ios", platform:"ios", label:"Managed macOS runner", status:"unavailable", remediation:"The hosted customer adapter is not yet connected to a lease-backed macOS runner." },
|
|
223
|
+
{ id:"managed-android", platform:"android", label:"Managed Android runner", status:"unavailable", remediation:"The hosted customer adapter is not yet connected to a lease-backed Android runner." },
|
|
224
|
+
{ id:"hosted-web", platform:"web", label:"Hosted Chromium", status:"needs-check", remediation:"Tapp verifies the isolated Chromium runtime when the selected web target starts." },
|
|
225
|
+
];
|
|
226
|
+
const executable = (name) => String(process.env.PATH || "").split(path.delimiter).some((dir) => dir && fs.existsSync(path.join(dir, name)));
|
|
227
|
+
const ios = process.platform === "darwin" && (fs.existsSync("/usr/bin/xcodebuild") || executable("xcodebuild"));
|
|
228
|
+
const android = executable(process.platform === "win32" ? "adb.exe" : "adb") || !!process.env.ANDROID_SDK_ROOT || !!process.env.ANDROID_HOME;
|
|
229
|
+
return [
|
|
230
|
+
{ id: "local-ios", platform: "ios", label: "Local Xcode simulator", status: ios ? "available" : "unavailable", remediation: ios ? "" : "Use a macOS runner with Xcode for iOS." },
|
|
231
|
+
{ id: "local-android", platform: "android", label: "Local Android runtime", status: android ? "available" : "needs-check", remediation: android ? "Start or connect an emulator/device." : "Install Android SDK platform-tools or connect a managed Android runner." },
|
|
232
|
+
{ id: "local-web", platform: "web", label: "Local Chromium", status: "needs-check", remediation: "Tapp verifies Playwright and Chromium when the target starts." },
|
|
233
|
+
];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function hostedRepository(repository) {
|
|
237
|
+
if (!repository) return null;
|
|
238
|
+
const { root: _root, ...safe } = repository;
|
|
239
|
+
return safe;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function hostedPayload(value) {
|
|
243
|
+
if (Array.isArray(value)) return value.map(hostedPayload);
|
|
244
|
+
if (!value || typeof value !== "object") {
|
|
245
|
+
if (typeof value === "string" && path.isAbsolute(value)) return `tapp-workspace:${path.basename(value)}`;
|
|
246
|
+
return value ?? null;
|
|
247
|
+
}
|
|
248
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, key === "repository" ? hostedRepository(item) : hostedPayload(item)]));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function requireMutationAuth(request, origin, csrfToken) {
|
|
252
|
+
const allowed = Array.isArray(origin) ? origin : [origin];
|
|
253
|
+
if (!allowed.includes(request.headers.origin) || !sameSecret(request.headers["x-tapp-csrf"], csrfToken)) {
|
|
254
|
+
const error = new Error("Same-origin CSRF validation failed");
|
|
255
|
+
error.statusCode = 403;
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function readFormBody(request) {
|
|
261
|
+
return new Promise((resolve, reject) => {
|
|
262
|
+
const chunks = [];
|
|
263
|
+
let received = 0;
|
|
264
|
+
request.on("data", (chunk) => {
|
|
265
|
+
received += chunk.length;
|
|
266
|
+
if (received > 64 * 1024) { request.destroy(); reject(Object.assign(new Error("Request body too large"), { statusCode: 413 })); return; }
|
|
267
|
+
chunks.push(chunk);
|
|
268
|
+
});
|
|
269
|
+
request.on("end", () => resolve(new URLSearchParams(Buffer.concat(chunks).toString("utf8"))));
|
|
270
|
+
request.on("error", reject);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function serveLoginPage(response, { failed = false } = {}) {
|
|
275
|
+
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
276
|
+
<title>tapp — sign in</title><style>
|
|
277
|
+
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0c1210;color:#e8efe9;font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
278
|
+
main{width:min(360px,90vw)}
|
|
279
|
+
h1{font-size:44px;margin:0 0 4px;letter-spacing:-.03em}h1 span{color:#4f6cf7}
|
|
280
|
+
p.tag{margin:0 0 28px;color:#93a89b;font-family:ui-monospace,monospace;font-size:14px}
|
|
281
|
+
form{display:grid;gap:12px}
|
|
282
|
+
label{display:grid;gap:6px;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#93a89b}
|
|
283
|
+
input{padding:11px 12px;border-radius:8px;border:1px solid #2c3a31;background:#131b17;color:#e8efe9;font-size:15px}
|
|
284
|
+
input:focus{outline:2px solid #4f6cf7;border-color:transparent}
|
|
285
|
+
button{margin-top:6px;padding:12px;border:0;border-radius:8px;background:#1f5c3d;color:#fff;font-size:15px;font-weight:600;cursor:pointer}
|
|
286
|
+
.err{background:#3a1a1c;border:1px solid #6e2a2e;color:#f0b9bd;padding:10px 12px;border-radius:8px;font-size:14px}
|
|
287
|
+
small{color:#657a6c}
|
|
288
|
+
</style></head><body><main>
|
|
289
|
+
<h1>tapp<span>.</span></h1><p class="tag">ship with proof.</p>
|
|
290
|
+
${failed ? '<p class="err" role="alert">That username or password did not match.</p>' : ""}
|
|
291
|
+
<form method="post" action="/api/auth">
|
|
292
|
+
<label>Username<input name="username" autocomplete="username" placeholder="username" required></label>
|
|
293
|
+
<label>Password<input name="password" type="password" autocomplete="current-password" placeholder="password" required></label>
|
|
294
|
+
<button type="submit">Launch Tapp →</button>
|
|
295
|
+
</form>
|
|
296
|
+
<p><small>Demo access: demo / demo. Uploaded repositories run in a restricted pilot workspace.</small></p>
|
|
297
|
+
</main></body></html>`;
|
|
298
|
+
response.writeHead(failed ? 401 : 200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
299
|
+
response.end(html);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function startBrowserProduct({ projectDir, port = 0, launch = false, workspaceRoot, githubProvider, publicOrigin = "", trustedProxyToken = "", directLogin = null } = {}) {
|
|
303
|
+
const hosted = !!trustedProxyToken || !!directLogin;
|
|
304
|
+
// publicOrigin accepts a comma-separated allowlist; the first entry is canonical.
|
|
305
|
+
const publicOrigins = String(publicOrigin || "").split(",").map((value) => value.trim().replace(/\/$/, "")).filter(Boolean);
|
|
306
|
+
if (hosted && !publicOrigins.length) throw new Error("Hosted browser mode requires an explicit publicOrigin");
|
|
307
|
+
const loginFailures = { count: 0, windowStartedAt: 0 };
|
|
308
|
+
const registry = new BrowserWorkspaceRegistry({ initialProjectDir: projectDir, workspaceRoot, githubProvider });
|
|
309
|
+
const serverStartedAt = new Date().toISOString();
|
|
310
|
+
const sessionToken = crypto.randomBytes(32).toString("base64url");
|
|
311
|
+
const csrfToken = crypto.randomBytes(24).toString("base64url");
|
|
312
|
+
const jobs = new Map();
|
|
313
|
+
let activeMutation = null;
|
|
314
|
+
let liveSession = null;
|
|
315
|
+
let liveManagedRuntime = null;
|
|
316
|
+
let origin = "";
|
|
317
|
+
|
|
318
|
+
const startJob = (operation, work, repositoryId = registry.current()?.id || null) => {
|
|
319
|
+
if (activeMutation) {
|
|
320
|
+
const error = new Error(`Operation '${activeMutation.operation}' is still running`);
|
|
321
|
+
error.statusCode = 409;
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
const job = { id: crypto.randomBytes(10).toString("hex"), operation, repositoryId, status: "running", createdAt: new Date().toISOString(), progress: [] };
|
|
325
|
+
const progress = (entry) => {
|
|
326
|
+
const normalized = typeof entry === "string" ? { text: entry } : entry || {};
|
|
327
|
+
job.progress.push({ at: new Date().toISOString(), phase: normalized.phase || operation, text: String(normalized.text || "").slice(-1200), ...(normalized.current ? { current: normalized.current, total: normalized.total } : {}) });
|
|
328
|
+
if (job.progress.length > 200) job.progress.splice(0, job.progress.length - 200);
|
|
329
|
+
};
|
|
330
|
+
jobs.set(job.id, job);
|
|
331
|
+
activeMutation = job;
|
|
332
|
+
Promise.resolve().then(() => work(progress)).then((result) => {
|
|
333
|
+
job.status = "completed";
|
|
334
|
+
job.result = result;
|
|
335
|
+
}).catch((error) => {
|
|
336
|
+
job.status = "failed";
|
|
337
|
+
job.error = { message: error.message || String(error), ...(error.details ? { details: error.details } : {}) };
|
|
338
|
+
}).finally(() => {
|
|
339
|
+
job.completedAt = new Date().toISOString();
|
|
340
|
+
if (activeMutation === job) activeMutation = null;
|
|
341
|
+
});
|
|
342
|
+
return job;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const server = http.createServer(async (request, response) => {
|
|
346
|
+
const url = new URL(request.url || "/", origin || "http://127.0.0.1");
|
|
347
|
+
const baseHeaders = {
|
|
348
|
+
// The application uses bounded runtime style properties for map layout and
|
|
349
|
+
// operation progress. Keep scripts locked to first-party files while
|
|
350
|
+
// allowing those styles to render in both local and relayed deployments.
|
|
351
|
+
"content-security-policy": "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
|
|
352
|
+
"referrer-policy": "no-referrer", "x-content-type-options": "nosniff", "x-frame-options": "DENY",
|
|
353
|
+
};
|
|
354
|
+
for (const [key, value] of Object.entries(baseHeaders)) response.setHeader(key, value);
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
if (request.method === "GET" && url.searchParams.has("token")) {
|
|
358
|
+
if (!sameSecret(url.searchParams.get("token"), sessionToken)) { response.writeHead(403); response.end("Invalid local session token"); return; }
|
|
359
|
+
response.writeHead(303, { location: "/", "set-cookie": `${SESSION_COOKIE}=${encodeURIComponent(sessionToken)}; HttpOnly; SameSite=Strict; Path=/` });
|
|
360
|
+
response.end();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (request.method === "GET" && url.pathname === "/api/version") {
|
|
364
|
+
json(response, 200, { product: "tapp-browser", release: process.env.TAPP_RELEASE || "dev", startedAt: serverStartedAt });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const proxyAuthenticated = !!trustedProxyToken && sameSecret(request.headers["x-tapp-proxy-token"], trustedProxyToken);
|
|
368
|
+
const authenticated = proxyAuthenticated || sameSecret(cookies(request)[SESSION_COOKIE], sessionToken);
|
|
369
|
+
if (!authenticated && directLogin) {
|
|
370
|
+
if (request.method === "GET" && url.pathname === "/login") { serveLoginPage(response, { failed: url.searchParams.has("failed") }); return; }
|
|
371
|
+
if (request.method === "POST" && url.pathname === "/api/auth") {
|
|
372
|
+
const now = Date.now();
|
|
373
|
+
if (now - loginFailures.windowStartedAt > 10 * 60_000) { loginFailures.windowStartedAt = now; loginFailures.count = 0; }
|
|
374
|
+
if (loginFailures.count >= 50) { response.writeHead(429, { "retry-after": "600", "content-type": "text/plain; charset=utf-8" }); response.end("Too many sign-in attempts; try again later."); return; }
|
|
375
|
+
// Browsers serialize Origin as the literal string "null" (not absent) for
|
|
376
|
+
// navigation-type requests — i.e. a real <form> POST, not fetch/XHR — when the
|
|
377
|
+
// response carries Referrer-Policy: no-referrer (see baseHeaders above and the
|
|
378
|
+
// Fetch spec's "append a request Origin header" algorithm). That is expected,
|
|
379
|
+
// spec-compliant behavior for every browser hitting this login form, not a
|
|
380
|
+
// cross-origin request, so trust the browser-guaranteed Sec-Fetch-Site header
|
|
381
|
+
// (unaffected by Referrer-Policy) to distinguish it from a genuine foreign origin.
|
|
382
|
+
const originHeader = request.headers.origin;
|
|
383
|
+
const sameOriginNavigation = originHeader === "null" && new Set(["same-origin", "none"]).has(request.headers["sec-fetch-site"]);
|
|
384
|
+
if (originHeader && originHeader !== "null" && !publicOrigins.includes(originHeader)) { response.writeHead(403); response.end("Cross-origin sign-in rejected"); return; }
|
|
385
|
+
if (originHeader === "null" && !sameOriginNavigation) { response.writeHead(403); response.end("Cross-origin sign-in rejected"); return; }
|
|
386
|
+
const form = await readFormBody(request);
|
|
387
|
+
const valid = sameSecret(form.get("username"), directLogin.username) && sameSecret(form.get("password"), directLogin.password);
|
|
388
|
+
if (!valid) {
|
|
389
|
+
loginFailures.count += 1;
|
|
390
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
391
|
+
response.writeHead(303, { location: "/login?failed=1" });
|
|
392
|
+
response.end();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
response.writeHead(303, { location: "/", "set-cookie": `${SESSION_COOKIE}=${encodeURIComponent(sessionToken)}; HttpOnly; SameSite=Strict; Secure; Path=/` });
|
|
396
|
+
response.end();
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (request.method === "GET" && !url.pathname.startsWith("/api/")) { response.writeHead(302, { location: "/login" }); response.end(); return; }
|
|
400
|
+
}
|
|
401
|
+
if (!authenticated) { response.writeHead(401, { "content-type": "text/plain; charset=utf-8" }); response.end(directLogin ? "Sign in at /login to continue." : "Open Tapp from the authenticated launch URL printed by the CLI."); return; }
|
|
402
|
+
|
|
403
|
+
if (request.method === "GET" && url.pathname === "/") {
|
|
404
|
+
serveBrowserIndex(response, hosted ? (proxyAuthenticated ? { apiBase:"/api/tapp", loginUrl:"/solutions/tapp" } : { apiBase:"", loginUrl:"/login" }) : {});
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (request.method === "GET" && url.pathname.startsWith("/assets/")) {
|
|
408
|
+
const file = assetPath(url.pathname);
|
|
409
|
+
if (!file) { response.writeHead(404); response.end("Not found"); return; }
|
|
410
|
+
serveFile(response, file);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (request.method === "GET" && url.pathname === "/api/session") {
|
|
414
|
+
json(response, 200, { csrfToken, mode: hosted ? "managed" : "local", product: "Tapp", root: hosted ? null : registry.currentRoot(), repository: hosted ? hostedRepository(registry.current()) : registry.current(), repositories: hosted ? registry.list().map(hostedRepository) : registry.list(), contract: customerProductContract, supportedPlatforms: customerProductContract.targetPlatforms, runners: runnerSummary({ hosted }) });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (request.method === "GET" && url.pathname === "/api/repositories") { json(response, 200, hosted ? { current:hostedRepository(registry.current()), repositories:registry.list().map(hostedRepository) } : { current: registry.current(), repositories: registry.list() }); return; }
|
|
418
|
+
if (request.method === "GET" && url.pathname === "/api/repositories/github") { json(response, 200, { repositories: await registry.listGithub() }); return; }
|
|
419
|
+
if (request.method === "GET" && url.pathname === "/api/project") { json(response, 200, hosted ? hostedPayload(projectSnapshot(registry)) : projectSnapshot(registry)); return; }
|
|
420
|
+
if (request.method === "GET" && url.pathname === "/api/live-session") { json(response, 200, liveSession || { active:false }); return; }
|
|
421
|
+
if (request.method === "GET" && url.pathname === "/api/live-session/frame") {
|
|
422
|
+
if (!liveSession?.active) { json(response, 409, { error:"No active live session" }); return; }
|
|
423
|
+
const engine = await import("./index.js");
|
|
424
|
+
const frame = await engine.captureInteractiveSessionFrame(1000);
|
|
425
|
+
if (frame?.error) { json(response, 500, { error:frame.error }); return; }
|
|
426
|
+
const data = Buffer.from(frame.data, "base64");
|
|
427
|
+
response.writeHead(200, { "content-type":frame.mimeType || "image/jpeg", "content-length":data.length, "cache-control":"no-store", "x-content-type-options":"nosniff" });
|
|
428
|
+
response.end(data);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const jobMatch = url.pathname.match(/^\/api\/jobs\/([a-f0-9]{20})$/);
|
|
433
|
+
if (request.method === "GET" && jobMatch) {
|
|
434
|
+
const job = jobs.get(jobMatch[1]);
|
|
435
|
+
if (!job) { json(response, 404, { error: "No such operation" }); return; }
|
|
436
|
+
json(response, 200, publicJob(job, { hosted }));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const captureMatch = url.pathname.match(/^\/evidence\/captures\/([A-Za-z0-9._-]{1,128})(?:\/(.*))?$/);
|
|
440
|
+
if (request.method === "GET" && captureMatch) {
|
|
441
|
+
const file = capturePath(captureMatch[1], captureMatch[2] || "report.html");
|
|
442
|
+
if (!file) { response.writeHead(404); response.end("Not found"); return; }
|
|
443
|
+
serveFile(response, file);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const uploadFileMatch = url.pathname.match(/^\/api\/repositories\/uploads\/(upload_[a-f0-9]{20})\/files$/);
|
|
448
|
+
if (request.method === "PUT" && uploadFileMatch) {
|
|
449
|
+
requireMutationAuth(request, hosted ? publicOrigins : origin, csrfToken);
|
|
450
|
+
let relativePath;
|
|
451
|
+
try { relativePath = decodeURIComponent(String(request.headers["x-tapp-relative-path"] || "")); }
|
|
452
|
+
catch { throw Object.assign(new Error("Repository file path header is invalid"), { statusCode: 400 }); }
|
|
453
|
+
const result = await registry.writeUploadFile(uploadFileMatch[1], relativePath, request, request.headers["content-length"]);
|
|
454
|
+
json(response, 200, result);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (request.method === "POST" && url.pathname === "/api/repositories/uploads") {
|
|
458
|
+
requireMutationAuth(request, hosted ? publicOrigins : origin, csrfToken);
|
|
459
|
+
json(response, 201, registry.createUpload(await readJsonBody(request)));
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const uploadCompleteMatch = url.pathname.match(/^\/api\/repositories\/uploads\/(upload_[a-f0-9]{20})\/complete$/);
|
|
463
|
+
if (request.method === "POST" && uploadCompleteMatch) {
|
|
464
|
+
requireMutationAuth(request, hosted ? publicOrigins : origin, csrfToken);
|
|
465
|
+
if (liveSession?.active) throw Object.assign(new Error("End the active live session before changing repositories"), { statusCode:409 });
|
|
466
|
+
const repository = registry.finishUpload(uploadCompleteMatch[1]);
|
|
467
|
+
json(response, 200, { repository: hosted ? hostedRepository(repository) : repository });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const uploadAbortMatch = url.pathname.match(/^\/api\/repositories\/uploads\/(upload_[a-f0-9]{20})$/);
|
|
471
|
+
if (request.method === "DELETE" && uploadAbortMatch) {
|
|
472
|
+
requireMutationAuth(request, hosted ? publicOrigins : origin, csrfToken);
|
|
473
|
+
json(response, 200, { aborted: registry.abortUpload(uploadAbortMatch[1]) });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (request.method === "POST" && url.pathname === "/api/repositories/select") {
|
|
477
|
+
requireMutationAuth(request, hosted ? publicOrigins : origin, csrfToken);
|
|
478
|
+
if (activeMutation) throw Object.assign(new Error(`Operation '${activeMutation.operation}' is still running`), { statusCode: 409 });
|
|
479
|
+
if (liveSession?.active) throw Object.assign(new Error("End the active live session before changing repositories"), { statusCode:409 });
|
|
480
|
+
const body = await readJsonBody(request);
|
|
481
|
+
const repository = registry.select(body.id);
|
|
482
|
+
json(response, 200, { repository: hosted ? hostedRepository(repository) : repository });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (request.method !== "POST" || !url.pathname.startsWith("/api/operations/")) { response.writeHead(404); response.end("Not found"); return; }
|
|
487
|
+
requireMutationAuth(request, hosted ? publicOrigins : origin, csrfToken);
|
|
488
|
+
const body = await readJsonBody(request);
|
|
489
|
+
const operation = url.pathname.slice("/api/operations/".length);
|
|
490
|
+
if (operation === "connect-github") {
|
|
491
|
+
if (liveSession?.active) throw Object.assign(new Error("End the active live session before changing repositories"), { statusCode:409 });
|
|
492
|
+
const job = startJob(operation, (progress) => registry.cloneGithub(body.repository, progress), null);
|
|
493
|
+
json(response, 202, { job: publicJob(job, { hosted }) });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const root = registry.currentRoot();
|
|
497
|
+
if (!root) throw Object.assign(new Error("Connect a local folder or GitHub repository first"), { statusCode: 409 });
|
|
498
|
+
const repositoryId = registry.current().id;
|
|
499
|
+
const job = startJob(operation, async (progress) => {
|
|
500
|
+
const engine = await import("./index.js");
|
|
501
|
+
const runtime = runtimeRequest(body);
|
|
502
|
+
if (liveSession?.active && !["session-act", "session-save-flow", "session-end"].includes(operation)) throw new Error("End the active live session before starting another build, exploration, validation, or gate operation");
|
|
503
|
+
if (operation === "session-start") {
|
|
504
|
+
const selected = selectedTarget(root, body);
|
|
505
|
+
const prepared = await prepareProductTarget({ projectDir: root, platform:selected.platform, target:selected.id, buildIos:engine.buildAppForSim, installIos:engine.installAppOnBootedSim, buildAndroid:engine.buildAndroidApp, onProgress:progress });
|
|
506
|
+
let result;
|
|
507
|
+
try {
|
|
508
|
+
if (selected.platform === "ios") {
|
|
509
|
+
result = await engine.startIosInteractiveSession(prepared.runtime.bundleId, {
|
|
510
|
+
...(runtime.testEmail ? { OCQA_TEST_EMAIL:runtime.testEmail } : {}),
|
|
511
|
+
...(runtime.testPassword ? { OCQA_TEST_PASSWORD:runtime.testPassword } : {}),
|
|
512
|
+
});
|
|
513
|
+
} else if (selected.platform === "android") {
|
|
514
|
+
result = await engine.startAndroidInteractiveSession(prepared.runtime.appId, { serial:runtime.serial, apkPath:prepared.runtime.apkPath, clearData:true, testEmail:runtime.testEmail || "", testPassword:runtime.testPassword || "" });
|
|
515
|
+
} else {
|
|
516
|
+
let sessionUrl = prepared.runtime.url || "";
|
|
517
|
+
if (!sessionUrl) {
|
|
518
|
+
liveManagedRuntime = await engine.startManagedWebTarget({ root, requestedTarget:selected.id, timeout:runtime.timeout, onStatus:(text) => progress({ phase:"runtime", text }) });
|
|
519
|
+
if (liveManagedRuntime?.error) throw Object.assign(new Error(liveManagedRuntime.error), { details:liveManagedRuntime.details || {} });
|
|
520
|
+
sessionUrl = liveManagedRuntime.url;
|
|
521
|
+
}
|
|
522
|
+
result = await engine.startWebInteractiveSession(sessionUrl, { testEmail:runtime.testEmail || "", testPassword:runtime.testPassword || "" });
|
|
523
|
+
}
|
|
524
|
+
if (result?.error) throw new Error(result.error);
|
|
525
|
+
liveSession = publicInteractiveResult(result, { active:true, platform:selected.platform, targetId:selected.id, targetName:selected.name, url:result?.url || "" });
|
|
526
|
+
return liveSession;
|
|
527
|
+
} catch (error) {
|
|
528
|
+
if (liveManagedRuntime) await engine.stopManagedWebTarget(liveManagedRuntime).catch(() => {});
|
|
529
|
+
liveManagedRuntime = null;
|
|
530
|
+
liveSession = null;
|
|
531
|
+
throw error;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (operation === "session-act") {
|
|
535
|
+
if (!liveSession?.active) throw new Error("Start a live session first");
|
|
536
|
+
const allowed = new Set(["tap", "type", "wait", "back", "swipe", "tree"]);
|
|
537
|
+
const action = String(body.action || "");
|
|
538
|
+
if (!allowed.has(action)) throw new Error(`Unsupported live-session action '${action}'`);
|
|
539
|
+
const result = await engine.actInteractiveSession({
|
|
540
|
+
action,
|
|
541
|
+
...(typeof body.id === "string" && body.id ? { id:body.id } : {}),
|
|
542
|
+
...(typeof body.label === "string" && body.label ? { label:body.label } : {}),
|
|
543
|
+
...(typeof body.text === "string" ? { text:body.text } : {}),
|
|
544
|
+
...(typeof body.direction === "string" ? { direction:body.direction } : {}),
|
|
545
|
+
...(Number.isFinite(Number(body.timeoutMs)) ? { timeoutMs:Number(body.timeoutMs) } : {}),
|
|
546
|
+
});
|
|
547
|
+
liveSession = publicInteractiveResult(result, liveSession);
|
|
548
|
+
if (result?.error || result?.status === "error") throw new Error(result.error || result.detail || "Live-session action failed");
|
|
549
|
+
return liveSession;
|
|
550
|
+
}
|
|
551
|
+
if (operation === "session-save-flow") {
|
|
552
|
+
if (!liveSession?.active) throw new Error("Start and drive a live session before saving a Flow");
|
|
553
|
+
const saved = await engine.saveInteractiveSessionFlow({
|
|
554
|
+
projectDir:root,
|
|
555
|
+
name:body.name,
|
|
556
|
+
addFinalAssertion:body.addFinalAssertion !== false,
|
|
557
|
+
replace:body.replace === true,
|
|
558
|
+
// Repository-managed web runtimes are resolved again by the
|
|
559
|
+
// target-aware product gate; ephemeral localhost ports never enter source.
|
|
560
|
+
url:"",
|
|
561
|
+
});
|
|
562
|
+
return { path:saved.path, flow:saved.flow, yaml:saved.yaml, replaced:saved.replaced };
|
|
563
|
+
}
|
|
564
|
+
if (operation === "session-end") {
|
|
565
|
+
await engine.endInteractiveSession();
|
|
566
|
+
if (liveManagedRuntime) await engine.stopManagedWebTarget(liveManagedRuntime).catch(() => {});
|
|
567
|
+
liveManagedRuntime = null;
|
|
568
|
+
liveSession = null;
|
|
569
|
+
return { active:false, ended:true };
|
|
570
|
+
}
|
|
571
|
+
if (operation === "initialize") {
|
|
572
|
+
if (body.explore !== true) {
|
|
573
|
+
return initializeProductProject({ projectDir: root, mode: body.write === false ? "inspect" : body.refresh === true ? "refresh" : "write", platform: String(body.platform || "").toLowerCase(), maxContracts: runtime.maxContracts });
|
|
574
|
+
}
|
|
575
|
+
if (!readProductProject({ projectDir: root }).model) await initializeProductProject({ projectDir: root, mode: "write", maxContracts: runtime.maxContracts });
|
|
576
|
+
const selected = selectedTarget(root, body);
|
|
577
|
+
const prepared = await prepareProductTarget({
|
|
578
|
+
projectDir:root,
|
|
579
|
+
platform:selected.platform,
|
|
580
|
+
target:selected.id,
|
|
581
|
+
buildIos:engine.buildAppForSim,
|
|
582
|
+
buildAndroid:engine.buildAndroidApp,
|
|
583
|
+
onProgress:progress,
|
|
584
|
+
});
|
|
585
|
+
return initializeProductProject({
|
|
586
|
+
projectDir: root, mode: "explore",
|
|
587
|
+
platform: selected.platform,
|
|
588
|
+
target: selected.platform === "ios" ? prepared.runtime.appPath : selected.id,
|
|
589
|
+
bundleId: prepared.runtime.bundleId || selected.runtime?.bundleId || "", appId: prepared.runtime.appId || selected.runtime?.applicationId || "",
|
|
590
|
+
apkPath: prepared?.runtime.apkPath, serial: runtime.serial,
|
|
591
|
+
scheme: selected.build?.proposedScheme || "", configuration: selected.build?.configuration || "Debug",
|
|
592
|
+
maxActions: runtime.maxActions, timeout: runtime.timeout, maxContracts: runtime.maxContracts,
|
|
593
|
+
testEmail: runtime.testEmail, testPassword: runtime.testPassword,
|
|
594
|
+
runExploration: engine.runInitExploration,
|
|
595
|
+
onProgress: (entry) => progress({ phase: "explore", text: `${entry.action || 0}/${entry.max || runtime.maxActions} actions · ${entry.states || 0} states` }),
|
|
596
|
+
onStatus: (text) => progress({ phase: "runtime", text }),
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
if (operation === "review") return reviewProductPlan({ projectDir: root, approve: body.approve || [], reject: body.reject || [], defer: body.defer || [] });
|
|
600
|
+
if (operation === "generate") return generateProductPlan({ projectDir: root });
|
|
601
|
+
if (operation === "validate") {
|
|
602
|
+
const selected = selectedTarget(root, body);
|
|
603
|
+
const prepared = await prepareProductTarget({ projectDir: root, platform: selected.platform, target: selected.id, buildIos: engine.buildAppForSim, installIos: engine.installAppOnBootedSim, buildAndroid: engine.buildAndroidApp, onProgress: progress });
|
|
604
|
+
return validateProductPlan({
|
|
605
|
+
projectDir: root, items: body.items || [], platform: selected.platform, target: selected.id,
|
|
606
|
+
url: prepared.runtime.url || "", bundleId: prepared.runtime.bundleId || "", appId: prepared.runtime.appId || "", apkPath: prepared.runtime.apkPath || "", serial: runtime.serial,
|
|
607
|
+
timeout: runtime.timeout, testEmail: runtime.testEmail, testPassword: runtime.testPassword,
|
|
608
|
+
startWebTarget: engine.startManagedWebTarget, stopWebTarget: engine.stopManagedWebTarget, onProgress: progress,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
if (operation === "promote") return promoteProductPlan({ projectDir: root, items: body.items || [] });
|
|
612
|
+
if (operation === "ci-preview") return prepareProductCi({ projectDir: root, actionRef: body.actionRef, defaultBranch: body.defaultBranch || "main" });
|
|
613
|
+
if (operation === "ci-install") return installProductCi({ projectDir: root, actionRef: body.actionRef, defaultBranch: body.defaultBranch || "main", replace: body.replace === true, allowUnresolved: body.allowUnresolved === true });
|
|
614
|
+
if (operation === "gate") {
|
|
615
|
+
const selected = selectedTarget(root, body);
|
|
616
|
+
const prepared = await prepareProductTarget({ projectDir: root, platform: selected.platform, target: selected.id, buildIos: engine.buildAppForSim, buildAndroid: engine.buildAndroidApp, onProgress: progress });
|
|
617
|
+
return runProductGate({
|
|
618
|
+
projectDir: root, platform: selected.platform, target: selected.id,
|
|
619
|
+
url: prepared.runtime.url || "", appPath: prepared.runtime.appPath || "", bundleId: prepared.runtime.bundleId || "", appId: prepared.runtime.appId || "", apkPath: prepared.runtime.apkPath || "", serial: runtime.serial,
|
|
620
|
+
actions: runtime.maxActions, timeout: runtime.timeout, baseline: body.baseline || "", testEmail: runtime.testEmail, testPassword: runtime.testPassword, onProgress: progress,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
if (operation === "baseline") {
|
|
624
|
+
const project = readProductProject({ projectDir: root });
|
|
625
|
+
const run = project.evidence.find((item) => item.id === body.runId);
|
|
626
|
+
if (!run?.reportPath) throw new Error("Select a completed local gate run before creating a baseline");
|
|
627
|
+
const platform = run.report?.platform || String(body.platform || "").toLowerCase();
|
|
628
|
+
const target = run.report?.targetKey || body.target || "";
|
|
629
|
+
return createProductBaseline({ projectDir: root, reportPath: run.reportPath, platform, target, replace: body.replace === true });
|
|
630
|
+
}
|
|
631
|
+
throw new Error(`Unsupported product operation '${operation}'`);
|
|
632
|
+
}, repositoryId);
|
|
633
|
+
json(response, 202, { job: publicJob(job, { hosted }) });
|
|
634
|
+
} catch (error) {
|
|
635
|
+
if (!response.headersSent) json(response, error.statusCode || 500, { error: error.message || String(error), ...(error.code ? { code: error.code } : {}) });
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
await new Promise((resolve, reject) => {
|
|
640
|
+
server.once("error", reject);
|
|
641
|
+
server.listen(Number(port) || 0, "127.0.0.1", resolve);
|
|
642
|
+
});
|
|
643
|
+
const address = server.address();
|
|
644
|
+
origin = `http://127.0.0.1:${address.port}`;
|
|
645
|
+
const launchUrl = `${origin}/?token=${encodeURIComponent(sessionToken)}`;
|
|
646
|
+
if (launch) openBrowser(launchUrl);
|
|
647
|
+
return {
|
|
648
|
+
root: registry.currentRoot(), origin, launchUrl, server, registry,
|
|
649
|
+
close: async () => {
|
|
650
|
+
if (liveSession?.active) {
|
|
651
|
+
const engine = await import("./index.js");
|
|
652
|
+
await engine.endInteractiveSession().catch(() => {});
|
|
653
|
+
if (liveManagedRuntime) await engine.stopManagedWebTarget(liveManagedRuntime).catch(() => {});
|
|
654
|
+
}
|
|
655
|
+
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
656
|
+
registry.close();
|
|
657
|
+
},
|
|
658
|
+
};
|
|
659
|
+
}
|