@aarwitz/tapp 0.15.0
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 +123 -0
- package/Harness/OCQAHarness/AppDelegate.swift +21 -0
- package/Harness/OCQAHarness/Info.plist +26 -0
- package/Harness/OCQAHarness.xcodeproj/project.pbxproj +199 -0
- package/Harness/OCQAHarness.xcodeproj/xcshareddata/xcschemes/OCQAHarnessUITests.xcscheme +22 -0
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +4526 -0
- package/Harness/OCQAHarnessUITests/Info.plist +22 -0
- package/Harness/generate-harness-xcodeproj.rb +254 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/bin/tapp.js +1382 -0
- package/browser/app.css +227 -0
- package/browser/app.js +675 -0
- package/browser/index.html +195 -0
- package/browser/product-contract.js +25 -0
- package/browser/view-model.js +16 -0
- package/docs/BROWSER-PRODUCT.md +72 -0
- package/docs/PRODUCT-ENGINE.md +102 -0
- package/docs/application-model.md +276 -0
- package/docs/scenarios.md +95 -0
- package/mcp-server/src/android-driver.js +287 -0
- package/mcp-server/src/android-explorer.js +197 -0
- package/mcp-server/src/android-flow.js +89 -0
- package/mcp-server/src/application-model.js +1597 -0
- package/mcp-server/src/browser-product.js +659 -0
- package/mcp-server/src/browser-workspaces.js +234 -0
- package/mcp-server/src/ci-report.js +557 -0
- package/mcp-server/src/ci-setup.js +359 -0
- package/mcp-server/src/contract-authoring.js +10 -0
- package/mcp-server/src/enrich.js +57 -0
- package/mcp-server/src/flow-runtime.js +127 -0
- package/mcp-server/src/html-report.js +124 -0
- package/mcp-server/src/index.js +3775 -0
- package/mcp-server/src/maintenance-proposal.js +178 -0
- package/mcp-server/src/managed-operation.js +61 -0
- package/mcp-server/src/pr-selection.js +841 -0
- package/mcp-server/src/product-execution.js +155 -0
- package/mcp-server/src/product-operations.js +526 -0
- package/mcp-server/src/project-config.js +101 -0
- package/mcp-server/src/release-contract.d.ts +81 -0
- package/mcp-server/src/release-contract.js +226 -0
- package/mcp-server/src/report.js +363 -0
- package/mcp-server/src/scenario-runtime.js +139 -0
- package/mcp-server/src/static-server.js +44 -0
- package/mcp-server/src/task-runtime.js +266 -0
- package/mcp-server/src/ui-map.js +661 -0
- package/mcp-server/src/web-explorer.js +493 -0
- package/mcp-server/src/web-flow.js +238 -0
- package/package.json +82 -0
- package/scripts/android-corpus-e2e.sh +30 -0
- package/scripts/ci-gate.sh +323 -0
- package/scripts/cleanup-xcode.sh +157 -0
- package/scripts/compile-contract.js +27 -0
- package/scripts/compile-flow.js +18 -0
- package/scripts/corpus-apps.txt +9 -0
- package/scripts/corpus-sweep.sh +121 -0
- package/scripts/coverage-eval.sh +92 -0
- package/scripts/coverage_eval_parse.py +95 -0
- package/scripts/deploy-and-build.sh +99 -0
- package/scripts/flow-platform.js +18 -0
- package/scripts/flow_ai_judge.py +102 -0
- package/scripts/flow_lib.py +154 -0
- package/scripts/mutation-recall-desktop.sh +186 -0
- package/scripts/mutation-recall.sh +121 -0
- package/scripts/mutation_lib.py +128 -0
- package/scripts/mutation_operators.py +144 -0
- package/scripts/platform-gate.js +186 -0
- package/scripts/pr-plan.js +68 -0
- package/scripts/quick-capture.sh +419 -0
- package/scripts/run-android-flow.js +27 -0
- package/scripts/run-flow.sh +90 -0
- package/scripts/run-web-flow.js +28 -0
- package/scripts/run-web-scenario.js +23 -0
- package/scripts/validation-matrix.sh +146 -0
- package/scripts/vision-fp-eval.sh +206 -0
- package/scripts/vision_escalation_responder.py +147 -0
- package/scripts/vision_fp_probe.py +221 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// Web exploration driver (beta) — the second platform behind the OCQA marker protocol.
|
|
2
|
+
//
|
|
3
|
+
// Drives a real browser via Playwright and emits the SAME OCQA_* marker lines the iOS
|
|
4
|
+
// XCUITest harness emits, into the same captures/<id>/ocqa-markers.txt layout — so the
|
|
5
|
+
// entire judgment layer (report.js verdict/dedup/regression, the CI gate, baselines,
|
|
6
|
+
// capture tooling) works on web runs unchanged. This file is the platform seam made
|
|
7
|
+
// concrete: a driver's whole contract is "emit honest markers".
|
|
8
|
+
//
|
|
9
|
+
// Deterministic by design, like the iOS harness: BFS over same-origin pages, a bounded
|
|
10
|
+
// button pass per page, deterministic issue detectors (uncaught JS exceptions, failed/5xx
|
|
11
|
+
// same-origin requests, broken links, dead buttons, visible error surfaces, blank pages,
|
|
12
|
+
// load timeouts). No LLM anywhere in the loop.
|
|
13
|
+
//
|
|
14
|
+
// Playwright is deliberately NOT a required dependency of Tapp (it would bloat every npx
|
|
15
|
+
// install with a browser download). It's resolved dynamically; exploreWeb() throws a
|
|
16
|
+
// clear install hint when it's missing.
|
|
17
|
+
|
|
18
|
+
import fs from "fs";
|
|
19
|
+
import path from "path";
|
|
20
|
+
import { createRequire } from "module";
|
|
21
|
+
import { execFileSync } from "child_process";
|
|
22
|
+
|
|
23
|
+
const SETTLE_MS = 500;
|
|
24
|
+
const CLICK_SETTLE_MS = 700;
|
|
25
|
+
const NAV_TIMEOUT_MS = 15_000;
|
|
26
|
+
const BUTTONS_PER_PAGE = 4;
|
|
27
|
+
const ERROR_TEXT_RE = /\b(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)\b/i;
|
|
28
|
+
|
|
29
|
+
// npx installs Tapp into its own cache, so a plain import("playwright") only resolves
|
|
30
|
+
// for repo-dev checkouts. Probe, in order: our own node_modules; the user's project
|
|
31
|
+
// (process.cwd()); the global npm root. ESM ignores NODE_PATH, so cwd/global need explicit
|
|
32
|
+
// resolution + import-by-absolute-path.
|
|
33
|
+
export async function loadPlaywright() {
|
|
34
|
+
try {
|
|
35
|
+
return await import("playwright");
|
|
36
|
+
} catch {}
|
|
37
|
+
const probes = [path.join(process.cwd(), "noop.js")];
|
|
38
|
+
try {
|
|
39
|
+
probes.push(path.join(execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim(), "noop.js"));
|
|
40
|
+
} catch {}
|
|
41
|
+
for (const from of probes) {
|
|
42
|
+
try {
|
|
43
|
+
return await import(createRequire(from).resolve("playwright"));
|
|
44
|
+
} catch {}
|
|
45
|
+
}
|
|
46
|
+
throw new Error(
|
|
47
|
+
"Web exploration needs Playwright (not bundled, to keep Tapp installs small). " +
|
|
48
|
+
"One-time setup, either works: `npm i playwright` in your project, or `npm i -g playwright` — " +
|
|
49
|
+
"then `npx playwright install chromium`."
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function submitWebLogin(page) {
|
|
54
|
+
const candidates = [
|
|
55
|
+
page.locator("button[type=submit], input[type=submit], form button").first(),
|
|
56
|
+
page.getByRole("button", { name: /sign ?in|log ?in|continue/i }).first(),
|
|
57
|
+
];
|
|
58
|
+
for (const candidate of candidates) {
|
|
59
|
+
if (await candidate.isVisible().catch(() => false)) {
|
|
60
|
+
await candidate.click({ timeout: 3000 });
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function webScreenTitle(info, fallback) {
|
|
68
|
+
return String(info?.heading || info?.title || fallback || "").trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function webActionScreen(activeObservation, fallback = "Unknown") {
|
|
72
|
+
return activeObservation?.screen || fallback;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function webNavigationAction(linkLabel, route) {
|
|
76
|
+
return String(linkLabel || "").trim() || String(route || "transition");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function webTransitionOrigin(pendingNavigation, currentScreen) {
|
|
80
|
+
return pendingNavigation?.fromScreen || currentScreen || null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function webBrowserLaunchOptions(environment = process.env) {
|
|
84
|
+
const browserProxy = String(environment.TAPP_BROWSER_PROXY_SERVER || "").trim();
|
|
85
|
+
if (environment.TAPP_ENFORCE_PUBLIC_EGRESS === "1" && !/^http:\/\/127\.0\.0\.1:\d+$/.test(browserProxy)) {
|
|
86
|
+
throw new Error("public egress policy proxy is required");
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
headless: true,
|
|
90
|
+
...(browserProxy ? { proxy: { server: browserProxy, bypass: "<-loopback>" } } : {}),
|
|
91
|
+
args: browserProxy ? [
|
|
92
|
+
"--disable-quic",
|
|
93
|
+
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
|
|
94
|
+
"--webrtc-ip-handling-policy=disable_non_proxied_udp",
|
|
95
|
+
"--proxy-bypass-list=<-loopback>",
|
|
96
|
+
] : [],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function normalizeWebSeedRoutes(url, routes, limit = 5) {
|
|
101
|
+
const origin = new URL(url);
|
|
102
|
+
const boundedLimit = Math.max(0, Math.min(10, Number(limit) || 0));
|
|
103
|
+
if (boundedLimit === 0) return [];
|
|
104
|
+
const startKey = origin.pathname.replace(/\/+$/, "") + origin.search || "/";
|
|
105
|
+
const normalized = [];
|
|
106
|
+
for (const raw of routes || []) {
|
|
107
|
+
const value = typeof raw === "string" ? raw : raw?.path;
|
|
108
|
+
if (!value) continue;
|
|
109
|
+
let route;
|
|
110
|
+
try { route = new URL(value, origin); } catch { continue; }
|
|
111
|
+
if (route.origin !== origin.origin || !/^https?:$/.test(route.protocol)) continue;
|
|
112
|
+
const key = route.pathname.replace(/\/+$/, "") + route.search || "/";
|
|
113
|
+
if (!normalized.includes(key) && key !== startKey) normalized.push(key);
|
|
114
|
+
if (normalized.length >= boundedLimit) break;
|
|
115
|
+
}
|
|
116
|
+
return normalized;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function webScreenRole(screen, inputs = []) {
|
|
120
|
+
const name = String(screen || "").toLowerCase();
|
|
121
|
+
if (inputs.some((input) => input.secure) || /sign ?in|log ?in/.test(name)) return "login";
|
|
122
|
+
if (/checkout|payment|purchase/.test(name)) return "checkout";
|
|
123
|
+
if (/message|chat|conversation/.test(name)) return "messaging";
|
|
124
|
+
if (/settings|preference/.test(name)) return "settings";
|
|
125
|
+
if (/feed|list|catalog|search|home/.test(name)) return "list";
|
|
126
|
+
if (inputs.length) return "form";
|
|
127
|
+
return "screen";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function slug(s) {
|
|
131
|
+
return String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "page";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function normalizeWebSeedTargets(seedTargets = [], limit = 5) {
|
|
135
|
+
const maximum = Math.max(0, Math.min(5, Number(limit) || 0));
|
|
136
|
+
if (!maximum || !Array.isArray(seedTargets)) return [];
|
|
137
|
+
const result = [];
|
|
138
|
+
const ids = new Set();
|
|
139
|
+
for (const target of seedTargets) {
|
|
140
|
+
if (target?.platform !== "web" || target?.status !== "planned" || target?.navigation?.status !== "replayable" || typeof target.id !== "string" || !target.id || ids.has(target.id)) continue;
|
|
141
|
+
const navigation = target.navigation;
|
|
142
|
+
if (navigation.mode === "route") {
|
|
143
|
+
if (typeof navigation.route !== "string" || !navigation.route.startsWith("/")) continue;
|
|
144
|
+
} else if (navigation.mode === "ui-map-path") {
|
|
145
|
+
if (!Array.isArray(navigation.steps) || navigation.steps.length > 8 || navigation.steps.some((step) =>
|
|
146
|
+
!["tap", "back"].includes(step?.action?.type) || typeof step?.action?.target !== "string" || !step.action.target)) continue;
|
|
147
|
+
} else continue;
|
|
148
|
+
ids.add(target.id);
|
|
149
|
+
result.push(structuredClone(target));
|
|
150
|
+
if (result.length >= maximum) break;
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", seedRoutes = [], seedTargets = [], onProgress }) {
|
|
156
|
+
const start = new URL(url);
|
|
157
|
+
if (!/^https?:$/.test(start.protocol)) throw new Error("url must be http(s)");
|
|
158
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
159
|
+
const markersPath = path.join(outDir, "ocqa-markers.txt");
|
|
160
|
+
const markersFd = fs.openSync(markersPath, "w");
|
|
161
|
+
const emit = (kind, payload) => fs.writeSync(markersFd, `OCQA_${kind}:${JSON.stringify(payload)}\n`);
|
|
162
|
+
|
|
163
|
+
const { chromium } = await loadPlaywright();
|
|
164
|
+
const browser = await chromium.launch(webBrowserLaunchOptions());
|
|
165
|
+
const page = await (await browser.newContext({ viewport: { width: 1280, height: 900 } })).newPage();
|
|
166
|
+
page.setDefaultTimeout(NAV_TIMEOUT_MS);
|
|
167
|
+
|
|
168
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
169
|
+
const issues = []; // emitted immediately; kept for counting only
|
|
170
|
+
const issue = (type, severity, title, screen, target) => {
|
|
171
|
+
issues.push(type);
|
|
172
|
+
emit("ISSUE", { type, severity, title, screen, ...(target ? { target } : {}) });
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// Request counter: cheap "did that click cause network activity" signal for the
|
|
176
|
+
// dead-button check (a button that fires a request is not dead).
|
|
177
|
+
let requestCount = 0;
|
|
178
|
+
|
|
179
|
+
// Async defect listeners: attribute to whatever screen is current when they fire.
|
|
180
|
+
let currentScreen = start.pathname;
|
|
181
|
+
let lastActionTarget = "";
|
|
182
|
+
page.on("request", () => { requestCount += 1; });
|
|
183
|
+
page.on("pageerror", (err) => issue("js_exception", "high", `Uncaught JS exception: ${String(err.message || err).slice(0, 120)}`, currentScreen));
|
|
184
|
+
page.on("response", (res) => {
|
|
185
|
+
try {
|
|
186
|
+
const u = new URL(res.url());
|
|
187
|
+
if (u.origin !== start.origin) return;
|
|
188
|
+
if (res.status() >= 500) issue("network_error", "high", `${res.status()} from ${u.pathname.slice(0, 80)}`, currentScreen);
|
|
189
|
+
else if (res.status() === 404 && res.request().resourceType() !== "document") {
|
|
190
|
+
issue("missing_asset", "medium", `404 asset: ${u.pathname.slice(0, 80)}`, currentScreen);
|
|
191
|
+
}
|
|
192
|
+
} catch {}
|
|
193
|
+
});
|
|
194
|
+
page.on("requestfailed", (req) => {
|
|
195
|
+
try {
|
|
196
|
+
const u = new URL(req.url());
|
|
197
|
+
if (u.origin !== start.origin) return;
|
|
198
|
+
issue("network_error", "medium", `Request failed: ${u.pathname.slice(0, 80)} (${req.failure()?.errorText || "?"})`, currentScreen);
|
|
199
|
+
} catch {}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const visited = new Set(); // screen keys (pathname+search)
|
|
203
|
+
const normalizedSeeds = normalizeWebSeedRoutes(url, seedRoutes);
|
|
204
|
+
const normalizedTargets = normalizeWebSeedTargets(seedTargets);
|
|
205
|
+
const targetRoutes = new Set(normalizedTargets.filter((target) => target.navigation.mode === "route").map((target) => target.navigation.route));
|
|
206
|
+
const frontier = [
|
|
207
|
+
{ target: start.pathname + start.search, action: start.pathname + start.search, fromScreen: null },
|
|
208
|
+
...normalizedTargets.map((target) => target.navigation.mode === "route"
|
|
209
|
+
? { target: target.navigation.route, action: `PR target ${target.navigation.route}`, fromScreen: null, prTarget: true, targetId: target.id }
|
|
210
|
+
: { 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 }),
|
|
211
|
+
...normalizedSeeds.filter((target) => !targetRoutes.has(target)).map((target) => ({ target, action: `PR target ${target}`, fromScreen: null, prTarget: true })),
|
|
212
|
+
];
|
|
213
|
+
const screenshotFor = new Set();
|
|
214
|
+
let actions = 0;
|
|
215
|
+
let screenCount = 0;
|
|
216
|
+
let lastScreen = null;
|
|
217
|
+
let pendingNavigation = null;
|
|
218
|
+
let loginTried = false;
|
|
219
|
+
|
|
220
|
+
const screenKey = () => {
|
|
221
|
+
const u = new URL(page.url());
|
|
222
|
+
return u.pathname.replace(/\/+$/, "") + u.search || "/";
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Read the page's state and emit OCQA_STATE — the web analog of the a11y snapshot.
|
|
226
|
+
async function observe() {
|
|
227
|
+
const info = await page.evaluate(() => {
|
|
228
|
+
const inputs = [...document.querySelectorAll("input, textarea, select")]
|
|
229
|
+
.filter((el) => el.type !== "hidden" && el.offsetParent !== null)
|
|
230
|
+
.slice(0, 12)
|
|
231
|
+
.map((el) => ({
|
|
232
|
+
label: (el.labels?.[0]?.textContent || el.placeholder || el.name || el.id || "").trim().slice(0, 60),
|
|
233
|
+
secure: el.type === "password",
|
|
234
|
+
}))
|
|
235
|
+
.filter((f) => f.label);
|
|
236
|
+
const controls = [...document.querySelectorAll("button, a[href], input, textarea, select, [role=button], [role=tab], [role=checkbox], [role=switch]")]
|
|
237
|
+
.filter((el) => el.type !== "hidden" && el.offsetParent !== null)
|
|
238
|
+
.slice(0, 60)
|
|
239
|
+
.map((el) => {
|
|
240
|
+
const tag = el.tagName.toLowerCase();
|
|
241
|
+
const role = el.getAttribute("role") || (tag === "a" ? "link" : tag === "button" ? "button" : "");
|
|
242
|
+
const field = ["input", "textarea", "select"].includes(tag);
|
|
243
|
+
const secure = el.type === "password";
|
|
244
|
+
const label = (el.labels?.[0]?.textContent || el.getAttribute("aria-label") || el.textContent || el.placeholder || el.name || el.id || "").trim().slice(0, 120);
|
|
245
|
+
return {
|
|
246
|
+
kind: field ? (secure ? "secureField" : "field") : role || "control",
|
|
247
|
+
role,
|
|
248
|
+
label,
|
|
249
|
+
id: el.id || "",
|
|
250
|
+
cssId: el.id || "",
|
|
251
|
+
testId: el.getAttribute("data-testid") || "",
|
|
252
|
+
accessibilityId: el.getAttribute("aria-label") || "",
|
|
253
|
+
secure,
|
|
254
|
+
enabled: !el.disabled && el.getAttribute("aria-disabled") !== "true",
|
|
255
|
+
hittable: true,
|
|
256
|
+
};
|
|
257
|
+
})
|
|
258
|
+
.filter((control) => control.label || control.id || control.testId);
|
|
259
|
+
return {
|
|
260
|
+
heading: document.querySelector("h1")?.textContent?.trim() || "",
|
|
261
|
+
title: document.title.trim(),
|
|
262
|
+
controlCount: document.querySelectorAll("a[href], button, [role=button], input, select, textarea").length,
|
|
263
|
+
textLen: (document.body?.innerText || "").trim().length,
|
|
264
|
+
alertText: [...document.querySelectorAll("[role=alert], [class*=error i]")]
|
|
265
|
+
.map((el) => el.textContent.trim()).filter(Boolean).join(" ").slice(0, 120),
|
|
266
|
+
inputs,
|
|
267
|
+
controls,
|
|
268
|
+
};
|
|
269
|
+
}).catch(() => null);
|
|
270
|
+
if (!info) return null;
|
|
271
|
+
|
|
272
|
+
const key = screenKey();
|
|
273
|
+
const screen = webScreenTitle(info, key);
|
|
274
|
+
const evidenceKey = `${key}::${screen}`;
|
|
275
|
+
currentScreen = screen;
|
|
276
|
+
emit("STATE", { screen, url: key, elements: info.controlCount, role: webScreenRole(screen, info.inputs), controls: info.controls, inputs: info.inputs, settled: true });
|
|
277
|
+
const completedNavigation = pendingNavigation;
|
|
278
|
+
const transitionFrom = webTransitionOrigin(completedNavigation, lastScreen);
|
|
279
|
+
const transitionAction = completedNavigation?.action || lastActionTarget;
|
|
280
|
+
if (transitionFrom && transitionFrom !== screen) emit("TRANSITION", { from: transitionFrom, to: screen, ...(transitionAction ? { action: transitionAction } : {}) });
|
|
281
|
+
pendingNavigation = null;
|
|
282
|
+
if (lastScreen !== screen) lastActionTarget = "";
|
|
283
|
+
lastScreen = screen;
|
|
284
|
+
if (completedNavigation?.prTarget) emit("PR_TARGET", { ...(completedNavigation.targetId ? { targetId: completedNavigation.targetId } : {}), route: completedNavigation.target, status: "observed", screen });
|
|
285
|
+
|
|
286
|
+
if (!screenshotFor.has(evidenceKey)) {
|
|
287
|
+
screenshotFor.add(evidenceKey);
|
|
288
|
+
screenCount = screenshotFor.size;
|
|
289
|
+
await page.screenshot({ path: path.join(outDir, `state_${screenCount}_${slug(screen)}.png`) }).catch(() => {});
|
|
290
|
+
// Deterministic per-page detectors run once per distinct screen.
|
|
291
|
+
if (info.textLen < 10) issue("blank_screen", "high", "Page rendered no visible text", screen);
|
|
292
|
+
else if (info.alertText && ERROR_TEXT_RE.test(info.alertText)) issue("error_surface", "high", `Error shown: ${info.alertText.slice(0, 80)}`, screen);
|
|
293
|
+
else if (ERROR_TEXT_RE.test(await page.evaluate(() => (document.body?.innerText || "").slice(0, 4000)).catch(() => ""))) {
|
|
294
|
+
issue("error_surface", "high", "Error text visible on page", screen);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return { key, screen, info };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Login preamble parity with iOS: if creds were given and a password field is present,
|
|
301
|
+
// fill + submit once, and flag auth_failed if we clearly bounced.
|
|
302
|
+
async function tryLogin(screen) {
|
|
303
|
+
if (loginTried || !testPassword) return null;
|
|
304
|
+
const pw = page.locator("input[type=password]").first();
|
|
305
|
+
if (!(await pw.isVisible().catch(() => false))) return null;
|
|
306
|
+
loginTried = true;
|
|
307
|
+
const emailSel = "input[type=email], input[name*=mail i], input[name*=user i], input[id*=mail i], input[id*=user i]";
|
|
308
|
+
if (testEmail) await page.locator(emailSel).first().fill(testEmail).catch(() => {});
|
|
309
|
+
await pw.fill(testPassword).catch(() => {});
|
|
310
|
+
lastActionTarget = "Sign in";
|
|
311
|
+
emit("ACTION", { type: "login", target: "Sign in", screen, narrative: "Filled and submitted the sign-in form with the provided test credentials" });
|
|
312
|
+
actions += 1;
|
|
313
|
+
await submitWebLogin(page).catch(() => false);
|
|
314
|
+
await page.waitForTimeout(CLICK_SETTLE_MS * 2);
|
|
315
|
+
// Still on the login form after a submit = the sign-in failed — full stop. (A quiet
|
|
316
|
+
// credential rejection often shows NO other symptom, so this must not be coupled to
|
|
317
|
+
// whether some other detector happened to fire during the attempt.)
|
|
318
|
+
const stillLogin = await page.locator("input[type=password]").first().isVisible().catch(() => false);
|
|
319
|
+
if (stillLogin) {
|
|
320
|
+
const errText = await page
|
|
321
|
+
.locator("[role=alert], .error, [class*=error i]")
|
|
322
|
+
.first()
|
|
323
|
+
.textContent({ timeout: 500 })
|
|
324
|
+
.catch(() => "");
|
|
325
|
+
issue(
|
|
326
|
+
"auth_failed",
|
|
327
|
+
"high",
|
|
328
|
+
`Sign-in attempt did not leave the login form${errText ? ` — “${errText.trim().slice(0, 80)}”` : ""}`,
|
|
329
|
+
currentScreen,
|
|
330
|
+
"login form"
|
|
331
|
+
);
|
|
332
|
+
} else {
|
|
333
|
+
// SPAs commonly replace the login view without changing URL. Capture the
|
|
334
|
+
// authenticated state immediately so coverage does not remain stuck at 1.
|
|
335
|
+
return await observe();
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const progress = () => {
|
|
341
|
+
emit("PROGRESS", { action: actions, max: maxActions, states: screenCount });
|
|
342
|
+
if (onProgress) try { onProgress({ action: actions, max: maxActions, states: screenCount }); } catch {}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
while (frontier.length && actions < maxActions && Date.now() < deadline) {
|
|
347
|
+
const entry = frontier.shift();
|
|
348
|
+
const target = entry.target;
|
|
349
|
+
const visitKey = entry.visitKey || target;
|
|
350
|
+
if (visited.has(visitKey)) continue;
|
|
351
|
+
visited.add(visitKey);
|
|
352
|
+
|
|
353
|
+
actions += 1;
|
|
354
|
+
lastActionTarget = webNavigationAction(entry.action, target);
|
|
355
|
+
pendingNavigation = entry.pathTarget ? { ...entry, prTarget: false } : entry;
|
|
356
|
+
emit("ACTION", { type: "open", target, via: lastActionTarget, narrative: `Opened ${target}` });
|
|
357
|
+
// Attribute load-time events (pageerror, 404s) to the page being loaded, not the one
|
|
358
|
+
// we just left; observe() refines this to the page title once it settles.
|
|
359
|
+
currentScreen = target;
|
|
360
|
+
const nav = await page.goto(start.origin + target, { waitUntil: "domcontentloaded" }).catch((err) => ({ navError: String(err.message || err) }));
|
|
361
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
362
|
+
if (nav && nav.navError) {
|
|
363
|
+
if (entry.prTarget) emit("PR_TARGET", { ...(entry.targetId ? { targetId: entry.targetId } : {}), ...(entry.pathTarget ? {} : { route: target }), status: "failed", error: nav.navError.slice(0, 160) });
|
|
364
|
+
pendingNavigation = null;
|
|
365
|
+
issue(/Timeout/i.test(nav.navError) ? "performance_timeout" : "network_error", "high", `Could not load ${target}: ${nav.navError.slice(0, 80)}`, target);
|
|
366
|
+
progress();
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (nav && typeof nav.status === "function" && nav.status() === 404) {
|
|
370
|
+
issue("broken_link", "medium", `Broken link: ${target} → 404`, target);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
let ob = await observe();
|
|
374
|
+
if (!ob) { progress(); continue; }
|
|
375
|
+
ob = await tryLogin(ob.screen) || ob;
|
|
376
|
+
|
|
377
|
+
if (entry.pathTarget) {
|
|
378
|
+
let pathError = "";
|
|
379
|
+
for (const step of entry.pathTarget.navigation.steps) {
|
|
380
|
+
if (actions >= maxActions || Date.now() >= deadline) { pathError = "Target path exceeded the exploration budget"; break; }
|
|
381
|
+
const action = step.action;
|
|
382
|
+
const beforeScreen = ob.screen;
|
|
383
|
+
actions += 1;
|
|
384
|
+
lastActionTarget = action.target;
|
|
385
|
+
emit("ACTION", { type: action.type, target: action.target, screen: beforeScreen, reason: "pr_ui_map_path", narrative: `Following observed UI Map path: ${action.type} ${action.target}` });
|
|
386
|
+
let acted = false;
|
|
387
|
+
if (action.type === "back") {
|
|
388
|
+
await page.goBack({ waitUntil: "domcontentloaded", timeout: step.wait?.timeoutMs || NAV_TIMEOUT_MS }).catch(() => {});
|
|
389
|
+
acted = true;
|
|
390
|
+
} else {
|
|
391
|
+
const selectors = [...(action.selectors || []), { kind: "label", value: action.target }];
|
|
392
|
+
for (const selector of selectors) {
|
|
393
|
+
let locator;
|
|
394
|
+
if (selector.kind === "testId") locator = page.locator(`[data-testid=${JSON.stringify(selector.value)}]`).first();
|
|
395
|
+
else if (selector.kind === "cssId") locator = page.locator(`[id=${JSON.stringify(selector.value)}]`).first();
|
|
396
|
+
else if (selector.kind === "accessibilityId") locator = page.locator(`[aria-label=${JSON.stringify(selector.value)}]`).first();
|
|
397
|
+
else if (selector.kind === "label") locator = page.getByText(selector.value, { exact: true }).first();
|
|
398
|
+
else continue;
|
|
399
|
+
if (await locator.isVisible().catch(() => false)) {
|
|
400
|
+
await locator.click({ timeout: Math.min(step.wait?.timeoutMs || NAV_TIMEOUT_MS, NAV_TIMEOUT_MS) }).catch(() => {});
|
|
401
|
+
acted = true;
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (!acted) { pathError = `Observed control was not found: ${action.target}`; break; }
|
|
407
|
+
pendingNavigation = { action: action.target, fromScreen: beforeScreen };
|
|
408
|
+
await page.waitForTimeout(CLICK_SETTLE_MS);
|
|
409
|
+
ob = await observe() || ob;
|
|
410
|
+
progress();
|
|
411
|
+
}
|
|
412
|
+
const expected = String(entry.pathTarget.node.semanticKey || "").toLowerCase();
|
|
413
|
+
const actual = slug(ob.screen);
|
|
414
|
+
if (!pathError && expected && actual !== expected) pathError = `Reached ${ob.screen}, expected ${entry.pathTarget.node.name}`;
|
|
415
|
+
if (pathError) {
|
|
416
|
+
emit("PR_TARGET", { targetId: entry.targetId, status: "failed", screen: ob.screen, error: pathError.slice(0, 160) });
|
|
417
|
+
issue("pr_target_unreachable", "high", pathError, ob.screen);
|
|
418
|
+
progress();
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
emit("PR_TARGET", { targetId: entry.targetId, status: "observed", screen: ob.screen });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Enqueue unvisited same-origin links (BFS keeps exploration order deterministic).
|
|
425
|
+
const links = await page.$$eval("a[href]", (as) => as.map((a) => ({
|
|
426
|
+
href: a.href,
|
|
427
|
+
label: (a.getAttribute("aria-label") || a.textContent || "").trim(),
|
|
428
|
+
}))).catch(() => []);
|
|
429
|
+
for (const link of links) {
|
|
430
|
+
try {
|
|
431
|
+
const u = new URL(link.href);
|
|
432
|
+
if (u.origin !== start.origin || !/^https?:$/.test(u.protocol)) continue;
|
|
433
|
+
const key = u.pathname.replace(/\/+$/, "") + u.search || "/";
|
|
434
|
+
if (!visited.has(key) && !frontier.some((item) => item.target === key)) {
|
|
435
|
+
frontier.push({ target: key, action: webNavigationAction(link.label, key), fromScreen: ob.screen });
|
|
436
|
+
}
|
|
437
|
+
} catch {}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Bounded button pass: click, watch for effect, flag dead controls (the web analog
|
|
441
|
+
// of the iOS dead-button detector). Navigations are undone so BFS order holds.
|
|
442
|
+
const buttons = page.locator("button:visible, [role=button]:visible, input[type=submit]:visible");
|
|
443
|
+
const n = Math.min(await buttons.count().catch(() => 0), BUTTONS_PER_PAGE);
|
|
444
|
+
for (let i = 0; i < n && actions < maxActions && Date.now() < deadline; i++) {
|
|
445
|
+
const b = buttons.nth(i);
|
|
446
|
+
const label = ((await b.textContent().catch(() => "")) || (await b.getAttribute("value").catch(() => "")) || "button").trim().slice(0, 40) || "button";
|
|
447
|
+
if (/log ?out|sign ?out|delete|remove/i.test(label)) continue; // don't destroy test state
|
|
448
|
+
const beforeUrl = page.url();
|
|
449
|
+
// Dead-button detection watches four real effect channels — DOM mutations, dialogs,
|
|
450
|
+
// network activity, and navigation — instead of the fragile innerHTML-length proxy
|
|
451
|
+
// (same length ≠ same page; unrelated tickers ≠ this button worked).
|
|
452
|
+
await page
|
|
453
|
+
.evaluate(() => {
|
|
454
|
+
window.__tappMut = 0;
|
|
455
|
+
if (window.__tappMo) window.__tappMo.disconnect();
|
|
456
|
+
window.__tappMo = new MutationObserver((muts) => { window.__tappMut += muts.length; });
|
|
457
|
+
window.__tappMo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
|
|
458
|
+
})
|
|
459
|
+
.catch(() => {});
|
|
460
|
+
const dialogsBefore = await page.locator("dialog[open], [role=dialog], [aria-modal=true]").count().catch(() => 0);
|
|
461
|
+
const reqBefore = requestCount;
|
|
462
|
+
actions += 1;
|
|
463
|
+
lastActionTarget = label;
|
|
464
|
+
emit("ACTION", { type: "tap", target: label, screen: webActionScreen(ob), narrative: `Tapped "${label}"` });
|
|
465
|
+
await b.click({ timeout: 3000 }).catch(() => {});
|
|
466
|
+
await page.waitForTimeout(CLICK_SETTLE_MS);
|
|
467
|
+
if (page.url() !== beforeUrl) {
|
|
468
|
+
await observe();
|
|
469
|
+
await page.goBack({ waitUntil: "domcontentloaded" }).catch(() => {});
|
|
470
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
471
|
+
} else {
|
|
472
|
+
const mutations = await page.evaluate(() => window.__tappMut || 0).catch(() => 0);
|
|
473
|
+
const dialogsAfter = await page.locator("dialog[open], [role=dialog], [aria-modal=true]").count().catch(() => 0);
|
|
474
|
+
const hadEffect = mutations > 0 || dialogsAfter !== dialogsBefore || requestCount > reqBefore;
|
|
475
|
+
if (!hadEffect) {
|
|
476
|
+
issue("unresponsive_element", "medium", `Button "${label}" does nothing`, ob.screen, label);
|
|
477
|
+
} else {
|
|
478
|
+
// Same-URL SPA transitions are real screens too; URL-only observation
|
|
479
|
+
// under-counted coverage and made healthy applications inconclusive.
|
|
480
|
+
ob = await observe() || ob;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
progress();
|
|
484
|
+
}
|
|
485
|
+
progress();
|
|
486
|
+
}
|
|
487
|
+
} finally {
|
|
488
|
+
emit("COMPLETE", { actions, screens: screenCount });
|
|
489
|
+
fs.closeSync(markersFd);
|
|
490
|
+
await browser.close().catch(() => {});
|
|
491
|
+
}
|
|
492
|
+
return { markersPath, outDir, actions, screens: screenCount, seedRoutes: normalizedSeeds, seedTargets: normalizedTargets };
|
|
493
|
+
}
|