@nextcommerce/campaigns-os 1.54.0 → 1.57.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 (111) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/CONTEXT.md +19 -5
  3. package/README.md +26 -12
  4. package/campaign-spec/README.md +15 -1
  5. package/campaign-spec/dist/checkout-flow.d.ts +90 -0
  6. package/campaign-spec/dist/checkout-flow.js +229 -0
  7. package/campaign-spec/dist/index.d.ts +1 -0
  8. package/campaign-spec/dist/index.js +6 -1
  9. package/campaign-spec/dist/normalize.d.ts +3 -2
  10. package/campaign-spec/dist/normalize.js +7 -5
  11. package/campaign-spec/dist/routing.d.ts +6 -3
  12. package/campaign-spec/dist/routing.js +6 -3
  13. package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
  14. package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
  15. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
  16. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
  17. package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
  18. package/campaign-spec/dist/rules/index.d.ts +4 -0
  19. package/campaign-spec/dist/rules/index.js +12 -0
  20. package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
  21. package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
  22. package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
  23. package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
  24. package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
  25. package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
  26. package/campaign-spec/dist/rules/schema-version.js +10 -5
  27. package/campaign-spec/dist/types.d.ts +20 -3
  28. package/campaign-spec/dist/types.js +3 -2
  29. package/compatibility.json +2 -2
  30. package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
  31. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  32. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  33. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  35. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  38. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  45. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  46. package/contracts/release-ledger.json +1146 -0
  47. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  48. package/contracts/supported-surface.json +9 -5
  49. package/docs/brand-theme-bridge.md +10 -0
  50. package/docs/build-packet.md +86 -26
  51. package/docs/campaigns-os-build-flow.md +1 -1
  52. package/docs/effects.md +2 -5
  53. package/docs/local-setup.md +1 -1
  54. package/docs/orientation-contract-reference.md +1 -1
  55. package/docs/qa-and-test-orders.md +133 -29
  56. package/docs/runtime-readiness.md +8 -3
  57. package/docs/skills-revision.md +10 -10
  58. package/docs/supported-surface.md +1 -1
  59. package/docs/versioning.md +4 -1
  60. package/docs/workflow-findings-sidecar.md +57 -2
  61. package/package.json +4 -1
  62. package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
  63. package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
  64. package/schemas/campaign-spec.v5.schema.json +586 -0
  65. package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
  66. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  67. package/skills/campaign-readback-classification/SKILL.md +3 -3
  68. package/skills/campaign-run-evidence/SKILL.md +3 -3
  69. package/skills/contribution-intake/SKILL.md +3 -3
  70. package/skills/next-campaigns-build/SKILL.md +6 -4
  71. package/skills/next-campaigns-os/SKILL.md +3 -3
  72. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  73. package/skills/next-campaigns-polish/SKILL.md +21 -6
  74. package/skills/next-campaigns-qa/SKILL.md +5 -4
  75. package/skills.json +10 -10
  76. package/src/adapter-decision-contract.mjs +27 -13
  77. package/src/brand-theme.mjs +44 -8
  78. package/src/build-brief.mjs +14 -5
  79. package/src/campaign-identity.mjs +33 -4
  80. package/src/cli-helpers.mjs +129 -0
  81. package/src/cli.mjs +156 -111
  82. package/src/commercial-journey.mjs +25 -2
  83. package/src/consent.mjs +3 -2
  84. package/src/design-source-package.mjs +1 -1
  85. package/src/design-source-publication.mjs +8 -8
  86. package/src/deviation.mjs +2 -1
  87. package/src/doctor/checks.mjs +212 -19
  88. package/src/doctor/inspect.mjs +4 -2
  89. package/src/doctor/next-step.mjs +46 -16
  90. package/src/doctor-sidecar.mjs +5 -3
  91. package/src/gate-actions.mjs +21 -1
  92. package/src/input-refresh.mjs +6 -6
  93. package/src/invocation.mjs +4 -3
  94. package/src/lifecycle.mjs +88 -6
  95. package/src/local-preview-policy.mjs +116 -12
  96. package/src/login.mjs +2 -1
  97. package/src/polish-browser.mjs +8 -2
  98. package/src/polish-node.mjs +9 -4
  99. package/src/polish-readability.mjs +2 -2
  100. package/src/qa-browser.mjs +741 -120
  101. package/src/qa-cart-entry.mjs +107 -20
  102. package/src/qa-node.mjs +179 -72
  103. package/src/qa-test-order-topology.mjs +118 -1
  104. package/src/qa-verdict-discovery.mjs +21 -4
  105. package/src/qa-verdict-publish.mjs +2 -1
  106. package/src/run-record.mjs +46 -34
  107. package/src/runtime-state-ignore.mjs +7 -5
  108. package/src/sdk-markup.mjs +41 -1
  109. package/src/source-html-intake.mjs +46 -4
  110. package/src/stage-ledger.mjs +18 -4
  111. package/src/stage-record.mjs +53 -16
@@ -7,6 +7,7 @@ import { createHash } from "node:crypto";
7
7
  import { runWithDeadline } from "./deadline.mjs";
8
8
  import { PLACEHOLDER_TEXT_ASSERTION_SUFFIX, SEVERITY, STATUS } from "./qa-verdict.mjs";
9
9
  import { contrastToolkit } from "./contrast.mjs";
10
+ import { boundedPolishDeadline } from "./polish-deadline.mjs";
10
11
  import { generatedTextRenders } from "./polish-readability.mjs";
11
12
  import {
12
13
  analyticsCaptureError,
@@ -16,7 +17,7 @@ import { attachAnalyticsCapture, diffAnalyticsParity } from "./qa-analytics-pari
16
17
  import { assessAnalyticsInventory } from "./qa-analytics-correctness.mjs";
17
18
  import { redactPersisted, redactUrlQueriesInText, redactUrlQuery } from "./qa-url-privacy.mjs";
18
19
  import { TRACKING_ADDED_BOUND_MS, TRACKING_OBSERVATION, createTrackingRun, trackingQaAssertion } from "./qa-tracking-params.mjs";
19
- import { runContentParamChecks } from "./qa-content-params.mjs";
20
+ import { CONTENT_PARAM_LIMITS, runContentParamChecks } from "./qa-content-params.mjs";
20
21
  import { createPolicyLinkBudget, hasPolicyLinkFields, readPageAnchors, runPolicyLinkChecks } from "./qa-policy-links.mjs";
21
22
  import {
22
23
  canonicalHttpUrl,
@@ -24,13 +25,20 @@ import {
24
25
  commonTestOrderPlan,
25
26
  fullTestOrderPaths,
26
27
  OFFER_PAGE_TYPES,
28
+ checkoutPathPages,
29
+ checkoutStepPages,
30
+ findPaymentPage,
31
+ isCheckoutStepTopologyPage,
27
32
  pageAtUrl,
33
+ paymentPagesForTopology,
28
34
  remainingActionDisposition,
29
35
  resolveTestOrderTopology,
30
36
  terminalAtUrl,
31
37
  } from "./qa-test-order-topology.mjs";
32
38
  import {
33
39
  CART_ENTRY_CODES,
40
+ CART_ENTRY_BUNDLE_CARD_SELECTOR,
41
+ CART_ENTRY_CHECKOUT_BUTTON_SELECTOR,
34
42
  CART_ENTRY_CONTROL_SELECTOR,
35
43
  cartEntryHrefFor,
36
44
  CART_ENTRY_ROUTE_ATTRIBUTE,
@@ -159,13 +167,17 @@ export async function runBrowserChecks(topologies, args = {}, options = {}) {
159
167
  // Content parameters (options.spec analytics.params.content), after the
160
168
  // page checks: every load in its own fresh context with the same options,
161
169
  // closed after the load. The rows go to options.qcResults; their verdict
162
- // assertions join the page checks'.
170
+ // assertions join the page checks'. options.contentParamLimits is an
171
+ // in-process test seam (never set from argv) that can only tighten the
172
+ // leg's CONTENT_PARAM_LIMITS, so a test of the budget or the readiness
173
+ // wait need not sit through the production bound.
163
174
  if (Array.isArray(options.qcResults)) {
164
175
  const contentParams = await runContentParamChecks({
165
176
  topologies,
166
177
  spec: options.spec,
167
178
  newContext: () => browser.newContext(contextOptions),
168
179
  withQueryParam,
180
+ limits: contentParamLimitsFrom(options.contentParamLimits),
169
181
  });
170
182
  options.qcResults.push(...contentParams.rows);
171
183
  assertions.push(...contentParams.assertions);
@@ -186,7 +198,9 @@ export async function runBrowserChecks(topologies, args = {}, options = {}) {
186
198
  }
187
199
 
188
200
  export async function runBrowserTestOrders(topologies, args = {}, runId = "local", options = {}) {
189
- const checkoutPage = findPage(topologies, "checkout");
201
+ // The Checkout (the page that takes payment) on the first entry path, never
202
+ // the first page typed checkout: on a split checkout that is step 1 (#641).
203
+ const checkoutPage = findPaymentPage(topologies);
190
204
  if (!checkoutPage?.url) {
191
205
  const coverage = upsellActionCoverageAssertion({ topologies, orders: [], checkoutPage });
192
206
  return {
@@ -1085,6 +1099,10 @@ async function runPageBrowserChecks(context, page, args, options = {}, policyLin
1085
1099
  }
1086
1100
  if (page.page_type === "checkout") {
1087
1101
  assertions.push(...await checkoutPaymentSurfaceAssertions(browserPage, page));
1102
+ }
1103
+ // Exit-intent and promo-code surfaces may sit on any checkout-flow page
1104
+ // (#641); each assertion runs only where the spec declares the surface.
1105
+ if (["select", "checkout_step", "checkout"].includes(page.page_type)) {
1088
1106
  assertions.push(...await checkoutOfferSurfaceAssertions(browserPage, page));
1089
1107
  }
1090
1108
  assertions.push(...await templateResidueAssertions(browserPage, page, options));
@@ -1313,7 +1331,7 @@ function sdkDebuggerEligible(page) {
1313
1331
 
1314
1332
  async function primaryCtaVisualAssertions(browserPage, page) {
1315
1333
  if (!primaryCtaCheckEligible(page)) return [];
1316
- const evidence = await inspectPrimaryCta(browserPage, page.expected_next_url);
1334
+ const evidence = await inspectPrimaryCta(browserPage, page.expected_next_url, primaryCtaDeclaredRoutes(page), primaryCtaRoutelessForms(page));
1317
1335
  return [primaryCtaAssertionFromEvidence(page, evidence)];
1318
1336
  }
1319
1337
 
@@ -1321,6 +1339,54 @@ function primaryCtaCheckEligible(page) {
1321
1339
  return Boolean(page?.expected_next_url);
1322
1340
  }
1323
1341
 
1342
+ // Checkout, upsell and downsell: the page types whose forward controls go
1343
+ // where the page declares, not where they point.
1344
+ function primaryCtaPageRoutesSdkControls(page) {
1345
+ const pageType = String(page?.page_type || "").toLowerCase();
1346
+ return pageType === "checkout" || pageType === "checkout_step" || OFFER_PAGE_TYPES.has(pageType);
1347
+ }
1348
+
1349
+ // The forms whose action is no route on this page, handed to the route rule
1350
+ // as its checkout-form selector. On a checkout, upsell or downsell the only
1351
+ // form-borne route is the one the page declares for its SDK control
1352
+ // (primaryCtaDeclaredRoutes), so no form's action is a route there: a
1353
+ // newsletter or search form beside the checkout form does not make its
1354
+ // submit button the route control. Elsewhere only the SDK checkout form,
1355
+ // which the SDK submits itself.
1356
+ function primaryCtaRoutelessForms(page) {
1357
+ return primaryCtaPageRoutesSdkControls(page) ? "form" : CHECKOUT_FORM_SELECTOR;
1358
+ }
1359
+
1360
+ // The SDK controls on this page that go to a route the page declares rather
1361
+ // than one they carry, each with that route: on a checkout, the checkout
1362
+ // form's submit button goes to the success route (the page's forward route);
1363
+ // on an upsell or downsell, each SDK upsell action goes to its accept or
1364
+ // decline route. The routes are the topology fields the static route-link
1365
+ // rows read, and the actions are the rendered upsell-control check's table.
1366
+ // A control is a route candidate when its route is the expected next route,
1367
+ // like any other. The submit button is any button that submits the form, so a
1368
+ // typeless one too, but not an express-checkout wallet button, which starts
1369
+ // the wallet flow; an upsell action counts only inside a [data-next-upsell]
1370
+ // container, the only place the SDK binds it.
1371
+ function primaryCtaDeclaredRoutes(page) {
1372
+ if (!primaryCtaPageRoutesSdkControls(page)) return [];
1373
+ // A checkout_step's form submits to its declared next step the same way the
1374
+ // Checkout's submits to its success route: the SDK navigates, not the form.
1375
+ if (["checkout", "checkout_step"].includes(String(page.page_type).toLowerCase())) {
1376
+ const notWallet = ":not([data-next-express-checkout]):not([data-next-express-checkout] *)";
1377
+ const submit = ['button[type="submit"]', "button:not([type])", 'input[type="submit"]']
1378
+ .map((control) => `${CHECKOUT_FORM_SELECTOR} ${control}${notWallet}`)
1379
+ .join(", ");
1380
+ return page.expected_next_url ? [{ selector: submit, url: page.expected_next_url }] : [];
1381
+ }
1382
+ return UPSELL_CONTROL_ROUTES
1383
+ .filter(({ field }) => page[field])
1384
+ .map(({ kind, field }) => ({
1385
+ selector: UPSELL_ACTION_SPELLINGS[kind].map((action) => `[data-next-upsell] ${upsellActionSelector(action)}`).join(", "),
1386
+ url: page[field],
1387
+ }));
1388
+ }
1389
+
1324
1390
  // Candidate CTAs: anything clickable, plus every SDK action control and the
1325
1391
  // SDK cart-entry control (the same locator the ladder's entry step and
1326
1392
  // advanceToCheckoutForm use). Only attributes the SDK declares are listed —
@@ -1336,19 +1402,21 @@ const PRIMARY_CTA_SELECTOR = ["a[href]", "button", "[role='button']", "[data-nex
1336
1402
  // stay free of module-scope references: the text is run in a fresh context by
1337
1403
  // a test (primary-CTA inspection script is self-contained) that would surface
1338
1404
  // a leaked identifier as a ReferenceError.
1339
- function primaryCtaInspectionScript(expectedUrl) {
1405
+ function primaryCtaInspectionScript(expectedUrl, declaredRoutes = [], checkoutFormSelector = CHECKOUT_FORM_SELECTOR) {
1340
1406
  const args = {
1341
1407
  routeUrl: expectedUrl,
1342
1408
  ctaSelector: PRIMARY_CTA_SELECTOR,
1343
1409
  cartEntrySelector: CART_ENTRY_CONTROL_SELECTOR,
1344
1410
  cartEntryRouteAttribute: CART_ENTRY_ROUTE_ATTRIBUTE,
1411
+ declaredRoutes,
1412
+ checkoutFormSelector,
1345
1413
  ignoredRouteAttributes: [...UNDECLARED_ROUTE_ATTRIBUTES],
1346
1414
  };
1347
1415
  return `(${inspectPrimaryCtaScript.toString()})(${JSON.stringify(args)}, ${cartEntryHrefFor.toString()}, ${contrastToolkit.toString()}, ${generatedTextRenders.toString()})`;
1348
1416
  }
1349
1417
 
1350
- async function inspectPrimaryCta(browserPage, expectedUrl) {
1351
- return browserPage.evaluate(primaryCtaInspectionScript(expectedUrl)).catch((error) => ({
1418
+ async function inspectPrimaryCta(browserPage, expectedUrl, declaredRoutes = [], checkoutFormSelector = CHECKOUT_FORM_SELECTOR) {
1419
+ return browserPage.evaluate(primaryCtaInspectionScript(expectedUrl, declaredRoutes, checkoutFormSelector)).catch((error) => ({
1352
1420
  ok: false,
1353
1421
  reason: "inspection_error",
1354
1422
  expected_url: expectedUrl,
@@ -1358,7 +1426,7 @@ async function inspectPrimaryCta(browserPage, expectedUrl) {
1358
1426
  }));
1359
1427
  }
1360
1428
 
1361
- function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, cartEntryRouteAttribute, ignoredRouteAttributes }, hrefForImpl, contrastToolkitImpl, generatedTextImpl) {
1429
+ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, cartEntryRouteAttribute, declaredRoutes, checkoutFormSelector, ignoredRouteAttributes }, hrefForImpl, contrastToolkitImpl, generatedTextImpl) {
1362
1430
  const CTA_SELECTOR = ctaSelector;
1363
1431
  const toolkit = contrastToolkitImpl();
1364
1432
 
@@ -1391,9 +1459,9 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
1391
1459
  .join("");
1392
1460
  return `${tag}${id}${classes}`;
1393
1461
  };
1394
- // The route a control leads to: the shared cart-entry rule, evaluated
1395
- // against this document's origin and base.
1396
- const hrefFor = (element) => hrefForImpl(element, { cartEntrySelector, cartEntryRouteAttribute, origin: location.origin, baseHref: location.href });
1462
+ // The route a control leads to: the shared cart-entry rule, with the
1463
+ // page's declared routes, evaluated against this document's origin and base.
1464
+ const hrefFor = (element) => hrefForImpl(element, { cartEntrySelector, cartEntryRouteAttribute, declaredRoutes, checkoutFormSelector, origin: location.origin, baseHref: location.href });
1397
1465
  // Route-shaped attributes the element carries that the rule above does not
1398
1466
  // consult: the undeclared spellings, plus the SDK route attribute on an
1399
1467
  // element that is not an SDK control (a decoy, not a route). Reported, not
@@ -1582,15 +1650,48 @@ function primaryCtaAssertionFromEvidence(page, evidence) {
1582
1650
  });
1583
1651
  }
1584
1652
 
1653
+ // The SDK's spellings of each upsell action: its upsell handler accepts the
1654
+ // offer on add or accept and declines it on skip or decline, and does nothing
1655
+ // on any other value. Every place QA locates an upsell control reads this one
1656
+ // table. The first spelling is the one reported when a page has none.
1657
+ export const UPSELL_ACTION_SPELLINGS = Object.freeze({
1658
+ accept: Object.freeze(["add", "accept"]),
1659
+ decline: Object.freeze(["skip", "decline"]),
1660
+ });
1661
+
1662
+ // The SDK upsell actions and the page field that declares where each goes.
1663
+ // The rendered upsell-control check and the primary-CTA route rule read this
1664
+ // one table.
1665
+ const UPSELL_CONTROL_ROUTES = Object.freeze([
1666
+ Object.freeze({ kind: "accept", field: "expected_accept_url" }),
1667
+ Object.freeze({ kind: "decline", field: "expected_decline_url" }),
1668
+ ]);
1669
+
1670
+ function upsellActionSelector(...actions) {
1671
+ return actions.map((action) => `[data-next-upsell-action="${action}"]`).join(", ");
1672
+ }
1673
+
1674
+ // The spelling of an upsell action this page renders: the first spelling
1675
+ // present, or the first spelling when the page has none.
1676
+ async function presentUpsellAction(page, kind) {
1677
+ const spellings = UPSELL_ACTION_SPELLINGS[kind];
1678
+ for (const action of spellings) {
1679
+ if (await page.locator(upsellActionSelector(action)).first().count().catch(() => 0)) return action;
1680
+ }
1681
+ return spellings[0];
1682
+ }
1683
+
1585
1684
  async function renderedUpsellControlAssertions(browserPage, page) {
1586
- const checks = [
1587
- ["accept", "add", page.expected_accept_url],
1588
- ["decline", "skip", page.expected_decline_url],
1589
- ];
1590
1685
  const assertions = [];
1591
- for (const [kind, action, expectedUrl] of checks) {
1686
+ for (const { kind, field } of UPSELL_CONTROL_ROUTES) {
1687
+ const expectedUrl = page[field];
1592
1688
  if (!expectedUrl) continue;
1593
- const count = await browserPage.locator(`[data-next-upsell-action="${action}"]`).count().catch(() => 0);
1689
+ const spellings = UPSELL_ACTION_SPELLINGS[kind];
1690
+ const counts = [];
1691
+ for (const action of spellings) counts.push(await browserPage.locator(upsellActionSelector(action)).count().catch(() => 0));
1692
+ const count = counts.reduce((sum, n) => sum + n, 0);
1693
+ const present = spellings.filter((_, index) => counts[index] > 0);
1694
+ const selector = upsellActionSelector(...(present.length ? present : spellings.slice(0, 1)));
1594
1695
  assertions.push(assertion({
1595
1696
  id: `browser-upsell-control:${page.page_id}:${kind}`,
1596
1697
  family: "browser-runtime",
@@ -1599,7 +1700,7 @@ async function renderedUpsellControlAssertions(browserPage, page) {
1599
1700
  severity: count > 0 ? undefined : SEVERITY.WARN,
1600
1701
  expected: `rendered SDK ${kind} control`,
1601
1702
  actual: count > 0 ? `${count} matching control(s)` : "not found",
1602
- evidence: { selector: `[data-next-upsell-action="${action}"]`, expected_url: expectedUrl },
1703
+ evidence: { selector, expected_url: expectedUrl },
1603
1704
  }));
1604
1705
  }
1605
1706
  return assertions;
@@ -1630,7 +1731,7 @@ async function checkoutPaymentSurfaceAssertions(browserPage, page) {
1630
1731
  card_number_selector: '[data-next-checkout-field="cc-number"], #spreedly-number',
1631
1732
  cvv_selector: '[data-next-checkout-field="cvv"], #spreedly-cvv',
1632
1733
  spreedly_frame_urls: spreedlyFrames.map((frame) => frame.url()).slice(0, 5),
1633
- next_step: "Run --test-order common for typed-card checkout proof (test cards bypass the gateway; no approval needed).",
1734
+ next_step: "Unless the operator has already said test orders are fine for this campaign, ask once, up front, before placing test orders, which leave real store order records. Then run --test-order common for typed-card checkout proof (test cards bypass the gateway and create no transactions; no permission flag).",
1634
1735
  },
1635
1736
  }), assertion({
1636
1737
  id: `browser-payment-geometry:${page.page_id}`,
@@ -2311,7 +2412,10 @@ function promoCodeSurfaceAssertion({ page, declaration, evidence }) {
2311
2412
 
2312
2413
  function contractPageType(page) {
2313
2414
  const type = String(page?.page_type || "").toLowerCase();
2314
- return type === "thankyou" ? "receipt" : type;
2415
+ if (type === "thankyou") return "receipt";
2416
+ // A checkout step renders the family's checkout surface without taking
2417
+ // payment (campaigns-os#641); the brand contract keys that surface "checkout".
2418
+ return type === "checkout_step" ? "checkout" : type;
2315
2419
  }
2316
2420
 
2317
2421
  // The palette-residue rows (style, logo, payment chrome) take their severity
@@ -3189,8 +3293,10 @@ function createFieldTrace() {
3189
3293
  // before the bounded ladder step starts and updated before each operation that
3190
3294
  // can hang. Its synchronous summary survives when the timeout wins the race.
3191
3295
  function createUpsellActionTrace({ page, events, topologyPlan, stepIndex, path, inspectTimeoutMs = 1000 }) {
3192
- const requestedAction = path === "accept" ? "add" : "skip";
3193
- const selector = `[data-next-upsell-action="${requestedAction}"]`;
3296
+ // The spelling the page renders, read again at each inspection.
3297
+ const kind = path === "accept" ? "accept" : "decline";
3298
+ let requestedAction = UPSELL_ACTION_SPELLINGS[kind][0];
3299
+ let selector = upsellActionSelector(requestedAction);
3194
3300
  let actionNavigationCount = Array.isArray(events?.navigations) ? events.navigations.length : 0;
3195
3301
  let actionUpsellRequestCount = (events?.requests || []).filter((request) => isOrderUpsellsUrl(request?.url)).length;
3196
3302
  let element = { present: null, visible: null, enabled: null };
@@ -3200,6 +3306,8 @@ function createUpsellActionTrace({ page, events, topologyPlan, stepIndex, path,
3200
3306
  let stepCompleted = false;
3201
3307
 
3202
3308
  const inspect = async () => {
3309
+ requestedAction = await presentUpsellAction(page, kind);
3310
+ selector = upsellActionSelector(requestedAction);
3203
3311
  const control = page.locator(selector).first();
3204
3312
  const count = await control.count().catch(() => 0);
3205
3313
  element = {
@@ -3617,7 +3725,12 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
3617
3725
  let orderDeadline = null;
3618
3726
  let events = { requests: [], responses: [], failed: [], console: [], pageErrors: [] };
3619
3727
  const email = testEmail(planArgs);
3620
- const entryPage = resolveCartEntryPage(options.topologies, checkoutPage);
3728
+ // The checkout path this order walks (campaigns-os#641): any checkout_step
3729
+ // pages before the Checkout. The cart is entered into the first form page
3730
+ // of the path, and the step pages are submitted in order before payment.
3731
+ const stepPages = checkoutStepPagesFor(options.topologies, checkoutPage);
3732
+ const formEntryPage = stepPages[0] || checkoutPage;
3733
+ const entryPage = resolvePathEntryPage(options.topologies, formEntryPage, stepPages);
3621
3734
  let tracking = null;
3622
3735
  try {
3623
3736
  tracking = options.tracking ? options.tracking.observe(planId(normalizedPlan)) : null;
@@ -3738,6 +3851,7 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
3738
3851
  ladder,
3739
3852
  checkoutPage,
3740
3853
  entryPage,
3854
+ stepPages,
3741
3855
  topologyPlan: normalizedPlan.topology_plan,
3742
3856
  path,
3743
3857
  args: planArgs,
@@ -3745,6 +3859,9 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
3745
3859
  reserveOrderCreation,
3746
3860
  selectorProbeCache: options.selectorProbeCache,
3747
3861
  tracking,
3862
+ // An in-process test seam only, like trackingTestHooks: unset in
3863
+ // every production run, so the card step waits CARD_READY_TIMEOUT_MS.
3864
+ cardReadyTimeoutMs: shorterBound(options.cardReadyTimeoutMs, CARD_READY_TIMEOUT_MS),
3748
3865
  }),
3749
3866
  orderTimeoutMs + ORDER_TIMEOUT_GRACE_MS,
3750
3867
  `order-path:${planId(normalizedPlan)}`,
@@ -3823,11 +3940,15 @@ function stablePrivateCaptureError(value) {
3823
3940
  return projectAnalyticsCaptureError(value, { fallbackKind: "unreadable" });
3824
3941
  }
3825
3942
 
3826
- async function executeTestOrderPath({ page, events, email, ladder, checkoutPage, entryPage = null, topologyPlan, path, args, deadline, reserveOrderCreation = null, selectorProbeCache = null, tracking = null }) {
3943
+ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage, entryPage = null, stepPages = [], topologyPlan, path, args, deadline, reserveOrderCreation = null, selectorProbeCache = null, tracking = null, cardReadyTimeoutMs = CARD_READY_TIMEOUT_MS }) {
3827
3944
  const stepTimeoutMs = numberArg(args["step-timeout-ms"], DEFAULT_STEP_TIMEOUT_MS);
3828
3945
  const budget = () => Math.min(stepTimeoutMs, deadline - Date.now());
3829
3946
  const hostedNow = () => hostedRedirectInfo(safePageUrl(page), checkoutPage.url);
3947
+ // The page the cart enters and the form filling starts on: the first
3948
+ // checkout_step of a multi-step path, else the Checkout itself.
3949
+ const formEntryPage = stepPages[0] || checkoutPage;
3830
3950
  const selectedPackages = parseCart(args["select-package"]);
3951
+ const currencyLoad = pathCurrencyLoad(args);
3831
3952
  let checkoutDisplay = null;
3832
3953
  let cartBeforeSubmit = null;
3833
3954
 
@@ -3843,7 +3964,8 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3843
3964
  // page the probe left there rather than loading the same URL a second time,
3844
3965
  // which would fire the SDK's page-view events twice into the same capture.
3845
3966
  const entry = await ladder.run(CART_ENTRY_STEP, () => enterCartViaLanding({
3846
- page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking,
3967
+ page, checkoutPage: formEntryPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking, currencyLoad,
3968
+ startsOnCheckoutStep: formEntryPage !== checkoutPage,
3847
3969
  }), { timeoutMs: budget() });
3848
3970
  const enteredViaLanding = Boolean(entry && typeof entry === "object" && entry.entered);
3849
3971
  // Responses captured from here on belong to the checkout the ladder drives.
@@ -3851,17 +3973,18 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3851
3973
  // made before the hand-off.
3852
3974
  const checkoutResponseOffset = events.responses.length;
3853
3975
 
3854
- await ladder.run("opened_checkout", () => openCheckoutForPath({ page, checkoutPage, entry, args, tracking }), { timeoutMs: budget() });
3976
+ await ladder.run("opened_checkout", () => openCheckoutForPath({ page, checkoutPage: formEntryPage, entry, args, tracking, currencyLoad }), { timeoutMs: budget() });
3855
3977
  await ladder.run("selected_bundle", async () => {
3856
3978
  // The requested package was selected on the entry page, where the cards
3857
3979
  // live; checkout renders none, so re-running strict selection here would
3858
3980
  // fail for the wrong reason.
3981
+ const selectedCards = [];
3859
3982
  const strictSelection = enteredViaLanding && selectedPackages.length
3860
3983
  ? `selected on entry page: ${entry.package_id || "(no package id on control)"}`
3861
- : await selectRequestedPackages(page, selectedPackages);
3862
- await selectRequestedCart(page, args);
3984
+ : await selectRequestedPackages(page, selectedPackages, selectedCards);
3985
+ const unappliedCart = await selectRequestedCart(page, args, { selectedPackages, selectedCards });
3863
3986
  await advanceToCheckoutForm(page);
3864
- if (strictSelection) return `selected requested package card(s): ${strictSelection}`;
3987
+ if (strictSelection) return `selected requested package card(s): ${[strictSelection, ...unappliedCart].join("; ")}`;
3865
3988
  return parseCart(args.cart).length ? `requested cart ${args.cart}` : "default bundle selection";
3866
3989
  }, { timeoutMs: budget() });
3867
3990
  await ladder.run("bump_state", async () => {
@@ -3874,8 +3997,20 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3874
3997
  const fieldTrace = createFieldTrace();
3875
3998
  await ladder.run("customer_fields_filled", async () => {
3876
3999
  ensurePageFillable(page, checkoutPage.url);
3877
- await fillCheckoutFields(page, args, email, { trace: fieldTrace, actionTimeoutMs: budget() });
4000
+ // On a multi-step path each page renders only its own fields, so every
4001
+ // field is filled where it is present and skipped where it is not. The
4002
+ // SDK's step validation refuses to advance when one is missing, which
4003
+ // the step rung below reports by name.
4004
+ await fillCheckoutFields(page, args, email, { trace: fieldTrace, actionTimeoutMs: budget(), presentOnly: stepPages.length > 0 });
3878
4005
  }, { timeoutMs: budget(), evidence: () => fieldTrace.summary() });
4006
+ // Walk the path: submit each checkout_step and assert it lands on the
4007
+ // step's declared next page, then fill what the arrived page renders. No
4008
+ // order is placed until the Checkout (campaigns-os#641).
4009
+ for (const [index, step] of stepPages.entries()) {
4010
+ await ladder.run("checkout_step_submitted", () => submitCheckoutStepAndArrive({
4011
+ page, step, index, args, email, budget, checkoutUrl: checkoutPage.url, tracking,
4012
+ }), { timeoutMs: budget() });
4013
+ }
3879
4014
  await ladder.run("coupon_applied", async () => {
3880
4015
  const code = stringArg(args["apply-coupon"]);
3881
4016
  if (!code) return { skip: "no --apply-coupon code requested" };
@@ -3884,7 +4019,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3884
4019
  }, { timeoutMs: budget() });
3885
4020
  await ladder.run("card_fields_filled", async () => {
3886
4021
  ensurePageFillable(page, checkoutPage.url);
3887
- return fillPaymentFields(page, args);
4022
+ return fillPaymentFields(page, args, { readyTimeoutMs: cardReadyTimeoutMs });
3888
4023
  }, { timeoutMs: budget() });
3889
4024
  await ladder.run("cart_created", async () => {
3890
4025
  const cart = cartCreationEvidence(events);
@@ -3940,6 +4075,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3940
4075
  display: checkoutDisplay,
3941
4076
  events,
3942
4077
  selected_packages: selectedPackages,
4078
+ requested_cart: parseCart(args.cart),
3943
4079
  });
3944
4080
  order.verification.total_parity = assessOrderTotalParity({
3945
4081
  display: checkoutDisplay,
@@ -4136,7 +4272,7 @@ function createSelectorProbeCache() {
4136
4272
  // loaded it and found a selection surface; either way a second load of the
4137
4273
  // same URL is what is avoided. Anywhere else (a cached probe answer skipped the
4138
4274
  // load; a fresh page) the checkout is opened here, once.
4139
- async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking = null }) {
4275
+ async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking = null, currencyLoad = null }) {
4140
4276
  const enteredViaLanding = Boolean(entry && typeof entry === "object" && entry.entered);
4141
4277
  if (enteredViaLanding) {
4142
4278
  await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
@@ -4145,14 +4281,14 @@ async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking =
4145
4281
  if (checkoutUrlPredicate(checkoutPage.url)(safePageUrl(page))) {
4146
4282
  return "already on checkout from the selector probe; not re-opened";
4147
4283
  }
4148
- await gotoAndSettle(page, checkoutPage.url, args, tracking);
4284
+ await gotoAndSettle(page, checkoutPage.url, args, tracking, currencyLoad);
4149
4285
  return null;
4150
4286
  }
4151
4287
 
4152
4288
  // The entry step body. Resolves to `{ skip }` when the checkout selects for
4153
4289
  // itself, to `{ entered: true, ... }` when the runner came in through the entry
4154
4290
  // page, and throws a coded error (never a bare timeout) when it cannot.
4155
- async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache = null, tracking = null }) {
4291
+ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache = null, tracking = null, currencyLoad = null, startsOnCheckoutStep = false }) {
4156
4292
  // Probe the checkout first: whether it carries a selection surface is a fact
4157
4293
  // about the rendered page, not about the spec (the spec cannot say it yet —
4158
4294
  // that is the design half of #206). The probe's load is the checkout's only
@@ -4169,7 +4305,7 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4169
4305
  let probe = cached ? "reused" : "loaded";
4170
4306
  let probeError = null;
4171
4307
  if (!surface) {
4172
- await gotoAndSettle(page, checkoutPage.url, args, tracking);
4308
+ await gotoAndSettle(page, checkoutPage.url, args, tracking, currencyLoad);
4173
4309
  const probed = await page.evaluate(checkoutSelectionSurfaceScript())
4174
4310
  .then((value) => ({ value }), (error) => ({ value: null, error: error?.message || String(error) }));
4175
4311
  if (probed.value) {
@@ -4188,6 +4324,15 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4188
4324
  evidence: { checkout_selection_surface: surface, ...probeEvidence },
4189
4325
  };
4190
4326
  }
4327
+ if (!entryPage?.url && startsOnCheckoutStep) {
4328
+ // A path that begins on its first checkout_step has no page before it to
4329
+ // fill the cart from; the step page fills it (the family's main package).
4330
+ // The empty-cart guard before submit still refuses an empty cart by name.
4331
+ return {
4332
+ skip: "the checkout path begins on its first checkout step and no page routes into it; the step page fills the cart, and the guard before submit checks it",
4333
+ evidence: { checkout_selection_surface: surface, ...probeEvidence },
4334
+ };
4335
+ }
4191
4336
  if (!entryPage?.url) {
4192
4337
  throw codedError(
4193
4338
  CART_ENTRY_CODES.ENTRY_UNRESOLVED,
@@ -4195,18 +4340,50 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4195
4340
  );
4196
4341
  }
4197
4342
 
4198
- await gotoAndSettle(page, entryPage.url, args, tracking);
4343
+ await gotoAndSettle(page, entryPage.url, args, tracking, currencyLoad);
4199
4344
  const sdkReady = await waitForSdkReady(page, Math.min(budget(), DEFAULT_SETTLE_TIMEOUT_MS));
4200
4345
  await tracking?.readDocument(page);
4201
- const controls = await page.evaluate(cartEntryControlsScript(), { selector: CART_ENTRY_CONTROL_SELECTOR, checkoutUrl: checkoutPage.url }).catch(() => []);
4346
+ const readControls = () => page.evaluate(cartEntryControlsScript(), {
4347
+ selector: CART_ENTRY_CONTROL_SELECTOR,
4348
+ checkoutUrl: checkoutPage.url,
4349
+ checkoutButtonSelector: CART_ENTRY_CHECKOUT_BUTTON_SELECTOR,
4350
+ bundleCardSelector: CART_ENTRY_BUNDLE_CARD_SELECTOR,
4351
+ }).catch(() => []);
4352
+ let controls = await readControls();
4353
+ // A two-step select page can hold its checkout button in a second step that
4354
+ // a variant control reveals (olympus-mv-two-step: "Select Color & Size").
4355
+ // When no checkout button is visible yet, click that control once and read
4356
+ // the controls again, so a hidden decoy link is never chosen over the
4357
+ // button the reveal shows.
4358
+ const checkoutButtons = controls.filter((entry) => entry.kind === "checkout_button");
4359
+ if (checkoutButtons.length && !checkoutButtons.some((entry) => entry.visible)) {
4360
+ const reveal = page.locator('[data-next-action="select-variants"]:visible').first();
4361
+ if (await reveal.count().catch(() => 0)) {
4362
+ await clickControl(reveal, { timeout: 5000 }).catch(() => {});
4363
+ await page.locator(CART_ENTRY_CHECKOUT_BUTTON_SELECTOR).filter({ visible: true }).first()
4364
+ .waitFor({ state: "visible", timeout: Math.max(1000, Math.min(budget(), 8000)) }).catch(() => {});
4365
+ controls = await readControls();
4366
+ }
4367
+ }
4202
4368
  const choice = chooseCartEntryControl(controls, selectedPackages);
4203
4369
  if (!choice.control) {
4204
4370
  throw codedError(CART_ENTRY_CODES.ENTRY_CONTROL_MISSING, `${choice.reason} (entry page ${redactUrlQuery(entryPage.url)})`);
4205
4371
  }
4206
4372
  const control = choice.control;
4373
+ // A select page (#641): the bundle card is the cart. --select-package clicks
4374
+ // the matching data-next-bundle-card first, strictly, as on a checkout that
4375
+ // selects for itself; otherwise the page's pre-selected card stands.
4376
+ let cardSelection = null;
4377
+ if (control.kind === "checkout_button") {
4378
+ cardSelection = choice.select_card ? await selectPackageCard(page, choice.select_card, []) : null;
4379
+ control.package_id = choice.select_card?.packageId || await selectedBundleCardPackage(page);
4380
+ }
4207
4381
  // The index is the control's position among what its own locator matches,
4208
4382
  // so it replays with nth(); no selector is rebuilt from an attribute value.
4209
- const target = page.locator(control.kind === "add_to_cart" ? CART_ENTRY_CONTROL_SELECTOR : "a[href]").nth(control.index);
4383
+ const controlSelector = control.kind === "add_to_cart"
4384
+ ? CART_ENTRY_CONTROL_SELECTOR
4385
+ : control.kind === "checkout_button" ? CART_ENTRY_CHECKOUT_BUTTON_SELECTOR : "a[href]";
4386
+ const target = page.locator(controlSelector).nth(control.index);
4210
4387
  const perpetual = await scrollControlIntoView(target);
4211
4388
  await clickControl(target, { timeout: 8000, perpetual });
4212
4389
 
@@ -4236,6 +4413,7 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4236
4413
  control_text: control.text || null,
4237
4414
  control_kind: control.kind,
4238
4415
  package_id: control.package_id || null,
4416
+ ...(cardSelection ? { bundle_card_selection: cardSelection } : {}),
4239
4417
  sdk_ready: sdkReady,
4240
4418
  arrived_url: redactUrlQuery(safePageUrl(page)),
4241
4419
  checkout_selection_surface: surface,
@@ -4244,6 +4422,92 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4244
4422
  };
4245
4423
  }
4246
4424
 
4425
+ // The package of the bundle card a select page shows as selected, or null.
4426
+ async function selectedBundleCardPackage(page) {
4427
+ return page.evaluate((cardSelector) => {
4428
+ const card = Array.from(document.querySelectorAll(cardSelector)).find((node) => (
4429
+ node.classList.contains("next-selected") || node.getAttribute("data-next-selected") === "true" || node.getAttribute("aria-checked") === "true"
4430
+ ));
4431
+ const holder = card && (card.hasAttribute("data-next-package-id") ? card : card.querySelector("[data-next-package-id]"));
4432
+ const value = holder ? String(holder.getAttribute("data-next-package-id") || "").trim() : "";
4433
+ return value || null;
4434
+ }, CART_ENTRY_BUNDLE_CARD_SELECTOR).catch(() => null);
4435
+ }
4436
+
4437
+ // One checkout_step rung: the page must be on this step, its form's submit is
4438
+ // clicked, and the page must land on the step's declared next page. Then the
4439
+ // fields the arrived page renders are filled. The SDK owns the navigation
4440
+ // (data-next-checkout-step on the form); the runner only waits for it.
4441
+ async function submitCheckoutStepAndArrive({ page, step, index, args, email, budget, checkoutUrl, tracking = null }) {
4442
+ ensurePageFillable(page, checkoutUrl);
4443
+ const here = safePageUrl(page);
4444
+ if (!checkoutUrlPredicate(step.url)(here)) {
4445
+ throw new Error(`expected to be on checkout step "${step.page_id || index + 1}" (${redactUrlQuery(step.url)}) but the page is at ${redactUrlQuery(here) || "(unknown)"}`);
4446
+ }
4447
+ const expected = step.expected_next_url;
4448
+ if (!expected) throw new Error(`checkout step "${step.page_id || index + 1}" declares no next page; the spec must route it (next_page) toward its Checkout`);
4449
+ await tracking?.readDocument(page);
4450
+ await submitCheckoutStep(page);
4451
+ const atNext = checkoutUrlPredicate(expected);
4452
+ const timeout = Math.max(1000, Math.min(budget(), numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS)));
4453
+ const arrived = await page.waitForURL((url) => atNext(url.toString()), { timeout }).then(() => true, () => false);
4454
+ if (!arrived) {
4455
+ throw new Error(`submitted checkout step "${step.page_id || index + 1}" but the page did not reach its declared next page ${redactUrlQuery(expected)} within ${timeout}ms (now at ${redactUrlQuery(safePageUrl(page)) || "(unknown)"}); a required field the step validates may be missing`);
4456
+ }
4457
+ await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
4458
+ await page.waitForTimeout(500);
4459
+ ensurePageFillable(page, checkoutUrl);
4460
+ const fieldTrace = createFieldTrace();
4461
+ await fillCheckoutFields(page, args, email, { trace: fieldTrace, actionTimeoutMs: budget(), presentOnly: true });
4462
+ return {
4463
+ detail: `step ${index + 1} "${step.page_id || "(unnamed)"}": submitted and landed on its declared next page ${redactUrlQuery(expected)}`,
4464
+ evidence: {
4465
+ step_page_id: step.page_id || null,
4466
+ step_number: index + 1,
4467
+ expected_next_url: redactUrlQuery(expected),
4468
+ arrived_url: redactUrlQuery(safePageUrl(page)),
4469
+ fields: fieldTrace.summary(),
4470
+ },
4471
+ };
4472
+ }
4473
+
4474
+ // The submit control of a checkout_step form. The step contract puts
4475
+ // data-next-checkout-step on the checkout form itself, so a page without one
4476
+ // is reported rather than submitted some other way.
4477
+ async function submitCheckoutStep(page) {
4478
+ await closeAddressAutocomplete(page);
4479
+ const scope = "form[data-next-checkout-step]";
4480
+ // Never an express-wallet button: on a step page those place an order.
4481
+ const notWallet = ":not([data-next-express-checkout]):not([data-next-express-checkout] *)";
4482
+ const submit = page.locator([`button[type="submit"]`, "button:not([type])", `input[type="submit"]`]
4483
+ .map((control) => `${scope} ${control}${notWallet}`).join(", ")).first();
4484
+ if (!await submit.count().catch(() => 0)) {
4485
+ throw new Error("checkout step page renders no form[data-next-checkout-step] with a submit control; the step form must carry data-next-checkout-step and data-next-step-number");
4486
+ }
4487
+ await submit.waitFor({ state: "visible" });
4488
+ const perpetual = await scrollControlIntoView(submit);
4489
+ await clickControl(submit, { forceFallback: false, perpetual });
4490
+ }
4491
+
4492
+ // The checkout_step pages on the path into `checkoutPage`, read from the
4493
+ // topology that holds it.
4494
+ function checkoutStepPagesFor(topologies, checkoutPage) {
4495
+ if (!checkoutPage) return [];
4496
+ const owner = (Array.isArray(topologies) ? topologies : []).find((topology) => (topology?.pages || []).includes(checkoutPage))
4497
+ || (Array.isArray(topologies) ? topologies : []).find((topology) => (topology?.pages || []).some((page) => checkoutUrlPredicate(checkoutPage.url)(page?.url)));
4498
+ return owner ? checkoutStepPages(owner, checkoutPage) : [];
4499
+ }
4500
+
4501
+ // The page the cart is entered from. Into a Checkout, the usual resolution.
4502
+ // Into a first checkout_step, only a page that routes straight into it: the
4503
+ // order-and-type fallbacks would pick another path's page in a campaign with
4504
+ // parallel paths, and a path that starts on its step has none (#641).
4505
+ function resolvePathEntryPage(topologies, formEntryPage, stepPages) {
4506
+ const resolved = resolveCartEntryPage(topologies, formEntryPage);
4507
+ if (!stepPages.length) return resolved;
4508
+ return resolved?.resolution === "routes_into_checkout" ? resolved : null;
4509
+ }
4510
+
4247
4511
  // The SDK installs `window.next` late in boot. Bounded and tolerant: a page
4248
4512
  // without the SDK (a plain forcePackageId link) still has a cart entry.
4249
4513
  async function waitForSdkReady(page, timeoutMs) {
@@ -4491,24 +4755,149 @@ function assessReceiptRendering(persistedLineCount, evidence = {}) {
4491
4755
  };
4492
4756
  }
4493
4757
 
4758
+ // `qa run --currency <code>`, validated and upper-cased. qa-node.mjs sets it
4759
+ // after its own check; a symbol, so no command-line flag can set it, and a
4760
+ // command that never validates --currency (qa parity) never loads with one.
4761
+ export const QA_RUN_CURRENCY = Symbol("qa run --currency");
4762
+
4763
+ // One order path's currency: added to the path's first runner load only —
4764
+ // the checkout probe, the entry page when the probe answer was reused, or the
4765
+ // checkout opened directly. Campaign Cart keeps a URL currency in session
4766
+ // storage for the rest of the tab's session, so later loads in the tab are
4767
+ // the plain URL; the receipt reload in recovery is not a path load at all.
4768
+ function pathCurrencyLoad(args) {
4769
+ const code = args?.[QA_RUN_CURRENCY];
4770
+ let pending = typeof code === "string" && /^[A-Z]{3}$/.test(code) ? code : null;
4771
+ return (url) => {
4772
+ if (!pending) return url;
4773
+ const entered = withQueryParam(url, "currency", pending);
4774
+ pending = null;
4775
+ return entered;
4776
+ };
4777
+ }
4778
+
4494
4779
  // With a tracking observer, the load is a runner navigation: before the
4495
- // attempt's first page-initiated hop it carries the run's synthetic seeds.
4496
- async function gotoAndSettle(page, url, args, tracking = null) {
4497
- const target = tracking ? tracking.runnerUrl(url, withQueryParam) : url;
4780
+ // attempt's first page-initiated hop it carries the run's synthetic seeds,
4781
+ // after the path's currency when this is the load that carries one.
4782
+ async function gotoAndSettle(page, url, args, tracking = null, currencyLoad = null) {
4783
+ const entered = currencyLoad ? currencyLoad(url) : url;
4784
+ const target = tracking ? tracking.runnerUrl(entered, withQueryParam) : entered;
4498
4785
  await page.goto(target, { waitUntil: "domcontentloaded", timeout: numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS) });
4499
4786
  await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
4500
4787
  await page.waitForTimeout(750);
4501
4788
  }
4502
4789
 
4503
- async function selectRequestedCart(page, args) {
4790
+ // Returns one note per --cart ref left unapplied beside a --select-package
4791
+ // selection; the selected_bundle step reports them.
4792
+ async function selectRequestedCart(page, args, { selectedPackages = [], selectedCards = [] } = {}) {
4504
4793
  const cart = parseCart(args.cart);
4794
+ if (!cart.length) return [];
4795
+ if (selectedPackages.length) return applyCartBesideSelection(page, cart, { selectedPackages, selectedCards });
4796
+ // A package a bundle card declares, asked for at an explicit quantity, is
4797
+ // chosen by the card whose items carry it at that quantity. The first
4798
+ // [data-next-package-id] for that package is usually the one-unit card's
4799
+ // inner node, so the plain click below would reset a multi-unit selection.
4800
+ // A bare ref names no quantity and clicks that first node, as it always has.
4801
+ // The cards are read again for each ref: a click may re-render the selector.
4505
4802
  for (const item of cart) {
4803
+ const ref = String(item.packageId);
4804
+ const bundleCards = (await renderedPackageCardCandidates(page))
4805
+ .filter((candidate) => candidate.bundle_id && Array.isArray(candidate.items));
4806
+ if (item.quantityExplicit && bundleCards.some((card) => card.items.some((entry) => entry.package_id === ref))) {
4807
+ await selectCartBundleCard(page, bundleCards, item);
4808
+ continue;
4809
+ }
4506
4810
  const target = page.locator(packageCardClickSelector({ package_id: item.packageId })).first();
4507
4811
  if (await target.count().catch(() => 0)) {
4508
4812
  const perpetual = await scrollControlIntoView(target);
4509
4813
  await clickControl(target, { timeout: 5000, forceFallback: false, perpetual }).catch(() => {});
4510
4814
  }
4511
4815
  }
4816
+ return [];
4817
+ }
4818
+
4819
+ function cardCarriesPackage(candidate, ref) {
4820
+ return String(candidate?.package_id || "") === ref
4821
+ || String(candidate?.bundle_id || "") === ref
4822
+ || (Array.isArray(candidate?.items) && candidate.items.some((entry) => String(entry?.package_id || "") === ref));
4823
+ }
4824
+
4825
+ // --cart beside --select-package: the card --select-package chose stays
4826
+ // selected. A --cart ref that card carries is already applied. Any other ref
4827
+ // is applied through its own control (an order bump toggle, or a card in a
4828
+ // selector group --select-package did not choose in), never by clicking
4829
+ // another card in the chosen card's group. A ref only such a click could
4830
+ // satisfy is left unapplied and named, so the order and its reconciliation
4831
+ // stay those of the card actually selected.
4832
+ async function applyCartBesideSelection(page, cart, { selectedPackages, selectedCards }) {
4833
+ const chosenIdentities = new Set(selectedCards.map(packageCardIdentity));
4834
+ const strictRefs = selectedPackages.map((item) => String(item.packageId));
4835
+ const unapplied = [];
4836
+ for (const item of cart) {
4837
+ const ref = String(item.packageId);
4838
+ const label = `--cart ${ref}:${item.quantity}`;
4839
+ const candidates = await renderedPackageCardCandidates(page, { placement: true });
4840
+ // Cards chosen on this page, or (when the choice was made on an entry
4841
+ // page) the selected cards that carry a --select-package ref.
4842
+ const chosen = candidates.filter((candidate) => !candidate.toggle && (
4843
+ chosenIdentities.has(packageCardIdentity(candidate))
4844
+ || (candidate.selected && strictRefs.some((strictRef) => cardCarriesPackage(candidate, strictRef)))
4845
+ ));
4846
+ const carrying = chosen.find((candidate) => cardCarriesPackage(candidate, ref));
4847
+ if (carrying) {
4848
+ const declared = carrying.items?.find((entry) => entry.package_id === ref)?.quantity;
4849
+ if (item.quantityExplicit && declared != null && declared !== item.quantity) {
4850
+ unapplied.push(`${label} not applied: the card --select-package chose declares ${ref}:${declared}`);
4851
+ }
4852
+ continue;
4853
+ }
4854
+ // A card in no recognised selector container may share the chosen card's
4855
+ // selection, so it is locked like a card in the chosen card's group.
4856
+ const lockedGroups = new Set(chosen.map((candidate) => candidate.group));
4857
+ const reachable = candidates.filter((candidate) => (
4858
+ cardCarriesPackage(candidate, ref)
4859
+ && (candidate.toggle || (candidate.group != null && !lockedGroups.has(candidate.group)))
4860
+ ));
4861
+ if (!reachable.length) {
4862
+ unapplied.push(candidates.some((candidate) => cardCarriesPackage(candidate, ref))
4863
+ ? `${label} not applied: only another card in the selector --select-package chose carries it`
4864
+ : `${label} not applied: no rendered card or control carries it`);
4865
+ continue;
4866
+ }
4867
+ let target = reachable.find((candidate) => candidate.toggle);
4868
+ if (!target) {
4869
+ const declaringCards = reachable.filter((candidate) => candidate.items?.some((entry) => entry.package_id === ref));
4870
+ try {
4871
+ target = declaringCards.length ? resolvePackageCardCandidate(declaringCards, item) : reachable[0];
4872
+ } catch (error) {
4873
+ unapplied.push(`${label} not applied: ${error.message}`);
4874
+ continue;
4875
+ }
4876
+ }
4877
+ const control = page.locator(PACKAGE_CARD_QUERY).nth(target.element_index);
4878
+ const perpetual = await scrollControlIntoView(control);
4879
+ await clickControl(control, { timeout: 5000, forceFallback: false, perpetual }).catch(() => {});
4880
+ }
4881
+ return unapplied;
4882
+ }
4883
+
4884
+ // Best-effort, like the rest of --cart: a ref no card satisfies leaves the
4885
+ // selection alone, and reconciliation reports the quantity the order carried.
4886
+ async function selectCartBundleCard(page, bundleCards, item) {
4887
+ const ref = String(item.packageId);
4888
+ const { selected_bundle_items: selected = [] } = await checkoutDisplayEvidence(page);
4889
+ if (selected.some((card) => card.items?.some((entry) => entry.package_id === ref && entry.quantity === item.quantity))) return;
4890
+ let candidate;
4891
+ try {
4892
+ candidate = resolvePackageCardCandidate(bundleCards, item);
4893
+ } catch {
4894
+ return;
4895
+ }
4896
+ const target = page.locator(packageCardClickSelector(candidate)).first();
4897
+ if (await target.count().catch(() => 0)) {
4898
+ const perpetual = await scrollControlIntoView(target);
4899
+ await clickControl(target, { timeout: 5000, forceFallback: false, perpetual }).catch(() => {});
4900
+ }
4512
4901
  }
4513
4902
 
4514
4903
  // --- Strict package/bundle card selection (--select-package) ---
@@ -4518,16 +4907,17 @@ async function selectRequestedCart(page, args) {
4518
4907
  // strict variant: the requested card must exist, be clickable, and an explicit
4519
4908
  // quantity must visibly enter the selected state — otherwise the
4520
4909
  // selected_bundle step fails instead of driving the wrong tier.
4521
- async function selectRequestedPackages(page, requested) {
4910
+ // `cards` collects the card each item selected, for --cart to leave alone.
4911
+ async function selectRequestedPackages(page, requested, cards = []) {
4522
4912
  if (!requested.length) return null;
4523
4913
  const details = [];
4524
4914
  for (const item of requested) {
4525
- details.push(await selectPackageCard(page, item));
4915
+ details.push(await selectPackageCard(page, item, cards));
4526
4916
  }
4527
4917
  return details.join("; ");
4528
4918
  }
4529
4919
 
4530
- async function selectPackageCard(page, item) {
4920
+ async function selectPackageCard(page, item, cards = []) {
4531
4921
  const candidate = resolvePackageCardCandidate(await renderedPackageCardCandidates(page), item);
4532
4922
  const selector = packageCardClickSelector(candidate);
4533
4923
  const target = page.locator(selector).first();
@@ -4549,14 +4939,36 @@ async function selectPackageCard(page, item) {
4549
4939
  if (packageCardProofIdentity(selected) !== packageCardProofIdentity(candidate)) {
4550
4940
  throw new Error(`--select-package ${item.packageId}: rendered card composition changed after click`);
4551
4941
  }
4942
+ cards.push(selected);
4552
4943
  const quantity = item.quantity || 1;
4553
4944
  return `${item.packageId}:${quantity} via ${selector}${state === "unknown" ? " (card exposes no selected-state marker; composition verified)" : ""}`;
4554
4945
  }
4555
4946
 
4556
- async function renderedPackageCardCandidates(page) {
4557
- return page.locator("[data-next-bundle-card], [data-next-selector-card], [data-next-package-id], [data-next-bundle-id]").evaluateAll((elements) => {
4947
+ const PACKAGE_CARD_QUERY = "[data-next-bundle-card], [data-next-selector-card], [data-next-package-id], [data-next-bundle-id]";
4948
+
4949
+ // `placement` adds where each card sits: its selector group (an index valid
4950
+ // for this read only, null for a card in no recognised selector container,
4951
+ // whose own wrapper says nothing about which cards it swaps with), whether it
4952
+ // is selected, whether it is an order-bump
4953
+ // style toggle rather than a selector card, and the index of its element in
4954
+ // PACKAGE_CARD_QUERY so that one exact element can be clicked.
4955
+ async function renderedPackageCardCandidates(page, { placement = false } = {}) {
4956
+ return page.locator(PACKAGE_CARD_QUERY).evaluateAll((elements, withPlacement) => {
4558
4957
  const cards = [];
4559
4958
  const seen = new Set();
4959
+ const groups = new Map();
4960
+ const groupOf = (card) => {
4961
+ const container = card.closest("[data-next-bundle-selector], [data-next-selector-id], [data-next-cart-selector]");
4962
+ if (!container) return null;
4963
+ if (!groups.has(container)) groups.set(container, groups.size);
4964
+ return groups.get(container);
4965
+ };
4966
+ const isSelected = (card) => (
4967
+ card.classList.contains("next-selected")
4968
+ || card.getAttribute("data-next-selected") === "true"
4969
+ || card.getAttribute("aria-checked") === "true"
4970
+ || card.querySelector('input[type="radio"], input[type="checkbox"]')?.checked === true
4971
+ );
4560
4972
  const parseItems = (value) => {
4561
4973
  if (!value) return null;
4562
4974
  try {
@@ -4570,7 +4982,7 @@ async function renderedPackageCardCandidates(page) {
4570
4982
  return null;
4571
4983
  }
4572
4984
  };
4573
- for (const element of elements) {
4985
+ for (const [index, element] of elements.entries()) {
4574
4986
  const card = element.closest("[data-next-bundle-card], [data-next-selector-card]") || element;
4575
4987
  if (seen.has(card)) continue;
4576
4988
  seen.add(card);
@@ -4587,10 +4999,19 @@ async function renderedPackageCardCandidates(page) {
4587
4999
  if (!items && packageId && Number.isFinite(declaredQuantity) && declaredQuantity > 0) {
4588
5000
  items = [{ package_id: packageId, quantity: declaredQuantity }];
4589
5001
  }
4590
- cards.push({ bundle_id: bundleId, package_id: packageId, items });
5002
+ const candidate = { bundle_id: bundleId, package_id: packageId, items };
5003
+ if (withPlacement) {
5004
+ Object.assign(candidate, {
5005
+ group: groupOf(card),
5006
+ selected: isSelected(card),
5007
+ toggle: Boolean(element.closest("[data-next-package-toggle], [data-next-toggle-card], [data-next-bump]")),
5008
+ element_index: index,
5009
+ });
5010
+ }
5011
+ cards.push(candidate);
4591
5012
  }
4592
5013
  return cards;
4593
- }).catch(() => []);
5014
+ }, placement).catch(() => []);
4594
5015
  }
4595
5016
 
4596
5017
  function packageCardIdentity(candidate) {
@@ -4622,9 +5043,15 @@ function packageCardClickSelector(candidate) {
4622
5043
  function resolvePackageCardCandidate(candidates, item) {
4623
5044
  const ref = String(item?.packageId || "");
4624
5045
  const quantity = Number(item?.quantity || 1);
4625
- const matchingRef = (candidates || []).filter((candidate) => (
5046
+ const identityMatches = (candidates || []).filter((candidate) => (
4626
5047
  String(candidate?.bundle_id || "") === ref || String(candidate?.package_id || "") === ref
4627
5048
  ));
5049
+ // A bundle card's package identity is also the package its
5050
+ // data-next-bundle-items declares, whether or not an inner node repeats it.
5051
+ const matchingRef = (candidates || []).filter((candidate) => (
5052
+ identityMatches.includes(candidate)
5053
+ || (Array.isArray(candidate?.items) && candidate.items.some((entry) => String(entry?.package_id || "") === ref))
5054
+ ));
4628
5055
  if (!matchingRef.length) {
4629
5056
  throw new Error(`--select-package ${ref}: no rendered card exposes that package or bundle identity`);
4630
5057
  }
@@ -4635,18 +5062,22 @@ function resolvePackageCardCandidate(candidates, item) {
4635
5062
  throw new Error(`--select-package ${ref}: rendered bundle identity is ambiguous across ${exactBundle.length} cards`);
4636
5063
  }
4637
5064
 
4638
- const exactComposition = matchingRef.filter((candidate) => (
5065
+ // A card matches when one of its declared items is the package at that
5066
+ // quantity, however many items it declares. A card declaring that item
5067
+ // alone is preferred over one declaring it among others (a 2x tier over a
5068
+ // 2x-plus-accessory kit); the kit stays reachable by its bundle id.
5069
+ const declaringItem = matchingRef.filter((candidate) => (
4639
5070
  Array.isArray(candidate?.items)
4640
- && candidate.items.length === 1
4641
- && String(candidate.items[0]?.package_id || "") === ref
4642
- && Number(candidate.items[0]?.quantity) === quantity
5071
+ && candidate.items.some((entry) => String(entry?.package_id || "") === ref && Number(entry?.quantity) === quantity)
4643
5072
  ));
5073
+ const declaringOnlyItem = declaringItem.filter((candidate) => candidate.items.length === 1);
5074
+ const exactComposition = declaringOnlyItem.length ? declaringOnlyItem : declaringItem;
4644
5075
  if (exactComposition.length === 1) return exactComposition[0];
4645
5076
  if (exactComposition.length > 1) {
4646
5077
  throw new Error(`--select-package ${ref}:${quantity}: rendered package composition is ambiguous across ${exactComposition.length} cards`);
4647
5078
  }
4648
- const unknownComposition = matchingRef.filter((candidate) => !Array.isArray(candidate?.items));
4649
- if (quantity === 1 && matchingRef.length === 1 && unknownComposition.length === 1) return unknownComposition[0];
5079
+ const unknownComposition = identityMatches.filter((candidate) => !Array.isArray(candidate?.items));
5080
+ if (quantity === 1 && identityMatches.length === 1 && unknownComposition.length === 1) return unknownComposition[0];
4650
5081
  throw new Error(`--select-package ${ref}:${quantity}: no rendered card has exactly package ${ref} at quantity ${quantity}`);
4651
5082
  }
4652
5083
 
@@ -5008,10 +5439,13 @@ async function fillCheckoutFields(page, args, email, options = {}) {
5008
5439
  // Field order, progressive-disclosure calls and optional/onlyVisible flags
5009
5440
  // below are unchanged; only the per-field recording wrapper is new.
5010
5441
  const track = (field, action, fn, opts = {}) => (trace ? trace.inspect(field, action, fn, opts) : fn());
5442
+ // presentOnly (a multi-step checkout page): every field optional and
5443
+ // visible-only, because each page of the path renders only its own.
5444
+ const present = options.presentOnly === true ? { optional: true, onlyVisible: true } : {};
5011
5445
  const fill = (field, value, opts = {}) =>
5012
- track(field, "fill", () => fillByField(page, field, value, { ...opts, actionTimeoutMs }), opts);
5446
+ track(field, "fill", () => fillByField(page, field, value, { ...opts, ...present, actionTimeoutMs }), { ...opts, ...present });
5013
5447
  const select = (field, value, opts = {}) =>
5014
- track(field, "select", () => selectByField(page, field, value, { ...opts, actionTimeoutMs }), opts);
5448
+ track(field, "select", () => selectByField(page, field, value, { ...opts, ...present, actionTimeoutMs }), { ...opts, ...present });
5015
5449
  const address = {
5016
5450
  firstName: stringArg(args["test-first-name"]) || "QA",
5017
5451
  lastName: stringArg(args["test-last-name"]) || "Playwright",
@@ -5092,7 +5526,7 @@ async function revealCheckoutForm(page, options = {}) {
5092
5526
  return true;
5093
5527
  }
5094
5528
 
5095
- async function fillPaymentFields(page, args) {
5529
+ async function fillPaymentFields(page, args, { readyTimeoutMs = CARD_READY_TIMEOUT_MS } = {}) {
5096
5530
  await clickCreditPaymentMethod(page);
5097
5531
  await selectByField(page, "exp-month", stringArg(args["test-exp-month"]) || DEFAULT_TEST_EXP_MONTH);
5098
5532
  await selectYear(page, stringArg(args["test-exp-year"]) || DEFAULT_TEST_EXP_YEAR);
@@ -5100,7 +5534,7 @@ async function fillPaymentFields(page, args) {
5100
5534
  const card = normalizeCard(stringArg(args["test-card"]) || DEFAULT_TEST_CARD);
5101
5535
  const cvv = stringArg(args["test-cvv"]) || DEFAULT_TEST_CVV;
5102
5536
  const frames = await cardFrames(page);
5103
- const readyWaitMs = await waitForCardFieldsReady(page);
5537
+ const readyWaitMs = await waitForCardFieldsReady(page, readyTimeoutMs);
5104
5538
  const numberInput = page.frameLocator(CARD_NUMBER_FRAME).locator("input").first();
5105
5539
  const cvvInput = page.frameLocator(CARD_CVV_FRAME).locator("input").first();
5106
5540
  const typed = async (input) => normalizeCard(await input.inputValue());
@@ -5136,12 +5570,13 @@ async function fillPaymentFields(page, args) {
5136
5570
  // SDK never set it is ready at once. The SDK also clears it when the card
5137
5571
  // script fails to load, so a class still set after the wait means the card
5138
5572
  // script never answered; the step refuses by name rather than type into
5139
- // fields that may drop the number.
5140
- async function waitForCardFieldsReady(page) {
5573
+ // fields that may drop the number. `timeoutMs` is CARD_READY_TIMEOUT_MS in
5574
+ // every production run; only an in-process test passes a shorter wait.
5575
+ async function waitForCardFieldsReady(page, timeoutMs = CARD_READY_TIMEOUT_MS) {
5141
5576
  const started = Date.now();
5142
- await page.waitForFunction(() => !document.querySelector(".next-loading-spreedly"), null, { timeout: CARD_READY_TIMEOUT_MS }).catch((error) => {
5577
+ await page.waitForFunction(() => !document.querySelector(".next-loading-spreedly"), null, { timeout: timeoutMs }).catch((error) => {
5143
5578
  if (error?.name !== "TimeoutError") throw error;
5144
- throw new Error(`card fields did not report ready within ${CARD_READY_TIMEOUT_MS / 1000}s: the checkout form still carries next-loading-spreedly, so the card script never finished loading`);
5579
+ throw new Error(`card fields did not report ready within ${timeoutMs / 1000}s: the checkout form still carries next-loading-spreedly, so the card script never finished loading`);
5145
5580
  });
5146
5581
  return Date.now() - started;
5147
5582
  }
@@ -5426,23 +5861,29 @@ async function clickControl(locator, { timeout, forceFallback = true, perpetual
5426
5861
  // single offer). Clicking the hidden action fails as not visible, so the
5427
5862
  // visible proxy is clicked instead, but only while the offer holds an SDK action
5428
5863
  // for it to forward to: otherwise the hidden action is clicked and fails as before.
5864
+ // The proxy forwards to the action of its own spelling.
5429
5865
  async function shopperUpsellControl(page, action) {
5430
- const actions = page.locator(`[data-next-upsell-action="${action}"]`);
5866
+ const actions = page.locator(upsellActionSelector(action));
5431
5867
  if (!await actions.count().catch(() => 0)) return actions.first();
5432
5868
  const visibleAction = actions.filter({ visible: true }).first();
5433
5869
  if (await visibleAction.count().catch(() => 0)) return visibleAction;
5434
5870
  // The proxy forwards to `[data-next-upsell="offer"] [data-next-upsell-action]`
5435
5871
  // (upsells.js), so only an action inside the offer is a target.
5436
- const inOffer = page.locator(`[data-next-upsell="offer"] [data-next-upsell-action="${action}"]`);
5872
+ const inOffer = page.locator(`[data-next-upsell="offer"] ${upsellActionSelector(action)}`);
5437
5873
  const proxy = page.locator(`[data-upsell-proxy="${action}"]`).filter({ visible: true }).first();
5438
5874
  if (await inOffer.count().catch(() => 0) && await proxy.count().catch(() => 0)) return proxy;
5439
5875
  return actions.first();
5440
5876
  }
5441
5877
 
5442
- async function clickUpsellPath(page, path, { trace = null } = {}) {
5878
+ // `clickTimeoutMs` and `mutationTimeoutMs` are UPSELL_CLICK_TIMEOUT_MS and
5879
+ // UPSELL_MUTATION_TIMEOUT_MS in every production run; only an in-process test
5880
+ // that waits one of them out passes a shorter bound.
5881
+ async function clickUpsellPath(page, path, { trace = null, clickTimeoutMs, mutationTimeoutMs } = {}) {
5882
+ const clickMs = shorterBound(clickTimeoutMs, UPSELL_CLICK_TIMEOUT_MS);
5883
+ const mutationMs = shorterBound(mutationTimeoutMs, UPSELL_MUTATION_TIMEOUT_MS);
5443
5884
  const offerUrl = safePageUrl(page);
5444
- const action = path === "accept" ? "add" : "skip";
5445
- const selector = `[data-next-upsell-action="${action}"]`;
5885
+ const action = await presentUpsellAction(page, path === "accept" ? "accept" : "decline");
5886
+ const selector = upsellActionSelector(action);
5446
5887
  const control = await shopperUpsellControl(page, action);
5447
5888
  if (!await control.count().catch(() => 0)) {
5448
5889
  return { path, clicked: false, error: `Missing upsell control ${selector}` };
@@ -5475,10 +5916,10 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
5475
5916
  if (!root || root.method() !== "POST" || !isOrderUpsellsUrl(root.url())) return false;
5476
5917
  const startedAt = responseRequestStartedAt(response);
5477
5918
  return startedAt === null || startedAt >= armedAt;
5478
- }, { timeout: UPSELL_MUTATION_TIMEOUT_MS + (perpetual ? 0 : UPSELL_CLICK_TIMEOUT_MS) }).catch(() => null)
5919
+ }, { timeout: mutationMs + (perpetual ? 0 : clickMs) }).catch(() => null)
5479
5920
  : Promise.resolve(null);
5480
5921
  trace?.markClickAttempted();
5481
- await clickControl(control, { timeout: UPSELL_CLICK_TIMEOUT_MS, perpetual });
5922
+ await clickControl(control, { timeout: clickMs, perpetual });
5482
5923
  trace?.markClickCompleted();
5483
5924
  const mutationResponse = await mutationPromise;
5484
5925
  const bodyRead = mutationResponse
@@ -5607,14 +6048,37 @@ async function checkoutDisplayEvidence(browserPage) {
5607
6048
  text: clean(row.textContent).slice(0, 160),
5608
6049
  }));
5609
6050
 
5610
- const selectedBundles = Array.from(document.querySelectorAll("[data-next-bundle-card]"))
6051
+ // A bundle card's packages are the ones its data-next-bundle-items
6052
+ // declares, at the per-package quantity the SDK puts in the cart. Its
6053
+ // data-next-bundle-id names the tier, not a package an order line can
6054
+ // carry, so it stands in only for a card that declares no items.
6055
+ const declaredItems = (card) => {
6056
+ try {
6057
+ const parsed = JSON.parse(card.getAttribute("data-next-bundle-items") || "null");
6058
+ if (!Array.isArray(parsed)) return null;
6059
+ const items = parsed.map((entry) => ({
6060
+ package_id: String(entry?.packageId ?? entry?.package_id ?? ""),
6061
+ quantity: Number(entry?.quantity ?? 1),
6062
+ })).filter((entry) => entry.package_id && Number.isFinite(entry.quantity) && entry.quantity > 0);
6063
+ return items.length ? items : null;
6064
+ } catch {
6065
+ return null;
6066
+ }
6067
+ };
6068
+ const selectedBundleCards = Array.from(document.querySelectorAll("[data-next-bundle-card]"))
5611
6069
  .filter((card) => (
5612
6070
  card.classList.contains("next-selected")
5613
6071
  || card.getAttribute("data-next-selected") === "true"
5614
6072
  || card.getAttribute("aria-checked") === "true"
5615
6073
  || card.querySelector('input[type="radio"], input[type="checkbox"]')?.checked === true
5616
- ))
5617
- .map(idOf)
6074
+ ));
6075
+ // A card whose data-next-bundle-items is malformed or empty is kept with
6076
+ // items: null, so reconciliation knows a selected card declared nothing.
6077
+ const selectedBundleItems = selectedBundleCards
6078
+ .filter((card) => card.hasAttribute("data-next-bundle-items"))
6079
+ .map((card) => ({ bundle_id: card.getAttribute("data-next-bundle-id") || null, items: declaredItems(card) }));
6080
+ const selectedBundles = selectedBundleCards
6081
+ .flatMap((card) => declaredItems(card)?.map((item) => item.package_id) || [idOf(card)])
5618
6082
  .filter(Boolean);
5619
6083
 
5620
6084
  const activeToggles = Array.from(document.querySelectorAll("[data-next-package-toggle], [data-next-toggle-card], [data-next-bump]"))
@@ -5642,6 +6106,9 @@ async function checkoutDisplayEvidence(browserPage) {
5642
6106
  summary_present: summaries.length > 0,
5643
6107
  summary_rows: summaryRows.slice(0, 40),
5644
6108
  selected_bundle_package_ids: [...new Set(selectedBundles)],
6109
+ // Uncapped, like selected_bundle_package_ids: every selected card's
6110
+ // items are reconciled.
6111
+ selected_bundle_items: selectedBundleItems,
5645
6112
  active_toggle_package_ids: [...new Set(activeToggles)],
5646
6113
  discount_rows: discountRows.slice(0, 20),
5647
6114
  total_text: totalNode ? clean(totalNode.textContent) : null,
@@ -5650,6 +6117,7 @@ async function checkoutDisplayEvidence(browserPage) {
5650
6117
  summary_present: false,
5651
6118
  summary_rows: [],
5652
6119
  selected_bundle_package_ids: [],
6120
+ selected_bundle_items: [],
5653
6121
  active_toggle_package_ids: [],
5654
6122
  discount_rows: [],
5655
6123
  total_text: null,
@@ -5692,7 +6160,29 @@ function displayedPackageIds(display) {
5692
6160
  };
5693
6161
  }
5694
6162
 
5695
- function reconcileOrderAgainstDisplay({ lines = [], display = null, events = null, selected_packages = [] } = {}) {
6163
+ // The packages and per-package quantities the selected bundle card(s)
6164
+ // declare. A package two selected cards declare at different quantities is
6165
+ // left out of `quantities`: the display itself does not say which one holds.
6166
+ // A card whose items are malformed or empty declares nothing.
6167
+ function selectedBundleDeclarations(display) {
6168
+ const declared = new Map();
6169
+ for (const card of display?.selected_bundle_items || []) {
6170
+ for (const item of card?.items || []) {
6171
+ const ref = String(item?.package_id ?? "");
6172
+ const quantity = Number(item?.quantity);
6173
+ if (!ref || !Number.isFinite(quantity) || quantity <= 0) continue;
6174
+ declared.set(ref, [...new Set([...(declared.get(ref) || []), quantity])]);
6175
+ }
6176
+ }
6177
+ return {
6178
+ refs: new Set(declared.keys()),
6179
+ quantities: [...declared]
6180
+ .filter(([, quantities]) => quantities.length === 1)
6181
+ .map(([packageId, [quantity]]) => ({ packageId, quantity })),
6182
+ };
6183
+ }
6184
+
6185
+ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = null, selected_packages = [], requested_cart = [] } = {}) {
5696
6186
  if (!display) {
5697
6187
  return { comparable: false, reason: "checkout display evidence was not captured for this order" };
5698
6188
  }
@@ -5714,9 +6204,37 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5714
6204
  }
5715
6205
 
5716
6206
  const nonUpsellLines = (lines || []).filter((line) => !line?.is_upsell);
6207
+ // A two-unit bundle card puts its package in the cart at quantity two, so a
6208
+ // line resolves at the quantity the selected card declares; --select-package
6209
+ // still wins for the packages it names.
6210
+ const declarations = selectedBundleDeclarations(display);
6211
+ const strictRefs = new Set((selected_packages || []).map((item) => String(item.packageId)));
6212
+ const resolutionPackages = [
6213
+ ...(selected_packages || []),
6214
+ ...declarations.quantities.filter((item) => !strictRefs.has(item.packageId)),
6215
+ ];
6216
+ // Without --select-package, every explicit --cart <pkg>:<qty> is judged on
6217
+ // its own: the order must carry that package at that quantity, whichever
6218
+ // card, if any, declares it. Beside --select-package the order is judged
6219
+ // against the card that flag chose; a --cart ref it left unapplied is named
6220
+ // by selected_bundle.
6221
+ const requestedCart = (selected_packages || []).length ? [] : (requested_cart || [])
6222
+ .filter((item) => item?.quantityExplicit);
6223
+ const requestedCartByRef = new Map(requestedCart.map((item) => [String(item.packageId), Number(item.quantity || 1)]));
5717
6224
  const displayed = new Set(resolved.displayed_package_ids);
5718
6225
  const summaryIds = new Set(resolved.summary_package_ids);
5719
6226
  const matchedSummaryIds = new Set();
6227
+ // Refs a non-upsell line resolved to. A line resolves to a package only at
6228
+ // the quantity --select-package names or the selected card declares, so
6229
+ // for a declared item this proves the quantity too.
6230
+ const chargedRefs = new Set();
6231
+ // --cart refs a line resolved to, or whose quantity mismatch is named.
6232
+ const answeredCartRefs = new Set();
6233
+ const unresolvedLines = [];
6234
+ const extra = [];
6235
+ const unresolved = [];
6236
+ const matchedQuantities = [];
6237
+ const quantityMismatches = [];
5720
6238
  // A checkout order bump persists with is_upsell: true (the platform's
5721
6239
  // reporting tag), yet the checkout summary displays it. Such a line charges
5722
6240
  // a displayed row; an is_upsell line the summary does not show is a
@@ -5724,23 +6242,24 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5724
6242
  let bumpLineCount = 0;
5725
6243
  for (const line of (lines || []).filter((entry) => entry?.is_upsell)) {
5726
6244
  const resolution = events ? campaignPackageResolutionForLine(events, line, {
5727
- selected_packages,
6245
+ selected_packages: resolutionPackages,
5728
6246
  preferred_refs: resolved.summary_package_ids,
5729
6247
  }) : null;
5730
6248
  const ref = resolution?.pkg?.ref_id == null ? null : String(resolution.pkg.ref_id);
5731
6249
  if (ref && summaryIds.has(ref)) {
5732
6250
  matchedSummaryIds.add(ref);
5733
6251
  bumpLineCount += 1;
6252
+ if (requestedCartByRef.has(ref)) {
6253
+ answeredCartRefs.add(ref);
6254
+ const mismatch = packageQuantityMismatch(ref, Number(resolution.pkg.qty ?? resolution.pkg.quantity), requestedCartByRef.get(ref), Number(line.quantity));
6255
+ if (mismatch) quantityMismatches.push(mismatch);
6256
+ }
5734
6257
  }
5735
6258
  }
5736
- const extra = [];
5737
- const unresolved = [];
5738
- const matchedQuantities = [];
5739
- const quantityMismatches = [];
5740
6259
 
5741
6260
  for (const line of nonUpsellLines) {
5742
6261
  const resolution = events ? campaignPackageResolutionForLine(events, line, {
5743
- selected_packages,
6262
+ selected_packages: resolutionPackages,
5744
6263
  preferred_refs: resolved.displayed_package_ids,
5745
6264
  }) : null;
5746
6265
  const meta = resolution?.pkg || null;
@@ -5750,10 +6269,15 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5750
6269
  // same tolerance linePriceDeltaEvidence applies). An unresolvable line
5751
6270
  // cannot prove a stray charge, so it is reported, never counted as one.
5752
6271
  unresolved.push({ title: line.title, quantity: line.quantity });
5753
- const mismatch = requestedPackageQuantityMismatch(events, line, selected_packages);
5754
- if (mismatch) quantityMismatches.push(mismatch);
6272
+ unresolvedLines.push(line);
6273
+ const mismatch = requestedPackageQuantityMismatch(events, line, [...selected_packages, ...requestedCart]);
6274
+ if (mismatch) {
6275
+ quantityMismatches.push(mismatch);
6276
+ answeredCartRefs.add(mismatch.package_ref_id);
6277
+ }
5755
6278
  continue;
5756
6279
  }
6280
+ chargedRefs.add(ref);
5757
6281
  const unitQuantity = Number(meta.qty ?? meta.quantity);
5758
6282
  matchedQuantities.push({
5759
6283
  package_ref_id: ref,
@@ -5761,6 +6285,13 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5761
6285
  purchase_multiplier: resolution.purchaseMultiplier,
5762
6286
  persisted_quantity: Number(line.quantity),
5763
6287
  });
6288
+ // The line resolved at the quantity the page selected; --cart asked for
6289
+ // another, so the order is not the one requested.
6290
+ if (requestedCartByRef.has(ref)) {
6291
+ answeredCartRefs.add(ref);
6292
+ const mismatch = packageQuantityMismatch(ref, unitQuantity, requestedCartByRef.get(ref), Number(line.quantity));
6293
+ if (mismatch) quantityMismatches.push(mismatch);
6294
+ }
5764
6295
  if (displayed.has(ref)) {
5765
6296
  if (summaryIds.has(ref)) matchedSummaryIds.add(ref);
5766
6297
  continue;
@@ -5772,8 +6303,26 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5772
6303
  price: line.price,
5773
6304
  });
5774
6305
  }
6306
+ // An explicit --cart ref the order carries no line for is not the order
6307
+ // requested either, as when a swap-mode selector kept only the last --cart
6308
+ // ref's card. An unresolved line that is that package at that quantity
6309
+ // still answers it.
6310
+ const packages = latestEventPackages(events);
6311
+ if (packages) {
6312
+ for (const [ref, purchaseMultiplier] of requestedCartByRef) {
6313
+ if (answeredCartRefs.has(ref)) continue;
6314
+ const pkg = packages.find((candidate) => String(candidate?.ref_id ?? candidate?.package_id ?? candidate?.id ?? "") === ref);
6315
+ if (pkg && unresolvedLines.some((line) => packageMatchesLine(pkg, line, { purchaseMultiplier }))) continue;
6316
+ quantityMismatches.push(absentPackageMismatch(ref, Number(pkg?.qty ?? pkg?.quantity), purchaseMultiplier));
6317
+ }
6318
+ }
5775
6319
 
5776
- const missing = [...summaryIds].filter((id) => !matchedSummaryIds.has(id));
6320
+ // Every item a selected bundle card declares must be charged, whether or not
6321
+ // the summary renders a row for it.
6322
+ const missing = [
6323
+ ...[...summaryIds].filter((id) => !matchedSummaryIds.has(id)),
6324
+ ...[...declarations.refs].filter((id) => !summaryIds.has(id) && !chargedRefs.has(id)),
6325
+ ];
5777
6326
  return {
5778
6327
  comparable: true,
5779
6328
  ok: extra.length === 0 && missing.length === 0 && quantityMismatches.length === 0,
@@ -5799,25 +6348,49 @@ function requestedPackageQuantityMismatch(events, line, selectedPackages) {
5799
6348
  const ref = String(selected.packageId);
5800
6349
  const pkg = packages.find((candidate) => String(candidate?.ref_id ?? candidate?.package_id ?? candidate?.id ?? "") === ref);
5801
6350
  if (!pkg || !packageIdentityMatchesLine(pkg, line)) continue;
5802
- const unitQuantity = Number(pkg.qty ?? pkg.quantity);
5803
- const purchaseMultiplier = Number(selected.quantity || 1);
5804
- const requestedQuantity = unitQuantity * purchaseMultiplier;
5805
- const persistedQuantity = Number(line?.quantity || 0);
5806
- if (Number.isFinite(requestedQuantity) && requestedQuantity !== persistedQuantity) {
5807
- return {
5808
- package_ref_id: ref,
5809
- unit_quantity: unitQuantity,
5810
- purchase_multiplier: purchaseMultiplier,
5811
- requested_quantity: requestedQuantity,
5812
- persisted_quantity: persistedQuantity,
5813
- reason: `package ${ref} requested ${requestedQuantity} unit(s) (${unitQuantity} per package × ${purchaseMultiplier}) but persisted ${persistedQuantity}`,
5814
- };
5815
- }
6351
+ const mismatch = packageQuantityMismatch(ref, Number(pkg.qty ?? pkg.quantity), Number(selected.quantity || 1), Number(line?.quantity || 0));
6352
+ if (mismatch) return mismatch;
5816
6353
  }
5817
6354
  }
5818
6355
  return null;
5819
6356
  }
5820
6357
 
6358
+ function packageQuantityMismatch(ref, unitQuantity, purchaseMultiplier, persistedQuantity) {
6359
+ const requestedQuantity = unitQuantity * purchaseMultiplier;
6360
+ if (!Number.isFinite(requestedQuantity) || requestedQuantity === persistedQuantity) return null;
6361
+ return {
6362
+ package_ref_id: ref,
6363
+ unit_quantity: unitQuantity,
6364
+ purchase_multiplier: purchaseMultiplier,
6365
+ requested_quantity: requestedQuantity,
6366
+ persisted_quantity: persistedQuantity,
6367
+ reason: `package ${ref} requested ${requestedQuantity} unit(s) (${unitQuantity} per package × ${purchaseMultiplier}) but persisted ${persistedQuantity}`,
6368
+ };
6369
+ }
6370
+
6371
+ function absentPackageMismatch(ref, unitQuantity, purchaseMultiplier) {
6372
+ const known = Number.isFinite(unitQuantity);
6373
+ return {
6374
+ package_ref_id: ref,
6375
+ unit_quantity: known ? unitQuantity : null,
6376
+ purchase_multiplier: purchaseMultiplier,
6377
+ requested_quantity: known ? unitQuantity * purchaseMultiplier : null,
6378
+ persisted_quantity: 0,
6379
+ reason: known
6380
+ ? `package ${ref} requested ${unitQuantity * purchaseMultiplier} unit(s) (${unitQuantity} per package × ${purchaseMultiplier}) but the order carries no line for it`
6381
+ : `package ${ref} requested at quantity ${purchaseMultiplier} but the order carries no line for it`,
6382
+ };
6383
+ }
6384
+
6385
+ function latestEventPackages(events) {
6386
+ const responses = events?.responses || [];
6387
+ for (let index = responses.length - 1; index >= 0; index -= 1) {
6388
+ const packages = responses[index]?.body?.packages;
6389
+ if (Array.isArray(packages)) return packages;
6390
+ }
6391
+ return null;
6392
+ }
6393
+
5821
6394
  function packageIdentityMatchesLine(pkg, line) {
5822
6395
  if (pkg?.product_sku && line?.sku) return normalizeLabel(pkg.product_sku) === normalizeLabel(line.sku);
5823
6396
  if (pkg?.product_variant_id != null && line?.variant_id != null) return Number(pkg.product_variant_id) === Number(line.variant_id);
@@ -5861,6 +6434,9 @@ function orderDisplayParityAssertion(page, planIdentifier, order) {
5861
6434
  if (reconciliation.missing.length) {
5862
6435
  parts.push(`displayed but never charged: ${reconciliation.missing.join(", ")}`);
5863
6436
  }
6437
+ if (reconciliation.quantity_mismatches?.length) {
6438
+ parts.push(`ordered quantity differs from the request: ${reconciliation.quantity_mismatches.map((entry) => entry.reason).join("; ")}`);
6439
+ }
5864
6440
  return assertion({
5865
6441
  ...base,
5866
6442
  status: STATUS.FAIL,
@@ -7035,7 +7611,7 @@ function testOrderPaths(mode, topologies = [], args = {}) {
7035
7611
  }
7036
7612
 
7037
7613
  function resolvePrimaryTestOrderTopology(topologies = []) {
7038
- const checkout = findPage(topologies, "checkout");
7614
+ const checkout = findPaymentPage(topologies);
7039
7615
  const topology = (topologies || []).find((candidate) => (candidate?.pages || []).includes(checkout)) || { pages: [] };
7040
7616
  return resolveTestOrderTopology(topology, checkout);
7041
7617
  }
@@ -7062,7 +7638,7 @@ function testOrderPlans(mode, topologies = [], args = {}, options = {}) {
7062
7638
  if (tiersMatch) return specTierPlans(topologies, args, tiersMatch[1] || "checkout", options);
7063
7639
  const selectPackage = stringArg(args["select-package"]);
7064
7640
  const applyCoupon = stringArg(args["apply-coupon"]);
7065
- const checkoutPage = findPage(topologies, "checkout");
7641
+ const checkoutPage = findPaymentPage(topologies);
7066
7642
  const topologyPlan = resolvePrimaryTestOrderTopology(topologies);
7067
7643
  return testOrderPaths(mode, topologies, args).map((path) => ({
7068
7644
  path,
@@ -7093,24 +7669,31 @@ function specTierPlans(topologies, args, variant, { warn = (line) => process.std
7093
7669
  // fail for the wrong reason. A later funnel's checkout with declarations
7094
7670
  // but no resolvable URL cannot be driven; it is warned about, not dropped
7095
7671
  // silently.
7096
- const primary = findPage(topologies, "checkout");
7672
+ const primary = findPaymentPage(topologies);
7097
7673
  if (!primary) {
7098
7674
  throw new Error("--test-order tiers requires a CampaignSpec-driven run with a checkout page; this spec/topology has no checkout page to derive tiers or coupons from (non-packet --site runs have none by design).");
7099
7675
  }
7100
7676
  const plans = [];
7101
- for (const topology of Array.isArray(topologies) ? topologies : []) {
7102
- const pages = Array.isArray(topology?.pages) ? topology.pages : [];
7103
- const checkoutPage = pages.find((page) => String(page?.page_type || "").toLowerCase() === "checkout");
7104
- if (!checkoutPage) continue;
7105
- const declaredTiers = declaredSelectorTiers(checkoutPage);
7106
- const bumps = declaredOrderBumps(checkoutPage);
7677
+ // Every Checkout of every funnel: split-test paths in one funnel each reach
7678
+ // their own (#641). A multi-step path declares its cart on the select page or
7679
+ // first step, not on the Checkout, so tiers and bumps are read from every
7680
+ // page of the path; coupons from the pages the runner fills (steps and the
7681
+ // Checkout), since a select page is left before any form is filled.
7682
+ const paymentPages = (Array.isArray(topologies) ? topologies : [])
7683
+ .flatMap((topology) => paymentPagesForTopology(topology).map((checkoutPage) => ({ topology, checkoutPage })));
7684
+ for (const { topology, checkoutPage } of paymentPages) {
7685
+ const pathPages = checkoutPathPages(topology, checkoutPage);
7686
+ const declaredTiers = uniqueTiers(pathPages.flatMap((page) => declaredSelectorTiers(page)));
7687
+ const bumps = [...new Set(pathPages.flatMap((page) => declaredOrderBumps(page)))];
7107
7688
  if (bumps.length) {
7108
7689
  warn(`[qa:test-order] checkout page "${checkoutPage.page_id || checkoutPage.label || "(unnamed)"}" declares order bump package(s) ${bumps.join(", ")} (is_order_bump or is_upsell) — not planned as selector tiers; bump coverage comes from --cart.`);
7109
7690
  }
7110
7691
  for (const tier of declaredTiers) declaredIdentities.add(selectorTierIdentity(tier));
7111
7692
  for (const ref of bumps) bumpRefs.add(ref);
7112
7693
  const tiers = narrowTo ? declaredTiers.filter((tier) => narrowTo.has(selectorTierIdentity(tier))) : declaredTiers;
7113
- const coupons = declaredCheckoutCoupons(checkoutPage);
7694
+ const coupons = mergeDeclaredCoupons(pathPages
7695
+ .filter((page) => page === checkoutPage || isCheckoutStepTopologyPage(page))
7696
+ .map((page) => declaredCheckoutCoupons(page)));
7114
7697
  if (!tiers.length && !coupons.length) continue;
7115
7698
  if (checkoutPage !== primary && !checkoutPage.url) {
7116
7699
  if (narrowTo && tiers.length) {
@@ -7232,6 +7815,28 @@ function selectorTierNarrowing(value) {
7232
7815
  // Declared coupons follow the repo's offer-surface rule (build-brief, cli
7233
7816
  // exit-pop gates): a surface counts only when `enabled === true` and it maps
7234
7817
  // an offer_code. Both surfaces mapping the same code collapse into one plan.
7818
+ // Tiers read from several pages of one path, first declaration kept.
7819
+ function uniqueTiers(tiers) {
7820
+ const seen = new Set();
7821
+ return tiers.filter((tier) => {
7822
+ const identity = selectorTierIdentity(tier);
7823
+ if (seen.has(identity)) return false;
7824
+ seen.add(identity);
7825
+ return true;
7826
+ });
7827
+ }
7828
+
7829
+ // Coupon lists from several pages of one path, merged by code.
7830
+ function mergeDeclaredCoupons(lists) {
7831
+ const merged = [];
7832
+ for (const coupon of lists.flat()) {
7833
+ const existing = merged.find((entry) => normalizeLabel(entry.code) === normalizeLabel(coupon.code));
7834
+ if (!existing) merged.push({ code: coupon.code, surfaces: [...coupon.surfaces] });
7835
+ else for (const surface of coupon.surfaces) if (!existing.surfaces.includes(surface)) existing.surfaces.push(surface);
7836
+ }
7837
+ return merged;
7838
+ }
7839
+
7235
7840
  function declaredCheckoutCoupons(checkoutPage) {
7236
7841
  const coupons = [];
7237
7842
  const add = (surface, block) => {
@@ -7561,7 +8166,7 @@ function describeCoverageDoubt(doubt) {
7561
8166
 
7562
8167
  // Page types that never carry an upsell action. Any other type, or none, does
7563
8168
  // not say either way.
7564
- const NON_OFFER_PAGE_TYPES = new Set(["presell", "landing", "select", "product", "checkout", "thankyou", "receipt"]);
8169
+ const NON_OFFER_PAGE_TYPES = new Set(["presell", "landing", "select", "product", "checkout_step", "checkout", "thankyou", "receipt"]);
7565
8170
  const UPSELL_COVERAGE_UNASSESSABLE = Object.freeze({
7566
8171
  no_url: "no URL",
7567
8172
  unresolvable_url: "URL is not an absolute http(s) address",
@@ -7584,7 +8189,7 @@ export function upsellActionCoverageWithoutOrders(topologies = []) {
7584
8189
  return upsellActionCoverageAssertion({
7585
8190
  topologies,
7586
8191
  orders: [],
7587
- checkoutPage: findPage(topologies, "checkout"),
8192
+ checkoutPage: findPaymentPage(topologies),
7588
8193
  testOrdersOff: true,
7589
8194
  });
7590
8195
  }
@@ -7667,7 +8272,7 @@ function extractReceiptLines(order) {
7667
8272
 
7668
8273
  async function waitForUpsellPageReady(page, args) {
7669
8274
  const timeoutMs = numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS);
7670
- await page.locator('[data-next-upsell], [data-next-upsell-action="add"], [data-next-upsell-action="skip"]').first()
8275
+ await page.locator(`[data-next-upsell], ${upsellActionSelector(...UPSELL_ACTION_SPELLINGS.accept, ...UPSELL_ACTION_SPELLINGS.decline)}`).first()
7671
8276
  .waitFor({ state: "visible", timeout: timeoutMs })
7672
8277
  .catch(() => {});
7673
8278
  await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
@@ -8047,14 +8652,6 @@ function persistedUpsellStep(step) {
8047
8652
  return projected;
8048
8653
  }
8049
8654
 
8050
- function findPage(topologies, type) {
8051
- for (const topology of topologies || []) {
8052
- const page = (topology.pages || []).find((candidate) => candidate.page_type === type);
8053
- if (page) return page;
8054
- }
8055
- return null;
8056
- }
8057
-
8058
8655
  function parseCart(value) {
8059
8656
  if (!value) return [];
8060
8657
  return String(value).split(",").map((part) => {
@@ -8117,6 +8714,26 @@ function numberArg(value, fallback) {
8117
8714
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
8118
8715
  }
8119
8716
 
8717
+ // A bound an in-process test seam passes, as the Polish deadlines take one
8718
+ // (boundedPolishDeadline): `value` when it is a positive safe integer no
8719
+ // larger than the production `fallback`, else `fallback`. A seam can shorten
8720
+ // a production wait, never lengthen it or switch it off (Playwright reads a
8721
+ // 0 timeout as none).
8722
+ const shorterBound = boundedPolishDeadline;
8723
+
8724
+ // The content parameter leg's limits with options.contentParamLimits (an
8725
+ // in-process test seam, never set from argv) applied field by field, each
8726
+ // only where it is no larger than CONTENT_PARAM_LIMITS: a time bound is a
8727
+ // shorterBound, and maxPairs may also be zero, as runContentParamChecks
8728
+ // allows.
8729
+ function contentParamLimitsFrom(overrides) {
8730
+ return Object.fromEntries(Object.entries(CONTENT_PARAM_LIMITS).map(([field, production]) => {
8731
+ const value = overrides?.[field];
8732
+ if (field !== "maxPairs") return [field, shorterBound(value, production)];
8733
+ return [field, Number.isSafeInteger(value) && value >= 0 && value <= production ? value : production];
8734
+ }));
8735
+ }
8736
+
8120
8737
  function trim(value) {
8121
8738
  return String(value || "").replace(/\s+/g, " ").trim();
8122
8739
  }
@@ -8158,9 +8775,12 @@ export const __qaBrowserTestHooks = Object.freeze({
8158
8775
  primaryCtaAssertionFromEvidence,
8159
8776
  inspectPrimaryCta,
8160
8777
  primaryCtaInspectionScript,
8778
+ primaryCtaDeclaredRoutes,
8779
+ primaryCtaRoutelessForms,
8161
8780
  clickCouponApplyControl,
8162
8781
  isOrderUpsellsUrl,
8163
8782
  clickUpsellPath,
8783
+ waitForUpsellPageReady,
8164
8784
  isPerpetuallyAnimated,
8165
8785
  readJsonResponseBody,
8166
8786
  readJsonResponseBodyWithin,
@@ -8219,6 +8839,7 @@ export const __qaBrowserTestHooks = Object.freeze({
8219
8839
  redactUrlQuery,
8220
8840
  enterCartViaLanding,
8221
8841
  openCheckoutForPath,
8842
+ pathCurrencyLoad,
8222
8843
  createSelectorProbeCache,
8223
8844
  RESIDUE_PAGE_TYPES,
8224
8845
  computedStyleResidueAssertions,