@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.
@@ -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",
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.4",
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.4 # or pin the reviewed release commit SHA
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.4 # or pin the reviewed release commit SHA
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.4 # or pin the reviewed release commit SHA
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
@@ -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:{ width:1280, height:900 } });
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() : "",
@@ -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 (issue.type === "placeholder_link" && issue.target) return { ...issue, screen: null };
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 ? "completed" : "coverage-floor-not-met";
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: { width: 1280, height: 900 } });
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: { width: 1280, height: 900 } });
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
- emit("ACTION", { type: "login", target: "Sign in", screen, narrative: "Filled and submitted the sign-in form with the provided test credentials" });
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
- emit("ACTION", { type: "open", target, via: lastActionTarget, narrative: `Opened ${target}` });
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
- 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}` });
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 (u.origin !== start.origin || !/^https?:$/.test(u.protocol)) continue;
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
- emit("ACTION", { type: "tap", target: label, screen: webActionScreen(ob), narrative: `Tapped "${label}"` });
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
- emit("COMPLETE", { actions, screens: screenCount, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried });
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: { width: 1280, height: 900 } });
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.4",
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
  }
@@ -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({ flow, url: process.argv[3], logPath, screenshotDir });
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.