@nextcommerce/campaigns-os 1.54.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 (53) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/CONTEXT.md +8 -5
  3. package/README.md +26 -12
  4. package/compatibility.json +1 -1
  5. package/contracts/release-ledger.json +441 -0
  6. package/contracts/supported-surface.json +2 -2
  7. package/docs/brand-theme-bridge.md +10 -0
  8. package/docs/build-packet.md +35 -19
  9. package/docs/campaigns-os-build-flow.md +1 -1
  10. package/docs/effects.md +2 -5
  11. package/docs/local-setup.md +1 -1
  12. package/docs/orientation-contract-reference.md +1 -1
  13. package/docs/qa-and-test-orders.md +55 -11
  14. package/docs/runtime-readiness.md +1 -1
  15. package/docs/skills-revision.md +10 -10
  16. package/docs/supported-surface.md +1 -1
  17. package/docs/workflow-findings-sidecar.md +57 -2
  18. package/package.json +3 -1
  19. package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
  20. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  21. package/skills/campaign-readback-classification/SKILL.md +3 -3
  22. package/skills/campaign-run-evidence/SKILL.md +3 -3
  23. package/skills/contribution-intake/SKILL.md +3 -3
  24. package/skills/next-campaigns-build/SKILL.md +3 -3
  25. package/skills/next-campaigns-os/SKILL.md +3 -3
  26. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  27. package/skills/next-campaigns-polish/SKILL.md +3 -3
  28. package/skills/next-campaigns-qa/SKILL.md +4 -4
  29. package/skills.json +10 -10
  30. package/src/adapter-decision-contract.mjs +26 -12
  31. package/src/brand-theme.mjs +44 -8
  32. package/src/campaign-identity.mjs +33 -4
  33. package/src/cli.mjs +36 -59
  34. package/src/consent.mjs +3 -2
  35. package/src/design-source-publication.mjs +1 -1
  36. package/src/deviation.mjs +2 -1
  37. package/src/doctor/checks.mjs +16 -6
  38. package/src/doctor/next-step.mjs +16 -6
  39. package/src/invocation.mjs +4 -3
  40. package/src/lifecycle.mjs +88 -6
  41. package/src/local-preview-policy.mjs +6 -0
  42. package/src/login.mjs +2 -1
  43. package/src/polish-browser.mjs +8 -2
  44. package/src/polish-node.mjs +6 -1
  45. package/src/polish-readability.mjs +2 -2
  46. package/src/qa-browser.mjs +521 -92
  47. package/src/qa-cart-entry.mjs +29 -6
  48. package/src/qa-node.mjs +144 -39
  49. package/src/qa-verdict-publish.mjs +2 -1
  50. package/src/run-record.mjs +46 -34
  51. package/src/sdk-markup.mjs +41 -1
  52. package/src/stage-ledger.mjs +14 -2
  53. package/src/stage-record.mjs +50 -13
@@ -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,
@@ -159,13 +160,17 @@ export async function runBrowserChecks(topologies, args = {}, options = {}) {
159
160
  // Content parameters (options.spec analytics.params.content), after the
160
161
  // page checks: every load in its own fresh context with the same options,
161
162
  // closed after the load. The rows go to options.qcResults; their verdict
162
- // 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.
163
167
  if (Array.isArray(options.qcResults)) {
164
168
  const contentParams = await runContentParamChecks({
165
169
  topologies,
166
170
  spec: options.spec,
167
171
  newContext: () => browser.newContext(contextOptions),
168
172
  withQueryParam,
173
+ limits: contentParamLimitsFrom(options.contentParamLimits),
169
174
  });
170
175
  options.qcResults.push(...contentParams.rows);
171
176
  assertions.push(...contentParams.assertions);
@@ -1313,7 +1318,7 @@ function sdkDebuggerEligible(page) {
1313
1318
 
1314
1319
  async function primaryCtaVisualAssertions(browserPage, page) {
1315
1320
  if (!primaryCtaCheckEligible(page)) return [];
1316
- const evidence = await inspectPrimaryCta(browserPage, page.expected_next_url);
1321
+ const evidence = await inspectPrimaryCta(browserPage, page.expected_next_url, primaryCtaDeclaredRoutes(page), primaryCtaRoutelessForms(page));
1317
1322
  return [primaryCtaAssertionFromEvidence(page, evidence)];
1318
1323
  }
1319
1324
 
@@ -1321,6 +1326,52 @@ function primaryCtaCheckEligible(page) {
1321
1326
  return Boolean(page?.expected_next_url);
1322
1327
  }
1323
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
+
1324
1375
  // Candidate CTAs: anything clickable, plus every SDK action control and the
1325
1376
  // SDK cart-entry control (the same locator the ladder's entry step and
1326
1377
  // advanceToCheckoutForm use). Only attributes the SDK declares are listed —
@@ -1336,19 +1387,21 @@ const PRIMARY_CTA_SELECTOR = ["a[href]", "button", "[role='button']", "[data-nex
1336
1387
  // stay free of module-scope references: the text is run in a fresh context by
1337
1388
  // a test (primary-CTA inspection script is self-contained) that would surface
1338
1389
  // a leaked identifier as a ReferenceError.
1339
- function primaryCtaInspectionScript(expectedUrl) {
1390
+ function primaryCtaInspectionScript(expectedUrl, declaredRoutes = [], checkoutFormSelector = CHECKOUT_FORM_SELECTOR) {
1340
1391
  const args = {
1341
1392
  routeUrl: expectedUrl,
1342
1393
  ctaSelector: PRIMARY_CTA_SELECTOR,
1343
1394
  cartEntrySelector: CART_ENTRY_CONTROL_SELECTOR,
1344
1395
  cartEntryRouteAttribute: CART_ENTRY_ROUTE_ATTRIBUTE,
1396
+ declaredRoutes,
1397
+ checkoutFormSelector,
1345
1398
  ignoredRouteAttributes: [...UNDECLARED_ROUTE_ATTRIBUTES],
1346
1399
  };
1347
1400
  return `(${inspectPrimaryCtaScript.toString()})(${JSON.stringify(args)}, ${cartEntryHrefFor.toString()}, ${contrastToolkit.toString()}, ${generatedTextRenders.toString()})`;
1348
1401
  }
1349
1402
 
1350
- async function inspectPrimaryCta(browserPage, expectedUrl) {
1351
- 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) => ({
1352
1405
  ok: false,
1353
1406
  reason: "inspection_error",
1354
1407
  expected_url: expectedUrl,
@@ -1358,7 +1411,7 @@ async function inspectPrimaryCta(browserPage, expectedUrl) {
1358
1411
  }));
1359
1412
  }
1360
1413
 
1361
- function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, cartEntryRouteAttribute, ignoredRouteAttributes }, hrefForImpl, contrastToolkitImpl, generatedTextImpl) {
1414
+ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, cartEntryRouteAttribute, declaredRoutes, checkoutFormSelector, ignoredRouteAttributes }, hrefForImpl, contrastToolkitImpl, generatedTextImpl) {
1362
1415
  const CTA_SELECTOR = ctaSelector;
1363
1416
  const toolkit = contrastToolkitImpl();
1364
1417
 
@@ -1391,9 +1444,9 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
1391
1444
  .join("");
1392
1445
  return `${tag}${id}${classes}`;
1393
1446
  };
1394
- // The route a control leads to: the shared cart-entry rule, evaluated
1395
- // against this document's origin and base.
1396
- const hrefFor = (element) => hrefForImpl(element, { cartEntrySelector, cartEntryRouteAttribute, origin: location.origin, baseHref: location.href });
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 });
1397
1450
  // Route-shaped attributes the element carries that the rule above does not
1398
1451
  // consult: the undeclared spellings, plus the SDK route attribute on an
1399
1452
  // element that is not an SDK control (a decoy, not a route). Reported, not
@@ -1582,15 +1635,48 @@ function primaryCtaAssertionFromEvidence(page, evidence) {
1582
1635
  });
1583
1636
  }
1584
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
+
1585
1669
  async function renderedUpsellControlAssertions(browserPage, page) {
1586
- const checks = [
1587
- ["accept", "add", page.expected_accept_url],
1588
- ["decline", "skip", page.expected_decline_url],
1589
- ];
1590
1670
  const assertions = [];
1591
- for (const [kind, action, expectedUrl] of checks) {
1671
+ for (const { kind, field } of UPSELL_CONTROL_ROUTES) {
1672
+ const expectedUrl = page[field];
1592
1673
  if (!expectedUrl) continue;
1593
- 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)));
1594
1680
  assertions.push(assertion({
1595
1681
  id: `browser-upsell-control:${page.page_id}:${kind}`,
1596
1682
  family: "browser-runtime",
@@ -1599,7 +1685,7 @@ async function renderedUpsellControlAssertions(browserPage, page) {
1599
1685
  severity: count > 0 ? undefined : SEVERITY.WARN,
1600
1686
  expected: `rendered SDK ${kind} control`,
1601
1687
  actual: count > 0 ? `${count} matching control(s)` : "not found",
1602
- evidence: { selector: `[data-next-upsell-action="${action}"]`, expected_url: expectedUrl },
1688
+ evidence: { selector, expected_url: expectedUrl },
1603
1689
  }));
1604
1690
  }
1605
1691
  return assertions;
@@ -1630,7 +1716,7 @@ async function checkoutPaymentSurfaceAssertions(browserPage, page) {
1630
1716
  card_number_selector: '[data-next-checkout-field="cc-number"], #spreedly-number',
1631
1717
  cvv_selector: '[data-next-checkout-field="cvv"], #spreedly-cvv',
1632
1718
  spreedly_frame_urls: spreedlyFrames.map((frame) => frame.url()).slice(0, 5),
1633
- next_step: "Run --test-order common for typed-card checkout proof (test cards bypass the gateway; no approval needed).",
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).",
1634
1720
  },
1635
1721
  }), assertion({
1636
1722
  id: `browser-payment-geometry:${page.page_id}`,
@@ -3189,8 +3275,10 @@ function createFieldTrace() {
3189
3275
  // before the bounded ladder step starts and updated before each operation that
3190
3276
  // can hang. Its synchronous summary survives when the timeout wins the race.
3191
3277
  function createUpsellActionTrace({ page, events, topologyPlan, stepIndex, path, inspectTimeoutMs = 1000 }) {
3192
- const requestedAction = path === "accept" ? "add" : "skip";
3193
- 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);
3194
3282
  let actionNavigationCount = Array.isArray(events?.navigations) ? events.navigations.length : 0;
3195
3283
  let actionUpsellRequestCount = (events?.requests || []).filter((request) => isOrderUpsellsUrl(request?.url)).length;
3196
3284
  let element = { present: null, visible: null, enabled: null };
@@ -3200,6 +3288,8 @@ function createUpsellActionTrace({ page, events, topologyPlan, stepIndex, path,
3200
3288
  let stepCompleted = false;
3201
3289
 
3202
3290
  const inspect = async () => {
3291
+ requestedAction = await presentUpsellAction(page, kind);
3292
+ selector = upsellActionSelector(requestedAction);
3203
3293
  const control = page.locator(selector).first();
3204
3294
  const count = await control.count().catch(() => 0);
3205
3295
  element = {
@@ -3745,6 +3835,9 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
3745
3835
  reserveOrderCreation,
3746
3836
  selectorProbeCache: options.selectorProbeCache,
3747
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),
3748
3841
  }),
3749
3842
  orderTimeoutMs + ORDER_TIMEOUT_GRACE_MS,
3750
3843
  `order-path:${planId(normalizedPlan)}`,
@@ -3823,11 +3916,12 @@ function stablePrivateCaptureError(value) {
3823
3916
  return projectAnalyticsCaptureError(value, { fallbackKind: "unreadable" });
3824
3917
  }
3825
3918
 
3826
- 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 }) {
3827
3920
  const stepTimeoutMs = numberArg(args["step-timeout-ms"], DEFAULT_STEP_TIMEOUT_MS);
3828
3921
  const budget = () => Math.min(stepTimeoutMs, deadline - Date.now());
3829
3922
  const hostedNow = () => hostedRedirectInfo(safePageUrl(page), checkoutPage.url);
3830
3923
  const selectedPackages = parseCart(args["select-package"]);
3924
+ const currencyLoad = pathCurrencyLoad(args);
3831
3925
  let checkoutDisplay = null;
3832
3926
  let cartBeforeSubmit = null;
3833
3927
 
@@ -3843,7 +3937,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3843
3937
  // page the probe left there rather than loading the same URL a second time,
3844
3938
  // which would fire the SDK's page-view events twice into the same capture.
3845
3939
  const entry = await ladder.run(CART_ENTRY_STEP, () => enterCartViaLanding({
3846
- page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking,
3940
+ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking, currencyLoad,
3847
3941
  }), { timeoutMs: budget() });
3848
3942
  const enteredViaLanding = Boolean(entry && typeof entry === "object" && entry.entered);
3849
3943
  // Responses captured from here on belong to the checkout the ladder drives.
@@ -3851,17 +3945,18 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3851
3945
  // made before the hand-off.
3852
3946
  const checkoutResponseOffset = events.responses.length;
3853
3947
 
3854
- 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() });
3855
3949
  await ladder.run("selected_bundle", async () => {
3856
3950
  // The requested package was selected on the entry page, where the cards
3857
3951
  // live; checkout renders none, so re-running strict selection here would
3858
3952
  // fail for the wrong reason.
3953
+ const selectedCards = [];
3859
3954
  const strictSelection = enteredViaLanding && selectedPackages.length
3860
3955
  ? `selected on entry page: ${entry.package_id || "(no package id on control)"}`
3861
- : await selectRequestedPackages(page, selectedPackages);
3862
- await selectRequestedCart(page, args);
3956
+ : await selectRequestedPackages(page, selectedPackages, selectedCards);
3957
+ const unappliedCart = await selectRequestedCart(page, args, { selectedPackages, selectedCards });
3863
3958
  await advanceToCheckoutForm(page);
3864
- if (strictSelection) return `selected requested package card(s): ${strictSelection}`;
3959
+ if (strictSelection) return `selected requested package card(s): ${[strictSelection, ...unappliedCart].join("; ")}`;
3865
3960
  return parseCart(args.cart).length ? `requested cart ${args.cart}` : "default bundle selection";
3866
3961
  }, { timeoutMs: budget() });
3867
3962
  await ladder.run("bump_state", async () => {
@@ -3884,7 +3979,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3884
3979
  }, { timeoutMs: budget() });
3885
3980
  await ladder.run("card_fields_filled", async () => {
3886
3981
  ensurePageFillable(page, checkoutPage.url);
3887
- return fillPaymentFields(page, args);
3982
+ return fillPaymentFields(page, args, { readyTimeoutMs: cardReadyTimeoutMs });
3888
3983
  }, { timeoutMs: budget() });
3889
3984
  await ladder.run("cart_created", async () => {
3890
3985
  const cart = cartCreationEvidence(events);
@@ -3940,6 +4035,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3940
4035
  display: checkoutDisplay,
3941
4036
  events,
3942
4037
  selected_packages: selectedPackages,
4038
+ requested_cart: parseCart(args.cart),
3943
4039
  });
3944
4040
  order.verification.total_parity = assessOrderTotalParity({
3945
4041
  display: checkoutDisplay,
@@ -4136,7 +4232,7 @@ function createSelectorProbeCache() {
4136
4232
  // loaded it and found a selection surface; either way a second load of the
4137
4233
  // same URL is what is avoided. Anywhere else (a cached probe answer skipped the
4138
4234
  // load; a fresh page) the checkout is opened here, once.
4139
- async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking = null }) {
4235
+ async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking = null, currencyLoad = null }) {
4140
4236
  const enteredViaLanding = Boolean(entry && typeof entry === "object" && entry.entered);
4141
4237
  if (enteredViaLanding) {
4142
4238
  await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
@@ -4145,14 +4241,14 @@ async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking =
4145
4241
  if (checkoutUrlPredicate(checkoutPage.url)(safePageUrl(page))) {
4146
4242
  return "already on checkout from the selector probe; not re-opened";
4147
4243
  }
4148
- await gotoAndSettle(page, checkoutPage.url, args, tracking);
4244
+ await gotoAndSettle(page, checkoutPage.url, args, tracking, currencyLoad);
4149
4245
  return null;
4150
4246
  }
4151
4247
 
4152
4248
  // The entry step body. Resolves to `{ skip }` when the checkout selects for
4153
4249
  // itself, to `{ entered: true, ... }` when the runner came in through the entry
4154
4250
  // page, and throws a coded error (never a bare timeout) when it cannot.
4155
- async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache = null, tracking = null }) {
4251
+ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache = null, tracking = null, currencyLoad = null }) {
4156
4252
  // Probe the checkout first: whether it carries a selection surface is a fact
4157
4253
  // about the rendered page, not about the spec (the spec cannot say it yet —
4158
4254
  // that is the design half of #206). The probe's load is the checkout's only
@@ -4169,7 +4265,7 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4169
4265
  let probe = cached ? "reused" : "loaded";
4170
4266
  let probeError = null;
4171
4267
  if (!surface) {
4172
- await gotoAndSettle(page, checkoutPage.url, args, tracking);
4268
+ await gotoAndSettle(page, checkoutPage.url, args, tracking, currencyLoad);
4173
4269
  const probed = await page.evaluate(checkoutSelectionSurfaceScript())
4174
4270
  .then((value) => ({ value }), (error) => ({ value: null, error: error?.message || String(error) }));
4175
4271
  if (probed.value) {
@@ -4195,7 +4291,7 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4195
4291
  );
4196
4292
  }
4197
4293
 
4198
- await gotoAndSettle(page, entryPage.url, args, tracking);
4294
+ await gotoAndSettle(page, entryPage.url, args, tracking, currencyLoad);
4199
4295
  const sdkReady = await waitForSdkReady(page, Math.min(budget(), DEFAULT_SETTLE_TIMEOUT_MS));
4200
4296
  await tracking?.readDocument(page);
4201
4297
  const controls = await page.evaluate(cartEntryControlsScript(), { selector: CART_ENTRY_CONTROL_SELECTOR, checkoutUrl: checkoutPage.url }).catch(() => []);
@@ -4491,24 +4587,149 @@ function assessReceiptRendering(persistedLineCount, evidence = {}) {
4491
4587
  };
4492
4588
  }
4493
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
+
4494
4611
  // With a tracking observer, the load is a runner navigation: before the
4495
- // attempt's first page-initiated hop it carries the run's synthetic seeds.
4496
- async function gotoAndSettle(page, url, args, tracking = null) {
4497
- const target = tracking ? tracking.runnerUrl(url, withQueryParam) : url;
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;
4498
4617
  await page.goto(target, { waitUntil: "domcontentloaded", timeout: numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS) });
4499
4618
  await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
4500
4619
  await page.waitForTimeout(750);
4501
4620
  }
4502
4621
 
4503
- 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 = [] } = {}) {
4504
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.
4505
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
+ }
4506
4642
  const target = page.locator(packageCardClickSelector({ package_id: item.packageId })).first();
4507
4643
  if (await target.count().catch(() => 0)) {
4508
4644
  const perpetual = await scrollControlIntoView(target);
4509
4645
  await clickControl(target, { timeout: 5000, forceFallback: false, perpetual }).catch(() => {});
4510
4646
  }
4511
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
+ }
4512
4733
  }
4513
4734
 
4514
4735
  // --- Strict package/bundle card selection (--select-package) ---
@@ -4518,16 +4739,17 @@ async function selectRequestedCart(page, args) {
4518
4739
  // strict variant: the requested card must exist, be clickable, and an explicit
4519
4740
  // quantity must visibly enter the selected state — otherwise the
4520
4741
  // selected_bundle step fails instead of driving the wrong tier.
4521
- 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 = []) {
4522
4744
  if (!requested.length) return null;
4523
4745
  const details = [];
4524
4746
  for (const item of requested) {
4525
- details.push(await selectPackageCard(page, item));
4747
+ details.push(await selectPackageCard(page, item, cards));
4526
4748
  }
4527
4749
  return details.join("; ");
4528
4750
  }
4529
4751
 
4530
- async function selectPackageCard(page, item) {
4752
+ async function selectPackageCard(page, item, cards = []) {
4531
4753
  const candidate = resolvePackageCardCandidate(await renderedPackageCardCandidates(page), item);
4532
4754
  const selector = packageCardClickSelector(candidate);
4533
4755
  const target = page.locator(selector).first();
@@ -4549,14 +4771,36 @@ async function selectPackageCard(page, item) {
4549
4771
  if (packageCardProofIdentity(selected) !== packageCardProofIdentity(candidate)) {
4550
4772
  throw new Error(`--select-package ${item.packageId}: rendered card composition changed after click`);
4551
4773
  }
4774
+ cards.push(selected);
4552
4775
  const quantity = item.quantity || 1;
4553
4776
  return `${item.packageId}:${quantity} via ${selector}${state === "unknown" ? " (card exposes no selected-state marker; composition verified)" : ""}`;
4554
4777
  }
4555
4778
 
4556
- async function renderedPackageCardCandidates(page) {
4557
- return page.locator("[data-next-bundle-card], [data-next-selector-card], [data-next-package-id], [data-next-bundle-id]").evaluateAll((elements) => {
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) => {
4558
4789
  const cards = [];
4559
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
+ );
4560
4804
  const parseItems = (value) => {
4561
4805
  if (!value) return null;
4562
4806
  try {
@@ -4570,7 +4814,7 @@ async function renderedPackageCardCandidates(page) {
4570
4814
  return null;
4571
4815
  }
4572
4816
  };
4573
- for (const element of elements) {
4817
+ for (const [index, element] of elements.entries()) {
4574
4818
  const card = element.closest("[data-next-bundle-card], [data-next-selector-card]") || element;
4575
4819
  if (seen.has(card)) continue;
4576
4820
  seen.add(card);
@@ -4587,10 +4831,19 @@ async function renderedPackageCardCandidates(page) {
4587
4831
  if (!items && packageId && Number.isFinite(declaredQuantity) && declaredQuantity > 0) {
4588
4832
  items = [{ package_id: packageId, quantity: declaredQuantity }];
4589
4833
  }
4590
- 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);
4591
4844
  }
4592
4845
  return cards;
4593
- }).catch(() => []);
4846
+ }, placement).catch(() => []);
4594
4847
  }
4595
4848
 
4596
4849
  function packageCardIdentity(candidate) {
@@ -4622,9 +4875,15 @@ function packageCardClickSelector(candidate) {
4622
4875
  function resolvePackageCardCandidate(candidates, item) {
4623
4876
  const ref = String(item?.packageId || "");
4624
4877
  const quantity = Number(item?.quantity || 1);
4625
- const matchingRef = (candidates || []).filter((candidate) => (
4878
+ const identityMatches = (candidates || []).filter((candidate) => (
4626
4879
  String(candidate?.bundle_id || "") === ref || String(candidate?.package_id || "") === ref
4627
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
+ ));
4628
4887
  if (!matchingRef.length) {
4629
4888
  throw new Error(`--select-package ${ref}: no rendered card exposes that package or bundle identity`);
4630
4889
  }
@@ -4635,18 +4894,22 @@ function resolvePackageCardCandidate(candidates, item) {
4635
4894
  throw new Error(`--select-package ${ref}: rendered bundle identity is ambiguous across ${exactBundle.length} cards`);
4636
4895
  }
4637
4896
 
4638
- 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) => (
4639
4902
  Array.isArray(candidate?.items)
4640
- && candidate.items.length === 1
4641
- && String(candidate.items[0]?.package_id || "") === ref
4642
- && Number(candidate.items[0]?.quantity) === quantity
4903
+ && candidate.items.some((entry) => String(entry?.package_id || "") === ref && Number(entry?.quantity) === quantity)
4643
4904
  ));
4905
+ const declaringOnlyItem = declaringItem.filter((candidate) => candidate.items.length === 1);
4906
+ const exactComposition = declaringOnlyItem.length ? declaringOnlyItem : declaringItem;
4644
4907
  if (exactComposition.length === 1) return exactComposition[0];
4645
4908
  if (exactComposition.length > 1) {
4646
4909
  throw new Error(`--select-package ${ref}:${quantity}: rendered package composition is ambiguous across ${exactComposition.length} cards`);
4647
4910
  }
4648
- const unknownComposition = matchingRef.filter((candidate) => !Array.isArray(candidate?.items));
4649
- 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];
4650
4913
  throw new Error(`--select-package ${ref}:${quantity}: no rendered card has exactly package ${ref} at quantity ${quantity}`);
4651
4914
  }
4652
4915
 
@@ -5092,7 +5355,7 @@ async function revealCheckoutForm(page, options = {}) {
5092
5355
  return true;
5093
5356
  }
5094
5357
 
5095
- async function fillPaymentFields(page, args) {
5358
+ async function fillPaymentFields(page, args, { readyTimeoutMs = CARD_READY_TIMEOUT_MS } = {}) {
5096
5359
  await clickCreditPaymentMethod(page);
5097
5360
  await selectByField(page, "exp-month", stringArg(args["test-exp-month"]) || DEFAULT_TEST_EXP_MONTH);
5098
5361
  await selectYear(page, stringArg(args["test-exp-year"]) || DEFAULT_TEST_EXP_YEAR);
@@ -5100,7 +5363,7 @@ async function fillPaymentFields(page, args) {
5100
5363
  const card = normalizeCard(stringArg(args["test-card"]) || DEFAULT_TEST_CARD);
5101
5364
  const cvv = stringArg(args["test-cvv"]) || DEFAULT_TEST_CVV;
5102
5365
  const frames = await cardFrames(page);
5103
- const readyWaitMs = await waitForCardFieldsReady(page);
5366
+ const readyWaitMs = await waitForCardFieldsReady(page, readyTimeoutMs);
5104
5367
  const numberInput = page.frameLocator(CARD_NUMBER_FRAME).locator("input").first();
5105
5368
  const cvvInput = page.frameLocator(CARD_CVV_FRAME).locator("input").first();
5106
5369
  const typed = async (input) => normalizeCard(await input.inputValue());
@@ -5136,12 +5399,13 @@ async function fillPaymentFields(page, args) {
5136
5399
  // SDK never set it is ready at once. The SDK also clears it when the card
5137
5400
  // script fails to load, so a class still set after the wait means the card
5138
5401
  // script never answered; the step refuses by name rather than type into
5139
- // fields that may drop the number.
5140
- async function waitForCardFieldsReady(page) {
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) {
5141
5405
  const started = Date.now();
5142
- await page.waitForFunction(() => !document.querySelector(".next-loading-spreedly"), null, { timeout: CARD_READY_TIMEOUT_MS }).catch((error) => {
5406
+ await page.waitForFunction(() => !document.querySelector(".next-loading-spreedly"), null, { timeout: timeoutMs }).catch((error) => {
5143
5407
  if (error?.name !== "TimeoutError") throw error;
5144
- throw new Error(`card fields did not report ready within ${CARD_READY_TIMEOUT_MS / 1000}s: the checkout form still carries next-loading-spreedly, so the card script never finished loading`);
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`);
5145
5409
  });
5146
5410
  return Date.now() - started;
5147
5411
  }
@@ -5426,23 +5690,29 @@ async function clickControl(locator, { timeout, forceFallback = true, perpetual
5426
5690
  // single offer). Clicking the hidden action fails as not visible, so the
5427
5691
  // visible proxy is clicked instead, but only while the offer holds an SDK action
5428
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.
5429
5694
  async function shopperUpsellControl(page, action) {
5430
- const actions = page.locator(`[data-next-upsell-action="${action}"]`);
5695
+ const actions = page.locator(upsellActionSelector(action));
5431
5696
  if (!await actions.count().catch(() => 0)) return actions.first();
5432
5697
  const visibleAction = actions.filter({ visible: true }).first();
5433
5698
  if (await visibleAction.count().catch(() => 0)) return visibleAction;
5434
5699
  // The proxy forwards to `[data-next-upsell="offer"] [data-next-upsell-action]`
5435
5700
  // (upsells.js), so only an action inside the offer is a target.
5436
- const inOffer = page.locator(`[data-next-upsell="offer"] [data-next-upsell-action="${action}"]`);
5701
+ const inOffer = page.locator(`[data-next-upsell="offer"] ${upsellActionSelector(action)}`);
5437
5702
  const proxy = page.locator(`[data-upsell-proxy="${action}"]`).filter({ visible: true }).first();
5438
5703
  if (await inOffer.count().catch(() => 0) && await proxy.count().catch(() => 0)) return proxy;
5439
5704
  return actions.first();
5440
5705
  }
5441
5706
 
5442
- 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);
5443
5713
  const offerUrl = safePageUrl(page);
5444
- const action = path === "accept" ? "add" : "skip";
5445
- const selector = `[data-next-upsell-action="${action}"]`;
5714
+ const action = await presentUpsellAction(page, path === "accept" ? "accept" : "decline");
5715
+ const selector = upsellActionSelector(action);
5446
5716
  const control = await shopperUpsellControl(page, action);
5447
5717
  if (!await control.count().catch(() => 0)) {
5448
5718
  return { path, clicked: false, error: `Missing upsell control ${selector}` };
@@ -5475,10 +5745,10 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
5475
5745
  if (!root || root.method() !== "POST" || !isOrderUpsellsUrl(root.url())) return false;
5476
5746
  const startedAt = responseRequestStartedAt(response);
5477
5747
  return startedAt === null || startedAt >= armedAt;
5478
- }, { timeout: UPSELL_MUTATION_TIMEOUT_MS + (perpetual ? 0 : UPSELL_CLICK_TIMEOUT_MS) }).catch(() => null)
5748
+ }, { timeout: mutationMs + (perpetual ? 0 : clickMs) }).catch(() => null)
5479
5749
  : Promise.resolve(null);
5480
5750
  trace?.markClickAttempted();
5481
- await clickControl(control, { timeout: UPSELL_CLICK_TIMEOUT_MS, perpetual });
5751
+ await clickControl(control, { timeout: clickMs, perpetual });
5482
5752
  trace?.markClickCompleted();
5483
5753
  const mutationResponse = await mutationPromise;
5484
5754
  const bodyRead = mutationResponse
@@ -5607,14 +5877,37 @@ async function checkoutDisplayEvidence(browserPage) {
5607
5877
  text: clean(row.textContent).slice(0, 160),
5608
5878
  }));
5609
5879
 
5610
- 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]"))
5611
5898
  .filter((card) => (
5612
5899
  card.classList.contains("next-selected")
5613
5900
  || card.getAttribute("data-next-selected") === "true"
5614
5901
  || card.getAttribute("aria-checked") === "true"
5615
5902
  || card.querySelector('input[type="radio"], input[type="checkbox"]')?.checked === true
5616
- ))
5617
- .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)])
5618
5911
  .filter(Boolean);
5619
5912
 
5620
5913
  const activeToggles = Array.from(document.querySelectorAll("[data-next-package-toggle], [data-next-toggle-card], [data-next-bump]"))
@@ -5642,6 +5935,9 @@ async function checkoutDisplayEvidence(browserPage) {
5642
5935
  summary_present: summaries.length > 0,
5643
5936
  summary_rows: summaryRows.slice(0, 40),
5644
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,
5645
5941
  active_toggle_package_ids: [...new Set(activeToggles)],
5646
5942
  discount_rows: discountRows.slice(0, 20),
5647
5943
  total_text: totalNode ? clean(totalNode.textContent) : null,
@@ -5650,6 +5946,7 @@ async function checkoutDisplayEvidence(browserPage) {
5650
5946
  summary_present: false,
5651
5947
  summary_rows: [],
5652
5948
  selected_bundle_package_ids: [],
5949
+ selected_bundle_items: [],
5653
5950
  active_toggle_package_ids: [],
5654
5951
  discount_rows: [],
5655
5952
  total_text: null,
@@ -5692,7 +5989,29 @@ function displayedPackageIds(display) {
5692
5989
  };
5693
5990
  }
5694
5991
 
5695
- 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 = [] } = {}) {
5696
6015
  if (!display) {
5697
6016
  return { comparable: false, reason: "checkout display evidence was not captured for this order" };
5698
6017
  }
@@ -5714,9 +6033,37 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5714
6033
  }
5715
6034
 
5716
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)]));
5717
6053
  const displayed = new Set(resolved.displayed_package_ids);
5718
6054
  const summaryIds = new Set(resolved.summary_package_ids);
5719
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 = [];
5720
6067
  // A checkout order bump persists with is_upsell: true (the platform's
5721
6068
  // reporting tag), yet the checkout summary displays it. Such a line charges
5722
6069
  // a displayed row; an is_upsell line the summary does not show is a
@@ -5724,23 +6071,24 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5724
6071
  let bumpLineCount = 0;
5725
6072
  for (const line of (lines || []).filter((entry) => entry?.is_upsell)) {
5726
6073
  const resolution = events ? campaignPackageResolutionForLine(events, line, {
5727
- selected_packages,
6074
+ selected_packages: resolutionPackages,
5728
6075
  preferred_refs: resolved.summary_package_ids,
5729
6076
  }) : null;
5730
6077
  const ref = resolution?.pkg?.ref_id == null ? null : String(resolution.pkg.ref_id);
5731
6078
  if (ref && summaryIds.has(ref)) {
5732
6079
  matchedSummaryIds.add(ref);
5733
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
+ }
5734
6086
  }
5735
6087
  }
5736
- const extra = [];
5737
- const unresolved = [];
5738
- const matchedQuantities = [];
5739
- const quantityMismatches = [];
5740
6088
 
5741
6089
  for (const line of nonUpsellLines) {
5742
6090
  const resolution = events ? campaignPackageResolutionForLine(events, line, {
5743
- selected_packages,
6091
+ selected_packages: resolutionPackages,
5744
6092
  preferred_refs: resolved.displayed_package_ids,
5745
6093
  }) : null;
5746
6094
  const meta = resolution?.pkg || null;
@@ -5750,10 +6098,15 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5750
6098
  // same tolerance linePriceDeltaEvidence applies). An unresolvable line
5751
6099
  // cannot prove a stray charge, so it is reported, never counted as one.
5752
6100
  unresolved.push({ title: line.title, quantity: line.quantity });
5753
- const mismatch = requestedPackageQuantityMismatch(events, line, selected_packages);
5754
- 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
+ }
5755
6107
  continue;
5756
6108
  }
6109
+ chargedRefs.add(ref);
5757
6110
  const unitQuantity = Number(meta.qty ?? meta.quantity);
5758
6111
  matchedQuantities.push({
5759
6112
  package_ref_id: ref,
@@ -5761,6 +6114,13 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5761
6114
  purchase_multiplier: resolution.purchaseMultiplier,
5762
6115
  persisted_quantity: Number(line.quantity),
5763
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
+ }
5764
6124
  if (displayed.has(ref)) {
5765
6125
  if (summaryIds.has(ref)) matchedSummaryIds.add(ref);
5766
6126
  continue;
@@ -5772,8 +6132,26 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5772
6132
  price: line.price,
5773
6133
  });
5774
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
+ }
5775
6148
 
5776
- 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
+ ];
5777
6155
  return {
5778
6156
  comparable: true,
5779
6157
  ok: extra.length === 0 && missing.length === 0 && quantityMismatches.length === 0,
@@ -5799,25 +6177,49 @@ function requestedPackageQuantityMismatch(events, line, selectedPackages) {
5799
6177
  const ref = String(selected.packageId);
5800
6178
  const pkg = packages.find((candidate) => String(candidate?.ref_id ?? candidate?.package_id ?? candidate?.id ?? "") === ref);
5801
6179
  if (!pkg || !packageIdentityMatchesLine(pkg, line)) continue;
5802
- const unitQuantity = Number(pkg.qty ?? pkg.quantity);
5803
- const purchaseMultiplier = Number(selected.quantity || 1);
5804
- const requestedQuantity = unitQuantity * purchaseMultiplier;
5805
- const persistedQuantity = Number(line?.quantity || 0);
5806
- if (Number.isFinite(requestedQuantity) && requestedQuantity !== persistedQuantity) {
5807
- return {
5808
- package_ref_id: ref,
5809
- unit_quantity: unitQuantity,
5810
- purchase_multiplier: purchaseMultiplier,
5811
- requested_quantity: requestedQuantity,
5812
- persisted_quantity: persistedQuantity,
5813
- reason: `package ${ref} requested ${requestedQuantity} unit(s) (${unitQuantity} per package × ${purchaseMultiplier}) but persisted ${persistedQuantity}`,
5814
- };
5815
- }
6180
+ const mismatch = packageQuantityMismatch(ref, Number(pkg.qty ?? pkg.quantity), Number(selected.quantity || 1), Number(line?.quantity || 0));
6181
+ if (mismatch) return mismatch;
5816
6182
  }
5817
6183
  }
5818
6184
  return null;
5819
6185
  }
5820
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
+
5821
6223
  function packageIdentityMatchesLine(pkg, line) {
5822
6224
  if (pkg?.product_sku && line?.sku) return normalizeLabel(pkg.product_sku) === normalizeLabel(line.sku);
5823
6225
  if (pkg?.product_variant_id != null && line?.variant_id != null) return Number(pkg.product_variant_id) === Number(line.variant_id);
@@ -5861,6 +6263,9 @@ function orderDisplayParityAssertion(page, planIdentifier, order) {
5861
6263
  if (reconciliation.missing.length) {
5862
6264
  parts.push(`displayed but never charged: ${reconciliation.missing.join(", ")}`);
5863
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
+ }
5864
6269
  return assertion({
5865
6270
  ...base,
5866
6271
  status: STATUS.FAIL,
@@ -7667,7 +8072,7 @@ function extractReceiptLines(order) {
7667
8072
 
7668
8073
  async function waitForUpsellPageReady(page, args) {
7669
8074
  const timeoutMs = numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS);
7670
- await page.locator('[data-next-upsell], [data-next-upsell-action="add"], [data-next-upsell-action="skip"]').first()
8075
+ await page.locator(`[data-next-upsell], ${upsellActionSelector(...UPSELL_ACTION_SPELLINGS.accept, ...UPSELL_ACTION_SPELLINGS.decline)}`).first()
7671
8076
  .waitFor({ state: "visible", timeout: timeoutMs })
7672
8077
  .catch(() => {});
7673
8078
  await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
@@ -8117,6 +8522,26 @@ function numberArg(value, fallback) {
8117
8522
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
8118
8523
  }
8119
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
+
8120
8545
  function trim(value) {
8121
8546
  return String(value || "").replace(/\s+/g, " ").trim();
8122
8547
  }
@@ -8158,9 +8583,12 @@ export const __qaBrowserTestHooks = Object.freeze({
8158
8583
  primaryCtaAssertionFromEvidence,
8159
8584
  inspectPrimaryCta,
8160
8585
  primaryCtaInspectionScript,
8586
+ primaryCtaDeclaredRoutes,
8587
+ primaryCtaRoutelessForms,
8161
8588
  clickCouponApplyControl,
8162
8589
  isOrderUpsellsUrl,
8163
8590
  clickUpsellPath,
8591
+ waitForUpsellPageReady,
8164
8592
  isPerpetuallyAnimated,
8165
8593
  readJsonResponseBody,
8166
8594
  readJsonResponseBodyWithin,
@@ -8219,6 +8647,7 @@ export const __qaBrowserTestHooks = Object.freeze({
8219
8647
  redactUrlQuery,
8220
8648
  enterCartViaLanding,
8221
8649
  openCheckoutForPath,
8650
+ pathCurrencyLoad,
8222
8651
  createSelectorProbeCache,
8223
8652
  RESIDUE_PAGE_TYPES,
8224
8653
  computedStyleResidueAssertions,