@aarwitz/tapp 0.17.0-rc.13 → 0.17.0-rc.15
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 +3 -3
- package/.claude-plugin/plugin.json +4 -4
- package/AGENTS.md +11 -5
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +11 -0
- package/README.md +52 -59
- package/bin/tapp.js +71 -27
- 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 +76 -9
- 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/html-report.js +3 -2
- package/mcp-server/src/index.js +139 -25
- package/mcp-server/src/pr-selection.js +2 -2
- package/mcp-server/src/product-operations.js +1 -1
- package/mcp-server/src/report.js +17 -4
- package/mcp-server/src/web-explorer.js +3 -2
- package/mcp-server/src/web-flow.js +17 -1
- package/package.json +4 -4
- package/scripts/flow_lib.py +1 -1
- package/scripts/quick-capture.sh +2 -2
- package/skills/tapp/SKILL.md +3 -2
package/mcp-server/src/index.js
CHANGED
|
@@ -335,6 +335,9 @@ export function findXcodeContainer(startDir) {
|
|
|
335
335
|
}
|
|
336
336
|
|
|
337
337
|
export async function buildAppForSim({ dir, container, scheme, configuration = "Debug" } = {}) {
|
|
338
|
+
const { storagePreflight } = await import("./environment-preflight.js");
|
|
339
|
+
const storage = storagePreflight(path.join(tappHome || os.tmpdir(), "app-builds"));
|
|
340
|
+
if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
|
|
338
341
|
const target = container || findXcodeContainer(dir || process.cwd());
|
|
339
342
|
if (!target) return { error: `No Xcode project or workspace found under ${dir || process.cwd()}` };
|
|
340
343
|
const isWorkspace = target.endsWith(".xcworkspace");
|
|
@@ -715,6 +718,28 @@ function templateValue(text) {
|
|
|
715
718
|
return text;
|
|
716
719
|
}
|
|
717
720
|
|
|
721
|
+
export function isStableFlowCheckpoint(value) {
|
|
722
|
+
const text = String(value || "").replace(/\s+/g, " ").trim();
|
|
723
|
+
if (!text || /^(loading|fetching|please wait|preparing|connecting|syncing|signing in)(?:[.…!]*|\s.*)$/i.test(text)) return false;
|
|
724
|
+
if (/^(?:mon|tues?|wed(?:nes)?|thu(?:rs)?|fri|sat(?:ur)?|sun)(?:day)?\b/i.test(text)) return false;
|
|
725
|
+
if (/^(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+\d{1,2}(?:,\s+\d{4})?$/i.test(text)) return false;
|
|
726
|
+
if (/^\d{4}-\d{2}-\d{2}(?:[ T].*)?$/.test(text)) return false;
|
|
727
|
+
return true;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
export function semanticTargetAtPoint(elements, x, y) {
|
|
731
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return "";
|
|
732
|
+
return (elements || [])
|
|
733
|
+
.filter((element) => {
|
|
734
|
+
const frame = element.frame || {};
|
|
735
|
+
return element.hittable !== false && Number.isFinite(frame.x) && Number.isFinite(frame.y) && Number.isFinite(frame.width) && Number.isFinite(frame.height)
|
|
736
|
+
&& x >= frame.x && y >= frame.y && x <= frame.x + frame.width && y <= frame.y + frame.height
|
|
737
|
+
&& String(element.id || element.identifier || element.label || "").trim();
|
|
738
|
+
})
|
|
739
|
+
.sort((a, b) => (a.frame.width * a.frame.height) - (b.frame.width * b.frame.height))
|
|
740
|
+
.map((element) => String(element.id || element.identifier || element.label || "").trim())[0] || "";
|
|
741
|
+
}
|
|
742
|
+
|
|
718
743
|
/** Append a Flow step for an act (record-by-doing). Inserts wait_for on screen change for
|
|
719
744
|
* deterministic replay. Inspection acts (tree/screenshot/wait) are not recorded. */
|
|
720
745
|
function recordStep(cmd, result) {
|
|
@@ -725,7 +750,7 @@ function recordStep(cmd, result) {
|
|
|
725
750
|
case "tap": {
|
|
726
751
|
const target = cmd.id || cmd.label || (typeof cmd.x === "number" ? `${cmd.x},${cmd.y}` : "");
|
|
727
752
|
if (target) activeSession.recording.push({ tap: target });
|
|
728
|
-
if (changed) activeSession.recording.push({ wait_for: newScreen });
|
|
753
|
+
if (changed && isStableFlowCheckpoint(newScreen)) activeSession.recording.push({ wait_for: newScreen });
|
|
729
754
|
break;
|
|
730
755
|
}
|
|
731
756
|
case "type": {
|
|
@@ -734,12 +759,16 @@ function recordStep(cmd, result) {
|
|
|
734
759
|
activeSession.recording.push({ type: step });
|
|
735
760
|
break;
|
|
736
761
|
}
|
|
762
|
+
case "login":
|
|
763
|
+
activeSession.recording.push({ login: { email: "$TEST_EMAIL", password: "$TEST_PASSWORD" } });
|
|
764
|
+
if (changed && isStableFlowCheckpoint(newScreen)) activeSession.recording.push({ wait_for: newScreen });
|
|
765
|
+
break;
|
|
737
766
|
case "swipe":
|
|
738
767
|
activeSession.recording.push({ swipe: cmd.direction || "up" });
|
|
739
768
|
break;
|
|
740
769
|
case "back":
|
|
741
770
|
activeSession.recording.push({ back: true });
|
|
742
|
-
if (changed) activeSession.recording.push({ wait_for: newScreen });
|
|
771
|
+
if (changed && isStableFlowCheckpoint(newScreen)) activeSession.recording.push({ wait_for: newScreen });
|
|
743
772
|
break;
|
|
744
773
|
default:
|
|
745
774
|
break; // tree / screenshot / wait are inspection, not test steps
|
|
@@ -748,7 +777,17 @@ function recordStep(cmd, result) {
|
|
|
748
777
|
}
|
|
749
778
|
|
|
750
779
|
async function sessionAct(cmd) {
|
|
751
|
-
|
|
780
|
+
const startedAt = Date.now();
|
|
781
|
+
const done = (result) => ({ ...result, durationMs: Date.now() - startedAt });
|
|
782
|
+
if (!activeSession || activeSession.ended) return done({ error: "No active session. Call tapp_session_start first." });
|
|
783
|
+
let coordinateResolvedTarget = "";
|
|
784
|
+
if (cmd.action === "tap" && !cmd.id && Number.isFinite(cmd.x) && Number.isFinite(cmd.y)) {
|
|
785
|
+
coordinateResolvedTarget = semanticTargetAtPoint(activeSession.latestTree?.elements, cmd.x, cmd.y);
|
|
786
|
+
if (coordinateResolvedTarget) {
|
|
787
|
+
const { x: _x, y: _y, ...semanticCommand } = cmd;
|
|
788
|
+
cmd = { ...semanticCommand, id: coordinateResolvedTarget };
|
|
789
|
+
}
|
|
790
|
+
}
|
|
752
791
|
if (activeSession.platform === "web") {
|
|
753
792
|
const session = activeSession;
|
|
754
793
|
let status = "ok";
|
|
@@ -756,9 +795,13 @@ async function sessionAct(cmd) {
|
|
|
756
795
|
let typedInto = null;
|
|
757
796
|
try {
|
|
758
797
|
if (cmd.action === "tap") {
|
|
759
|
-
const
|
|
760
|
-
if (!
|
|
761
|
-
else
|
|
798
|
+
const target = cmd.id || cmd.label || "";
|
|
799
|
+
if (!target && Number.isFinite(cmd.x) && Number.isFinite(cmd.y)) await session.page.mouse.click(cmd.x, cmd.y);
|
|
800
|
+
else {
|
|
801
|
+
const locator = await firstVisibleWebLocator(session.page, target);
|
|
802
|
+
if (!locator) { status = "not_found"; detail = "No visible web control matched the semantic target"; }
|
|
803
|
+
else await locator.click({ timeout:10_000 });
|
|
804
|
+
}
|
|
762
805
|
} else if (cmd.action === "type") {
|
|
763
806
|
const locator = await firstVisibleWebLocator(session.page, cmd.id || cmd.label || "", { input:true });
|
|
764
807
|
if (!locator) { status = "not_found"; detail = "No visible web field matched the semantic target"; }
|
|
@@ -771,6 +814,28 @@ async function sessionAct(cmd) {
|
|
|
771
814
|
if (!locator) await session.page.waitForTimeout(120);
|
|
772
815
|
}
|
|
773
816
|
if (!locator) { status = "timeout"; detail = `Timed out waiting for ${cmd.id || cmd.text || "target"}`; }
|
|
817
|
+
} else if (cmd.action === "login") {
|
|
818
|
+
const emailValue = cmd.email || session.creds.email || "";
|
|
819
|
+
const passwordValue = cmd.password || session.creds.password || "";
|
|
820
|
+
if (!emailValue || !passwordValue) { status = "missing_credentials"; detail = "Email and password are required"; }
|
|
821
|
+
else {
|
|
822
|
+
const email = await firstVisibleWebLocator(session.page, "Email", { input:true })
|
|
823
|
+
|| session.page.locator("input[type=email], input[name*=mail i], input[name*=user i], input[id*=mail i], input[id*=user i]").first();
|
|
824
|
+
const password = session.page.locator("input[type=password]").first();
|
|
825
|
+
if (!(await email.isVisible().catch(() => false)) || !(await password.isVisible().catch(() => false))) {
|
|
826
|
+
status = "not_found"; detail = "Could not identify email and password fields";
|
|
827
|
+
} else {
|
|
828
|
+
await email.fill(emailValue);
|
|
829
|
+
await password.fill(passwordValue);
|
|
830
|
+
const submit = session.page.getByRole("button", { name:/sign in|log in|login|continue|submit/i }).first();
|
|
831
|
+
if (!(await submit.isVisible().catch(() => false))) { status = "not_found"; detail = "Could not identify a sign-in control"; }
|
|
832
|
+
else {
|
|
833
|
+
await submit.click();
|
|
834
|
+
await session.page.waitForTimeout(500);
|
|
835
|
+
if (await password.isVisible().catch(() => false)) { status = "still_on_login"; detail = "Submit left the app on the login screen"; }
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
774
839
|
} else if (cmd.action === "back") {
|
|
775
840
|
await session.page.goBack({ waitUntil:"domcontentloaded", timeout:10_000 }).catch(() => {});
|
|
776
841
|
} else if (cmd.action === "swipe") {
|
|
@@ -789,7 +854,7 @@ async function sessionAct(cmd) {
|
|
|
789
854
|
}
|
|
790
855
|
const snapshot = treeSnapshot();
|
|
791
856
|
if (status === "ok") recordStep(cmd, snapshot);
|
|
792
|
-
return { status, typedInto, detail, ...snapshot, recordedSteps:session.recording.length, url:session.latestTree?.url || "" };
|
|
857
|
+
return done({ status, typedInto, detail, ...snapshot, recordedSteps:session.recording.length, url:session.latestTree?.url || "", ...(coordinateResolvedTarget ? { coordinateResolvedTarget } : {}) });
|
|
793
858
|
}
|
|
794
859
|
if (activeSession.platform === "android") {
|
|
795
860
|
const s = activeSession;
|
|
@@ -845,7 +910,7 @@ async function sessionAct(cmd) {
|
|
|
845
910
|
s.treeVersion += 1;
|
|
846
911
|
const snap = treeSnapshot();
|
|
847
912
|
if (status === "ok") recordStep(cmd, snap);
|
|
848
|
-
return { status, typedInto, detail, ...snap, recordedSteps: s.recording.length };
|
|
913
|
+
return done({ status, typedInto, detail, ...snap, recordedSteps: s.recording.length, ...(coordinateResolvedTarget ? { coordinateResolvedTarget } : {}) });
|
|
849
914
|
}
|
|
850
915
|
activeSession.seq += 1;
|
|
851
916
|
const seq = activeSession.seq;
|
|
@@ -874,7 +939,7 @@ async function sessionAct(cmd) {
|
|
|
874
939
|
while (activeSession.treeVersion === beforeVer && Date.now() < td && !activeSession.ended) await sleep(150);
|
|
875
940
|
const snap = treeSnapshot();
|
|
876
941
|
if (status === "ok") recordStep(cmd, snap); // record only successful acts
|
|
877
|
-
return { status, typedInto, detail, ...snap, recordedSteps: activeSession ? activeSession.recording.length : 0 };
|
|
942
|
+
return done({ status, typedInto, detail, ...snap, recordedSteps: activeSession ? activeSession.recording.length : 0, ...(coordinateResolvedTarget ? { coordinateResolvedTarget } : {}) });
|
|
878
943
|
}
|
|
879
944
|
|
|
880
945
|
async function endSession() {
|
|
@@ -919,7 +984,7 @@ export async function saveInteractiveSessionFlow({ projectDir, name, addFinalAss
|
|
|
919
984
|
if (!projectDir || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error("A valid repository root is required to save a Flow");
|
|
920
985
|
const steps = [...(activeSession.recording || [])];
|
|
921
986
|
if (steps.length === 0) throw new Error("Nothing recorded yet — perform some live-session actions first.");
|
|
922
|
-
if (addFinalAssertion && activeSession.lastScreen) {
|
|
987
|
+
if (addFinalAssertion && activeSession.lastScreen && isStableFlowCheckpoint(activeSession.lastScreen)) {
|
|
923
988
|
const last = steps[steps.length - 1] || {};
|
|
924
989
|
if (!("assert_screen" in last)) steps.push({ assert_screen: activeSession.lastScreen });
|
|
925
990
|
}
|
|
@@ -1244,6 +1309,7 @@ export function explorationEnvFromArgs(args) {
|
|
|
1244
1309
|
const env = {};
|
|
1245
1310
|
if (isNonEmptyString(args.testEmail)) env.OCQA_TEST_EMAIL = args.testEmail;
|
|
1246
1311
|
if (isNonEmptyString(args.testPassword)) env.OCQA_TEST_PASSWORD = args.testPassword;
|
|
1312
|
+
if (isNonEmptyString(args.testEmail) || isNonEmptyString(args.testPassword)) env.OCQA_CREDENTIALS_EXPLICIT = "1";
|
|
1247
1313
|
if (Array.isArray(args.appLaunchArgs)) {
|
|
1248
1314
|
const a = args.appLaunchArgs.filter((s) => typeof s === "string" && s.length > 0);
|
|
1249
1315
|
if (a.length) env.OCQA_APP_LAUNCH_ARGS_JSON = JSON.stringify(a);
|
|
@@ -1335,9 +1401,9 @@ function fmtDuration(ms) {
|
|
|
1335
1401
|
export function qaNextSteps(report, surface = "mcp") {
|
|
1336
1402
|
if (surface === "cli") {
|
|
1337
1403
|
const next = [];
|
|
1338
|
-
if (report?.findings?.length) next.push("inspect the evidence with `tapp report latest`");
|
|
1339
|
-
next.push("re-run with `--baseline <report.json>` to compare a fix (
|
|
1340
|
-
next.push("replay a committed journey with `tapp flow run <file>`");
|
|
1404
|
+
if (report?.findings?.length) next.push("inspect the evidence with `npx -y @aarwitz/tapp@latest report latest`");
|
|
1405
|
+
next.push("save this run with `--json <report.json>`, then re-run with `--baseline <report.json>` to compare a fix (`tapp ci` gates it)");
|
|
1406
|
+
next.push("replay a committed journey with `npx -y @aarwitz/tapp@latest flow run <file>`");
|
|
1341
1407
|
return next;
|
|
1342
1408
|
}
|
|
1343
1409
|
const next = [];
|
|
@@ -1359,6 +1425,7 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
|
|
|
1359
1425
|
L.push(`### 🔭 Exploration complete — ${badge} · ${observationSummary(report)}${bundleId ? `\n\`${bundleId}\`` : ""}`);
|
|
1360
1426
|
L.push("");
|
|
1361
1427
|
L.push(report.headline);
|
|
1428
|
+
if (report.credentialWarning) L.push("", `> ⚠️ ${report.credentialWarning}`);
|
|
1362
1429
|
L.push("");
|
|
1363
1430
|
L.push(`**Coverage** — ${report.screensExplored} screens · ${report.actionsPerformed} actions${timedOut ? " · ⏱️ hit time limit" : ""}`);
|
|
1364
1431
|
if (report.platform === "web") {
|
|
@@ -1373,7 +1440,7 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
|
|
|
1373
1440
|
L.push("");
|
|
1374
1441
|
L.push("**Findings**");
|
|
1375
1442
|
for (const f of report.findings.slice(0, 12)) {
|
|
1376
|
-
L.push(`- ${SEV[f.severity] || "•"} \`${f.severity}\` ${f.title}${f.screen ? ` — on *${f.screen}*` : ""}`);
|
|
1443
|
+
L.push(`- ${SEV[f.severity] || "•"} \`${f.severity}\` ${f.title}${f.screen ? ` — on *${f.screen}*` : ""}${f.url ? ` — ${f.url}` : ""}`);
|
|
1377
1444
|
if (f.aiAnalysis) L.push(` - why: ${String(f.aiAnalysis).slice(0, 200)}`);
|
|
1378
1445
|
if (f.suggestedFix) L.push(` - fix: ${String(f.suggestedFix).slice(0, 200)}`);
|
|
1379
1446
|
}
|
|
@@ -1390,7 +1457,7 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
|
|
|
1390
1457
|
// The merge decision is the gate's job (tapp ci), not exploration's (ADR-0005).
|
|
1391
1458
|
L.push("");
|
|
1392
1459
|
L.push(
|
|
1393
|
-
`**Since last run** — +${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved (comparison only — run \`tapp ci\` to gate)`
|
|
1460
|
+
`**Since last run** — +${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved (comparison only — run \`npx -y @aarwitz/tapp@latest ci\` to gate)`
|
|
1394
1461
|
);
|
|
1395
1462
|
}
|
|
1396
1463
|
if (inputHint) {
|
|
@@ -1474,6 +1541,9 @@ export function formatScreen(screenTitle, elements) {
|
|
|
1474
1541
|
// the surfaces can't drift.)
|
|
1475
1542
|
|
|
1476
1543
|
export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], watch = false, surface = "mcp", onProgress = () => {} }) {
|
|
1544
|
+
const { storagePreflight } = await import("./environment-preflight.js");
|
|
1545
|
+
const storage = storagePreflight(capturesDir);
|
|
1546
|
+
if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
|
|
1477
1547
|
const actions = Math.max(1, Math.min(1000, asInteger(maxActions, 60)));
|
|
1478
1548
|
const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
|
|
1479
1549
|
const id = "web-" + new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/^(\d{8})/, "$1-");
|
|
@@ -1516,6 +1586,9 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
|
|
|
1516
1586
|
}
|
|
1517
1587
|
|
|
1518
1588
|
export async function runQaAndroid({ appId, apkPath, serial, maxActions, timeout, testEmail, testPassword, baselineFindings, clearData = true, seedTargets = [], surface = "mcp", onProgress = () => {} }) {
|
|
1589
|
+
const { storagePreflight } = await import("./environment-preflight.js");
|
|
1590
|
+
const storage = storagePreflight(capturesDir);
|
|
1591
|
+
if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
|
|
1519
1592
|
const actions = Math.max(1, Math.min(1000, asInteger(maxActions, 60)));
|
|
1520
1593
|
const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
|
|
1521
1594
|
const id = "android-" + new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/^(\d{8})/, "$1-");
|
|
@@ -1559,6 +1632,9 @@ export async function runQaAndroid({ appId, apkPath, serial, maxActions, timeout
|
|
|
1559
1632
|
}
|
|
1560
1633
|
|
|
1561
1634
|
export async function runQaIos({ bundleId, maxActions, timeout, args = {}, surface = "mcp", onProgress = () => {} }) {
|
|
1635
|
+
const { storagePreflight } = await import("./environment-preflight.js");
|
|
1636
|
+
const storage = storagePreflight(capturesDir);
|
|
1637
|
+
if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
|
|
1562
1638
|
const captureScript = path.join(scriptsDir, "quick-capture.sh");
|
|
1563
1639
|
if (!fs.existsSync(captureScript)) return { error: "Capture script not found", details: { captureScript } };
|
|
1564
1640
|
|
|
@@ -1801,7 +1877,7 @@ export async function runExploreTarget({
|
|
|
1801
1877
|
|
|
1802
1878
|
const modelPath = existingProjectArtifactPath(root, "application-model.json");
|
|
1803
1879
|
if (!fs.existsSync(modelPath)) {
|
|
1804
|
-
return { error: "No application model found — run `tapp init` first, or pass an explicit target (a bundle id, a path/to/App.app, a repo dir, --app-id/--apk, or an http(s) URL)." };
|
|
1880
|
+
return { error: "No application model found — call `tapp_init` or run `npx -y @aarwitz/tapp@latest init` first, or pass an explicit target (a bundle id, a path/to/App.app, a repo dir, --app-id/--apk, or an http(s) URL)." };
|
|
1805
1881
|
}
|
|
1806
1882
|
let model;
|
|
1807
1883
|
try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
|
|
@@ -1839,7 +1915,7 @@ export async function runExploreTarget({
|
|
|
1839
1915
|
|
|
1840
1916
|
if (selectedPlatform === "android") {
|
|
1841
1917
|
const appId = String(selected.runtime?.applicationId || "").trim();
|
|
1842
|
-
if (!appId) return { error: `The Android target '${selected.name}' has no confirmed application id — confirm it and rerun \`tapp init\`, or pass --app-id.` };
|
|
1918
|
+
if (!appId) return { error: `The Android target '${selected.name}' has no confirmed application id — confirm it and call \`tapp_init\` or rerun \`npx -y @aarwitz/tapp@latest init\`, or pass --app-id.` };
|
|
1843
1919
|
const task = selected.build?.task || "assembleDebug";
|
|
1844
1920
|
onStatus(`Building the Android APK (${task})…`);
|
|
1845
1921
|
const built = await buildAndroidApp({
|
|
@@ -1877,6 +1953,22 @@ function openLocalPort() {
|
|
|
1877
1953
|
});
|
|
1878
1954
|
}
|
|
1879
1955
|
|
|
1956
|
+
function localPortAvailable(port) {
|
|
1957
|
+
return new Promise((resolve) => {
|
|
1958
|
+
const server = net.createServer();
|
|
1959
|
+
server.unref();
|
|
1960
|
+
server.once("error", () => resolve(false));
|
|
1961
|
+
server.listen(port, "127.0.0.1", () => server.close(() => resolve(true)));
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
export function managedWebDefaultPort(dependencies = {}) {
|
|
1966
|
+
if (dependencies.vite) return 5173;
|
|
1967
|
+
if (dependencies.next) return 3000;
|
|
1968
|
+
if (dependencies["react-scripts"]) return 3000;
|
|
1969
|
+
return 0;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1880
1972
|
function managedInstallSpec(command) {
|
|
1881
1973
|
const known = {
|
|
1882
1974
|
"npm ci": ["npm", ["ci"]],
|
|
@@ -1961,7 +2053,9 @@ export async function startManagedWebTarget({ root, requestedTarget = "", timeou
|
|
|
1961
2053
|
const pkg = (() => { try { return JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf8")); } catch { return {}; } })();
|
|
1962
2054
|
const dependencies = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
1963
2055
|
const declaredPort = startMatch ? declaredPortFromStartScript(pkg.scripts?.[startMatch[1]]) : 0;
|
|
1964
|
-
const
|
|
2056
|
+
const frameworkPort = declaredPort ? 0 : managedWebDefaultPort(dependencies);
|
|
2057
|
+
const port = declaredPort || (frameworkPort && await localPortAvailable(frameworkPort) ? frameworkPort : await openLocalPort());
|
|
2058
|
+
const portBasis = declaredPort ? "repository-declared" : port === frameworkPort ? "framework-default" : "available-ephemeral";
|
|
1965
2059
|
let command = "npm";
|
|
1966
2060
|
let startArgs;
|
|
1967
2061
|
let startDir = projectDir;
|
|
@@ -1988,13 +2082,13 @@ export async function startManagedWebTarget({ root, requestedTarget = "", timeou
|
|
|
1988
2082
|
const append = (chunk) => fs.appendFileSync(logPath, String(chunk));
|
|
1989
2083
|
child.stdout.on("data", append);
|
|
1990
2084
|
child.stderr.on("data", append);
|
|
1991
|
-
onStatus(`Managed web runtime: ${command === process.execPath ? "Tapp static server" : `npm ${startArgs.join(" ")}`} → http://127.0.0.1:${port}`);
|
|
2085
|
+
onStatus(`Managed web runtime: ${command === process.execPath ? "Tapp static server" : `npm ${startArgs.join(" ")}`} → http://127.0.0.1:${port} (${portBasis} port)`);
|
|
1992
2086
|
const ready = await waitForOwnedUrl(`http://127.0.0.1:${port}`, child, Math.min(budgetMs, 60_000), logPath);
|
|
1993
2087
|
if (ready.error) {
|
|
1994
2088
|
await stopManagedWebTarget({ child, detached: process.platform !== "win32" });
|
|
1995
2089
|
return ready;
|
|
1996
2090
|
}
|
|
1997
|
-
return { child, detached: process.platform !== "win32", url: `http://127.0.0.1:${port}`, logPath, install, build, start: { command, args: startArgs, cwd: startDir } };
|
|
2091
|
+
return { child, detached: process.platform !== "win32", url: `http://127.0.0.1:${port}`, logPath, install, build, start: { command, args: startArgs, cwd: startDir, portBasis } };
|
|
1998
2092
|
}
|
|
1999
2093
|
|
|
2000
2094
|
export async function stopManagedWebTarget(runtime) {
|
|
@@ -2322,7 +2416,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2322
2416
|
"Findings from a previous run (pass back the `findings` array a prior tapp_explore returned). " +
|
|
2323
2417
|
"When provided, the result adds a `regression` COMPARISON {counts:{new,persisting,resolved}, " +
|
|
2324
2418
|
"newFindings, resolved} vs. that baseline — an observation, not a gate signal. To gate a merge, " +
|
|
2325
|
-
"run `tapp ci` (or the GitHub Action): it applies the deterministic policy and returns the " +
|
|
2419
|
+
"run `npx -y @aarwitz/tapp@latest ci` (or the GitHub Action): it applies the deterministic policy and returns the " +
|
|
2326
2420
|
"pass/fail/inconclusive outcome.",
|
|
2327
2421
|
},
|
|
2328
2422
|
},
|
|
@@ -2858,7 +2952,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2858
2952
|
const icon = c.ok ? "✅" : c.required ? "❌" : "⚪️";
|
|
2859
2953
|
L.push(`- ${icon} **${c.check}** — ${String(c.value).split("\n")[0] || "—"}`);
|
|
2860
2954
|
}
|
|
2861
|
-
if (!ready) L.push("", "Run `tapp doctor` in the application repository for exact remediation.");
|
|
2955
|
+
if (!ready) L.push("", "Run `npx -y @aarwitz/tapp@latest doctor` in the application repository for exact remediation.");
|
|
2862
2956
|
return richResult(L.join("\n"), { ok: ready, workspaceRoot, checks, platforms: Object.fromEntries(platformChecks.map((check) => [check.check.toLowerCase(), check.ok])) });
|
|
2863
2957
|
}
|
|
2864
2958
|
|
|
@@ -2878,11 +2972,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2878
2972
|
if (inst.error) return errorResult(inst.error);
|
|
2879
2973
|
bundleId = inst.bundleId;
|
|
2880
2974
|
}
|
|
2975
|
+
let modelRefresh = null;
|
|
2976
|
+
let modelRefreshWarning = "";
|
|
2977
|
+
if (bundleId) {
|
|
2978
|
+
try {
|
|
2979
|
+
const { persistIosBuildValidation } = await import("./application-model.js");
|
|
2980
|
+
modelRefresh = await persistIosBuildValidation({ projectDir: dir, bundleId, container: built.container, scheme: built.scheme, configuration: built.configuration });
|
|
2981
|
+
} catch (error) {
|
|
2982
|
+
modelRefreshWarning = error.message || String(error);
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2881
2985
|
const text =
|
|
2882
2986
|
`🔨 Built **${path.basename(built.appPath)}** (scheme \`${built.scheme}\`) in ${fmtDuration(Date.now() - startedAt)}` +
|
|
2883
2987
|
(bundleId ? ` — installed on the simulator as \`${bundleId}\`` : "") +
|
|
2884
2988
|
`\n\nNext: \`tapp_explore\` with \`appBundleId: "${bundleId || "<install it first>"}"\`.`;
|
|
2885
|
-
return richResult(text, { ok: true, appPath: built.appPath, scheme: built.scheme, container: built.container, bundleId });
|
|
2989
|
+
return richResult(text + (modelRefresh ? `\nApplication model refreshed: \`${modelRefresh.modelPath}\`.` : modelRefreshWarning ? `\n⚠️ Application model refresh failed: ${modelRefreshWarning}` : ""), { ok: true, appPath: built.appPath, scheme: built.scheme, container: built.container, bundleId, modelRefreshed: !!modelRefresh, ...(modelRefreshWarning ? { modelRefreshWarning } : {}) });
|
|
2886
2990
|
}
|
|
2887
2991
|
|
|
2888
2992
|
if (name === "tapp_capture") {
|
|
@@ -3201,7 +3305,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3201
3305
|
credentials,
|
|
3202
3306
|
replace: asBoolean(args.replace),
|
|
3203
3307
|
});
|
|
3204
|
-
|
|
3308
|
+
const { refreshExistingInitArtifacts } = await import("./application-model.js");
|
|
3309
|
+
let refreshed = null;
|
|
3310
|
+
let refreshWarning = "";
|
|
3311
|
+
try { refreshed = await refreshExistingInitArtifacts({ projectDir }); }
|
|
3312
|
+
catch (error) { refreshWarning = error.message || String(error); }
|
|
3313
|
+
return richResult(
|
|
3314
|
+
`✅ Actor '${args.name.trim()}' configured with ${Object.keys(result.actor.credentials).length} environment binding(s); no credential values were accepted or written${refreshed ? " · application model refreshed" : ""}${refreshWarning ? `\n⚠️ Application model refresh failed: ${refreshWarning}` : ""}`,
|
|
3315
|
+
{ path: result.path, actor: result.actor, modelRefreshed: !!refreshed, ...(refreshed ? { modelPath: refreshed.modelPath, planPath: refreshed.planPath } : {}), ...(refreshWarning ? { refreshWarning } : {}) },
|
|
3316
|
+
);
|
|
3205
3317
|
} catch (error) { return errorResult("Actor not configured", { detail: error.message || String(error) }); }
|
|
3206
3318
|
}
|
|
3207
3319
|
|
|
@@ -3969,7 +4081,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3969
4081
|
const detailNote = !ok && r.detail ? ` — ${r.detail}` : "";
|
|
3970
4082
|
const head = `${did} — ${ok ? "ok" : `⚠️ ${r.status}${detailNote}`} → now on **${r.screenTitle || "Unknown"}**`;
|
|
3971
4083
|
const rec = typeof r.recordedSteps === "number" ? `\n\n🔴 Recording — ${r.recordedSteps} step(s). \`tapp_flow_save\` to keep it as a test.` : "";
|
|
3972
|
-
|
|
4084
|
+
const result = richResult(head + "\n\n" + formatScreen(r.screenTitle, r.elements) + rec, r);
|
|
4085
|
+
if (!ok) result.isError = true;
|
|
4086
|
+
return result;
|
|
3973
4087
|
}
|
|
3974
4088
|
|
|
3975
4089
|
if (name === "tapp_flow_save") {
|
|
@@ -410,7 +410,7 @@ export function adoptPrCoverageProposal({ projectDir, prPlanPath, item, releaseP
|
|
|
410
410
|
if (!prPlanPath || !fs.existsSync(source)) throw new Error(`PR plan not found: ${source || "(missing path)"}`);
|
|
411
411
|
const targetPath = releasePlanPath === ".tapp/release-plan.json" ? existingProjectArtifactPath(root, "release-plan.json") : path.resolve(root, releasePlanPath);
|
|
412
412
|
if (!inside(root, targetPath)) throw new Error("Release plan path must stay inside the project directory");
|
|
413
|
-
if (!fs.existsSync(targetPath)) throw new Error(`Release plan not found: ${targetPath}; run tapp init first`);
|
|
413
|
+
if (!fs.existsSync(targetPath)) throw new Error(`Release plan not found: ${targetPath}; run npx -y @aarwitz/tapp@latest init first`);
|
|
414
414
|
const prPlan = JSON.parse(fs.readFileSync(source, "utf8"));
|
|
415
415
|
if (prPlan?.schemaVersion !== 1 || !Array.isArray(prPlan.explorationTargets)) throw new Error("PR plan must be an executed Tapp PR plan v1");
|
|
416
416
|
const matches = prPlan.explorationTargets.filter((target) => target.id === item);
|
|
@@ -428,7 +428,7 @@ export function adoptPrCoverageProposal({ projectDir, prPlanPath, item, releaseP
|
|
|
428
428
|
if (!ground || !fs.existsSync(mapPath)) throw new Error("Coverage proposal requires the repository's persistent UI Map");
|
|
429
429
|
const map = JSON.parse(fs.readFileSync(mapPath, "utf8"));
|
|
430
430
|
const node = (map.nodes || []).find((candidate) => candidate.id === ground.id && candidate.status !== "proposed");
|
|
431
|
-
const refreshAdvice = "refresh the persistent UI Map with `tapp init --explore --refresh`, rerun `tapp pr gate`, then retry `tapp pr adopt`";
|
|
431
|
+
const refreshAdvice = "refresh the persistent UI Map with `npx -y @aarwitz/tapp@latest init --explore --refresh`, rerun `npx -y @aarwitz/tapp@latest pr gate`, then retry `npx -y @aarwitz/tapp@latest pr adopt`";
|
|
432
432
|
if (!node) throw new Error(`Coverage proposal UI Map node is stale or missing: ${ground.id}; ${refreshAdvice}`);
|
|
433
433
|
if (target.navigation?.route && !(node.routes || []).some((route) => route.platform === target.platform && route.path === target.navigation.route && route.replayable === true)) {
|
|
434
434
|
throw new Error(`Coverage proposal route is stale in the persistent UI Map: ${target.navigation.route}; ${refreshAdvice}`);
|
|
@@ -79,7 +79,7 @@ function initTargetChoices(model, platform = "") {
|
|
|
79
79
|
const candidates = (model.targets || []).filter((target) => !platform || target.platform === platform);
|
|
80
80
|
return candidates.map((target) => ({
|
|
81
81
|
target,
|
|
82
|
-
command: `tapp init . --explore --platform ${target.platform} --target ${target.sourcePath === "." ? JSON.stringify(target.name) : JSON.stringify(target.sourcePath)}`,
|
|
82
|
+
command: `npx -y @aarwitz/tapp@latest init . --explore --platform ${target.platform} --target ${target.sourcePath === "." ? JSON.stringify(target.name) : JSON.stringify(target.sourcePath)}`,
|
|
83
83
|
}));
|
|
84
84
|
}
|
|
85
85
|
|
package/mcp-server/src/report.js
CHANGED
|
@@ -146,6 +146,7 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
146
146
|
const screenElementCounts = {}; // screen -> max elements observed (content-collapse detection)
|
|
147
147
|
let anySecure = false;
|
|
148
148
|
let loginAttempted = false;
|
|
149
|
+
let credentialsProvided = base.complete?.credentialsProvided === true;
|
|
149
150
|
let actions = 0;
|
|
150
151
|
|
|
151
152
|
for (const line of raw.split(/\r?\n/)) {
|
|
@@ -158,7 +159,8 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
158
159
|
// `target` gives a finding its identity beyond type|screen — two dead buttons on the
|
|
159
160
|
// same screen are two findings, and fixing one while breaking another is a regression.
|
|
160
161
|
const target = (typeof o.control === "string" && o.control) || (typeof o.target === "string" && o.target) || null;
|
|
161
|
-
|
|
162
|
+
const url = typeof o.url === "string" && o.url.trim() ? o.url.trim() : null;
|
|
163
|
+
rawIssues.push({ type: o.type, severity: sev, title: o.title, screen: o.screen || null, target, url, step: o.step ?? null });
|
|
162
164
|
} catch {
|
|
163
165
|
/* ignore malformed */
|
|
164
166
|
}
|
|
@@ -175,6 +177,8 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
175
177
|
t === "OCQA_STATE:login_preamble_two_step_submitted"
|
|
176
178
|
) {
|
|
177
179
|
loginAttempted = true;
|
|
180
|
+
} else if (t === "OCQA_STATE:credentials_supplied") {
|
|
181
|
+
credentialsProvided = true;
|
|
178
182
|
} else if (t.startsWith("OCQA_STATE:{")) {
|
|
179
183
|
try {
|
|
180
184
|
const s = JSON.parse(t.slice("OCQA_STATE:".length));
|
|
@@ -273,9 +277,9 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
273
277
|
const coverageFloorMet = platform === "web"
|
|
274
278
|
? screensExplored >= 1 && actionsPerformed >= 1
|
|
275
279
|
: screensExplored >= 2 && actionsPerformed >= 3;
|
|
276
|
-
const
|
|
277
|
-
const inconclusive = !coverageFloorMet ||
|
|
278
|
-
const stopReason =
|
|
280
|
+
const unexercisedLoginWall = anySecure && !loginAttempted && screensExplored <= 1;
|
|
281
|
+
const inconclusive = !coverageFloorMet || unexercisedLoginWall || timeBudgetExhausted;
|
|
282
|
+
const stopReason = unexercisedLoginWall ? (credentialsProvided ? "login-wall-credentials-unused" : "login-wall-no-credentials")
|
|
279
283
|
: timeBudgetExhausted ? "time-budget-exhausted"
|
|
280
284
|
: coverageFloorMet ? "completed" : "coverage-floor-not-met";
|
|
281
285
|
|
|
@@ -338,7 +342,9 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
338
342
|
// "Failed sign-ins" is only a claim when credentials were actually submitted. Merely seeing a
|
|
339
343
|
// password field proves that a login surface was reached, not that authentication was exercised.
|
|
340
344
|
if (loginAttempted) checkedFor.splice(2, 0, "failed sign-ins");
|
|
345
|
+
else if (anySecure && credentialsProvided) notChecked.push("sign-in behavior (test credentials were supplied, but no sign-in attempt was observed)");
|
|
341
346
|
else if (anySecure) notChecked.push("sign-in behavior (login form reached, no test credentials supplied)");
|
|
347
|
+
else if (credentialsProvided) conditionsNotReached.push("sign-in (test credentials were supplied but no login form was encountered, so they were not used)");
|
|
342
348
|
else conditionsNotReached.push("sign-in (no login form encountered this run)");
|
|
343
349
|
if (timeBudgetExhausted) notChecked.push("the full requested action budget (run reached its wall-clock timeout)");
|
|
344
350
|
|
|
@@ -385,6 +391,13 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
385
391
|
screenElementCounts,
|
|
386
392
|
inputFieldsEncountered,
|
|
387
393
|
loginEncountered: anySecure,
|
|
394
|
+
credentialsProvided,
|
|
395
|
+
credentialsUsed: loginAttempted,
|
|
396
|
+
credentialWarning: credentialsProvided && !loginAttempted
|
|
397
|
+
? anySecure
|
|
398
|
+
? "Test credentials were supplied, but this run did not submit the login form. Authentication was not exercised."
|
|
399
|
+
: "Test credentials were supplied, but this run did not encounter a login form. They were not used."
|
|
400
|
+
: null,
|
|
388
401
|
complete: base.complete,
|
|
389
402
|
relativeMarkersFilePath: base.relativeMarkersFilePath,
|
|
390
403
|
};
|
|
@@ -544,7 +544,8 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
544
544
|
const issues = []; // emitted immediately; kept for counting only
|
|
545
545
|
const issue = (type, severity, title, screen, target) => {
|
|
546
546
|
issues.push(type);
|
|
547
|
-
|
|
547
|
+
const pageUrl = page.url();
|
|
548
|
+
emit("ISSUE", { type, severity, title, screen, ...(target ? { target } : {}), ...(pageUrl && pageUrl !== "about:blank" ? { url: pageUrl } : {}) });
|
|
548
549
|
};
|
|
549
550
|
|
|
550
551
|
// Async defect listeners: attribute to whatever screen is current when they fire.
|
|
@@ -925,7 +926,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
925
926
|
progress();
|
|
926
927
|
}
|
|
927
928
|
} finally {
|
|
928
|
-
emit("COMPLETE", { actions, screens: screenCount });
|
|
929
|
+
emit("COMPLETE", { actions, screens: screenCount, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried });
|
|
929
930
|
fs.closeSync(markersFd);
|
|
930
931
|
await browser.close().catch(() => {});
|
|
931
932
|
}
|
|
@@ -132,6 +132,22 @@ export async function executeWebFlowStep({ page, step, vars = {}, defaultTimeout
|
|
|
132
132
|
const el = await locateWebElement(page, target);
|
|
133
133
|
if (!el) throw new Error(`no field ‘${target}’ to type into`);
|
|
134
134
|
await el.fill(value);
|
|
135
|
+
} else if (action === "login") {
|
|
136
|
+
const emailValue = substituteFlowValue(raw.params.email || "$TEST_EMAIL", vars);
|
|
137
|
+
const passwordValue = substituteFlowValue(raw.params.password || "$TEST_PASSWORD", vars);
|
|
138
|
+
const email = await firstVisible([
|
|
139
|
+
page.locator("input[type=email]"), page.locator("input[name*=mail i]"),
|
|
140
|
+
page.locator("input[name*=user i]"), page.getByLabel(/email|user/i),
|
|
141
|
+
]);
|
|
142
|
+
const password = await firstVisible([page.locator("input[type=password]"), page.getByLabel(/password|passcode/i)]);
|
|
143
|
+
if (!email || !password) throw new Error("could not identify email and password fields");
|
|
144
|
+
await email.fill(emailValue);
|
|
145
|
+
await password.fill(passwordValue);
|
|
146
|
+
const submit = await firstVisible([page.getByRole("button", { name:/sign in|log in|login|continue|submit/i })]);
|
|
147
|
+
if (!submit) throw new Error("could not identify a sign-in control");
|
|
148
|
+
await submit.click();
|
|
149
|
+
await page.waitForTimeout(400);
|
|
150
|
+
if (await password.isVisible().catch(() => false)) throw new Error("submit left the app on the login screen");
|
|
135
151
|
} else if (action === "swipe") {
|
|
136
152
|
const dy = target.toLowerCase() === "down" ? -600 : 600;
|
|
137
153
|
await page.evaluate((y) => window.scrollBy({ top: y, behavior: "instant" }), dy);
|
|
@@ -173,7 +189,7 @@ export async function executeWebFlowStep({ page, step, vars = {}, defaultTimeout
|
|
|
173
189
|
status = "fail";
|
|
174
190
|
detail = error.message || String(error);
|
|
175
191
|
}
|
|
176
|
-
return { action, target: target || value, status, detail, task: raw.task };
|
|
192
|
+
return { action, target: action === "login" ? "sign-in form" : target || value, status, detail, task: raw.task };
|
|
177
193
|
}
|
|
178
194
|
|
|
179
195
|
export async function runWebFlow({ flow, url, logPath, screenshotDir, playwright }) {
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aarwitz/tapp",
|
|
3
|
-
"version": "0.17.0-rc.
|
|
3
|
+
"version": "0.17.0-rc.15",
|
|
4
4
|
"mcpName": "io.github.aarwitz/tapp",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Agent-driven app testing for iOS, Android, and web—real screens, replayable flows, and deterministic CI gates.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"bin": {
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"type": "git",
|
|
60
60
|
"url": "git+https://github.com/aarwitz/tapp.git"
|
|
61
61
|
},
|
|
62
|
-
"homepage": "https://
|
|
62
|
+
"homepage": "https://runtapp.com/",
|
|
63
63
|
"bugs": {
|
|
64
64
|
"url": "https://github.com/aarwitz/tapp/issues"
|
|
65
65
|
},
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
"mobile"
|
|
90
90
|
],
|
|
91
91
|
"scripts": {
|
|
92
|
-
"test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js vscode-extension/test/bridge.test.js",
|
|
92
|
+
"test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/presentation-contract.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js vscode-extension/test/bridge.test.js",
|
|
93
93
|
"test:browser-journey": "node --test tests/browser-journey.test.js",
|
|
94
94
|
"test:browser-native": "TAPP_RUN_NATIVE_BROWSER=1 node --test tests/browser-native-journey.test.js"
|
|
95
95
|
}
|
package/scripts/flow_lib.py
CHANGED
|
@@ -102,7 +102,7 @@ def report(path, as_json=False):
|
|
|
102
102
|
return 0 if passed else 1
|
|
103
103
|
|
|
104
104
|
icon = {"pass": "✅", "fail": "❌", "skip": "⚪️"}
|
|
105
|
-
verb = {"tap": "👆 tap", "type": "⌨️ type", "swipe": "↔️ swipe", "back": "◀️ back",
|
|
105
|
+
verb = {"tap": "👆 tap", "type": "⌨️ type", "login": "🔐 sign in", "swipe": "↔️ swipe", "back": "◀️ back",
|
|
106
106
|
"wait": "⏳ wait", "wait_for": "⏳ wait for", "assert_screen": "🔎 screen is",
|
|
107
107
|
"assert_exists": "🔎 exists", "assert_absent": "🔎 absent", "assert_text": "🔎 text",
|
|
108
108
|
"assert_ai": "🤖 ai"}
|
package/scripts/quick-capture.sh
CHANGED
|
@@ -23,9 +23,9 @@ TAPP_RUNTIME_HOME="${TAPP_HOME:-}"
|
|
|
23
23
|
if [[ -n "${TAPP_CAPTURE_DIR:-}" ]]; then
|
|
24
24
|
CAPTURE_DIR="$TAPP_CAPTURE_DIR"
|
|
25
25
|
elif [[ -n "$TAPP_RUNTIME_HOME" ]]; then
|
|
26
|
-
CAPTURE_DIR="$TAPP_RUNTIME_HOME/captures
|
|
26
|
+
CAPTURE_DIR="$TAPP_RUNTIME_HOME/captures/ios-$(date +%Y%m%d-%H%M%S)"
|
|
27
27
|
else
|
|
28
|
-
CAPTURE_DIR="$PROJECT_ROOT/captures
|
|
28
|
+
CAPTURE_DIR="$PROJECT_ROOT/captures/ios-$(date +%Y%m%d-%H%M%S)"
|
|
29
29
|
fi
|
|
30
30
|
if [[ -n "$TAPP_RUNTIME_HOME" ]]; then
|
|
31
31
|
HARNESS_DERIVED="$TAPP_RUNTIME_HOME/harness-derived"
|
package/skills/tapp/SKILL.md
CHANGED
|
@@ -33,8 +33,9 @@ When the user asks for a general first test of a repository:
|
|
|
33
33
|
3. If Tapp returns `target-selection-required`, present its actual choices and ask the user to pick.
|
|
34
34
|
Never guess among multiple targets. Re-run with the selected platform/target exactly as Tapp
|
|
35
35
|
instructs.
|
|
36
|
-
4. If a prerequisite is missing,
|
|
37
|
-
scope, and
|
|
36
|
+
4. If a prerequisite is missing, call `tapp_health` when MCP is connected or run
|
|
37
|
+
`npx -y @aarwitz/tapp@latest doctor`, apply only the stated remediation that is in scope, and
|
|
38
|
+
retry once.
|
|
38
39
|
|
|
39
40
|
For a focused request, use the requested target directly rather than forcing repository onboarding.
|
|
40
41
|
Targets may be a repository path, Xcode container, `.app`, iOS bundle id, APK plus Android app id,
|