@aarwitz/tapp 0.17.0-rc.9 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +34 -0
- package/.claude-plugin/plugin.json +33 -0
- package/AGENTS.md +44 -15
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +65 -7
- package/README.md +141 -81
- package/bin/tapp.js +253 -64
- package/docs/BROWSER-PRODUCT.md +1 -1
- package/docs/application-model.md +12 -3
- package/docs/scenarios.md +1 -1
- package/mcp-server/src/android-driver.js +13 -2
- package/mcp-server/src/android-explorer.js +3 -1
- package/mcp-server/src/android-flow.js +18 -1
- package/mcp-server/src/application-model.js +83 -13
- package/mcp-server/src/ci-report.js +2 -2
- package/mcp-server/src/ci-setup.js +4 -4
- package/mcp-server/src/environment-preflight.js +43 -0
- package/mcp-server/src/focused-navigation.js +271 -0
- package/mcp-server/src/html-report.js +3 -2
- package/mcp-server/src/index.js +565 -134
- package/mcp-server/src/pr-selection.js +2 -2
- package/mcp-server/src/product-operations.js +111 -6
- package/mcp-server/src/report.js +17 -4
- package/mcp-server/src/ui-map.js +2 -2
- package/mcp-server/src/web-explorer.js +123 -10
- package/mcp-server/src/web-flow.js +17 -1
- package/package.json +6 -4
- package/scripts/ci-gate.sh +42 -0
- package/scripts/flow_lib.py +1 -1
- package/scripts/quick-capture.sh +44 -13
- package/scripts/run-flow.sh +12 -1
- package/skills/tapp/SKILL.md +95 -0
- package/skills/tapp/agents/openai.yaml +4 -0
- package/skills/tapp/references/commands.md +105 -0
|
@@ -305,14 +305,25 @@ export class AndroidDriver {
|
|
|
305
305
|
return r.stdout;
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
-
async settle(timeoutMs = 2200) {
|
|
308
|
+
async settle(timeoutMs = 2200, previousSnapshot = null) {
|
|
309
309
|
const deadline = Date.now() + timeoutMs;
|
|
310
|
+
const fingerprintOf = (snapshot) => (snapshot?.elements || [])
|
|
311
|
+
.map((e) => `${androidElementKey(e)}:${e.text}:${e.x},${e.y}`).join("|");
|
|
312
|
+
const previousScreen = String(previousSnapshot?.screenTitle || "");
|
|
313
|
+
const previousFingerprint = fingerprintOf(previousSnapshot);
|
|
310
314
|
let previous = "";
|
|
311
315
|
let stable = 0;
|
|
312
316
|
let latest;
|
|
313
317
|
while (Date.now() < deadline) {
|
|
314
318
|
latest = await this.snapshot();
|
|
315
|
-
const fingerprint = latest
|
|
319
|
+
const fingerprint = fingerprintOf(latest);
|
|
320
|
+
// UIAutomator's dump command itself waits for the UI to become idle. If its first complete
|
|
321
|
+
// snapshot proves that the requested interaction changed the screen, a second identical
|
|
322
|
+
// dump adds roughly two seconds without adding evidence. Preserve the two-snapshot stability
|
|
323
|
+
// requirement when nothing changed (including delayed navigation and no-op controls).
|
|
324
|
+
if (previousSnapshot && fingerprint && (
|
|
325
|
+
String(latest.screenTitle || "") !== previousScreen || fingerprint !== previousFingerprint
|
|
326
|
+
)) return latest;
|
|
316
327
|
if (fingerprint === previous) stable += 1; else stable = 0;
|
|
317
328
|
if (stable >= 1) return latest;
|
|
318
329
|
previous = fingerprint;
|
|
@@ -74,6 +74,7 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
|
|
|
74
74
|
const visited = new Map();
|
|
75
75
|
let issues = 0;
|
|
76
76
|
let actions = 0;
|
|
77
|
+
let loginTried = false;
|
|
77
78
|
const crashExitBaseline = typeof d.latestCrashExitInfo === "function" ? await d.latestCrashExitInfo() : null;
|
|
78
79
|
let snap = await d.launch({ clearData });
|
|
79
80
|
let crashReported = false;
|
|
@@ -210,6 +211,7 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
|
|
|
210
211
|
if (candidate) {
|
|
211
212
|
const target = controlLabel(candidate);
|
|
212
213
|
const loginSubmit = inputs.some((input) => input.secure) && isAndroidAuthSubmit(candidate);
|
|
214
|
+
if (loginSubmit) loginTried = true;
|
|
213
215
|
tried.add(`${hash}|tap|${target}`);
|
|
214
216
|
const before = hash;
|
|
215
217
|
const r = await d.tap(target, snap);
|
|
@@ -260,7 +262,7 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
|
|
|
260
262
|
}
|
|
261
263
|
|
|
262
264
|
const timedOut = Date.now() >= deadline;
|
|
263
|
-
emit("COMPLETE", { actions, states: visited.size, issues, screens: [...new Set(visited.values())].join(","), outcome: timedOut ? "timeout" : "complete", timedOut, ...(timedOut ? { timeoutSeconds: timeoutSec } : {}) });
|
|
265
|
+
emit("COMPLETE", { actions, states: visited.size, issues, screens: [...new Set(visited.values())].join(","), outcome: timedOut ? "timeout" : "complete", timedOut, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried, ...(timedOut ? { timeoutSeconds: timeoutSec } : {}) });
|
|
264
266
|
onProgress({ action: actions, max: maxActions, states: visited.size });
|
|
265
267
|
return { markersPath, outDir, actions, states: visited.size, issues, timedOut, seedTargets: normalizedTargets };
|
|
266
268
|
}
|
|
@@ -43,6 +43,23 @@ export async function runAndroidFlow({ flow, appId, apkPath, serial, logPath, sc
|
|
|
43
43
|
} else if (action === "type") {
|
|
44
44
|
const r = await d.type(target, value, snap);
|
|
45
45
|
if (r.status !== "ok") throw new Error(r.detail || `no field ‘${target}’ to type into`);
|
|
46
|
+
} else if (action === "login") {
|
|
47
|
+
const fields = snap.elements.filter((element) => /EditText/i.test(element.type));
|
|
48
|
+
const emailField = fields.find((element) => /email|user/i.test(`${element.id} ${element.label}`)) || fields.find((element) => !element.secure);
|
|
49
|
+
const passwordField = fields.find((element) => element.secure || /password|passcode/i.test(`${element.id} ${element.label}`));
|
|
50
|
+
if (!emailField || !passwordField) throw new Error("could not identify email and password fields");
|
|
51
|
+
const emailValue = substituteFlowValue(raw.params.email || "$TEST_EMAIL", vars);
|
|
52
|
+
const passwordValue = substituteFlowValue(raw.params.password || "$TEST_PASSWORD", vars);
|
|
53
|
+
const emailResult = await d.type(emailField.id || emailField.label, emailValue, snap);
|
|
54
|
+
if (emailResult.status !== "ok") throw new Error(emailResult.detail || "could not fill the email field");
|
|
55
|
+
snap = await d.settle();
|
|
56
|
+
const passwordResult = await d.type(passwordField.id || passwordField.label, passwordValue, snap);
|
|
57
|
+
if (passwordResult.status !== "ok") throw new Error(passwordResult.detail || "could not fill the password field");
|
|
58
|
+
snap = await d.settle();
|
|
59
|
+
const submit = snap.elements.find((element) => element.clickable && /sign in|log in|login|continue|submit/i.test(`${element.text} ${element.label} ${element.id}`));
|
|
60
|
+
if (!submit) throw new Error("could not identify a sign-in control");
|
|
61
|
+
const submitResult = await d.tap(submit.id || submit.description || submit.text, snap);
|
|
62
|
+
if (submitResult.status !== "ok") throw new Error(submitResult.detail || "could not submit the login form");
|
|
46
63
|
} else if (action === "swipe") {
|
|
47
64
|
await d.swipe(target || "up");
|
|
48
65
|
} else if (action === "back") {
|
|
@@ -81,7 +98,7 @@ export async function runAndroidFlow({ flow, appId, apkPath, serial, logPath, sc
|
|
|
81
98
|
detail = error.message || String(error);
|
|
82
99
|
if (screenshotDir) await d.screenshot(path.join(screenshotDir, `flow-failure-${i + 1}.png`)).catch(() => {});
|
|
83
100
|
}
|
|
84
|
-
log.step({ index: i + 1, action, target: target || value, status, detail, task: raw.task });
|
|
101
|
+
log.step({ index: i + 1, action, target: action === "login" ? "sign-in form" : target || value, status, detail, task: raw.task });
|
|
85
102
|
if (status === "fail" && !flow.continueOnFailure) break;
|
|
86
103
|
}
|
|
87
104
|
if (screenshotDir) await d.screenshot(path.join(screenshotDir, "flow-final.png")).catch(() => {});
|
|
@@ -90,6 +90,7 @@ function applyRuntimeTargetValidation(root, targets, validation) {
|
|
|
90
90
|
const bundleId = String(validation.target || validation.resolution.bundleId || "").trim();
|
|
91
91
|
if (!container || !scheme || !bundleId) return targets;
|
|
92
92
|
const captureId = String(validation.evidence?.captureId || "").trim();
|
|
93
|
+
const explored = !!captureId;
|
|
93
94
|
return targets.map((target) => {
|
|
94
95
|
if (target.platform !== "ios" || posix(target.sourcePath) !== container) return target;
|
|
95
96
|
return {
|
|
@@ -109,7 +110,7 @@ function applyRuntimeTargetValidation(root, targets, validation) {
|
|
|
109
110
|
runtimeValidation: {
|
|
110
111
|
status: "validated",
|
|
111
112
|
basis: "runtime-observed",
|
|
112
|
-
operation: "xcode-build-install-explore",
|
|
113
|
+
operation: explored ? "xcode-build-install-explore" : "xcode-build-install",
|
|
113
114
|
target: bundleId,
|
|
114
115
|
build: { container, scheme, configuration },
|
|
115
116
|
evidence: {
|
|
@@ -118,7 +119,9 @@ function applyRuntimeTargetValidation(root, targets, validation) {
|
|
|
118
119
|
findingCount: Number(validation.evidence?.findingCount || 0),
|
|
119
120
|
...(validation.evidence?.observedAt ? { observedAt: String(validation.evidence.observedAt) } : {}),
|
|
120
121
|
},
|
|
121
|
-
detail:
|
|
122
|
+
detail: explored
|
|
123
|
+
? "Tapp built this repository target with the recorded scheme, installed it, launched it, and produced UI Map evidence."
|
|
124
|
+
: "Tapp built this repository target with the recorded scheme and installed it on an iOS simulator.",
|
|
122
125
|
},
|
|
123
126
|
};
|
|
124
127
|
});
|
|
@@ -133,7 +136,7 @@ function persistedTargetValidations(root, outDir) {
|
|
|
133
136
|
if (prior?.schemaVersion !== 1 || prior.kind !== "tapp-application-model" || !Array.isArray(prior.targets)) return [];
|
|
134
137
|
return prior.targets.flatMap((target) => {
|
|
135
138
|
const validation = target?.runtimeValidation;
|
|
136
|
-
if (target?.platform !== "ios" || validation?.status !== "validated" || validation?.basis !== "runtime-observed" ||
|
|
139
|
+
if (target?.platform !== "ios" || validation?.status !== "validated" || validation?.basis !== "runtime-observed" || !["xcode-build-install", "xcode-build-install-explore"].includes(validation?.operation)) return [];
|
|
137
140
|
const capture = String(validation.evidence?.capture || "");
|
|
138
141
|
return [{
|
|
139
142
|
platform: "ios",
|
|
@@ -307,6 +310,31 @@ function loadTargetUiMaps(root, targets) {
|
|
|
307
310
|
const scopeRoot = path.join(root, scope === "." ? "" : scope);
|
|
308
311
|
const expectedPath = posix(path.join(scope === "." ? "" : scope, projectArtifactDirectory(scopeRoot), "ui-map.json"));
|
|
309
312
|
let loaded = expectedPath === rootMap.summary.path ? rootMap : loadUiMapAt(root, expectedPath);
|
|
313
|
+
// Multiple targets can legitimately share an artifact directory (for example, a root Xcode
|
|
314
|
+
// project beside a root Vite app). A file being at the target's expected path does not prove
|
|
315
|
+
// it describes that target. Never attach a web-only map to iOS merely because both resolve to
|
|
316
|
+
// `.tapp/ui-map.json`; that duplicates proposals and, worse, claims runtime evidence for the
|
|
317
|
+
// wrong application surface.
|
|
318
|
+
if (loaded.map && targets.length > 1 && !uiMapTargetsTarget(loaded.map, target, targets)) {
|
|
319
|
+
loaded = {
|
|
320
|
+
map: null,
|
|
321
|
+
summary: {
|
|
322
|
+
path: expectedPath,
|
|
323
|
+
status: "missing",
|
|
324
|
+
nodeCount: 0,
|
|
325
|
+
edgeCount: 0,
|
|
326
|
+
controlCount: 0,
|
|
327
|
+
uncoveredNodeIds: [],
|
|
328
|
+
uncoveredEdgeIds: [],
|
|
329
|
+
platforms: [],
|
|
330
|
+
rejectedArtifact: {
|
|
331
|
+
path: loaded.summary.path,
|
|
332
|
+
platforms: loaded.summary.platforms || [],
|
|
333
|
+
reason: "artifact metadata does not identify this target",
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
310
338
|
if (!loaded.map && rootMap.map && (targets.length === 1 || uiMapTargetsTarget(rootMap.map, target, targets))) loaded = rootMap;
|
|
311
339
|
return {
|
|
312
340
|
targetId: target.id,
|
|
@@ -424,7 +452,7 @@ function applicationName(root, targets) {
|
|
|
424
452
|
return pkg?.name || (targets.length === 1 ? targets[0].name : path.basename(root));
|
|
425
453
|
}
|
|
426
454
|
|
|
427
|
-
export async function inspectApplicationRepository({ projectDir, ownedUrl = "", platform = "", targetValidation = null, outDir = ".tapp" } = {}) {
|
|
455
|
+
export async function inspectApplicationRepository({ projectDir, ownedUrl = "", platform = "", targetValidation = null, defaultTargetId = "", outDir = ".tapp" } = {}) {
|
|
428
456
|
const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
|
|
429
457
|
const inventory = walk(root);
|
|
430
458
|
let targets = [
|
|
@@ -541,10 +569,10 @@ export async function inspectApplicationRepository({ projectDir, ownedUrl = "",
|
|
|
541
569
|
const requirements = [];
|
|
542
570
|
if (!targets.length) requirements.push({ id: "target", severity: "blocking", status: "missing", message: "No iOS application project, Android application module, or browser application target was detected.", remediation: "Pass the representative target explicitly or add its unavoidable build/runtime configuration." });
|
|
543
571
|
for (const target of targets) {
|
|
544
|
-
if (target.platform === "ios" && target.status !== "configured") requirements.push({ id: `${target.id}:scheme`, severity: "blocking", status: "needs-confirmation", message: `Confirm a shared build scheme for ${target.name}.`, remediation: `Run tapp build ${target.sourcePath} or provide the scheme during init/build.` });
|
|
572
|
+
if (target.platform === "ios" && target.status !== "configured") requirements.push({ id: `${target.id}:scheme`, severity: "blocking", status: "needs-confirmation", message: `Confirm a shared build scheme for ${target.name}.`, remediation: `Run npx -y @aarwitz/tapp@latest build ${target.sourcePath} or provide the scheme during init/build.` });
|
|
545
573
|
if (target.platform === "android" && !target.runtime.applicationId) requirements.push({ id: `${target.id}:application-id`, severity: "blocking", status: "missing", message: `Android application id was not statically detected for ${target.name}.`, remediation: "Provide --app-id or expose applicationId in the application module." });
|
|
546
|
-
if (target.platform === "web" && !target.runtime.ownedUrl && target.runtime.management !== "tapp-managed") requirements.push({ id: `${target.id}:owned-url`, severity: "blocking", status: "missing", message: `No safe managed runtime or owned URL is available for ${target.name}.`, remediation: "Add a deterministic start/static target or start the app and rerun tapp init with --url http://127.0.0.1:<port>." });
|
|
547
|
-
if (target.platform === "web" && target.build.dependencyStatus === "missing-lockfile") requirements.push({ id: `${target.id}:dependency-lock`, severity: "blocking", status: "missing", message: `${target.name} declares browser dependencies without an observed dependency lockfile.`, remediation: "Commit the package-manager lockfile so Tapp can install dependencies reproducibly, then rerun tapp init." });
|
|
574
|
+
if (target.platform === "web" && !target.runtime.ownedUrl && target.runtime.management !== "tapp-managed") requirements.push({ id: `${target.id}:owned-url`, severity: "blocking", status: "missing", message: `No safe managed runtime or owned URL is available for ${target.name}.`, remediation: "Add a deterministic start/static target or start the app and rerun npx -y @aarwitz/tapp@latest init with --url http://127.0.0.1:<port>." });
|
|
575
|
+
if (target.platform === "web" && target.build.dependencyStatus === "missing-lockfile") requirements.push({ id: `${target.id}:dependency-lock`, severity: "blocking", status: "missing", message: `${target.name} declares browser dependencies without an observed dependency lockfile.`, remediation: "Commit the package-manager lockfile so Tapp can install dependencies reproducibly, then rerun npx -y @aarwitz/tapp@latest init." });
|
|
548
576
|
}
|
|
549
577
|
const incompleteMaps = uiMaps.filter((record) => record.summary.status !== "observed");
|
|
550
578
|
for (const record of incompleteMaps.length ? incompleteMaps : (!targets.length && uiMap.status !== "observed" ? [{ summary: uiMap }] : [])) {
|
|
@@ -553,7 +581,7 @@ export async function inspectApplicationRepository({ projectDir, ownedUrl = "",
|
|
|
553
581
|
requirements.push({
|
|
554
582
|
id: targets.length <= 1 ? "ui-map" : `${target.id}:ui-map`, severity: "blocking", status: summary.status === "inconclusive" ? "inconclusive" : "missing",
|
|
555
583
|
message: target ? (summary.status === "inconclusive" ? `The UI Map for ${target.name} is inconclusive.` : `No grounded UI Map exists for ${target.name}.`) : "No repository UI Map has been grounded in a real run.",
|
|
556
|
-
remediation: target ? `Build/launch ${target.name}, explore the real target, and retain its map at ${summary.expectedPath || summary.path}.` : "Build/launch the target and run tapp init --explore so real exploration evidence is merged into .tapp/ui-map.json.",
|
|
584
|
+
remediation: target ? `Build/launch ${target.name}, explore the real target, and retain its map at ${summary.expectedPath || summary.path}.` : "Build/launch the target and run npx -y @aarwitz/tapp@latest init --explore so real exploration evidence is merged into .tapp/ui-map.json.",
|
|
557
585
|
});
|
|
558
586
|
}
|
|
559
587
|
if (!contracts.length) requirements.push({ id: "contracts", severity: "warning", status: "missing", message: "No reviewed release contracts exist yet.", remediation: "Review the proposed release plan, then generate and validate a compact set of contracts." });
|
|
@@ -561,17 +589,20 @@ export async function inspectApplicationRepository({ projectDir, ownedUrl = "",
|
|
|
561
589
|
for (const error of projectConfiguration.errors) requirements.push({ id: stableId("project-config-error", error), severity: "blocking", status: "invalid", message: error, remediation: `Fix ${projectConfiguration.relativePath}; actor configuration must contain environment-variable bindings, never credential values.` });
|
|
562
590
|
for (const actor of actors) {
|
|
563
591
|
const missingBindings = actor.credentialRequirements.filter((credential) => !actor.credentialBindings[credential]);
|
|
564
|
-
if (missingBindings.length) requirements.push({ id: `actor:${actor.name}:credential-bindings`, severity: "blocking", status: "missing", message: `Actor '${actor.name}' has unbound credential requirements: ${missingBindings.join(", ")}.`, remediation: `Run tapp actor set ${actor.name} --credential <name>=<ENV_NAME> for each credential, then replace any literal contract credentials with $ENV_NAME placeholders.` });
|
|
592
|
+
if (missingBindings.length) requirements.push({ id: `actor:${actor.name}:credential-bindings`, severity: "blocking", status: "missing", message: `Actor '${actor.name}' has unbound credential requirements: ${missingBindings.join(", ")}.`, remediation: `Run npx -y @aarwitz/tapp@latest actor set ${actor.name} --credential <name>=<ENV_NAME> for each credential, then replace any literal contract credentials with $ENV_NAME placeholders.` });
|
|
565
593
|
if (actor.bindingConflicts.length) requirements.push({ id: `actor:${actor.name}:credential-conflicts`, severity: "blocking", status: "conflict", message: `Actor '${actor.name}' has conflicting credential environment bindings.`, remediation: `Align ${projectConfiguration.relativePath} and reviewed contracts; Tapp will not guess which secret binding is correct.` });
|
|
566
594
|
}
|
|
567
595
|
for (const error of taskErrors) requirements.push({ id: stableId("task-error", error.path), severity: "blocking", status: "invalid", message: error.error, remediation: `Fix ${error.path} before generation.` });
|
|
568
596
|
for (const error of contractErrors) requirements.push({ id: stableId("contract-error", error.path), severity: "blocking", status: "invalid", message: error.error, remediation: `Fix ${error.path} before trusting the release plan.` });
|
|
569
597
|
|
|
598
|
+
const recordedDefaultTargetId = targets.some((target) => target.id === defaultTargetId)
|
|
599
|
+
? defaultTargetId
|
|
600
|
+
: targets.length === 1 ? targets[0].id : "";
|
|
570
601
|
const model = {
|
|
571
602
|
schemaVersion: 1, kind: "tapp-application-model",
|
|
572
|
-
// defaultTargetId
|
|
573
|
-
//
|
|
574
|
-
application: { name: applicationName(root, targets), repositoryRoot: ".", platforms: [...new Set(targets.map((target) => target.platform))].sort(), targetIds: targets.map((target) => target.id), defaultTargetId:
|
|
603
|
+
// defaultTargetId is recorded only when selection is unambiguous or the user explicitly chose
|
|
604
|
+
// a target during source-connected exploration. Detection order is not user intent.
|
|
605
|
+
application: { name: applicationName(root, targets), repositoryRoot: ".", platforms: [...new Set(targets.map((target) => target.platform))].sort(), targetIds: targets.map((target) => target.id), defaultTargetId: recordedDefaultTargetId },
|
|
575
606
|
targets,
|
|
576
607
|
actors,
|
|
577
608
|
entities,
|
|
@@ -956,6 +987,45 @@ export function writeInitArtifacts({ root, model, plan, outDir = ".tapp", refres
|
|
|
956
987
|
return { modelPath, planPath, plan: mergedPlan };
|
|
957
988
|
}
|
|
958
989
|
|
|
990
|
+
export function findApplicationModelRoot(startPath) {
|
|
991
|
+
let current = path.resolve(startPath || process.cwd());
|
|
992
|
+
try {
|
|
993
|
+
if (!fs.statSync(current).isDirectory() || /\.(?:xcodeproj|xcworkspace)$/.test(current)) current = path.dirname(current);
|
|
994
|
+
} catch {
|
|
995
|
+
current = path.dirname(current);
|
|
996
|
+
}
|
|
997
|
+
while (true) {
|
|
998
|
+
const modelPath = path.join(current, projectArtifactDirectory(current), "application-model.json");
|
|
999
|
+
if (fs.existsSync(modelPath)) return current;
|
|
1000
|
+
const parent = path.dirname(current);
|
|
1001
|
+
if (parent === current) return null;
|
|
1002
|
+
current = parent;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// A successful standalone `tapp build` is runtime evidence that a proposed Xcode scheme is real.
|
|
1007
|
+
// Refresh an existing model immediately so a later `tapp init --refresh` does not forget it. This
|
|
1008
|
+
// deliberately does nothing before init: build remains a build command and does not mint a product
|
|
1009
|
+
// model unless the repository already opted into one.
|
|
1010
|
+
export async function persistIosBuildValidation({ projectDir, bundleId, container, scheme, configuration = "Debug" } = {}) {
|
|
1011
|
+
return refreshExistingInitArtifacts({
|
|
1012
|
+
projectDir: container || projectDir,
|
|
1013
|
+
targetValidation: {
|
|
1014
|
+
platform: "ios",
|
|
1015
|
+
target: bundleId,
|
|
1016
|
+
resolution: { kind: "xcode-build-installed", bundleId, build: { container, scheme, configuration } },
|
|
1017
|
+
evidence: { observedAt: new Date().toISOString() },
|
|
1018
|
+
},
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
export async function refreshExistingInitArtifacts({ projectDir, ...options } = {}) {
|
|
1023
|
+
const root = findApplicationModelRoot(projectDir);
|
|
1024
|
+
if (!root) return null;
|
|
1025
|
+
const built = await buildInitArtifacts({ projectDir: root, ...options });
|
|
1026
|
+
return { root, ...writeInitArtifacts({ ...built, refresh: true }) };
|
|
1027
|
+
}
|
|
1028
|
+
|
|
959
1029
|
export function reviewReleasePlan(plan, { approve = [], reject = [], defer = [] } = {}) {
|
|
960
1030
|
const choices = new Map();
|
|
961
1031
|
for (const [decision, values] of Object.entries({ approved: approve, rejected: reject, deferred: defer })) {
|
|
@@ -994,7 +1064,7 @@ function edgeSupportsPlatform(edge, nodes, platform) {
|
|
|
994
1064
|
function shortestObservedPath(map, platform, targetId, { startNodeId } = {}) {
|
|
995
1065
|
const nodes = new Map((map.nodes || []).map((node) => [node.id, node]));
|
|
996
1066
|
const entryId = startNodeId || map.app?.navigationRoots?.[platform] || map.app?.entryNodes?.[platform];
|
|
997
|
-
if (!entryId || !nodes.has(entryId)) throw new Error(`UI Map has no observed ${platform} navigation root; rerun tapp init --refresh --explore before generating Tasks`);
|
|
1067
|
+
if (!entryId || !nodes.has(entryId)) throw new Error(`UI Map has no observed ${platform} navigation root; rerun npx -y @aarwitz/tapp@latest init --refresh --explore before generating Tasks`);
|
|
998
1068
|
if (!nodes.has(targetId)) throw new Error(`UI Map proposal target '${targetId}' is no longer present`);
|
|
999
1069
|
if (entryId === targetId) return [];
|
|
1000
1070
|
const outgoing = new Map();
|
|
@@ -422,10 +422,10 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
|
|
|
422
422
|
? ` at \`${target.navigation.route}\``
|
|
423
423
|
: target.navigation?.mode === "ui-map-path" ? ` through ${(target.navigation.steps || []).length} observed map edge(s)` : "";
|
|
424
424
|
lines.push(`- ${icon} PR exploration **${target.node?.name || target.id}** — ${target.execution?.status || target.status}${navigation}`);
|
|
425
|
-
if (target.coverageProposal?.status === "awaiting-explicit-adoption") lines.push(` - Reviewable coverage proposal ready; never auto-applied. Next: \`tapp pr adopt <pr-plan.json> --item ${target.id} --project-dir .\``);
|
|
425
|
+
if (target.coverageProposal?.status === "awaiting-explicit-adoption") lines.push(` - Reviewable coverage proposal ready; never auto-applied. Next: \`npx -y @aarwitz/tapp@latest pr adopt <pr-plan.json> --item ${target.id} --project-dir .\``);
|
|
426
426
|
if (target.existingReleasePlanItem) {
|
|
427
427
|
lines.push(` - Existing release-plan item \`${target.existingReleasePlanItem.name}\` remains ${target.existingReleasePlanItem.decision}; no duplicate or decision change was made.`);
|
|
428
|
-
lines.push(` - Optional explicit evidence reconciliation: \`tapp pr adopt <pr-plan.json> --item ${target.id} --project-dir .\``);
|
|
428
|
+
lines.push(` - Optional explicit evidence reconciliation: \`npx -y @aarwitz/tapp@latest pr adopt <pr-plan.json> --item ${target.id} --project-dir .\``);
|
|
429
429
|
}
|
|
430
430
|
}
|
|
431
431
|
for (const candidate of prPlan.maintenanceCandidates || []) {
|
|
@@ -17,7 +17,7 @@ export function targetSlug(value) {
|
|
|
17
17
|
|
|
18
18
|
export function selectApplicationTarget(model, { platform = "", target = "", useDefault = false } = {}) {
|
|
19
19
|
if (model?.kind !== "tapp-application-model" || !Array.isArray(model.targets)) {
|
|
20
|
-
throw new Error("Expected a Tapp application model; run tapp init first");
|
|
20
|
+
throw new Error("Expected a Tapp application model; run npx -y @aarwitz/tapp@latest init first");
|
|
21
21
|
}
|
|
22
22
|
const selectedPlatform = String(platform || "").toLowerCase();
|
|
23
23
|
let candidates = model.targets.filter((item) => !selectedPlatform || item.platform === selectedPlatform);
|
|
@@ -215,7 +215,7 @@ function credentialConfiguration(model, targetContracts = []) {
|
|
|
215
215
|
if (requirements.has("email")) bindings.add("TAPP_TEST_EMAIL");
|
|
216
216
|
if (requirements.has("password")) bindings.add("TAPP_TEST_PASSWORD");
|
|
217
217
|
}
|
|
218
|
-
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 .tapp/project.json`);
|
|
218
|
+
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 npx -y @aarwitz/tapp@latest init from valid .tapp/project.json`);
|
|
219
219
|
const inputs = {};
|
|
220
220
|
const primaryEmail = primary.credentialBindings?.email || (!primary.credentialBindings && requirements.has("email") ? "TAPP_TEST_EMAIL" : "");
|
|
221
221
|
const primaryPassword = primary.credentialBindings?.password || (!primary.credentialBindings && requirements.has("password") ? "TAPP_TEST_PASSWORD" : "");
|
|
@@ -241,7 +241,7 @@ function targetInputs(root, model, target) {
|
|
|
241
241
|
const androidProjectPath = path.resolve(root, androidProject);
|
|
242
242
|
const androidProjectRelative = path.relative(root, androidProjectPath);
|
|
243
243
|
if (path.isAbsolute(androidProjectRelative) || androidProjectRelative === ".." || androidProjectRelative.startsWith(`..${path.sep}`)) unresolved.push("Android Gradle project resolves outside the repository");
|
|
244
|
-
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`);
|
|
244
|
+
else if (!fs.existsSync(path.join(androidProjectPath, "gradlew"))) unresolved.push(`Gradle wrapper is missing from ${androidProject}; commit gradlew or rerun npx -y @aarwitz/tapp@latest init from the Gradle repository root`);
|
|
245
245
|
inputs["android-project"] = androidProject;
|
|
246
246
|
inputs["android-task"] = target.build?.task || "assembleDebug";
|
|
247
247
|
if (target.runtime?.applicationId) inputs["android-app-id"] = target.runtime.applicationId;
|
|
@@ -282,7 +282,7 @@ export function renderGithubWorkflow({ projectDir, model, actionRef, defaultBran
|
|
|
282
282
|
const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
|
|
283
283
|
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");
|
|
284
284
|
if (!/^[A-Za-z0-9._\/-]+$/.test(defaultBranch) || defaultBranch.startsWith("/")) throw new Error("--default-branch contains unsupported characters");
|
|
285
|
-
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");
|
|
285
|
+
if (model?.kind !== "tapp-application-model" || !Array.isArray(model.targets) || !model.targets.length) throw new Error("Application model has no targets; run npx -y @aarwitz/tapp@latest init first");
|
|
286
286
|
const occupied = new Set();
|
|
287
287
|
const jobs = [];
|
|
288
288
|
const manifestTargets = [];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const STORAGE_BLOCK_BYTES = 256 * 1024 * 1024;
|
|
5
|
+
export const STORAGE_WARN_BYTES = 5 * 1024 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
function existingAncestor(candidate) {
|
|
8
|
+
let current = path.resolve(candidate || process.cwd());
|
|
9
|
+
while (!fs.existsSync(current)) {
|
|
10
|
+
const parent = path.dirname(current);
|
|
11
|
+
if (parent === current) return null;
|
|
12
|
+
current = parent;
|
|
13
|
+
}
|
|
14
|
+
return current;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatStorage(bytes) {
|
|
18
|
+
const gib = Number(bytes || 0) / (1024 ** 3);
|
|
19
|
+
return gib >= 1 ? `${gib.toFixed(gib >= 10 ? 0 : 1)} GiB` : `${Math.round(Number(bytes || 0) / (1024 ** 2))} MiB`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function storagePreflight(candidate, { statfs = fs.statfsSync, blockBytes = STORAGE_BLOCK_BYTES, warnBytes = STORAGE_WARN_BYTES } = {}) {
|
|
23
|
+
const checkedPath = existingAncestor(candidate);
|
|
24
|
+
if (!checkedPath || typeof statfs !== "function") return { ok: true, level: "unknown", path: checkedPath || path.resolve(candidate || process.cwd()), freeBytes: null };
|
|
25
|
+
try {
|
|
26
|
+
const stats = statfs(checkedPath);
|
|
27
|
+
const freeBytes = Number(stats.bavail ?? stats.bfree ?? 0) * Number(stats.bsize ?? 0);
|
|
28
|
+
const level = freeBytes < blockBytes ? "blocked" : freeBytes < warnBytes ? "warning" : "ok";
|
|
29
|
+
return {
|
|
30
|
+
ok: level !== "blocked",
|
|
31
|
+
level,
|
|
32
|
+
path: checkedPath,
|
|
33
|
+
freeBytes,
|
|
34
|
+
message: level === "blocked"
|
|
35
|
+
? `Only ${formatStorage(freeBytes)} is free where Tapp writes evidence. Free disk space before testing; the run was not started and no app-crash finding was created.`
|
|
36
|
+
: level === "warning"
|
|
37
|
+
? `Only ${formatStorage(freeBytes)} is free where Tapp writes builds and evidence; iOS builds can require several GiB.`
|
|
38
|
+
: `${formatStorage(freeBytes)} free for Tapp builds and evidence`,
|
|
39
|
+
};
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return { ok: true, level: "unknown", path: checkedPath, freeBytes: null, message: `Free disk space could not be checked: ${error.message || String(error)}` };
|
|
42
|
+
}
|
|
43
|
+
}
|