@aarwitz/tapp 0.16.5 โ†’ 0.17.0-rc.1

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.
@@ -106,16 +106,31 @@ export function findingEvaluationTier(finding, platform = "ios") {
106
106
  return platform === "web" && WEB_SAMPLED_ISSUE_TYPES.has(finding?.type) ? "sampled" : "deterministic";
107
107
  }
108
108
 
109
- export function verdictBadge(report) {
110
- if (report?.platform === "web" && report?.verdict === "ready") return "๐Ÿ”ต AUTOMATED CHECKS COMPLETE";
111
- return { ready: "๐ŸŸข SHIP-READY", caution: "๐ŸŸก CAUTION", blocked: "๐Ÿ”ด BLOCKED" }[report?.verdict] || report?.verdict;
109
+ // The deterministic block-by-findings rule, expressed over verdict-tier finding COUNTS โ€” not the
110
+ // score scalar and not the `verdict` label โ€” so the CI gate survives removal of verdict/releaseScore
111
+ // from exploration output. It encodes exactly what `verdict === "blocked"` used to: a critical
112
+ // finding always blocks; otherwise, on a CONCLUSIVE run, a risk threshold blocks. The risk threshold
113
+ // is kept as an explicit, chosen rule (ADR-0005) โ€” `riskFromCounts` is the single source of that
114
+ // formula, shared with buildQaReport's verdict label. Inconclusive runs are a separate gate outcome,
115
+ // never a findings-block, so a thin run reports `inconclusive`, not `fail`.
116
+ export function riskFromCounts({ critical = 0, high = 0, medium = 0 } = {}) {
117
+ return Math.max(0, Math.min(100, 100 - critical * 25 - high * 10 - medium * 3));
118
+ }
119
+ export function findingsBlock(deterministicFindingCounts = {}, { inconclusive = false } = {}) {
120
+ if ((deterministicFindingCounts.critical || 0) > 0) return true;
121
+ if (inconclusive) return false;
122
+ return riskFromCounts(deterministicFindingCounts) < 50;
123
+ }
124
+
125
+ // Exploration OBSERVES; it never renders a ship verdict or score (ADR-0005). These label the
126
+ // observation honestly. The release decision (pass/fail/inconclusive) is the gate's, shown separately.
127
+ export function observationBadge(report) {
128
+ return report?.inconclusive ? "๐ŸŸก INCONCLUSIVE (exploration)" : "๐Ÿ”ญ EXPLORED";
112
129
  }
113
130
 
114
- export function qaScoreLabel(report) {
115
- const score = report?.releaseScore ?? report?.confidence;
116
- if (Number.isFinite(score)) return `release score ${score}/100`;
117
- if (report?.platform === "web") return "exploratory web ยท no scalar score";
118
- return "score unavailable";
131
+ export function observationSummary(report) {
132
+ const n = report?.findingCounts?.total || 0;
133
+ return `${report?.screensExplored || 0} screens ยท ${report?.actionsPerformed || 0} actions ยท ${n} finding(s) ยท observation only`;
119
134
  }
120
135
 
121
136
  // Turn a capture's OCQA markers into the same ship/no-ship report Tapp produces:
@@ -201,6 +216,10 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
201
216
  findings.push({
202
217
  ...i,
203
218
  category: ISSUE_CATEGORY[i.type] || i.type,
219
+ // Structural evidence authority (ADR-0005): marker-derived findings are deterministic. The
220
+ // default gate consumes only deterministic-authority evidence; model-observed findings
221
+ // (vision/assert_ai) carry authority:"model-observed" and are advisory, never gate fails.
222
+ authority: "deterministic",
204
223
  ...(platform === "web" ? { evaluationTier: findingEvaluationTier(i, platform) } : {}),
205
224
  });
206
225
  }
@@ -222,27 +241,18 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
222
241
  const verdictLow = verdictFindings.filter((f) => f.severity === "low").length;
223
242
  const sampledFindings = platform === "web" ? findings.filter((finding) => finding.evaluationTier === "sampled") : [];
224
243
 
225
- // Coverage floor: a verdict is only trustworthy if the app was actually exercised.
244
+ // Coverage floor: exploration is inconclusive if the app wasn't actually exercised. Exploration
245
+ // OBSERVES โ€” it does not render a ship verdict or score (ADR-0005). Judgment (pass/fail/
246
+ // inconclusive) is the gate's job (evaluateGate), computed from these findings + coverage + policy.
226
247
  const inconclusive = screensExplored < 2 || actionsPerformed < 3;
227
- let riskScore = Math.max(0, Math.min(100, 100 - verdictCrit * 25 - verdictHigh * 10 - verdictMed * 3));
228
- if (inconclusive) riskScore = Math.min(riskScore, 40);
229
-
230
- let verdict;
231
- if (verdictCrit > 0) verdict = "blocked";
232
- else if (inconclusive) verdict = "caution";
233
- else if (riskScore < 50) verdict = "blocked";
234
- else if (verdictHigh > 0 || riskScore < 80) verdict = "caution";
235
- else verdict = "ready";
236
248
 
237
249
  const headline = inconclusive
238
250
  ? `Inconclusive โ€” only ${screensExplored} screen(s) / ${actionsPerformed} action(s) explored. The app may have crashed on launch, be stuck behind a sign-in wall, or otherwise prevent exploration. Absence of issues is NOT a pass.`
239
- : verdict === "ready"
251
+ : findings.length === 0
240
252
  ? platform === "web"
241
- ? "Automated web checks completed โ€” no release-blocking deterministic findings in the exercised surfaces. Sampled control probes are advisory. This is not a content, privacy, brand, or business-claim review."
242
- : "Ship-ready โ€” no release-blocking issues found."
243
- : verdict === "caution"
244
- ? `Proceed with caution โ€” ${findings.length} issue(s) to review.`
245
- : `Not ready โ€” ${findings.length} issue(s): ${crit} critical, ${high} high, ${med} medium, ${low} low.`;
253
+ ? "Automated web checks completed โ€” no deterministic findings in the exercised surfaces. Sampled control probes are advisory. An observation, not a release decision, and not a content, privacy, brand, or business-claim review."
254
+ : "No issues surfaced in the exercised surfaces. An observation, not a release decision."
255
+ : `${findings.length} issue(s) surfaced for review (${crit} critical, ${high} high, ${med} medium, ${low} low). An observation, not a release decision.`;
246
256
 
247
257
  // The verdict's own honesty label: exactly which defect classes this run checked, which
248
258
  // it structurally could NOT check, and which conditions never came up โ€” so "checked" is
@@ -295,17 +305,21 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
295
305
  else conditionsNotReached.push("sign-in (no login form encountered this run)");
296
306
 
297
307
  return {
298
- verdict,
299
- // Exploratory web QA deliberately has no scalar. Its verdict derives from deterministic
300
- // checks on exercised pages; budget-capped control probes remain visible but advisory.
301
- // Native keeps the legacy heuristic score until it has an equivalent tier split.
302
- confidence: platform === "web" ? null : riskScore,
303
- releaseScore: platform === "web" ? null : riskScore,
304
- scoreUnavailableReason: platform === "web"
305
- ? "Exploratory web runs report deterministic findings, advisory sampled probes, and coverage instead of a scalar release score."
306
- : null,
308
+ // An ExplorationRun observation: findings + coverage + evidence, NO ship verdict or score
309
+ // (ADR-0005). The gate (evaluateGate) turns this into a pass/fail/inconclusive release outcome.
310
+ kind: "tapp-exploration-run",
311
+ schemaVersion: 1,
312
+ // Complete ExplorationRun contract (ADR-0005 ยง4). runStatus/stopReason describe HOW the run
313
+ // ended; coverage/evidence/uiMap/comparison are the structured observation. uiMap and comparison
314
+ // are populated by consumers that build the map / diff a baseline (null in the bare observation).
315
+ runStatus: inconclusive ? "limited" : "completed",
316
+ stopReason: inconclusive ? "coverage-floor-not-met" : "completed",
307
317
  headline,
308
318
  inconclusive,
319
+ coverage: { screensExplored, actionsPerformed, screens: Array.from(screens) },
320
+ evidence: { markers: base.relativeMarkersFilePath },
321
+ uiMap: null,
322
+ comparison: null,
309
323
  checkedFor,
310
324
  notChecked,
311
325
  conditionsNotReached,
@@ -313,7 +327,7 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
313
327
  screensExplored,
314
328
  actionsPerformed,
315
329
  findingCounts: { critical: crit, high, medium: med, low, total: findings.length },
316
- verdictFindingCounts: {
330
+ deterministicFindingCounts: {
317
331
  critical: verdictCrit,
318
332
  high: verdictHigh,
319
333
  medium: verdictMed,
@@ -355,6 +369,7 @@ export function computeContentCollapse(currentCounts, baselineCounts) {
355
369
  type: "content_collapse",
356
370
  severity: "high",
357
371
  category: "content_collapse",
372
+ authority: "deterministic",
358
373
  title: `Screen lost most of its content (${base} โ†’ ${cur} elements)`,
359
374
  screen,
360
375
  step: null,
@@ -383,6 +398,7 @@ export function computeReachabilityLoss(current, baseline) {
383
398
  type: "screen_unreachable",
384
399
  severity: "high",
385
400
  category: "navigation_dead_end",
401
+ authority: "deterministic",
386
402
  title: "Screen explored in the baseline was never reached this run",
387
403
  screen,
388
404
  step: null,
@@ -421,15 +437,84 @@ export function computeRegression(current, baseline) {
421
437
  const newFindings = current.filter((f) => !currentMatches(f));
422
438
  const persisting = current.filter((f) => currentMatches(f));
423
439
  const resolved = baseline.filter((b) => !baselineMatched(b));
424
- const newCritical = newFindings.filter((f) => f.severity === "critical").length;
425
- const newHigh = newFindings.filter((f) => f.severity === "high").length;
426
440
 
441
+ // Comparison ONLY โ€” no gate/pass/fail signal (ADR-0005). Exploration surfaces this diff; the merge
442
+ // decision is the gate's job. evaluateGate derives its regression fail from `newFindings` severities.
427
443
  return {
428
444
  hadBaseline: true,
429
445
  counts: { new: newFindings.length, persisting: persisting.length, resolved: resolved.length },
430
446
  newFindings,
431
447
  resolved,
432
- // CI gate: fail the build when this run introduced new high/critical findings vs. the baseline.
433
- gate: { newCritical, newHigh, failed: newCritical + newHigh > 0 },
448
+ };
449
+ }
450
+
451
+ // The gate's public outcome model (ADR-0005). A merge gate is ultimately block / don't-block, but
452
+ // callers need to distinguish WHY: a deterministic violation is not the same as "we couldn't get
453
+ // the evidence." Exit codes are the CI contract; precedence is fail > inconclusive > pass.
454
+ export const GATE_EXIT = { pass: 0, fail: 1, error: 2, inconclusive: 3 };
455
+ // Bump when the gate's decision semantics change (NOT the npm version). Recorded on every GateRun.
456
+ export const GATE_POLICY_VERSION = "1";
457
+
458
+ // Pure gate evaluator: frozen evidence + policy โ†’ a GateRun decision. Extracted verbatim from the
459
+ // former inline logic in ci-report.js so the `[char]` characterization tests keep passing โ€” the
460
+ // merge decision (block/don't-block) is unchanged; this only classifies each reason as a
461
+ // deterministic `fail` or an evidence-absent `inconclusive` and folds them by precedence. Reason
462
+ // MESSAGES are preserved exactly (several are asserted by tests).
463
+ //
464
+ // DECOUPLED FROM THE SCORE (ADR-0005): the block-by-findings decision reads `deterministicFindingCounts` +
465
+ // `inconclusive` via `findingsBlock`, NOT the score scalar or the `verdict` label. `verdict`/
466
+ // `releaseScore` can therefore be removed from exploration output without changing any merge
467
+ // decision. The `riskScore < 50` threshold is retained deliberately (kept explicit, inside
468
+ // `findingsBlock`) and locked by the `[char]` risk-threshold test.
469
+ export function evaluateGate({ report, regression = null, flows = [], scenarios = [], contracts = [], prPlan = null, baseline = null, failOn = "gate" } = {}) {
470
+ const reasons = []; // { kind: "fail" | "inconclusive", message }
471
+ const fail = (message) => reasons.push({ kind: "fail", message });
472
+ const inconclusive = (message) => reasons.push({ kind: "inconclusive", message });
473
+
474
+ // Classify each replayed suite by evidence authority (ADR-0005): a DETERMINISTIC step failure (or
475
+ // a non-model failure like an aborted/missing run) is a real fail; a suite with no deterministic
476
+ // failure that carries a model-observed (assert_ai) assertion cannot be decided by the default
477
+ // deterministic gate โ†’ inconclusive/needs-review (it must not silently pass, and a model verdict
478
+ // must not masquerade as a deterministic fail). No `--policy probabilistic` opt-in in 0.17.
479
+ const deterministicFail = (s) => s.deterministicFailed === true || (s.passed === false && !s.modelObserved);
480
+ const classify = (s) => (deterministicFail(s) ? "fail" : s.modelObserved ? "needs-review" : "pass");
481
+ for (const [label, suites] of [["flow", flows], ["multi-actor scenario", scenarios], ["release contract", contracts]]) {
482
+ const failed = suites.filter((s) => classify(s) === "fail");
483
+ if (failed.length) fail(`${failed.length} ${label}(s) failed`);
484
+ const needsReview = suites.filter((s) => classify(s) === "needs-review");
485
+ if (needsReview.length) inconclusive(`${needsReview.length} ${label}(s) contain assert_ai (model-observed); the deterministic gate cannot decide them โ€” review, or add an explicit probabilistic policy`);
486
+ }
487
+ // Selected-but-unexecuted work is missing evidence, not an observed violation โ†’ inconclusive.
488
+ if (prPlan?.execution?.notRun) inconclusive(`${prPlan.execution.notRun} selected release contract(s) did not run`);
489
+ if (prPlan?.execution?.explorationFailed) inconclusive(`${prPlan.execution.explorationFailed} planned PR exploration target(s) failed or were not reached`);
490
+
491
+ if (failOn === "any") {
492
+ if (report.findingCounts.total > 0) fail(`${report.findingCounts.total} finding(s) (fail-on: any)`);
493
+ // "any" is the strictest policy โ€” an inconclusive run (evidence not obtained) must never pass it.
494
+ if (report.inconclusive) inconclusive("run was inconclusive (coverage floor not met)");
495
+ } else if (failOn === "absolute" || (failOn === "gate" && !regression)) {
496
+ if (findingsBlock(report.deterministicFindingCounts, { inconclusive: report.inconclusive })) fail("blocking deterministic finding(s)");
497
+ if (report.inconclusive) inconclusive("run was inconclusive (coverage floor not met)");
498
+ } else {
499
+ if (regression?.newFindings?.length) {
500
+ const newCritical = regression.newFindings.filter((f) => f.severity === "critical").length;
501
+ const newHigh = regression.newFindings.filter((f) => f.severity === "high").length;
502
+ if (newCritical + newHigh > 0) fail(`${newCritical} new critical + ${newHigh} new high vs. baseline`);
503
+ }
504
+ if (findingsBlock(report.deterministicFindingCounts, { inconclusive: report.inconclusive })) fail("blocking deterministic finding(s)");
505
+ if (report.inconclusive && !baseline?.inconclusive) inconclusive("run became inconclusive vs. baseline (app may no longer launch/explore)");
506
+ }
507
+
508
+ const outcome = reasons.some((r) => r.kind === "fail") ? "fail"
509
+ : reasons.some((r) => r.kind === "inconclusive") ? "inconclusive"
510
+ : "pass";
511
+ return {
512
+ policy: failOn,
513
+ outcome,
514
+ exitCode: GATE_EXIT[outcome],
515
+ failed: outcome !== "pass", // retained for markdown/JSON consumers during migration
516
+ reasons: reasons.map((r) => r.message),
517
+ reasonDetails: reasons,
518
+ policyVersion: GATE_POLICY_VERSION,
434
519
  };
435
520
  }
@@ -109,7 +109,7 @@ export function loadTaskRegistry({ sourcePath, projectDir = "", taskFiles = [] }
109
109
  // Draft contracts generated under `.tapp/proposals/contracts` may compile
110
110
  // against sibling untrusted Task drafts. Ordinary committed contracts never
111
111
  // see this directory, so a proposal cannot silently enter the release gate.
112
- const proposalSource = [".tapp", ".autotap"].some((directory) => String(path.resolve(sourcePath || "")).includes(`${path.sep}${directory}${path.sep}proposals${path.sep}`));
112
+ const proposalSource = String(path.resolve(sourcePath || "")).includes(`${path.sep}.tapp${path.sep}proposals${path.sep}`);
113
113
  const proposalDir = proposalSource && tappDir ? path.join(tappDir, "proposals", "tasks") : "";
114
114
  const proposed = proposalDir && fs.existsSync(proposalDir)
115
115
  ? fs.readdirSync(proposalDir).filter((name) => /\.ya?ml$|\.json$/i.test(name)).map((name) => path.join(proposalDir, name))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.16.5",
3
+ "version": "0.17.0-rc.1",
4
4
  "mcpName": "io.github.aarwitz/tapp",
5
5
  "description": "Release contracts, autonomous QA, and evidence-backed CI gates for iOS, Android, and web.",
6
6
  "license": "MIT",
@@ -87,7 +87,7 @@
87
87
  "mobile"
88
88
  ],
89
89
  "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/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/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",
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/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",
91
91
  "test:browser-journey": "node --test tests/browser-journey.test.js",
92
92
  "test:browser-native": "TAPP_RUN_NATIVE_BROWSER=1 node --test tests/browser-native-journey.test.js"
93
93
  }
@@ -25,7 +25,7 @@
25
25
  # [--pr-plan-out <file.json>] # persist the reviewable selection plan
26
26
  # [--baseline <file.json>] # prior report to diff against (skipped if absent)
27
27
  # [--target-key <stable-id>] # isolates target-specific baselines in monorepos
28
- # [--fail-on gate|blocked|any] # gate policy (default gate; see ci-report.js)
28
+ # [--fail-on gate|absolute|any] # gate policy (default gate; see ci-report.js)
29
29
  # [--json-out <file.json>] # write the full report (use as the next baseline)
30
30
  # [--md-out <file.md>] # write the rendered markdown report (for a PR comment)
31
31
  # [--device <name>] # simulator device to boot if none is (default "iPhone 16 Pro")
@@ -74,7 +74,7 @@ done
74
74
  [[ "$PLATFORM" == "ios" || "$PLATFORM" == "android" || "$PLATFORM" == "web" ]] || { echo "โŒ --platform must be ios|android|web" >&2; exit 2; }
75
75
  [[ "$ACTIONS" =~ ^[1-9][0-9]*$ ]] || { echo "โŒ --actions must be a positive integer" >&2; exit 2; }
76
76
  [[ "$TIMEOUT" =~ ^[1-9][0-9]*$ ]] || { echo "โŒ --timeout must be a positive integer" >&2; exit 2; }
77
- [[ "$FAIL_ON" == "gate" || "$FAIL_ON" == "blocked" || "$FAIL_ON" == "any" ]] || { echo "โŒ --fail-on must be gate|blocked|any" >&2; exit 2; }
77
+ [[ "$FAIL_ON" == "gate" || "$FAIL_ON" == "absolute" || "$FAIL_ON" == "any" ]] || { echo "โŒ --fail-on must be gate|absolute|any" >&2; exit 2; }
78
78
  if [[ -n "$PROJECT_DIR" ]]; then
79
79
  [[ -d "$PROJECT_DIR" ]] || { echo "โŒ Project directory not found: $PROJECT_DIR" >&2; exit 2; }
80
80
  PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)"
@@ -274,7 +274,7 @@ xcrun simctl install "$UDID" "$APP_PATH" || { echo "โŒ simctl install failed
274
274
  # โ”€โ”€ Autonomous exploration (quick-capture builds the harness itself if needed).
275
275
  step "Explore ($ACTIONS actions, ${TIMEOUT}s watchdog)"
276
276
  set +e
277
- CAPTURE_ROOT="${TAPP_HOME:-${AUTOTAP_HOME:-$ROOT}}/captures"
277
+ CAPTURE_ROOT="${TAPP_HOME:-$ROOT}/captures"
278
278
  mkdir -p "$CAPTURE_ROOT"
279
279
  CAPTURE_DIR="$(mktemp -d "$CAPTURE_ROOT/ci.XXXXXX")"
280
280
  TAPP_CAPTURE_DIR="$CAPTURE_DIR" OCQA_PR_TARGET_JSON="$IOS_PR_TARGET_JSON" "$ROOT/scripts/quick-capture.sh" explore "$BUNDLE_ID" --actions "$ACTIONS" --timeout "$TIMEOUT"
@@ -19,7 +19,7 @@ PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
19
19
  # accidentally reuse stale markers). TAPP_HOME redirects all writable output otherwise โ€”
20
20
  # set by the `tapp` CLI when running as an installed npm package, where the package dir
21
21
  # must stay read-only. Unset (repo dev flow), everything lands in the repo as before.
22
- TAPP_RUNTIME_HOME="${TAPP_HOME:-${AUTOTAP_HOME:-}}"
22
+ 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,7 +26,7 @@ UDID="$(xcrun simctl list devices booted -j 2>/dev/null | python3 -c 'import sys
26
26
  [ -z "$UDID" ] && { echo "โŒ No booted simulator."; exit 2; }
27
27
  # When run through the installed `tapp` CLI, the harness cache lives under TAPP_HOME.
28
28
  XCTR=""
29
- TAPP_RUNTIME_HOME="${TAPP_HOME:-${AUTOTAP_HOME:-}}"
29
+ TAPP_RUNTIME_HOME="${TAPP_HOME:-}"
30
30
  [ -n "$TAPP_RUNTIME_HOME" ] && XCTR="$(find "$TAPP_RUNTIME_HOME/harness-derived/Build/Products" -name '*.xctestrun' 2>/dev/null | head -1)"
31
31
  [ -z "$XCTR" ] && XCTR="$(find "$HOME/Library/Developer/Xcode/DerivedData/OCQAHarness-"*/Build/Products -name '*.xctestrun' 2>/dev/null | head -1)"
32
32
  [ -z "$XCTR" ] && XCTR="$(find /tmp/tapp-harness-derived/Build/Products -name '*.xctestrun' 2>/dev/null | head -1)"