@nextcommerce/campaigns-os 1.43.2 → 1.47.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/AGENTS.md +5 -0
- package/CHANGELOG.md +798 -5103
- package/README.md +33 -12
- package/agents/claude/CLAUDE.md +1 -1
- package/agents/codex/AGENTS.md +1 -1
- package/agents/copilot/copilot-instructions.md +1 -1
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
- package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
- package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
- package/campaign-spec/dist/types.d.ts +2 -2
- package/compatibility.json +1 -1
- package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
- package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
- package/contracts/commerce-surface-catalog.json +1204 -129
- package/contracts/effects.v1.json +1179 -116
- package/contracts/orientation-reason-codes.v1.json +7 -0
- package/contracts/release-ledger.json +2515 -5919
- package/contracts/supported-surface.json +7 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +3 -3
- package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
- package/docs/brand-theme-bridge.md +81 -0
- package/docs/build-packet.md +180 -23
- package/docs/campaigns-os-build-flow.md +3 -3
- package/docs/design-source-package.md +73 -0
- package/docs/effects.md +50 -8
- package/docs/gateway-login.md +3 -0
- package/docs/local-setup.md +7 -4
- package/docs/orientation-contract-reference.md +42 -2
- package/docs/polish-evidence.md +74 -0
- package/docs/qa-and-test-orders.md +118 -14
- package/docs/release-ledger-authoring-guide.md +64 -4
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +2 -2
- package/docs/versioning.md +4 -1
- package/package.json +1 -1
- package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +7 -6
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +7 -6
- package/skills/next-campaigns-os/SKILL.md +7 -7
- package/skills/next-campaigns-os/references/session-intake.md +9 -3
- package/skills/next-campaigns-os-setup/SKILL.md +5 -5
- package/skills/next-campaigns-polish/SKILL.md +28 -9
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills.json +10 -10
- package/src/brand-theme.mjs +320 -20
- package/src/built-script-syntax.mjs +116 -15
- package/src/built-site-scope.mjs +16 -4
- package/src/cli.mjs +280 -46
- package/src/commercial-parity.mjs +48 -2
- package/src/deviation.mjs +13 -1
- package/src/diagnostic.mjs +6 -2
- package/src/doctor/checks.mjs +319 -81
- package/src/doctor/inspect.mjs +55 -13
- package/src/doctor/source-provenance.mjs +184 -0
- package/src/invocation.mjs +4 -0
- package/src/live-campaign-refs.mjs +466 -0
- package/src/login.mjs +2 -2
- package/src/page-kit-store-profile.mjs +69 -12
- package/src/page-kit-sync.mjs +31 -12
- package/src/progress-node.mjs +3 -1
- package/src/qa-analytics-parity.mjs +37 -2
- package/src/qa-binding-evidence.mjs +4 -2
- package/src/qa-browser.mjs +612 -40
- package/src/qa-commercial-parity.mjs +48 -5
- package/src/qa-node.mjs +122 -7
- package/src/qa-test-order-topology.mjs +148 -0
- package/src/sdk-markup.mjs +32 -7
- package/src/sdk-storage-compatibility.mjs +3 -2
- package/src/source-html-intake.mjs +116 -0
- package/src/stage-record.mjs +551 -0
- package/src/tooling-setup.mjs +9 -0
- package/src/upsell-selector-scope.mjs +112 -2
package/src/qa-browser.mjs
CHANGED
|
@@ -16,7 +16,9 @@ import { redactUrlQuery } from "./qa-url-privacy.mjs";
|
|
|
16
16
|
import {
|
|
17
17
|
canonicalHttpUrl,
|
|
18
18
|
commonTestOrderPaths,
|
|
19
|
+
commonTestOrderPlan,
|
|
19
20
|
fullTestOrderPaths,
|
|
21
|
+
OFFER_PAGE_TYPES,
|
|
20
22
|
pageAtUrl,
|
|
21
23
|
remainingActionDisposition,
|
|
22
24
|
resolveTestOrderTopology,
|
|
@@ -137,6 +139,7 @@ export async function runBrowserChecks(topologies, args = {}, options = {}) {
|
|
|
137
139
|
export async function runBrowserTestOrders(topologies, args = {}, runId = "local", options = {}) {
|
|
138
140
|
const checkoutPage = findPage(topologies, "checkout");
|
|
139
141
|
if (!checkoutPage?.url) {
|
|
142
|
+
const coverage = upsellActionCoverageAssertion({ topologies, orders: [], checkoutPage });
|
|
140
143
|
return {
|
|
141
144
|
orders: [],
|
|
142
145
|
receiptAnalytics: { plannedPlanIds: [], attempts: [] },
|
|
@@ -149,7 +152,7 @@ export async function runBrowserTestOrders(topologies, args = {}, runId = "local
|
|
|
149
152
|
severity: SEVERITY.BLOCKER,
|
|
150
153
|
expected: "checkout page URL",
|
|
151
154
|
actual: "missing",
|
|
152
|
-
})],
|
|
155
|
+
}), ...(coverage ? [coverage] : [])],
|
|
153
156
|
};
|
|
154
157
|
}
|
|
155
158
|
|
|
@@ -158,6 +161,8 @@ export async function runBrowserTestOrders(topologies, args = {}, runId = "local
|
|
|
158
161
|
// discover that the operator needs to raise --max-test-orders.
|
|
159
162
|
const plans = testOrderPlans(args["test-order"], topologies, args);
|
|
160
163
|
enforceTestOrderLimit(plans, args);
|
|
164
|
+
const orderPathPlan = isCommonTestOrderMode(args["test-order"]) ? commonOrderPathPlan(topologies, args) : null;
|
|
165
|
+
if (orderPathPlan) (options.warn || ((line) => process.stderr.write(`${line}\n`)))(describeCommonOrderPathPlan(orderPathPlan));
|
|
161
166
|
const creationBudget = createOrderCreationBudget({ plans, args });
|
|
162
167
|
|
|
163
168
|
const browser = await launchChromium(args);
|
|
@@ -175,7 +180,7 @@ export async function runBrowserTestOrders(topologies, args = {}, runId = "local
|
|
|
175
180
|
runId,
|
|
176
181
|
// The topologies travel with the options so each attempt can resolve the
|
|
177
182
|
// funnel's cart-entry page for the checkout it drives (campaigns-os#206).
|
|
178
|
-
options: { ...options, creationBudget, topologies },
|
|
183
|
+
options: { ...options, creationBudget, topologies, orderPathPlan },
|
|
179
184
|
});
|
|
180
185
|
return {
|
|
181
186
|
orders: dispatched.orders,
|
|
@@ -215,6 +220,9 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
215
220
|
|
|
216
221
|
const assertions = [];
|
|
217
222
|
const orders = [];
|
|
223
|
+
// The plan behind each entry in `orders`, index for index, so the upsell
|
|
224
|
+
// coverage row can tell which funnel an order ran through.
|
|
225
|
+
const orderPlans = [];
|
|
218
226
|
try {
|
|
219
227
|
for (let index = 0; index < plans.length; index += 1) {
|
|
220
228
|
const plan = plans[index];
|
|
@@ -224,6 +232,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
224
232
|
const pageForPlan = (typeof plan === "object" && plan?.checkout_page?.url) ? plan.checkout_page : checkoutPage;
|
|
225
233
|
const firstAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
|
|
226
234
|
orders.push(firstAttempt.order);
|
|
235
|
+
orderPlans.push(plan);
|
|
227
236
|
// Every attempt this path actually SUBMITTED, in order. The confirmed
|
|
228
237
|
// creation count is read from these and never from the deciding result
|
|
229
238
|
// alone: a recovered result is derived from the first attempt, so
|
|
@@ -281,7 +290,10 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
281
290
|
try {
|
|
282
291
|
const rerunAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
|
|
283
292
|
attemptsForPlan.push(rerunAttempt);
|
|
284
|
-
if (rerunAttempt.order)
|
|
293
|
+
if (rerunAttempt.order) {
|
|
294
|
+
orders.push(rerunAttempt.order);
|
|
295
|
+
orderPlans.push(plan);
|
|
296
|
+
}
|
|
285
297
|
if (rerunAttempt.budget_exhausted) {
|
|
286
298
|
// The re-run stopped itself before its submit click, so it
|
|
287
299
|
// proved nothing. Letting it decide would erase this path's
|
|
@@ -389,6 +401,15 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
389
401
|
evidence: { planned_paths: plans.map((plan) => planId(plan)), completed_paths: orders.map((order) => order?.plan_id || order?.path) },
|
|
390
402
|
}));
|
|
391
403
|
}
|
|
404
|
+
const coverage = upsellActionCoverageAssertion({
|
|
405
|
+
topologies: options.topologies || [],
|
|
406
|
+
plans,
|
|
407
|
+
orders,
|
|
408
|
+
orderPlans,
|
|
409
|
+
checkoutPage,
|
|
410
|
+
orderPathPlan: options.orderPathPlan || null,
|
|
411
|
+
});
|
|
412
|
+
if (coverage) assertions.push(coverage);
|
|
392
413
|
|
|
393
414
|
return { orders, assertions, receiptAnalytics, journeyAnalytics, creationBudget };
|
|
394
415
|
}
|
|
@@ -407,11 +428,11 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
407
428
|
// with --analytics-baseline's legacy receipt, and identity resolution cannot
|
|
408
429
|
// derive a receipt page yet (receipt-aware capture is out of packet 01's
|
|
409
430
|
// scope). Absent that override, the candidate IS the resolved target.
|
|
410
|
-
function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl, capturePage = null, rootFallback = null }) {
|
|
431
|
+
function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl, capturePage = null, rootFallback = null, candidatePage = null }) {
|
|
411
432
|
const baselinePublicUrl = redactUrlQuery(baselineUrl);
|
|
412
433
|
const candidatePublicUrl = redactUrlQuery(candidateUrl);
|
|
413
434
|
const analyticsPage = { page_id: "analytics", url: candidatePublicUrl || baselinePublicUrl || undefined };
|
|
414
|
-
const assertions = diffAnalyticsParity(baseline, candidate, { url: candidatePublicUrl });
|
|
435
|
+
const assertions = diffAnalyticsParity(baseline, candidate, { url: candidatePublicUrl, ...(candidatePage ? { candidatePage } : {}) });
|
|
415
436
|
assertions.unshift(assertion({
|
|
416
437
|
id: "analytics-parity:capture",
|
|
417
438
|
family: "analytics-parity",
|
|
@@ -519,9 +540,24 @@ async function captureAnalyticsParityInContext(context, baselineUrl, targetUrl,
|
|
|
519
540
|
candidateUrl: selected.url,
|
|
520
541
|
capturePage: selected.capturePage,
|
|
521
542
|
rootFallback: selected.rootFallback,
|
|
543
|
+
candidatePage: automaticParityCandidatePage(selected),
|
|
522
544
|
});
|
|
523
545
|
}
|
|
524
546
|
|
|
547
|
+
// #512: what the automatic candidate is, for the Purchase checks. The campaign
|
|
548
|
+
// root is never a receipt; a built entry is one only when its topology page
|
|
549
|
+
// type says so. A receipt loaded without an order fires no Purchase, so the
|
|
550
|
+
// leg does not go looking for the topology's receipt step: the operator pairs
|
|
551
|
+
// receipts with --analytics-candidate.
|
|
552
|
+
function automaticParityCandidatePage(selected) {
|
|
553
|
+
const pageType = selected.pageType || null;
|
|
554
|
+
return {
|
|
555
|
+
receipt: contractPageType({ page_type: pageType }) === "receipt",
|
|
556
|
+
source: selected.capturePage?.source || null,
|
|
557
|
+
page_type: pageType,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
525
561
|
// Analytics CORRECTNESS inventory leg: capture ONE page and assess only
|
|
526
562
|
// declared tags/pixels. Purchase is finalized later from the canonical
|
|
527
563
|
// typed-card order's recognized receipt; this inventory visit is never treated
|
|
@@ -695,6 +731,7 @@ function analyticsCaptureCandidates(url, options = {}) {
|
|
|
695
731
|
source: "built_entry",
|
|
696
732
|
page_id: entry.page_id || null,
|
|
697
733
|
funnel_id: entry.funnel_id || null,
|
|
734
|
+
page_type: entry.page_type || null,
|
|
698
735
|
// The query value is redacted from every record; this says the entry
|
|
699
736
|
// was told apart from the root by its query alone.
|
|
700
737
|
...(url && isQueryRoutedFrom(trim(entry.url), url) ? { query_routed: true } : {}),
|
|
@@ -740,6 +777,7 @@ async function captureFirstAnsweringAnalyticsPage(context, url, args, extraHosts
|
|
|
740
777
|
...queryRouted(candidate),
|
|
741
778
|
http_status: httpStatus ?? null,
|
|
742
779
|
},
|
|
780
|
+
pageType: candidate.page_type || null,
|
|
743
781
|
rootFallback: usedFallback ? rootFallback : null,
|
|
744
782
|
};
|
|
745
783
|
}
|
|
@@ -912,7 +950,9 @@ async function runPageBrowserChecks(context, page, args, options = {}) {
|
|
|
912
950
|
const pageErrors = [];
|
|
913
951
|
const failedRequests = [];
|
|
914
952
|
browserPage.on("console", (message) => {
|
|
915
|
-
|
|
953
|
+
// The location URL is kept beside the text: a "Failed to load resource"
|
|
954
|
+
// message names its status but not the request, which is the location.
|
|
955
|
+
if (message.type() === "error") consoleErrors.push({ text: trim(message.text()), url: message.location?.()?.url || null });
|
|
916
956
|
});
|
|
917
957
|
browserPage.on("pageerror", (error) => pageErrors.push(trim(error.message)));
|
|
918
958
|
browserPage.on("requestfailed", (request) => {
|
|
@@ -1019,16 +1059,65 @@ function isIgnorableFailedRequest(request) {
|
|
|
1019
1059
|
}
|
|
1020
1060
|
}
|
|
1021
1061
|
|
|
1062
|
+
// `messages` are { text, url } records (url: the console message's location);
|
|
1063
|
+
// the result is the actionable messages' text.
|
|
1022
1064
|
async function actionableRuntimeConsoleErrors(browserPage, messages) {
|
|
1023
1065
|
if (!messages.length) return [];
|
|
1024
1066
|
const runtimeReady = await browserPage.evaluate(() => (
|
|
1025
1067
|
document.documentElement.classList.contains("next-display-ready")
|
|
1026
1068
|
|| Boolean(window.next && Object.keys(window.next).length)
|
|
1027
1069
|
)).catch(() => false);
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1070
|
+
// The page's final URL, after redirects: the host the browser actually
|
|
1071
|
+
// shows, not the host the page record requested.
|
|
1072
|
+
const pageUrl = browserPage.url();
|
|
1073
|
+
return messages
|
|
1074
|
+
.filter((message) => {
|
|
1075
|
+
if (runtimeReady && isKnownSdkLoaderFalsePositive(message.text)) return false;
|
|
1076
|
+
if (isNetlifyPreviewDrawerConsoleError(pageUrl, message)) return false;
|
|
1077
|
+
return true;
|
|
1078
|
+
})
|
|
1079
|
+
.map((message) => message.text);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Netlify injects its deploy-preview drawer (the collaboration toolbar) into
|
|
1083
|
+
// preview pages, and a failed drawer request is logged as a "Failed to load
|
|
1084
|
+
// resource" console error on every page (a 428 has been observed). That error
|
|
1085
|
+
// is Netlify's, not the campaign's.
|
|
1086
|
+
//
|
|
1087
|
+
// Each exempt request is an exact host plus an exact path:
|
|
1088
|
+
// netlify-cdp-loader.netlify.app /netlify.js — the drawer's loader script,
|
|
1089
|
+
// the one `<script src>` Netlify injects into preview HTML.
|
|
1090
|
+
// It is exempt only on a page whose final URL (after redirects) is a Netlify
|
|
1091
|
+
// preview host: any *.netlify.app host, or a deploy-preview subdomain on a
|
|
1092
|
+
// custom domain (deploy-preview-7.shop.example.com, or
|
|
1093
|
+
// deploy-preview-7--shop.example.com). Nothing else is exempt: any other path
|
|
1094
|
+
// on a Netlify host (app.netlify.com included), and the drawer's own URL on any
|
|
1095
|
+
// other page host, still counts. A "Failed to load resource" message names its
|
|
1096
|
+
// status but not the request; the request is the message's location URL.
|
|
1097
|
+
const NETLIFY_PREVIEW_DRAWER_REQUESTS = Object.freeze([
|
|
1098
|
+
{ hostname: "netlify-cdp-loader.netlify.app", pathname: "/netlify.js" },
|
|
1099
|
+
]);
|
|
1100
|
+
|
|
1101
|
+
// The first label of a deploy-preview host: `deploy-preview-<n>`, or
|
|
1102
|
+
// `deploy-preview-<n>--<site>` for a per-deploy host on a custom domain.
|
|
1103
|
+
// Only numeric ids match, deliberately: Netlify issues numeric deploy-preview
|
|
1104
|
+
// ids, and widening would hide real errors on any host named deploy-preview-*.
|
|
1105
|
+
const DEPLOY_PREVIEW_LABEL = /^deploy-preview-\d+(?:--[a-z0-9-]+)?$/;
|
|
1106
|
+
|
|
1107
|
+
function isNetlifyPreviewHost(hostname) {
|
|
1108
|
+
const labels = hostname.split(".");
|
|
1109
|
+
return hostname.endsWith(".netlify.app") || (labels.length > 2 && DEPLOY_PREVIEW_LABEL.test(labels[0]));
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function isNetlifyPreviewDrawerConsoleError(pageUrl, message) {
|
|
1113
|
+
if (!/^Failed to load resource\b/.test(String(message?.text || ""))) return false;
|
|
1114
|
+
try {
|
|
1115
|
+
const request = new URL(message.url);
|
|
1116
|
+
return isNetlifyPreviewHost(new URL(pageUrl).hostname)
|
|
1117
|
+
&& NETLIFY_PREVIEW_DRAWER_REQUESTS.some((drawer) => drawer.hostname === request.hostname && drawer.pathname === request.pathname);
|
|
1118
|
+
} catch {
|
|
1119
|
+
return false;
|
|
1120
|
+
}
|
|
1032
1121
|
}
|
|
1033
1122
|
|
|
1034
1123
|
function isKnownSdkLoaderFalsePositive(message) {
|
|
@@ -1486,13 +1575,126 @@ async function checkoutCommerceStructureAssertions(browserPage, page) {
|
|
|
1486
1575
|
}
|
|
1487
1576
|
|
|
1488
1577
|
const evidence = await inspectCommerceStructure(browserPage, contract);
|
|
1578
|
+
const behaviour = await inspectCheckoutBehaviour(browserPage);
|
|
1489
1579
|
return [commerceStructureAssertionFromEvidence(page, {
|
|
1490
1580
|
template_family: family,
|
|
1491
1581
|
contract_status: contractStatus,
|
|
1492
1582
|
...evidence,
|
|
1583
|
+
behaviour,
|
|
1493
1584
|
})];
|
|
1494
1585
|
}
|
|
1495
1586
|
|
|
1587
|
+
// The checkout's wrapper and page composition belong to the campaign source,
|
|
1588
|
+
// not the template family (#532). A catalog rule is family shell when every
|
|
1589
|
+
// selector it reads is on this list: the layout wrapper and column classes the
|
|
1590
|
+
// pinned catalog uses, and the family include's shipping field row marker.
|
|
1591
|
+
// Every other selector is SDK wiring the checkout needs to work
|
|
1592
|
+
// (`[data-next-checkout]`, `[os-checkout-payment]`, `[data-next-cart-summary]`,
|
|
1593
|
+
// `[data-next-bundle-slots-for]`) and is never relaxed. That includes any
|
|
1594
|
+
// selector not listed here, even a bare class such as the hosted payment
|
|
1595
|
+
// field's `.input-flds`, so a catalog recorded in the packet cannot widen it.
|
|
1596
|
+
const FAMILY_SHELL_SELECTORS = Object.freeze([
|
|
1597
|
+
".checkout-wrapper",
|
|
1598
|
+
".checkout-layout__left",
|
|
1599
|
+
".checkout-layout__right",
|
|
1600
|
+
".checkout__layout",
|
|
1601
|
+
".checkout__column--left",
|
|
1602
|
+
".checkout__column--right",
|
|
1603
|
+
'[data-next-component="shipping-field-row"]',
|
|
1604
|
+
]);
|
|
1605
|
+
|
|
1606
|
+
function isFamilyShellSelector(selector) {
|
|
1607
|
+
return FAMILY_SHELL_SELECTORS.includes(String(selector || "").trim());
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function isFamilyShellCheck(check) {
|
|
1611
|
+
const selectors = Array.isArray(check?.selectors) ? check.selectors : [];
|
|
1612
|
+
return selectors.length > 0 && selectors.every(isFamilyShellSelector);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// What a checkout has to do, whoever composed it. The required fields are the
|
|
1616
|
+
// contact and shipping fields the test-order form fill types without the
|
|
1617
|
+
// optional flag (fillCheckoutFields): each must be a form control carrying
|
|
1618
|
+
// data-next-checkout-field inside a <form data-next-checkout="form">, and not a
|
|
1619
|
+
// type="hidden" input or a disabled control (its own attribute or a disabled
|
|
1620
|
+
// fieldset), since a customer cannot fill either. Visibility is not required:
|
|
1621
|
+
// progressive-reveal checkouts hide the address fields until a country is
|
|
1622
|
+
// chosen. The total is the cart-summary total the order-total parity check
|
|
1623
|
+
// reads.
|
|
1624
|
+
const CHECKOUT_FORM_SELECTOR = 'form[data-next-checkout="form"]';
|
|
1625
|
+
const CHECKOUT_BEHAVIOUR_REQUIRED_FIELDS = Object.freeze([
|
|
1626
|
+
"email",
|
|
1627
|
+
"fname",
|
|
1628
|
+
"lname",
|
|
1629
|
+
"country",
|
|
1630
|
+
"address1",
|
|
1631
|
+
"city",
|
|
1632
|
+
"province",
|
|
1633
|
+
"postal",
|
|
1634
|
+
]);
|
|
1635
|
+
|
|
1636
|
+
function checkoutBehaviourProbeInput() {
|
|
1637
|
+
return {
|
|
1638
|
+
formSelector: CHECKOUT_FORM_SELECTOR,
|
|
1639
|
+
requiredFields: [...CHECKOUT_BEHAVIOUR_REQUIRED_FIELDS],
|
|
1640
|
+
totalSelectors: checkoutTotalSelectors(),
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
async function inspectCheckoutBehaviour(browserPage) {
|
|
1645
|
+
return browserPage.evaluate((input) => {
|
|
1646
|
+
const visible = (element) => {
|
|
1647
|
+
const rect = element.getBoundingClientRect();
|
|
1648
|
+
const style = getComputedStyle(element);
|
|
1649
|
+
return rect.width > 0
|
|
1650
|
+
&& rect.height > 0
|
|
1651
|
+
&& style.display !== "none"
|
|
1652
|
+
&& style.visibility !== "hidden"
|
|
1653
|
+
&& Number(style.opacity || "1") !== 0;
|
|
1654
|
+
};
|
|
1655
|
+
const forms = Array.from(document.querySelectorAll(input.formSelector));
|
|
1656
|
+
const controls = new Set(["INPUT", "SELECT", "TEXTAREA"]);
|
|
1657
|
+
// readonly has no effect on a select, so only an input or textarea loses it.
|
|
1658
|
+
const fillable = (field) => controls.has(field.tagName)
|
|
1659
|
+
&& !(field.tagName === "INPUT" && field.type === "hidden")
|
|
1660
|
+
&& !field.matches(":disabled")
|
|
1661
|
+
&& !(field.tagName !== "SELECT" && field.hasAttribute("readonly"))
|
|
1662
|
+
&& field.getAttribute("aria-disabled") !== "true";
|
|
1663
|
+
const missing = input.requiredFields.filter((name) => !forms.some((form) => Array
|
|
1664
|
+
.from(form.querySelectorAll("[data-next-checkout-field]"))
|
|
1665
|
+
.some((field) => field.getAttribute("data-next-checkout-field") === name && fillable(field))));
|
|
1666
|
+
const totals = input.totalSelectors.flatMap((selector) => {
|
|
1667
|
+
try {
|
|
1668
|
+
return Array.from(document.querySelectorAll(selector));
|
|
1669
|
+
} catch {
|
|
1670
|
+
return [];
|
|
1671
|
+
}
|
|
1672
|
+
});
|
|
1673
|
+
const visibleTotals = totals.filter((element) => visible(element) && (element.textContent || "").trim().length > 0);
|
|
1674
|
+
const checkoutForm = { selector: input.formSelector, count: forms.length, status: forms.length ? "pass" : "fail" };
|
|
1675
|
+
const fieldsBound = {
|
|
1676
|
+
required: input.requiredFields,
|
|
1677
|
+
missing,
|
|
1678
|
+
status: forms.length && !missing.length ? "pass" : "fail",
|
|
1679
|
+
};
|
|
1680
|
+
const totalVisible = {
|
|
1681
|
+
selectors: input.totalSelectors,
|
|
1682
|
+
count: totals.length,
|
|
1683
|
+
visible_count: visibleTotals.length,
|
|
1684
|
+
status: visibleTotals.length ? "pass" : "fail",
|
|
1685
|
+
};
|
|
1686
|
+
return {
|
|
1687
|
+
status: [checkoutForm, fieldsBound, totalVisible].every((check) => check.status === "pass") ? "pass" : "fail",
|
|
1688
|
+
checkout_form: checkoutForm,
|
|
1689
|
+
fields_bound: fieldsBound,
|
|
1690
|
+
total_visible: totalVisible,
|
|
1691
|
+
};
|
|
1692
|
+
}, checkoutBehaviourProbeInput()).catch((error) => ({
|
|
1693
|
+
status: "fail",
|
|
1694
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1695
|
+
}));
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1496
1698
|
async function inspectCommerceStructure(browserPage, contract) {
|
|
1497
1699
|
const safeContract = isPlainObject(contract) ? contract : {};
|
|
1498
1700
|
const checks = await browserPage.evaluate((input) => {
|
|
@@ -1577,18 +1779,28 @@ function commerceStructureAssertionFromEvidence(page, evidence) {
|
|
|
1577
1779
|
evidence,
|
|
1578
1780
|
});
|
|
1579
1781
|
}
|
|
1580
|
-
const
|
|
1782
|
+
const classified = checks.map((check) => ({ ...check, kind: isFamilyShellCheck(check) ? "family_shell" : "sdk_wiring" }));
|
|
1783
|
+
const failed = classified.filter((check) => check.status === "fail");
|
|
1784
|
+
// A missing family-shell selector is a warning only when nothing else failed
|
|
1785
|
+
// and the behaviour probe ran and passed; absent or failing behaviour
|
|
1786
|
+
// evidence keeps the row a failure.
|
|
1787
|
+
const shellOnly = failed.length > 0 && failed.every((check) => check.kind === "family_shell");
|
|
1788
|
+
const behaviourPassed = evidence?.behaviour?.status === "pass";
|
|
1789
|
+
const relaxed = shellOnly && behaviourPassed;
|
|
1790
|
+
const missing = `${checks.length - failed.length}/${checks.length} structure check(s) passed; missing ${failed.map((check) => check.name).join(", ")}`;
|
|
1581
1791
|
return assertion({
|
|
1582
1792
|
id: `browser-commerce-structure:${page.page_id}`,
|
|
1583
1793
|
family: "browser-runtime",
|
|
1584
1794
|
page,
|
|
1585
|
-
status: failed.length ? STATUS.
|
|
1795
|
+
status: !failed.length ? STATUS.PASS : relaxed ? STATUS.WARN : STATUS.FAIL,
|
|
1586
1796
|
severity: failed.length ? SEVERITY.WARN : undefined,
|
|
1587
1797
|
expected: "rendered checkout conforms to the selected template-family commerce structure contract",
|
|
1588
|
-
actual: failed.length
|
|
1589
|
-
? `${checks.length
|
|
1590
|
-
:
|
|
1591
|
-
|
|
1798
|
+
actual: !failed.length
|
|
1799
|
+
? `${checks.length}/${checks.length} structure check(s) passed`
|
|
1800
|
+
: relaxed
|
|
1801
|
+
? `${missing}; the checkout form, bound fields and visible total pass, so the missing family shell is source-owned`
|
|
1802
|
+
: missing,
|
|
1803
|
+
evidence: { ...evidence, checks: classified },
|
|
1592
1804
|
});
|
|
1593
1805
|
}
|
|
1594
1806
|
|
|
@@ -2590,13 +2802,13 @@ async function pricingVisibilityAssertions(browserPage, page, options = {}) {
|
|
|
2590
2802
|
if (!surfaces) return [];
|
|
2591
2803
|
const pageType = contractPageType(page);
|
|
2592
2804
|
if (["upsell", "downsell"].includes(pageType)) {
|
|
2593
|
-
const selectors = surfaces.upsell?.price_row_selectors
|
|
2805
|
+
const selectors = withSdkPriceDisplaySelectors(surfaces.upsell?.price_row_selectors);
|
|
2594
2806
|
if (!selectors.length) return [];
|
|
2595
2807
|
const visibleCount = await countVisiblePriceRows(browserPage, selectors);
|
|
2596
2808
|
return [upsellPriceVisibilityAssertion({ page, selectors, visibleCount })];
|
|
2597
2809
|
}
|
|
2598
2810
|
if (pageType === "checkout") {
|
|
2599
|
-
const selectors = surfaces.checkout_bundle?.price_row_selectors
|
|
2811
|
+
const selectors = withSdkPriceDisplaySelectors(surfaces.checkout_bundle?.price_row_selectors);
|
|
2600
2812
|
// Two price surfaces satisfy this check. The contract's bundle price rows
|
|
2601
2813
|
// are one; the rendered cart-summary total is the other — the same
|
|
2602
2814
|
// selectors the order-total parity check reads at submit. A family that
|
|
@@ -2615,11 +2827,29 @@ async function pricingVisibilityAssertions(browserPage, page, options = {}) {
|
|
|
2615
2827
|
return [];
|
|
2616
2828
|
}
|
|
2617
2829
|
|
|
2830
|
+
// The SDK renders a bundle or upsell price through data-next-bundle-display as
|
|
2831
|
+
// well as data-next-display (#532). A contract surface that lists price rows
|
|
2832
|
+
// also counts that attribute; an empty list stays empty so a contract that
|
|
2833
|
+
// declares no rows keeps skipping the count.
|
|
2834
|
+
const SDK_BUNDLE_PRICE_DISPLAY_SELECTOR = "[data-next-bundle-display*='price']";
|
|
2835
|
+
|
|
2836
|
+
function withSdkPriceDisplaySelectors(selectors) {
|
|
2837
|
+
const list = Array.isArray(selectors) ? selectors : [];
|
|
2838
|
+
if (!list.length || list.includes(SDK_BUNDLE_PRICE_DISPLAY_SELECTOR)) return list;
|
|
2839
|
+
return [...list, SDK_BUNDLE_PRICE_DISPLAY_SELECTOR];
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2618
2842
|
// Visible = non-zero bounding box, display != none, visibility != hidden. This
|
|
2619
2843
|
// is what caught nothing in the dogfood run: a campaign CSS rule display:none'd
|
|
2620
2844
|
// the only price row on a full-price upsell and 48/48 checks still passed.
|
|
2845
|
+
// A bundle-display node is the price text itself, so it also needs
|
|
2846
|
+
// non-whitespace text: a sized but empty node is a price the SDK never filled.
|
|
2847
|
+
// The rule follows the node's attribute, not the selector that reached it
|
|
2848
|
+
// first, so a bundle-display node that also carries `.price-wrapper` still
|
|
2849
|
+
// needs text.
|
|
2621
2850
|
async function countVisiblePriceRows(browserPage, selectors) {
|
|
2622
|
-
return browserPage.evaluate((targets) => {
|
|
2851
|
+
return browserPage.evaluate(({ targets, bundlePriceSelector }) => {
|
|
2852
|
+
const textRequired = (element) => element.matches(bundlePriceSelector);
|
|
2623
2853
|
const visible = (element) => {
|
|
2624
2854
|
const rect = element.getBoundingClientRect();
|
|
2625
2855
|
const style = getComputedStyle(element);
|
|
@@ -2632,14 +2862,16 @@ async function countVisiblePriceRows(browserPage, selectors) {
|
|
|
2632
2862
|
for (const element of document.querySelectorAll(selector)) {
|
|
2633
2863
|
if (seen.has(element)) continue;
|
|
2634
2864
|
seen.add(element);
|
|
2635
|
-
if (visible(element))
|
|
2865
|
+
if (!visible(element)) continue;
|
|
2866
|
+
if (textRequired(element) && !(element.textContent || "").trim()) continue;
|
|
2867
|
+
count += 1;
|
|
2636
2868
|
}
|
|
2637
2869
|
} catch {
|
|
2638
2870
|
// invalid selector: contract bug surfaced elsewhere
|
|
2639
2871
|
}
|
|
2640
2872
|
}
|
|
2641
2873
|
return count;
|
|
2642
|
-
}, selectors).catch(() => 0);
|
|
2874
|
+
}, { targets: selectors, bundlePriceSelector: SDK_BUNDLE_PRICE_DISPLAY_SELECTOR }).catch(() => 0);
|
|
2643
2875
|
}
|
|
2644
2876
|
|
|
2645
2877
|
// Page-scoped like every other per-page row (`template-residue:<page>:…`,
|
|
@@ -4956,10 +5188,18 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
|
|
|
4956
5188
|
// the browser's wall time at request start), and armedAt is read before the
|
|
4957
5189
|
// click is sent, so this click's request starts at or after it. A request
|
|
4958
5190
|
// with no known start time is not excluded: nothing shows it is stale.
|
|
5191
|
+
//
|
|
5192
|
+
// A redirected POST (307/308) is one mutation across several hops, and
|
|
5193
|
+
// Playwright gives each hop its own Request. The watch matches a hop by the
|
|
5194
|
+
// request that started its chain, and passes over a hop the browser follows,
|
|
5195
|
+
// so it resolves on the chain's final response: that is the one carrying the
|
|
5196
|
+
// order. A chain with no final response is not answered (#516).
|
|
4959
5197
|
const armedAt = Date.now();
|
|
4960
5198
|
const mutationPromise = path === "accept"
|
|
4961
5199
|
? page.waitForResponse((response) => {
|
|
4962
|
-
if (
|
|
5200
|
+
if (isFollowedRedirect(response)) return false;
|
|
5201
|
+
const root = responseRequest(response);
|
|
5202
|
+
if (!root || root.method() !== "POST" || !isOrderUpsellsUrl(root.url())) return false;
|
|
4963
5203
|
const startedAt = responseRequestStartedAt(response);
|
|
4964
5204
|
return startedAt === null || startedAt >= armedAt;
|
|
4965
5205
|
}, { timeout: UPSELL_MUTATION_TIMEOUT_MS + (perpetual ? 0 : UPSELL_CLICK_TIMEOUT_MS) }).catch(() => null)
|
|
@@ -4990,9 +5230,9 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
|
|
|
4990
5230
|
// waits for a later read-back before it judges the step.
|
|
4991
5231
|
...(bodyRead ? { api_response_body_read: { timed_out: bodyRead.timed_out, waited_ms: bodyRead.waited_ms, bound_ms: bodyRead.bound_ms } } : {}),
|
|
4992
5232
|
};
|
|
4993
|
-
// The request this click made
|
|
4994
|
-
// same order-upsells URL, so a late body
|
|
4995
|
-
// answers this request.
|
|
5233
|
+
// The request this click made (the root of its redirect chain). Another
|
|
5234
|
+
// step's upsell mutation posts to the same order-upsells URL, so a late body
|
|
5235
|
+
// is this step's only when it answers this request.
|
|
4996
5236
|
const mutationRequest = mutationResponse ? responseRequest(mutationResponse) : null;
|
|
4997
5237
|
if (mutationRequest) record[REQUEST_IDENTITY] = mutationRequest;
|
|
4998
5238
|
return record;
|
|
@@ -5976,21 +6216,52 @@ function mutationRespondedAt(response) {
|
|
|
5976
6216
|
// it). Playwright hands every listener the same Request object for one
|
|
5977
6217
|
// request, and a different one for each request, even to the same URL: an
|
|
5978
6218
|
// upsell step matches its own mutation's late body by this identity (#505).
|
|
6219
|
+
//
|
|
6220
|
+
// A redirect gives each hop a new Request, so the identity is the request
|
|
6221
|
+
// that started the chain (followed back through redirectedFrom()): every hop
|
|
6222
|
+
// of one redirected POST carries the same identity, and two POSTs never do
|
|
6223
|
+
// (#516).
|
|
5979
6224
|
const REQUEST_IDENTITY = Symbol("campaigns-os.request-identity");
|
|
5980
6225
|
|
|
5981
6226
|
function responseRequest(response) {
|
|
5982
6227
|
try {
|
|
5983
|
-
return response.request()
|
|
6228
|
+
return redirectChainRoot(response.request());
|
|
5984
6229
|
} catch {
|
|
5985
6230
|
return null;
|
|
5986
6231
|
}
|
|
5987
6232
|
}
|
|
5988
6233
|
|
|
6234
|
+
// Walks back to the chain's first request. A visited set ends the walk on any
|
|
6235
|
+
// chain, however long, so every hop of one chain reports the same root.
|
|
6236
|
+
function redirectChainRoot(request) {
|
|
6237
|
+
let root = request || null;
|
|
6238
|
+
const visited = new Set();
|
|
6239
|
+
while (root && typeof root.redirectedFrom === "function" && !visited.has(root)) {
|
|
6240
|
+
visited.add(root);
|
|
6241
|
+
const previous = root.redirectedFrom();
|
|
6242
|
+
if (!previous) break;
|
|
6243
|
+
root = previous;
|
|
6244
|
+
}
|
|
6245
|
+
return root;
|
|
6246
|
+
}
|
|
6247
|
+
|
|
6248
|
+
// A 3xx hop the browser follows: it names a Location, and the chain's answer
|
|
6249
|
+
// is a later hop's response. A 3xx with no Location is a final response.
|
|
6250
|
+
function isFollowedRedirect(response) {
|
|
6251
|
+
try {
|
|
6252
|
+
const status = response.status();
|
|
6253
|
+
return status >= 300 && status < 400 && Boolean(response.headers()?.location);
|
|
6254
|
+
} catch {
|
|
6255
|
+
return false;
|
|
6256
|
+
}
|
|
6257
|
+
}
|
|
6258
|
+
|
|
5989
6259
|
// When the browser started a response's request, in epoch milliseconds (the
|
|
5990
|
-
// same clock as Date.now()), or null when Playwright does not report it.
|
|
6260
|
+
// same clock as Date.now()), or null when Playwright does not report it. For a
|
|
6261
|
+
// redirected request this is when its chain's first hop started.
|
|
5991
6262
|
function responseRequestStartedAt(response) {
|
|
5992
6263
|
try {
|
|
5993
|
-
const startTime = response
|
|
6264
|
+
const startTime = responseRequest(response).timing().startTime;
|
|
5994
6265
|
return Number.isFinite(startTime) && startTime > 0 ? startTime : null;
|
|
5995
6266
|
} catch {
|
|
5996
6267
|
return null;
|
|
@@ -6013,11 +6284,16 @@ function captureCheckoutEvents(page) {
|
|
|
6013
6284
|
// polls for it. A bound here would record a slow but successful order body
|
|
6014
6285
|
// as null for good, and the order would read as not created.
|
|
6015
6286
|
page.on("response", async (response) => {
|
|
6016
|
-
|
|
6287
|
+
// A redirected chain is logged by where it started, so its final hop is
|
|
6288
|
+
// captured even when it answers from a URL this filter would not match
|
|
6289
|
+
// (#516): the late-body search finds it by request identity.
|
|
6290
|
+
const request = responseRequest(response);
|
|
6291
|
+
let chainUrl = null;
|
|
6292
|
+
try { chainUrl = request?.url() ?? null; } catch { /* identity only */ }
|
|
6293
|
+
if (!interesting.test(response.url()) && !(chainUrl && interesting.test(chainUrl))) return;
|
|
6017
6294
|
// Taken before the body read: an entry lands in the log when its body
|
|
6018
6295
|
// finishes, so its position says nothing about when it was requested.
|
|
6019
6296
|
const requestStartedAt = responseRequestStartedAt(response);
|
|
6020
|
-
const request = responseRequest(response);
|
|
6021
6297
|
const entry = {
|
|
6022
6298
|
status: response.status(),
|
|
6023
6299
|
url: response.url(),
|
|
@@ -6313,12 +6589,13 @@ async function closeAddressAutocomplete(page) {
|
|
|
6313
6589
|
}
|
|
6314
6590
|
}
|
|
6315
6591
|
|
|
6316
|
-
function testOrderPaths(mode, topologies = []) {
|
|
6592
|
+
function testOrderPaths(mode, topologies = [], args = {}) {
|
|
6317
6593
|
const normalized = String(mode || "off").toLowerCase();
|
|
6318
6594
|
// `common` (also the bare `--test-order` flag, which parses to boolean true)
|
|
6319
|
-
// is the default
|
|
6595
|
+
// is the default: every actual terminal path when they fit under the flood
|
|
6596
|
+
// cap, otherwise the sample plus one decline path per uncovered offer page.
|
|
6320
6597
|
// `full` is the explicit opt-in for every actual terminal path.
|
|
6321
|
-
if (normalized
|
|
6598
|
+
if (isCommonTestOrderMode(normalized)) return commonOrderPathPlan(topologies, args).paths;
|
|
6322
6599
|
if (normalized === "full") return fullTestOrderPaths(resolvePrimaryTestOrderTopology(topologies));
|
|
6323
6600
|
if (normalized === "both") return ["accept", "decline"];
|
|
6324
6601
|
if (["checkout", "accept", "decline"].includes(normalized)) return [normalized];
|
|
@@ -6356,7 +6633,7 @@ function testOrderPlans(mode, topologies = [], args = {}, options = {}) {
|
|
|
6356
6633
|
const applyCoupon = stringArg(args["apply-coupon"]);
|
|
6357
6634
|
const checkoutPage = findPage(topologies, "checkout");
|
|
6358
6635
|
const topologyPlan = resolvePrimaryTestOrderTopology(topologies);
|
|
6359
|
-
return testOrderPaths(mode, topologies).map((path) => ({
|
|
6636
|
+
return testOrderPaths(mode, topologies, args).map((path) => ({
|
|
6360
6637
|
path,
|
|
6361
6638
|
select_package: selectPackage,
|
|
6362
6639
|
apply_coupon: applyCoupon,
|
|
@@ -6634,15 +6911,306 @@ function summarizeTestOrderPlan(plan) {
|
|
|
6634
6911
|
};
|
|
6635
6912
|
}
|
|
6636
6913
|
|
|
6637
|
-
// The
|
|
6638
|
-
//
|
|
6639
|
-
//
|
|
6640
|
-
//
|
|
6914
|
+
// The "common shapes" sample: checkout baseline, plus first-offer accept and
|
|
6915
|
+
// decline when the checkout enters an offer graph, plus the shortest real
|
|
6916
|
+
// receipt path when that adds coverage. 1-4 orders. `tiers:common` crosses
|
|
6917
|
+
// each tier with this sample; the operator `common` depth builds on it through
|
|
6918
|
+
// commonOrderPathPlan. Bundle/quantity and bump coverage come from `--cart`;
|
|
6641
6919
|
// every actual terminal path comes from `full`.
|
|
6642
6920
|
function testOrderCommonPaths(topologies = []) {
|
|
6643
6921
|
return commonTestOrderPaths(resolvePrimaryTestOrderTopology(topologies));
|
|
6644
6922
|
}
|
|
6645
6923
|
|
|
6924
|
+
function isCommonTestOrderMode(mode) {
|
|
6925
|
+
const normalized = String(mode ?? "off").toLowerCase();
|
|
6926
|
+
return normalized === "common" || normalized === "true";
|
|
6927
|
+
}
|
|
6928
|
+
|
|
6929
|
+
// The operator `common` depth, planned against the same cap the flood guard
|
|
6930
|
+
// enforces, so the plan that decides "full fits" is the plan that is checked.
|
|
6931
|
+
function commonOrderPathPlan(topologies = [], args = {}) {
|
|
6932
|
+
return commonTestOrderPlan(resolvePrimaryTestOrderTopology(topologies), {
|
|
6933
|
+
cap: numberArg(args["max-test-orders"], DEFAULT_MAX_TEST_ORDERS),
|
|
6934
|
+
});
|
|
6935
|
+
}
|
|
6936
|
+
|
|
6937
|
+
function describeCommonOrderPathPlan(plan) {
|
|
6938
|
+
const head = plan.effective_depth === "full"
|
|
6939
|
+
? `[qa:test-order] common runs every actual terminal path (${plan.paths.length}, at or under the cap of ${plan.cap}).`
|
|
6940
|
+
: `[qa:test-order] common runs ${plan.paths.length} path(s): the sample${plan.coverage_paths.length ? ` plus ${plan.coverage_paths.join(", ")} to click through offer declines` : ""}${plan.full_path_count ? `; full would plan ${plan.full_path_count}, above the cap of ${plan.cap}` : ""}.`;
|
|
6941
|
+
if (!plan.uncovered_pages.length) return head;
|
|
6942
|
+
const pages = plan.uncovered_pages.map((page) => `${page.page_id || "(unnamed)"}${page.reason === "unreachable" ? " (unreachable from the checkout)" : ""}`);
|
|
6943
|
+
return `${head} No planned path clicks the decline on: ${pages.join(", ")}. Use --test-order full with the --max-test-orders it names to cover them.`;
|
|
6944
|
+
}
|
|
6945
|
+
|
|
6946
|
+
// Which offer pages had their decline clicked by an order this run actually
|
|
6947
|
+
// placed (#530). The unit is the decline control: an order that reached a page,
|
|
6948
|
+
// or clicked only its accept, does not count, because a broken decline link is
|
|
6949
|
+
// the failure this row exists to surface. Read from the click records the
|
|
6950
|
+
// runner kept, never from the plan.
|
|
6951
|
+
//
|
|
6952
|
+
// The row is conservative by construction. It may be `pass` or `warn` only
|
|
6953
|
+
// when coverage is certain: every funnel in the run lists its pages, every page
|
|
6954
|
+
// is either a known non-offer type or an offer page with its own absolute URL
|
|
6955
|
+
// (no URL shared with another page), every plan belongs to exactly one of those
|
|
6956
|
+
// funnels (same checkout, same page list), and every click a placed order
|
|
6957
|
+
// recorded lands on a declared offer page of that order's own funnel. A click
|
|
6958
|
+
// credits only the funnel whose plan made it, never another funnel by URL. Any
|
|
6959
|
+
// doubt, no placed order, or `--test-order off` makes the row `manual_review`
|
|
6960
|
+
// naming the pages and the reasons. When certain, it is `warn` naming each
|
|
6961
|
+
// page whose decline no order clicked, else `pass`. No row when coverage is
|
|
6962
|
+
// certain and no funnel has an offer page, or when nothing was topologised,
|
|
6963
|
+
// planned or placed. `orderPlans` holds the plan behind each entry in
|
|
6964
|
+
// `orders`, index for index.
|
|
6965
|
+
function upsellActionCoverageAssertion({ topologies = [], plans = [], orders = [], orderPlans = [], checkoutPage = null, orderPathPlan = null, testOrdersOff = false } = {}) {
|
|
6966
|
+
const funnels = (Array.isArray(topologies) ? topologies : []).map((topology, index) => ({
|
|
6967
|
+
index,
|
|
6968
|
+
funnel_id: topology?.funnel_id || null,
|
|
6969
|
+
pages: Array.isArray(topology?.pages) ? topology.pages : null,
|
|
6970
|
+
}));
|
|
6971
|
+
const funnelName = (funnel) => funnel.funnel_id || `(unnamed funnel ${funnel.index + 1})`;
|
|
6972
|
+
// Run-level reasons coverage is not certain; per-page reasons live on the
|
|
6973
|
+
// page entries.
|
|
6974
|
+
const doubts = [];
|
|
6975
|
+
|
|
6976
|
+
// Every offer page of every funnel, and every page whose type does not rule
|
|
6977
|
+
// it out. The same declaration listed twice in one funnel is one page.
|
|
6978
|
+
const entries = [];
|
|
6979
|
+
for (const funnel of funnels) {
|
|
6980
|
+
if (!funnel.pages) {
|
|
6981
|
+
entries.push({ funnel, key: null, page_id: null, page_type: null, label: "(page list missing)", reason: "no_pages" });
|
|
6982
|
+
continue;
|
|
6983
|
+
}
|
|
6984
|
+
const rows = new Set();
|
|
6985
|
+
funnel.pages.forEach((page, index) => {
|
|
6986
|
+
const type = String(page?.page_type || "").toLowerCase().replace(/[-_]/g, "");
|
|
6987
|
+
if (NON_OFFER_PAGE_TYPES.has(type)) return;
|
|
6988
|
+
const key = canonicalHttpUrl(page?.url);
|
|
6989
|
+
const row = `${page?.page_id ?? ""}\u0000${key ?? `#${index}`}`;
|
|
6990
|
+
if (rows.has(row)) return;
|
|
6991
|
+
rows.add(row);
|
|
6992
|
+
entries.push({
|
|
6993
|
+
funnel,
|
|
6994
|
+
key,
|
|
6995
|
+
page_id: page?.page_id || null,
|
|
6996
|
+
page_type: page?.page_type || null,
|
|
6997
|
+
label: page?.page_id || `(unnamed page ${index + 1})`,
|
|
6998
|
+
reason: !OFFER_PAGE_TYPES.has(type)
|
|
6999
|
+
? "unknown_page_type"
|
|
7000
|
+
: key ? null : (typeof page?.url === "string" && page.url.trim() ? "unresolvable_url" : "no_url"),
|
|
7001
|
+
});
|
|
7002
|
+
});
|
|
7003
|
+
}
|
|
7004
|
+
const entriesByKey = new Map();
|
|
7005
|
+
for (const entry of entries) {
|
|
7006
|
+
if (!entry.key) continue;
|
|
7007
|
+
if (!entriesByKey.has(entry.key)) entriesByKey.set(entry.key, []);
|
|
7008
|
+
entriesByKey.get(entry.key).push(entry);
|
|
7009
|
+
}
|
|
7010
|
+
for (const shared of entriesByKey.values()) {
|
|
7011
|
+
if (shared.length < 2) continue;
|
|
7012
|
+
for (const entry of shared) entry.reason ||= "shared_url";
|
|
7013
|
+
}
|
|
7014
|
+
|
|
7015
|
+
// A plan belongs to a funnel only when its checkout is that funnel's own
|
|
7016
|
+
// checkout page (the same object, or failing that the one funnel whose
|
|
7017
|
+
// checkout has its URL) and the page list it planned over is that funnel's.
|
|
7018
|
+
const planOwners = new Map();
|
|
7019
|
+
const ownerOf = (plan) => {
|
|
7020
|
+
if (planOwners.has(plan)) return planOwners.get(plan);
|
|
7021
|
+
let owner = null;
|
|
7022
|
+
if (plan && typeof plan === "object") {
|
|
7023
|
+
let candidates = plan.checkout_page ? funnels.filter((funnel) => funnel.pages?.includes(plan.checkout_page)) : [];
|
|
7024
|
+
if (!candidates.length) {
|
|
7025
|
+
const checkoutKey = canonicalHttpUrl(plan.checkout_page?.url || plan.topology_plan?.checkout_url);
|
|
7026
|
+
candidates = checkoutKey
|
|
7027
|
+
? funnels.filter((funnel) => funnel.pages?.some((page) => String(page?.page_type || "").toLowerCase() === "checkout" && canonicalHttpUrl(page?.url) === checkoutKey))
|
|
7028
|
+
: [];
|
|
7029
|
+
}
|
|
7030
|
+
const [candidate] = candidates;
|
|
7031
|
+
if (candidates.length === 1 && (!plan.topology_plan || plannedOverFunnel(plan.topology_plan, candidate))) owner = candidate;
|
|
7032
|
+
}
|
|
7033
|
+
planOwners.set(plan, owner);
|
|
7034
|
+
return owner;
|
|
7035
|
+
};
|
|
7036
|
+
const unattributedPlans = [...new Set([...plans, ...orderPlans])].filter((plan) => !ownerOf(plan));
|
|
7037
|
+
if (unattributedPlans.length) {
|
|
7038
|
+
doubts.push({ reason: "unattributed_plan", plans: unattributedPlans.map((plan) => (plan && typeof plan === "object") || typeof plan === "string" ? planId(plan) : String(plan)) });
|
|
7039
|
+
}
|
|
7040
|
+
|
|
7041
|
+
const placedByFunnel = new Map();
|
|
7042
|
+
const undeclaredClicks = new Set();
|
|
7043
|
+
let unattributedClicks = 0;
|
|
7044
|
+
let placed = 0;
|
|
7045
|
+
orders.forEach((order, index) => {
|
|
7046
|
+
if (!isPlacedTestOrder(order)) return;
|
|
7047
|
+
placed += 1;
|
|
7048
|
+
const funnel = ownerOf(orderPlans[index]);
|
|
7049
|
+
if (funnel) placedByFunnel.set(funnel, (placedByFunnel.get(funnel) || 0) + 1);
|
|
7050
|
+
for (const step of Array.isArray(order.upsell_steps) ? order.upsell_steps : []) {
|
|
7051
|
+
if (step?.clicked !== true) continue;
|
|
7052
|
+
const key = canonicalHttpUrl(step?.offer_url);
|
|
7053
|
+
if (!key || !funnel) {
|
|
7054
|
+
unattributedClicks += 1;
|
|
7055
|
+
continue;
|
|
7056
|
+
}
|
|
7057
|
+
const entry = entries.find((candidate) => candidate.funnel === funnel && candidate.key === key);
|
|
7058
|
+
if (!entry) {
|
|
7059
|
+
undeclaredClicks.add(key);
|
|
7060
|
+
continue;
|
|
7061
|
+
}
|
|
7062
|
+
if (step.path === "accept") entry.accept_clicked = true;
|
|
7063
|
+
if (step.path === "decline") entry.decline_clicked = true;
|
|
7064
|
+
}
|
|
7065
|
+
});
|
|
7066
|
+
if (unattributedClicks) doubts.push({ reason: "unattributed_click", clicks: unattributedClicks });
|
|
7067
|
+
if (undeclaredClicks.size) doubts.push({ reason: "undeclared_click", urls: [...undeclaredClicks].map((key) => new URL(key).pathname) });
|
|
7068
|
+
|
|
7069
|
+
if (!funnels.length) {
|
|
7070
|
+
// No funnel topology reached this row, so its pages cannot be listed. That
|
|
7071
|
+
// is unknown, not "no offer pages".
|
|
7072
|
+
if (!(plans.length || orders.length)) return null;
|
|
7073
|
+
doubts.unshift({ reason: "no_topology" });
|
|
7074
|
+
}
|
|
7075
|
+
if (!entries.length && !doubts.length) return null;
|
|
7076
|
+
|
|
7077
|
+
const nameFunnels = funnels.length > 1;
|
|
7078
|
+
const pageName = (entry) => {
|
|
7079
|
+
const notes = [];
|
|
7080
|
+
if (nameFunnels) notes.push(`funnel ${funnelName(entry.funnel)}`);
|
|
7081
|
+
if (entry.reason) notes.push(UPSELL_COVERAGE_UNASSESSABLE[entry.reason]);
|
|
7082
|
+
return `${entry.label}${notes.length ? ` (${notes.join("; ")})` : ""}`;
|
|
7083
|
+
};
|
|
7084
|
+
const pages = entries.map((entry) => ({
|
|
7085
|
+
page_id: entry.page_id,
|
|
7086
|
+
page_type: entry.page_type,
|
|
7087
|
+
funnel_id: entry.funnel.funnel_id,
|
|
7088
|
+
accept_clicked: entry.accept_clicked === true,
|
|
7089
|
+
decline_clicked: entry.decline_clicked === true,
|
|
7090
|
+
...(entry.reason ? { assessable: false, reason: entry.reason } : {}),
|
|
7091
|
+
}));
|
|
7092
|
+
const open = entries.filter((entry) => !entry.decline_clicked || entry.reason);
|
|
7093
|
+
const unassessable = entries.filter((entry) => entry.reason);
|
|
7094
|
+
const evidence = {
|
|
7095
|
+
basis: "decline_clicked",
|
|
7096
|
+
orders_placed: placed,
|
|
7097
|
+
pages,
|
|
7098
|
+
not_clicked_through: open.map((entry) => entry.label),
|
|
7099
|
+
not_assessable: unassessable.map((entry) => ({ page_id: entry.page_id, funnel_id: entry.funnel.funnel_id, reason: entry.reason })),
|
|
7100
|
+
uncertainty: doubts,
|
|
7101
|
+
funnels: funnels.map((funnel) => ({
|
|
7102
|
+
funnel_id: funnel.funnel_id,
|
|
7103
|
+
orders_placed: placedByFunnel.get(funnel) || 0,
|
|
7104
|
+
not_clicked_through: open.filter((entry) => entry.funnel === funnel).map((entry) => entry.label),
|
|
7105
|
+
})),
|
|
7106
|
+
...(orderPathPlan ? {
|
|
7107
|
+
order_path_depth: orderPathPlan.requested_depth,
|
|
7108
|
+
order_path_depth_effective: orderPathPlan.effective_depth,
|
|
7109
|
+
order_path_depth_reason: orderPathPlan.reason,
|
|
7110
|
+
max_test_orders: orderPathPlan.cap,
|
|
7111
|
+
full_path_count: orderPathPlan.full_path_count,
|
|
7112
|
+
coverage_paths: orderPathPlan.coverage_paths,
|
|
7113
|
+
planned_uncovered_pages: orderPathPlan.uncovered_pages,
|
|
7114
|
+
} : {}),
|
|
7115
|
+
};
|
|
7116
|
+
const base = {
|
|
7117
|
+
id: "browser-test-order:upsell-action-coverage",
|
|
7118
|
+
family: "browser-test-order",
|
|
7119
|
+
page: checkoutPage || { page_id: "checkout" },
|
|
7120
|
+
expected: "every page with upsell actions has its decline clicked by at least one test order of its own funnel",
|
|
7121
|
+
};
|
|
7122
|
+
|
|
7123
|
+
// The one predicate pass and warn depend on.
|
|
7124
|
+
const certain = placed > 0 && !testOrdersOff && !doubts.length && !unassessable.length;
|
|
7125
|
+
if (!certain) {
|
|
7126
|
+
const reason = doubts[0]?.reason === "no_topology"
|
|
7127
|
+
? "no_topology"
|
|
7128
|
+
: testOrdersOff ? "test_orders_off" : !placed ? "no_order_recorded" : "not_assessable";
|
|
7129
|
+
const why = reason === "no_topology"
|
|
7130
|
+
? "no funnel topology reached the coverage check, so the pages with upsell actions could not be listed"
|
|
7131
|
+
: reason === "test_orders_off"
|
|
7132
|
+
? "browser test orders were off (--test-order off)"
|
|
7133
|
+
: reason === "no_order_recorded"
|
|
7134
|
+
? "no test order was placed"
|
|
7135
|
+
: `coverage cannot be attributed with certainty (${[
|
|
7136
|
+
...doubts.map(describeCoverageDoubt),
|
|
7137
|
+
...(unassessable.length ? [`${unassessable.length} page(s) cannot be matched to a click`] : []),
|
|
7138
|
+
].join("; ")})`;
|
|
7139
|
+
const named = open.length ? `, so ${open.length} of ${pages.length} page(s) with upsell actions are not proved clicked through: ${open.map(pageName).join(", ")}` : "";
|
|
7140
|
+
return assertion({
|
|
7141
|
+
...base,
|
|
7142
|
+
status: STATUS.MANUAL_REVIEW,
|
|
7143
|
+
severity: SEVERITY.WARN,
|
|
7144
|
+
actual: `${why}${named}`,
|
|
7145
|
+
evidence: { ...evidence, reason },
|
|
7146
|
+
});
|
|
7147
|
+
}
|
|
7148
|
+
if (open.length) {
|
|
7149
|
+
const unrun = [...new Set(open.map((entry) => entry.funnel))].filter((funnel) => !placedByFunnel.get(funnel));
|
|
7150
|
+
return assertion({
|
|
7151
|
+
...base,
|
|
7152
|
+
status: STATUS.WARN,
|
|
7153
|
+
severity: SEVERITY.WARN,
|
|
7154
|
+
actual: `no test order clicked the decline on ${open.length} of ${pages.length} page(s) with upsell actions: ${open.map(pageName).join(", ")}${unrun.length ? `. No test order ran through funnel(s) ${unrun.map(funnelName).join(", ")}` : ""}`,
|
|
7155
|
+
evidence,
|
|
7156
|
+
});
|
|
7157
|
+
}
|
|
7158
|
+
return assertion({
|
|
7159
|
+
...base,
|
|
7160
|
+
status: STATUS.PASS,
|
|
7161
|
+
actual: `decline clicked on all ${pages.length} page(s) with upsell actions`,
|
|
7162
|
+
evidence,
|
|
7163
|
+
});
|
|
7164
|
+
}
|
|
7165
|
+
|
|
7166
|
+
// True when a resolved topology plan was planned over exactly this funnel: the
|
|
7167
|
+
// same funnel id and the same page list, page for page.
|
|
7168
|
+
function plannedOverFunnel(topologyPlan, funnel) {
|
|
7169
|
+
if ((topologyPlan?.topology_id || null) !== (funnel.funnel_id || "default")) return false;
|
|
7170
|
+
const planned = Array.isArray(topologyPlan?.route_pages) ? topologyPlan.route_pages : null;
|
|
7171
|
+
if (!planned || planned.length !== funnel.pages.length) return false;
|
|
7172
|
+
return funnel.pages.every((page, index) => (page?.page_id || null) === planned[index]?.page_id
|
|
7173
|
+
&& (page?.page_type || null) === planned[index]?.page_type
|
|
7174
|
+
&& (page?.url || null) === planned[index]?.url);
|
|
7175
|
+
}
|
|
7176
|
+
|
|
7177
|
+
function describeCoverageDoubt(doubt) {
|
|
7178
|
+
if (doubt.reason === "unattributed_plan") return `order plan(s) ${doubt.plans.join(", ")} cannot be matched to exactly one funnel's checkout and page list`;
|
|
7179
|
+
if (doubt.reason === "unattributed_click") return `${doubt.clicks} recorded click(s) carry no usable page URL or come from an order with no funnel`;
|
|
7180
|
+
if (doubt.reason === "undeclared_click") return `an order clicked page(s) its funnel does not declare: ${doubt.urls.join(", ")}`;
|
|
7181
|
+
return doubt.reason;
|
|
7182
|
+
}
|
|
7183
|
+
|
|
7184
|
+
// Page types that never carry an upsell action. Any other type, or none, does
|
|
7185
|
+
// not say either way.
|
|
7186
|
+
const NON_OFFER_PAGE_TYPES = new Set(["presell", "landing", "select", "product", "checkout", "thankyou", "receipt"]);
|
|
7187
|
+
const UPSELL_COVERAGE_UNASSESSABLE = Object.freeze({
|
|
7188
|
+
no_url: "no URL",
|
|
7189
|
+
unresolvable_url: "URL is not an absolute http(s) address",
|
|
7190
|
+
unknown_page_type: "page type does not say whether it offers anything",
|
|
7191
|
+
shared_url: "URL shared with another page",
|
|
7192
|
+
no_pages: "funnel lists no pages",
|
|
7193
|
+
});
|
|
7194
|
+
|
|
7195
|
+
// An attempt counts as a placed order only when it carries the order reference
|
|
7196
|
+
// the runner read back; a failure placeholder has none.
|
|
7197
|
+
function isPlacedTestOrder(order) {
|
|
7198
|
+
if (!order || typeof order !== "object") return false;
|
|
7199
|
+
return [order.ref_id, order.next_order_id].some((value) => value != null && String(value).trim() !== "");
|
|
7200
|
+
}
|
|
7201
|
+
|
|
7202
|
+
// The coverage row for a run that drove no browser test order at all
|
|
7203
|
+
// (`--test-order off`), so its verdict never reads as every decline clicked.
|
|
7204
|
+
// Null when no funnel has offer pages, as in a run that placed orders.
|
|
7205
|
+
export function upsellActionCoverageWithoutOrders(topologies = []) {
|
|
7206
|
+
return upsellActionCoverageAssertion({
|
|
7207
|
+
topologies,
|
|
7208
|
+
orders: [],
|
|
7209
|
+
checkoutPage: findPage(topologies, "checkout"),
|
|
7210
|
+
testOrdersOff: true,
|
|
7211
|
+
});
|
|
7212
|
+
}
|
|
7213
|
+
|
|
6646
7214
|
function enforceTestOrderLimit(plans, args) {
|
|
6647
7215
|
const maxOrders = numberArg(args["max-test-orders"], DEFAULT_MAX_TEST_ORDERS);
|
|
6648
7216
|
if (plans.length <= maxOrders) return;
|
|
@@ -6659,7 +7227,7 @@ function enforceTestOrderLimit(plans, args) {
|
|
|
6659
7227
|
throw new Error([
|
|
6660
7228
|
`--test-order ${args["test-order"]} expands to ${plans.length} typed-card order(s), above --max-test-orders ${maxOrders}.`,
|
|
6661
7229
|
`Planned paths: ${preview}.`,
|
|
6662
|
-
`This cap guards against an accidental order flood, not a permission gate. Use --test-order common for the default
|
|
7230
|
+
`This cap guards against an accidental order flood, not a permission gate. Use --test-order common for the default depth, or rerun with --max-test-orders ${plans.length} for this exhaustive proof.`,
|
|
6663
7231
|
"The cap bounds planned paths. Actual order creations are bounded separately by --max-order-creations, which defaults to the planned path count and is reserved before each submit.",
|
|
6664
7232
|
].join(" "));
|
|
6665
7233
|
}
|
|
@@ -6862,7 +7430,8 @@ async function waitForLateUpsellEvidence(events, { responseIndexBefore, mutation
|
|
|
6862
7430
|
const response = fresh[index];
|
|
6863
7431
|
if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) continue;
|
|
6864
7432
|
if (!(response.status >= 200 && response.status < 300)) continue;
|
|
6865
|
-
|
|
7433
|
+
// A redirected mutation's final hop may answer from another URL; its
|
|
7434
|
+
// identity, the root of its chain, is what ties it to this step (#516).
|
|
6866
7435
|
if (mutationRequest && response[REQUEST_IDENTITY] === mutationRequest) return { source: "late_upsell_body", body: response.body };
|
|
6867
7436
|
}
|
|
6868
7437
|
for (let index = fresh.length - 1; index >= 0; index -= 1) {
|
|
@@ -7204,6 +7773,9 @@ export const __qaBrowserTestHooks = Object.freeze({
|
|
|
7204
7773
|
testEmail,
|
|
7205
7774
|
testOrderPaths,
|
|
7206
7775
|
testOrderPlans,
|
|
7776
|
+
commonOrderPathPlan,
|
|
7777
|
+
describeCommonOrderPathPlan,
|
|
7778
|
+
upsellActionCoverageAssertion,
|
|
7207
7779
|
planId,
|
|
7208
7780
|
argsForPlan,
|
|
7209
7781
|
enforceTestOrderLimit,
|