@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.
- package/CHANGELOG.md +145 -0
- package/CONTEXT.md +8 -5
- package/README.md +26 -11
- package/compatibility.json +1 -1
- package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -27
- package/contracts/effects.v1.json +96 -37
- package/contracts/release-ledger.json +774 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/brand-theme-bridge.md +10 -0
- package/docs/build-packet.md +45 -22
- package/docs/campaign-standardization-report.md +32 -0
- package/docs/campaigns-os-build-flow.md +1 -1
- package/docs/design-source-package.md +7 -3
- package/docs/effects.md +4 -6
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/progress-snapshots.md +3 -1
- package/docs/qa-and-test-orders.md +83 -14
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +15 -0
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +2 -1
- package/docs/workflow-findings-sidecar.md +57 -2
- package/package.json +3 -1
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
- package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +3 -3
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +4 -4
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +26 -12
- package/src/brand-theme.mjs +44 -8
- package/src/campaign-ecosystem.mjs +153 -31
- package/src/campaign-identity.mjs +33 -4
- package/src/cli.mjs +58 -62
- package/src/consent.mjs +3 -2
- package/src/design-source-publication.mjs +1 -1
- package/src/deviation.mjs +2 -1
- package/src/directory-lock.mjs +79 -7
- package/src/doctor/checks.mjs +28 -8
- package/src/doctor/inspect.mjs +13 -0
- package/src/doctor/next-step.mjs +16 -6
- package/src/invocation.mjs +7 -4
- package/src/lifecycle.mjs +88 -6
- package/src/local-preview-policy.mjs +6 -0
- package/src/login.mjs +2 -1
- package/src/polish-browser.mjs +8 -2
- package/src/polish-node.mjs +6 -1
- package/src/polish-readability.mjs +2 -2
- package/src/progress-node.mjs +3 -2
- package/src/qa-browser.mjs +595 -95
- package/src/qa-cart-entry.mjs +29 -6
- package/src/qa-node.mjs +177 -43
- package/src/qa-verdict-publish.mjs +2 -1
- package/src/run-record.mjs +46 -34
- package/src/sdk-attribute-index.mjs +111 -23
- package/src/sdk-markup.mjs +89 -10
- package/src/sdk-repin.mjs +282 -0
- package/src/stage-ledger.mjs +15 -3
- package/src/stage-record.mjs +85 -17
- package/src/standardization-report.mjs +26 -1
- package/src/target-lock.mjs +9 -1
package/src/qa-browser.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
1671
|
+
for (const { kind, field } of UPSELL_CONTROL_ROUTES) {
|
|
1672
|
+
const expectedUrl = page[field];
|
|
1576
1673
|
if (!expectedUrl) continue;
|
|
1577
|
-
const
|
|
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
|
|
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: "
|
|
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
|
-
|
|
3177
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
4481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4541
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
4633
|
-
if (quantity === 1 &&
|
|
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
|
|
5087
|
-
const
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
await
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
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(
|
|
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"]
|
|
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
|
-
|
|
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" ? "
|
|
5374
|
-
const selector =
|
|
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:
|
|
5748
|
+
}, { timeout: mutationMs + (perpetual ? 0 : clickMs) }).catch(() => null)
|
|
5408
5749
|
: Promise.resolve(null);
|
|
5409
5750
|
trace?.markClickAttempted();
|
|
5410
|
-
await clickControl(control, { timeout:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5683
|
-
|
|
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
|
-
|
|
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
|
|
5732
|
-
|
|
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(
|
|
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,
|