@nextcommerce/campaigns-os 1.53.0 → 1.55.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 (71) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/CONTEXT.md +8 -5
  3. package/README.md +26 -11
  4. package/compatibility.json +1 -1
  5. package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
  6. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  7. package/contracts/commerce-surface-catalog.json +27 -27
  8. package/contracts/effects.v1.json +96 -37
  9. package/contracts/release-ledger.json +774 -0
  10. package/contracts/supported-surface.json +4 -4
  11. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  12. package/docs/brand-theme-bridge.md +10 -0
  13. package/docs/build-packet.md +45 -22
  14. package/docs/campaign-standardization-report.md +32 -0
  15. package/docs/campaigns-os-build-flow.md +1 -1
  16. package/docs/design-source-package.md +7 -3
  17. package/docs/effects.md +4 -6
  18. package/docs/local-setup.md +1 -1
  19. package/docs/orientation-contract-reference.md +1 -1
  20. package/docs/progress-snapshots.md +3 -1
  21. package/docs/qa-and-test-orders.md +83 -14
  22. package/docs/runtime-readiness.md +1 -1
  23. package/docs/sdk-storage-compatibility.md +15 -0
  24. package/docs/skills-revision.md +10 -10
  25. package/docs/supported-surface.md +2 -1
  26. package/docs/workflow-findings-sidecar.md +57 -2
  27. package/package.json +3 -1
  28. package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
  29. package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
  30. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  31. package/skills/campaign-readback-classification/SKILL.md +3 -3
  32. package/skills/campaign-run-evidence/SKILL.md +3 -3
  33. package/skills/contribution-intake/SKILL.md +3 -3
  34. package/skills/next-campaigns-build/SKILL.md +3 -3
  35. package/skills/next-campaigns-os/SKILL.md +3 -3
  36. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  37. package/skills/next-campaigns-polish/SKILL.md +3 -3
  38. package/skills/next-campaigns-qa/SKILL.md +4 -4
  39. package/skills.json +10 -10
  40. package/src/adapter-decision-contract.mjs +26 -12
  41. package/src/brand-theme.mjs +44 -8
  42. package/src/campaign-ecosystem.mjs +153 -31
  43. package/src/campaign-identity.mjs +33 -4
  44. package/src/cli.mjs +58 -62
  45. package/src/consent.mjs +3 -2
  46. package/src/design-source-publication.mjs +1 -1
  47. package/src/deviation.mjs +2 -1
  48. package/src/directory-lock.mjs +79 -7
  49. package/src/doctor/checks.mjs +28 -8
  50. package/src/doctor/inspect.mjs +13 -0
  51. package/src/doctor/next-step.mjs +16 -6
  52. package/src/invocation.mjs +7 -4
  53. package/src/lifecycle.mjs +88 -6
  54. package/src/local-preview-policy.mjs +6 -0
  55. package/src/login.mjs +2 -1
  56. package/src/polish-browser.mjs +8 -2
  57. package/src/polish-node.mjs +6 -1
  58. package/src/polish-readability.mjs +2 -2
  59. package/src/progress-node.mjs +3 -2
  60. package/src/qa-browser.mjs +595 -95
  61. package/src/qa-cart-entry.mjs +29 -6
  62. package/src/qa-node.mjs +177 -43
  63. package/src/qa-verdict-publish.mjs +2 -1
  64. package/src/run-record.mjs +46 -34
  65. package/src/sdk-attribute-index.mjs +111 -23
  66. package/src/sdk-markup.mjs +89 -10
  67. package/src/sdk-repin.mjs +282 -0
  68. package/src/stage-ledger.mjs +15 -3
  69. package/src/stage-record.mjs +85 -17
  70. package/src/standardization-report.mjs +26 -1
  71. package/src/target-lock.mjs +9 -1
@@ -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,
@@ -77,6 +78,22 @@ const DEFAULT_TEST_CARD = "6011111111111117";
77
78
  const DEFAULT_TEST_CVV = "123";
78
79
  const DEFAULT_TEST_EXP_MONTH = "12";
79
80
  const DEFAULT_TEST_EXP_YEAR = "2030";
81
+ // The card number and CVV iframes, matched by id prefix because each id ends
82
+ // in a per-load suffix. SDK 0.4.40 and earlier mount Spreedly's iFrame v1
83
+ // (spreedly-number-frame-1234); 0.4.41 mounts the Spreedly Checkout SDK fields
84
+ // that NEXT's payments.29next.com/js/v1/payment.js loads
85
+ // (spreedly-hosted-number-oujmii2uzjk). A page carries one generation or the
86
+ // other, and each frame holds a single text input. fillPaymentFields refuses
87
+ // by name unless exactly one number frame and one CVV frame match, so a page
88
+ // mounting both generations, or two forms, fails at the card step instead of
89
+ // on whichever frame happens to come first.
90
+ const CARD_NUMBER_FRAME = 'iframe[id^="spreedly-number-frame"], iframe[id^="spreedly-hosted-number"]';
91
+ const CARD_CVV_FRAME = 'iframe[id^="spreedly-cvv-frame"], iframe[id^="spreedly-hosted-cvv"]';
92
+ // Typing the card is read back and retried this many times in all before the
93
+ // card step fails by name.
94
+ const CARD_TYPE_ATTEMPTS = 3;
95
+ // How long the card step waits for the SDK to report the card fields ready.
96
+ const CARD_READY_TIMEOUT_MS = 15000;
80
97
  const DEFAULT_MAX_TEST_ORDERS = 6;
81
98
  // Planned-path ids listed in a refused --max-test-orders message before the
82
99
  // remainder is counted rather than printed.
@@ -143,13 +160,17 @@ export async function runBrowserChecks(topologies, args = {}, options = {}) {
143
160
  // Content parameters (options.spec analytics.params.content), after the
144
161
  // page checks: every load in its own fresh context with the same options,
145
162
  // closed after the load. The rows go to options.qcResults; their verdict
146
- // assertions join the page checks'.
163
+ // assertions join the page checks'. options.contentParamLimits is an
164
+ // in-process test seam (never set from argv) that can only tighten the
165
+ // leg's CONTENT_PARAM_LIMITS, so a test of the budget or the readiness
166
+ // wait need not sit through the production bound.
147
167
  if (Array.isArray(options.qcResults)) {
148
168
  const contentParams = await runContentParamChecks({
149
169
  topologies,
150
170
  spec: options.spec,
151
171
  newContext: () => browser.newContext(contextOptions),
152
172
  withQueryParam,
173
+ limits: contentParamLimitsFrom(options.contentParamLimits),
153
174
  });
154
175
  options.qcResults.push(...contentParams.rows);
155
176
  assertions.push(...contentParams.assertions);
@@ -1297,7 +1318,7 @@ function sdkDebuggerEligible(page) {
1297
1318
 
1298
1319
  async function primaryCtaVisualAssertions(browserPage, page) {
1299
1320
  if (!primaryCtaCheckEligible(page)) return [];
1300
- const evidence = await inspectPrimaryCta(browserPage, page.expected_next_url);
1321
+ const evidence = await inspectPrimaryCta(browserPage, page.expected_next_url, primaryCtaDeclaredRoutes(page), primaryCtaRoutelessForms(page));
1301
1322
  return [primaryCtaAssertionFromEvidence(page, evidence)];
1302
1323
  }
1303
1324
 
@@ -1305,6 +1326,52 @@ function primaryCtaCheckEligible(page) {
1305
1326
  return Boolean(page?.expected_next_url);
1306
1327
  }
1307
1328
 
1329
+ // Checkout, upsell and downsell: the page types whose forward controls go
1330
+ // where the page declares, not where they point.
1331
+ function primaryCtaPageRoutesSdkControls(page) {
1332
+ const pageType = String(page?.page_type || "").toLowerCase();
1333
+ return pageType === "checkout" || OFFER_PAGE_TYPES.has(pageType);
1334
+ }
1335
+
1336
+ // The forms whose action is no route on this page, handed to the route rule
1337
+ // as its checkout-form selector. On a checkout, upsell or downsell the only
1338
+ // form-borne route is the one the page declares for its SDK control
1339
+ // (primaryCtaDeclaredRoutes), so no form's action is a route there: a
1340
+ // newsletter or search form beside the checkout form does not make its
1341
+ // submit button the route control. Elsewhere only the SDK checkout form,
1342
+ // which the SDK submits itself.
1343
+ function primaryCtaRoutelessForms(page) {
1344
+ return primaryCtaPageRoutesSdkControls(page) ? "form" : CHECKOUT_FORM_SELECTOR;
1345
+ }
1346
+
1347
+ // The SDK controls on this page that go to a route the page declares rather
1348
+ // than one they carry, each with that route: on a checkout, the checkout
1349
+ // form's submit button goes to the success route (the page's forward route);
1350
+ // on an upsell or downsell, each SDK upsell action goes to its accept or
1351
+ // decline route. The routes are the topology fields the static route-link
1352
+ // rows read, and the actions are the rendered upsell-control check's table.
1353
+ // A control is a route candidate when its route is the expected next route,
1354
+ // like any other. The submit button is any button that submits the form, so a
1355
+ // typeless one too, but not an express-checkout wallet button, which starts
1356
+ // the wallet flow; an upsell action counts only inside a [data-next-upsell]
1357
+ // container, the only place the SDK binds it.
1358
+ function primaryCtaDeclaredRoutes(page) {
1359
+ if (!primaryCtaPageRoutesSdkControls(page)) return [];
1360
+ if (String(page.page_type).toLowerCase() === "checkout") {
1361
+ const notWallet = ":not([data-next-express-checkout]):not([data-next-express-checkout] *)";
1362
+ const submit = ['button[type="submit"]', "button:not([type])", 'input[type="submit"]']
1363
+ .map((control) => `${CHECKOUT_FORM_SELECTOR} ${control}${notWallet}`)
1364
+ .join(", ");
1365
+ return page.expected_next_url ? [{ selector: submit, url: page.expected_next_url }] : [];
1366
+ }
1367
+ return UPSELL_CONTROL_ROUTES
1368
+ .filter(({ field }) => page[field])
1369
+ .map(({ kind, field }) => ({
1370
+ selector: UPSELL_ACTION_SPELLINGS[kind].map((action) => `[data-next-upsell] ${upsellActionSelector(action)}`).join(", "),
1371
+ url: page[field],
1372
+ }));
1373
+ }
1374
+
1308
1375
  // Candidate CTAs: anything clickable, plus every SDK action control and the
1309
1376
  // SDK cart-entry control (the same locator the ladder's entry step and
1310
1377
  // advanceToCheckoutForm use). Only attributes the SDK declares are listed —
@@ -1320,19 +1387,21 @@ const PRIMARY_CTA_SELECTOR = ["a[href]", "button", "[role='button']", "[data-nex
1320
1387
  // stay free of module-scope references: the text is run in a fresh context by
1321
1388
  // a test (primary-CTA inspection script is self-contained) that would surface
1322
1389
  // a leaked identifier as a ReferenceError.
1323
- function primaryCtaInspectionScript(expectedUrl) {
1390
+ function primaryCtaInspectionScript(expectedUrl, declaredRoutes = [], checkoutFormSelector = CHECKOUT_FORM_SELECTOR) {
1324
1391
  const args = {
1325
1392
  routeUrl: expectedUrl,
1326
1393
  ctaSelector: PRIMARY_CTA_SELECTOR,
1327
1394
  cartEntrySelector: CART_ENTRY_CONTROL_SELECTOR,
1328
1395
  cartEntryRouteAttribute: CART_ENTRY_ROUTE_ATTRIBUTE,
1396
+ declaredRoutes,
1397
+ checkoutFormSelector,
1329
1398
  ignoredRouteAttributes: [...UNDECLARED_ROUTE_ATTRIBUTES],
1330
1399
  };
1331
1400
  return `(${inspectPrimaryCtaScript.toString()})(${JSON.stringify(args)}, ${cartEntryHrefFor.toString()}, ${contrastToolkit.toString()}, ${generatedTextRenders.toString()})`;
1332
1401
  }
1333
1402
 
1334
- async function inspectPrimaryCta(browserPage, expectedUrl) {
1335
- return browserPage.evaluate(primaryCtaInspectionScript(expectedUrl)).catch((error) => ({
1403
+ async function inspectPrimaryCta(browserPage, expectedUrl, declaredRoutes = [], checkoutFormSelector = CHECKOUT_FORM_SELECTOR) {
1404
+ return browserPage.evaluate(primaryCtaInspectionScript(expectedUrl, declaredRoutes, checkoutFormSelector)).catch((error) => ({
1336
1405
  ok: false,
1337
1406
  reason: "inspection_error",
1338
1407
  expected_url: expectedUrl,
@@ -1342,7 +1411,7 @@ async function inspectPrimaryCta(browserPage, expectedUrl) {
1342
1411
  }));
1343
1412
  }
1344
1413
 
1345
- function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, cartEntryRouteAttribute, ignoredRouteAttributes }, hrefForImpl, contrastToolkitImpl, generatedTextImpl) {
1414
+ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, cartEntryRouteAttribute, declaredRoutes, checkoutFormSelector, ignoredRouteAttributes }, hrefForImpl, contrastToolkitImpl, generatedTextImpl) {
1346
1415
  const CTA_SELECTOR = ctaSelector;
1347
1416
  const toolkit = contrastToolkitImpl();
1348
1417
 
@@ -1375,9 +1444,9 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
1375
1444
  .join("");
1376
1445
  return `${tag}${id}${classes}`;
1377
1446
  };
1378
- // The route a control leads to: the shared cart-entry rule, evaluated
1379
- // against this document's origin and base.
1380
- const hrefFor = (element) => hrefForImpl(element, { cartEntrySelector, cartEntryRouteAttribute, origin: location.origin, baseHref: location.href });
1447
+ // The route a control leads to: the shared cart-entry rule, with the
1448
+ // page's declared routes, evaluated against this document's origin and base.
1449
+ const hrefFor = (element) => hrefForImpl(element, { cartEntrySelector, cartEntryRouteAttribute, declaredRoutes, checkoutFormSelector, origin: location.origin, baseHref: location.href });
1381
1450
  // Route-shaped attributes the element carries that the rule above does not
1382
1451
  // consult: the undeclared spellings, plus the SDK route attribute on an
1383
1452
  // element that is not an SDK control (a decoy, not a route). Reported, not
@@ -1566,15 +1635,48 @@ function primaryCtaAssertionFromEvidence(page, evidence) {
1566
1635
  });
1567
1636
  }
1568
1637
 
1638
+ // The SDK's spellings of each upsell action: its upsell handler accepts the
1639
+ // offer on add or accept and declines it on skip or decline, and does nothing
1640
+ // on any other value. Every place QA locates an upsell control reads this one
1641
+ // table. The first spelling is the one reported when a page has none.
1642
+ export const UPSELL_ACTION_SPELLINGS = Object.freeze({
1643
+ accept: Object.freeze(["add", "accept"]),
1644
+ decline: Object.freeze(["skip", "decline"]),
1645
+ });
1646
+
1647
+ // The SDK upsell actions and the page field that declares where each goes.
1648
+ // The rendered upsell-control check and the primary-CTA route rule read this
1649
+ // one table.
1650
+ const UPSELL_CONTROL_ROUTES = Object.freeze([
1651
+ Object.freeze({ kind: "accept", field: "expected_accept_url" }),
1652
+ Object.freeze({ kind: "decline", field: "expected_decline_url" }),
1653
+ ]);
1654
+
1655
+ function upsellActionSelector(...actions) {
1656
+ return actions.map((action) => `[data-next-upsell-action="${action}"]`).join(", ");
1657
+ }
1658
+
1659
+ // The spelling of an upsell action this page renders: the first spelling
1660
+ // present, or the first spelling when the page has none.
1661
+ async function presentUpsellAction(page, kind) {
1662
+ const spellings = UPSELL_ACTION_SPELLINGS[kind];
1663
+ for (const action of spellings) {
1664
+ if (await page.locator(upsellActionSelector(action)).first().count().catch(() => 0)) return action;
1665
+ }
1666
+ return spellings[0];
1667
+ }
1668
+
1569
1669
  async function renderedUpsellControlAssertions(browserPage, page) {
1570
- const checks = [
1571
- ["accept", "add", page.expected_accept_url],
1572
- ["decline", "skip", page.expected_decline_url],
1573
- ];
1574
1670
  const assertions = [];
1575
- for (const [kind, action, expectedUrl] of checks) {
1671
+ for (const { kind, field } of UPSELL_CONTROL_ROUTES) {
1672
+ const expectedUrl = page[field];
1576
1673
  if (!expectedUrl) continue;
1577
- const count = await browserPage.locator(`[data-next-upsell-action="${action}"]`).count().catch(() => 0);
1674
+ const spellings = UPSELL_ACTION_SPELLINGS[kind];
1675
+ const counts = [];
1676
+ for (const action of spellings) counts.push(await browserPage.locator(upsellActionSelector(action)).count().catch(() => 0));
1677
+ const count = counts.reduce((sum, n) => sum + n, 0);
1678
+ const present = spellings.filter((_, index) => counts[index] > 0);
1679
+ const selector = upsellActionSelector(...(present.length ? present : spellings.slice(0, 1)));
1578
1680
  assertions.push(assertion({
1579
1681
  id: `browser-upsell-control:${page.page_id}:${kind}`,
1580
1682
  family: "browser-runtime",
@@ -1583,7 +1685,7 @@ async function renderedUpsellControlAssertions(browserPage, page) {
1583
1685
  severity: count > 0 ? undefined : SEVERITY.WARN,
1584
1686
  expected: `rendered SDK ${kind} control`,
1585
1687
  actual: count > 0 ? `${count} matching control(s)` : "not found",
1586
- evidence: { selector: `[data-next-upsell-action="${action}"]`, expected_url: expectedUrl },
1688
+ evidence: { selector, expected_url: expectedUrl },
1587
1689
  }));
1588
1690
  }
1589
1691
  return assertions;
@@ -1614,7 +1716,7 @@ async function checkoutPaymentSurfaceAssertions(browserPage, page) {
1614
1716
  card_number_selector: '[data-next-checkout-field="cc-number"], #spreedly-number',
1615
1717
  cvv_selector: '[data-next-checkout-field="cvv"], #spreedly-cvv',
1616
1718
  spreedly_frame_urls: spreedlyFrames.map((frame) => frame.url()).slice(0, 5),
1617
- next_step: "Run --test-order common for typed-card checkout proof (test cards bypass the gateway; no approval needed).",
1719
+ 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).",
1618
1720
  },
1619
1721
  }), assertion({
1620
1722
  id: `browser-payment-geometry:${page.page_id}`,
@@ -3173,8 +3275,10 @@ function createFieldTrace() {
3173
3275
  // before the bounded ladder step starts and updated before each operation that
3174
3276
  // can hang. Its synchronous summary survives when the timeout wins the race.
3175
3277
  function createUpsellActionTrace({ page, events, topologyPlan, stepIndex, path, inspectTimeoutMs = 1000 }) {
3176
- const requestedAction = path === "accept" ? "add" : "skip";
3177
- const selector = `[data-next-upsell-action="${requestedAction}"]`;
3278
+ // The spelling the page renders, read again at each inspection.
3279
+ const kind = path === "accept" ? "accept" : "decline";
3280
+ let requestedAction = UPSELL_ACTION_SPELLINGS[kind][0];
3281
+ let selector = upsellActionSelector(requestedAction);
3178
3282
  let actionNavigationCount = Array.isArray(events?.navigations) ? events.navigations.length : 0;
3179
3283
  let actionUpsellRequestCount = (events?.requests || []).filter((request) => isOrderUpsellsUrl(request?.url)).length;
3180
3284
  let element = { present: null, visible: null, enabled: null };
@@ -3184,6 +3288,8 @@ function createUpsellActionTrace({ page, events, topologyPlan, stepIndex, path,
3184
3288
  let stepCompleted = false;
3185
3289
 
3186
3290
  const inspect = async () => {
3291
+ requestedAction = await presentUpsellAction(page, kind);
3292
+ selector = upsellActionSelector(requestedAction);
3187
3293
  const control = page.locator(selector).first();
3188
3294
  const count = await control.count().catch(() => 0);
3189
3295
  element = {
@@ -3729,6 +3835,9 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
3729
3835
  reserveOrderCreation,
3730
3836
  selectorProbeCache: options.selectorProbeCache,
3731
3837
  tracking,
3838
+ // An in-process test seam only, like trackingTestHooks: unset in
3839
+ // every production run, so the card step waits CARD_READY_TIMEOUT_MS.
3840
+ cardReadyTimeoutMs: shorterBound(options.cardReadyTimeoutMs, CARD_READY_TIMEOUT_MS),
3732
3841
  }),
3733
3842
  orderTimeoutMs + ORDER_TIMEOUT_GRACE_MS,
3734
3843
  `order-path:${planId(normalizedPlan)}`,
@@ -3807,11 +3916,12 @@ function stablePrivateCaptureError(value) {
3807
3916
  return projectAnalyticsCaptureError(value, { fallbackKind: "unreadable" });
3808
3917
  }
3809
3918
 
3810
- async function executeTestOrderPath({ page, events, email, ladder, checkoutPage, entryPage = null, topologyPlan, path, args, deadline, reserveOrderCreation = null, selectorProbeCache = null, tracking = null }) {
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 }) {
3811
3920
  const stepTimeoutMs = numberArg(args["step-timeout-ms"], DEFAULT_STEP_TIMEOUT_MS);
3812
3921
  const budget = () => Math.min(stepTimeoutMs, deadline - Date.now());
3813
3922
  const hostedNow = () => hostedRedirectInfo(safePageUrl(page), checkoutPage.url);
3814
3923
  const selectedPackages = parseCart(args["select-package"]);
3924
+ const currencyLoad = pathCurrencyLoad(args);
3815
3925
  let checkoutDisplay = null;
3816
3926
  let cartBeforeSubmit = null;
3817
3927
 
@@ -3827,7 +3937,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3827
3937
  // page the probe left there rather than loading the same URL a second time,
3828
3938
  // which would fire the SDK's page-view events twice into the same capture.
3829
3939
  const entry = await ladder.run(CART_ENTRY_STEP, () => enterCartViaLanding({
3830
- page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking,
3940
+ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking, currencyLoad,
3831
3941
  }), { timeoutMs: budget() });
3832
3942
  const enteredViaLanding = Boolean(entry && typeof entry === "object" && entry.entered);
3833
3943
  // Responses captured from here on belong to the checkout the ladder drives.
@@ -3835,17 +3945,18 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3835
3945
  // made before the hand-off.
3836
3946
  const checkoutResponseOffset = events.responses.length;
3837
3947
 
3838
- await ladder.run("opened_checkout", () => openCheckoutForPath({ page, checkoutPage, entry, args, tracking }), { timeoutMs: budget() });
3948
+ await ladder.run("opened_checkout", () => openCheckoutForPath({ page, checkoutPage, entry, args, tracking, currencyLoad }), { timeoutMs: budget() });
3839
3949
  await ladder.run("selected_bundle", async () => {
3840
3950
  // The requested package was selected on the entry page, where the cards
3841
3951
  // live; checkout renders none, so re-running strict selection here would
3842
3952
  // fail for the wrong reason.
3953
+ const selectedCards = [];
3843
3954
  const strictSelection = enteredViaLanding && selectedPackages.length
3844
3955
  ? `selected on entry page: ${entry.package_id || "(no package id on control)"}`
3845
- : await selectRequestedPackages(page, selectedPackages);
3846
- await selectRequestedCart(page, args);
3956
+ : await selectRequestedPackages(page, selectedPackages, selectedCards);
3957
+ const unappliedCart = await selectRequestedCart(page, args, { selectedPackages, selectedCards });
3847
3958
  await advanceToCheckoutForm(page);
3848
- if (strictSelection) return `selected requested package card(s): ${strictSelection}`;
3959
+ if (strictSelection) return `selected requested package card(s): ${[strictSelection, ...unappliedCart].join("; ")}`;
3849
3960
  return parseCart(args.cart).length ? `requested cart ${args.cart}` : "default bundle selection";
3850
3961
  }, { timeoutMs: budget() });
3851
3962
  await ladder.run("bump_state", async () => {
@@ -3868,7 +3979,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3868
3979
  }, { timeoutMs: budget() });
3869
3980
  await ladder.run("card_fields_filled", async () => {
3870
3981
  ensurePageFillable(page, checkoutPage.url);
3871
- await fillPaymentFields(page, args);
3982
+ return fillPaymentFields(page, args, { readyTimeoutMs: cardReadyTimeoutMs });
3872
3983
  }, { timeoutMs: budget() });
3873
3984
  await ladder.run("cart_created", async () => {
3874
3985
  const cart = cartCreationEvidence(events);
@@ -3924,6 +4035,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3924
4035
  display: checkoutDisplay,
3925
4036
  events,
3926
4037
  selected_packages: selectedPackages,
4038
+ requested_cart: parseCart(args.cart),
3927
4039
  });
3928
4040
  order.verification.total_parity = assessOrderTotalParity({
3929
4041
  display: checkoutDisplay,
@@ -4120,7 +4232,7 @@ function createSelectorProbeCache() {
4120
4232
  // loaded it and found a selection surface; either way a second load of the
4121
4233
  // same URL is what is avoided. Anywhere else (a cached probe answer skipped the
4122
4234
  // load; a fresh page) the checkout is opened here, once.
4123
- async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking = null }) {
4235
+ async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking = null, currencyLoad = null }) {
4124
4236
  const enteredViaLanding = Boolean(entry && typeof entry === "object" && entry.entered);
4125
4237
  if (enteredViaLanding) {
4126
4238
  await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
@@ -4129,14 +4241,14 @@ async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking =
4129
4241
  if (checkoutUrlPredicate(checkoutPage.url)(safePageUrl(page))) {
4130
4242
  return "already on checkout from the selector probe; not re-opened";
4131
4243
  }
4132
- await gotoAndSettle(page, checkoutPage.url, args, tracking);
4244
+ await gotoAndSettle(page, checkoutPage.url, args, tracking, currencyLoad);
4133
4245
  return null;
4134
4246
  }
4135
4247
 
4136
4248
  // The entry step body. Resolves to `{ skip }` when the checkout selects for
4137
4249
  // itself, to `{ entered: true, ... }` when the runner came in through the entry
4138
4250
  // page, and throws a coded error (never a bare timeout) when it cannot.
4139
- async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache = null, tracking = null }) {
4251
+ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache = null, tracking = null, currencyLoad = null }) {
4140
4252
  // Probe the checkout first: whether it carries a selection surface is a fact
4141
4253
  // about the rendered page, not about the spec (the spec cannot say it yet —
4142
4254
  // that is the design half of #206). The probe's load is the checkout's only
@@ -4153,7 +4265,7 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4153
4265
  let probe = cached ? "reused" : "loaded";
4154
4266
  let probeError = null;
4155
4267
  if (!surface) {
4156
- await gotoAndSettle(page, checkoutPage.url, args, tracking);
4268
+ await gotoAndSettle(page, checkoutPage.url, args, tracking, currencyLoad);
4157
4269
  const probed = await page.evaluate(checkoutSelectionSurfaceScript())
4158
4270
  .then((value) => ({ value }), (error) => ({ value: null, error: error?.message || String(error) }));
4159
4271
  if (probed.value) {
@@ -4179,7 +4291,7 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4179
4291
  );
4180
4292
  }
4181
4293
 
4182
- await gotoAndSettle(page, entryPage.url, args, tracking);
4294
+ await gotoAndSettle(page, entryPage.url, args, tracking, currencyLoad);
4183
4295
  const sdkReady = await waitForSdkReady(page, Math.min(budget(), DEFAULT_SETTLE_TIMEOUT_MS));
4184
4296
  await tracking?.readDocument(page);
4185
4297
  const controls = await page.evaluate(cartEntryControlsScript(), { selector: CART_ENTRY_CONTROL_SELECTOR, checkoutUrl: checkoutPage.url }).catch(() => []);
@@ -4475,24 +4587,149 @@ function assessReceiptRendering(persistedLineCount, evidence = {}) {
4475
4587
  };
4476
4588
  }
4477
4589
 
4590
+ // `qa run --currency <code>`, validated and upper-cased. qa-node.mjs sets it
4591
+ // after its own check; a symbol, so no command-line flag can set it, and a
4592
+ // command that never validates --currency (qa parity) never loads with one.
4593
+ export const QA_RUN_CURRENCY = Symbol("qa run --currency");
4594
+
4595
+ // One order path's currency: added to the path's first runner load only —
4596
+ // the checkout probe, the entry page when the probe answer was reused, or the
4597
+ // checkout opened directly. Campaign Cart keeps a URL currency in session
4598
+ // storage for the rest of the tab's session, so later loads in the tab are
4599
+ // the plain URL; the receipt reload in recovery is not a path load at all.
4600
+ function pathCurrencyLoad(args) {
4601
+ const code = args?.[QA_RUN_CURRENCY];
4602
+ let pending = typeof code === "string" && /^[A-Z]{3}$/.test(code) ? code : null;
4603
+ return (url) => {
4604
+ if (!pending) return url;
4605
+ const entered = withQueryParam(url, "currency", pending);
4606
+ pending = null;
4607
+ return entered;
4608
+ };
4609
+ }
4610
+
4478
4611
  // With a tracking observer, the load is a runner navigation: before the
4479
- // attempt's first page-initiated hop it carries the run's synthetic seeds.
4480
- async function gotoAndSettle(page, url, args, tracking = null) {
4481
- const target = tracking ? tracking.runnerUrl(url, withQueryParam) : url;
4612
+ // attempt's first page-initiated hop it carries the run's synthetic seeds,
4613
+ // after the path's currency when this is the load that carries one.
4614
+ async function gotoAndSettle(page, url, args, tracking = null, currencyLoad = null) {
4615
+ const entered = currencyLoad ? currencyLoad(url) : url;
4616
+ const target = tracking ? tracking.runnerUrl(entered, withQueryParam) : entered;
4482
4617
  await page.goto(target, { waitUntil: "domcontentloaded", timeout: numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS) });
4483
4618
  await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
4484
4619
  await page.waitForTimeout(750);
4485
4620
  }
4486
4621
 
4487
- async function selectRequestedCart(page, args) {
4622
+ // Returns one note per --cart ref left unapplied beside a --select-package
4623
+ // selection; the selected_bundle step reports them.
4624
+ async function selectRequestedCart(page, args, { selectedPackages = [], selectedCards = [] } = {}) {
4488
4625
  const cart = parseCart(args.cart);
4626
+ if (!cart.length) return [];
4627
+ if (selectedPackages.length) return applyCartBesideSelection(page, cart, { selectedPackages, selectedCards });
4628
+ // A package a bundle card declares, asked for at an explicit quantity, is
4629
+ // chosen by the card whose items carry it at that quantity. The first
4630
+ // [data-next-package-id] for that package is usually the one-unit card's
4631
+ // inner node, so the plain click below would reset a multi-unit selection.
4632
+ // A bare ref names no quantity and clicks that first node, as it always has.
4633
+ // The cards are read again for each ref: a click may re-render the selector.
4489
4634
  for (const item of cart) {
4635
+ const ref = String(item.packageId);
4636
+ const bundleCards = (await renderedPackageCardCandidates(page))
4637
+ .filter((candidate) => candidate.bundle_id && Array.isArray(candidate.items));
4638
+ if (item.quantityExplicit && bundleCards.some((card) => card.items.some((entry) => entry.package_id === ref))) {
4639
+ await selectCartBundleCard(page, bundleCards, item);
4640
+ continue;
4641
+ }
4490
4642
  const target = page.locator(packageCardClickSelector({ package_id: item.packageId })).first();
4491
4643
  if (await target.count().catch(() => 0)) {
4492
4644
  const perpetual = await scrollControlIntoView(target);
4493
4645
  await clickControl(target, { timeout: 5000, forceFallback: false, perpetual }).catch(() => {});
4494
4646
  }
4495
4647
  }
4648
+ return [];
4649
+ }
4650
+
4651
+ function cardCarriesPackage(candidate, ref) {
4652
+ return String(candidate?.package_id || "") === ref
4653
+ || String(candidate?.bundle_id || "") === ref
4654
+ || (Array.isArray(candidate?.items) && candidate.items.some((entry) => String(entry?.package_id || "") === ref));
4655
+ }
4656
+
4657
+ // --cart beside --select-package: the card --select-package chose stays
4658
+ // selected. A --cart ref that card carries is already applied. Any other ref
4659
+ // is applied through its own control (an order bump toggle, or a card in a
4660
+ // selector group --select-package did not choose in), never by clicking
4661
+ // another card in the chosen card's group. A ref only such a click could
4662
+ // satisfy is left unapplied and named, so the order and its reconciliation
4663
+ // stay those of the card actually selected.
4664
+ async function applyCartBesideSelection(page, cart, { selectedPackages, selectedCards }) {
4665
+ const chosenIdentities = new Set(selectedCards.map(packageCardIdentity));
4666
+ const strictRefs = selectedPackages.map((item) => String(item.packageId));
4667
+ const unapplied = [];
4668
+ for (const item of cart) {
4669
+ const ref = String(item.packageId);
4670
+ const label = `--cart ${ref}:${item.quantity}`;
4671
+ const candidates = await renderedPackageCardCandidates(page, { placement: true });
4672
+ // Cards chosen on this page, or (when the choice was made on an entry
4673
+ // page) the selected cards that carry a --select-package ref.
4674
+ const chosen = candidates.filter((candidate) => !candidate.toggle && (
4675
+ chosenIdentities.has(packageCardIdentity(candidate))
4676
+ || (candidate.selected && strictRefs.some((strictRef) => cardCarriesPackage(candidate, strictRef)))
4677
+ ));
4678
+ const carrying = chosen.find((candidate) => cardCarriesPackage(candidate, ref));
4679
+ if (carrying) {
4680
+ const declared = carrying.items?.find((entry) => entry.package_id === ref)?.quantity;
4681
+ if (item.quantityExplicit && declared != null && declared !== item.quantity) {
4682
+ unapplied.push(`${label} not applied: the card --select-package chose declares ${ref}:${declared}`);
4683
+ }
4684
+ continue;
4685
+ }
4686
+ // A card in no recognised selector container may share the chosen card's
4687
+ // selection, so it is locked like a card in the chosen card's group.
4688
+ const lockedGroups = new Set(chosen.map((candidate) => candidate.group));
4689
+ const reachable = candidates.filter((candidate) => (
4690
+ cardCarriesPackage(candidate, ref)
4691
+ && (candidate.toggle || (candidate.group != null && !lockedGroups.has(candidate.group)))
4692
+ ));
4693
+ if (!reachable.length) {
4694
+ unapplied.push(candidates.some((candidate) => cardCarriesPackage(candidate, ref))
4695
+ ? `${label} not applied: only another card in the selector --select-package chose carries it`
4696
+ : `${label} not applied: no rendered card or control carries it`);
4697
+ continue;
4698
+ }
4699
+ let target = reachable.find((candidate) => candidate.toggle);
4700
+ if (!target) {
4701
+ const declaringCards = reachable.filter((candidate) => candidate.items?.some((entry) => entry.package_id === ref));
4702
+ try {
4703
+ target = declaringCards.length ? resolvePackageCardCandidate(declaringCards, item) : reachable[0];
4704
+ } catch (error) {
4705
+ unapplied.push(`${label} not applied: ${error.message}`);
4706
+ continue;
4707
+ }
4708
+ }
4709
+ const control = page.locator(PACKAGE_CARD_QUERY).nth(target.element_index);
4710
+ const perpetual = await scrollControlIntoView(control);
4711
+ await clickControl(control, { timeout: 5000, forceFallback: false, perpetual }).catch(() => {});
4712
+ }
4713
+ return unapplied;
4714
+ }
4715
+
4716
+ // Best-effort, like the rest of --cart: a ref no card satisfies leaves the
4717
+ // selection alone, and reconciliation reports the quantity the order carried.
4718
+ async function selectCartBundleCard(page, bundleCards, item) {
4719
+ const ref = String(item.packageId);
4720
+ const { selected_bundle_items: selected = [] } = await checkoutDisplayEvidence(page);
4721
+ if (selected.some((card) => card.items?.some((entry) => entry.package_id === ref && entry.quantity === item.quantity))) return;
4722
+ let candidate;
4723
+ try {
4724
+ candidate = resolvePackageCardCandidate(bundleCards, item);
4725
+ } catch {
4726
+ return;
4727
+ }
4728
+ const target = page.locator(packageCardClickSelector(candidate)).first();
4729
+ if (await target.count().catch(() => 0)) {
4730
+ const perpetual = await scrollControlIntoView(target);
4731
+ await clickControl(target, { timeout: 5000, forceFallback: false, perpetual }).catch(() => {});
4732
+ }
4496
4733
  }
4497
4734
 
4498
4735
  // --- Strict package/bundle card selection (--select-package) ---
@@ -4502,16 +4739,17 @@ async function selectRequestedCart(page, args) {
4502
4739
  // strict variant: the requested card must exist, be clickable, and an explicit
4503
4740
  // quantity must visibly enter the selected state — otherwise the
4504
4741
  // selected_bundle step fails instead of driving the wrong tier.
4505
- async function selectRequestedPackages(page, requested) {
4742
+ // `cards` collects the card each item selected, for --cart to leave alone.
4743
+ async function selectRequestedPackages(page, requested, cards = []) {
4506
4744
  if (!requested.length) return null;
4507
4745
  const details = [];
4508
4746
  for (const item of requested) {
4509
- details.push(await selectPackageCard(page, item));
4747
+ details.push(await selectPackageCard(page, item, cards));
4510
4748
  }
4511
4749
  return details.join("; ");
4512
4750
  }
4513
4751
 
4514
- async function selectPackageCard(page, item) {
4752
+ async function selectPackageCard(page, item, cards = []) {
4515
4753
  const candidate = resolvePackageCardCandidate(await renderedPackageCardCandidates(page), item);
4516
4754
  const selector = packageCardClickSelector(candidate);
4517
4755
  const target = page.locator(selector).first();
@@ -4533,14 +4771,36 @@ async function selectPackageCard(page, item) {
4533
4771
  if (packageCardProofIdentity(selected) !== packageCardProofIdentity(candidate)) {
4534
4772
  throw new Error(`--select-package ${item.packageId}: rendered card composition changed after click`);
4535
4773
  }
4774
+ cards.push(selected);
4536
4775
  const quantity = item.quantity || 1;
4537
4776
  return `${item.packageId}:${quantity} via ${selector}${state === "unknown" ? " (card exposes no selected-state marker; composition verified)" : ""}`;
4538
4777
  }
4539
4778
 
4540
- async function renderedPackageCardCandidates(page) {
4541
- return page.locator("[data-next-bundle-card], [data-next-selector-card], [data-next-package-id], [data-next-bundle-id]").evaluateAll((elements) => {
4779
+ const PACKAGE_CARD_QUERY = "[data-next-bundle-card], [data-next-selector-card], [data-next-package-id], [data-next-bundle-id]";
4780
+
4781
+ // `placement` adds where each card sits: its selector group (an index valid
4782
+ // for this read only, null for a card in no recognised selector container,
4783
+ // whose own wrapper says nothing about which cards it swaps with), whether it
4784
+ // is selected, whether it is an order-bump
4785
+ // style toggle rather than a selector card, and the index of its element in
4786
+ // PACKAGE_CARD_QUERY so that one exact element can be clicked.
4787
+ async function renderedPackageCardCandidates(page, { placement = false } = {}) {
4788
+ return page.locator(PACKAGE_CARD_QUERY).evaluateAll((elements, withPlacement) => {
4542
4789
  const cards = [];
4543
4790
  const seen = new Set();
4791
+ const groups = new Map();
4792
+ const groupOf = (card) => {
4793
+ const container = card.closest("[data-next-bundle-selector], [data-next-selector-id], [data-next-cart-selector]");
4794
+ if (!container) return null;
4795
+ if (!groups.has(container)) groups.set(container, groups.size);
4796
+ return groups.get(container);
4797
+ };
4798
+ const isSelected = (card) => (
4799
+ card.classList.contains("next-selected")
4800
+ || card.getAttribute("data-next-selected") === "true"
4801
+ || card.getAttribute("aria-checked") === "true"
4802
+ || card.querySelector('input[type="radio"], input[type="checkbox"]')?.checked === true
4803
+ );
4544
4804
  const parseItems = (value) => {
4545
4805
  if (!value) return null;
4546
4806
  try {
@@ -4554,7 +4814,7 @@ async function renderedPackageCardCandidates(page) {
4554
4814
  return null;
4555
4815
  }
4556
4816
  };
4557
- for (const element of elements) {
4817
+ for (const [index, element] of elements.entries()) {
4558
4818
  const card = element.closest("[data-next-bundle-card], [data-next-selector-card]") || element;
4559
4819
  if (seen.has(card)) continue;
4560
4820
  seen.add(card);
@@ -4571,10 +4831,19 @@ async function renderedPackageCardCandidates(page) {
4571
4831
  if (!items && packageId && Number.isFinite(declaredQuantity) && declaredQuantity > 0) {
4572
4832
  items = [{ package_id: packageId, quantity: declaredQuantity }];
4573
4833
  }
4574
- cards.push({ bundle_id: bundleId, package_id: packageId, items });
4834
+ const candidate = { bundle_id: bundleId, package_id: packageId, items };
4835
+ if (withPlacement) {
4836
+ Object.assign(candidate, {
4837
+ group: groupOf(card),
4838
+ selected: isSelected(card),
4839
+ toggle: Boolean(element.closest("[data-next-package-toggle], [data-next-toggle-card], [data-next-bump]")),
4840
+ element_index: index,
4841
+ });
4842
+ }
4843
+ cards.push(candidate);
4575
4844
  }
4576
4845
  return cards;
4577
- }).catch(() => []);
4846
+ }, placement).catch(() => []);
4578
4847
  }
4579
4848
 
4580
4849
  function packageCardIdentity(candidate) {
@@ -4606,9 +4875,15 @@ function packageCardClickSelector(candidate) {
4606
4875
  function resolvePackageCardCandidate(candidates, item) {
4607
4876
  const ref = String(item?.packageId || "");
4608
4877
  const quantity = Number(item?.quantity || 1);
4609
- const matchingRef = (candidates || []).filter((candidate) => (
4878
+ const identityMatches = (candidates || []).filter((candidate) => (
4610
4879
  String(candidate?.bundle_id || "") === ref || String(candidate?.package_id || "") === ref
4611
4880
  ));
4881
+ // A bundle card's package identity is also the package its
4882
+ // data-next-bundle-items declares, whether or not an inner node repeats it.
4883
+ const matchingRef = (candidates || []).filter((candidate) => (
4884
+ identityMatches.includes(candidate)
4885
+ || (Array.isArray(candidate?.items) && candidate.items.some((entry) => String(entry?.package_id || "") === ref))
4886
+ ));
4612
4887
  if (!matchingRef.length) {
4613
4888
  throw new Error(`--select-package ${ref}: no rendered card exposes that package or bundle identity`);
4614
4889
  }
@@ -4619,18 +4894,22 @@ function resolvePackageCardCandidate(candidates, item) {
4619
4894
  throw new Error(`--select-package ${ref}: rendered bundle identity is ambiguous across ${exactBundle.length} cards`);
4620
4895
  }
4621
4896
 
4622
- const exactComposition = matchingRef.filter((candidate) => (
4897
+ // A card matches when one of its declared items is the package at that
4898
+ // quantity, however many items it declares. A card declaring that item
4899
+ // alone is preferred over one declaring it among others (a 2x tier over a
4900
+ // 2x-plus-accessory kit); the kit stays reachable by its bundle id.
4901
+ const declaringItem = matchingRef.filter((candidate) => (
4623
4902
  Array.isArray(candidate?.items)
4624
- && candidate.items.length === 1
4625
- && String(candidate.items[0]?.package_id || "") === ref
4626
- && Number(candidate.items[0]?.quantity) === quantity
4903
+ && candidate.items.some((entry) => String(entry?.package_id || "") === ref && Number(entry?.quantity) === quantity)
4627
4904
  ));
4905
+ const declaringOnlyItem = declaringItem.filter((candidate) => candidate.items.length === 1);
4906
+ const exactComposition = declaringOnlyItem.length ? declaringOnlyItem : declaringItem;
4628
4907
  if (exactComposition.length === 1) return exactComposition[0];
4629
4908
  if (exactComposition.length > 1) {
4630
4909
  throw new Error(`--select-package ${ref}:${quantity}: rendered package composition is ambiguous across ${exactComposition.length} cards`);
4631
4910
  }
4632
- const unknownComposition = matchingRef.filter((candidate) => !Array.isArray(candidate?.items));
4633
- if (quantity === 1 && matchingRef.length === 1 && unknownComposition.length === 1) return unknownComposition[0];
4911
+ const unknownComposition = identityMatches.filter((candidate) => !Array.isArray(candidate?.items));
4912
+ if (quantity === 1 && identityMatches.length === 1 && unknownComposition.length === 1) return unknownComposition[0];
4634
4913
  throw new Error(`--select-package ${ref}:${quantity}: no rendered card has exactly package ${ref} at quantity ${quantity}`);
4635
4914
  }
4636
4915
 
@@ -5076,21 +5355,77 @@ async function revealCheckoutForm(page, options = {}) {
5076
5355
  return true;
5077
5356
  }
5078
5357
 
5079
- async function fillPaymentFields(page, args) {
5358
+ async function fillPaymentFields(page, args, { readyTimeoutMs = CARD_READY_TIMEOUT_MS } = {}) {
5080
5359
  await clickCreditPaymentMethod(page);
5081
5360
  await selectByField(page, "exp-month", stringArg(args["test-exp-month"]) || DEFAULT_TEST_EXP_MONTH);
5082
5361
  await selectYear(page, stringArg(args["test-exp-year"]) || DEFAULT_TEST_EXP_YEAR);
5083
5362
 
5084
5363
  const card = normalizeCard(stringArg(args["test-card"]) || DEFAULT_TEST_CARD);
5085
5364
  const cvv = stringArg(args["test-cvv"]) || DEFAULT_TEST_CVV;
5086
- const numberInput = page.frameLocator('iframe[id^="spreedly-number-frame"]').locator("input").first();
5087
- const cvvInput = page.frameLocator('iframe[id^="spreedly-cvv-frame"]').locator("input").first();
5088
- await numberInput.click();
5089
- await numberInput.pressSequentially(card, { delay: 20 });
5090
- await cvvInput.click();
5091
- await cvvInput.pressSequentially(cvv, { delay: 20 });
5092
- await page.locator("body").click({ position: { x: 20, y: 20 } }).catch(() => {});
5093
- await page.waitForTimeout(500);
5365
+ const frames = await cardFrames(page);
5366
+ const readyWaitMs = await waitForCardFieldsReady(page, readyTimeoutMs);
5367
+ const numberInput = page.frameLocator(CARD_NUMBER_FRAME).locator("input").first();
5368
+ const cvvInput = page.frameLocator(CARD_CVV_FRAME).locator("input").first();
5369
+ const typed = async (input) => normalizeCard(await input.inputValue());
5370
+ let attempts = 0;
5371
+ let held = null;
5372
+ while (attempts < CARD_TYPE_ATTEMPTS) {
5373
+ attempts++;
5374
+ if (attempts > 1) {
5375
+ await numberInput.fill("");
5376
+ await cvvInput.fill("");
5377
+ }
5378
+ await numberInput.click();
5379
+ await numberInput.pressSequentially(card, { delay: 20 });
5380
+ await cvvInput.click();
5381
+ await cvvInput.pressSequentially(cvv, { delay: 20 });
5382
+ await page.locator("body").click({ position: { x: 20, y: 20 } }).catch(() => {});
5383
+ await page.waitForTimeout(500);
5384
+ held = { number: await typed(numberInput), cvv: await typed(cvvInput) };
5385
+ if (held.number === card && held.cvv === normalizeCard(cvv)) {
5386
+ return { evidence: { ...frames, ready_wait_ms: readyWaitMs, attempts } };
5387
+ }
5388
+ }
5389
+ throw new Error(`card fields did not keep the typed card after ${attempts} attempts: number field holds ${held.number.length} digit(s) ending ${held.number.slice(-4) || "(empty)"} (typed ${card.length} ending ${card.slice(-4)}), CVV field holds ${held.cvv.length} digit(s)`);
5390
+ }
5391
+
5392
+ // Typing into the card fields before the SDK reports them ready can lose
5393
+ // keystrokes: on SDK 0.4.41 the Spreedly-hosted number input exists before
5394
+ // its script has loaded, and digits typed in that window are dropped (seen
5395
+ // as a number missing its leading digits, which the SDK then refuses without
5396
+ // tokenizing, so the order never posts). The checkout form carries
5397
+ // next-loading-spreedly from before the card iframes mount until the fields
5398
+ // are ready, in 0.4.38 through 0.4.41, so wait for it to clear. A page whose
5399
+ // SDK never set it is ready at once. The SDK also clears it when the card
5400
+ // script fails to load, so a class still set after the wait means the card
5401
+ // script never answered; the step refuses by name rather than type into
5402
+ // fields that may drop the number. `timeoutMs` is CARD_READY_TIMEOUT_MS in
5403
+ // every production run; only an in-process test passes a shorter wait.
5404
+ async function waitForCardFieldsReady(page, timeoutMs = CARD_READY_TIMEOUT_MS) {
5405
+ const started = Date.now();
5406
+ await page.waitForFunction(() => !document.querySelector(".next-loading-spreedly"), null, { timeout: timeoutMs }).catch((error) => {
5407
+ if (error?.name !== "TimeoutError") throw error;
5408
+ 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`);
5409
+ });
5410
+ return Date.now() - started;
5411
+ }
5412
+
5413
+ // The card iframes the step is about to type into, once the SDK has mounted
5414
+ // them. Exactly one of each, of one generation, or the step fails by name.
5415
+ async function cardFrames(page) {
5416
+ await page.locator(CARD_NUMBER_FRAME).first().waitFor({ state: "attached" });
5417
+ await page.locator(CARD_CVV_FRAME).first().waitFor({ state: "attached" });
5418
+ const ids = async (selector) => page.locator(selector).evaluateAll((nodes) => nodes.map((node) => node.id));
5419
+ const number = await ids(CARD_NUMBER_FRAME);
5420
+ const cvv = await ids(CARD_CVV_FRAME);
5421
+ const all = [...number, ...cvv];
5422
+ const generation = all.every((id) => id.startsWith("spreedly-hosted-"))
5423
+ ? "spreedly-hosted"
5424
+ : all.every((id) => /^spreedly-(number|cvv)-frame/.test(id)) ? "spreedly-iframe-v1" : "mixed";
5425
+ if (number.length !== 1 || cvv.length !== 1 || generation === "mixed") {
5426
+ throw new Error(`expected one card number iframe and one CVV iframe of one generation; found number=[${number.join(", ")}] cvv=[${cvv.join(", ")}]`);
5427
+ }
5428
+ return { generation, number_frame_id: number[0], cvv_frame_id: cvv[0] };
5094
5429
  }
5095
5430
 
5096
5431
  async function clickCreditPaymentMethod(page) {
@@ -5355,23 +5690,29 @@ async function clickControl(locator, { timeout, forceFallback = true, perpetual
5355
5690
  // single offer). Clicking the hidden action fails as not visible, so the
5356
5691
  // visible proxy is clicked instead, but only while the offer holds an SDK action
5357
5692
  // for it to forward to: otherwise the hidden action is clicked and fails as before.
5693
+ // The proxy forwards to the action of its own spelling.
5358
5694
  async function shopperUpsellControl(page, action) {
5359
- const actions = page.locator(`[data-next-upsell-action="${action}"]`);
5695
+ const actions = page.locator(upsellActionSelector(action));
5360
5696
  if (!await actions.count().catch(() => 0)) return actions.first();
5361
5697
  const visibleAction = actions.filter({ visible: true }).first();
5362
5698
  if (await visibleAction.count().catch(() => 0)) return visibleAction;
5363
5699
  // The proxy forwards to `[data-next-upsell="offer"] [data-next-upsell-action]`
5364
5700
  // (upsells.js), so only an action inside the offer is a target.
5365
- const inOffer = page.locator(`[data-next-upsell="offer"] [data-next-upsell-action="${action}"]`);
5701
+ const inOffer = page.locator(`[data-next-upsell="offer"] ${upsellActionSelector(action)}`);
5366
5702
  const proxy = page.locator(`[data-upsell-proxy="${action}"]`).filter({ visible: true }).first();
5367
5703
  if (await inOffer.count().catch(() => 0) && await proxy.count().catch(() => 0)) return proxy;
5368
5704
  return actions.first();
5369
5705
  }
5370
5706
 
5371
- async function clickUpsellPath(page, path, { trace = null } = {}) {
5707
+ // `clickTimeoutMs` and `mutationTimeoutMs` are UPSELL_CLICK_TIMEOUT_MS and
5708
+ // UPSELL_MUTATION_TIMEOUT_MS in every production run; only an in-process test
5709
+ // that waits one of them out passes a shorter bound.
5710
+ async function clickUpsellPath(page, path, { trace = null, clickTimeoutMs, mutationTimeoutMs } = {}) {
5711
+ const clickMs = shorterBound(clickTimeoutMs, UPSELL_CLICK_TIMEOUT_MS);
5712
+ const mutationMs = shorterBound(mutationTimeoutMs, UPSELL_MUTATION_TIMEOUT_MS);
5372
5713
  const offerUrl = safePageUrl(page);
5373
- const action = path === "accept" ? "add" : "skip";
5374
- const selector = `[data-next-upsell-action="${action}"]`;
5714
+ const action = await presentUpsellAction(page, path === "accept" ? "accept" : "decline");
5715
+ const selector = upsellActionSelector(action);
5375
5716
  const control = await shopperUpsellControl(page, action);
5376
5717
  if (!await control.count().catch(() => 0)) {
5377
5718
  return { path, clicked: false, error: `Missing upsell control ${selector}` };
@@ -5404,10 +5745,10 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
5404
5745
  if (!root || root.method() !== "POST" || !isOrderUpsellsUrl(root.url())) return false;
5405
5746
  const startedAt = responseRequestStartedAt(response);
5406
5747
  return startedAt === null || startedAt >= armedAt;
5407
- }, { timeout: UPSELL_MUTATION_TIMEOUT_MS + (perpetual ? 0 : UPSELL_CLICK_TIMEOUT_MS) }).catch(() => null)
5748
+ }, { timeout: mutationMs + (perpetual ? 0 : clickMs) }).catch(() => null)
5408
5749
  : Promise.resolve(null);
5409
5750
  trace?.markClickAttempted();
5410
- await clickControl(control, { timeout: UPSELL_CLICK_TIMEOUT_MS, perpetual });
5751
+ await clickControl(control, { timeout: clickMs, perpetual });
5411
5752
  trace?.markClickCompleted();
5412
5753
  const mutationResponse = await mutationPromise;
5413
5754
  const bodyRead = mutationResponse
@@ -5536,14 +5877,37 @@ async function checkoutDisplayEvidence(browserPage) {
5536
5877
  text: clean(row.textContent).slice(0, 160),
5537
5878
  }));
5538
5879
 
5539
- const selectedBundles = Array.from(document.querySelectorAll("[data-next-bundle-card]"))
5880
+ // A bundle card's packages are the ones its data-next-bundle-items
5881
+ // declares, at the per-package quantity the SDK puts in the cart. Its
5882
+ // data-next-bundle-id names the tier, not a package an order line can
5883
+ // carry, so it stands in only for a card that declares no items.
5884
+ const declaredItems = (card) => {
5885
+ try {
5886
+ const parsed = JSON.parse(card.getAttribute("data-next-bundle-items") || "null");
5887
+ if (!Array.isArray(parsed)) return null;
5888
+ const items = parsed.map((entry) => ({
5889
+ package_id: String(entry?.packageId ?? entry?.package_id ?? ""),
5890
+ quantity: Number(entry?.quantity ?? 1),
5891
+ })).filter((entry) => entry.package_id && Number.isFinite(entry.quantity) && entry.quantity > 0);
5892
+ return items.length ? items : null;
5893
+ } catch {
5894
+ return null;
5895
+ }
5896
+ };
5897
+ const selectedBundleCards = Array.from(document.querySelectorAll("[data-next-bundle-card]"))
5540
5898
  .filter((card) => (
5541
5899
  card.classList.contains("next-selected")
5542
5900
  || card.getAttribute("data-next-selected") === "true"
5543
5901
  || card.getAttribute("aria-checked") === "true"
5544
5902
  || card.querySelector('input[type="radio"], input[type="checkbox"]')?.checked === true
5545
- ))
5546
- .map(idOf)
5903
+ ));
5904
+ // A card whose data-next-bundle-items is malformed or empty is kept with
5905
+ // items: null, so reconciliation knows a selected card declared nothing.
5906
+ const selectedBundleItems = selectedBundleCards
5907
+ .filter((card) => card.hasAttribute("data-next-bundle-items"))
5908
+ .map((card) => ({ bundle_id: card.getAttribute("data-next-bundle-id") || null, items: declaredItems(card) }));
5909
+ const selectedBundles = selectedBundleCards
5910
+ .flatMap((card) => declaredItems(card)?.map((item) => item.package_id) || [idOf(card)])
5547
5911
  .filter(Boolean);
5548
5912
 
5549
5913
  const activeToggles = Array.from(document.querySelectorAll("[data-next-package-toggle], [data-next-toggle-card], [data-next-bump]"))
@@ -5571,6 +5935,9 @@ async function checkoutDisplayEvidence(browserPage) {
5571
5935
  summary_present: summaries.length > 0,
5572
5936
  summary_rows: summaryRows.slice(0, 40),
5573
5937
  selected_bundle_package_ids: [...new Set(selectedBundles)],
5938
+ // Uncapped, like selected_bundle_package_ids: every selected card's
5939
+ // items are reconciled.
5940
+ selected_bundle_items: selectedBundleItems,
5574
5941
  active_toggle_package_ids: [...new Set(activeToggles)],
5575
5942
  discount_rows: discountRows.slice(0, 20),
5576
5943
  total_text: totalNode ? clean(totalNode.textContent) : null,
@@ -5579,6 +5946,7 @@ async function checkoutDisplayEvidence(browserPage) {
5579
5946
  summary_present: false,
5580
5947
  summary_rows: [],
5581
5948
  selected_bundle_package_ids: [],
5949
+ selected_bundle_items: [],
5582
5950
  active_toggle_package_ids: [],
5583
5951
  discount_rows: [],
5584
5952
  total_text: null,
@@ -5621,7 +5989,29 @@ function displayedPackageIds(display) {
5621
5989
  };
5622
5990
  }
5623
5991
 
5624
- function reconcileOrderAgainstDisplay({ lines = [], display = null, events = null, selected_packages = [] } = {}) {
5992
+ // The packages and per-package quantities the selected bundle card(s)
5993
+ // declare. A package two selected cards declare at different quantities is
5994
+ // left out of `quantities`: the display itself does not say which one holds.
5995
+ // A card whose items are malformed or empty declares nothing.
5996
+ function selectedBundleDeclarations(display) {
5997
+ const declared = new Map();
5998
+ for (const card of display?.selected_bundle_items || []) {
5999
+ for (const item of card?.items || []) {
6000
+ const ref = String(item?.package_id ?? "");
6001
+ const quantity = Number(item?.quantity);
6002
+ if (!ref || !Number.isFinite(quantity) || quantity <= 0) continue;
6003
+ declared.set(ref, [...new Set([...(declared.get(ref) || []), quantity])]);
6004
+ }
6005
+ }
6006
+ return {
6007
+ refs: new Set(declared.keys()),
6008
+ quantities: [...declared]
6009
+ .filter(([, quantities]) => quantities.length === 1)
6010
+ .map(([packageId, [quantity]]) => ({ packageId, quantity })),
6011
+ };
6012
+ }
6013
+
6014
+ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = null, selected_packages = [], requested_cart = [] } = {}) {
5625
6015
  if (!display) {
5626
6016
  return { comparable: false, reason: "checkout display evidence was not captured for this order" };
5627
6017
  }
@@ -5643,9 +6033,37 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5643
6033
  }
5644
6034
 
5645
6035
  const nonUpsellLines = (lines || []).filter((line) => !line?.is_upsell);
6036
+ // A two-unit bundle card puts its package in the cart at quantity two, so a
6037
+ // line resolves at the quantity the selected card declares; --select-package
6038
+ // still wins for the packages it names.
6039
+ const declarations = selectedBundleDeclarations(display);
6040
+ const strictRefs = new Set((selected_packages || []).map((item) => String(item.packageId)));
6041
+ const resolutionPackages = [
6042
+ ...(selected_packages || []),
6043
+ ...declarations.quantities.filter((item) => !strictRefs.has(item.packageId)),
6044
+ ];
6045
+ // Without --select-package, every explicit --cart <pkg>:<qty> is judged on
6046
+ // its own: the order must carry that package at that quantity, whichever
6047
+ // card, if any, declares it. Beside --select-package the order is judged
6048
+ // against the card that flag chose; a --cart ref it left unapplied is named
6049
+ // by selected_bundle.
6050
+ const requestedCart = (selected_packages || []).length ? [] : (requested_cart || [])
6051
+ .filter((item) => item?.quantityExplicit);
6052
+ const requestedCartByRef = new Map(requestedCart.map((item) => [String(item.packageId), Number(item.quantity || 1)]));
5646
6053
  const displayed = new Set(resolved.displayed_package_ids);
5647
6054
  const summaryIds = new Set(resolved.summary_package_ids);
5648
6055
  const matchedSummaryIds = new Set();
6056
+ // Refs a non-upsell line resolved to. A line resolves to a package only at
6057
+ // the quantity --select-package names or the selected card declares, so
6058
+ // for a declared item this proves the quantity too.
6059
+ const chargedRefs = new Set();
6060
+ // --cart refs a line resolved to, or whose quantity mismatch is named.
6061
+ const answeredCartRefs = new Set();
6062
+ const unresolvedLines = [];
6063
+ const extra = [];
6064
+ const unresolved = [];
6065
+ const matchedQuantities = [];
6066
+ const quantityMismatches = [];
5649
6067
  // A checkout order bump persists with is_upsell: true (the platform's
5650
6068
  // reporting tag), yet the checkout summary displays it. Such a line charges
5651
6069
  // a displayed row; an is_upsell line the summary does not show is a
@@ -5653,23 +6071,24 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5653
6071
  let bumpLineCount = 0;
5654
6072
  for (const line of (lines || []).filter((entry) => entry?.is_upsell)) {
5655
6073
  const resolution = events ? campaignPackageResolutionForLine(events, line, {
5656
- selected_packages,
6074
+ selected_packages: resolutionPackages,
5657
6075
  preferred_refs: resolved.summary_package_ids,
5658
6076
  }) : null;
5659
6077
  const ref = resolution?.pkg?.ref_id == null ? null : String(resolution.pkg.ref_id);
5660
6078
  if (ref && summaryIds.has(ref)) {
5661
6079
  matchedSummaryIds.add(ref);
5662
6080
  bumpLineCount += 1;
6081
+ if (requestedCartByRef.has(ref)) {
6082
+ answeredCartRefs.add(ref);
6083
+ const mismatch = packageQuantityMismatch(ref, Number(resolution.pkg.qty ?? resolution.pkg.quantity), requestedCartByRef.get(ref), Number(line.quantity));
6084
+ if (mismatch) quantityMismatches.push(mismatch);
6085
+ }
5663
6086
  }
5664
6087
  }
5665
- const extra = [];
5666
- const unresolved = [];
5667
- const matchedQuantities = [];
5668
- const quantityMismatches = [];
5669
6088
 
5670
6089
  for (const line of nonUpsellLines) {
5671
6090
  const resolution = events ? campaignPackageResolutionForLine(events, line, {
5672
- selected_packages,
6091
+ selected_packages: resolutionPackages,
5673
6092
  preferred_refs: resolved.displayed_package_ids,
5674
6093
  }) : null;
5675
6094
  const meta = resolution?.pkg || null;
@@ -5679,10 +6098,15 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5679
6098
  // same tolerance linePriceDeltaEvidence applies). An unresolvable line
5680
6099
  // cannot prove a stray charge, so it is reported, never counted as one.
5681
6100
  unresolved.push({ title: line.title, quantity: line.quantity });
5682
- const mismatch = requestedPackageQuantityMismatch(events, line, selected_packages);
5683
- if (mismatch) quantityMismatches.push(mismatch);
6101
+ unresolvedLines.push(line);
6102
+ const mismatch = requestedPackageQuantityMismatch(events, line, [...selected_packages, ...requestedCart]);
6103
+ if (mismatch) {
6104
+ quantityMismatches.push(mismatch);
6105
+ answeredCartRefs.add(mismatch.package_ref_id);
6106
+ }
5684
6107
  continue;
5685
6108
  }
6109
+ chargedRefs.add(ref);
5686
6110
  const unitQuantity = Number(meta.qty ?? meta.quantity);
5687
6111
  matchedQuantities.push({
5688
6112
  package_ref_id: ref,
@@ -5690,6 +6114,13 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5690
6114
  purchase_multiplier: resolution.purchaseMultiplier,
5691
6115
  persisted_quantity: Number(line.quantity),
5692
6116
  });
6117
+ // The line resolved at the quantity the page selected; --cart asked for
6118
+ // another, so the order is not the one requested.
6119
+ if (requestedCartByRef.has(ref)) {
6120
+ answeredCartRefs.add(ref);
6121
+ const mismatch = packageQuantityMismatch(ref, unitQuantity, requestedCartByRef.get(ref), Number(line.quantity));
6122
+ if (mismatch) quantityMismatches.push(mismatch);
6123
+ }
5693
6124
  if (displayed.has(ref)) {
5694
6125
  if (summaryIds.has(ref)) matchedSummaryIds.add(ref);
5695
6126
  continue;
@@ -5701,8 +6132,26 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5701
6132
  price: line.price,
5702
6133
  });
5703
6134
  }
6135
+ // An explicit --cart ref the order carries no line for is not the order
6136
+ // requested either, as when a swap-mode selector kept only the last --cart
6137
+ // ref's card. An unresolved line that is that package at that quantity
6138
+ // still answers it.
6139
+ const packages = latestEventPackages(events);
6140
+ if (packages) {
6141
+ for (const [ref, purchaseMultiplier] of requestedCartByRef) {
6142
+ if (answeredCartRefs.has(ref)) continue;
6143
+ const pkg = packages.find((candidate) => String(candidate?.ref_id ?? candidate?.package_id ?? candidate?.id ?? "") === ref);
6144
+ if (pkg && unresolvedLines.some((line) => packageMatchesLine(pkg, line, { purchaseMultiplier }))) continue;
6145
+ quantityMismatches.push(absentPackageMismatch(ref, Number(pkg?.qty ?? pkg?.quantity), purchaseMultiplier));
6146
+ }
6147
+ }
5704
6148
 
5705
- const missing = [...summaryIds].filter((id) => !matchedSummaryIds.has(id));
6149
+ // Every item a selected bundle card declares must be charged, whether or not
6150
+ // the summary renders a row for it.
6151
+ const missing = [
6152
+ ...[...summaryIds].filter((id) => !matchedSummaryIds.has(id)),
6153
+ ...[...declarations.refs].filter((id) => !summaryIds.has(id) && !chargedRefs.has(id)),
6154
+ ];
5706
6155
  return {
5707
6156
  comparable: true,
5708
6157
  ok: extra.length === 0 && missing.length === 0 && quantityMismatches.length === 0,
@@ -5728,25 +6177,49 @@ function requestedPackageQuantityMismatch(events, line, selectedPackages) {
5728
6177
  const ref = String(selected.packageId);
5729
6178
  const pkg = packages.find((candidate) => String(candidate?.ref_id ?? candidate?.package_id ?? candidate?.id ?? "") === ref);
5730
6179
  if (!pkg || !packageIdentityMatchesLine(pkg, line)) continue;
5731
- const unitQuantity = Number(pkg.qty ?? pkg.quantity);
5732
- const purchaseMultiplier = Number(selected.quantity || 1);
5733
- const requestedQuantity = unitQuantity * purchaseMultiplier;
5734
- const persistedQuantity = Number(line?.quantity || 0);
5735
- if (Number.isFinite(requestedQuantity) && requestedQuantity !== persistedQuantity) {
5736
- return {
5737
- package_ref_id: ref,
5738
- unit_quantity: unitQuantity,
5739
- purchase_multiplier: purchaseMultiplier,
5740
- requested_quantity: requestedQuantity,
5741
- persisted_quantity: persistedQuantity,
5742
- reason: `package ${ref} requested ${requestedQuantity} unit(s) (${unitQuantity} per package × ${purchaseMultiplier}) but persisted ${persistedQuantity}`,
5743
- };
5744
- }
6180
+ const mismatch = packageQuantityMismatch(ref, Number(pkg.qty ?? pkg.quantity), Number(selected.quantity || 1), Number(line?.quantity || 0));
6181
+ if (mismatch) return mismatch;
5745
6182
  }
5746
6183
  }
5747
6184
  return null;
5748
6185
  }
5749
6186
 
6187
+ function packageQuantityMismatch(ref, unitQuantity, purchaseMultiplier, persistedQuantity) {
6188
+ const requestedQuantity = unitQuantity * purchaseMultiplier;
6189
+ if (!Number.isFinite(requestedQuantity) || requestedQuantity === persistedQuantity) return null;
6190
+ return {
6191
+ package_ref_id: ref,
6192
+ unit_quantity: unitQuantity,
6193
+ purchase_multiplier: purchaseMultiplier,
6194
+ requested_quantity: requestedQuantity,
6195
+ persisted_quantity: persistedQuantity,
6196
+ reason: `package ${ref} requested ${requestedQuantity} unit(s) (${unitQuantity} per package × ${purchaseMultiplier}) but persisted ${persistedQuantity}`,
6197
+ };
6198
+ }
6199
+
6200
+ function absentPackageMismatch(ref, unitQuantity, purchaseMultiplier) {
6201
+ const known = Number.isFinite(unitQuantity);
6202
+ return {
6203
+ package_ref_id: ref,
6204
+ unit_quantity: known ? unitQuantity : null,
6205
+ purchase_multiplier: purchaseMultiplier,
6206
+ requested_quantity: known ? unitQuantity * purchaseMultiplier : null,
6207
+ persisted_quantity: 0,
6208
+ reason: known
6209
+ ? `package ${ref} requested ${unitQuantity * purchaseMultiplier} unit(s) (${unitQuantity} per package × ${purchaseMultiplier}) but the order carries no line for it`
6210
+ : `package ${ref} requested at quantity ${purchaseMultiplier} but the order carries no line for it`,
6211
+ };
6212
+ }
6213
+
6214
+ function latestEventPackages(events) {
6215
+ const responses = events?.responses || [];
6216
+ for (let index = responses.length - 1; index >= 0; index -= 1) {
6217
+ const packages = responses[index]?.body?.packages;
6218
+ if (Array.isArray(packages)) return packages;
6219
+ }
6220
+ return null;
6221
+ }
6222
+
5750
6223
  function packageIdentityMatchesLine(pkg, line) {
5751
6224
  if (pkg?.product_sku && line?.sku) return normalizeLabel(pkg.product_sku) === normalizeLabel(line.sku);
5752
6225
  if (pkg?.product_variant_id != null && line?.variant_id != null) return Number(pkg.product_variant_id) === Number(line.variant_id);
@@ -5790,6 +6263,9 @@ function orderDisplayParityAssertion(page, planIdentifier, order) {
5790
6263
  if (reconciliation.missing.length) {
5791
6264
  parts.push(`displayed but never charged: ${reconciliation.missing.join(", ")}`);
5792
6265
  }
6266
+ if (reconciliation.quantity_mismatches?.length) {
6267
+ parts.push(`ordered quantity differs from the request: ${reconciliation.quantity_mismatches.map((entry) => entry.reason).join("; ")}`);
6268
+ }
5793
6269
  return assertion({
5794
6270
  ...base,
5795
6271
  status: STATUS.FAIL,
@@ -7596,7 +8072,7 @@ function extractReceiptLines(order) {
7596
8072
 
7597
8073
  async function waitForUpsellPageReady(page, args) {
7598
8074
  const timeoutMs = numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS);
7599
- await page.locator('[data-next-upsell], [data-next-upsell-action="add"], [data-next-upsell-action="skip"]').first()
8075
+ await page.locator(`[data-next-upsell], ${upsellActionSelector(...UPSELL_ACTION_SPELLINGS.accept, ...UPSELL_ACTION_SPELLINGS.decline)}`).first()
7600
8076
  .waitFor({ state: "visible", timeout: timeoutMs })
7601
8077
  .catch(() => {});
7602
8078
  await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
@@ -8046,6 +8522,26 @@ function numberArg(value, fallback) {
8046
8522
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
8047
8523
  }
8048
8524
 
8525
+ // A bound an in-process test seam passes, as the Polish deadlines take one
8526
+ // (boundedPolishDeadline): `value` when it is a positive safe integer no
8527
+ // larger than the production `fallback`, else `fallback`. A seam can shorten
8528
+ // a production wait, never lengthen it or switch it off (Playwright reads a
8529
+ // 0 timeout as none).
8530
+ const shorterBound = boundedPolishDeadline;
8531
+
8532
+ // The content parameter leg's limits with options.contentParamLimits (an
8533
+ // in-process test seam, never set from argv) applied field by field, each
8534
+ // only where it is no larger than CONTENT_PARAM_LIMITS: a time bound is a
8535
+ // shorterBound, and maxPairs may also be zero, as runContentParamChecks
8536
+ // allows.
8537
+ function contentParamLimitsFrom(overrides) {
8538
+ return Object.fromEntries(Object.entries(CONTENT_PARAM_LIMITS).map(([field, production]) => {
8539
+ const value = overrides?.[field];
8540
+ if (field !== "maxPairs") return [field, shorterBound(value, production)];
8541
+ return [field, Number.isSafeInteger(value) && value >= 0 && value <= production ? value : production];
8542
+ }));
8543
+ }
8544
+
8049
8545
  function trim(value) {
8050
8546
  return String(value || "").replace(/\s+/g, " ").trim();
8051
8547
  }
@@ -8087,9 +8583,12 @@ export const __qaBrowserTestHooks = Object.freeze({
8087
8583
  primaryCtaAssertionFromEvidence,
8088
8584
  inspectPrimaryCta,
8089
8585
  primaryCtaInspectionScript,
8586
+ primaryCtaDeclaredRoutes,
8587
+ primaryCtaRoutelessForms,
8090
8588
  clickCouponApplyControl,
8091
8589
  isOrderUpsellsUrl,
8092
8590
  clickUpsellPath,
8591
+ waitForUpsellPageReady,
8093
8592
  isPerpetuallyAnimated,
8094
8593
  readJsonResponseBody,
8095
8594
  readJsonResponseBodyWithin,
@@ -8148,6 +8647,7 @@ export const __qaBrowserTestHooks = Object.freeze({
8148
8647
  redactUrlQuery,
8149
8648
  enterCartViaLanding,
8150
8649
  openCheckoutForPath,
8650
+ pathCurrencyLoad,
8151
8651
  createSelectorProbeCache,
8152
8652
  RESIDUE_PAGE_TYPES,
8153
8653
  computedStyleResidueAssertions,