@nextcommerce/campaigns-os 1.43.1 → 1.46.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.
Files changed (92) hide show
  1. package/AGENTS.md +9 -2
  2. package/CHANGELOG.md +1099 -5103
  3. package/README.md +34 -13
  4. package/agents/claude/CLAUDE.md +1 -1
  5. package/agents/codex/AGENTS.md +1 -1
  6. package/agents/copilot/copilot-instructions.md +1 -1
  7. package/agents/cursor/campaigns-os.mdc +1 -1
  8. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
  9. package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
  10. package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
  11. package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
  12. package/campaign-spec/dist/types.d.ts +2 -2
  13. package/contracts/agent-relevant-change-policy.v1.json +5 -0
  14. package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
  15. package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
  16. package/contracts/effects.v1.json +1184 -121
  17. package/contracts/orientation-reason-codes.v1.json +7 -0
  18. package/contracts/release-ledger.json +2190 -5260
  19. package/contracts/supported-surface.json +7 -4
  20. package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
  21. package/docs/brand-theme-bridge.md +81 -0
  22. package/docs/build-packet.md +222 -23
  23. package/docs/campaigns-os-build-flow.md +4 -3
  24. package/docs/design-source-package.md +162 -15
  25. package/docs/effects.md +66 -12
  26. package/docs/gateway-login.md +3 -0
  27. package/docs/local-setup.md +1 -1
  28. package/docs/orientation-contract-reference.md +42 -2
  29. package/docs/polish-evidence.md +74 -0
  30. package/docs/progress-snapshots.md +10 -6
  31. package/docs/qa-and-test-orders.md +230 -20
  32. package/docs/release-ledger-authoring-guide.md +70 -8
  33. package/docs/runtime-readiness.md +1 -1
  34. package/docs/sdk-storage-compatibility.md +1 -1
  35. package/docs/skills-revision.md +10 -10
  36. package/docs/supported-surface.md +2 -2
  37. package/docs/versioning.md +4 -1
  38. package/package.json +1 -1
  39. package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
  40. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
  41. package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
  42. package/skills/campaign-readback-classification/SKILL.md +3 -3
  43. package/skills/campaign-run-evidence/SKILL.md +7 -6
  44. package/skills/contribution-intake/SKILL.md +3 -3
  45. package/skills/next-campaigns-build/SKILL.md +7 -6
  46. package/skills/next-campaigns-os/SKILL.md +7 -7
  47. package/skills/next-campaigns-os/references/session-intake.md +9 -3
  48. package/skills/next-campaigns-os-setup/SKILL.md +5 -5
  49. package/skills/next-campaigns-polish/SKILL.md +28 -9
  50. package/skills/next-campaigns-qa/SKILL.md +7 -4
  51. package/skills.json +10 -10
  52. package/src/brand-theme.mjs +320 -20
  53. package/src/build-brief.mjs +6 -4
  54. package/src/built-script-syntax.mjs +480 -0
  55. package/src/built-site-scope.mjs +16 -4
  56. package/src/campaigns-api-key.mjs +99 -0
  57. package/src/cli-helpers.mjs +118 -0
  58. package/src/cli.mjs +1530 -7580
  59. package/src/commercial-parity.mjs +48 -2
  60. package/src/design-source-package.mjs +1 -1
  61. package/src/design-source-publication.mjs +898 -0
  62. package/src/deviation.mjs +13 -1
  63. package/src/diagnostic.mjs +6 -2
  64. package/src/directory-lock.mjs +270 -0
  65. package/src/doctor/checks.mjs +4654 -0
  66. package/src/doctor/inspect.mjs +678 -0
  67. package/src/doctor/next-step.mjs +731 -0
  68. package/src/doctor/source-provenance.mjs +184 -0
  69. package/src/install-invocation.mjs +29 -0
  70. package/src/invocation.mjs +183 -0
  71. package/src/live-campaign-refs.mjs +466 -0
  72. package/src/login.mjs +2 -2
  73. package/src/page-kit-store-profile.mjs +69 -12
  74. package/src/page-kit-sync.mjs +31 -12
  75. package/src/private-template-source.mjs +1 -1
  76. package/src/progress-node.mjs +9 -36
  77. package/src/proof-policy.mjs +1 -1
  78. package/src/qa-analytics-correctness.mjs +3 -0
  79. package/src/qa-binding-evidence.mjs +76 -11
  80. package/src/qa-browser.mjs +1316 -105
  81. package/src/qa-build-scope.mjs +47 -0
  82. package/src/qa-commercial-parity.mjs +48 -5
  83. package/src/qa-node.mjs +339 -19
  84. package/src/qa-test-order-topology.mjs +148 -0
  85. package/src/sdk-markup.mjs +72 -8
  86. package/src/source-html-intake.mjs +117 -1
  87. package/src/source-html-manifest.mjs +9 -2
  88. package/src/stage-ledger.mjs +28 -0
  89. package/src/stage-record.mjs +551 -0
  90. package/src/target-lock.mjs +54 -0
  91. package/src/template-brand-contract.mjs +17 -1
  92. package/src/upsell-selector-scope.mjs +112 -2
@@ -16,7 +16,9 @@ import { redactUrlQuery } from "./qa-url-privacy.mjs";
16
16
  import {
17
17
  canonicalHttpUrl,
18
18
  commonTestOrderPaths,
19
+ commonTestOrderPlan,
19
20
  fullTestOrderPaths,
21
+ OFFER_PAGE_TYPES,
20
22
  pageAtUrl,
21
23
  remainingActionDisposition,
22
24
  resolveTestOrderTopology,
@@ -55,6 +57,7 @@ import {
55
57
  placeholderTextResidueMatches,
56
58
  referencedDemoAssetBasenames,
57
59
  summarizePlaceholderTerms,
60
+ withoutHiddenPaymentLogos,
58
61
  } from "./template-brand-contract.mjs";
59
62
 
60
63
  const DEFAULT_BROWSER_TIMEOUT_MS = 30000;
@@ -136,6 +139,7 @@ export async function runBrowserChecks(topologies, args = {}, options = {}) {
136
139
  export async function runBrowserTestOrders(topologies, args = {}, runId = "local", options = {}) {
137
140
  const checkoutPage = findPage(topologies, "checkout");
138
141
  if (!checkoutPage?.url) {
142
+ const coverage = upsellActionCoverageAssertion({ topologies, orders: [], checkoutPage });
139
143
  return {
140
144
  orders: [],
141
145
  receiptAnalytics: { plannedPlanIds: [], attempts: [] },
@@ -148,7 +152,7 @@ export async function runBrowserTestOrders(topologies, args = {}, runId = "local
148
152
  severity: SEVERITY.BLOCKER,
149
153
  expected: "checkout page URL",
150
154
  actual: "missing",
151
- })],
155
+ }), ...(coverage ? [coverage] : [])],
152
156
  };
153
157
  }
154
158
 
@@ -157,6 +161,8 @@ export async function runBrowserTestOrders(topologies, args = {}, runId = "local
157
161
  // discover that the operator needs to raise --max-test-orders.
158
162
  const plans = testOrderPlans(args["test-order"], topologies, args);
159
163
  enforceTestOrderLimit(plans, args);
164
+ const orderPathPlan = isCommonTestOrderMode(args["test-order"]) ? commonOrderPathPlan(topologies, args) : null;
165
+ if (orderPathPlan) (options.warn || ((line) => process.stderr.write(`${line}\n`)))(describeCommonOrderPathPlan(orderPathPlan));
160
166
  const creationBudget = createOrderCreationBudget({ plans, args });
161
167
 
162
168
  const browser = await launchChromium(args);
@@ -174,7 +180,7 @@ export async function runBrowserTestOrders(topologies, args = {}, runId = "local
174
180
  runId,
175
181
  // The topologies travel with the options so each attempt can resolve the
176
182
  // funnel's cart-entry page for the checkout it drives (campaigns-os#206).
177
- options: { ...options, creationBudget, topologies },
183
+ options: { ...options, creationBudget, topologies, orderPathPlan },
178
184
  });
179
185
  return {
180
186
  orders: dispatched.orders,
@@ -214,6 +220,9 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
214
220
 
215
221
  const assertions = [];
216
222
  const orders = [];
223
+ // The plan behind each entry in `orders`, index for index, so the upsell
224
+ // coverage row can tell which funnel an order ran through.
225
+ const orderPlans = [];
217
226
  try {
218
227
  for (let index = 0; index < plans.length; index += 1) {
219
228
  const plan = plans[index];
@@ -223,6 +232,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
223
232
  const pageForPlan = (typeof plan === "object" && plan?.checkout_page?.url) ? plan.checkout_page : checkoutPage;
224
233
  const firstAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
225
234
  orders.push(firstAttempt.order);
235
+ orderPlans.push(plan);
226
236
  // Every attempt this path actually SUBMITTED, in order. The confirmed
227
237
  // creation count is read from these and never from the deciding result
228
238
  // alone: a recovered result is derived from the first attempt, so
@@ -256,6 +266,11 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
256
266
  // reader could be surprised by. Over-charging costs at worst one unspent
257
267
  // planned path; under-charging costs a real order nobody budgeted for.
258
268
  creationBudget.consume({ plan_id: identifier, kind: "hosted_checkout_redirect" });
269
+ } else if (firstAttempt.upsell_unverified) {
270
+ // Created, and every check passed except an accepted upsell nothing
271
+ // could prove or disprove. A re-run would buy a second order to ask
272
+ // again, and recovery cannot re-check an upsell read-only, so the
273
+ // attempt stands and goes to manual review.
259
274
  } else if (!firstAttempt.ok) {
260
275
  const classification = classifyTestOrderCreation(firstAttempt);
261
276
  if (classification.creation === "not_created") {
@@ -275,7 +290,10 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
275
290
  try {
276
291
  const rerunAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
277
292
  attemptsForPlan.push(rerunAttempt);
278
- if (rerunAttempt.order) orders.push(rerunAttempt.order);
293
+ if (rerunAttempt.order) {
294
+ orders.push(rerunAttempt.order);
295
+ orderPlans.push(plan);
296
+ }
279
297
  if (rerunAttempt.budget_exhausted) {
280
298
  // The re-run stopped itself before its submit click, so it
281
299
  // proved nothing. Letting it decide would erase this path's
@@ -383,6 +401,15 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
383
401
  evidence: { planned_paths: plans.map((plan) => planId(plan)), completed_paths: orders.map((order) => order?.plan_id || order?.path) },
384
402
  }));
385
403
  }
404
+ const coverage = upsellActionCoverageAssertion({
405
+ topologies: options.topologies || [],
406
+ plans,
407
+ orders,
408
+ orderPlans,
409
+ checkoutPage,
410
+ orderPathPlan: options.orderPathPlan || null,
411
+ });
412
+ if (coverage) assertions.push(coverage);
386
413
 
387
414
  return { orders, assertions, receiptAnalytics, journeyAnalytics, creationBudget };
388
415
  }
@@ -401,7 +428,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
401
428
  // with --analytics-baseline's legacy receipt, and identity resolution cannot
402
429
  // derive a receipt page yet (receipt-aware capture is out of packet 01's
403
430
  // scope). Absent that override, the candidate IS the resolved target.
404
- function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl }) {
431
+ function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl, capturePage = null, rootFallback = null }) {
405
432
  const baselinePublicUrl = redactUrlQuery(baselineUrl);
406
433
  const candidatePublicUrl = redactUrlQuery(candidateUrl);
407
434
  const analyticsPage = { page_id: "analytics", url: candidatePublicUrl || baselinePublicUrl || undefined };
@@ -421,6 +448,8 @@ function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, ca
421
448
  candidate_event_count: candidate.eventNames.length,
422
449
  baseline_inventory: Object.fromEntries(Object.entries(baseline.inventory).map(([k, v]) => [k, v.length])),
423
450
  candidate_inventory: Object.fromEntries(Object.entries(candidate.inventory).map(([k, v]) => [k, v.length])),
451
+ ...(capturePage ? { capture_page: capturePage } : {}),
452
+ ...(rootFallback ? { root_fallback: rootFallback } : {}),
424
453
  },
425
454
  }));
426
455
  return assertions;
@@ -474,9 +503,14 @@ export async function runAnalyticsParityChecks(args = {}, options = {}) {
474
503
  extraHTTPHeaders: args["auth-cookie"] ? { Cookie: String(args["auth-cookie"]) } : undefined,
475
504
  });
476
505
  try {
477
- const baseline = await captureAnalyticsForUrl(context, baselineUrl, args, extraHosts);
478
- const candidate = await captureAnalyticsForUrl(context, candidateUrl, args, extraHosts);
479
- return analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl });
506
+ // An explicit --analytics-candidate (the receipt pairing above) is the
507
+ // page the operator named, so it is captured as given.
508
+ if (trim(args["analytics-candidate"])) {
509
+ const baseline = await captureAnalyticsForUrl(context, baselineUrl, args, extraHosts);
510
+ const candidate = await captureAnalyticsForUrl(context, candidateUrl, args, extraHosts);
511
+ return analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl });
512
+ }
513
+ return await captureAnalyticsParityInContext(context, baselineUrl, candidateUrl, args, extraHosts, options);
480
514
  } catch (error) {
481
515
  return [analyticsParityRunnerFailureAssertion({ baselineUrl, candidateUrl, error })];
482
516
  } finally {
@@ -485,16 +519,57 @@ export async function runAnalyticsParityChecks(args = {}, options = {}) {
485
519
  }
486
520
  }
487
521
 
488
- // Analytics CORRECTNESS inventory leg: capture ONE campaign-root page and
489
- // assess only declared tags/pixels. Purchase is finalized later from the
490
- // canonical typed-card order's recognized receipt; this root visit is never
491
- // treated as Purchase authority.
522
+ // #503: the resolved-target candidate gets the same partial-scope fallback as
523
+ // the correctness inventory (#493). A partial build has no page at the
524
+ // identity root, so the candidate is the first built in-scope entry, and a
525
+ // root that answers non-2xx falls back the same way. The candidate is picked
526
+ // first: when nothing in scope answers, the leg reports
527
+ // no_in_scope_page_captured (skipped) or no_capture_page_answered (blocker)
528
+ // without loading the baseline, instead of diffing the baseline against an
529
+ // empty or generic page.
530
+ async function captureAnalyticsParityInContext(context, baselineUrl, targetUrl, args, extraHosts, options = {}) {
531
+ const selected = await captureFirstAnsweringAnalyticsPage(context, targetUrl, args, extraHosts, options);
532
+ if (!selected.capture) {
533
+ return [analyticsCorrectnessNoCapturePageAssertion({ rootUrl: targetUrl, attempts: selected.attempts, family: "analytics-parity" })];
534
+ }
535
+ const baseline = await captureAnalyticsForUrl(context, baselineUrl, args, extraHosts);
536
+ return analyticsParityCaptureAssertions({
537
+ baseline,
538
+ candidate: selected.capture,
539
+ baselineUrl,
540
+ candidateUrl: selected.url,
541
+ capturePage: selected.capturePage,
542
+ rootFallback: selected.rootFallback,
543
+ });
544
+ }
545
+
546
+ // Analytics CORRECTNESS inventory leg: capture ONE page and assess only
547
+ // declared tags/pixels. Purchase is finalized later from the canonical
548
+ // typed-card order's recognized receipt; this inventory visit is never treated
549
+ // as Purchase authority.
492
550
  // `options.target` is the capture target resolved from the campaign's
493
551
  // identity (public_route_slug + route_root) in qa-node — packet 01 / INV-2:
494
552
  // this leg no longer reads --analytics-candidate or --base-url; the URL it
495
553
  // visits is a function of resolved identity, recorded on every assertion.
496
- function analyticsCorrectnessCaptureAssertions({ capture, contract, url }) {
554
+ //
555
+ // #493: a partial build can have no page at that campaign root (the built
556
+ // entry is deeper, e.g. checkout/). When qa-node reports the root out of the
557
+ // built scope (`options.rootInScope === false`), or the root answers non-2xx,
558
+ // the leg captures the first built in-scope entry instead
559
+ // (`options.fallbackTargets`, the same entry the partial-scope planner
560
+ // selects) and records which page it used. When nothing in scope can be
561
+ // captured, the leg is skipped when the build has no capturable page, and
562
+ // fails as a blocker when candidates existed but none answered 2xx; neither
563
+ // case fails every declared vendor against an empty page.
564
+ // #500: the URL the capture page actually settled on, after redirects, keyed by
565
+ // the capture object so the parity capture shape stays unchanged. Local-serve
566
+ // review (qa-node) downgrades a silent pixel only when this is loopback: a
567
+ // localhost root that redirects to a production host measured production.
568
+ const ANALYTICS_CAPTURE_DOCUMENT_URL = new WeakMap();
569
+
570
+ function analyticsCorrectnessCaptureAssertions({ capture, contract, url, capturePage = null, rootFallback = null, finalUrl = ANALYTICS_CAPTURE_DOCUMENT_URL.get(capture) ?? null }) {
497
571
  const publicUrl = redactUrlQuery(url);
572
+ const publicFinalUrl = redactUrlQuery(finalUrl) || null;
498
573
  const analyticsPage = { page_id: "analytics", url: publicUrl || undefined };
499
574
  const assertions = assessAnalyticsInventory(capture, contract || {}, { url: publicUrl });
500
575
  assertions.unshift(assertion({
@@ -503,18 +578,63 @@ function analyticsCorrectnessCaptureAssertions({ capture, contract, url }) {
503
578
  page: analyticsPage,
504
579
  status: STATUS.PASS,
505
580
  expected: "live dataLayer + tag-fire capture on the candidate page",
506
- actual: `events=${capture.eventNames.length}, tags=${Object.values(capture.inventory).flat().length}`,
581
+ actual: `events=${capture.eventNames.length}, tags=${Object.values(capture.inventory).flat().length}`
582
+ + (rootFallback ? ` (captured built entry ${publicUrl}; campaign root ${rootFallback.reason === "non_2xx" ? `answered HTTP ${rootFallback.http_status}` : "is out of the built scope"})` : ""),
507
583
  // Counts only. Root capture is provider/tag inventory, never Purchase
508
584
  // authority, even if a stray Purchase happens to appear there.
509
585
  evidence: {
510
586
  url: publicUrl,
511
587
  event_count: capture.eventNames.length,
512
588
  inventory: Object.fromEntries(Object.entries(capture.inventory).map(([k, v]) => [k, v.length])),
589
+ // The page URL after redirects and settling; `capture_page.url` and
590
+ // `url` are the URL requested.
591
+ final_url: publicFinalUrl,
592
+ ...(capturePage ? { capture_page: capturePage } : {}),
593
+ ...(rootFallback ? { root_fallback: rootFallback } : {}),
513
594
  },
514
595
  }));
515
596
  return assertions;
516
597
  }
517
598
 
599
+ // No page was captured. Two cases, kept apart so a failed capture never
600
+ // silently removes analytics gating:
601
+ // - nothing built was capturable (the root is out of the built scope and no
602
+ // built entry exists): SKIPPED, disposition-neutral, since there is no page
603
+ // whose tags could be measured;
604
+ // - candidates existed but none answered 2xx (e.g. transient 503s): the
605
+ // declared vendors went unmeasured, which is a blocker naming each attempt,
606
+ // so a later successful order cannot report the run ready.
607
+ // The parity leg (#503) reports the same two outcomes under its own family.
608
+ function analyticsCorrectnessNoCapturePageAssertion({ rootUrl, attempts, family = "analytics-correctness" }) {
609
+ const publicUrl = redactUrlQuery(rootUrl);
610
+ const page = { page_id: "analytics", url: publicUrl || undefined };
611
+ const expected = "live dataLayer + tag-fire capture on the campaign root or the first built in-scope page";
612
+ const loaded = attempts.filter((attempt) => attempt.outcome === "non_2xx" || attempt.outcome === "navigation_error");
613
+ if (!loaded.length) {
614
+ return assertion({
615
+ id: `${family}:capture`,
616
+ family,
617
+ page,
618
+ status: STATUS.SKIPPED,
619
+ expected,
620
+ actual: "no_in_scope_page_captured: the campaign root is out of the built scope and the build has no in-scope entry page to capture",
621
+ evidence: { url: publicUrl, reason: "no_in_scope_page_captured", attempts },
622
+ });
623
+ }
624
+ return assertion({
625
+ id: `${family}:capture`,
626
+ family,
627
+ page,
628
+ status: STATUS.FAIL,
629
+ severity: SEVERITY.BLOCKER,
630
+ expected,
631
+ actual: `no_capture_page_answered: declared analytics went unmeasured; ${loaded.map((attempt) => (attempt.outcome === "navigation_error"
632
+ ? `${attempt.url} failed to load (${attempt.error_code})`
633
+ : `${attempt.url} answered HTTP ${attempt.http_status}`)).join(", ")}`,
634
+ evidence: { url: publicUrl, reason: "no_capture_page_answered", attempts },
635
+ });
636
+ }
637
+
518
638
  function analyticsCorrectnessRunnerFailureAssertion({ url, error }) {
519
639
  const publicUrl = redactUrlQuery(url);
520
640
  const captureError = projectAnalyticsCaptureError(error, { fallbackKind: "unreadable" });
@@ -530,6 +650,147 @@ function analyticsCorrectnessRunnerFailureAssertion({ url, error }) {
530
650
  });
531
651
  }
532
652
 
653
+ function isHttpOk(status) {
654
+ // A null status (same-document or non-HTTP navigation) is not evidence of
655
+ // a missing page, so it keeps the capture.
656
+ return status == null || (status >= 200 && status < 300);
657
+ }
658
+
659
+ // One capture per page: `/campaign`, `/campaign/` and `/campaign/index.html`
660
+ // are the same page, so a topology URL written differently from the root is
661
+ // not loaded twice.
662
+ // #503: step routing is path-based in every certified family (page-kit builds
663
+ // each page to its own `<route>/index.html`; a spec route has its query
664
+ // stripped), so a query string does not normally name a different page. A
665
+ // URL that declares its own query (`/campaign/?step=checkout`) is still kept
666
+ // apart from a page without that query: merging it into the root on path alone
667
+ // measured the root's generic answer instead of that entry. A URL without a
668
+ // query of its own names the page on any query (an operator's `?preview=` on
669
+ // the root does not split it from the built page at that path).
670
+ function analyticsCapturePageKey(value) {
671
+ try {
672
+ const parsed = new URL(value);
673
+ const path = parsed.pathname.replace(/(?:^|\/)index\.html$/, "/").replace(/\/+$/, "");
674
+ parsed.searchParams.sort();
675
+ return { path: `${parsed.origin}${path}/`, query: parsed.searchParams.toString() };
676
+ } catch {
677
+ return typeof value === "string" && value.trim() ? { path: redactUrlQuery(value), query: "" } : null;
678
+ }
679
+ }
680
+
681
+ // True when `candidate` is the page `known` already names (see
682
+ // analyticsCapturePageKey). Shared with qa-node's root-in-scope judgment so
683
+ // scope and deduplication cannot disagree about which page is the root.
684
+ export function isSameAnalyticsCapturePage(candidate, known) {
685
+ const a = analyticsCapturePageKey(candidate);
686
+ const b = analyticsCapturePageKey(known);
687
+ if (!a || !b || a.path !== b.path) return false;
688
+ return !a.query || a.query === b.query;
689
+ }
690
+
691
+ // A URL on `root`'s path told apart from it by a query of its own: the entry
692
+ // `isSameAnalyticsCapturePage` keeps separate from the root although its
693
+ // redacted URL reads as the root.
694
+ function isQueryRoutedFrom(value, root) {
695
+ const a = analyticsCapturePageKey(value);
696
+ const b = analyticsCapturePageKey(root);
697
+ return !!a && !!b && a.path === b.path && !!a.query && a.query !== b.query;
698
+ }
699
+
700
+ function analyticsCaptureCandidates(url, options = {}) {
701
+ const candidates = [];
702
+ const seen = [];
703
+ const add = (candidate) => {
704
+ if (!candidate.url || seen.some((known) => isSameAnalyticsCapturePage(candidate.url, known))) return;
705
+ seen.push(candidate.url);
706
+ candidates.push(candidate);
707
+ };
708
+ const rootInScope = options.rootInScope !== false;
709
+ if (rootInScope) add({ url, source: "campaign_root" });
710
+ // An out-of-scope root stays unvisited even if a fallback names it.
711
+ else if (url) seen.push(url);
712
+ for (const entry of Array.isArray(options.fallbackTargets) ? options.fallbackTargets : []) {
713
+ if (!entry || !trim(entry.url)) continue;
714
+ add({
715
+ url: trim(entry.url),
716
+ source: "built_entry",
717
+ page_id: entry.page_id || null,
718
+ funnel_id: entry.funnel_id || null,
719
+ // The query value is redacted from every record; this says the entry
720
+ // was told apart from the root by its query alone.
721
+ ...(url && isQueryRoutedFrom(trim(entry.url), url) ? { query_routed: true } : {}),
722
+ });
723
+ }
724
+ return { candidates, rootInScope, rootKey: redactUrlQuery(url) };
725
+ }
726
+
727
+ // Walks the capture candidates (the campaign root when in scope, then the
728
+ // built entries) and captures the first page that answers 2xx. Shared by the
729
+ // correctness inventory (#493) and the opt-in parity candidate (#503) so both
730
+ // legs pick the same page. Returns `{ capture, url, capturePage, rootFallback }`
731
+ // for the page it used, or `{ capture: null, attempts }` when none answered.
732
+ async function captureFirstAnsweringAnalyticsPage(context, url, args, extraHosts, options = {}, { strictCollect = false } = {}) {
733
+ const { candidates, rootInScope, rootKey } = analyticsCaptureCandidates(url, options);
734
+ const attempts = [];
735
+ if (!rootInScope) attempts.push({ url: rootKey, source: "campaign_root", outcome: "out_of_built_scope", http_status: null });
736
+ let rootFallback = rootInScope ? null : { url: rootKey, reason: "out_of_built_scope", http_status: null };
737
+ const queryRouted = (candidate) => (candidate.query_routed ? { query_routed: true } : {});
738
+ for (const candidate of candidates) {
739
+ const { capture, httpStatus, navigationError } = await captureAnalyticsPage(
740
+ context, candidate.url, args, extraHosts, { perPageNavigationErrors: true, strictCollect },
741
+ );
742
+ if (navigationError) {
743
+ // One page failing to load (a timeout, a refused connection) moves on to
744
+ // the next candidate; the blocker lists it if none answers.
745
+ attempts.push({ url: redactUrlQuery(candidate.url), source: candidate.source, ...queryRouted(candidate), outcome: "navigation_error", http_status: null, error_code: navigationError });
746
+ if (candidate.source === "campaign_root") {
747
+ rootFallback = { url: rootKey, reason: "navigation_error", http_status: null, error_code: navigationError };
748
+ }
749
+ continue;
750
+ }
751
+ if (isHttpOk(httpStatus)) {
752
+ const usedFallback = candidate.source !== "campaign_root";
753
+ return {
754
+ capture,
755
+ url: candidate.url,
756
+ capturePage: {
757
+ url: redactUrlQuery(candidate.url),
758
+ source: candidate.source,
759
+ ...(candidate.page_id ? { page_id: candidate.page_id } : {}),
760
+ ...(candidate.funnel_id ? { funnel_id: candidate.funnel_id } : {}),
761
+ ...queryRouted(candidate),
762
+ http_status: httpStatus ?? null,
763
+ },
764
+ rootFallback: usedFallback ? rootFallback : null,
765
+ };
766
+ }
767
+ attempts.push({ url: redactUrlQuery(candidate.url), source: candidate.source, ...queryRouted(candidate), outcome: "non_2xx", http_status: httpStatus });
768
+ if (candidate.source === "campaign_root") {
769
+ rootFallback = { url: rootKey, reason: "non_2xx", http_status: httpStatus };
770
+ }
771
+ }
772
+ return { capture: null, attempts };
773
+ }
774
+
775
+ // Browser-owning half split out so a real-browser test can drive the fallback
776
+ // against a route-fulfilled context without a second Chromium launch policy.
777
+ async function captureAnalyticsCorrectnessInContext(context, url, contract, args, extraHosts, options = {}) {
778
+ // strictCollect: a page.evaluate() that fails during collection (the
779
+ // execution context destroyed by a reload, a crashed page) must not read
780
+ // as a clean empty capture. It propagates and becomes the
781
+ // analytics-correctness:runner blocker, so no tag check is emitted from an
782
+ // unmeasured page and local-serve review has nothing to downgrade (#500).
783
+ const selected = await captureFirstAnsweringAnalyticsPage(context, url, args, extraHosts, options, { strictCollect: true });
784
+ if (!selected.capture) return [analyticsCorrectnessNoCapturePageAssertion({ rootUrl: url, attempts: selected.attempts })];
785
+ return analyticsCorrectnessCaptureAssertions({
786
+ capture: selected.capture,
787
+ contract,
788
+ url: selected.url,
789
+ capturePage: selected.capturePage,
790
+ rootFallback: selected.rootFallback,
791
+ });
792
+ }
793
+
533
794
  export async function runAnalyticsCorrectnessChecks(args = {}, contract = {}, options = {}) {
534
795
  const url = trim(options.target?.url) || null;
535
796
  const correctnessPage = { page_id: "analytics", url: redactUrlQuery(url) || undefined };
@@ -545,12 +806,7 @@ export async function runAnalyticsCorrectnessChecks(args = {}, contract = {}, op
545
806
  })];
546
807
  }
547
808
 
548
- // Seed the host filter with declared out-of-band vendor names so vendors whose
549
- // host contains their name (everflow, northbeam, …) get captured.
550
- const vendorHosts = ((contract && contract.out_of_band_pixels) || [])
551
- .map((p) => (p && p.vendor ? String(p.vendor) : null))
552
- .filter(Boolean);
553
- const extraHosts = [...analyticsExtraHosts(args), ...vendorHosts];
809
+ const extraHosts = analyticsCorrectnessExtraHosts(args, contract);
554
810
 
555
811
  const browser = await launchChromium(args);
556
812
  const context = await browser.newContext({
@@ -558,8 +814,7 @@ export async function runAnalyticsCorrectnessChecks(args = {}, contract = {}, op
558
814
  extraHTTPHeaders: args["auth-cookie"] ? { Cookie: String(args["auth-cookie"]) } : undefined,
559
815
  });
560
816
  try {
561
- const capture = await captureAnalyticsForUrl(context, url, args, extraHosts);
562
- return analyticsCorrectnessCaptureAssertions({ capture, contract, url });
817
+ return await captureAnalyticsCorrectnessInContext(context, url, contract, args, extraHosts, options);
563
818
  } catch (error) {
564
819
  return [analyticsCorrectnessRunnerFailureAssertion({ url, error })];
565
820
  } finally {
@@ -568,6 +823,15 @@ export async function runAnalyticsCorrectnessChecks(args = {}, contract = {}, op
568
823
  }
569
824
  }
570
825
 
826
+ // Seed the host filter with declared out-of-band vendor names so vendors whose
827
+ // host contains their name (everflow, northbeam, …) get captured.
828
+ function analyticsCorrectnessExtraHosts(args, contract) {
829
+ const vendorHosts = ((contract && contract.out_of_band_pixels) || [])
830
+ .map((p) => (p && p.vendor ? String(p.vendor) : null))
831
+ .filter(Boolean);
832
+ return [...analyticsExtraHosts(args), ...vendorHosts];
833
+ }
834
+
571
835
  function analyticsExtraHosts(args) {
572
836
  const raw = args["analytics-hosts"];
573
837
  if (!raw) return [];
@@ -575,6 +839,22 @@ function analyticsExtraHosts(args) {
575
839
  }
576
840
 
577
841
  export async function captureAnalyticsForUrl(context, url, args, extraHosts = []) {
842
+ return (await captureAnalyticsPage(context, url, args, extraHosts)).capture;
843
+ }
844
+
845
+ // Same capture, plus the main-document HTTP status, which the correctness leg
846
+ // uses to tell an empty page from a missing one (#493). Kept off the capture
847
+ // object so parity comparisons never see it.
848
+ // A stable code for a failed navigation: Playwright's TimeoutError, or the
849
+ // net::ERR_* token Chromium reports. The raw message is dropped because it
850
+ // echoes the URL (query included) and a call log.
851
+ function analyticsNavigationErrorCode(error) {
852
+ if (error?.name === "TimeoutError") return "navigation_timeout";
853
+ const netError = String(error?.message || "").match(/net::ERR_[A-Z0-9_]+/);
854
+ return netError ? netError[0] : "navigation_failed";
855
+ }
856
+
857
+ async function captureAnalyticsPage(context, url, args, extraHosts = [], options = {}) {
578
858
  const page = await context.newPage();
579
859
  const capture = await attachAnalyticsCapture(page, { extraHosts });
580
860
  const timeoutMs = numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS);
@@ -583,11 +863,27 @@ export async function captureAnalyticsForUrl(context, url, args, extraHosts = []
583
863
  // domcontentloaded (not "load") so a single stuck analytics beacon — exactly
584
864
  // the kind of subresource we're capturing — can't starve the goto timeout.
585
865
  // Mirrors runPageBrowserChecks; the settle wait below lets async tags fire.
586
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: timeoutMs });
866
+ let response;
867
+ try {
868
+ response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: timeoutMs });
869
+ } catch (error) {
870
+ // Only the navigation is per-page. A closed page or a disconnected
871
+ // browser is the runner failing, not this URL, so it stays an error.
872
+ if (!options.perPageNavigationErrors || page.isClosed() || context.browser()?.isConnected() === false) throw error;
873
+ return { capture: null, httpStatus: null, navigationError: analyticsNavigationErrorCode(error) };
874
+ }
875
+ let httpStatus = null;
876
+ try { httpStatus = typeof response?.status === "function" ? response.status() : null; } catch { httpStatus = null; }
587
877
  await page.waitForLoadState("networkidle", { timeout: settleMs }).catch(() => {});
588
878
  // Let async GTM/pixel tags and deferred dataLayer pushes fire before reading.
589
879
  await page.waitForTimeout(settleMs);
590
- return await capture.collect();
880
+ // Parity keeps the historical best-effort read; the correctness leg
881
+ // collects strictly (see captureAnalyticsCorrectnessInContext).
882
+ const collected = await capture.collect({ strict: options.strictCollect === true });
883
+ let finalUrl = null;
884
+ try { finalUrl = page.url() || null; } catch { finalUrl = null; }
885
+ if (collected && typeof collected === "object" && finalUrl) ANALYTICS_CAPTURE_DOCUMENT_URL.set(collected, finalUrl);
886
+ return { capture: collected, httpStatus };
591
887
  } finally {
592
888
  capture.detach();
593
889
  await page.close().catch(() => {});
@@ -637,7 +933,9 @@ async function runPageBrowserChecks(context, page, args, options = {}) {
637
933
  const pageErrors = [];
638
934
  const failedRequests = [];
639
935
  browserPage.on("console", (message) => {
640
- if (message.type() === "error") consoleErrors.push(trim(message.text()));
936
+ // The location URL is kept beside the text: a "Failed to load resource"
937
+ // message names its status but not the request, which is the location.
938
+ if (message.type() === "error") consoleErrors.push({ text: trim(message.text()), url: message.location?.()?.url || null });
641
939
  });
642
940
  browserPage.on("pageerror", (error) => pageErrors.push(trim(error.message)));
643
941
  browserPage.on("requestfailed", (request) => {
@@ -744,16 +1042,65 @@ function isIgnorableFailedRequest(request) {
744
1042
  }
745
1043
  }
746
1044
 
1045
+ // `messages` are { text, url } records (url: the console message's location);
1046
+ // the result is the actionable messages' text.
747
1047
  async function actionableRuntimeConsoleErrors(browserPage, messages) {
748
1048
  if (!messages.length) return [];
749
1049
  const runtimeReady = await browserPage.evaluate(() => (
750
1050
  document.documentElement.classList.contains("next-display-ready")
751
1051
  || Boolean(window.next && Object.keys(window.next).length)
752
1052
  )).catch(() => false);
753
- return messages.filter((message) => {
754
- if (runtimeReady && isKnownSdkLoaderFalsePositive(message)) return false;
755
- return true;
756
- });
1053
+ // The page's final URL, after redirects: the host the browser actually
1054
+ // shows, not the host the page record requested.
1055
+ const pageUrl = browserPage.url();
1056
+ return messages
1057
+ .filter((message) => {
1058
+ if (runtimeReady && isKnownSdkLoaderFalsePositive(message.text)) return false;
1059
+ if (isNetlifyPreviewDrawerConsoleError(pageUrl, message)) return false;
1060
+ return true;
1061
+ })
1062
+ .map((message) => message.text);
1063
+ }
1064
+
1065
+ // Netlify injects its deploy-preview drawer (the collaboration toolbar) into
1066
+ // preview pages, and a failed drawer request is logged as a "Failed to load
1067
+ // resource" console error on every page (a 428 has been observed). That error
1068
+ // is Netlify's, not the campaign's.
1069
+ //
1070
+ // Each exempt request is an exact host plus an exact path:
1071
+ // netlify-cdp-loader.netlify.app /netlify.js — the drawer's loader script,
1072
+ // the one `<script src>` Netlify injects into preview HTML.
1073
+ // It is exempt only on a page whose final URL (after redirects) is a Netlify
1074
+ // preview host: any *.netlify.app host, or a deploy-preview subdomain on a
1075
+ // custom domain (deploy-preview-7.shop.example.com, or
1076
+ // deploy-preview-7--shop.example.com). Nothing else is exempt: any other path
1077
+ // on a Netlify host (app.netlify.com included), and the drawer's own URL on any
1078
+ // other page host, still counts. A "Failed to load resource" message names its
1079
+ // status but not the request; the request is the message's location URL.
1080
+ const NETLIFY_PREVIEW_DRAWER_REQUESTS = Object.freeze([
1081
+ { hostname: "netlify-cdp-loader.netlify.app", pathname: "/netlify.js" },
1082
+ ]);
1083
+
1084
+ // The first label of a deploy-preview host: `deploy-preview-<n>`, or
1085
+ // `deploy-preview-<n>--<site>` for a per-deploy host on a custom domain.
1086
+ // Only numeric ids match, deliberately: Netlify issues numeric deploy-preview
1087
+ // ids, and widening would hide real errors on any host named deploy-preview-*.
1088
+ const DEPLOY_PREVIEW_LABEL = /^deploy-preview-\d+(?:--[a-z0-9-]+)?$/;
1089
+
1090
+ function isNetlifyPreviewHost(hostname) {
1091
+ const labels = hostname.split(".");
1092
+ return hostname.endsWith(".netlify.app") || (labels.length > 2 && DEPLOY_PREVIEW_LABEL.test(labels[0]));
1093
+ }
1094
+
1095
+ function isNetlifyPreviewDrawerConsoleError(pageUrl, message) {
1096
+ if (!/^Failed to load resource\b/.test(String(message?.text || ""))) return false;
1097
+ try {
1098
+ const request = new URL(message.url);
1099
+ return isNetlifyPreviewHost(new URL(pageUrl).hostname)
1100
+ && NETLIFY_PREVIEW_DRAWER_REQUESTS.some((drawer) => drawer.hostname === request.hostname && drawer.pathname === request.pathname);
1101
+ } catch {
1102
+ return false;
1103
+ }
757
1104
  }
758
1105
 
759
1106
  function isKnownSdkLoaderFalsePositive(message) {
@@ -1211,13 +1558,126 @@ async function checkoutCommerceStructureAssertions(browserPage, page) {
1211
1558
  }
1212
1559
 
1213
1560
  const evidence = await inspectCommerceStructure(browserPage, contract);
1561
+ const behaviour = await inspectCheckoutBehaviour(browserPage);
1214
1562
  return [commerceStructureAssertionFromEvidence(page, {
1215
1563
  template_family: family,
1216
1564
  contract_status: contractStatus,
1217
1565
  ...evidence,
1566
+ behaviour,
1218
1567
  })];
1219
1568
  }
1220
1569
 
1570
+ // The checkout's wrapper and page composition belong to the campaign source,
1571
+ // not the template family (#532). A catalog rule is family shell when every
1572
+ // selector it reads is on this list: the layout wrapper and column classes the
1573
+ // pinned catalog uses, and the family include's shipping field row marker.
1574
+ // Every other selector is SDK wiring the checkout needs to work
1575
+ // (`[data-next-checkout]`, `[os-checkout-payment]`, `[data-next-cart-summary]`,
1576
+ // `[data-next-bundle-slots-for]`) and is never relaxed. That includes any
1577
+ // selector not listed here, even a bare class such as the hosted payment
1578
+ // field's `.input-flds`, so a catalog recorded in the packet cannot widen it.
1579
+ const FAMILY_SHELL_SELECTORS = Object.freeze([
1580
+ ".checkout-wrapper",
1581
+ ".checkout-layout__left",
1582
+ ".checkout-layout__right",
1583
+ ".checkout__layout",
1584
+ ".checkout__column--left",
1585
+ ".checkout__column--right",
1586
+ '[data-next-component="shipping-field-row"]',
1587
+ ]);
1588
+
1589
+ function isFamilyShellSelector(selector) {
1590
+ return FAMILY_SHELL_SELECTORS.includes(String(selector || "").trim());
1591
+ }
1592
+
1593
+ function isFamilyShellCheck(check) {
1594
+ const selectors = Array.isArray(check?.selectors) ? check.selectors : [];
1595
+ return selectors.length > 0 && selectors.every(isFamilyShellSelector);
1596
+ }
1597
+
1598
+ // What a checkout has to do, whoever composed it. The required fields are the
1599
+ // contact and shipping fields the test-order form fill types without the
1600
+ // optional flag (fillCheckoutFields): each must be a form control carrying
1601
+ // data-next-checkout-field inside a <form data-next-checkout="form">, and not a
1602
+ // type="hidden" input or a disabled control (its own attribute or a disabled
1603
+ // fieldset), since a customer cannot fill either. Visibility is not required:
1604
+ // progressive-reveal checkouts hide the address fields until a country is
1605
+ // chosen. The total is the cart-summary total the order-total parity check
1606
+ // reads.
1607
+ const CHECKOUT_FORM_SELECTOR = 'form[data-next-checkout="form"]';
1608
+ const CHECKOUT_BEHAVIOUR_REQUIRED_FIELDS = Object.freeze([
1609
+ "email",
1610
+ "fname",
1611
+ "lname",
1612
+ "country",
1613
+ "address1",
1614
+ "city",
1615
+ "province",
1616
+ "postal",
1617
+ ]);
1618
+
1619
+ function checkoutBehaviourProbeInput() {
1620
+ return {
1621
+ formSelector: CHECKOUT_FORM_SELECTOR,
1622
+ requiredFields: [...CHECKOUT_BEHAVIOUR_REQUIRED_FIELDS],
1623
+ totalSelectors: checkoutTotalSelectors(),
1624
+ };
1625
+ }
1626
+
1627
+ async function inspectCheckoutBehaviour(browserPage) {
1628
+ return browserPage.evaluate((input) => {
1629
+ const visible = (element) => {
1630
+ const rect = element.getBoundingClientRect();
1631
+ const style = getComputedStyle(element);
1632
+ return rect.width > 0
1633
+ && rect.height > 0
1634
+ && style.display !== "none"
1635
+ && style.visibility !== "hidden"
1636
+ && Number(style.opacity || "1") !== 0;
1637
+ };
1638
+ const forms = Array.from(document.querySelectorAll(input.formSelector));
1639
+ const controls = new Set(["INPUT", "SELECT", "TEXTAREA"]);
1640
+ // readonly has no effect on a select, so only an input or textarea loses it.
1641
+ const fillable = (field) => controls.has(field.tagName)
1642
+ && !(field.tagName === "INPUT" && field.type === "hidden")
1643
+ && !field.matches(":disabled")
1644
+ && !(field.tagName !== "SELECT" && field.hasAttribute("readonly"))
1645
+ && field.getAttribute("aria-disabled") !== "true";
1646
+ const missing = input.requiredFields.filter((name) => !forms.some((form) => Array
1647
+ .from(form.querySelectorAll("[data-next-checkout-field]"))
1648
+ .some((field) => field.getAttribute("data-next-checkout-field") === name && fillable(field))));
1649
+ const totals = input.totalSelectors.flatMap((selector) => {
1650
+ try {
1651
+ return Array.from(document.querySelectorAll(selector));
1652
+ } catch {
1653
+ return [];
1654
+ }
1655
+ });
1656
+ const visibleTotals = totals.filter((element) => visible(element) && (element.textContent || "").trim().length > 0);
1657
+ const checkoutForm = { selector: input.formSelector, count: forms.length, status: forms.length ? "pass" : "fail" };
1658
+ const fieldsBound = {
1659
+ required: input.requiredFields,
1660
+ missing,
1661
+ status: forms.length && !missing.length ? "pass" : "fail",
1662
+ };
1663
+ const totalVisible = {
1664
+ selectors: input.totalSelectors,
1665
+ count: totals.length,
1666
+ visible_count: visibleTotals.length,
1667
+ status: visibleTotals.length ? "pass" : "fail",
1668
+ };
1669
+ return {
1670
+ status: [checkoutForm, fieldsBound, totalVisible].every((check) => check.status === "pass") ? "pass" : "fail",
1671
+ checkout_form: checkoutForm,
1672
+ fields_bound: fieldsBound,
1673
+ total_visible: totalVisible,
1674
+ };
1675
+ }, checkoutBehaviourProbeInput()).catch((error) => ({
1676
+ status: "fail",
1677
+ error: error instanceof Error ? error.message : String(error),
1678
+ }));
1679
+ }
1680
+
1221
1681
  async function inspectCommerceStructure(browserPage, contract) {
1222
1682
  const safeContract = isPlainObject(contract) ? contract : {};
1223
1683
  const checks = await browserPage.evaluate((input) => {
@@ -1302,18 +1762,28 @@ function commerceStructureAssertionFromEvidence(page, evidence) {
1302
1762
  evidence,
1303
1763
  });
1304
1764
  }
1305
- const failed = checks.filter((check) => check.status === "fail");
1765
+ const classified = checks.map((check) => ({ ...check, kind: isFamilyShellCheck(check) ? "family_shell" : "sdk_wiring" }));
1766
+ const failed = classified.filter((check) => check.status === "fail");
1767
+ // A missing family-shell selector is a warning only when nothing else failed
1768
+ // and the behaviour probe ran and passed; absent or failing behaviour
1769
+ // evidence keeps the row a failure.
1770
+ const shellOnly = failed.length > 0 && failed.every((check) => check.kind === "family_shell");
1771
+ const behaviourPassed = evidence?.behaviour?.status === "pass";
1772
+ const relaxed = shellOnly && behaviourPassed;
1773
+ const missing = `${checks.length - failed.length}/${checks.length} structure check(s) passed; missing ${failed.map((check) => check.name).join(", ")}`;
1306
1774
  return assertion({
1307
1775
  id: `browser-commerce-structure:${page.page_id}`,
1308
1776
  family: "browser-runtime",
1309
1777
  page,
1310
- status: failed.length ? STATUS.FAIL : STATUS.PASS,
1778
+ status: !failed.length ? STATUS.PASS : relaxed ? STATUS.WARN : STATUS.FAIL,
1311
1779
  severity: failed.length ? SEVERITY.WARN : undefined,
1312
1780
  expected: "rendered checkout conforms to the selected template-family commerce structure contract",
1313
- actual: failed.length
1314
- ? `${checks.length - failed.length}/${checks.length} structure check(s) passed; missing ${failed.map((check) => check.name).join(", ")}`
1315
- : `${checks.length}/${checks.length} structure check(s) passed`,
1316
- evidence,
1781
+ actual: !failed.length
1782
+ ? `${checks.length}/${checks.length} structure check(s) passed`
1783
+ : relaxed
1784
+ ? `${missing}; the checkout form, bound fields and visible total pass, so the missing family shell is source-owned`
1785
+ : missing,
1786
+ evidence: { ...evidence, checks: classified },
1317
1787
  });
1318
1788
  }
1319
1789
 
@@ -1732,7 +2202,9 @@ async function templateResidueAssertions(browserPage, page, options = {}) {
1732
2202
  if (chrome && Array.isArray(supported) && supported.length) {
1733
2203
  const unsupported = (chrome.methods || []).filter((method) => !supported.includes(method));
1734
2204
  if (unsupported.length) {
1735
- const html = await browserPage.content().catch(() => "");
2205
+ // Logos the template still keeps hidden (payment-logos.html) are gated,
2206
+ // not residue; a revealed one stays in the HTML and is judged below.
2207
+ const html = withoutHiddenPaymentLogos(await browserPage.content().catch(() => ""));
1736
2208
  // One evaluate for ALL unsupported methods' selectors; partition the
1737
2209
  // visibility results per method in JS to keep browser round-trips flat.
1738
2210
  const artifactsByMethod = new Map(unsupported.map((method) => [method, methodPaymentArtifacts(chrome, method)]));
@@ -2313,13 +2785,13 @@ async function pricingVisibilityAssertions(browserPage, page, options = {}) {
2313
2785
  if (!surfaces) return [];
2314
2786
  const pageType = contractPageType(page);
2315
2787
  if (["upsell", "downsell"].includes(pageType)) {
2316
- const selectors = surfaces.upsell?.price_row_selectors || [];
2788
+ const selectors = withSdkPriceDisplaySelectors(surfaces.upsell?.price_row_selectors);
2317
2789
  if (!selectors.length) return [];
2318
2790
  const visibleCount = await countVisiblePriceRows(browserPage, selectors);
2319
2791
  return [upsellPriceVisibilityAssertion({ page, selectors, visibleCount })];
2320
2792
  }
2321
2793
  if (pageType === "checkout") {
2322
- const selectors = surfaces.checkout_bundle?.price_row_selectors || [];
2794
+ const selectors = withSdkPriceDisplaySelectors(surfaces.checkout_bundle?.price_row_selectors);
2323
2795
  // Two price surfaces satisfy this check. The contract's bundle price rows
2324
2796
  // are one; the rendered cart-summary total is the other — the same
2325
2797
  // selectors the order-total parity check reads at submit. A family that
@@ -2338,11 +2810,29 @@ async function pricingVisibilityAssertions(browserPage, page, options = {}) {
2338
2810
  return [];
2339
2811
  }
2340
2812
 
2813
+ // The SDK renders a bundle or upsell price through data-next-bundle-display as
2814
+ // well as data-next-display (#532). A contract surface that lists price rows
2815
+ // also counts that attribute; an empty list stays empty so a contract that
2816
+ // declares no rows keeps skipping the count.
2817
+ const SDK_BUNDLE_PRICE_DISPLAY_SELECTOR = "[data-next-bundle-display*='price']";
2818
+
2819
+ function withSdkPriceDisplaySelectors(selectors) {
2820
+ const list = Array.isArray(selectors) ? selectors : [];
2821
+ if (!list.length || list.includes(SDK_BUNDLE_PRICE_DISPLAY_SELECTOR)) return list;
2822
+ return [...list, SDK_BUNDLE_PRICE_DISPLAY_SELECTOR];
2823
+ }
2824
+
2341
2825
  // Visible = non-zero bounding box, display != none, visibility != hidden. This
2342
2826
  // is what caught nothing in the dogfood run: a campaign CSS rule display:none'd
2343
2827
  // the only price row on a full-price upsell and 48/48 checks still passed.
2828
+ // A bundle-display node is the price text itself, so it also needs
2829
+ // non-whitespace text: a sized but empty node is a price the SDK never filled.
2830
+ // The rule follows the node's attribute, not the selector that reached it
2831
+ // first, so a bundle-display node that also carries `.price-wrapper` still
2832
+ // needs text.
2344
2833
  async function countVisiblePriceRows(browserPage, selectors) {
2345
- return browserPage.evaluate((targets) => {
2834
+ return browserPage.evaluate(({ targets, bundlePriceSelector }) => {
2835
+ const textRequired = (element) => element.matches(bundlePriceSelector);
2346
2836
  const visible = (element) => {
2347
2837
  const rect = element.getBoundingClientRect();
2348
2838
  const style = getComputedStyle(element);
@@ -2355,14 +2845,16 @@ async function countVisiblePriceRows(browserPage, selectors) {
2355
2845
  for (const element of document.querySelectorAll(selector)) {
2356
2846
  if (seen.has(element)) continue;
2357
2847
  seen.add(element);
2358
- if (visible(element)) count += 1;
2848
+ if (!visible(element)) continue;
2849
+ if (textRequired(element) && !(element.textContent || "").trim()) continue;
2850
+ count += 1;
2359
2851
  }
2360
2852
  } catch {
2361
2853
  // invalid selector: contract bug surfaced elsewhere
2362
2854
  }
2363
2855
  }
2364
2856
  return count;
2365
- }, selectors).catch(() => 0);
2857
+ }, { targets: selectors, bundlePriceSelector: SDK_BUNDLE_PRICE_DISPLAY_SELECTOR }).catch(() => 0);
2366
2858
  }
2367
2859
 
2368
2860
  // Page-scoped like every other per-page row (`template-residue:<page>:…`,
@@ -2776,6 +3268,11 @@ async function collectOrderAnalytics({
2776
3268
  deadline,
2777
3269
  now = () => Date.now(),
2778
3270
  wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
3271
+ // The page's document location, read right after the settled collection
3272
+ // (#500). The order's final_url is recorded before this settle window, so a
3273
+ // receipt that redirects while analytics settle would otherwise be judged
3274
+ // by where it started, not where Purchase was measured.
3275
+ readDocumentUrl = null,
2779
3276
  }) {
2780
3277
  const result = {};
2781
3278
  if (!captureHandle) return result;
@@ -2806,14 +3303,17 @@ async function collectOrderAnalytics({
2806
3303
 
2807
3304
  const collection = deadlineConsumed
2808
3305
  ? { timedOut: true }
2809
- : await runWithinAnalyticsDeadline(async () => (
2810
- typeof captureHandle.collectScopes === "function"
2811
- ? captureHandle.collectScopes({ strict: true })
3306
+ : await runWithinAnalyticsDeadline(async () => {
3307
+ const scopes = typeof captureHandle.collectScopes === "function"
3308
+ ? await captureHandle.collectScopes({ strict: true })
2812
3309
  : {
2813
3310
  journey: await captureHandle.collect({ strict: true }),
2814
3311
  currentDocument: await captureHandle.collect({ strict: true, scope: "current-document" }),
2815
- }
2816
- ), { deadline, now });
3312
+ };
3313
+ let documentUrl = null;
3314
+ try { documentUrl = typeof readDocumentUrl === "function" ? (readDocumentUrl() || null) : null; } catch { documentUrl = null; }
3315
+ return { ...scopes, documentUrl };
3316
+ }, { deadline, now });
2817
3317
  if (collection.timedOut || now() > deadline) {
2818
3318
  result.journeyCaptureError = analyticsCaptureError("collectionDeadline");
2819
3319
  if (receiptRecognized && !settleError) {
@@ -2824,7 +3324,10 @@ async function collectOrderAnalytics({
2824
3324
  if (receiptRecognized && !settleError) result.receiptCaptureError = analyticsCaptureError("unreadable");
2825
3325
  } else {
2826
3326
  result.journeyCapture = collection.value.journey;
2827
- if (receiptRecognized && !settleError) result.receiptCapture = collection.value.currentDocument;
3327
+ if (receiptRecognized && !settleError) {
3328
+ result.receiptCapture = collection.value.currentDocument;
3329
+ if (collection.value.documentUrl) result.receiptDocumentUrl = collection.value.documentUrl;
3330
+ }
2828
3331
  }
2829
3332
  if (receiptRecognized && settleError) result.receiptCaptureError = settleError;
2830
3333
  return result;
@@ -2952,11 +3455,13 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
2952
3455
  receiptRecognized,
2953
3456
  settleMs: numberArg(planArgs["analytics-settle"], DEFAULT_SETTLE_TIMEOUT_MS),
2954
3457
  deadline: orderDeadline ?? Date.now(),
3458
+ readDocumentUrl: () => (page ? safePageUrl(page) : null),
2955
3459
  });
2956
3460
  if (captures.journeyCapture) result.analytics_journey_capture = captures.journeyCapture;
2957
3461
  if (captures.journeyCaptureError) result.analytics_journey_capture_error = captures.journeyCaptureError;
2958
3462
  if (captures.receiptCapture) result.receipt_analytics_capture = captures.receiptCapture;
2959
3463
  if (captures.receiptCaptureError) result.receipt_analytics_capture_error = captures.receiptCaptureError;
3464
+ if (captures.receiptDocumentUrl) result.receipt_document_url = captures.receiptDocumentUrl;
2960
3465
  } else if (analyticsAttachError) {
2961
3466
  result.analytics_journey_capture_error = analyticsAttachError;
2962
3467
  if (receiptRecognized) result.receipt_analytics_capture_error = analyticsAttachError;
@@ -3089,6 +3594,11 @@ function receiptAnalyticsAttempt(plan, result) {
3089
3594
  planId: id,
3090
3595
  receiptRecognized,
3091
3596
  receiptUrl: redactUrlQuery(finalUrl),
3597
+ // Where the receipt page actually was once its analytics settled and were
3598
+ // collected; absent when that reading was not taken.
3599
+ ...(receiptRecognized && result?.receipt_document_url
3600
+ ? { receiptDocumentUrl: redactUrlQuery(result.receipt_document_url) }
3601
+ : {}),
3092
3602
  ...(receiptRecognized && result?.receipt_analytics_capture
3093
3603
  ? { capture: result.receipt_analytics_capture }
3094
3604
  : {}),
@@ -3260,19 +3770,29 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3260
3770
  }
3261
3771
  const step = upsellSteps[stepIndex];
3262
3772
  const actionTrace = createUpsellActionTrace({ page, events, topologyPlan, stepIndex, path: step });
3773
+ const stepBudgetMs = budget();
3774
+ const stepStartedMs = Date.now();
3263
3775
  await ladder.run("upsell_action", async () => {
3264
3776
  const initialLineItems = order.receipt_line_items.slice();
3265
3777
  const initialUpsellMutationCount = upsellMutationCount(events);
3266
3778
  await actionTrace.inspect();
3267
3779
  await waitForUpsellPageReady(page, args);
3268
3780
  await actionTrace.inspect();
3781
+ const responseIndexBefore = events.responses.length;
3269
3782
  const upsell = await clickUpsellPath(page, step, { events, stepIndex, trace: actionTrace });
3270
3783
  const preferredOrderBody = upsell.api_response_order_body || null;
3271
3784
  delete upsell.api_response_order_body;
3272
3785
  order.upsell = upsell;
3273
3786
  order.upsell_steps.push(upsell);
3274
3787
  order.final_url = safePageUrl(page);
3275
- const refreshed = await buildOrderEvidence({ page, events, path, email, checkoutPage, args, preferredOrderBody });
3788
+ const { refreshed, lateUpsellEvidence } = await refreshUpsellStepEvidence({
3789
+ page, events, path, email, checkoutPage, args, step, upsell, preferredOrderBody, initialLineItems, responseIndexBefore,
3790
+ // Keep the rest of the step's budget for the read-back after the wait.
3791
+ lateWaitMs: Math.min(
3792
+ LATE_UPSELL_EVIDENCE_TIMEOUT_MS,
3793
+ stepBudgetMs - (Date.now() - stepStartedMs) - LATE_UPSELL_EVIDENCE_RESERVE_MS,
3794
+ ),
3795
+ });
3276
3796
  order.final_receipt_line_items = refreshed.receipt_line_items;
3277
3797
  if (refreshed.receipt_line_items.length) {
3278
3798
  order.cart_state = refreshed.cart_state;
@@ -3284,9 +3804,9 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3284
3804
  order.verification.currency = refreshed.verification.currency;
3285
3805
  }
3286
3806
  if (step === "accept") {
3287
- const proof = acceptedUpsellProof(order.receipt_line_items, initialLineItems, upsell.expected_items, events);
3807
+ const proof = acceptedUpsellStepProof(upsell, lateUpsellEvidence, acceptedUpsellProof(order.receipt_line_items, initialLineItems, upsell.expected_items, events));
3288
3808
  upsell.verification = {
3289
- accepted_upsell_line_present: proof.ok,
3809
+ accepted_upsell_line_present: proof.unverified ? null : proof.ok,
3290
3810
  accepted_upsell_match: proof,
3291
3811
  upsell_api_response_seen: upsell.api_response_seen,
3292
3812
  upsell_api_response_status: upsell.api_response_status,
@@ -3304,7 +3824,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3304
3824
  actionTrace.markStepCompleted();
3305
3825
  return `step ${stepIndex + 1}: ${step}`;
3306
3826
  }, {
3307
- timeoutMs: budget(),
3827
+ timeoutMs: stepBudgetMs,
3308
3828
  evidence: actionTrace.summary,
3309
3829
  formatError: actionTrace.formatError,
3310
3830
  });
@@ -3336,7 +3856,19 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3336
3856
 
3337
3857
  const acceptedSteps = (order.upsell_steps || []).filter((step) => step.path === "accept");
3338
3858
  if (acceptedSteps.length) {
3339
- order.verification.accepted_upsell_line_present = acceptedSteps.every((step) => step.verification?.accepted_upsell_line_present === true);
3859
+ const unverifiedSteps = acceptedSteps.filter((step) => step.verification?.accepted_upsell_match?.unverified === true);
3860
+ const confirmedMissing = acceptedSteps.some((step) => step.verification?.accepted_upsell_line_present === false);
3861
+ // Unknown, not false, when every step that is not proven is only
3862
+ // unverified: false would read as a confirmed missing line.
3863
+ order.verification.accepted_upsell_line_present = unverifiedSteps.length && !confirmedMissing
3864
+ ? null
3865
+ : acceptedSteps.every((step) => step.verification?.accepted_upsell_line_present === true);
3866
+ if (unverifiedSteps.length) {
3867
+ order.verification.upsell_unverified = unverifiedSteps.map((step) => step.verification.accepted_upsell_match.reason);
3868
+ // Whatever else the path finds, an order whose accepted upsell nothing
3869
+ // proved is not a verified order (#505).
3870
+ order.verification.verified = false;
3871
+ }
3340
3872
  order.verification.upsell_api_response_seen = acceptedSteps.every((step) => step.verification?.upsell_api_response_seen === true);
3341
3873
  order.verification.accepted_upsell_matches = acceptedSteps.map((step) => step.verification?.accepted_upsell_match).filter(Boolean);
3342
3874
  }
@@ -3362,10 +3894,20 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3362
3894
  }
3363
3895
 
3364
3896
  const pathFailures = [...stepFailures, ...receiptFailures, ...couponFailures];
3365
- const ok = order.ok && pathFailures.length === 0;
3897
+ const clean = order.ok && pathFailures.length === 0;
3898
+ // A path whose only open question is an unverified accepted upsell is not
3899
+ // a pass: nothing proved the line was added. `ok` stays false so no reader
3900
+ // of the result or the order takes it as proven, and `upsell_unverified`
3901
+ // tells it apart from a failure: the dispatcher neither re-runs it (that
3902
+ // would buy a second order) nor recovers it, and the assertion reports it
3903
+ // for manual review (#505). The order itself was created and read back, so
3904
+ // order.ok keeps saying so; only its verification drops to unverified.
3905
+ const upsellUnverified = clean ? order.verification.upsell_unverified || null : null;
3906
+ const ok = clean && !upsellUnverified;
3366
3907
  return {
3367
3908
  ok,
3368
- error: ok ? null : order.error || order.upsell?.error || pathFailures.join("; ") || "accepted upsell did not appear in final order lines",
3909
+ ...(upsellUnverified ? { upsell_unverified: upsellUnverified } : {}),
3910
+ error: clean ? null : order.error || order.upsell?.error || pathFailures.join("; ") || "accepted upsell did not appear in final order lines",
3369
3911
  order,
3370
3912
  events: sanitizedEvents(events),
3371
3913
  };
@@ -3466,10 +4008,8 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
3466
4008
  // The index is the control's position among what its own locator matches,
3467
4009
  // so it replays with nth(); no selector is rebuilt from an attribute value.
3468
4010
  const target = page.locator(control.kind === "add_to_cart" ? CART_ENTRY_CONTROL_SELECTOR : "a[href]").nth(control.index);
3469
- await target.scrollIntoViewIfNeeded().catch(() => {});
3470
- await target.click({ timeout: 8000 }).catch(async () => {
3471
- await target.click({ force: true, timeout: 8000 });
3472
- });
4011
+ const perpetual = await scrollControlIntoView(target);
4012
+ await clickControl(target, { timeout: 8000, perpetual });
3473
4013
 
3474
4014
  // The SDK owns the navigation (data-next-url, or the link the SDK reads
3475
4015
  // forcePackageId from on arrival). Waiting for the URL is what proves the
@@ -3763,8 +4303,8 @@ async function selectRequestedCart(page, args) {
3763
4303
  for (const item of cart) {
3764
4304
  const target = page.locator(packageCardClickSelector({ package_id: item.packageId })).first();
3765
4305
  if (await target.count().catch(() => 0)) {
3766
- await target.scrollIntoViewIfNeeded().catch(() => {});
3767
- await target.click({ timeout: 5000 }).catch(() => {});
4306
+ const perpetual = await scrollControlIntoView(target);
4307
+ await clickControl(target, { timeout: 5000, forceFallback: false, perpetual }).catch(() => {});
3768
4308
  }
3769
4309
  }
3770
4310
  }
@@ -3789,10 +4329,8 @@ async function selectPackageCard(page, item) {
3789
4329
  const candidate = resolvePackageCardCandidate(await renderedPackageCardCandidates(page), item);
3790
4330
  const selector = packageCardClickSelector(candidate);
3791
4331
  const target = page.locator(selector).first();
3792
- await target.scrollIntoViewIfNeeded().catch(() => {});
3793
- await target.click({ timeout: 5000 }).catch(async () => {
3794
- await target.click({ force: true, timeout: 5000 });
3795
- });
4332
+ const perpetual = await scrollControlIntoView(target);
4333
+ await clickControl(target, { timeout: 5000, perpetual });
3796
4334
  const state = await packageCardSelectionState(page, selector);
3797
4335
  if (state === "unselected") {
3798
4336
  throw new Error(`--select-package ${item.packageId}: card matched ${selector} but did not enter a selected state after click`);
@@ -4390,8 +4928,8 @@ async function submitCheckout(page) {
4390
4928
  await closeAddressAutocomplete(page);
4391
4929
  const submit = page.locator('button.submit-button[os-checkout-payment="combo"], button[os-checkout-payment="combo"], button[type="submit"]').first();
4392
4930
  await submit.waitFor({ state: "visible" });
4393
- await submit.scrollIntoViewIfNeeded();
4394
- await submit.click();
4931
+ const perpetual = await scrollControlIntoView(submit);
4932
+ await clickControl(submit, { forceFallback: false, perpetual });
4395
4933
  }
4396
4934
 
4397
4935
  // When events are supplied (the post-submit wait), a platform-rejected order
@@ -4536,6 +5074,81 @@ async function waitForLateOrderEvidence(page, events, { timeoutMs = 4000, interv
4536
5074
  }
4537
5075
  }
4538
5076
 
5077
+ // Playwright waits for an element to be "stable" (the same box on two
5078
+ // consecutive animation frames) before scrollIntoViewIfNeeded() and click().
5079
+ // A control under an infinite geometry animation never is: stock upsell accept
5080
+ // buttons carry pb-animate="pulse-upsell", which the shared next-core.css
5081
+ // scales forever with !important and no reduced-motion override. An unbounded
5082
+ // scroll then burned the whole 30s default action timeout and a normal click
5083
+ // another 10s before the forced fallback fired (#481). Scrolling through the
5084
+ // DOM has no stability wait, and a perpetually animated control is clicked with
5085
+ // force once visible, because waiting for it to settle can only time out.
5086
+ const CONTROL_SCROLL_TIMEOUT_MS = 2000;
5087
+ const UPSELL_CLICK_TIMEOUT_MS = 10000;
5088
+ const UPSELL_MUTATION_TIMEOUT_MS = 20000;
5089
+
5090
+ // Runs in the page: optionally scrolls the control to the viewport centre
5091
+ // (no stability wait), and reports whether the control or an ancestor runs an
5092
+ // infinite animation over a property that moves or resizes its box.
5093
+ // Paint-only loops (opacity, color, shadow) leave the box stable, so those
5094
+ // still take the normal click. Self-contained so Playwright can serialize it.
5095
+ function probeControlInPage(element, { scroll }) {
5096
+ if (scroll) element.scrollIntoView({ block: "center", inline: "nearest" });
5097
+ // Properties that move or resize the element's box, or its position
5098
+ // inside an animated ancestor. Paint-only properties are left out.
5099
+ const geometry = /^(transform|translate|scale|rotate|perspective|top|left|right|bottom|inset|width|height|blockSize|inlineSize|min|max|margin|padding|gap|rowGap|columnGap|fontSize|lineHeight|letterSpacing|textIndent|border(Top|Right|Bottom|Left|Block|Inline)?(Start|End)?Width)/;
5100
+ const movesBox = (animation) => {
5101
+ const timing = animation.effect?.getComputedTiming?.();
5102
+ if (animation.playState !== "running" || timing?.iterations !== Infinity) return false;
5103
+ const keyframes = animation.effect?.getKeyframes?.() || [];
5104
+ return keyframes.some((frame) => Object.keys(frame).some((property) => geometry.test(property)));
5105
+ };
5106
+ for (let node = element; node; node = node.parentElement) {
5107
+ if (typeof node.getAnimations === "function" && node.getAnimations().some(movesBox)) return true;
5108
+ }
5109
+ return false;
5110
+ }
5111
+
5112
+ async function probeControl(locator, { scroll, timeout = CONTROL_SCROLL_TIMEOUT_MS }) {
5113
+ try {
5114
+ return await locator.evaluate(probeControlInPage, { scroll }, { timeout }) === true;
5115
+ } catch {
5116
+ // Best effort, as the scroll always was: the click still scrolls itself,
5117
+ // and an unprobed control takes the normal click.
5118
+ return false;
5119
+ }
5120
+ }
5121
+
5122
+ // One page round trip per click: scrolls the control into view and returns
5123
+ // the `perpetual` flag clickControl takes, so no call site probes twice.
5124
+ async function scrollControlIntoView(locator, options = {}) {
5125
+ return probeControl(locator, { ...options, scroll: true });
5126
+ }
5127
+
5128
+ async function isPerpetuallyAnimated(locator, options = {}) {
5129
+ return probeControl(locator, { ...options, scroll: false });
5130
+ }
5131
+
5132
+ // `perpetual` is the flag scrollControlIntoView returned, so the click does
5133
+ // not probe again; without it the click probes for itself. forceFallback: false keeps a caller's strict click
5134
+ // for a control that can settle; a perpetually animated control is always
5135
+ // forced, whatever forceFallback says, because a strict click on it can only
5136
+ // time out. It must still become visible first, or the visibility error throws.
5137
+ async function clickControl(locator, { timeout, forceFallback = true, perpetual } = {}) {
5138
+ const animated = perpetual ?? await isPerpetuallyAnimated(locator);
5139
+ if (animated) {
5140
+ await locator.waitFor({ state: "visible", timeout });
5141
+ await locator.click({ force: true, timeout });
5142
+ return;
5143
+ }
5144
+ try {
5145
+ await locator.click({ timeout });
5146
+ } catch (error) {
5147
+ if (!forceFallback) throw error;
5148
+ await locator.click({ force: true, timeout });
5149
+ }
5150
+ }
5151
+
4539
5152
  async function clickUpsellPath(page, path, { trace = null } = {}) {
4540
5153
  const offerUrl = safePageUrl(page);
4541
5154
  const action = path === "accept" ? "add" : "skip";
@@ -4545,27 +5158,36 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
4545
5158
  return { path, clicked: false, error: `Missing upsell control ${selector}` };
4546
5159
  }
4547
5160
  const expectedItems = path === "accept" ? await selectedUpsellItems(page) : [];
5161
+ const perpetual = await scrollControlIntoView(control);
5162
+ // Armed at the click, not before the scroll, and budgeted to outlast a
5163
+ // normal attempt that times out into its forced fallback: the watch must
5164
+ // still be listening when the click that actually fires posts (#481).
5165
+ //
5166
+ // Only a request started at or after the watch was armed can be this
5167
+ // click's. An earlier step posts to the same order-upsells URL, and when
5168
+ // its own watch expired its response may still arrive during this click
5169
+ // (#505). armedAt and the request's start time are both epoch milliseconds
5170
+ // on the system clock (Date.now() here; Playwright's timing().startTime is
5171
+ // the browser's wall time at request start), and armedAt is read before the
5172
+ // click is sent, so this click's request starts at or after it. A request
5173
+ // with no known start time is not excluded: nothing shows it is stale.
5174
+ const armedAt = Date.now();
4548
5175
  const mutationPromise = path === "accept"
4549
- ? page.waitForResponse((response) => (
4550
- response.request().method() === "POST"
4551
- && isOrderUpsellsUrl(response.url())
4552
- ), { timeout: 20000 }).catch(() => null)
5176
+ ? page.waitForResponse((response) => {
5177
+ if (response.request().method() !== "POST" || !isOrderUpsellsUrl(response.url())) return false;
5178
+ const startedAt = responseRequestStartedAt(response);
5179
+ return startedAt === null || startedAt >= armedAt;
5180
+ }, { timeout: UPSELL_MUTATION_TIMEOUT_MS + (perpetual ? 0 : UPSELL_CLICK_TIMEOUT_MS) }).catch(() => null)
4553
5181
  : Promise.resolve(null);
4554
- await control.scrollIntoViewIfNeeded().catch(() => {});
4555
5182
  trace?.markClickAttempted();
4556
- let clickCompleted = false;
4557
- try {
4558
- await control.click({ timeout: 10000 });
4559
- clickCompleted = true;
4560
- } catch {
4561
- await control.click({ force: true });
4562
- clickCompleted = true;
4563
- }
4564
- if (clickCompleted) trace?.markClickCompleted();
5183
+ await clickControl(control, { timeout: UPSELL_CLICK_TIMEOUT_MS, perpetual });
5184
+ trace?.markClickCompleted();
4565
5185
  const mutationResponse = await mutationPromise;
4566
- const mutationBody = mutationResponse ? await readJsonResponseBody(mutationResponse) : null;
5186
+ const bodyRead = mutationResponse
5187
+ ? await readJsonResponseBodyBounded(mutationResponse, RESPONSE_BODY_READ_TIMEOUT_MS)
5188
+ : null;
4567
5189
  await waitForCheckoutResult(page);
4568
- return {
5190
+ const record = {
4569
5191
  path,
4570
5192
  clicked: true,
4571
5193
  offer_url: offerUrl,
@@ -4574,8 +5196,21 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
4574
5196
  api_response_seen: Boolean(mutationResponse),
4575
5197
  api_response_status: mutationResponse?.status() || null,
4576
5198
  api_response_url: mutationResponse?.url() || null,
4577
- api_response_order_body: mutationBody,
5199
+ api_response_order_body: bodyRead?.body ?? null,
5200
+ // When the mutation's response arrived (epoch ms, the browser's clock as
5201
+ // Date.now()). Only a read-back requested after this reflects the upsell.
5202
+ mutation_responded_at: mutationRespondedAt(mutationResponse),
5203
+ // How the bounded body read ended. A timed-out read means the mutation's
5204
+ // own evidence is missing, not that the upsell failed; the runner then
5205
+ // waits for a later read-back before it judges the step.
5206
+ ...(bodyRead ? { api_response_body_read: { timed_out: bodyRead.timed_out, waited_ms: bodyRead.waited_ms, bound_ms: bodyRead.bound_ms } } : {}),
4578
5207
  };
5208
+ // The request this click made. Another step's upsell mutation posts to the
5209
+ // same order-upsells URL, so a late body is this step's only when it
5210
+ // answers this request.
5211
+ const mutationRequest = mutationResponse ? responseRequest(mutationResponse) : null;
5212
+ if (mutationRequest) record[REQUEST_IDENTITY] = mutationRequest;
5213
+ return record;
4579
5214
  }
4580
5215
 
4581
5216
  function receiptProofEvidence(order) {
@@ -5325,13 +5960,22 @@ async function recoverCreatedOrder({ context, attempt, plan = null, checkoutPage
5325
5960
  }
5326
5961
 
5327
5962
  const cleared = remaining.length === 0;
5963
+ // An unverified accepted upsell is not a failure recovery can clear, nor
5964
+ // one it can re-check: re-reading the order says nothing about which
5965
+ // mutation added what. With everything else cleared, the recovered result
5966
+ // is the same manual-review shape executeTestOrderPath returns (#505).
5967
+ const upsellUnverified = Array.isArray(order.verification?.upsell_unverified) && order.verification.upsell_unverified.length
5968
+ ? order.verification.upsell_unverified
5969
+ : null;
5970
+ if (upsellUnverified) recovered.verification.verified = false;
5328
5971
  return {
5329
5972
  attempts: 1,
5330
5973
  cleared,
5331
5974
  checks,
5332
5975
  result: {
5333
5976
  ...attempt,
5334
- ok: cleared,
5977
+ ok: cleared && !upsellUnverified,
5978
+ ...(cleared && upsellUnverified ? { upsell_unverified: upsellUnverified } : {}),
5335
5979
  error: cleared ? null : remaining.join("; "),
5336
5980
  order: recovered,
5337
5981
  },
@@ -5469,19 +6113,35 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
5469
6113
  // runner deliberately refused to re-run, and the operator needs to know that
5470
6114
  // before they run the path again by hand.
5471
6115
  const stoppedForAmbiguity = creationRecord?.action === "stopped";
6116
+ // The order was created and nothing failed, but an accepted upsell could
6117
+ // not be checked: its mutation body never loaded and no read-back arrived.
6118
+ // Neither proved nor disproved, so a human decides, as for a hosted checkout.
6119
+ // The result is not ok (the path is not proven), but nothing failed either.
6120
+ // A result that says ok while its order still carries unverified reasons is
6121
+ // read the same way: never a pass. No current producer does that (both
6122
+ // executeTestOrderPath and recoverCreatedOrder clear ok when an upsell is
6123
+ // unverified); this arm stops a future ok-producing path, like the recovery
6124
+ // that once promoted an unverified upsell to pass, from doing it silently.
6125
+ const orderUnverified = result.order?.verification?.upsell_unverified;
6126
+ const upsellUnverified = Array.isArray(result.upsell_unverified) && result.upsell_unverified.length
6127
+ ? result.upsell_unverified
6128
+ : result.ok && Array.isArray(orderUnverified) && orderUnverified.length ? orderUnverified : null;
6129
+ const created = result.ok || Boolean(upsellUnverified);
5472
6130
  return assertion({
5473
6131
  id: `browser-test-order:${id}`,
5474
6132
  family: "browser-test-order",
5475
6133
  page,
5476
- status: result.ok ? STATUS.PASS : STATUS.FAIL,
5477
- severity: result.ok ? undefined : SEVERITY.BLOCKER,
6134
+ status: created ? (upsellUnverified ? STATUS.MANUAL_REVIEW : STATUS.PASS) : STATUS.FAIL,
6135
+ severity: created ? (upsellUnverified ? SEVERITY.WARN : undefined) : SEVERITY.BLOCKER,
5478
6136
  expected: "test order created through deployed checkout page",
5479
- actual: result.ok
5480
- ? result.order.next_order_id || result.order.ref_id
6137
+ actual: created
6138
+ ? upsellUnverified
6139
+ ? `${result.order.next_order_id || result.order.ref_id}; ${upsellUnverified.join("; ")}`
6140
+ : result.order.next_order_id || result.order.ref_id
5481
6141
  : stoppedForAmbiguity
5482
6142
  ? `${failureText} — not re-run: ${creationRecord.classification.reason}. Check for an existing order against this run's QA email before running this path again.`
5483
6143
  : failureText,
5484
- evidence: result.ok
6144
+ evidence: created
5485
6145
  ? {
5486
6146
  ...planEvidence,
5487
6147
  ...retry,
@@ -5494,6 +6154,7 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
5494
6154
  line_count: result.order.receipt_line_items.length,
5495
6155
  ...(receiptProofEvidence(result.order) ? { receipt_proof: receiptProofEvidence(result.order) } : {}),
5496
6156
  ...(path === "accept" ? { accepted_upsell_line_present: result.order.verification?.accepted_upsell_line_present } : {}),
6157
+ ...(upsellUnverified ? { upsell_unverified: upsellUnverified } : {}),
5497
6158
  ...(result.order.upsell ? { upsell_clicked: result.order.upsell.clicked, upsell_final_url: result.order.upsell.final_url } : {}),
5498
6159
  ...(result.order.upsell_steps ? { upsell_steps: result.order.upsell_steps.map(summarizeUpsellStep) } : {}),
5499
6160
  ...(result.order.verification?.accepted_upsell_matches ? { accepted_upsell_matches: result.order.verification.accepted_upsell_matches } : {}),
@@ -5514,6 +6175,43 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
5514
6175
  });
5515
6176
  }
5516
6177
 
6178
+ // When a response's headers arrived, in epoch milliseconds, or null.
6179
+ function mutationRespondedAt(response) {
6180
+ try {
6181
+ const timing = response.request().timing();
6182
+ const at = timing.startTime + timing.responseStart;
6183
+ return Number.isFinite(at) && timing.startTime > 0 && timing.responseStart >= 0 ? at : null;
6184
+ } catch {
6185
+ return null;
6186
+ }
6187
+ }
6188
+
6189
+ // The Playwright Request a response answers, kept under a symbol key so it
6190
+ // never reaches serialized evidence (JSON and the sanitized event log skip
6191
+ // it). Playwright hands every listener the same Request object for one
6192
+ // request, and a different one for each request, even to the same URL: an
6193
+ // upsell step matches its own mutation's late body by this identity (#505).
6194
+ const REQUEST_IDENTITY = Symbol("campaigns-os.request-identity");
6195
+
6196
+ function responseRequest(response) {
6197
+ try {
6198
+ return response.request() || null;
6199
+ } catch {
6200
+ return null;
6201
+ }
6202
+ }
6203
+
6204
+ // When the browser started a response's request, in epoch milliseconds (the
6205
+ // same clock as Date.now()), or null when Playwright does not report it.
6206
+ function responseRequestStartedAt(response) {
6207
+ try {
6208
+ const startTime = response.request().timing().startTime;
6209
+ return Number.isFinite(startTime) && startTime > 0 ? startTime : null;
6210
+ } catch {
6211
+ return null;
6212
+ }
6213
+ }
6214
+
5517
6215
  function captureCheckoutEvents(page) {
5518
6216
  const events = { requests: [], responses: [], failed: [], console: [], pageErrors: [], navigations: [] };
5519
6217
  const interesting = /\/api\/v1\/(?:orders|upsells|carts)\/?|\/transactions|spreedly|campaigns\.apps/i;
@@ -5525,13 +6223,24 @@ function captureCheckoutEvents(page) {
5525
6223
  postData: summarizeRequestPostData(request.postData()),
5526
6224
  });
5527
6225
  });
6226
+ // Nothing awaits this listener, so its body read stays unbounded: an entry
6227
+ // lands whenever its body finishes loading, and waitForLateOrderEvidence
6228
+ // polls for it. A bound here would record a slow but successful order body
6229
+ // as null for good, and the order would read as not created.
5528
6230
  page.on("response", async (response) => {
5529
6231
  if (!interesting.test(response.url())) return;
5530
- events.responses.push({
6232
+ // Taken before the body read: an entry lands in the log when its body
6233
+ // finishes, so its position says nothing about when it was requested.
6234
+ const requestStartedAt = responseRequestStartedAt(response);
6235
+ const request = responseRequest(response);
6236
+ const entry = {
5531
6237
  status: response.status(),
5532
6238
  url: response.url(),
5533
- body: await readJsonResponseBody(response),
5534
- });
6239
+ request_started_at: requestStartedAt,
6240
+ body: await readJsonResponseBodyWhenLoaded(response),
6241
+ };
6242
+ if (request) entry[REQUEST_IDENTITY] = request;
6243
+ events.responses.push(entry);
5535
6244
  });
5536
6245
  page.on("requestfailed", (request) => {
5537
6246
  if (!interesting.test(request.url())) return;
@@ -5552,11 +6261,63 @@ function captureCheckoutEvents(page) {
5552
6261
  return events;
5553
6262
  }
5554
6263
 
5555
- async function readJsonResponseBody(response) {
5556
- const text = await response.text().catch(() => null);
6264
+ // Playwright's response.text() waits for the body to finish loading. A page
6265
+ // that navigates away as soon as the headers land (an SDK that reads only the
6266
+ // status before redirecting) can leave that wait pending forever. Only a
6267
+ // caller that blocks the run on the body needs a bound: the upsell mutation
6268
+ // read in clickUpsellPath. There the body is evidence, not the proof of the
6269
+ // response, so an unread body is reported as null after a short bound instead
6270
+ // of hanging the order run. Playwright has no way to cancel a pending body
6271
+ // read; the abandoned read is a protocol callback, not a socket this process
6272
+ // owns, and it is released when the run closes the browser context.
6273
+ const RESPONSE_BODY_READ_TIMEOUT_MS = 3000;
6274
+
6275
+ // For callers that nothing awaits (the checkout event listener): a late body
6276
+ // still arrives, and a body that never loads just never records an entry.
6277
+ async function readJsonResponseBodyWhenLoaded(response) {
6278
+ const text = await Promise.resolve().then(() => response.text()).catch(() => null);
5557
6279
  return parseMaybeJson(redactSensitive(text));
5558
6280
  }
5559
6281
 
6282
+ // Callers that block on the read always get the fixed bound; only the test
6283
+ // hook picks a shorter one.
6284
+ async function readJsonResponseBody(response) {
6285
+ return readJsonResponseBodyWithin(response, RESPONSE_BODY_READ_TIMEOUT_MS);
6286
+ }
6287
+
6288
+ async function readJsonResponseBodyWithin(response, timeoutMs) {
6289
+ return (await readJsonResponseBodyBounded(response, timeoutMs)).body;
6290
+ }
6291
+
6292
+ // The bounded read, with a record of how it ended. `timed_out` is true only
6293
+ // when the bound fired while the read was still pending, never when the read
6294
+ // failed or returned nothing: a caller must be able to tell "the body was not
6295
+ // read in time" (the mutation may still have succeeded) from "the body was
6296
+ // read". `waited_ms` covers the read alone.
6297
+ async function readJsonResponseBodyBounded(response, timeoutMs) {
6298
+ // A missing, zero or unbounded value would reintroduce the hang.
6299
+ if (!(Number.isFinite(timeoutMs) && timeoutMs > 0)) timeoutMs = RESPONSE_BODY_READ_TIMEOUT_MS;
6300
+ const started = Date.now();
6301
+ const TIMED_OUT = Symbol("timed out");
6302
+ let timer = null;
6303
+ const bounded = new Promise((resolve) => { timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs); });
6304
+ const read = Promise.resolve()
6305
+ .then(() => response.text())
6306
+ .catch(() => null);
6307
+ try {
6308
+ const text = await Promise.race([read, bounded]);
6309
+ const timedOut = text === TIMED_OUT;
6310
+ return {
6311
+ body: timedOut ? null : parseMaybeJson(redactSensitive(text)),
6312
+ timed_out: timedOut,
6313
+ waited_ms: Date.now() - started,
6314
+ bound_ms: timeoutMs,
6315
+ };
6316
+ } finally {
6317
+ clearTimeout(timer);
6318
+ }
6319
+ }
6320
+
5560
6321
  function lastJsonResponse(events, pattern) {
5561
6322
  for (let index = events.responses.length - 1; index >= 0; index -= 1) {
5562
6323
  const response = events.responses[index];
@@ -5621,8 +6382,8 @@ async function clickVisibleControlByText(page, pattern, { within = null } = {})
5621
6382
  const control = controls.nth(index);
5622
6383
  const text = trim((await control.innerText().catch(() => "")) || (await control.getAttribute("value").catch(() => "")));
5623
6384
  if (!pattern.test(text)) continue;
5624
- await control.scrollIntoViewIfNeeded().catch(() => {});
5625
- await control.click({ timeout: 8000 });
6385
+ const perpetual = await scrollControlIntoView(control);
6386
+ await clickControl(control, { timeout: 8000, forceFallback: false, perpetual });
5626
6387
  return true;
5627
6388
  }
5628
6389
  throw new Error(`No visible control matched ${pattern}`);
@@ -5767,12 +6528,13 @@ async function closeAddressAutocomplete(page) {
5767
6528
  }
5768
6529
  }
5769
6530
 
5770
- function testOrderPaths(mode, topologies = []) {
6531
+ function testOrderPaths(mode, topologies = [], args = {}) {
5771
6532
  const normalized = String(mode || "off").toLowerCase();
5772
6533
  // `common` (also the bare `--test-order` flag, which parses to boolean true)
5773
- // is the default sample: at most four graph-derived shapes for everyday QA.
6534
+ // is the default: every actual terminal path when they fit under the flood
6535
+ // cap, otherwise the sample plus one decline path per uncovered offer page.
5774
6536
  // `full` is the explicit opt-in for every actual terminal path.
5775
- if (normalized === "common" || normalized === "true") return testOrderCommonPaths(topologies);
6537
+ if (isCommonTestOrderMode(normalized)) return commonOrderPathPlan(topologies, args).paths;
5776
6538
  if (normalized === "full") return fullTestOrderPaths(resolvePrimaryTestOrderTopology(topologies));
5777
6539
  if (normalized === "both") return ["accept", "decline"];
5778
6540
  if (["checkout", "accept", "decline"].includes(normalized)) return [normalized];
@@ -5810,7 +6572,7 @@ function testOrderPlans(mode, topologies = [], args = {}, options = {}) {
5810
6572
  const applyCoupon = stringArg(args["apply-coupon"]);
5811
6573
  const checkoutPage = findPage(topologies, "checkout");
5812
6574
  const topologyPlan = resolvePrimaryTestOrderTopology(topologies);
5813
- return testOrderPaths(mode, topologies).map((path) => ({
6575
+ return testOrderPaths(mode, topologies, args).map((path) => ({
5814
6576
  path,
5815
6577
  select_package: selectPackage,
5816
6578
  apply_coupon: applyCoupon,
@@ -6088,15 +6850,306 @@ function summarizeTestOrderPlan(plan) {
6088
6850
  };
6089
6851
  }
6090
6852
 
6091
- // The default "common shapes" sample: checkout baseline, plus first-offer
6092
- // accept and decline when the checkout enters an offer graph, plus the shortest
6093
- // real receipt path when that adds coverage. Stays within 1-4 orders so it never
6094
- // trips the flood cap. Bundle/quantity and bump coverage come from `--cart`;
6853
+ // The "common shapes" sample: checkout baseline, plus first-offer accept and
6854
+ // decline when the checkout enters an offer graph, plus the shortest real
6855
+ // receipt path when that adds coverage. 1-4 orders. `tiers:common` crosses
6856
+ // each tier with this sample; the operator `common` depth builds on it through
6857
+ // commonOrderPathPlan. Bundle/quantity and bump coverage come from `--cart`;
6095
6858
  // every actual terminal path comes from `full`.
6096
6859
  function testOrderCommonPaths(topologies = []) {
6097
6860
  return commonTestOrderPaths(resolvePrimaryTestOrderTopology(topologies));
6098
6861
  }
6099
6862
 
6863
+ function isCommonTestOrderMode(mode) {
6864
+ const normalized = String(mode ?? "off").toLowerCase();
6865
+ return normalized === "common" || normalized === "true";
6866
+ }
6867
+
6868
+ // The operator `common` depth, planned against the same cap the flood guard
6869
+ // enforces, so the plan that decides "full fits" is the plan that is checked.
6870
+ function commonOrderPathPlan(topologies = [], args = {}) {
6871
+ return commonTestOrderPlan(resolvePrimaryTestOrderTopology(topologies), {
6872
+ cap: numberArg(args["max-test-orders"], DEFAULT_MAX_TEST_ORDERS),
6873
+ });
6874
+ }
6875
+
6876
+ function describeCommonOrderPathPlan(plan) {
6877
+ const head = plan.effective_depth === "full"
6878
+ ? `[qa:test-order] common runs every actual terminal path (${plan.paths.length}, at or under the cap of ${plan.cap}).`
6879
+ : `[qa:test-order] common runs ${plan.paths.length} path(s): the sample${plan.coverage_paths.length ? ` plus ${plan.coverage_paths.join(", ")} to click through offer declines` : ""}${plan.full_path_count ? `; full would plan ${plan.full_path_count}, above the cap of ${plan.cap}` : ""}.`;
6880
+ if (!plan.uncovered_pages.length) return head;
6881
+ const pages = plan.uncovered_pages.map((page) => `${page.page_id || "(unnamed)"}${page.reason === "unreachable" ? " (unreachable from the checkout)" : ""}`);
6882
+ return `${head} No planned path clicks the decline on: ${pages.join(", ")}. Use --test-order full with the --max-test-orders it names to cover them.`;
6883
+ }
6884
+
6885
+ // Which offer pages had their decline clicked by an order this run actually
6886
+ // placed (#530). The unit is the decline control: an order that reached a page,
6887
+ // or clicked only its accept, does not count, because a broken decline link is
6888
+ // the failure this row exists to surface. Read from the click records the
6889
+ // runner kept, never from the plan.
6890
+ //
6891
+ // The row is conservative by construction. It may be `pass` or `warn` only
6892
+ // when coverage is certain: every funnel in the run lists its pages, every page
6893
+ // is either a known non-offer type or an offer page with its own absolute URL
6894
+ // (no URL shared with another page), every plan belongs to exactly one of those
6895
+ // funnels (same checkout, same page list), and every click a placed order
6896
+ // recorded lands on a declared offer page of that order's own funnel. A click
6897
+ // credits only the funnel whose plan made it, never another funnel by URL. Any
6898
+ // doubt, no placed order, or `--test-order off` makes the row `manual_review`
6899
+ // naming the pages and the reasons. When certain, it is `warn` naming each
6900
+ // page whose decline no order clicked, else `pass`. No row when coverage is
6901
+ // certain and no funnel has an offer page, or when nothing was topologised,
6902
+ // planned or placed. `orderPlans` holds the plan behind each entry in
6903
+ // `orders`, index for index.
6904
+ function upsellActionCoverageAssertion({ topologies = [], plans = [], orders = [], orderPlans = [], checkoutPage = null, orderPathPlan = null, testOrdersOff = false } = {}) {
6905
+ const funnels = (Array.isArray(topologies) ? topologies : []).map((topology, index) => ({
6906
+ index,
6907
+ funnel_id: topology?.funnel_id || null,
6908
+ pages: Array.isArray(topology?.pages) ? topology.pages : null,
6909
+ }));
6910
+ const funnelName = (funnel) => funnel.funnel_id || `(unnamed funnel ${funnel.index + 1})`;
6911
+ // Run-level reasons coverage is not certain; per-page reasons live on the
6912
+ // page entries.
6913
+ const doubts = [];
6914
+
6915
+ // Every offer page of every funnel, and every page whose type does not rule
6916
+ // it out. The same declaration listed twice in one funnel is one page.
6917
+ const entries = [];
6918
+ for (const funnel of funnels) {
6919
+ if (!funnel.pages) {
6920
+ entries.push({ funnel, key: null, page_id: null, page_type: null, label: "(page list missing)", reason: "no_pages" });
6921
+ continue;
6922
+ }
6923
+ const rows = new Set();
6924
+ funnel.pages.forEach((page, index) => {
6925
+ const type = String(page?.page_type || "").toLowerCase().replace(/[-_]/g, "");
6926
+ if (NON_OFFER_PAGE_TYPES.has(type)) return;
6927
+ const key = canonicalHttpUrl(page?.url);
6928
+ const row = `${page?.page_id ?? ""}\u0000${key ?? `#${index}`}`;
6929
+ if (rows.has(row)) return;
6930
+ rows.add(row);
6931
+ entries.push({
6932
+ funnel,
6933
+ key,
6934
+ page_id: page?.page_id || null,
6935
+ page_type: page?.page_type || null,
6936
+ label: page?.page_id || `(unnamed page ${index + 1})`,
6937
+ reason: !OFFER_PAGE_TYPES.has(type)
6938
+ ? "unknown_page_type"
6939
+ : key ? null : (typeof page?.url === "string" && page.url.trim() ? "unresolvable_url" : "no_url"),
6940
+ });
6941
+ });
6942
+ }
6943
+ const entriesByKey = new Map();
6944
+ for (const entry of entries) {
6945
+ if (!entry.key) continue;
6946
+ if (!entriesByKey.has(entry.key)) entriesByKey.set(entry.key, []);
6947
+ entriesByKey.get(entry.key).push(entry);
6948
+ }
6949
+ for (const shared of entriesByKey.values()) {
6950
+ if (shared.length < 2) continue;
6951
+ for (const entry of shared) entry.reason ||= "shared_url";
6952
+ }
6953
+
6954
+ // A plan belongs to a funnel only when its checkout is that funnel's own
6955
+ // checkout page (the same object, or failing that the one funnel whose
6956
+ // checkout has its URL) and the page list it planned over is that funnel's.
6957
+ const planOwners = new Map();
6958
+ const ownerOf = (plan) => {
6959
+ if (planOwners.has(plan)) return planOwners.get(plan);
6960
+ let owner = null;
6961
+ if (plan && typeof plan === "object") {
6962
+ let candidates = plan.checkout_page ? funnels.filter((funnel) => funnel.pages?.includes(plan.checkout_page)) : [];
6963
+ if (!candidates.length) {
6964
+ const checkoutKey = canonicalHttpUrl(plan.checkout_page?.url || plan.topology_plan?.checkout_url);
6965
+ candidates = checkoutKey
6966
+ ? funnels.filter((funnel) => funnel.pages?.some((page) => String(page?.page_type || "").toLowerCase() === "checkout" && canonicalHttpUrl(page?.url) === checkoutKey))
6967
+ : [];
6968
+ }
6969
+ const [candidate] = candidates;
6970
+ if (candidates.length === 1 && (!plan.topology_plan || plannedOverFunnel(plan.topology_plan, candidate))) owner = candidate;
6971
+ }
6972
+ planOwners.set(plan, owner);
6973
+ return owner;
6974
+ };
6975
+ const unattributedPlans = [...new Set([...plans, ...orderPlans])].filter((plan) => !ownerOf(plan));
6976
+ if (unattributedPlans.length) {
6977
+ doubts.push({ reason: "unattributed_plan", plans: unattributedPlans.map((plan) => (plan && typeof plan === "object") || typeof plan === "string" ? planId(plan) : String(plan)) });
6978
+ }
6979
+
6980
+ const placedByFunnel = new Map();
6981
+ const undeclaredClicks = new Set();
6982
+ let unattributedClicks = 0;
6983
+ let placed = 0;
6984
+ orders.forEach((order, index) => {
6985
+ if (!isPlacedTestOrder(order)) return;
6986
+ placed += 1;
6987
+ const funnel = ownerOf(orderPlans[index]);
6988
+ if (funnel) placedByFunnel.set(funnel, (placedByFunnel.get(funnel) || 0) + 1);
6989
+ for (const step of Array.isArray(order.upsell_steps) ? order.upsell_steps : []) {
6990
+ if (step?.clicked !== true) continue;
6991
+ const key = canonicalHttpUrl(step?.offer_url);
6992
+ if (!key || !funnel) {
6993
+ unattributedClicks += 1;
6994
+ continue;
6995
+ }
6996
+ const entry = entries.find((candidate) => candidate.funnel === funnel && candidate.key === key);
6997
+ if (!entry) {
6998
+ undeclaredClicks.add(key);
6999
+ continue;
7000
+ }
7001
+ if (step.path === "accept") entry.accept_clicked = true;
7002
+ if (step.path === "decline") entry.decline_clicked = true;
7003
+ }
7004
+ });
7005
+ if (unattributedClicks) doubts.push({ reason: "unattributed_click", clicks: unattributedClicks });
7006
+ if (undeclaredClicks.size) doubts.push({ reason: "undeclared_click", urls: [...undeclaredClicks].map((key) => new URL(key).pathname) });
7007
+
7008
+ if (!funnels.length) {
7009
+ // No funnel topology reached this row, so its pages cannot be listed. That
7010
+ // is unknown, not "no offer pages".
7011
+ if (!(plans.length || orders.length)) return null;
7012
+ doubts.unshift({ reason: "no_topology" });
7013
+ }
7014
+ if (!entries.length && !doubts.length) return null;
7015
+
7016
+ const nameFunnels = funnels.length > 1;
7017
+ const pageName = (entry) => {
7018
+ const notes = [];
7019
+ if (nameFunnels) notes.push(`funnel ${funnelName(entry.funnel)}`);
7020
+ if (entry.reason) notes.push(UPSELL_COVERAGE_UNASSESSABLE[entry.reason]);
7021
+ return `${entry.label}${notes.length ? ` (${notes.join("; ")})` : ""}`;
7022
+ };
7023
+ const pages = entries.map((entry) => ({
7024
+ page_id: entry.page_id,
7025
+ page_type: entry.page_type,
7026
+ funnel_id: entry.funnel.funnel_id,
7027
+ accept_clicked: entry.accept_clicked === true,
7028
+ decline_clicked: entry.decline_clicked === true,
7029
+ ...(entry.reason ? { assessable: false, reason: entry.reason } : {}),
7030
+ }));
7031
+ const open = entries.filter((entry) => !entry.decline_clicked || entry.reason);
7032
+ const unassessable = entries.filter((entry) => entry.reason);
7033
+ const evidence = {
7034
+ basis: "decline_clicked",
7035
+ orders_placed: placed,
7036
+ pages,
7037
+ not_clicked_through: open.map((entry) => entry.label),
7038
+ not_assessable: unassessable.map((entry) => ({ page_id: entry.page_id, funnel_id: entry.funnel.funnel_id, reason: entry.reason })),
7039
+ uncertainty: doubts,
7040
+ funnels: funnels.map((funnel) => ({
7041
+ funnel_id: funnel.funnel_id,
7042
+ orders_placed: placedByFunnel.get(funnel) || 0,
7043
+ not_clicked_through: open.filter((entry) => entry.funnel === funnel).map((entry) => entry.label),
7044
+ })),
7045
+ ...(orderPathPlan ? {
7046
+ order_path_depth: orderPathPlan.requested_depth,
7047
+ order_path_depth_effective: orderPathPlan.effective_depth,
7048
+ order_path_depth_reason: orderPathPlan.reason,
7049
+ max_test_orders: orderPathPlan.cap,
7050
+ full_path_count: orderPathPlan.full_path_count,
7051
+ coverage_paths: orderPathPlan.coverage_paths,
7052
+ planned_uncovered_pages: orderPathPlan.uncovered_pages,
7053
+ } : {}),
7054
+ };
7055
+ const base = {
7056
+ id: "browser-test-order:upsell-action-coverage",
7057
+ family: "browser-test-order",
7058
+ page: checkoutPage || { page_id: "checkout" },
7059
+ expected: "every page with upsell actions has its decline clicked by at least one test order of its own funnel",
7060
+ };
7061
+
7062
+ // The one predicate pass and warn depend on.
7063
+ const certain = placed > 0 && !testOrdersOff && !doubts.length && !unassessable.length;
7064
+ if (!certain) {
7065
+ const reason = doubts[0]?.reason === "no_topology"
7066
+ ? "no_topology"
7067
+ : testOrdersOff ? "test_orders_off" : !placed ? "no_order_recorded" : "not_assessable";
7068
+ const why = reason === "no_topology"
7069
+ ? "no funnel topology reached the coverage check, so the pages with upsell actions could not be listed"
7070
+ : reason === "test_orders_off"
7071
+ ? "browser test orders were off (--test-order off)"
7072
+ : reason === "no_order_recorded"
7073
+ ? "no test order was placed"
7074
+ : `coverage cannot be attributed with certainty (${[
7075
+ ...doubts.map(describeCoverageDoubt),
7076
+ ...(unassessable.length ? [`${unassessable.length} page(s) cannot be matched to a click`] : []),
7077
+ ].join("; ")})`;
7078
+ const named = open.length ? `, so ${open.length} of ${pages.length} page(s) with upsell actions are not proved clicked through: ${open.map(pageName).join(", ")}` : "";
7079
+ return assertion({
7080
+ ...base,
7081
+ status: STATUS.MANUAL_REVIEW,
7082
+ severity: SEVERITY.WARN,
7083
+ actual: `${why}${named}`,
7084
+ evidence: { ...evidence, reason },
7085
+ });
7086
+ }
7087
+ if (open.length) {
7088
+ const unrun = [...new Set(open.map((entry) => entry.funnel))].filter((funnel) => !placedByFunnel.get(funnel));
7089
+ return assertion({
7090
+ ...base,
7091
+ status: STATUS.WARN,
7092
+ severity: SEVERITY.WARN,
7093
+ actual: `no test order clicked the decline on ${open.length} of ${pages.length} page(s) with upsell actions: ${open.map(pageName).join(", ")}${unrun.length ? `. No test order ran through funnel(s) ${unrun.map(funnelName).join(", ")}` : ""}`,
7094
+ evidence,
7095
+ });
7096
+ }
7097
+ return assertion({
7098
+ ...base,
7099
+ status: STATUS.PASS,
7100
+ actual: `decline clicked on all ${pages.length} page(s) with upsell actions`,
7101
+ evidence,
7102
+ });
7103
+ }
7104
+
7105
+ // True when a resolved topology plan was planned over exactly this funnel: the
7106
+ // same funnel id and the same page list, page for page.
7107
+ function plannedOverFunnel(topologyPlan, funnel) {
7108
+ if ((topologyPlan?.topology_id || null) !== (funnel.funnel_id || "default")) return false;
7109
+ const planned = Array.isArray(topologyPlan?.route_pages) ? topologyPlan.route_pages : null;
7110
+ if (!planned || planned.length !== funnel.pages.length) return false;
7111
+ return funnel.pages.every((page, index) => (page?.page_id || null) === planned[index]?.page_id
7112
+ && (page?.page_type || null) === planned[index]?.page_type
7113
+ && (page?.url || null) === planned[index]?.url);
7114
+ }
7115
+
7116
+ function describeCoverageDoubt(doubt) {
7117
+ if (doubt.reason === "unattributed_plan") return `order plan(s) ${doubt.plans.join(", ")} cannot be matched to exactly one funnel's checkout and page list`;
7118
+ if (doubt.reason === "unattributed_click") return `${doubt.clicks} recorded click(s) carry no usable page URL or come from an order with no funnel`;
7119
+ if (doubt.reason === "undeclared_click") return `an order clicked page(s) its funnel does not declare: ${doubt.urls.join(", ")}`;
7120
+ return doubt.reason;
7121
+ }
7122
+
7123
+ // Page types that never carry an upsell action. Any other type, or none, does
7124
+ // not say either way.
7125
+ const NON_OFFER_PAGE_TYPES = new Set(["presell", "landing", "select", "product", "checkout", "thankyou", "receipt"]);
7126
+ const UPSELL_COVERAGE_UNASSESSABLE = Object.freeze({
7127
+ no_url: "no URL",
7128
+ unresolvable_url: "URL is not an absolute http(s) address",
7129
+ unknown_page_type: "page type does not say whether it offers anything",
7130
+ shared_url: "URL shared with another page",
7131
+ no_pages: "funnel lists no pages",
7132
+ });
7133
+
7134
+ // An attempt counts as a placed order only when it carries the order reference
7135
+ // the runner read back; a failure placeholder has none.
7136
+ function isPlacedTestOrder(order) {
7137
+ if (!order || typeof order !== "object") return false;
7138
+ return [order.ref_id, order.next_order_id].some((value) => value != null && String(value).trim() !== "");
7139
+ }
7140
+
7141
+ // The coverage row for a run that drove no browser test order at all
7142
+ // (`--test-order off`), so its verdict never reads as every decline clicked.
7143
+ // Null when no funnel has offer pages, as in a run that placed orders.
7144
+ export function upsellActionCoverageWithoutOrders(topologies = []) {
7145
+ return upsellActionCoverageAssertion({
7146
+ topologies,
7147
+ orders: [],
7148
+ checkoutPage: findPage(topologies, "checkout"),
7149
+ testOrdersOff: true,
7150
+ });
7151
+ }
7152
+
6100
7153
  function enforceTestOrderLimit(plans, args) {
6101
7154
  const maxOrders = numberArg(args["max-test-orders"], DEFAULT_MAX_TEST_ORDERS);
6102
7155
  if (plans.length <= maxOrders) return;
@@ -6113,7 +7166,7 @@ function enforceTestOrderLimit(plans, args) {
6113
7166
  throw new Error([
6114
7167
  `--test-order ${args["test-order"]} expands to ${plans.length} typed-card order(s), above --max-test-orders ${maxOrders}.`,
6115
7168
  `Planned paths: ${preview}.`,
6116
- `This cap guards against an accidental order flood, not a permission gate. Use --test-order common for the default sample, or rerun with --max-test-orders ${plans.length} for this exhaustive proof.`,
7169
+ `This cap guards against an accidental order flood, not a permission gate. Use --test-order common for the default depth, or rerun with --max-test-orders ${plans.length} for this exhaustive proof.`,
6117
7170
  "The cap bounds planned paths. Actual order creations are bounded separately by --max-order-creations, which defaults to the planned path count and is reserved before each submit.",
6118
7171
  ].join(" "));
6119
7172
  }
@@ -6228,9 +7281,13 @@ async function selectedUpsellItems(page) {
6228
7281
  // order-upsell API added it. The live network observation (apiResponseSeen) is a
6229
7282
  // best-effort signal that can miss the request on fast stepper-accept client nav, so
6230
7283
  // it must not block on its own. Block only when the read-back proof also fails.
7284
+ //
7285
+ // An unverified proof (the mutation answered 2xx but its body never loaded, and
7286
+ // no later read-back arrived) is not a failure: nothing showed the line
7287
+ // missing. It is reported for manual review instead (upsell_unverified).
6231
7288
  function upsellAcceptStepFailures(stepIndex, proof, apiResponseSeen) {
6232
7289
  const failures = [];
6233
- if (!proof.ok) {
7290
+ if (!proof.ok && !proof.unverified) {
6234
7291
  failures.push(`step ${stepIndex + 1}: ${proof.reason}`);
6235
7292
  if (!apiResponseSeen) {
6236
7293
  failures.push(`step ${stepIndex + 1}: upsell accept did not call order upsell API`);
@@ -6252,6 +7309,141 @@ function upsellActionStepFailures(stepIndex, action, upsell, proof) {
6252
7309
  return failures;
6253
7310
  }
6254
7311
 
7312
+ // How long an accept whose mutation body timed out waits for other evidence
7313
+ // of that mutation, and the slice of the step budget kept for the read-back
7314
+ // after it. The whole step stays inside its budget (45s by default).
7315
+ const LATE_UPSELL_EVIDENCE_TIMEOUT_MS = 15000;
7316
+ const LATE_UPSELL_EVIDENCE_RESERVE_MS = 3000;
7317
+
7318
+ function upsellBodyReadTimedOut(upsell) {
7319
+ const status = Number(upsell?.api_response_status);
7320
+ return upsell?.api_response_body_read?.timed_out === true && status >= 200 && status < 300;
7321
+ }
7322
+
7323
+ // After the bounded read gave up on a 2xx upsell mutation body, the lines the
7324
+ // runner already holds are the checkout's, from before the accept. Judging
7325
+ // the upsell against them would call a slow success "no new upsell line".
7326
+ // Wait, within a bound, for evidence of this mutation captured after the
7327
+ // click: its own body landing late in the event log (the unbounded listener
7328
+ // keeps reading it), or an order read-back whose lines carry the accepted
7329
+ // upsell. Returns the body to judge from, or source "none" when neither came.
7330
+ //
7331
+ // "Its own body" is the entry answering the very request this step's click
7332
+ // made (mutationRequest, Playwright request identity), not any order-upsells
7333
+ // URL: every upsell step in a path, on one page or on separate pages, posts
7334
+ // to the same /orders/<ref>/upsells/ URL, and an earlier step's slow body can
7335
+ // land during this step's wait. Taking it would judge this step by another
7336
+ // step's lines. With no request identity no late body counts (#505).
7337
+ //
7338
+ // A read-back whose request started after the upsell mutation's response
7339
+ // arrived (by the browser's request start time, not its position in the log,
7340
+ // which reflects when its body finished) that shows the persisted order
7341
+ // without the accepted line is a definitive negative, not an absence of
7342
+ // evidence. Anchoring on the mutation, not the click attempt, also excludes a
7343
+ // read-back started while the click was still waiting to fire. It does not end the wait early (a
7344
+ // later read-back may still carry the line), but when the wait ends with no
7345
+ // positive evidence the latest such read-back is returned as
7346
+ // "order_read_back_missing_line", and the step fails instead of going to
7347
+ // manual review. A read-back requested before that, or with no known start
7348
+ // time, never counts as a negative.
7349
+ async function waitForLateUpsellEvidence(events, { responseIndexBefore, mutationRequest = null, mutationRespondedAt = null, initialLineItems, expectedItems, timeoutMs, intervalMs = 250 }) {
7350
+ const started = Date.now();
7351
+ const deadline = started + Math.max(0, Number(timeoutMs) || 0);
7352
+ const latestMissingLineReadBack = () => {
7353
+ const fresh = events.responses.slice(responseIndexBefore);
7354
+ for (let index = fresh.length - 1; index >= 0; index -= 1) {
7355
+ const response = fresh[index];
7356
+ if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) continue;
7357
+ if (!(response.status >= 200 && response.status < 300)) continue;
7358
+ if (!ORDER_DETAIL_RESPONSE_PATTERN.test(response.url)) continue;
7359
+ if (!(Number.isFinite(mutationRespondedAt) && Number.isFinite(response.request_started_at) && response.request_started_at >= mutationRespondedAt)) continue;
7360
+ const lines = extractReceiptLines(response.body);
7361
+ if (!Array.isArray(lines) || lines.length === 0) continue;
7362
+ return response.body;
7363
+ }
7364
+ return null;
7365
+ };
7366
+ const find = () => {
7367
+ const fresh = events.responses.slice(responseIndexBefore);
7368
+ for (let index = fresh.length - 1; index >= 0; index -= 1) {
7369
+ const response = fresh[index];
7370
+ if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) continue;
7371
+ if (!(response.status >= 200 && response.status < 300)) continue;
7372
+ if (!ORDER_UPSELLS_RESPONSE_PATTERN.test(response.url)) continue;
7373
+ if (mutationRequest && response[REQUEST_IDENTITY] === mutationRequest) return { source: "late_upsell_body", body: response.body };
7374
+ }
7375
+ for (let index = fresh.length - 1; index >= 0; index -= 1) {
7376
+ const response = fresh[index];
7377
+ if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) continue;
7378
+ if (!(response.status >= 200 && response.status < 300)) continue;
7379
+ if (!ORDER_DETAIL_RESPONSE_PATTERN.test(response.url)) continue;
7380
+ const proof = acceptedUpsellProof(extractReceiptLines(response.body), initialLineItems, expectedItems, events);
7381
+ if (proof.ok) return { source: "order_read_back", body: response.body };
7382
+ }
7383
+ return null;
7384
+ };
7385
+ for (;;) {
7386
+ const found = find();
7387
+ if (found) return { ...found, waited_ms: Date.now() - started };
7388
+ if (Date.now() >= deadline) {
7389
+ const missing = latestMissingLineReadBack();
7390
+ if (missing) return { source: "order_read_back_missing_line", body: missing, waited_ms: Date.now() - started };
7391
+ return { source: "none", body: null, waited_ms: Date.now() - started };
7392
+ }
7393
+ await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, Math.max(1, deadline - Date.now()))));
7394
+ }
7395
+ }
7396
+
7397
+ // The order evidence an upsell step is judged against. An accept whose
7398
+ // mutation body read timed out first waits for later evidence of that
7399
+ // mutation (waitForLateUpsellEvidence), and judges from that body when one
7400
+ // arrives: the runner's last order body is the checkout's otherwise.
7401
+ async function refreshUpsellStepEvidence({ page, events, path, email, checkoutPage, args, step, upsell, preferredOrderBody = null, initialLineItems, responseIndexBefore, lateWaitMs = LATE_UPSELL_EVIDENCE_TIMEOUT_MS }) {
7402
+ let lateUpsellEvidence = null;
7403
+ let judgedBody = preferredOrderBody;
7404
+ if (step === "accept" && upsellBodyReadTimedOut(upsell)) {
7405
+ lateUpsellEvidence = await waitForLateUpsellEvidence(events, {
7406
+ responseIndexBefore,
7407
+ mutationRequest: upsell[REQUEST_IDENTITY] || null,
7408
+ mutationRespondedAt: upsell.mutation_responded_at,
7409
+ initialLineItems,
7410
+ expectedItems: upsell.expected_items,
7411
+ timeoutMs: lateWaitMs,
7412
+ });
7413
+ upsell.late_evidence = { source: lateUpsellEvidence.source, waited_ms: lateUpsellEvidence.waited_ms };
7414
+ if (lateUpsellEvidence.body) judgedBody = lateUpsellEvidence.body;
7415
+ }
7416
+ const refreshed = await buildOrderEvidence({ page, events, path, email, checkoutPage, args, preferredOrderBody: judgedBody });
7417
+ return { refreshed, lateUpsellEvidence };
7418
+ }
7419
+
7420
+ // The accepted-upsell proof for one step, given how its evidence arrived. A
7421
+ // failed proof stands as a failure unless the mutation answered 2xx, its body
7422
+ // read timed out, and no later evidence of it arrived: then the lines judged
7423
+ // are stale, and the step is unverified rather than missing its upsell. A
7424
+ // post-click read-back without the line ("order_read_back_missing_line") is
7425
+ // such evidence, so the failure stands.
7426
+ //
7427
+ // A passing proof from those stale lines is no more evidence than a failing
7428
+ // one: lines on hand before this step's mutation answered cannot carry its
7429
+ // upsell, and can carry an earlier step's (whose body may have landed on the
7430
+ // same order-upsells URL meanwhile), so it is unverified too (#505).
7431
+ function acceptedUpsellStepProof(upsell, lateEvidence, proof) {
7432
+ if (!upsellBodyReadTimedOut(upsell)) return proof;
7433
+ if (lateEvidence && lateEvidence.source !== "none") return proof;
7434
+ const read = upsell.api_response_body_read;
7435
+ return {
7436
+ ...proof,
7437
+ ok: false,
7438
+ unverified: true,
7439
+ reason: `accepted upsell unverified: the order upsell API answered HTTP ${upsell.api_response_status} but its body did not load within ${read.bound_ms}ms, and no later order read-back showed the accepted line within ${lateEvidence?.waited_ms ?? 0}ms`,
7440
+ matched_lines: [],
7441
+ stale_reason: proof.ok
7442
+ ? "the order lines on hand predate this step's upsell mutation, so a match in them is not evidence of it"
7443
+ : proof.reason,
7444
+ };
7445
+ }
7446
+
6255
7447
  function acceptedUpsellProof(lines, initialLines, expectedItems, events) {
6256
7448
  if (!Array.isArray(lines) || lines.length === 0) {
6257
7449
  return { ok: false, reason: "final order lines were empty", expected_items: expectedItems || [], matched_lines: [] };
@@ -6352,6 +7544,8 @@ function summarizeUpsellStep(step) {
6352
7544
  expected_items: step.expected_items,
6353
7545
  api_response_seen: step.api_response_seen,
6354
7546
  api_response_status: step.api_response_status,
7547
+ ...(step.api_response_body_read ? { api_response_body_read: step.api_response_body_read } : {}),
7548
+ ...(step.late_evidence ? { late_evidence: step.late_evidence } : {}),
6355
7549
  accepted_upsell_line_present: step.verification?.accepted_upsell_line_present,
6356
7550
  };
6357
7551
  }
@@ -6489,6 +7683,8 @@ export const __qaBrowserTestHooks = Object.freeze({
6489
7683
  COUPON_APPLY_CONTROL_SELECTOR,
6490
7684
  analyticsCorrectnessCaptureAssertions,
6491
7685
  analyticsCorrectnessRunnerFailureAssertion,
7686
+ captureAnalyticsCorrectnessInContext,
7687
+ captureAnalyticsParityInContext,
6492
7688
  analyticsParityCaptureAssertions,
6493
7689
  analyticsParityRunnerFailureAssertion,
6494
7690
  acceptedUpsellProof,
@@ -6500,9 +7696,24 @@ export const __qaBrowserTestHooks = Object.freeze({
6500
7696
  primaryCtaInspectionScript,
6501
7697
  clickCouponApplyControl,
6502
7698
  isOrderUpsellsUrl,
7699
+ clickUpsellPath,
7700
+ isPerpetuallyAnimated,
7701
+ readJsonResponseBody,
7702
+ readJsonResponseBodyWithin,
7703
+ readJsonResponseBodyBounded,
7704
+ RESPONSE_BODY_READ_TIMEOUT_MS,
7705
+ waitForLateUpsellEvidence,
7706
+ refreshUpsellStepEvidence,
7707
+ acceptedUpsellStepProof,
7708
+ REQUEST_IDENTITY,
7709
+ captureCheckoutEvents,
7710
+ buildOrderEvidence,
6503
7711
  testEmail,
6504
7712
  testOrderPaths,
6505
7713
  testOrderPlans,
7714
+ commonOrderPathPlan,
7715
+ describeCommonOrderPathPlan,
7716
+ upsellActionCoverageAssertion,
6506
7717
  planId,
6507
7718
  argsForPlan,
6508
7719
  enforceTestOrderLimit,