@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.
- package/CHANGELOG.md +84 -0
- package/CONTEXT.md +8 -5
- package/README.md +26 -12
- package/compatibility.json +1 -1
- package/contracts/release-ledger.json +441 -0
- package/contracts/supported-surface.json +2 -2
- package/docs/brand-theme-bridge.md +10 -0
- package/docs/build-packet.md +35 -19
- package/docs/campaigns-os-build-flow.md +1 -1
- package/docs/effects.md +2 -5
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +55 -11
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -1
- package/docs/workflow-findings-sidecar.md +57 -2
- package/package.json +3 -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-identity.mjs +33 -4
- package/src/cli.mjs +36 -59
- package/src/consent.mjs +3 -2
- package/src/design-source-publication.mjs +1 -1
- package/src/deviation.mjs +2 -1
- package/src/doctor/checks.mjs +16 -6
- package/src/doctor/next-step.mjs +16 -6
- package/src/invocation.mjs +4 -3
- 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/qa-browser.mjs +521 -92
- package/src/qa-cart-entry.mjs +29 -6
- package/src/qa-node.mjs +144 -39
- package/src/qa-verdict-publish.mjs +2 -1
- package/src/run-record.mjs +46 -34
- package/src/sdk-markup.mjs +41 -1
- package/src/stage-ledger.mjs +14 -2
- package/src/stage-record.mjs +50 -13
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,
|
|
@@ -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,
|
|
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
|
|
1671
|
+
for (const { kind, field } of UPSELL_CONTROL_ROUTES) {
|
|
1672
|
+
const expectedUrl = page[field];
|
|
1592
1673
|
if (!expectedUrl) continue;
|
|
1593
|
-
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)));
|
|
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
|
|
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: "
|
|
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
|
-
|
|
3193
|
-
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);
|
|
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
|
-
|
|
4497
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4557
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
4649
|
-
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];
|
|
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
|
-
|
|
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:
|
|
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 ${
|
|
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(
|
|
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"]
|
|
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
|
-
|
|
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" ? "
|
|
5445
|
-
const selector =
|
|
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:
|
|
5748
|
+
}, { timeout: mutationMs + (perpetual ? 0 : clickMs) }).catch(() => null)
|
|
5479
5749
|
: Promise.resolve(null);
|
|
5480
5750
|
trace?.markClickAttempted();
|
|
5481
|
-
await clickControl(control, { timeout:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5754
|
-
|
|
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
|
-
|
|
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
|
|
5803
|
-
|
|
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(
|
|
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,
|