@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.
@@ -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
- if (!activeSession || activeSession.ended) return { error: "No active session. Call tapp_session_start first." };
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 locator = await firstVisibleWebLocator(session.page, cmd.id || cmd.label || "");
760
- if (!locator) { status = "not_found"; detail = "No visible web control matched the semantic target"; }
761
- else await locator.click({ timeout:10_000 });
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 (then `tapp ci` to gate it)");
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 port = declaredPort || await openLocalPort();
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
- return richResult(`✅ Actor '${args.name.trim()}' configured with ${Object.keys(result.actor.credentials).length} environment binding(s); no credential values were accepted or written`, { path: result.path, actor: result.actor, next: "Run tapp_init refresh to update the application model and release plan." });
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
- return richResult(head + "\n\n" + formatScreen(r.screenTitle, r.elements) + rec, r);
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
 
@@ -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
- rawIssues.push({ type: o.type, severity: sev, title: o.title, screen: o.screen || null, target, step: o.step ?? null });
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 credentiallessLoginWall = anySecure && !loginAttempted && screensExplored <= 1;
277
- const inconclusive = !coverageFloorMet || credentiallessLoginWall || timeBudgetExhausted;
278
- const stopReason = credentiallessLoginWall ? "login-wall-no-credentials"
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
- emit("ISSUE", { type, severity, title, screen, ...(target ? { target } : {}) });
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.13",
3
+ "version": "0.17.0-rc.15",
4
4
  "mcpName": "io.github.aarwitz/tapp",
5
- "description": "Release contracts, autonomous QA, and evidence-backed CI gates for iOS, Android, and web.",
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://github.com/aarwitz/tapp#readme",
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
  }
@@ -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"}
@@ -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/$(date +%Y%m%d-%H%M%S)"
26
+ CAPTURE_DIR="$TAPP_RUNTIME_HOME/captures/ios-$(date +%Y%m%d-%H%M%S)"
27
27
  else
28
- CAPTURE_DIR="$PROJECT_ROOT/captures/$(date +%Y%m%d-%H%M%S)"
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"
@@ -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, run `tapp doctor`, apply only the stated remediation that is in
37
- scope, and retry once.
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,