@nextcommerce/campaigns-os 1.55.0 → 1.59.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 +124 -0
- package/CONTEXT.md +11 -0
- package/campaign-spec/README.md +15 -1
- package/campaign-spec/dist/checkout-flow.d.ts +97 -0
- package/campaign-spec/dist/checkout-flow.js +245 -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/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +26 -27
- package/contracts/effects.v1.json +5 -5
- 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 +1137 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
- package/contracts/supported-surface.json +9 -5
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/build-packet.md +68 -10
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +124 -22
- 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/package.json +2 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +4 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -1
- package/schemas/campaign-spec.v5.schema.json +586 -0
- 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 +4 -3
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/build-brief.mjs +14 -5
- package/src/built-site-scope.mjs +91 -0
- package/src/cli-helpers.mjs +129 -0
- package/src/cli.mjs +117 -57
- package/src/commercial-journey.mjs +25 -2
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +7 -7
- package/src/doctor/checks.mjs +269 -16
- package/src/doctor/inspect.mjs +4 -2
- package/src/doctor/next-step.mjs +18 -9
- package/src/doctor-sidecar.mjs +5 -3
- package/src/gate-actions.mjs +21 -1
- package/src/input-refresh.mjs +6 -6
- package/src/local-preview-policy.mjs +110 -12
- package/src/polish-node.mjs +8 -7
- package/src/qa-browser.mjs +363 -36
- package/src/qa-cart-entry.mjs +183 -14
- package/src/qa-node.mjs +38 -34
- package/src/qa-test-order-topology.mjs +118 -1
- package/src/qa-verdict-discovery.mjs +21 -4
- package/src/qa-verdict.mjs +24 -1
- package/src/runtime-state-ignore.mjs +7 -5
- package/src/sdk-attribute-index.mjs +8 -7
- package/src/source-html-intake.mjs +58 -7
- package/src/stage-ledger.mjs +4 -2
- package/src/stage-record.mjs +203 -36
package/src/qa-browser.mjs
CHANGED
|
@@ -25,13 +25,20 @@ import {
|
|
|
25
25
|
commonTestOrderPlan,
|
|
26
26
|
fullTestOrderPaths,
|
|
27
27
|
OFFER_PAGE_TYPES,
|
|
28
|
+
checkoutPathPages,
|
|
29
|
+
checkoutStepPages,
|
|
30
|
+
findPaymentPage,
|
|
31
|
+
isCheckoutStepTopologyPage,
|
|
28
32
|
pageAtUrl,
|
|
33
|
+
paymentPagesForTopology,
|
|
29
34
|
remainingActionDisposition,
|
|
30
35
|
resolveTestOrderTopology,
|
|
31
36
|
terminalAtUrl,
|
|
32
37
|
} from "./qa-test-order-topology.mjs";
|
|
33
38
|
import {
|
|
34
39
|
CART_ENTRY_CODES,
|
|
40
|
+
CART_ENTRY_BUNDLE_CARD_SELECTOR,
|
|
41
|
+
CART_ENTRY_CHECKOUT_BUTTON_SELECTOR,
|
|
35
42
|
CART_ENTRY_CONTROL_SELECTOR,
|
|
36
43
|
cartEntryHrefFor,
|
|
37
44
|
CART_ENTRY_ROUTE_ATTRIBUTE,
|
|
@@ -45,6 +52,13 @@ import {
|
|
|
45
52
|
isCartEntryCode,
|
|
46
53
|
resolveCartEntryPage,
|
|
47
54
|
sdkCartSnapshotScript,
|
|
55
|
+
SLOT_VARIANT_DROPDOWN_ROW_SELECTOR,
|
|
56
|
+
SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR,
|
|
57
|
+
SLOT_VARIANT_FIELD_SELECTOR,
|
|
58
|
+
SLOT_VARIANT_SELECT_SELECTOR,
|
|
59
|
+
slotVariantKey,
|
|
60
|
+
slotVariantLabel,
|
|
61
|
+
slotVariantStepScript,
|
|
48
62
|
summarizeSelectionSurface,
|
|
49
63
|
UNDECLARED_ROUTE_ATTRIBUTES,
|
|
50
64
|
} from "./qa-cart-entry.mjs";
|
|
@@ -191,7 +205,9 @@ export async function runBrowserChecks(topologies, args = {}, options = {}) {
|
|
|
191
205
|
}
|
|
192
206
|
|
|
193
207
|
export async function runBrowserTestOrders(topologies, args = {}, runId = "local", options = {}) {
|
|
194
|
-
|
|
208
|
+
// The Checkout (the page that takes payment) on the first entry path, never
|
|
209
|
+
// the first page typed checkout: on a split checkout that is step 1 (#641).
|
|
210
|
+
const checkoutPage = findPaymentPage(topologies);
|
|
195
211
|
if (!checkoutPage?.url) {
|
|
196
212
|
const coverage = upsellActionCoverageAssertion({ topologies, orders: [], checkoutPage });
|
|
197
213
|
return {
|
|
@@ -295,7 +311,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
295
311
|
const identifier = planId(plan);
|
|
296
312
|
const pageForPlan = (typeof plan === "object" && plan?.checkout_page?.url) ? plan.checkout_page : checkoutPage;
|
|
297
313
|
const firstAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
|
|
298
|
-
orders.push(firstAttempt.order);
|
|
314
|
+
orders.push(testOrderEntry(firstAttempt.order, identifier, 1));
|
|
299
315
|
orderPlans.push(plan);
|
|
300
316
|
// Every attempt this path actually SUBMITTED, in order. The confirmed
|
|
301
317
|
// creation count is read from these and never from the deciding result
|
|
@@ -355,7 +371,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
355
371
|
const rerunAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
|
|
356
372
|
attemptsForPlan.push(rerunAttempt);
|
|
357
373
|
if (rerunAttempt.order) {
|
|
358
|
-
orders.push(rerunAttempt.order);
|
|
374
|
+
orders.push(testOrderEntry(rerunAttempt.order, identifier, 2));
|
|
359
375
|
orderPlans.push(plan);
|
|
360
376
|
}
|
|
361
377
|
if (rerunAttempt.budget_exhausted) {
|
|
@@ -491,6 +507,17 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
491
507
|
return { orders, assertions, receiptAnalytics, journeyAnalytics, creationBudget, qcResults };
|
|
492
508
|
}
|
|
493
509
|
|
|
510
|
+
// One test_orders[] entry per attempt, never one per planned path: a re-run
|
|
511
|
+
// places its own real order and the operator cleaning up test orders needs both.
|
|
512
|
+
// So each entry names the planned path it belongs to (`plan_id`, the same id the
|
|
513
|
+
// creation budget charges) and which attempt it was, 1-based, so a reader can
|
|
514
|
+
// tell 5 paths with one retry each from 10 paths (#661). A copy, so the attempt
|
|
515
|
+
// result the assertions read is left as the runner returned it.
|
|
516
|
+
function testOrderEntry(order, identifier, attempt) {
|
|
517
|
+
if (!order || typeof order !== "object") return order;
|
|
518
|
+
return { ...order, plan_id: order.plan_id ?? identifier, attempt };
|
|
519
|
+
}
|
|
520
|
+
|
|
494
521
|
// Analytics-parity leg: capture the live dataLayer event stream + GTM/pixel
|
|
495
522
|
// tag-fires on a baseline (legacy) URL and a candidate (migrated) URL, then
|
|
496
523
|
// diff them into parity assertions. Highest-value target is the thank-you /
|
|
@@ -1090,6 +1117,10 @@ async function runPageBrowserChecks(context, page, args, options = {}, policyLin
|
|
|
1090
1117
|
}
|
|
1091
1118
|
if (page.page_type === "checkout") {
|
|
1092
1119
|
assertions.push(...await checkoutPaymentSurfaceAssertions(browserPage, page));
|
|
1120
|
+
}
|
|
1121
|
+
// Exit-intent and promo-code surfaces may sit on any checkout-flow page
|
|
1122
|
+
// (#641); each assertion runs only where the spec declares the surface.
|
|
1123
|
+
if (["select", "checkout_step", "checkout"].includes(page.page_type)) {
|
|
1093
1124
|
assertions.push(...await checkoutOfferSurfaceAssertions(browserPage, page));
|
|
1094
1125
|
}
|
|
1095
1126
|
assertions.push(...await templateResidueAssertions(browserPage, page, options));
|
|
@@ -1330,7 +1361,7 @@ function primaryCtaCheckEligible(page) {
|
|
|
1330
1361
|
// where the page declares, not where they point.
|
|
1331
1362
|
function primaryCtaPageRoutesSdkControls(page) {
|
|
1332
1363
|
const pageType = String(page?.page_type || "").toLowerCase();
|
|
1333
|
-
return pageType === "checkout" || OFFER_PAGE_TYPES.has(pageType);
|
|
1364
|
+
return pageType === "checkout" || pageType === "checkout_step" || OFFER_PAGE_TYPES.has(pageType);
|
|
1334
1365
|
}
|
|
1335
1366
|
|
|
1336
1367
|
// The forms whose action is no route on this page, handed to the route rule
|
|
@@ -1357,7 +1388,9 @@ function primaryCtaRoutelessForms(page) {
|
|
|
1357
1388
|
// container, the only place the SDK binds it.
|
|
1358
1389
|
function primaryCtaDeclaredRoutes(page) {
|
|
1359
1390
|
if (!primaryCtaPageRoutesSdkControls(page)) return [];
|
|
1360
|
-
|
|
1391
|
+
// A checkout_step's form submits to its declared next step the same way the
|
|
1392
|
+
// Checkout's submits to its success route: the SDK navigates, not the form.
|
|
1393
|
+
if (["checkout", "checkout_step"].includes(String(page.page_type).toLowerCase())) {
|
|
1361
1394
|
const notWallet = ":not([data-next-express-checkout]):not([data-next-express-checkout] *)";
|
|
1362
1395
|
const submit = ['button[type="submit"]', "button:not([type])", 'input[type="submit"]']
|
|
1363
1396
|
.map((control) => `${CHECKOUT_FORM_SELECTOR} ${control}${notWallet}`)
|
|
@@ -2397,7 +2430,10 @@ function promoCodeSurfaceAssertion({ page, declaration, evidence }) {
|
|
|
2397
2430
|
|
|
2398
2431
|
function contractPageType(page) {
|
|
2399
2432
|
const type = String(page?.page_type || "").toLowerCase();
|
|
2400
|
-
|
|
2433
|
+
if (type === "thankyou") return "receipt";
|
|
2434
|
+
// A checkout step renders the family's checkout surface without taking
|
|
2435
|
+
// payment (campaigns-os#641); the brand contract keys that surface "checkout".
|
|
2436
|
+
return type === "checkout_step" ? "checkout" : type;
|
|
2401
2437
|
}
|
|
2402
2438
|
|
|
2403
2439
|
// The palette-residue rows (style, logo, payment chrome) take their severity
|
|
@@ -3707,7 +3743,12 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
|
|
|
3707
3743
|
let orderDeadline = null;
|
|
3708
3744
|
let events = { requests: [], responses: [], failed: [], console: [], pageErrors: [] };
|
|
3709
3745
|
const email = testEmail(planArgs);
|
|
3710
|
-
|
|
3746
|
+
// The checkout path this order walks (campaigns-os#641): any checkout_step
|
|
3747
|
+
// pages before the Checkout. The cart is entered into the first form page
|
|
3748
|
+
// of the path, and the step pages are submitted in order before payment.
|
|
3749
|
+
const stepPages = checkoutStepPagesFor(options.topologies, checkoutPage);
|
|
3750
|
+
const formEntryPage = stepPages[0] || checkoutPage;
|
|
3751
|
+
const entryPage = resolvePathEntryPage(options.topologies, formEntryPage, stepPages);
|
|
3711
3752
|
let tracking = null;
|
|
3712
3753
|
try {
|
|
3713
3754
|
tracking = options.tracking ? options.tracking.observe(planId(normalizedPlan)) : null;
|
|
@@ -3828,6 +3869,7 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
|
|
|
3828
3869
|
ladder,
|
|
3829
3870
|
checkoutPage,
|
|
3830
3871
|
entryPage,
|
|
3872
|
+
stepPages,
|
|
3831
3873
|
topologyPlan: normalizedPlan.topology_plan,
|
|
3832
3874
|
path,
|
|
3833
3875
|
args: planArgs,
|
|
@@ -3916,10 +3958,13 @@ function stablePrivateCaptureError(value) {
|
|
|
3916
3958
|
return projectAnalyticsCaptureError(value, { fallbackKind: "unreadable" });
|
|
3917
3959
|
}
|
|
3918
3960
|
|
|
3919
|
-
async function executeTestOrderPath({ page, events, email, ladder, checkoutPage, entryPage = null, topologyPlan, path, args, deadline, reserveOrderCreation = null, selectorProbeCache = null, tracking = null, cardReadyTimeoutMs = CARD_READY_TIMEOUT_MS }) {
|
|
3961
|
+
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 }) {
|
|
3920
3962
|
const stepTimeoutMs = numberArg(args["step-timeout-ms"], DEFAULT_STEP_TIMEOUT_MS);
|
|
3921
3963
|
const budget = () => Math.min(stepTimeoutMs, deadline - Date.now());
|
|
3922
3964
|
const hostedNow = () => hostedRedirectInfo(safePageUrl(page), checkoutPage.url);
|
|
3965
|
+
// The page the cart enters and the form filling starts on: the first
|
|
3966
|
+
// checkout_step of a multi-step path, else the Checkout itself.
|
|
3967
|
+
const formEntryPage = stepPages[0] || checkoutPage;
|
|
3923
3968
|
const selectedPackages = parseCart(args["select-package"]);
|
|
3924
3969
|
const currencyLoad = pathCurrencyLoad(args);
|
|
3925
3970
|
let checkoutDisplay = null;
|
|
@@ -3937,7 +3982,8 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3937
3982
|
// page the probe left there rather than loading the same URL a second time,
|
|
3938
3983
|
// which would fire the SDK's page-view events twice into the same capture.
|
|
3939
3984
|
const entry = await ladder.run(CART_ENTRY_STEP, () => enterCartViaLanding({
|
|
3940
|
-
page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking, currencyLoad,
|
|
3985
|
+
page, checkoutPage: formEntryPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking, currencyLoad,
|
|
3986
|
+
startsOnCheckoutStep: formEntryPage !== checkoutPage,
|
|
3941
3987
|
}), { timeoutMs: budget() });
|
|
3942
3988
|
const enteredViaLanding = Boolean(entry && typeof entry === "object" && entry.entered);
|
|
3943
3989
|
// Responses captured from here on belong to the checkout the ladder drives.
|
|
@@ -3945,7 +3991,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3945
3991
|
// made before the hand-off.
|
|
3946
3992
|
const checkoutResponseOffset = events.responses.length;
|
|
3947
3993
|
|
|
3948
|
-
await ladder.run("opened_checkout", () => openCheckoutForPath({ page, checkoutPage, entry, args, tracking, currencyLoad }), { timeoutMs: budget() });
|
|
3994
|
+
await ladder.run("opened_checkout", () => openCheckoutForPath({ page, checkoutPage: formEntryPage, entry, args, tracking, currencyLoad }), { timeoutMs: budget() });
|
|
3949
3995
|
await ladder.run("selected_bundle", async () => {
|
|
3950
3996
|
// The requested package was selected on the entry page, where the cards
|
|
3951
3997
|
// live; checkout renders none, so re-running strict selection here would
|
|
@@ -3969,8 +4015,20 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3969
4015
|
const fieldTrace = createFieldTrace();
|
|
3970
4016
|
await ladder.run("customer_fields_filled", async () => {
|
|
3971
4017
|
ensurePageFillable(page, checkoutPage.url);
|
|
3972
|
-
|
|
4018
|
+
// On a multi-step path each page renders only its own fields, so every
|
|
4019
|
+
// field is filled where it is present and skipped where it is not. The
|
|
4020
|
+
// SDK's step validation refuses to advance when one is missing, which
|
|
4021
|
+
// the step rung below reports by name.
|
|
4022
|
+
await fillCheckoutFields(page, args, email, { trace: fieldTrace, actionTimeoutMs: budget(), presentOnly: stepPages.length > 0 });
|
|
3973
4023
|
}, { timeoutMs: budget(), evidence: () => fieldTrace.summary() });
|
|
4024
|
+
// Walk the path: submit each checkout_step and assert it lands on the
|
|
4025
|
+
// step's declared next page, then fill what the arrived page renders. No
|
|
4026
|
+
// order is placed until the Checkout (campaigns-os#641).
|
|
4027
|
+
for (const [index, step] of stepPages.entries()) {
|
|
4028
|
+
await ladder.run("checkout_step_submitted", () => submitCheckoutStepAndArrive({
|
|
4029
|
+
page, step, index, args, email, budget, checkoutUrl: checkoutPage.url, tracking,
|
|
4030
|
+
}), { timeoutMs: budget() });
|
|
4031
|
+
}
|
|
3974
4032
|
await ladder.run("coupon_applied", async () => {
|
|
3975
4033
|
const code = stringArg(args["apply-coupon"]);
|
|
3976
4034
|
if (!code) return { skip: "no --apply-coupon code requested" };
|
|
@@ -4248,7 +4306,7 @@ async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking =
|
|
|
4248
4306
|
// The entry step body. Resolves to `{ skip }` when the checkout selects for
|
|
4249
4307
|
// itself, to `{ entered: true, ... }` when the runner came in through the entry
|
|
4250
4308
|
// page, and throws a coded error (never a bare timeout) when it cannot.
|
|
4251
|
-
async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache = null, tracking = null, currencyLoad = null }) {
|
|
4309
|
+
async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache = null, tracking = null, currencyLoad = null, startsOnCheckoutStep = false }) {
|
|
4252
4310
|
// Probe the checkout first: whether it carries a selection surface is a fact
|
|
4253
4311
|
// about the rendered page, not about the spec (the spec cannot say it yet —
|
|
4254
4312
|
// that is the design half of #206). The probe's load is the checkout's only
|
|
@@ -4284,6 +4342,15 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4284
4342
|
evidence: { checkout_selection_surface: surface, ...probeEvidence },
|
|
4285
4343
|
};
|
|
4286
4344
|
}
|
|
4345
|
+
if (!entryPage?.url && startsOnCheckoutStep) {
|
|
4346
|
+
// A path that begins on its first checkout_step has no page before it to
|
|
4347
|
+
// fill the cart from; the step page fills it (the family's main package).
|
|
4348
|
+
// The empty-cart guard before submit still refuses an empty cart by name.
|
|
4349
|
+
return {
|
|
4350
|
+
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",
|
|
4351
|
+
evidence: { checkout_selection_surface: surface, ...probeEvidence },
|
|
4352
|
+
};
|
|
4353
|
+
}
|
|
4287
4354
|
if (!entryPage?.url) {
|
|
4288
4355
|
throw codedError(
|
|
4289
4356
|
CART_ENTRY_CODES.ENTRY_UNRESOLVED,
|
|
@@ -4294,15 +4361,52 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4294
4361
|
await gotoAndSettle(page, entryPage.url, args, tracking, currencyLoad);
|
|
4295
4362
|
const sdkReady = await waitForSdkReady(page, Math.min(budget(), DEFAULT_SETTLE_TIMEOUT_MS));
|
|
4296
4363
|
await tracking?.readDocument(page);
|
|
4297
|
-
const
|
|
4364
|
+
const readControls = () => page.evaluate(cartEntryControlsScript(), {
|
|
4365
|
+
selector: CART_ENTRY_CONTROL_SELECTOR,
|
|
4366
|
+
checkoutUrl: checkoutPage.url,
|
|
4367
|
+
checkoutButtonSelector: CART_ENTRY_CHECKOUT_BUTTON_SELECTOR,
|
|
4368
|
+
bundleCardSelector: CART_ENTRY_BUNDLE_CARD_SELECTOR,
|
|
4369
|
+
}).catch(() => []);
|
|
4370
|
+
let controls = await readControls();
|
|
4371
|
+
// A two-step select page can hold its checkout button in a second step that
|
|
4372
|
+
// a variant control reveals (olympus-mv-two-step: "Select Color & Size").
|
|
4373
|
+
// When no checkout button is visible yet, click that control once and read
|
|
4374
|
+
// the controls again, so a hidden decoy link is never chosen over the
|
|
4375
|
+
// button the reveal shows.
|
|
4376
|
+
const checkoutButtons = controls.filter((entry) => entry.kind === "checkout_button");
|
|
4377
|
+
if (checkoutButtons.length && !checkoutButtons.some((entry) => entry.visible)) {
|
|
4378
|
+
const reveal = page.locator('[data-next-action="select-variants"]:visible').first();
|
|
4379
|
+
if (await reveal.count().catch(() => 0)) {
|
|
4380
|
+
await clickControl(reveal, { timeout: 5000 }).catch(() => {});
|
|
4381
|
+
await page.locator(CART_ENTRY_CHECKOUT_BUTTON_SELECTOR).filter({ visible: true }).first()
|
|
4382
|
+
.waitFor({ state: "visible", timeout: Math.max(1000, Math.min(budget(), 8000)) }).catch(() => {});
|
|
4383
|
+
controls = await readControls();
|
|
4384
|
+
}
|
|
4385
|
+
}
|
|
4298
4386
|
const choice = chooseCartEntryControl(controls, selectedPackages);
|
|
4299
4387
|
if (!choice.control) {
|
|
4300
4388
|
throw codedError(CART_ENTRY_CODES.ENTRY_CONTROL_MISSING, `${choice.reason} (entry page ${redactUrlQuery(entryPage.url)})`);
|
|
4301
4389
|
}
|
|
4302
4390
|
const control = choice.control;
|
|
4391
|
+
// A select page (#641): the bundle card is the cart. --select-package clicks
|
|
4392
|
+
// the matching data-next-bundle-card first, strictly, as on a checkout that
|
|
4393
|
+
// selects for itself; otherwise the page's pre-selected card stands.
|
|
4394
|
+
let cardSelection = null;
|
|
4395
|
+
if (control.kind === "checkout_button") {
|
|
4396
|
+
cardSelection = choice.select_card ? await selectPackageCard(page, choice.select_card, []) : null;
|
|
4397
|
+
control.package_id = choice.select_card?.packageId || await selectedBundleCardPackage(page);
|
|
4398
|
+
}
|
|
4399
|
+
// Each bundle slot may need a variant (size, colour) before the page lets
|
|
4400
|
+
// Next through (campaigns-os#667). Fill every visible empty slot variant
|
|
4401
|
+
// select with its first in-stock option now, after the card that decides
|
|
4402
|
+
// the slots, and refuse by name when one has nothing to choose.
|
|
4403
|
+
const variantSelections = control.kind === "checkout_button" ? await fillSlotVariants(page) : [];
|
|
4303
4404
|
// The index is the control's position among what its own locator matches,
|
|
4304
4405
|
// so it replays with nth(); no selector is rebuilt from an attribute value.
|
|
4305
|
-
const
|
|
4406
|
+
const controlSelector = control.kind === "add_to_cart"
|
|
4407
|
+
? CART_ENTRY_CONTROL_SELECTOR
|
|
4408
|
+
: control.kind === "checkout_button" ? CART_ENTRY_CHECKOUT_BUTTON_SELECTOR : "a[href]";
|
|
4409
|
+
const target = page.locator(controlSelector).nth(control.index);
|
|
4306
4410
|
const perpetual = await scrollControlIntoView(target);
|
|
4307
4411
|
await clickControl(target, { timeout: 8000, perpetual });
|
|
4308
4412
|
|
|
@@ -4332,6 +4436,8 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4332
4436
|
control_text: control.text || null,
|
|
4333
4437
|
control_kind: control.kind,
|
|
4334
4438
|
package_id: control.package_id || null,
|
|
4439
|
+
...(cardSelection ? { bundle_card_selection: cardSelection } : {}),
|
|
4440
|
+
...(variantSelections.length ? { variant_selections: variantSelections } : {}),
|
|
4335
4441
|
sdk_ready: sdkReady,
|
|
4336
4442
|
arrived_url: redactUrlQuery(safePageUrl(page)),
|
|
4337
4443
|
checkout_selection_surface: surface,
|
|
@@ -4340,6 +4446,203 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4340
4446
|
};
|
|
4341
4447
|
}
|
|
4342
4448
|
|
|
4449
|
+
// Chooses a select page's slot variants one at a time (a choice can
|
|
4450
|
+
// re-render its slot) and returns the choices. A field showing a dropdown UI
|
|
4451
|
+
// is picked like a shopper, toggle then row, even when its select is
|
|
4452
|
+
// pre-filled, because a page may count a field as chosen only when a row was
|
|
4453
|
+
// clicked (campaigns-os#667). A select with no dropdown UI is filled only when
|
|
4454
|
+
// empty; a pre-filled native-only page returns [] untouched. Throws
|
|
4455
|
+
// ENTRY_VARIANT_UNFILLED, before any click on the checkout control, when a
|
|
4456
|
+
// field has no in-stock row or option, a pick cannot be clicked, or the page
|
|
4457
|
+
// keeps asking for more choices.
|
|
4458
|
+
const SLOT_VARIANT_FILL_LIMIT = 40;
|
|
4459
|
+
const SLOT_VARIANT_ROW_WAIT_MS = 2000;
|
|
4460
|
+
|
|
4461
|
+
// The slot variant field a pick names, found again by its slot identity
|
|
4462
|
+
// (data-next-bundle-id, data-next-slot-index, variant code) so a re-render
|
|
4463
|
+
// since the pick was planned does not leave a stale index; the planned index
|
|
4464
|
+
// is the fallback for a field that carries no slot index.
|
|
4465
|
+
async function slotVariantField(page, pick) {
|
|
4466
|
+
const fields = page.locator(SLOT_VARIANT_FIELD_SELECTOR);
|
|
4467
|
+
const index = await fields.evaluateAll((nodes, want) => {
|
|
4468
|
+
const clean = (value) => String(value || "").replace(/\s+/g, " ").trim();
|
|
4469
|
+
const found = nodes.findIndex((node) => {
|
|
4470
|
+
const slotIndex = Number.parseInt(node.getAttribute("data-next-slot-index") ?? "", 10);
|
|
4471
|
+
if (!Number.isFinite(slotIndex) || slotIndex + 1 !== want.slot) return false;
|
|
4472
|
+
if ((clean(node.getAttribute("data-next-bundle-id")) || null) !== want.bundle_id) return false;
|
|
4473
|
+
const select = node.querySelector("select");
|
|
4474
|
+
const code = clean(select?.getAttribute("data-next-variant-code") || select?.getAttribute("data-variant-code") || node.getAttribute("data-next-variant-code") || select?.name) || null;
|
|
4475
|
+
return code === want.variant_code;
|
|
4476
|
+
});
|
|
4477
|
+
return found >= 0 ? found : want.field_index;
|
|
4478
|
+
}, pick).catch(() => pick.field_index);
|
|
4479
|
+
return fields.nth(index);
|
|
4480
|
+
}
|
|
4481
|
+
|
|
4482
|
+
// The pick's row, matched by its value among the field's rows (no selector is
|
|
4483
|
+
// built from the value), once it is visible; null if it does not show in time.
|
|
4484
|
+
async function visibleSlotVariantRow(page, pick, timeoutMs) {
|
|
4485
|
+
const deadline = Date.now() + timeoutMs;
|
|
4486
|
+
for (;;) {
|
|
4487
|
+
const field = await slotVariantField(page, pick);
|
|
4488
|
+
const rows = field.locator(SLOT_VARIANT_DROPDOWN_ROW_SELECTOR);
|
|
4489
|
+
const index = await rows.evaluateAll((nodes, value) => nodes.findIndex((node) => {
|
|
4490
|
+
if (String(node.getAttribute("value") || "").replace(/\s+/g, " ").trim() !== value) return false;
|
|
4491
|
+
const rect = node.getBoundingClientRect();
|
|
4492
|
+
const style = getComputedStyle(node);
|
|
4493
|
+
return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
|
|
4494
|
+
}), pick.value).catch(() => -1);
|
|
4495
|
+
if (index >= 0) return rows.nth(index);
|
|
4496
|
+
if (Date.now() >= deadline) return null;
|
|
4497
|
+
await page.waitForTimeout(100);
|
|
4498
|
+
}
|
|
4499
|
+
}
|
|
4500
|
+
async function fillSlotVariants(page) {
|
|
4501
|
+
const selections = [];
|
|
4502
|
+
const picked = [];
|
|
4503
|
+
const step = () => page.evaluate(slotVariantStepScript(), {
|
|
4504
|
+
selector: SLOT_VARIANT_SELECT_SELECTOR,
|
|
4505
|
+
fieldSelector: SLOT_VARIANT_FIELD_SELECTOR,
|
|
4506
|
+
toggleSelector: SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR,
|
|
4507
|
+
rowSelector: SLOT_VARIANT_DROPDOWN_ROW_SELECTOR,
|
|
4508
|
+
picked,
|
|
4509
|
+
}).catch(() => ({ done: true }));
|
|
4510
|
+
for (let pass = 0; pass < SLOT_VARIANT_FILL_LIMIT; pass += 1) {
|
|
4511
|
+
const result = await step();
|
|
4512
|
+
if (result?.done) {
|
|
4513
|
+
if (selections.length) await page.waitForLoadState("networkidle", { timeout: 3000 }).catch(() => {});
|
|
4514
|
+
return selections;
|
|
4515
|
+
}
|
|
4516
|
+
if (result?.unfillable?.detail) {
|
|
4517
|
+
throw codedError(CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED, `${slotVariantLabel(result.unfillable)}: ${result.unfillable.detail}; the page would refuse Next`);
|
|
4518
|
+
}
|
|
4519
|
+
if (result?.unfillable) {
|
|
4520
|
+
throw codedError(
|
|
4521
|
+
CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
|
|
4522
|
+
`${slotVariantLabel(result.unfillable)} has no in-stock ${result.unfillable.via === "dropdown" ? "dropdown row" : "option"} to choose (${result.unfillable.options} ${result.unfillable.via === "dropdown" ? "row" : "option"}(s), all disabled or placeholder); the page would refuse Next`,
|
|
4523
|
+
);
|
|
4524
|
+
}
|
|
4525
|
+
const choice = result.pick || result.filled;
|
|
4526
|
+
if (result.pick) {
|
|
4527
|
+
try {
|
|
4528
|
+
const field = await slotVariantField(page, result.pick);
|
|
4529
|
+
await clickControl(field.locator(SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR).filter({ visible: true }).first(), { timeout: 5000 });
|
|
4530
|
+
// The menu may open late or re-render on toggle: re-resolve the field
|
|
4531
|
+
// and the row by its value, and wait for that row to show.
|
|
4532
|
+
const row = await visibleSlotVariantRow(page, result.pick, SLOT_VARIANT_ROW_WAIT_MS);
|
|
4533
|
+
if (!row) {
|
|
4534
|
+
throw codedError(
|
|
4535
|
+
CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
|
|
4536
|
+
`${slotVariantLabel(choice)} dropdown row "${choice.value}" did not show within ${SLOT_VARIANT_ROW_WAIT_MS}ms of opening the dropdown; the page would refuse Next`,
|
|
4537
|
+
);
|
|
4538
|
+
}
|
|
4539
|
+
await clickControl(row, { timeout: 5000 });
|
|
4540
|
+
} catch (error) {
|
|
4541
|
+
if (error?.code === CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED) throw error;
|
|
4542
|
+
throw codedError(
|
|
4543
|
+
CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
|
|
4544
|
+
`${slotVariantLabel(choice)} dropdown row "${choice.value}" could not be clicked (${String(error?.message || error).split("\n")[0]}); the page would refuse Next`,
|
|
4545
|
+
);
|
|
4546
|
+
}
|
|
4547
|
+
picked.push(slotVariantKey(choice));
|
|
4548
|
+
}
|
|
4549
|
+
selections.push({ bundle_id: choice.bundle_id, slot: choice.slot, variant_code: choice.variant_code, value: choice.value, via: result.pick ? "dropdown" : "select" });
|
|
4550
|
+
await page.waitForTimeout(250);
|
|
4551
|
+
}
|
|
4552
|
+
const pending = await step();
|
|
4553
|
+
if (pending?.done) return selections;
|
|
4554
|
+
throw codedError(
|
|
4555
|
+
CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
|
|
4556
|
+
`${slotVariantLabel(pending.pick || pending.filled || pending.unfillable)} still needed a choice after ${SLOT_VARIANT_FILL_LIMIT} variant choices; the page keeps clearing them`,
|
|
4557
|
+
);
|
|
4558
|
+
}
|
|
4559
|
+
|
|
4560
|
+
// The package of the bundle card a select page shows as selected, or null.
|
|
4561
|
+
async function selectedBundleCardPackage(page) {
|
|
4562
|
+
return page.evaluate((cardSelector) => {
|
|
4563
|
+
const card = Array.from(document.querySelectorAll(cardSelector)).find((node) => (
|
|
4564
|
+
node.classList.contains("next-selected") || node.getAttribute("data-next-selected") === "true" || node.getAttribute("aria-checked") === "true"
|
|
4565
|
+
));
|
|
4566
|
+
const holder = card && (card.hasAttribute("data-next-package-id") ? card : card.querySelector("[data-next-package-id]"));
|
|
4567
|
+
const value = holder ? String(holder.getAttribute("data-next-package-id") || "").trim() : "";
|
|
4568
|
+
return value || null;
|
|
4569
|
+
}, CART_ENTRY_BUNDLE_CARD_SELECTOR).catch(() => null);
|
|
4570
|
+
}
|
|
4571
|
+
|
|
4572
|
+
// One checkout_step rung: the page must be on this step, its form's submit is
|
|
4573
|
+
// clicked, and the page must land on the step's declared next page. Then the
|
|
4574
|
+
// fields the arrived page renders are filled. The SDK owns the navigation
|
|
4575
|
+
// (data-next-checkout-step on the form); the runner only waits for it.
|
|
4576
|
+
async function submitCheckoutStepAndArrive({ page, step, index, args, email, budget, checkoutUrl, tracking = null }) {
|
|
4577
|
+
ensurePageFillable(page, checkoutUrl);
|
|
4578
|
+
const here = safePageUrl(page);
|
|
4579
|
+
if (!checkoutUrlPredicate(step.url)(here)) {
|
|
4580
|
+
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)"}`);
|
|
4581
|
+
}
|
|
4582
|
+
const expected = step.expected_next_url;
|
|
4583
|
+
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`);
|
|
4584
|
+
await tracking?.readDocument(page);
|
|
4585
|
+
await submitCheckoutStep(page);
|
|
4586
|
+
const atNext = checkoutUrlPredicate(expected);
|
|
4587
|
+
const timeout = Math.max(1000, Math.min(budget(), numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS)));
|
|
4588
|
+
const arrived = await page.waitForURL((url) => atNext(url.toString()), { timeout }).then(() => true, () => false);
|
|
4589
|
+
if (!arrived) {
|
|
4590
|
+
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`);
|
|
4591
|
+
}
|
|
4592
|
+
await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
|
|
4593
|
+
await page.waitForTimeout(500);
|
|
4594
|
+
ensurePageFillable(page, checkoutUrl);
|
|
4595
|
+
const fieldTrace = createFieldTrace();
|
|
4596
|
+
await fillCheckoutFields(page, args, email, { trace: fieldTrace, actionTimeoutMs: budget(), presentOnly: true });
|
|
4597
|
+
return {
|
|
4598
|
+
detail: `step ${index + 1} "${step.page_id || "(unnamed)"}": submitted and landed on its declared next page ${redactUrlQuery(expected)}`,
|
|
4599
|
+
evidence: {
|
|
4600
|
+
step_page_id: step.page_id || null,
|
|
4601
|
+
step_number: index + 1,
|
|
4602
|
+
expected_next_url: redactUrlQuery(expected),
|
|
4603
|
+
arrived_url: redactUrlQuery(safePageUrl(page)),
|
|
4604
|
+
fields: fieldTrace.summary(),
|
|
4605
|
+
},
|
|
4606
|
+
};
|
|
4607
|
+
}
|
|
4608
|
+
|
|
4609
|
+
// The submit control of a checkout_step form. The step contract puts
|
|
4610
|
+
// data-next-checkout-step on the checkout form itself, so a page without one
|
|
4611
|
+
// is reported rather than submitted some other way.
|
|
4612
|
+
async function submitCheckoutStep(page) {
|
|
4613
|
+
await closeAddressAutocomplete(page);
|
|
4614
|
+
const scope = "form[data-next-checkout-step]";
|
|
4615
|
+
// Never an express-wallet button: on a step page those place an order.
|
|
4616
|
+
const notWallet = ":not([data-next-express-checkout]):not([data-next-express-checkout] *)";
|
|
4617
|
+
const submit = page.locator([`button[type="submit"]`, "button:not([type])", `input[type="submit"]`]
|
|
4618
|
+
.map((control) => `${scope} ${control}${notWallet}`).join(", ")).first();
|
|
4619
|
+
if (!await submit.count().catch(() => 0)) {
|
|
4620
|
+
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");
|
|
4621
|
+
}
|
|
4622
|
+
await submit.waitFor({ state: "visible" });
|
|
4623
|
+
const perpetual = await scrollControlIntoView(submit);
|
|
4624
|
+
await clickControl(submit, { forceFallback: false, perpetual });
|
|
4625
|
+
}
|
|
4626
|
+
|
|
4627
|
+
// The checkout_step pages on the path into `checkoutPage`, read from the
|
|
4628
|
+
// topology that holds it.
|
|
4629
|
+
function checkoutStepPagesFor(topologies, checkoutPage) {
|
|
4630
|
+
if (!checkoutPage) return [];
|
|
4631
|
+
const owner = (Array.isArray(topologies) ? topologies : []).find((topology) => (topology?.pages || []).includes(checkoutPage))
|
|
4632
|
+
|| (Array.isArray(topologies) ? topologies : []).find((topology) => (topology?.pages || []).some((page) => checkoutUrlPredicate(checkoutPage.url)(page?.url)));
|
|
4633
|
+
return owner ? checkoutStepPages(owner, checkoutPage) : [];
|
|
4634
|
+
}
|
|
4635
|
+
|
|
4636
|
+
// The page the cart is entered from. Into a Checkout, the usual resolution.
|
|
4637
|
+
// Into a first checkout_step, only a page that routes straight into it: the
|
|
4638
|
+
// order-and-type fallbacks would pick another path's page in a campaign with
|
|
4639
|
+
// parallel paths, and a path that starts on its step has none (#641).
|
|
4640
|
+
function resolvePathEntryPage(topologies, formEntryPage, stepPages) {
|
|
4641
|
+
const resolved = resolveCartEntryPage(topologies, formEntryPage);
|
|
4642
|
+
if (!stepPages.length) return resolved;
|
|
4643
|
+
return resolved?.resolution === "routes_into_checkout" ? resolved : null;
|
|
4644
|
+
}
|
|
4645
|
+
|
|
4343
4646
|
// The SDK installs `window.next` late in boot. Bounded and tolerant: a page
|
|
4344
4647
|
// without the SDK (a plain forcePackageId link) still has a cart entry.
|
|
4345
4648
|
async function waitForSdkReady(page, timeoutMs) {
|
|
@@ -5271,10 +5574,13 @@ async function fillCheckoutFields(page, args, email, options = {}) {
|
|
|
5271
5574
|
// Field order, progressive-disclosure calls and optional/onlyVisible flags
|
|
5272
5575
|
// below are unchanged; only the per-field recording wrapper is new.
|
|
5273
5576
|
const track = (field, action, fn, opts = {}) => (trace ? trace.inspect(field, action, fn, opts) : fn());
|
|
5577
|
+
// presentOnly (a multi-step checkout page): every field optional and
|
|
5578
|
+
// visible-only, because each page of the path renders only its own.
|
|
5579
|
+
const present = options.presentOnly === true ? { optional: true, onlyVisible: true } : {};
|
|
5274
5580
|
const fill = (field, value, opts = {}) =>
|
|
5275
|
-
track(field, "fill", () => fillByField(page, field, value, { ...opts, actionTimeoutMs }), opts);
|
|
5581
|
+
track(field, "fill", () => fillByField(page, field, value, { ...opts, ...present, actionTimeoutMs }), { ...opts, ...present });
|
|
5276
5582
|
const select = (field, value, opts = {}) =>
|
|
5277
|
-
track(field, "select", () => selectByField(page, field, value, { ...opts, actionTimeoutMs }), opts);
|
|
5583
|
+
track(field, "select", () => selectByField(page, field, value, { ...opts, ...present, actionTimeoutMs }), { ...opts, ...present });
|
|
5278
5584
|
const address = {
|
|
5279
5585
|
firstName: stringArg(args["test-first-name"]) || "QA",
|
|
5280
5586
|
lastName: stringArg(args["test-last-name"]) || "Playwright",
|
|
@@ -7440,7 +7746,7 @@ function testOrderPaths(mode, topologies = [], args = {}) {
|
|
|
7440
7746
|
}
|
|
7441
7747
|
|
|
7442
7748
|
function resolvePrimaryTestOrderTopology(topologies = []) {
|
|
7443
|
-
const checkout =
|
|
7749
|
+
const checkout = findPaymentPage(topologies);
|
|
7444
7750
|
const topology = (topologies || []).find((candidate) => (candidate?.pages || []).includes(checkout)) || { pages: [] };
|
|
7445
7751
|
return resolveTestOrderTopology(topology, checkout);
|
|
7446
7752
|
}
|
|
@@ -7467,7 +7773,7 @@ function testOrderPlans(mode, topologies = [], args = {}, options = {}) {
|
|
|
7467
7773
|
if (tiersMatch) return specTierPlans(topologies, args, tiersMatch[1] || "checkout", options);
|
|
7468
7774
|
const selectPackage = stringArg(args["select-package"]);
|
|
7469
7775
|
const applyCoupon = stringArg(args["apply-coupon"]);
|
|
7470
|
-
const checkoutPage =
|
|
7776
|
+
const checkoutPage = findPaymentPage(topologies);
|
|
7471
7777
|
const topologyPlan = resolvePrimaryTestOrderTopology(topologies);
|
|
7472
7778
|
return testOrderPaths(mode, topologies, args).map((path) => ({
|
|
7473
7779
|
path,
|
|
@@ -7498,24 +7804,31 @@ function specTierPlans(topologies, args, variant, { warn = (line) => process.std
|
|
|
7498
7804
|
// fail for the wrong reason. A later funnel's checkout with declarations
|
|
7499
7805
|
// but no resolvable URL cannot be driven; it is warned about, not dropped
|
|
7500
7806
|
// silently.
|
|
7501
|
-
const primary =
|
|
7807
|
+
const primary = findPaymentPage(topologies);
|
|
7502
7808
|
if (!primary) {
|
|
7503
7809
|
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).");
|
|
7504
7810
|
}
|
|
7505
7811
|
const plans = [];
|
|
7506
|
-
|
|
7507
|
-
|
|
7508
|
-
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
7812
|
+
// Every Checkout of every funnel: split-test paths in one funnel each reach
|
|
7813
|
+
// their own (#641). A multi-step path declares its cart on the select page or
|
|
7814
|
+
// first step, not on the Checkout, so tiers and bumps are read from every
|
|
7815
|
+
// page of the path; coupons from the pages the runner fills (steps and the
|
|
7816
|
+
// Checkout), since a select page is left before any form is filled.
|
|
7817
|
+
const paymentPages = (Array.isArray(topologies) ? topologies : [])
|
|
7818
|
+
.flatMap((topology) => paymentPagesForTopology(topology).map((checkoutPage) => ({ topology, checkoutPage })));
|
|
7819
|
+
for (const { topology, checkoutPage } of paymentPages) {
|
|
7820
|
+
const pathPages = checkoutPathPages(topology, checkoutPage);
|
|
7821
|
+
const declaredTiers = uniqueTiers(pathPages.flatMap((page) => declaredSelectorTiers(page)));
|
|
7822
|
+
const bumps = [...new Set(pathPages.flatMap((page) => declaredOrderBumps(page)))];
|
|
7512
7823
|
if (bumps.length) {
|
|
7513
7824
|
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.`);
|
|
7514
7825
|
}
|
|
7515
7826
|
for (const tier of declaredTiers) declaredIdentities.add(selectorTierIdentity(tier));
|
|
7516
7827
|
for (const ref of bumps) bumpRefs.add(ref);
|
|
7517
7828
|
const tiers = narrowTo ? declaredTiers.filter((tier) => narrowTo.has(selectorTierIdentity(tier))) : declaredTiers;
|
|
7518
|
-
const coupons =
|
|
7829
|
+
const coupons = mergeDeclaredCoupons(pathPages
|
|
7830
|
+
.filter((page) => page === checkoutPage || isCheckoutStepTopologyPage(page))
|
|
7831
|
+
.map((page) => declaredCheckoutCoupons(page)));
|
|
7519
7832
|
if (!tiers.length && !coupons.length) continue;
|
|
7520
7833
|
if (checkoutPage !== primary && !checkoutPage.url) {
|
|
7521
7834
|
if (narrowTo && tiers.length) {
|
|
@@ -7637,6 +7950,28 @@ function selectorTierNarrowing(value) {
|
|
|
7637
7950
|
// Declared coupons follow the repo's offer-surface rule (build-brief, cli
|
|
7638
7951
|
// exit-pop gates): a surface counts only when `enabled === true` and it maps
|
|
7639
7952
|
// an offer_code. Both surfaces mapping the same code collapse into one plan.
|
|
7953
|
+
// Tiers read from several pages of one path, first declaration kept.
|
|
7954
|
+
function uniqueTiers(tiers) {
|
|
7955
|
+
const seen = new Set();
|
|
7956
|
+
return tiers.filter((tier) => {
|
|
7957
|
+
const identity = selectorTierIdentity(tier);
|
|
7958
|
+
if (seen.has(identity)) return false;
|
|
7959
|
+
seen.add(identity);
|
|
7960
|
+
return true;
|
|
7961
|
+
});
|
|
7962
|
+
}
|
|
7963
|
+
|
|
7964
|
+
// Coupon lists from several pages of one path, merged by code.
|
|
7965
|
+
function mergeDeclaredCoupons(lists) {
|
|
7966
|
+
const merged = [];
|
|
7967
|
+
for (const coupon of lists.flat()) {
|
|
7968
|
+
const existing = merged.find((entry) => normalizeLabel(entry.code) === normalizeLabel(coupon.code));
|
|
7969
|
+
if (!existing) merged.push({ code: coupon.code, surfaces: [...coupon.surfaces] });
|
|
7970
|
+
else for (const surface of coupon.surfaces) if (!existing.surfaces.includes(surface)) existing.surfaces.push(surface);
|
|
7971
|
+
}
|
|
7972
|
+
return merged;
|
|
7973
|
+
}
|
|
7974
|
+
|
|
7640
7975
|
function declaredCheckoutCoupons(checkoutPage) {
|
|
7641
7976
|
const coupons = [];
|
|
7642
7977
|
const add = (surface, block) => {
|
|
@@ -7966,7 +8301,7 @@ function describeCoverageDoubt(doubt) {
|
|
|
7966
8301
|
|
|
7967
8302
|
// Page types that never carry an upsell action. Any other type, or none, does
|
|
7968
8303
|
// not say either way.
|
|
7969
|
-
const NON_OFFER_PAGE_TYPES = new Set(["presell", "landing", "select", "product", "checkout", "thankyou", "receipt"]);
|
|
8304
|
+
const NON_OFFER_PAGE_TYPES = new Set(["presell", "landing", "select", "product", "checkout_step", "checkout", "thankyou", "receipt"]);
|
|
7970
8305
|
const UPSELL_COVERAGE_UNASSESSABLE = Object.freeze({
|
|
7971
8306
|
no_url: "no URL",
|
|
7972
8307
|
unresolvable_url: "URL is not an absolute http(s) address",
|
|
@@ -7989,7 +8324,7 @@ export function upsellActionCoverageWithoutOrders(topologies = []) {
|
|
|
7989
8324
|
return upsellActionCoverageAssertion({
|
|
7990
8325
|
topologies,
|
|
7991
8326
|
orders: [],
|
|
7992
|
-
checkoutPage:
|
|
8327
|
+
checkoutPage: findPaymentPage(topologies),
|
|
7993
8328
|
testOrdersOff: true,
|
|
7994
8329
|
});
|
|
7995
8330
|
}
|
|
@@ -8452,14 +8787,6 @@ function persistedUpsellStep(step) {
|
|
|
8452
8787
|
return projected;
|
|
8453
8788
|
}
|
|
8454
8789
|
|
|
8455
|
-
function findPage(topologies, type) {
|
|
8456
|
-
for (const topology of topologies || []) {
|
|
8457
|
-
const page = (topology.pages || []).find((candidate) => candidate.page_type === type);
|
|
8458
|
-
if (page) return page;
|
|
8459
|
-
}
|
|
8460
|
-
return null;
|
|
8461
|
-
}
|
|
8462
|
-
|
|
8463
8790
|
function parseCart(value) {
|
|
8464
8791
|
if (!value) return [];
|
|
8465
8792
|
return String(value).split(",").map((part) => {
|