@nextcommerce/campaigns-os 1.55.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 (93) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/CONTEXT.md +11 -0
  3. package/campaign-spec/README.md +15 -1
  4. package/campaign-spec/dist/checkout-flow.d.ts +90 -0
  5. package/campaign-spec/dist/checkout-flow.js +229 -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/fixtures/runtime-recipe/accept/current.json +6 -1
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  43. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  45. package/contracts/release-ledger.json +705 -0
  46. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  47. package/contracts/supported-surface.json +8 -4
  48. package/docs/build-packet.md +52 -8
  49. package/docs/local-setup.md +1 -1
  50. package/docs/orientation-contract-reference.md +1 -1
  51. package/docs/qa-and-test-orders.md +78 -18
  52. package/docs/runtime-readiness.md +8 -3
  53. package/docs/skills-revision.md +10 -10
  54. package/docs/supported-surface.md +1 -1
  55. package/docs/versioning.md +4 -1
  56. package/package.json +2 -1
  57. package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
  58. package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
  59. package/schemas/campaign-spec.v5.schema.json +586 -0
  60. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  61. package/skills/campaign-readback-classification/SKILL.md +3 -3
  62. package/skills/campaign-run-evidence/SKILL.md +3 -3
  63. package/skills/contribution-intake/SKILL.md +3 -3
  64. package/skills/next-campaigns-build/SKILL.md +6 -4
  65. package/skills/next-campaigns-os/SKILL.md +3 -3
  66. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  67. package/skills/next-campaigns-polish/SKILL.md +21 -6
  68. package/skills/next-campaigns-qa/SKILL.md +4 -3
  69. package/skills.json +10 -10
  70. package/src/adapter-decision-contract.mjs +1 -1
  71. package/src/build-brief.mjs +14 -5
  72. package/src/cli-helpers.mjs +129 -0
  73. package/src/cli.mjs +121 -53
  74. package/src/commercial-journey.mjs +25 -2
  75. package/src/design-source-package.mjs +1 -1
  76. package/src/design-source-publication.mjs +7 -7
  77. package/src/doctor/checks.mjs +196 -13
  78. package/src/doctor/inspect.mjs +4 -2
  79. package/src/doctor/next-step.mjs +31 -11
  80. package/src/doctor-sidecar.mjs +5 -3
  81. package/src/gate-actions.mjs +21 -1
  82. package/src/input-refresh.mjs +6 -6
  83. package/src/local-preview-policy.mjs +110 -12
  84. package/src/polish-node.mjs +3 -3
  85. package/src/qa-browser.mjs +226 -34
  86. package/src/qa-cart-entry.mjs +78 -14
  87. package/src/qa-node.mjs +35 -33
  88. package/src/qa-test-order-topology.mjs +118 -1
  89. package/src/qa-verdict-discovery.mjs +21 -4
  90. package/src/runtime-state-ignore.mjs +7 -5
  91. package/src/source-html-intake.mjs +46 -4
  92. package/src/stage-ledger.mjs +4 -2
  93. package/src/stage-record.mjs +3 -3
@@ -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,
@@ -191,7 +198,9 @@ export async function runBrowserChecks(topologies, args = {}, options = {}) {
191
198
  }
192
199
 
193
200
  export async function runBrowserTestOrders(topologies, args = {}, runId = "local", options = {}) {
194
- 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);
195
204
  if (!checkoutPage?.url) {
196
205
  const coverage = upsellActionCoverageAssertion({ topologies, orders: [], checkoutPage });
197
206
  return {
@@ -1090,6 +1099,10 @@ async function runPageBrowserChecks(context, page, args, options = {}, policyLin
1090
1099
  }
1091
1100
  if (page.page_type === "checkout") {
1092
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)) {
1093
1106
  assertions.push(...await checkoutOfferSurfaceAssertions(browserPage, page));
1094
1107
  }
1095
1108
  assertions.push(...await templateResidueAssertions(browserPage, page, options));
@@ -1330,7 +1343,7 @@ function primaryCtaCheckEligible(page) {
1330
1343
  // where the page declares, not where they point.
1331
1344
  function primaryCtaPageRoutesSdkControls(page) {
1332
1345
  const pageType = String(page?.page_type || "").toLowerCase();
1333
- return pageType === "checkout" || OFFER_PAGE_TYPES.has(pageType);
1346
+ return pageType === "checkout" || pageType === "checkout_step" || OFFER_PAGE_TYPES.has(pageType);
1334
1347
  }
1335
1348
 
1336
1349
  // The forms whose action is no route on this page, handed to the route rule
@@ -1357,7 +1370,9 @@ function primaryCtaRoutelessForms(page) {
1357
1370
  // container, the only place the SDK binds it.
1358
1371
  function primaryCtaDeclaredRoutes(page) {
1359
1372
  if (!primaryCtaPageRoutesSdkControls(page)) return [];
1360
- if (String(page.page_type).toLowerCase() === "checkout") {
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())) {
1361
1376
  const notWallet = ":not([data-next-express-checkout]):not([data-next-express-checkout] *)";
1362
1377
  const submit = ['button[type="submit"]', "button:not([type])", 'input[type="submit"]']
1363
1378
  .map((control) => `${CHECKOUT_FORM_SELECTOR} ${control}${notWallet}`)
@@ -2397,7 +2412,10 @@ function promoCodeSurfaceAssertion({ page, declaration, evidence }) {
2397
2412
 
2398
2413
  function contractPageType(page) {
2399
2414
  const type = String(page?.page_type || "").toLowerCase();
2400
- 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;
2401
2419
  }
2402
2420
 
2403
2421
  // The palette-residue rows (style, logo, payment chrome) take their severity
@@ -3707,7 +3725,12 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
3707
3725
  let orderDeadline = null;
3708
3726
  let events = { requests: [], responses: [], failed: [], console: [], pageErrors: [] };
3709
3727
  const email = testEmail(planArgs);
3710
- 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);
3711
3734
  let tracking = null;
3712
3735
  try {
3713
3736
  tracking = options.tracking ? options.tracking.observe(planId(normalizedPlan)) : null;
@@ -3828,6 +3851,7 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
3828
3851
  ladder,
3829
3852
  checkoutPage,
3830
3853
  entryPage,
3854
+ stepPages,
3831
3855
  topologyPlan: normalizedPlan.topology_plan,
3832
3856
  path,
3833
3857
  args: planArgs,
@@ -3916,10 +3940,13 @@ function stablePrivateCaptureError(value) {
3916
3940
  return projectAnalyticsCaptureError(value, { fallbackKind: "unreadable" });
3917
3941
  }
3918
3942
 
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 }) {
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 }) {
3920
3944
  const stepTimeoutMs = numberArg(args["step-timeout-ms"], DEFAULT_STEP_TIMEOUT_MS);
3921
3945
  const budget = () => Math.min(stepTimeoutMs, deadline - Date.now());
3922
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;
3923
3950
  const selectedPackages = parseCart(args["select-package"]);
3924
3951
  const currencyLoad = pathCurrencyLoad(args);
3925
3952
  let checkoutDisplay = null;
@@ -3937,7 +3964,8 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3937
3964
  // page the probe left there rather than loading the same URL a second time,
3938
3965
  // which would fire the SDK's page-view events twice into the same capture.
3939
3966
  const entry = await ladder.run(CART_ENTRY_STEP, () => enterCartViaLanding({
3940
- page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking, currencyLoad,
3967
+ page, checkoutPage: formEntryPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking, currencyLoad,
3968
+ startsOnCheckoutStep: formEntryPage !== checkoutPage,
3941
3969
  }), { timeoutMs: budget() });
3942
3970
  const enteredViaLanding = Boolean(entry && typeof entry === "object" && entry.entered);
3943
3971
  // Responses captured from here on belong to the checkout the ladder drives.
@@ -3945,7 +3973,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3945
3973
  // made before the hand-off.
3946
3974
  const checkoutResponseOffset = events.responses.length;
3947
3975
 
3948
- await ladder.run("opened_checkout", () => openCheckoutForPath({ page, checkoutPage, entry, args, tracking, currencyLoad }), { timeoutMs: budget() });
3976
+ await ladder.run("opened_checkout", () => openCheckoutForPath({ page, checkoutPage: formEntryPage, entry, args, tracking, currencyLoad }), { timeoutMs: budget() });
3949
3977
  await ladder.run("selected_bundle", async () => {
3950
3978
  // The requested package was selected on the entry page, where the cards
3951
3979
  // live; checkout renders none, so re-running strict selection here would
@@ -3969,8 +3997,20 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3969
3997
  const fieldTrace = createFieldTrace();
3970
3998
  await ladder.run("customer_fields_filled", async () => {
3971
3999
  ensurePageFillable(page, checkoutPage.url);
3972
- 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 });
3973
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
+ }
3974
4014
  await ladder.run("coupon_applied", async () => {
3975
4015
  const code = stringArg(args["apply-coupon"]);
3976
4016
  if (!code) return { skip: "no --apply-coupon code requested" };
@@ -4248,7 +4288,7 @@ async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking =
4248
4288
  // The entry step body. Resolves to `{ skip }` when the checkout selects for
4249
4289
  // itself, to `{ entered: true, ... }` when the runner came in through the entry
4250
4290
  // 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 }) {
4291
+ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache = null, tracking = null, currencyLoad = null, startsOnCheckoutStep = false }) {
4252
4292
  // Probe the checkout first: whether it carries a selection surface is a fact
4253
4293
  // about the rendered page, not about the spec (the spec cannot say it yet —
4254
4294
  // that is the design half of #206). The probe's load is the checkout's only
@@ -4284,6 +4324,15 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4284
4324
  evidence: { checkout_selection_surface: surface, ...probeEvidence },
4285
4325
  };
4286
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
+ }
4287
4336
  if (!entryPage?.url) {
4288
4337
  throw codedError(
4289
4338
  CART_ENTRY_CODES.ENTRY_UNRESOLVED,
@@ -4294,15 +4343,47 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4294
4343
  await gotoAndSettle(page, entryPage.url, args, tracking, currencyLoad);
4295
4344
  const sdkReady = await waitForSdkReady(page, Math.min(budget(), DEFAULT_SETTLE_TIMEOUT_MS));
4296
4345
  await tracking?.readDocument(page);
4297
- 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
+ }
4298
4368
  const choice = chooseCartEntryControl(controls, selectedPackages);
4299
4369
  if (!choice.control) {
4300
4370
  throw codedError(CART_ENTRY_CODES.ENTRY_CONTROL_MISSING, `${choice.reason} (entry page ${redactUrlQuery(entryPage.url)})`);
4301
4371
  }
4302
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
+ }
4303
4381
  // The index is the control's position among what its own locator matches,
4304
4382
  // 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);
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);
4306
4387
  const perpetual = await scrollControlIntoView(target);
4307
4388
  await clickControl(target, { timeout: 8000, perpetual });
4308
4389
 
@@ -4332,6 +4413,7 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4332
4413
  control_text: control.text || null,
4333
4414
  control_kind: control.kind,
4334
4415
  package_id: control.package_id || null,
4416
+ ...(cardSelection ? { bundle_card_selection: cardSelection } : {}),
4335
4417
  sdk_ready: sdkReady,
4336
4418
  arrived_url: redactUrlQuery(safePageUrl(page)),
4337
4419
  checkout_selection_surface: surface,
@@ -4340,6 +4422,92 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4340
4422
  };
4341
4423
  }
4342
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
+
4343
4511
  // The SDK installs `window.next` late in boot. Bounded and tolerant: a page
4344
4512
  // without the SDK (a plain forcePackageId link) still has a cart entry.
4345
4513
  async function waitForSdkReady(page, timeoutMs) {
@@ -5271,10 +5439,13 @@ async function fillCheckoutFields(page, args, email, options = {}) {
5271
5439
  // Field order, progressive-disclosure calls and optional/onlyVisible flags
5272
5440
  // below are unchanged; only the per-field recording wrapper is new.
5273
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 } : {};
5274
5445
  const fill = (field, value, opts = {}) =>
5275
- track(field, "fill", () => fillByField(page, field, value, { ...opts, actionTimeoutMs }), opts);
5446
+ track(field, "fill", () => fillByField(page, field, value, { ...opts, ...present, actionTimeoutMs }), { ...opts, ...present });
5276
5447
  const select = (field, value, opts = {}) =>
5277
- track(field, "select", () => selectByField(page, field, value, { ...opts, actionTimeoutMs }), opts);
5448
+ track(field, "select", () => selectByField(page, field, value, { ...opts, ...present, actionTimeoutMs }), { ...opts, ...present });
5278
5449
  const address = {
5279
5450
  firstName: stringArg(args["test-first-name"]) || "QA",
5280
5451
  lastName: stringArg(args["test-last-name"]) || "Playwright",
@@ -7440,7 +7611,7 @@ function testOrderPaths(mode, topologies = [], args = {}) {
7440
7611
  }
7441
7612
 
7442
7613
  function resolvePrimaryTestOrderTopology(topologies = []) {
7443
- const checkout = findPage(topologies, "checkout");
7614
+ const checkout = findPaymentPage(topologies);
7444
7615
  const topology = (topologies || []).find((candidate) => (candidate?.pages || []).includes(checkout)) || { pages: [] };
7445
7616
  return resolveTestOrderTopology(topology, checkout);
7446
7617
  }
@@ -7467,7 +7638,7 @@ function testOrderPlans(mode, topologies = [], args = {}, options = {}) {
7467
7638
  if (tiersMatch) return specTierPlans(topologies, args, tiersMatch[1] || "checkout", options);
7468
7639
  const selectPackage = stringArg(args["select-package"]);
7469
7640
  const applyCoupon = stringArg(args["apply-coupon"]);
7470
- const checkoutPage = findPage(topologies, "checkout");
7641
+ const checkoutPage = findPaymentPage(topologies);
7471
7642
  const topologyPlan = resolvePrimaryTestOrderTopology(topologies);
7472
7643
  return testOrderPaths(mode, topologies, args).map((path) => ({
7473
7644
  path,
@@ -7498,24 +7669,31 @@ function specTierPlans(topologies, args, variant, { warn = (line) => process.std
7498
7669
  // fail for the wrong reason. A later funnel's checkout with declarations
7499
7670
  // but no resolvable URL cannot be driven; it is warned about, not dropped
7500
7671
  // silently.
7501
- const primary = findPage(topologies, "checkout");
7672
+ const primary = findPaymentPage(topologies);
7502
7673
  if (!primary) {
7503
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).");
7504
7675
  }
7505
7676
  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);
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)))];
7512
7688
  if (bumps.length) {
7513
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.`);
7514
7690
  }
7515
7691
  for (const tier of declaredTiers) declaredIdentities.add(selectorTierIdentity(tier));
7516
7692
  for (const ref of bumps) bumpRefs.add(ref);
7517
7693
  const tiers = narrowTo ? declaredTiers.filter((tier) => narrowTo.has(selectorTierIdentity(tier))) : declaredTiers;
7518
- const coupons = declaredCheckoutCoupons(checkoutPage);
7694
+ const coupons = mergeDeclaredCoupons(pathPages
7695
+ .filter((page) => page === checkoutPage || isCheckoutStepTopologyPage(page))
7696
+ .map((page) => declaredCheckoutCoupons(page)));
7519
7697
  if (!tiers.length && !coupons.length) continue;
7520
7698
  if (checkoutPage !== primary && !checkoutPage.url) {
7521
7699
  if (narrowTo && tiers.length) {
@@ -7637,6 +7815,28 @@ function selectorTierNarrowing(value) {
7637
7815
  // Declared coupons follow the repo's offer-surface rule (build-brief, cli
7638
7816
  // exit-pop gates): a surface counts only when `enabled === true` and it maps
7639
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
+
7640
7840
  function declaredCheckoutCoupons(checkoutPage) {
7641
7841
  const coupons = [];
7642
7842
  const add = (surface, block) => {
@@ -7966,7 +8166,7 @@ function describeCoverageDoubt(doubt) {
7966
8166
 
7967
8167
  // Page types that never carry an upsell action. Any other type, or none, does
7968
8168
  // not say either way.
7969
- 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"]);
7970
8170
  const UPSELL_COVERAGE_UNASSESSABLE = Object.freeze({
7971
8171
  no_url: "no URL",
7972
8172
  unresolvable_url: "URL is not an absolute http(s) address",
@@ -7989,7 +8189,7 @@ export function upsellActionCoverageWithoutOrders(topologies = []) {
7989
8189
  return upsellActionCoverageAssertion({
7990
8190
  topologies,
7991
8191
  orders: [],
7992
- checkoutPage: findPage(topologies, "checkout"),
8192
+ checkoutPage: findPaymentPage(topologies),
7993
8193
  testOrdersOff: true,
7994
8194
  });
7995
8195
  }
@@ -8452,14 +8652,6 @@ function persistedUpsellStep(step) {
8452
8652
  return projected;
8453
8653
  }
8454
8654
 
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
8655
  function parseCart(value) {
8464
8656
  if (!value) return [];
8465
8657
  return String(value).split(",").map((part) => {
@@ -127,6 +127,17 @@ export function cartEntryHrefFor(element, { cartEntrySelector, cartEntryRouteAtt
127
127
  }
128
128
  }
129
129
 
130
+ // A select page (campaigns-os#641) has no add-to-cart control: its swap-mode
131
+ // bundle selector writes the cart when a card is chosen, the SDK persists the
132
+ // cart between pages, and a checkout button or link moves on. The runner
133
+ // treats "bundle cards plus one of these" as a cart-entry control: the SDK's
134
+ // checkout action, or a plain link into the next page. Matched by this one
135
+ // selector so the chosen control replays with nth().
136
+ export const CART_ENTRY_CHECKOUT_BUTTON_SELECTOR = '[data-next-action="checkout"], a[href]';
137
+
138
+ // The bundle cards a select page renders, outside the cart summary.
139
+ export const CART_ENTRY_BUNDLE_CARD_SELECTOR = "[data-next-bundle-card]";
140
+
130
141
  // Route-shaped spellings the runner used to consult and no longer does,
131
142
  // because the SDK never declares them. They are not routes, but a page that
132
143
  // carries one is reported so an operator can tell "the runner narrowed its
@@ -138,8 +149,10 @@ export const UNDECLARED_ROUTE_ATTRIBUTES = Object.freeze(["data-next-href", "dat
138
149
  // shop-single-step landing declares; the rest are the entry-like types
139
150
  // `deriveEntryUrls` already recognises.
140
151
  // Pages that can only follow the checkout: the checkout itself plus the
141
- // topology module's own offer and receipt sets, so the two never drift.
142
- const POST_CHECKOUT_PAGE_TYPES = new Set(["checkout", ...OFFER_PAGE_TYPES, ...RECEIPT_PAGE_TYPES]);
152
+ // topology module's own offer and receipt sets, so the two never drift. A
153
+ // checkout_step is never a cart entry either: it is a form page of the
154
+ // checkout path itself (campaigns-os#641).
155
+ const POST_CHECKOUT_PAGE_TYPES = new Set(["checkout", "checkout_step", ...OFFER_PAGE_TYPES, ...RECEIPT_PAGE_TYPES]);
143
156
  const PREFERRED_ENTRY_PAGE_TYPES = ["select", "landing", "product", "presell", "entry", "lander", "advertorial", "listicle", "review", "opt-in", "optin"];
144
157
 
145
158
  export function codedError(code, message) {
@@ -234,18 +247,22 @@ export function summarizeSelectionSurface(surface) {
234
247
  return parts.length ? parts.join(", ") : "none";
235
248
  }
236
249
 
237
- // evaluate() body: the cart-entry controls the entry page renders. Two kinds:
238
- // add_to_cart the SDK action controls (CART_ENTRY_CONTROL_SELECTOR); the
239
- // SDK adds the package and navigates via data-next-url;
240
- // checkout_link a plain link into the checkout URL carrying
241
- // `?forcePackageId=`, which the SDK reads on the checkout page
242
- // to pre-load the cart. This is what the certified
243
- // shop-single-step landing renders today.
250
+ // evaluate() body: the cart-entry controls the entry page renders. Three kinds:
251
+ // add_to_cart the SDK action controls (CART_ENTRY_CONTROL_SELECTOR); the
252
+ // SDK adds the package and navigates via data-next-url;
253
+ // checkout_link a plain link into the checkout URL carrying
254
+ // `?forcePackageId=`, which the SDK reads on the checkout
255
+ // page to pre-load the cart. This is what the certified
256
+ // shop-single-step landing renders today;
257
+ // checkout_button on a page with bundle cards only: the SDK checkout action
258
+ // or a plain link into the checkout URL. The selected card
259
+ // is the cart; `bundle_package_ids` lists what the cards
260
+ // carry so --select-package can name one (campaigns-os#641).
244
261
  // Each control reports its visibility, text, and the package id it carries
245
262
  // (own attribute, nearest card, or the forcePackageId ref), so the caller can
246
263
  // prefer a visible SDK control and honour --select-package in one round trip.
247
264
  export function cartEntryControlsScript() {
248
- return ({ selector, checkoutUrl }) => {
265
+ return ({ selector, checkoutUrl, checkoutButtonSelector = null, bundleCardSelector = null }) => {
249
266
  const clean = (value) => String(value || "").replace(/\s+/g, " ").trim();
250
267
  const isVisible = (element) => {
251
268
  const rect = element.getBoundingClientRect();
@@ -313,7 +330,39 @@ export function cartEntryControlsScript() {
313
330
  }];
314
331
  })
315
332
  : [];
316
- return [...actions, ...links];
333
+ const cards = Array.from(document.querySelectorAll(bundleCardSelector || "[data-next-bundle-card]"))
334
+ .filter((card) => !card.closest("[data-next-cart-summary], [data-next-upsell-context], [data-next-upsell], template"));
335
+ const bundlePackageIds = [...new Set(cards.flatMap((card) => {
336
+ const own = clean(card.getAttribute("data-next-package-id"));
337
+ const nested = Array.from(card.querySelectorAll("[data-next-package-id]")).map((node) => clean(node.getAttribute("data-next-package-id")));
338
+ return [own, ...nested].filter(Boolean);
339
+ }))];
340
+ const buttons = cards.length && checkoutButtonSelector
341
+ ? Array.from(document.querySelectorAll(checkoutButtonSelector)).flatMap((element, index) => {
342
+ const isAction = element.getAttribute("data-next-action") === "checkout";
343
+ if (!isAction) {
344
+ const href = element.getAttribute("href");
345
+ if (!checkout || canonical(href) !== checkout) return [];
346
+ try {
347
+ if (new URL(href, document.baseURI).searchParams.get("forcePackageId")) return [];
348
+ } catch {
349
+ return [];
350
+ }
351
+ }
352
+ return [{
353
+ kind: "checkout_button",
354
+ index,
355
+ visible: isVisible(element),
356
+ text: label(element),
357
+ package_id: null,
358
+ bundle_package_ids: bundlePackageIds,
359
+ quantity: 1,
360
+ next_url: isAction ? null : element.getAttribute("href"),
361
+ tag: element.tagName.toLowerCase(),
362
+ }];
363
+ })
364
+ : [];
365
+ return [...actions, ...links, ...buttons];
317
366
  };
318
367
  }
319
368
 
@@ -323,7 +372,7 @@ export function cartEntryControlsScript() {
323
372
  // control is only used when nothing is visible.
324
373
  export function chooseCartEntryControl(controls = [], requested = []) {
325
374
  const list = Array.isArray(controls) ? controls : [];
326
- if (!list.length) return { control: null, reason: "no add-to-cart control or forcePackageId checkout link rendered on the entry page" };
375
+ if (!list.length) return { control: null, reason: "no add-to-cart control, forcePackageId checkout link, or bundle cards with a checkout button rendered on the entry page" };
327
376
  const wanted = Array.isArray(requested) ? requested.filter((item) => item?.packageId) : [];
328
377
  if (wanted.length > 1) {
329
378
  return {
@@ -331,9 +380,24 @@ export function chooseCartEntryControl(controls = [], requested = []) {
331
380
  reason: `--select-package requested ${wanted.length} refs, but a landing-page entry can select at most one package before the SDK navigates to checkout`,
332
381
  };
333
382
  }
383
+ // A select page's checkout button carries no package of its own: the
384
+ // requested package is the bundle card the runner clicks before it. Used
385
+ // only when no add-to-cart control or forcePackageId link carries the ref.
386
+ const buttons = list.filter((control) => control.kind === "checkout_button");
387
+ const carriers = list.filter((control) => control.kind !== "checkout_button");
334
388
  const byRef = wanted.length
335
- ? list.filter((control) => String(control.package_id) === String(wanted[0].packageId))
336
- : list;
389
+ ? carriers.filter((control) => String(control.package_id) === String(wanted[0].packageId))
390
+ : carriers;
391
+ if (!byRef.length && buttons.length) {
392
+ const pick = (pool) => pool.find((control) => control.visible) || pool[0];
393
+ if (!wanted.length) return { control: pick(buttons), reason: null };
394
+ const withCard = buttons.filter((control) => (control.bundle_package_ids || []).map(String).includes(String(wanted[0].packageId)));
395
+ if (withCard.length) return { control: pick(withCard), select_card: wanted[0], reason: null };
396
+ return {
397
+ control: null,
398
+ reason: `--select-package ${wanted[0].packageId}: no bundle card on the entry page carries package ${wanted[0].packageId} (rendered: ${[...new Set(buttons.flatMap((control) => control.bundle_package_ids || []))].join(", ") || "(none)"})`,
399
+ };
400
+ }
337
401
  if (!byRef.length) {
338
402
  return {
339
403
  control: null,