@nextcommerce/campaigns-os 1.43.1 → 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 +9 -2
- package/CHANGELOG.md +1099 -5103
- package/README.md +34 -13
- 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/agent-relevant-change-policy.v1.json +5 -0
- 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 +1184 -121
- package/contracts/orientation-reason-codes.v1.json +7 -0
- package/contracts/release-ledger.json +2190 -5260
- 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 +222 -23
- package/docs/campaigns-os-build-flow.md +4 -3
- package/docs/design-source-package.md +162 -15
- package/docs/effects.md +66 -12
- 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/progress-snapshots.md +10 -6
- package/docs/qa-and-test-orders.md +230 -20
- package/docs/release-ledger-authoring-guide.md +70 -8
- 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/build-brief.mjs +6 -4
- package/src/built-script-syntax.mjs +480 -0
- package/src/built-site-scope.mjs +16 -4
- package/src/campaigns-api-key.mjs +99 -0
- package/src/cli-helpers.mjs +118 -0
- package/src/cli.mjs +1530 -7580
- package/src/commercial-parity.mjs +48 -2
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +898 -0
- package/src/deviation.mjs +13 -1
- package/src/diagnostic.mjs +6 -2
- package/src/directory-lock.mjs +270 -0
- package/src/doctor/checks.mjs +4654 -0
- package/src/doctor/inspect.mjs +678 -0
- package/src/doctor/next-step.mjs +731 -0
- package/src/doctor/source-provenance.mjs +184 -0
- package/src/install-invocation.mjs +29 -0
- package/src/invocation.mjs +183 -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/private-template-source.mjs +1 -1
- package/src/progress-node.mjs +9 -36
- package/src/proof-policy.mjs +1 -1
- package/src/qa-analytics-correctness.mjs +3 -0
- package/src/qa-binding-evidence.mjs +76 -11
- package/src/qa-browser.mjs +1316 -105
- package/src/qa-build-scope.mjs +47 -0
- package/src/qa-commercial-parity.mjs +48 -5
- package/src/qa-node.mjs +339 -19
- package/src/qa-test-order-topology.mjs +148 -0
- package/src/sdk-markup.mjs +72 -8
- package/src/source-html-intake.mjs +117 -1
- package/src/source-html-manifest.mjs +9 -2
- package/src/stage-ledger.mjs +28 -0
- package/src/stage-record.mjs +551 -0
- package/src/target-lock.mjs +54 -0
- package/src/template-brand-contract.mjs +17 -1
- 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,
|
|
@@ -55,6 +57,7 @@ import {
|
|
|
55
57
|
placeholderTextResidueMatches,
|
|
56
58
|
referencedDemoAssetBasenames,
|
|
57
59
|
summarizePlaceholderTerms,
|
|
60
|
+
withoutHiddenPaymentLogos,
|
|
58
61
|
} from "./template-brand-contract.mjs";
|
|
59
62
|
|
|
60
63
|
const DEFAULT_BROWSER_TIMEOUT_MS = 30000;
|
|
@@ -136,6 +139,7 @@ export async function runBrowserChecks(topologies, args = {}, options = {}) {
|
|
|
136
139
|
export async function runBrowserTestOrders(topologies, args = {}, runId = "local", options = {}) {
|
|
137
140
|
const checkoutPage = findPage(topologies, "checkout");
|
|
138
141
|
if (!checkoutPage?.url) {
|
|
142
|
+
const coverage = upsellActionCoverageAssertion({ topologies, orders: [], checkoutPage });
|
|
139
143
|
return {
|
|
140
144
|
orders: [],
|
|
141
145
|
receiptAnalytics: { plannedPlanIds: [], attempts: [] },
|
|
@@ -148,7 +152,7 @@ export async function runBrowserTestOrders(topologies, args = {}, runId = "local
|
|
|
148
152
|
severity: SEVERITY.BLOCKER,
|
|
149
153
|
expected: "checkout page URL",
|
|
150
154
|
actual: "missing",
|
|
151
|
-
})],
|
|
155
|
+
}), ...(coverage ? [coverage] : [])],
|
|
152
156
|
};
|
|
153
157
|
}
|
|
154
158
|
|
|
@@ -157,6 +161,8 @@ export async function runBrowserTestOrders(topologies, args = {}, runId = "local
|
|
|
157
161
|
// discover that the operator needs to raise --max-test-orders.
|
|
158
162
|
const plans = testOrderPlans(args["test-order"], topologies, args);
|
|
159
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));
|
|
160
166
|
const creationBudget = createOrderCreationBudget({ plans, args });
|
|
161
167
|
|
|
162
168
|
const browser = await launchChromium(args);
|
|
@@ -174,7 +180,7 @@ export async function runBrowserTestOrders(topologies, args = {}, runId = "local
|
|
|
174
180
|
runId,
|
|
175
181
|
// The topologies travel with the options so each attempt can resolve the
|
|
176
182
|
// funnel's cart-entry page for the checkout it drives (campaigns-os#206).
|
|
177
|
-
options: { ...options, creationBudget, topologies },
|
|
183
|
+
options: { ...options, creationBudget, topologies, orderPathPlan },
|
|
178
184
|
});
|
|
179
185
|
return {
|
|
180
186
|
orders: dispatched.orders,
|
|
@@ -214,6 +220,9 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
214
220
|
|
|
215
221
|
const assertions = [];
|
|
216
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 = [];
|
|
217
226
|
try {
|
|
218
227
|
for (let index = 0; index < plans.length; index += 1) {
|
|
219
228
|
const plan = plans[index];
|
|
@@ -223,6 +232,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
223
232
|
const pageForPlan = (typeof plan === "object" && plan?.checkout_page?.url) ? plan.checkout_page : checkoutPage;
|
|
224
233
|
const firstAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
|
|
225
234
|
orders.push(firstAttempt.order);
|
|
235
|
+
orderPlans.push(plan);
|
|
226
236
|
// Every attempt this path actually SUBMITTED, in order. The confirmed
|
|
227
237
|
// creation count is read from these and never from the deciding result
|
|
228
238
|
// alone: a recovered result is derived from the first attempt, so
|
|
@@ -256,6 +266,11 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
256
266
|
// reader could be surprised by. Over-charging costs at worst one unspent
|
|
257
267
|
// planned path; under-charging costs a real order nobody budgeted for.
|
|
258
268
|
creationBudget.consume({ plan_id: identifier, kind: "hosted_checkout_redirect" });
|
|
269
|
+
} else if (firstAttempt.upsell_unverified) {
|
|
270
|
+
// Created, and every check passed except an accepted upsell nothing
|
|
271
|
+
// could prove or disprove. A re-run would buy a second order to ask
|
|
272
|
+
// again, and recovery cannot re-check an upsell read-only, so the
|
|
273
|
+
// attempt stands and goes to manual review.
|
|
259
274
|
} else if (!firstAttempt.ok) {
|
|
260
275
|
const classification = classifyTestOrderCreation(firstAttempt);
|
|
261
276
|
if (classification.creation === "not_created") {
|
|
@@ -275,7 +290,10 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
275
290
|
try {
|
|
276
291
|
const rerunAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
|
|
277
292
|
attemptsForPlan.push(rerunAttempt);
|
|
278
|
-
if (rerunAttempt.order)
|
|
293
|
+
if (rerunAttempt.order) {
|
|
294
|
+
orders.push(rerunAttempt.order);
|
|
295
|
+
orderPlans.push(plan);
|
|
296
|
+
}
|
|
279
297
|
if (rerunAttempt.budget_exhausted) {
|
|
280
298
|
// The re-run stopped itself before its submit click, so it
|
|
281
299
|
// proved nothing. Letting it decide would erase this path's
|
|
@@ -383,6 +401,15 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
383
401
|
evidence: { planned_paths: plans.map((plan) => planId(plan)), completed_paths: orders.map((order) => order?.plan_id || order?.path) },
|
|
384
402
|
}));
|
|
385
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);
|
|
386
413
|
|
|
387
414
|
return { orders, assertions, receiptAnalytics, journeyAnalytics, creationBudget };
|
|
388
415
|
}
|
|
@@ -401,7 +428,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
401
428
|
// with --analytics-baseline's legacy receipt, and identity resolution cannot
|
|
402
429
|
// derive a receipt page yet (receipt-aware capture is out of packet 01's
|
|
403
430
|
// scope). Absent that override, the candidate IS the resolved target.
|
|
404
|
-
function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl }) {
|
|
431
|
+
function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl, capturePage = null, rootFallback = null }) {
|
|
405
432
|
const baselinePublicUrl = redactUrlQuery(baselineUrl);
|
|
406
433
|
const candidatePublicUrl = redactUrlQuery(candidateUrl);
|
|
407
434
|
const analyticsPage = { page_id: "analytics", url: candidatePublicUrl || baselinePublicUrl || undefined };
|
|
@@ -421,6 +448,8 @@ function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, ca
|
|
|
421
448
|
candidate_event_count: candidate.eventNames.length,
|
|
422
449
|
baseline_inventory: Object.fromEntries(Object.entries(baseline.inventory).map(([k, v]) => [k, v.length])),
|
|
423
450
|
candidate_inventory: Object.fromEntries(Object.entries(candidate.inventory).map(([k, v]) => [k, v.length])),
|
|
451
|
+
...(capturePage ? { capture_page: capturePage } : {}),
|
|
452
|
+
...(rootFallback ? { root_fallback: rootFallback } : {}),
|
|
424
453
|
},
|
|
425
454
|
}));
|
|
426
455
|
return assertions;
|
|
@@ -474,9 +503,14 @@ export async function runAnalyticsParityChecks(args = {}, options = {}) {
|
|
|
474
503
|
extraHTTPHeaders: args["auth-cookie"] ? { Cookie: String(args["auth-cookie"]) } : undefined,
|
|
475
504
|
});
|
|
476
505
|
try {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
506
|
+
// An explicit --analytics-candidate (the receipt pairing above) is the
|
|
507
|
+
// page the operator named, so it is captured as given.
|
|
508
|
+
if (trim(args["analytics-candidate"])) {
|
|
509
|
+
const baseline = await captureAnalyticsForUrl(context, baselineUrl, args, extraHosts);
|
|
510
|
+
const candidate = await captureAnalyticsForUrl(context, candidateUrl, args, extraHosts);
|
|
511
|
+
return analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl });
|
|
512
|
+
}
|
|
513
|
+
return await captureAnalyticsParityInContext(context, baselineUrl, candidateUrl, args, extraHosts, options);
|
|
480
514
|
} catch (error) {
|
|
481
515
|
return [analyticsParityRunnerFailureAssertion({ baselineUrl, candidateUrl, error })];
|
|
482
516
|
} finally {
|
|
@@ -485,16 +519,57 @@ export async function runAnalyticsParityChecks(args = {}, options = {}) {
|
|
|
485
519
|
}
|
|
486
520
|
}
|
|
487
521
|
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
522
|
+
// #503: the resolved-target candidate gets the same partial-scope fallback as
|
|
523
|
+
// the correctness inventory (#493). A partial build has no page at the
|
|
524
|
+
// identity root, so the candidate is the first built in-scope entry, and a
|
|
525
|
+
// root that answers non-2xx falls back the same way. The candidate is picked
|
|
526
|
+
// first: when nothing in scope answers, the leg reports
|
|
527
|
+
// no_in_scope_page_captured (skipped) or no_capture_page_answered (blocker)
|
|
528
|
+
// without loading the baseline, instead of diffing the baseline against an
|
|
529
|
+
// empty or generic page.
|
|
530
|
+
async function captureAnalyticsParityInContext(context, baselineUrl, targetUrl, args, extraHosts, options = {}) {
|
|
531
|
+
const selected = await captureFirstAnsweringAnalyticsPage(context, targetUrl, args, extraHosts, options);
|
|
532
|
+
if (!selected.capture) {
|
|
533
|
+
return [analyticsCorrectnessNoCapturePageAssertion({ rootUrl: targetUrl, attempts: selected.attempts, family: "analytics-parity" })];
|
|
534
|
+
}
|
|
535
|
+
const baseline = await captureAnalyticsForUrl(context, baselineUrl, args, extraHosts);
|
|
536
|
+
return analyticsParityCaptureAssertions({
|
|
537
|
+
baseline,
|
|
538
|
+
candidate: selected.capture,
|
|
539
|
+
baselineUrl,
|
|
540
|
+
candidateUrl: selected.url,
|
|
541
|
+
capturePage: selected.capturePage,
|
|
542
|
+
rootFallback: selected.rootFallback,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Analytics CORRECTNESS inventory leg: capture ONE page and assess only
|
|
547
|
+
// declared tags/pixels. Purchase is finalized later from the canonical
|
|
548
|
+
// typed-card order's recognized receipt; this inventory visit is never treated
|
|
549
|
+
// as Purchase authority.
|
|
492
550
|
// `options.target` is the capture target resolved from the campaign's
|
|
493
551
|
// identity (public_route_slug + route_root) in qa-node — packet 01 / INV-2:
|
|
494
552
|
// this leg no longer reads --analytics-candidate or --base-url; the URL it
|
|
495
553
|
// visits is a function of resolved identity, recorded on every assertion.
|
|
496
|
-
|
|
554
|
+
//
|
|
555
|
+
// #493: a partial build can have no page at that campaign root (the built
|
|
556
|
+
// entry is deeper, e.g. checkout/). When qa-node reports the root out of the
|
|
557
|
+
// built scope (`options.rootInScope === false`), or the root answers non-2xx,
|
|
558
|
+
// the leg captures the first built in-scope entry instead
|
|
559
|
+
// (`options.fallbackTargets`, the same entry the partial-scope planner
|
|
560
|
+
// selects) and records which page it used. When nothing in scope can be
|
|
561
|
+
// captured, the leg is skipped when the build has no capturable page, and
|
|
562
|
+
// fails as a blocker when candidates existed but none answered 2xx; neither
|
|
563
|
+
// case fails every declared vendor against an empty page.
|
|
564
|
+
// #500: the URL the capture page actually settled on, after redirects, keyed by
|
|
565
|
+
// the capture object so the parity capture shape stays unchanged. Local-serve
|
|
566
|
+
// review (qa-node) downgrades a silent pixel only when this is loopback: a
|
|
567
|
+
// localhost root that redirects to a production host measured production.
|
|
568
|
+
const ANALYTICS_CAPTURE_DOCUMENT_URL = new WeakMap();
|
|
569
|
+
|
|
570
|
+
function analyticsCorrectnessCaptureAssertions({ capture, contract, url, capturePage = null, rootFallback = null, finalUrl = ANALYTICS_CAPTURE_DOCUMENT_URL.get(capture) ?? null }) {
|
|
497
571
|
const publicUrl = redactUrlQuery(url);
|
|
572
|
+
const publicFinalUrl = redactUrlQuery(finalUrl) || null;
|
|
498
573
|
const analyticsPage = { page_id: "analytics", url: publicUrl || undefined };
|
|
499
574
|
const assertions = assessAnalyticsInventory(capture, contract || {}, { url: publicUrl });
|
|
500
575
|
assertions.unshift(assertion({
|
|
@@ -503,18 +578,63 @@ function analyticsCorrectnessCaptureAssertions({ capture, contract, url }) {
|
|
|
503
578
|
page: analyticsPage,
|
|
504
579
|
status: STATUS.PASS,
|
|
505
580
|
expected: "live dataLayer + tag-fire capture on the candidate page",
|
|
506
|
-
actual: `events=${capture.eventNames.length}, tags=${Object.values(capture.inventory).flat().length}
|
|
581
|
+
actual: `events=${capture.eventNames.length}, tags=${Object.values(capture.inventory).flat().length}`
|
|
582
|
+
+ (rootFallback ? ` (captured built entry ${publicUrl}; campaign root ${rootFallback.reason === "non_2xx" ? `answered HTTP ${rootFallback.http_status}` : "is out of the built scope"})` : ""),
|
|
507
583
|
// Counts only. Root capture is provider/tag inventory, never Purchase
|
|
508
584
|
// authority, even if a stray Purchase happens to appear there.
|
|
509
585
|
evidence: {
|
|
510
586
|
url: publicUrl,
|
|
511
587
|
event_count: capture.eventNames.length,
|
|
512
588
|
inventory: Object.fromEntries(Object.entries(capture.inventory).map(([k, v]) => [k, v.length])),
|
|
589
|
+
// The page URL after redirects and settling; `capture_page.url` and
|
|
590
|
+
// `url` are the URL requested.
|
|
591
|
+
final_url: publicFinalUrl,
|
|
592
|
+
...(capturePage ? { capture_page: capturePage } : {}),
|
|
593
|
+
...(rootFallback ? { root_fallback: rootFallback } : {}),
|
|
513
594
|
},
|
|
514
595
|
}));
|
|
515
596
|
return assertions;
|
|
516
597
|
}
|
|
517
598
|
|
|
599
|
+
// No page was captured. Two cases, kept apart so a failed capture never
|
|
600
|
+
// silently removes analytics gating:
|
|
601
|
+
// - nothing built was capturable (the root is out of the built scope and no
|
|
602
|
+
// built entry exists): SKIPPED, disposition-neutral, since there is no page
|
|
603
|
+
// whose tags could be measured;
|
|
604
|
+
// - candidates existed but none answered 2xx (e.g. transient 503s): the
|
|
605
|
+
// declared vendors went unmeasured, which is a blocker naming each attempt,
|
|
606
|
+
// so a later successful order cannot report the run ready.
|
|
607
|
+
// The parity leg (#503) reports the same two outcomes under its own family.
|
|
608
|
+
function analyticsCorrectnessNoCapturePageAssertion({ rootUrl, attempts, family = "analytics-correctness" }) {
|
|
609
|
+
const publicUrl = redactUrlQuery(rootUrl);
|
|
610
|
+
const page = { page_id: "analytics", url: publicUrl || undefined };
|
|
611
|
+
const expected = "live dataLayer + tag-fire capture on the campaign root or the first built in-scope page";
|
|
612
|
+
const loaded = attempts.filter((attempt) => attempt.outcome === "non_2xx" || attempt.outcome === "navigation_error");
|
|
613
|
+
if (!loaded.length) {
|
|
614
|
+
return assertion({
|
|
615
|
+
id: `${family}:capture`,
|
|
616
|
+
family,
|
|
617
|
+
page,
|
|
618
|
+
status: STATUS.SKIPPED,
|
|
619
|
+
expected,
|
|
620
|
+
actual: "no_in_scope_page_captured: the campaign root is out of the built scope and the build has no in-scope entry page to capture",
|
|
621
|
+
evidence: { url: publicUrl, reason: "no_in_scope_page_captured", attempts },
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
return assertion({
|
|
625
|
+
id: `${family}:capture`,
|
|
626
|
+
family,
|
|
627
|
+
page,
|
|
628
|
+
status: STATUS.FAIL,
|
|
629
|
+
severity: SEVERITY.BLOCKER,
|
|
630
|
+
expected,
|
|
631
|
+
actual: `no_capture_page_answered: declared analytics went unmeasured; ${loaded.map((attempt) => (attempt.outcome === "navigation_error"
|
|
632
|
+
? `${attempt.url} failed to load (${attempt.error_code})`
|
|
633
|
+
: `${attempt.url} answered HTTP ${attempt.http_status}`)).join(", ")}`,
|
|
634
|
+
evidence: { url: publicUrl, reason: "no_capture_page_answered", attempts },
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
518
638
|
function analyticsCorrectnessRunnerFailureAssertion({ url, error }) {
|
|
519
639
|
const publicUrl = redactUrlQuery(url);
|
|
520
640
|
const captureError = projectAnalyticsCaptureError(error, { fallbackKind: "unreadable" });
|
|
@@ -530,6 +650,147 @@ function analyticsCorrectnessRunnerFailureAssertion({ url, error }) {
|
|
|
530
650
|
});
|
|
531
651
|
}
|
|
532
652
|
|
|
653
|
+
function isHttpOk(status) {
|
|
654
|
+
// A null status (same-document or non-HTTP navigation) is not evidence of
|
|
655
|
+
// a missing page, so it keeps the capture.
|
|
656
|
+
return status == null || (status >= 200 && status < 300);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// One capture per page: `/campaign`, `/campaign/` and `/campaign/index.html`
|
|
660
|
+
// are the same page, so a topology URL written differently from the root is
|
|
661
|
+
// not loaded twice.
|
|
662
|
+
// #503: step routing is path-based in every certified family (page-kit builds
|
|
663
|
+
// each page to its own `<route>/index.html`; a spec route has its query
|
|
664
|
+
// stripped), so a query string does not normally name a different page. A
|
|
665
|
+
// URL that declares its own query (`/campaign/?step=checkout`) is still kept
|
|
666
|
+
// apart from a page without that query: merging it into the root on path alone
|
|
667
|
+
// measured the root's generic answer instead of that entry. A URL without a
|
|
668
|
+
// query of its own names the page on any query (an operator's `?preview=` on
|
|
669
|
+
// the root does not split it from the built page at that path).
|
|
670
|
+
function analyticsCapturePageKey(value) {
|
|
671
|
+
try {
|
|
672
|
+
const parsed = new URL(value);
|
|
673
|
+
const path = parsed.pathname.replace(/(?:^|\/)index\.html$/, "/").replace(/\/+$/, "");
|
|
674
|
+
parsed.searchParams.sort();
|
|
675
|
+
return { path: `${parsed.origin}${path}/`, query: parsed.searchParams.toString() };
|
|
676
|
+
} catch {
|
|
677
|
+
return typeof value === "string" && value.trim() ? { path: redactUrlQuery(value), query: "" } : null;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// True when `candidate` is the page `known` already names (see
|
|
682
|
+
// analyticsCapturePageKey). Shared with qa-node's root-in-scope judgment so
|
|
683
|
+
// scope and deduplication cannot disagree about which page is the root.
|
|
684
|
+
export function isSameAnalyticsCapturePage(candidate, known) {
|
|
685
|
+
const a = analyticsCapturePageKey(candidate);
|
|
686
|
+
const b = analyticsCapturePageKey(known);
|
|
687
|
+
if (!a || !b || a.path !== b.path) return false;
|
|
688
|
+
return !a.query || a.query === b.query;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// A URL on `root`'s path told apart from it by a query of its own: the entry
|
|
692
|
+
// `isSameAnalyticsCapturePage` keeps separate from the root although its
|
|
693
|
+
// redacted URL reads as the root.
|
|
694
|
+
function isQueryRoutedFrom(value, root) {
|
|
695
|
+
const a = analyticsCapturePageKey(value);
|
|
696
|
+
const b = analyticsCapturePageKey(root);
|
|
697
|
+
return !!a && !!b && a.path === b.path && !!a.query && a.query !== b.query;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function analyticsCaptureCandidates(url, options = {}) {
|
|
701
|
+
const candidates = [];
|
|
702
|
+
const seen = [];
|
|
703
|
+
const add = (candidate) => {
|
|
704
|
+
if (!candidate.url || seen.some((known) => isSameAnalyticsCapturePage(candidate.url, known))) return;
|
|
705
|
+
seen.push(candidate.url);
|
|
706
|
+
candidates.push(candidate);
|
|
707
|
+
};
|
|
708
|
+
const rootInScope = options.rootInScope !== false;
|
|
709
|
+
if (rootInScope) add({ url, source: "campaign_root" });
|
|
710
|
+
// An out-of-scope root stays unvisited even if a fallback names it.
|
|
711
|
+
else if (url) seen.push(url);
|
|
712
|
+
for (const entry of Array.isArray(options.fallbackTargets) ? options.fallbackTargets : []) {
|
|
713
|
+
if (!entry || !trim(entry.url)) continue;
|
|
714
|
+
add({
|
|
715
|
+
url: trim(entry.url),
|
|
716
|
+
source: "built_entry",
|
|
717
|
+
page_id: entry.page_id || null,
|
|
718
|
+
funnel_id: entry.funnel_id || null,
|
|
719
|
+
// The query value is redacted from every record; this says the entry
|
|
720
|
+
// was told apart from the root by its query alone.
|
|
721
|
+
...(url && isQueryRoutedFrom(trim(entry.url), url) ? { query_routed: true } : {}),
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
return { candidates, rootInScope, rootKey: redactUrlQuery(url) };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Walks the capture candidates (the campaign root when in scope, then the
|
|
728
|
+
// built entries) and captures the first page that answers 2xx. Shared by the
|
|
729
|
+
// correctness inventory (#493) and the opt-in parity candidate (#503) so both
|
|
730
|
+
// legs pick the same page. Returns `{ capture, url, capturePage, rootFallback }`
|
|
731
|
+
// for the page it used, or `{ capture: null, attempts }` when none answered.
|
|
732
|
+
async function captureFirstAnsweringAnalyticsPage(context, url, args, extraHosts, options = {}, { strictCollect = false } = {}) {
|
|
733
|
+
const { candidates, rootInScope, rootKey } = analyticsCaptureCandidates(url, options);
|
|
734
|
+
const attempts = [];
|
|
735
|
+
if (!rootInScope) attempts.push({ url: rootKey, source: "campaign_root", outcome: "out_of_built_scope", http_status: null });
|
|
736
|
+
let rootFallback = rootInScope ? null : { url: rootKey, reason: "out_of_built_scope", http_status: null };
|
|
737
|
+
const queryRouted = (candidate) => (candidate.query_routed ? { query_routed: true } : {});
|
|
738
|
+
for (const candidate of candidates) {
|
|
739
|
+
const { capture, httpStatus, navigationError } = await captureAnalyticsPage(
|
|
740
|
+
context, candidate.url, args, extraHosts, { perPageNavigationErrors: true, strictCollect },
|
|
741
|
+
);
|
|
742
|
+
if (navigationError) {
|
|
743
|
+
// One page failing to load (a timeout, a refused connection) moves on to
|
|
744
|
+
// the next candidate; the blocker lists it if none answers.
|
|
745
|
+
attempts.push({ url: redactUrlQuery(candidate.url), source: candidate.source, ...queryRouted(candidate), outcome: "navigation_error", http_status: null, error_code: navigationError });
|
|
746
|
+
if (candidate.source === "campaign_root") {
|
|
747
|
+
rootFallback = { url: rootKey, reason: "navigation_error", http_status: null, error_code: navigationError };
|
|
748
|
+
}
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
if (isHttpOk(httpStatus)) {
|
|
752
|
+
const usedFallback = candidate.source !== "campaign_root";
|
|
753
|
+
return {
|
|
754
|
+
capture,
|
|
755
|
+
url: candidate.url,
|
|
756
|
+
capturePage: {
|
|
757
|
+
url: redactUrlQuery(candidate.url),
|
|
758
|
+
source: candidate.source,
|
|
759
|
+
...(candidate.page_id ? { page_id: candidate.page_id } : {}),
|
|
760
|
+
...(candidate.funnel_id ? { funnel_id: candidate.funnel_id } : {}),
|
|
761
|
+
...queryRouted(candidate),
|
|
762
|
+
http_status: httpStatus ?? null,
|
|
763
|
+
},
|
|
764
|
+
rootFallback: usedFallback ? rootFallback : null,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
attempts.push({ url: redactUrlQuery(candidate.url), source: candidate.source, ...queryRouted(candidate), outcome: "non_2xx", http_status: httpStatus });
|
|
768
|
+
if (candidate.source === "campaign_root") {
|
|
769
|
+
rootFallback = { url: rootKey, reason: "non_2xx", http_status: httpStatus };
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return { capture: null, attempts };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Browser-owning half split out so a real-browser test can drive the fallback
|
|
776
|
+
// against a route-fulfilled context without a second Chromium launch policy.
|
|
777
|
+
async function captureAnalyticsCorrectnessInContext(context, url, contract, args, extraHosts, options = {}) {
|
|
778
|
+
// strictCollect: a page.evaluate() that fails during collection (the
|
|
779
|
+
// execution context destroyed by a reload, a crashed page) must not read
|
|
780
|
+
// as a clean empty capture. It propagates and becomes the
|
|
781
|
+
// analytics-correctness:runner blocker, so no tag check is emitted from an
|
|
782
|
+
// unmeasured page and local-serve review has nothing to downgrade (#500).
|
|
783
|
+
const selected = await captureFirstAnsweringAnalyticsPage(context, url, args, extraHosts, options, { strictCollect: true });
|
|
784
|
+
if (!selected.capture) return [analyticsCorrectnessNoCapturePageAssertion({ rootUrl: url, attempts: selected.attempts })];
|
|
785
|
+
return analyticsCorrectnessCaptureAssertions({
|
|
786
|
+
capture: selected.capture,
|
|
787
|
+
contract,
|
|
788
|
+
url: selected.url,
|
|
789
|
+
capturePage: selected.capturePage,
|
|
790
|
+
rootFallback: selected.rootFallback,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
533
794
|
export async function runAnalyticsCorrectnessChecks(args = {}, contract = {}, options = {}) {
|
|
534
795
|
const url = trim(options.target?.url) || null;
|
|
535
796
|
const correctnessPage = { page_id: "analytics", url: redactUrlQuery(url) || undefined };
|
|
@@ -545,12 +806,7 @@ export async function runAnalyticsCorrectnessChecks(args = {}, contract = {}, op
|
|
|
545
806
|
})];
|
|
546
807
|
}
|
|
547
808
|
|
|
548
|
-
|
|
549
|
-
// host contains their name (everflow, northbeam, …) get captured.
|
|
550
|
-
const vendorHosts = ((contract && contract.out_of_band_pixels) || [])
|
|
551
|
-
.map((p) => (p && p.vendor ? String(p.vendor) : null))
|
|
552
|
-
.filter(Boolean);
|
|
553
|
-
const extraHosts = [...analyticsExtraHosts(args), ...vendorHosts];
|
|
809
|
+
const extraHosts = analyticsCorrectnessExtraHosts(args, contract);
|
|
554
810
|
|
|
555
811
|
const browser = await launchChromium(args);
|
|
556
812
|
const context = await browser.newContext({
|
|
@@ -558,8 +814,7 @@ export async function runAnalyticsCorrectnessChecks(args = {}, contract = {}, op
|
|
|
558
814
|
extraHTTPHeaders: args["auth-cookie"] ? { Cookie: String(args["auth-cookie"]) } : undefined,
|
|
559
815
|
});
|
|
560
816
|
try {
|
|
561
|
-
|
|
562
|
-
return analyticsCorrectnessCaptureAssertions({ capture, contract, url });
|
|
817
|
+
return await captureAnalyticsCorrectnessInContext(context, url, contract, args, extraHosts, options);
|
|
563
818
|
} catch (error) {
|
|
564
819
|
return [analyticsCorrectnessRunnerFailureAssertion({ url, error })];
|
|
565
820
|
} finally {
|
|
@@ -568,6 +823,15 @@ export async function runAnalyticsCorrectnessChecks(args = {}, contract = {}, op
|
|
|
568
823
|
}
|
|
569
824
|
}
|
|
570
825
|
|
|
826
|
+
// Seed the host filter with declared out-of-band vendor names so vendors whose
|
|
827
|
+
// host contains their name (everflow, northbeam, …) get captured.
|
|
828
|
+
function analyticsCorrectnessExtraHosts(args, contract) {
|
|
829
|
+
const vendorHosts = ((contract && contract.out_of_band_pixels) || [])
|
|
830
|
+
.map((p) => (p && p.vendor ? String(p.vendor) : null))
|
|
831
|
+
.filter(Boolean);
|
|
832
|
+
return [...analyticsExtraHosts(args), ...vendorHosts];
|
|
833
|
+
}
|
|
834
|
+
|
|
571
835
|
function analyticsExtraHosts(args) {
|
|
572
836
|
const raw = args["analytics-hosts"];
|
|
573
837
|
if (!raw) return [];
|
|
@@ -575,6 +839,22 @@ function analyticsExtraHosts(args) {
|
|
|
575
839
|
}
|
|
576
840
|
|
|
577
841
|
export async function captureAnalyticsForUrl(context, url, args, extraHosts = []) {
|
|
842
|
+
return (await captureAnalyticsPage(context, url, args, extraHosts)).capture;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Same capture, plus the main-document HTTP status, which the correctness leg
|
|
846
|
+
// uses to tell an empty page from a missing one (#493). Kept off the capture
|
|
847
|
+
// object so parity comparisons never see it.
|
|
848
|
+
// A stable code for a failed navigation: Playwright's TimeoutError, or the
|
|
849
|
+
// net::ERR_* token Chromium reports. The raw message is dropped because it
|
|
850
|
+
// echoes the URL (query included) and a call log.
|
|
851
|
+
function analyticsNavigationErrorCode(error) {
|
|
852
|
+
if (error?.name === "TimeoutError") return "navigation_timeout";
|
|
853
|
+
const netError = String(error?.message || "").match(/net::ERR_[A-Z0-9_]+/);
|
|
854
|
+
return netError ? netError[0] : "navigation_failed";
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function captureAnalyticsPage(context, url, args, extraHosts = [], options = {}) {
|
|
578
858
|
const page = await context.newPage();
|
|
579
859
|
const capture = await attachAnalyticsCapture(page, { extraHosts });
|
|
580
860
|
const timeoutMs = numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS);
|
|
@@ -583,11 +863,27 @@ export async function captureAnalyticsForUrl(context, url, args, extraHosts = []
|
|
|
583
863
|
// domcontentloaded (not "load") so a single stuck analytics beacon — exactly
|
|
584
864
|
// the kind of subresource we're capturing — can't starve the goto timeout.
|
|
585
865
|
// Mirrors runPageBrowserChecks; the settle wait below lets async tags fire.
|
|
586
|
-
|
|
866
|
+
let response;
|
|
867
|
+
try {
|
|
868
|
+
response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: timeoutMs });
|
|
869
|
+
} catch (error) {
|
|
870
|
+
// Only the navigation is per-page. A closed page or a disconnected
|
|
871
|
+
// browser is the runner failing, not this URL, so it stays an error.
|
|
872
|
+
if (!options.perPageNavigationErrors || page.isClosed() || context.browser()?.isConnected() === false) throw error;
|
|
873
|
+
return { capture: null, httpStatus: null, navigationError: analyticsNavigationErrorCode(error) };
|
|
874
|
+
}
|
|
875
|
+
let httpStatus = null;
|
|
876
|
+
try { httpStatus = typeof response?.status === "function" ? response.status() : null; } catch { httpStatus = null; }
|
|
587
877
|
await page.waitForLoadState("networkidle", { timeout: settleMs }).catch(() => {});
|
|
588
878
|
// Let async GTM/pixel tags and deferred dataLayer pushes fire before reading.
|
|
589
879
|
await page.waitForTimeout(settleMs);
|
|
590
|
-
|
|
880
|
+
// Parity keeps the historical best-effort read; the correctness leg
|
|
881
|
+
// collects strictly (see captureAnalyticsCorrectnessInContext).
|
|
882
|
+
const collected = await capture.collect({ strict: options.strictCollect === true });
|
|
883
|
+
let finalUrl = null;
|
|
884
|
+
try { finalUrl = page.url() || null; } catch { finalUrl = null; }
|
|
885
|
+
if (collected && typeof collected === "object" && finalUrl) ANALYTICS_CAPTURE_DOCUMENT_URL.set(collected, finalUrl);
|
|
886
|
+
return { capture: collected, httpStatus };
|
|
591
887
|
} finally {
|
|
592
888
|
capture.detach();
|
|
593
889
|
await page.close().catch(() => {});
|
|
@@ -637,7 +933,9 @@ async function runPageBrowserChecks(context, page, args, options = {}) {
|
|
|
637
933
|
const pageErrors = [];
|
|
638
934
|
const failedRequests = [];
|
|
639
935
|
browserPage.on("console", (message) => {
|
|
640
|
-
|
|
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 });
|
|
641
939
|
});
|
|
642
940
|
browserPage.on("pageerror", (error) => pageErrors.push(trim(error.message)));
|
|
643
941
|
browserPage.on("requestfailed", (request) => {
|
|
@@ -744,16 +1042,65 @@ function isIgnorableFailedRequest(request) {
|
|
|
744
1042
|
}
|
|
745
1043
|
}
|
|
746
1044
|
|
|
1045
|
+
// `messages` are { text, url } records (url: the console message's location);
|
|
1046
|
+
// the result is the actionable messages' text.
|
|
747
1047
|
async function actionableRuntimeConsoleErrors(browserPage, messages) {
|
|
748
1048
|
if (!messages.length) return [];
|
|
749
1049
|
const runtimeReady = await browserPage.evaluate(() => (
|
|
750
1050
|
document.documentElement.classList.contains("next-display-ready")
|
|
751
1051
|
|| Boolean(window.next && Object.keys(window.next).length)
|
|
752
1052
|
)).catch(() => false);
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
+
}
|
|
757
1104
|
}
|
|
758
1105
|
|
|
759
1106
|
function isKnownSdkLoaderFalsePositive(message) {
|
|
@@ -1211,13 +1558,126 @@ async function checkoutCommerceStructureAssertions(browserPage, page) {
|
|
|
1211
1558
|
}
|
|
1212
1559
|
|
|
1213
1560
|
const evidence = await inspectCommerceStructure(browserPage, contract);
|
|
1561
|
+
const behaviour = await inspectCheckoutBehaviour(browserPage);
|
|
1214
1562
|
return [commerceStructureAssertionFromEvidence(page, {
|
|
1215
1563
|
template_family: family,
|
|
1216
1564
|
contract_status: contractStatus,
|
|
1217
1565
|
...evidence,
|
|
1566
|
+
behaviour,
|
|
1218
1567
|
})];
|
|
1219
1568
|
}
|
|
1220
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
|
+
|
|
1221
1681
|
async function inspectCommerceStructure(browserPage, contract) {
|
|
1222
1682
|
const safeContract = isPlainObject(contract) ? contract : {};
|
|
1223
1683
|
const checks = await browserPage.evaluate((input) => {
|
|
@@ -1302,18 +1762,28 @@ function commerceStructureAssertionFromEvidence(page, evidence) {
|
|
|
1302
1762
|
evidence,
|
|
1303
1763
|
});
|
|
1304
1764
|
}
|
|
1305
|
-
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(", ")}`;
|
|
1306
1774
|
return assertion({
|
|
1307
1775
|
id: `browser-commerce-structure:${page.page_id}`,
|
|
1308
1776
|
family: "browser-runtime",
|
|
1309
1777
|
page,
|
|
1310
|
-
status: failed.length ? STATUS.
|
|
1778
|
+
status: !failed.length ? STATUS.PASS : relaxed ? STATUS.WARN : STATUS.FAIL,
|
|
1311
1779
|
severity: failed.length ? SEVERITY.WARN : undefined,
|
|
1312
1780
|
expected: "rendered checkout conforms to the selected template-family commerce structure contract",
|
|
1313
|
-
actual: failed.length
|
|
1314
|
-
? `${checks.length
|
|
1315
|
-
:
|
|
1316
|
-
|
|
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 },
|
|
1317
1787
|
});
|
|
1318
1788
|
}
|
|
1319
1789
|
|
|
@@ -1732,7 +2202,9 @@ async function templateResidueAssertions(browserPage, page, options = {}) {
|
|
|
1732
2202
|
if (chrome && Array.isArray(supported) && supported.length) {
|
|
1733
2203
|
const unsupported = (chrome.methods || []).filter((method) => !supported.includes(method));
|
|
1734
2204
|
if (unsupported.length) {
|
|
1735
|
-
|
|
2205
|
+
// Logos the template still keeps hidden (payment-logos.html) are gated,
|
|
2206
|
+
// not residue; a revealed one stays in the HTML and is judged below.
|
|
2207
|
+
const html = withoutHiddenPaymentLogos(await browserPage.content().catch(() => ""));
|
|
1736
2208
|
// One evaluate for ALL unsupported methods' selectors; partition the
|
|
1737
2209
|
// visibility results per method in JS to keep browser round-trips flat.
|
|
1738
2210
|
const artifactsByMethod = new Map(unsupported.map((method) => [method, methodPaymentArtifacts(chrome, method)]));
|
|
@@ -2313,13 +2785,13 @@ async function pricingVisibilityAssertions(browserPage, page, options = {}) {
|
|
|
2313
2785
|
if (!surfaces) return [];
|
|
2314
2786
|
const pageType = contractPageType(page);
|
|
2315
2787
|
if (["upsell", "downsell"].includes(pageType)) {
|
|
2316
|
-
const selectors = surfaces.upsell?.price_row_selectors
|
|
2788
|
+
const selectors = withSdkPriceDisplaySelectors(surfaces.upsell?.price_row_selectors);
|
|
2317
2789
|
if (!selectors.length) return [];
|
|
2318
2790
|
const visibleCount = await countVisiblePriceRows(browserPage, selectors);
|
|
2319
2791
|
return [upsellPriceVisibilityAssertion({ page, selectors, visibleCount })];
|
|
2320
2792
|
}
|
|
2321
2793
|
if (pageType === "checkout") {
|
|
2322
|
-
const selectors = surfaces.checkout_bundle?.price_row_selectors
|
|
2794
|
+
const selectors = withSdkPriceDisplaySelectors(surfaces.checkout_bundle?.price_row_selectors);
|
|
2323
2795
|
// Two price surfaces satisfy this check. The contract's bundle price rows
|
|
2324
2796
|
// are one; the rendered cart-summary total is the other — the same
|
|
2325
2797
|
// selectors the order-total parity check reads at submit. A family that
|
|
@@ -2338,11 +2810,29 @@ async function pricingVisibilityAssertions(browserPage, page, options = {}) {
|
|
|
2338
2810
|
return [];
|
|
2339
2811
|
}
|
|
2340
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
|
+
|
|
2341
2825
|
// Visible = non-zero bounding box, display != none, visibility != hidden. This
|
|
2342
2826
|
// is what caught nothing in the dogfood run: a campaign CSS rule display:none'd
|
|
2343
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.
|
|
2344
2833
|
async function countVisiblePriceRows(browserPage, selectors) {
|
|
2345
|
-
return browserPage.evaluate((targets) => {
|
|
2834
|
+
return browserPage.evaluate(({ targets, bundlePriceSelector }) => {
|
|
2835
|
+
const textRequired = (element) => element.matches(bundlePriceSelector);
|
|
2346
2836
|
const visible = (element) => {
|
|
2347
2837
|
const rect = element.getBoundingClientRect();
|
|
2348
2838
|
const style = getComputedStyle(element);
|
|
@@ -2355,14 +2845,16 @@ async function countVisiblePriceRows(browserPage, selectors) {
|
|
|
2355
2845
|
for (const element of document.querySelectorAll(selector)) {
|
|
2356
2846
|
if (seen.has(element)) continue;
|
|
2357
2847
|
seen.add(element);
|
|
2358
|
-
if (visible(element))
|
|
2848
|
+
if (!visible(element)) continue;
|
|
2849
|
+
if (textRequired(element) && !(element.textContent || "").trim()) continue;
|
|
2850
|
+
count += 1;
|
|
2359
2851
|
}
|
|
2360
2852
|
} catch {
|
|
2361
2853
|
// invalid selector: contract bug surfaced elsewhere
|
|
2362
2854
|
}
|
|
2363
2855
|
}
|
|
2364
2856
|
return count;
|
|
2365
|
-
}, selectors).catch(() => 0);
|
|
2857
|
+
}, { targets: selectors, bundlePriceSelector: SDK_BUNDLE_PRICE_DISPLAY_SELECTOR }).catch(() => 0);
|
|
2366
2858
|
}
|
|
2367
2859
|
|
|
2368
2860
|
// Page-scoped like every other per-page row (`template-residue:<page>:…`,
|
|
@@ -2776,6 +3268,11 @@ async function collectOrderAnalytics({
|
|
|
2776
3268
|
deadline,
|
|
2777
3269
|
now = () => Date.now(),
|
|
2778
3270
|
wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
3271
|
+
// The page's document location, read right after the settled collection
|
|
3272
|
+
// (#500). The order's final_url is recorded before this settle window, so a
|
|
3273
|
+
// receipt that redirects while analytics settle would otherwise be judged
|
|
3274
|
+
// by where it started, not where Purchase was measured.
|
|
3275
|
+
readDocumentUrl = null,
|
|
2779
3276
|
}) {
|
|
2780
3277
|
const result = {};
|
|
2781
3278
|
if (!captureHandle) return result;
|
|
@@ -2806,14 +3303,17 @@ async function collectOrderAnalytics({
|
|
|
2806
3303
|
|
|
2807
3304
|
const collection = deadlineConsumed
|
|
2808
3305
|
? { timedOut: true }
|
|
2809
|
-
: await runWithinAnalyticsDeadline(async () =>
|
|
2810
|
-
typeof captureHandle.collectScopes === "function"
|
|
2811
|
-
? captureHandle.collectScopes({ strict: true })
|
|
3306
|
+
: await runWithinAnalyticsDeadline(async () => {
|
|
3307
|
+
const scopes = typeof captureHandle.collectScopes === "function"
|
|
3308
|
+
? await captureHandle.collectScopes({ strict: true })
|
|
2812
3309
|
: {
|
|
2813
3310
|
journey: await captureHandle.collect({ strict: true }),
|
|
2814
3311
|
currentDocument: await captureHandle.collect({ strict: true, scope: "current-document" }),
|
|
2815
|
-
}
|
|
2816
|
-
|
|
3312
|
+
};
|
|
3313
|
+
let documentUrl = null;
|
|
3314
|
+
try { documentUrl = typeof readDocumentUrl === "function" ? (readDocumentUrl() || null) : null; } catch { documentUrl = null; }
|
|
3315
|
+
return { ...scopes, documentUrl };
|
|
3316
|
+
}, { deadline, now });
|
|
2817
3317
|
if (collection.timedOut || now() > deadline) {
|
|
2818
3318
|
result.journeyCaptureError = analyticsCaptureError("collectionDeadline");
|
|
2819
3319
|
if (receiptRecognized && !settleError) {
|
|
@@ -2824,7 +3324,10 @@ async function collectOrderAnalytics({
|
|
|
2824
3324
|
if (receiptRecognized && !settleError) result.receiptCaptureError = analyticsCaptureError("unreadable");
|
|
2825
3325
|
} else {
|
|
2826
3326
|
result.journeyCapture = collection.value.journey;
|
|
2827
|
-
if (receiptRecognized && !settleError)
|
|
3327
|
+
if (receiptRecognized && !settleError) {
|
|
3328
|
+
result.receiptCapture = collection.value.currentDocument;
|
|
3329
|
+
if (collection.value.documentUrl) result.receiptDocumentUrl = collection.value.documentUrl;
|
|
3330
|
+
}
|
|
2828
3331
|
}
|
|
2829
3332
|
if (receiptRecognized && settleError) result.receiptCaptureError = settleError;
|
|
2830
3333
|
return result;
|
|
@@ -2952,11 +3455,13 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
|
|
|
2952
3455
|
receiptRecognized,
|
|
2953
3456
|
settleMs: numberArg(planArgs["analytics-settle"], DEFAULT_SETTLE_TIMEOUT_MS),
|
|
2954
3457
|
deadline: orderDeadline ?? Date.now(),
|
|
3458
|
+
readDocumentUrl: () => (page ? safePageUrl(page) : null),
|
|
2955
3459
|
});
|
|
2956
3460
|
if (captures.journeyCapture) result.analytics_journey_capture = captures.journeyCapture;
|
|
2957
3461
|
if (captures.journeyCaptureError) result.analytics_journey_capture_error = captures.journeyCaptureError;
|
|
2958
3462
|
if (captures.receiptCapture) result.receipt_analytics_capture = captures.receiptCapture;
|
|
2959
3463
|
if (captures.receiptCaptureError) result.receipt_analytics_capture_error = captures.receiptCaptureError;
|
|
3464
|
+
if (captures.receiptDocumentUrl) result.receipt_document_url = captures.receiptDocumentUrl;
|
|
2960
3465
|
} else if (analyticsAttachError) {
|
|
2961
3466
|
result.analytics_journey_capture_error = analyticsAttachError;
|
|
2962
3467
|
if (receiptRecognized) result.receipt_analytics_capture_error = analyticsAttachError;
|
|
@@ -3089,6 +3594,11 @@ function receiptAnalyticsAttempt(plan, result) {
|
|
|
3089
3594
|
planId: id,
|
|
3090
3595
|
receiptRecognized,
|
|
3091
3596
|
receiptUrl: redactUrlQuery(finalUrl),
|
|
3597
|
+
// Where the receipt page actually was once its analytics settled and were
|
|
3598
|
+
// collected; absent when that reading was not taken.
|
|
3599
|
+
...(receiptRecognized && result?.receipt_document_url
|
|
3600
|
+
? { receiptDocumentUrl: redactUrlQuery(result.receipt_document_url) }
|
|
3601
|
+
: {}),
|
|
3092
3602
|
...(receiptRecognized && result?.receipt_analytics_capture
|
|
3093
3603
|
? { capture: result.receipt_analytics_capture }
|
|
3094
3604
|
: {}),
|
|
@@ -3260,19 +3770,29 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3260
3770
|
}
|
|
3261
3771
|
const step = upsellSteps[stepIndex];
|
|
3262
3772
|
const actionTrace = createUpsellActionTrace({ page, events, topologyPlan, stepIndex, path: step });
|
|
3773
|
+
const stepBudgetMs = budget();
|
|
3774
|
+
const stepStartedMs = Date.now();
|
|
3263
3775
|
await ladder.run("upsell_action", async () => {
|
|
3264
3776
|
const initialLineItems = order.receipt_line_items.slice();
|
|
3265
3777
|
const initialUpsellMutationCount = upsellMutationCount(events);
|
|
3266
3778
|
await actionTrace.inspect();
|
|
3267
3779
|
await waitForUpsellPageReady(page, args);
|
|
3268
3780
|
await actionTrace.inspect();
|
|
3781
|
+
const responseIndexBefore = events.responses.length;
|
|
3269
3782
|
const upsell = await clickUpsellPath(page, step, { events, stepIndex, trace: actionTrace });
|
|
3270
3783
|
const preferredOrderBody = upsell.api_response_order_body || null;
|
|
3271
3784
|
delete upsell.api_response_order_body;
|
|
3272
3785
|
order.upsell = upsell;
|
|
3273
3786
|
order.upsell_steps.push(upsell);
|
|
3274
3787
|
order.final_url = safePageUrl(page);
|
|
3275
|
-
const refreshed = await
|
|
3788
|
+
const { refreshed, lateUpsellEvidence } = await refreshUpsellStepEvidence({
|
|
3789
|
+
page, events, path, email, checkoutPage, args, step, upsell, preferredOrderBody, initialLineItems, responseIndexBefore,
|
|
3790
|
+
// Keep the rest of the step's budget for the read-back after the wait.
|
|
3791
|
+
lateWaitMs: Math.min(
|
|
3792
|
+
LATE_UPSELL_EVIDENCE_TIMEOUT_MS,
|
|
3793
|
+
stepBudgetMs - (Date.now() - stepStartedMs) - LATE_UPSELL_EVIDENCE_RESERVE_MS,
|
|
3794
|
+
),
|
|
3795
|
+
});
|
|
3276
3796
|
order.final_receipt_line_items = refreshed.receipt_line_items;
|
|
3277
3797
|
if (refreshed.receipt_line_items.length) {
|
|
3278
3798
|
order.cart_state = refreshed.cart_state;
|
|
@@ -3284,9 +3804,9 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3284
3804
|
order.verification.currency = refreshed.verification.currency;
|
|
3285
3805
|
}
|
|
3286
3806
|
if (step === "accept") {
|
|
3287
|
-
const proof = acceptedUpsellProof(order.receipt_line_items, initialLineItems, upsell.expected_items, events);
|
|
3807
|
+
const proof = acceptedUpsellStepProof(upsell, lateUpsellEvidence, acceptedUpsellProof(order.receipt_line_items, initialLineItems, upsell.expected_items, events));
|
|
3288
3808
|
upsell.verification = {
|
|
3289
|
-
accepted_upsell_line_present: proof.ok,
|
|
3809
|
+
accepted_upsell_line_present: proof.unverified ? null : proof.ok,
|
|
3290
3810
|
accepted_upsell_match: proof,
|
|
3291
3811
|
upsell_api_response_seen: upsell.api_response_seen,
|
|
3292
3812
|
upsell_api_response_status: upsell.api_response_status,
|
|
@@ -3304,7 +3824,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3304
3824
|
actionTrace.markStepCompleted();
|
|
3305
3825
|
return `step ${stepIndex + 1}: ${step}`;
|
|
3306
3826
|
}, {
|
|
3307
|
-
timeoutMs:
|
|
3827
|
+
timeoutMs: stepBudgetMs,
|
|
3308
3828
|
evidence: actionTrace.summary,
|
|
3309
3829
|
formatError: actionTrace.formatError,
|
|
3310
3830
|
});
|
|
@@ -3336,7 +3856,19 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3336
3856
|
|
|
3337
3857
|
const acceptedSteps = (order.upsell_steps || []).filter((step) => step.path === "accept");
|
|
3338
3858
|
if (acceptedSteps.length) {
|
|
3339
|
-
|
|
3859
|
+
const unverifiedSteps = acceptedSteps.filter((step) => step.verification?.accepted_upsell_match?.unverified === true);
|
|
3860
|
+
const confirmedMissing = acceptedSteps.some((step) => step.verification?.accepted_upsell_line_present === false);
|
|
3861
|
+
// Unknown, not false, when every step that is not proven is only
|
|
3862
|
+
// unverified: false would read as a confirmed missing line.
|
|
3863
|
+
order.verification.accepted_upsell_line_present = unverifiedSteps.length && !confirmedMissing
|
|
3864
|
+
? null
|
|
3865
|
+
: acceptedSteps.every((step) => step.verification?.accepted_upsell_line_present === true);
|
|
3866
|
+
if (unverifiedSteps.length) {
|
|
3867
|
+
order.verification.upsell_unverified = unverifiedSteps.map((step) => step.verification.accepted_upsell_match.reason);
|
|
3868
|
+
// Whatever else the path finds, an order whose accepted upsell nothing
|
|
3869
|
+
// proved is not a verified order (#505).
|
|
3870
|
+
order.verification.verified = false;
|
|
3871
|
+
}
|
|
3340
3872
|
order.verification.upsell_api_response_seen = acceptedSteps.every((step) => step.verification?.upsell_api_response_seen === true);
|
|
3341
3873
|
order.verification.accepted_upsell_matches = acceptedSteps.map((step) => step.verification?.accepted_upsell_match).filter(Boolean);
|
|
3342
3874
|
}
|
|
@@ -3362,10 +3894,20 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3362
3894
|
}
|
|
3363
3895
|
|
|
3364
3896
|
const pathFailures = [...stepFailures, ...receiptFailures, ...couponFailures];
|
|
3365
|
-
const
|
|
3897
|
+
const clean = order.ok && pathFailures.length === 0;
|
|
3898
|
+
// A path whose only open question is an unverified accepted upsell is not
|
|
3899
|
+
// a pass: nothing proved the line was added. `ok` stays false so no reader
|
|
3900
|
+
// of the result or the order takes it as proven, and `upsell_unverified`
|
|
3901
|
+
// tells it apart from a failure: the dispatcher neither re-runs it (that
|
|
3902
|
+
// would buy a second order) nor recovers it, and the assertion reports it
|
|
3903
|
+
// for manual review (#505). The order itself was created and read back, so
|
|
3904
|
+
// order.ok keeps saying so; only its verification drops to unverified.
|
|
3905
|
+
const upsellUnverified = clean ? order.verification.upsell_unverified || null : null;
|
|
3906
|
+
const ok = clean && !upsellUnverified;
|
|
3366
3907
|
return {
|
|
3367
3908
|
ok,
|
|
3368
|
-
|
|
3909
|
+
...(upsellUnverified ? { upsell_unverified: upsellUnverified } : {}),
|
|
3910
|
+
error: clean ? null : order.error || order.upsell?.error || pathFailures.join("; ") || "accepted upsell did not appear in final order lines",
|
|
3369
3911
|
order,
|
|
3370
3912
|
events: sanitizedEvents(events),
|
|
3371
3913
|
};
|
|
@@ -3466,10 +4008,8 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
3466
4008
|
// The index is the control's position among what its own locator matches,
|
|
3467
4009
|
// so it replays with nth(); no selector is rebuilt from an attribute value.
|
|
3468
4010
|
const target = page.locator(control.kind === "add_to_cart" ? CART_ENTRY_CONTROL_SELECTOR : "a[href]").nth(control.index);
|
|
3469
|
-
await target
|
|
3470
|
-
await target
|
|
3471
|
-
await target.click({ force: true, timeout: 8000 });
|
|
3472
|
-
});
|
|
4011
|
+
const perpetual = await scrollControlIntoView(target);
|
|
4012
|
+
await clickControl(target, { timeout: 8000, perpetual });
|
|
3473
4013
|
|
|
3474
4014
|
// The SDK owns the navigation (data-next-url, or the link the SDK reads
|
|
3475
4015
|
// forcePackageId from on arrival). Waiting for the URL is what proves the
|
|
@@ -3763,8 +4303,8 @@ async function selectRequestedCart(page, args) {
|
|
|
3763
4303
|
for (const item of cart) {
|
|
3764
4304
|
const target = page.locator(packageCardClickSelector({ package_id: item.packageId })).first();
|
|
3765
4305
|
if (await target.count().catch(() => 0)) {
|
|
3766
|
-
await target
|
|
3767
|
-
await target
|
|
4306
|
+
const perpetual = await scrollControlIntoView(target);
|
|
4307
|
+
await clickControl(target, { timeout: 5000, forceFallback: false, perpetual }).catch(() => {});
|
|
3768
4308
|
}
|
|
3769
4309
|
}
|
|
3770
4310
|
}
|
|
@@ -3789,10 +4329,8 @@ async function selectPackageCard(page, item) {
|
|
|
3789
4329
|
const candidate = resolvePackageCardCandidate(await renderedPackageCardCandidates(page), item);
|
|
3790
4330
|
const selector = packageCardClickSelector(candidate);
|
|
3791
4331
|
const target = page.locator(selector).first();
|
|
3792
|
-
await target
|
|
3793
|
-
await target
|
|
3794
|
-
await target.click({ force: true, timeout: 5000 });
|
|
3795
|
-
});
|
|
4332
|
+
const perpetual = await scrollControlIntoView(target);
|
|
4333
|
+
await clickControl(target, { timeout: 5000, perpetual });
|
|
3796
4334
|
const state = await packageCardSelectionState(page, selector);
|
|
3797
4335
|
if (state === "unselected") {
|
|
3798
4336
|
throw new Error(`--select-package ${item.packageId}: card matched ${selector} but did not enter a selected state after click`);
|
|
@@ -4390,8 +4928,8 @@ async function submitCheckout(page) {
|
|
|
4390
4928
|
await closeAddressAutocomplete(page);
|
|
4391
4929
|
const submit = page.locator('button.submit-button[os-checkout-payment="combo"], button[os-checkout-payment="combo"], button[type="submit"]').first();
|
|
4392
4930
|
await submit.waitFor({ state: "visible" });
|
|
4393
|
-
await submit
|
|
4394
|
-
await submit
|
|
4931
|
+
const perpetual = await scrollControlIntoView(submit);
|
|
4932
|
+
await clickControl(submit, { forceFallback: false, perpetual });
|
|
4395
4933
|
}
|
|
4396
4934
|
|
|
4397
4935
|
// When events are supplied (the post-submit wait), a platform-rejected order
|
|
@@ -4536,6 +5074,81 @@ async function waitForLateOrderEvidence(page, events, { timeoutMs = 4000, interv
|
|
|
4536
5074
|
}
|
|
4537
5075
|
}
|
|
4538
5076
|
|
|
5077
|
+
// Playwright waits for an element to be "stable" (the same box on two
|
|
5078
|
+
// consecutive animation frames) before scrollIntoViewIfNeeded() and click().
|
|
5079
|
+
// A control under an infinite geometry animation never is: stock upsell accept
|
|
5080
|
+
// buttons carry pb-animate="pulse-upsell", which the shared next-core.css
|
|
5081
|
+
// scales forever with !important and no reduced-motion override. An unbounded
|
|
5082
|
+
// scroll then burned the whole 30s default action timeout and a normal click
|
|
5083
|
+
// another 10s before the forced fallback fired (#481). Scrolling through the
|
|
5084
|
+
// DOM has no stability wait, and a perpetually animated control is clicked with
|
|
5085
|
+
// force once visible, because waiting for it to settle can only time out.
|
|
5086
|
+
const CONTROL_SCROLL_TIMEOUT_MS = 2000;
|
|
5087
|
+
const UPSELL_CLICK_TIMEOUT_MS = 10000;
|
|
5088
|
+
const UPSELL_MUTATION_TIMEOUT_MS = 20000;
|
|
5089
|
+
|
|
5090
|
+
// Runs in the page: optionally scrolls the control to the viewport centre
|
|
5091
|
+
// (no stability wait), and reports whether the control or an ancestor runs an
|
|
5092
|
+
// infinite animation over a property that moves or resizes its box.
|
|
5093
|
+
// Paint-only loops (opacity, color, shadow) leave the box stable, so those
|
|
5094
|
+
// still take the normal click. Self-contained so Playwright can serialize it.
|
|
5095
|
+
function probeControlInPage(element, { scroll }) {
|
|
5096
|
+
if (scroll) element.scrollIntoView({ block: "center", inline: "nearest" });
|
|
5097
|
+
// Properties that move or resize the element's box, or its position
|
|
5098
|
+
// inside an animated ancestor. Paint-only properties are left out.
|
|
5099
|
+
const geometry = /^(transform|translate|scale|rotate|perspective|top|left|right|bottom|inset|width|height|blockSize|inlineSize|min|max|margin|padding|gap|rowGap|columnGap|fontSize|lineHeight|letterSpacing|textIndent|border(Top|Right|Bottom|Left|Block|Inline)?(Start|End)?Width)/;
|
|
5100
|
+
const movesBox = (animation) => {
|
|
5101
|
+
const timing = animation.effect?.getComputedTiming?.();
|
|
5102
|
+
if (animation.playState !== "running" || timing?.iterations !== Infinity) return false;
|
|
5103
|
+
const keyframes = animation.effect?.getKeyframes?.() || [];
|
|
5104
|
+
return keyframes.some((frame) => Object.keys(frame).some((property) => geometry.test(property)));
|
|
5105
|
+
};
|
|
5106
|
+
for (let node = element; node; node = node.parentElement) {
|
|
5107
|
+
if (typeof node.getAnimations === "function" && node.getAnimations().some(movesBox)) return true;
|
|
5108
|
+
}
|
|
5109
|
+
return false;
|
|
5110
|
+
}
|
|
5111
|
+
|
|
5112
|
+
async function probeControl(locator, { scroll, timeout = CONTROL_SCROLL_TIMEOUT_MS }) {
|
|
5113
|
+
try {
|
|
5114
|
+
return await locator.evaluate(probeControlInPage, { scroll }, { timeout }) === true;
|
|
5115
|
+
} catch {
|
|
5116
|
+
// Best effort, as the scroll always was: the click still scrolls itself,
|
|
5117
|
+
// and an unprobed control takes the normal click.
|
|
5118
|
+
return false;
|
|
5119
|
+
}
|
|
5120
|
+
}
|
|
5121
|
+
|
|
5122
|
+
// One page round trip per click: scrolls the control into view and returns
|
|
5123
|
+
// the `perpetual` flag clickControl takes, so no call site probes twice.
|
|
5124
|
+
async function scrollControlIntoView(locator, options = {}) {
|
|
5125
|
+
return probeControl(locator, { ...options, scroll: true });
|
|
5126
|
+
}
|
|
5127
|
+
|
|
5128
|
+
async function isPerpetuallyAnimated(locator, options = {}) {
|
|
5129
|
+
return probeControl(locator, { ...options, scroll: false });
|
|
5130
|
+
}
|
|
5131
|
+
|
|
5132
|
+
// `perpetual` is the flag scrollControlIntoView returned, so the click does
|
|
5133
|
+
// not probe again; without it the click probes for itself. forceFallback: false keeps a caller's strict click
|
|
5134
|
+
// for a control that can settle; a perpetually animated control is always
|
|
5135
|
+
// forced, whatever forceFallback says, because a strict click on it can only
|
|
5136
|
+
// time out. It must still become visible first, or the visibility error throws.
|
|
5137
|
+
async function clickControl(locator, { timeout, forceFallback = true, perpetual } = {}) {
|
|
5138
|
+
const animated = perpetual ?? await isPerpetuallyAnimated(locator);
|
|
5139
|
+
if (animated) {
|
|
5140
|
+
await locator.waitFor({ state: "visible", timeout });
|
|
5141
|
+
await locator.click({ force: true, timeout });
|
|
5142
|
+
return;
|
|
5143
|
+
}
|
|
5144
|
+
try {
|
|
5145
|
+
await locator.click({ timeout });
|
|
5146
|
+
} catch (error) {
|
|
5147
|
+
if (!forceFallback) throw error;
|
|
5148
|
+
await locator.click({ force: true, timeout });
|
|
5149
|
+
}
|
|
5150
|
+
}
|
|
5151
|
+
|
|
4539
5152
|
async function clickUpsellPath(page, path, { trace = null } = {}) {
|
|
4540
5153
|
const offerUrl = safePageUrl(page);
|
|
4541
5154
|
const action = path === "accept" ? "add" : "skip";
|
|
@@ -4545,27 +5158,36 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
|
|
|
4545
5158
|
return { path, clicked: false, error: `Missing upsell control ${selector}` };
|
|
4546
5159
|
}
|
|
4547
5160
|
const expectedItems = path === "accept" ? await selectedUpsellItems(page) : [];
|
|
5161
|
+
const perpetual = await scrollControlIntoView(control);
|
|
5162
|
+
// Armed at the click, not before the scroll, and budgeted to outlast a
|
|
5163
|
+
// normal attempt that times out into its forced fallback: the watch must
|
|
5164
|
+
// still be listening when the click that actually fires posts (#481).
|
|
5165
|
+
//
|
|
5166
|
+
// Only a request started at or after the watch was armed can be this
|
|
5167
|
+
// click's. An earlier step posts to the same order-upsells URL, and when
|
|
5168
|
+
// its own watch expired its response may still arrive during this click
|
|
5169
|
+
// (#505). armedAt and the request's start time are both epoch milliseconds
|
|
5170
|
+
// on the system clock (Date.now() here; Playwright's timing().startTime is
|
|
5171
|
+
// the browser's wall time at request start), and armedAt is read before the
|
|
5172
|
+
// click is sent, so this click's request starts at or after it. A request
|
|
5173
|
+
// with no known start time is not excluded: nothing shows it is stale.
|
|
5174
|
+
const armedAt = Date.now();
|
|
4548
5175
|
const mutationPromise = path === "accept"
|
|
4549
|
-
? page.waitForResponse((response) =>
|
|
4550
|
-
response.request().method()
|
|
4551
|
-
|
|
4552
|
-
|
|
5176
|
+
? page.waitForResponse((response) => {
|
|
5177
|
+
if (response.request().method() !== "POST" || !isOrderUpsellsUrl(response.url())) return false;
|
|
5178
|
+
const startedAt = responseRequestStartedAt(response);
|
|
5179
|
+
return startedAt === null || startedAt >= armedAt;
|
|
5180
|
+
}, { timeout: UPSELL_MUTATION_TIMEOUT_MS + (perpetual ? 0 : UPSELL_CLICK_TIMEOUT_MS) }).catch(() => null)
|
|
4553
5181
|
: Promise.resolve(null);
|
|
4554
|
-
await control.scrollIntoViewIfNeeded().catch(() => {});
|
|
4555
5182
|
trace?.markClickAttempted();
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
await control.click({ timeout: 10000 });
|
|
4559
|
-
clickCompleted = true;
|
|
4560
|
-
} catch {
|
|
4561
|
-
await control.click({ force: true });
|
|
4562
|
-
clickCompleted = true;
|
|
4563
|
-
}
|
|
4564
|
-
if (clickCompleted) trace?.markClickCompleted();
|
|
5183
|
+
await clickControl(control, { timeout: UPSELL_CLICK_TIMEOUT_MS, perpetual });
|
|
5184
|
+
trace?.markClickCompleted();
|
|
4565
5185
|
const mutationResponse = await mutationPromise;
|
|
4566
|
-
const
|
|
5186
|
+
const bodyRead = mutationResponse
|
|
5187
|
+
? await readJsonResponseBodyBounded(mutationResponse, RESPONSE_BODY_READ_TIMEOUT_MS)
|
|
5188
|
+
: null;
|
|
4567
5189
|
await waitForCheckoutResult(page);
|
|
4568
|
-
|
|
5190
|
+
const record = {
|
|
4569
5191
|
path,
|
|
4570
5192
|
clicked: true,
|
|
4571
5193
|
offer_url: offerUrl,
|
|
@@ -4574,8 +5196,21 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
|
|
|
4574
5196
|
api_response_seen: Boolean(mutationResponse),
|
|
4575
5197
|
api_response_status: mutationResponse?.status() || null,
|
|
4576
5198
|
api_response_url: mutationResponse?.url() || null,
|
|
4577
|
-
api_response_order_body:
|
|
5199
|
+
api_response_order_body: bodyRead?.body ?? null,
|
|
5200
|
+
// When the mutation's response arrived (epoch ms, the browser's clock as
|
|
5201
|
+
// Date.now()). Only a read-back requested after this reflects the upsell.
|
|
5202
|
+
mutation_responded_at: mutationRespondedAt(mutationResponse),
|
|
5203
|
+
// How the bounded body read ended. A timed-out read means the mutation's
|
|
5204
|
+
// own evidence is missing, not that the upsell failed; the runner then
|
|
5205
|
+
// waits for a later read-back before it judges the step.
|
|
5206
|
+
...(bodyRead ? { api_response_body_read: { timed_out: bodyRead.timed_out, waited_ms: bodyRead.waited_ms, bound_ms: bodyRead.bound_ms } } : {}),
|
|
4578
5207
|
};
|
|
5208
|
+
// The request this click made. Another step's upsell mutation posts to the
|
|
5209
|
+
// same order-upsells URL, so a late body is this step's only when it
|
|
5210
|
+
// answers this request.
|
|
5211
|
+
const mutationRequest = mutationResponse ? responseRequest(mutationResponse) : null;
|
|
5212
|
+
if (mutationRequest) record[REQUEST_IDENTITY] = mutationRequest;
|
|
5213
|
+
return record;
|
|
4579
5214
|
}
|
|
4580
5215
|
|
|
4581
5216
|
function receiptProofEvidence(order) {
|
|
@@ -5325,13 +5960,22 @@ async function recoverCreatedOrder({ context, attempt, plan = null, checkoutPage
|
|
|
5325
5960
|
}
|
|
5326
5961
|
|
|
5327
5962
|
const cleared = remaining.length === 0;
|
|
5963
|
+
// An unverified accepted upsell is not a failure recovery can clear, nor
|
|
5964
|
+
// one it can re-check: re-reading the order says nothing about which
|
|
5965
|
+
// mutation added what. With everything else cleared, the recovered result
|
|
5966
|
+
// is the same manual-review shape executeTestOrderPath returns (#505).
|
|
5967
|
+
const upsellUnverified = Array.isArray(order.verification?.upsell_unverified) && order.verification.upsell_unverified.length
|
|
5968
|
+
? order.verification.upsell_unverified
|
|
5969
|
+
: null;
|
|
5970
|
+
if (upsellUnverified) recovered.verification.verified = false;
|
|
5328
5971
|
return {
|
|
5329
5972
|
attempts: 1,
|
|
5330
5973
|
cleared,
|
|
5331
5974
|
checks,
|
|
5332
5975
|
result: {
|
|
5333
5976
|
...attempt,
|
|
5334
|
-
ok: cleared,
|
|
5977
|
+
ok: cleared && !upsellUnverified,
|
|
5978
|
+
...(cleared && upsellUnverified ? { upsell_unverified: upsellUnverified } : {}),
|
|
5335
5979
|
error: cleared ? null : remaining.join("; "),
|
|
5336
5980
|
order: recovered,
|
|
5337
5981
|
},
|
|
@@ -5469,19 +6113,35 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
|
|
|
5469
6113
|
// runner deliberately refused to re-run, and the operator needs to know that
|
|
5470
6114
|
// before they run the path again by hand.
|
|
5471
6115
|
const stoppedForAmbiguity = creationRecord?.action === "stopped";
|
|
6116
|
+
// The order was created and nothing failed, but an accepted upsell could
|
|
6117
|
+
// not be checked: its mutation body never loaded and no read-back arrived.
|
|
6118
|
+
// Neither proved nor disproved, so a human decides, as for a hosted checkout.
|
|
6119
|
+
// The result is not ok (the path is not proven), but nothing failed either.
|
|
6120
|
+
// A result that says ok while its order still carries unverified reasons is
|
|
6121
|
+
// read the same way: never a pass. No current producer does that (both
|
|
6122
|
+
// executeTestOrderPath and recoverCreatedOrder clear ok when an upsell is
|
|
6123
|
+
// unverified); this arm stops a future ok-producing path, like the recovery
|
|
6124
|
+
// that once promoted an unverified upsell to pass, from doing it silently.
|
|
6125
|
+
const orderUnverified = result.order?.verification?.upsell_unverified;
|
|
6126
|
+
const upsellUnverified = Array.isArray(result.upsell_unverified) && result.upsell_unverified.length
|
|
6127
|
+
? result.upsell_unverified
|
|
6128
|
+
: result.ok && Array.isArray(orderUnverified) && orderUnverified.length ? orderUnverified : null;
|
|
6129
|
+
const created = result.ok || Boolean(upsellUnverified);
|
|
5472
6130
|
return assertion({
|
|
5473
6131
|
id: `browser-test-order:${id}`,
|
|
5474
6132
|
family: "browser-test-order",
|
|
5475
6133
|
page,
|
|
5476
|
-
status:
|
|
5477
|
-
severity:
|
|
6134
|
+
status: created ? (upsellUnverified ? STATUS.MANUAL_REVIEW : STATUS.PASS) : STATUS.FAIL,
|
|
6135
|
+
severity: created ? (upsellUnverified ? SEVERITY.WARN : undefined) : SEVERITY.BLOCKER,
|
|
5478
6136
|
expected: "test order created through deployed checkout page",
|
|
5479
|
-
actual:
|
|
5480
|
-
?
|
|
6137
|
+
actual: created
|
|
6138
|
+
? upsellUnverified
|
|
6139
|
+
? `${result.order.next_order_id || result.order.ref_id}; ${upsellUnverified.join("; ")}`
|
|
6140
|
+
: result.order.next_order_id || result.order.ref_id
|
|
5481
6141
|
: stoppedForAmbiguity
|
|
5482
6142
|
? `${failureText} — not re-run: ${creationRecord.classification.reason}. Check for an existing order against this run's QA email before running this path again.`
|
|
5483
6143
|
: failureText,
|
|
5484
|
-
evidence:
|
|
6144
|
+
evidence: created
|
|
5485
6145
|
? {
|
|
5486
6146
|
...planEvidence,
|
|
5487
6147
|
...retry,
|
|
@@ -5494,6 +6154,7 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
|
|
|
5494
6154
|
line_count: result.order.receipt_line_items.length,
|
|
5495
6155
|
...(receiptProofEvidence(result.order) ? { receipt_proof: receiptProofEvidence(result.order) } : {}),
|
|
5496
6156
|
...(path === "accept" ? { accepted_upsell_line_present: result.order.verification?.accepted_upsell_line_present } : {}),
|
|
6157
|
+
...(upsellUnverified ? { upsell_unverified: upsellUnverified } : {}),
|
|
5497
6158
|
...(result.order.upsell ? { upsell_clicked: result.order.upsell.clicked, upsell_final_url: result.order.upsell.final_url } : {}),
|
|
5498
6159
|
...(result.order.upsell_steps ? { upsell_steps: result.order.upsell_steps.map(summarizeUpsellStep) } : {}),
|
|
5499
6160
|
...(result.order.verification?.accepted_upsell_matches ? { accepted_upsell_matches: result.order.verification.accepted_upsell_matches } : {}),
|
|
@@ -5514,6 +6175,43 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
|
|
|
5514
6175
|
});
|
|
5515
6176
|
}
|
|
5516
6177
|
|
|
6178
|
+
// When a response's headers arrived, in epoch milliseconds, or null.
|
|
6179
|
+
function mutationRespondedAt(response) {
|
|
6180
|
+
try {
|
|
6181
|
+
const timing = response.request().timing();
|
|
6182
|
+
const at = timing.startTime + timing.responseStart;
|
|
6183
|
+
return Number.isFinite(at) && timing.startTime > 0 && timing.responseStart >= 0 ? at : null;
|
|
6184
|
+
} catch {
|
|
6185
|
+
return null;
|
|
6186
|
+
}
|
|
6187
|
+
}
|
|
6188
|
+
|
|
6189
|
+
// The Playwright Request a response answers, kept under a symbol key so it
|
|
6190
|
+
// never reaches serialized evidence (JSON and the sanitized event log skip
|
|
6191
|
+
// it). Playwright hands every listener the same Request object for one
|
|
6192
|
+
// request, and a different one for each request, even to the same URL: an
|
|
6193
|
+
// upsell step matches its own mutation's late body by this identity (#505).
|
|
6194
|
+
const REQUEST_IDENTITY = Symbol("campaigns-os.request-identity");
|
|
6195
|
+
|
|
6196
|
+
function responseRequest(response) {
|
|
6197
|
+
try {
|
|
6198
|
+
return response.request() || null;
|
|
6199
|
+
} catch {
|
|
6200
|
+
return null;
|
|
6201
|
+
}
|
|
6202
|
+
}
|
|
6203
|
+
|
|
6204
|
+
// When the browser started a response's request, in epoch milliseconds (the
|
|
6205
|
+
// same clock as Date.now()), or null when Playwright does not report it.
|
|
6206
|
+
function responseRequestStartedAt(response) {
|
|
6207
|
+
try {
|
|
6208
|
+
const startTime = response.request().timing().startTime;
|
|
6209
|
+
return Number.isFinite(startTime) && startTime > 0 ? startTime : null;
|
|
6210
|
+
} catch {
|
|
6211
|
+
return null;
|
|
6212
|
+
}
|
|
6213
|
+
}
|
|
6214
|
+
|
|
5517
6215
|
function captureCheckoutEvents(page) {
|
|
5518
6216
|
const events = { requests: [], responses: [], failed: [], console: [], pageErrors: [], navigations: [] };
|
|
5519
6217
|
const interesting = /\/api\/v1\/(?:orders|upsells|carts)\/?|\/transactions|spreedly|campaigns\.apps/i;
|
|
@@ -5525,13 +6223,24 @@ function captureCheckoutEvents(page) {
|
|
|
5525
6223
|
postData: summarizeRequestPostData(request.postData()),
|
|
5526
6224
|
});
|
|
5527
6225
|
});
|
|
6226
|
+
// Nothing awaits this listener, so its body read stays unbounded: an entry
|
|
6227
|
+
// lands whenever its body finishes loading, and waitForLateOrderEvidence
|
|
6228
|
+
// polls for it. A bound here would record a slow but successful order body
|
|
6229
|
+
// as null for good, and the order would read as not created.
|
|
5528
6230
|
page.on("response", async (response) => {
|
|
5529
6231
|
if (!interesting.test(response.url())) return;
|
|
5530
|
-
|
|
6232
|
+
// Taken before the body read: an entry lands in the log when its body
|
|
6233
|
+
// finishes, so its position says nothing about when it was requested.
|
|
6234
|
+
const requestStartedAt = responseRequestStartedAt(response);
|
|
6235
|
+
const request = responseRequest(response);
|
|
6236
|
+
const entry = {
|
|
5531
6237
|
status: response.status(),
|
|
5532
6238
|
url: response.url(),
|
|
5533
|
-
|
|
5534
|
-
|
|
6239
|
+
request_started_at: requestStartedAt,
|
|
6240
|
+
body: await readJsonResponseBodyWhenLoaded(response),
|
|
6241
|
+
};
|
|
6242
|
+
if (request) entry[REQUEST_IDENTITY] = request;
|
|
6243
|
+
events.responses.push(entry);
|
|
5535
6244
|
});
|
|
5536
6245
|
page.on("requestfailed", (request) => {
|
|
5537
6246
|
if (!interesting.test(request.url())) return;
|
|
@@ -5552,11 +6261,63 @@ function captureCheckoutEvents(page) {
|
|
|
5552
6261
|
return events;
|
|
5553
6262
|
}
|
|
5554
6263
|
|
|
5555
|
-
|
|
5556
|
-
|
|
6264
|
+
// Playwright's response.text() waits for the body to finish loading. A page
|
|
6265
|
+
// that navigates away as soon as the headers land (an SDK that reads only the
|
|
6266
|
+
// status before redirecting) can leave that wait pending forever. Only a
|
|
6267
|
+
// caller that blocks the run on the body needs a bound: the upsell mutation
|
|
6268
|
+
// read in clickUpsellPath. There the body is evidence, not the proof of the
|
|
6269
|
+
// response, so an unread body is reported as null after a short bound instead
|
|
6270
|
+
// of hanging the order run. Playwright has no way to cancel a pending body
|
|
6271
|
+
// read; the abandoned read is a protocol callback, not a socket this process
|
|
6272
|
+
// owns, and it is released when the run closes the browser context.
|
|
6273
|
+
const RESPONSE_BODY_READ_TIMEOUT_MS = 3000;
|
|
6274
|
+
|
|
6275
|
+
// For callers that nothing awaits (the checkout event listener): a late body
|
|
6276
|
+
// still arrives, and a body that never loads just never records an entry.
|
|
6277
|
+
async function readJsonResponseBodyWhenLoaded(response) {
|
|
6278
|
+
const text = await Promise.resolve().then(() => response.text()).catch(() => null);
|
|
5557
6279
|
return parseMaybeJson(redactSensitive(text));
|
|
5558
6280
|
}
|
|
5559
6281
|
|
|
6282
|
+
// Callers that block on the read always get the fixed bound; only the test
|
|
6283
|
+
// hook picks a shorter one.
|
|
6284
|
+
async function readJsonResponseBody(response) {
|
|
6285
|
+
return readJsonResponseBodyWithin(response, RESPONSE_BODY_READ_TIMEOUT_MS);
|
|
6286
|
+
}
|
|
6287
|
+
|
|
6288
|
+
async function readJsonResponseBodyWithin(response, timeoutMs) {
|
|
6289
|
+
return (await readJsonResponseBodyBounded(response, timeoutMs)).body;
|
|
6290
|
+
}
|
|
6291
|
+
|
|
6292
|
+
// The bounded read, with a record of how it ended. `timed_out` is true only
|
|
6293
|
+
// when the bound fired while the read was still pending, never when the read
|
|
6294
|
+
// failed or returned nothing: a caller must be able to tell "the body was not
|
|
6295
|
+
// read in time" (the mutation may still have succeeded) from "the body was
|
|
6296
|
+
// read". `waited_ms` covers the read alone.
|
|
6297
|
+
async function readJsonResponseBodyBounded(response, timeoutMs) {
|
|
6298
|
+
// A missing, zero or unbounded value would reintroduce the hang.
|
|
6299
|
+
if (!(Number.isFinite(timeoutMs) && timeoutMs > 0)) timeoutMs = RESPONSE_BODY_READ_TIMEOUT_MS;
|
|
6300
|
+
const started = Date.now();
|
|
6301
|
+
const TIMED_OUT = Symbol("timed out");
|
|
6302
|
+
let timer = null;
|
|
6303
|
+
const bounded = new Promise((resolve) => { timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs); });
|
|
6304
|
+
const read = Promise.resolve()
|
|
6305
|
+
.then(() => response.text())
|
|
6306
|
+
.catch(() => null);
|
|
6307
|
+
try {
|
|
6308
|
+
const text = await Promise.race([read, bounded]);
|
|
6309
|
+
const timedOut = text === TIMED_OUT;
|
|
6310
|
+
return {
|
|
6311
|
+
body: timedOut ? null : parseMaybeJson(redactSensitive(text)),
|
|
6312
|
+
timed_out: timedOut,
|
|
6313
|
+
waited_ms: Date.now() - started,
|
|
6314
|
+
bound_ms: timeoutMs,
|
|
6315
|
+
};
|
|
6316
|
+
} finally {
|
|
6317
|
+
clearTimeout(timer);
|
|
6318
|
+
}
|
|
6319
|
+
}
|
|
6320
|
+
|
|
5560
6321
|
function lastJsonResponse(events, pattern) {
|
|
5561
6322
|
for (let index = events.responses.length - 1; index >= 0; index -= 1) {
|
|
5562
6323
|
const response = events.responses[index];
|
|
@@ -5621,8 +6382,8 @@ async function clickVisibleControlByText(page, pattern, { within = null } = {})
|
|
|
5621
6382
|
const control = controls.nth(index);
|
|
5622
6383
|
const text = trim((await control.innerText().catch(() => "")) || (await control.getAttribute("value").catch(() => "")));
|
|
5623
6384
|
if (!pattern.test(text)) continue;
|
|
5624
|
-
await control
|
|
5625
|
-
await control
|
|
6385
|
+
const perpetual = await scrollControlIntoView(control);
|
|
6386
|
+
await clickControl(control, { timeout: 8000, forceFallback: false, perpetual });
|
|
5626
6387
|
return true;
|
|
5627
6388
|
}
|
|
5628
6389
|
throw new Error(`No visible control matched ${pattern}`);
|
|
@@ -5767,12 +6528,13 @@ async function closeAddressAutocomplete(page) {
|
|
|
5767
6528
|
}
|
|
5768
6529
|
}
|
|
5769
6530
|
|
|
5770
|
-
function testOrderPaths(mode, topologies = []) {
|
|
6531
|
+
function testOrderPaths(mode, topologies = [], args = {}) {
|
|
5771
6532
|
const normalized = String(mode || "off").toLowerCase();
|
|
5772
6533
|
// `common` (also the bare `--test-order` flag, which parses to boolean true)
|
|
5773
|
-
// 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.
|
|
5774
6536
|
// `full` is the explicit opt-in for every actual terminal path.
|
|
5775
|
-
if (normalized
|
|
6537
|
+
if (isCommonTestOrderMode(normalized)) return commonOrderPathPlan(topologies, args).paths;
|
|
5776
6538
|
if (normalized === "full") return fullTestOrderPaths(resolvePrimaryTestOrderTopology(topologies));
|
|
5777
6539
|
if (normalized === "both") return ["accept", "decline"];
|
|
5778
6540
|
if (["checkout", "accept", "decline"].includes(normalized)) return [normalized];
|
|
@@ -5810,7 +6572,7 @@ function testOrderPlans(mode, topologies = [], args = {}, options = {}) {
|
|
|
5810
6572
|
const applyCoupon = stringArg(args["apply-coupon"]);
|
|
5811
6573
|
const checkoutPage = findPage(topologies, "checkout");
|
|
5812
6574
|
const topologyPlan = resolvePrimaryTestOrderTopology(topologies);
|
|
5813
|
-
return testOrderPaths(mode, topologies).map((path) => ({
|
|
6575
|
+
return testOrderPaths(mode, topologies, args).map((path) => ({
|
|
5814
6576
|
path,
|
|
5815
6577
|
select_package: selectPackage,
|
|
5816
6578
|
apply_coupon: applyCoupon,
|
|
@@ -6088,15 +6850,306 @@ function summarizeTestOrderPlan(plan) {
|
|
|
6088
6850
|
};
|
|
6089
6851
|
}
|
|
6090
6852
|
|
|
6091
|
-
// The
|
|
6092
|
-
//
|
|
6093
|
-
//
|
|
6094
|
-
//
|
|
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`;
|
|
6095
6858
|
// every actual terminal path comes from `full`.
|
|
6096
6859
|
function testOrderCommonPaths(topologies = []) {
|
|
6097
6860
|
return commonTestOrderPaths(resolvePrimaryTestOrderTopology(topologies));
|
|
6098
6861
|
}
|
|
6099
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
|
+
|
|
6100
7153
|
function enforceTestOrderLimit(plans, args) {
|
|
6101
7154
|
const maxOrders = numberArg(args["max-test-orders"], DEFAULT_MAX_TEST_ORDERS);
|
|
6102
7155
|
if (plans.length <= maxOrders) return;
|
|
@@ -6113,7 +7166,7 @@ function enforceTestOrderLimit(plans, args) {
|
|
|
6113
7166
|
throw new Error([
|
|
6114
7167
|
`--test-order ${args["test-order"]} expands to ${plans.length} typed-card order(s), above --max-test-orders ${maxOrders}.`,
|
|
6115
7168
|
`Planned paths: ${preview}.`,
|
|
6116
|
-
`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.`,
|
|
6117
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.",
|
|
6118
7171
|
].join(" "));
|
|
6119
7172
|
}
|
|
@@ -6228,9 +7281,13 @@ async function selectedUpsellItems(page) {
|
|
|
6228
7281
|
// order-upsell API added it. The live network observation (apiResponseSeen) is a
|
|
6229
7282
|
// best-effort signal that can miss the request on fast stepper-accept client nav, so
|
|
6230
7283
|
// it must not block on its own. Block only when the read-back proof also fails.
|
|
7284
|
+
//
|
|
7285
|
+
// An unverified proof (the mutation answered 2xx but its body never loaded, and
|
|
7286
|
+
// no later read-back arrived) is not a failure: nothing showed the line
|
|
7287
|
+
// missing. It is reported for manual review instead (upsell_unverified).
|
|
6231
7288
|
function upsellAcceptStepFailures(stepIndex, proof, apiResponseSeen) {
|
|
6232
7289
|
const failures = [];
|
|
6233
|
-
if (!proof.ok) {
|
|
7290
|
+
if (!proof.ok && !proof.unverified) {
|
|
6234
7291
|
failures.push(`step ${stepIndex + 1}: ${proof.reason}`);
|
|
6235
7292
|
if (!apiResponseSeen) {
|
|
6236
7293
|
failures.push(`step ${stepIndex + 1}: upsell accept did not call order upsell API`);
|
|
@@ -6252,6 +7309,141 @@ function upsellActionStepFailures(stepIndex, action, upsell, proof) {
|
|
|
6252
7309
|
return failures;
|
|
6253
7310
|
}
|
|
6254
7311
|
|
|
7312
|
+
// How long an accept whose mutation body timed out waits for other evidence
|
|
7313
|
+
// of that mutation, and the slice of the step budget kept for the read-back
|
|
7314
|
+
// after it. The whole step stays inside its budget (45s by default).
|
|
7315
|
+
const LATE_UPSELL_EVIDENCE_TIMEOUT_MS = 15000;
|
|
7316
|
+
const LATE_UPSELL_EVIDENCE_RESERVE_MS = 3000;
|
|
7317
|
+
|
|
7318
|
+
function upsellBodyReadTimedOut(upsell) {
|
|
7319
|
+
const status = Number(upsell?.api_response_status);
|
|
7320
|
+
return upsell?.api_response_body_read?.timed_out === true && status >= 200 && status < 300;
|
|
7321
|
+
}
|
|
7322
|
+
|
|
7323
|
+
// After the bounded read gave up on a 2xx upsell mutation body, the lines the
|
|
7324
|
+
// runner already holds are the checkout's, from before the accept. Judging
|
|
7325
|
+
// the upsell against them would call a slow success "no new upsell line".
|
|
7326
|
+
// Wait, within a bound, for evidence of this mutation captured after the
|
|
7327
|
+
// click: its own body landing late in the event log (the unbounded listener
|
|
7328
|
+
// keeps reading it), or an order read-back whose lines carry the accepted
|
|
7329
|
+
// upsell. Returns the body to judge from, or source "none" when neither came.
|
|
7330
|
+
//
|
|
7331
|
+
// "Its own body" is the entry answering the very request this step's click
|
|
7332
|
+
// made (mutationRequest, Playwright request identity), not any order-upsells
|
|
7333
|
+
// URL: every upsell step in a path, on one page or on separate pages, posts
|
|
7334
|
+
// to the same /orders/<ref>/upsells/ URL, and an earlier step's slow body can
|
|
7335
|
+
// land during this step's wait. Taking it would judge this step by another
|
|
7336
|
+
// step's lines. With no request identity no late body counts (#505).
|
|
7337
|
+
//
|
|
7338
|
+
// A read-back whose request started after the upsell mutation's response
|
|
7339
|
+
// arrived (by the browser's request start time, not its position in the log,
|
|
7340
|
+
// which reflects when its body finished) that shows the persisted order
|
|
7341
|
+
// without the accepted line is a definitive negative, not an absence of
|
|
7342
|
+
// evidence. Anchoring on the mutation, not the click attempt, also excludes a
|
|
7343
|
+
// read-back started while the click was still waiting to fire. It does not end the wait early (a
|
|
7344
|
+
// later read-back may still carry the line), but when the wait ends with no
|
|
7345
|
+
// positive evidence the latest such read-back is returned as
|
|
7346
|
+
// "order_read_back_missing_line", and the step fails instead of going to
|
|
7347
|
+
// manual review. A read-back requested before that, or with no known start
|
|
7348
|
+
// time, never counts as a negative.
|
|
7349
|
+
async function waitForLateUpsellEvidence(events, { responseIndexBefore, mutationRequest = null, mutationRespondedAt = null, initialLineItems, expectedItems, timeoutMs, intervalMs = 250 }) {
|
|
7350
|
+
const started = Date.now();
|
|
7351
|
+
const deadline = started + Math.max(0, Number(timeoutMs) || 0);
|
|
7352
|
+
const latestMissingLineReadBack = () => {
|
|
7353
|
+
const fresh = events.responses.slice(responseIndexBefore);
|
|
7354
|
+
for (let index = fresh.length - 1; index >= 0; index -= 1) {
|
|
7355
|
+
const response = fresh[index];
|
|
7356
|
+
if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) continue;
|
|
7357
|
+
if (!(response.status >= 200 && response.status < 300)) continue;
|
|
7358
|
+
if (!ORDER_DETAIL_RESPONSE_PATTERN.test(response.url)) continue;
|
|
7359
|
+
if (!(Number.isFinite(mutationRespondedAt) && Number.isFinite(response.request_started_at) && response.request_started_at >= mutationRespondedAt)) continue;
|
|
7360
|
+
const lines = extractReceiptLines(response.body);
|
|
7361
|
+
if (!Array.isArray(lines) || lines.length === 0) continue;
|
|
7362
|
+
return response.body;
|
|
7363
|
+
}
|
|
7364
|
+
return null;
|
|
7365
|
+
};
|
|
7366
|
+
const find = () => {
|
|
7367
|
+
const fresh = events.responses.slice(responseIndexBefore);
|
|
7368
|
+
for (let index = fresh.length - 1; index >= 0; index -= 1) {
|
|
7369
|
+
const response = fresh[index];
|
|
7370
|
+
if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) continue;
|
|
7371
|
+
if (!(response.status >= 200 && response.status < 300)) continue;
|
|
7372
|
+
if (!ORDER_UPSELLS_RESPONSE_PATTERN.test(response.url)) continue;
|
|
7373
|
+
if (mutationRequest && response[REQUEST_IDENTITY] === mutationRequest) return { source: "late_upsell_body", body: response.body };
|
|
7374
|
+
}
|
|
7375
|
+
for (let index = fresh.length - 1; index >= 0; index -= 1) {
|
|
7376
|
+
const response = fresh[index];
|
|
7377
|
+
if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) continue;
|
|
7378
|
+
if (!(response.status >= 200 && response.status < 300)) continue;
|
|
7379
|
+
if (!ORDER_DETAIL_RESPONSE_PATTERN.test(response.url)) continue;
|
|
7380
|
+
const proof = acceptedUpsellProof(extractReceiptLines(response.body), initialLineItems, expectedItems, events);
|
|
7381
|
+
if (proof.ok) return { source: "order_read_back", body: response.body };
|
|
7382
|
+
}
|
|
7383
|
+
return null;
|
|
7384
|
+
};
|
|
7385
|
+
for (;;) {
|
|
7386
|
+
const found = find();
|
|
7387
|
+
if (found) return { ...found, waited_ms: Date.now() - started };
|
|
7388
|
+
if (Date.now() >= deadline) {
|
|
7389
|
+
const missing = latestMissingLineReadBack();
|
|
7390
|
+
if (missing) return { source: "order_read_back_missing_line", body: missing, waited_ms: Date.now() - started };
|
|
7391
|
+
return { source: "none", body: null, waited_ms: Date.now() - started };
|
|
7392
|
+
}
|
|
7393
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, Math.max(1, deadline - Date.now()))));
|
|
7394
|
+
}
|
|
7395
|
+
}
|
|
7396
|
+
|
|
7397
|
+
// The order evidence an upsell step is judged against. An accept whose
|
|
7398
|
+
// mutation body read timed out first waits for later evidence of that
|
|
7399
|
+
// mutation (waitForLateUpsellEvidence), and judges from that body when one
|
|
7400
|
+
// arrives: the runner's last order body is the checkout's otherwise.
|
|
7401
|
+
async function refreshUpsellStepEvidence({ page, events, path, email, checkoutPage, args, step, upsell, preferredOrderBody = null, initialLineItems, responseIndexBefore, lateWaitMs = LATE_UPSELL_EVIDENCE_TIMEOUT_MS }) {
|
|
7402
|
+
let lateUpsellEvidence = null;
|
|
7403
|
+
let judgedBody = preferredOrderBody;
|
|
7404
|
+
if (step === "accept" && upsellBodyReadTimedOut(upsell)) {
|
|
7405
|
+
lateUpsellEvidence = await waitForLateUpsellEvidence(events, {
|
|
7406
|
+
responseIndexBefore,
|
|
7407
|
+
mutationRequest: upsell[REQUEST_IDENTITY] || null,
|
|
7408
|
+
mutationRespondedAt: upsell.mutation_responded_at,
|
|
7409
|
+
initialLineItems,
|
|
7410
|
+
expectedItems: upsell.expected_items,
|
|
7411
|
+
timeoutMs: lateWaitMs,
|
|
7412
|
+
});
|
|
7413
|
+
upsell.late_evidence = { source: lateUpsellEvidence.source, waited_ms: lateUpsellEvidence.waited_ms };
|
|
7414
|
+
if (lateUpsellEvidence.body) judgedBody = lateUpsellEvidence.body;
|
|
7415
|
+
}
|
|
7416
|
+
const refreshed = await buildOrderEvidence({ page, events, path, email, checkoutPage, args, preferredOrderBody: judgedBody });
|
|
7417
|
+
return { refreshed, lateUpsellEvidence };
|
|
7418
|
+
}
|
|
7419
|
+
|
|
7420
|
+
// The accepted-upsell proof for one step, given how its evidence arrived. A
|
|
7421
|
+
// failed proof stands as a failure unless the mutation answered 2xx, its body
|
|
7422
|
+
// read timed out, and no later evidence of it arrived: then the lines judged
|
|
7423
|
+
// are stale, and the step is unverified rather than missing its upsell. A
|
|
7424
|
+
// post-click read-back without the line ("order_read_back_missing_line") is
|
|
7425
|
+
// such evidence, so the failure stands.
|
|
7426
|
+
//
|
|
7427
|
+
// A passing proof from those stale lines is no more evidence than a failing
|
|
7428
|
+
// one: lines on hand before this step's mutation answered cannot carry its
|
|
7429
|
+
// upsell, and can carry an earlier step's (whose body may have landed on the
|
|
7430
|
+
// same order-upsells URL meanwhile), so it is unverified too (#505).
|
|
7431
|
+
function acceptedUpsellStepProof(upsell, lateEvidence, proof) {
|
|
7432
|
+
if (!upsellBodyReadTimedOut(upsell)) return proof;
|
|
7433
|
+
if (lateEvidence && lateEvidence.source !== "none") return proof;
|
|
7434
|
+
const read = upsell.api_response_body_read;
|
|
7435
|
+
return {
|
|
7436
|
+
...proof,
|
|
7437
|
+
ok: false,
|
|
7438
|
+
unverified: true,
|
|
7439
|
+
reason: `accepted upsell unverified: the order upsell API answered HTTP ${upsell.api_response_status} but its body did not load within ${read.bound_ms}ms, and no later order read-back showed the accepted line within ${lateEvidence?.waited_ms ?? 0}ms`,
|
|
7440
|
+
matched_lines: [],
|
|
7441
|
+
stale_reason: proof.ok
|
|
7442
|
+
? "the order lines on hand predate this step's upsell mutation, so a match in them is not evidence of it"
|
|
7443
|
+
: proof.reason,
|
|
7444
|
+
};
|
|
7445
|
+
}
|
|
7446
|
+
|
|
6255
7447
|
function acceptedUpsellProof(lines, initialLines, expectedItems, events) {
|
|
6256
7448
|
if (!Array.isArray(lines) || lines.length === 0) {
|
|
6257
7449
|
return { ok: false, reason: "final order lines were empty", expected_items: expectedItems || [], matched_lines: [] };
|
|
@@ -6352,6 +7544,8 @@ function summarizeUpsellStep(step) {
|
|
|
6352
7544
|
expected_items: step.expected_items,
|
|
6353
7545
|
api_response_seen: step.api_response_seen,
|
|
6354
7546
|
api_response_status: step.api_response_status,
|
|
7547
|
+
...(step.api_response_body_read ? { api_response_body_read: step.api_response_body_read } : {}),
|
|
7548
|
+
...(step.late_evidence ? { late_evidence: step.late_evidence } : {}),
|
|
6355
7549
|
accepted_upsell_line_present: step.verification?.accepted_upsell_line_present,
|
|
6356
7550
|
};
|
|
6357
7551
|
}
|
|
@@ -6489,6 +7683,8 @@ export const __qaBrowserTestHooks = Object.freeze({
|
|
|
6489
7683
|
COUPON_APPLY_CONTROL_SELECTOR,
|
|
6490
7684
|
analyticsCorrectnessCaptureAssertions,
|
|
6491
7685
|
analyticsCorrectnessRunnerFailureAssertion,
|
|
7686
|
+
captureAnalyticsCorrectnessInContext,
|
|
7687
|
+
captureAnalyticsParityInContext,
|
|
6492
7688
|
analyticsParityCaptureAssertions,
|
|
6493
7689
|
analyticsParityRunnerFailureAssertion,
|
|
6494
7690
|
acceptedUpsellProof,
|
|
@@ -6500,9 +7696,24 @@ export const __qaBrowserTestHooks = Object.freeze({
|
|
|
6500
7696
|
primaryCtaInspectionScript,
|
|
6501
7697
|
clickCouponApplyControl,
|
|
6502
7698
|
isOrderUpsellsUrl,
|
|
7699
|
+
clickUpsellPath,
|
|
7700
|
+
isPerpetuallyAnimated,
|
|
7701
|
+
readJsonResponseBody,
|
|
7702
|
+
readJsonResponseBodyWithin,
|
|
7703
|
+
readJsonResponseBodyBounded,
|
|
7704
|
+
RESPONSE_BODY_READ_TIMEOUT_MS,
|
|
7705
|
+
waitForLateUpsellEvidence,
|
|
7706
|
+
refreshUpsellStepEvidence,
|
|
7707
|
+
acceptedUpsellStepProof,
|
|
7708
|
+
REQUEST_IDENTITY,
|
|
7709
|
+
captureCheckoutEvents,
|
|
7710
|
+
buildOrderEvidence,
|
|
6503
7711
|
testEmail,
|
|
6504
7712
|
testOrderPaths,
|
|
6505
7713
|
testOrderPlans,
|
|
7714
|
+
commonOrderPathPlan,
|
|
7715
|
+
describeCommonOrderPathPlan,
|
|
7716
|
+
upsellActionCoverageAssertion,
|
|
6506
7717
|
planId,
|
|
6507
7718
|
argsForPlan,
|
|
6508
7719
|
enforceTestOrderLimit,
|