@aarwitz/tapp 0.16.1 โ 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 +5 -0
- package/README.md +8 -0
- package/bin/tapp.js +26 -4
- package/mcp-server/src/index.js +23 -11
- package/mcp-server/src/report.js +19 -1
- package/mcp-server/src/web-explorer.js +154 -24
- package/package.json +1 -1
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({
|
|
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({
|
|
537
|
-
|
|
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);
|
|
@@ -1046,7 +1064,9 @@ switch (command) {
|
|
|
1046
1064
|
}
|
|
1047
1065
|
|
|
1048
1066
|
console.log(`\n Home: ${tappHome}`);
|
|
1049
|
-
console.log(healthy
|
|
1067
|
+
console.log(healthy
|
|
1068
|
+
? "\nReady. Start with:\n npx -y @aarwitz/tapp open [target]\n npx -y @aarwitz/tapp qa [target]"
|
|
1069
|
+
: "\nFix the โ items above, then re-run: tapp doctor");
|
|
1050
1070
|
process.exit(healthy ? 0 : 1);
|
|
1051
1071
|
}
|
|
1052
1072
|
|
|
@@ -1356,9 +1376,11 @@ switch (command) {
|
|
|
1356
1376
|
|
|
1357
1377
|
Zero-config verbs (agents and humans can just run these โ no server, no setup):
|
|
1358
1378
|
tapp open [target] Launch the app โ screen summary + screenshot saved to a file
|
|
1379
|
+
(web: --tap TEXT ยท --wait-for TEXT ยท --out FILE)
|
|
1359
1380
|
tapp qa [target] Autonomous QA โ verdict + findings + evidence
|
|
1360
1381
|
(--platform ios|android|web ยท --app-id ID ยท --apk FILE ยท --actions N)
|
|
1361
1382
|
tapp tree [target] Accessibility tree of the current screen (--json for every element)
|
|
1383
|
+
(web: --tap TEXT ยท --wait-for TEXT)
|
|
1362
1384
|
tapp flow run FILE Replay a committed deterministic Flow (no AI/API key)
|
|
1363
1385
|
tapp flow validate FILE Validate a Flow without launching a target
|
|
1364
1386
|
tapp task validate FILE Validate a reusable deterministic Task (+ optional UI Map grounding)
|
package/mcp-server/src/index.js
CHANGED
|
@@ -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
|
|
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
|
|
package/mcp-server/src/report.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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], [
|
|
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:
|
|
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
|
-
|
|
346
|
-
screenshotFor.
|
|
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:
|
|
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
|
|
352
|
-
|
|
353
|
-
issue("error_surface", "high",
|
|
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.
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
|
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 = (
|
|
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
|
-
|
|
525
|
-
|
|
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
|
|
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