@nextcommerce/campaigns-os 1.55.0 → 1.59.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 (100) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/CONTEXT.md +11 -0
  3. package/campaign-spec/README.md +15 -1
  4. package/campaign-spec/dist/checkout-flow.d.ts +97 -0
  5. package/campaign-spec/dist/checkout-flow.js +245 -0
  6. package/campaign-spec/dist/index.d.ts +1 -0
  7. package/campaign-spec/dist/index.js +6 -1
  8. package/campaign-spec/dist/normalize.d.ts +3 -2
  9. package/campaign-spec/dist/normalize.js +7 -5
  10. package/campaign-spec/dist/routing.d.ts +6 -3
  11. package/campaign-spec/dist/routing.js +6 -3
  12. package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
  13. package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
  14. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
  15. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
  16. package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
  17. package/campaign-spec/dist/rules/index.d.ts +4 -0
  18. package/campaign-spec/dist/rules/index.js +12 -0
  19. package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
  20. package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
  21. package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
  22. package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
  23. package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
  24. package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
  25. package/campaign-spec/dist/rules/schema-version.js +10 -5
  26. package/campaign-spec/dist/types.d.ts +20 -3
  27. package/campaign-spec/dist/types.js +3 -2
  28. package/compatibility.json +2 -2
  29. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  30. package/contracts/commerce-surface-catalog.json +26 -27
  31. package/contracts/effects.v1.json +5 -5
  32. package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
  33. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  35. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  38. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  39. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  43. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  45. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  46. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  47. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  48. package/contracts/release-ledger.json +1137 -0
  49. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  50. package/contracts/supported-surface.json +9 -5
  51. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  52. package/docs/build-packet.md +68 -10
  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 +124 -22
  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/package.json +2 -1
  61. package/schemas/campaign-runtime-build-context.v0.schema.json +4 -1
  62. package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -1
  63. package/schemas/campaign-spec.v5.schema.json +586 -0
  64. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  65. package/skills/campaign-readback-classification/SKILL.md +3 -3
  66. package/skills/campaign-run-evidence/SKILL.md +3 -3
  67. package/skills/contribution-intake/SKILL.md +3 -3
  68. package/skills/next-campaigns-build/SKILL.md +6 -4
  69. package/skills/next-campaigns-os/SKILL.md +3 -3
  70. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  71. package/skills/next-campaigns-polish/SKILL.md +21 -6
  72. package/skills/next-campaigns-qa/SKILL.md +4 -3
  73. package/skills.json +10 -10
  74. package/src/adapter-decision-contract.mjs +1 -1
  75. package/src/build-brief.mjs +14 -5
  76. package/src/built-site-scope.mjs +91 -0
  77. package/src/cli-helpers.mjs +129 -0
  78. package/src/cli.mjs +117 -57
  79. package/src/commercial-journey.mjs +25 -2
  80. package/src/design-source-package.mjs +1 -1
  81. package/src/design-source-publication.mjs +7 -7
  82. package/src/doctor/checks.mjs +269 -16
  83. package/src/doctor/inspect.mjs +4 -2
  84. package/src/doctor/next-step.mjs +18 -9
  85. package/src/doctor-sidecar.mjs +5 -3
  86. package/src/gate-actions.mjs +21 -1
  87. package/src/input-refresh.mjs +6 -6
  88. package/src/local-preview-policy.mjs +110 -12
  89. package/src/polish-node.mjs +8 -7
  90. package/src/qa-browser.mjs +363 -36
  91. package/src/qa-cart-entry.mjs +183 -14
  92. package/src/qa-node.mjs +38 -34
  93. package/src/qa-test-order-topology.mjs +118 -1
  94. package/src/qa-verdict-discovery.mjs +21 -4
  95. package/src/qa-verdict.mjs +24 -1
  96. package/src/runtime-state-ignore.mjs +7 -5
  97. package/src/sdk-attribute-index.mjs +8 -7
  98. package/src/source-html-intake.mjs +58 -7
  99. package/src/stage-ledger.mjs +4 -2
  100. package/src/stage-record.mjs +203 -36
@@ -25,13 +25,20 @@ import {
25
25
  commonTestOrderPlan,
26
26
  fullTestOrderPaths,
27
27
  OFFER_PAGE_TYPES,
28
+ checkoutPathPages,
29
+ checkoutStepPages,
30
+ findPaymentPage,
31
+ isCheckoutStepTopologyPage,
28
32
  pageAtUrl,
33
+ paymentPagesForTopology,
29
34
  remainingActionDisposition,
30
35
  resolveTestOrderTopology,
31
36
  terminalAtUrl,
32
37
  } from "./qa-test-order-topology.mjs";
33
38
  import {
34
39
  CART_ENTRY_CODES,
40
+ CART_ENTRY_BUNDLE_CARD_SELECTOR,
41
+ CART_ENTRY_CHECKOUT_BUTTON_SELECTOR,
35
42
  CART_ENTRY_CONTROL_SELECTOR,
36
43
  cartEntryHrefFor,
37
44
  CART_ENTRY_ROUTE_ATTRIBUTE,
@@ -45,6 +52,13 @@ import {
45
52
  isCartEntryCode,
46
53
  resolveCartEntryPage,
47
54
  sdkCartSnapshotScript,
55
+ SLOT_VARIANT_DROPDOWN_ROW_SELECTOR,
56
+ SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR,
57
+ SLOT_VARIANT_FIELD_SELECTOR,
58
+ SLOT_VARIANT_SELECT_SELECTOR,
59
+ slotVariantKey,
60
+ slotVariantLabel,
61
+ slotVariantStepScript,
48
62
  summarizeSelectionSurface,
49
63
  UNDECLARED_ROUTE_ATTRIBUTES,
50
64
  } from "./qa-cart-entry.mjs";
@@ -191,7 +205,9 @@ export async function runBrowserChecks(topologies, args = {}, options = {}) {
191
205
  }
192
206
 
193
207
  export async function runBrowserTestOrders(topologies, args = {}, runId = "local", options = {}) {
194
- const checkoutPage = findPage(topologies, "checkout");
208
+ // The Checkout (the page that takes payment) on the first entry path, never
209
+ // the first page typed checkout: on a split checkout that is step 1 (#641).
210
+ const checkoutPage = findPaymentPage(topologies);
195
211
  if (!checkoutPage?.url) {
196
212
  const coverage = upsellActionCoverageAssertion({ topologies, orders: [], checkoutPage });
197
213
  return {
@@ -295,7 +311,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
295
311
  const identifier = planId(plan);
296
312
  const pageForPlan = (typeof plan === "object" && plan?.checkout_page?.url) ? plan.checkout_page : checkoutPage;
297
313
  const firstAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
298
- orders.push(firstAttempt.order);
314
+ orders.push(testOrderEntry(firstAttempt.order, identifier, 1));
299
315
  orderPlans.push(plan);
300
316
  // Every attempt this path actually SUBMITTED, in order. The confirmed
301
317
  // creation count is read from these and never from the deciding result
@@ -355,7 +371,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
355
371
  const rerunAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
356
372
  attemptsForPlan.push(rerunAttempt);
357
373
  if (rerunAttempt.order) {
358
- orders.push(rerunAttempt.order);
374
+ orders.push(testOrderEntry(rerunAttempt.order, identifier, 2));
359
375
  orderPlans.push(plan);
360
376
  }
361
377
  if (rerunAttempt.budget_exhausted) {
@@ -491,6 +507,17 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
491
507
  return { orders, assertions, receiptAnalytics, journeyAnalytics, creationBudget, qcResults };
492
508
  }
493
509
 
510
+ // One test_orders[] entry per attempt, never one per planned path: a re-run
511
+ // places its own real order and the operator cleaning up test orders needs both.
512
+ // So each entry names the planned path it belongs to (`plan_id`, the same id the
513
+ // creation budget charges) and which attempt it was, 1-based, so a reader can
514
+ // tell 5 paths with one retry each from 10 paths (#661). A copy, so the attempt
515
+ // result the assertions read is left as the runner returned it.
516
+ function testOrderEntry(order, identifier, attempt) {
517
+ if (!order || typeof order !== "object") return order;
518
+ return { ...order, plan_id: order.plan_id ?? identifier, attempt };
519
+ }
520
+
494
521
  // Analytics-parity leg: capture the live dataLayer event stream + GTM/pixel
495
522
  // tag-fires on a baseline (legacy) URL and a candidate (migrated) URL, then
496
523
  // diff them into parity assertions. Highest-value target is the thank-you /
@@ -1090,6 +1117,10 @@ async function runPageBrowserChecks(context, page, args, options = {}, policyLin
1090
1117
  }
1091
1118
  if (page.page_type === "checkout") {
1092
1119
  assertions.push(...await checkoutPaymentSurfaceAssertions(browserPage, page));
1120
+ }
1121
+ // Exit-intent and promo-code surfaces may sit on any checkout-flow page
1122
+ // (#641); each assertion runs only where the spec declares the surface.
1123
+ if (["select", "checkout_step", "checkout"].includes(page.page_type)) {
1093
1124
  assertions.push(...await checkoutOfferSurfaceAssertions(browserPage, page));
1094
1125
  }
1095
1126
  assertions.push(...await templateResidueAssertions(browserPage, page, options));
@@ -1330,7 +1361,7 @@ function primaryCtaCheckEligible(page) {
1330
1361
  // where the page declares, not where they point.
1331
1362
  function primaryCtaPageRoutesSdkControls(page) {
1332
1363
  const pageType = String(page?.page_type || "").toLowerCase();
1333
- return pageType === "checkout" || OFFER_PAGE_TYPES.has(pageType);
1364
+ return pageType === "checkout" || pageType === "checkout_step" || OFFER_PAGE_TYPES.has(pageType);
1334
1365
  }
1335
1366
 
1336
1367
  // The forms whose action is no route on this page, handed to the route rule
@@ -1357,7 +1388,9 @@ function primaryCtaRoutelessForms(page) {
1357
1388
  // container, the only place the SDK binds it.
1358
1389
  function primaryCtaDeclaredRoutes(page) {
1359
1390
  if (!primaryCtaPageRoutesSdkControls(page)) return [];
1360
- if (String(page.page_type).toLowerCase() === "checkout") {
1391
+ // A checkout_step's form submits to its declared next step the same way the
1392
+ // Checkout's submits to its success route: the SDK navigates, not the form.
1393
+ if (["checkout", "checkout_step"].includes(String(page.page_type).toLowerCase())) {
1361
1394
  const notWallet = ":not([data-next-express-checkout]):not([data-next-express-checkout] *)";
1362
1395
  const submit = ['button[type="submit"]', "button:not([type])", 'input[type="submit"]']
1363
1396
  .map((control) => `${CHECKOUT_FORM_SELECTOR} ${control}${notWallet}`)
@@ -2397,7 +2430,10 @@ function promoCodeSurfaceAssertion({ page, declaration, evidence }) {
2397
2430
 
2398
2431
  function contractPageType(page) {
2399
2432
  const type = String(page?.page_type || "").toLowerCase();
2400
- return type === "thankyou" ? "receipt" : type;
2433
+ if (type === "thankyou") return "receipt";
2434
+ // A checkout step renders the family's checkout surface without taking
2435
+ // payment (campaigns-os#641); the brand contract keys that surface "checkout".
2436
+ return type === "checkout_step" ? "checkout" : type;
2401
2437
  }
2402
2438
 
2403
2439
  // The palette-residue rows (style, logo, payment chrome) take their severity
@@ -3707,7 +3743,12 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
3707
3743
  let orderDeadline = null;
3708
3744
  let events = { requests: [], responses: [], failed: [], console: [], pageErrors: [] };
3709
3745
  const email = testEmail(planArgs);
3710
- const entryPage = resolveCartEntryPage(options.topologies, checkoutPage);
3746
+ // The checkout path this order walks (campaigns-os#641): any checkout_step
3747
+ // pages before the Checkout. The cart is entered into the first form page
3748
+ // of the path, and the step pages are submitted in order before payment.
3749
+ const stepPages = checkoutStepPagesFor(options.topologies, checkoutPage);
3750
+ const formEntryPage = stepPages[0] || checkoutPage;
3751
+ const entryPage = resolvePathEntryPage(options.topologies, formEntryPage, stepPages);
3711
3752
  let tracking = null;
3712
3753
  try {
3713
3754
  tracking = options.tracking ? options.tracking.observe(planId(normalizedPlan)) : null;
@@ -3828,6 +3869,7 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
3828
3869
  ladder,
3829
3870
  checkoutPage,
3830
3871
  entryPage,
3872
+ stepPages,
3831
3873
  topologyPlan: normalizedPlan.topology_plan,
3832
3874
  path,
3833
3875
  args: planArgs,
@@ -3916,10 +3958,13 @@ function stablePrivateCaptureError(value) {
3916
3958
  return projectAnalyticsCaptureError(value, { fallbackKind: "unreadable" });
3917
3959
  }
3918
3960
 
3919
- async function executeTestOrderPath({ page, events, email, ladder, checkoutPage, entryPage = null, topologyPlan, path, args, deadline, reserveOrderCreation = null, selectorProbeCache = null, tracking = null, cardReadyTimeoutMs = CARD_READY_TIMEOUT_MS }) {
3961
+ 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 }) {
3920
3962
  const stepTimeoutMs = numberArg(args["step-timeout-ms"], DEFAULT_STEP_TIMEOUT_MS);
3921
3963
  const budget = () => Math.min(stepTimeoutMs, deadline - Date.now());
3922
3964
  const hostedNow = () => hostedRedirectInfo(safePageUrl(page), checkoutPage.url);
3965
+ // The page the cart enters and the form filling starts on: the first
3966
+ // checkout_step of a multi-step path, else the Checkout itself.
3967
+ const formEntryPage = stepPages[0] || checkoutPage;
3923
3968
  const selectedPackages = parseCart(args["select-package"]);
3924
3969
  const currencyLoad = pathCurrencyLoad(args);
3925
3970
  let checkoutDisplay = null;
@@ -3937,7 +3982,8 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3937
3982
  // page the probe left there rather than loading the same URL a second time,
3938
3983
  // which would fire the SDK's page-view events twice into the same capture.
3939
3984
  const entry = await ladder.run(CART_ENTRY_STEP, () => enterCartViaLanding({
3940
- page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking, currencyLoad,
3985
+ page, checkoutPage: formEntryPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking, currencyLoad,
3986
+ startsOnCheckoutStep: formEntryPage !== checkoutPage,
3941
3987
  }), { timeoutMs: budget() });
3942
3988
  const enteredViaLanding = Boolean(entry && typeof entry === "object" && entry.entered);
3943
3989
  // Responses captured from here on belong to the checkout the ladder drives.
@@ -3945,7 +3991,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3945
3991
  // made before the hand-off.
3946
3992
  const checkoutResponseOffset = events.responses.length;
3947
3993
 
3948
- await ladder.run("opened_checkout", () => openCheckoutForPath({ page, checkoutPage, entry, args, tracking, currencyLoad }), { timeoutMs: budget() });
3994
+ await ladder.run("opened_checkout", () => openCheckoutForPath({ page, checkoutPage: formEntryPage, entry, args, tracking, currencyLoad }), { timeoutMs: budget() });
3949
3995
  await ladder.run("selected_bundle", async () => {
3950
3996
  // The requested package was selected on the entry page, where the cards
3951
3997
  // live; checkout renders none, so re-running strict selection here would
@@ -3969,8 +4015,20 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3969
4015
  const fieldTrace = createFieldTrace();
3970
4016
  await ladder.run("customer_fields_filled", async () => {
3971
4017
  ensurePageFillable(page, checkoutPage.url);
3972
- await fillCheckoutFields(page, args, email, { trace: fieldTrace, actionTimeoutMs: budget() });
4018
+ // On a multi-step path each page renders only its own fields, so every
4019
+ // field is filled where it is present and skipped where it is not. The
4020
+ // SDK's step validation refuses to advance when one is missing, which
4021
+ // the step rung below reports by name.
4022
+ await fillCheckoutFields(page, args, email, { trace: fieldTrace, actionTimeoutMs: budget(), presentOnly: stepPages.length > 0 });
3973
4023
  }, { timeoutMs: budget(), evidence: () => fieldTrace.summary() });
4024
+ // Walk the path: submit each checkout_step and assert it lands on the
4025
+ // step's declared next page, then fill what the arrived page renders. No
4026
+ // order is placed until the Checkout (campaigns-os#641).
4027
+ for (const [index, step] of stepPages.entries()) {
4028
+ await ladder.run("checkout_step_submitted", () => submitCheckoutStepAndArrive({
4029
+ page, step, index, args, email, budget, checkoutUrl: checkoutPage.url, tracking,
4030
+ }), { timeoutMs: budget() });
4031
+ }
3974
4032
  await ladder.run("coupon_applied", async () => {
3975
4033
  const code = stringArg(args["apply-coupon"]);
3976
4034
  if (!code) return { skip: "no --apply-coupon code requested" };
@@ -4248,7 +4306,7 @@ async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking =
4248
4306
  // The entry step body. Resolves to `{ skip }` when the checkout selects for
4249
4307
  // itself, to `{ entered: true, ... }` when the runner came in through the entry
4250
4308
  // page, and throws a coded error (never a bare timeout) when it cannot.
4251
- async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache = null, tracking = null, currencyLoad = null }) {
4309
+ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache = null, tracking = null, currencyLoad = null, startsOnCheckoutStep = false }) {
4252
4310
  // Probe the checkout first: whether it carries a selection surface is a fact
4253
4311
  // about the rendered page, not about the spec (the spec cannot say it yet —
4254
4312
  // that is the design half of #206). The probe's load is the checkout's only
@@ -4284,6 +4342,15 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4284
4342
  evidence: { checkout_selection_surface: surface, ...probeEvidence },
4285
4343
  };
4286
4344
  }
4345
+ if (!entryPage?.url && startsOnCheckoutStep) {
4346
+ // A path that begins on its first checkout_step has no page before it to
4347
+ // fill the cart from; the step page fills it (the family's main package).
4348
+ // The empty-cart guard before submit still refuses an empty cart by name.
4349
+ return {
4350
+ 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",
4351
+ evidence: { checkout_selection_surface: surface, ...probeEvidence },
4352
+ };
4353
+ }
4287
4354
  if (!entryPage?.url) {
4288
4355
  throw codedError(
4289
4356
  CART_ENTRY_CODES.ENTRY_UNRESOLVED,
@@ -4294,15 +4361,52 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4294
4361
  await gotoAndSettle(page, entryPage.url, args, tracking, currencyLoad);
4295
4362
  const sdkReady = await waitForSdkReady(page, Math.min(budget(), DEFAULT_SETTLE_TIMEOUT_MS));
4296
4363
  await tracking?.readDocument(page);
4297
- const controls = await page.evaluate(cartEntryControlsScript(), { selector: CART_ENTRY_CONTROL_SELECTOR, checkoutUrl: checkoutPage.url }).catch(() => []);
4364
+ const readControls = () => page.evaluate(cartEntryControlsScript(), {
4365
+ selector: CART_ENTRY_CONTROL_SELECTOR,
4366
+ checkoutUrl: checkoutPage.url,
4367
+ checkoutButtonSelector: CART_ENTRY_CHECKOUT_BUTTON_SELECTOR,
4368
+ bundleCardSelector: CART_ENTRY_BUNDLE_CARD_SELECTOR,
4369
+ }).catch(() => []);
4370
+ let controls = await readControls();
4371
+ // A two-step select page can hold its checkout button in a second step that
4372
+ // a variant control reveals (olympus-mv-two-step: "Select Color & Size").
4373
+ // When no checkout button is visible yet, click that control once and read
4374
+ // the controls again, so a hidden decoy link is never chosen over the
4375
+ // button the reveal shows.
4376
+ const checkoutButtons = controls.filter((entry) => entry.kind === "checkout_button");
4377
+ if (checkoutButtons.length && !checkoutButtons.some((entry) => entry.visible)) {
4378
+ const reveal = page.locator('[data-next-action="select-variants"]:visible').first();
4379
+ if (await reveal.count().catch(() => 0)) {
4380
+ await clickControl(reveal, { timeout: 5000 }).catch(() => {});
4381
+ await page.locator(CART_ENTRY_CHECKOUT_BUTTON_SELECTOR).filter({ visible: true }).first()
4382
+ .waitFor({ state: "visible", timeout: Math.max(1000, Math.min(budget(), 8000)) }).catch(() => {});
4383
+ controls = await readControls();
4384
+ }
4385
+ }
4298
4386
  const choice = chooseCartEntryControl(controls, selectedPackages);
4299
4387
  if (!choice.control) {
4300
4388
  throw codedError(CART_ENTRY_CODES.ENTRY_CONTROL_MISSING, `${choice.reason} (entry page ${redactUrlQuery(entryPage.url)})`);
4301
4389
  }
4302
4390
  const control = choice.control;
4391
+ // A select page (#641): the bundle card is the cart. --select-package clicks
4392
+ // the matching data-next-bundle-card first, strictly, as on a checkout that
4393
+ // selects for itself; otherwise the page's pre-selected card stands.
4394
+ let cardSelection = null;
4395
+ if (control.kind === "checkout_button") {
4396
+ cardSelection = choice.select_card ? await selectPackageCard(page, choice.select_card, []) : null;
4397
+ control.package_id = choice.select_card?.packageId || await selectedBundleCardPackage(page);
4398
+ }
4399
+ // Each bundle slot may need a variant (size, colour) before the page lets
4400
+ // Next through (campaigns-os#667). Fill every visible empty slot variant
4401
+ // select with its first in-stock option now, after the card that decides
4402
+ // the slots, and refuse by name when one has nothing to choose.
4403
+ const variantSelections = control.kind === "checkout_button" ? await fillSlotVariants(page) : [];
4303
4404
  // The index is the control's position among what its own locator matches,
4304
4405
  // so it replays with nth(); no selector is rebuilt from an attribute value.
4305
- const target = page.locator(control.kind === "add_to_cart" ? CART_ENTRY_CONTROL_SELECTOR : "a[href]").nth(control.index);
4406
+ const controlSelector = control.kind === "add_to_cart"
4407
+ ? CART_ENTRY_CONTROL_SELECTOR
4408
+ : control.kind === "checkout_button" ? CART_ENTRY_CHECKOUT_BUTTON_SELECTOR : "a[href]";
4409
+ const target = page.locator(controlSelector).nth(control.index);
4306
4410
  const perpetual = await scrollControlIntoView(target);
4307
4411
  await clickControl(target, { timeout: 8000, perpetual });
4308
4412
 
@@ -4332,6 +4436,8 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4332
4436
  control_text: control.text || null,
4333
4437
  control_kind: control.kind,
4334
4438
  package_id: control.package_id || null,
4439
+ ...(cardSelection ? { bundle_card_selection: cardSelection } : {}),
4440
+ ...(variantSelections.length ? { variant_selections: variantSelections } : {}),
4335
4441
  sdk_ready: sdkReady,
4336
4442
  arrived_url: redactUrlQuery(safePageUrl(page)),
4337
4443
  checkout_selection_surface: surface,
@@ -4340,6 +4446,203 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4340
4446
  };
4341
4447
  }
4342
4448
 
4449
+ // Chooses a select page's slot variants one at a time (a choice can
4450
+ // re-render its slot) and returns the choices. A field showing a dropdown UI
4451
+ // is picked like a shopper, toggle then row, even when its select is
4452
+ // pre-filled, because a page may count a field as chosen only when a row was
4453
+ // clicked (campaigns-os#667). A select with no dropdown UI is filled only when
4454
+ // empty; a pre-filled native-only page returns [] untouched. Throws
4455
+ // ENTRY_VARIANT_UNFILLED, before any click on the checkout control, when a
4456
+ // field has no in-stock row or option, a pick cannot be clicked, or the page
4457
+ // keeps asking for more choices.
4458
+ const SLOT_VARIANT_FILL_LIMIT = 40;
4459
+ const SLOT_VARIANT_ROW_WAIT_MS = 2000;
4460
+
4461
+ // The slot variant field a pick names, found again by its slot identity
4462
+ // (data-next-bundle-id, data-next-slot-index, variant code) so a re-render
4463
+ // since the pick was planned does not leave a stale index; the planned index
4464
+ // is the fallback for a field that carries no slot index.
4465
+ async function slotVariantField(page, pick) {
4466
+ const fields = page.locator(SLOT_VARIANT_FIELD_SELECTOR);
4467
+ const index = await fields.evaluateAll((nodes, want) => {
4468
+ const clean = (value) => String(value || "").replace(/\s+/g, " ").trim();
4469
+ const found = nodes.findIndex((node) => {
4470
+ const slotIndex = Number.parseInt(node.getAttribute("data-next-slot-index") ?? "", 10);
4471
+ if (!Number.isFinite(slotIndex) || slotIndex + 1 !== want.slot) return false;
4472
+ if ((clean(node.getAttribute("data-next-bundle-id")) || null) !== want.bundle_id) return false;
4473
+ const select = node.querySelector("select");
4474
+ const code = clean(select?.getAttribute("data-next-variant-code") || select?.getAttribute("data-variant-code") || node.getAttribute("data-next-variant-code") || select?.name) || null;
4475
+ return code === want.variant_code;
4476
+ });
4477
+ return found >= 0 ? found : want.field_index;
4478
+ }, pick).catch(() => pick.field_index);
4479
+ return fields.nth(index);
4480
+ }
4481
+
4482
+ // The pick's row, matched by its value among the field's rows (no selector is
4483
+ // built from the value), once it is visible; null if it does not show in time.
4484
+ async function visibleSlotVariantRow(page, pick, timeoutMs) {
4485
+ const deadline = Date.now() + timeoutMs;
4486
+ for (;;) {
4487
+ const field = await slotVariantField(page, pick);
4488
+ const rows = field.locator(SLOT_VARIANT_DROPDOWN_ROW_SELECTOR);
4489
+ const index = await rows.evaluateAll((nodes, value) => nodes.findIndex((node) => {
4490
+ if (String(node.getAttribute("value") || "").replace(/\s+/g, " ").trim() !== value) return false;
4491
+ const rect = node.getBoundingClientRect();
4492
+ const style = getComputedStyle(node);
4493
+ return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
4494
+ }), pick.value).catch(() => -1);
4495
+ if (index >= 0) return rows.nth(index);
4496
+ if (Date.now() >= deadline) return null;
4497
+ await page.waitForTimeout(100);
4498
+ }
4499
+ }
4500
+ async function fillSlotVariants(page) {
4501
+ const selections = [];
4502
+ const picked = [];
4503
+ const step = () => page.evaluate(slotVariantStepScript(), {
4504
+ selector: SLOT_VARIANT_SELECT_SELECTOR,
4505
+ fieldSelector: SLOT_VARIANT_FIELD_SELECTOR,
4506
+ toggleSelector: SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR,
4507
+ rowSelector: SLOT_VARIANT_DROPDOWN_ROW_SELECTOR,
4508
+ picked,
4509
+ }).catch(() => ({ done: true }));
4510
+ for (let pass = 0; pass < SLOT_VARIANT_FILL_LIMIT; pass += 1) {
4511
+ const result = await step();
4512
+ if (result?.done) {
4513
+ if (selections.length) await page.waitForLoadState("networkidle", { timeout: 3000 }).catch(() => {});
4514
+ return selections;
4515
+ }
4516
+ if (result?.unfillable?.detail) {
4517
+ throw codedError(CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED, `${slotVariantLabel(result.unfillable)}: ${result.unfillable.detail}; the page would refuse Next`);
4518
+ }
4519
+ if (result?.unfillable) {
4520
+ throw codedError(
4521
+ CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
4522
+ `${slotVariantLabel(result.unfillable)} has no in-stock ${result.unfillable.via === "dropdown" ? "dropdown row" : "option"} to choose (${result.unfillable.options} ${result.unfillable.via === "dropdown" ? "row" : "option"}(s), all disabled or placeholder); the page would refuse Next`,
4523
+ );
4524
+ }
4525
+ const choice = result.pick || result.filled;
4526
+ if (result.pick) {
4527
+ try {
4528
+ const field = await slotVariantField(page, result.pick);
4529
+ await clickControl(field.locator(SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR).filter({ visible: true }).first(), { timeout: 5000 });
4530
+ // The menu may open late or re-render on toggle: re-resolve the field
4531
+ // and the row by its value, and wait for that row to show.
4532
+ const row = await visibleSlotVariantRow(page, result.pick, SLOT_VARIANT_ROW_WAIT_MS);
4533
+ if (!row) {
4534
+ throw codedError(
4535
+ CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
4536
+ `${slotVariantLabel(choice)} dropdown row "${choice.value}" did not show within ${SLOT_VARIANT_ROW_WAIT_MS}ms of opening the dropdown; the page would refuse Next`,
4537
+ );
4538
+ }
4539
+ await clickControl(row, { timeout: 5000 });
4540
+ } catch (error) {
4541
+ if (error?.code === CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED) throw error;
4542
+ throw codedError(
4543
+ CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
4544
+ `${slotVariantLabel(choice)} dropdown row "${choice.value}" could not be clicked (${String(error?.message || error).split("\n")[0]}); the page would refuse Next`,
4545
+ );
4546
+ }
4547
+ picked.push(slotVariantKey(choice));
4548
+ }
4549
+ selections.push({ bundle_id: choice.bundle_id, slot: choice.slot, variant_code: choice.variant_code, value: choice.value, via: result.pick ? "dropdown" : "select" });
4550
+ await page.waitForTimeout(250);
4551
+ }
4552
+ const pending = await step();
4553
+ if (pending?.done) return selections;
4554
+ throw codedError(
4555
+ CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
4556
+ `${slotVariantLabel(pending.pick || pending.filled || pending.unfillable)} still needed a choice after ${SLOT_VARIANT_FILL_LIMIT} variant choices; the page keeps clearing them`,
4557
+ );
4558
+ }
4559
+
4560
+ // The package of the bundle card a select page shows as selected, or null.
4561
+ async function selectedBundleCardPackage(page) {
4562
+ return page.evaluate((cardSelector) => {
4563
+ const card = Array.from(document.querySelectorAll(cardSelector)).find((node) => (
4564
+ node.classList.contains("next-selected") || node.getAttribute("data-next-selected") === "true" || node.getAttribute("aria-checked") === "true"
4565
+ ));
4566
+ const holder = card && (card.hasAttribute("data-next-package-id") ? card : card.querySelector("[data-next-package-id]"));
4567
+ const value = holder ? String(holder.getAttribute("data-next-package-id") || "").trim() : "";
4568
+ return value || null;
4569
+ }, CART_ENTRY_BUNDLE_CARD_SELECTOR).catch(() => null);
4570
+ }
4571
+
4572
+ // One checkout_step rung: the page must be on this step, its form's submit is
4573
+ // clicked, and the page must land on the step's declared next page. Then the
4574
+ // fields the arrived page renders are filled. The SDK owns the navigation
4575
+ // (data-next-checkout-step on the form); the runner only waits for it.
4576
+ async function submitCheckoutStepAndArrive({ page, step, index, args, email, budget, checkoutUrl, tracking = null }) {
4577
+ ensurePageFillable(page, checkoutUrl);
4578
+ const here = safePageUrl(page);
4579
+ if (!checkoutUrlPredicate(step.url)(here)) {
4580
+ 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)"}`);
4581
+ }
4582
+ const expected = step.expected_next_url;
4583
+ 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`);
4584
+ await tracking?.readDocument(page);
4585
+ await submitCheckoutStep(page);
4586
+ const atNext = checkoutUrlPredicate(expected);
4587
+ const timeout = Math.max(1000, Math.min(budget(), numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS)));
4588
+ const arrived = await page.waitForURL((url) => atNext(url.toString()), { timeout }).then(() => true, () => false);
4589
+ if (!arrived) {
4590
+ 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`);
4591
+ }
4592
+ await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
4593
+ await page.waitForTimeout(500);
4594
+ ensurePageFillable(page, checkoutUrl);
4595
+ const fieldTrace = createFieldTrace();
4596
+ await fillCheckoutFields(page, args, email, { trace: fieldTrace, actionTimeoutMs: budget(), presentOnly: true });
4597
+ return {
4598
+ detail: `step ${index + 1} "${step.page_id || "(unnamed)"}": submitted and landed on its declared next page ${redactUrlQuery(expected)}`,
4599
+ evidence: {
4600
+ step_page_id: step.page_id || null,
4601
+ step_number: index + 1,
4602
+ expected_next_url: redactUrlQuery(expected),
4603
+ arrived_url: redactUrlQuery(safePageUrl(page)),
4604
+ fields: fieldTrace.summary(),
4605
+ },
4606
+ };
4607
+ }
4608
+
4609
+ // The submit control of a checkout_step form. The step contract puts
4610
+ // data-next-checkout-step on the checkout form itself, so a page without one
4611
+ // is reported rather than submitted some other way.
4612
+ async function submitCheckoutStep(page) {
4613
+ await closeAddressAutocomplete(page);
4614
+ const scope = "form[data-next-checkout-step]";
4615
+ // Never an express-wallet button: on a step page those place an order.
4616
+ const notWallet = ":not([data-next-express-checkout]):not([data-next-express-checkout] *)";
4617
+ const submit = page.locator([`button[type="submit"]`, "button:not([type])", `input[type="submit"]`]
4618
+ .map((control) => `${scope} ${control}${notWallet}`).join(", ")).first();
4619
+ if (!await submit.count().catch(() => 0)) {
4620
+ 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");
4621
+ }
4622
+ await submit.waitFor({ state: "visible" });
4623
+ const perpetual = await scrollControlIntoView(submit);
4624
+ await clickControl(submit, { forceFallback: false, perpetual });
4625
+ }
4626
+
4627
+ // The checkout_step pages on the path into `checkoutPage`, read from the
4628
+ // topology that holds it.
4629
+ function checkoutStepPagesFor(topologies, checkoutPage) {
4630
+ if (!checkoutPage) return [];
4631
+ const owner = (Array.isArray(topologies) ? topologies : []).find((topology) => (topology?.pages || []).includes(checkoutPage))
4632
+ || (Array.isArray(topologies) ? topologies : []).find((topology) => (topology?.pages || []).some((page) => checkoutUrlPredicate(checkoutPage.url)(page?.url)));
4633
+ return owner ? checkoutStepPages(owner, checkoutPage) : [];
4634
+ }
4635
+
4636
+ // The page the cart is entered from. Into a Checkout, the usual resolution.
4637
+ // Into a first checkout_step, only a page that routes straight into it: the
4638
+ // order-and-type fallbacks would pick another path's page in a campaign with
4639
+ // parallel paths, and a path that starts on its step has none (#641).
4640
+ function resolvePathEntryPage(topologies, formEntryPage, stepPages) {
4641
+ const resolved = resolveCartEntryPage(topologies, formEntryPage);
4642
+ if (!stepPages.length) return resolved;
4643
+ return resolved?.resolution === "routes_into_checkout" ? resolved : null;
4644
+ }
4645
+
4343
4646
  // The SDK installs `window.next` late in boot. Bounded and tolerant: a page
4344
4647
  // without the SDK (a plain forcePackageId link) still has a cart entry.
4345
4648
  async function waitForSdkReady(page, timeoutMs) {
@@ -5271,10 +5574,13 @@ async function fillCheckoutFields(page, args, email, options = {}) {
5271
5574
  // Field order, progressive-disclosure calls and optional/onlyVisible flags
5272
5575
  // below are unchanged; only the per-field recording wrapper is new.
5273
5576
  const track = (field, action, fn, opts = {}) => (trace ? trace.inspect(field, action, fn, opts) : fn());
5577
+ // presentOnly (a multi-step checkout page): every field optional and
5578
+ // visible-only, because each page of the path renders only its own.
5579
+ const present = options.presentOnly === true ? { optional: true, onlyVisible: true } : {};
5274
5580
  const fill = (field, value, opts = {}) =>
5275
- track(field, "fill", () => fillByField(page, field, value, { ...opts, actionTimeoutMs }), opts);
5581
+ track(field, "fill", () => fillByField(page, field, value, { ...opts, ...present, actionTimeoutMs }), { ...opts, ...present });
5276
5582
  const select = (field, value, opts = {}) =>
5277
- track(field, "select", () => selectByField(page, field, value, { ...opts, actionTimeoutMs }), opts);
5583
+ track(field, "select", () => selectByField(page, field, value, { ...opts, ...present, actionTimeoutMs }), { ...opts, ...present });
5278
5584
  const address = {
5279
5585
  firstName: stringArg(args["test-first-name"]) || "QA",
5280
5586
  lastName: stringArg(args["test-last-name"]) || "Playwright",
@@ -7440,7 +7746,7 @@ function testOrderPaths(mode, topologies = [], args = {}) {
7440
7746
  }
7441
7747
 
7442
7748
  function resolvePrimaryTestOrderTopology(topologies = []) {
7443
- const checkout = findPage(topologies, "checkout");
7749
+ const checkout = findPaymentPage(topologies);
7444
7750
  const topology = (topologies || []).find((candidate) => (candidate?.pages || []).includes(checkout)) || { pages: [] };
7445
7751
  return resolveTestOrderTopology(topology, checkout);
7446
7752
  }
@@ -7467,7 +7773,7 @@ function testOrderPlans(mode, topologies = [], args = {}, options = {}) {
7467
7773
  if (tiersMatch) return specTierPlans(topologies, args, tiersMatch[1] || "checkout", options);
7468
7774
  const selectPackage = stringArg(args["select-package"]);
7469
7775
  const applyCoupon = stringArg(args["apply-coupon"]);
7470
- const checkoutPage = findPage(topologies, "checkout");
7776
+ const checkoutPage = findPaymentPage(topologies);
7471
7777
  const topologyPlan = resolvePrimaryTestOrderTopology(topologies);
7472
7778
  return testOrderPaths(mode, topologies, args).map((path) => ({
7473
7779
  path,
@@ -7498,24 +7804,31 @@ function specTierPlans(topologies, args, variant, { warn = (line) => process.std
7498
7804
  // fail for the wrong reason. A later funnel's checkout with declarations
7499
7805
  // but no resolvable URL cannot be driven; it is warned about, not dropped
7500
7806
  // silently.
7501
- const primary = findPage(topologies, "checkout");
7807
+ const primary = findPaymentPage(topologies);
7502
7808
  if (!primary) {
7503
7809
  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).");
7504
7810
  }
7505
7811
  const plans = [];
7506
- for (const topology of Array.isArray(topologies) ? topologies : []) {
7507
- const pages = Array.isArray(topology?.pages) ? topology.pages : [];
7508
- const checkoutPage = pages.find((page) => String(page?.page_type || "").toLowerCase() === "checkout");
7509
- if (!checkoutPage) continue;
7510
- const declaredTiers = declaredSelectorTiers(checkoutPage);
7511
- const bumps = declaredOrderBumps(checkoutPage);
7812
+ // Every Checkout of every funnel: split-test paths in one funnel each reach
7813
+ // their own (#641). A multi-step path declares its cart on the select page or
7814
+ // first step, not on the Checkout, so tiers and bumps are read from every
7815
+ // page of the path; coupons from the pages the runner fills (steps and the
7816
+ // Checkout), since a select page is left before any form is filled.
7817
+ const paymentPages = (Array.isArray(topologies) ? topologies : [])
7818
+ .flatMap((topology) => paymentPagesForTopology(topology).map((checkoutPage) => ({ topology, checkoutPage })));
7819
+ for (const { topology, checkoutPage } of paymentPages) {
7820
+ const pathPages = checkoutPathPages(topology, checkoutPage);
7821
+ const declaredTiers = uniqueTiers(pathPages.flatMap((page) => declaredSelectorTiers(page)));
7822
+ const bumps = [...new Set(pathPages.flatMap((page) => declaredOrderBumps(page)))];
7512
7823
  if (bumps.length) {
7513
7824
  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.`);
7514
7825
  }
7515
7826
  for (const tier of declaredTiers) declaredIdentities.add(selectorTierIdentity(tier));
7516
7827
  for (const ref of bumps) bumpRefs.add(ref);
7517
7828
  const tiers = narrowTo ? declaredTiers.filter((tier) => narrowTo.has(selectorTierIdentity(tier))) : declaredTiers;
7518
- const coupons = declaredCheckoutCoupons(checkoutPage);
7829
+ const coupons = mergeDeclaredCoupons(pathPages
7830
+ .filter((page) => page === checkoutPage || isCheckoutStepTopologyPage(page))
7831
+ .map((page) => declaredCheckoutCoupons(page)));
7519
7832
  if (!tiers.length && !coupons.length) continue;
7520
7833
  if (checkoutPage !== primary && !checkoutPage.url) {
7521
7834
  if (narrowTo && tiers.length) {
@@ -7637,6 +7950,28 @@ function selectorTierNarrowing(value) {
7637
7950
  // Declared coupons follow the repo's offer-surface rule (build-brief, cli
7638
7951
  // exit-pop gates): a surface counts only when `enabled === true` and it maps
7639
7952
  // an offer_code. Both surfaces mapping the same code collapse into one plan.
7953
+ // Tiers read from several pages of one path, first declaration kept.
7954
+ function uniqueTiers(tiers) {
7955
+ const seen = new Set();
7956
+ return tiers.filter((tier) => {
7957
+ const identity = selectorTierIdentity(tier);
7958
+ if (seen.has(identity)) return false;
7959
+ seen.add(identity);
7960
+ return true;
7961
+ });
7962
+ }
7963
+
7964
+ // Coupon lists from several pages of one path, merged by code.
7965
+ function mergeDeclaredCoupons(lists) {
7966
+ const merged = [];
7967
+ for (const coupon of lists.flat()) {
7968
+ const existing = merged.find((entry) => normalizeLabel(entry.code) === normalizeLabel(coupon.code));
7969
+ if (!existing) merged.push({ code: coupon.code, surfaces: [...coupon.surfaces] });
7970
+ else for (const surface of coupon.surfaces) if (!existing.surfaces.includes(surface)) existing.surfaces.push(surface);
7971
+ }
7972
+ return merged;
7973
+ }
7974
+
7640
7975
  function declaredCheckoutCoupons(checkoutPage) {
7641
7976
  const coupons = [];
7642
7977
  const add = (surface, block) => {
@@ -7966,7 +8301,7 @@ function describeCoverageDoubt(doubt) {
7966
8301
 
7967
8302
  // Page types that never carry an upsell action. Any other type, or none, does
7968
8303
  // not say either way.
7969
- const NON_OFFER_PAGE_TYPES = new Set(["presell", "landing", "select", "product", "checkout", "thankyou", "receipt"]);
8304
+ const NON_OFFER_PAGE_TYPES = new Set(["presell", "landing", "select", "product", "checkout_step", "checkout", "thankyou", "receipt"]);
7970
8305
  const UPSELL_COVERAGE_UNASSESSABLE = Object.freeze({
7971
8306
  no_url: "no URL",
7972
8307
  unresolvable_url: "URL is not an absolute http(s) address",
@@ -7989,7 +8324,7 @@ export function upsellActionCoverageWithoutOrders(topologies = []) {
7989
8324
  return upsellActionCoverageAssertion({
7990
8325
  topologies,
7991
8326
  orders: [],
7992
- checkoutPage: findPage(topologies, "checkout"),
8327
+ checkoutPage: findPaymentPage(topologies),
7993
8328
  testOrdersOff: true,
7994
8329
  });
7995
8330
  }
@@ -8452,14 +8787,6 @@ function persistedUpsellStep(step) {
8452
8787
  return projected;
8453
8788
  }
8454
8789
 
8455
- function findPage(topologies, type) {
8456
- for (const topology of topologies || []) {
8457
- const page = (topology.pages || []).find((candidate) => candidate.page_type === type);
8458
- if (page) return page;
8459
- }
8460
- return null;
8461
- }
8462
-
8463
8790
  function parseCart(value) {
8464
8791
  if (!value) return [];
8465
8792
  return String(value).split(",").map((part) => {