@nextcommerce/campaigns-os 1.43.2 → 1.46.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 +648 -5103
- package/README.md +32 -11
- 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/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
- package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
- package/contracts/effects.v1.json +1176 -113
- package/contracts/orientation-reason-codes.v1.json +7 -0
- package/contracts/release-ledger.json +2345 -6087
- package/contracts/supported-surface.json +7 -4
- 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 +158 -21
- 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 +1 -1
- package/docs/orientation-contract-reference.md +42 -2
- package/docs/polish-evidence.md +74 -0
- package/docs/qa-and-test-orders.md +99 -13
- 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-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 +5 -2
- package/src/doctor/checks.mjs +320 -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-browser.mjs +538 -28
- 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 +72 -8
- package/src/source-html-intake.mjs +116 -0
- package/src/stage-record.mjs +551 -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
|
}
|
|
@@ -912,7 +933,9 @@ async function runPageBrowserChecks(context, page, args, options = {}) {
|
|
|
912
933
|
const pageErrors = [];
|
|
913
934
|
const failedRequests = [];
|
|
914
935
|
browserPage.on("console", (message) => {
|
|
915
|
-
|
|
936
|
+
// The location URL is kept beside the text: a "Failed to load resource"
|
|
937
|
+
// message names its status but not the request, which is the location.
|
|
938
|
+
if (message.type() === "error") consoleErrors.push({ text: trim(message.text()), url: message.location?.()?.url || null });
|
|
916
939
|
});
|
|
917
940
|
browserPage.on("pageerror", (error) => pageErrors.push(trim(error.message)));
|
|
918
941
|
browserPage.on("requestfailed", (request) => {
|
|
@@ -1019,16 +1042,65 @@ function isIgnorableFailedRequest(request) {
|
|
|
1019
1042
|
}
|
|
1020
1043
|
}
|
|
1021
1044
|
|
|
1045
|
+
// `messages` are { text, url } records (url: the console message's location);
|
|
1046
|
+
// the result is the actionable messages' text.
|
|
1022
1047
|
async function actionableRuntimeConsoleErrors(browserPage, messages) {
|
|
1023
1048
|
if (!messages.length) return [];
|
|
1024
1049
|
const runtimeReady = await browserPage.evaluate(() => (
|
|
1025
1050
|
document.documentElement.classList.contains("next-display-ready")
|
|
1026
1051
|
|| Boolean(window.next && Object.keys(window.next).length)
|
|
1027
1052
|
)).catch(() => false);
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1053
|
+
// The page's final URL, after redirects: the host the browser actually
|
|
1054
|
+
// shows, not the host the page record requested.
|
|
1055
|
+
const pageUrl = browserPage.url();
|
|
1056
|
+
return messages
|
|
1057
|
+
.filter((message) => {
|
|
1058
|
+
if (runtimeReady && isKnownSdkLoaderFalsePositive(message.text)) return false;
|
|
1059
|
+
if (isNetlifyPreviewDrawerConsoleError(pageUrl, message)) return false;
|
|
1060
|
+
return true;
|
|
1061
|
+
})
|
|
1062
|
+
.map((message) => message.text);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Netlify injects its deploy-preview drawer (the collaboration toolbar) into
|
|
1066
|
+
// preview pages, and a failed drawer request is logged as a "Failed to load
|
|
1067
|
+
// resource" console error on every page (a 428 has been observed). That error
|
|
1068
|
+
// is Netlify's, not the campaign's.
|
|
1069
|
+
//
|
|
1070
|
+
// Each exempt request is an exact host plus an exact path:
|
|
1071
|
+
// netlify-cdp-loader.netlify.app /netlify.js — the drawer's loader script,
|
|
1072
|
+
// the one `<script src>` Netlify injects into preview HTML.
|
|
1073
|
+
// It is exempt only on a page whose final URL (after redirects) is a Netlify
|
|
1074
|
+
// preview host: any *.netlify.app host, or a deploy-preview subdomain on a
|
|
1075
|
+
// custom domain (deploy-preview-7.shop.example.com, or
|
|
1076
|
+
// deploy-preview-7--shop.example.com). Nothing else is exempt: any other path
|
|
1077
|
+
// on a Netlify host (app.netlify.com included), and the drawer's own URL on any
|
|
1078
|
+
// other page host, still counts. A "Failed to load resource" message names its
|
|
1079
|
+
// status but not the request; the request is the message's location URL.
|
|
1080
|
+
const NETLIFY_PREVIEW_DRAWER_REQUESTS = Object.freeze([
|
|
1081
|
+
{ hostname: "netlify-cdp-loader.netlify.app", pathname: "/netlify.js" },
|
|
1082
|
+
]);
|
|
1083
|
+
|
|
1084
|
+
// The first label of a deploy-preview host: `deploy-preview-<n>`, or
|
|
1085
|
+
// `deploy-preview-<n>--<site>` for a per-deploy host on a custom domain.
|
|
1086
|
+
// Only numeric ids match, deliberately: Netlify issues numeric deploy-preview
|
|
1087
|
+
// ids, and widening would hide real errors on any host named deploy-preview-*.
|
|
1088
|
+
const DEPLOY_PREVIEW_LABEL = /^deploy-preview-\d+(?:--[a-z0-9-]+)?$/;
|
|
1089
|
+
|
|
1090
|
+
function isNetlifyPreviewHost(hostname) {
|
|
1091
|
+
const labels = hostname.split(".");
|
|
1092
|
+
return hostname.endsWith(".netlify.app") || (labels.length > 2 && DEPLOY_PREVIEW_LABEL.test(labels[0]));
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function isNetlifyPreviewDrawerConsoleError(pageUrl, message) {
|
|
1096
|
+
if (!/^Failed to load resource\b/.test(String(message?.text || ""))) return false;
|
|
1097
|
+
try {
|
|
1098
|
+
const request = new URL(message.url);
|
|
1099
|
+
return isNetlifyPreviewHost(new URL(pageUrl).hostname)
|
|
1100
|
+
&& NETLIFY_PREVIEW_DRAWER_REQUESTS.some((drawer) => drawer.hostname === request.hostname && drawer.pathname === request.pathname);
|
|
1101
|
+
} catch {
|
|
1102
|
+
return false;
|
|
1103
|
+
}
|
|
1032
1104
|
}
|
|
1033
1105
|
|
|
1034
1106
|
function isKnownSdkLoaderFalsePositive(message) {
|
|
@@ -1486,13 +1558,126 @@ async function checkoutCommerceStructureAssertions(browserPage, page) {
|
|
|
1486
1558
|
}
|
|
1487
1559
|
|
|
1488
1560
|
const evidence = await inspectCommerceStructure(browserPage, contract);
|
|
1561
|
+
const behaviour = await inspectCheckoutBehaviour(browserPage);
|
|
1489
1562
|
return [commerceStructureAssertionFromEvidence(page, {
|
|
1490
1563
|
template_family: family,
|
|
1491
1564
|
contract_status: contractStatus,
|
|
1492
1565
|
...evidence,
|
|
1566
|
+
behaviour,
|
|
1493
1567
|
})];
|
|
1494
1568
|
}
|
|
1495
1569
|
|
|
1570
|
+
// The checkout's wrapper and page composition belong to the campaign source,
|
|
1571
|
+
// not the template family (#532). A catalog rule is family shell when every
|
|
1572
|
+
// selector it reads is on this list: the layout wrapper and column classes the
|
|
1573
|
+
// pinned catalog uses, and the family include's shipping field row marker.
|
|
1574
|
+
// Every other selector is SDK wiring the checkout needs to work
|
|
1575
|
+
// (`[data-next-checkout]`, `[os-checkout-payment]`, `[data-next-cart-summary]`,
|
|
1576
|
+
// `[data-next-bundle-slots-for]`) and is never relaxed. That includes any
|
|
1577
|
+
// selector not listed here, even a bare class such as the hosted payment
|
|
1578
|
+
// field's `.input-flds`, so a catalog recorded in the packet cannot widen it.
|
|
1579
|
+
const FAMILY_SHELL_SELECTORS = Object.freeze([
|
|
1580
|
+
".checkout-wrapper",
|
|
1581
|
+
".checkout-layout__left",
|
|
1582
|
+
".checkout-layout__right",
|
|
1583
|
+
".checkout__layout",
|
|
1584
|
+
".checkout__column--left",
|
|
1585
|
+
".checkout__column--right",
|
|
1586
|
+
'[data-next-component="shipping-field-row"]',
|
|
1587
|
+
]);
|
|
1588
|
+
|
|
1589
|
+
function isFamilyShellSelector(selector) {
|
|
1590
|
+
return FAMILY_SHELL_SELECTORS.includes(String(selector || "").trim());
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
function isFamilyShellCheck(check) {
|
|
1594
|
+
const selectors = Array.isArray(check?.selectors) ? check.selectors : [];
|
|
1595
|
+
return selectors.length > 0 && selectors.every(isFamilyShellSelector);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// What a checkout has to do, whoever composed it. The required fields are the
|
|
1599
|
+
// contact and shipping fields the test-order form fill types without the
|
|
1600
|
+
// optional flag (fillCheckoutFields): each must be a form control carrying
|
|
1601
|
+
// data-next-checkout-field inside a <form data-next-checkout="form">, and not a
|
|
1602
|
+
// type="hidden" input or a disabled control (its own attribute or a disabled
|
|
1603
|
+
// fieldset), since a customer cannot fill either. Visibility is not required:
|
|
1604
|
+
// progressive-reveal checkouts hide the address fields until a country is
|
|
1605
|
+
// chosen. The total is the cart-summary total the order-total parity check
|
|
1606
|
+
// reads.
|
|
1607
|
+
const CHECKOUT_FORM_SELECTOR = 'form[data-next-checkout="form"]';
|
|
1608
|
+
const CHECKOUT_BEHAVIOUR_REQUIRED_FIELDS = Object.freeze([
|
|
1609
|
+
"email",
|
|
1610
|
+
"fname",
|
|
1611
|
+
"lname",
|
|
1612
|
+
"country",
|
|
1613
|
+
"address1",
|
|
1614
|
+
"city",
|
|
1615
|
+
"province",
|
|
1616
|
+
"postal",
|
|
1617
|
+
]);
|
|
1618
|
+
|
|
1619
|
+
function checkoutBehaviourProbeInput() {
|
|
1620
|
+
return {
|
|
1621
|
+
formSelector: CHECKOUT_FORM_SELECTOR,
|
|
1622
|
+
requiredFields: [...CHECKOUT_BEHAVIOUR_REQUIRED_FIELDS],
|
|
1623
|
+
totalSelectors: checkoutTotalSelectors(),
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
async function inspectCheckoutBehaviour(browserPage) {
|
|
1628
|
+
return browserPage.evaluate((input) => {
|
|
1629
|
+
const visible = (element) => {
|
|
1630
|
+
const rect = element.getBoundingClientRect();
|
|
1631
|
+
const style = getComputedStyle(element);
|
|
1632
|
+
return rect.width > 0
|
|
1633
|
+
&& rect.height > 0
|
|
1634
|
+
&& style.display !== "none"
|
|
1635
|
+
&& style.visibility !== "hidden"
|
|
1636
|
+
&& Number(style.opacity || "1") !== 0;
|
|
1637
|
+
};
|
|
1638
|
+
const forms = Array.from(document.querySelectorAll(input.formSelector));
|
|
1639
|
+
const controls = new Set(["INPUT", "SELECT", "TEXTAREA"]);
|
|
1640
|
+
// readonly has no effect on a select, so only an input or textarea loses it.
|
|
1641
|
+
const fillable = (field) => controls.has(field.tagName)
|
|
1642
|
+
&& !(field.tagName === "INPUT" && field.type === "hidden")
|
|
1643
|
+
&& !field.matches(":disabled")
|
|
1644
|
+
&& !(field.tagName !== "SELECT" && field.hasAttribute("readonly"))
|
|
1645
|
+
&& field.getAttribute("aria-disabled") !== "true";
|
|
1646
|
+
const missing = input.requiredFields.filter((name) => !forms.some((form) => Array
|
|
1647
|
+
.from(form.querySelectorAll("[data-next-checkout-field]"))
|
|
1648
|
+
.some((field) => field.getAttribute("data-next-checkout-field") === name && fillable(field))));
|
|
1649
|
+
const totals = input.totalSelectors.flatMap((selector) => {
|
|
1650
|
+
try {
|
|
1651
|
+
return Array.from(document.querySelectorAll(selector));
|
|
1652
|
+
} catch {
|
|
1653
|
+
return [];
|
|
1654
|
+
}
|
|
1655
|
+
});
|
|
1656
|
+
const visibleTotals = totals.filter((element) => visible(element) && (element.textContent || "").trim().length > 0);
|
|
1657
|
+
const checkoutForm = { selector: input.formSelector, count: forms.length, status: forms.length ? "pass" : "fail" };
|
|
1658
|
+
const fieldsBound = {
|
|
1659
|
+
required: input.requiredFields,
|
|
1660
|
+
missing,
|
|
1661
|
+
status: forms.length && !missing.length ? "pass" : "fail",
|
|
1662
|
+
};
|
|
1663
|
+
const totalVisible = {
|
|
1664
|
+
selectors: input.totalSelectors,
|
|
1665
|
+
count: totals.length,
|
|
1666
|
+
visible_count: visibleTotals.length,
|
|
1667
|
+
status: visibleTotals.length ? "pass" : "fail",
|
|
1668
|
+
};
|
|
1669
|
+
return {
|
|
1670
|
+
status: [checkoutForm, fieldsBound, totalVisible].every((check) => check.status === "pass") ? "pass" : "fail",
|
|
1671
|
+
checkout_form: checkoutForm,
|
|
1672
|
+
fields_bound: fieldsBound,
|
|
1673
|
+
total_visible: totalVisible,
|
|
1674
|
+
};
|
|
1675
|
+
}, checkoutBehaviourProbeInput()).catch((error) => ({
|
|
1676
|
+
status: "fail",
|
|
1677
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1678
|
+
}));
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1496
1681
|
async function inspectCommerceStructure(browserPage, contract) {
|
|
1497
1682
|
const safeContract = isPlainObject(contract) ? contract : {};
|
|
1498
1683
|
const checks = await browserPage.evaluate((input) => {
|
|
@@ -1577,18 +1762,28 @@ function commerceStructureAssertionFromEvidence(page, evidence) {
|
|
|
1577
1762
|
evidence,
|
|
1578
1763
|
});
|
|
1579
1764
|
}
|
|
1580
|
-
const
|
|
1765
|
+
const classified = checks.map((check) => ({ ...check, kind: isFamilyShellCheck(check) ? "family_shell" : "sdk_wiring" }));
|
|
1766
|
+
const failed = classified.filter((check) => check.status === "fail");
|
|
1767
|
+
// A missing family-shell selector is a warning only when nothing else failed
|
|
1768
|
+
// and the behaviour probe ran and passed; absent or failing behaviour
|
|
1769
|
+
// evidence keeps the row a failure.
|
|
1770
|
+
const shellOnly = failed.length > 0 && failed.every((check) => check.kind === "family_shell");
|
|
1771
|
+
const behaviourPassed = evidence?.behaviour?.status === "pass";
|
|
1772
|
+
const relaxed = shellOnly && behaviourPassed;
|
|
1773
|
+
const missing = `${checks.length - failed.length}/${checks.length} structure check(s) passed; missing ${failed.map((check) => check.name).join(", ")}`;
|
|
1581
1774
|
return assertion({
|
|
1582
1775
|
id: `browser-commerce-structure:${page.page_id}`,
|
|
1583
1776
|
family: "browser-runtime",
|
|
1584
1777
|
page,
|
|
1585
|
-
status: failed.length ? STATUS.
|
|
1778
|
+
status: !failed.length ? STATUS.PASS : relaxed ? STATUS.WARN : STATUS.FAIL,
|
|
1586
1779
|
severity: failed.length ? SEVERITY.WARN : undefined,
|
|
1587
1780
|
expected: "rendered checkout conforms to the selected template-family commerce structure contract",
|
|
1588
|
-
actual: failed.length
|
|
1589
|
-
? `${checks.length
|
|
1590
|
-
:
|
|
1591
|
-
|
|
1781
|
+
actual: !failed.length
|
|
1782
|
+
? `${checks.length}/${checks.length} structure check(s) passed`
|
|
1783
|
+
: relaxed
|
|
1784
|
+
? `${missing}; the checkout form, bound fields and visible total pass, so the missing family shell is source-owned`
|
|
1785
|
+
: missing,
|
|
1786
|
+
evidence: { ...evidence, checks: classified },
|
|
1592
1787
|
});
|
|
1593
1788
|
}
|
|
1594
1789
|
|
|
@@ -2590,13 +2785,13 @@ async function pricingVisibilityAssertions(browserPage, page, options = {}) {
|
|
|
2590
2785
|
if (!surfaces) return [];
|
|
2591
2786
|
const pageType = contractPageType(page);
|
|
2592
2787
|
if (["upsell", "downsell"].includes(pageType)) {
|
|
2593
|
-
const selectors = surfaces.upsell?.price_row_selectors
|
|
2788
|
+
const selectors = withSdkPriceDisplaySelectors(surfaces.upsell?.price_row_selectors);
|
|
2594
2789
|
if (!selectors.length) return [];
|
|
2595
2790
|
const visibleCount = await countVisiblePriceRows(browserPage, selectors);
|
|
2596
2791
|
return [upsellPriceVisibilityAssertion({ page, selectors, visibleCount })];
|
|
2597
2792
|
}
|
|
2598
2793
|
if (pageType === "checkout") {
|
|
2599
|
-
const selectors = surfaces.checkout_bundle?.price_row_selectors
|
|
2794
|
+
const selectors = withSdkPriceDisplaySelectors(surfaces.checkout_bundle?.price_row_selectors);
|
|
2600
2795
|
// Two price surfaces satisfy this check. The contract's bundle price rows
|
|
2601
2796
|
// are one; the rendered cart-summary total is the other — the same
|
|
2602
2797
|
// selectors the order-total parity check reads at submit. A family that
|
|
@@ -2615,11 +2810,29 @@ async function pricingVisibilityAssertions(browserPage, page, options = {}) {
|
|
|
2615
2810
|
return [];
|
|
2616
2811
|
}
|
|
2617
2812
|
|
|
2813
|
+
// The SDK renders a bundle or upsell price through data-next-bundle-display as
|
|
2814
|
+
// well as data-next-display (#532). A contract surface that lists price rows
|
|
2815
|
+
// also counts that attribute; an empty list stays empty so a contract that
|
|
2816
|
+
// declares no rows keeps skipping the count.
|
|
2817
|
+
const SDK_BUNDLE_PRICE_DISPLAY_SELECTOR = "[data-next-bundle-display*='price']";
|
|
2818
|
+
|
|
2819
|
+
function withSdkPriceDisplaySelectors(selectors) {
|
|
2820
|
+
const list = Array.isArray(selectors) ? selectors : [];
|
|
2821
|
+
if (!list.length || list.includes(SDK_BUNDLE_PRICE_DISPLAY_SELECTOR)) return list;
|
|
2822
|
+
return [...list, SDK_BUNDLE_PRICE_DISPLAY_SELECTOR];
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2618
2825
|
// Visible = non-zero bounding box, display != none, visibility != hidden. This
|
|
2619
2826
|
// is what caught nothing in the dogfood run: a campaign CSS rule display:none'd
|
|
2620
2827
|
// the only price row on a full-price upsell and 48/48 checks still passed.
|
|
2828
|
+
// A bundle-display node is the price text itself, so it also needs
|
|
2829
|
+
// non-whitespace text: a sized but empty node is a price the SDK never filled.
|
|
2830
|
+
// The rule follows the node's attribute, not the selector that reached it
|
|
2831
|
+
// first, so a bundle-display node that also carries `.price-wrapper` still
|
|
2832
|
+
// needs text.
|
|
2621
2833
|
async function countVisiblePriceRows(browserPage, selectors) {
|
|
2622
|
-
return browserPage.evaluate((targets) => {
|
|
2834
|
+
return browserPage.evaluate(({ targets, bundlePriceSelector }) => {
|
|
2835
|
+
const textRequired = (element) => element.matches(bundlePriceSelector);
|
|
2623
2836
|
const visible = (element) => {
|
|
2624
2837
|
const rect = element.getBoundingClientRect();
|
|
2625
2838
|
const style = getComputedStyle(element);
|
|
@@ -2632,14 +2845,16 @@ async function countVisiblePriceRows(browserPage, selectors) {
|
|
|
2632
2845
|
for (const element of document.querySelectorAll(selector)) {
|
|
2633
2846
|
if (seen.has(element)) continue;
|
|
2634
2847
|
seen.add(element);
|
|
2635
|
-
if (visible(element))
|
|
2848
|
+
if (!visible(element)) continue;
|
|
2849
|
+
if (textRequired(element) && !(element.textContent || "").trim()) continue;
|
|
2850
|
+
count += 1;
|
|
2636
2851
|
}
|
|
2637
2852
|
} catch {
|
|
2638
2853
|
// invalid selector: contract bug surfaced elsewhere
|
|
2639
2854
|
}
|
|
2640
2855
|
}
|
|
2641
2856
|
return count;
|
|
2642
|
-
}, selectors).catch(() => 0);
|
|
2857
|
+
}, { targets: selectors, bundlePriceSelector: SDK_BUNDLE_PRICE_DISPLAY_SELECTOR }).catch(() => 0);
|
|
2643
2858
|
}
|
|
2644
2859
|
|
|
2645
2860
|
// Page-scoped like every other per-page row (`template-residue:<page>:…`,
|
|
@@ -6313,12 +6528,13 @@ async function closeAddressAutocomplete(page) {
|
|
|
6313
6528
|
}
|
|
6314
6529
|
}
|
|
6315
6530
|
|
|
6316
|
-
function testOrderPaths(mode, topologies = []) {
|
|
6531
|
+
function testOrderPaths(mode, topologies = [], args = {}) {
|
|
6317
6532
|
const normalized = String(mode || "off").toLowerCase();
|
|
6318
6533
|
// `common` (also the bare `--test-order` flag, which parses to boolean true)
|
|
6319
|
-
// is the default
|
|
6534
|
+
// is the default: every actual terminal path when they fit under the flood
|
|
6535
|
+
// cap, otherwise the sample plus one decline path per uncovered offer page.
|
|
6320
6536
|
// `full` is the explicit opt-in for every actual terminal path.
|
|
6321
|
-
if (normalized
|
|
6537
|
+
if (isCommonTestOrderMode(normalized)) return commonOrderPathPlan(topologies, args).paths;
|
|
6322
6538
|
if (normalized === "full") return fullTestOrderPaths(resolvePrimaryTestOrderTopology(topologies));
|
|
6323
6539
|
if (normalized === "both") return ["accept", "decline"];
|
|
6324
6540
|
if (["checkout", "accept", "decline"].includes(normalized)) return [normalized];
|
|
@@ -6356,7 +6572,7 @@ function testOrderPlans(mode, topologies = [], args = {}, options = {}) {
|
|
|
6356
6572
|
const applyCoupon = stringArg(args["apply-coupon"]);
|
|
6357
6573
|
const checkoutPage = findPage(topologies, "checkout");
|
|
6358
6574
|
const topologyPlan = resolvePrimaryTestOrderTopology(topologies);
|
|
6359
|
-
return testOrderPaths(mode, topologies).map((path) => ({
|
|
6575
|
+
return testOrderPaths(mode, topologies, args).map((path) => ({
|
|
6360
6576
|
path,
|
|
6361
6577
|
select_package: selectPackage,
|
|
6362
6578
|
apply_coupon: applyCoupon,
|
|
@@ -6634,15 +6850,306 @@ function summarizeTestOrderPlan(plan) {
|
|
|
6634
6850
|
};
|
|
6635
6851
|
}
|
|
6636
6852
|
|
|
6637
|
-
// The
|
|
6638
|
-
//
|
|
6639
|
-
//
|
|
6640
|
-
//
|
|
6853
|
+
// The "common shapes" sample: checkout baseline, plus first-offer accept and
|
|
6854
|
+
// decline when the checkout enters an offer graph, plus the shortest real
|
|
6855
|
+
// receipt path when that adds coverage. 1-4 orders. `tiers:common` crosses
|
|
6856
|
+
// each tier with this sample; the operator `common` depth builds on it through
|
|
6857
|
+
// commonOrderPathPlan. Bundle/quantity and bump coverage come from `--cart`;
|
|
6641
6858
|
// every actual terminal path comes from `full`.
|
|
6642
6859
|
function testOrderCommonPaths(topologies = []) {
|
|
6643
6860
|
return commonTestOrderPaths(resolvePrimaryTestOrderTopology(topologies));
|
|
6644
6861
|
}
|
|
6645
6862
|
|
|
6863
|
+
function isCommonTestOrderMode(mode) {
|
|
6864
|
+
const normalized = String(mode ?? "off").toLowerCase();
|
|
6865
|
+
return normalized === "common" || normalized === "true";
|
|
6866
|
+
}
|
|
6867
|
+
|
|
6868
|
+
// The operator `common` depth, planned against the same cap the flood guard
|
|
6869
|
+
// enforces, so the plan that decides "full fits" is the plan that is checked.
|
|
6870
|
+
function commonOrderPathPlan(topologies = [], args = {}) {
|
|
6871
|
+
return commonTestOrderPlan(resolvePrimaryTestOrderTopology(topologies), {
|
|
6872
|
+
cap: numberArg(args["max-test-orders"], DEFAULT_MAX_TEST_ORDERS),
|
|
6873
|
+
});
|
|
6874
|
+
}
|
|
6875
|
+
|
|
6876
|
+
function describeCommonOrderPathPlan(plan) {
|
|
6877
|
+
const head = plan.effective_depth === "full"
|
|
6878
|
+
? `[qa:test-order] common runs every actual terminal path (${plan.paths.length}, at or under the cap of ${plan.cap}).`
|
|
6879
|
+
: `[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}` : ""}.`;
|
|
6880
|
+
if (!plan.uncovered_pages.length) return head;
|
|
6881
|
+
const pages = plan.uncovered_pages.map((page) => `${page.page_id || "(unnamed)"}${page.reason === "unreachable" ? " (unreachable from the checkout)" : ""}`);
|
|
6882
|
+
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.`;
|
|
6883
|
+
}
|
|
6884
|
+
|
|
6885
|
+
// Which offer pages had their decline clicked by an order this run actually
|
|
6886
|
+
// placed (#530). The unit is the decline control: an order that reached a page,
|
|
6887
|
+
// or clicked only its accept, does not count, because a broken decline link is
|
|
6888
|
+
// the failure this row exists to surface. Read from the click records the
|
|
6889
|
+
// runner kept, never from the plan.
|
|
6890
|
+
//
|
|
6891
|
+
// The row is conservative by construction. It may be `pass` or `warn` only
|
|
6892
|
+
// when coverage is certain: every funnel in the run lists its pages, every page
|
|
6893
|
+
// is either a known non-offer type or an offer page with its own absolute URL
|
|
6894
|
+
// (no URL shared with another page), every plan belongs to exactly one of those
|
|
6895
|
+
// funnels (same checkout, same page list), and every click a placed order
|
|
6896
|
+
// recorded lands on a declared offer page of that order's own funnel. A click
|
|
6897
|
+
// credits only the funnel whose plan made it, never another funnel by URL. Any
|
|
6898
|
+
// doubt, no placed order, or `--test-order off` makes the row `manual_review`
|
|
6899
|
+
// naming the pages and the reasons. When certain, it is `warn` naming each
|
|
6900
|
+
// page whose decline no order clicked, else `pass`. No row when coverage is
|
|
6901
|
+
// certain and no funnel has an offer page, or when nothing was topologised,
|
|
6902
|
+
// planned or placed. `orderPlans` holds the plan behind each entry in
|
|
6903
|
+
// `orders`, index for index.
|
|
6904
|
+
function upsellActionCoverageAssertion({ topologies = [], plans = [], orders = [], orderPlans = [], checkoutPage = null, orderPathPlan = null, testOrdersOff = false } = {}) {
|
|
6905
|
+
const funnels = (Array.isArray(topologies) ? topologies : []).map((topology, index) => ({
|
|
6906
|
+
index,
|
|
6907
|
+
funnel_id: topology?.funnel_id || null,
|
|
6908
|
+
pages: Array.isArray(topology?.pages) ? topology.pages : null,
|
|
6909
|
+
}));
|
|
6910
|
+
const funnelName = (funnel) => funnel.funnel_id || `(unnamed funnel ${funnel.index + 1})`;
|
|
6911
|
+
// Run-level reasons coverage is not certain; per-page reasons live on the
|
|
6912
|
+
// page entries.
|
|
6913
|
+
const doubts = [];
|
|
6914
|
+
|
|
6915
|
+
// Every offer page of every funnel, and every page whose type does not rule
|
|
6916
|
+
// it out. The same declaration listed twice in one funnel is one page.
|
|
6917
|
+
const entries = [];
|
|
6918
|
+
for (const funnel of funnels) {
|
|
6919
|
+
if (!funnel.pages) {
|
|
6920
|
+
entries.push({ funnel, key: null, page_id: null, page_type: null, label: "(page list missing)", reason: "no_pages" });
|
|
6921
|
+
continue;
|
|
6922
|
+
}
|
|
6923
|
+
const rows = new Set();
|
|
6924
|
+
funnel.pages.forEach((page, index) => {
|
|
6925
|
+
const type = String(page?.page_type || "").toLowerCase().replace(/[-_]/g, "");
|
|
6926
|
+
if (NON_OFFER_PAGE_TYPES.has(type)) return;
|
|
6927
|
+
const key = canonicalHttpUrl(page?.url);
|
|
6928
|
+
const row = `${page?.page_id ?? ""}\u0000${key ?? `#${index}`}`;
|
|
6929
|
+
if (rows.has(row)) return;
|
|
6930
|
+
rows.add(row);
|
|
6931
|
+
entries.push({
|
|
6932
|
+
funnel,
|
|
6933
|
+
key,
|
|
6934
|
+
page_id: page?.page_id || null,
|
|
6935
|
+
page_type: page?.page_type || null,
|
|
6936
|
+
label: page?.page_id || `(unnamed page ${index + 1})`,
|
|
6937
|
+
reason: !OFFER_PAGE_TYPES.has(type)
|
|
6938
|
+
? "unknown_page_type"
|
|
6939
|
+
: key ? null : (typeof page?.url === "string" && page.url.trim() ? "unresolvable_url" : "no_url"),
|
|
6940
|
+
});
|
|
6941
|
+
});
|
|
6942
|
+
}
|
|
6943
|
+
const entriesByKey = new Map();
|
|
6944
|
+
for (const entry of entries) {
|
|
6945
|
+
if (!entry.key) continue;
|
|
6946
|
+
if (!entriesByKey.has(entry.key)) entriesByKey.set(entry.key, []);
|
|
6947
|
+
entriesByKey.get(entry.key).push(entry);
|
|
6948
|
+
}
|
|
6949
|
+
for (const shared of entriesByKey.values()) {
|
|
6950
|
+
if (shared.length < 2) continue;
|
|
6951
|
+
for (const entry of shared) entry.reason ||= "shared_url";
|
|
6952
|
+
}
|
|
6953
|
+
|
|
6954
|
+
// A plan belongs to a funnel only when its checkout is that funnel's own
|
|
6955
|
+
// checkout page (the same object, or failing that the one funnel whose
|
|
6956
|
+
// checkout has its URL) and the page list it planned over is that funnel's.
|
|
6957
|
+
const planOwners = new Map();
|
|
6958
|
+
const ownerOf = (plan) => {
|
|
6959
|
+
if (planOwners.has(plan)) return planOwners.get(plan);
|
|
6960
|
+
let owner = null;
|
|
6961
|
+
if (plan && typeof plan === "object") {
|
|
6962
|
+
let candidates = plan.checkout_page ? funnels.filter((funnel) => funnel.pages?.includes(plan.checkout_page)) : [];
|
|
6963
|
+
if (!candidates.length) {
|
|
6964
|
+
const checkoutKey = canonicalHttpUrl(plan.checkout_page?.url || plan.topology_plan?.checkout_url);
|
|
6965
|
+
candidates = checkoutKey
|
|
6966
|
+
? funnels.filter((funnel) => funnel.pages?.some((page) => String(page?.page_type || "").toLowerCase() === "checkout" && canonicalHttpUrl(page?.url) === checkoutKey))
|
|
6967
|
+
: [];
|
|
6968
|
+
}
|
|
6969
|
+
const [candidate] = candidates;
|
|
6970
|
+
if (candidates.length === 1 && (!plan.topology_plan || plannedOverFunnel(plan.topology_plan, candidate))) owner = candidate;
|
|
6971
|
+
}
|
|
6972
|
+
planOwners.set(plan, owner);
|
|
6973
|
+
return owner;
|
|
6974
|
+
};
|
|
6975
|
+
const unattributedPlans = [...new Set([...plans, ...orderPlans])].filter((plan) => !ownerOf(plan));
|
|
6976
|
+
if (unattributedPlans.length) {
|
|
6977
|
+
doubts.push({ reason: "unattributed_plan", plans: unattributedPlans.map((plan) => (plan && typeof plan === "object") || typeof plan === "string" ? planId(plan) : String(plan)) });
|
|
6978
|
+
}
|
|
6979
|
+
|
|
6980
|
+
const placedByFunnel = new Map();
|
|
6981
|
+
const undeclaredClicks = new Set();
|
|
6982
|
+
let unattributedClicks = 0;
|
|
6983
|
+
let placed = 0;
|
|
6984
|
+
orders.forEach((order, index) => {
|
|
6985
|
+
if (!isPlacedTestOrder(order)) return;
|
|
6986
|
+
placed += 1;
|
|
6987
|
+
const funnel = ownerOf(orderPlans[index]);
|
|
6988
|
+
if (funnel) placedByFunnel.set(funnel, (placedByFunnel.get(funnel) || 0) + 1);
|
|
6989
|
+
for (const step of Array.isArray(order.upsell_steps) ? order.upsell_steps : []) {
|
|
6990
|
+
if (step?.clicked !== true) continue;
|
|
6991
|
+
const key = canonicalHttpUrl(step?.offer_url);
|
|
6992
|
+
if (!key || !funnel) {
|
|
6993
|
+
unattributedClicks += 1;
|
|
6994
|
+
continue;
|
|
6995
|
+
}
|
|
6996
|
+
const entry = entries.find((candidate) => candidate.funnel === funnel && candidate.key === key);
|
|
6997
|
+
if (!entry) {
|
|
6998
|
+
undeclaredClicks.add(key);
|
|
6999
|
+
continue;
|
|
7000
|
+
}
|
|
7001
|
+
if (step.path === "accept") entry.accept_clicked = true;
|
|
7002
|
+
if (step.path === "decline") entry.decline_clicked = true;
|
|
7003
|
+
}
|
|
7004
|
+
});
|
|
7005
|
+
if (unattributedClicks) doubts.push({ reason: "unattributed_click", clicks: unattributedClicks });
|
|
7006
|
+
if (undeclaredClicks.size) doubts.push({ reason: "undeclared_click", urls: [...undeclaredClicks].map((key) => new URL(key).pathname) });
|
|
7007
|
+
|
|
7008
|
+
if (!funnels.length) {
|
|
7009
|
+
// No funnel topology reached this row, so its pages cannot be listed. That
|
|
7010
|
+
// is unknown, not "no offer pages".
|
|
7011
|
+
if (!(plans.length || orders.length)) return null;
|
|
7012
|
+
doubts.unshift({ reason: "no_topology" });
|
|
7013
|
+
}
|
|
7014
|
+
if (!entries.length && !doubts.length) return null;
|
|
7015
|
+
|
|
7016
|
+
const nameFunnels = funnels.length > 1;
|
|
7017
|
+
const pageName = (entry) => {
|
|
7018
|
+
const notes = [];
|
|
7019
|
+
if (nameFunnels) notes.push(`funnel ${funnelName(entry.funnel)}`);
|
|
7020
|
+
if (entry.reason) notes.push(UPSELL_COVERAGE_UNASSESSABLE[entry.reason]);
|
|
7021
|
+
return `${entry.label}${notes.length ? ` (${notes.join("; ")})` : ""}`;
|
|
7022
|
+
};
|
|
7023
|
+
const pages = entries.map((entry) => ({
|
|
7024
|
+
page_id: entry.page_id,
|
|
7025
|
+
page_type: entry.page_type,
|
|
7026
|
+
funnel_id: entry.funnel.funnel_id,
|
|
7027
|
+
accept_clicked: entry.accept_clicked === true,
|
|
7028
|
+
decline_clicked: entry.decline_clicked === true,
|
|
7029
|
+
...(entry.reason ? { assessable: false, reason: entry.reason } : {}),
|
|
7030
|
+
}));
|
|
7031
|
+
const open = entries.filter((entry) => !entry.decline_clicked || entry.reason);
|
|
7032
|
+
const unassessable = entries.filter((entry) => entry.reason);
|
|
7033
|
+
const evidence = {
|
|
7034
|
+
basis: "decline_clicked",
|
|
7035
|
+
orders_placed: placed,
|
|
7036
|
+
pages,
|
|
7037
|
+
not_clicked_through: open.map((entry) => entry.label),
|
|
7038
|
+
not_assessable: unassessable.map((entry) => ({ page_id: entry.page_id, funnel_id: entry.funnel.funnel_id, reason: entry.reason })),
|
|
7039
|
+
uncertainty: doubts,
|
|
7040
|
+
funnels: funnels.map((funnel) => ({
|
|
7041
|
+
funnel_id: funnel.funnel_id,
|
|
7042
|
+
orders_placed: placedByFunnel.get(funnel) || 0,
|
|
7043
|
+
not_clicked_through: open.filter((entry) => entry.funnel === funnel).map((entry) => entry.label),
|
|
7044
|
+
})),
|
|
7045
|
+
...(orderPathPlan ? {
|
|
7046
|
+
order_path_depth: orderPathPlan.requested_depth,
|
|
7047
|
+
order_path_depth_effective: orderPathPlan.effective_depth,
|
|
7048
|
+
order_path_depth_reason: orderPathPlan.reason,
|
|
7049
|
+
max_test_orders: orderPathPlan.cap,
|
|
7050
|
+
full_path_count: orderPathPlan.full_path_count,
|
|
7051
|
+
coverage_paths: orderPathPlan.coverage_paths,
|
|
7052
|
+
planned_uncovered_pages: orderPathPlan.uncovered_pages,
|
|
7053
|
+
} : {}),
|
|
7054
|
+
};
|
|
7055
|
+
const base = {
|
|
7056
|
+
id: "browser-test-order:upsell-action-coverage",
|
|
7057
|
+
family: "browser-test-order",
|
|
7058
|
+
page: checkoutPage || { page_id: "checkout" },
|
|
7059
|
+
expected: "every page with upsell actions has its decline clicked by at least one test order of its own funnel",
|
|
7060
|
+
};
|
|
7061
|
+
|
|
7062
|
+
// The one predicate pass and warn depend on.
|
|
7063
|
+
const certain = placed > 0 && !testOrdersOff && !doubts.length && !unassessable.length;
|
|
7064
|
+
if (!certain) {
|
|
7065
|
+
const reason = doubts[0]?.reason === "no_topology"
|
|
7066
|
+
? "no_topology"
|
|
7067
|
+
: testOrdersOff ? "test_orders_off" : !placed ? "no_order_recorded" : "not_assessable";
|
|
7068
|
+
const why = reason === "no_topology"
|
|
7069
|
+
? "no funnel topology reached the coverage check, so the pages with upsell actions could not be listed"
|
|
7070
|
+
: reason === "test_orders_off"
|
|
7071
|
+
? "browser test orders were off (--test-order off)"
|
|
7072
|
+
: reason === "no_order_recorded"
|
|
7073
|
+
? "no test order was placed"
|
|
7074
|
+
: `coverage cannot be attributed with certainty (${[
|
|
7075
|
+
...doubts.map(describeCoverageDoubt),
|
|
7076
|
+
...(unassessable.length ? [`${unassessable.length} page(s) cannot be matched to a click`] : []),
|
|
7077
|
+
].join("; ")})`;
|
|
7078
|
+
const named = open.length ? `, so ${open.length} of ${pages.length} page(s) with upsell actions are not proved clicked through: ${open.map(pageName).join(", ")}` : "";
|
|
7079
|
+
return assertion({
|
|
7080
|
+
...base,
|
|
7081
|
+
status: STATUS.MANUAL_REVIEW,
|
|
7082
|
+
severity: SEVERITY.WARN,
|
|
7083
|
+
actual: `${why}${named}`,
|
|
7084
|
+
evidence: { ...evidence, reason },
|
|
7085
|
+
});
|
|
7086
|
+
}
|
|
7087
|
+
if (open.length) {
|
|
7088
|
+
const unrun = [...new Set(open.map((entry) => entry.funnel))].filter((funnel) => !placedByFunnel.get(funnel));
|
|
7089
|
+
return assertion({
|
|
7090
|
+
...base,
|
|
7091
|
+
status: STATUS.WARN,
|
|
7092
|
+
severity: SEVERITY.WARN,
|
|
7093
|
+
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(", ")}` : ""}`,
|
|
7094
|
+
evidence,
|
|
7095
|
+
});
|
|
7096
|
+
}
|
|
7097
|
+
return assertion({
|
|
7098
|
+
...base,
|
|
7099
|
+
status: STATUS.PASS,
|
|
7100
|
+
actual: `decline clicked on all ${pages.length} page(s) with upsell actions`,
|
|
7101
|
+
evidence,
|
|
7102
|
+
});
|
|
7103
|
+
}
|
|
7104
|
+
|
|
7105
|
+
// True when a resolved topology plan was planned over exactly this funnel: the
|
|
7106
|
+
// same funnel id and the same page list, page for page.
|
|
7107
|
+
function plannedOverFunnel(topologyPlan, funnel) {
|
|
7108
|
+
if ((topologyPlan?.topology_id || null) !== (funnel.funnel_id || "default")) return false;
|
|
7109
|
+
const planned = Array.isArray(topologyPlan?.route_pages) ? topologyPlan.route_pages : null;
|
|
7110
|
+
if (!planned || planned.length !== funnel.pages.length) return false;
|
|
7111
|
+
return funnel.pages.every((page, index) => (page?.page_id || null) === planned[index]?.page_id
|
|
7112
|
+
&& (page?.page_type || null) === planned[index]?.page_type
|
|
7113
|
+
&& (page?.url || null) === planned[index]?.url);
|
|
7114
|
+
}
|
|
7115
|
+
|
|
7116
|
+
function describeCoverageDoubt(doubt) {
|
|
7117
|
+
if (doubt.reason === "unattributed_plan") return `order plan(s) ${doubt.plans.join(", ")} cannot be matched to exactly one funnel's checkout and page list`;
|
|
7118
|
+
if (doubt.reason === "unattributed_click") return `${doubt.clicks} recorded click(s) carry no usable page URL or come from an order with no funnel`;
|
|
7119
|
+
if (doubt.reason === "undeclared_click") return `an order clicked page(s) its funnel does not declare: ${doubt.urls.join(", ")}`;
|
|
7120
|
+
return doubt.reason;
|
|
7121
|
+
}
|
|
7122
|
+
|
|
7123
|
+
// Page types that never carry an upsell action. Any other type, or none, does
|
|
7124
|
+
// not say either way.
|
|
7125
|
+
const NON_OFFER_PAGE_TYPES = new Set(["presell", "landing", "select", "product", "checkout", "thankyou", "receipt"]);
|
|
7126
|
+
const UPSELL_COVERAGE_UNASSESSABLE = Object.freeze({
|
|
7127
|
+
no_url: "no URL",
|
|
7128
|
+
unresolvable_url: "URL is not an absolute http(s) address",
|
|
7129
|
+
unknown_page_type: "page type does not say whether it offers anything",
|
|
7130
|
+
shared_url: "URL shared with another page",
|
|
7131
|
+
no_pages: "funnel lists no pages",
|
|
7132
|
+
});
|
|
7133
|
+
|
|
7134
|
+
// An attempt counts as a placed order only when it carries the order reference
|
|
7135
|
+
// the runner read back; a failure placeholder has none.
|
|
7136
|
+
function isPlacedTestOrder(order) {
|
|
7137
|
+
if (!order || typeof order !== "object") return false;
|
|
7138
|
+
return [order.ref_id, order.next_order_id].some((value) => value != null && String(value).trim() !== "");
|
|
7139
|
+
}
|
|
7140
|
+
|
|
7141
|
+
// The coverage row for a run that drove no browser test order at all
|
|
7142
|
+
// (`--test-order off`), so its verdict never reads as every decline clicked.
|
|
7143
|
+
// Null when no funnel has offer pages, as in a run that placed orders.
|
|
7144
|
+
export function upsellActionCoverageWithoutOrders(topologies = []) {
|
|
7145
|
+
return upsellActionCoverageAssertion({
|
|
7146
|
+
topologies,
|
|
7147
|
+
orders: [],
|
|
7148
|
+
checkoutPage: findPage(topologies, "checkout"),
|
|
7149
|
+
testOrdersOff: true,
|
|
7150
|
+
});
|
|
7151
|
+
}
|
|
7152
|
+
|
|
6646
7153
|
function enforceTestOrderLimit(plans, args) {
|
|
6647
7154
|
const maxOrders = numberArg(args["max-test-orders"], DEFAULT_MAX_TEST_ORDERS);
|
|
6648
7155
|
if (plans.length <= maxOrders) return;
|
|
@@ -6659,7 +7166,7 @@ function enforceTestOrderLimit(plans, args) {
|
|
|
6659
7166
|
throw new Error([
|
|
6660
7167
|
`--test-order ${args["test-order"]} expands to ${plans.length} typed-card order(s), above --max-test-orders ${maxOrders}.`,
|
|
6661
7168
|
`Planned paths: ${preview}.`,
|
|
6662
|
-
`This cap guards against an accidental order flood, not a permission gate. Use --test-order common for the default
|
|
7169
|
+
`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
7170
|
"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
7171
|
].join(" "));
|
|
6665
7172
|
}
|
|
@@ -7204,6 +7711,9 @@ export const __qaBrowserTestHooks = Object.freeze({
|
|
|
7204
7711
|
testEmail,
|
|
7205
7712
|
testOrderPaths,
|
|
7206
7713
|
testOrderPlans,
|
|
7714
|
+
commonOrderPathPlan,
|
|
7715
|
+
describeCommonOrderPathPlan,
|
|
7716
|
+
upsellActionCoverageAssertion,
|
|
7207
7717
|
planId,
|
|
7208
7718
|
argsForPlan,
|
|
7209
7719
|
enforceTestOrderLimit,
|