@nextcommerce/campaigns-os 1.55.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 +64 -0
- package/CONTEXT.md +11 -0
- 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 +705 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
- package/contracts/supported-surface.json +8 -4
- package/docs/build-packet.md +52 -8
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +78 -18
- 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 +3 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -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/cli-helpers.mjs +129 -0
- package/src/cli.mjs +121 -53
- 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 +196 -13
- package/src/doctor/inspect.mjs +4 -2
- package/src/doctor/next-step.mjs +31 -11
- 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 +3 -3
- package/src/qa-browser.mjs +226 -34
- package/src/qa-cart-entry.mjs +78 -14
- package/src/qa-node.mjs +35 -33
- package/src/qa-test-order-topology.mjs +118 -1
- package/src/qa-verdict-discovery.mjs +21 -4
- package/src/runtime-state-ignore.mjs +7 -5
- package/src/source-html-intake.mjs +46 -4
- package/src/stage-ledger.mjs +4 -2
- package/src/stage-record.mjs +3 -3
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,
|
|
@@ -191,7 +198,9 @@ export async function runBrowserChecks(topologies, args = {}, options = {}) {
|
|
|
191
198
|
}
|
|
192
199
|
|
|
193
200
|
export async function runBrowserTestOrders(topologies, args = {}, runId = "local", options = {}) {
|
|
194
|
-
|
|
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);
|
|
195
204
|
if (!checkoutPage?.url) {
|
|
196
205
|
const coverage = upsellActionCoverageAssertion({ topologies, orders: [], checkoutPage });
|
|
197
206
|
return {
|
|
@@ -1090,6 +1099,10 @@ async function runPageBrowserChecks(context, page, args, options = {}, policyLin
|
|
|
1090
1099
|
}
|
|
1091
1100
|
if (page.page_type === "checkout") {
|
|
1092
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)) {
|
|
1093
1106
|
assertions.push(...await checkoutOfferSurfaceAssertions(browserPage, page));
|
|
1094
1107
|
}
|
|
1095
1108
|
assertions.push(...await templateResidueAssertions(browserPage, page, options));
|
|
@@ -1330,7 +1343,7 @@ function primaryCtaCheckEligible(page) {
|
|
|
1330
1343
|
// where the page declares, not where they point.
|
|
1331
1344
|
function primaryCtaPageRoutesSdkControls(page) {
|
|
1332
1345
|
const pageType = String(page?.page_type || "").toLowerCase();
|
|
1333
|
-
return pageType === "checkout" || OFFER_PAGE_TYPES.has(pageType);
|
|
1346
|
+
return pageType === "checkout" || pageType === "checkout_step" || OFFER_PAGE_TYPES.has(pageType);
|
|
1334
1347
|
}
|
|
1335
1348
|
|
|
1336
1349
|
// The forms whose action is no route on this page, handed to the route rule
|
|
@@ -1357,7 +1370,9 @@ function primaryCtaRoutelessForms(page) {
|
|
|
1357
1370
|
// container, the only place the SDK binds it.
|
|
1358
1371
|
function primaryCtaDeclaredRoutes(page) {
|
|
1359
1372
|
if (!primaryCtaPageRoutesSdkControls(page)) return [];
|
|
1360
|
-
|
|
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())) {
|
|
1361
1376
|
const notWallet = ":not([data-next-express-checkout]):not([data-next-express-checkout] *)";
|
|
1362
1377
|
const submit = ['button[type="submit"]', "button:not([type])", 'input[type="submit"]']
|
|
1363
1378
|
.map((control) => `${CHECKOUT_FORM_SELECTOR} ${control}${notWallet}`)
|
|
@@ -2397,7 +2412,10 @@ function promoCodeSurfaceAssertion({ page, declaration, evidence }) {
|
|
|
2397
2412
|
|
|
2398
2413
|
function contractPageType(page) {
|
|
2399
2414
|
const type = String(page?.page_type || "").toLowerCase();
|
|
2400
|
-
|
|
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;
|
|
2401
2419
|
}
|
|
2402
2420
|
|
|
2403
2421
|
// The palette-residue rows (style, logo, payment chrome) take their severity
|
|
@@ -3707,7 +3725,12 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
|
|
|
3707
3725
|
let orderDeadline = null;
|
|
3708
3726
|
let events = { requests: [], responses: [], failed: [], console: [], pageErrors: [] };
|
|
3709
3727
|
const email = testEmail(planArgs);
|
|
3710
|
-
|
|
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);
|
|
3711
3734
|
let tracking = null;
|
|
3712
3735
|
try {
|
|
3713
3736
|
tracking = options.tracking ? options.tracking.observe(planId(normalizedPlan)) : null;
|
|
@@ -3828,6 +3851,7 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
|
|
|
3828
3851
|
ladder,
|
|
3829
3852
|
checkoutPage,
|
|
3830
3853
|
entryPage,
|
|
3854
|
+
stepPages,
|
|
3831
3855
|
topologyPlan: normalizedPlan.topology_plan,
|
|
3832
3856
|
path,
|
|
3833
3857
|
args: planArgs,
|
|
@@ -3916,10 +3940,13 @@ function stablePrivateCaptureError(value) {
|
|
|
3916
3940
|
return projectAnalyticsCaptureError(value, { fallbackKind: "unreadable" });
|
|
3917
3941
|
}
|
|
3918
3942
|
|
|
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 }) {
|
|
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 }) {
|
|
3920
3944
|
const stepTimeoutMs = numberArg(args["step-timeout-ms"], DEFAULT_STEP_TIMEOUT_MS);
|
|
3921
3945
|
const budget = () => Math.min(stepTimeoutMs, deadline - Date.now());
|
|
3922
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;
|
|
3923
3950
|
const selectedPackages = parseCart(args["select-package"]);
|
|
3924
3951
|
const currencyLoad = pathCurrencyLoad(args);
|
|
3925
3952
|
let checkoutDisplay = null;
|
|
@@ -3937,7 +3964,8 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3937
3964
|
// page the probe left there rather than loading the same URL a second time,
|
|
3938
3965
|
// which would fire the SDK's page-view events twice into the same capture.
|
|
3939
3966
|
const entry = await ladder.run(CART_ENTRY_STEP, () => enterCartViaLanding({
|
|
3940
|
-
page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking, currencyLoad,
|
|
3967
|
+
page, checkoutPage: formEntryPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking, currencyLoad,
|
|
3968
|
+
startsOnCheckoutStep: formEntryPage !== checkoutPage,
|
|
3941
3969
|
}), { timeoutMs: budget() });
|
|
3942
3970
|
const enteredViaLanding = Boolean(entry && typeof entry === "object" && entry.entered);
|
|
3943
3971
|
// Responses captured from here on belong to the checkout the ladder drives.
|
|
@@ -3945,7 +3973,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3945
3973
|
// made before the hand-off.
|
|
3946
3974
|
const checkoutResponseOffset = events.responses.length;
|
|
3947
3975
|
|
|
3948
|
-
await ladder.run("opened_checkout", () => openCheckoutForPath({ page, checkoutPage, entry, args, tracking, currencyLoad }), { timeoutMs: budget() });
|
|
3976
|
+
await ladder.run("opened_checkout", () => openCheckoutForPath({ page, checkoutPage: formEntryPage, entry, args, tracking, currencyLoad }), { timeoutMs: budget() });
|
|
3949
3977
|
await ladder.run("selected_bundle", async () => {
|
|
3950
3978
|
// The requested package was selected on the entry page, where the cards
|
|
3951
3979
|
// live; checkout renders none, so re-running strict selection here would
|
|
@@ -3969,8 +3997,20 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3969
3997
|
const fieldTrace = createFieldTrace();
|
|
3970
3998
|
await ladder.run("customer_fields_filled", async () => {
|
|
3971
3999
|
ensurePageFillable(page, checkoutPage.url);
|
|
3972
|
-
|
|
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 });
|
|
3973
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
|
+
}
|
|
3974
4014
|
await ladder.run("coupon_applied", async () => {
|
|
3975
4015
|
const code = stringArg(args["apply-coupon"]);
|
|
3976
4016
|
if (!code) return { skip: "no --apply-coupon code requested" };
|
|
@@ -4248,7 +4288,7 @@ async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking =
|
|
|
4248
4288
|
// The entry step body. Resolves to `{ skip }` when the checkout selects for
|
|
4249
4289
|
// itself, to `{ entered: true, ... }` when the runner came in through the entry
|
|
4250
4290
|
// 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 }) {
|
|
4291
|
+
async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache = null, tracking = null, currencyLoad = null, startsOnCheckoutStep = false }) {
|
|
4252
4292
|
// Probe the checkout first: whether it carries a selection surface is a fact
|
|
4253
4293
|
// about the rendered page, not about the spec (the spec cannot say it yet —
|
|
4254
4294
|
// that is the design half of #206). The probe's load is the checkout's only
|
|
@@ -4284,6 +4324,15 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4284
4324
|
evidence: { checkout_selection_surface: surface, ...probeEvidence },
|
|
4285
4325
|
};
|
|
4286
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
|
+
}
|
|
4287
4336
|
if (!entryPage?.url) {
|
|
4288
4337
|
throw codedError(
|
|
4289
4338
|
CART_ENTRY_CODES.ENTRY_UNRESOLVED,
|
|
@@ -4294,15 +4343,47 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4294
4343
|
await gotoAndSettle(page, entryPage.url, args, tracking, currencyLoad);
|
|
4295
4344
|
const sdkReady = await waitForSdkReady(page, Math.min(budget(), DEFAULT_SETTLE_TIMEOUT_MS));
|
|
4296
4345
|
await tracking?.readDocument(page);
|
|
4297
|
-
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
|
+
}
|
|
4298
4368
|
const choice = chooseCartEntryControl(controls, selectedPackages);
|
|
4299
4369
|
if (!choice.control) {
|
|
4300
4370
|
throw codedError(CART_ENTRY_CODES.ENTRY_CONTROL_MISSING, `${choice.reason} (entry page ${redactUrlQuery(entryPage.url)})`);
|
|
4301
4371
|
}
|
|
4302
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
|
+
}
|
|
4303
4381
|
// The index is the control's position among what its own locator matches,
|
|
4304
4382
|
// so it replays with nth(); no selector is rebuilt from an attribute value.
|
|
4305
|
-
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);
|
|
4306
4387
|
const perpetual = await scrollControlIntoView(target);
|
|
4307
4388
|
await clickControl(target, { timeout: 8000, perpetual });
|
|
4308
4389
|
|
|
@@ -4332,6 +4413,7 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4332
4413
|
control_text: control.text || null,
|
|
4333
4414
|
control_kind: control.kind,
|
|
4334
4415
|
package_id: control.package_id || null,
|
|
4416
|
+
...(cardSelection ? { bundle_card_selection: cardSelection } : {}),
|
|
4335
4417
|
sdk_ready: sdkReady,
|
|
4336
4418
|
arrived_url: redactUrlQuery(safePageUrl(page)),
|
|
4337
4419
|
checkout_selection_surface: surface,
|
|
@@ -4340,6 +4422,92 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4340
4422
|
};
|
|
4341
4423
|
}
|
|
4342
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
|
+
|
|
4343
4511
|
// The SDK installs `window.next` late in boot. Bounded and tolerant: a page
|
|
4344
4512
|
// without the SDK (a plain forcePackageId link) still has a cart entry.
|
|
4345
4513
|
async function waitForSdkReady(page, timeoutMs) {
|
|
@@ -5271,10 +5439,13 @@ async function fillCheckoutFields(page, args, email, options = {}) {
|
|
|
5271
5439
|
// Field order, progressive-disclosure calls and optional/onlyVisible flags
|
|
5272
5440
|
// below are unchanged; only the per-field recording wrapper is new.
|
|
5273
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 } : {};
|
|
5274
5445
|
const fill = (field, value, opts = {}) =>
|
|
5275
|
-
track(field, "fill", () => fillByField(page, field, value, { ...opts, actionTimeoutMs }), opts);
|
|
5446
|
+
track(field, "fill", () => fillByField(page, field, value, { ...opts, ...present, actionTimeoutMs }), { ...opts, ...present });
|
|
5276
5447
|
const select = (field, value, opts = {}) =>
|
|
5277
|
-
track(field, "select", () => selectByField(page, field, value, { ...opts, actionTimeoutMs }), opts);
|
|
5448
|
+
track(field, "select", () => selectByField(page, field, value, { ...opts, ...present, actionTimeoutMs }), { ...opts, ...present });
|
|
5278
5449
|
const address = {
|
|
5279
5450
|
firstName: stringArg(args["test-first-name"]) || "QA",
|
|
5280
5451
|
lastName: stringArg(args["test-last-name"]) || "Playwright",
|
|
@@ -7440,7 +7611,7 @@ function testOrderPaths(mode, topologies = [], args = {}) {
|
|
|
7440
7611
|
}
|
|
7441
7612
|
|
|
7442
7613
|
function resolvePrimaryTestOrderTopology(topologies = []) {
|
|
7443
|
-
const checkout =
|
|
7614
|
+
const checkout = findPaymentPage(topologies);
|
|
7444
7615
|
const topology = (topologies || []).find((candidate) => (candidate?.pages || []).includes(checkout)) || { pages: [] };
|
|
7445
7616
|
return resolveTestOrderTopology(topology, checkout);
|
|
7446
7617
|
}
|
|
@@ -7467,7 +7638,7 @@ function testOrderPlans(mode, topologies = [], args = {}, options = {}) {
|
|
|
7467
7638
|
if (tiersMatch) return specTierPlans(topologies, args, tiersMatch[1] || "checkout", options);
|
|
7468
7639
|
const selectPackage = stringArg(args["select-package"]);
|
|
7469
7640
|
const applyCoupon = stringArg(args["apply-coupon"]);
|
|
7470
|
-
const checkoutPage =
|
|
7641
|
+
const checkoutPage = findPaymentPage(topologies);
|
|
7471
7642
|
const topologyPlan = resolvePrimaryTestOrderTopology(topologies);
|
|
7472
7643
|
return testOrderPaths(mode, topologies, args).map((path) => ({
|
|
7473
7644
|
path,
|
|
@@ -7498,24 +7669,31 @@ function specTierPlans(topologies, args, variant, { warn = (line) => process.std
|
|
|
7498
7669
|
// fail for the wrong reason. A later funnel's checkout with declarations
|
|
7499
7670
|
// but no resolvable URL cannot be driven; it is warned about, not dropped
|
|
7500
7671
|
// silently.
|
|
7501
|
-
const primary =
|
|
7672
|
+
const primary = findPaymentPage(topologies);
|
|
7502
7673
|
if (!primary) {
|
|
7503
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).");
|
|
7504
7675
|
}
|
|
7505
7676
|
const plans = [];
|
|
7506
|
-
|
|
7507
|
-
|
|
7508
|
-
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
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)))];
|
|
7512
7688
|
if (bumps.length) {
|
|
7513
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.`);
|
|
7514
7690
|
}
|
|
7515
7691
|
for (const tier of declaredTiers) declaredIdentities.add(selectorTierIdentity(tier));
|
|
7516
7692
|
for (const ref of bumps) bumpRefs.add(ref);
|
|
7517
7693
|
const tiers = narrowTo ? declaredTiers.filter((tier) => narrowTo.has(selectorTierIdentity(tier))) : declaredTiers;
|
|
7518
|
-
const coupons =
|
|
7694
|
+
const coupons = mergeDeclaredCoupons(pathPages
|
|
7695
|
+
.filter((page) => page === checkoutPage || isCheckoutStepTopologyPage(page))
|
|
7696
|
+
.map((page) => declaredCheckoutCoupons(page)));
|
|
7519
7697
|
if (!tiers.length && !coupons.length) continue;
|
|
7520
7698
|
if (checkoutPage !== primary && !checkoutPage.url) {
|
|
7521
7699
|
if (narrowTo && tiers.length) {
|
|
@@ -7637,6 +7815,28 @@ function selectorTierNarrowing(value) {
|
|
|
7637
7815
|
// Declared coupons follow the repo's offer-surface rule (build-brief, cli
|
|
7638
7816
|
// exit-pop gates): a surface counts only when `enabled === true` and it maps
|
|
7639
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
|
+
|
|
7640
7840
|
function declaredCheckoutCoupons(checkoutPage) {
|
|
7641
7841
|
const coupons = [];
|
|
7642
7842
|
const add = (surface, block) => {
|
|
@@ -7966,7 +8166,7 @@ function describeCoverageDoubt(doubt) {
|
|
|
7966
8166
|
|
|
7967
8167
|
// Page types that never carry an upsell action. Any other type, or none, does
|
|
7968
8168
|
// not say either way.
|
|
7969
|
-
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"]);
|
|
7970
8170
|
const UPSELL_COVERAGE_UNASSESSABLE = Object.freeze({
|
|
7971
8171
|
no_url: "no URL",
|
|
7972
8172
|
unresolvable_url: "URL is not an absolute http(s) address",
|
|
@@ -7989,7 +8189,7 @@ export function upsellActionCoverageWithoutOrders(topologies = []) {
|
|
|
7989
8189
|
return upsellActionCoverageAssertion({
|
|
7990
8190
|
topologies,
|
|
7991
8191
|
orders: [],
|
|
7992
|
-
checkoutPage:
|
|
8192
|
+
checkoutPage: findPaymentPage(topologies),
|
|
7993
8193
|
testOrdersOff: true,
|
|
7994
8194
|
});
|
|
7995
8195
|
}
|
|
@@ -8452,14 +8652,6 @@ function persistedUpsellStep(step) {
|
|
|
8452
8652
|
return projected;
|
|
8453
8653
|
}
|
|
8454
8654
|
|
|
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
8655
|
function parseCart(value) {
|
|
8464
8656
|
if (!value) return [];
|
|
8465
8657
|
return String(value).split(",").map((part) => {
|
package/src/qa-cart-entry.mjs
CHANGED
|
@@ -127,6 +127,17 @@ export function cartEntryHrefFor(element, { cartEntrySelector, cartEntryRouteAtt
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
// A select page (campaigns-os#641) has no add-to-cart control: its swap-mode
|
|
131
|
+
// bundle selector writes the cart when a card is chosen, the SDK persists the
|
|
132
|
+
// cart between pages, and a checkout button or link moves on. The runner
|
|
133
|
+
// treats "bundle cards plus one of these" as a cart-entry control: the SDK's
|
|
134
|
+
// checkout action, or a plain link into the next page. Matched by this one
|
|
135
|
+
// selector so the chosen control replays with nth().
|
|
136
|
+
export const CART_ENTRY_CHECKOUT_BUTTON_SELECTOR = '[data-next-action="checkout"], a[href]';
|
|
137
|
+
|
|
138
|
+
// The bundle cards a select page renders, outside the cart summary.
|
|
139
|
+
export const CART_ENTRY_BUNDLE_CARD_SELECTOR = "[data-next-bundle-card]";
|
|
140
|
+
|
|
130
141
|
// Route-shaped spellings the runner used to consult and no longer does,
|
|
131
142
|
// because the SDK never declares them. They are not routes, but a page that
|
|
132
143
|
// carries one is reported so an operator can tell "the runner narrowed its
|
|
@@ -138,8 +149,10 @@ export const UNDECLARED_ROUTE_ATTRIBUTES = Object.freeze(["data-next-href", "dat
|
|
|
138
149
|
// shop-single-step landing declares; the rest are the entry-like types
|
|
139
150
|
// `deriveEntryUrls` already recognises.
|
|
140
151
|
// Pages that can only follow the checkout: the checkout itself plus the
|
|
141
|
-
// topology module's own offer and receipt sets, so the two never drift.
|
|
142
|
-
|
|
152
|
+
// topology module's own offer and receipt sets, so the two never drift. A
|
|
153
|
+
// checkout_step is never a cart entry either: it is a form page of the
|
|
154
|
+
// checkout path itself (campaigns-os#641).
|
|
155
|
+
const POST_CHECKOUT_PAGE_TYPES = new Set(["checkout", "checkout_step", ...OFFER_PAGE_TYPES, ...RECEIPT_PAGE_TYPES]);
|
|
143
156
|
const PREFERRED_ENTRY_PAGE_TYPES = ["select", "landing", "product", "presell", "entry", "lander", "advertorial", "listicle", "review", "opt-in", "optin"];
|
|
144
157
|
|
|
145
158
|
export function codedError(code, message) {
|
|
@@ -234,18 +247,22 @@ export function summarizeSelectionSurface(surface) {
|
|
|
234
247
|
return parts.length ? parts.join(", ") : "none";
|
|
235
248
|
}
|
|
236
249
|
|
|
237
|
-
// evaluate() body: the cart-entry controls the entry page renders.
|
|
238
|
-
// add_to_cart
|
|
239
|
-
//
|
|
240
|
-
// checkout_link
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
250
|
+
// evaluate() body: the cart-entry controls the entry page renders. Three kinds:
|
|
251
|
+
// add_to_cart the SDK action controls (CART_ENTRY_CONTROL_SELECTOR); the
|
|
252
|
+
// SDK adds the package and navigates via data-next-url;
|
|
253
|
+
// checkout_link a plain link into the checkout URL carrying
|
|
254
|
+
// `?forcePackageId=`, which the SDK reads on the checkout
|
|
255
|
+
// page to pre-load the cart. This is what the certified
|
|
256
|
+
// shop-single-step landing renders today;
|
|
257
|
+
// checkout_button on a page with bundle cards only: the SDK checkout action
|
|
258
|
+
// or a plain link into the checkout URL. The selected card
|
|
259
|
+
// is the cart; `bundle_package_ids` lists what the cards
|
|
260
|
+
// carry so --select-package can name one (campaigns-os#641).
|
|
244
261
|
// Each control reports its visibility, text, and the package id it carries
|
|
245
262
|
// (own attribute, nearest card, or the forcePackageId ref), so the caller can
|
|
246
263
|
// prefer a visible SDK control and honour --select-package in one round trip.
|
|
247
264
|
export function cartEntryControlsScript() {
|
|
248
|
-
return ({ selector, checkoutUrl }) => {
|
|
265
|
+
return ({ selector, checkoutUrl, checkoutButtonSelector = null, bundleCardSelector = null }) => {
|
|
249
266
|
const clean = (value) => String(value || "").replace(/\s+/g, " ").trim();
|
|
250
267
|
const isVisible = (element) => {
|
|
251
268
|
const rect = element.getBoundingClientRect();
|
|
@@ -313,7 +330,39 @@ export function cartEntryControlsScript() {
|
|
|
313
330
|
}];
|
|
314
331
|
})
|
|
315
332
|
: [];
|
|
316
|
-
|
|
333
|
+
const cards = Array.from(document.querySelectorAll(bundleCardSelector || "[data-next-bundle-card]"))
|
|
334
|
+
.filter((card) => !card.closest("[data-next-cart-summary], [data-next-upsell-context], [data-next-upsell], template"));
|
|
335
|
+
const bundlePackageIds = [...new Set(cards.flatMap((card) => {
|
|
336
|
+
const own = clean(card.getAttribute("data-next-package-id"));
|
|
337
|
+
const nested = Array.from(card.querySelectorAll("[data-next-package-id]")).map((node) => clean(node.getAttribute("data-next-package-id")));
|
|
338
|
+
return [own, ...nested].filter(Boolean);
|
|
339
|
+
}))];
|
|
340
|
+
const buttons = cards.length && checkoutButtonSelector
|
|
341
|
+
? Array.from(document.querySelectorAll(checkoutButtonSelector)).flatMap((element, index) => {
|
|
342
|
+
const isAction = element.getAttribute("data-next-action") === "checkout";
|
|
343
|
+
if (!isAction) {
|
|
344
|
+
const href = element.getAttribute("href");
|
|
345
|
+
if (!checkout || canonical(href) !== checkout) return [];
|
|
346
|
+
try {
|
|
347
|
+
if (new URL(href, document.baseURI).searchParams.get("forcePackageId")) return [];
|
|
348
|
+
} catch {
|
|
349
|
+
return [];
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return [{
|
|
353
|
+
kind: "checkout_button",
|
|
354
|
+
index,
|
|
355
|
+
visible: isVisible(element),
|
|
356
|
+
text: label(element),
|
|
357
|
+
package_id: null,
|
|
358
|
+
bundle_package_ids: bundlePackageIds,
|
|
359
|
+
quantity: 1,
|
|
360
|
+
next_url: isAction ? null : element.getAttribute("href"),
|
|
361
|
+
tag: element.tagName.toLowerCase(),
|
|
362
|
+
}];
|
|
363
|
+
})
|
|
364
|
+
: [];
|
|
365
|
+
return [...actions, ...links, ...buttons];
|
|
317
366
|
};
|
|
318
367
|
}
|
|
319
368
|
|
|
@@ -323,7 +372,7 @@ export function cartEntryControlsScript() {
|
|
|
323
372
|
// control is only used when nothing is visible.
|
|
324
373
|
export function chooseCartEntryControl(controls = [], requested = []) {
|
|
325
374
|
const list = Array.isArray(controls) ? controls : [];
|
|
326
|
-
if (!list.length) return { control: null, reason: "no add-to-cart control
|
|
375
|
+
if (!list.length) return { control: null, reason: "no add-to-cart control, forcePackageId checkout link, or bundle cards with a checkout button rendered on the entry page" };
|
|
327
376
|
const wanted = Array.isArray(requested) ? requested.filter((item) => item?.packageId) : [];
|
|
328
377
|
if (wanted.length > 1) {
|
|
329
378
|
return {
|
|
@@ -331,9 +380,24 @@ export function chooseCartEntryControl(controls = [], requested = []) {
|
|
|
331
380
|
reason: `--select-package requested ${wanted.length} refs, but a landing-page entry can select at most one package before the SDK navigates to checkout`,
|
|
332
381
|
};
|
|
333
382
|
}
|
|
383
|
+
// A select page's checkout button carries no package of its own: the
|
|
384
|
+
// requested package is the bundle card the runner clicks before it. Used
|
|
385
|
+
// only when no add-to-cart control or forcePackageId link carries the ref.
|
|
386
|
+
const buttons = list.filter((control) => control.kind === "checkout_button");
|
|
387
|
+
const carriers = list.filter((control) => control.kind !== "checkout_button");
|
|
334
388
|
const byRef = wanted.length
|
|
335
|
-
?
|
|
336
|
-
:
|
|
389
|
+
? carriers.filter((control) => String(control.package_id) === String(wanted[0].packageId))
|
|
390
|
+
: carriers;
|
|
391
|
+
if (!byRef.length && buttons.length) {
|
|
392
|
+
const pick = (pool) => pool.find((control) => control.visible) || pool[0];
|
|
393
|
+
if (!wanted.length) return { control: pick(buttons), reason: null };
|
|
394
|
+
const withCard = buttons.filter((control) => (control.bundle_package_ids || []).map(String).includes(String(wanted[0].packageId)));
|
|
395
|
+
if (withCard.length) return { control: pick(withCard), select_card: wanted[0], reason: null };
|
|
396
|
+
return {
|
|
397
|
+
control: null,
|
|
398
|
+
reason: `--select-package ${wanted[0].packageId}: no bundle card on the entry page carries package ${wanted[0].packageId} (rendered: ${[...new Set(buttons.flatMap((control) => control.bundle_package_ids || []))].join(", ") || "(none)"})`,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
337
401
|
if (!byRef.length) {
|
|
338
402
|
return {
|
|
339
403
|
control: null,
|