@aarwitz/tapp 0.16.2 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -21,6 +21,11 @@ npx -y @aarwitz/tapp qa app.apk --platform android --app-id com.acme.app
21
21
  npx -y @aarwitz/tapp flow run .tapp/flows/smoke.yml # committed, keyless E2E replay
22
22
  ```
23
23
 
24
+ For focused web evidence, `open` and `tree` accept one semantic interaction plus an async content
25
+ wait: `tapp open https://example.com --tap "Not now" --wait-for "Dashboard"`. Tapp waits for the
26
+ page to stabilize before capturing it and warns honestly if the bounded wait ends while it is still
27
+ loading or changing.
28
+
24
29
  **Seeing the screen, per client:** if you can read image files into your context (Claude
25
30
  Code's Read tool, Codex's view-image), open the saved screenshot path the CLI prints —
26
31
  that IS the screen. If you cannot (Cursor, VS Code Copilot), connect the MCP server
package/README.md CHANGED
@@ -99,6 +99,14 @@ npx -y @aarwitz/tapp build [dir] # just build + install (scheme auto-detecte
99
99
  Web (beta): `npx -y @aarwitz/tapp qa http://localhost:3000` *(one-time setup:
100
100
  `npm i -g playwright && npx playwright install chromium`)*
101
101
 
102
+ Focused web inspection waits briefly for loading states to settle. If a consent or location modal
103
+ blocks the screen, dismiss it and wait for the content you care about in the same package-only call:
104
+
105
+ ```bash
106
+ npx -y @aarwitz/tapp open https://example.com --tap "Not now" --wait-for "Dashboard"
107
+ npx -y @aarwitz/tapp tree https://example.com --tap "Not now" --wait-for "Dashboard" --json
108
+ ```
109
+
102
110
  Android:
103
111
 
104
112
  ```bash
package/bin/tapp.js CHANGED
@@ -424,6 +424,7 @@ switch (command) {
424
424
  testEmail: flags.email,
425
425
  testPassword: flags.password,
426
426
  baselineFindings,
427
+ surface: "cli",
427
428
  onProgress,
428
429
  })
429
430
  : platform === "android"
@@ -435,6 +436,7 @@ switch (command) {
435
436
  testPassword: flags.password,
436
437
  baselineFindings,
437
438
  clearData: flags["keep-data"] !== true,
439
+ surface: "cli",
438
440
  onProgress,
439
441
  })
440
442
  : await engine.runQaIos({
@@ -442,6 +444,7 @@ switch (command) {
442
444
  maxActions: flags.actions,
443
445
  timeout: flags.timeout,
444
446
  args: { testEmail: flags.email, testPassword: flags.password, baselineFindings },
447
+ surface: "cli",
445
448
  onProgress,
446
449
  });
447
450
  process.stderr.write("\n");
@@ -469,13 +472,21 @@ switch (command) {
469
472
  }
470
473
  try {
471
474
  const { inspectWebPage } = await import(path.join(packageRoot, "mcp-server", "src", "web-explorer.js"));
472
- const snap = await inspectWebPage({ url, timeoutMs: Number(flags.timeout) * 1000 || 15_000 });
475
+ const snap = await inspectWebPage({
476
+ url,
477
+ timeoutMs: Number(flags.timeout) * 1000 || 15_000,
478
+ tapText: typeof flags.tap === "string" ? flags.tap : "",
479
+ waitForText: typeof flags["wait-for"] === "string" ? flags["wait-for"] : "",
480
+ });
473
481
  const out = typeof flags.out === "string" ? path.resolve(flags.out) : path.join(tappHome, "shots", `web-${Date.now()}.png`);
474
482
  fs.mkdirSync(path.dirname(out), { recursive: true });
475
483
  fs.writeFileSync(out, snap.image);
476
484
  console.log(`🌐 Opened \`${snap.url}\`\n`);
485
+ if (typeof flags.tap === "string") console.log(`šŸ‘† Tapped \`${flags.tap}\`\n`);
486
+ if (typeof flags["wait-for"] === "string") console.log(`ā³ Found \`${flags["wait-for"]}\`\n`);
477
487
  console.log(engine.formatScreen(snap.screenTitle, snap.elements));
478
488
  console.log(`\nšŸ“ø Screenshot: ${out}`);
489
+ if (!snap.settled) console.error("āš ļø Page still showed a loading or changing state when the bounded wait ended.");
479
490
  } catch (error) {
480
491
  console.error(`āŒ ${error.message || String(error)}`);
481
492
  process.exit(1);
@@ -533,9 +544,16 @@ switch (command) {
533
544
  }
534
545
  try {
535
546
  const { inspectWebPage } = await import(path.join(packageRoot, "mcp-server", "src", "web-explorer.js"));
536
- const snap = await inspectWebPage({ url, timeoutMs: Number(flags.timeout) * 1000 || 15_000, screenshot: false });
537
- if (flags.json) console.log(JSON.stringify({ platform: "web", url: snap.url, screenTitle: snap.screenTitle, elements: snap.elements }, null, 2));
547
+ const snap = await inspectWebPage({
548
+ url,
549
+ timeoutMs: Number(flags.timeout) * 1000 || 15_000,
550
+ screenshot: false,
551
+ tapText: typeof flags.tap === "string" ? flags.tap : "",
552
+ waitForText: typeof flags["wait-for"] === "string" ? flags["wait-for"] : "",
553
+ });
554
+ if (flags.json) console.log(JSON.stringify({ platform: "web", url: snap.url, screenTitle: snap.screenTitle, settled: snap.settled, elements: snap.elements }, null, 2));
538
555
  else console.log(engine.formatScreen(snap.screenTitle, snap.elements));
556
+ if (!snap.settled) console.error("āš ļø Page still showed a loading or changing state when the bounded wait ended.");
539
557
  } catch (error) {
540
558
  console.error(`āŒ ${error.message || String(error)}`);
541
559
  process.exit(1);
@@ -1358,9 +1376,11 @@ switch (command) {
1358
1376
 
1359
1377
  Zero-config verbs (agents and humans can just run these — no server, no setup):
1360
1378
  tapp open [target] Launch the app → screen summary + screenshot saved to a file
1379
+ (web: --tap TEXT Ā· --wait-for TEXT Ā· --out FILE)
1361
1380
  tapp qa [target] Autonomous QA → verdict + findings + evidence
1362
1381
  (--platform ios|android|web Ā· --app-id ID Ā· --apk FILE Ā· --actions N)
1363
1382
  tapp tree [target] Accessibility tree of the current screen (--json for every element)
1383
+ (web: --tap TEXT Ā· --wait-for TEXT)
1364
1384
  tapp flow run FILE Replay a committed deterministic Flow (no AI/API key)
1365
1385
  tapp flow validate FILE Validate a Flow without launching a target
1366
1386
  tapp task validate FILE Validate a reusable deterministic Task (+ optional UI Map grounding)
@@ -1304,7 +1304,22 @@ function fmtDuration(ms) {
1304
1304
  }
1305
1305
 
1306
1306
  /** Format a QA report as a scannable release readout with next-step suggestions. */
1307
- function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured, reportHtml, recording, uiMap } = {}) {
1307
+ export function qaNextSteps(report, surface = "mcp") {
1308
+ if (surface === "cli") {
1309
+ const next = [];
1310
+ if (report?.findings?.length) next.push("inspect the evidence with `tapp report latest`");
1311
+ next.push("re-run with `--baseline <report.json>` to gate a fix");
1312
+ next.push("replay a committed journey with `tapp flow run <file>`");
1313
+ return next;
1314
+ }
1315
+ const next = [];
1316
+ if (report?.findings?.length) next.push("open a flagged screen with `tapp_open_app`");
1317
+ next.push("re-run with `baselineFindings` to gate a fix");
1318
+ next.push("drive it step-by-step via `tapp_session_start`");
1319
+ return next;
1320
+ }
1321
+
1322
+ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured, reportHtml, recording, uiMap, surface = "mcp" } = {}) {
1308
1323
  const c = report.findingCounts || {};
1309
1324
  const badge = VERDICT_BADGE[report.verdict] || report.verdict;
1310
1325
  const sevBits = ["critical", "high", "medium", "low"]
@@ -1359,10 +1374,7 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
1359
1374
  }
1360
1375
  }
1361
1376
  }
1362
- const next = [];
1363
- if (report.findings && report.findings.length) next.push("open a flagged screen with `tapp_open_app`");
1364
- next.push("re-run with `baselineFindings` to gate a fix");
1365
- next.push("drive it step-by-step via `tapp_session_start`");
1377
+ const next = qaNextSteps(report, surface);
1366
1378
  L.push("");
1367
1379
  L.push(`**Next** — ${next.join(" Ā· ")}`);
1368
1380
  // The gate hook belongs at the moment the user thinks "I want this on every PR" —
@@ -1428,7 +1440,7 @@ export function formatScreen(screenTitle, elements) {
1428
1440
  // `tapp` CLI verbs in bin/tapp.js — same pattern as report.js. Keep orchestration HERE so
1429
1441
  // the surfaces can't drift.)
1430
1442
 
1431
- export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], onProgress = () => {} }) {
1443
+ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], surface = "mcp", onProgress = () => {} }) {
1432
1444
  const actions = Math.max(1, Math.min(1000, asInteger(maxActions, 60)));
1433
1445
  const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
1434
1446
  const id = "web-" + new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/^(\d{8})/, "$1-");
@@ -1465,11 +1477,11 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
1465
1477
  reportHtml = writeHtmlReport(outDir, { report, label: url.trim() });
1466
1478
  } catch { /* evidence page is best-effort */ }
1467
1479
  const structured = { ...report, regression, platform: "web", uiMap, reportHtml, exploration: { seedRoutes: webResult.seedRoutes || [], targets: webResult.seedTargets || [] }, capture: { id, path: outDir, relativePath: path.relative(repoRoot, outDir) } };
1468
- const text = formatQaReport(report, { regression, bundleId: url.trim(), aiConfigured: !!backend, reportHtml, uiMap: uiMap.error ? null : uiMap });
1480
+ const text = formatQaReport(report, { regression, bundleId: url.trim(), aiConfigured: !!backend, reportHtml, uiMap: uiMap.error ? null : uiMap, surface });
1469
1481
  return { structured, text };
1470
1482
  }
1471
1483
 
1472
- export async function runQaAndroid({ appId, apkPath, serial, maxActions, timeout, testEmail, testPassword, baselineFindings, clearData = true, seedTargets = [], onProgress = () => {} }) {
1484
+ export async function runQaAndroid({ appId, apkPath, serial, maxActions, timeout, testEmail, testPassword, baselineFindings, clearData = true, seedTargets = [], surface = "mcp", onProgress = () => {} }) {
1473
1485
  const actions = Math.max(1, Math.min(1000, asInteger(maxActions, 60)));
1474
1486
  const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
1475
1487
  const id = "android-" + new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/^(\d{8})/, "$1-");
@@ -1508,11 +1520,11 @@ export async function runQaAndroid({ appId, apkPath, serial, maxActions, timeout
1508
1520
  reportHtml = writeHtmlReport(outDir, { report, label: appId.trim() });
1509
1521
  } catch {}
1510
1522
  const structured = { ...report, regression, platform: "android", uiMap, reportHtml, exploration: { targets: androidResult.seedTargets || [] }, capture: { id, path: outDir, relativePath: path.relative(repoRoot, outDir) } };
1511
- const text = formatQaReport(report, { regression, bundleId: appId.trim(), aiConfigured: !!backend, reportHtml, uiMap: uiMap.error ? null : uiMap });
1523
+ const text = formatQaReport(report, { regression, bundleId: appId.trim(), aiConfigured: !!backend, reportHtml, uiMap: uiMap.error ? null : uiMap, surface });
1512
1524
  return { structured, text };
1513
1525
  }
1514
1526
 
1515
- export async function runQaIos({ bundleId, maxActions, timeout, args = {}, onProgress = () => {} }) {
1527
+ export async function runQaIos({ bundleId, maxActions, timeout, args = {}, surface = "mcp", onProgress = () => {} }) {
1516
1528
  const captureScript = path.join(scriptsDir, "quick-capture.sh");
1517
1529
  if (!fs.existsSync(captureScript)) return { error: "Capture script not found", details: { captureScript } };
1518
1530
 
@@ -1576,7 +1588,7 @@ export async function runQaIos({ bundleId, maxActions, timeout, args = {}, onPro
1576
1588
  timedOut,
1577
1589
  autoBooted: sim.autoBooted || false,
1578
1590
  };
1579
- const text = formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured: !!backend, reportHtml, recording, uiMap: uiMap.error ? null : uiMap });
1591
+ const text = formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured: !!backend, reportHtml, recording, uiMap: uiMap.error ? null : uiMap, surface });
1580
1592
  return { structured, text };
1581
1593
  }
1582
1594
 
@@ -152,11 +152,29 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
152
152
 
153
153
  const inputFieldsEncountered = Array.from(inputsByScreen.entries()).map(([screen, fields]) => ({ screen, fields }));
154
154
 
155
+ // Web resource failures belong to the resource, not every route that referenced it.
156
+ // Chromium can also surface one 404 through both response and requestfailed listeners;
157
+ // keep the concrete missing-asset finding and discard that transport-level duplicate.
158
+ const normalizedIssues = rawIssues.map((issue) => {
159
+ if (platform !== "web" || !["missing_asset", "network_error"].includes(issue.type)) return issue;
160
+ const title = String(issue.title || "");
161
+ const match = issue.type === "missing_asset"
162
+ ? title.match(/^404 asset:\s+(\S+)/i)
163
+ : title.match(/^Request failed:\s+(\S+)/i);
164
+ const resource = String(issue.target || match?.[1] || "").replace(/[?#].*$/, "");
165
+ return resource ? { ...issue, screen: null, target: resource } : issue;
166
+ });
167
+ const missingResources = new Set(normalizedIssues
168
+ .filter((issue) => issue.type === "missing_asset" && issue.target)
169
+ .map((issue) => issue.target));
170
+ const reportIssues = normalizedIssues.filter((issue) =>
171
+ !(issue.type === "network_error" && issue.target && missingResources.has(issue.target) && /^Request failed:/i.test(String(issue.title || ""))));
172
+
155
173
  // Dedup by stable signature (type|screen|target) so repeated detections count once —
156
174
  // but DIFFERENT controls failing on the same screen each count.
157
175
  const seen = new Set();
158
176
  const findings = [];
159
- for (const i of rawIssues) {
177
+ for (const i of reportIssues) {
160
178
  const key = `${i.type}|${i.screen}|${i.target ?? ""}`;
161
179
  if (seen.has(key)) continue;
162
180
  seen.add(key);
@@ -20,11 +20,89 @@ import path from "path";
20
20
  import { createRequire } from "module";
21
21
  import { execFileSync } from "child_process";
22
22
 
23
- const SETTLE_MS = 500;
24
23
  const CLICK_SETTLE_MS = 700;
25
24
  const NAV_TIMEOUT_MS = 15_000;
26
25
  const BUTTONS_PER_PAGE = 4;
27
26
  const ERROR_TEXT_RE = /\b(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)\b/i;
27
+ const STANDALONE_ERROR_TEXT_RE = /^(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)(?:[.!:]|\s|$)/i;
28
+
29
+ export function webErrorSurfaceText({ alertText = "", candidateTexts = [] } = {}) {
30
+ const alert = String(alertText || "").trim();
31
+ if (alert && ERROR_TEXT_RE.test(alert)) return alert;
32
+ return (candidateTexts || [])
33
+ .map((text) => String(text || "").trim())
34
+ .find((text) => STANDALONE_ERROR_TEXT_RE.test(text)) || "";
35
+ }
36
+
37
+ export function webControlLabel({ text = "", value = "", ariaLabel = "", title = "", id = "" } = {}) {
38
+ return [text, value, ariaLabel, title, id]
39
+ .map((part) => String(part || "").trim())
40
+ .find(Boolean) || "button";
41
+ }
42
+
43
+ // Wait for a page to stop presenting an explicit loading state and for its semantic
44
+ // surface to remain unchanged across a couple of samples. This is intentionally bounded:
45
+ // live counters and animation-heavy pages still return evidence, marked unsettled.
46
+ export async function waitForWebStability(page, { timeoutMs = 5_000, intervalMs = 250, stableSamples = 3 } = {}) {
47
+ const boundedTimeout = Math.max(250, Math.min(15_000, Number(timeoutMs) || 5_000));
48
+ const boundedInterval = Math.max(100, Math.min(1_000, Number(intervalMs) || 250));
49
+ const requiredSamples = Math.max(1, Math.min(5, Number(stableSamples) || 2));
50
+ const started = Date.now();
51
+ let previousSignature = "";
52
+ let matchingSamples = 0;
53
+ let latest = { busy: false, signature: "" };
54
+
55
+ while (Date.now() - started < boundedTimeout) {
56
+ latest = await page.evaluate(() => {
57
+ const visible = (element) => {
58
+ const style = window.getComputedStyle(element);
59
+ return style.visibility !== "hidden" && style.display !== "none" && element.getClientRects().length > 0;
60
+ };
61
+ const busySelector = "[aria-busy=true], [role=progressbar], .loading, .spinner, [class*=loading i], [class*=spinner i]";
62
+ const busyElement = [...document.querySelectorAll(busySelector)].some(visible);
63
+ const busyText = [...document.querySelectorAll("h1, h2, h3, p, [role=status]")]
64
+ .filter(visible)
65
+ .map((element) => (element.textContent || "").trim())
66
+ .some((text) => /^(loading|fetching|please wait|preparing|connecting)(?:[.…!]*|\s.*)$/i.test(text));
67
+ const bodyText = (document.body?.innerText || "").replace(/\s+/g, " ").trim().slice(0, 2_000);
68
+ const signature = JSON.stringify([
69
+ location.href,
70
+ document.querySelector("h1")?.textContent?.trim() || "",
71
+ document.title,
72
+ document.querySelectorAll("button, a[href], input, textarea, select, [role=button]").length,
73
+ bodyText,
74
+ ]);
75
+ return { busy: busyElement || busyText, signature };
76
+ }).catch(() => latest);
77
+
78
+ if (!latest.busy && latest.signature === previousSignature) matchingSamples += 1;
79
+ else matchingSamples = !latest.busy ? 1 : 0;
80
+ previousSignature = latest.signature;
81
+ if (!latest.busy && matchingSamples >= requiredSamples) {
82
+ return { settled: true, busy: false, elapsedMs: Date.now() - started };
83
+ }
84
+ await page.waitForTimeout(boundedInterval);
85
+ }
86
+ return { settled: false, busy: !!latest.busy, elapsedMs: Date.now() - started };
87
+ }
88
+
89
+ async function tapWebText(page, text, timeoutMs) {
90
+ const requested = String(text || "").trim();
91
+ if (!requested) return false;
92
+ const candidates = [
93
+ page.getByRole("button", { name: requested, exact: true }).first(),
94
+ page.getByRole("link", { name: requested, exact: true }).first(),
95
+ page.getByText(requested, { exact: true }).first(),
96
+ ];
97
+ for (const candidate of candidates) {
98
+ if (!(await candidate.isVisible().catch(() => false))) continue;
99
+ try {
100
+ await candidate.click({ timeout: Math.min(timeoutMs, 5_000) });
101
+ return true;
102
+ } catch {}
103
+ }
104
+ throw new Error(`Could not tap visible text ā€œ${requested}ā€`);
105
+ }
28
106
 
29
107
  // npx installs Tapp into its own cache, so a plain import("playwright") only resolves
30
108
  // for repo-dev checkouts. Probe, in order: our own node_modules; the user's project
@@ -100,7 +178,7 @@ export function webBrowserLaunchOptions(environment = process.env) {
100
178
  // Focused one-screen inspection for the agent-facing `tapp open <url>` and `tapp tree <url>`
101
179
  // commands. This deliberately does no exploration or judgment; it opens exactly one page,
102
180
  // captures the visible semantic controls, and optionally takes one screenshot.
103
- export async function inspectWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, screenshot = true }) {
181
+ export async function inspectWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, screenshot = true, tapText = "", waitForText = "" }) {
104
182
  let target;
105
183
  try { target = new URL(url); }
106
184
  catch { throw new Error("Web inspection needs a valid http(s) URL"); }
@@ -115,7 +193,20 @@ export async function inspectWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, screensh
115
193
  page.setDefaultTimeout(boundedTimeout);
116
194
  const response = await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: boundedTimeout });
117
195
  if (response && response.status() >= 400) throw new Error(`Could not open ${target.href}: HTTP ${response.status()}`);
118
- await page.waitForTimeout(SETTLE_MS);
196
+ let stability = await waitForWebStability(page, { timeoutMs: Math.min(5_000, boundedTimeout) });
197
+ if (tapText) {
198
+ await tapWebText(page, tapText, boundedTimeout);
199
+ stability = await waitForWebStability(page, { timeoutMs: Math.min(5_000, boundedTimeout) });
200
+ }
201
+ if (waitForText) {
202
+ const requested = String(waitForText).trim();
203
+ try {
204
+ await page.getByText(requested, { exact: false }).first().waitFor({ state: "visible", timeout: boundedTimeout });
205
+ } catch {
206
+ throw new Error(`Timed out waiting for visible text ā€œ${requested}ā€`);
207
+ }
208
+ stability = await waitForWebStability(page, { timeoutMs: Math.min(5_000, boundedTimeout) });
209
+ }
119
210
  const observed = await page.evaluate(() => {
120
211
  const visible = (element) => element.offsetParent !== null;
121
212
  const controls = [...document.querySelectorAll("button, a[href], input, textarea, select, [role=button], [role=tab], [role=checkbox], [role=switch]")]
@@ -150,6 +241,8 @@ export async function inspectWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, screensh
150
241
  screenTitle: webScreenTitle(observed, target.pathname || target.href),
151
242
  elements: observed.controls,
152
243
  image,
244
+ settled: stability.settled,
245
+ busy: stability.busy,
153
246
  };
154
247
  } finally {
155
248
  await browser.close().catch(() => {});
@@ -246,7 +339,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
246
339
  if (u.origin !== start.origin) return;
247
340
  if (res.status() >= 500) issue("network_error", "high", `${res.status()} from ${u.pathname.slice(0, 80)}`, currentScreen);
248
341
  else if (res.status() === 404 && res.request().resourceType() !== "document") {
249
- issue("missing_asset", "medium", `404 asset: ${u.pathname.slice(0, 80)}`, currentScreen);
342
+ issue("missing_asset", "medium", `404 asset: ${u.pathname.slice(0, 80)}`, currentScreen, u.pathname);
250
343
  }
251
344
  } catch {}
252
345
  });
@@ -254,7 +347,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
254
347
  try {
255
348
  const u = new URL(req.url());
256
349
  if (u.origin !== start.origin) return;
257
- issue("network_error", "medium", `Request failed: ${u.pathname.slice(0, 80)} (${req.failure()?.errorText || "?"})`, currentScreen);
350
+ issue("network_error", "medium", `Request failed: ${u.pathname.slice(0, 80)} (${req.failure()?.errorText || "?"})`, currentScreen, u.pathname);
258
351
  } catch {}
259
352
  });
260
353
 
@@ -269,7 +362,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
269
362
  : { target: start.pathname + start.search, visitKey: `pr-path:${target.id}`, action: `PR target ${target.node.name}`, fromScreen: null, prTarget: true, targetId: target.id, pathTarget: target }),
270
363
  ...normalizedSeeds.filter((target) => !targetRoutes.has(target)).map((target) => ({ target, action: `PR target ${target}`, fromScreen: null, prTarget: true })),
271
364
  ];
272
- const screenshotFor = new Set();
365
+ const screenshotFor = new Map();
273
366
  let actions = 0;
274
367
  let screenCount = 0;
275
368
  let lastScreen = null;
@@ -320,8 +413,21 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
320
413
  title: document.title.trim(),
321
414
  controlCount: document.querySelectorAll("a[href], button, [role=button], input, select, textarea").length,
322
415
  textLen: (document.body?.innerText || "").trim().length,
323
- alertText: [...document.querySelectorAll("[role=alert], [class*=error i]")]
416
+ alertText: [...document.querySelectorAll("[role=alert], [aria-live=assertive]")]
324
417
  .map((el) => el.textContent.trim()).filter(Boolean).join(" ").slice(0, 120),
418
+ errorCandidateTexts: [...document.querySelectorAll("h1, h2, h3, p, [data-error], [data-testid*=error i]")]
419
+ .filter((el) => el.offsetParent !== null)
420
+ .map((el) => (el.textContent || "").trim().slice(0, 240))
421
+ .filter(Boolean)
422
+ .slice(0, 40),
423
+ busy: [...document.querySelectorAll("[aria-busy=true], [role=progressbar], .loading, .spinner, [class*=loading i], [class*=spinner i]")]
424
+ .some((el) => {
425
+ const style = window.getComputedStyle(el);
426
+ return style.visibility !== "hidden" && style.display !== "none" && el.getClientRects().length > 0;
427
+ }) || [...document.querySelectorAll("h1, h2, h3, p, [role=status]")]
428
+ .filter((el) => el.offsetParent !== null)
429
+ .map((el) => (el.textContent || "").trim())
430
+ .some((text) => /^(loading|fetching|please wait|preparing|connecting)(?:[.…!]*|\s.*)$/i.test(text)),
325
431
  inputs,
326
432
  controls,
327
433
  };
@@ -332,7 +438,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
332
438
  const screen = webScreenTitle(info, key);
333
439
  const evidenceKey = `${key}::${screen}`;
334
440
  currentScreen = screen;
335
- emit("STATE", { screen, url: key, elements: info.controlCount, role: webScreenRole(screen, info.inputs), controls: info.controls, inputs: info.inputs, settled: true });
441
+ emit("STATE", { screen, url: key, elements: info.controlCount, role: webScreenRole(screen, info.inputs), controls: info.controls, inputs: info.inputs, settled: !info.busy });
336
442
  const completedNavigation = pendingNavigation;
337
443
  const transitionFrom = webTransitionOrigin(completedNavigation, lastScreen);
338
444
  const transitionAction = completedNavigation?.action || lastActionTarget;
@@ -342,15 +448,23 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
342
448
  lastScreen = screen;
343
449
  if (completedNavigation?.prTarget) emit("PR_TARGET", { ...(completedNavigation.targetId ? { targetId: completedNavigation.targetId } : {}), route: completedNavigation.target, status: "observed", screen });
344
450
 
345
- if (!screenshotFor.has(evidenceKey)) {
346
- screenshotFor.add(evidenceKey);
451
+ const busyRouteEntry = !info.busy
452
+ ? [...screenshotFor.entries()].find(([, value]) => value.route === key && value.busy)
453
+ : null;
454
+ const existingKey = screenshotFor.has(evidenceKey) ? evidenceKey : busyRouteEntry?.[0];
455
+ const existingScreenshot = existingKey ? screenshotFor.get(existingKey) : null;
456
+ const shouldCapture = !existingScreenshot || (existingScreenshot.busy && !info.busy);
457
+ if (shouldCapture) {
458
+ const screenshotPath = existingScreenshot?.path || path.join(outDir, `state_${screenshotFor.size + 1}_${slug(screen)}.png`);
459
+ if (existingKey && existingKey !== evidenceKey) screenshotFor.delete(existingKey);
460
+ screenshotFor.set(evidenceKey, { path: screenshotPath, busy: info.busy, route: key });
347
461
  screenCount = screenshotFor.size;
348
- await page.screenshot({ path: path.join(outDir, `state_${screenCount}_${slug(screen)}.png`) }).catch(() => {});
462
+ await page.screenshot({ path: screenshotPath }).catch(() => {});
349
463
  // Deterministic per-page detectors run once per distinct screen.
350
464
  if (info.textLen < 10) issue("blank_screen", "high", "Page rendered no visible text", screen);
351
- else if (info.alertText && ERROR_TEXT_RE.test(info.alertText)) issue("error_surface", "high", `Error shown: ${info.alertText.slice(0, 80)}`, screen);
352
- else if (ERROR_TEXT_RE.test(await page.evaluate(() => (document.body?.innerText || "").slice(0, 4000)).catch(() => ""))) {
353
- issue("error_surface", "high", "Error text visible on page", screen);
465
+ else {
466
+ const errorText = webErrorSurfaceText({ alertText: info.alertText, candidateTexts: info.errorCandidateTexts });
467
+ if (errorText) issue("error_surface", "high", `Error shown: ${errorText.slice(0, 80)}`, screen);
354
468
  }
355
469
  }
356
470
  return { key, screen, info };
@@ -370,7 +484,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
370
484
  emit("ACTION", { type: "login", target: "Sign in", screen, narrative: "Filled and submitted the sign-in form with the provided test credentials" });
371
485
  actions += 1;
372
486
  await submitWebLogin(page).catch(() => false);
373
- await page.waitForTimeout(CLICK_SETTLE_MS * 2);
487
+ await waitForWebStability(page, { timeoutMs: Math.min(5_000, CLICK_SETTLE_MS * 6) });
374
488
  // Still on the login form after a submit = the sign-in failed — full stop. (A quiet
375
489
  // credential rejection often shows NO other symptom, so this must not be coupled to
376
490
  // whether some other detector happened to fire during the attempt.)
@@ -417,7 +531,6 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
417
531
  // we just left; observe() refines this to the page title once it settles.
418
532
  currentScreen = target;
419
533
  const nav = await page.goto(start.origin + target, { waitUntil: "domcontentloaded" }).catch((err) => ({ navError: String(err.message || err) }));
420
- await page.waitForTimeout(SETTLE_MS);
421
534
  if (nav && nav.navError) {
422
535
  if (entry.prTarget) emit("PR_TARGET", { ...(entry.targetId ? { targetId: entry.targetId } : {}), ...(entry.pathTarget ? {} : { route: target }), status: "failed", error: nav.navError.slice(0, 160) });
423
536
  pendingNavigation = null;
@@ -425,6 +538,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
425
538
  progress();
426
539
  continue;
427
540
  }
541
+ await waitForWebStability(page);
428
542
  if (nav && typeof nav.status === "function" && nav.status() === 404) {
429
543
  issue("broken_link", "medium", `Broken link: ${target} → 404`, target);
430
544
  }
@@ -456,15 +570,17 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
456
570
  else if (selector.kind === "label") locator = page.getByText(selector.value, { exact: true }).first();
457
571
  else continue;
458
572
  if (await locator.isVisible().catch(() => false)) {
459
- await locator.click({ timeout: Math.min(step.wait?.timeoutMs || NAV_TIMEOUT_MS, NAV_TIMEOUT_MS) }).catch(() => {});
460
- acted = true;
461
- break;
573
+ try {
574
+ await locator.click({ timeout: Math.min(step.wait?.timeoutMs || NAV_TIMEOUT_MS, NAV_TIMEOUT_MS) });
575
+ acted = true;
576
+ break;
577
+ } catch {}
462
578
  }
463
579
  }
464
580
  }
465
581
  if (!acted) { pathError = `Observed control was not found: ${action.target}`; break; }
466
582
  pendingNavigation = { action: action.target, fromScreen: beforeScreen };
467
- await page.waitForTimeout(CLICK_SETTLE_MS);
583
+ await waitForWebStability(page);
468
584
  ob = await observe() || ob;
469
585
  progress();
470
586
  }
@@ -502,7 +618,13 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
502
618
  const n = Math.min(await buttons.count().catch(() => 0), BUTTONS_PER_PAGE);
503
619
  for (let i = 0; i < n && actions < maxActions && Date.now() < deadline; i++) {
504
620
  const b = buttons.nth(i);
505
- const label = ((await b.textContent().catch(() => "")) || (await b.getAttribute("value").catch(() => "")) || "button").trim().slice(0, 40) || "button";
621
+ const label = webControlLabel({
622
+ text: await b.textContent().catch(() => ""),
623
+ value: await b.getAttribute("value").catch(() => ""),
624
+ ariaLabel: await b.getAttribute("aria-label").catch(() => ""),
625
+ title: await b.getAttribute("title").catch(() => ""),
626
+ id: await b.getAttribute("id").catch(() => ""),
627
+ }).slice(0, 40);
506
628
  if (/log ?out|sign ?out|delete|remove/i.test(label)) continue; // don't destroy test state
507
629
  const beforeUrl = page.url();
508
630
  // Dead-button detection watches four real effect channels — DOM mutations, dialogs,
@@ -521,12 +643,20 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
521
643
  actions += 1;
522
644
  lastActionTarget = label;
523
645
  emit("ACTION", { type: "tap", target: label, screen: webActionScreen(ob), narrative: `Tapped "${label}"` });
524
- await b.click({ timeout: 3000 }).catch(() => {});
525
- await page.waitForTimeout(CLICK_SETTLE_MS);
646
+ let clickSucceeded = false;
647
+ try {
648
+ await b.click({ timeout: 3000 });
649
+ clickSucceeded = true;
650
+ } catch {}
651
+ if (!clickSucceeded) {
652
+ progress();
653
+ continue;
654
+ }
655
+ await waitForWebStability(page);
526
656
  if (page.url() !== beforeUrl) {
527
657
  await observe();
528
658
  await page.goBack({ waitUntil: "domcontentloaded" }).catch(() => {});
529
- await page.waitForTimeout(SETTLE_MS);
659
+ await waitForWebStability(page);
530
660
  } else {
531
661
  const mutations = await page.evaluate(() => window.__tappMut || 0).catch(() => 0);
532
662
  const dialogsAfter = await page.locator("dialog[open], [role=dialog], [aria-modal=true]").count().catch(() => 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.16.2",
3
+ "version": "0.16.3",
4
4
  "mcpName": "io.github.aarwitz/tapp",
5
5
  "description": "Release contracts, autonomous QA, and evidence-backed CI gates for iOS, Android, and web.",
6
6
  "license": "MIT",