@nextcommerce/campaigns-os 1.43.2 → 1.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +648 -5103
  3. package/README.md +32 -11
  4. package/agents/claude/CLAUDE.md +1 -1
  5. package/agents/codex/AGENTS.md +1 -1
  6. package/agents/copilot/copilot-instructions.md +1 -1
  7. package/agents/cursor/campaigns-os.mdc +1 -1
  8. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
  9. package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
  10. package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
  11. package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
  12. package/campaign-spec/dist/types.d.ts +2 -2
  13. package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
  14. package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
  15. package/contracts/effects.v1.json +1176 -113
  16. package/contracts/orientation-reason-codes.v1.json +7 -0
  17. package/contracts/release-ledger.json +2345 -6087
  18. package/contracts/supported-surface.json +7 -4
  19. package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
  20. package/docs/brand-theme-bridge.md +81 -0
  21. package/docs/build-packet.md +158 -21
  22. package/docs/campaigns-os-build-flow.md +3 -3
  23. package/docs/design-source-package.md +73 -0
  24. package/docs/effects.md +50 -8
  25. package/docs/gateway-login.md +3 -0
  26. package/docs/local-setup.md +1 -1
  27. package/docs/orientation-contract-reference.md +42 -2
  28. package/docs/polish-evidence.md +74 -0
  29. package/docs/qa-and-test-orders.md +99 -13
  30. package/docs/release-ledger-authoring-guide.md +64 -4
  31. package/docs/runtime-readiness.md +1 -1
  32. package/docs/sdk-storage-compatibility.md +1 -1
  33. package/docs/skills-revision.md +10 -10
  34. package/docs/supported-surface.md +2 -2
  35. package/docs/versioning.md +4 -1
  36. package/package.json +1 -1
  37. package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
  38. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
  39. package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
  40. package/skills/campaign-readback-classification/SKILL.md +3 -3
  41. package/skills/campaign-run-evidence/SKILL.md +7 -6
  42. package/skills/contribution-intake/SKILL.md +3 -3
  43. package/skills/next-campaigns-build/SKILL.md +7 -6
  44. package/skills/next-campaigns-os/SKILL.md +7 -7
  45. package/skills/next-campaigns-os/references/session-intake.md +9 -3
  46. package/skills/next-campaigns-os-setup/SKILL.md +5 -5
  47. package/skills/next-campaigns-polish/SKILL.md +28 -9
  48. package/skills/next-campaigns-qa/SKILL.md +7 -4
  49. package/skills.json +10 -10
  50. package/src/brand-theme.mjs +320 -20
  51. package/src/built-site-scope.mjs +16 -4
  52. package/src/cli.mjs +280 -46
  53. package/src/commercial-parity.mjs +48 -2
  54. package/src/deviation.mjs +13 -1
  55. package/src/diagnostic.mjs +5 -2
  56. package/src/doctor/checks.mjs +320 -81
  57. package/src/doctor/inspect.mjs +55 -13
  58. package/src/doctor/source-provenance.mjs +184 -0
  59. package/src/invocation.mjs +4 -0
  60. package/src/live-campaign-refs.mjs +466 -0
  61. package/src/login.mjs +2 -2
  62. package/src/page-kit-store-profile.mjs +69 -12
  63. package/src/page-kit-sync.mjs +31 -12
  64. package/src/progress-node.mjs +3 -1
  65. package/src/qa-browser.mjs +538 -28
  66. package/src/qa-commercial-parity.mjs +48 -5
  67. package/src/qa-node.mjs +122 -7
  68. package/src/qa-test-order-topology.mjs +148 -0
  69. package/src/sdk-markup.mjs +72 -8
  70. package/src/source-html-intake.mjs +116 -0
  71. package/src/stage-record.mjs +551 -0
  72. 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
  }
@@ -912,7 +933,9 @@ async function runPageBrowserChecks(context, page, args, options = {}) {
912
933
  const pageErrors = [];
913
934
  const failedRequests = [];
914
935
  browserPage.on("console", (message) => {
915
- if (message.type() === "error") consoleErrors.push(trim(message.text()));
936
+ // The location URL is kept beside the text: a "Failed to load resource"
937
+ // message names its status but not the request, which is the location.
938
+ if (message.type() === "error") consoleErrors.push({ text: trim(message.text()), url: message.location?.()?.url || null });
916
939
  });
917
940
  browserPage.on("pageerror", (error) => pageErrors.push(trim(error.message)));
918
941
  browserPage.on("requestfailed", (request) => {
@@ -1019,16 +1042,65 @@ function isIgnorableFailedRequest(request) {
1019
1042
  }
1020
1043
  }
1021
1044
 
1045
+ // `messages` are { text, url } records (url: the console message's location);
1046
+ // the result is the actionable messages' text.
1022
1047
  async function actionableRuntimeConsoleErrors(browserPage, messages) {
1023
1048
  if (!messages.length) return [];
1024
1049
  const runtimeReady = await browserPage.evaluate(() => (
1025
1050
  document.documentElement.classList.contains("next-display-ready")
1026
1051
  || Boolean(window.next && Object.keys(window.next).length)
1027
1052
  )).catch(() => false);
1028
- return messages.filter((message) => {
1029
- if (runtimeReady && isKnownSdkLoaderFalsePositive(message)) return false;
1030
- return true;
1031
- });
1053
+ // The page's final URL, after redirects: the host the browser actually
1054
+ // shows, not the host the page record requested.
1055
+ const pageUrl = browserPage.url();
1056
+ return messages
1057
+ .filter((message) => {
1058
+ if (runtimeReady && isKnownSdkLoaderFalsePositive(message.text)) return false;
1059
+ if (isNetlifyPreviewDrawerConsoleError(pageUrl, message)) return false;
1060
+ return true;
1061
+ })
1062
+ .map((message) => message.text);
1063
+ }
1064
+
1065
+ // Netlify injects its deploy-preview drawer (the collaboration toolbar) into
1066
+ // preview pages, and a failed drawer request is logged as a "Failed to load
1067
+ // resource" console error on every page (a 428 has been observed). That error
1068
+ // is Netlify's, not the campaign's.
1069
+ //
1070
+ // Each exempt request is an exact host plus an exact path:
1071
+ // netlify-cdp-loader.netlify.app /netlify.js — the drawer's loader script,
1072
+ // the one `<script src>` Netlify injects into preview HTML.
1073
+ // It is exempt only on a page whose final URL (after redirects) is a Netlify
1074
+ // preview host: any *.netlify.app host, or a deploy-preview subdomain on a
1075
+ // custom domain (deploy-preview-7.shop.example.com, or
1076
+ // deploy-preview-7--shop.example.com). Nothing else is exempt: any other path
1077
+ // on a Netlify host (app.netlify.com included), and the drawer's own URL on any
1078
+ // other page host, still counts. A "Failed to load resource" message names its
1079
+ // status but not the request; the request is the message's location URL.
1080
+ const NETLIFY_PREVIEW_DRAWER_REQUESTS = Object.freeze([
1081
+ { hostname: "netlify-cdp-loader.netlify.app", pathname: "/netlify.js" },
1082
+ ]);
1083
+
1084
+ // The first label of a deploy-preview host: `deploy-preview-<n>`, or
1085
+ // `deploy-preview-<n>--<site>` for a per-deploy host on a custom domain.
1086
+ // Only numeric ids match, deliberately: Netlify issues numeric deploy-preview
1087
+ // ids, and widening would hide real errors on any host named deploy-preview-*.
1088
+ const DEPLOY_PREVIEW_LABEL = /^deploy-preview-\d+(?:--[a-z0-9-]+)?$/;
1089
+
1090
+ function isNetlifyPreviewHost(hostname) {
1091
+ const labels = hostname.split(".");
1092
+ return hostname.endsWith(".netlify.app") || (labels.length > 2 && DEPLOY_PREVIEW_LABEL.test(labels[0]));
1093
+ }
1094
+
1095
+ function isNetlifyPreviewDrawerConsoleError(pageUrl, message) {
1096
+ if (!/^Failed to load resource\b/.test(String(message?.text || ""))) return false;
1097
+ try {
1098
+ const request = new URL(message.url);
1099
+ return isNetlifyPreviewHost(new URL(pageUrl).hostname)
1100
+ && NETLIFY_PREVIEW_DRAWER_REQUESTS.some((drawer) => drawer.hostname === request.hostname && drawer.pathname === request.pathname);
1101
+ } catch {
1102
+ return false;
1103
+ }
1032
1104
  }
1033
1105
 
1034
1106
  function isKnownSdkLoaderFalsePositive(message) {
@@ -1486,13 +1558,126 @@ async function checkoutCommerceStructureAssertions(browserPage, page) {
1486
1558
  }
1487
1559
 
1488
1560
  const evidence = await inspectCommerceStructure(browserPage, contract);
1561
+ const behaviour = await inspectCheckoutBehaviour(browserPage);
1489
1562
  return [commerceStructureAssertionFromEvidence(page, {
1490
1563
  template_family: family,
1491
1564
  contract_status: contractStatus,
1492
1565
  ...evidence,
1566
+ behaviour,
1493
1567
  })];
1494
1568
  }
1495
1569
 
1570
+ // The checkout's wrapper and page composition belong to the campaign source,
1571
+ // not the template family (#532). A catalog rule is family shell when every
1572
+ // selector it reads is on this list: the layout wrapper and column classes the
1573
+ // pinned catalog uses, and the family include's shipping field row marker.
1574
+ // Every other selector is SDK wiring the checkout needs to work
1575
+ // (`[data-next-checkout]`, `[os-checkout-payment]`, `[data-next-cart-summary]`,
1576
+ // `[data-next-bundle-slots-for]`) and is never relaxed. That includes any
1577
+ // selector not listed here, even a bare class such as the hosted payment
1578
+ // field's `.input-flds`, so a catalog recorded in the packet cannot widen it.
1579
+ const FAMILY_SHELL_SELECTORS = Object.freeze([
1580
+ ".checkout-wrapper",
1581
+ ".checkout-layout__left",
1582
+ ".checkout-layout__right",
1583
+ ".checkout__layout",
1584
+ ".checkout__column--left",
1585
+ ".checkout__column--right",
1586
+ '[data-next-component="shipping-field-row"]',
1587
+ ]);
1588
+
1589
+ function isFamilyShellSelector(selector) {
1590
+ return FAMILY_SHELL_SELECTORS.includes(String(selector || "").trim());
1591
+ }
1592
+
1593
+ function isFamilyShellCheck(check) {
1594
+ const selectors = Array.isArray(check?.selectors) ? check.selectors : [];
1595
+ return selectors.length > 0 && selectors.every(isFamilyShellSelector);
1596
+ }
1597
+
1598
+ // What a checkout has to do, whoever composed it. The required fields are the
1599
+ // contact and shipping fields the test-order form fill types without the
1600
+ // optional flag (fillCheckoutFields): each must be a form control carrying
1601
+ // data-next-checkout-field inside a <form data-next-checkout="form">, and not a
1602
+ // type="hidden" input or a disabled control (its own attribute or a disabled
1603
+ // fieldset), since a customer cannot fill either. Visibility is not required:
1604
+ // progressive-reveal checkouts hide the address fields until a country is
1605
+ // chosen. The total is the cart-summary total the order-total parity check
1606
+ // reads.
1607
+ const CHECKOUT_FORM_SELECTOR = 'form[data-next-checkout="form"]';
1608
+ const CHECKOUT_BEHAVIOUR_REQUIRED_FIELDS = Object.freeze([
1609
+ "email",
1610
+ "fname",
1611
+ "lname",
1612
+ "country",
1613
+ "address1",
1614
+ "city",
1615
+ "province",
1616
+ "postal",
1617
+ ]);
1618
+
1619
+ function checkoutBehaviourProbeInput() {
1620
+ return {
1621
+ formSelector: CHECKOUT_FORM_SELECTOR,
1622
+ requiredFields: [...CHECKOUT_BEHAVIOUR_REQUIRED_FIELDS],
1623
+ totalSelectors: checkoutTotalSelectors(),
1624
+ };
1625
+ }
1626
+
1627
+ async function inspectCheckoutBehaviour(browserPage) {
1628
+ return browserPage.evaluate((input) => {
1629
+ const visible = (element) => {
1630
+ const rect = element.getBoundingClientRect();
1631
+ const style = getComputedStyle(element);
1632
+ return rect.width > 0
1633
+ && rect.height > 0
1634
+ && style.display !== "none"
1635
+ && style.visibility !== "hidden"
1636
+ && Number(style.opacity || "1") !== 0;
1637
+ };
1638
+ const forms = Array.from(document.querySelectorAll(input.formSelector));
1639
+ const controls = new Set(["INPUT", "SELECT", "TEXTAREA"]);
1640
+ // readonly has no effect on a select, so only an input or textarea loses it.
1641
+ const fillable = (field) => controls.has(field.tagName)
1642
+ && !(field.tagName === "INPUT" && field.type === "hidden")
1643
+ && !field.matches(":disabled")
1644
+ && !(field.tagName !== "SELECT" && field.hasAttribute("readonly"))
1645
+ && field.getAttribute("aria-disabled") !== "true";
1646
+ const missing = input.requiredFields.filter((name) => !forms.some((form) => Array
1647
+ .from(form.querySelectorAll("[data-next-checkout-field]"))
1648
+ .some((field) => field.getAttribute("data-next-checkout-field") === name && fillable(field))));
1649
+ const totals = input.totalSelectors.flatMap((selector) => {
1650
+ try {
1651
+ return Array.from(document.querySelectorAll(selector));
1652
+ } catch {
1653
+ return [];
1654
+ }
1655
+ });
1656
+ const visibleTotals = totals.filter((element) => visible(element) && (element.textContent || "").trim().length > 0);
1657
+ const checkoutForm = { selector: input.formSelector, count: forms.length, status: forms.length ? "pass" : "fail" };
1658
+ const fieldsBound = {
1659
+ required: input.requiredFields,
1660
+ missing,
1661
+ status: forms.length && !missing.length ? "pass" : "fail",
1662
+ };
1663
+ const totalVisible = {
1664
+ selectors: input.totalSelectors,
1665
+ count: totals.length,
1666
+ visible_count: visibleTotals.length,
1667
+ status: visibleTotals.length ? "pass" : "fail",
1668
+ };
1669
+ return {
1670
+ status: [checkoutForm, fieldsBound, totalVisible].every((check) => check.status === "pass") ? "pass" : "fail",
1671
+ checkout_form: checkoutForm,
1672
+ fields_bound: fieldsBound,
1673
+ total_visible: totalVisible,
1674
+ };
1675
+ }, checkoutBehaviourProbeInput()).catch((error) => ({
1676
+ status: "fail",
1677
+ error: error instanceof Error ? error.message : String(error),
1678
+ }));
1679
+ }
1680
+
1496
1681
  async function inspectCommerceStructure(browserPage, contract) {
1497
1682
  const safeContract = isPlainObject(contract) ? contract : {};
1498
1683
  const checks = await browserPage.evaluate((input) => {
@@ -1577,18 +1762,28 @@ function commerceStructureAssertionFromEvidence(page, evidence) {
1577
1762
  evidence,
1578
1763
  });
1579
1764
  }
1580
- const failed = checks.filter((check) => check.status === "fail");
1765
+ const classified = checks.map((check) => ({ ...check, kind: isFamilyShellCheck(check) ? "family_shell" : "sdk_wiring" }));
1766
+ const failed = classified.filter((check) => check.status === "fail");
1767
+ // A missing family-shell selector is a warning only when nothing else failed
1768
+ // and the behaviour probe ran and passed; absent or failing behaviour
1769
+ // evidence keeps the row a failure.
1770
+ const shellOnly = failed.length > 0 && failed.every((check) => check.kind === "family_shell");
1771
+ const behaviourPassed = evidence?.behaviour?.status === "pass";
1772
+ const relaxed = shellOnly && behaviourPassed;
1773
+ const missing = `${checks.length - failed.length}/${checks.length} structure check(s) passed; missing ${failed.map((check) => check.name).join(", ")}`;
1581
1774
  return assertion({
1582
1775
  id: `browser-commerce-structure:${page.page_id}`,
1583
1776
  family: "browser-runtime",
1584
1777
  page,
1585
- status: failed.length ? STATUS.FAIL : STATUS.PASS,
1778
+ status: !failed.length ? STATUS.PASS : relaxed ? STATUS.WARN : STATUS.FAIL,
1586
1779
  severity: failed.length ? SEVERITY.WARN : undefined,
1587
1780
  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,
1781
+ actual: !failed.length
1782
+ ? `${checks.length}/${checks.length} structure check(s) passed`
1783
+ : relaxed
1784
+ ? `${missing}; the checkout form, bound fields and visible total pass, so the missing family shell is source-owned`
1785
+ : missing,
1786
+ evidence: { ...evidence, checks: classified },
1592
1787
  });
1593
1788
  }
1594
1789
 
@@ -2590,13 +2785,13 @@ async function pricingVisibilityAssertions(browserPage, page, options = {}) {
2590
2785
  if (!surfaces) return [];
2591
2786
  const pageType = contractPageType(page);
2592
2787
  if (["upsell", "downsell"].includes(pageType)) {
2593
- const selectors = surfaces.upsell?.price_row_selectors || [];
2788
+ const selectors = withSdkPriceDisplaySelectors(surfaces.upsell?.price_row_selectors);
2594
2789
  if (!selectors.length) return [];
2595
2790
  const visibleCount = await countVisiblePriceRows(browserPage, selectors);
2596
2791
  return [upsellPriceVisibilityAssertion({ page, selectors, visibleCount })];
2597
2792
  }
2598
2793
  if (pageType === "checkout") {
2599
- const selectors = surfaces.checkout_bundle?.price_row_selectors || [];
2794
+ const selectors = withSdkPriceDisplaySelectors(surfaces.checkout_bundle?.price_row_selectors);
2600
2795
  // Two price surfaces satisfy this check. The contract's bundle price rows
2601
2796
  // are one; the rendered cart-summary total is the other — the same
2602
2797
  // selectors the order-total parity check reads at submit. A family that
@@ -2615,11 +2810,29 @@ async function pricingVisibilityAssertions(browserPage, page, options = {}) {
2615
2810
  return [];
2616
2811
  }
2617
2812
 
2813
+ // The SDK renders a bundle or upsell price through data-next-bundle-display as
2814
+ // well as data-next-display (#532). A contract surface that lists price rows
2815
+ // also counts that attribute; an empty list stays empty so a contract that
2816
+ // declares no rows keeps skipping the count.
2817
+ const SDK_BUNDLE_PRICE_DISPLAY_SELECTOR = "[data-next-bundle-display*='price']";
2818
+
2819
+ function withSdkPriceDisplaySelectors(selectors) {
2820
+ const list = Array.isArray(selectors) ? selectors : [];
2821
+ if (!list.length || list.includes(SDK_BUNDLE_PRICE_DISPLAY_SELECTOR)) return list;
2822
+ return [...list, SDK_BUNDLE_PRICE_DISPLAY_SELECTOR];
2823
+ }
2824
+
2618
2825
  // Visible = non-zero bounding box, display != none, visibility != hidden. This
2619
2826
  // is what caught nothing in the dogfood run: a campaign CSS rule display:none'd
2620
2827
  // the only price row on a full-price upsell and 48/48 checks still passed.
2828
+ // A bundle-display node is the price text itself, so it also needs
2829
+ // non-whitespace text: a sized but empty node is a price the SDK never filled.
2830
+ // The rule follows the node's attribute, not the selector that reached it
2831
+ // first, so a bundle-display node that also carries `.price-wrapper` still
2832
+ // needs text.
2621
2833
  async function countVisiblePriceRows(browserPage, selectors) {
2622
- return browserPage.evaluate((targets) => {
2834
+ return browserPage.evaluate(({ targets, bundlePriceSelector }) => {
2835
+ const textRequired = (element) => element.matches(bundlePriceSelector);
2623
2836
  const visible = (element) => {
2624
2837
  const rect = element.getBoundingClientRect();
2625
2838
  const style = getComputedStyle(element);
@@ -2632,14 +2845,16 @@ async function countVisiblePriceRows(browserPage, selectors) {
2632
2845
  for (const element of document.querySelectorAll(selector)) {
2633
2846
  if (seen.has(element)) continue;
2634
2847
  seen.add(element);
2635
- if (visible(element)) count += 1;
2848
+ if (!visible(element)) continue;
2849
+ if (textRequired(element) && !(element.textContent || "").trim()) continue;
2850
+ count += 1;
2636
2851
  }
2637
2852
  } catch {
2638
2853
  // invalid selector: contract bug surfaced elsewhere
2639
2854
  }
2640
2855
  }
2641
2856
  return count;
2642
- }, selectors).catch(() => 0);
2857
+ }, { targets: selectors, bundlePriceSelector: SDK_BUNDLE_PRICE_DISPLAY_SELECTOR }).catch(() => 0);
2643
2858
  }
2644
2859
 
2645
2860
  // Page-scoped like every other per-page row (`template-residue:<page>:…`,
@@ -6313,12 +6528,13 @@ async function closeAddressAutocomplete(page) {
6313
6528
  }
6314
6529
  }
6315
6530
 
6316
- function testOrderPaths(mode, topologies = []) {
6531
+ function testOrderPaths(mode, topologies = [], args = {}) {
6317
6532
  const normalized = String(mode || "off").toLowerCase();
6318
6533
  // `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.
6534
+ // is the default: every actual terminal path when they fit under the flood
6535
+ // cap, otherwise the sample plus one decline path per uncovered offer page.
6320
6536
  // `full` is the explicit opt-in for every actual terminal path.
6321
- if (normalized === "common" || normalized === "true") return testOrderCommonPaths(topologies);
6537
+ if (isCommonTestOrderMode(normalized)) return commonOrderPathPlan(topologies, args).paths;
6322
6538
  if (normalized === "full") return fullTestOrderPaths(resolvePrimaryTestOrderTopology(topologies));
6323
6539
  if (normalized === "both") return ["accept", "decline"];
6324
6540
  if (["checkout", "accept", "decline"].includes(normalized)) return [normalized];
@@ -6356,7 +6572,7 @@ function testOrderPlans(mode, topologies = [], args = {}, options = {}) {
6356
6572
  const applyCoupon = stringArg(args["apply-coupon"]);
6357
6573
  const checkoutPage = findPage(topologies, "checkout");
6358
6574
  const topologyPlan = resolvePrimaryTestOrderTopology(topologies);
6359
- return testOrderPaths(mode, topologies).map((path) => ({
6575
+ return testOrderPaths(mode, topologies, args).map((path) => ({
6360
6576
  path,
6361
6577
  select_package: selectPackage,
6362
6578
  apply_coupon: applyCoupon,
@@ -6634,15 +6850,306 @@ function summarizeTestOrderPlan(plan) {
6634
6850
  };
6635
6851
  }
6636
6852
 
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`;
6853
+ // The "common shapes" sample: checkout baseline, plus first-offer accept and
6854
+ // decline when the checkout enters an offer graph, plus the shortest real
6855
+ // receipt path when that adds coverage. 1-4 orders. `tiers:common` crosses
6856
+ // each tier with this sample; the operator `common` depth builds on it through
6857
+ // commonOrderPathPlan. Bundle/quantity and bump coverage come from `--cart`;
6641
6858
  // every actual terminal path comes from `full`.
6642
6859
  function testOrderCommonPaths(topologies = []) {
6643
6860
  return commonTestOrderPaths(resolvePrimaryTestOrderTopology(topologies));
6644
6861
  }
6645
6862
 
6863
+ function isCommonTestOrderMode(mode) {
6864
+ const normalized = String(mode ?? "off").toLowerCase();
6865
+ return normalized === "common" || normalized === "true";
6866
+ }
6867
+
6868
+ // The operator `common` depth, planned against the same cap the flood guard
6869
+ // enforces, so the plan that decides "full fits" is the plan that is checked.
6870
+ function commonOrderPathPlan(topologies = [], args = {}) {
6871
+ return commonTestOrderPlan(resolvePrimaryTestOrderTopology(topologies), {
6872
+ cap: numberArg(args["max-test-orders"], DEFAULT_MAX_TEST_ORDERS),
6873
+ });
6874
+ }
6875
+
6876
+ function describeCommonOrderPathPlan(plan) {
6877
+ const head = plan.effective_depth === "full"
6878
+ ? `[qa:test-order] common runs every actual terminal path (${plan.paths.length}, at or under the cap of ${plan.cap}).`
6879
+ : `[qa:test-order] common runs ${plan.paths.length} path(s): the sample${plan.coverage_paths.length ? ` plus ${plan.coverage_paths.join(", ")} to click through offer declines` : ""}${plan.full_path_count ? `; full would plan ${plan.full_path_count}, above the cap of ${plan.cap}` : ""}.`;
6880
+ if (!plan.uncovered_pages.length) return head;
6881
+ const pages = plan.uncovered_pages.map((page) => `${page.page_id || "(unnamed)"}${page.reason === "unreachable" ? " (unreachable from the checkout)" : ""}`);
6882
+ return `${head} No planned path clicks the decline on: ${pages.join(", ")}. Use --test-order full with the --max-test-orders it names to cover them.`;
6883
+ }
6884
+
6885
+ // Which offer pages had their decline clicked by an order this run actually
6886
+ // placed (#530). The unit is the decline control: an order that reached a page,
6887
+ // or clicked only its accept, does not count, because a broken decline link is
6888
+ // the failure this row exists to surface. Read from the click records the
6889
+ // runner kept, never from the plan.
6890
+ //
6891
+ // The row is conservative by construction. It may be `pass` or `warn` only
6892
+ // when coverage is certain: every funnel in the run lists its pages, every page
6893
+ // is either a known non-offer type or an offer page with its own absolute URL
6894
+ // (no URL shared with another page), every plan belongs to exactly one of those
6895
+ // funnels (same checkout, same page list), and every click a placed order
6896
+ // recorded lands on a declared offer page of that order's own funnel. A click
6897
+ // credits only the funnel whose plan made it, never another funnel by URL. Any
6898
+ // doubt, no placed order, or `--test-order off` makes the row `manual_review`
6899
+ // naming the pages and the reasons. When certain, it is `warn` naming each
6900
+ // page whose decline no order clicked, else `pass`. No row when coverage is
6901
+ // certain and no funnel has an offer page, or when nothing was topologised,
6902
+ // planned or placed. `orderPlans` holds the plan behind each entry in
6903
+ // `orders`, index for index.
6904
+ function upsellActionCoverageAssertion({ topologies = [], plans = [], orders = [], orderPlans = [], checkoutPage = null, orderPathPlan = null, testOrdersOff = false } = {}) {
6905
+ const funnels = (Array.isArray(topologies) ? topologies : []).map((topology, index) => ({
6906
+ index,
6907
+ funnel_id: topology?.funnel_id || null,
6908
+ pages: Array.isArray(topology?.pages) ? topology.pages : null,
6909
+ }));
6910
+ const funnelName = (funnel) => funnel.funnel_id || `(unnamed funnel ${funnel.index + 1})`;
6911
+ // Run-level reasons coverage is not certain; per-page reasons live on the
6912
+ // page entries.
6913
+ const doubts = [];
6914
+
6915
+ // Every offer page of every funnel, and every page whose type does not rule
6916
+ // it out. The same declaration listed twice in one funnel is one page.
6917
+ const entries = [];
6918
+ for (const funnel of funnels) {
6919
+ if (!funnel.pages) {
6920
+ entries.push({ funnel, key: null, page_id: null, page_type: null, label: "(page list missing)", reason: "no_pages" });
6921
+ continue;
6922
+ }
6923
+ const rows = new Set();
6924
+ funnel.pages.forEach((page, index) => {
6925
+ const type = String(page?.page_type || "").toLowerCase().replace(/[-_]/g, "");
6926
+ if (NON_OFFER_PAGE_TYPES.has(type)) return;
6927
+ const key = canonicalHttpUrl(page?.url);
6928
+ const row = `${page?.page_id ?? ""}\u0000${key ?? `#${index}`}`;
6929
+ if (rows.has(row)) return;
6930
+ rows.add(row);
6931
+ entries.push({
6932
+ funnel,
6933
+ key,
6934
+ page_id: page?.page_id || null,
6935
+ page_type: page?.page_type || null,
6936
+ label: page?.page_id || `(unnamed page ${index + 1})`,
6937
+ reason: !OFFER_PAGE_TYPES.has(type)
6938
+ ? "unknown_page_type"
6939
+ : key ? null : (typeof page?.url === "string" && page.url.trim() ? "unresolvable_url" : "no_url"),
6940
+ });
6941
+ });
6942
+ }
6943
+ const entriesByKey = new Map();
6944
+ for (const entry of entries) {
6945
+ if (!entry.key) continue;
6946
+ if (!entriesByKey.has(entry.key)) entriesByKey.set(entry.key, []);
6947
+ entriesByKey.get(entry.key).push(entry);
6948
+ }
6949
+ for (const shared of entriesByKey.values()) {
6950
+ if (shared.length < 2) continue;
6951
+ for (const entry of shared) entry.reason ||= "shared_url";
6952
+ }
6953
+
6954
+ // A plan belongs to a funnel only when its checkout is that funnel's own
6955
+ // checkout page (the same object, or failing that the one funnel whose
6956
+ // checkout has its URL) and the page list it planned over is that funnel's.
6957
+ const planOwners = new Map();
6958
+ const ownerOf = (plan) => {
6959
+ if (planOwners.has(plan)) return planOwners.get(plan);
6960
+ let owner = null;
6961
+ if (plan && typeof plan === "object") {
6962
+ let candidates = plan.checkout_page ? funnels.filter((funnel) => funnel.pages?.includes(plan.checkout_page)) : [];
6963
+ if (!candidates.length) {
6964
+ const checkoutKey = canonicalHttpUrl(plan.checkout_page?.url || plan.topology_plan?.checkout_url);
6965
+ candidates = checkoutKey
6966
+ ? funnels.filter((funnel) => funnel.pages?.some((page) => String(page?.page_type || "").toLowerCase() === "checkout" && canonicalHttpUrl(page?.url) === checkoutKey))
6967
+ : [];
6968
+ }
6969
+ const [candidate] = candidates;
6970
+ if (candidates.length === 1 && (!plan.topology_plan || plannedOverFunnel(plan.topology_plan, candidate))) owner = candidate;
6971
+ }
6972
+ planOwners.set(plan, owner);
6973
+ return owner;
6974
+ };
6975
+ const unattributedPlans = [...new Set([...plans, ...orderPlans])].filter((plan) => !ownerOf(plan));
6976
+ if (unattributedPlans.length) {
6977
+ doubts.push({ reason: "unattributed_plan", plans: unattributedPlans.map((plan) => (plan && typeof plan === "object") || typeof plan === "string" ? planId(plan) : String(plan)) });
6978
+ }
6979
+
6980
+ const placedByFunnel = new Map();
6981
+ const undeclaredClicks = new Set();
6982
+ let unattributedClicks = 0;
6983
+ let placed = 0;
6984
+ orders.forEach((order, index) => {
6985
+ if (!isPlacedTestOrder(order)) return;
6986
+ placed += 1;
6987
+ const funnel = ownerOf(orderPlans[index]);
6988
+ if (funnel) placedByFunnel.set(funnel, (placedByFunnel.get(funnel) || 0) + 1);
6989
+ for (const step of Array.isArray(order.upsell_steps) ? order.upsell_steps : []) {
6990
+ if (step?.clicked !== true) continue;
6991
+ const key = canonicalHttpUrl(step?.offer_url);
6992
+ if (!key || !funnel) {
6993
+ unattributedClicks += 1;
6994
+ continue;
6995
+ }
6996
+ const entry = entries.find((candidate) => candidate.funnel === funnel && candidate.key === key);
6997
+ if (!entry) {
6998
+ undeclaredClicks.add(key);
6999
+ continue;
7000
+ }
7001
+ if (step.path === "accept") entry.accept_clicked = true;
7002
+ if (step.path === "decline") entry.decline_clicked = true;
7003
+ }
7004
+ });
7005
+ if (unattributedClicks) doubts.push({ reason: "unattributed_click", clicks: unattributedClicks });
7006
+ if (undeclaredClicks.size) doubts.push({ reason: "undeclared_click", urls: [...undeclaredClicks].map((key) => new URL(key).pathname) });
7007
+
7008
+ if (!funnels.length) {
7009
+ // No funnel topology reached this row, so its pages cannot be listed. That
7010
+ // is unknown, not "no offer pages".
7011
+ if (!(plans.length || orders.length)) return null;
7012
+ doubts.unshift({ reason: "no_topology" });
7013
+ }
7014
+ if (!entries.length && !doubts.length) return null;
7015
+
7016
+ const nameFunnels = funnels.length > 1;
7017
+ const pageName = (entry) => {
7018
+ const notes = [];
7019
+ if (nameFunnels) notes.push(`funnel ${funnelName(entry.funnel)}`);
7020
+ if (entry.reason) notes.push(UPSELL_COVERAGE_UNASSESSABLE[entry.reason]);
7021
+ return `${entry.label}${notes.length ? ` (${notes.join("; ")})` : ""}`;
7022
+ };
7023
+ const pages = entries.map((entry) => ({
7024
+ page_id: entry.page_id,
7025
+ page_type: entry.page_type,
7026
+ funnel_id: entry.funnel.funnel_id,
7027
+ accept_clicked: entry.accept_clicked === true,
7028
+ decline_clicked: entry.decline_clicked === true,
7029
+ ...(entry.reason ? { assessable: false, reason: entry.reason } : {}),
7030
+ }));
7031
+ const open = entries.filter((entry) => !entry.decline_clicked || entry.reason);
7032
+ const unassessable = entries.filter((entry) => entry.reason);
7033
+ const evidence = {
7034
+ basis: "decline_clicked",
7035
+ orders_placed: placed,
7036
+ pages,
7037
+ not_clicked_through: open.map((entry) => entry.label),
7038
+ not_assessable: unassessable.map((entry) => ({ page_id: entry.page_id, funnel_id: entry.funnel.funnel_id, reason: entry.reason })),
7039
+ uncertainty: doubts,
7040
+ funnels: funnels.map((funnel) => ({
7041
+ funnel_id: funnel.funnel_id,
7042
+ orders_placed: placedByFunnel.get(funnel) || 0,
7043
+ not_clicked_through: open.filter((entry) => entry.funnel === funnel).map((entry) => entry.label),
7044
+ })),
7045
+ ...(orderPathPlan ? {
7046
+ order_path_depth: orderPathPlan.requested_depth,
7047
+ order_path_depth_effective: orderPathPlan.effective_depth,
7048
+ order_path_depth_reason: orderPathPlan.reason,
7049
+ max_test_orders: orderPathPlan.cap,
7050
+ full_path_count: orderPathPlan.full_path_count,
7051
+ coverage_paths: orderPathPlan.coverage_paths,
7052
+ planned_uncovered_pages: orderPathPlan.uncovered_pages,
7053
+ } : {}),
7054
+ };
7055
+ const base = {
7056
+ id: "browser-test-order:upsell-action-coverage",
7057
+ family: "browser-test-order",
7058
+ page: checkoutPage || { page_id: "checkout" },
7059
+ expected: "every page with upsell actions has its decline clicked by at least one test order of its own funnel",
7060
+ };
7061
+
7062
+ // The one predicate pass and warn depend on.
7063
+ const certain = placed > 0 && !testOrdersOff && !doubts.length && !unassessable.length;
7064
+ if (!certain) {
7065
+ const reason = doubts[0]?.reason === "no_topology"
7066
+ ? "no_topology"
7067
+ : testOrdersOff ? "test_orders_off" : !placed ? "no_order_recorded" : "not_assessable";
7068
+ const why = reason === "no_topology"
7069
+ ? "no funnel topology reached the coverage check, so the pages with upsell actions could not be listed"
7070
+ : reason === "test_orders_off"
7071
+ ? "browser test orders were off (--test-order off)"
7072
+ : reason === "no_order_recorded"
7073
+ ? "no test order was placed"
7074
+ : `coverage cannot be attributed with certainty (${[
7075
+ ...doubts.map(describeCoverageDoubt),
7076
+ ...(unassessable.length ? [`${unassessable.length} page(s) cannot be matched to a click`] : []),
7077
+ ].join("; ")})`;
7078
+ const named = open.length ? `, so ${open.length} of ${pages.length} page(s) with upsell actions are not proved clicked through: ${open.map(pageName).join(", ")}` : "";
7079
+ return assertion({
7080
+ ...base,
7081
+ status: STATUS.MANUAL_REVIEW,
7082
+ severity: SEVERITY.WARN,
7083
+ actual: `${why}${named}`,
7084
+ evidence: { ...evidence, reason },
7085
+ });
7086
+ }
7087
+ if (open.length) {
7088
+ const unrun = [...new Set(open.map((entry) => entry.funnel))].filter((funnel) => !placedByFunnel.get(funnel));
7089
+ return assertion({
7090
+ ...base,
7091
+ status: STATUS.WARN,
7092
+ severity: SEVERITY.WARN,
7093
+ actual: `no test order clicked the decline on ${open.length} of ${pages.length} page(s) with upsell actions: ${open.map(pageName).join(", ")}${unrun.length ? `. No test order ran through funnel(s) ${unrun.map(funnelName).join(", ")}` : ""}`,
7094
+ evidence,
7095
+ });
7096
+ }
7097
+ return assertion({
7098
+ ...base,
7099
+ status: STATUS.PASS,
7100
+ actual: `decline clicked on all ${pages.length} page(s) with upsell actions`,
7101
+ evidence,
7102
+ });
7103
+ }
7104
+
7105
+ // True when a resolved topology plan was planned over exactly this funnel: the
7106
+ // same funnel id and the same page list, page for page.
7107
+ function plannedOverFunnel(topologyPlan, funnel) {
7108
+ if ((topologyPlan?.topology_id || null) !== (funnel.funnel_id || "default")) return false;
7109
+ const planned = Array.isArray(topologyPlan?.route_pages) ? topologyPlan.route_pages : null;
7110
+ if (!planned || planned.length !== funnel.pages.length) return false;
7111
+ return funnel.pages.every((page, index) => (page?.page_id || null) === planned[index]?.page_id
7112
+ && (page?.page_type || null) === planned[index]?.page_type
7113
+ && (page?.url || null) === planned[index]?.url);
7114
+ }
7115
+
7116
+ function describeCoverageDoubt(doubt) {
7117
+ if (doubt.reason === "unattributed_plan") return `order plan(s) ${doubt.plans.join(", ")} cannot be matched to exactly one funnel's checkout and page list`;
7118
+ if (doubt.reason === "unattributed_click") return `${doubt.clicks} recorded click(s) carry no usable page URL or come from an order with no funnel`;
7119
+ if (doubt.reason === "undeclared_click") return `an order clicked page(s) its funnel does not declare: ${doubt.urls.join(", ")}`;
7120
+ return doubt.reason;
7121
+ }
7122
+
7123
+ // Page types that never carry an upsell action. Any other type, or none, does
7124
+ // not say either way.
7125
+ const NON_OFFER_PAGE_TYPES = new Set(["presell", "landing", "select", "product", "checkout", "thankyou", "receipt"]);
7126
+ const UPSELL_COVERAGE_UNASSESSABLE = Object.freeze({
7127
+ no_url: "no URL",
7128
+ unresolvable_url: "URL is not an absolute http(s) address",
7129
+ unknown_page_type: "page type does not say whether it offers anything",
7130
+ shared_url: "URL shared with another page",
7131
+ no_pages: "funnel lists no pages",
7132
+ });
7133
+
7134
+ // An attempt counts as a placed order only when it carries the order reference
7135
+ // the runner read back; a failure placeholder has none.
7136
+ function isPlacedTestOrder(order) {
7137
+ if (!order || typeof order !== "object") return false;
7138
+ return [order.ref_id, order.next_order_id].some((value) => value != null && String(value).trim() !== "");
7139
+ }
7140
+
7141
+ // The coverage row for a run that drove no browser test order at all
7142
+ // (`--test-order off`), so its verdict never reads as every decline clicked.
7143
+ // Null when no funnel has offer pages, as in a run that placed orders.
7144
+ export function upsellActionCoverageWithoutOrders(topologies = []) {
7145
+ return upsellActionCoverageAssertion({
7146
+ topologies,
7147
+ orders: [],
7148
+ checkoutPage: findPage(topologies, "checkout"),
7149
+ testOrdersOff: true,
7150
+ });
7151
+ }
7152
+
6646
7153
  function enforceTestOrderLimit(plans, args) {
6647
7154
  const maxOrders = numberArg(args["max-test-orders"], DEFAULT_MAX_TEST_ORDERS);
6648
7155
  if (plans.length <= maxOrders) return;
@@ -6659,7 +7166,7 @@ function enforceTestOrderLimit(plans, args) {
6659
7166
  throw new Error([
6660
7167
  `--test-order ${args["test-order"]} expands to ${plans.length} typed-card order(s), above --max-test-orders ${maxOrders}.`,
6661
7168
  `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.`,
7169
+ `This cap guards against an accidental order flood, not a permission gate. Use --test-order common for the default depth, or rerun with --max-test-orders ${plans.length} for this exhaustive proof.`,
6663
7170
  "The cap bounds planned paths. Actual order creations are bounded separately by --max-order-creations, which defaults to the planned path count and is reserved before each submit.",
6664
7171
  ].join(" "));
6665
7172
  }
@@ -7204,6 +7711,9 @@ export const __qaBrowserTestHooks = Object.freeze({
7204
7711
  testEmail,
7205
7712
  testOrderPaths,
7206
7713
  testOrderPlans,
7714
+ commonOrderPathPlan,
7715
+ describeCommonOrderPathPlan,
7716
+ upsellActionCoverageAssertion,
7207
7717
  planId,
7208
7718
  argsForPlan,
7209
7719
  enforceTestOrderLimit,