@nextcommerce/campaigns-os 1.43.1 → 1.43.2

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 (56) hide show
  1. package/AGENTS.md +4 -2
  2. package/CHANGELOG.md +451 -0
  3. package/README.md +2 -2
  4. package/contracts/agent-relevant-change-policy.v1.json +5 -0
  5. package/contracts/effects.v1.json +8 -8
  6. package/contracts/release-ledger.json +672 -0
  7. package/contracts/supported-surface.json +2 -2
  8. package/docs/build-packet.md +64 -2
  9. package/docs/campaigns-os-build-flow.md +1 -0
  10. package/docs/design-source-package.md +89 -15
  11. package/docs/effects.md +16 -4
  12. package/docs/local-setup.md +1 -1
  13. package/docs/orientation-contract-reference.md +1 -1
  14. package/docs/progress-snapshots.md +10 -6
  15. package/docs/qa-and-test-orders.md +131 -7
  16. package/docs/release-ledger-authoring-guide.md +6 -4
  17. package/docs/runtime-readiness.md +1 -1
  18. package/docs/skills-revision.md +10 -10
  19. package/package.json +1 -1
  20. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  21. package/skills/campaign-readback-classification/SKILL.md +3 -3
  22. package/skills/campaign-run-evidence/SKILL.md +3 -3
  23. package/skills/contribution-intake/SKILL.md +3 -3
  24. package/skills/next-campaigns-build/SKILL.md +4 -4
  25. package/skills/next-campaigns-os/SKILL.md +3 -3
  26. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  27. package/skills/next-campaigns-polish/SKILL.md +3 -3
  28. package/skills/next-campaigns-qa/SKILL.md +3 -3
  29. package/skills.json +10 -10
  30. package/src/build-brief.mjs +6 -4
  31. package/src/built-script-syntax.mjs +480 -0
  32. package/src/campaigns-api-key.mjs +99 -0
  33. package/src/cli-helpers.mjs +118 -0
  34. package/src/cli.mjs +1211 -7495
  35. package/src/design-source-package.mjs +1 -1
  36. package/src/design-source-publication.mjs +898 -0
  37. package/src/diagnostic.mjs +2 -1
  38. package/src/directory-lock.mjs +270 -0
  39. package/src/doctor/checks.mjs +4415 -0
  40. package/src/doctor/inspect.mjs +636 -0
  41. package/src/doctor/next-step.mjs +731 -0
  42. package/src/install-invocation.mjs +29 -0
  43. package/src/invocation.mjs +179 -0
  44. package/src/private-template-source.mjs +1 -1
  45. package/src/progress-node.mjs +6 -35
  46. package/src/proof-policy.mjs +1 -1
  47. package/src/qa-analytics-correctness.mjs +3 -0
  48. package/src/qa-binding-evidence.mjs +76 -11
  49. package/src/qa-browser.mjs +778 -77
  50. package/src/qa-build-scope.mjs +47 -0
  51. package/src/qa-node.mjs +218 -13
  52. package/src/source-html-intake.mjs +1 -1
  53. package/src/source-html-manifest.mjs +9 -2
  54. package/src/stage-ledger.mjs +28 -0
  55. package/src/target-lock.mjs +54 -0
  56. package/src/template-brand-contract.mjs +17 -1
@@ -55,6 +55,7 @@ import {
55
55
  placeholderTextResidueMatches,
56
56
  referencedDemoAssetBasenames,
57
57
  summarizePlaceholderTerms,
58
+ withoutHiddenPaymentLogos,
58
59
  } from "./template-brand-contract.mjs";
59
60
 
60
61
  const DEFAULT_BROWSER_TIMEOUT_MS = 30000;
@@ -256,6 +257,11 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
256
257
  // reader could be surprised by. Over-charging costs at worst one unspent
257
258
  // planned path; under-charging costs a real order nobody budgeted for.
258
259
  creationBudget.consume({ plan_id: identifier, kind: "hosted_checkout_redirect" });
260
+ } else if (firstAttempt.upsell_unverified) {
261
+ // Created, and every check passed except an accepted upsell nothing
262
+ // could prove or disprove. A re-run would buy a second order to ask
263
+ // again, and recovery cannot re-check an upsell read-only, so the
264
+ // attempt stands and goes to manual review.
259
265
  } else if (!firstAttempt.ok) {
260
266
  const classification = classifyTestOrderCreation(firstAttempt);
261
267
  if (classification.creation === "not_created") {
@@ -401,7 +407,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
401
407
  // with --analytics-baseline's legacy receipt, and identity resolution cannot
402
408
  // derive a receipt page yet (receipt-aware capture is out of packet 01's
403
409
  // scope). Absent that override, the candidate IS the resolved target.
404
- function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl }) {
410
+ function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl, capturePage = null, rootFallback = null }) {
405
411
  const baselinePublicUrl = redactUrlQuery(baselineUrl);
406
412
  const candidatePublicUrl = redactUrlQuery(candidateUrl);
407
413
  const analyticsPage = { page_id: "analytics", url: candidatePublicUrl || baselinePublicUrl || undefined };
@@ -421,6 +427,8 @@ function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, ca
421
427
  candidate_event_count: candidate.eventNames.length,
422
428
  baseline_inventory: Object.fromEntries(Object.entries(baseline.inventory).map(([k, v]) => [k, v.length])),
423
429
  candidate_inventory: Object.fromEntries(Object.entries(candidate.inventory).map(([k, v]) => [k, v.length])),
430
+ ...(capturePage ? { capture_page: capturePage } : {}),
431
+ ...(rootFallback ? { root_fallback: rootFallback } : {}),
424
432
  },
425
433
  }));
426
434
  return assertions;
@@ -474,9 +482,14 @@ export async function runAnalyticsParityChecks(args = {}, options = {}) {
474
482
  extraHTTPHeaders: args["auth-cookie"] ? { Cookie: String(args["auth-cookie"]) } : undefined,
475
483
  });
476
484
  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 });
485
+ // An explicit --analytics-candidate (the receipt pairing above) is the
486
+ // page the operator named, so it is captured as given.
487
+ if (trim(args["analytics-candidate"])) {
488
+ const baseline = await captureAnalyticsForUrl(context, baselineUrl, args, extraHosts);
489
+ const candidate = await captureAnalyticsForUrl(context, candidateUrl, args, extraHosts);
490
+ return analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl });
491
+ }
492
+ return await captureAnalyticsParityInContext(context, baselineUrl, candidateUrl, args, extraHosts, options);
480
493
  } catch (error) {
481
494
  return [analyticsParityRunnerFailureAssertion({ baselineUrl, candidateUrl, error })];
482
495
  } finally {
@@ -485,16 +498,57 @@ export async function runAnalyticsParityChecks(args = {}, options = {}) {
485
498
  }
486
499
  }
487
500
 
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.
501
+ // #503: the resolved-target candidate gets the same partial-scope fallback as
502
+ // the correctness inventory (#493). A partial build has no page at the
503
+ // identity root, so the candidate is the first built in-scope entry, and a
504
+ // root that answers non-2xx falls back the same way. The candidate is picked
505
+ // first: when nothing in scope answers, the leg reports
506
+ // no_in_scope_page_captured (skipped) or no_capture_page_answered (blocker)
507
+ // without loading the baseline, instead of diffing the baseline against an
508
+ // empty or generic page.
509
+ async function captureAnalyticsParityInContext(context, baselineUrl, targetUrl, args, extraHosts, options = {}) {
510
+ const selected = await captureFirstAnsweringAnalyticsPage(context, targetUrl, args, extraHosts, options);
511
+ if (!selected.capture) {
512
+ return [analyticsCorrectnessNoCapturePageAssertion({ rootUrl: targetUrl, attempts: selected.attempts, family: "analytics-parity" })];
513
+ }
514
+ const baseline = await captureAnalyticsForUrl(context, baselineUrl, args, extraHosts);
515
+ return analyticsParityCaptureAssertions({
516
+ baseline,
517
+ candidate: selected.capture,
518
+ baselineUrl,
519
+ candidateUrl: selected.url,
520
+ capturePage: selected.capturePage,
521
+ rootFallback: selected.rootFallback,
522
+ });
523
+ }
524
+
525
+ // Analytics CORRECTNESS inventory leg: capture ONE page and assess only
526
+ // declared tags/pixels. Purchase is finalized later from the canonical
527
+ // typed-card order's recognized receipt; this inventory visit is never treated
528
+ // as Purchase authority.
492
529
  // `options.target` is the capture target resolved from the campaign's
493
530
  // identity (public_route_slug + route_root) in qa-node — packet 01 / INV-2:
494
531
  // this leg no longer reads --analytics-candidate or --base-url; the URL it
495
532
  // visits is a function of resolved identity, recorded on every assertion.
496
- function analyticsCorrectnessCaptureAssertions({ capture, contract, url }) {
533
+ //
534
+ // #493: a partial build can have no page at that campaign root (the built
535
+ // entry is deeper, e.g. checkout/). When qa-node reports the root out of the
536
+ // built scope (`options.rootInScope === false`), or the root answers non-2xx,
537
+ // the leg captures the first built in-scope entry instead
538
+ // (`options.fallbackTargets`, the same entry the partial-scope planner
539
+ // selects) and records which page it used. When nothing in scope can be
540
+ // captured, the leg is skipped when the build has no capturable page, and
541
+ // fails as a blocker when candidates existed but none answered 2xx; neither
542
+ // case fails every declared vendor against an empty page.
543
+ // #500: the URL the capture page actually settled on, after redirects, keyed by
544
+ // the capture object so the parity capture shape stays unchanged. Local-serve
545
+ // review (qa-node) downgrades a silent pixel only when this is loopback: a
546
+ // localhost root that redirects to a production host measured production.
547
+ const ANALYTICS_CAPTURE_DOCUMENT_URL = new WeakMap();
548
+
549
+ function analyticsCorrectnessCaptureAssertions({ capture, contract, url, capturePage = null, rootFallback = null, finalUrl = ANALYTICS_CAPTURE_DOCUMENT_URL.get(capture) ?? null }) {
497
550
  const publicUrl = redactUrlQuery(url);
551
+ const publicFinalUrl = redactUrlQuery(finalUrl) || null;
498
552
  const analyticsPage = { page_id: "analytics", url: publicUrl || undefined };
499
553
  const assertions = assessAnalyticsInventory(capture, contract || {}, { url: publicUrl });
500
554
  assertions.unshift(assertion({
@@ -503,18 +557,63 @@ function analyticsCorrectnessCaptureAssertions({ capture, contract, url }) {
503
557
  page: analyticsPage,
504
558
  status: STATUS.PASS,
505
559
  expected: "live dataLayer + tag-fire capture on the candidate page",
506
- actual: `events=${capture.eventNames.length}, tags=${Object.values(capture.inventory).flat().length}`,
560
+ actual: `events=${capture.eventNames.length}, tags=${Object.values(capture.inventory).flat().length}`
561
+ + (rootFallback ? ` (captured built entry ${publicUrl}; campaign root ${rootFallback.reason === "non_2xx" ? `answered HTTP ${rootFallback.http_status}` : "is out of the built scope"})` : ""),
507
562
  // Counts only. Root capture is provider/tag inventory, never Purchase
508
563
  // authority, even if a stray Purchase happens to appear there.
509
564
  evidence: {
510
565
  url: publicUrl,
511
566
  event_count: capture.eventNames.length,
512
567
  inventory: Object.fromEntries(Object.entries(capture.inventory).map(([k, v]) => [k, v.length])),
568
+ // The page URL after redirects and settling; `capture_page.url` and
569
+ // `url` are the URL requested.
570
+ final_url: publicFinalUrl,
571
+ ...(capturePage ? { capture_page: capturePage } : {}),
572
+ ...(rootFallback ? { root_fallback: rootFallback } : {}),
513
573
  },
514
574
  }));
515
575
  return assertions;
516
576
  }
517
577
 
578
+ // No page was captured. Two cases, kept apart so a failed capture never
579
+ // silently removes analytics gating:
580
+ // - nothing built was capturable (the root is out of the built scope and no
581
+ // built entry exists): SKIPPED, disposition-neutral, since there is no page
582
+ // whose tags could be measured;
583
+ // - candidates existed but none answered 2xx (e.g. transient 503s): the
584
+ // declared vendors went unmeasured, which is a blocker naming each attempt,
585
+ // so a later successful order cannot report the run ready.
586
+ // The parity leg (#503) reports the same two outcomes under its own family.
587
+ function analyticsCorrectnessNoCapturePageAssertion({ rootUrl, attempts, family = "analytics-correctness" }) {
588
+ const publicUrl = redactUrlQuery(rootUrl);
589
+ const page = { page_id: "analytics", url: publicUrl || undefined };
590
+ const expected = "live dataLayer + tag-fire capture on the campaign root or the first built in-scope page";
591
+ const loaded = attempts.filter((attempt) => attempt.outcome === "non_2xx" || attempt.outcome === "navigation_error");
592
+ if (!loaded.length) {
593
+ return assertion({
594
+ id: `${family}:capture`,
595
+ family,
596
+ page,
597
+ status: STATUS.SKIPPED,
598
+ expected,
599
+ 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",
600
+ evidence: { url: publicUrl, reason: "no_in_scope_page_captured", attempts },
601
+ });
602
+ }
603
+ return assertion({
604
+ id: `${family}:capture`,
605
+ family,
606
+ page,
607
+ status: STATUS.FAIL,
608
+ severity: SEVERITY.BLOCKER,
609
+ expected,
610
+ actual: `no_capture_page_answered: declared analytics went unmeasured; ${loaded.map((attempt) => (attempt.outcome === "navigation_error"
611
+ ? `${attempt.url} failed to load (${attempt.error_code})`
612
+ : `${attempt.url} answered HTTP ${attempt.http_status}`)).join(", ")}`,
613
+ evidence: { url: publicUrl, reason: "no_capture_page_answered", attempts },
614
+ });
615
+ }
616
+
518
617
  function analyticsCorrectnessRunnerFailureAssertion({ url, error }) {
519
618
  const publicUrl = redactUrlQuery(url);
520
619
  const captureError = projectAnalyticsCaptureError(error, { fallbackKind: "unreadable" });
@@ -530,6 +629,147 @@ function analyticsCorrectnessRunnerFailureAssertion({ url, error }) {
530
629
  });
531
630
  }
532
631
 
632
+ function isHttpOk(status) {
633
+ // A null status (same-document or non-HTTP navigation) is not evidence of
634
+ // a missing page, so it keeps the capture.
635
+ return status == null || (status >= 200 && status < 300);
636
+ }
637
+
638
+ // One capture per page: `/campaign`, `/campaign/` and `/campaign/index.html`
639
+ // are the same page, so a topology URL written differently from the root is
640
+ // not loaded twice.
641
+ // #503: step routing is path-based in every certified family (page-kit builds
642
+ // each page to its own `<route>/index.html`; a spec route has its query
643
+ // stripped), so a query string does not normally name a different page. A
644
+ // URL that declares its own query (`/campaign/?step=checkout`) is still kept
645
+ // apart from a page without that query: merging it into the root on path alone
646
+ // measured the root's generic answer instead of that entry. A URL without a
647
+ // query of its own names the page on any query (an operator's `?preview=` on
648
+ // the root does not split it from the built page at that path).
649
+ function analyticsCapturePageKey(value) {
650
+ try {
651
+ const parsed = new URL(value);
652
+ const path = parsed.pathname.replace(/(?:^|\/)index\.html$/, "/").replace(/\/+$/, "");
653
+ parsed.searchParams.sort();
654
+ return { path: `${parsed.origin}${path}/`, query: parsed.searchParams.toString() };
655
+ } catch {
656
+ return typeof value === "string" && value.trim() ? { path: redactUrlQuery(value), query: "" } : null;
657
+ }
658
+ }
659
+
660
+ // True when `candidate` is the page `known` already names (see
661
+ // analyticsCapturePageKey). Shared with qa-node's root-in-scope judgment so
662
+ // scope and deduplication cannot disagree about which page is the root.
663
+ export function isSameAnalyticsCapturePage(candidate, known) {
664
+ const a = analyticsCapturePageKey(candidate);
665
+ const b = analyticsCapturePageKey(known);
666
+ if (!a || !b || a.path !== b.path) return false;
667
+ return !a.query || a.query === b.query;
668
+ }
669
+
670
+ // A URL on `root`'s path told apart from it by a query of its own: the entry
671
+ // `isSameAnalyticsCapturePage` keeps separate from the root although its
672
+ // redacted URL reads as the root.
673
+ function isQueryRoutedFrom(value, root) {
674
+ const a = analyticsCapturePageKey(value);
675
+ const b = analyticsCapturePageKey(root);
676
+ return !!a && !!b && a.path === b.path && !!a.query && a.query !== b.query;
677
+ }
678
+
679
+ function analyticsCaptureCandidates(url, options = {}) {
680
+ const candidates = [];
681
+ const seen = [];
682
+ const add = (candidate) => {
683
+ if (!candidate.url || seen.some((known) => isSameAnalyticsCapturePage(candidate.url, known))) return;
684
+ seen.push(candidate.url);
685
+ candidates.push(candidate);
686
+ };
687
+ const rootInScope = options.rootInScope !== false;
688
+ if (rootInScope) add({ url, source: "campaign_root" });
689
+ // An out-of-scope root stays unvisited even if a fallback names it.
690
+ else if (url) seen.push(url);
691
+ for (const entry of Array.isArray(options.fallbackTargets) ? options.fallbackTargets : []) {
692
+ if (!entry || !trim(entry.url)) continue;
693
+ add({
694
+ url: trim(entry.url),
695
+ source: "built_entry",
696
+ page_id: entry.page_id || null,
697
+ funnel_id: entry.funnel_id || null,
698
+ // The query value is redacted from every record; this says the entry
699
+ // was told apart from the root by its query alone.
700
+ ...(url && isQueryRoutedFrom(trim(entry.url), url) ? { query_routed: true } : {}),
701
+ });
702
+ }
703
+ return { candidates, rootInScope, rootKey: redactUrlQuery(url) };
704
+ }
705
+
706
+ // Walks the capture candidates (the campaign root when in scope, then the
707
+ // built entries) and captures the first page that answers 2xx. Shared by the
708
+ // correctness inventory (#493) and the opt-in parity candidate (#503) so both
709
+ // legs pick the same page. Returns `{ capture, url, capturePage, rootFallback }`
710
+ // for the page it used, or `{ capture: null, attempts }` when none answered.
711
+ async function captureFirstAnsweringAnalyticsPage(context, url, args, extraHosts, options = {}, { strictCollect = false } = {}) {
712
+ const { candidates, rootInScope, rootKey } = analyticsCaptureCandidates(url, options);
713
+ const attempts = [];
714
+ if (!rootInScope) attempts.push({ url: rootKey, source: "campaign_root", outcome: "out_of_built_scope", http_status: null });
715
+ let rootFallback = rootInScope ? null : { url: rootKey, reason: "out_of_built_scope", http_status: null };
716
+ const queryRouted = (candidate) => (candidate.query_routed ? { query_routed: true } : {});
717
+ for (const candidate of candidates) {
718
+ const { capture, httpStatus, navigationError } = await captureAnalyticsPage(
719
+ context, candidate.url, args, extraHosts, { perPageNavigationErrors: true, strictCollect },
720
+ );
721
+ if (navigationError) {
722
+ // One page failing to load (a timeout, a refused connection) moves on to
723
+ // the next candidate; the blocker lists it if none answers.
724
+ attempts.push({ url: redactUrlQuery(candidate.url), source: candidate.source, ...queryRouted(candidate), outcome: "navigation_error", http_status: null, error_code: navigationError });
725
+ if (candidate.source === "campaign_root") {
726
+ rootFallback = { url: rootKey, reason: "navigation_error", http_status: null, error_code: navigationError };
727
+ }
728
+ continue;
729
+ }
730
+ if (isHttpOk(httpStatus)) {
731
+ const usedFallback = candidate.source !== "campaign_root";
732
+ return {
733
+ capture,
734
+ url: candidate.url,
735
+ capturePage: {
736
+ url: redactUrlQuery(candidate.url),
737
+ source: candidate.source,
738
+ ...(candidate.page_id ? { page_id: candidate.page_id } : {}),
739
+ ...(candidate.funnel_id ? { funnel_id: candidate.funnel_id } : {}),
740
+ ...queryRouted(candidate),
741
+ http_status: httpStatus ?? null,
742
+ },
743
+ rootFallback: usedFallback ? rootFallback : null,
744
+ };
745
+ }
746
+ attempts.push({ url: redactUrlQuery(candidate.url), source: candidate.source, ...queryRouted(candidate), outcome: "non_2xx", http_status: httpStatus });
747
+ if (candidate.source === "campaign_root") {
748
+ rootFallback = { url: rootKey, reason: "non_2xx", http_status: httpStatus };
749
+ }
750
+ }
751
+ return { capture: null, attempts };
752
+ }
753
+
754
+ // Browser-owning half split out so a real-browser test can drive the fallback
755
+ // against a route-fulfilled context without a second Chromium launch policy.
756
+ async function captureAnalyticsCorrectnessInContext(context, url, contract, args, extraHosts, options = {}) {
757
+ // strictCollect: a page.evaluate() that fails during collection (the
758
+ // execution context destroyed by a reload, a crashed page) must not read
759
+ // as a clean empty capture. It propagates and becomes the
760
+ // analytics-correctness:runner blocker, so no tag check is emitted from an
761
+ // unmeasured page and local-serve review has nothing to downgrade (#500).
762
+ const selected = await captureFirstAnsweringAnalyticsPage(context, url, args, extraHosts, options, { strictCollect: true });
763
+ if (!selected.capture) return [analyticsCorrectnessNoCapturePageAssertion({ rootUrl: url, attempts: selected.attempts })];
764
+ return analyticsCorrectnessCaptureAssertions({
765
+ capture: selected.capture,
766
+ contract,
767
+ url: selected.url,
768
+ capturePage: selected.capturePage,
769
+ rootFallback: selected.rootFallback,
770
+ });
771
+ }
772
+
533
773
  export async function runAnalyticsCorrectnessChecks(args = {}, contract = {}, options = {}) {
534
774
  const url = trim(options.target?.url) || null;
535
775
  const correctnessPage = { page_id: "analytics", url: redactUrlQuery(url) || undefined };
@@ -545,12 +785,7 @@ export async function runAnalyticsCorrectnessChecks(args = {}, contract = {}, op
545
785
  })];
546
786
  }
547
787
 
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];
788
+ const extraHosts = analyticsCorrectnessExtraHosts(args, contract);
554
789
 
555
790
  const browser = await launchChromium(args);
556
791
  const context = await browser.newContext({
@@ -558,8 +793,7 @@ export async function runAnalyticsCorrectnessChecks(args = {}, contract = {}, op
558
793
  extraHTTPHeaders: args["auth-cookie"] ? { Cookie: String(args["auth-cookie"]) } : undefined,
559
794
  });
560
795
  try {
561
- const capture = await captureAnalyticsForUrl(context, url, args, extraHosts);
562
- return analyticsCorrectnessCaptureAssertions({ capture, contract, url });
796
+ return await captureAnalyticsCorrectnessInContext(context, url, contract, args, extraHosts, options);
563
797
  } catch (error) {
564
798
  return [analyticsCorrectnessRunnerFailureAssertion({ url, error })];
565
799
  } finally {
@@ -568,6 +802,15 @@ export async function runAnalyticsCorrectnessChecks(args = {}, contract = {}, op
568
802
  }
569
803
  }
570
804
 
805
+ // Seed the host filter with declared out-of-band vendor names so vendors whose
806
+ // host contains their name (everflow, northbeam, …) get captured.
807
+ function analyticsCorrectnessExtraHosts(args, contract) {
808
+ const vendorHosts = ((contract && contract.out_of_band_pixels) || [])
809
+ .map((p) => (p && p.vendor ? String(p.vendor) : null))
810
+ .filter(Boolean);
811
+ return [...analyticsExtraHosts(args), ...vendorHosts];
812
+ }
813
+
571
814
  function analyticsExtraHosts(args) {
572
815
  const raw = args["analytics-hosts"];
573
816
  if (!raw) return [];
@@ -575,6 +818,22 @@ function analyticsExtraHosts(args) {
575
818
  }
576
819
 
577
820
  export async function captureAnalyticsForUrl(context, url, args, extraHosts = []) {
821
+ return (await captureAnalyticsPage(context, url, args, extraHosts)).capture;
822
+ }
823
+
824
+ // Same capture, plus the main-document HTTP status, which the correctness leg
825
+ // uses to tell an empty page from a missing one (#493). Kept off the capture
826
+ // object so parity comparisons never see it.
827
+ // A stable code for a failed navigation: Playwright's TimeoutError, or the
828
+ // net::ERR_* token Chromium reports. The raw message is dropped because it
829
+ // echoes the URL (query included) and a call log.
830
+ function analyticsNavigationErrorCode(error) {
831
+ if (error?.name === "TimeoutError") return "navigation_timeout";
832
+ const netError = String(error?.message || "").match(/net::ERR_[A-Z0-9_]+/);
833
+ return netError ? netError[0] : "navigation_failed";
834
+ }
835
+
836
+ async function captureAnalyticsPage(context, url, args, extraHosts = [], options = {}) {
578
837
  const page = await context.newPage();
579
838
  const capture = await attachAnalyticsCapture(page, { extraHosts });
580
839
  const timeoutMs = numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS);
@@ -583,11 +842,27 @@ export async function captureAnalyticsForUrl(context, url, args, extraHosts = []
583
842
  // domcontentloaded (not "load") so a single stuck analytics beacon — exactly
584
843
  // the kind of subresource we're capturing — can't starve the goto timeout.
585
844
  // Mirrors runPageBrowserChecks; the settle wait below lets async tags fire.
586
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: timeoutMs });
845
+ let response;
846
+ try {
847
+ response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: timeoutMs });
848
+ } catch (error) {
849
+ // Only the navigation is per-page. A closed page or a disconnected
850
+ // browser is the runner failing, not this URL, so it stays an error.
851
+ if (!options.perPageNavigationErrors || page.isClosed() || context.browser()?.isConnected() === false) throw error;
852
+ return { capture: null, httpStatus: null, navigationError: analyticsNavigationErrorCode(error) };
853
+ }
854
+ let httpStatus = null;
855
+ try { httpStatus = typeof response?.status === "function" ? response.status() : null; } catch { httpStatus = null; }
587
856
  await page.waitForLoadState("networkidle", { timeout: settleMs }).catch(() => {});
588
857
  // Let async GTM/pixel tags and deferred dataLayer pushes fire before reading.
589
858
  await page.waitForTimeout(settleMs);
590
- return await capture.collect();
859
+ // Parity keeps the historical best-effort read; the correctness leg
860
+ // collects strictly (see captureAnalyticsCorrectnessInContext).
861
+ const collected = await capture.collect({ strict: options.strictCollect === true });
862
+ let finalUrl = null;
863
+ try { finalUrl = page.url() || null; } catch { finalUrl = null; }
864
+ if (collected && typeof collected === "object" && finalUrl) ANALYTICS_CAPTURE_DOCUMENT_URL.set(collected, finalUrl);
865
+ return { capture: collected, httpStatus };
591
866
  } finally {
592
867
  capture.detach();
593
868
  await page.close().catch(() => {});
@@ -1732,7 +2007,9 @@ async function templateResidueAssertions(browserPage, page, options = {}) {
1732
2007
  if (chrome && Array.isArray(supported) && supported.length) {
1733
2008
  const unsupported = (chrome.methods || []).filter((method) => !supported.includes(method));
1734
2009
  if (unsupported.length) {
1735
- const html = await browserPage.content().catch(() => "");
2010
+ // Logos the template still keeps hidden (payment-logos.html) are gated,
2011
+ // not residue; a revealed one stays in the HTML and is judged below.
2012
+ const html = withoutHiddenPaymentLogos(await browserPage.content().catch(() => ""));
1736
2013
  // One evaluate for ALL unsupported methods' selectors; partition the
1737
2014
  // visibility results per method in JS to keep browser round-trips flat.
1738
2015
  const artifactsByMethod = new Map(unsupported.map((method) => [method, methodPaymentArtifacts(chrome, method)]));
@@ -2776,6 +3053,11 @@ async function collectOrderAnalytics({
2776
3053
  deadline,
2777
3054
  now = () => Date.now(),
2778
3055
  wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
3056
+ // The page's document location, read right after the settled collection
3057
+ // (#500). The order's final_url is recorded before this settle window, so a
3058
+ // receipt that redirects while analytics settle would otherwise be judged
3059
+ // by where it started, not where Purchase was measured.
3060
+ readDocumentUrl = null,
2779
3061
  }) {
2780
3062
  const result = {};
2781
3063
  if (!captureHandle) return result;
@@ -2806,14 +3088,17 @@ async function collectOrderAnalytics({
2806
3088
 
2807
3089
  const collection = deadlineConsumed
2808
3090
  ? { timedOut: true }
2809
- : await runWithinAnalyticsDeadline(async () => (
2810
- typeof captureHandle.collectScopes === "function"
2811
- ? captureHandle.collectScopes({ strict: true })
3091
+ : await runWithinAnalyticsDeadline(async () => {
3092
+ const scopes = typeof captureHandle.collectScopes === "function"
3093
+ ? await captureHandle.collectScopes({ strict: true })
2812
3094
  : {
2813
3095
  journey: await captureHandle.collect({ strict: true }),
2814
3096
  currentDocument: await captureHandle.collect({ strict: true, scope: "current-document" }),
2815
- }
2816
- ), { deadline, now });
3097
+ };
3098
+ let documentUrl = null;
3099
+ try { documentUrl = typeof readDocumentUrl === "function" ? (readDocumentUrl() || null) : null; } catch { documentUrl = null; }
3100
+ return { ...scopes, documentUrl };
3101
+ }, { deadline, now });
2817
3102
  if (collection.timedOut || now() > deadline) {
2818
3103
  result.journeyCaptureError = analyticsCaptureError("collectionDeadline");
2819
3104
  if (receiptRecognized && !settleError) {
@@ -2824,7 +3109,10 @@ async function collectOrderAnalytics({
2824
3109
  if (receiptRecognized && !settleError) result.receiptCaptureError = analyticsCaptureError("unreadable");
2825
3110
  } else {
2826
3111
  result.journeyCapture = collection.value.journey;
2827
- if (receiptRecognized && !settleError) result.receiptCapture = collection.value.currentDocument;
3112
+ if (receiptRecognized && !settleError) {
3113
+ result.receiptCapture = collection.value.currentDocument;
3114
+ if (collection.value.documentUrl) result.receiptDocumentUrl = collection.value.documentUrl;
3115
+ }
2828
3116
  }
2829
3117
  if (receiptRecognized && settleError) result.receiptCaptureError = settleError;
2830
3118
  return result;
@@ -2952,11 +3240,13 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
2952
3240
  receiptRecognized,
2953
3241
  settleMs: numberArg(planArgs["analytics-settle"], DEFAULT_SETTLE_TIMEOUT_MS),
2954
3242
  deadline: orderDeadline ?? Date.now(),
3243
+ readDocumentUrl: () => (page ? safePageUrl(page) : null),
2955
3244
  });
2956
3245
  if (captures.journeyCapture) result.analytics_journey_capture = captures.journeyCapture;
2957
3246
  if (captures.journeyCaptureError) result.analytics_journey_capture_error = captures.journeyCaptureError;
2958
3247
  if (captures.receiptCapture) result.receipt_analytics_capture = captures.receiptCapture;
2959
3248
  if (captures.receiptCaptureError) result.receipt_analytics_capture_error = captures.receiptCaptureError;
3249
+ if (captures.receiptDocumentUrl) result.receipt_document_url = captures.receiptDocumentUrl;
2960
3250
  } else if (analyticsAttachError) {
2961
3251
  result.analytics_journey_capture_error = analyticsAttachError;
2962
3252
  if (receiptRecognized) result.receipt_analytics_capture_error = analyticsAttachError;
@@ -3089,6 +3379,11 @@ function receiptAnalyticsAttempt(plan, result) {
3089
3379
  planId: id,
3090
3380
  receiptRecognized,
3091
3381
  receiptUrl: redactUrlQuery(finalUrl),
3382
+ // Where the receipt page actually was once its analytics settled and were
3383
+ // collected; absent when that reading was not taken.
3384
+ ...(receiptRecognized && result?.receipt_document_url
3385
+ ? { receiptDocumentUrl: redactUrlQuery(result.receipt_document_url) }
3386
+ : {}),
3092
3387
  ...(receiptRecognized && result?.receipt_analytics_capture
3093
3388
  ? { capture: result.receipt_analytics_capture }
3094
3389
  : {}),
@@ -3260,19 +3555,29 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3260
3555
  }
3261
3556
  const step = upsellSteps[stepIndex];
3262
3557
  const actionTrace = createUpsellActionTrace({ page, events, topologyPlan, stepIndex, path: step });
3558
+ const stepBudgetMs = budget();
3559
+ const stepStartedMs = Date.now();
3263
3560
  await ladder.run("upsell_action", async () => {
3264
3561
  const initialLineItems = order.receipt_line_items.slice();
3265
3562
  const initialUpsellMutationCount = upsellMutationCount(events);
3266
3563
  await actionTrace.inspect();
3267
3564
  await waitForUpsellPageReady(page, args);
3268
3565
  await actionTrace.inspect();
3566
+ const responseIndexBefore = events.responses.length;
3269
3567
  const upsell = await clickUpsellPath(page, step, { events, stepIndex, trace: actionTrace });
3270
3568
  const preferredOrderBody = upsell.api_response_order_body || null;
3271
3569
  delete upsell.api_response_order_body;
3272
3570
  order.upsell = upsell;
3273
3571
  order.upsell_steps.push(upsell);
3274
3572
  order.final_url = safePageUrl(page);
3275
- const refreshed = await buildOrderEvidence({ page, events, path, email, checkoutPage, args, preferredOrderBody });
3573
+ const { refreshed, lateUpsellEvidence } = await refreshUpsellStepEvidence({
3574
+ page, events, path, email, checkoutPage, args, step, upsell, preferredOrderBody, initialLineItems, responseIndexBefore,
3575
+ // Keep the rest of the step's budget for the read-back after the wait.
3576
+ lateWaitMs: Math.min(
3577
+ LATE_UPSELL_EVIDENCE_TIMEOUT_MS,
3578
+ stepBudgetMs - (Date.now() - stepStartedMs) - LATE_UPSELL_EVIDENCE_RESERVE_MS,
3579
+ ),
3580
+ });
3276
3581
  order.final_receipt_line_items = refreshed.receipt_line_items;
3277
3582
  if (refreshed.receipt_line_items.length) {
3278
3583
  order.cart_state = refreshed.cart_state;
@@ -3284,9 +3589,9 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3284
3589
  order.verification.currency = refreshed.verification.currency;
3285
3590
  }
3286
3591
  if (step === "accept") {
3287
- const proof = acceptedUpsellProof(order.receipt_line_items, initialLineItems, upsell.expected_items, events);
3592
+ const proof = acceptedUpsellStepProof(upsell, lateUpsellEvidence, acceptedUpsellProof(order.receipt_line_items, initialLineItems, upsell.expected_items, events));
3288
3593
  upsell.verification = {
3289
- accepted_upsell_line_present: proof.ok,
3594
+ accepted_upsell_line_present: proof.unverified ? null : proof.ok,
3290
3595
  accepted_upsell_match: proof,
3291
3596
  upsell_api_response_seen: upsell.api_response_seen,
3292
3597
  upsell_api_response_status: upsell.api_response_status,
@@ -3304,7 +3609,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3304
3609
  actionTrace.markStepCompleted();
3305
3610
  return `step ${stepIndex + 1}: ${step}`;
3306
3611
  }, {
3307
- timeoutMs: budget(),
3612
+ timeoutMs: stepBudgetMs,
3308
3613
  evidence: actionTrace.summary,
3309
3614
  formatError: actionTrace.formatError,
3310
3615
  });
@@ -3336,7 +3641,19 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3336
3641
 
3337
3642
  const acceptedSteps = (order.upsell_steps || []).filter((step) => step.path === "accept");
3338
3643
  if (acceptedSteps.length) {
3339
- order.verification.accepted_upsell_line_present = acceptedSteps.every((step) => step.verification?.accepted_upsell_line_present === true);
3644
+ const unverifiedSteps = acceptedSteps.filter((step) => step.verification?.accepted_upsell_match?.unverified === true);
3645
+ const confirmedMissing = acceptedSteps.some((step) => step.verification?.accepted_upsell_line_present === false);
3646
+ // Unknown, not false, when every step that is not proven is only
3647
+ // unverified: false would read as a confirmed missing line.
3648
+ order.verification.accepted_upsell_line_present = unverifiedSteps.length && !confirmedMissing
3649
+ ? null
3650
+ : acceptedSteps.every((step) => step.verification?.accepted_upsell_line_present === true);
3651
+ if (unverifiedSteps.length) {
3652
+ order.verification.upsell_unverified = unverifiedSteps.map((step) => step.verification.accepted_upsell_match.reason);
3653
+ // Whatever else the path finds, an order whose accepted upsell nothing
3654
+ // proved is not a verified order (#505).
3655
+ order.verification.verified = false;
3656
+ }
3340
3657
  order.verification.upsell_api_response_seen = acceptedSteps.every((step) => step.verification?.upsell_api_response_seen === true);
3341
3658
  order.verification.accepted_upsell_matches = acceptedSteps.map((step) => step.verification?.accepted_upsell_match).filter(Boolean);
3342
3659
  }
@@ -3362,10 +3679,20 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3362
3679
  }
3363
3680
 
3364
3681
  const pathFailures = [...stepFailures, ...receiptFailures, ...couponFailures];
3365
- const ok = order.ok && pathFailures.length === 0;
3682
+ const clean = order.ok && pathFailures.length === 0;
3683
+ // A path whose only open question is an unverified accepted upsell is not
3684
+ // a pass: nothing proved the line was added. `ok` stays false so no reader
3685
+ // of the result or the order takes it as proven, and `upsell_unverified`
3686
+ // tells it apart from a failure: the dispatcher neither re-runs it (that
3687
+ // would buy a second order) nor recovers it, and the assertion reports it
3688
+ // for manual review (#505). The order itself was created and read back, so
3689
+ // order.ok keeps saying so; only its verification drops to unverified.
3690
+ const upsellUnverified = clean ? order.verification.upsell_unverified || null : null;
3691
+ const ok = clean && !upsellUnverified;
3366
3692
  return {
3367
3693
  ok,
3368
- error: ok ? null : order.error || order.upsell?.error || pathFailures.join("; ") || "accepted upsell did not appear in final order lines",
3694
+ ...(upsellUnverified ? { upsell_unverified: upsellUnverified } : {}),
3695
+ error: clean ? null : order.error || order.upsell?.error || pathFailures.join("; ") || "accepted upsell did not appear in final order lines",
3369
3696
  order,
3370
3697
  events: sanitizedEvents(events),
3371
3698
  };
@@ -3466,10 +3793,8 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
3466
3793
  // The index is the control's position among what its own locator matches,
3467
3794
  // so it replays with nth(); no selector is rebuilt from an attribute value.
3468
3795
  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
- });
3796
+ const perpetual = await scrollControlIntoView(target);
3797
+ await clickControl(target, { timeout: 8000, perpetual });
3473
3798
 
3474
3799
  // The SDK owns the navigation (data-next-url, or the link the SDK reads
3475
3800
  // forcePackageId from on arrival). Waiting for the URL is what proves the
@@ -3763,8 +4088,8 @@ async function selectRequestedCart(page, args) {
3763
4088
  for (const item of cart) {
3764
4089
  const target = page.locator(packageCardClickSelector({ package_id: item.packageId })).first();
3765
4090
  if (await target.count().catch(() => 0)) {
3766
- await target.scrollIntoViewIfNeeded().catch(() => {});
3767
- await target.click({ timeout: 5000 }).catch(() => {});
4091
+ const perpetual = await scrollControlIntoView(target);
4092
+ await clickControl(target, { timeout: 5000, forceFallback: false, perpetual }).catch(() => {});
3768
4093
  }
3769
4094
  }
3770
4095
  }
@@ -3789,10 +4114,8 @@ async function selectPackageCard(page, item) {
3789
4114
  const candidate = resolvePackageCardCandidate(await renderedPackageCardCandidates(page), item);
3790
4115
  const selector = packageCardClickSelector(candidate);
3791
4116
  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
- });
4117
+ const perpetual = await scrollControlIntoView(target);
4118
+ await clickControl(target, { timeout: 5000, perpetual });
3796
4119
  const state = await packageCardSelectionState(page, selector);
3797
4120
  if (state === "unselected") {
3798
4121
  throw new Error(`--select-package ${item.packageId}: card matched ${selector} but did not enter a selected state after click`);
@@ -4390,8 +4713,8 @@ async function submitCheckout(page) {
4390
4713
  await closeAddressAutocomplete(page);
4391
4714
  const submit = page.locator('button.submit-button[os-checkout-payment="combo"], button[os-checkout-payment="combo"], button[type="submit"]').first();
4392
4715
  await submit.waitFor({ state: "visible" });
4393
- await submit.scrollIntoViewIfNeeded();
4394
- await submit.click();
4716
+ const perpetual = await scrollControlIntoView(submit);
4717
+ await clickControl(submit, { forceFallback: false, perpetual });
4395
4718
  }
4396
4719
 
4397
4720
  // When events are supplied (the post-submit wait), a platform-rejected order
@@ -4536,6 +4859,81 @@ async function waitForLateOrderEvidence(page, events, { timeoutMs = 4000, interv
4536
4859
  }
4537
4860
  }
4538
4861
 
4862
+ // Playwright waits for an element to be "stable" (the same box on two
4863
+ // consecutive animation frames) before scrollIntoViewIfNeeded() and click().
4864
+ // A control under an infinite geometry animation never is: stock upsell accept
4865
+ // buttons carry pb-animate="pulse-upsell", which the shared next-core.css
4866
+ // scales forever with !important and no reduced-motion override. An unbounded
4867
+ // scroll then burned the whole 30s default action timeout and a normal click
4868
+ // another 10s before the forced fallback fired (#481). Scrolling through the
4869
+ // DOM has no stability wait, and a perpetually animated control is clicked with
4870
+ // force once visible, because waiting for it to settle can only time out.
4871
+ const CONTROL_SCROLL_TIMEOUT_MS = 2000;
4872
+ const UPSELL_CLICK_TIMEOUT_MS = 10000;
4873
+ const UPSELL_MUTATION_TIMEOUT_MS = 20000;
4874
+
4875
+ // Runs in the page: optionally scrolls the control to the viewport centre
4876
+ // (no stability wait), and reports whether the control or an ancestor runs an
4877
+ // infinite animation over a property that moves or resizes its box.
4878
+ // Paint-only loops (opacity, color, shadow) leave the box stable, so those
4879
+ // still take the normal click. Self-contained so Playwright can serialize it.
4880
+ function probeControlInPage(element, { scroll }) {
4881
+ if (scroll) element.scrollIntoView({ block: "center", inline: "nearest" });
4882
+ // Properties that move or resize the element's box, or its position
4883
+ // inside an animated ancestor. Paint-only properties are left out.
4884
+ 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)/;
4885
+ const movesBox = (animation) => {
4886
+ const timing = animation.effect?.getComputedTiming?.();
4887
+ if (animation.playState !== "running" || timing?.iterations !== Infinity) return false;
4888
+ const keyframes = animation.effect?.getKeyframes?.() || [];
4889
+ return keyframes.some((frame) => Object.keys(frame).some((property) => geometry.test(property)));
4890
+ };
4891
+ for (let node = element; node; node = node.parentElement) {
4892
+ if (typeof node.getAnimations === "function" && node.getAnimations().some(movesBox)) return true;
4893
+ }
4894
+ return false;
4895
+ }
4896
+
4897
+ async function probeControl(locator, { scroll, timeout = CONTROL_SCROLL_TIMEOUT_MS }) {
4898
+ try {
4899
+ return await locator.evaluate(probeControlInPage, { scroll }, { timeout }) === true;
4900
+ } catch {
4901
+ // Best effort, as the scroll always was: the click still scrolls itself,
4902
+ // and an unprobed control takes the normal click.
4903
+ return false;
4904
+ }
4905
+ }
4906
+
4907
+ // One page round trip per click: scrolls the control into view and returns
4908
+ // the `perpetual` flag clickControl takes, so no call site probes twice.
4909
+ async function scrollControlIntoView(locator, options = {}) {
4910
+ return probeControl(locator, { ...options, scroll: true });
4911
+ }
4912
+
4913
+ async function isPerpetuallyAnimated(locator, options = {}) {
4914
+ return probeControl(locator, { ...options, scroll: false });
4915
+ }
4916
+
4917
+ // `perpetual` is the flag scrollControlIntoView returned, so the click does
4918
+ // not probe again; without it the click probes for itself. forceFallback: false keeps a caller's strict click
4919
+ // for a control that can settle; a perpetually animated control is always
4920
+ // forced, whatever forceFallback says, because a strict click on it can only
4921
+ // time out. It must still become visible first, or the visibility error throws.
4922
+ async function clickControl(locator, { timeout, forceFallback = true, perpetual } = {}) {
4923
+ const animated = perpetual ?? await isPerpetuallyAnimated(locator);
4924
+ if (animated) {
4925
+ await locator.waitFor({ state: "visible", timeout });
4926
+ await locator.click({ force: true, timeout });
4927
+ return;
4928
+ }
4929
+ try {
4930
+ await locator.click({ timeout });
4931
+ } catch (error) {
4932
+ if (!forceFallback) throw error;
4933
+ await locator.click({ force: true, timeout });
4934
+ }
4935
+ }
4936
+
4539
4937
  async function clickUpsellPath(page, path, { trace = null } = {}) {
4540
4938
  const offerUrl = safePageUrl(page);
4541
4939
  const action = path === "accept" ? "add" : "skip";
@@ -4545,27 +4943,36 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
4545
4943
  return { path, clicked: false, error: `Missing upsell control ${selector}` };
4546
4944
  }
4547
4945
  const expectedItems = path === "accept" ? await selectedUpsellItems(page) : [];
4946
+ const perpetual = await scrollControlIntoView(control);
4947
+ // Armed at the click, not before the scroll, and budgeted to outlast a
4948
+ // normal attempt that times out into its forced fallback: the watch must
4949
+ // still be listening when the click that actually fires posts (#481).
4950
+ //
4951
+ // Only a request started at or after the watch was armed can be this
4952
+ // click's. An earlier step posts to the same order-upsells URL, and when
4953
+ // its own watch expired its response may still arrive during this click
4954
+ // (#505). armedAt and the request's start time are both epoch milliseconds
4955
+ // on the system clock (Date.now() here; Playwright's timing().startTime is
4956
+ // the browser's wall time at request start), and armedAt is read before the
4957
+ // click is sent, so this click's request starts at or after it. A request
4958
+ // with no known start time is not excluded: nothing shows it is stale.
4959
+ const armedAt = Date.now();
4548
4960
  const mutationPromise = path === "accept"
4549
- ? page.waitForResponse((response) => (
4550
- response.request().method() === "POST"
4551
- && isOrderUpsellsUrl(response.url())
4552
- ), { timeout: 20000 }).catch(() => null)
4961
+ ? page.waitForResponse((response) => {
4962
+ if (response.request().method() !== "POST" || !isOrderUpsellsUrl(response.url())) return false;
4963
+ const startedAt = responseRequestStartedAt(response);
4964
+ return startedAt === null || startedAt >= armedAt;
4965
+ }, { timeout: UPSELL_MUTATION_TIMEOUT_MS + (perpetual ? 0 : UPSELL_CLICK_TIMEOUT_MS) }).catch(() => null)
4553
4966
  : Promise.resolve(null);
4554
- await control.scrollIntoViewIfNeeded().catch(() => {});
4555
4967
  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();
4968
+ await clickControl(control, { timeout: UPSELL_CLICK_TIMEOUT_MS, perpetual });
4969
+ trace?.markClickCompleted();
4565
4970
  const mutationResponse = await mutationPromise;
4566
- const mutationBody = mutationResponse ? await readJsonResponseBody(mutationResponse) : null;
4971
+ const bodyRead = mutationResponse
4972
+ ? await readJsonResponseBodyBounded(mutationResponse, RESPONSE_BODY_READ_TIMEOUT_MS)
4973
+ : null;
4567
4974
  await waitForCheckoutResult(page);
4568
- return {
4975
+ const record = {
4569
4976
  path,
4570
4977
  clicked: true,
4571
4978
  offer_url: offerUrl,
@@ -4574,8 +4981,21 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
4574
4981
  api_response_seen: Boolean(mutationResponse),
4575
4982
  api_response_status: mutationResponse?.status() || null,
4576
4983
  api_response_url: mutationResponse?.url() || null,
4577
- api_response_order_body: mutationBody,
4984
+ api_response_order_body: bodyRead?.body ?? null,
4985
+ // When the mutation's response arrived (epoch ms, the browser's clock as
4986
+ // Date.now()). Only a read-back requested after this reflects the upsell.
4987
+ mutation_responded_at: mutationRespondedAt(mutationResponse),
4988
+ // How the bounded body read ended. A timed-out read means the mutation's
4989
+ // own evidence is missing, not that the upsell failed; the runner then
4990
+ // waits for a later read-back before it judges the step.
4991
+ ...(bodyRead ? { api_response_body_read: { timed_out: bodyRead.timed_out, waited_ms: bodyRead.waited_ms, bound_ms: bodyRead.bound_ms } } : {}),
4578
4992
  };
4993
+ // The request this click made. Another step's upsell mutation posts to the
4994
+ // same order-upsells URL, so a late body is this step's only when it
4995
+ // answers this request.
4996
+ const mutationRequest = mutationResponse ? responseRequest(mutationResponse) : null;
4997
+ if (mutationRequest) record[REQUEST_IDENTITY] = mutationRequest;
4998
+ return record;
4579
4999
  }
4580
5000
 
4581
5001
  function receiptProofEvidence(order) {
@@ -5325,13 +5745,22 @@ async function recoverCreatedOrder({ context, attempt, plan = null, checkoutPage
5325
5745
  }
5326
5746
 
5327
5747
  const cleared = remaining.length === 0;
5748
+ // An unverified accepted upsell is not a failure recovery can clear, nor
5749
+ // one it can re-check: re-reading the order says nothing about which
5750
+ // mutation added what. With everything else cleared, the recovered result
5751
+ // is the same manual-review shape executeTestOrderPath returns (#505).
5752
+ const upsellUnverified = Array.isArray(order.verification?.upsell_unverified) && order.verification.upsell_unverified.length
5753
+ ? order.verification.upsell_unverified
5754
+ : null;
5755
+ if (upsellUnverified) recovered.verification.verified = false;
5328
5756
  return {
5329
5757
  attempts: 1,
5330
5758
  cleared,
5331
5759
  checks,
5332
5760
  result: {
5333
5761
  ...attempt,
5334
- ok: cleared,
5762
+ ok: cleared && !upsellUnverified,
5763
+ ...(cleared && upsellUnverified ? { upsell_unverified: upsellUnverified } : {}),
5335
5764
  error: cleared ? null : remaining.join("; "),
5336
5765
  order: recovered,
5337
5766
  },
@@ -5469,19 +5898,35 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
5469
5898
  // runner deliberately refused to re-run, and the operator needs to know that
5470
5899
  // before they run the path again by hand.
5471
5900
  const stoppedForAmbiguity = creationRecord?.action === "stopped";
5901
+ // The order was created and nothing failed, but an accepted upsell could
5902
+ // not be checked: its mutation body never loaded and no read-back arrived.
5903
+ // Neither proved nor disproved, so a human decides, as for a hosted checkout.
5904
+ // The result is not ok (the path is not proven), but nothing failed either.
5905
+ // A result that says ok while its order still carries unverified reasons is
5906
+ // read the same way: never a pass. No current producer does that (both
5907
+ // executeTestOrderPath and recoverCreatedOrder clear ok when an upsell is
5908
+ // unverified); this arm stops a future ok-producing path, like the recovery
5909
+ // that once promoted an unverified upsell to pass, from doing it silently.
5910
+ const orderUnverified = result.order?.verification?.upsell_unverified;
5911
+ const upsellUnverified = Array.isArray(result.upsell_unverified) && result.upsell_unverified.length
5912
+ ? result.upsell_unverified
5913
+ : result.ok && Array.isArray(orderUnverified) && orderUnverified.length ? orderUnverified : null;
5914
+ const created = result.ok || Boolean(upsellUnverified);
5472
5915
  return assertion({
5473
5916
  id: `browser-test-order:${id}`,
5474
5917
  family: "browser-test-order",
5475
5918
  page,
5476
- status: result.ok ? STATUS.PASS : STATUS.FAIL,
5477
- severity: result.ok ? undefined : SEVERITY.BLOCKER,
5919
+ status: created ? (upsellUnverified ? STATUS.MANUAL_REVIEW : STATUS.PASS) : STATUS.FAIL,
5920
+ severity: created ? (upsellUnverified ? SEVERITY.WARN : undefined) : SEVERITY.BLOCKER,
5478
5921
  expected: "test order created through deployed checkout page",
5479
- actual: result.ok
5480
- ? result.order.next_order_id || result.order.ref_id
5922
+ actual: created
5923
+ ? upsellUnverified
5924
+ ? `${result.order.next_order_id || result.order.ref_id}; ${upsellUnverified.join("; ")}`
5925
+ : result.order.next_order_id || result.order.ref_id
5481
5926
  : stoppedForAmbiguity
5482
5927
  ? `${failureText} — not re-run: ${creationRecord.classification.reason}. Check for an existing order against this run's QA email before running this path again.`
5483
5928
  : failureText,
5484
- evidence: result.ok
5929
+ evidence: created
5485
5930
  ? {
5486
5931
  ...planEvidence,
5487
5932
  ...retry,
@@ -5494,6 +5939,7 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
5494
5939
  line_count: result.order.receipt_line_items.length,
5495
5940
  ...(receiptProofEvidence(result.order) ? { receipt_proof: receiptProofEvidence(result.order) } : {}),
5496
5941
  ...(path === "accept" ? { accepted_upsell_line_present: result.order.verification?.accepted_upsell_line_present } : {}),
5942
+ ...(upsellUnverified ? { upsell_unverified: upsellUnverified } : {}),
5497
5943
  ...(result.order.upsell ? { upsell_clicked: result.order.upsell.clicked, upsell_final_url: result.order.upsell.final_url } : {}),
5498
5944
  ...(result.order.upsell_steps ? { upsell_steps: result.order.upsell_steps.map(summarizeUpsellStep) } : {}),
5499
5945
  ...(result.order.verification?.accepted_upsell_matches ? { accepted_upsell_matches: result.order.verification.accepted_upsell_matches } : {}),
@@ -5514,6 +5960,43 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
5514
5960
  });
5515
5961
  }
5516
5962
 
5963
+ // When a response's headers arrived, in epoch milliseconds, or null.
5964
+ function mutationRespondedAt(response) {
5965
+ try {
5966
+ const timing = response.request().timing();
5967
+ const at = timing.startTime + timing.responseStart;
5968
+ return Number.isFinite(at) && timing.startTime > 0 && timing.responseStart >= 0 ? at : null;
5969
+ } catch {
5970
+ return null;
5971
+ }
5972
+ }
5973
+
5974
+ // The Playwright Request a response answers, kept under a symbol key so it
5975
+ // never reaches serialized evidence (JSON and the sanitized event log skip
5976
+ // it). Playwright hands every listener the same Request object for one
5977
+ // request, and a different one for each request, even to the same URL: an
5978
+ // upsell step matches its own mutation's late body by this identity (#505).
5979
+ const REQUEST_IDENTITY = Symbol("campaigns-os.request-identity");
5980
+
5981
+ function responseRequest(response) {
5982
+ try {
5983
+ return response.request() || null;
5984
+ } catch {
5985
+ return null;
5986
+ }
5987
+ }
5988
+
5989
+ // When the browser started a response's request, in epoch milliseconds (the
5990
+ // same clock as Date.now()), or null when Playwright does not report it.
5991
+ function responseRequestStartedAt(response) {
5992
+ try {
5993
+ const startTime = response.request().timing().startTime;
5994
+ return Number.isFinite(startTime) && startTime > 0 ? startTime : null;
5995
+ } catch {
5996
+ return null;
5997
+ }
5998
+ }
5999
+
5517
6000
  function captureCheckoutEvents(page) {
5518
6001
  const events = { requests: [], responses: [], failed: [], console: [], pageErrors: [], navigations: [] };
5519
6002
  const interesting = /\/api\/v1\/(?:orders|upsells|carts)\/?|\/transactions|spreedly|campaigns\.apps/i;
@@ -5525,13 +6008,24 @@ function captureCheckoutEvents(page) {
5525
6008
  postData: summarizeRequestPostData(request.postData()),
5526
6009
  });
5527
6010
  });
6011
+ // Nothing awaits this listener, so its body read stays unbounded: an entry
6012
+ // lands whenever its body finishes loading, and waitForLateOrderEvidence
6013
+ // polls for it. A bound here would record a slow but successful order body
6014
+ // as null for good, and the order would read as not created.
5528
6015
  page.on("response", async (response) => {
5529
6016
  if (!interesting.test(response.url())) return;
5530
- events.responses.push({
6017
+ // Taken before the body read: an entry lands in the log when its body
6018
+ // finishes, so its position says nothing about when it was requested.
6019
+ const requestStartedAt = responseRequestStartedAt(response);
6020
+ const request = responseRequest(response);
6021
+ const entry = {
5531
6022
  status: response.status(),
5532
6023
  url: response.url(),
5533
- body: await readJsonResponseBody(response),
5534
- });
6024
+ request_started_at: requestStartedAt,
6025
+ body: await readJsonResponseBodyWhenLoaded(response),
6026
+ };
6027
+ if (request) entry[REQUEST_IDENTITY] = request;
6028
+ events.responses.push(entry);
5535
6029
  });
5536
6030
  page.on("requestfailed", (request) => {
5537
6031
  if (!interesting.test(request.url())) return;
@@ -5552,11 +6046,63 @@ function captureCheckoutEvents(page) {
5552
6046
  return events;
5553
6047
  }
5554
6048
 
5555
- async function readJsonResponseBody(response) {
5556
- const text = await response.text().catch(() => null);
6049
+ // Playwright's response.text() waits for the body to finish loading. A page
6050
+ // that navigates away as soon as the headers land (an SDK that reads only the
6051
+ // status before redirecting) can leave that wait pending forever. Only a
6052
+ // caller that blocks the run on the body needs a bound: the upsell mutation
6053
+ // read in clickUpsellPath. There the body is evidence, not the proof of the
6054
+ // response, so an unread body is reported as null after a short bound instead
6055
+ // of hanging the order run. Playwright has no way to cancel a pending body
6056
+ // read; the abandoned read is a protocol callback, not a socket this process
6057
+ // owns, and it is released when the run closes the browser context.
6058
+ const RESPONSE_BODY_READ_TIMEOUT_MS = 3000;
6059
+
6060
+ // For callers that nothing awaits (the checkout event listener): a late body
6061
+ // still arrives, and a body that never loads just never records an entry.
6062
+ async function readJsonResponseBodyWhenLoaded(response) {
6063
+ const text = await Promise.resolve().then(() => response.text()).catch(() => null);
5557
6064
  return parseMaybeJson(redactSensitive(text));
5558
6065
  }
5559
6066
 
6067
+ // Callers that block on the read always get the fixed bound; only the test
6068
+ // hook picks a shorter one.
6069
+ async function readJsonResponseBody(response) {
6070
+ return readJsonResponseBodyWithin(response, RESPONSE_BODY_READ_TIMEOUT_MS);
6071
+ }
6072
+
6073
+ async function readJsonResponseBodyWithin(response, timeoutMs) {
6074
+ return (await readJsonResponseBodyBounded(response, timeoutMs)).body;
6075
+ }
6076
+
6077
+ // The bounded read, with a record of how it ended. `timed_out` is true only
6078
+ // when the bound fired while the read was still pending, never when the read
6079
+ // failed or returned nothing: a caller must be able to tell "the body was not
6080
+ // read in time" (the mutation may still have succeeded) from "the body was
6081
+ // read". `waited_ms` covers the read alone.
6082
+ async function readJsonResponseBodyBounded(response, timeoutMs) {
6083
+ // A missing, zero or unbounded value would reintroduce the hang.
6084
+ if (!(Number.isFinite(timeoutMs) && timeoutMs > 0)) timeoutMs = RESPONSE_BODY_READ_TIMEOUT_MS;
6085
+ const started = Date.now();
6086
+ const TIMED_OUT = Symbol("timed out");
6087
+ let timer = null;
6088
+ const bounded = new Promise((resolve) => { timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs); });
6089
+ const read = Promise.resolve()
6090
+ .then(() => response.text())
6091
+ .catch(() => null);
6092
+ try {
6093
+ const text = await Promise.race([read, bounded]);
6094
+ const timedOut = text === TIMED_OUT;
6095
+ return {
6096
+ body: timedOut ? null : parseMaybeJson(redactSensitive(text)),
6097
+ timed_out: timedOut,
6098
+ waited_ms: Date.now() - started,
6099
+ bound_ms: timeoutMs,
6100
+ };
6101
+ } finally {
6102
+ clearTimeout(timer);
6103
+ }
6104
+ }
6105
+
5560
6106
  function lastJsonResponse(events, pattern) {
5561
6107
  for (let index = events.responses.length - 1; index >= 0; index -= 1) {
5562
6108
  const response = events.responses[index];
@@ -5621,8 +6167,8 @@ async function clickVisibleControlByText(page, pattern, { within = null } = {})
5621
6167
  const control = controls.nth(index);
5622
6168
  const text = trim((await control.innerText().catch(() => "")) || (await control.getAttribute("value").catch(() => "")));
5623
6169
  if (!pattern.test(text)) continue;
5624
- await control.scrollIntoViewIfNeeded().catch(() => {});
5625
- await control.click({ timeout: 8000 });
6170
+ const perpetual = await scrollControlIntoView(control);
6171
+ await clickControl(control, { timeout: 8000, forceFallback: false, perpetual });
5626
6172
  return true;
5627
6173
  }
5628
6174
  throw new Error(`No visible control matched ${pattern}`);
@@ -6228,9 +6774,13 @@ async function selectedUpsellItems(page) {
6228
6774
  // order-upsell API added it. The live network observation (apiResponseSeen) is a
6229
6775
  // best-effort signal that can miss the request on fast stepper-accept client nav, so
6230
6776
  // it must not block on its own. Block only when the read-back proof also fails.
6777
+ //
6778
+ // An unverified proof (the mutation answered 2xx but its body never loaded, and
6779
+ // no later read-back arrived) is not a failure: nothing showed the line
6780
+ // missing. It is reported for manual review instead (upsell_unverified).
6231
6781
  function upsellAcceptStepFailures(stepIndex, proof, apiResponseSeen) {
6232
6782
  const failures = [];
6233
- if (!proof.ok) {
6783
+ if (!proof.ok && !proof.unverified) {
6234
6784
  failures.push(`step ${stepIndex + 1}: ${proof.reason}`);
6235
6785
  if (!apiResponseSeen) {
6236
6786
  failures.push(`step ${stepIndex + 1}: upsell accept did not call order upsell API`);
@@ -6252,6 +6802,141 @@ function upsellActionStepFailures(stepIndex, action, upsell, proof) {
6252
6802
  return failures;
6253
6803
  }
6254
6804
 
6805
+ // How long an accept whose mutation body timed out waits for other evidence
6806
+ // of that mutation, and the slice of the step budget kept for the read-back
6807
+ // after it. The whole step stays inside its budget (45s by default).
6808
+ const LATE_UPSELL_EVIDENCE_TIMEOUT_MS = 15000;
6809
+ const LATE_UPSELL_EVIDENCE_RESERVE_MS = 3000;
6810
+
6811
+ function upsellBodyReadTimedOut(upsell) {
6812
+ const status = Number(upsell?.api_response_status);
6813
+ return upsell?.api_response_body_read?.timed_out === true && status >= 200 && status < 300;
6814
+ }
6815
+
6816
+ // After the bounded read gave up on a 2xx upsell mutation body, the lines the
6817
+ // runner already holds are the checkout's, from before the accept. Judging
6818
+ // the upsell against them would call a slow success "no new upsell line".
6819
+ // Wait, within a bound, for evidence of this mutation captured after the
6820
+ // click: its own body landing late in the event log (the unbounded listener
6821
+ // keeps reading it), or an order read-back whose lines carry the accepted
6822
+ // upsell. Returns the body to judge from, or source "none" when neither came.
6823
+ //
6824
+ // "Its own body" is the entry answering the very request this step's click
6825
+ // made (mutationRequest, Playwright request identity), not any order-upsells
6826
+ // URL: every upsell step in a path, on one page or on separate pages, posts
6827
+ // to the same /orders/<ref>/upsells/ URL, and an earlier step's slow body can
6828
+ // land during this step's wait. Taking it would judge this step by another
6829
+ // step's lines. With no request identity no late body counts (#505).
6830
+ //
6831
+ // A read-back whose request started after the upsell mutation's response
6832
+ // arrived (by the browser's request start time, not its position in the log,
6833
+ // which reflects when its body finished) that shows the persisted order
6834
+ // without the accepted line is a definitive negative, not an absence of
6835
+ // evidence. Anchoring on the mutation, not the click attempt, also excludes a
6836
+ // read-back started while the click was still waiting to fire. It does not end the wait early (a
6837
+ // later read-back may still carry the line), but when the wait ends with no
6838
+ // positive evidence the latest such read-back is returned as
6839
+ // "order_read_back_missing_line", and the step fails instead of going to
6840
+ // manual review. A read-back requested before that, or with no known start
6841
+ // time, never counts as a negative.
6842
+ async function waitForLateUpsellEvidence(events, { responseIndexBefore, mutationRequest = null, mutationRespondedAt = null, initialLineItems, expectedItems, timeoutMs, intervalMs = 250 }) {
6843
+ const started = Date.now();
6844
+ const deadline = started + Math.max(0, Number(timeoutMs) || 0);
6845
+ const latestMissingLineReadBack = () => {
6846
+ const fresh = events.responses.slice(responseIndexBefore);
6847
+ for (let index = fresh.length - 1; index >= 0; index -= 1) {
6848
+ const response = fresh[index];
6849
+ if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) continue;
6850
+ if (!(response.status >= 200 && response.status < 300)) continue;
6851
+ if (!ORDER_DETAIL_RESPONSE_PATTERN.test(response.url)) continue;
6852
+ if (!(Number.isFinite(mutationRespondedAt) && Number.isFinite(response.request_started_at) && response.request_started_at >= mutationRespondedAt)) continue;
6853
+ const lines = extractReceiptLines(response.body);
6854
+ if (!Array.isArray(lines) || lines.length === 0) continue;
6855
+ return response.body;
6856
+ }
6857
+ return null;
6858
+ };
6859
+ const find = () => {
6860
+ const fresh = events.responses.slice(responseIndexBefore);
6861
+ for (let index = fresh.length - 1; index >= 0; index -= 1) {
6862
+ const response = fresh[index];
6863
+ if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) continue;
6864
+ if (!(response.status >= 200 && response.status < 300)) continue;
6865
+ if (!ORDER_UPSELLS_RESPONSE_PATTERN.test(response.url)) continue;
6866
+ if (mutationRequest && response[REQUEST_IDENTITY] === mutationRequest) return { source: "late_upsell_body", body: response.body };
6867
+ }
6868
+ for (let index = fresh.length - 1; index >= 0; index -= 1) {
6869
+ const response = fresh[index];
6870
+ if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) continue;
6871
+ if (!(response.status >= 200 && response.status < 300)) continue;
6872
+ if (!ORDER_DETAIL_RESPONSE_PATTERN.test(response.url)) continue;
6873
+ const proof = acceptedUpsellProof(extractReceiptLines(response.body), initialLineItems, expectedItems, events);
6874
+ if (proof.ok) return { source: "order_read_back", body: response.body };
6875
+ }
6876
+ return null;
6877
+ };
6878
+ for (;;) {
6879
+ const found = find();
6880
+ if (found) return { ...found, waited_ms: Date.now() - started };
6881
+ if (Date.now() >= deadline) {
6882
+ const missing = latestMissingLineReadBack();
6883
+ if (missing) return { source: "order_read_back_missing_line", body: missing, waited_ms: Date.now() - started };
6884
+ return { source: "none", body: null, waited_ms: Date.now() - started };
6885
+ }
6886
+ await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, Math.max(1, deadline - Date.now()))));
6887
+ }
6888
+ }
6889
+
6890
+ // The order evidence an upsell step is judged against. An accept whose
6891
+ // mutation body read timed out first waits for later evidence of that
6892
+ // mutation (waitForLateUpsellEvidence), and judges from that body when one
6893
+ // arrives: the runner's last order body is the checkout's otherwise.
6894
+ async function refreshUpsellStepEvidence({ page, events, path, email, checkoutPage, args, step, upsell, preferredOrderBody = null, initialLineItems, responseIndexBefore, lateWaitMs = LATE_UPSELL_EVIDENCE_TIMEOUT_MS }) {
6895
+ let lateUpsellEvidence = null;
6896
+ let judgedBody = preferredOrderBody;
6897
+ if (step === "accept" && upsellBodyReadTimedOut(upsell)) {
6898
+ lateUpsellEvidence = await waitForLateUpsellEvidence(events, {
6899
+ responseIndexBefore,
6900
+ mutationRequest: upsell[REQUEST_IDENTITY] || null,
6901
+ mutationRespondedAt: upsell.mutation_responded_at,
6902
+ initialLineItems,
6903
+ expectedItems: upsell.expected_items,
6904
+ timeoutMs: lateWaitMs,
6905
+ });
6906
+ upsell.late_evidence = { source: lateUpsellEvidence.source, waited_ms: lateUpsellEvidence.waited_ms };
6907
+ if (lateUpsellEvidence.body) judgedBody = lateUpsellEvidence.body;
6908
+ }
6909
+ const refreshed = await buildOrderEvidence({ page, events, path, email, checkoutPage, args, preferredOrderBody: judgedBody });
6910
+ return { refreshed, lateUpsellEvidence };
6911
+ }
6912
+
6913
+ // The accepted-upsell proof for one step, given how its evidence arrived. A
6914
+ // failed proof stands as a failure unless the mutation answered 2xx, its body
6915
+ // read timed out, and no later evidence of it arrived: then the lines judged
6916
+ // are stale, and the step is unverified rather than missing its upsell. A
6917
+ // post-click read-back without the line ("order_read_back_missing_line") is
6918
+ // such evidence, so the failure stands.
6919
+ //
6920
+ // A passing proof from those stale lines is no more evidence than a failing
6921
+ // one: lines on hand before this step's mutation answered cannot carry its
6922
+ // upsell, and can carry an earlier step's (whose body may have landed on the
6923
+ // same order-upsells URL meanwhile), so it is unverified too (#505).
6924
+ function acceptedUpsellStepProof(upsell, lateEvidence, proof) {
6925
+ if (!upsellBodyReadTimedOut(upsell)) return proof;
6926
+ if (lateEvidence && lateEvidence.source !== "none") return proof;
6927
+ const read = upsell.api_response_body_read;
6928
+ return {
6929
+ ...proof,
6930
+ ok: false,
6931
+ unverified: true,
6932
+ 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`,
6933
+ matched_lines: [],
6934
+ stale_reason: proof.ok
6935
+ ? "the order lines on hand predate this step's upsell mutation, so a match in them is not evidence of it"
6936
+ : proof.reason,
6937
+ };
6938
+ }
6939
+
6255
6940
  function acceptedUpsellProof(lines, initialLines, expectedItems, events) {
6256
6941
  if (!Array.isArray(lines) || lines.length === 0) {
6257
6942
  return { ok: false, reason: "final order lines were empty", expected_items: expectedItems || [], matched_lines: [] };
@@ -6352,6 +7037,8 @@ function summarizeUpsellStep(step) {
6352
7037
  expected_items: step.expected_items,
6353
7038
  api_response_seen: step.api_response_seen,
6354
7039
  api_response_status: step.api_response_status,
7040
+ ...(step.api_response_body_read ? { api_response_body_read: step.api_response_body_read } : {}),
7041
+ ...(step.late_evidence ? { late_evidence: step.late_evidence } : {}),
6355
7042
  accepted_upsell_line_present: step.verification?.accepted_upsell_line_present,
6356
7043
  };
6357
7044
  }
@@ -6489,6 +7176,8 @@ export const __qaBrowserTestHooks = Object.freeze({
6489
7176
  COUPON_APPLY_CONTROL_SELECTOR,
6490
7177
  analyticsCorrectnessCaptureAssertions,
6491
7178
  analyticsCorrectnessRunnerFailureAssertion,
7179
+ captureAnalyticsCorrectnessInContext,
7180
+ captureAnalyticsParityInContext,
6492
7181
  analyticsParityCaptureAssertions,
6493
7182
  analyticsParityRunnerFailureAssertion,
6494
7183
  acceptedUpsellProof,
@@ -6500,6 +7189,18 @@ export const __qaBrowserTestHooks = Object.freeze({
6500
7189
  primaryCtaInspectionScript,
6501
7190
  clickCouponApplyControl,
6502
7191
  isOrderUpsellsUrl,
7192
+ clickUpsellPath,
7193
+ isPerpetuallyAnimated,
7194
+ readJsonResponseBody,
7195
+ readJsonResponseBodyWithin,
7196
+ readJsonResponseBodyBounded,
7197
+ RESPONSE_BODY_READ_TIMEOUT_MS,
7198
+ waitForLateUpsellEvidence,
7199
+ refreshUpsellStepEvidence,
7200
+ acceptedUpsellStepProof,
7201
+ REQUEST_IDENTITY,
7202
+ captureCheckoutEvents,
7203
+ buildOrderEvidence,
6503
7204
  testEmail,
6504
7205
  testOrderPaths,
6505
7206
  testOrderPlans,