@aarwitz/tapp 0.17.4 → 0.17.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +2 -2
- package/bin/tapp.js +13 -4
- package/docs/scenarios.md +1 -1
- package/mcp-server/src/index.js +18 -6
- package/mcp-server/src/report.js +43 -5
- package/mcp-server/src/web-explorer.js +141 -17
- package/mcp-server/src/web-flow.js +3 -3
- package/package.json +2 -2
- package/scripts/run-web-flow.js +8 -1
- package/skills/tapp/references/commands.md +7 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tapp",
|
|
3
3
|
"description": "Give Claude hands and eyes on iOS, Android, and web apps, with exploration, replayable flows, evidence, and deterministic CI gates.",
|
|
4
|
-
"version": "0.17.
|
|
4
|
+
"version": "0.17.5",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Aaron Horowitz",
|
|
7
7
|
"url": "https://github.com/aarwitz"
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"command": "npx",
|
|
25
25
|
"args": [
|
|
26
26
|
"-y",
|
|
27
|
-
"@aarwitz/tapp@0.17.
|
|
27
|
+
"@aarwitz/tapp@0.17.5",
|
|
28
28
|
"mcp"
|
|
29
29
|
],
|
|
30
30
|
"cwd": "${CLAUDE_PROJECT_DIR}"
|
package/README.md
CHANGED
|
@@ -348,7 +348,7 @@ jobs:
|
|
|
348
348
|
timeout-minutes: 45
|
|
349
349
|
steps:
|
|
350
350
|
- uses: actions/checkout@v4
|
|
351
|
-
- uses: aarwitz/tapp@v0.17.
|
|
351
|
+
- uses: aarwitz/tapp@v0.17.5 # or pin the reviewed release commit SHA
|
|
352
352
|
with:
|
|
353
353
|
project: MyApp.xcodeproj # or MyApp.xcworkspace
|
|
354
354
|
scheme: MyApp
|
|
@@ -398,7 +398,7 @@ Android CI runs on Linux with an emulator/device already connected. The Action c
|
|
|
398
398
|
or accept a prebuilt one:
|
|
399
399
|
|
|
400
400
|
```yaml
|
|
401
|
-
- uses: aarwitz/tapp@v0.17.
|
|
401
|
+
- uses: aarwitz/tapp@v0.17.5 # or pin the reviewed release commit SHA
|
|
402
402
|
with:
|
|
403
403
|
platform: android
|
|
404
404
|
android-app-id: com.acme.app
|
package/bin/tapp.js
CHANGED
|
@@ -262,15 +262,15 @@ async function resolveTargetOrExit(engine, input) {
|
|
|
262
262
|
|
|
263
263
|
function safeCommandUsage(verb) {
|
|
264
264
|
const usage = {
|
|
265
|
-
explore: "tapp explore [target] [--platform ios|android|web] [--actions N] [--timeout SEC] [--email VALUE] [--password VALUE] [--baseline FILE] [--json FILE]\n Web: [--watch] opens Tapp's controlled browser and shows its actions\n iOS launch configuration: [--launch-arg VALUE ...] [--launch-env '{\"KEY\":\"VALUE\"}']\n Android: [--app-id ID] [--apk FILE] [--serial ID] [--keep-data]",
|
|
265
|
+
explore: "tapp explore [target] [--platform ios|android|web] [--actions N] [--timeout SEC] [--email VALUE] [--password VALUE] [--baseline FILE] [--json FILE]\n Web: [--watch] opens Tapp's controlled browser and shows its actions; [--device \"iPhone 13\"] [--viewport 390x844] render at a device profile or explicit size\n iOS launch configuration: [--launch-arg VALUE ...] [--launch-env '{\"KEY\":\"VALUE\"}']\n Android: [--app-id ID] [--apk FILE] [--serial ID] [--keep-data]",
|
|
266
266
|
focus: "tapp focus \"SCREEN OR CONTROL\" [target] [--platform ios|android|web] [--project-dir REPO] [--target NAME|PATH] [--map FILE] [--out FILE]",
|
|
267
267
|
init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--watch] [--dry-run]",
|
|
268
|
-
open: "tapp open [target] [--platform ios|android|web] [--out FILE] [--tap TEXT] [--wait-for TEXT]",
|
|
269
|
-
tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]",
|
|
268
|
+
open: "tapp open [target] [--platform ios|android|web] [--out FILE] [--tap TEXT] [--wait-for TEXT]\n Web: [--device \"iPhone 13\"] [--viewport 390x844] [--full-page]",
|
|
269
|
+
tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]\n Web: [--device \"iPhone 13\"] [--viewport 390x844]",
|
|
270
270
|
shot: "tapp shot [--out FILE]",
|
|
271
271
|
apps: "tapp apps",
|
|
272
272
|
build: "tapp build [repo] [--scheme NAME] [--configuration NAME]",
|
|
273
|
-
flow: "tapp flow example\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--actor NAME] [--email VALUE] [--password VALUE]",
|
|
273
|
+
flow: "tapp flow example\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--actor NAME] [--email VALUE] [--password VALUE] [--device \"iPhone 13\"] [--viewport 390x844]",
|
|
274
274
|
task: "tapp task validate FILE [--platform PLATFORM] [--map FILE]\ntapp task compile FILE --platform PLATFORM [--inputs JSON] [--out FILE]\ntapp task run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID] [--inputs JSON]",
|
|
275
275
|
contract: "tapp contract validate FILE [--platform PLATFORM] [--map FILE]\ntapp contract compile FILE --platform PLATFORM [--out FILE]\ntapp contract run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID]",
|
|
276
276
|
scenario: "tapp scenario validate FILE [--project-dir DIR]\ntapp scenario run FILE --platform web --url URL [--project-dir DIR]",
|
|
@@ -774,6 +774,8 @@ switch (command) {
|
|
|
774
774
|
testPassword: flags.password,
|
|
775
775
|
baselineFindings,
|
|
776
776
|
watch: flags.watch === true,
|
|
777
|
+
device: typeof flags.device === "string" ? flags.device : "",
|
|
778
|
+
viewport: typeof flags.viewport === "string" ? flags.viewport : "",
|
|
777
779
|
surface: "cli",
|
|
778
780
|
onProgress,
|
|
779
781
|
})
|
|
@@ -827,6 +829,9 @@ switch (command) {
|
|
|
827
829
|
timeoutMs: Number(flags.timeout) * 1000 || 15_000,
|
|
828
830
|
tapText: typeof flags.tap === "string" ? flags.tap : "",
|
|
829
831
|
waitForText: typeof flags["wait-for"] === "string" ? flags["wait-for"] : "",
|
|
832
|
+
device: typeof flags.device === "string" ? flags.device : "",
|
|
833
|
+
viewport: typeof flags.viewport === "string" ? flags.viewport : "",
|
|
834
|
+
fullPage: flags["full-page"] === true,
|
|
830
835
|
});
|
|
831
836
|
const out = typeof flags.out === "string" ? path.resolve(flags.out) : path.join(tappHome, "shots", `web-${Date.now()}.png`);
|
|
832
837
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
@@ -899,6 +904,8 @@ switch (command) {
|
|
|
899
904
|
screenshot: false,
|
|
900
905
|
tapText: typeof flags.tap === "string" ? flags.tap : "",
|
|
901
906
|
waitForText: typeof flags["wait-for"] === "string" ? flags["wait-for"] : "",
|
|
907
|
+
device: typeof flags.device === "string" ? flags.device : "",
|
|
908
|
+
viewport: typeof flags.viewport === "string" ? flags.viewport : "",
|
|
902
909
|
});
|
|
903
910
|
if (flags.json) console.log(JSON.stringify({ platform: "web", url: snap.url, screenTitle: snap.screenTitle, settled: snap.settled, elements: snap.elements }, null, 2));
|
|
904
911
|
else console.log(engine.formatScreen(snap.screenTitle, snap.elements));
|
|
@@ -1146,6 +1153,8 @@ switch (command) {
|
|
|
1146
1153
|
const env = { ...process.env, FLOW_LOG: flowLog, TAPP_FLOW_EVIDENCE_DIR: evidenceDir };
|
|
1147
1154
|
if (typeof flags.email === "string") env.OCQA_TEST_EMAIL = flags.email;
|
|
1148
1155
|
if (typeof flags.password === "string") env.OCQA_TEST_PASSWORD = flags.password;
|
|
1156
|
+
if (typeof flags.device === "string") env.TAPP_WEB_DEVICE = flags.device;
|
|
1157
|
+
if (typeof flags.viewport === "string") env.TAPP_WEB_VIEWPORT = flags.viewport;
|
|
1149
1158
|
if (typeof flags.actor === "string" && flags.actor) {
|
|
1150
1159
|
const { readProjectConfig } = await import(path.join(packageRoot, "mcp-server", "src", "project-config.js"));
|
|
1151
1160
|
const loaded = readProjectConfig(process.cwd());
|
package/docs/scenarios.md
CHANGED
|
@@ -74,7 +74,7 @@ tapp ci --platform web --url http://127.0.0.1:4180 \
|
|
|
74
74
|
GitHub Action:
|
|
75
75
|
|
|
76
76
|
```yaml
|
|
77
|
-
- uses: aarwitz/tapp@v0.17.
|
|
77
|
+
- uses: aarwitz/tapp@v0.17.5 # or pin the reviewed release commit SHA
|
|
78
78
|
with:
|
|
79
79
|
platform: web
|
|
80
80
|
url: http://127.0.0.1:4180
|
package/mcp-server/src/index.js
CHANGED
|
@@ -778,16 +778,16 @@ async function firstVisibleWebLocator(page, target, { input = false } = {}) {
|
|
|
778
778
|
return await fallback.isVisible().catch(() => false) ? fallback : null;
|
|
779
779
|
}
|
|
780
780
|
|
|
781
|
-
async function startWebSession(url, { testEmail = "", testPassword = "" } = {}) {
|
|
781
|
+
async function startWebSession(url, { testEmail = "", testPassword = "", device = "", viewport = "" } = {}) {
|
|
782
782
|
if (activeSession && !activeSession.ended) return { error: "A session is already active; call tapp_session_end first.", screen:treeSnapshot() };
|
|
783
783
|
let browser;
|
|
784
784
|
try {
|
|
785
785
|
const parsed = new URL(String(url || ""));
|
|
786
786
|
if (!/^https?:$/.test(parsed.protocol)) return { error:"Web session URL must be http(s)" };
|
|
787
|
-
const { loadPlaywright } = await import("./web-explorer.js");
|
|
788
|
-
const { chromium } = await loadPlaywright();
|
|
787
|
+
const { loadPlaywright, webContextOptions } = await import("./web-explorer.js");
|
|
788
|
+
const { chromium, devices } = await loadPlaywright();
|
|
789
789
|
browser = await chromium.launch({ headless:true });
|
|
790
|
-
const context = await browser.newContext({ viewport
|
|
790
|
+
const context = await browser.newContext(webContextOptions({ device, viewport, devices }));
|
|
791
791
|
const page = await context.newPage();
|
|
792
792
|
await page.goto(parsed.href, { waitUntil:"domcontentloaded", timeout:30_000 });
|
|
793
793
|
await page.waitForLoadState("networkidle", { timeout:5_000 }).catch(() => {});
|
|
@@ -1754,7 +1754,7 @@ export function formatScreen(screenTitle, elements) {
|
|
|
1754
1754
|
// `tapp` CLI verbs in bin/tapp.js — same pattern as report.js. Keep orchestration HERE so
|
|
1755
1755
|
// the surfaces can't drift.)
|
|
1756
1756
|
|
|
1757
|
-
export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], watch = false, surface = "mcp", onProgress = () => {} }) {
|
|
1757
|
+
export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], watch = false, surface = "mcp", onProgress = () => {}, device = "", viewport = "" }) {
|
|
1758
1758
|
const { storagePreflight } = await import("./environment-preflight.js");
|
|
1759
1759
|
const storage = storagePreflight(capturesDir);
|
|
1760
1760
|
if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
|
|
@@ -1776,6 +1776,8 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
|
|
|
1776
1776
|
seedTargets,
|
|
1777
1777
|
watch: watch === true,
|
|
1778
1778
|
onProgress,
|
|
1779
|
+
device: isNonEmptyString(device) ? device.trim() : "",
|
|
1780
|
+
viewport: isNonEmptyString(viewport) ? viewport.trim() : "",
|
|
1779
1781
|
});
|
|
1780
1782
|
} catch (err) {
|
|
1781
1783
|
return { error: String(err.message || err) };
|
|
@@ -2602,6 +2604,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2602
2604
|
clearData: { type: "boolean", default: true, description: "Android: clear app data before launch for a repeatable starting state." },
|
|
2603
2605
|
url: { type: "string", description: "Web (beta): URL of the app to explore in a real browser (same-origin only; your own app/staging). Provide exactly one of appBundleId | url." },
|
|
2604
2606
|
watch: { type: "boolean", default: false, description: "Web only: open Tapp's controlled Chromium window and show a cursor/HUD for each exploration action. Evidence screenshots exclude the overlay." },
|
|
2607
|
+
device: { type: "string", description: "Web only: render as a Playwright device profile (e.g. \"iPhone 13\") — viewport, user agent, touch" },
|
|
2608
|
+
viewport: { type: "string", description: "Web only: explicit viewport WIDTHxHEIGHT (e.g. \"390x844\"); overrides the device profile's viewport" },
|
|
2605
2609
|
maxActions: { type: "integer", minimum: 1, maximum: 1000, default: 60, description: "Exploration action budget" },
|
|
2606
2610
|
timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600, description: "Max wall-clock seconds" },
|
|
2607
2611
|
testEmail: { type: "string", description: "Email for the login preamble, if the app has a sign-in" },
|
|
@@ -2890,6 +2894,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2890
2894
|
testEmail: { type: "string", description: "Value for $TEST_EMAIL" },
|
|
2891
2895
|
testPassword: { type: "string", description: "Value for $TEST_PASSWORD" },
|
|
2892
2896
|
actor: { type: "string", description: "Configured actor name; resolves $TEST_EMAIL/$TEST_PASSWORD from the actor's env-var bindings (explicit testEmail/testPassword win)" },
|
|
2897
|
+
device: { type: "string", description: "Web flows: replay in a Playwright device profile (e.g. \"iPhone 13\")" },
|
|
2898
|
+
viewport: { type: "string", description: "Web flows: explicit viewport WIDTHxHEIGHT (e.g. \"390x844\")" },
|
|
2893
2899
|
},
|
|
2894
2900
|
},
|
|
2895
2901
|
},
|
|
@@ -3078,6 +3084,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
3078
3084
|
appBundleId: { type: "string", description: "Bundle id of the installed app to drive" },
|
|
3079
3085
|
androidAppId: { type: "string", description: "Android application id to drive (alternative to appBundleId)" },
|
|
3080
3086
|
url: { type: "string", description: "Owned http(s) web app URL to drive (alternative to appBundleId/androidAppId); omit all three target identifiers to build/start an unambiguous owned web target from projectDir" },
|
|
3087
|
+
device: { type: "string", description: "Web sessions: render as a Playwright device profile (e.g. \"iPhone 13\")" },
|
|
3088
|
+
viewport: { type: "string", description: "Web sessions: explicit viewport WIDTHxHEIGHT (e.g. \"390x844\")" },
|
|
3081
3089
|
target: { type: "string", description: "Optional managed-web target name/path when projectDir contains multiple browser applications" },
|
|
3082
3090
|
apkPath: { type: "string", description: "Android APK to install before starting" },
|
|
3083
3091
|
androidSerial: { type: "string", description: "Android adb device serial" },
|
|
@@ -3444,6 +3452,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3444
3452
|
testPassword: args.testPassword,
|
|
3445
3453
|
baselineFindings: args.baselineFindings,
|
|
3446
3454
|
watch: args.watch === true,
|
|
3455
|
+
device: isNonEmptyString(args.device) ? args.device.trim() : "",
|
|
3456
|
+
viewport: isNonEmptyString(args.viewport) ? args.viewport.trim() : "",
|
|
3447
3457
|
onProgress: notifyProgress("pages reached"),
|
|
3448
3458
|
});
|
|
3449
3459
|
if (r.error) return errorResult(r.error, r.details || {});
|
|
@@ -3983,6 +3993,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3983
3993
|
url: isNonEmptyString(args.url) ? args.url.trim() : undefined,
|
|
3984
3994
|
logPath: flowLog,
|
|
3985
3995
|
screenshotDir: evidenceDir,
|
|
3996
|
+
device: isNonEmptyString(args.device) ? args.device.trim() : "",
|
|
3997
|
+
viewport: isNonEmptyString(args.viewport) ? args.viewport.trim() : "",
|
|
3986
3998
|
});
|
|
3987
3999
|
run = { ...run, code: result.passed ? 0 : 1, evidenceDir };
|
|
3988
4000
|
} catch (error) {
|
|
@@ -4334,7 +4346,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4334
4346
|
: android
|
|
4335
4347
|
? await startAndroidSession(target, { serial: args.androidSerial, apkPath: args.apkPath, clearData: args.clearData !== false, testEmail: args.testEmail, testPassword: args.testPassword })
|
|
4336
4348
|
: web
|
|
4337
|
-
? await startWebSession(target, { testEmail:args.testEmail, testPassword:args.testPassword })
|
|
4349
|
+
? await startWebSession(target, { testEmail:args.testEmail, testPassword:args.testPassword, device:isNonEmptyString(args.device) ? args.device.trim() : "", viewport:isNonEmptyString(args.viewport) ? args.viewport.trim() : "" })
|
|
4338
4350
|
: await startManagedWebInteractiveSession({
|
|
4339
4351
|
projectDir:focusProjectDir,
|
|
4340
4352
|
requestedTarget:isNonEmptyString(args.target) ? args.target.trim() : "",
|
package/mcp-server/src/report.js
CHANGED
|
@@ -72,6 +72,7 @@ export function parseOcqaMarkers(markersFilePath) {
|
|
|
72
72
|
)
|
|
73
73
|
),
|
|
74
74
|
complete,
|
|
75
|
+
actions,
|
|
75
76
|
recentActions: actions.slice(-5),
|
|
76
77
|
recentTransitions: transitions.slice(-5),
|
|
77
78
|
recentIssues: issues.slice(-5),
|
|
@@ -87,6 +88,10 @@ export const ISSUE_CATEGORY = {
|
|
|
87
88
|
error_surface: "network_error_surface",
|
|
88
89
|
unresponsive_element: "unresponsive_element",
|
|
89
90
|
placeholder_link: "broken_link",
|
|
91
|
+
anchor_missing: "broken_link",
|
|
92
|
+
unresolvable_host: "broken_link",
|
|
93
|
+
mailto_no_mx: "broken_link",
|
|
94
|
+
outbound_unavailable: "broken_link",
|
|
90
95
|
dead_end: "navigation_dead_end",
|
|
91
96
|
navigation_loop: "repeated_loop",
|
|
92
97
|
navigation_trap: "navigation_dead_end",
|
|
@@ -96,7 +101,7 @@ export const ISSUE_CATEGORY = {
|
|
|
96
101
|
explore_timeout: "performance_timeout",
|
|
97
102
|
};
|
|
98
103
|
export const CRITICAL_ISSUE_TYPES = new Set(["crash"]);
|
|
99
|
-
export const WEB_SAMPLED_ISSUE_TYPES = new Set(["unresponsive_element"]);
|
|
104
|
+
export const WEB_SAMPLED_ISSUE_TYPES = new Set(["unresponsive_element", "outbound_unavailable"]);
|
|
100
105
|
|
|
101
106
|
export function severityRank(s) {
|
|
102
107
|
return { critical: 0, high: 1, medium: 2, low: 3 }[s] ?? 4;
|
|
@@ -206,7 +211,9 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
206
211
|
// keep the concrete missing-asset finding and discard that transport-level duplicate.
|
|
207
212
|
const normalizedIssues = rawIssues.map((issue) => {
|
|
208
213
|
if (platform !== "web") return issue;
|
|
209
|
-
if (
|
|
214
|
+
if (["placeholder_link", "anchor_missing", "unresolvable_host", "mailto_no_mx", "outbound_unavailable"].includes(issue.type) && issue.target) {
|
|
215
|
+
return { ...issue, screen: null };
|
|
216
|
+
}
|
|
210
217
|
if (!["missing_asset", "network_error"].includes(issue.type)) return issue;
|
|
211
218
|
const title = String(issue.title || "");
|
|
212
219
|
const match = issue.type === "missing_asset"
|
|
@@ -279,9 +286,16 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
279
286
|
: screensExplored >= 2 && actionsPerformed >= 3;
|
|
280
287
|
const unexercisedLoginWall = anySecure && !loginAttempted && screensExplored <= 1;
|
|
281
288
|
const inconclusive = !coverageFloorMet || unexercisedLoginWall || timeBudgetExhausted;
|
|
289
|
+
// "completed" is reserved for a run that exhausted its action budget; a drained frontier is
|
|
290
|
+
// reported as its own honest reason so a 2-action sweep of a small surface never reads like a
|
|
291
|
+
// 40-action campaign. Drivers signal the real cause in COMPLETE.stop; captures from drivers
|
|
292
|
+
// that predate the field keep the old inference.
|
|
293
|
+
const driverStop = base.complete && typeof base.complete === "object" ? base.complete.stop : null;
|
|
282
294
|
const stopReason = unexercisedLoginWall ? (credentialsProvided ? "login-wall-credentials-unused" : "login-wall-no-credentials")
|
|
283
295
|
: timeBudgetExhausted ? "time-budget-exhausted"
|
|
284
|
-
: coverageFloorMet ? "
|
|
296
|
+
: !coverageFloorMet ? "coverage-floor-not-met"
|
|
297
|
+
: driverStop === "frontier-drained" ? "no-unexplored-in-scope-controls"
|
|
298
|
+
: "completed";
|
|
285
299
|
|
|
286
300
|
const headline = timeBudgetExhausted
|
|
287
301
|
? `Inconclusive — exploration reached its ${base.complete?.timeoutSeconds || "configured"}s time budget after ${actionsPerformed} action(s) across ${screensExplored} screen(s). Findings are partial; this is not an app performance finding. Increase --timeout or request fewer actions.`
|
|
@@ -301,10 +315,17 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
301
315
|
let checkedFor;
|
|
302
316
|
let notChecked;
|
|
303
317
|
if (platform === "web") {
|
|
318
|
+
const outbound = base.complete && typeof base.complete === "object" ? base.complete.outbound : null;
|
|
304
319
|
checkedFor = [
|
|
305
|
-
"page errors (uncaught exceptions)", "failed/5xx requests", "broken links (404)",
|
|
306
|
-
"placeholder links with no destination", "sampled dead-button probes (advisory)", "error text on pages", "load timeouts",
|
|
320
|
+
"page errors (uncaught exceptions)", "failed/5xx requests", "broken links (404, same-origin crawl)",
|
|
321
|
+
"placeholder links and anchors with no destination", "sampled dead-button probes (advisory)", "error text on pages", "load timeouts",
|
|
307
322
|
];
|
|
323
|
+
if (outbound && !outbound.skipped && (outbound.total || outbound.mailtos)) {
|
|
324
|
+
checkedFor.push(outbound.total > outbound.checked
|
|
325
|
+
? `outbound link reachability — DNS · HTTP · unavailable-shell heuristic (first ${outbound.checked} of ${outbound.total})`
|
|
326
|
+
: "outbound link reachability (DNS · HTTP · unavailable-shell heuristic)");
|
|
327
|
+
if (outbound.mailtos) checkedFor.push("mailto address domains (MX/A records)");
|
|
328
|
+
}
|
|
308
329
|
notChecked = [
|
|
309
330
|
"app-specific business logic (cover with Flows: record or generate, then assert)",
|
|
310
331
|
"content and claim accuracy (including copy versus API data)",
|
|
@@ -314,6 +335,11 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
314
335
|
"only the first few visible buttons per page are probed (web beta)",
|
|
315
336
|
"content & reachability regressions require a baseline",
|
|
316
337
|
];
|
|
338
|
+
if (outbound?.skipped === "egress-policy") notChecked.push("outbound links and mailto domains (skipped by the public-egress policy)");
|
|
339
|
+
else if (!outbound || (!outbound.total && !outbound.mailtos)) conditionsNotReached.push("outbound links (none encountered this run)");
|
|
340
|
+
if (inputFieldsEncountered.length && !loginAttempted) {
|
|
341
|
+
notChecked.push(`form submission (${inputFieldsEncountered.reduce((n, s) => n + s.fields.length, 0)} field(s) catalogued, none submitted)`);
|
|
342
|
+
}
|
|
317
343
|
} else if (platform === "android") {
|
|
318
344
|
checkedFor = [
|
|
319
345
|
"crashes / process exits", "dead controls", "error surfaces", "blank screens",
|
|
@@ -361,6 +387,18 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
361
387
|
headline,
|
|
362
388
|
inconclusive,
|
|
363
389
|
coverage: { screensExplored, actionsPerformed, screens: Array.from(screens) },
|
|
390
|
+
// Per-action evidence: what was done, to what, on which screen, at what offset — so an
|
|
391
|
+
// agent can distinguish "clicked Services, clicked FAQ" from "clicked the same button twice".
|
|
392
|
+
trace: (base.actions || [])
|
|
393
|
+
.filter((action) => action && typeof action === "object")
|
|
394
|
+
.map((action) => ({
|
|
395
|
+
...(typeof action.t === "number" ? { t: action.t } : {}),
|
|
396
|
+
type: action.type || "action",
|
|
397
|
+
target: action.target || "",
|
|
398
|
+
...(action.screen ? { screen: action.screen } : {}),
|
|
399
|
+
...(action.via ? { via: action.via } : {}),
|
|
400
|
+
...(action.reason ? { reason: action.reason } : {}),
|
|
401
|
+
})),
|
|
364
402
|
evidence: { markers: base.relativeMarkersFilePath },
|
|
365
403
|
uiMap: null,
|
|
366
404
|
comparison: null,
|
|
@@ -23,6 +23,7 @@ import { execFileSync } from "child_process";
|
|
|
23
23
|
const CLICK_SETTLE_MS = 700;
|
|
24
24
|
const NAV_TIMEOUT_MS = 15_000;
|
|
25
25
|
const BUTTONS_PER_PAGE = 4;
|
|
26
|
+
const OUTBOUND_LINK_LIMIT = 10;
|
|
26
27
|
const WATCH_ACTION_DELAY_MS = 350;
|
|
27
28
|
const ERROR_TEXT_RE = /\b(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)\b/i;
|
|
28
29
|
const STANDALONE_ERROR_TEXT_RE = /^(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)(?:[.!:]|\s|$)/i;
|
|
@@ -299,6 +300,42 @@ export function webBrowserLaunchOptions(environment = process.env, { watch = fal
|
|
|
299
300
|
};
|
|
300
301
|
}
|
|
301
302
|
|
|
303
|
+
export function parseWebViewport(value) {
|
|
304
|
+
if (!value) return null;
|
|
305
|
+
const match = /^(\d{2,5})[xX](\d{2,5})$/.exec(String(value).trim());
|
|
306
|
+
if (!match) throw new Error(`Invalid viewport '${value}'; expected WIDTHxHEIGHT, e.g. 390x844`);
|
|
307
|
+
return { width: Number(match[1]), height: Number(match[2]) };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// The one seam for every web browser context: an optional Playwright device profile (viewport,
|
|
311
|
+
// user agent, touch, scale factor) with an explicit WIDTHxHEIGHT override on top; the historical
|
|
312
|
+
// 1280×900 desktop default otherwise. `defaultBrowserType` is stripped because newContext
|
|
313
|
+
// rejects it — we always drive the profile through chromium.
|
|
314
|
+
export function webContextOptions({ device = "", viewport = "", devices = null } = {}) {
|
|
315
|
+
const name = String(device || "").trim();
|
|
316
|
+
let base = {};
|
|
317
|
+
if (name) {
|
|
318
|
+
const profile = devices ? devices[name] : null;
|
|
319
|
+
if (!profile) {
|
|
320
|
+
const head = name.toLowerCase().split(" ")[0];
|
|
321
|
+
const close = devices ? Object.keys(devices).filter((d) => d.toLowerCase().includes(head)).slice(0, 5) : [];
|
|
322
|
+
throw new Error(`Unknown Playwright device '${name}'${close.length ? `; close matches: ${close.join(", ")}` : ""}`);
|
|
323
|
+
}
|
|
324
|
+
const { defaultBrowserType: _ignored, ...rest } = profile;
|
|
325
|
+
base = rest;
|
|
326
|
+
}
|
|
327
|
+
const parsed = parseWebViewport(viewport);
|
|
328
|
+
return { ...base, viewport: parsed || base.viewport || { width: 1280, height: 900 } };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Conservative "the page answered 200 but is an unavailable shell" phrases (outbound links to
|
|
332
|
+
// social platforms that never 404). Precision over recall: only unmistakable copy matches.
|
|
333
|
+
export function webUnavailableShellPhrase(html) {
|
|
334
|
+
const text = String(html || "").slice(0, 120_000);
|
|
335
|
+
const match = /(this content isn'?t available|content (?:is )?(?:currently )?unavailable|this page isn'?t available|page (?:can'?t|cannot) be found|page not found|isn'?t available right now)/i.exec(text);
|
|
336
|
+
return match ? match[1] : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
302
339
|
// A headed Playwright browser does not move the host OS pointer when locator.click() runs. In
|
|
303
340
|
// explicit watch mode, draw a pointer inside the controlled page so a human can follow Tapp's
|
|
304
341
|
// real actions. The UI lives in a closed shadow root, ignores pointer events, and is hidden from
|
|
@@ -398,16 +435,16 @@ async function screenshotWithoutWebWatchUi(page, options, watch) {
|
|
|
398
435
|
// Focused one-screen inspection for the agent-facing `tapp open <url>` and `tapp tree <url>`
|
|
399
436
|
// commands. This deliberately does no exploration or judgment; it opens exactly one page,
|
|
400
437
|
// captures the visible semantic controls, and optionally takes one screenshot.
|
|
401
|
-
export async function inspectWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, screenshot = true, tapText = "", waitForText = "" }) {
|
|
438
|
+
export async function inspectWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, screenshot = true, tapText = "", waitForText = "", device = "", viewport = "", fullPage = false }) {
|
|
402
439
|
let target;
|
|
403
440
|
try { target = new URL(url); }
|
|
404
441
|
catch { throw new Error("Web inspection needs a valid http(s) URL"); }
|
|
405
442
|
if (!/^https?:$/.test(target.protocol)) throw new Error("Web inspection needs a valid http(s) URL");
|
|
406
443
|
|
|
407
|
-
const { chromium } = await loadPlaywright();
|
|
444
|
+
const { chromium, devices } = await loadPlaywright();
|
|
408
445
|
const browser = await chromium.launch(webBrowserLaunchOptions());
|
|
409
446
|
try {
|
|
410
|
-
const context = await browser.newContext({ viewport
|
|
447
|
+
const context = await browser.newContext(webContextOptions({ device, viewport, devices }));
|
|
411
448
|
const page = await context.newPage();
|
|
412
449
|
const boundedTimeout = Math.max(1000, Math.min(60_000, Number(timeoutMs) || NAV_TIMEOUT_MS));
|
|
413
450
|
page.setDefaultTimeout(boundedTimeout);
|
|
@@ -455,7 +492,7 @@ export async function inspectWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, screensh
|
|
|
455
492
|
controls,
|
|
456
493
|
};
|
|
457
494
|
});
|
|
458
|
-
const image = screenshot ? await page.screenshot({ type: "png" }) : null;
|
|
495
|
+
const image = screenshot ? await page.screenshot({ type: "png", fullPage: !!fullPage }) : null;
|
|
459
496
|
return {
|
|
460
497
|
url: page.url(),
|
|
461
498
|
screenTitle: webScreenTitle(observed, target.pathname || target.href),
|
|
@@ -524,17 +561,19 @@ export function normalizeWebSeedTargets(seedTargets = [], limit = 5) {
|
|
|
524
561
|
return result;
|
|
525
562
|
}
|
|
526
563
|
|
|
527
|
-
export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", seedRoutes = [], seedTargets = [], watch = false, onProgress }) {
|
|
564
|
+
export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", seedRoutes = [], seedTargets = [], watch = false, onProgress, device = "", viewport = "" }) {
|
|
528
565
|
const start = new URL(url);
|
|
529
566
|
if (!/^https?:$/.test(start.protocol)) throw new Error("url must be http(s)");
|
|
530
567
|
fs.mkdirSync(outDir, { recursive: true });
|
|
531
568
|
const markersPath = path.join(outDir, "ocqa-markers.txt");
|
|
532
569
|
const markersFd = fs.openSync(markersPath, "w");
|
|
533
570
|
const emit = (kind, payload) => fs.writeSync(markersFd, `OCQA_${kind}:${JSON.stringify(payload)}\n`);
|
|
571
|
+
const startedAtMs = Date.now();
|
|
572
|
+
const emitAction = (payload) => emit("ACTION", { t: Date.now() - startedAtMs, ...payload });
|
|
534
573
|
|
|
535
|
-
const { chromium } = await loadPlaywright();
|
|
574
|
+
const { chromium, devices } = await loadPlaywright();
|
|
536
575
|
const browser = await chromium.launch(webBrowserLaunchOptions(process.env, { watch }));
|
|
537
|
-
const context = await browser.newContext({ viewport
|
|
576
|
+
const context = await browser.newContext(webContextOptions({ device, viewport, devices }));
|
|
538
577
|
await installWebListenerTracking(context);
|
|
539
578
|
if (watch) await installWebWatchUi(context);
|
|
540
579
|
const page = await context.newPage();
|
|
@@ -585,6 +624,10 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
585
624
|
];
|
|
586
625
|
const screenshotFor = new Map();
|
|
587
626
|
const placeholderLinksSeen = new Set();
|
|
627
|
+
const missingAnchorsSeen = new Set();
|
|
628
|
+
const outboundLinks = new Map(); // href → { label, screen }
|
|
629
|
+
const mailtoLinks = new Map(); // address → { screen }
|
|
630
|
+
const outbound = { total: 0, checked: 0, mailtos: 0 };
|
|
588
631
|
let actions = 0;
|
|
589
632
|
let screenCount = 0;
|
|
590
633
|
let lastScreen = null;
|
|
@@ -607,12 +650,12 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
607
650
|
secure: el.type === "password",
|
|
608
651
|
}))
|
|
609
652
|
.filter((f) => f.label);
|
|
610
|
-
const controls = [...document.querySelectorAll("button, a[href], input, textarea, select, [role=button], [role=tab], [role=checkbox], [role=switch]")]
|
|
653
|
+
const controls = [...document.querySelectorAll("button, a[href], input, textarea, select, summary, [role=button], [role=tab], [role=checkbox], [role=switch]")]
|
|
611
654
|
.filter((el) => el.type !== "hidden" && el.offsetParent !== null)
|
|
612
655
|
.slice(0, 60)
|
|
613
656
|
.map((el) => {
|
|
614
657
|
const tag = el.tagName.toLowerCase();
|
|
615
|
-
const role = el.getAttribute("role") || (tag === "a" ? "link" : tag === "button" ? "button" : "");
|
|
658
|
+
const role = el.getAttribute("role") || (tag === "a" ? "link" : tag === "button" || tag === "summary" ? "button" : "");
|
|
616
659
|
const field = ["input", "textarea", "select"].includes(tag);
|
|
617
660
|
const secure = el.type === "password";
|
|
618
661
|
const label = (el.labels?.[0]?.textContent || el.getAttribute("aria-label") || el.textContent || el.placeholder || el.name || el.id || "").trim().slice(0, 120);
|
|
@@ -674,6 +717,18 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
674
717
|
fingerprint: el.id || el.getAttribute("data-testid") || svgPath.slice(0, 80) || `link-${index + 1}`,
|
|
675
718
|
};
|
|
676
719
|
}),
|
|
720
|
+
missingAnchors: [...document.querySelectorAll('a[href^="#"]')]
|
|
721
|
+
.filter((el) => el.offsetParent !== null)
|
|
722
|
+
.map((el) => el.getAttribute("href") || "")
|
|
723
|
+
.filter((href) => href.length > 1)
|
|
724
|
+
.filter((href) => {
|
|
725
|
+
const id = decodeURIComponent(href.slice(1));
|
|
726
|
+
try {
|
|
727
|
+
const esc = window.CSS && CSS.escape ? CSS.escape(id) : id;
|
|
728
|
+
return !document.getElementById(id) && !document.querySelector(`a[name="${esc}"]`);
|
|
729
|
+
} catch { return !document.getElementById(id); }
|
|
730
|
+
})
|
|
731
|
+
.slice(0, 20),
|
|
677
732
|
inputs,
|
|
678
733
|
controls,
|
|
679
734
|
};
|
|
@@ -717,6 +772,13 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
717
772
|
placeholderLinksSeen.add(finding.target);
|
|
718
773
|
issue(finding.type, finding.severity, finding.title, screen, finding.target);
|
|
719
774
|
}
|
|
775
|
+
// Anchor links pointing at ids that do not exist are deterministic dead navigation.
|
|
776
|
+
for (const anchor of info.missingAnchors || []) {
|
|
777
|
+
const anchorTarget = `${key}${anchor}`;
|
|
778
|
+
if (missingAnchorsSeen.has(anchorTarget)) continue;
|
|
779
|
+
missingAnchorsSeen.add(anchorTarget);
|
|
780
|
+
issue("anchor_missing", "medium", `Anchor link "${anchor}" has no matching element on the page`, screen, anchorTarget);
|
|
781
|
+
}
|
|
720
782
|
}
|
|
721
783
|
return { key, screen, info };
|
|
722
784
|
}
|
|
@@ -737,7 +799,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
737
799
|
if (watch) await showWebWatchAction(page, { locator: pw, action: "Type", target: "Password" });
|
|
738
800
|
await pw.fill(testPassword).catch(() => {});
|
|
739
801
|
lastActionTarget = "Sign in";
|
|
740
|
-
|
|
802
|
+
emitAction({ type: "login", target: "Sign in", screen, narrative: "Filled and submitted the sign-in form with the provided test credentials" });
|
|
741
803
|
actions += 1;
|
|
742
804
|
await submitWebLogin(page, watch
|
|
743
805
|
? (locator) => showWebWatchAction(page, { locator, action: "Click", target: "Sign in" })
|
|
@@ -784,7 +846,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
784
846
|
actions += 1;
|
|
785
847
|
lastActionTarget = webNavigationAction(entry.action, target);
|
|
786
848
|
pendingNavigation = entry.pathTarget ? { ...entry, prTarget: false } : entry;
|
|
787
|
-
|
|
849
|
+
emitAction({ type: "open", target, via: lastActionTarget, narrative: `Opened ${target}` });
|
|
788
850
|
// Attribute load-time events (pageerror, 404s) to the page being loaded, not the one
|
|
789
851
|
// we just left; observe() refines this to the page title once it settles.
|
|
790
852
|
currentScreen = target;
|
|
@@ -814,7 +876,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
814
876
|
const beforeScreen = ob.screen;
|
|
815
877
|
actions += 1;
|
|
816
878
|
lastActionTarget = action.target;
|
|
817
|
-
|
|
879
|
+
emitAction({ type: action.type, target: action.target, screen: beforeScreen, reason: "pr_ui_map_path", narrative: `Following observed UI Map path: ${action.type} ${action.target}` });
|
|
818
880
|
let acted = false;
|
|
819
881
|
if (action.type === "back") {
|
|
820
882
|
if (watch) await showWebWatchAction(page, { action: "Back", target: beforeScreen });
|
|
@@ -857,15 +919,27 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
857
919
|
emit("PR_TARGET", { targetId: entry.targetId, status: "observed", screen: ob.screen });
|
|
858
920
|
}
|
|
859
921
|
|
|
860
|
-
// Enqueue unvisited same-origin links (BFS keeps exploration order deterministic)
|
|
922
|
+
// Enqueue unvisited same-origin links (BFS keeps exploration order deterministic);
|
|
923
|
+
// collect outbound http(s) links and mailto addresses for the post-crawl audit.
|
|
861
924
|
const links = await page.$$eval("a[href]", (as) => as.map((a) => ({
|
|
862
925
|
href: a.href,
|
|
926
|
+
raw: a.getAttribute("href") || "",
|
|
863
927
|
label: (a.getAttribute("aria-label") || a.textContent || "").trim(),
|
|
864
928
|
}))).catch(() => []);
|
|
865
929
|
for (const link of links) {
|
|
866
930
|
try {
|
|
931
|
+
if (/^mailto:/i.test(link.raw)) {
|
|
932
|
+
const address = link.raw.replace(/^mailto:/i, "").split("?")[0].trim();
|
|
933
|
+
if (address.includes("@") && !mailtoLinks.has(address)) mailtoLinks.set(address, { screen: ob.screen });
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
867
936
|
const u = new URL(link.href);
|
|
868
|
-
if (
|
|
937
|
+
if (!/^https?:$/.test(u.protocol)) continue;
|
|
938
|
+
if (u.origin !== start.origin) {
|
|
939
|
+
const clean = u.origin + u.pathname;
|
|
940
|
+
if (!outboundLinks.has(clean)) outboundLinks.set(clean, { label: link.label, screen: ob.screen });
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
869
943
|
const key = u.pathname.replace(/\/+$/, "") + u.search || "/";
|
|
870
944
|
if (!visited.has(key) && !frontier.some((item) => item.target === key)) {
|
|
871
945
|
frontier.push({ target: key, action: webNavigationAction(link.label, key), fromScreen: ob.screen });
|
|
@@ -875,7 +949,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
875
949
|
|
|
876
950
|
// Bounded button pass: click, watch for effect, flag dead controls (the web analog
|
|
877
951
|
// of the iOS dead-button detector). Navigations are undone so BFS order holds.
|
|
878
|
-
const buttons = page.locator("button:visible, [role=button]:visible, input[type=submit]:visible");
|
|
952
|
+
const buttons = page.locator("button:visible, [role=button]:visible, input[type=submit]:visible, summary:visible");
|
|
879
953
|
const n = Math.min(await buttons.count().catch(() => 0), BUTTONS_PER_PAGE);
|
|
880
954
|
for (let i = 0; i < n && actions < maxActions && Date.now() < deadline; i++) {
|
|
881
955
|
const b = buttons.nth(i);
|
|
@@ -893,7 +967,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
893
967
|
const beforeState = await captureWebControlState(page, b);
|
|
894
968
|
actions += 1;
|
|
895
969
|
lastActionTarget = label;
|
|
896
|
-
|
|
970
|
+
emitAction({ type: "tap", target: label, screen: webActionScreen(ob), narrative: `Tapped "${label}"` });
|
|
897
971
|
let clickSucceeded = false;
|
|
898
972
|
try {
|
|
899
973
|
if (watch) await showWebWatchAction(page, { locator: b, action: "Click", target: label });
|
|
@@ -925,8 +999,58 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
925
999
|
}
|
|
926
1000
|
progress();
|
|
927
1001
|
}
|
|
1002
|
+
|
|
1003
|
+
// Post-crawl outbound audit: DNS + bounded HTTP for external links, MX for mailto.
|
|
1004
|
+
// These calls leave the proxied browser, so the whole pass is skipped (and reported as
|
|
1005
|
+
// not-checked) when the public-egress policy is enforced.
|
|
1006
|
+
outbound.total = outboundLinks.size;
|
|
1007
|
+
outbound.mailtos = mailtoLinks.size;
|
|
1008
|
+
if (process.env.TAPP_ENFORCE_PUBLIC_EGRESS === "1") {
|
|
1009
|
+
outbound.skipped = "egress-policy";
|
|
1010
|
+
} else if (outboundLinks.size || mailtoLinks.size) {
|
|
1011
|
+
const dns = await import("node:dns/promises");
|
|
1012
|
+
const hostResolvable = new Map();
|
|
1013
|
+
const resolves = async (host) => {
|
|
1014
|
+
if (!hostResolvable.has(host)) {
|
|
1015
|
+
hostResolvable.set(host, await dns.lookup(host).then(() => true).catch(() => false));
|
|
1016
|
+
}
|
|
1017
|
+
return hostResolvable.get(host);
|
|
1018
|
+
};
|
|
1019
|
+
const targets = [...outboundLinks.entries()].slice(0, OUTBOUND_LINK_LIMIT);
|
|
1020
|
+
outbound.checked = targets.length;
|
|
1021
|
+
for (const [href, meta] of targets) {
|
|
1022
|
+
if (Date.now() >= deadline) break;
|
|
1023
|
+
const u = new URL(href);
|
|
1024
|
+
if (!(await resolves(u.hostname))) {
|
|
1025
|
+
issue("unresolvable_host", "medium", `Outbound link host does not resolve: ${u.hostname}`, meta.screen, href);
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
try {
|
|
1029
|
+
const res = await fetch(href, { redirect: "follow", signal: AbortSignal.timeout(6000), headers: { "user-agent": "Mozilla/5.0 (compatible; tapp-link-audit)" } });
|
|
1030
|
+
if (res.status >= 400) {
|
|
1031
|
+
issue("broken_link", "medium", `Outbound link returns HTTP ${res.status}: ${href.slice(0, 100)}`, meta.screen, href);
|
|
1032
|
+
} else {
|
|
1033
|
+
const phrase = webUnavailableShellPhrase(await res.text().catch(() => ""));
|
|
1034
|
+
if (phrase) issue("outbound_unavailable", "low", `Outbound link returns 200 but shows "${phrase}": ${href.slice(0, 100)}`, meta.screen, href);
|
|
1035
|
+
}
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
issue("unresolvable_host", "medium", `Outbound link unreachable: ${href.slice(0, 100)}`, meta.screen, href);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
for (const [address, meta] of mailtoLinks) {
|
|
1041
|
+
if (Date.now() >= deadline) break;
|
|
1042
|
+
const domain = (address.split("@")[1] || "").toLowerCase();
|
|
1043
|
+
if (!domain) continue;
|
|
1044
|
+
// RFC 5321 implicit-MX: a domain with no MX but an A/AAAA record can still receive.
|
|
1045
|
+
const deliverable = await dns.resolveMx(domain).then((records) => records.length > 0).catch(() => false)
|
|
1046
|
+
|| await dns.lookup(domain).then(() => true).catch(() => false);
|
|
1047
|
+
if (!deliverable) issue("mailto_no_mx", "medium", `mailto: domain cannot receive email (no MX or address record): ${address}`, meta.screen, address);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
928
1050
|
} finally {
|
|
929
|
-
|
|
1051
|
+
const timedOut = Date.now() >= deadline;
|
|
1052
|
+
const stop = timedOut ? "time-budget" : actions >= maxActions ? "action-budget" : "frontier-drained";
|
|
1053
|
+
emit("COMPLETE", { actions, screens: screenCount, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried, timedOut, stop, outbound });
|
|
930
1054
|
fs.closeSync(markersFd);
|
|
931
1055
|
await browser.close().catch(() => {});
|
|
932
1056
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { FlowLog, flowVariables, normalizeFlowStep, substituteFlowValue } from "./flow-runtime.js";
|
|
6
|
-
import { loadPlaywright } from "./web-explorer.js";
|
|
6
|
+
import { loadPlaywright, webContextOptions } from "./web-explorer.js";
|
|
7
7
|
|
|
8
8
|
const DEFAULT_TIMEOUT = 6000;
|
|
9
9
|
|
|
@@ -192,7 +192,7 @@ export async function executeWebFlowStep({ page, step, vars = {}, defaultTimeout
|
|
|
192
192
|
return { action, target: action === "login" ? "sign-in form" : target || value, status, detail, task: raw.task };
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
export async function runWebFlow({ flow, url, logPath, screenshotDir, playwright }) {
|
|
195
|
+
export async function runWebFlow({ flow, url, logPath, screenshotDir, playwright, device = "", viewport = "" }) {
|
|
196
196
|
const startUrl = url || flow.url || (/^https?:\/\//i.test(flow.app || "") ? flow.app : "");
|
|
197
197
|
if (!startUrl) throw new Error("Web Flow needs `url:` (or an http(s) `app:` value)");
|
|
198
198
|
if (logPath) fs.rmSync(logPath, { force: true });
|
|
@@ -202,7 +202,7 @@ export async function runWebFlow({ flow, url, logPath, screenshotDir, playwright
|
|
|
202
202
|
const log = new FlowLog({ logPath, flow: loggedFlow });
|
|
203
203
|
const pw = playwright || await loadPlaywright();
|
|
204
204
|
const browser = await pw.chromium.launch({ headless: true });
|
|
205
|
-
const context = await browser.newContext({ viewport
|
|
205
|
+
const context = await browser.newContext(webContextOptions({ device, viewport, devices: pw.devices }));
|
|
206
206
|
const page = await context.newPage();
|
|
207
207
|
const timeout = Number(flow.timeoutMs) || DEFAULT_TIMEOUT;
|
|
208
208
|
page.setDefaultTimeout(timeout);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aarwitz/tapp",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.5",
|
|
4
4
|
"mcpName": "io.github.aarwitz/tapp",
|
|
5
5
|
"description": "Let coding agents verify UI changes on real iOS, Android, and web surfaces, then enforce reviewed proof in deterministic CI.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
"mobile"
|
|
90
90
|
],
|
|
91
91
|
"scripts": {
|
|
92
|
-
"test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/focused-navigation.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/presentation-contract.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js vscode-extension/test/bridge.test.js",
|
|
92
|
+
"test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/focused-navigation.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-link-audit.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/presentation-contract.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js vscode-extension/test/bridge.test.js",
|
|
93
93
|
"test:browser-journey": "node --test tests/browser-journey.test.js",
|
|
94
94
|
"test:browser-native": "TAPP_RUN_NATIVE_BROWSER=1 node --test tests/browser-native-journey.test.js"
|
|
95
95
|
}
|
package/scripts/run-web-flow.js
CHANGED
|
@@ -18,7 +18,14 @@ const logPath = process.env.FLOW_LOG || path.join(os.tmpdir(), `tapp-web-flow-${
|
|
|
18
18
|
const screenshotDir = process.env.TAPP_FLOW_EVIDENCE_DIR || path.join(os.tmpdir(), `tapp-web-flow-${token}`);
|
|
19
19
|
|
|
20
20
|
try {
|
|
21
|
-
const result = await runWebFlow({
|
|
21
|
+
const result = await runWebFlow({
|
|
22
|
+
flow,
|
|
23
|
+
url: process.argv[3],
|
|
24
|
+
logPath,
|
|
25
|
+
screenshotDir,
|
|
26
|
+
device: process.env.TAPP_WEB_DEVICE || "",
|
|
27
|
+
viewport: process.env.TAPP_WEB_VIEWPORT || "",
|
|
28
|
+
});
|
|
22
29
|
const report = spawnSync("python3", [path.join(root, "scripts", "flow_lib.py"), "report", logPath], { encoding: "utf8" });
|
|
23
30
|
process.stdout.write((report.stdout || "").trim() + "\n");
|
|
24
31
|
process.exit(result.passed ? 0 : 1);
|
|
@@ -104,6 +104,13 @@ npx -y @aarwitz/tapp@latest flow run .tapp/flows/smoke.yml
|
|
|
104
104
|
npx -y @aarwitz/tapp@latest ci
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
Web commands (`open`, `tree`, `explore`, `flow run`) accept `--device "iPhone 13"` (any
|
|
108
|
+
Playwright device profile) or `--viewport 390x844` to render at mobile sizes, and `open`
|
|
109
|
+
accepts `--full-page` for a full-height screenshot — use these for "does it look right on a
|
|
110
|
+
phone" checks. Web explores also audit outbound links (DNS, HTTP status, unavailable-shell
|
|
111
|
+
heuristic) and `mailto:` domains, report an honest `stopReason` (`no-unexplored-in-scope-controls`
|
|
112
|
+
when a small surface is swept before the budget), and return a per-action `trace` in `--json`.
|
|
113
|
+
|
|
107
114
|
`tapp explore` observes. `tapp ci` applies versioned deterministic policy to evidence, selected
|
|
108
115
|
Flows/Scenarios/contracts, coverage, and any target-scoped baseline. Its outcomes are `pass`, `fail`,
|
|
109
116
|
or `inconclusive`; both `fail` and `inconclusive` block a merge.
|