@nextcommerce/campaigns-os 1.54.0 → 1.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +148 -0
- package/CONTEXT.md +19 -5
- package/README.md +26 -12
- package/campaign-spec/README.md +15 -1
- package/campaign-spec/dist/checkout-flow.d.ts +90 -0
- package/campaign-spec/dist/checkout-flow.js +229 -0
- package/campaign-spec/dist/index.d.ts +1 -0
- package/campaign-spec/dist/index.js +6 -1
- package/campaign-spec/dist/normalize.d.ts +3 -2
- package/campaign-spec/dist/normalize.js +7 -5
- package/campaign-spec/dist/routing.d.ts +6 -3
- package/campaign-spec/dist/routing.js +6 -3
- package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
- package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
- package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
- package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
- package/campaign-spec/dist/rules/index.d.ts +4 -0
- package/campaign-spec/dist/rules/index.js +12 -0
- package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
- package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
- package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
- package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
- package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
- package/campaign-spec/dist/rules/schema-version.js +10 -5
- package/campaign-spec/dist/types.d.ts +20 -3
- package/campaign-spec/dist/types.js +3 -2
- package/compatibility.json +2 -2
- package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
- package/contracts/release-ledger.json +1146 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
- package/contracts/supported-surface.json +9 -5
- package/docs/brand-theme-bridge.md +10 -0
- package/docs/build-packet.md +86 -26
- 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 +133 -29
- package/docs/runtime-readiness.md +8 -3
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -1
- package/docs/versioning.md +4 -1
- package/docs/workflow-findings-sidecar.md +57 -2
- package/package.json +4 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
- package/schemas/campaign-spec.v5.schema.json +586 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
- package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
- 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 +6 -4
- 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 +21 -6
- package/skills/next-campaigns-qa/SKILL.md +5 -4
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +27 -13
- package/src/brand-theme.mjs +44 -8
- package/src/build-brief.mjs +14 -5
- package/src/campaign-identity.mjs +33 -4
- package/src/cli-helpers.mjs +129 -0
- package/src/cli.mjs +156 -111
- package/src/commercial-journey.mjs +25 -2
- package/src/consent.mjs +3 -2
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +8 -8
- package/src/deviation.mjs +2 -1
- package/src/doctor/checks.mjs +212 -19
- package/src/doctor/inspect.mjs +4 -2
- package/src/doctor/next-step.mjs +46 -16
- package/src/doctor-sidecar.mjs +5 -3
- package/src/gate-actions.mjs +21 -1
- package/src/input-refresh.mjs +6 -6
- package/src/invocation.mjs +4 -3
- package/src/lifecycle.mjs +88 -6
- package/src/local-preview-policy.mjs +116 -12
- package/src/login.mjs +2 -1
- package/src/polish-browser.mjs +8 -2
- package/src/polish-node.mjs +9 -4
- package/src/polish-readability.mjs +2 -2
- package/src/qa-browser.mjs +741 -120
- package/src/qa-cart-entry.mjs +107 -20
- package/src/qa-node.mjs +179 -72
- package/src/qa-test-order-topology.mjs +118 -1
- package/src/qa-verdict-discovery.mjs +21 -4
- package/src/qa-verdict-publish.mjs +2 -1
- package/src/run-record.mjs +46 -34
- package/src/runtime-state-ignore.mjs +7 -5
- package/src/sdk-markup.mjs +41 -1
- package/src/source-html-intake.mjs +46 -4
- package/src/stage-ledger.mjs +18 -4
- package/src/stage-record.mjs +53 -16
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,
|
|
@@ -24,13 +25,20 @@ import {
|
|
|
24
25
|
commonTestOrderPlan,
|
|
25
26
|
fullTestOrderPaths,
|
|
26
27
|
OFFER_PAGE_TYPES,
|
|
28
|
+
checkoutPathPages,
|
|
29
|
+
checkoutStepPages,
|
|
30
|
+
findPaymentPage,
|
|
31
|
+
isCheckoutStepTopologyPage,
|
|
27
32
|
pageAtUrl,
|
|
33
|
+
paymentPagesForTopology,
|
|
28
34
|
remainingActionDisposition,
|
|
29
35
|
resolveTestOrderTopology,
|
|
30
36
|
terminalAtUrl,
|
|
31
37
|
} from "./qa-test-order-topology.mjs";
|
|
32
38
|
import {
|
|
33
39
|
CART_ENTRY_CODES,
|
|
40
|
+
CART_ENTRY_BUNDLE_CARD_SELECTOR,
|
|
41
|
+
CART_ENTRY_CHECKOUT_BUTTON_SELECTOR,
|
|
34
42
|
CART_ENTRY_CONTROL_SELECTOR,
|
|
35
43
|
cartEntryHrefFor,
|
|
36
44
|
CART_ENTRY_ROUTE_ATTRIBUTE,
|
|
@@ -159,13 +167,17 @@ export async function runBrowserChecks(topologies, args = {}, options = {}) {
|
|
|
159
167
|
// Content parameters (options.spec analytics.params.content), after the
|
|
160
168
|
// page checks: every load in its own fresh context with the same options,
|
|
161
169
|
// closed after the load. The rows go to options.qcResults; their verdict
|
|
162
|
-
// assertions join the page checks'.
|
|
170
|
+
// assertions join the page checks'. options.contentParamLimits is an
|
|
171
|
+
// in-process test seam (never set from argv) that can only tighten the
|
|
172
|
+
// leg's CONTENT_PARAM_LIMITS, so a test of the budget or the readiness
|
|
173
|
+
// wait need not sit through the production bound.
|
|
163
174
|
if (Array.isArray(options.qcResults)) {
|
|
164
175
|
const contentParams = await runContentParamChecks({
|
|
165
176
|
topologies,
|
|
166
177
|
spec: options.spec,
|
|
167
178
|
newContext: () => browser.newContext(contextOptions),
|
|
168
179
|
withQueryParam,
|
|
180
|
+
limits: contentParamLimitsFrom(options.contentParamLimits),
|
|
169
181
|
});
|
|
170
182
|
options.qcResults.push(...contentParams.rows);
|
|
171
183
|
assertions.push(...contentParams.assertions);
|
|
@@ -186,7 +198,9 @@ export async function runBrowserChecks(topologies, args = {}, options = {}) {
|
|
|
186
198
|
}
|
|
187
199
|
|
|
188
200
|
export async function runBrowserTestOrders(topologies, args = {}, runId = "local", options = {}) {
|
|
189
|
-
|
|
201
|
+
// The Checkout (the page that takes payment) on the first entry path, never
|
|
202
|
+
// the first page typed checkout: on a split checkout that is step 1 (#641).
|
|
203
|
+
const checkoutPage = findPaymentPage(topologies);
|
|
190
204
|
if (!checkoutPage?.url) {
|
|
191
205
|
const coverage = upsellActionCoverageAssertion({ topologies, orders: [], checkoutPage });
|
|
192
206
|
return {
|
|
@@ -1085,6 +1099,10 @@ async function runPageBrowserChecks(context, page, args, options = {}, policyLin
|
|
|
1085
1099
|
}
|
|
1086
1100
|
if (page.page_type === "checkout") {
|
|
1087
1101
|
assertions.push(...await checkoutPaymentSurfaceAssertions(browserPage, page));
|
|
1102
|
+
}
|
|
1103
|
+
// Exit-intent and promo-code surfaces may sit on any checkout-flow page
|
|
1104
|
+
// (#641); each assertion runs only where the spec declares the surface.
|
|
1105
|
+
if (["select", "checkout_step", "checkout"].includes(page.page_type)) {
|
|
1088
1106
|
assertions.push(...await checkoutOfferSurfaceAssertions(browserPage, page));
|
|
1089
1107
|
}
|
|
1090
1108
|
assertions.push(...await templateResidueAssertions(browserPage, page, options));
|
|
@@ -1313,7 +1331,7 @@ function sdkDebuggerEligible(page) {
|
|
|
1313
1331
|
|
|
1314
1332
|
async function primaryCtaVisualAssertions(browserPage, page) {
|
|
1315
1333
|
if (!primaryCtaCheckEligible(page)) return [];
|
|
1316
|
-
const evidence = await inspectPrimaryCta(browserPage, page.expected_next_url);
|
|
1334
|
+
const evidence = await inspectPrimaryCta(browserPage, page.expected_next_url, primaryCtaDeclaredRoutes(page), primaryCtaRoutelessForms(page));
|
|
1317
1335
|
return [primaryCtaAssertionFromEvidence(page, evidence)];
|
|
1318
1336
|
}
|
|
1319
1337
|
|
|
@@ -1321,6 +1339,54 @@ function primaryCtaCheckEligible(page) {
|
|
|
1321
1339
|
return Boolean(page?.expected_next_url);
|
|
1322
1340
|
}
|
|
1323
1341
|
|
|
1342
|
+
// Checkout, upsell and downsell: the page types whose forward controls go
|
|
1343
|
+
// where the page declares, not where they point.
|
|
1344
|
+
function primaryCtaPageRoutesSdkControls(page) {
|
|
1345
|
+
const pageType = String(page?.page_type || "").toLowerCase();
|
|
1346
|
+
return pageType === "checkout" || pageType === "checkout_step" || OFFER_PAGE_TYPES.has(pageType);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// The forms whose action is no route on this page, handed to the route rule
|
|
1350
|
+
// as its checkout-form selector. On a checkout, upsell or downsell the only
|
|
1351
|
+
// form-borne route is the one the page declares for its SDK control
|
|
1352
|
+
// (primaryCtaDeclaredRoutes), so no form's action is a route there: a
|
|
1353
|
+
// newsletter or search form beside the checkout form does not make its
|
|
1354
|
+
// submit button the route control. Elsewhere only the SDK checkout form,
|
|
1355
|
+
// which the SDK submits itself.
|
|
1356
|
+
function primaryCtaRoutelessForms(page) {
|
|
1357
|
+
return primaryCtaPageRoutesSdkControls(page) ? "form" : CHECKOUT_FORM_SELECTOR;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// The SDK controls on this page that go to a route the page declares rather
|
|
1361
|
+
// than one they carry, each with that route: on a checkout, the checkout
|
|
1362
|
+
// form's submit button goes to the success route (the page's forward route);
|
|
1363
|
+
// on an upsell or downsell, each SDK upsell action goes to its accept or
|
|
1364
|
+
// decline route. The routes are the topology fields the static route-link
|
|
1365
|
+
// rows read, and the actions are the rendered upsell-control check's table.
|
|
1366
|
+
// A control is a route candidate when its route is the expected next route,
|
|
1367
|
+
// like any other. The submit button is any button that submits the form, so a
|
|
1368
|
+
// typeless one too, but not an express-checkout wallet button, which starts
|
|
1369
|
+
// the wallet flow; an upsell action counts only inside a [data-next-upsell]
|
|
1370
|
+
// container, the only place the SDK binds it.
|
|
1371
|
+
function primaryCtaDeclaredRoutes(page) {
|
|
1372
|
+
if (!primaryCtaPageRoutesSdkControls(page)) return [];
|
|
1373
|
+
// A checkout_step's form submits to its declared next step the same way the
|
|
1374
|
+
// Checkout's submits to its success route: the SDK navigates, not the form.
|
|
1375
|
+
if (["checkout", "checkout_step"].includes(String(page.page_type).toLowerCase())) {
|
|
1376
|
+
const notWallet = ":not([data-next-express-checkout]):not([data-next-express-checkout] *)";
|
|
1377
|
+
const submit = ['button[type="submit"]', "button:not([type])", 'input[type="submit"]']
|
|
1378
|
+
.map((control) => `${CHECKOUT_FORM_SELECTOR} ${control}${notWallet}`)
|
|
1379
|
+
.join(", ");
|
|
1380
|
+
return page.expected_next_url ? [{ selector: submit, url: page.expected_next_url }] : [];
|
|
1381
|
+
}
|
|
1382
|
+
return UPSELL_CONTROL_ROUTES
|
|
1383
|
+
.filter(({ field }) => page[field])
|
|
1384
|
+
.map(({ kind, field }) => ({
|
|
1385
|
+
selector: UPSELL_ACTION_SPELLINGS[kind].map((action) => `[data-next-upsell] ${upsellActionSelector(action)}`).join(", "),
|
|
1386
|
+
url: page[field],
|
|
1387
|
+
}));
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1324
1390
|
// Candidate CTAs: anything clickable, plus every SDK action control and the
|
|
1325
1391
|
// SDK cart-entry control (the same locator the ladder's entry step and
|
|
1326
1392
|
// advanceToCheckoutForm use). Only attributes the SDK declares are listed —
|
|
@@ -1336,19 +1402,21 @@ const PRIMARY_CTA_SELECTOR = ["a[href]", "button", "[role='button']", "[data-nex
|
|
|
1336
1402
|
// stay free of module-scope references: the text is run in a fresh context by
|
|
1337
1403
|
// a test (primary-CTA inspection script is self-contained) that would surface
|
|
1338
1404
|
// a leaked identifier as a ReferenceError.
|
|
1339
|
-
function primaryCtaInspectionScript(expectedUrl) {
|
|
1405
|
+
function primaryCtaInspectionScript(expectedUrl, declaredRoutes = [], checkoutFormSelector = CHECKOUT_FORM_SELECTOR) {
|
|
1340
1406
|
const args = {
|
|
1341
1407
|
routeUrl: expectedUrl,
|
|
1342
1408
|
ctaSelector: PRIMARY_CTA_SELECTOR,
|
|
1343
1409
|
cartEntrySelector: CART_ENTRY_CONTROL_SELECTOR,
|
|
1344
1410
|
cartEntryRouteAttribute: CART_ENTRY_ROUTE_ATTRIBUTE,
|
|
1411
|
+
declaredRoutes,
|
|
1412
|
+
checkoutFormSelector,
|
|
1345
1413
|
ignoredRouteAttributes: [...UNDECLARED_ROUTE_ATTRIBUTES],
|
|
1346
1414
|
};
|
|
1347
1415
|
return `(${inspectPrimaryCtaScript.toString()})(${JSON.stringify(args)}, ${cartEntryHrefFor.toString()}, ${contrastToolkit.toString()}, ${generatedTextRenders.toString()})`;
|
|
1348
1416
|
}
|
|
1349
1417
|
|
|
1350
|
-
async function inspectPrimaryCta(browserPage, expectedUrl) {
|
|
1351
|
-
return browserPage.evaluate(primaryCtaInspectionScript(expectedUrl)).catch((error) => ({
|
|
1418
|
+
async function inspectPrimaryCta(browserPage, expectedUrl, declaredRoutes = [], checkoutFormSelector = CHECKOUT_FORM_SELECTOR) {
|
|
1419
|
+
return browserPage.evaluate(primaryCtaInspectionScript(expectedUrl, declaredRoutes, checkoutFormSelector)).catch((error) => ({
|
|
1352
1420
|
ok: false,
|
|
1353
1421
|
reason: "inspection_error",
|
|
1354
1422
|
expected_url: expectedUrl,
|
|
@@ -1358,7 +1426,7 @@ async function inspectPrimaryCta(browserPage, expectedUrl) {
|
|
|
1358
1426
|
}));
|
|
1359
1427
|
}
|
|
1360
1428
|
|
|
1361
|
-
function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, cartEntryRouteAttribute, ignoredRouteAttributes }, hrefForImpl, contrastToolkitImpl, generatedTextImpl) {
|
|
1429
|
+
function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, cartEntryRouteAttribute, declaredRoutes, checkoutFormSelector, ignoredRouteAttributes }, hrefForImpl, contrastToolkitImpl, generatedTextImpl) {
|
|
1362
1430
|
const CTA_SELECTOR = ctaSelector;
|
|
1363
1431
|
const toolkit = contrastToolkitImpl();
|
|
1364
1432
|
|
|
@@ -1391,9 +1459,9 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
|
|
|
1391
1459
|
.join("");
|
|
1392
1460
|
return `${tag}${id}${classes}`;
|
|
1393
1461
|
};
|
|
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 });
|
|
1462
|
+
// The route a control leads to: the shared cart-entry rule, with the
|
|
1463
|
+
// page's declared routes, evaluated against this document's origin and base.
|
|
1464
|
+
const hrefFor = (element) => hrefForImpl(element, { cartEntrySelector, cartEntryRouteAttribute, declaredRoutes, checkoutFormSelector, origin: location.origin, baseHref: location.href });
|
|
1397
1465
|
// Route-shaped attributes the element carries that the rule above does not
|
|
1398
1466
|
// consult: the undeclared spellings, plus the SDK route attribute on an
|
|
1399
1467
|
// element that is not an SDK control (a decoy, not a route). Reported, not
|
|
@@ -1582,15 +1650,48 @@ function primaryCtaAssertionFromEvidence(page, evidence) {
|
|
|
1582
1650
|
});
|
|
1583
1651
|
}
|
|
1584
1652
|
|
|
1653
|
+
// The SDK's spellings of each upsell action: its upsell handler accepts the
|
|
1654
|
+
// offer on add or accept and declines it on skip or decline, and does nothing
|
|
1655
|
+
// on any other value. Every place QA locates an upsell control reads this one
|
|
1656
|
+
// table. The first spelling is the one reported when a page has none.
|
|
1657
|
+
export const UPSELL_ACTION_SPELLINGS = Object.freeze({
|
|
1658
|
+
accept: Object.freeze(["add", "accept"]),
|
|
1659
|
+
decline: Object.freeze(["skip", "decline"]),
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
// The SDK upsell actions and the page field that declares where each goes.
|
|
1663
|
+
// The rendered upsell-control check and the primary-CTA route rule read this
|
|
1664
|
+
// one table.
|
|
1665
|
+
const UPSELL_CONTROL_ROUTES = Object.freeze([
|
|
1666
|
+
Object.freeze({ kind: "accept", field: "expected_accept_url" }),
|
|
1667
|
+
Object.freeze({ kind: "decline", field: "expected_decline_url" }),
|
|
1668
|
+
]);
|
|
1669
|
+
|
|
1670
|
+
function upsellActionSelector(...actions) {
|
|
1671
|
+
return actions.map((action) => `[data-next-upsell-action="${action}"]`).join(", ");
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// The spelling of an upsell action this page renders: the first spelling
|
|
1675
|
+
// present, or the first spelling when the page has none.
|
|
1676
|
+
async function presentUpsellAction(page, kind) {
|
|
1677
|
+
const spellings = UPSELL_ACTION_SPELLINGS[kind];
|
|
1678
|
+
for (const action of spellings) {
|
|
1679
|
+
if (await page.locator(upsellActionSelector(action)).first().count().catch(() => 0)) return action;
|
|
1680
|
+
}
|
|
1681
|
+
return spellings[0];
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1585
1684
|
async function renderedUpsellControlAssertions(browserPage, page) {
|
|
1586
|
-
const checks = [
|
|
1587
|
-
["accept", "add", page.expected_accept_url],
|
|
1588
|
-
["decline", "skip", page.expected_decline_url],
|
|
1589
|
-
];
|
|
1590
1685
|
const assertions = [];
|
|
1591
|
-
for (const
|
|
1686
|
+
for (const { kind, field } of UPSELL_CONTROL_ROUTES) {
|
|
1687
|
+
const expectedUrl = page[field];
|
|
1592
1688
|
if (!expectedUrl) continue;
|
|
1593
|
-
const
|
|
1689
|
+
const spellings = UPSELL_ACTION_SPELLINGS[kind];
|
|
1690
|
+
const counts = [];
|
|
1691
|
+
for (const action of spellings) counts.push(await browserPage.locator(upsellActionSelector(action)).count().catch(() => 0));
|
|
1692
|
+
const count = counts.reduce((sum, n) => sum + n, 0);
|
|
1693
|
+
const present = spellings.filter((_, index) => counts[index] > 0);
|
|
1694
|
+
const selector = upsellActionSelector(...(present.length ? present : spellings.slice(0, 1)));
|
|
1594
1695
|
assertions.push(assertion({
|
|
1595
1696
|
id: `browser-upsell-control:${page.page_id}:${kind}`,
|
|
1596
1697
|
family: "browser-runtime",
|
|
@@ -1599,7 +1700,7 @@ async function renderedUpsellControlAssertions(browserPage, page) {
|
|
|
1599
1700
|
severity: count > 0 ? undefined : SEVERITY.WARN,
|
|
1600
1701
|
expected: `rendered SDK ${kind} control`,
|
|
1601
1702
|
actual: count > 0 ? `${count} matching control(s)` : "not found",
|
|
1602
|
-
evidence: { selector
|
|
1703
|
+
evidence: { selector, expected_url: expectedUrl },
|
|
1603
1704
|
}));
|
|
1604
1705
|
}
|
|
1605
1706
|
return assertions;
|
|
@@ -1630,7 +1731,7 @@ async function checkoutPaymentSurfaceAssertions(browserPage, page) {
|
|
|
1630
1731
|
card_number_selector: '[data-next-checkout-field="cc-number"], #spreedly-number',
|
|
1631
1732
|
cvv_selector: '[data-next-checkout-field="cvv"], #spreedly-cvv',
|
|
1632
1733
|
spreedly_frame_urls: spreedlyFrames.map((frame) => frame.url()).slice(0, 5),
|
|
1633
|
-
next_step: "
|
|
1734
|
+
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
1735
|
},
|
|
1635
1736
|
}), assertion({
|
|
1636
1737
|
id: `browser-payment-geometry:${page.page_id}`,
|
|
@@ -2311,7 +2412,10 @@ function promoCodeSurfaceAssertion({ page, declaration, evidence }) {
|
|
|
2311
2412
|
|
|
2312
2413
|
function contractPageType(page) {
|
|
2313
2414
|
const type = String(page?.page_type || "").toLowerCase();
|
|
2314
|
-
|
|
2415
|
+
if (type === "thankyou") return "receipt";
|
|
2416
|
+
// A checkout step renders the family's checkout surface without taking
|
|
2417
|
+
// payment (campaigns-os#641); the brand contract keys that surface "checkout".
|
|
2418
|
+
return type === "checkout_step" ? "checkout" : type;
|
|
2315
2419
|
}
|
|
2316
2420
|
|
|
2317
2421
|
// The palette-residue rows (style, logo, payment chrome) take their severity
|
|
@@ -3189,8 +3293,10 @@ function createFieldTrace() {
|
|
|
3189
3293
|
// before the bounded ladder step starts and updated before each operation that
|
|
3190
3294
|
// can hang. Its synchronous summary survives when the timeout wins the race.
|
|
3191
3295
|
function createUpsellActionTrace({ page, events, topologyPlan, stepIndex, path, inspectTimeoutMs = 1000 }) {
|
|
3192
|
-
|
|
3193
|
-
const
|
|
3296
|
+
// The spelling the page renders, read again at each inspection.
|
|
3297
|
+
const kind = path === "accept" ? "accept" : "decline";
|
|
3298
|
+
let requestedAction = UPSELL_ACTION_SPELLINGS[kind][0];
|
|
3299
|
+
let selector = upsellActionSelector(requestedAction);
|
|
3194
3300
|
let actionNavigationCount = Array.isArray(events?.navigations) ? events.navigations.length : 0;
|
|
3195
3301
|
let actionUpsellRequestCount = (events?.requests || []).filter((request) => isOrderUpsellsUrl(request?.url)).length;
|
|
3196
3302
|
let element = { present: null, visible: null, enabled: null };
|
|
@@ -3200,6 +3306,8 @@ function createUpsellActionTrace({ page, events, topologyPlan, stepIndex, path,
|
|
|
3200
3306
|
let stepCompleted = false;
|
|
3201
3307
|
|
|
3202
3308
|
const inspect = async () => {
|
|
3309
|
+
requestedAction = await presentUpsellAction(page, kind);
|
|
3310
|
+
selector = upsellActionSelector(requestedAction);
|
|
3203
3311
|
const control = page.locator(selector).first();
|
|
3204
3312
|
const count = await control.count().catch(() => 0);
|
|
3205
3313
|
element = {
|
|
@@ -3617,7 +3725,12 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
|
|
|
3617
3725
|
let orderDeadline = null;
|
|
3618
3726
|
let events = { requests: [], responses: [], failed: [], console: [], pageErrors: [] };
|
|
3619
3727
|
const email = testEmail(planArgs);
|
|
3620
|
-
|
|
3728
|
+
// The checkout path this order walks (campaigns-os#641): any checkout_step
|
|
3729
|
+
// pages before the Checkout. The cart is entered into the first form page
|
|
3730
|
+
// of the path, and the step pages are submitted in order before payment.
|
|
3731
|
+
const stepPages = checkoutStepPagesFor(options.topologies, checkoutPage);
|
|
3732
|
+
const formEntryPage = stepPages[0] || checkoutPage;
|
|
3733
|
+
const entryPage = resolvePathEntryPage(options.topologies, formEntryPage, stepPages);
|
|
3621
3734
|
let tracking = null;
|
|
3622
3735
|
try {
|
|
3623
3736
|
tracking = options.tracking ? options.tracking.observe(planId(normalizedPlan)) : null;
|
|
@@ -3738,6 +3851,7 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
|
|
|
3738
3851
|
ladder,
|
|
3739
3852
|
checkoutPage,
|
|
3740
3853
|
entryPage,
|
|
3854
|
+
stepPages,
|
|
3741
3855
|
topologyPlan: normalizedPlan.topology_plan,
|
|
3742
3856
|
path,
|
|
3743
3857
|
args: planArgs,
|
|
@@ -3745,6 +3859,9 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
|
|
|
3745
3859
|
reserveOrderCreation,
|
|
3746
3860
|
selectorProbeCache: options.selectorProbeCache,
|
|
3747
3861
|
tracking,
|
|
3862
|
+
// An in-process test seam only, like trackingTestHooks: unset in
|
|
3863
|
+
// every production run, so the card step waits CARD_READY_TIMEOUT_MS.
|
|
3864
|
+
cardReadyTimeoutMs: shorterBound(options.cardReadyTimeoutMs, CARD_READY_TIMEOUT_MS),
|
|
3748
3865
|
}),
|
|
3749
3866
|
orderTimeoutMs + ORDER_TIMEOUT_GRACE_MS,
|
|
3750
3867
|
`order-path:${planId(normalizedPlan)}`,
|
|
@@ -3823,11 +3940,15 @@ function stablePrivateCaptureError(value) {
|
|
|
3823
3940
|
return projectAnalyticsCaptureError(value, { fallbackKind: "unreadable" });
|
|
3824
3941
|
}
|
|
3825
3942
|
|
|
3826
|
-
async function executeTestOrderPath({ page, events, email, ladder, checkoutPage, entryPage = null, topologyPlan, path, args, deadline, reserveOrderCreation = null, selectorProbeCache = null, tracking = null }) {
|
|
3943
|
+
async function executeTestOrderPath({ page, events, email, ladder, checkoutPage, entryPage = null, stepPages = [], topologyPlan, path, args, deadline, reserveOrderCreation = null, selectorProbeCache = null, tracking = null, cardReadyTimeoutMs = CARD_READY_TIMEOUT_MS }) {
|
|
3827
3944
|
const stepTimeoutMs = numberArg(args["step-timeout-ms"], DEFAULT_STEP_TIMEOUT_MS);
|
|
3828
3945
|
const budget = () => Math.min(stepTimeoutMs, deadline - Date.now());
|
|
3829
3946
|
const hostedNow = () => hostedRedirectInfo(safePageUrl(page), checkoutPage.url);
|
|
3947
|
+
// The page the cart enters and the form filling starts on: the first
|
|
3948
|
+
// checkout_step of a multi-step path, else the Checkout itself.
|
|
3949
|
+
const formEntryPage = stepPages[0] || checkoutPage;
|
|
3830
3950
|
const selectedPackages = parseCart(args["select-package"]);
|
|
3951
|
+
const currencyLoad = pathCurrencyLoad(args);
|
|
3831
3952
|
let checkoutDisplay = null;
|
|
3832
3953
|
let cartBeforeSubmit = null;
|
|
3833
3954
|
|
|
@@ -3843,7 +3964,8 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3843
3964
|
// page the probe left there rather than loading the same URL a second time,
|
|
3844
3965
|
// which would fire the SDK's page-view events twice into the same capture.
|
|
3845
3966
|
const entry = await ladder.run(CART_ENTRY_STEP, () => enterCartViaLanding({
|
|
3846
|
-
page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking,
|
|
3967
|
+
page, checkoutPage: formEntryPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking, currencyLoad,
|
|
3968
|
+
startsOnCheckoutStep: formEntryPage !== checkoutPage,
|
|
3847
3969
|
}), { timeoutMs: budget() });
|
|
3848
3970
|
const enteredViaLanding = Boolean(entry && typeof entry === "object" && entry.entered);
|
|
3849
3971
|
// Responses captured from here on belong to the checkout the ladder drives.
|
|
@@ -3851,17 +3973,18 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3851
3973
|
// made before the hand-off.
|
|
3852
3974
|
const checkoutResponseOffset = events.responses.length;
|
|
3853
3975
|
|
|
3854
|
-
await ladder.run("opened_checkout", () => openCheckoutForPath({ page, checkoutPage, entry, args, tracking }), { timeoutMs: budget() });
|
|
3976
|
+
await ladder.run("opened_checkout", () => openCheckoutForPath({ page, checkoutPage: formEntryPage, entry, args, tracking, currencyLoad }), { timeoutMs: budget() });
|
|
3855
3977
|
await ladder.run("selected_bundle", async () => {
|
|
3856
3978
|
// The requested package was selected on the entry page, where the cards
|
|
3857
3979
|
// live; checkout renders none, so re-running strict selection here would
|
|
3858
3980
|
// fail for the wrong reason.
|
|
3981
|
+
const selectedCards = [];
|
|
3859
3982
|
const strictSelection = enteredViaLanding && selectedPackages.length
|
|
3860
3983
|
? `selected on entry page: ${entry.package_id || "(no package id on control)"}`
|
|
3861
|
-
: await selectRequestedPackages(page, selectedPackages);
|
|
3862
|
-
await selectRequestedCart(page, args);
|
|
3984
|
+
: await selectRequestedPackages(page, selectedPackages, selectedCards);
|
|
3985
|
+
const unappliedCart = await selectRequestedCart(page, args, { selectedPackages, selectedCards });
|
|
3863
3986
|
await advanceToCheckoutForm(page);
|
|
3864
|
-
if (strictSelection) return `selected requested package card(s): ${strictSelection}`;
|
|
3987
|
+
if (strictSelection) return `selected requested package card(s): ${[strictSelection, ...unappliedCart].join("; ")}`;
|
|
3865
3988
|
return parseCart(args.cart).length ? `requested cart ${args.cart}` : "default bundle selection";
|
|
3866
3989
|
}, { timeoutMs: budget() });
|
|
3867
3990
|
await ladder.run("bump_state", async () => {
|
|
@@ -3874,8 +3997,20 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3874
3997
|
const fieldTrace = createFieldTrace();
|
|
3875
3998
|
await ladder.run("customer_fields_filled", async () => {
|
|
3876
3999
|
ensurePageFillable(page, checkoutPage.url);
|
|
3877
|
-
|
|
4000
|
+
// On a multi-step path each page renders only its own fields, so every
|
|
4001
|
+
// field is filled where it is present and skipped where it is not. The
|
|
4002
|
+
// SDK's step validation refuses to advance when one is missing, which
|
|
4003
|
+
// the step rung below reports by name.
|
|
4004
|
+
await fillCheckoutFields(page, args, email, { trace: fieldTrace, actionTimeoutMs: budget(), presentOnly: stepPages.length > 0 });
|
|
3878
4005
|
}, { timeoutMs: budget(), evidence: () => fieldTrace.summary() });
|
|
4006
|
+
// Walk the path: submit each checkout_step and assert it lands on the
|
|
4007
|
+
// step's declared next page, then fill what the arrived page renders. No
|
|
4008
|
+
// order is placed until the Checkout (campaigns-os#641).
|
|
4009
|
+
for (const [index, step] of stepPages.entries()) {
|
|
4010
|
+
await ladder.run("checkout_step_submitted", () => submitCheckoutStepAndArrive({
|
|
4011
|
+
page, step, index, args, email, budget, checkoutUrl: checkoutPage.url, tracking,
|
|
4012
|
+
}), { timeoutMs: budget() });
|
|
4013
|
+
}
|
|
3879
4014
|
await ladder.run("coupon_applied", async () => {
|
|
3880
4015
|
const code = stringArg(args["apply-coupon"]);
|
|
3881
4016
|
if (!code) return { skip: "no --apply-coupon code requested" };
|
|
@@ -3884,7 +4019,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3884
4019
|
}, { timeoutMs: budget() });
|
|
3885
4020
|
await ladder.run("card_fields_filled", async () => {
|
|
3886
4021
|
ensurePageFillable(page, checkoutPage.url);
|
|
3887
|
-
return fillPaymentFields(page, args);
|
|
4022
|
+
return fillPaymentFields(page, args, { readyTimeoutMs: cardReadyTimeoutMs });
|
|
3888
4023
|
}, { timeoutMs: budget() });
|
|
3889
4024
|
await ladder.run("cart_created", async () => {
|
|
3890
4025
|
const cart = cartCreationEvidence(events);
|
|
@@ -3940,6 +4075,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3940
4075
|
display: checkoutDisplay,
|
|
3941
4076
|
events,
|
|
3942
4077
|
selected_packages: selectedPackages,
|
|
4078
|
+
requested_cart: parseCart(args.cart),
|
|
3943
4079
|
});
|
|
3944
4080
|
order.verification.total_parity = assessOrderTotalParity({
|
|
3945
4081
|
display: checkoutDisplay,
|
|
@@ -4136,7 +4272,7 @@ function createSelectorProbeCache() {
|
|
|
4136
4272
|
// loaded it and found a selection surface; either way a second load of the
|
|
4137
4273
|
// same URL is what is avoided. Anywhere else (a cached probe answer skipped the
|
|
4138
4274
|
// load; a fresh page) the checkout is opened here, once.
|
|
4139
|
-
async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking = null }) {
|
|
4275
|
+
async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking = null, currencyLoad = null }) {
|
|
4140
4276
|
const enteredViaLanding = Boolean(entry && typeof entry === "object" && entry.entered);
|
|
4141
4277
|
if (enteredViaLanding) {
|
|
4142
4278
|
await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
|
|
@@ -4145,14 +4281,14 @@ async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking =
|
|
|
4145
4281
|
if (checkoutUrlPredicate(checkoutPage.url)(safePageUrl(page))) {
|
|
4146
4282
|
return "already on checkout from the selector probe; not re-opened";
|
|
4147
4283
|
}
|
|
4148
|
-
await gotoAndSettle(page, checkoutPage.url, args, tracking);
|
|
4284
|
+
await gotoAndSettle(page, checkoutPage.url, args, tracking, currencyLoad);
|
|
4149
4285
|
return null;
|
|
4150
4286
|
}
|
|
4151
4287
|
|
|
4152
4288
|
// The entry step body. Resolves to `{ skip }` when the checkout selects for
|
|
4153
4289
|
// itself, to `{ entered: true, ... }` when the runner came in through the entry
|
|
4154
4290
|
// 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 }) {
|
|
4291
|
+
async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache = null, tracking = null, currencyLoad = null, startsOnCheckoutStep = false }) {
|
|
4156
4292
|
// Probe the checkout first: whether it carries a selection surface is a fact
|
|
4157
4293
|
// about the rendered page, not about the spec (the spec cannot say it yet —
|
|
4158
4294
|
// that is the design half of #206). The probe's load is the checkout's only
|
|
@@ -4169,7 +4305,7 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4169
4305
|
let probe = cached ? "reused" : "loaded";
|
|
4170
4306
|
let probeError = null;
|
|
4171
4307
|
if (!surface) {
|
|
4172
|
-
await gotoAndSettle(page, checkoutPage.url, args, tracking);
|
|
4308
|
+
await gotoAndSettle(page, checkoutPage.url, args, tracking, currencyLoad);
|
|
4173
4309
|
const probed = await page.evaluate(checkoutSelectionSurfaceScript())
|
|
4174
4310
|
.then((value) => ({ value }), (error) => ({ value: null, error: error?.message || String(error) }));
|
|
4175
4311
|
if (probed.value) {
|
|
@@ -4188,6 +4324,15 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4188
4324
|
evidence: { checkout_selection_surface: surface, ...probeEvidence },
|
|
4189
4325
|
};
|
|
4190
4326
|
}
|
|
4327
|
+
if (!entryPage?.url && startsOnCheckoutStep) {
|
|
4328
|
+
// A path that begins on its first checkout_step has no page before it to
|
|
4329
|
+
// fill the cart from; the step page fills it (the family's main package).
|
|
4330
|
+
// The empty-cart guard before submit still refuses an empty cart by name.
|
|
4331
|
+
return {
|
|
4332
|
+
skip: "the checkout path begins on its first checkout step and no page routes into it; the step page fills the cart, and the guard before submit checks it",
|
|
4333
|
+
evidence: { checkout_selection_surface: surface, ...probeEvidence },
|
|
4334
|
+
};
|
|
4335
|
+
}
|
|
4191
4336
|
if (!entryPage?.url) {
|
|
4192
4337
|
throw codedError(
|
|
4193
4338
|
CART_ENTRY_CODES.ENTRY_UNRESOLVED,
|
|
@@ -4195,18 +4340,50 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4195
4340
|
);
|
|
4196
4341
|
}
|
|
4197
4342
|
|
|
4198
|
-
await gotoAndSettle(page, entryPage.url, args, tracking);
|
|
4343
|
+
await gotoAndSettle(page, entryPage.url, args, tracking, currencyLoad);
|
|
4199
4344
|
const sdkReady = await waitForSdkReady(page, Math.min(budget(), DEFAULT_SETTLE_TIMEOUT_MS));
|
|
4200
4345
|
await tracking?.readDocument(page);
|
|
4201
|
-
const
|
|
4346
|
+
const readControls = () => page.evaluate(cartEntryControlsScript(), {
|
|
4347
|
+
selector: CART_ENTRY_CONTROL_SELECTOR,
|
|
4348
|
+
checkoutUrl: checkoutPage.url,
|
|
4349
|
+
checkoutButtonSelector: CART_ENTRY_CHECKOUT_BUTTON_SELECTOR,
|
|
4350
|
+
bundleCardSelector: CART_ENTRY_BUNDLE_CARD_SELECTOR,
|
|
4351
|
+
}).catch(() => []);
|
|
4352
|
+
let controls = await readControls();
|
|
4353
|
+
// A two-step select page can hold its checkout button in a second step that
|
|
4354
|
+
// a variant control reveals (olympus-mv-two-step: "Select Color & Size").
|
|
4355
|
+
// When no checkout button is visible yet, click that control once and read
|
|
4356
|
+
// the controls again, so a hidden decoy link is never chosen over the
|
|
4357
|
+
// button the reveal shows.
|
|
4358
|
+
const checkoutButtons = controls.filter((entry) => entry.kind === "checkout_button");
|
|
4359
|
+
if (checkoutButtons.length && !checkoutButtons.some((entry) => entry.visible)) {
|
|
4360
|
+
const reveal = page.locator('[data-next-action="select-variants"]:visible').first();
|
|
4361
|
+
if (await reveal.count().catch(() => 0)) {
|
|
4362
|
+
await clickControl(reveal, { timeout: 5000 }).catch(() => {});
|
|
4363
|
+
await page.locator(CART_ENTRY_CHECKOUT_BUTTON_SELECTOR).filter({ visible: true }).first()
|
|
4364
|
+
.waitFor({ state: "visible", timeout: Math.max(1000, Math.min(budget(), 8000)) }).catch(() => {});
|
|
4365
|
+
controls = await readControls();
|
|
4366
|
+
}
|
|
4367
|
+
}
|
|
4202
4368
|
const choice = chooseCartEntryControl(controls, selectedPackages);
|
|
4203
4369
|
if (!choice.control) {
|
|
4204
4370
|
throw codedError(CART_ENTRY_CODES.ENTRY_CONTROL_MISSING, `${choice.reason} (entry page ${redactUrlQuery(entryPage.url)})`);
|
|
4205
4371
|
}
|
|
4206
4372
|
const control = choice.control;
|
|
4373
|
+
// A select page (#641): the bundle card is the cart. --select-package clicks
|
|
4374
|
+
// the matching data-next-bundle-card first, strictly, as on a checkout that
|
|
4375
|
+
// selects for itself; otherwise the page's pre-selected card stands.
|
|
4376
|
+
let cardSelection = null;
|
|
4377
|
+
if (control.kind === "checkout_button") {
|
|
4378
|
+
cardSelection = choice.select_card ? await selectPackageCard(page, choice.select_card, []) : null;
|
|
4379
|
+
control.package_id = choice.select_card?.packageId || await selectedBundleCardPackage(page);
|
|
4380
|
+
}
|
|
4207
4381
|
// The index is the control's position among what its own locator matches,
|
|
4208
4382
|
// so it replays with nth(); no selector is rebuilt from an attribute value.
|
|
4209
|
-
const
|
|
4383
|
+
const controlSelector = control.kind === "add_to_cart"
|
|
4384
|
+
? CART_ENTRY_CONTROL_SELECTOR
|
|
4385
|
+
: control.kind === "checkout_button" ? CART_ENTRY_CHECKOUT_BUTTON_SELECTOR : "a[href]";
|
|
4386
|
+
const target = page.locator(controlSelector).nth(control.index);
|
|
4210
4387
|
const perpetual = await scrollControlIntoView(target);
|
|
4211
4388
|
await clickControl(target, { timeout: 8000, perpetual });
|
|
4212
4389
|
|
|
@@ -4236,6 +4413,7 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4236
4413
|
control_text: control.text || null,
|
|
4237
4414
|
control_kind: control.kind,
|
|
4238
4415
|
package_id: control.package_id || null,
|
|
4416
|
+
...(cardSelection ? { bundle_card_selection: cardSelection } : {}),
|
|
4239
4417
|
sdk_ready: sdkReady,
|
|
4240
4418
|
arrived_url: redactUrlQuery(safePageUrl(page)),
|
|
4241
4419
|
checkout_selection_surface: surface,
|
|
@@ -4244,6 +4422,92 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4244
4422
|
};
|
|
4245
4423
|
}
|
|
4246
4424
|
|
|
4425
|
+
// The package of the bundle card a select page shows as selected, or null.
|
|
4426
|
+
async function selectedBundleCardPackage(page) {
|
|
4427
|
+
return page.evaluate((cardSelector) => {
|
|
4428
|
+
const card = Array.from(document.querySelectorAll(cardSelector)).find((node) => (
|
|
4429
|
+
node.classList.contains("next-selected") || node.getAttribute("data-next-selected") === "true" || node.getAttribute("aria-checked") === "true"
|
|
4430
|
+
));
|
|
4431
|
+
const holder = card && (card.hasAttribute("data-next-package-id") ? card : card.querySelector("[data-next-package-id]"));
|
|
4432
|
+
const value = holder ? String(holder.getAttribute("data-next-package-id") || "").trim() : "";
|
|
4433
|
+
return value || null;
|
|
4434
|
+
}, CART_ENTRY_BUNDLE_CARD_SELECTOR).catch(() => null);
|
|
4435
|
+
}
|
|
4436
|
+
|
|
4437
|
+
// One checkout_step rung: the page must be on this step, its form's submit is
|
|
4438
|
+
// clicked, and the page must land on the step's declared next page. Then the
|
|
4439
|
+
// fields the arrived page renders are filled. The SDK owns the navigation
|
|
4440
|
+
// (data-next-checkout-step on the form); the runner only waits for it.
|
|
4441
|
+
async function submitCheckoutStepAndArrive({ page, step, index, args, email, budget, checkoutUrl, tracking = null }) {
|
|
4442
|
+
ensurePageFillable(page, checkoutUrl);
|
|
4443
|
+
const here = safePageUrl(page);
|
|
4444
|
+
if (!checkoutUrlPredicate(step.url)(here)) {
|
|
4445
|
+
throw new Error(`expected to be on checkout step "${step.page_id || index + 1}" (${redactUrlQuery(step.url)}) but the page is at ${redactUrlQuery(here) || "(unknown)"}`);
|
|
4446
|
+
}
|
|
4447
|
+
const expected = step.expected_next_url;
|
|
4448
|
+
if (!expected) throw new Error(`checkout step "${step.page_id || index + 1}" declares no next page; the spec must route it (next_page) toward its Checkout`);
|
|
4449
|
+
await tracking?.readDocument(page);
|
|
4450
|
+
await submitCheckoutStep(page);
|
|
4451
|
+
const atNext = checkoutUrlPredicate(expected);
|
|
4452
|
+
const timeout = Math.max(1000, Math.min(budget(), numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS)));
|
|
4453
|
+
const arrived = await page.waitForURL((url) => atNext(url.toString()), { timeout }).then(() => true, () => false);
|
|
4454
|
+
if (!arrived) {
|
|
4455
|
+
throw new Error(`submitted checkout step "${step.page_id || index + 1}" but the page did not reach its declared next page ${redactUrlQuery(expected)} within ${timeout}ms (now at ${redactUrlQuery(safePageUrl(page)) || "(unknown)"}); a required field the step validates may be missing`);
|
|
4456
|
+
}
|
|
4457
|
+
await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
|
|
4458
|
+
await page.waitForTimeout(500);
|
|
4459
|
+
ensurePageFillable(page, checkoutUrl);
|
|
4460
|
+
const fieldTrace = createFieldTrace();
|
|
4461
|
+
await fillCheckoutFields(page, args, email, { trace: fieldTrace, actionTimeoutMs: budget(), presentOnly: true });
|
|
4462
|
+
return {
|
|
4463
|
+
detail: `step ${index + 1} "${step.page_id || "(unnamed)"}": submitted and landed on its declared next page ${redactUrlQuery(expected)}`,
|
|
4464
|
+
evidence: {
|
|
4465
|
+
step_page_id: step.page_id || null,
|
|
4466
|
+
step_number: index + 1,
|
|
4467
|
+
expected_next_url: redactUrlQuery(expected),
|
|
4468
|
+
arrived_url: redactUrlQuery(safePageUrl(page)),
|
|
4469
|
+
fields: fieldTrace.summary(),
|
|
4470
|
+
},
|
|
4471
|
+
};
|
|
4472
|
+
}
|
|
4473
|
+
|
|
4474
|
+
// The submit control of a checkout_step form. The step contract puts
|
|
4475
|
+
// data-next-checkout-step on the checkout form itself, so a page without one
|
|
4476
|
+
// is reported rather than submitted some other way.
|
|
4477
|
+
async function submitCheckoutStep(page) {
|
|
4478
|
+
await closeAddressAutocomplete(page);
|
|
4479
|
+
const scope = "form[data-next-checkout-step]";
|
|
4480
|
+
// Never an express-wallet button: on a step page those place an order.
|
|
4481
|
+
const notWallet = ":not([data-next-express-checkout]):not([data-next-express-checkout] *)";
|
|
4482
|
+
const submit = page.locator([`button[type="submit"]`, "button:not([type])", `input[type="submit"]`]
|
|
4483
|
+
.map((control) => `${scope} ${control}${notWallet}`).join(", ")).first();
|
|
4484
|
+
if (!await submit.count().catch(() => 0)) {
|
|
4485
|
+
throw new Error("checkout step page renders no form[data-next-checkout-step] with a submit control; the step form must carry data-next-checkout-step and data-next-step-number");
|
|
4486
|
+
}
|
|
4487
|
+
await submit.waitFor({ state: "visible" });
|
|
4488
|
+
const perpetual = await scrollControlIntoView(submit);
|
|
4489
|
+
await clickControl(submit, { forceFallback: false, perpetual });
|
|
4490
|
+
}
|
|
4491
|
+
|
|
4492
|
+
// The checkout_step pages on the path into `checkoutPage`, read from the
|
|
4493
|
+
// topology that holds it.
|
|
4494
|
+
function checkoutStepPagesFor(topologies, checkoutPage) {
|
|
4495
|
+
if (!checkoutPage) return [];
|
|
4496
|
+
const owner = (Array.isArray(topologies) ? topologies : []).find((topology) => (topology?.pages || []).includes(checkoutPage))
|
|
4497
|
+
|| (Array.isArray(topologies) ? topologies : []).find((topology) => (topology?.pages || []).some((page) => checkoutUrlPredicate(checkoutPage.url)(page?.url)));
|
|
4498
|
+
return owner ? checkoutStepPages(owner, checkoutPage) : [];
|
|
4499
|
+
}
|
|
4500
|
+
|
|
4501
|
+
// The page the cart is entered from. Into a Checkout, the usual resolution.
|
|
4502
|
+
// Into a first checkout_step, only a page that routes straight into it: the
|
|
4503
|
+
// order-and-type fallbacks would pick another path's page in a campaign with
|
|
4504
|
+
// parallel paths, and a path that starts on its step has none (#641).
|
|
4505
|
+
function resolvePathEntryPage(topologies, formEntryPage, stepPages) {
|
|
4506
|
+
const resolved = resolveCartEntryPage(topologies, formEntryPage);
|
|
4507
|
+
if (!stepPages.length) return resolved;
|
|
4508
|
+
return resolved?.resolution === "routes_into_checkout" ? resolved : null;
|
|
4509
|
+
}
|
|
4510
|
+
|
|
4247
4511
|
// The SDK installs `window.next` late in boot. Bounded and tolerant: a page
|
|
4248
4512
|
// without the SDK (a plain forcePackageId link) still has a cart entry.
|
|
4249
4513
|
async function waitForSdkReady(page, timeoutMs) {
|
|
@@ -4491,24 +4755,149 @@ function assessReceiptRendering(persistedLineCount, evidence = {}) {
|
|
|
4491
4755
|
};
|
|
4492
4756
|
}
|
|
4493
4757
|
|
|
4758
|
+
// `qa run --currency <code>`, validated and upper-cased. qa-node.mjs sets it
|
|
4759
|
+
// after its own check; a symbol, so no command-line flag can set it, and a
|
|
4760
|
+
// command that never validates --currency (qa parity) never loads with one.
|
|
4761
|
+
export const QA_RUN_CURRENCY = Symbol("qa run --currency");
|
|
4762
|
+
|
|
4763
|
+
// One order path's currency: added to the path's first runner load only —
|
|
4764
|
+
// the checkout probe, the entry page when the probe answer was reused, or the
|
|
4765
|
+
// checkout opened directly. Campaign Cart keeps a URL currency in session
|
|
4766
|
+
// storage for the rest of the tab's session, so later loads in the tab are
|
|
4767
|
+
// the plain URL; the receipt reload in recovery is not a path load at all.
|
|
4768
|
+
function pathCurrencyLoad(args) {
|
|
4769
|
+
const code = args?.[QA_RUN_CURRENCY];
|
|
4770
|
+
let pending = typeof code === "string" && /^[A-Z]{3}$/.test(code) ? code : null;
|
|
4771
|
+
return (url) => {
|
|
4772
|
+
if (!pending) return url;
|
|
4773
|
+
const entered = withQueryParam(url, "currency", pending);
|
|
4774
|
+
pending = null;
|
|
4775
|
+
return entered;
|
|
4776
|
+
};
|
|
4777
|
+
}
|
|
4778
|
+
|
|
4494
4779
|
// 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
|
-
|
|
4780
|
+
// attempt's first page-initiated hop it carries the run's synthetic seeds,
|
|
4781
|
+
// after the path's currency when this is the load that carries one.
|
|
4782
|
+
async function gotoAndSettle(page, url, args, tracking = null, currencyLoad = null) {
|
|
4783
|
+
const entered = currencyLoad ? currencyLoad(url) : url;
|
|
4784
|
+
const target = tracking ? tracking.runnerUrl(entered, withQueryParam) : entered;
|
|
4498
4785
|
await page.goto(target, { waitUntil: "domcontentloaded", timeout: numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS) });
|
|
4499
4786
|
await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
|
|
4500
4787
|
await page.waitForTimeout(750);
|
|
4501
4788
|
}
|
|
4502
4789
|
|
|
4503
|
-
|
|
4790
|
+
// Returns one note per --cart ref left unapplied beside a --select-package
|
|
4791
|
+
// selection; the selected_bundle step reports them.
|
|
4792
|
+
async function selectRequestedCart(page, args, { selectedPackages = [], selectedCards = [] } = {}) {
|
|
4504
4793
|
const cart = parseCart(args.cart);
|
|
4794
|
+
if (!cart.length) return [];
|
|
4795
|
+
if (selectedPackages.length) return applyCartBesideSelection(page, cart, { selectedPackages, selectedCards });
|
|
4796
|
+
// A package a bundle card declares, asked for at an explicit quantity, is
|
|
4797
|
+
// chosen by the card whose items carry it at that quantity. The first
|
|
4798
|
+
// [data-next-package-id] for that package is usually the one-unit card's
|
|
4799
|
+
// inner node, so the plain click below would reset a multi-unit selection.
|
|
4800
|
+
// A bare ref names no quantity and clicks that first node, as it always has.
|
|
4801
|
+
// The cards are read again for each ref: a click may re-render the selector.
|
|
4505
4802
|
for (const item of cart) {
|
|
4803
|
+
const ref = String(item.packageId);
|
|
4804
|
+
const bundleCards = (await renderedPackageCardCandidates(page))
|
|
4805
|
+
.filter((candidate) => candidate.bundle_id && Array.isArray(candidate.items));
|
|
4806
|
+
if (item.quantityExplicit && bundleCards.some((card) => card.items.some((entry) => entry.package_id === ref))) {
|
|
4807
|
+
await selectCartBundleCard(page, bundleCards, item);
|
|
4808
|
+
continue;
|
|
4809
|
+
}
|
|
4506
4810
|
const target = page.locator(packageCardClickSelector({ package_id: item.packageId })).first();
|
|
4507
4811
|
if (await target.count().catch(() => 0)) {
|
|
4508
4812
|
const perpetual = await scrollControlIntoView(target);
|
|
4509
4813
|
await clickControl(target, { timeout: 5000, forceFallback: false, perpetual }).catch(() => {});
|
|
4510
4814
|
}
|
|
4511
4815
|
}
|
|
4816
|
+
return [];
|
|
4817
|
+
}
|
|
4818
|
+
|
|
4819
|
+
function cardCarriesPackage(candidate, ref) {
|
|
4820
|
+
return String(candidate?.package_id || "") === ref
|
|
4821
|
+
|| String(candidate?.bundle_id || "") === ref
|
|
4822
|
+
|| (Array.isArray(candidate?.items) && candidate.items.some((entry) => String(entry?.package_id || "") === ref));
|
|
4823
|
+
}
|
|
4824
|
+
|
|
4825
|
+
// --cart beside --select-package: the card --select-package chose stays
|
|
4826
|
+
// selected. A --cart ref that card carries is already applied. Any other ref
|
|
4827
|
+
// is applied through its own control (an order bump toggle, or a card in a
|
|
4828
|
+
// selector group --select-package did not choose in), never by clicking
|
|
4829
|
+
// another card in the chosen card's group. A ref only such a click could
|
|
4830
|
+
// satisfy is left unapplied and named, so the order and its reconciliation
|
|
4831
|
+
// stay those of the card actually selected.
|
|
4832
|
+
async function applyCartBesideSelection(page, cart, { selectedPackages, selectedCards }) {
|
|
4833
|
+
const chosenIdentities = new Set(selectedCards.map(packageCardIdentity));
|
|
4834
|
+
const strictRefs = selectedPackages.map((item) => String(item.packageId));
|
|
4835
|
+
const unapplied = [];
|
|
4836
|
+
for (const item of cart) {
|
|
4837
|
+
const ref = String(item.packageId);
|
|
4838
|
+
const label = `--cart ${ref}:${item.quantity}`;
|
|
4839
|
+
const candidates = await renderedPackageCardCandidates(page, { placement: true });
|
|
4840
|
+
// Cards chosen on this page, or (when the choice was made on an entry
|
|
4841
|
+
// page) the selected cards that carry a --select-package ref.
|
|
4842
|
+
const chosen = candidates.filter((candidate) => !candidate.toggle && (
|
|
4843
|
+
chosenIdentities.has(packageCardIdentity(candidate))
|
|
4844
|
+
|| (candidate.selected && strictRefs.some((strictRef) => cardCarriesPackage(candidate, strictRef)))
|
|
4845
|
+
));
|
|
4846
|
+
const carrying = chosen.find((candidate) => cardCarriesPackage(candidate, ref));
|
|
4847
|
+
if (carrying) {
|
|
4848
|
+
const declared = carrying.items?.find((entry) => entry.package_id === ref)?.quantity;
|
|
4849
|
+
if (item.quantityExplicit && declared != null && declared !== item.quantity) {
|
|
4850
|
+
unapplied.push(`${label} not applied: the card --select-package chose declares ${ref}:${declared}`);
|
|
4851
|
+
}
|
|
4852
|
+
continue;
|
|
4853
|
+
}
|
|
4854
|
+
// A card in no recognised selector container may share the chosen card's
|
|
4855
|
+
// selection, so it is locked like a card in the chosen card's group.
|
|
4856
|
+
const lockedGroups = new Set(chosen.map((candidate) => candidate.group));
|
|
4857
|
+
const reachable = candidates.filter((candidate) => (
|
|
4858
|
+
cardCarriesPackage(candidate, ref)
|
|
4859
|
+
&& (candidate.toggle || (candidate.group != null && !lockedGroups.has(candidate.group)))
|
|
4860
|
+
));
|
|
4861
|
+
if (!reachable.length) {
|
|
4862
|
+
unapplied.push(candidates.some((candidate) => cardCarriesPackage(candidate, ref))
|
|
4863
|
+
? `${label} not applied: only another card in the selector --select-package chose carries it`
|
|
4864
|
+
: `${label} not applied: no rendered card or control carries it`);
|
|
4865
|
+
continue;
|
|
4866
|
+
}
|
|
4867
|
+
let target = reachable.find((candidate) => candidate.toggle);
|
|
4868
|
+
if (!target) {
|
|
4869
|
+
const declaringCards = reachable.filter((candidate) => candidate.items?.some((entry) => entry.package_id === ref));
|
|
4870
|
+
try {
|
|
4871
|
+
target = declaringCards.length ? resolvePackageCardCandidate(declaringCards, item) : reachable[0];
|
|
4872
|
+
} catch (error) {
|
|
4873
|
+
unapplied.push(`${label} not applied: ${error.message}`);
|
|
4874
|
+
continue;
|
|
4875
|
+
}
|
|
4876
|
+
}
|
|
4877
|
+
const control = page.locator(PACKAGE_CARD_QUERY).nth(target.element_index);
|
|
4878
|
+
const perpetual = await scrollControlIntoView(control);
|
|
4879
|
+
await clickControl(control, { timeout: 5000, forceFallback: false, perpetual }).catch(() => {});
|
|
4880
|
+
}
|
|
4881
|
+
return unapplied;
|
|
4882
|
+
}
|
|
4883
|
+
|
|
4884
|
+
// Best-effort, like the rest of --cart: a ref no card satisfies leaves the
|
|
4885
|
+
// selection alone, and reconciliation reports the quantity the order carried.
|
|
4886
|
+
async function selectCartBundleCard(page, bundleCards, item) {
|
|
4887
|
+
const ref = String(item.packageId);
|
|
4888
|
+
const { selected_bundle_items: selected = [] } = await checkoutDisplayEvidence(page);
|
|
4889
|
+
if (selected.some((card) => card.items?.some((entry) => entry.package_id === ref && entry.quantity === item.quantity))) return;
|
|
4890
|
+
let candidate;
|
|
4891
|
+
try {
|
|
4892
|
+
candidate = resolvePackageCardCandidate(bundleCards, item);
|
|
4893
|
+
} catch {
|
|
4894
|
+
return;
|
|
4895
|
+
}
|
|
4896
|
+
const target = page.locator(packageCardClickSelector(candidate)).first();
|
|
4897
|
+
if (await target.count().catch(() => 0)) {
|
|
4898
|
+
const perpetual = await scrollControlIntoView(target);
|
|
4899
|
+
await clickControl(target, { timeout: 5000, forceFallback: false, perpetual }).catch(() => {});
|
|
4900
|
+
}
|
|
4512
4901
|
}
|
|
4513
4902
|
|
|
4514
4903
|
// --- Strict package/bundle card selection (--select-package) ---
|
|
@@ -4518,16 +4907,17 @@ async function selectRequestedCart(page, args) {
|
|
|
4518
4907
|
// strict variant: the requested card must exist, be clickable, and an explicit
|
|
4519
4908
|
// quantity must visibly enter the selected state — otherwise the
|
|
4520
4909
|
// selected_bundle step fails instead of driving the wrong tier.
|
|
4521
|
-
|
|
4910
|
+
// `cards` collects the card each item selected, for --cart to leave alone.
|
|
4911
|
+
async function selectRequestedPackages(page, requested, cards = []) {
|
|
4522
4912
|
if (!requested.length) return null;
|
|
4523
4913
|
const details = [];
|
|
4524
4914
|
for (const item of requested) {
|
|
4525
|
-
details.push(await selectPackageCard(page, item));
|
|
4915
|
+
details.push(await selectPackageCard(page, item, cards));
|
|
4526
4916
|
}
|
|
4527
4917
|
return details.join("; ");
|
|
4528
4918
|
}
|
|
4529
4919
|
|
|
4530
|
-
async function selectPackageCard(page, item) {
|
|
4920
|
+
async function selectPackageCard(page, item, cards = []) {
|
|
4531
4921
|
const candidate = resolvePackageCardCandidate(await renderedPackageCardCandidates(page), item);
|
|
4532
4922
|
const selector = packageCardClickSelector(candidate);
|
|
4533
4923
|
const target = page.locator(selector).first();
|
|
@@ -4549,14 +4939,36 @@ async function selectPackageCard(page, item) {
|
|
|
4549
4939
|
if (packageCardProofIdentity(selected) !== packageCardProofIdentity(candidate)) {
|
|
4550
4940
|
throw new Error(`--select-package ${item.packageId}: rendered card composition changed after click`);
|
|
4551
4941
|
}
|
|
4942
|
+
cards.push(selected);
|
|
4552
4943
|
const quantity = item.quantity || 1;
|
|
4553
4944
|
return `${item.packageId}:${quantity} via ${selector}${state === "unknown" ? " (card exposes no selected-state marker; composition verified)" : ""}`;
|
|
4554
4945
|
}
|
|
4555
4946
|
|
|
4556
|
-
|
|
4557
|
-
|
|
4947
|
+
const PACKAGE_CARD_QUERY = "[data-next-bundle-card], [data-next-selector-card], [data-next-package-id], [data-next-bundle-id]";
|
|
4948
|
+
|
|
4949
|
+
// `placement` adds where each card sits: its selector group (an index valid
|
|
4950
|
+
// for this read only, null for a card in no recognised selector container,
|
|
4951
|
+
// whose own wrapper says nothing about which cards it swaps with), whether it
|
|
4952
|
+
// is selected, whether it is an order-bump
|
|
4953
|
+
// style toggle rather than a selector card, and the index of its element in
|
|
4954
|
+
// PACKAGE_CARD_QUERY so that one exact element can be clicked.
|
|
4955
|
+
async function renderedPackageCardCandidates(page, { placement = false } = {}) {
|
|
4956
|
+
return page.locator(PACKAGE_CARD_QUERY).evaluateAll((elements, withPlacement) => {
|
|
4558
4957
|
const cards = [];
|
|
4559
4958
|
const seen = new Set();
|
|
4959
|
+
const groups = new Map();
|
|
4960
|
+
const groupOf = (card) => {
|
|
4961
|
+
const container = card.closest("[data-next-bundle-selector], [data-next-selector-id], [data-next-cart-selector]");
|
|
4962
|
+
if (!container) return null;
|
|
4963
|
+
if (!groups.has(container)) groups.set(container, groups.size);
|
|
4964
|
+
return groups.get(container);
|
|
4965
|
+
};
|
|
4966
|
+
const isSelected = (card) => (
|
|
4967
|
+
card.classList.contains("next-selected")
|
|
4968
|
+
|| card.getAttribute("data-next-selected") === "true"
|
|
4969
|
+
|| card.getAttribute("aria-checked") === "true"
|
|
4970
|
+
|| card.querySelector('input[type="radio"], input[type="checkbox"]')?.checked === true
|
|
4971
|
+
);
|
|
4560
4972
|
const parseItems = (value) => {
|
|
4561
4973
|
if (!value) return null;
|
|
4562
4974
|
try {
|
|
@@ -4570,7 +4982,7 @@ async function renderedPackageCardCandidates(page) {
|
|
|
4570
4982
|
return null;
|
|
4571
4983
|
}
|
|
4572
4984
|
};
|
|
4573
|
-
for (const element of elements) {
|
|
4985
|
+
for (const [index, element] of elements.entries()) {
|
|
4574
4986
|
const card = element.closest("[data-next-bundle-card], [data-next-selector-card]") || element;
|
|
4575
4987
|
if (seen.has(card)) continue;
|
|
4576
4988
|
seen.add(card);
|
|
@@ -4587,10 +4999,19 @@ async function renderedPackageCardCandidates(page) {
|
|
|
4587
4999
|
if (!items && packageId && Number.isFinite(declaredQuantity) && declaredQuantity > 0) {
|
|
4588
5000
|
items = [{ package_id: packageId, quantity: declaredQuantity }];
|
|
4589
5001
|
}
|
|
4590
|
-
|
|
5002
|
+
const candidate = { bundle_id: bundleId, package_id: packageId, items };
|
|
5003
|
+
if (withPlacement) {
|
|
5004
|
+
Object.assign(candidate, {
|
|
5005
|
+
group: groupOf(card),
|
|
5006
|
+
selected: isSelected(card),
|
|
5007
|
+
toggle: Boolean(element.closest("[data-next-package-toggle], [data-next-toggle-card], [data-next-bump]")),
|
|
5008
|
+
element_index: index,
|
|
5009
|
+
});
|
|
5010
|
+
}
|
|
5011
|
+
cards.push(candidate);
|
|
4591
5012
|
}
|
|
4592
5013
|
return cards;
|
|
4593
|
-
}).catch(() => []);
|
|
5014
|
+
}, placement).catch(() => []);
|
|
4594
5015
|
}
|
|
4595
5016
|
|
|
4596
5017
|
function packageCardIdentity(candidate) {
|
|
@@ -4622,9 +5043,15 @@ function packageCardClickSelector(candidate) {
|
|
|
4622
5043
|
function resolvePackageCardCandidate(candidates, item) {
|
|
4623
5044
|
const ref = String(item?.packageId || "");
|
|
4624
5045
|
const quantity = Number(item?.quantity || 1);
|
|
4625
|
-
const
|
|
5046
|
+
const identityMatches = (candidates || []).filter((candidate) => (
|
|
4626
5047
|
String(candidate?.bundle_id || "") === ref || String(candidate?.package_id || "") === ref
|
|
4627
5048
|
));
|
|
5049
|
+
// A bundle card's package identity is also the package its
|
|
5050
|
+
// data-next-bundle-items declares, whether or not an inner node repeats it.
|
|
5051
|
+
const matchingRef = (candidates || []).filter((candidate) => (
|
|
5052
|
+
identityMatches.includes(candidate)
|
|
5053
|
+
|| (Array.isArray(candidate?.items) && candidate.items.some((entry) => String(entry?.package_id || "") === ref))
|
|
5054
|
+
));
|
|
4628
5055
|
if (!matchingRef.length) {
|
|
4629
5056
|
throw new Error(`--select-package ${ref}: no rendered card exposes that package or bundle identity`);
|
|
4630
5057
|
}
|
|
@@ -4635,18 +5062,22 @@ function resolvePackageCardCandidate(candidates, item) {
|
|
|
4635
5062
|
throw new Error(`--select-package ${ref}: rendered bundle identity is ambiguous across ${exactBundle.length} cards`);
|
|
4636
5063
|
}
|
|
4637
5064
|
|
|
4638
|
-
|
|
5065
|
+
// A card matches when one of its declared items is the package at that
|
|
5066
|
+
// quantity, however many items it declares. A card declaring that item
|
|
5067
|
+
// alone is preferred over one declaring it among others (a 2x tier over a
|
|
5068
|
+
// 2x-plus-accessory kit); the kit stays reachable by its bundle id.
|
|
5069
|
+
const declaringItem = matchingRef.filter((candidate) => (
|
|
4639
5070
|
Array.isArray(candidate?.items)
|
|
4640
|
-
&& candidate.items.
|
|
4641
|
-
&& String(candidate.items[0]?.package_id || "") === ref
|
|
4642
|
-
&& Number(candidate.items[0]?.quantity) === quantity
|
|
5071
|
+
&& candidate.items.some((entry) => String(entry?.package_id || "") === ref && Number(entry?.quantity) === quantity)
|
|
4643
5072
|
));
|
|
5073
|
+
const declaringOnlyItem = declaringItem.filter((candidate) => candidate.items.length === 1);
|
|
5074
|
+
const exactComposition = declaringOnlyItem.length ? declaringOnlyItem : declaringItem;
|
|
4644
5075
|
if (exactComposition.length === 1) return exactComposition[0];
|
|
4645
5076
|
if (exactComposition.length > 1) {
|
|
4646
5077
|
throw new Error(`--select-package ${ref}:${quantity}: rendered package composition is ambiguous across ${exactComposition.length} cards`);
|
|
4647
5078
|
}
|
|
4648
|
-
const unknownComposition =
|
|
4649
|
-
if (quantity === 1 &&
|
|
5079
|
+
const unknownComposition = identityMatches.filter((candidate) => !Array.isArray(candidate?.items));
|
|
5080
|
+
if (quantity === 1 && identityMatches.length === 1 && unknownComposition.length === 1) return unknownComposition[0];
|
|
4650
5081
|
throw new Error(`--select-package ${ref}:${quantity}: no rendered card has exactly package ${ref} at quantity ${quantity}`);
|
|
4651
5082
|
}
|
|
4652
5083
|
|
|
@@ -5008,10 +5439,13 @@ async function fillCheckoutFields(page, args, email, options = {}) {
|
|
|
5008
5439
|
// Field order, progressive-disclosure calls and optional/onlyVisible flags
|
|
5009
5440
|
// below are unchanged; only the per-field recording wrapper is new.
|
|
5010
5441
|
const track = (field, action, fn, opts = {}) => (trace ? trace.inspect(field, action, fn, opts) : fn());
|
|
5442
|
+
// presentOnly (a multi-step checkout page): every field optional and
|
|
5443
|
+
// visible-only, because each page of the path renders only its own.
|
|
5444
|
+
const present = options.presentOnly === true ? { optional: true, onlyVisible: true } : {};
|
|
5011
5445
|
const fill = (field, value, opts = {}) =>
|
|
5012
|
-
track(field, "fill", () => fillByField(page, field, value, { ...opts, actionTimeoutMs }), opts);
|
|
5446
|
+
track(field, "fill", () => fillByField(page, field, value, { ...opts, ...present, actionTimeoutMs }), { ...opts, ...present });
|
|
5013
5447
|
const select = (field, value, opts = {}) =>
|
|
5014
|
-
track(field, "select", () => selectByField(page, field, value, { ...opts, actionTimeoutMs }), opts);
|
|
5448
|
+
track(field, "select", () => selectByField(page, field, value, { ...opts, ...present, actionTimeoutMs }), { ...opts, ...present });
|
|
5015
5449
|
const address = {
|
|
5016
5450
|
firstName: stringArg(args["test-first-name"]) || "QA",
|
|
5017
5451
|
lastName: stringArg(args["test-last-name"]) || "Playwright",
|
|
@@ -5092,7 +5526,7 @@ async function revealCheckoutForm(page, options = {}) {
|
|
|
5092
5526
|
return true;
|
|
5093
5527
|
}
|
|
5094
5528
|
|
|
5095
|
-
async function fillPaymentFields(page, args) {
|
|
5529
|
+
async function fillPaymentFields(page, args, { readyTimeoutMs = CARD_READY_TIMEOUT_MS } = {}) {
|
|
5096
5530
|
await clickCreditPaymentMethod(page);
|
|
5097
5531
|
await selectByField(page, "exp-month", stringArg(args["test-exp-month"]) || DEFAULT_TEST_EXP_MONTH);
|
|
5098
5532
|
await selectYear(page, stringArg(args["test-exp-year"]) || DEFAULT_TEST_EXP_YEAR);
|
|
@@ -5100,7 +5534,7 @@ async function fillPaymentFields(page, args) {
|
|
|
5100
5534
|
const card = normalizeCard(stringArg(args["test-card"]) || DEFAULT_TEST_CARD);
|
|
5101
5535
|
const cvv = stringArg(args["test-cvv"]) || DEFAULT_TEST_CVV;
|
|
5102
5536
|
const frames = await cardFrames(page);
|
|
5103
|
-
const readyWaitMs = await waitForCardFieldsReady(page);
|
|
5537
|
+
const readyWaitMs = await waitForCardFieldsReady(page, readyTimeoutMs);
|
|
5104
5538
|
const numberInput = page.frameLocator(CARD_NUMBER_FRAME).locator("input").first();
|
|
5105
5539
|
const cvvInput = page.frameLocator(CARD_CVV_FRAME).locator("input").first();
|
|
5106
5540
|
const typed = async (input) => normalizeCard(await input.inputValue());
|
|
@@ -5136,12 +5570,13 @@ async function fillPaymentFields(page, args) {
|
|
|
5136
5570
|
// SDK never set it is ready at once. The SDK also clears it when the card
|
|
5137
5571
|
// script fails to load, so a class still set after the wait means the card
|
|
5138
5572
|
// script never answered; the step refuses by name rather than type into
|
|
5139
|
-
// fields that may drop the number.
|
|
5140
|
-
|
|
5573
|
+
// fields that may drop the number. `timeoutMs` is CARD_READY_TIMEOUT_MS in
|
|
5574
|
+
// every production run; only an in-process test passes a shorter wait.
|
|
5575
|
+
async function waitForCardFieldsReady(page, timeoutMs = CARD_READY_TIMEOUT_MS) {
|
|
5141
5576
|
const started = Date.now();
|
|
5142
|
-
await page.waitForFunction(() => !document.querySelector(".next-loading-spreedly"), null, { timeout:
|
|
5577
|
+
await page.waitForFunction(() => !document.querySelector(".next-loading-spreedly"), null, { timeout: timeoutMs }).catch((error) => {
|
|
5143
5578
|
if (error?.name !== "TimeoutError") throw error;
|
|
5144
|
-
throw new Error(`card fields did not report ready within ${
|
|
5579
|
+
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
5580
|
});
|
|
5146
5581
|
return Date.now() - started;
|
|
5147
5582
|
}
|
|
@@ -5426,23 +5861,29 @@ async function clickControl(locator, { timeout, forceFallback = true, perpetual
|
|
|
5426
5861
|
// single offer). Clicking the hidden action fails as not visible, so the
|
|
5427
5862
|
// visible proxy is clicked instead, but only while the offer holds an SDK action
|
|
5428
5863
|
// for it to forward to: otherwise the hidden action is clicked and fails as before.
|
|
5864
|
+
// The proxy forwards to the action of its own spelling.
|
|
5429
5865
|
async function shopperUpsellControl(page, action) {
|
|
5430
|
-
const actions = page.locator(
|
|
5866
|
+
const actions = page.locator(upsellActionSelector(action));
|
|
5431
5867
|
if (!await actions.count().catch(() => 0)) return actions.first();
|
|
5432
5868
|
const visibleAction = actions.filter({ visible: true }).first();
|
|
5433
5869
|
if (await visibleAction.count().catch(() => 0)) return visibleAction;
|
|
5434
5870
|
// The proxy forwards to `[data-next-upsell="offer"] [data-next-upsell-action]`
|
|
5435
5871
|
// (upsells.js), so only an action inside the offer is a target.
|
|
5436
|
-
const inOffer = page.locator(`[data-next-upsell="offer"]
|
|
5872
|
+
const inOffer = page.locator(`[data-next-upsell="offer"] ${upsellActionSelector(action)}`);
|
|
5437
5873
|
const proxy = page.locator(`[data-upsell-proxy="${action}"]`).filter({ visible: true }).first();
|
|
5438
5874
|
if (await inOffer.count().catch(() => 0) && await proxy.count().catch(() => 0)) return proxy;
|
|
5439
5875
|
return actions.first();
|
|
5440
5876
|
}
|
|
5441
5877
|
|
|
5442
|
-
|
|
5878
|
+
// `clickTimeoutMs` and `mutationTimeoutMs` are UPSELL_CLICK_TIMEOUT_MS and
|
|
5879
|
+
// UPSELL_MUTATION_TIMEOUT_MS in every production run; only an in-process test
|
|
5880
|
+
// that waits one of them out passes a shorter bound.
|
|
5881
|
+
async function clickUpsellPath(page, path, { trace = null, clickTimeoutMs, mutationTimeoutMs } = {}) {
|
|
5882
|
+
const clickMs = shorterBound(clickTimeoutMs, UPSELL_CLICK_TIMEOUT_MS);
|
|
5883
|
+
const mutationMs = shorterBound(mutationTimeoutMs, UPSELL_MUTATION_TIMEOUT_MS);
|
|
5443
5884
|
const offerUrl = safePageUrl(page);
|
|
5444
|
-
const action = path === "accept" ? "
|
|
5445
|
-
const selector =
|
|
5885
|
+
const action = await presentUpsellAction(page, path === "accept" ? "accept" : "decline");
|
|
5886
|
+
const selector = upsellActionSelector(action);
|
|
5446
5887
|
const control = await shopperUpsellControl(page, action);
|
|
5447
5888
|
if (!await control.count().catch(() => 0)) {
|
|
5448
5889
|
return { path, clicked: false, error: `Missing upsell control ${selector}` };
|
|
@@ -5475,10 +5916,10 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
|
|
|
5475
5916
|
if (!root || root.method() !== "POST" || !isOrderUpsellsUrl(root.url())) return false;
|
|
5476
5917
|
const startedAt = responseRequestStartedAt(response);
|
|
5477
5918
|
return startedAt === null || startedAt >= armedAt;
|
|
5478
|
-
}, { timeout:
|
|
5919
|
+
}, { timeout: mutationMs + (perpetual ? 0 : clickMs) }).catch(() => null)
|
|
5479
5920
|
: Promise.resolve(null);
|
|
5480
5921
|
trace?.markClickAttempted();
|
|
5481
|
-
await clickControl(control, { timeout:
|
|
5922
|
+
await clickControl(control, { timeout: clickMs, perpetual });
|
|
5482
5923
|
trace?.markClickCompleted();
|
|
5483
5924
|
const mutationResponse = await mutationPromise;
|
|
5484
5925
|
const bodyRead = mutationResponse
|
|
@@ -5607,14 +6048,37 @@ async function checkoutDisplayEvidence(browserPage) {
|
|
|
5607
6048
|
text: clean(row.textContent).slice(0, 160),
|
|
5608
6049
|
}));
|
|
5609
6050
|
|
|
5610
|
-
|
|
6051
|
+
// A bundle card's packages are the ones its data-next-bundle-items
|
|
6052
|
+
// declares, at the per-package quantity the SDK puts in the cart. Its
|
|
6053
|
+
// data-next-bundle-id names the tier, not a package an order line can
|
|
6054
|
+
// carry, so it stands in only for a card that declares no items.
|
|
6055
|
+
const declaredItems = (card) => {
|
|
6056
|
+
try {
|
|
6057
|
+
const parsed = JSON.parse(card.getAttribute("data-next-bundle-items") || "null");
|
|
6058
|
+
if (!Array.isArray(parsed)) return null;
|
|
6059
|
+
const items = parsed.map((entry) => ({
|
|
6060
|
+
package_id: String(entry?.packageId ?? entry?.package_id ?? ""),
|
|
6061
|
+
quantity: Number(entry?.quantity ?? 1),
|
|
6062
|
+
})).filter((entry) => entry.package_id && Number.isFinite(entry.quantity) && entry.quantity > 0);
|
|
6063
|
+
return items.length ? items : null;
|
|
6064
|
+
} catch {
|
|
6065
|
+
return null;
|
|
6066
|
+
}
|
|
6067
|
+
};
|
|
6068
|
+
const selectedBundleCards = Array.from(document.querySelectorAll("[data-next-bundle-card]"))
|
|
5611
6069
|
.filter((card) => (
|
|
5612
6070
|
card.classList.contains("next-selected")
|
|
5613
6071
|
|| card.getAttribute("data-next-selected") === "true"
|
|
5614
6072
|
|| card.getAttribute("aria-checked") === "true"
|
|
5615
6073
|
|| card.querySelector('input[type="radio"], input[type="checkbox"]')?.checked === true
|
|
5616
|
-
))
|
|
5617
|
-
|
|
6074
|
+
));
|
|
6075
|
+
// A card whose data-next-bundle-items is malformed or empty is kept with
|
|
6076
|
+
// items: null, so reconciliation knows a selected card declared nothing.
|
|
6077
|
+
const selectedBundleItems = selectedBundleCards
|
|
6078
|
+
.filter((card) => card.hasAttribute("data-next-bundle-items"))
|
|
6079
|
+
.map((card) => ({ bundle_id: card.getAttribute("data-next-bundle-id") || null, items: declaredItems(card) }));
|
|
6080
|
+
const selectedBundles = selectedBundleCards
|
|
6081
|
+
.flatMap((card) => declaredItems(card)?.map((item) => item.package_id) || [idOf(card)])
|
|
5618
6082
|
.filter(Boolean);
|
|
5619
6083
|
|
|
5620
6084
|
const activeToggles = Array.from(document.querySelectorAll("[data-next-package-toggle], [data-next-toggle-card], [data-next-bump]"))
|
|
@@ -5642,6 +6106,9 @@ async function checkoutDisplayEvidence(browserPage) {
|
|
|
5642
6106
|
summary_present: summaries.length > 0,
|
|
5643
6107
|
summary_rows: summaryRows.slice(0, 40),
|
|
5644
6108
|
selected_bundle_package_ids: [...new Set(selectedBundles)],
|
|
6109
|
+
// Uncapped, like selected_bundle_package_ids: every selected card's
|
|
6110
|
+
// items are reconciled.
|
|
6111
|
+
selected_bundle_items: selectedBundleItems,
|
|
5645
6112
|
active_toggle_package_ids: [...new Set(activeToggles)],
|
|
5646
6113
|
discount_rows: discountRows.slice(0, 20),
|
|
5647
6114
|
total_text: totalNode ? clean(totalNode.textContent) : null,
|
|
@@ -5650,6 +6117,7 @@ async function checkoutDisplayEvidence(browserPage) {
|
|
|
5650
6117
|
summary_present: false,
|
|
5651
6118
|
summary_rows: [],
|
|
5652
6119
|
selected_bundle_package_ids: [],
|
|
6120
|
+
selected_bundle_items: [],
|
|
5653
6121
|
active_toggle_package_ids: [],
|
|
5654
6122
|
discount_rows: [],
|
|
5655
6123
|
total_text: null,
|
|
@@ -5692,7 +6160,29 @@ function displayedPackageIds(display) {
|
|
|
5692
6160
|
};
|
|
5693
6161
|
}
|
|
5694
6162
|
|
|
5695
|
-
|
|
6163
|
+
// The packages and per-package quantities the selected bundle card(s)
|
|
6164
|
+
// declare. A package two selected cards declare at different quantities is
|
|
6165
|
+
// left out of `quantities`: the display itself does not say which one holds.
|
|
6166
|
+
// A card whose items are malformed or empty declares nothing.
|
|
6167
|
+
function selectedBundleDeclarations(display) {
|
|
6168
|
+
const declared = new Map();
|
|
6169
|
+
for (const card of display?.selected_bundle_items || []) {
|
|
6170
|
+
for (const item of card?.items || []) {
|
|
6171
|
+
const ref = String(item?.package_id ?? "");
|
|
6172
|
+
const quantity = Number(item?.quantity);
|
|
6173
|
+
if (!ref || !Number.isFinite(quantity) || quantity <= 0) continue;
|
|
6174
|
+
declared.set(ref, [...new Set([...(declared.get(ref) || []), quantity])]);
|
|
6175
|
+
}
|
|
6176
|
+
}
|
|
6177
|
+
return {
|
|
6178
|
+
refs: new Set(declared.keys()),
|
|
6179
|
+
quantities: [...declared]
|
|
6180
|
+
.filter(([, quantities]) => quantities.length === 1)
|
|
6181
|
+
.map(([packageId, [quantity]]) => ({ packageId, quantity })),
|
|
6182
|
+
};
|
|
6183
|
+
}
|
|
6184
|
+
|
|
6185
|
+
function reconcileOrderAgainstDisplay({ lines = [], display = null, events = null, selected_packages = [], requested_cart = [] } = {}) {
|
|
5696
6186
|
if (!display) {
|
|
5697
6187
|
return { comparable: false, reason: "checkout display evidence was not captured for this order" };
|
|
5698
6188
|
}
|
|
@@ -5714,9 +6204,37 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
|
|
|
5714
6204
|
}
|
|
5715
6205
|
|
|
5716
6206
|
const nonUpsellLines = (lines || []).filter((line) => !line?.is_upsell);
|
|
6207
|
+
// A two-unit bundle card puts its package in the cart at quantity two, so a
|
|
6208
|
+
// line resolves at the quantity the selected card declares; --select-package
|
|
6209
|
+
// still wins for the packages it names.
|
|
6210
|
+
const declarations = selectedBundleDeclarations(display);
|
|
6211
|
+
const strictRefs = new Set((selected_packages || []).map((item) => String(item.packageId)));
|
|
6212
|
+
const resolutionPackages = [
|
|
6213
|
+
...(selected_packages || []),
|
|
6214
|
+
...declarations.quantities.filter((item) => !strictRefs.has(item.packageId)),
|
|
6215
|
+
];
|
|
6216
|
+
// Without --select-package, every explicit --cart <pkg>:<qty> is judged on
|
|
6217
|
+
// its own: the order must carry that package at that quantity, whichever
|
|
6218
|
+
// card, if any, declares it. Beside --select-package the order is judged
|
|
6219
|
+
// against the card that flag chose; a --cart ref it left unapplied is named
|
|
6220
|
+
// by selected_bundle.
|
|
6221
|
+
const requestedCart = (selected_packages || []).length ? [] : (requested_cart || [])
|
|
6222
|
+
.filter((item) => item?.quantityExplicit);
|
|
6223
|
+
const requestedCartByRef = new Map(requestedCart.map((item) => [String(item.packageId), Number(item.quantity || 1)]));
|
|
5717
6224
|
const displayed = new Set(resolved.displayed_package_ids);
|
|
5718
6225
|
const summaryIds = new Set(resolved.summary_package_ids);
|
|
5719
6226
|
const matchedSummaryIds = new Set();
|
|
6227
|
+
// Refs a non-upsell line resolved to. A line resolves to a package only at
|
|
6228
|
+
// the quantity --select-package names or the selected card declares, so
|
|
6229
|
+
// for a declared item this proves the quantity too.
|
|
6230
|
+
const chargedRefs = new Set();
|
|
6231
|
+
// --cart refs a line resolved to, or whose quantity mismatch is named.
|
|
6232
|
+
const answeredCartRefs = new Set();
|
|
6233
|
+
const unresolvedLines = [];
|
|
6234
|
+
const extra = [];
|
|
6235
|
+
const unresolved = [];
|
|
6236
|
+
const matchedQuantities = [];
|
|
6237
|
+
const quantityMismatches = [];
|
|
5720
6238
|
// A checkout order bump persists with is_upsell: true (the platform's
|
|
5721
6239
|
// reporting tag), yet the checkout summary displays it. Such a line charges
|
|
5722
6240
|
// a displayed row; an is_upsell line the summary does not show is a
|
|
@@ -5724,23 +6242,24 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
|
|
|
5724
6242
|
let bumpLineCount = 0;
|
|
5725
6243
|
for (const line of (lines || []).filter((entry) => entry?.is_upsell)) {
|
|
5726
6244
|
const resolution = events ? campaignPackageResolutionForLine(events, line, {
|
|
5727
|
-
selected_packages,
|
|
6245
|
+
selected_packages: resolutionPackages,
|
|
5728
6246
|
preferred_refs: resolved.summary_package_ids,
|
|
5729
6247
|
}) : null;
|
|
5730
6248
|
const ref = resolution?.pkg?.ref_id == null ? null : String(resolution.pkg.ref_id);
|
|
5731
6249
|
if (ref && summaryIds.has(ref)) {
|
|
5732
6250
|
matchedSummaryIds.add(ref);
|
|
5733
6251
|
bumpLineCount += 1;
|
|
6252
|
+
if (requestedCartByRef.has(ref)) {
|
|
6253
|
+
answeredCartRefs.add(ref);
|
|
6254
|
+
const mismatch = packageQuantityMismatch(ref, Number(resolution.pkg.qty ?? resolution.pkg.quantity), requestedCartByRef.get(ref), Number(line.quantity));
|
|
6255
|
+
if (mismatch) quantityMismatches.push(mismatch);
|
|
6256
|
+
}
|
|
5734
6257
|
}
|
|
5735
6258
|
}
|
|
5736
|
-
const extra = [];
|
|
5737
|
-
const unresolved = [];
|
|
5738
|
-
const matchedQuantities = [];
|
|
5739
|
-
const quantityMismatches = [];
|
|
5740
6259
|
|
|
5741
6260
|
for (const line of nonUpsellLines) {
|
|
5742
6261
|
const resolution = events ? campaignPackageResolutionForLine(events, line, {
|
|
5743
|
-
selected_packages,
|
|
6262
|
+
selected_packages: resolutionPackages,
|
|
5744
6263
|
preferred_refs: resolved.displayed_package_ids,
|
|
5745
6264
|
}) : null;
|
|
5746
6265
|
const meta = resolution?.pkg || null;
|
|
@@ -5750,10 +6269,15 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
|
|
|
5750
6269
|
// same tolerance linePriceDeltaEvidence applies). An unresolvable line
|
|
5751
6270
|
// cannot prove a stray charge, so it is reported, never counted as one.
|
|
5752
6271
|
unresolved.push({ title: line.title, quantity: line.quantity });
|
|
5753
|
-
|
|
5754
|
-
|
|
6272
|
+
unresolvedLines.push(line);
|
|
6273
|
+
const mismatch = requestedPackageQuantityMismatch(events, line, [...selected_packages, ...requestedCart]);
|
|
6274
|
+
if (mismatch) {
|
|
6275
|
+
quantityMismatches.push(mismatch);
|
|
6276
|
+
answeredCartRefs.add(mismatch.package_ref_id);
|
|
6277
|
+
}
|
|
5755
6278
|
continue;
|
|
5756
6279
|
}
|
|
6280
|
+
chargedRefs.add(ref);
|
|
5757
6281
|
const unitQuantity = Number(meta.qty ?? meta.quantity);
|
|
5758
6282
|
matchedQuantities.push({
|
|
5759
6283
|
package_ref_id: ref,
|
|
@@ -5761,6 +6285,13 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
|
|
|
5761
6285
|
purchase_multiplier: resolution.purchaseMultiplier,
|
|
5762
6286
|
persisted_quantity: Number(line.quantity),
|
|
5763
6287
|
});
|
|
6288
|
+
// The line resolved at the quantity the page selected; --cart asked for
|
|
6289
|
+
// another, so the order is not the one requested.
|
|
6290
|
+
if (requestedCartByRef.has(ref)) {
|
|
6291
|
+
answeredCartRefs.add(ref);
|
|
6292
|
+
const mismatch = packageQuantityMismatch(ref, unitQuantity, requestedCartByRef.get(ref), Number(line.quantity));
|
|
6293
|
+
if (mismatch) quantityMismatches.push(mismatch);
|
|
6294
|
+
}
|
|
5764
6295
|
if (displayed.has(ref)) {
|
|
5765
6296
|
if (summaryIds.has(ref)) matchedSummaryIds.add(ref);
|
|
5766
6297
|
continue;
|
|
@@ -5772,8 +6303,26 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
|
|
|
5772
6303
|
price: line.price,
|
|
5773
6304
|
});
|
|
5774
6305
|
}
|
|
6306
|
+
// An explicit --cart ref the order carries no line for is not the order
|
|
6307
|
+
// requested either, as when a swap-mode selector kept only the last --cart
|
|
6308
|
+
// ref's card. An unresolved line that is that package at that quantity
|
|
6309
|
+
// still answers it.
|
|
6310
|
+
const packages = latestEventPackages(events);
|
|
6311
|
+
if (packages) {
|
|
6312
|
+
for (const [ref, purchaseMultiplier] of requestedCartByRef) {
|
|
6313
|
+
if (answeredCartRefs.has(ref)) continue;
|
|
6314
|
+
const pkg = packages.find((candidate) => String(candidate?.ref_id ?? candidate?.package_id ?? candidate?.id ?? "") === ref);
|
|
6315
|
+
if (pkg && unresolvedLines.some((line) => packageMatchesLine(pkg, line, { purchaseMultiplier }))) continue;
|
|
6316
|
+
quantityMismatches.push(absentPackageMismatch(ref, Number(pkg?.qty ?? pkg?.quantity), purchaseMultiplier));
|
|
6317
|
+
}
|
|
6318
|
+
}
|
|
5775
6319
|
|
|
5776
|
-
|
|
6320
|
+
// Every item a selected bundle card declares must be charged, whether or not
|
|
6321
|
+
// the summary renders a row for it.
|
|
6322
|
+
const missing = [
|
|
6323
|
+
...[...summaryIds].filter((id) => !matchedSummaryIds.has(id)),
|
|
6324
|
+
...[...declarations.refs].filter((id) => !summaryIds.has(id) && !chargedRefs.has(id)),
|
|
6325
|
+
];
|
|
5777
6326
|
return {
|
|
5778
6327
|
comparable: true,
|
|
5779
6328
|
ok: extra.length === 0 && missing.length === 0 && quantityMismatches.length === 0,
|
|
@@ -5799,25 +6348,49 @@ function requestedPackageQuantityMismatch(events, line, selectedPackages) {
|
|
|
5799
6348
|
const ref = String(selected.packageId);
|
|
5800
6349
|
const pkg = packages.find((candidate) => String(candidate?.ref_id ?? candidate?.package_id ?? candidate?.id ?? "") === ref);
|
|
5801
6350
|
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
|
-
}
|
|
6351
|
+
const mismatch = packageQuantityMismatch(ref, Number(pkg.qty ?? pkg.quantity), Number(selected.quantity || 1), Number(line?.quantity || 0));
|
|
6352
|
+
if (mismatch) return mismatch;
|
|
5816
6353
|
}
|
|
5817
6354
|
}
|
|
5818
6355
|
return null;
|
|
5819
6356
|
}
|
|
5820
6357
|
|
|
6358
|
+
function packageQuantityMismatch(ref, unitQuantity, purchaseMultiplier, persistedQuantity) {
|
|
6359
|
+
const requestedQuantity = unitQuantity * purchaseMultiplier;
|
|
6360
|
+
if (!Number.isFinite(requestedQuantity) || requestedQuantity === persistedQuantity) return null;
|
|
6361
|
+
return {
|
|
6362
|
+
package_ref_id: ref,
|
|
6363
|
+
unit_quantity: unitQuantity,
|
|
6364
|
+
purchase_multiplier: purchaseMultiplier,
|
|
6365
|
+
requested_quantity: requestedQuantity,
|
|
6366
|
+
persisted_quantity: persistedQuantity,
|
|
6367
|
+
reason: `package ${ref} requested ${requestedQuantity} unit(s) (${unitQuantity} per package × ${purchaseMultiplier}) but persisted ${persistedQuantity}`,
|
|
6368
|
+
};
|
|
6369
|
+
}
|
|
6370
|
+
|
|
6371
|
+
function absentPackageMismatch(ref, unitQuantity, purchaseMultiplier) {
|
|
6372
|
+
const known = Number.isFinite(unitQuantity);
|
|
6373
|
+
return {
|
|
6374
|
+
package_ref_id: ref,
|
|
6375
|
+
unit_quantity: known ? unitQuantity : null,
|
|
6376
|
+
purchase_multiplier: purchaseMultiplier,
|
|
6377
|
+
requested_quantity: known ? unitQuantity * purchaseMultiplier : null,
|
|
6378
|
+
persisted_quantity: 0,
|
|
6379
|
+
reason: known
|
|
6380
|
+
? `package ${ref} requested ${unitQuantity * purchaseMultiplier} unit(s) (${unitQuantity} per package × ${purchaseMultiplier}) but the order carries no line for it`
|
|
6381
|
+
: `package ${ref} requested at quantity ${purchaseMultiplier} but the order carries no line for it`,
|
|
6382
|
+
};
|
|
6383
|
+
}
|
|
6384
|
+
|
|
6385
|
+
function latestEventPackages(events) {
|
|
6386
|
+
const responses = events?.responses || [];
|
|
6387
|
+
for (let index = responses.length - 1; index >= 0; index -= 1) {
|
|
6388
|
+
const packages = responses[index]?.body?.packages;
|
|
6389
|
+
if (Array.isArray(packages)) return packages;
|
|
6390
|
+
}
|
|
6391
|
+
return null;
|
|
6392
|
+
}
|
|
6393
|
+
|
|
5821
6394
|
function packageIdentityMatchesLine(pkg, line) {
|
|
5822
6395
|
if (pkg?.product_sku && line?.sku) return normalizeLabel(pkg.product_sku) === normalizeLabel(line.sku);
|
|
5823
6396
|
if (pkg?.product_variant_id != null && line?.variant_id != null) return Number(pkg.product_variant_id) === Number(line.variant_id);
|
|
@@ -5861,6 +6434,9 @@ function orderDisplayParityAssertion(page, planIdentifier, order) {
|
|
|
5861
6434
|
if (reconciliation.missing.length) {
|
|
5862
6435
|
parts.push(`displayed but never charged: ${reconciliation.missing.join(", ")}`);
|
|
5863
6436
|
}
|
|
6437
|
+
if (reconciliation.quantity_mismatches?.length) {
|
|
6438
|
+
parts.push(`ordered quantity differs from the request: ${reconciliation.quantity_mismatches.map((entry) => entry.reason).join("; ")}`);
|
|
6439
|
+
}
|
|
5864
6440
|
return assertion({
|
|
5865
6441
|
...base,
|
|
5866
6442
|
status: STATUS.FAIL,
|
|
@@ -7035,7 +7611,7 @@ function testOrderPaths(mode, topologies = [], args = {}) {
|
|
|
7035
7611
|
}
|
|
7036
7612
|
|
|
7037
7613
|
function resolvePrimaryTestOrderTopology(topologies = []) {
|
|
7038
|
-
const checkout =
|
|
7614
|
+
const checkout = findPaymentPage(topologies);
|
|
7039
7615
|
const topology = (topologies || []).find((candidate) => (candidate?.pages || []).includes(checkout)) || { pages: [] };
|
|
7040
7616
|
return resolveTestOrderTopology(topology, checkout);
|
|
7041
7617
|
}
|
|
@@ -7062,7 +7638,7 @@ function testOrderPlans(mode, topologies = [], args = {}, options = {}) {
|
|
|
7062
7638
|
if (tiersMatch) return specTierPlans(topologies, args, tiersMatch[1] || "checkout", options);
|
|
7063
7639
|
const selectPackage = stringArg(args["select-package"]);
|
|
7064
7640
|
const applyCoupon = stringArg(args["apply-coupon"]);
|
|
7065
|
-
const checkoutPage =
|
|
7641
|
+
const checkoutPage = findPaymentPage(topologies);
|
|
7066
7642
|
const topologyPlan = resolvePrimaryTestOrderTopology(topologies);
|
|
7067
7643
|
return testOrderPaths(mode, topologies, args).map((path) => ({
|
|
7068
7644
|
path,
|
|
@@ -7093,24 +7669,31 @@ function specTierPlans(topologies, args, variant, { warn = (line) => process.std
|
|
|
7093
7669
|
// fail for the wrong reason. A later funnel's checkout with declarations
|
|
7094
7670
|
// but no resolvable URL cannot be driven; it is warned about, not dropped
|
|
7095
7671
|
// silently.
|
|
7096
|
-
const primary =
|
|
7672
|
+
const primary = findPaymentPage(topologies);
|
|
7097
7673
|
if (!primary) {
|
|
7098
7674
|
throw new Error("--test-order tiers requires a CampaignSpec-driven run with a checkout page; this spec/topology has no checkout page to derive tiers or coupons from (non-packet --site runs have none by design).");
|
|
7099
7675
|
}
|
|
7100
7676
|
const plans = [];
|
|
7101
|
-
|
|
7102
|
-
|
|
7103
|
-
|
|
7104
|
-
|
|
7105
|
-
|
|
7106
|
-
|
|
7677
|
+
// Every Checkout of every funnel: split-test paths in one funnel each reach
|
|
7678
|
+
// their own (#641). A multi-step path declares its cart on the select page or
|
|
7679
|
+
// first step, not on the Checkout, so tiers and bumps are read from every
|
|
7680
|
+
// page of the path; coupons from the pages the runner fills (steps and the
|
|
7681
|
+
// Checkout), since a select page is left before any form is filled.
|
|
7682
|
+
const paymentPages = (Array.isArray(topologies) ? topologies : [])
|
|
7683
|
+
.flatMap((topology) => paymentPagesForTopology(topology).map((checkoutPage) => ({ topology, checkoutPage })));
|
|
7684
|
+
for (const { topology, checkoutPage } of paymentPages) {
|
|
7685
|
+
const pathPages = checkoutPathPages(topology, checkoutPage);
|
|
7686
|
+
const declaredTiers = uniqueTiers(pathPages.flatMap((page) => declaredSelectorTiers(page)));
|
|
7687
|
+
const bumps = [...new Set(pathPages.flatMap((page) => declaredOrderBumps(page)))];
|
|
7107
7688
|
if (bumps.length) {
|
|
7108
7689
|
warn(`[qa:test-order] checkout page "${checkoutPage.page_id || checkoutPage.label || "(unnamed)"}" declares order bump package(s) ${bumps.join(", ")} (is_order_bump or is_upsell) — not planned as selector tiers; bump coverage comes from --cart.`);
|
|
7109
7690
|
}
|
|
7110
7691
|
for (const tier of declaredTiers) declaredIdentities.add(selectorTierIdentity(tier));
|
|
7111
7692
|
for (const ref of bumps) bumpRefs.add(ref);
|
|
7112
7693
|
const tiers = narrowTo ? declaredTiers.filter((tier) => narrowTo.has(selectorTierIdentity(tier))) : declaredTiers;
|
|
7113
|
-
const coupons =
|
|
7694
|
+
const coupons = mergeDeclaredCoupons(pathPages
|
|
7695
|
+
.filter((page) => page === checkoutPage || isCheckoutStepTopologyPage(page))
|
|
7696
|
+
.map((page) => declaredCheckoutCoupons(page)));
|
|
7114
7697
|
if (!tiers.length && !coupons.length) continue;
|
|
7115
7698
|
if (checkoutPage !== primary && !checkoutPage.url) {
|
|
7116
7699
|
if (narrowTo && tiers.length) {
|
|
@@ -7232,6 +7815,28 @@ function selectorTierNarrowing(value) {
|
|
|
7232
7815
|
// Declared coupons follow the repo's offer-surface rule (build-brief, cli
|
|
7233
7816
|
// exit-pop gates): a surface counts only when `enabled === true` and it maps
|
|
7234
7817
|
// an offer_code. Both surfaces mapping the same code collapse into one plan.
|
|
7818
|
+
// Tiers read from several pages of one path, first declaration kept.
|
|
7819
|
+
function uniqueTiers(tiers) {
|
|
7820
|
+
const seen = new Set();
|
|
7821
|
+
return tiers.filter((tier) => {
|
|
7822
|
+
const identity = selectorTierIdentity(tier);
|
|
7823
|
+
if (seen.has(identity)) return false;
|
|
7824
|
+
seen.add(identity);
|
|
7825
|
+
return true;
|
|
7826
|
+
});
|
|
7827
|
+
}
|
|
7828
|
+
|
|
7829
|
+
// Coupon lists from several pages of one path, merged by code.
|
|
7830
|
+
function mergeDeclaredCoupons(lists) {
|
|
7831
|
+
const merged = [];
|
|
7832
|
+
for (const coupon of lists.flat()) {
|
|
7833
|
+
const existing = merged.find((entry) => normalizeLabel(entry.code) === normalizeLabel(coupon.code));
|
|
7834
|
+
if (!existing) merged.push({ code: coupon.code, surfaces: [...coupon.surfaces] });
|
|
7835
|
+
else for (const surface of coupon.surfaces) if (!existing.surfaces.includes(surface)) existing.surfaces.push(surface);
|
|
7836
|
+
}
|
|
7837
|
+
return merged;
|
|
7838
|
+
}
|
|
7839
|
+
|
|
7235
7840
|
function declaredCheckoutCoupons(checkoutPage) {
|
|
7236
7841
|
const coupons = [];
|
|
7237
7842
|
const add = (surface, block) => {
|
|
@@ -7561,7 +8166,7 @@ function describeCoverageDoubt(doubt) {
|
|
|
7561
8166
|
|
|
7562
8167
|
// Page types that never carry an upsell action. Any other type, or none, does
|
|
7563
8168
|
// not say either way.
|
|
7564
|
-
const NON_OFFER_PAGE_TYPES = new Set(["presell", "landing", "select", "product", "checkout", "thankyou", "receipt"]);
|
|
8169
|
+
const NON_OFFER_PAGE_TYPES = new Set(["presell", "landing", "select", "product", "checkout_step", "checkout", "thankyou", "receipt"]);
|
|
7565
8170
|
const UPSELL_COVERAGE_UNASSESSABLE = Object.freeze({
|
|
7566
8171
|
no_url: "no URL",
|
|
7567
8172
|
unresolvable_url: "URL is not an absolute http(s) address",
|
|
@@ -7584,7 +8189,7 @@ export function upsellActionCoverageWithoutOrders(topologies = []) {
|
|
|
7584
8189
|
return upsellActionCoverageAssertion({
|
|
7585
8190
|
topologies,
|
|
7586
8191
|
orders: [],
|
|
7587
|
-
checkoutPage:
|
|
8192
|
+
checkoutPage: findPaymentPage(topologies),
|
|
7588
8193
|
testOrdersOff: true,
|
|
7589
8194
|
});
|
|
7590
8195
|
}
|
|
@@ -7667,7 +8272,7 @@ function extractReceiptLines(order) {
|
|
|
7667
8272
|
|
|
7668
8273
|
async function waitForUpsellPageReady(page, args) {
|
|
7669
8274
|
const timeoutMs = numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS);
|
|
7670
|
-
await page.locator(
|
|
8275
|
+
await page.locator(`[data-next-upsell], ${upsellActionSelector(...UPSELL_ACTION_SPELLINGS.accept, ...UPSELL_ACTION_SPELLINGS.decline)}`).first()
|
|
7671
8276
|
.waitFor({ state: "visible", timeout: timeoutMs })
|
|
7672
8277
|
.catch(() => {});
|
|
7673
8278
|
await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
|
|
@@ -8047,14 +8652,6 @@ function persistedUpsellStep(step) {
|
|
|
8047
8652
|
return projected;
|
|
8048
8653
|
}
|
|
8049
8654
|
|
|
8050
|
-
function findPage(topologies, type) {
|
|
8051
|
-
for (const topology of topologies || []) {
|
|
8052
|
-
const page = (topology.pages || []).find((candidate) => candidate.page_type === type);
|
|
8053
|
-
if (page) return page;
|
|
8054
|
-
}
|
|
8055
|
-
return null;
|
|
8056
|
-
}
|
|
8057
|
-
|
|
8058
8655
|
function parseCart(value) {
|
|
8059
8656
|
if (!value) return [];
|
|
8060
8657
|
return String(value).split(",").map((part) => {
|
|
@@ -8117,6 +8714,26 @@ function numberArg(value, fallback) {
|
|
|
8117
8714
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
8118
8715
|
}
|
|
8119
8716
|
|
|
8717
|
+
// A bound an in-process test seam passes, as the Polish deadlines take one
|
|
8718
|
+
// (boundedPolishDeadline): `value` when it is a positive safe integer no
|
|
8719
|
+
// larger than the production `fallback`, else `fallback`. A seam can shorten
|
|
8720
|
+
// a production wait, never lengthen it or switch it off (Playwright reads a
|
|
8721
|
+
// 0 timeout as none).
|
|
8722
|
+
const shorterBound = boundedPolishDeadline;
|
|
8723
|
+
|
|
8724
|
+
// The content parameter leg's limits with options.contentParamLimits (an
|
|
8725
|
+
// in-process test seam, never set from argv) applied field by field, each
|
|
8726
|
+
// only where it is no larger than CONTENT_PARAM_LIMITS: a time bound is a
|
|
8727
|
+
// shorterBound, and maxPairs may also be zero, as runContentParamChecks
|
|
8728
|
+
// allows.
|
|
8729
|
+
function contentParamLimitsFrom(overrides) {
|
|
8730
|
+
return Object.fromEntries(Object.entries(CONTENT_PARAM_LIMITS).map(([field, production]) => {
|
|
8731
|
+
const value = overrides?.[field];
|
|
8732
|
+
if (field !== "maxPairs") return [field, shorterBound(value, production)];
|
|
8733
|
+
return [field, Number.isSafeInteger(value) && value >= 0 && value <= production ? value : production];
|
|
8734
|
+
}));
|
|
8735
|
+
}
|
|
8736
|
+
|
|
8120
8737
|
function trim(value) {
|
|
8121
8738
|
return String(value || "").replace(/\s+/g, " ").trim();
|
|
8122
8739
|
}
|
|
@@ -8158,9 +8775,12 @@ export const __qaBrowserTestHooks = Object.freeze({
|
|
|
8158
8775
|
primaryCtaAssertionFromEvidence,
|
|
8159
8776
|
inspectPrimaryCta,
|
|
8160
8777
|
primaryCtaInspectionScript,
|
|
8778
|
+
primaryCtaDeclaredRoutes,
|
|
8779
|
+
primaryCtaRoutelessForms,
|
|
8161
8780
|
clickCouponApplyControl,
|
|
8162
8781
|
isOrderUpsellsUrl,
|
|
8163
8782
|
clickUpsellPath,
|
|
8783
|
+
waitForUpsellPageReady,
|
|
8164
8784
|
isPerpetuallyAnimated,
|
|
8165
8785
|
readJsonResponseBody,
|
|
8166
8786
|
readJsonResponseBodyWithin,
|
|
@@ -8219,6 +8839,7 @@ export const __qaBrowserTestHooks = Object.freeze({
|
|
|
8219
8839
|
redactUrlQuery,
|
|
8220
8840
|
enterCartViaLanding,
|
|
8221
8841
|
openCheckoutForPath,
|
|
8842
|
+
pathCurrencyLoad,
|
|
8222
8843
|
createSelectorProbeCache,
|
|
8223
8844
|
RESIDUE_PAGE_TYPES,
|
|
8224
8845
|
computedStyleResidueAssertions,
|