@aarwitz/tapp 0.17.0-rc.8 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -410,7 +410,7 @@ export function adoptPrCoverageProposal({ projectDir, prPlanPath, item, releaseP
410
410
  if (!prPlanPath || !fs.existsSync(source)) throw new Error(`PR plan not found: ${source || "(missing path)"}`);
411
411
  const targetPath = releasePlanPath === ".tapp/release-plan.json" ? existingProjectArtifactPath(root, "release-plan.json") : path.resolve(root, releasePlanPath);
412
412
  if (!inside(root, targetPath)) throw new Error("Release plan path must stay inside the project directory");
413
- if (!fs.existsSync(targetPath)) throw new Error(`Release plan not found: ${targetPath}; run tapp init first`);
413
+ if (!fs.existsSync(targetPath)) throw new Error(`Release plan not found: ${targetPath}; run npx -y @aarwitz/tapp@latest init first`);
414
414
  const prPlan = JSON.parse(fs.readFileSync(source, "utf8"));
415
415
  if (prPlan?.schemaVersion !== 1 || !Array.isArray(prPlan.explorationTargets)) throw new Error("PR plan must be an executed Tapp PR plan v1");
416
416
  const matches = prPlan.explorationTargets.filter((target) => target.id === item);
@@ -428,7 +428,7 @@ export function adoptPrCoverageProposal({ projectDir, prPlanPath, item, releaseP
428
428
  if (!ground || !fs.existsSync(mapPath)) throw new Error("Coverage proposal requires the repository's persistent UI Map");
429
429
  const map = JSON.parse(fs.readFileSync(mapPath, "utf8"));
430
430
  const node = (map.nodes || []).find((candidate) => candidate.id === ground.id && candidate.status !== "proposed");
431
- const refreshAdvice = "refresh the persistent UI Map with `tapp init --explore --refresh`, rerun `tapp pr gate`, then retry `tapp pr adopt`";
431
+ const refreshAdvice = "refresh the persistent UI Map with `npx -y @aarwitz/tapp@latest init --explore --refresh`, rerun `npx -y @aarwitz/tapp@latest pr gate`, then retry `npx -y @aarwitz/tapp@latest pr adopt`";
432
432
  if (!node) throw new Error(`Coverage proposal UI Map node is stale or missing: ${ground.id}; ${refreshAdvice}`);
433
433
  if (target.navigation?.route && !(node.routes || []).some((route) => route.platform === target.platform && route.path === target.navigation.route && route.replayable === true)) {
434
434
  throw new Error(`Coverage proposal route is stale in the persistent UI Map: ${target.navigation.route}; ${refreshAdvice}`);
@@ -64,6 +64,90 @@ function artifactPaths(root, outDir = ".tapp") {
64
64
  };
65
65
  }
66
66
 
67
+ function normalizedInitTarget(root, target) {
68
+ const requested = String(target || "").trim();
69
+ if (!requested) return "";
70
+ let absolute;
71
+ try { absolute = fs.realpathSync(path.resolve(root, requested)); }
72
+ catch { absolute = path.resolve(root, requested); }
73
+ if (absolute === root) return "";
74
+ if (inside(root, absolute)) return path.relative(root, absolute).replaceAll(path.sep, "/");
75
+ return requested;
76
+ }
77
+
78
+ function initTargetChoices(model, platform = "") {
79
+ const candidates = (model.targets || []).filter((target) => !platform || target.platform === platform);
80
+ return candidates.map((target) => ({
81
+ target,
82
+ command: `npx -y @aarwitz/tapp@latest init . --explore --platform ${target.platform} --target ${target.sourcePath === "." ? JSON.stringify(target.name) : JSON.stringify(target.sourcePath)}`,
83
+ }));
84
+ }
85
+
86
+ function selectInitExplorationTarget(model, {
87
+ root,
88
+ platform = "",
89
+ target = "",
90
+ appId = "",
91
+ } = {}) {
92
+ const selectedPlatform = String(platform || "").trim().toLowerCase();
93
+ if (selectedPlatform && !["ios", "android", "web"].includes(selectedPlatform)) throw new Error("platform must be ios|android|web");
94
+ let requested = normalizedInitTarget(root, target);
95
+ if (!requested && appId) {
96
+ const androidMatch = (model.targets || []).find((candidate) => candidate.platform === "android" && candidate.runtime?.applicationId === appId);
97
+ if (androidMatch) requested = androidMatch.id;
98
+ }
99
+ try {
100
+ return selectApplicationTarget(model, {
101
+ platform: selectedPlatform,
102
+ target: requested,
103
+ // `init --explore` is the explicit onboarding/refresh operation: it asks whenever several
104
+ // targets are plausible. Only a later bare `tapp explore` consumes the recorded default.
105
+ useDefault: false,
106
+ });
107
+ } catch (error) {
108
+ const choices = initTargetChoices(model, selectedPlatform);
109
+ if (choices.length > 1 && !requested) {
110
+ const selection = new Error(
111
+ `Multiple application targets were detected; Tapp will not guess which one you mean:\n` +
112
+ choices.map(({ target: choice }) => ` - ${choice.platform}:${choice.name} (${choice.sourcePath})`).join("\n") +
113
+ `\nRerun with one of:\n` + choices.map(({ command }) => ` ${command}`).join("\n")
114
+ );
115
+ selection.code = "TAPP_TARGET_SELECTION_REQUIRED";
116
+ selection.details = {
117
+ reason: "target-selection-required",
118
+ choices: choices.map(({ target: choice, command }) => ({
119
+ id: choice.id,
120
+ platform: choice.platform,
121
+ name: choice.name,
122
+ sourcePath: choice.sourcePath,
123
+ selector: choice.sourcePath === "." ? choice.name : choice.sourcePath,
124
+ command,
125
+ })),
126
+ };
127
+ throw selection;
128
+ }
129
+ throw error;
130
+ }
131
+ }
132
+
133
+ export function scopeProductRequirements(model, { selectedTargetId = "" } = {}) {
134
+ const targets = Array.isArray(model?.targets) ? model.targets : [];
135
+ const requirements = Array.isArray(model?.requirements) ? model.requirements : [];
136
+ const selected = String(selectedTargetId || "").trim();
137
+ const enriched = requirements.map((requirement) => {
138
+ const target = targets.find((candidate) => requirement.targetId === candidate.id || String(requirement.id || "").startsWith(`${candidate.id}:`));
139
+ return target
140
+ ? { ...requirement, targetId: target.id, targetPlatform: target.platform, targetName: target.name }
141
+ : { ...requirement };
142
+ });
143
+ if (!selected) return { selectedTargetId: "", active: enriched, deferred: [] };
144
+ return {
145
+ selectedTargetId: selected,
146
+ active: enriched.filter((requirement) => !requirement.targetId || requirement.targetId === selected),
147
+ deferred: enriched.filter((requirement) => requirement.targetId && requirement.targetId !== selected),
148
+ };
149
+ }
150
+
67
151
  function productRunRoot(root) {
68
152
  const home = process.env.TAPP_HOME || path.join(os.homedir(), ".tapp");
69
153
  const identity = crypto.createHash("sha256").update(root).digest("hex").slice(0, 16);
@@ -279,6 +363,7 @@ export async function initializeProductProject({
279
363
  maxContracts = 15,
280
364
  testEmail,
281
365
  testPassword,
366
+ watch = false,
282
367
  runExploration,
283
368
  onProgress = () => {},
284
369
  onStatus = () => {},
@@ -287,22 +372,41 @@ export async function initializeProductProject({
287
372
  if (!["inspect", "write", "refresh", "explore"].includes(mode)) throw new Error("mode must be inspect|write|refresh|explore");
288
373
  if (!Number.isInteger(Number(maxContracts)) || Number(maxContracts) < 1 || Number(maxContracts) > 50) throw new Error("maxContracts must be between 1 and 50");
289
374
  const paths = artifactPaths(root, outDir);
375
+ const priorModel = readJson(paths.model);
290
376
  let exploration = null;
377
+ let selectedTarget = null;
291
378
  if (mode === "explore") {
292
379
  if (typeof runExploration !== "function") throw new Error("The selected adapter did not provide a platform exploration capability");
293
- const selectedPlatform = String(platform || (ownedUrl ? "web" : appId || apkPath ? "android" : "ios")).toLowerCase();
380
+ const selectedPlatform = String(platform || (ownedUrl ? "web" : appId || apkPath ? "android" : "")).toLowerCase();
381
+ const inspected = await buildInitArtifacts({
382
+ projectDir: root,
383
+ ownedUrl,
384
+ outDir,
385
+ maxContracts: Number(maxContracts),
386
+ defaultTargetId: priorModel?.application?.defaultTargetId || "",
387
+ });
388
+ selectedTarget = selectInitExplorationTarget(inspected.model, {
389
+ root,
390
+ platform: selectedPlatform,
391
+ target,
392
+ appId,
393
+ });
394
+ const sourceTarget = selectedTarget.sourcePath === "." ? root : path.resolve(root, selectedTarget.sourcePath);
294
395
  exploration = await runExploration({
295
- projectDir: root, platform: selectedPlatform, outDir, url: ownedUrl, target: target || root,
296
- bundleId, appId, apkPath, serial, scheme, configuration, maxActions: Number(maxActions), timeout: Number(timeout),
297
- testEmail, testPassword, onProgress, onStatus,
396
+ projectDir: root, platform: selectedTarget.platform, outDir, url: ownedUrl, target: sourceTarget,
397
+ bundleId, appId: appId || selectedTarget.runtime?.applicationId || "", apkPath, serial, scheme, configuration, maxActions: Number(maxActions), timeout: Number(timeout),
398
+ testEmail, testPassword, watch, onProgress: (progress) => onProgress({ ...progress, platform: selectedTarget.platform }), onStatus,
298
399
  });
299
400
  if (exploration?.error) throw Object.assign(new Error(exploration.error), { details: exploration.details || {} });
300
401
  }
301
402
  const built = await buildInitArtifacts({
302
403
  projectDir: root,
303
404
  ownedUrl: ownedUrl || (exploration?.platform === "web" && !exploration.managedRuntime ? exploration.target : ""),
304
- platform: String(platform || "").toLowerCase(),
405
+ // Exploration chooses one runnable surface, but the repository model must retain every detected
406
+ // application target. Otherwise selecting web would silently erase the native app (and vice versa).
407
+ platform: mode === "explore" ? "" : String(platform || "").toLowerCase(),
305
408
  targetValidation: exploration?.targetValidation || null,
409
+ defaultTargetId: selectedTarget?.id || priorModel?.application?.defaultTargetId || "",
306
410
  outDir,
307
411
  maxContracts: Number(maxContracts),
308
412
  });
@@ -317,7 +421,8 @@ export async function initializeProductProject({
317
421
  invalidateValidation: mode === "explore",
318
422
  });
319
423
  }
320
- return { operation: "initialize", mode, model: built.model, plan: written?.plan || built.plan, exploration, written, project: readProductProject({ projectDir: root, outDir }) };
424
+ const requirementScope = scopeProductRequirements(built.model, { selectedTargetId: selectedTarget?.id || "" });
425
+ return { operation: "initialize", mode, model: built.model, plan: written?.plan || built.plan, exploration, selectedTarget, requirementScope, written, project: readProductProject({ projectDir: root, outDir }) };
321
426
  }
322
427
 
323
428
  function resolvePlan(root, outDir, planPath = "") {
@@ -146,6 +146,7 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
146
146
  const screenElementCounts = {}; // screen -> max elements observed (content-collapse detection)
147
147
  let anySecure = false;
148
148
  let loginAttempted = false;
149
+ let credentialsProvided = base.complete?.credentialsProvided === true;
149
150
  let actions = 0;
150
151
 
151
152
  for (const line of raw.split(/\r?\n/)) {
@@ -158,7 +159,8 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
158
159
  // `target` gives a finding its identity beyond type|screen — two dead buttons on the
159
160
  // same screen are two findings, and fixing one while breaking another is a regression.
160
161
  const target = (typeof o.control === "string" && o.control) || (typeof o.target === "string" && o.target) || null;
161
- rawIssues.push({ type: o.type, severity: sev, title: o.title, screen: o.screen || null, target, step: o.step ?? null });
162
+ const url = typeof o.url === "string" && o.url.trim() ? o.url.trim() : null;
163
+ rawIssues.push({ type: o.type, severity: sev, title: o.title, screen: o.screen || null, target, url, step: o.step ?? null });
162
164
  } catch {
163
165
  /* ignore malformed */
164
166
  }
@@ -175,6 +177,8 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
175
177
  t === "OCQA_STATE:login_preamble_two_step_submitted"
176
178
  ) {
177
179
  loginAttempted = true;
180
+ } else if (t === "OCQA_STATE:credentials_supplied") {
181
+ credentialsProvided = true;
178
182
  } else if (t.startsWith("OCQA_STATE:{")) {
179
183
  try {
180
184
  const s = JSON.parse(t.slice("OCQA_STATE:".length));
@@ -273,9 +277,9 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
273
277
  const coverageFloorMet = platform === "web"
274
278
  ? screensExplored >= 1 && actionsPerformed >= 1
275
279
  : screensExplored >= 2 && actionsPerformed >= 3;
276
- const credentiallessLoginWall = anySecure && !loginAttempted && screensExplored <= 1;
277
- const inconclusive = !coverageFloorMet || credentiallessLoginWall || timeBudgetExhausted;
278
- const stopReason = credentiallessLoginWall ? "login-wall-no-credentials"
280
+ const unexercisedLoginWall = anySecure && !loginAttempted && screensExplored <= 1;
281
+ const inconclusive = !coverageFloorMet || unexercisedLoginWall || timeBudgetExhausted;
282
+ const stopReason = unexercisedLoginWall ? (credentialsProvided ? "login-wall-credentials-unused" : "login-wall-no-credentials")
279
283
  : timeBudgetExhausted ? "time-budget-exhausted"
280
284
  : coverageFloorMet ? "completed" : "coverage-floor-not-met";
281
285
 
@@ -338,7 +342,9 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
338
342
  // "Failed sign-ins" is only a claim when credentials were actually submitted. Merely seeing a
339
343
  // password field proves that a login surface was reached, not that authentication was exercised.
340
344
  if (loginAttempted) checkedFor.splice(2, 0, "failed sign-ins");
345
+ else if (anySecure && credentialsProvided) notChecked.push("sign-in behavior (test credentials were supplied, but no sign-in attempt was observed)");
341
346
  else if (anySecure) notChecked.push("sign-in behavior (login form reached, no test credentials supplied)");
347
+ else if (credentialsProvided) conditionsNotReached.push("sign-in (test credentials were supplied but no login form was encountered, so they were not used)");
342
348
  else conditionsNotReached.push("sign-in (no login form encountered this run)");
343
349
  if (timeBudgetExhausted) notChecked.push("the full requested action budget (run reached its wall-clock timeout)");
344
350
 
@@ -385,6 +391,13 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
385
391
  screenElementCounts,
386
392
  inputFieldsEncountered,
387
393
  loginEncountered: anySecure,
394
+ credentialsProvided,
395
+ credentialsUsed: loginAttempted,
396
+ credentialWarning: credentialsProvided && !loginAttempted
397
+ ? anySecure
398
+ ? "Test credentials were supplied, but this run did not submit the login form. Authentication was not exercised."
399
+ : "Test credentials were supplied, but this run did not encounter a login form. They were not used."
400
+ : null,
388
401
  complete: base.complete,
389
402
  relativeMarkersFilePath: base.relativeMarkersFilePath,
390
403
  };
@@ -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 WATCH_ACTION_DELAY_MS = 350;
26
27
  const ERROR_TEXT_RE = /\b(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)\b/i;
27
28
  const STANDALONE_ERROR_TEXT_RE = /^(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)(?:[.!:]|\s|$)/i;
28
29
 
@@ -249,13 +250,14 @@ export async function loadPlaywright() {
249
250
  );
250
251
  }
251
252
 
252
- export async function submitWebLogin(page) {
253
+ export async function submitWebLogin(page, beforeClick = null) {
253
254
  const candidates = [
254
255
  page.locator("button[type=submit], input[type=submit], form button").first(),
255
256
  page.getByRole("button", { name: /sign ?in|log ?in|continue/i }).first(),
256
257
  ];
257
258
  for (const candidate of candidates) {
258
259
  if (await candidate.isVisible().catch(() => false)) {
260
+ if (beforeClick) await beforeClick(candidate);
259
261
  await candidate.click({ timeout: 3000 });
260
262
  return true;
261
263
  }
@@ -279,13 +281,14 @@ export function webTransitionOrigin(pendingNavigation, currentScreen) {
279
281
  return pendingNavigation?.fromScreen || currentScreen || null;
280
282
  }
281
283
 
282
- export function webBrowserLaunchOptions(environment = process.env) {
284
+ export function webBrowserLaunchOptions(environment = process.env, { watch = false } = {}) {
283
285
  const browserProxy = String(environment.TAPP_BROWSER_PROXY_SERVER || "").trim();
284
286
  if (environment.TAPP_ENFORCE_PUBLIC_EGRESS === "1" && !/^http:\/\/127\.0\.0\.1:\d+$/.test(browserProxy)) {
285
287
  throw new Error("public egress policy proxy is required");
286
288
  }
287
289
  return {
288
- headless: true,
290
+ headless: !watch,
291
+ ...(watch ? { slowMo: 200 } : {}),
289
292
  ...(browserProxy ? { proxy: { server: browserProxy, bypass: "<-loopback>" } } : {}),
290
293
  args: browserProxy ? [
291
294
  "--disable-quic",
@@ -296,6 +299,102 @@ export function webBrowserLaunchOptions(environment = process.env) {
296
299
  };
297
300
  }
298
301
 
302
+ // A headed Playwright browser does not move the host OS pointer when locator.click() runs. In
303
+ // explicit watch mode, draw a pointer inside the controlled page so a human can follow Tapp's
304
+ // real actions. The UI lives in a closed shadow root, ignores pointer events, and is hidden from
305
+ // evidence screenshots; it therefore cannot become an app control or alter detector input.
306
+ async function installWebWatchUi(context) {
307
+ await context.addInitScript(() => {
308
+ const stateKey = Symbol.for("tapp.watchUi");
309
+ const ensure = () => {
310
+ if (window[stateKey]?.host?.isConnected) return window[stateKey];
311
+ const host = document.createElement("div");
312
+ host.setAttribute("data-tapp-watch-ui", "");
313
+ host.setAttribute("aria-hidden", "true");
314
+ Object.assign(host.style, {
315
+ position: "fixed",
316
+ inset: "0",
317
+ zIndex: "2147483647",
318
+ pointerEvents: "none",
319
+ });
320
+ const shadow = host.attachShadow({ mode: "closed" });
321
+ const style = document.createElement("style");
322
+ style.textContent = `
323
+ .cursor { position: fixed; left: 24px; top: 72px; width: 22px; height: 28px;
324
+ filter: drop-shadow(0 2px 2px rgba(0,0,0,.45)); transition: left 260ms ease, top 260ms ease;
325
+ transform: rotate(-8deg); }
326
+ .cursor::before { content: ""; display: block; width: 100%; height: 100%; background: #111827;
327
+ clip-path: polygon(0 0, 0 88%, 25% 67%, 39% 100%, 53% 93%, 39% 61%, 70% 61%); }
328
+ .cursor::after { content: ""; position: absolute; inset: 2px 3px 4px 2px; background: white;
329
+ clip-path: polygon(0 0, 0 79%, 25% 59%, 40% 91%, 46% 88%, 32% 55%, 60% 55%); }
330
+ .hud { position: fixed; top: 14px; right: 14px; max-width: min(420px, calc(100vw - 28px));
331
+ box-sizing: border-box; padding: 9px 12px; border-radius: 10px; color: white;
332
+ background: rgba(17,24,39,.92); box-shadow: 0 5px 18px rgba(0,0,0,.24);
333
+ font: 600 13px/1.35 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
334
+ .brand { color: #93c5fd; margin-right: 6px; }
335
+ `;
336
+ const cursor = document.createElement("div");
337
+ cursor.className = "cursor";
338
+ const hud = document.createElement("div");
339
+ hud.className = "hud";
340
+ shadow.append(style, cursor, hud);
341
+ (document.documentElement || document).appendChild(host);
342
+ const state = { host, cursor, hud };
343
+ Object.defineProperty(window, stateKey, { value: state, configurable: true });
344
+ return state;
345
+ };
346
+ Object.defineProperty(window, "__tappShowWatchAction", {
347
+ configurable: true,
348
+ value: ({ x, y, action, target }) => {
349
+ const state = ensure();
350
+ state.host.style.display = "block";
351
+ if (Number.isFinite(x) && Number.isFinite(y)) {
352
+ state.cursor.style.left = `${Math.max(4, Math.min(window.innerWidth - 26, x))}px`;
353
+ state.cursor.style.top = `${Math.max(4, Math.min(window.innerHeight - 32, y))}px`;
354
+ }
355
+ state.hud.replaceChildren();
356
+ const brand = document.createElement("span");
357
+ brand.className = "brand";
358
+ brand.textContent = "Tapp";
359
+ state.hud.append(brand, document.createTextNode(`${action}${target ? ` · ${target}` : ""}`));
360
+ },
361
+ });
362
+ Object.defineProperty(window, "__tappSetWatchUiVisible", {
363
+ configurable: true,
364
+ value: (visible) => {
365
+ if (window[stateKey]?.host) window[stateKey].host.style.display = visible ? "block" : "none";
366
+ },
367
+ });
368
+ });
369
+ }
370
+
371
+ async function showWebWatchAction(page, { locator = null, action = "Exploring", target = "" } = {}) {
372
+ let x = 28;
373
+ let y = 76;
374
+ if (locator) {
375
+ await locator.scrollIntoViewIfNeeded().catch(() => {});
376
+ const box = await locator.boundingBox().catch(() => null);
377
+ if (box) {
378
+ x = box.x + box.width / 2;
379
+ y = box.y + box.height / 2;
380
+ }
381
+ }
382
+ await page.evaluate(({ x, y, action, target }) => {
383
+ window.__tappShowWatchAction?.({ x, y, action, target });
384
+ }, { x, y, action, target }).catch(() => {});
385
+ await page.waitForTimeout(WATCH_ACTION_DELAY_MS).catch(() => {});
386
+ }
387
+
388
+ async function screenshotWithoutWebWatchUi(page, options, watch) {
389
+ if (!watch) return page.screenshot(options);
390
+ await page.evaluate(() => window.__tappSetWatchUiVisible?.(false)).catch(() => {});
391
+ try {
392
+ return await page.screenshot(options);
393
+ } finally {
394
+ await page.evaluate(() => window.__tappSetWatchUiVisible?.(true)).catch(() => {});
395
+ }
396
+ }
397
+
299
398
  // Focused one-screen inspection for the agent-facing `tapp open <url>` and `tapp tree <url>`
300
399
  // commands. This deliberately does no exploration or judgment; it opens exactly one page,
301
400
  // captures the visible semantic controls, and optionally takes one screenshot.
@@ -425,7 +524,7 @@ export function normalizeWebSeedTargets(seedTargets = [], limit = 5) {
425
524
  return result;
426
525
  }
427
526
 
428
- export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", seedRoutes = [], seedTargets = [], onProgress }) {
527
+ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", seedRoutes = [], seedTargets = [], watch = false, onProgress }) {
429
528
  const start = new URL(url);
430
529
  if (!/^https?:$/.test(start.protocol)) throw new Error("url must be http(s)");
431
530
  fs.mkdirSync(outDir, { recursive: true });
@@ -434,9 +533,10 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
434
533
  const emit = (kind, payload) => fs.writeSync(markersFd, `OCQA_${kind}:${JSON.stringify(payload)}\n`);
435
534
 
436
535
  const { chromium } = await loadPlaywright();
437
- const browser = await chromium.launch(webBrowserLaunchOptions());
536
+ const browser = await chromium.launch(webBrowserLaunchOptions(process.env, { watch }));
438
537
  const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
439
538
  await installWebListenerTracking(context);
539
+ if (watch) await installWebWatchUi(context);
440
540
  const page = await context.newPage();
441
541
  page.setDefaultTimeout(NAV_TIMEOUT_MS);
442
542
 
@@ -444,7 +544,8 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
444
544
  const issues = []; // emitted immediately; kept for counting only
445
545
  const issue = (type, severity, title, screen, target) => {
446
546
  issues.push(type);
447
- emit("ISSUE", { type, severity, title, screen, ...(target ? { target } : {}) });
547
+ const pageUrl = page.url();
548
+ emit("ISSUE", { type, severity, title, screen, ...(target ? { target } : {}), ...(pageUrl && pageUrl !== "about:blank" ? { url: pageUrl } : {}) });
448
549
  };
449
550
 
450
551
  // Async defect listeners: attribute to whatever screen is current when they fire.
@@ -604,7 +705,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
604
705
  if (existingKey && existingKey !== evidenceKey) screenshotFor.delete(existingKey);
605
706
  screenshotFor.set(evidenceKey, { path: screenshotPath, busy: info.busy, route: key });
606
707
  screenCount = screenshotFor.size;
607
- await page.screenshot({ path: screenshotPath }).catch(() => {});
708
+ await screenshotWithoutWebWatchUi(page, { path: screenshotPath }, watch).catch(() => {});
608
709
  // Deterministic per-page detectors run once per distinct screen.
609
710
  if (webPageAppearsBlank(info)) issue("blank_screen", "high", "Page rendered no visible content", screen);
610
711
  else {
@@ -628,12 +729,19 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
628
729
  if (!(await pw.isVisible().catch(() => false))) return null;
629
730
  loginTried = true;
630
731
  const emailSel = "input[type=email], input[name*=mail i], input[name*=user i], input[id*=mail i], input[id*=user i]";
631
- if (testEmail) await page.locator(emailSel).first().fill(testEmail).catch(() => {});
732
+ if (testEmail) {
733
+ const email = page.locator(emailSel).first();
734
+ if (watch) await showWebWatchAction(page, { locator: email, action: "Type", target: "Email" });
735
+ await email.fill(testEmail).catch(() => {});
736
+ }
737
+ if (watch) await showWebWatchAction(page, { locator: pw, action: "Type", target: "Password" });
632
738
  await pw.fill(testPassword).catch(() => {});
633
739
  lastActionTarget = "Sign in";
634
740
  emit("ACTION", { type: "login", target: "Sign in", screen, narrative: "Filled and submitted the sign-in form with the provided test credentials" });
635
741
  actions += 1;
636
- await submitWebLogin(page).catch(() => false);
742
+ await submitWebLogin(page, watch
743
+ ? (locator) => showWebWatchAction(page, { locator, action: "Click", target: "Sign in" })
744
+ : null).catch(() => false);
637
745
  await waitForWebStability(page, { timeoutMs: Math.min(5_000, CLICK_SETTLE_MS * 6) });
638
746
  // Still on the login form after a submit = the sign-in failed — full stop. (A quiet
639
747
  // credential rejection often shows NO other symptom, so this must not be coupled to
@@ -688,6 +796,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
688
796
  progress();
689
797
  continue;
690
798
  }
799
+ if (watch) await showWebWatchAction(page, { action: "Open", target });
691
800
  await waitForWebStability(page);
692
801
  if (nav && typeof nav.status === "function" && nav.status() === 404) {
693
802
  issue("broken_link", "medium", `Broken link: ${target} → 404`, target);
@@ -708,6 +817,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
708
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}` });
709
818
  let acted = false;
710
819
  if (action.type === "back") {
820
+ if (watch) await showWebWatchAction(page, { action: "Back", target: beforeScreen });
711
821
  await page.goBack({ waitUntil: "domcontentloaded", timeout: step.wait?.timeoutMs || NAV_TIMEOUT_MS }).catch(() => {});
712
822
  acted = true;
713
823
  } else {
@@ -721,6 +831,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
721
831
  else continue;
722
832
  if (await locator.isVisible().catch(() => false)) {
723
833
  try {
834
+ if (watch) await showWebWatchAction(page, { locator, action: "Click", target: action.target });
724
835
  await locator.click({ timeout: Math.min(step.wait?.timeoutMs || NAV_TIMEOUT_MS, NAV_TIMEOUT_MS) });
725
836
  acted = true;
726
837
  break;
@@ -785,6 +896,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
785
896
  emit("ACTION", { type: "tap", target: label, screen: webActionScreen(ob), narrative: `Tapped "${label}"` });
786
897
  let clickSucceeded = false;
787
898
  try {
899
+ if (watch) await showWebWatchAction(page, { locator: b, action: "Click", target: label });
788
900
  await b.click({ timeout: 3000 });
789
901
  clickSucceeded = true;
790
902
  } catch {}
@@ -795,6 +907,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
795
907
  await waitForWebStability(page);
796
908
  if (page.url() !== beforeState.url) {
797
909
  await observe();
910
+ if (watch) await showWebWatchAction(page, { action: "Back", target: ob.screen });
798
911
  await page.goBack({ waitUntil: "domcontentloaded" }).catch(() => {});
799
912
  await waitForWebStability(page);
800
913
  } else {
@@ -813,7 +926,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
813
926
  progress();
814
927
  }
815
928
  } finally {
816
- emit("COMPLETE", { actions, screens: screenCount });
929
+ emit("COMPLETE", { actions, screens: screenCount, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried });
817
930
  fs.closeSync(markersFd);
818
931
  await browser.close().catch(() => {});
819
932
  }
@@ -132,6 +132,22 @@ export async function executeWebFlowStep({ page, step, vars = {}, defaultTimeout
132
132
  const el = await locateWebElement(page, target);
133
133
  if (!el) throw new Error(`no field ‘${target}’ to type into`);
134
134
  await el.fill(value);
135
+ } else if (action === "login") {
136
+ const emailValue = substituteFlowValue(raw.params.email || "$TEST_EMAIL", vars);
137
+ const passwordValue = substituteFlowValue(raw.params.password || "$TEST_PASSWORD", vars);
138
+ const email = await firstVisible([
139
+ page.locator("input[type=email]"), page.locator("input[name*=mail i]"),
140
+ page.locator("input[name*=user i]"), page.getByLabel(/email|user/i),
141
+ ]);
142
+ const password = await firstVisible([page.locator("input[type=password]"), page.getByLabel(/password|passcode/i)]);
143
+ if (!email || !password) throw new Error("could not identify email and password fields");
144
+ await email.fill(emailValue);
145
+ await password.fill(passwordValue);
146
+ const submit = await firstVisible([page.getByRole("button", { name:/sign in|log in|login|continue|submit/i })]);
147
+ if (!submit) throw new Error("could not identify a sign-in control");
148
+ await submit.click();
149
+ await page.waitForTimeout(400);
150
+ if (await password.isVisible().catch(() => false)) throw new Error("submit left the app on the login screen");
135
151
  } else if (action === "swipe") {
136
152
  const dy = target.toLowerCase() === "down" ? -600 : 600;
137
153
  await page.evaluate((y) => window.scrollBy({ top: y, behavior: "instant" }), dy);
@@ -173,7 +189,7 @@ export async function executeWebFlowStep({ page, step, vars = {}, defaultTimeout
173
189
  status = "fail";
174
190
  detail = error.message || String(error);
175
191
  }
176
- return { action, target: target || value, status, detail, task: raw.task };
192
+ return { action, target: action === "login" ? "sign-in form" : target || value, status, detail, task: raw.task };
177
193
  }
178
194
 
179
195
  export async function runWebFlow({ flow, url, logPath, screenshotDir, playwright }) {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.0-rc.8",
3
+ "version": "0.17.0",
4
4
  "mcpName": "io.github.aarwitz/tapp",
5
- "description": "Release contracts, autonomous QA, and evidence-backed CI gates for iOS, Android, and web.",
5
+ "description": "Agent-driven app testing for iOS, Android, and web—real screens, replayable flows, and deterministic CI gates.",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "bin": {
@@ -41,6 +41,8 @@
41
41
  "Harness/OCQAHarnessUITests/",
42
42
  "Harness/OCQAHarness.xcodeproj/",
43
43
  "Harness/generate-harness-xcodeproj.rb",
44
+ "skills/",
45
+ ".claude-plugin/",
44
46
  "AGENTS.md"
45
47
  ],
46
48
  "dependencies": {
@@ -57,7 +59,7 @@
57
59
  "type": "git",
58
60
  "url": "git+https://github.com/aarwitz/tapp.git"
59
61
  },
60
- "homepage": "https://github.com/aarwitz/tapp#readme",
62
+ "homepage": "https://runtapp.com/",
61
63
  "bugs": {
62
64
  "url": "https://github.com/aarwitz/tapp/issues"
63
65
  },
@@ -87,7 +89,7 @@
87
89
  "mobile"
88
90
  ],
89
91
  "scripts": {
90
- "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/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/action.test.js tests/package-surface.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.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/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",
91
93
  "test:browser-journey": "node --test tests/browser-journey.test.js",
92
94
  "test:browser-native": "TAPP_RUN_NATIVE_BROWSER=1 node --test tests/browser-native-journey.test.js"
93
95
  }
@@ -86,6 +86,48 @@ TAPP_PROJECT_ARTIFACTS=""
86
86
  if [[ -n "$PROJECT_DIR" ]]; then
87
87
  [[ -d "$PROJECT_DIR/.tapp" ]] && TAPP_PROJECT_ARTIFACTS="$PROJECT_DIR/.tapp"
88
88
  fi
89
+
90
+ # A repository-connected gate must retain the stable application-model target identity in its
91
+ # report. Otherwise its first passing report cannot become a target-scoped baseline, even though
92
+ # Tapp already knows exactly which application it built and exercised. Explicit --target-key still
93
+ # wins (the composite Action supplies one); the CLI derives it only when the repository model makes
94
+ # the selection conclusive. Multi-target repositories remain strict and print the available ids.
95
+ if [[ -z "$TARGET_KEY" && -n "$TAPP_PROJECT_ARTIFACTS" && -f "$TAPP_PROJECT_ARTIFACTS/application-model.json" ]]; then
96
+ if ! TARGET_KEY="$(node - "$TAPP_PROJECT_ARTIFACTS/application-model.json" "$PLATFORM" "$WEB_TARGET" "$APP_ID" "$BUNDLE_ID" <<'NODE'
97
+ const fs = require("fs");
98
+ const [modelPath, platform, webTarget, appId, bundleId] = process.argv.slice(2);
99
+ let model;
100
+ try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
101
+ catch (error) { console.error(`❌ Could not read application model ${modelPath}: ${error.message}`); process.exit(2); }
102
+ if (model?.kind !== "tapp-application-model" || !Array.isArray(model.targets)) {
103
+ console.error("❌ Expected .tapp/application-model.json to contain a Tapp application model");
104
+ process.exit(2);
105
+ }
106
+ let candidates = model.targets.filter((target) => target.platform === platform);
107
+ const requested = platform === "web" ? webTarget : platform === "android" ? appId : bundleId;
108
+ if (requested) {
109
+ const normalized = requested.replaceAll("\\", "/").replace(/^\.\//, "");
110
+ candidates = candidates.filter((target) => {
111
+ const identities = [target.id, target.name, target.sourcePath];
112
+ if (platform === "android") identities.push(target.runtime?.applicationId);
113
+ if (platform === "ios") identities.push(target.runtime?.bundleId);
114
+ return identities.some((value) => String(value || "").replaceAll("\\", "/") === normalized);
115
+ });
116
+ }
117
+ if (candidates.length !== 1) {
118
+ const available = model.targets.filter((target) => target.platform === platform);
119
+ console.error(candidates.length
120
+ ? `❌ Multiple ${platform} targets match. Pass --target-key explicitly: ${candidates.map((target) => target.id).join(", ")}`
121
+ : `❌ Could not resolve one ${platform} target from the application model.${available.length ? ` Available target ids: ${available.map((target) => target.id).join(", ")}` : ""}`);
122
+ process.exit(2);
123
+ }
124
+ process.stdout.write(String(candidates[0].id));
125
+ NODE
126
+ )"; then
127
+ exit 2
128
+ fi
129
+ echo "Target identity: $PLATFORM:$TARGET_KEY (from .tapp/application-model.json)"
130
+ fi
89
131
  [[ -z "$FLOWS" && -n "$TAPP_PROJECT_ARTIFACTS" && -d "$TAPP_PROJECT_ARTIFACTS/flows" ]] && FLOWS="$TAPP_PROJECT_ARTIFACTS/flows/*.yml"
90
132
  [[ "$PLATFORM" == "web" && -z "$SCENARIOS" && -n "$TAPP_PROJECT_ARTIFACTS" && -d "$TAPP_PROJECT_ARTIFACTS/scenarios" ]] && SCENARIOS="$TAPP_PROJECT_ARTIFACTS/scenarios/*.yml"
91
133
  [[ -z "$CONTRACTS" && -n "$TAPP_PROJECT_ARTIFACTS" && -d "$TAPP_PROJECT_ARTIFACTS/contracts" ]] && CONTRACTS="$TAPP_PROJECT_ARTIFACTS/contracts/*.contract.ts"
@@ -102,7 +102,7 @@ def report(path, as_json=False):
102
102
  return 0 if passed else 1
103
103
 
104
104
  icon = {"pass": "✅", "fail": "❌", "skip": "⚪️"}
105
- verb = {"tap": "👆 tap", "type": "⌨️ type", "swipe": "↔️ swipe", "back": "◀️ back",
105
+ verb = {"tap": "👆 tap", "type": "⌨️ type", "login": "🔐 sign in", "swipe": "↔️ swipe", "back": "◀️ back",
106
106
  "wait": "⏳ wait", "wait_for": "⏳ wait for", "assert_screen": "🔎 screen is",
107
107
  "assert_exists": "🔎 exists", "assert_absent": "🔎 absent", "assert_text": "🔎 text",
108
108
  "assert_ai": "🤖 ai"}
@@ -23,9 +23,9 @@ TAPP_RUNTIME_HOME="${TAPP_HOME:-}"
23
23
  if [[ -n "${TAPP_CAPTURE_DIR:-}" ]]; then
24
24
  CAPTURE_DIR="$TAPP_CAPTURE_DIR"
25
25
  elif [[ -n "$TAPP_RUNTIME_HOME" ]]; then
26
- CAPTURE_DIR="$TAPP_RUNTIME_HOME/captures/$(date +%Y%m%d-%H%M%S)"
26
+ CAPTURE_DIR="$TAPP_RUNTIME_HOME/captures/ios-$(date +%Y%m%d-%H%M%S)"
27
27
  else
28
- CAPTURE_DIR="$PROJECT_ROOT/captures/$(date +%Y%m%d-%H%M%S)"
28
+ CAPTURE_DIR="$PROJECT_ROOT/captures/ios-$(date +%Y%m%d-%H%M%S)"
29
29
  fi
30
30
  if [[ -n "$TAPP_RUNTIME_HOME" ]]; then
31
31
  HARNESS_DERIVED="$TAPP_RUNTIME_HOME/harness-derived"