@nextcommerce/campaigns-os 1.43.2 → 1.47.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 (80) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +798 -5103
  3. package/README.md +33 -12
  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/compatibility.json +1 -1
  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/commerce-surface-catalog.json +1204 -129
  17. package/contracts/effects.v1.json +1179 -116
  18. package/contracts/orientation-reason-codes.v1.json +7 -0
  19. package/contracts/release-ledger.json +2515 -5919
  20. package/contracts/supported-surface.json +7 -4
  21. package/contracts/template-brand-contract.shared-commerce.v0.json +3 -3
  22. package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
  23. package/docs/brand-theme-bridge.md +81 -0
  24. package/docs/build-packet.md +180 -23
  25. package/docs/campaigns-os-build-flow.md +3 -3
  26. package/docs/design-source-package.md +73 -0
  27. package/docs/effects.md +50 -8
  28. package/docs/gateway-login.md +3 -0
  29. package/docs/local-setup.md +7 -4
  30. package/docs/orientation-contract-reference.md +42 -2
  31. package/docs/polish-evidence.md +74 -0
  32. package/docs/qa-and-test-orders.md +118 -14
  33. package/docs/release-ledger-authoring-guide.md +64 -4
  34. package/docs/runtime-readiness.md +1 -1
  35. package/docs/sdk-storage-compatibility.md +1 -1
  36. package/docs/skills-revision.md +10 -10
  37. package/docs/supported-surface.md +2 -2
  38. package/docs/versioning.md +4 -1
  39. package/package.json +1 -1
  40. package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
  41. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
  42. package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
  43. package/skills/campaign-readback-classification/SKILL.md +3 -3
  44. package/skills/campaign-run-evidence/SKILL.md +7 -6
  45. package/skills/contribution-intake/SKILL.md +3 -3
  46. package/skills/next-campaigns-build/SKILL.md +7 -6
  47. package/skills/next-campaigns-os/SKILL.md +7 -7
  48. package/skills/next-campaigns-os/references/session-intake.md +9 -3
  49. package/skills/next-campaigns-os-setup/SKILL.md +5 -5
  50. package/skills/next-campaigns-polish/SKILL.md +28 -9
  51. package/skills/next-campaigns-qa/SKILL.md +7 -4
  52. package/skills.json +10 -10
  53. package/src/brand-theme.mjs +320 -20
  54. package/src/built-script-syntax.mjs +116 -15
  55. package/src/built-site-scope.mjs +16 -4
  56. package/src/cli.mjs +280 -46
  57. package/src/commercial-parity.mjs +48 -2
  58. package/src/deviation.mjs +13 -1
  59. package/src/diagnostic.mjs +6 -2
  60. package/src/doctor/checks.mjs +319 -81
  61. package/src/doctor/inspect.mjs +55 -13
  62. package/src/doctor/source-provenance.mjs +184 -0
  63. package/src/invocation.mjs +4 -0
  64. package/src/live-campaign-refs.mjs +466 -0
  65. package/src/login.mjs +2 -2
  66. package/src/page-kit-store-profile.mjs +69 -12
  67. package/src/page-kit-sync.mjs +31 -12
  68. package/src/progress-node.mjs +3 -1
  69. package/src/qa-analytics-parity.mjs +37 -2
  70. package/src/qa-binding-evidence.mjs +4 -2
  71. package/src/qa-browser.mjs +612 -40
  72. package/src/qa-commercial-parity.mjs +48 -5
  73. package/src/qa-node.mjs +122 -7
  74. package/src/qa-test-order-topology.mjs +148 -0
  75. package/src/sdk-markup.mjs +32 -7
  76. package/src/sdk-storage-compatibility.mjs +3 -2
  77. package/src/source-html-intake.mjs +116 -0
  78. package/src/stage-record.mjs +551 -0
  79. package/src/tooling-setup.mjs +9 -0
  80. 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,
@@ -137,6 +139,7 @@ export async function runBrowserChecks(topologies, args = {}, options = {}) {
137
139
  export async function runBrowserTestOrders(topologies, args = {}, runId = "local", options = {}) {
138
140
  const checkoutPage = findPage(topologies, "checkout");
139
141
  if (!checkoutPage?.url) {
142
+ const coverage = upsellActionCoverageAssertion({ topologies, orders: [], checkoutPage });
140
143
  return {
141
144
  orders: [],
142
145
  receiptAnalytics: { plannedPlanIds: [], attempts: [] },
@@ -149,7 +152,7 @@ export async function runBrowserTestOrders(topologies, args = {}, runId = "local
149
152
  severity: SEVERITY.BLOCKER,
150
153
  expected: "checkout page URL",
151
154
  actual: "missing",
152
- })],
155
+ }), ...(coverage ? [coverage] : [])],
153
156
  };
154
157
  }
155
158
 
@@ -158,6 +161,8 @@ export async function runBrowserTestOrders(topologies, args = {}, runId = "local
158
161
  // discover that the operator needs to raise --max-test-orders.
159
162
  const plans = testOrderPlans(args["test-order"], topologies, args);
160
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));
161
166
  const creationBudget = createOrderCreationBudget({ plans, args });
162
167
 
163
168
  const browser = await launchChromium(args);
@@ -175,7 +180,7 @@ export async function runBrowserTestOrders(topologies, args = {}, runId = "local
175
180
  runId,
176
181
  // The topologies travel with the options so each attempt can resolve the
177
182
  // funnel's cart-entry page for the checkout it drives (campaigns-os#206).
178
- options: { ...options, creationBudget, topologies },
183
+ options: { ...options, creationBudget, topologies, orderPathPlan },
179
184
  });
180
185
  return {
181
186
  orders: dispatched.orders,
@@ -215,6 +220,9 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
215
220
 
216
221
  const assertions = [];
217
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 = [];
218
226
  try {
219
227
  for (let index = 0; index < plans.length; index += 1) {
220
228
  const plan = plans[index];
@@ -224,6 +232,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
224
232
  const pageForPlan = (typeof plan === "object" && plan?.checkout_page?.url) ? plan.checkout_page : checkoutPage;
225
233
  const firstAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
226
234
  orders.push(firstAttempt.order);
235
+ orderPlans.push(plan);
227
236
  // Every attempt this path actually SUBMITTED, in order. The confirmed
228
237
  // creation count is read from these and never from the deciding result
229
238
  // alone: a recovered result is derived from the first attempt, so
@@ -281,7 +290,10 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
281
290
  try {
282
291
  const rerunAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
283
292
  attemptsForPlan.push(rerunAttempt);
284
- if (rerunAttempt.order) orders.push(rerunAttempt.order);
293
+ if (rerunAttempt.order) {
294
+ orders.push(rerunAttempt.order);
295
+ orderPlans.push(plan);
296
+ }
285
297
  if (rerunAttempt.budget_exhausted) {
286
298
  // The re-run stopped itself before its submit click, so it
287
299
  // proved nothing. Letting it decide would erase this path's
@@ -389,6 +401,15 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
389
401
  evidence: { planned_paths: plans.map((plan) => planId(plan)), completed_paths: orders.map((order) => order?.plan_id || order?.path) },
390
402
  }));
391
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);
392
413
 
393
414
  return { orders, assertions, receiptAnalytics, journeyAnalytics, creationBudget };
394
415
  }
@@ -407,11 +428,11 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
407
428
  // with --analytics-baseline's legacy receipt, and identity resolution cannot
408
429
  // derive a receipt page yet (receipt-aware capture is out of packet 01's
409
430
  // scope). Absent that override, the candidate IS the resolved target.
410
- function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl, capturePage = null, rootFallback = null }) {
431
+ function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl, capturePage = null, rootFallback = null, candidatePage = null }) {
411
432
  const baselinePublicUrl = redactUrlQuery(baselineUrl);
412
433
  const candidatePublicUrl = redactUrlQuery(candidateUrl);
413
434
  const analyticsPage = { page_id: "analytics", url: candidatePublicUrl || baselinePublicUrl || undefined };
414
- const assertions = diffAnalyticsParity(baseline, candidate, { url: candidatePublicUrl });
435
+ const assertions = diffAnalyticsParity(baseline, candidate, { url: candidatePublicUrl, ...(candidatePage ? { candidatePage } : {}) });
415
436
  assertions.unshift(assertion({
416
437
  id: "analytics-parity:capture",
417
438
  family: "analytics-parity",
@@ -519,9 +540,24 @@ async function captureAnalyticsParityInContext(context, baselineUrl, targetUrl,
519
540
  candidateUrl: selected.url,
520
541
  capturePage: selected.capturePage,
521
542
  rootFallback: selected.rootFallback,
543
+ candidatePage: automaticParityCandidatePage(selected),
522
544
  });
523
545
  }
524
546
 
547
+ // #512: what the automatic candidate is, for the Purchase checks. The campaign
548
+ // root is never a receipt; a built entry is one only when its topology page
549
+ // type says so. A receipt loaded without an order fires no Purchase, so the
550
+ // leg does not go looking for the topology's receipt step: the operator pairs
551
+ // receipts with --analytics-candidate.
552
+ function automaticParityCandidatePage(selected) {
553
+ const pageType = selected.pageType || null;
554
+ return {
555
+ receipt: contractPageType({ page_type: pageType }) === "receipt",
556
+ source: selected.capturePage?.source || null,
557
+ page_type: pageType,
558
+ };
559
+ }
560
+
525
561
  // Analytics CORRECTNESS inventory leg: capture ONE page and assess only
526
562
  // declared tags/pixels. Purchase is finalized later from the canonical
527
563
  // typed-card order's recognized receipt; this inventory visit is never treated
@@ -695,6 +731,7 @@ function analyticsCaptureCandidates(url, options = {}) {
695
731
  source: "built_entry",
696
732
  page_id: entry.page_id || null,
697
733
  funnel_id: entry.funnel_id || null,
734
+ page_type: entry.page_type || null,
698
735
  // The query value is redacted from every record; this says the entry
699
736
  // was told apart from the root by its query alone.
700
737
  ...(url && isQueryRoutedFrom(trim(entry.url), url) ? { query_routed: true } : {}),
@@ -740,6 +777,7 @@ async function captureFirstAnsweringAnalyticsPage(context, url, args, extraHosts
740
777
  ...queryRouted(candidate),
741
778
  http_status: httpStatus ?? null,
742
779
  },
780
+ pageType: candidate.page_type || null,
743
781
  rootFallback: usedFallback ? rootFallback : null,
744
782
  };
745
783
  }
@@ -912,7 +950,9 @@ async function runPageBrowserChecks(context, page, args, options = {}) {
912
950
  const pageErrors = [];
913
951
  const failedRequests = [];
914
952
  browserPage.on("console", (message) => {
915
- if (message.type() === "error") consoleErrors.push(trim(message.text()));
953
+ // The location URL is kept beside the text: a "Failed to load resource"
954
+ // message names its status but not the request, which is the location.
955
+ if (message.type() === "error") consoleErrors.push({ text: trim(message.text()), url: message.location?.()?.url || null });
916
956
  });
917
957
  browserPage.on("pageerror", (error) => pageErrors.push(trim(error.message)));
918
958
  browserPage.on("requestfailed", (request) => {
@@ -1019,16 +1059,65 @@ function isIgnorableFailedRequest(request) {
1019
1059
  }
1020
1060
  }
1021
1061
 
1062
+ // `messages` are { text, url } records (url: the console message's location);
1063
+ // the result is the actionable messages' text.
1022
1064
  async function actionableRuntimeConsoleErrors(browserPage, messages) {
1023
1065
  if (!messages.length) return [];
1024
1066
  const runtimeReady = await browserPage.evaluate(() => (
1025
1067
  document.documentElement.classList.contains("next-display-ready")
1026
1068
  || Boolean(window.next && Object.keys(window.next).length)
1027
1069
  )).catch(() => false);
1028
- return messages.filter((message) => {
1029
- if (runtimeReady && isKnownSdkLoaderFalsePositive(message)) return false;
1030
- return true;
1031
- });
1070
+ // The page's final URL, after redirects: the host the browser actually
1071
+ // shows, not the host the page record requested.
1072
+ const pageUrl = browserPage.url();
1073
+ return messages
1074
+ .filter((message) => {
1075
+ if (runtimeReady && isKnownSdkLoaderFalsePositive(message.text)) return false;
1076
+ if (isNetlifyPreviewDrawerConsoleError(pageUrl, message)) return false;
1077
+ return true;
1078
+ })
1079
+ .map((message) => message.text);
1080
+ }
1081
+
1082
+ // Netlify injects its deploy-preview drawer (the collaboration toolbar) into
1083
+ // preview pages, and a failed drawer request is logged as a "Failed to load
1084
+ // resource" console error on every page (a 428 has been observed). That error
1085
+ // is Netlify's, not the campaign's.
1086
+ //
1087
+ // Each exempt request is an exact host plus an exact path:
1088
+ // netlify-cdp-loader.netlify.app /netlify.js — the drawer's loader script,
1089
+ // the one `<script src>` Netlify injects into preview HTML.
1090
+ // It is exempt only on a page whose final URL (after redirects) is a Netlify
1091
+ // preview host: any *.netlify.app host, or a deploy-preview subdomain on a
1092
+ // custom domain (deploy-preview-7.shop.example.com, or
1093
+ // deploy-preview-7--shop.example.com). Nothing else is exempt: any other path
1094
+ // on a Netlify host (app.netlify.com included), and the drawer's own URL on any
1095
+ // other page host, still counts. A "Failed to load resource" message names its
1096
+ // status but not the request; the request is the message's location URL.
1097
+ const NETLIFY_PREVIEW_DRAWER_REQUESTS = Object.freeze([
1098
+ { hostname: "netlify-cdp-loader.netlify.app", pathname: "/netlify.js" },
1099
+ ]);
1100
+
1101
+ // The first label of a deploy-preview host: `deploy-preview-<n>`, or
1102
+ // `deploy-preview-<n>--<site>` for a per-deploy host on a custom domain.
1103
+ // Only numeric ids match, deliberately: Netlify issues numeric deploy-preview
1104
+ // ids, and widening would hide real errors on any host named deploy-preview-*.
1105
+ const DEPLOY_PREVIEW_LABEL = /^deploy-preview-\d+(?:--[a-z0-9-]+)?$/;
1106
+
1107
+ function isNetlifyPreviewHost(hostname) {
1108
+ const labels = hostname.split(".");
1109
+ return hostname.endsWith(".netlify.app") || (labels.length > 2 && DEPLOY_PREVIEW_LABEL.test(labels[0]));
1110
+ }
1111
+
1112
+ function isNetlifyPreviewDrawerConsoleError(pageUrl, message) {
1113
+ if (!/^Failed to load resource\b/.test(String(message?.text || ""))) return false;
1114
+ try {
1115
+ const request = new URL(message.url);
1116
+ return isNetlifyPreviewHost(new URL(pageUrl).hostname)
1117
+ && NETLIFY_PREVIEW_DRAWER_REQUESTS.some((drawer) => drawer.hostname === request.hostname && drawer.pathname === request.pathname);
1118
+ } catch {
1119
+ return false;
1120
+ }
1032
1121
  }
1033
1122
 
1034
1123
  function isKnownSdkLoaderFalsePositive(message) {
@@ -1486,13 +1575,126 @@ async function checkoutCommerceStructureAssertions(browserPage, page) {
1486
1575
  }
1487
1576
 
1488
1577
  const evidence = await inspectCommerceStructure(browserPage, contract);
1578
+ const behaviour = await inspectCheckoutBehaviour(browserPage);
1489
1579
  return [commerceStructureAssertionFromEvidence(page, {
1490
1580
  template_family: family,
1491
1581
  contract_status: contractStatus,
1492
1582
  ...evidence,
1583
+ behaviour,
1493
1584
  })];
1494
1585
  }
1495
1586
 
1587
+ // The checkout's wrapper and page composition belong to the campaign source,
1588
+ // not the template family (#532). A catalog rule is family shell when every
1589
+ // selector it reads is on this list: the layout wrapper and column classes the
1590
+ // pinned catalog uses, and the family include's shipping field row marker.
1591
+ // Every other selector is SDK wiring the checkout needs to work
1592
+ // (`[data-next-checkout]`, `[os-checkout-payment]`, `[data-next-cart-summary]`,
1593
+ // `[data-next-bundle-slots-for]`) and is never relaxed. That includes any
1594
+ // selector not listed here, even a bare class such as the hosted payment
1595
+ // field's `.input-flds`, so a catalog recorded in the packet cannot widen it.
1596
+ const FAMILY_SHELL_SELECTORS = Object.freeze([
1597
+ ".checkout-wrapper",
1598
+ ".checkout-layout__left",
1599
+ ".checkout-layout__right",
1600
+ ".checkout__layout",
1601
+ ".checkout__column--left",
1602
+ ".checkout__column--right",
1603
+ '[data-next-component="shipping-field-row"]',
1604
+ ]);
1605
+
1606
+ function isFamilyShellSelector(selector) {
1607
+ return FAMILY_SHELL_SELECTORS.includes(String(selector || "").trim());
1608
+ }
1609
+
1610
+ function isFamilyShellCheck(check) {
1611
+ const selectors = Array.isArray(check?.selectors) ? check.selectors : [];
1612
+ return selectors.length > 0 && selectors.every(isFamilyShellSelector);
1613
+ }
1614
+
1615
+ // What a checkout has to do, whoever composed it. The required fields are the
1616
+ // contact and shipping fields the test-order form fill types without the
1617
+ // optional flag (fillCheckoutFields): each must be a form control carrying
1618
+ // data-next-checkout-field inside a <form data-next-checkout="form">, and not a
1619
+ // type="hidden" input or a disabled control (its own attribute or a disabled
1620
+ // fieldset), since a customer cannot fill either. Visibility is not required:
1621
+ // progressive-reveal checkouts hide the address fields until a country is
1622
+ // chosen. The total is the cart-summary total the order-total parity check
1623
+ // reads.
1624
+ const CHECKOUT_FORM_SELECTOR = 'form[data-next-checkout="form"]';
1625
+ const CHECKOUT_BEHAVIOUR_REQUIRED_FIELDS = Object.freeze([
1626
+ "email",
1627
+ "fname",
1628
+ "lname",
1629
+ "country",
1630
+ "address1",
1631
+ "city",
1632
+ "province",
1633
+ "postal",
1634
+ ]);
1635
+
1636
+ function checkoutBehaviourProbeInput() {
1637
+ return {
1638
+ formSelector: CHECKOUT_FORM_SELECTOR,
1639
+ requiredFields: [...CHECKOUT_BEHAVIOUR_REQUIRED_FIELDS],
1640
+ totalSelectors: checkoutTotalSelectors(),
1641
+ };
1642
+ }
1643
+
1644
+ async function inspectCheckoutBehaviour(browserPage) {
1645
+ return browserPage.evaluate((input) => {
1646
+ const visible = (element) => {
1647
+ const rect = element.getBoundingClientRect();
1648
+ const style = getComputedStyle(element);
1649
+ return rect.width > 0
1650
+ && rect.height > 0
1651
+ && style.display !== "none"
1652
+ && style.visibility !== "hidden"
1653
+ && Number(style.opacity || "1") !== 0;
1654
+ };
1655
+ const forms = Array.from(document.querySelectorAll(input.formSelector));
1656
+ const controls = new Set(["INPUT", "SELECT", "TEXTAREA"]);
1657
+ // readonly has no effect on a select, so only an input or textarea loses it.
1658
+ const fillable = (field) => controls.has(field.tagName)
1659
+ && !(field.tagName === "INPUT" && field.type === "hidden")
1660
+ && !field.matches(":disabled")
1661
+ && !(field.tagName !== "SELECT" && field.hasAttribute("readonly"))
1662
+ && field.getAttribute("aria-disabled") !== "true";
1663
+ const missing = input.requiredFields.filter((name) => !forms.some((form) => Array
1664
+ .from(form.querySelectorAll("[data-next-checkout-field]"))
1665
+ .some((field) => field.getAttribute("data-next-checkout-field") === name && fillable(field))));
1666
+ const totals = input.totalSelectors.flatMap((selector) => {
1667
+ try {
1668
+ return Array.from(document.querySelectorAll(selector));
1669
+ } catch {
1670
+ return [];
1671
+ }
1672
+ });
1673
+ const visibleTotals = totals.filter((element) => visible(element) && (element.textContent || "").trim().length > 0);
1674
+ const checkoutForm = { selector: input.formSelector, count: forms.length, status: forms.length ? "pass" : "fail" };
1675
+ const fieldsBound = {
1676
+ required: input.requiredFields,
1677
+ missing,
1678
+ status: forms.length && !missing.length ? "pass" : "fail",
1679
+ };
1680
+ const totalVisible = {
1681
+ selectors: input.totalSelectors,
1682
+ count: totals.length,
1683
+ visible_count: visibleTotals.length,
1684
+ status: visibleTotals.length ? "pass" : "fail",
1685
+ };
1686
+ return {
1687
+ status: [checkoutForm, fieldsBound, totalVisible].every((check) => check.status === "pass") ? "pass" : "fail",
1688
+ checkout_form: checkoutForm,
1689
+ fields_bound: fieldsBound,
1690
+ total_visible: totalVisible,
1691
+ };
1692
+ }, checkoutBehaviourProbeInput()).catch((error) => ({
1693
+ status: "fail",
1694
+ error: error instanceof Error ? error.message : String(error),
1695
+ }));
1696
+ }
1697
+
1496
1698
  async function inspectCommerceStructure(browserPage, contract) {
1497
1699
  const safeContract = isPlainObject(contract) ? contract : {};
1498
1700
  const checks = await browserPage.evaluate((input) => {
@@ -1577,18 +1779,28 @@ function commerceStructureAssertionFromEvidence(page, evidence) {
1577
1779
  evidence,
1578
1780
  });
1579
1781
  }
1580
- const failed = checks.filter((check) => check.status === "fail");
1782
+ const classified = checks.map((check) => ({ ...check, kind: isFamilyShellCheck(check) ? "family_shell" : "sdk_wiring" }));
1783
+ const failed = classified.filter((check) => check.status === "fail");
1784
+ // A missing family-shell selector is a warning only when nothing else failed
1785
+ // and the behaviour probe ran and passed; absent or failing behaviour
1786
+ // evidence keeps the row a failure.
1787
+ const shellOnly = failed.length > 0 && failed.every((check) => check.kind === "family_shell");
1788
+ const behaviourPassed = evidence?.behaviour?.status === "pass";
1789
+ const relaxed = shellOnly && behaviourPassed;
1790
+ const missing = `${checks.length - failed.length}/${checks.length} structure check(s) passed; missing ${failed.map((check) => check.name).join(", ")}`;
1581
1791
  return assertion({
1582
1792
  id: `browser-commerce-structure:${page.page_id}`,
1583
1793
  family: "browser-runtime",
1584
1794
  page,
1585
- status: failed.length ? STATUS.FAIL : STATUS.PASS,
1795
+ status: !failed.length ? STATUS.PASS : relaxed ? STATUS.WARN : STATUS.FAIL,
1586
1796
  severity: failed.length ? SEVERITY.WARN : undefined,
1587
1797
  expected: "rendered checkout conforms to the selected template-family commerce structure contract",
1588
- actual: failed.length
1589
- ? `${checks.length - failed.length}/${checks.length} structure check(s) passed; missing ${failed.map((check) => check.name).join(", ")}`
1590
- : `${checks.length}/${checks.length} structure check(s) passed`,
1591
- evidence,
1798
+ actual: !failed.length
1799
+ ? `${checks.length}/${checks.length} structure check(s) passed`
1800
+ : relaxed
1801
+ ? `${missing}; the checkout form, bound fields and visible total pass, so the missing family shell is source-owned`
1802
+ : missing,
1803
+ evidence: { ...evidence, checks: classified },
1592
1804
  });
1593
1805
  }
1594
1806
 
@@ -2590,13 +2802,13 @@ async function pricingVisibilityAssertions(browserPage, page, options = {}) {
2590
2802
  if (!surfaces) return [];
2591
2803
  const pageType = contractPageType(page);
2592
2804
  if (["upsell", "downsell"].includes(pageType)) {
2593
- const selectors = surfaces.upsell?.price_row_selectors || [];
2805
+ const selectors = withSdkPriceDisplaySelectors(surfaces.upsell?.price_row_selectors);
2594
2806
  if (!selectors.length) return [];
2595
2807
  const visibleCount = await countVisiblePriceRows(browserPage, selectors);
2596
2808
  return [upsellPriceVisibilityAssertion({ page, selectors, visibleCount })];
2597
2809
  }
2598
2810
  if (pageType === "checkout") {
2599
- const selectors = surfaces.checkout_bundle?.price_row_selectors || [];
2811
+ const selectors = withSdkPriceDisplaySelectors(surfaces.checkout_bundle?.price_row_selectors);
2600
2812
  // Two price surfaces satisfy this check. The contract's bundle price rows
2601
2813
  // are one; the rendered cart-summary total is the other — the same
2602
2814
  // selectors the order-total parity check reads at submit. A family that
@@ -2615,11 +2827,29 @@ async function pricingVisibilityAssertions(browserPage, page, options = {}) {
2615
2827
  return [];
2616
2828
  }
2617
2829
 
2830
+ // The SDK renders a bundle or upsell price through data-next-bundle-display as
2831
+ // well as data-next-display (#532). A contract surface that lists price rows
2832
+ // also counts that attribute; an empty list stays empty so a contract that
2833
+ // declares no rows keeps skipping the count.
2834
+ const SDK_BUNDLE_PRICE_DISPLAY_SELECTOR = "[data-next-bundle-display*='price']";
2835
+
2836
+ function withSdkPriceDisplaySelectors(selectors) {
2837
+ const list = Array.isArray(selectors) ? selectors : [];
2838
+ if (!list.length || list.includes(SDK_BUNDLE_PRICE_DISPLAY_SELECTOR)) return list;
2839
+ return [...list, SDK_BUNDLE_PRICE_DISPLAY_SELECTOR];
2840
+ }
2841
+
2618
2842
  // Visible = non-zero bounding box, display != none, visibility != hidden. This
2619
2843
  // is what caught nothing in the dogfood run: a campaign CSS rule display:none'd
2620
2844
  // the only price row on a full-price upsell and 48/48 checks still passed.
2845
+ // A bundle-display node is the price text itself, so it also needs
2846
+ // non-whitespace text: a sized but empty node is a price the SDK never filled.
2847
+ // The rule follows the node's attribute, not the selector that reached it
2848
+ // first, so a bundle-display node that also carries `.price-wrapper` still
2849
+ // needs text.
2621
2850
  async function countVisiblePriceRows(browserPage, selectors) {
2622
- return browserPage.evaluate((targets) => {
2851
+ return browserPage.evaluate(({ targets, bundlePriceSelector }) => {
2852
+ const textRequired = (element) => element.matches(bundlePriceSelector);
2623
2853
  const visible = (element) => {
2624
2854
  const rect = element.getBoundingClientRect();
2625
2855
  const style = getComputedStyle(element);
@@ -2632,14 +2862,16 @@ async function countVisiblePriceRows(browserPage, selectors) {
2632
2862
  for (const element of document.querySelectorAll(selector)) {
2633
2863
  if (seen.has(element)) continue;
2634
2864
  seen.add(element);
2635
- if (visible(element)) count += 1;
2865
+ if (!visible(element)) continue;
2866
+ if (textRequired(element) && !(element.textContent || "").trim()) continue;
2867
+ count += 1;
2636
2868
  }
2637
2869
  } catch {
2638
2870
  // invalid selector: contract bug surfaced elsewhere
2639
2871
  }
2640
2872
  }
2641
2873
  return count;
2642
- }, selectors).catch(() => 0);
2874
+ }, { targets: selectors, bundlePriceSelector: SDK_BUNDLE_PRICE_DISPLAY_SELECTOR }).catch(() => 0);
2643
2875
  }
2644
2876
 
2645
2877
  // Page-scoped like every other per-page row (`template-residue:<page>:…`,
@@ -4956,10 +5188,18 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
4956
5188
  // the browser's wall time at request start), and armedAt is read before the
4957
5189
  // click is sent, so this click's request starts at or after it. A request
4958
5190
  // with no known start time is not excluded: nothing shows it is stale.
5191
+ //
5192
+ // A redirected POST (307/308) is one mutation across several hops, and
5193
+ // Playwright gives each hop its own Request. The watch matches a hop by the
5194
+ // request that started its chain, and passes over a hop the browser follows,
5195
+ // so it resolves on the chain's final response: that is the one carrying the
5196
+ // order. A chain with no final response is not answered (#516).
4959
5197
  const armedAt = Date.now();
4960
5198
  const mutationPromise = path === "accept"
4961
5199
  ? page.waitForResponse((response) => {
4962
- if (response.request().method() !== "POST" || !isOrderUpsellsUrl(response.url())) return false;
5200
+ if (isFollowedRedirect(response)) return false;
5201
+ const root = responseRequest(response);
5202
+ if (!root || root.method() !== "POST" || !isOrderUpsellsUrl(root.url())) return false;
4963
5203
  const startedAt = responseRequestStartedAt(response);
4964
5204
  return startedAt === null || startedAt >= armedAt;
4965
5205
  }, { timeout: UPSELL_MUTATION_TIMEOUT_MS + (perpetual ? 0 : UPSELL_CLICK_TIMEOUT_MS) }).catch(() => null)
@@ -4990,9 +5230,9 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
4990
5230
  // waits for a later read-back before it judges the step.
4991
5231
  ...(bodyRead ? { api_response_body_read: { timed_out: bodyRead.timed_out, waited_ms: bodyRead.waited_ms, bound_ms: bodyRead.bound_ms } } : {}),
4992
5232
  };
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.
5233
+ // The request this click made (the root of its redirect chain). Another
5234
+ // step's upsell mutation posts to the same order-upsells URL, so a late body
5235
+ // is this step's only when it answers this request.
4996
5236
  const mutationRequest = mutationResponse ? responseRequest(mutationResponse) : null;
4997
5237
  if (mutationRequest) record[REQUEST_IDENTITY] = mutationRequest;
4998
5238
  return record;
@@ -5976,21 +6216,52 @@ function mutationRespondedAt(response) {
5976
6216
  // it). Playwright hands every listener the same Request object for one
5977
6217
  // request, and a different one for each request, even to the same URL: an
5978
6218
  // upsell step matches its own mutation's late body by this identity (#505).
6219
+ //
6220
+ // A redirect gives each hop a new Request, so the identity is the request
6221
+ // that started the chain (followed back through redirectedFrom()): every hop
6222
+ // of one redirected POST carries the same identity, and two POSTs never do
6223
+ // (#516).
5979
6224
  const REQUEST_IDENTITY = Symbol("campaigns-os.request-identity");
5980
6225
 
5981
6226
  function responseRequest(response) {
5982
6227
  try {
5983
- return response.request() || null;
6228
+ return redirectChainRoot(response.request());
5984
6229
  } catch {
5985
6230
  return null;
5986
6231
  }
5987
6232
  }
5988
6233
 
6234
+ // Walks back to the chain's first request. A visited set ends the walk on any
6235
+ // chain, however long, so every hop of one chain reports the same root.
6236
+ function redirectChainRoot(request) {
6237
+ let root = request || null;
6238
+ const visited = new Set();
6239
+ while (root && typeof root.redirectedFrom === "function" && !visited.has(root)) {
6240
+ visited.add(root);
6241
+ const previous = root.redirectedFrom();
6242
+ if (!previous) break;
6243
+ root = previous;
6244
+ }
6245
+ return root;
6246
+ }
6247
+
6248
+ // A 3xx hop the browser follows: it names a Location, and the chain's answer
6249
+ // is a later hop's response. A 3xx with no Location is a final response.
6250
+ function isFollowedRedirect(response) {
6251
+ try {
6252
+ const status = response.status();
6253
+ return status >= 300 && status < 400 && Boolean(response.headers()?.location);
6254
+ } catch {
6255
+ return false;
6256
+ }
6257
+ }
6258
+
5989
6259
  // 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.
6260
+ // same clock as Date.now()), or null when Playwright does not report it. For a
6261
+ // redirected request this is when its chain's first hop started.
5991
6262
  function responseRequestStartedAt(response) {
5992
6263
  try {
5993
- const startTime = response.request().timing().startTime;
6264
+ const startTime = responseRequest(response).timing().startTime;
5994
6265
  return Number.isFinite(startTime) && startTime > 0 ? startTime : null;
5995
6266
  } catch {
5996
6267
  return null;
@@ -6013,11 +6284,16 @@ function captureCheckoutEvents(page) {
6013
6284
  // polls for it. A bound here would record a slow but successful order body
6014
6285
  // as null for good, and the order would read as not created.
6015
6286
  page.on("response", async (response) => {
6016
- if (!interesting.test(response.url())) return;
6287
+ // A redirected chain is logged by where it started, so its final hop is
6288
+ // captured even when it answers from a URL this filter would not match
6289
+ // (#516): the late-body search finds it by request identity.
6290
+ const request = responseRequest(response);
6291
+ let chainUrl = null;
6292
+ try { chainUrl = request?.url() ?? null; } catch { /* identity only */ }
6293
+ if (!interesting.test(response.url()) && !(chainUrl && interesting.test(chainUrl))) return;
6017
6294
  // Taken before the body read: an entry lands in the log when its body
6018
6295
  // finishes, so its position says nothing about when it was requested.
6019
6296
  const requestStartedAt = responseRequestStartedAt(response);
6020
- const request = responseRequest(response);
6021
6297
  const entry = {
6022
6298
  status: response.status(),
6023
6299
  url: response.url(),
@@ -6313,12 +6589,13 @@ async function closeAddressAutocomplete(page) {
6313
6589
  }
6314
6590
  }
6315
6591
 
6316
- function testOrderPaths(mode, topologies = []) {
6592
+ function testOrderPaths(mode, topologies = [], args = {}) {
6317
6593
  const normalized = String(mode || "off").toLowerCase();
6318
6594
  // `common` (also the bare `--test-order` flag, which parses to boolean true)
6319
- // is the default sample: at most four graph-derived shapes for everyday QA.
6595
+ // is the default: every actual terminal path when they fit under the flood
6596
+ // cap, otherwise the sample plus one decline path per uncovered offer page.
6320
6597
  // `full` is the explicit opt-in for every actual terminal path.
6321
- if (normalized === "common" || normalized === "true") return testOrderCommonPaths(topologies);
6598
+ if (isCommonTestOrderMode(normalized)) return commonOrderPathPlan(topologies, args).paths;
6322
6599
  if (normalized === "full") return fullTestOrderPaths(resolvePrimaryTestOrderTopology(topologies));
6323
6600
  if (normalized === "both") return ["accept", "decline"];
6324
6601
  if (["checkout", "accept", "decline"].includes(normalized)) return [normalized];
@@ -6356,7 +6633,7 @@ function testOrderPlans(mode, topologies = [], args = {}, options = {}) {
6356
6633
  const applyCoupon = stringArg(args["apply-coupon"]);
6357
6634
  const checkoutPage = findPage(topologies, "checkout");
6358
6635
  const topologyPlan = resolvePrimaryTestOrderTopology(topologies);
6359
- return testOrderPaths(mode, topologies).map((path) => ({
6636
+ return testOrderPaths(mode, topologies, args).map((path) => ({
6360
6637
  path,
6361
6638
  select_package: selectPackage,
6362
6639
  apply_coupon: applyCoupon,
@@ -6634,15 +6911,306 @@ function summarizeTestOrderPlan(plan) {
6634
6911
  };
6635
6912
  }
6636
6913
 
6637
- // The default "common shapes" sample: checkout baseline, plus first-offer
6638
- // accept and decline when the checkout enters an offer graph, plus the shortest
6639
- // real receipt path when that adds coverage. Stays within 1-4 orders so it never
6640
- // trips the flood cap. Bundle/quantity and bump coverage come from `--cart`;
6914
+ // The "common shapes" sample: checkout baseline, plus first-offer accept and
6915
+ // decline when the checkout enters an offer graph, plus the shortest real
6916
+ // receipt path when that adds coverage. 1-4 orders. `tiers:common` crosses
6917
+ // each tier with this sample; the operator `common` depth builds on it through
6918
+ // commonOrderPathPlan. Bundle/quantity and bump coverage come from `--cart`;
6641
6919
  // every actual terminal path comes from `full`.
6642
6920
  function testOrderCommonPaths(topologies = []) {
6643
6921
  return commonTestOrderPaths(resolvePrimaryTestOrderTopology(topologies));
6644
6922
  }
6645
6923
 
6924
+ function isCommonTestOrderMode(mode) {
6925
+ const normalized = String(mode ?? "off").toLowerCase();
6926
+ return normalized === "common" || normalized === "true";
6927
+ }
6928
+
6929
+ // The operator `common` depth, planned against the same cap the flood guard
6930
+ // enforces, so the plan that decides "full fits" is the plan that is checked.
6931
+ function commonOrderPathPlan(topologies = [], args = {}) {
6932
+ return commonTestOrderPlan(resolvePrimaryTestOrderTopology(topologies), {
6933
+ cap: numberArg(args["max-test-orders"], DEFAULT_MAX_TEST_ORDERS),
6934
+ });
6935
+ }
6936
+
6937
+ function describeCommonOrderPathPlan(plan) {
6938
+ const head = plan.effective_depth === "full"
6939
+ ? `[qa:test-order] common runs every actual terminal path (${plan.paths.length}, at or under the cap of ${plan.cap}).`
6940
+ : `[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}` : ""}.`;
6941
+ if (!plan.uncovered_pages.length) return head;
6942
+ const pages = plan.uncovered_pages.map((page) => `${page.page_id || "(unnamed)"}${page.reason === "unreachable" ? " (unreachable from the checkout)" : ""}`);
6943
+ 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.`;
6944
+ }
6945
+
6946
+ // Which offer pages had their decline clicked by an order this run actually
6947
+ // placed (#530). The unit is the decline control: an order that reached a page,
6948
+ // or clicked only its accept, does not count, because a broken decline link is
6949
+ // the failure this row exists to surface. Read from the click records the
6950
+ // runner kept, never from the plan.
6951
+ //
6952
+ // The row is conservative by construction. It may be `pass` or `warn` only
6953
+ // when coverage is certain: every funnel in the run lists its pages, every page
6954
+ // is either a known non-offer type or an offer page with its own absolute URL
6955
+ // (no URL shared with another page), every plan belongs to exactly one of those
6956
+ // funnels (same checkout, same page list), and every click a placed order
6957
+ // recorded lands on a declared offer page of that order's own funnel. A click
6958
+ // credits only the funnel whose plan made it, never another funnel by URL. Any
6959
+ // doubt, no placed order, or `--test-order off` makes the row `manual_review`
6960
+ // naming the pages and the reasons. When certain, it is `warn` naming each
6961
+ // page whose decline no order clicked, else `pass`. No row when coverage is
6962
+ // certain and no funnel has an offer page, or when nothing was topologised,
6963
+ // planned or placed. `orderPlans` holds the plan behind each entry in
6964
+ // `orders`, index for index.
6965
+ function upsellActionCoverageAssertion({ topologies = [], plans = [], orders = [], orderPlans = [], checkoutPage = null, orderPathPlan = null, testOrdersOff = false } = {}) {
6966
+ const funnels = (Array.isArray(topologies) ? topologies : []).map((topology, index) => ({
6967
+ index,
6968
+ funnel_id: topology?.funnel_id || null,
6969
+ pages: Array.isArray(topology?.pages) ? topology.pages : null,
6970
+ }));
6971
+ const funnelName = (funnel) => funnel.funnel_id || `(unnamed funnel ${funnel.index + 1})`;
6972
+ // Run-level reasons coverage is not certain; per-page reasons live on the
6973
+ // page entries.
6974
+ const doubts = [];
6975
+
6976
+ // Every offer page of every funnel, and every page whose type does not rule
6977
+ // it out. The same declaration listed twice in one funnel is one page.
6978
+ const entries = [];
6979
+ for (const funnel of funnels) {
6980
+ if (!funnel.pages) {
6981
+ entries.push({ funnel, key: null, page_id: null, page_type: null, label: "(page list missing)", reason: "no_pages" });
6982
+ continue;
6983
+ }
6984
+ const rows = new Set();
6985
+ funnel.pages.forEach((page, index) => {
6986
+ const type = String(page?.page_type || "").toLowerCase().replace(/[-_]/g, "");
6987
+ if (NON_OFFER_PAGE_TYPES.has(type)) return;
6988
+ const key = canonicalHttpUrl(page?.url);
6989
+ const row = `${page?.page_id ?? ""}\u0000${key ?? `#${index}`}`;
6990
+ if (rows.has(row)) return;
6991
+ rows.add(row);
6992
+ entries.push({
6993
+ funnel,
6994
+ key,
6995
+ page_id: page?.page_id || null,
6996
+ page_type: page?.page_type || null,
6997
+ label: page?.page_id || `(unnamed page ${index + 1})`,
6998
+ reason: !OFFER_PAGE_TYPES.has(type)
6999
+ ? "unknown_page_type"
7000
+ : key ? null : (typeof page?.url === "string" && page.url.trim() ? "unresolvable_url" : "no_url"),
7001
+ });
7002
+ });
7003
+ }
7004
+ const entriesByKey = new Map();
7005
+ for (const entry of entries) {
7006
+ if (!entry.key) continue;
7007
+ if (!entriesByKey.has(entry.key)) entriesByKey.set(entry.key, []);
7008
+ entriesByKey.get(entry.key).push(entry);
7009
+ }
7010
+ for (const shared of entriesByKey.values()) {
7011
+ if (shared.length < 2) continue;
7012
+ for (const entry of shared) entry.reason ||= "shared_url";
7013
+ }
7014
+
7015
+ // A plan belongs to a funnel only when its checkout is that funnel's own
7016
+ // checkout page (the same object, or failing that the one funnel whose
7017
+ // checkout has its URL) and the page list it planned over is that funnel's.
7018
+ const planOwners = new Map();
7019
+ const ownerOf = (plan) => {
7020
+ if (planOwners.has(plan)) return planOwners.get(plan);
7021
+ let owner = null;
7022
+ if (plan && typeof plan === "object") {
7023
+ let candidates = plan.checkout_page ? funnels.filter((funnel) => funnel.pages?.includes(plan.checkout_page)) : [];
7024
+ if (!candidates.length) {
7025
+ const checkoutKey = canonicalHttpUrl(plan.checkout_page?.url || plan.topology_plan?.checkout_url);
7026
+ candidates = checkoutKey
7027
+ ? funnels.filter((funnel) => funnel.pages?.some((page) => String(page?.page_type || "").toLowerCase() === "checkout" && canonicalHttpUrl(page?.url) === checkoutKey))
7028
+ : [];
7029
+ }
7030
+ const [candidate] = candidates;
7031
+ if (candidates.length === 1 && (!plan.topology_plan || plannedOverFunnel(plan.topology_plan, candidate))) owner = candidate;
7032
+ }
7033
+ planOwners.set(plan, owner);
7034
+ return owner;
7035
+ };
7036
+ const unattributedPlans = [...new Set([...plans, ...orderPlans])].filter((plan) => !ownerOf(plan));
7037
+ if (unattributedPlans.length) {
7038
+ doubts.push({ reason: "unattributed_plan", plans: unattributedPlans.map((plan) => (plan && typeof plan === "object") || typeof plan === "string" ? planId(plan) : String(plan)) });
7039
+ }
7040
+
7041
+ const placedByFunnel = new Map();
7042
+ const undeclaredClicks = new Set();
7043
+ let unattributedClicks = 0;
7044
+ let placed = 0;
7045
+ orders.forEach((order, index) => {
7046
+ if (!isPlacedTestOrder(order)) return;
7047
+ placed += 1;
7048
+ const funnel = ownerOf(orderPlans[index]);
7049
+ if (funnel) placedByFunnel.set(funnel, (placedByFunnel.get(funnel) || 0) + 1);
7050
+ for (const step of Array.isArray(order.upsell_steps) ? order.upsell_steps : []) {
7051
+ if (step?.clicked !== true) continue;
7052
+ const key = canonicalHttpUrl(step?.offer_url);
7053
+ if (!key || !funnel) {
7054
+ unattributedClicks += 1;
7055
+ continue;
7056
+ }
7057
+ const entry = entries.find((candidate) => candidate.funnel === funnel && candidate.key === key);
7058
+ if (!entry) {
7059
+ undeclaredClicks.add(key);
7060
+ continue;
7061
+ }
7062
+ if (step.path === "accept") entry.accept_clicked = true;
7063
+ if (step.path === "decline") entry.decline_clicked = true;
7064
+ }
7065
+ });
7066
+ if (unattributedClicks) doubts.push({ reason: "unattributed_click", clicks: unattributedClicks });
7067
+ if (undeclaredClicks.size) doubts.push({ reason: "undeclared_click", urls: [...undeclaredClicks].map((key) => new URL(key).pathname) });
7068
+
7069
+ if (!funnels.length) {
7070
+ // No funnel topology reached this row, so its pages cannot be listed. That
7071
+ // is unknown, not "no offer pages".
7072
+ if (!(plans.length || orders.length)) return null;
7073
+ doubts.unshift({ reason: "no_topology" });
7074
+ }
7075
+ if (!entries.length && !doubts.length) return null;
7076
+
7077
+ const nameFunnels = funnels.length > 1;
7078
+ const pageName = (entry) => {
7079
+ const notes = [];
7080
+ if (nameFunnels) notes.push(`funnel ${funnelName(entry.funnel)}`);
7081
+ if (entry.reason) notes.push(UPSELL_COVERAGE_UNASSESSABLE[entry.reason]);
7082
+ return `${entry.label}${notes.length ? ` (${notes.join("; ")})` : ""}`;
7083
+ };
7084
+ const pages = entries.map((entry) => ({
7085
+ page_id: entry.page_id,
7086
+ page_type: entry.page_type,
7087
+ funnel_id: entry.funnel.funnel_id,
7088
+ accept_clicked: entry.accept_clicked === true,
7089
+ decline_clicked: entry.decline_clicked === true,
7090
+ ...(entry.reason ? { assessable: false, reason: entry.reason } : {}),
7091
+ }));
7092
+ const open = entries.filter((entry) => !entry.decline_clicked || entry.reason);
7093
+ const unassessable = entries.filter((entry) => entry.reason);
7094
+ const evidence = {
7095
+ basis: "decline_clicked",
7096
+ orders_placed: placed,
7097
+ pages,
7098
+ not_clicked_through: open.map((entry) => entry.label),
7099
+ not_assessable: unassessable.map((entry) => ({ page_id: entry.page_id, funnel_id: entry.funnel.funnel_id, reason: entry.reason })),
7100
+ uncertainty: doubts,
7101
+ funnels: funnels.map((funnel) => ({
7102
+ funnel_id: funnel.funnel_id,
7103
+ orders_placed: placedByFunnel.get(funnel) || 0,
7104
+ not_clicked_through: open.filter((entry) => entry.funnel === funnel).map((entry) => entry.label),
7105
+ })),
7106
+ ...(orderPathPlan ? {
7107
+ order_path_depth: orderPathPlan.requested_depth,
7108
+ order_path_depth_effective: orderPathPlan.effective_depth,
7109
+ order_path_depth_reason: orderPathPlan.reason,
7110
+ max_test_orders: orderPathPlan.cap,
7111
+ full_path_count: orderPathPlan.full_path_count,
7112
+ coverage_paths: orderPathPlan.coverage_paths,
7113
+ planned_uncovered_pages: orderPathPlan.uncovered_pages,
7114
+ } : {}),
7115
+ };
7116
+ const base = {
7117
+ id: "browser-test-order:upsell-action-coverage",
7118
+ family: "browser-test-order",
7119
+ page: checkoutPage || { page_id: "checkout" },
7120
+ expected: "every page with upsell actions has its decline clicked by at least one test order of its own funnel",
7121
+ };
7122
+
7123
+ // The one predicate pass and warn depend on.
7124
+ const certain = placed > 0 && !testOrdersOff && !doubts.length && !unassessable.length;
7125
+ if (!certain) {
7126
+ const reason = doubts[0]?.reason === "no_topology"
7127
+ ? "no_topology"
7128
+ : testOrdersOff ? "test_orders_off" : !placed ? "no_order_recorded" : "not_assessable";
7129
+ const why = reason === "no_topology"
7130
+ ? "no funnel topology reached the coverage check, so the pages with upsell actions could not be listed"
7131
+ : reason === "test_orders_off"
7132
+ ? "browser test orders were off (--test-order off)"
7133
+ : reason === "no_order_recorded"
7134
+ ? "no test order was placed"
7135
+ : `coverage cannot be attributed with certainty (${[
7136
+ ...doubts.map(describeCoverageDoubt),
7137
+ ...(unassessable.length ? [`${unassessable.length} page(s) cannot be matched to a click`] : []),
7138
+ ].join("; ")})`;
7139
+ const named = open.length ? `, so ${open.length} of ${pages.length} page(s) with upsell actions are not proved clicked through: ${open.map(pageName).join(", ")}` : "";
7140
+ return assertion({
7141
+ ...base,
7142
+ status: STATUS.MANUAL_REVIEW,
7143
+ severity: SEVERITY.WARN,
7144
+ actual: `${why}${named}`,
7145
+ evidence: { ...evidence, reason },
7146
+ });
7147
+ }
7148
+ if (open.length) {
7149
+ const unrun = [...new Set(open.map((entry) => entry.funnel))].filter((funnel) => !placedByFunnel.get(funnel));
7150
+ return assertion({
7151
+ ...base,
7152
+ status: STATUS.WARN,
7153
+ severity: SEVERITY.WARN,
7154
+ 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(", ")}` : ""}`,
7155
+ evidence,
7156
+ });
7157
+ }
7158
+ return assertion({
7159
+ ...base,
7160
+ status: STATUS.PASS,
7161
+ actual: `decline clicked on all ${pages.length} page(s) with upsell actions`,
7162
+ evidence,
7163
+ });
7164
+ }
7165
+
7166
+ // True when a resolved topology plan was planned over exactly this funnel: the
7167
+ // same funnel id and the same page list, page for page.
7168
+ function plannedOverFunnel(topologyPlan, funnel) {
7169
+ if ((topologyPlan?.topology_id || null) !== (funnel.funnel_id || "default")) return false;
7170
+ const planned = Array.isArray(topologyPlan?.route_pages) ? topologyPlan.route_pages : null;
7171
+ if (!planned || planned.length !== funnel.pages.length) return false;
7172
+ return funnel.pages.every((page, index) => (page?.page_id || null) === planned[index]?.page_id
7173
+ && (page?.page_type || null) === planned[index]?.page_type
7174
+ && (page?.url || null) === planned[index]?.url);
7175
+ }
7176
+
7177
+ function describeCoverageDoubt(doubt) {
7178
+ if (doubt.reason === "unattributed_plan") return `order plan(s) ${doubt.plans.join(", ")} cannot be matched to exactly one funnel's checkout and page list`;
7179
+ if (doubt.reason === "unattributed_click") return `${doubt.clicks} recorded click(s) carry no usable page URL or come from an order with no funnel`;
7180
+ if (doubt.reason === "undeclared_click") return `an order clicked page(s) its funnel does not declare: ${doubt.urls.join(", ")}`;
7181
+ return doubt.reason;
7182
+ }
7183
+
7184
+ // Page types that never carry an upsell action. Any other type, or none, does
7185
+ // not say either way.
7186
+ const NON_OFFER_PAGE_TYPES = new Set(["presell", "landing", "select", "product", "checkout", "thankyou", "receipt"]);
7187
+ const UPSELL_COVERAGE_UNASSESSABLE = Object.freeze({
7188
+ no_url: "no URL",
7189
+ unresolvable_url: "URL is not an absolute http(s) address",
7190
+ unknown_page_type: "page type does not say whether it offers anything",
7191
+ shared_url: "URL shared with another page",
7192
+ no_pages: "funnel lists no pages",
7193
+ });
7194
+
7195
+ // An attempt counts as a placed order only when it carries the order reference
7196
+ // the runner read back; a failure placeholder has none.
7197
+ function isPlacedTestOrder(order) {
7198
+ if (!order || typeof order !== "object") return false;
7199
+ return [order.ref_id, order.next_order_id].some((value) => value != null && String(value).trim() !== "");
7200
+ }
7201
+
7202
+ // The coverage row for a run that drove no browser test order at all
7203
+ // (`--test-order off`), so its verdict never reads as every decline clicked.
7204
+ // Null when no funnel has offer pages, as in a run that placed orders.
7205
+ export function upsellActionCoverageWithoutOrders(topologies = []) {
7206
+ return upsellActionCoverageAssertion({
7207
+ topologies,
7208
+ orders: [],
7209
+ checkoutPage: findPage(topologies, "checkout"),
7210
+ testOrdersOff: true,
7211
+ });
7212
+ }
7213
+
6646
7214
  function enforceTestOrderLimit(plans, args) {
6647
7215
  const maxOrders = numberArg(args["max-test-orders"], DEFAULT_MAX_TEST_ORDERS);
6648
7216
  if (plans.length <= maxOrders) return;
@@ -6659,7 +7227,7 @@ function enforceTestOrderLimit(plans, args) {
6659
7227
  throw new Error([
6660
7228
  `--test-order ${args["test-order"]} expands to ${plans.length} typed-card order(s), above --max-test-orders ${maxOrders}.`,
6661
7229
  `Planned paths: ${preview}.`,
6662
- `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.`,
7230
+ `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.`,
6663
7231
  "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.",
6664
7232
  ].join(" "));
6665
7233
  }
@@ -6862,7 +7430,8 @@ async function waitForLateUpsellEvidence(events, { responseIndexBefore, mutation
6862
7430
  const response = fresh[index];
6863
7431
  if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) continue;
6864
7432
  if (!(response.status >= 200 && response.status < 300)) continue;
6865
- if (!ORDER_UPSELLS_RESPONSE_PATTERN.test(response.url)) continue;
7433
+ // A redirected mutation's final hop may answer from another URL; its
7434
+ // identity, the root of its chain, is what ties it to this step (#516).
6866
7435
  if (mutationRequest && response[REQUEST_IDENTITY] === mutationRequest) return { source: "late_upsell_body", body: response.body };
6867
7436
  }
6868
7437
  for (let index = fresh.length - 1; index >= 0; index -= 1) {
@@ -7204,6 +7773,9 @@ export const __qaBrowserTestHooks = Object.freeze({
7204
7773
  testEmail,
7205
7774
  testOrderPaths,
7206
7775
  testOrderPlans,
7776
+ commonOrderPathPlan,
7777
+ describeCommonOrderPathPlan,
7778
+ upsellActionCoverageAssertion,
7207
7779
  planId,
7208
7780
  argsForPlan,
7209
7781
  enforceTestOrderLimit,