@nextcommerce/campaigns-os 1.41.2 → 1.43.2
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 +4 -2
- package/CHANGELOG.md +629 -0
- package/README.md +8 -6
- package/agents/claude/CLAUDE.md +5 -1
- package/campaign-spec/dist/types.d.ts +2 -0
- package/contracts/agent-relevant-change-policy.v1.json +5 -0
- package/contracts/effects.v1.json +118 -25
- package/contracts/migration-sidecar-bundle.v0.json +9 -0
- package/contracts/release-ledger.json +1424 -0
- package/contracts/supported-surface.json +12 -11
- package/docs/build-packet.md +120 -8
- package/docs/campaigns-os-build-flow.md +3 -2
- package/docs/design-source-package.md +89 -15
- package/docs/effects.md +83 -2
- package/docs/local-setup.md +51 -0
- package/docs/migration-sidecar-bundle.md +6 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/progress-snapshots.md +16 -6
- package/docs/qa-and-test-orders.md +157 -17
- package/docs/release-ledger-authoring-guide.md +6 -4
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +3 -2
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +6 -1
- package/schemas/campaign-spec.v4.schema.json +4 -0
- package/schemas/campaigns-os-progress-snapshot.v0.schema.json +1 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +1 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +1 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +13 -8
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +8 -6
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +4 -4
- package/skills/next-campaigns-os/SKILL.md +17 -4
- package/skills/next-campaigns-os/references/session-intake.md +4 -4
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +10 -9
- package/skills.json +11 -11
- package/src/build-brief.mjs +6 -4
- package/src/built-script-syntax.mjs +480 -0
- package/src/campaigns-api-key.mjs +99 -0
- package/src/cli-helpers.mjs +118 -0
- package/src/cli.mjs +796 -6963
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +898 -0
- package/src/diagnostic.mjs +2 -1
- package/src/directory-lock.mjs +270 -0
- package/src/doctor/checks.mjs +4415 -0
- package/src/doctor/inspect.mjs +636 -0
- package/src/doctor/next-step.mjs +731 -0
- package/src/finding-cause.mjs +14 -10
- package/src/install-invocation.mjs +29 -0
- package/src/invocation.mjs +179 -0
- package/src/lifecycle.mjs +5 -4
- package/src/polish-node.mjs +5 -2
- package/src/private-template-source.mjs +1 -1
- package/src/progress-node.mjs +9 -37
- package/src/progress.mjs +5 -3
- 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 +778 -77
- package/src/qa-build-scope.mjs +47 -0
- package/src/qa-node.mjs +276 -39
- package/src/qa-publish.mjs +4 -0
- package/src/qa-sidecar.mjs +2 -0
- package/src/qa-verdict-discovery.mjs +11 -0
- package/src/qa-verdict-publish.mjs +1 -0
- package/src/qa-verdict.mjs +8 -1
- package/src/readback.mjs +2 -1
- package/src/run-record-closeout.mjs +3 -4
- package/src/run-record.mjs +4 -0
- package/src/sidecar-bundle.mjs +21 -0
- package/src/source-html-intake.mjs +1 -1
- package/src/source-html-manifest.mjs +9 -2
- package/src/spec-source-identity.mjs +44 -0
- package/src/stage-ledger.mjs +32 -1
- package/src/target-lock.mjs +54 -0
- package/src/template-brand-contract.mjs +17 -1
- package/src/tooling-setup.mjs +160 -0
package/src/qa-browser.mjs
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
placeholderTextResidueMatches,
|
|
56
56
|
referencedDemoAssetBasenames,
|
|
57
57
|
summarizePlaceholderTerms,
|
|
58
|
+
withoutHiddenPaymentLogos,
|
|
58
59
|
} from "./template-brand-contract.mjs";
|
|
59
60
|
|
|
60
61
|
const DEFAULT_BROWSER_TIMEOUT_MS = 30000;
|
|
@@ -256,6 +257,11 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
256
257
|
// reader could be surprised by. Over-charging costs at worst one unspent
|
|
257
258
|
// planned path; under-charging costs a real order nobody budgeted for.
|
|
258
259
|
creationBudget.consume({ plan_id: identifier, kind: "hosted_checkout_redirect" });
|
|
260
|
+
} else if (firstAttempt.upsell_unverified) {
|
|
261
|
+
// Created, and every check passed except an accepted upsell nothing
|
|
262
|
+
// could prove or disprove. A re-run would buy a second order to ask
|
|
263
|
+
// again, and recovery cannot re-check an upsell read-only, so the
|
|
264
|
+
// attempt stands and goes to manual review.
|
|
259
265
|
} else if (!firstAttempt.ok) {
|
|
260
266
|
const classification = classifyTestOrderCreation(firstAttempt);
|
|
261
267
|
if (classification.creation === "not_created") {
|
|
@@ -401,7 +407,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
401
407
|
// with --analytics-baseline's legacy receipt, and identity resolution cannot
|
|
402
408
|
// derive a receipt page yet (receipt-aware capture is out of packet 01's
|
|
403
409
|
// scope). Absent that override, the candidate IS the resolved target.
|
|
404
|
-
function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl }) {
|
|
410
|
+
function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl, capturePage = null, rootFallback = null }) {
|
|
405
411
|
const baselinePublicUrl = redactUrlQuery(baselineUrl);
|
|
406
412
|
const candidatePublicUrl = redactUrlQuery(candidateUrl);
|
|
407
413
|
const analyticsPage = { page_id: "analytics", url: candidatePublicUrl || baselinePublicUrl || undefined };
|
|
@@ -421,6 +427,8 @@ function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, ca
|
|
|
421
427
|
candidate_event_count: candidate.eventNames.length,
|
|
422
428
|
baseline_inventory: Object.fromEntries(Object.entries(baseline.inventory).map(([k, v]) => [k, v.length])),
|
|
423
429
|
candidate_inventory: Object.fromEntries(Object.entries(candidate.inventory).map(([k, v]) => [k, v.length])),
|
|
430
|
+
...(capturePage ? { capture_page: capturePage } : {}),
|
|
431
|
+
...(rootFallback ? { root_fallback: rootFallback } : {}),
|
|
424
432
|
},
|
|
425
433
|
}));
|
|
426
434
|
return assertions;
|
|
@@ -474,9 +482,14 @@ export async function runAnalyticsParityChecks(args = {}, options = {}) {
|
|
|
474
482
|
extraHTTPHeaders: args["auth-cookie"] ? { Cookie: String(args["auth-cookie"]) } : undefined,
|
|
475
483
|
});
|
|
476
484
|
try {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
485
|
+
// An explicit --analytics-candidate (the receipt pairing above) is the
|
|
486
|
+
// page the operator named, so it is captured as given.
|
|
487
|
+
if (trim(args["analytics-candidate"])) {
|
|
488
|
+
const baseline = await captureAnalyticsForUrl(context, baselineUrl, args, extraHosts);
|
|
489
|
+
const candidate = await captureAnalyticsForUrl(context, candidateUrl, args, extraHosts);
|
|
490
|
+
return analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl });
|
|
491
|
+
}
|
|
492
|
+
return await captureAnalyticsParityInContext(context, baselineUrl, candidateUrl, args, extraHosts, options);
|
|
480
493
|
} catch (error) {
|
|
481
494
|
return [analyticsParityRunnerFailureAssertion({ baselineUrl, candidateUrl, error })];
|
|
482
495
|
} finally {
|
|
@@ -485,16 +498,57 @@ export async function runAnalyticsParityChecks(args = {}, options = {}) {
|
|
|
485
498
|
}
|
|
486
499
|
}
|
|
487
500
|
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
501
|
+
// #503: the resolved-target candidate gets the same partial-scope fallback as
|
|
502
|
+
// the correctness inventory (#493). A partial build has no page at the
|
|
503
|
+
// identity root, so the candidate is the first built in-scope entry, and a
|
|
504
|
+
// root that answers non-2xx falls back the same way. The candidate is picked
|
|
505
|
+
// first: when nothing in scope answers, the leg reports
|
|
506
|
+
// no_in_scope_page_captured (skipped) or no_capture_page_answered (blocker)
|
|
507
|
+
// without loading the baseline, instead of diffing the baseline against an
|
|
508
|
+
// empty or generic page.
|
|
509
|
+
async function captureAnalyticsParityInContext(context, baselineUrl, targetUrl, args, extraHosts, options = {}) {
|
|
510
|
+
const selected = await captureFirstAnsweringAnalyticsPage(context, targetUrl, args, extraHosts, options);
|
|
511
|
+
if (!selected.capture) {
|
|
512
|
+
return [analyticsCorrectnessNoCapturePageAssertion({ rootUrl: targetUrl, attempts: selected.attempts, family: "analytics-parity" })];
|
|
513
|
+
}
|
|
514
|
+
const baseline = await captureAnalyticsForUrl(context, baselineUrl, args, extraHosts);
|
|
515
|
+
return analyticsParityCaptureAssertions({
|
|
516
|
+
baseline,
|
|
517
|
+
candidate: selected.capture,
|
|
518
|
+
baselineUrl,
|
|
519
|
+
candidateUrl: selected.url,
|
|
520
|
+
capturePage: selected.capturePage,
|
|
521
|
+
rootFallback: selected.rootFallback,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Analytics CORRECTNESS inventory leg: capture ONE page and assess only
|
|
526
|
+
// declared tags/pixels. Purchase is finalized later from the canonical
|
|
527
|
+
// typed-card order's recognized receipt; this inventory visit is never treated
|
|
528
|
+
// as Purchase authority.
|
|
492
529
|
// `options.target` is the capture target resolved from the campaign's
|
|
493
530
|
// identity (public_route_slug + route_root) in qa-node — packet 01 / INV-2:
|
|
494
531
|
// this leg no longer reads --analytics-candidate or --base-url; the URL it
|
|
495
532
|
// visits is a function of resolved identity, recorded on every assertion.
|
|
496
|
-
|
|
533
|
+
//
|
|
534
|
+
// #493: a partial build can have no page at that campaign root (the built
|
|
535
|
+
// entry is deeper, e.g. checkout/). When qa-node reports the root out of the
|
|
536
|
+
// built scope (`options.rootInScope === false`), or the root answers non-2xx,
|
|
537
|
+
// the leg captures the first built in-scope entry instead
|
|
538
|
+
// (`options.fallbackTargets`, the same entry the partial-scope planner
|
|
539
|
+
// selects) and records which page it used. When nothing in scope can be
|
|
540
|
+
// captured, the leg is skipped when the build has no capturable page, and
|
|
541
|
+
// fails as a blocker when candidates existed but none answered 2xx; neither
|
|
542
|
+
// case fails every declared vendor against an empty page.
|
|
543
|
+
// #500: the URL the capture page actually settled on, after redirects, keyed by
|
|
544
|
+
// the capture object so the parity capture shape stays unchanged. Local-serve
|
|
545
|
+
// review (qa-node) downgrades a silent pixel only when this is loopback: a
|
|
546
|
+
// localhost root that redirects to a production host measured production.
|
|
547
|
+
const ANALYTICS_CAPTURE_DOCUMENT_URL = new WeakMap();
|
|
548
|
+
|
|
549
|
+
function analyticsCorrectnessCaptureAssertions({ capture, contract, url, capturePage = null, rootFallback = null, finalUrl = ANALYTICS_CAPTURE_DOCUMENT_URL.get(capture) ?? null }) {
|
|
497
550
|
const publicUrl = redactUrlQuery(url);
|
|
551
|
+
const publicFinalUrl = redactUrlQuery(finalUrl) || null;
|
|
498
552
|
const analyticsPage = { page_id: "analytics", url: publicUrl || undefined };
|
|
499
553
|
const assertions = assessAnalyticsInventory(capture, contract || {}, { url: publicUrl });
|
|
500
554
|
assertions.unshift(assertion({
|
|
@@ -503,18 +557,63 @@ function analyticsCorrectnessCaptureAssertions({ capture, contract, url }) {
|
|
|
503
557
|
page: analyticsPage,
|
|
504
558
|
status: STATUS.PASS,
|
|
505
559
|
expected: "live dataLayer + tag-fire capture on the candidate page",
|
|
506
|
-
actual: `events=${capture.eventNames.length}, tags=${Object.values(capture.inventory).flat().length}
|
|
560
|
+
actual: `events=${capture.eventNames.length}, tags=${Object.values(capture.inventory).flat().length}`
|
|
561
|
+
+ (rootFallback ? ` (captured built entry ${publicUrl}; campaign root ${rootFallback.reason === "non_2xx" ? `answered HTTP ${rootFallback.http_status}` : "is out of the built scope"})` : ""),
|
|
507
562
|
// Counts only. Root capture is provider/tag inventory, never Purchase
|
|
508
563
|
// authority, even if a stray Purchase happens to appear there.
|
|
509
564
|
evidence: {
|
|
510
565
|
url: publicUrl,
|
|
511
566
|
event_count: capture.eventNames.length,
|
|
512
567
|
inventory: Object.fromEntries(Object.entries(capture.inventory).map(([k, v]) => [k, v.length])),
|
|
568
|
+
// The page URL after redirects and settling; `capture_page.url` and
|
|
569
|
+
// `url` are the URL requested.
|
|
570
|
+
final_url: publicFinalUrl,
|
|
571
|
+
...(capturePage ? { capture_page: capturePage } : {}),
|
|
572
|
+
...(rootFallback ? { root_fallback: rootFallback } : {}),
|
|
513
573
|
},
|
|
514
574
|
}));
|
|
515
575
|
return assertions;
|
|
516
576
|
}
|
|
517
577
|
|
|
578
|
+
// No page was captured. Two cases, kept apart so a failed capture never
|
|
579
|
+
// silently removes analytics gating:
|
|
580
|
+
// - nothing built was capturable (the root is out of the built scope and no
|
|
581
|
+
// built entry exists): SKIPPED, disposition-neutral, since there is no page
|
|
582
|
+
// whose tags could be measured;
|
|
583
|
+
// - candidates existed but none answered 2xx (e.g. transient 503s): the
|
|
584
|
+
// declared vendors went unmeasured, which is a blocker naming each attempt,
|
|
585
|
+
// so a later successful order cannot report the run ready.
|
|
586
|
+
// The parity leg (#503) reports the same two outcomes under its own family.
|
|
587
|
+
function analyticsCorrectnessNoCapturePageAssertion({ rootUrl, attempts, family = "analytics-correctness" }) {
|
|
588
|
+
const publicUrl = redactUrlQuery(rootUrl);
|
|
589
|
+
const page = { page_id: "analytics", url: publicUrl || undefined };
|
|
590
|
+
const expected = "live dataLayer + tag-fire capture on the campaign root or the first built in-scope page";
|
|
591
|
+
const loaded = attempts.filter((attempt) => attempt.outcome === "non_2xx" || attempt.outcome === "navigation_error");
|
|
592
|
+
if (!loaded.length) {
|
|
593
|
+
return assertion({
|
|
594
|
+
id: `${family}:capture`,
|
|
595
|
+
family,
|
|
596
|
+
page,
|
|
597
|
+
status: STATUS.SKIPPED,
|
|
598
|
+
expected,
|
|
599
|
+
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",
|
|
600
|
+
evidence: { url: publicUrl, reason: "no_in_scope_page_captured", attempts },
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
return assertion({
|
|
604
|
+
id: `${family}:capture`,
|
|
605
|
+
family,
|
|
606
|
+
page,
|
|
607
|
+
status: STATUS.FAIL,
|
|
608
|
+
severity: SEVERITY.BLOCKER,
|
|
609
|
+
expected,
|
|
610
|
+
actual: `no_capture_page_answered: declared analytics went unmeasured; ${loaded.map((attempt) => (attempt.outcome === "navigation_error"
|
|
611
|
+
? `${attempt.url} failed to load (${attempt.error_code})`
|
|
612
|
+
: `${attempt.url} answered HTTP ${attempt.http_status}`)).join(", ")}`,
|
|
613
|
+
evidence: { url: publicUrl, reason: "no_capture_page_answered", attempts },
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
518
617
|
function analyticsCorrectnessRunnerFailureAssertion({ url, error }) {
|
|
519
618
|
const publicUrl = redactUrlQuery(url);
|
|
520
619
|
const captureError = projectAnalyticsCaptureError(error, { fallbackKind: "unreadable" });
|
|
@@ -530,6 +629,147 @@ function analyticsCorrectnessRunnerFailureAssertion({ url, error }) {
|
|
|
530
629
|
});
|
|
531
630
|
}
|
|
532
631
|
|
|
632
|
+
function isHttpOk(status) {
|
|
633
|
+
// A null status (same-document or non-HTTP navigation) is not evidence of
|
|
634
|
+
// a missing page, so it keeps the capture.
|
|
635
|
+
return status == null || (status >= 200 && status < 300);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// One capture per page: `/campaign`, `/campaign/` and `/campaign/index.html`
|
|
639
|
+
// are the same page, so a topology URL written differently from the root is
|
|
640
|
+
// not loaded twice.
|
|
641
|
+
// #503: step routing is path-based in every certified family (page-kit builds
|
|
642
|
+
// each page to its own `<route>/index.html`; a spec route has its query
|
|
643
|
+
// stripped), so a query string does not normally name a different page. A
|
|
644
|
+
// URL that declares its own query (`/campaign/?step=checkout`) is still kept
|
|
645
|
+
// apart from a page without that query: merging it into the root on path alone
|
|
646
|
+
// measured the root's generic answer instead of that entry. A URL without a
|
|
647
|
+
// query of its own names the page on any query (an operator's `?preview=` on
|
|
648
|
+
// the root does not split it from the built page at that path).
|
|
649
|
+
function analyticsCapturePageKey(value) {
|
|
650
|
+
try {
|
|
651
|
+
const parsed = new URL(value);
|
|
652
|
+
const path = parsed.pathname.replace(/(?:^|\/)index\.html$/, "/").replace(/\/+$/, "");
|
|
653
|
+
parsed.searchParams.sort();
|
|
654
|
+
return { path: `${parsed.origin}${path}/`, query: parsed.searchParams.toString() };
|
|
655
|
+
} catch {
|
|
656
|
+
return typeof value === "string" && value.trim() ? { path: redactUrlQuery(value), query: "" } : null;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// True when `candidate` is the page `known` already names (see
|
|
661
|
+
// analyticsCapturePageKey). Shared with qa-node's root-in-scope judgment so
|
|
662
|
+
// scope and deduplication cannot disagree about which page is the root.
|
|
663
|
+
export function isSameAnalyticsCapturePage(candidate, known) {
|
|
664
|
+
const a = analyticsCapturePageKey(candidate);
|
|
665
|
+
const b = analyticsCapturePageKey(known);
|
|
666
|
+
if (!a || !b || a.path !== b.path) return false;
|
|
667
|
+
return !a.query || a.query === b.query;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// A URL on `root`'s path told apart from it by a query of its own: the entry
|
|
671
|
+
// `isSameAnalyticsCapturePage` keeps separate from the root although its
|
|
672
|
+
// redacted URL reads as the root.
|
|
673
|
+
function isQueryRoutedFrom(value, root) {
|
|
674
|
+
const a = analyticsCapturePageKey(value);
|
|
675
|
+
const b = analyticsCapturePageKey(root);
|
|
676
|
+
return !!a && !!b && a.path === b.path && !!a.query && a.query !== b.query;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function analyticsCaptureCandidates(url, options = {}) {
|
|
680
|
+
const candidates = [];
|
|
681
|
+
const seen = [];
|
|
682
|
+
const add = (candidate) => {
|
|
683
|
+
if (!candidate.url || seen.some((known) => isSameAnalyticsCapturePage(candidate.url, known))) return;
|
|
684
|
+
seen.push(candidate.url);
|
|
685
|
+
candidates.push(candidate);
|
|
686
|
+
};
|
|
687
|
+
const rootInScope = options.rootInScope !== false;
|
|
688
|
+
if (rootInScope) add({ url, source: "campaign_root" });
|
|
689
|
+
// An out-of-scope root stays unvisited even if a fallback names it.
|
|
690
|
+
else if (url) seen.push(url);
|
|
691
|
+
for (const entry of Array.isArray(options.fallbackTargets) ? options.fallbackTargets : []) {
|
|
692
|
+
if (!entry || !trim(entry.url)) continue;
|
|
693
|
+
add({
|
|
694
|
+
url: trim(entry.url),
|
|
695
|
+
source: "built_entry",
|
|
696
|
+
page_id: entry.page_id || null,
|
|
697
|
+
funnel_id: entry.funnel_id || null,
|
|
698
|
+
// The query value is redacted from every record; this says the entry
|
|
699
|
+
// was told apart from the root by its query alone.
|
|
700
|
+
...(url && isQueryRoutedFrom(trim(entry.url), url) ? { query_routed: true } : {}),
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
return { candidates, rootInScope, rootKey: redactUrlQuery(url) };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Walks the capture candidates (the campaign root when in scope, then the
|
|
707
|
+
// built entries) and captures the first page that answers 2xx. Shared by the
|
|
708
|
+
// correctness inventory (#493) and the opt-in parity candidate (#503) so both
|
|
709
|
+
// legs pick the same page. Returns `{ capture, url, capturePage, rootFallback }`
|
|
710
|
+
// for the page it used, or `{ capture: null, attempts }` when none answered.
|
|
711
|
+
async function captureFirstAnsweringAnalyticsPage(context, url, args, extraHosts, options = {}, { strictCollect = false } = {}) {
|
|
712
|
+
const { candidates, rootInScope, rootKey } = analyticsCaptureCandidates(url, options);
|
|
713
|
+
const attempts = [];
|
|
714
|
+
if (!rootInScope) attempts.push({ url: rootKey, source: "campaign_root", outcome: "out_of_built_scope", http_status: null });
|
|
715
|
+
let rootFallback = rootInScope ? null : { url: rootKey, reason: "out_of_built_scope", http_status: null };
|
|
716
|
+
const queryRouted = (candidate) => (candidate.query_routed ? { query_routed: true } : {});
|
|
717
|
+
for (const candidate of candidates) {
|
|
718
|
+
const { capture, httpStatus, navigationError } = await captureAnalyticsPage(
|
|
719
|
+
context, candidate.url, args, extraHosts, { perPageNavigationErrors: true, strictCollect },
|
|
720
|
+
);
|
|
721
|
+
if (navigationError) {
|
|
722
|
+
// One page failing to load (a timeout, a refused connection) moves on to
|
|
723
|
+
// the next candidate; the blocker lists it if none answers.
|
|
724
|
+
attempts.push({ url: redactUrlQuery(candidate.url), source: candidate.source, ...queryRouted(candidate), outcome: "navigation_error", http_status: null, error_code: navigationError });
|
|
725
|
+
if (candidate.source === "campaign_root") {
|
|
726
|
+
rootFallback = { url: rootKey, reason: "navigation_error", http_status: null, error_code: navigationError };
|
|
727
|
+
}
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
if (isHttpOk(httpStatus)) {
|
|
731
|
+
const usedFallback = candidate.source !== "campaign_root";
|
|
732
|
+
return {
|
|
733
|
+
capture,
|
|
734
|
+
url: candidate.url,
|
|
735
|
+
capturePage: {
|
|
736
|
+
url: redactUrlQuery(candidate.url),
|
|
737
|
+
source: candidate.source,
|
|
738
|
+
...(candidate.page_id ? { page_id: candidate.page_id } : {}),
|
|
739
|
+
...(candidate.funnel_id ? { funnel_id: candidate.funnel_id } : {}),
|
|
740
|
+
...queryRouted(candidate),
|
|
741
|
+
http_status: httpStatus ?? null,
|
|
742
|
+
},
|
|
743
|
+
rootFallback: usedFallback ? rootFallback : null,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
attempts.push({ url: redactUrlQuery(candidate.url), source: candidate.source, ...queryRouted(candidate), outcome: "non_2xx", http_status: httpStatus });
|
|
747
|
+
if (candidate.source === "campaign_root") {
|
|
748
|
+
rootFallback = { url: rootKey, reason: "non_2xx", http_status: httpStatus };
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return { capture: null, attempts };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Browser-owning half split out so a real-browser test can drive the fallback
|
|
755
|
+
// against a route-fulfilled context without a second Chromium launch policy.
|
|
756
|
+
async function captureAnalyticsCorrectnessInContext(context, url, contract, args, extraHosts, options = {}) {
|
|
757
|
+
// strictCollect: a page.evaluate() that fails during collection (the
|
|
758
|
+
// execution context destroyed by a reload, a crashed page) must not read
|
|
759
|
+
// as a clean empty capture. It propagates and becomes the
|
|
760
|
+
// analytics-correctness:runner blocker, so no tag check is emitted from an
|
|
761
|
+
// unmeasured page and local-serve review has nothing to downgrade (#500).
|
|
762
|
+
const selected = await captureFirstAnsweringAnalyticsPage(context, url, args, extraHosts, options, { strictCollect: true });
|
|
763
|
+
if (!selected.capture) return [analyticsCorrectnessNoCapturePageAssertion({ rootUrl: url, attempts: selected.attempts })];
|
|
764
|
+
return analyticsCorrectnessCaptureAssertions({
|
|
765
|
+
capture: selected.capture,
|
|
766
|
+
contract,
|
|
767
|
+
url: selected.url,
|
|
768
|
+
capturePage: selected.capturePage,
|
|
769
|
+
rootFallback: selected.rootFallback,
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
533
773
|
export async function runAnalyticsCorrectnessChecks(args = {}, contract = {}, options = {}) {
|
|
534
774
|
const url = trim(options.target?.url) || null;
|
|
535
775
|
const correctnessPage = { page_id: "analytics", url: redactUrlQuery(url) || undefined };
|
|
@@ -545,12 +785,7 @@ export async function runAnalyticsCorrectnessChecks(args = {}, contract = {}, op
|
|
|
545
785
|
})];
|
|
546
786
|
}
|
|
547
787
|
|
|
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];
|
|
788
|
+
const extraHosts = analyticsCorrectnessExtraHosts(args, contract);
|
|
554
789
|
|
|
555
790
|
const browser = await launchChromium(args);
|
|
556
791
|
const context = await browser.newContext({
|
|
@@ -558,8 +793,7 @@ export async function runAnalyticsCorrectnessChecks(args = {}, contract = {}, op
|
|
|
558
793
|
extraHTTPHeaders: args["auth-cookie"] ? { Cookie: String(args["auth-cookie"]) } : undefined,
|
|
559
794
|
});
|
|
560
795
|
try {
|
|
561
|
-
|
|
562
|
-
return analyticsCorrectnessCaptureAssertions({ capture, contract, url });
|
|
796
|
+
return await captureAnalyticsCorrectnessInContext(context, url, contract, args, extraHosts, options);
|
|
563
797
|
} catch (error) {
|
|
564
798
|
return [analyticsCorrectnessRunnerFailureAssertion({ url, error })];
|
|
565
799
|
} finally {
|
|
@@ -568,6 +802,15 @@ export async function runAnalyticsCorrectnessChecks(args = {}, contract = {}, op
|
|
|
568
802
|
}
|
|
569
803
|
}
|
|
570
804
|
|
|
805
|
+
// Seed the host filter with declared out-of-band vendor names so vendors whose
|
|
806
|
+
// host contains their name (everflow, northbeam, …) get captured.
|
|
807
|
+
function analyticsCorrectnessExtraHosts(args, contract) {
|
|
808
|
+
const vendorHosts = ((contract && contract.out_of_band_pixels) || [])
|
|
809
|
+
.map((p) => (p && p.vendor ? String(p.vendor) : null))
|
|
810
|
+
.filter(Boolean);
|
|
811
|
+
return [...analyticsExtraHosts(args), ...vendorHosts];
|
|
812
|
+
}
|
|
813
|
+
|
|
571
814
|
function analyticsExtraHosts(args) {
|
|
572
815
|
const raw = args["analytics-hosts"];
|
|
573
816
|
if (!raw) return [];
|
|
@@ -575,6 +818,22 @@ function analyticsExtraHosts(args) {
|
|
|
575
818
|
}
|
|
576
819
|
|
|
577
820
|
export async function captureAnalyticsForUrl(context, url, args, extraHosts = []) {
|
|
821
|
+
return (await captureAnalyticsPage(context, url, args, extraHosts)).capture;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Same capture, plus the main-document HTTP status, which the correctness leg
|
|
825
|
+
// uses to tell an empty page from a missing one (#493). Kept off the capture
|
|
826
|
+
// object so parity comparisons never see it.
|
|
827
|
+
// A stable code for a failed navigation: Playwright's TimeoutError, or the
|
|
828
|
+
// net::ERR_* token Chromium reports. The raw message is dropped because it
|
|
829
|
+
// echoes the URL (query included) and a call log.
|
|
830
|
+
function analyticsNavigationErrorCode(error) {
|
|
831
|
+
if (error?.name === "TimeoutError") return "navigation_timeout";
|
|
832
|
+
const netError = String(error?.message || "").match(/net::ERR_[A-Z0-9_]+/);
|
|
833
|
+
return netError ? netError[0] : "navigation_failed";
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async function captureAnalyticsPage(context, url, args, extraHosts = [], options = {}) {
|
|
578
837
|
const page = await context.newPage();
|
|
579
838
|
const capture = await attachAnalyticsCapture(page, { extraHosts });
|
|
580
839
|
const timeoutMs = numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS);
|
|
@@ -583,11 +842,27 @@ export async function captureAnalyticsForUrl(context, url, args, extraHosts = []
|
|
|
583
842
|
// domcontentloaded (not "load") so a single stuck analytics beacon — exactly
|
|
584
843
|
// the kind of subresource we're capturing — can't starve the goto timeout.
|
|
585
844
|
// Mirrors runPageBrowserChecks; the settle wait below lets async tags fire.
|
|
586
|
-
|
|
845
|
+
let response;
|
|
846
|
+
try {
|
|
847
|
+
response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: timeoutMs });
|
|
848
|
+
} catch (error) {
|
|
849
|
+
// Only the navigation is per-page. A closed page or a disconnected
|
|
850
|
+
// browser is the runner failing, not this URL, so it stays an error.
|
|
851
|
+
if (!options.perPageNavigationErrors || page.isClosed() || context.browser()?.isConnected() === false) throw error;
|
|
852
|
+
return { capture: null, httpStatus: null, navigationError: analyticsNavigationErrorCode(error) };
|
|
853
|
+
}
|
|
854
|
+
let httpStatus = null;
|
|
855
|
+
try { httpStatus = typeof response?.status === "function" ? response.status() : null; } catch { httpStatus = null; }
|
|
587
856
|
await page.waitForLoadState("networkidle", { timeout: settleMs }).catch(() => {});
|
|
588
857
|
// Let async GTM/pixel tags and deferred dataLayer pushes fire before reading.
|
|
589
858
|
await page.waitForTimeout(settleMs);
|
|
590
|
-
|
|
859
|
+
// Parity keeps the historical best-effort read; the correctness leg
|
|
860
|
+
// collects strictly (see captureAnalyticsCorrectnessInContext).
|
|
861
|
+
const collected = await capture.collect({ strict: options.strictCollect === true });
|
|
862
|
+
let finalUrl = null;
|
|
863
|
+
try { finalUrl = page.url() || null; } catch { finalUrl = null; }
|
|
864
|
+
if (collected && typeof collected === "object" && finalUrl) ANALYTICS_CAPTURE_DOCUMENT_URL.set(collected, finalUrl);
|
|
865
|
+
return { capture: collected, httpStatus };
|
|
591
866
|
} finally {
|
|
592
867
|
capture.detach();
|
|
593
868
|
await page.close().catch(() => {});
|
|
@@ -1732,7 +2007,9 @@ async function templateResidueAssertions(browserPage, page, options = {}) {
|
|
|
1732
2007
|
if (chrome && Array.isArray(supported) && supported.length) {
|
|
1733
2008
|
const unsupported = (chrome.methods || []).filter((method) => !supported.includes(method));
|
|
1734
2009
|
if (unsupported.length) {
|
|
1735
|
-
|
|
2010
|
+
// Logos the template still keeps hidden (payment-logos.html) are gated,
|
|
2011
|
+
// not residue; a revealed one stays in the HTML and is judged below.
|
|
2012
|
+
const html = withoutHiddenPaymentLogos(await browserPage.content().catch(() => ""));
|
|
1736
2013
|
// One evaluate for ALL unsupported methods' selectors; partition the
|
|
1737
2014
|
// visibility results per method in JS to keep browser round-trips flat.
|
|
1738
2015
|
const artifactsByMethod = new Map(unsupported.map((method) => [method, methodPaymentArtifacts(chrome, method)]));
|
|
@@ -2776,6 +3053,11 @@ async function collectOrderAnalytics({
|
|
|
2776
3053
|
deadline,
|
|
2777
3054
|
now = () => Date.now(),
|
|
2778
3055
|
wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
3056
|
+
// The page's document location, read right after the settled collection
|
|
3057
|
+
// (#500). The order's final_url is recorded before this settle window, so a
|
|
3058
|
+
// receipt that redirects while analytics settle would otherwise be judged
|
|
3059
|
+
// by where it started, not where Purchase was measured.
|
|
3060
|
+
readDocumentUrl = null,
|
|
2779
3061
|
}) {
|
|
2780
3062
|
const result = {};
|
|
2781
3063
|
if (!captureHandle) return result;
|
|
@@ -2806,14 +3088,17 @@ async function collectOrderAnalytics({
|
|
|
2806
3088
|
|
|
2807
3089
|
const collection = deadlineConsumed
|
|
2808
3090
|
? { timedOut: true }
|
|
2809
|
-
: await runWithinAnalyticsDeadline(async () =>
|
|
2810
|
-
typeof captureHandle.collectScopes === "function"
|
|
2811
|
-
? captureHandle.collectScopes({ strict: true })
|
|
3091
|
+
: await runWithinAnalyticsDeadline(async () => {
|
|
3092
|
+
const scopes = typeof captureHandle.collectScopes === "function"
|
|
3093
|
+
? await captureHandle.collectScopes({ strict: true })
|
|
2812
3094
|
: {
|
|
2813
3095
|
journey: await captureHandle.collect({ strict: true }),
|
|
2814
3096
|
currentDocument: await captureHandle.collect({ strict: true, scope: "current-document" }),
|
|
2815
|
-
}
|
|
2816
|
-
|
|
3097
|
+
};
|
|
3098
|
+
let documentUrl = null;
|
|
3099
|
+
try { documentUrl = typeof readDocumentUrl === "function" ? (readDocumentUrl() || null) : null; } catch { documentUrl = null; }
|
|
3100
|
+
return { ...scopes, documentUrl };
|
|
3101
|
+
}, { deadline, now });
|
|
2817
3102
|
if (collection.timedOut || now() > deadline) {
|
|
2818
3103
|
result.journeyCaptureError = analyticsCaptureError("collectionDeadline");
|
|
2819
3104
|
if (receiptRecognized && !settleError) {
|
|
@@ -2824,7 +3109,10 @@ async function collectOrderAnalytics({
|
|
|
2824
3109
|
if (receiptRecognized && !settleError) result.receiptCaptureError = analyticsCaptureError("unreadable");
|
|
2825
3110
|
} else {
|
|
2826
3111
|
result.journeyCapture = collection.value.journey;
|
|
2827
|
-
if (receiptRecognized && !settleError)
|
|
3112
|
+
if (receiptRecognized && !settleError) {
|
|
3113
|
+
result.receiptCapture = collection.value.currentDocument;
|
|
3114
|
+
if (collection.value.documentUrl) result.receiptDocumentUrl = collection.value.documentUrl;
|
|
3115
|
+
}
|
|
2828
3116
|
}
|
|
2829
3117
|
if (receiptRecognized && settleError) result.receiptCaptureError = settleError;
|
|
2830
3118
|
return result;
|
|
@@ -2952,11 +3240,13 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
|
|
|
2952
3240
|
receiptRecognized,
|
|
2953
3241
|
settleMs: numberArg(planArgs["analytics-settle"], DEFAULT_SETTLE_TIMEOUT_MS),
|
|
2954
3242
|
deadline: orderDeadline ?? Date.now(),
|
|
3243
|
+
readDocumentUrl: () => (page ? safePageUrl(page) : null),
|
|
2955
3244
|
});
|
|
2956
3245
|
if (captures.journeyCapture) result.analytics_journey_capture = captures.journeyCapture;
|
|
2957
3246
|
if (captures.journeyCaptureError) result.analytics_journey_capture_error = captures.journeyCaptureError;
|
|
2958
3247
|
if (captures.receiptCapture) result.receipt_analytics_capture = captures.receiptCapture;
|
|
2959
3248
|
if (captures.receiptCaptureError) result.receipt_analytics_capture_error = captures.receiptCaptureError;
|
|
3249
|
+
if (captures.receiptDocumentUrl) result.receipt_document_url = captures.receiptDocumentUrl;
|
|
2960
3250
|
} else if (analyticsAttachError) {
|
|
2961
3251
|
result.analytics_journey_capture_error = analyticsAttachError;
|
|
2962
3252
|
if (receiptRecognized) result.receipt_analytics_capture_error = analyticsAttachError;
|
|
@@ -3089,6 +3379,11 @@ function receiptAnalyticsAttempt(plan, result) {
|
|
|
3089
3379
|
planId: id,
|
|
3090
3380
|
receiptRecognized,
|
|
3091
3381
|
receiptUrl: redactUrlQuery(finalUrl),
|
|
3382
|
+
// Where the receipt page actually was once its analytics settled and were
|
|
3383
|
+
// collected; absent when that reading was not taken.
|
|
3384
|
+
...(receiptRecognized && result?.receipt_document_url
|
|
3385
|
+
? { receiptDocumentUrl: redactUrlQuery(result.receipt_document_url) }
|
|
3386
|
+
: {}),
|
|
3092
3387
|
...(receiptRecognized && result?.receipt_analytics_capture
|
|
3093
3388
|
? { capture: result.receipt_analytics_capture }
|
|
3094
3389
|
: {}),
|
|
@@ -3260,19 +3555,29 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3260
3555
|
}
|
|
3261
3556
|
const step = upsellSteps[stepIndex];
|
|
3262
3557
|
const actionTrace = createUpsellActionTrace({ page, events, topologyPlan, stepIndex, path: step });
|
|
3558
|
+
const stepBudgetMs = budget();
|
|
3559
|
+
const stepStartedMs = Date.now();
|
|
3263
3560
|
await ladder.run("upsell_action", async () => {
|
|
3264
3561
|
const initialLineItems = order.receipt_line_items.slice();
|
|
3265
3562
|
const initialUpsellMutationCount = upsellMutationCount(events);
|
|
3266
3563
|
await actionTrace.inspect();
|
|
3267
3564
|
await waitForUpsellPageReady(page, args);
|
|
3268
3565
|
await actionTrace.inspect();
|
|
3566
|
+
const responseIndexBefore = events.responses.length;
|
|
3269
3567
|
const upsell = await clickUpsellPath(page, step, { events, stepIndex, trace: actionTrace });
|
|
3270
3568
|
const preferredOrderBody = upsell.api_response_order_body || null;
|
|
3271
3569
|
delete upsell.api_response_order_body;
|
|
3272
3570
|
order.upsell = upsell;
|
|
3273
3571
|
order.upsell_steps.push(upsell);
|
|
3274
3572
|
order.final_url = safePageUrl(page);
|
|
3275
|
-
const refreshed = await
|
|
3573
|
+
const { refreshed, lateUpsellEvidence } = await refreshUpsellStepEvidence({
|
|
3574
|
+
page, events, path, email, checkoutPage, args, step, upsell, preferredOrderBody, initialLineItems, responseIndexBefore,
|
|
3575
|
+
// Keep the rest of the step's budget for the read-back after the wait.
|
|
3576
|
+
lateWaitMs: Math.min(
|
|
3577
|
+
LATE_UPSELL_EVIDENCE_TIMEOUT_MS,
|
|
3578
|
+
stepBudgetMs - (Date.now() - stepStartedMs) - LATE_UPSELL_EVIDENCE_RESERVE_MS,
|
|
3579
|
+
),
|
|
3580
|
+
});
|
|
3276
3581
|
order.final_receipt_line_items = refreshed.receipt_line_items;
|
|
3277
3582
|
if (refreshed.receipt_line_items.length) {
|
|
3278
3583
|
order.cart_state = refreshed.cart_state;
|
|
@@ -3284,9 +3589,9 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3284
3589
|
order.verification.currency = refreshed.verification.currency;
|
|
3285
3590
|
}
|
|
3286
3591
|
if (step === "accept") {
|
|
3287
|
-
const proof = acceptedUpsellProof(order.receipt_line_items, initialLineItems, upsell.expected_items, events);
|
|
3592
|
+
const proof = acceptedUpsellStepProof(upsell, lateUpsellEvidence, acceptedUpsellProof(order.receipt_line_items, initialLineItems, upsell.expected_items, events));
|
|
3288
3593
|
upsell.verification = {
|
|
3289
|
-
accepted_upsell_line_present: proof.ok,
|
|
3594
|
+
accepted_upsell_line_present: proof.unverified ? null : proof.ok,
|
|
3290
3595
|
accepted_upsell_match: proof,
|
|
3291
3596
|
upsell_api_response_seen: upsell.api_response_seen,
|
|
3292
3597
|
upsell_api_response_status: upsell.api_response_status,
|
|
@@ -3304,7 +3609,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3304
3609
|
actionTrace.markStepCompleted();
|
|
3305
3610
|
return `step ${stepIndex + 1}: ${step}`;
|
|
3306
3611
|
}, {
|
|
3307
|
-
timeoutMs:
|
|
3612
|
+
timeoutMs: stepBudgetMs,
|
|
3308
3613
|
evidence: actionTrace.summary,
|
|
3309
3614
|
formatError: actionTrace.formatError,
|
|
3310
3615
|
});
|
|
@@ -3336,7 +3641,19 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3336
3641
|
|
|
3337
3642
|
const acceptedSteps = (order.upsell_steps || []).filter((step) => step.path === "accept");
|
|
3338
3643
|
if (acceptedSteps.length) {
|
|
3339
|
-
|
|
3644
|
+
const unverifiedSteps = acceptedSteps.filter((step) => step.verification?.accepted_upsell_match?.unverified === true);
|
|
3645
|
+
const confirmedMissing = acceptedSteps.some((step) => step.verification?.accepted_upsell_line_present === false);
|
|
3646
|
+
// Unknown, not false, when every step that is not proven is only
|
|
3647
|
+
// unverified: false would read as a confirmed missing line.
|
|
3648
|
+
order.verification.accepted_upsell_line_present = unverifiedSteps.length && !confirmedMissing
|
|
3649
|
+
? null
|
|
3650
|
+
: acceptedSteps.every((step) => step.verification?.accepted_upsell_line_present === true);
|
|
3651
|
+
if (unverifiedSteps.length) {
|
|
3652
|
+
order.verification.upsell_unverified = unverifiedSteps.map((step) => step.verification.accepted_upsell_match.reason);
|
|
3653
|
+
// Whatever else the path finds, an order whose accepted upsell nothing
|
|
3654
|
+
// proved is not a verified order (#505).
|
|
3655
|
+
order.verification.verified = false;
|
|
3656
|
+
}
|
|
3340
3657
|
order.verification.upsell_api_response_seen = acceptedSteps.every((step) => step.verification?.upsell_api_response_seen === true);
|
|
3341
3658
|
order.verification.accepted_upsell_matches = acceptedSteps.map((step) => step.verification?.accepted_upsell_match).filter(Boolean);
|
|
3342
3659
|
}
|
|
@@ -3362,10 +3679,20 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3362
3679
|
}
|
|
3363
3680
|
|
|
3364
3681
|
const pathFailures = [...stepFailures, ...receiptFailures, ...couponFailures];
|
|
3365
|
-
const
|
|
3682
|
+
const clean = order.ok && pathFailures.length === 0;
|
|
3683
|
+
// A path whose only open question is an unverified accepted upsell is not
|
|
3684
|
+
// a pass: nothing proved the line was added. `ok` stays false so no reader
|
|
3685
|
+
// of the result or the order takes it as proven, and `upsell_unverified`
|
|
3686
|
+
// tells it apart from a failure: the dispatcher neither re-runs it (that
|
|
3687
|
+
// would buy a second order) nor recovers it, and the assertion reports it
|
|
3688
|
+
// for manual review (#505). The order itself was created and read back, so
|
|
3689
|
+
// order.ok keeps saying so; only its verification drops to unverified.
|
|
3690
|
+
const upsellUnverified = clean ? order.verification.upsell_unverified || null : null;
|
|
3691
|
+
const ok = clean && !upsellUnverified;
|
|
3366
3692
|
return {
|
|
3367
3693
|
ok,
|
|
3368
|
-
|
|
3694
|
+
...(upsellUnverified ? { upsell_unverified: upsellUnverified } : {}),
|
|
3695
|
+
error: clean ? null : order.error || order.upsell?.error || pathFailures.join("; ") || "accepted upsell did not appear in final order lines",
|
|
3369
3696
|
order,
|
|
3370
3697
|
events: sanitizedEvents(events),
|
|
3371
3698
|
};
|
|
@@ -3466,10 +3793,8 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
3466
3793
|
// The index is the control's position among what its own locator matches,
|
|
3467
3794
|
// so it replays with nth(); no selector is rebuilt from an attribute value.
|
|
3468
3795
|
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
|
-
});
|
|
3796
|
+
const perpetual = await scrollControlIntoView(target);
|
|
3797
|
+
await clickControl(target, { timeout: 8000, perpetual });
|
|
3473
3798
|
|
|
3474
3799
|
// The SDK owns the navigation (data-next-url, or the link the SDK reads
|
|
3475
3800
|
// forcePackageId from on arrival). Waiting for the URL is what proves the
|
|
@@ -3763,8 +4088,8 @@ async function selectRequestedCart(page, args) {
|
|
|
3763
4088
|
for (const item of cart) {
|
|
3764
4089
|
const target = page.locator(packageCardClickSelector({ package_id: item.packageId })).first();
|
|
3765
4090
|
if (await target.count().catch(() => 0)) {
|
|
3766
|
-
await target
|
|
3767
|
-
await target
|
|
4091
|
+
const perpetual = await scrollControlIntoView(target);
|
|
4092
|
+
await clickControl(target, { timeout: 5000, forceFallback: false, perpetual }).catch(() => {});
|
|
3768
4093
|
}
|
|
3769
4094
|
}
|
|
3770
4095
|
}
|
|
@@ -3789,10 +4114,8 @@ async function selectPackageCard(page, item) {
|
|
|
3789
4114
|
const candidate = resolvePackageCardCandidate(await renderedPackageCardCandidates(page), item);
|
|
3790
4115
|
const selector = packageCardClickSelector(candidate);
|
|
3791
4116
|
const target = page.locator(selector).first();
|
|
3792
|
-
await target
|
|
3793
|
-
await target
|
|
3794
|
-
await target.click({ force: true, timeout: 5000 });
|
|
3795
|
-
});
|
|
4117
|
+
const perpetual = await scrollControlIntoView(target);
|
|
4118
|
+
await clickControl(target, { timeout: 5000, perpetual });
|
|
3796
4119
|
const state = await packageCardSelectionState(page, selector);
|
|
3797
4120
|
if (state === "unselected") {
|
|
3798
4121
|
throw new Error(`--select-package ${item.packageId}: card matched ${selector} but did not enter a selected state after click`);
|
|
@@ -4390,8 +4713,8 @@ async function submitCheckout(page) {
|
|
|
4390
4713
|
await closeAddressAutocomplete(page);
|
|
4391
4714
|
const submit = page.locator('button.submit-button[os-checkout-payment="combo"], button[os-checkout-payment="combo"], button[type="submit"]').first();
|
|
4392
4715
|
await submit.waitFor({ state: "visible" });
|
|
4393
|
-
await submit
|
|
4394
|
-
await submit
|
|
4716
|
+
const perpetual = await scrollControlIntoView(submit);
|
|
4717
|
+
await clickControl(submit, { forceFallback: false, perpetual });
|
|
4395
4718
|
}
|
|
4396
4719
|
|
|
4397
4720
|
// When events are supplied (the post-submit wait), a platform-rejected order
|
|
@@ -4536,6 +4859,81 @@ async function waitForLateOrderEvidence(page, events, { timeoutMs = 4000, interv
|
|
|
4536
4859
|
}
|
|
4537
4860
|
}
|
|
4538
4861
|
|
|
4862
|
+
// Playwright waits for an element to be "stable" (the same box on two
|
|
4863
|
+
// consecutive animation frames) before scrollIntoViewIfNeeded() and click().
|
|
4864
|
+
// A control under an infinite geometry animation never is: stock upsell accept
|
|
4865
|
+
// buttons carry pb-animate="pulse-upsell", which the shared next-core.css
|
|
4866
|
+
// scales forever with !important and no reduced-motion override. An unbounded
|
|
4867
|
+
// scroll then burned the whole 30s default action timeout and a normal click
|
|
4868
|
+
// another 10s before the forced fallback fired (#481). Scrolling through the
|
|
4869
|
+
// DOM has no stability wait, and a perpetually animated control is clicked with
|
|
4870
|
+
// force once visible, because waiting for it to settle can only time out.
|
|
4871
|
+
const CONTROL_SCROLL_TIMEOUT_MS = 2000;
|
|
4872
|
+
const UPSELL_CLICK_TIMEOUT_MS = 10000;
|
|
4873
|
+
const UPSELL_MUTATION_TIMEOUT_MS = 20000;
|
|
4874
|
+
|
|
4875
|
+
// Runs in the page: optionally scrolls the control to the viewport centre
|
|
4876
|
+
// (no stability wait), and reports whether the control or an ancestor runs an
|
|
4877
|
+
// infinite animation over a property that moves or resizes its box.
|
|
4878
|
+
// Paint-only loops (opacity, color, shadow) leave the box stable, so those
|
|
4879
|
+
// still take the normal click. Self-contained so Playwright can serialize it.
|
|
4880
|
+
function probeControlInPage(element, { scroll }) {
|
|
4881
|
+
if (scroll) element.scrollIntoView({ block: "center", inline: "nearest" });
|
|
4882
|
+
// Properties that move or resize the element's box, or its position
|
|
4883
|
+
// inside an animated ancestor. Paint-only properties are left out.
|
|
4884
|
+
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)/;
|
|
4885
|
+
const movesBox = (animation) => {
|
|
4886
|
+
const timing = animation.effect?.getComputedTiming?.();
|
|
4887
|
+
if (animation.playState !== "running" || timing?.iterations !== Infinity) return false;
|
|
4888
|
+
const keyframes = animation.effect?.getKeyframes?.() || [];
|
|
4889
|
+
return keyframes.some((frame) => Object.keys(frame).some((property) => geometry.test(property)));
|
|
4890
|
+
};
|
|
4891
|
+
for (let node = element; node; node = node.parentElement) {
|
|
4892
|
+
if (typeof node.getAnimations === "function" && node.getAnimations().some(movesBox)) return true;
|
|
4893
|
+
}
|
|
4894
|
+
return false;
|
|
4895
|
+
}
|
|
4896
|
+
|
|
4897
|
+
async function probeControl(locator, { scroll, timeout = CONTROL_SCROLL_TIMEOUT_MS }) {
|
|
4898
|
+
try {
|
|
4899
|
+
return await locator.evaluate(probeControlInPage, { scroll }, { timeout }) === true;
|
|
4900
|
+
} catch {
|
|
4901
|
+
// Best effort, as the scroll always was: the click still scrolls itself,
|
|
4902
|
+
// and an unprobed control takes the normal click.
|
|
4903
|
+
return false;
|
|
4904
|
+
}
|
|
4905
|
+
}
|
|
4906
|
+
|
|
4907
|
+
// One page round trip per click: scrolls the control into view and returns
|
|
4908
|
+
// the `perpetual` flag clickControl takes, so no call site probes twice.
|
|
4909
|
+
async function scrollControlIntoView(locator, options = {}) {
|
|
4910
|
+
return probeControl(locator, { ...options, scroll: true });
|
|
4911
|
+
}
|
|
4912
|
+
|
|
4913
|
+
async function isPerpetuallyAnimated(locator, options = {}) {
|
|
4914
|
+
return probeControl(locator, { ...options, scroll: false });
|
|
4915
|
+
}
|
|
4916
|
+
|
|
4917
|
+
// `perpetual` is the flag scrollControlIntoView returned, so the click does
|
|
4918
|
+
// not probe again; without it the click probes for itself. forceFallback: false keeps a caller's strict click
|
|
4919
|
+
// for a control that can settle; a perpetually animated control is always
|
|
4920
|
+
// forced, whatever forceFallback says, because a strict click on it can only
|
|
4921
|
+
// time out. It must still become visible first, or the visibility error throws.
|
|
4922
|
+
async function clickControl(locator, { timeout, forceFallback = true, perpetual } = {}) {
|
|
4923
|
+
const animated = perpetual ?? await isPerpetuallyAnimated(locator);
|
|
4924
|
+
if (animated) {
|
|
4925
|
+
await locator.waitFor({ state: "visible", timeout });
|
|
4926
|
+
await locator.click({ force: true, timeout });
|
|
4927
|
+
return;
|
|
4928
|
+
}
|
|
4929
|
+
try {
|
|
4930
|
+
await locator.click({ timeout });
|
|
4931
|
+
} catch (error) {
|
|
4932
|
+
if (!forceFallback) throw error;
|
|
4933
|
+
await locator.click({ force: true, timeout });
|
|
4934
|
+
}
|
|
4935
|
+
}
|
|
4936
|
+
|
|
4539
4937
|
async function clickUpsellPath(page, path, { trace = null } = {}) {
|
|
4540
4938
|
const offerUrl = safePageUrl(page);
|
|
4541
4939
|
const action = path === "accept" ? "add" : "skip";
|
|
@@ -4545,27 +4943,36 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
|
|
|
4545
4943
|
return { path, clicked: false, error: `Missing upsell control ${selector}` };
|
|
4546
4944
|
}
|
|
4547
4945
|
const expectedItems = path === "accept" ? await selectedUpsellItems(page) : [];
|
|
4946
|
+
const perpetual = await scrollControlIntoView(control);
|
|
4947
|
+
// Armed at the click, not before the scroll, and budgeted to outlast a
|
|
4948
|
+
// normal attempt that times out into its forced fallback: the watch must
|
|
4949
|
+
// still be listening when the click that actually fires posts (#481).
|
|
4950
|
+
//
|
|
4951
|
+
// Only a request started at or after the watch was armed can be this
|
|
4952
|
+
// click's. An earlier step posts to the same order-upsells URL, and when
|
|
4953
|
+
// its own watch expired its response may still arrive during this click
|
|
4954
|
+
// (#505). armedAt and the request's start time are both epoch milliseconds
|
|
4955
|
+
// on the system clock (Date.now() here; Playwright's timing().startTime is
|
|
4956
|
+
// the browser's wall time at request start), and armedAt is read before the
|
|
4957
|
+
// click is sent, so this click's request starts at or after it. A request
|
|
4958
|
+
// with no known start time is not excluded: nothing shows it is stale.
|
|
4959
|
+
const armedAt = Date.now();
|
|
4548
4960
|
const mutationPromise = path === "accept"
|
|
4549
|
-
? page.waitForResponse((response) =>
|
|
4550
|
-
response.request().method()
|
|
4551
|
-
|
|
4552
|
-
|
|
4961
|
+
? page.waitForResponse((response) => {
|
|
4962
|
+
if (response.request().method() !== "POST" || !isOrderUpsellsUrl(response.url())) return false;
|
|
4963
|
+
const startedAt = responseRequestStartedAt(response);
|
|
4964
|
+
return startedAt === null || startedAt >= armedAt;
|
|
4965
|
+
}, { timeout: UPSELL_MUTATION_TIMEOUT_MS + (perpetual ? 0 : UPSELL_CLICK_TIMEOUT_MS) }).catch(() => null)
|
|
4553
4966
|
: Promise.resolve(null);
|
|
4554
|
-
await control.scrollIntoViewIfNeeded().catch(() => {});
|
|
4555
4967
|
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();
|
|
4968
|
+
await clickControl(control, { timeout: UPSELL_CLICK_TIMEOUT_MS, perpetual });
|
|
4969
|
+
trace?.markClickCompleted();
|
|
4565
4970
|
const mutationResponse = await mutationPromise;
|
|
4566
|
-
const
|
|
4971
|
+
const bodyRead = mutationResponse
|
|
4972
|
+
? await readJsonResponseBodyBounded(mutationResponse, RESPONSE_BODY_READ_TIMEOUT_MS)
|
|
4973
|
+
: null;
|
|
4567
4974
|
await waitForCheckoutResult(page);
|
|
4568
|
-
|
|
4975
|
+
const record = {
|
|
4569
4976
|
path,
|
|
4570
4977
|
clicked: true,
|
|
4571
4978
|
offer_url: offerUrl,
|
|
@@ -4574,8 +4981,21 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
|
|
|
4574
4981
|
api_response_seen: Boolean(mutationResponse),
|
|
4575
4982
|
api_response_status: mutationResponse?.status() || null,
|
|
4576
4983
|
api_response_url: mutationResponse?.url() || null,
|
|
4577
|
-
api_response_order_body:
|
|
4984
|
+
api_response_order_body: bodyRead?.body ?? null,
|
|
4985
|
+
// When the mutation's response arrived (epoch ms, the browser's clock as
|
|
4986
|
+
// Date.now()). Only a read-back requested after this reflects the upsell.
|
|
4987
|
+
mutation_responded_at: mutationRespondedAt(mutationResponse),
|
|
4988
|
+
// How the bounded body read ended. A timed-out read means the mutation's
|
|
4989
|
+
// own evidence is missing, not that the upsell failed; the runner then
|
|
4990
|
+
// waits for a later read-back before it judges the step.
|
|
4991
|
+
...(bodyRead ? { api_response_body_read: { timed_out: bodyRead.timed_out, waited_ms: bodyRead.waited_ms, bound_ms: bodyRead.bound_ms } } : {}),
|
|
4578
4992
|
};
|
|
4993
|
+
// The request this click made. Another step's upsell mutation posts to the
|
|
4994
|
+
// same order-upsells URL, so a late body is this step's only when it
|
|
4995
|
+
// answers this request.
|
|
4996
|
+
const mutationRequest = mutationResponse ? responseRequest(mutationResponse) : null;
|
|
4997
|
+
if (mutationRequest) record[REQUEST_IDENTITY] = mutationRequest;
|
|
4998
|
+
return record;
|
|
4579
4999
|
}
|
|
4580
5000
|
|
|
4581
5001
|
function receiptProofEvidence(order) {
|
|
@@ -5325,13 +5745,22 @@ async function recoverCreatedOrder({ context, attempt, plan = null, checkoutPage
|
|
|
5325
5745
|
}
|
|
5326
5746
|
|
|
5327
5747
|
const cleared = remaining.length === 0;
|
|
5748
|
+
// An unverified accepted upsell is not a failure recovery can clear, nor
|
|
5749
|
+
// one it can re-check: re-reading the order says nothing about which
|
|
5750
|
+
// mutation added what. With everything else cleared, the recovered result
|
|
5751
|
+
// is the same manual-review shape executeTestOrderPath returns (#505).
|
|
5752
|
+
const upsellUnverified = Array.isArray(order.verification?.upsell_unverified) && order.verification.upsell_unverified.length
|
|
5753
|
+
? order.verification.upsell_unverified
|
|
5754
|
+
: null;
|
|
5755
|
+
if (upsellUnverified) recovered.verification.verified = false;
|
|
5328
5756
|
return {
|
|
5329
5757
|
attempts: 1,
|
|
5330
5758
|
cleared,
|
|
5331
5759
|
checks,
|
|
5332
5760
|
result: {
|
|
5333
5761
|
...attempt,
|
|
5334
|
-
ok: cleared,
|
|
5762
|
+
ok: cleared && !upsellUnverified,
|
|
5763
|
+
...(cleared && upsellUnverified ? { upsell_unverified: upsellUnverified } : {}),
|
|
5335
5764
|
error: cleared ? null : remaining.join("; "),
|
|
5336
5765
|
order: recovered,
|
|
5337
5766
|
},
|
|
@@ -5469,19 +5898,35 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
|
|
|
5469
5898
|
// runner deliberately refused to re-run, and the operator needs to know that
|
|
5470
5899
|
// before they run the path again by hand.
|
|
5471
5900
|
const stoppedForAmbiguity = creationRecord?.action === "stopped";
|
|
5901
|
+
// The order was created and nothing failed, but an accepted upsell could
|
|
5902
|
+
// not be checked: its mutation body never loaded and no read-back arrived.
|
|
5903
|
+
// Neither proved nor disproved, so a human decides, as for a hosted checkout.
|
|
5904
|
+
// The result is not ok (the path is not proven), but nothing failed either.
|
|
5905
|
+
// A result that says ok while its order still carries unverified reasons is
|
|
5906
|
+
// read the same way: never a pass. No current producer does that (both
|
|
5907
|
+
// executeTestOrderPath and recoverCreatedOrder clear ok when an upsell is
|
|
5908
|
+
// unverified); this arm stops a future ok-producing path, like the recovery
|
|
5909
|
+
// that once promoted an unverified upsell to pass, from doing it silently.
|
|
5910
|
+
const orderUnverified = result.order?.verification?.upsell_unverified;
|
|
5911
|
+
const upsellUnverified = Array.isArray(result.upsell_unverified) && result.upsell_unverified.length
|
|
5912
|
+
? result.upsell_unverified
|
|
5913
|
+
: result.ok && Array.isArray(orderUnverified) && orderUnverified.length ? orderUnverified : null;
|
|
5914
|
+
const created = result.ok || Boolean(upsellUnverified);
|
|
5472
5915
|
return assertion({
|
|
5473
5916
|
id: `browser-test-order:${id}`,
|
|
5474
5917
|
family: "browser-test-order",
|
|
5475
5918
|
page,
|
|
5476
|
-
status:
|
|
5477
|
-
severity:
|
|
5919
|
+
status: created ? (upsellUnverified ? STATUS.MANUAL_REVIEW : STATUS.PASS) : STATUS.FAIL,
|
|
5920
|
+
severity: created ? (upsellUnverified ? SEVERITY.WARN : undefined) : SEVERITY.BLOCKER,
|
|
5478
5921
|
expected: "test order created through deployed checkout page",
|
|
5479
|
-
actual:
|
|
5480
|
-
?
|
|
5922
|
+
actual: created
|
|
5923
|
+
? upsellUnverified
|
|
5924
|
+
? `${result.order.next_order_id || result.order.ref_id}; ${upsellUnverified.join("; ")}`
|
|
5925
|
+
: result.order.next_order_id || result.order.ref_id
|
|
5481
5926
|
: stoppedForAmbiguity
|
|
5482
5927
|
? `${failureText} — not re-run: ${creationRecord.classification.reason}. Check for an existing order against this run's QA email before running this path again.`
|
|
5483
5928
|
: failureText,
|
|
5484
|
-
evidence:
|
|
5929
|
+
evidence: created
|
|
5485
5930
|
? {
|
|
5486
5931
|
...planEvidence,
|
|
5487
5932
|
...retry,
|
|
@@ -5494,6 +5939,7 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
|
|
|
5494
5939
|
line_count: result.order.receipt_line_items.length,
|
|
5495
5940
|
...(receiptProofEvidence(result.order) ? { receipt_proof: receiptProofEvidence(result.order) } : {}),
|
|
5496
5941
|
...(path === "accept" ? { accepted_upsell_line_present: result.order.verification?.accepted_upsell_line_present } : {}),
|
|
5942
|
+
...(upsellUnverified ? { upsell_unverified: upsellUnverified } : {}),
|
|
5497
5943
|
...(result.order.upsell ? { upsell_clicked: result.order.upsell.clicked, upsell_final_url: result.order.upsell.final_url } : {}),
|
|
5498
5944
|
...(result.order.upsell_steps ? { upsell_steps: result.order.upsell_steps.map(summarizeUpsellStep) } : {}),
|
|
5499
5945
|
...(result.order.verification?.accepted_upsell_matches ? { accepted_upsell_matches: result.order.verification.accepted_upsell_matches } : {}),
|
|
@@ -5514,6 +5960,43 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
|
|
|
5514
5960
|
});
|
|
5515
5961
|
}
|
|
5516
5962
|
|
|
5963
|
+
// When a response's headers arrived, in epoch milliseconds, or null.
|
|
5964
|
+
function mutationRespondedAt(response) {
|
|
5965
|
+
try {
|
|
5966
|
+
const timing = response.request().timing();
|
|
5967
|
+
const at = timing.startTime + timing.responseStart;
|
|
5968
|
+
return Number.isFinite(at) && timing.startTime > 0 && timing.responseStart >= 0 ? at : null;
|
|
5969
|
+
} catch {
|
|
5970
|
+
return null;
|
|
5971
|
+
}
|
|
5972
|
+
}
|
|
5973
|
+
|
|
5974
|
+
// The Playwright Request a response answers, kept under a symbol key so it
|
|
5975
|
+
// never reaches serialized evidence (JSON and the sanitized event log skip
|
|
5976
|
+
// it). Playwright hands every listener the same Request object for one
|
|
5977
|
+
// request, and a different one for each request, even to the same URL: an
|
|
5978
|
+
// upsell step matches its own mutation's late body by this identity (#505).
|
|
5979
|
+
const REQUEST_IDENTITY = Symbol("campaigns-os.request-identity");
|
|
5980
|
+
|
|
5981
|
+
function responseRequest(response) {
|
|
5982
|
+
try {
|
|
5983
|
+
return response.request() || null;
|
|
5984
|
+
} catch {
|
|
5985
|
+
return null;
|
|
5986
|
+
}
|
|
5987
|
+
}
|
|
5988
|
+
|
|
5989
|
+
// When the browser started a response's request, in epoch milliseconds (the
|
|
5990
|
+
// same clock as Date.now()), or null when Playwright does not report it.
|
|
5991
|
+
function responseRequestStartedAt(response) {
|
|
5992
|
+
try {
|
|
5993
|
+
const startTime = response.request().timing().startTime;
|
|
5994
|
+
return Number.isFinite(startTime) && startTime > 0 ? startTime : null;
|
|
5995
|
+
} catch {
|
|
5996
|
+
return null;
|
|
5997
|
+
}
|
|
5998
|
+
}
|
|
5999
|
+
|
|
5517
6000
|
function captureCheckoutEvents(page) {
|
|
5518
6001
|
const events = { requests: [], responses: [], failed: [], console: [], pageErrors: [], navigations: [] };
|
|
5519
6002
|
const interesting = /\/api\/v1\/(?:orders|upsells|carts)\/?|\/transactions|spreedly|campaigns\.apps/i;
|
|
@@ -5525,13 +6008,24 @@ function captureCheckoutEvents(page) {
|
|
|
5525
6008
|
postData: summarizeRequestPostData(request.postData()),
|
|
5526
6009
|
});
|
|
5527
6010
|
});
|
|
6011
|
+
// Nothing awaits this listener, so its body read stays unbounded: an entry
|
|
6012
|
+
// lands whenever its body finishes loading, and waitForLateOrderEvidence
|
|
6013
|
+
// polls for it. A bound here would record a slow but successful order body
|
|
6014
|
+
// as null for good, and the order would read as not created.
|
|
5528
6015
|
page.on("response", async (response) => {
|
|
5529
6016
|
if (!interesting.test(response.url())) return;
|
|
5530
|
-
|
|
6017
|
+
// Taken before the body read: an entry lands in the log when its body
|
|
6018
|
+
// finishes, so its position says nothing about when it was requested.
|
|
6019
|
+
const requestStartedAt = responseRequestStartedAt(response);
|
|
6020
|
+
const request = responseRequest(response);
|
|
6021
|
+
const entry = {
|
|
5531
6022
|
status: response.status(),
|
|
5532
6023
|
url: response.url(),
|
|
5533
|
-
|
|
5534
|
-
|
|
6024
|
+
request_started_at: requestStartedAt,
|
|
6025
|
+
body: await readJsonResponseBodyWhenLoaded(response),
|
|
6026
|
+
};
|
|
6027
|
+
if (request) entry[REQUEST_IDENTITY] = request;
|
|
6028
|
+
events.responses.push(entry);
|
|
5535
6029
|
});
|
|
5536
6030
|
page.on("requestfailed", (request) => {
|
|
5537
6031
|
if (!interesting.test(request.url())) return;
|
|
@@ -5552,11 +6046,63 @@ function captureCheckoutEvents(page) {
|
|
|
5552
6046
|
return events;
|
|
5553
6047
|
}
|
|
5554
6048
|
|
|
5555
|
-
|
|
5556
|
-
|
|
6049
|
+
// Playwright's response.text() waits for the body to finish loading. A page
|
|
6050
|
+
// that navigates away as soon as the headers land (an SDK that reads only the
|
|
6051
|
+
// status before redirecting) can leave that wait pending forever. Only a
|
|
6052
|
+
// caller that blocks the run on the body needs a bound: the upsell mutation
|
|
6053
|
+
// read in clickUpsellPath. There the body is evidence, not the proof of the
|
|
6054
|
+
// response, so an unread body is reported as null after a short bound instead
|
|
6055
|
+
// of hanging the order run. Playwright has no way to cancel a pending body
|
|
6056
|
+
// read; the abandoned read is a protocol callback, not a socket this process
|
|
6057
|
+
// owns, and it is released when the run closes the browser context.
|
|
6058
|
+
const RESPONSE_BODY_READ_TIMEOUT_MS = 3000;
|
|
6059
|
+
|
|
6060
|
+
// For callers that nothing awaits (the checkout event listener): a late body
|
|
6061
|
+
// still arrives, and a body that never loads just never records an entry.
|
|
6062
|
+
async function readJsonResponseBodyWhenLoaded(response) {
|
|
6063
|
+
const text = await Promise.resolve().then(() => response.text()).catch(() => null);
|
|
5557
6064
|
return parseMaybeJson(redactSensitive(text));
|
|
5558
6065
|
}
|
|
5559
6066
|
|
|
6067
|
+
// Callers that block on the read always get the fixed bound; only the test
|
|
6068
|
+
// hook picks a shorter one.
|
|
6069
|
+
async function readJsonResponseBody(response) {
|
|
6070
|
+
return readJsonResponseBodyWithin(response, RESPONSE_BODY_READ_TIMEOUT_MS);
|
|
6071
|
+
}
|
|
6072
|
+
|
|
6073
|
+
async function readJsonResponseBodyWithin(response, timeoutMs) {
|
|
6074
|
+
return (await readJsonResponseBodyBounded(response, timeoutMs)).body;
|
|
6075
|
+
}
|
|
6076
|
+
|
|
6077
|
+
// The bounded read, with a record of how it ended. `timed_out` is true only
|
|
6078
|
+
// when the bound fired while the read was still pending, never when the read
|
|
6079
|
+
// failed or returned nothing: a caller must be able to tell "the body was not
|
|
6080
|
+
// read in time" (the mutation may still have succeeded) from "the body was
|
|
6081
|
+
// read". `waited_ms` covers the read alone.
|
|
6082
|
+
async function readJsonResponseBodyBounded(response, timeoutMs) {
|
|
6083
|
+
// A missing, zero or unbounded value would reintroduce the hang.
|
|
6084
|
+
if (!(Number.isFinite(timeoutMs) && timeoutMs > 0)) timeoutMs = RESPONSE_BODY_READ_TIMEOUT_MS;
|
|
6085
|
+
const started = Date.now();
|
|
6086
|
+
const TIMED_OUT = Symbol("timed out");
|
|
6087
|
+
let timer = null;
|
|
6088
|
+
const bounded = new Promise((resolve) => { timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs); });
|
|
6089
|
+
const read = Promise.resolve()
|
|
6090
|
+
.then(() => response.text())
|
|
6091
|
+
.catch(() => null);
|
|
6092
|
+
try {
|
|
6093
|
+
const text = await Promise.race([read, bounded]);
|
|
6094
|
+
const timedOut = text === TIMED_OUT;
|
|
6095
|
+
return {
|
|
6096
|
+
body: timedOut ? null : parseMaybeJson(redactSensitive(text)),
|
|
6097
|
+
timed_out: timedOut,
|
|
6098
|
+
waited_ms: Date.now() - started,
|
|
6099
|
+
bound_ms: timeoutMs,
|
|
6100
|
+
};
|
|
6101
|
+
} finally {
|
|
6102
|
+
clearTimeout(timer);
|
|
6103
|
+
}
|
|
6104
|
+
}
|
|
6105
|
+
|
|
5560
6106
|
function lastJsonResponse(events, pattern) {
|
|
5561
6107
|
for (let index = events.responses.length - 1; index >= 0; index -= 1) {
|
|
5562
6108
|
const response = events.responses[index];
|
|
@@ -5621,8 +6167,8 @@ async function clickVisibleControlByText(page, pattern, { within = null } = {})
|
|
|
5621
6167
|
const control = controls.nth(index);
|
|
5622
6168
|
const text = trim((await control.innerText().catch(() => "")) || (await control.getAttribute("value").catch(() => "")));
|
|
5623
6169
|
if (!pattern.test(text)) continue;
|
|
5624
|
-
await control
|
|
5625
|
-
await control
|
|
6170
|
+
const perpetual = await scrollControlIntoView(control);
|
|
6171
|
+
await clickControl(control, { timeout: 8000, forceFallback: false, perpetual });
|
|
5626
6172
|
return true;
|
|
5627
6173
|
}
|
|
5628
6174
|
throw new Error(`No visible control matched ${pattern}`);
|
|
@@ -6228,9 +6774,13 @@ async function selectedUpsellItems(page) {
|
|
|
6228
6774
|
// order-upsell API added it. The live network observation (apiResponseSeen) is a
|
|
6229
6775
|
// best-effort signal that can miss the request on fast stepper-accept client nav, so
|
|
6230
6776
|
// it must not block on its own. Block only when the read-back proof also fails.
|
|
6777
|
+
//
|
|
6778
|
+
// An unverified proof (the mutation answered 2xx but its body never loaded, and
|
|
6779
|
+
// no later read-back arrived) is not a failure: nothing showed the line
|
|
6780
|
+
// missing. It is reported for manual review instead (upsell_unverified).
|
|
6231
6781
|
function upsellAcceptStepFailures(stepIndex, proof, apiResponseSeen) {
|
|
6232
6782
|
const failures = [];
|
|
6233
|
-
if (!proof.ok) {
|
|
6783
|
+
if (!proof.ok && !proof.unverified) {
|
|
6234
6784
|
failures.push(`step ${stepIndex + 1}: ${proof.reason}`);
|
|
6235
6785
|
if (!apiResponseSeen) {
|
|
6236
6786
|
failures.push(`step ${stepIndex + 1}: upsell accept did not call order upsell API`);
|
|
@@ -6252,6 +6802,141 @@ function upsellActionStepFailures(stepIndex, action, upsell, proof) {
|
|
|
6252
6802
|
return failures;
|
|
6253
6803
|
}
|
|
6254
6804
|
|
|
6805
|
+
// How long an accept whose mutation body timed out waits for other evidence
|
|
6806
|
+
// of that mutation, and the slice of the step budget kept for the read-back
|
|
6807
|
+
// after it. The whole step stays inside its budget (45s by default).
|
|
6808
|
+
const LATE_UPSELL_EVIDENCE_TIMEOUT_MS = 15000;
|
|
6809
|
+
const LATE_UPSELL_EVIDENCE_RESERVE_MS = 3000;
|
|
6810
|
+
|
|
6811
|
+
function upsellBodyReadTimedOut(upsell) {
|
|
6812
|
+
const status = Number(upsell?.api_response_status);
|
|
6813
|
+
return upsell?.api_response_body_read?.timed_out === true && status >= 200 && status < 300;
|
|
6814
|
+
}
|
|
6815
|
+
|
|
6816
|
+
// After the bounded read gave up on a 2xx upsell mutation body, the lines the
|
|
6817
|
+
// runner already holds are the checkout's, from before the accept. Judging
|
|
6818
|
+
// the upsell against them would call a slow success "no new upsell line".
|
|
6819
|
+
// Wait, within a bound, for evidence of this mutation captured after the
|
|
6820
|
+
// click: its own body landing late in the event log (the unbounded listener
|
|
6821
|
+
// keeps reading it), or an order read-back whose lines carry the accepted
|
|
6822
|
+
// upsell. Returns the body to judge from, or source "none" when neither came.
|
|
6823
|
+
//
|
|
6824
|
+
// "Its own body" is the entry answering the very request this step's click
|
|
6825
|
+
// made (mutationRequest, Playwright request identity), not any order-upsells
|
|
6826
|
+
// URL: every upsell step in a path, on one page or on separate pages, posts
|
|
6827
|
+
// to the same /orders/<ref>/upsells/ URL, and an earlier step's slow body can
|
|
6828
|
+
// land during this step's wait. Taking it would judge this step by another
|
|
6829
|
+
// step's lines. With no request identity no late body counts (#505).
|
|
6830
|
+
//
|
|
6831
|
+
// A read-back whose request started after the upsell mutation's response
|
|
6832
|
+
// arrived (by the browser's request start time, not its position in the log,
|
|
6833
|
+
// which reflects when its body finished) that shows the persisted order
|
|
6834
|
+
// without the accepted line is a definitive negative, not an absence of
|
|
6835
|
+
// evidence. Anchoring on the mutation, not the click attempt, also excludes a
|
|
6836
|
+
// read-back started while the click was still waiting to fire. It does not end the wait early (a
|
|
6837
|
+
// later read-back may still carry the line), but when the wait ends with no
|
|
6838
|
+
// positive evidence the latest such read-back is returned as
|
|
6839
|
+
// "order_read_back_missing_line", and the step fails instead of going to
|
|
6840
|
+
// manual review. A read-back requested before that, or with no known start
|
|
6841
|
+
// time, never counts as a negative.
|
|
6842
|
+
async function waitForLateUpsellEvidence(events, { responseIndexBefore, mutationRequest = null, mutationRespondedAt = null, initialLineItems, expectedItems, timeoutMs, intervalMs = 250 }) {
|
|
6843
|
+
const started = Date.now();
|
|
6844
|
+
const deadline = started + Math.max(0, Number(timeoutMs) || 0);
|
|
6845
|
+
const latestMissingLineReadBack = () => {
|
|
6846
|
+
const fresh = events.responses.slice(responseIndexBefore);
|
|
6847
|
+
for (let index = fresh.length - 1; index >= 0; index -= 1) {
|
|
6848
|
+
const response = fresh[index];
|
|
6849
|
+
if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) continue;
|
|
6850
|
+
if (!(response.status >= 200 && response.status < 300)) continue;
|
|
6851
|
+
if (!ORDER_DETAIL_RESPONSE_PATTERN.test(response.url)) continue;
|
|
6852
|
+
if (!(Number.isFinite(mutationRespondedAt) && Number.isFinite(response.request_started_at) && response.request_started_at >= mutationRespondedAt)) continue;
|
|
6853
|
+
const lines = extractReceiptLines(response.body);
|
|
6854
|
+
if (!Array.isArray(lines) || lines.length === 0) continue;
|
|
6855
|
+
return response.body;
|
|
6856
|
+
}
|
|
6857
|
+
return null;
|
|
6858
|
+
};
|
|
6859
|
+
const find = () => {
|
|
6860
|
+
const fresh = events.responses.slice(responseIndexBefore);
|
|
6861
|
+
for (let index = fresh.length - 1; index >= 0; index -= 1) {
|
|
6862
|
+
const response = fresh[index];
|
|
6863
|
+
if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) continue;
|
|
6864
|
+
if (!(response.status >= 200 && response.status < 300)) continue;
|
|
6865
|
+
if (!ORDER_UPSELLS_RESPONSE_PATTERN.test(response.url)) continue;
|
|
6866
|
+
if (mutationRequest && response[REQUEST_IDENTITY] === mutationRequest) return { source: "late_upsell_body", body: response.body };
|
|
6867
|
+
}
|
|
6868
|
+
for (let index = fresh.length - 1; index >= 0; index -= 1) {
|
|
6869
|
+
const response = fresh[index];
|
|
6870
|
+
if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) continue;
|
|
6871
|
+
if (!(response.status >= 200 && response.status < 300)) continue;
|
|
6872
|
+
if (!ORDER_DETAIL_RESPONSE_PATTERN.test(response.url)) continue;
|
|
6873
|
+
const proof = acceptedUpsellProof(extractReceiptLines(response.body), initialLineItems, expectedItems, events);
|
|
6874
|
+
if (proof.ok) return { source: "order_read_back", body: response.body };
|
|
6875
|
+
}
|
|
6876
|
+
return null;
|
|
6877
|
+
};
|
|
6878
|
+
for (;;) {
|
|
6879
|
+
const found = find();
|
|
6880
|
+
if (found) return { ...found, waited_ms: Date.now() - started };
|
|
6881
|
+
if (Date.now() >= deadline) {
|
|
6882
|
+
const missing = latestMissingLineReadBack();
|
|
6883
|
+
if (missing) return { source: "order_read_back_missing_line", body: missing, waited_ms: Date.now() - started };
|
|
6884
|
+
return { source: "none", body: null, waited_ms: Date.now() - started };
|
|
6885
|
+
}
|
|
6886
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, Math.max(1, deadline - Date.now()))));
|
|
6887
|
+
}
|
|
6888
|
+
}
|
|
6889
|
+
|
|
6890
|
+
// The order evidence an upsell step is judged against. An accept whose
|
|
6891
|
+
// mutation body read timed out first waits for later evidence of that
|
|
6892
|
+
// mutation (waitForLateUpsellEvidence), and judges from that body when one
|
|
6893
|
+
// arrives: the runner's last order body is the checkout's otherwise.
|
|
6894
|
+
async function refreshUpsellStepEvidence({ page, events, path, email, checkoutPage, args, step, upsell, preferredOrderBody = null, initialLineItems, responseIndexBefore, lateWaitMs = LATE_UPSELL_EVIDENCE_TIMEOUT_MS }) {
|
|
6895
|
+
let lateUpsellEvidence = null;
|
|
6896
|
+
let judgedBody = preferredOrderBody;
|
|
6897
|
+
if (step === "accept" && upsellBodyReadTimedOut(upsell)) {
|
|
6898
|
+
lateUpsellEvidence = await waitForLateUpsellEvidence(events, {
|
|
6899
|
+
responseIndexBefore,
|
|
6900
|
+
mutationRequest: upsell[REQUEST_IDENTITY] || null,
|
|
6901
|
+
mutationRespondedAt: upsell.mutation_responded_at,
|
|
6902
|
+
initialLineItems,
|
|
6903
|
+
expectedItems: upsell.expected_items,
|
|
6904
|
+
timeoutMs: lateWaitMs,
|
|
6905
|
+
});
|
|
6906
|
+
upsell.late_evidence = { source: lateUpsellEvidence.source, waited_ms: lateUpsellEvidence.waited_ms };
|
|
6907
|
+
if (lateUpsellEvidence.body) judgedBody = lateUpsellEvidence.body;
|
|
6908
|
+
}
|
|
6909
|
+
const refreshed = await buildOrderEvidence({ page, events, path, email, checkoutPage, args, preferredOrderBody: judgedBody });
|
|
6910
|
+
return { refreshed, lateUpsellEvidence };
|
|
6911
|
+
}
|
|
6912
|
+
|
|
6913
|
+
// The accepted-upsell proof for one step, given how its evidence arrived. A
|
|
6914
|
+
// failed proof stands as a failure unless the mutation answered 2xx, its body
|
|
6915
|
+
// read timed out, and no later evidence of it arrived: then the lines judged
|
|
6916
|
+
// are stale, and the step is unverified rather than missing its upsell. A
|
|
6917
|
+
// post-click read-back without the line ("order_read_back_missing_line") is
|
|
6918
|
+
// such evidence, so the failure stands.
|
|
6919
|
+
//
|
|
6920
|
+
// A passing proof from those stale lines is no more evidence than a failing
|
|
6921
|
+
// one: lines on hand before this step's mutation answered cannot carry its
|
|
6922
|
+
// upsell, and can carry an earlier step's (whose body may have landed on the
|
|
6923
|
+
// same order-upsells URL meanwhile), so it is unverified too (#505).
|
|
6924
|
+
function acceptedUpsellStepProof(upsell, lateEvidence, proof) {
|
|
6925
|
+
if (!upsellBodyReadTimedOut(upsell)) return proof;
|
|
6926
|
+
if (lateEvidence && lateEvidence.source !== "none") return proof;
|
|
6927
|
+
const read = upsell.api_response_body_read;
|
|
6928
|
+
return {
|
|
6929
|
+
...proof,
|
|
6930
|
+
ok: false,
|
|
6931
|
+
unverified: true,
|
|
6932
|
+
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`,
|
|
6933
|
+
matched_lines: [],
|
|
6934
|
+
stale_reason: proof.ok
|
|
6935
|
+
? "the order lines on hand predate this step's upsell mutation, so a match in them is not evidence of it"
|
|
6936
|
+
: proof.reason,
|
|
6937
|
+
};
|
|
6938
|
+
}
|
|
6939
|
+
|
|
6255
6940
|
function acceptedUpsellProof(lines, initialLines, expectedItems, events) {
|
|
6256
6941
|
if (!Array.isArray(lines) || lines.length === 0) {
|
|
6257
6942
|
return { ok: false, reason: "final order lines were empty", expected_items: expectedItems || [], matched_lines: [] };
|
|
@@ -6352,6 +7037,8 @@ function summarizeUpsellStep(step) {
|
|
|
6352
7037
|
expected_items: step.expected_items,
|
|
6353
7038
|
api_response_seen: step.api_response_seen,
|
|
6354
7039
|
api_response_status: step.api_response_status,
|
|
7040
|
+
...(step.api_response_body_read ? { api_response_body_read: step.api_response_body_read } : {}),
|
|
7041
|
+
...(step.late_evidence ? { late_evidence: step.late_evidence } : {}),
|
|
6355
7042
|
accepted_upsell_line_present: step.verification?.accepted_upsell_line_present,
|
|
6356
7043
|
};
|
|
6357
7044
|
}
|
|
@@ -6489,6 +7176,8 @@ export const __qaBrowserTestHooks = Object.freeze({
|
|
|
6489
7176
|
COUPON_APPLY_CONTROL_SELECTOR,
|
|
6490
7177
|
analyticsCorrectnessCaptureAssertions,
|
|
6491
7178
|
analyticsCorrectnessRunnerFailureAssertion,
|
|
7179
|
+
captureAnalyticsCorrectnessInContext,
|
|
7180
|
+
captureAnalyticsParityInContext,
|
|
6492
7181
|
analyticsParityCaptureAssertions,
|
|
6493
7182
|
analyticsParityRunnerFailureAssertion,
|
|
6494
7183
|
acceptedUpsellProof,
|
|
@@ -6500,6 +7189,18 @@ export const __qaBrowserTestHooks = Object.freeze({
|
|
|
6500
7189
|
primaryCtaInspectionScript,
|
|
6501
7190
|
clickCouponApplyControl,
|
|
6502
7191
|
isOrderUpsellsUrl,
|
|
7192
|
+
clickUpsellPath,
|
|
7193
|
+
isPerpetuallyAnimated,
|
|
7194
|
+
readJsonResponseBody,
|
|
7195
|
+
readJsonResponseBodyWithin,
|
|
7196
|
+
readJsonResponseBodyBounded,
|
|
7197
|
+
RESPONSE_BODY_READ_TIMEOUT_MS,
|
|
7198
|
+
waitForLateUpsellEvidence,
|
|
7199
|
+
refreshUpsellStepEvidence,
|
|
7200
|
+
acceptedUpsellStepProof,
|
|
7201
|
+
REQUEST_IDENTITY,
|
|
7202
|
+
captureCheckoutEvents,
|
|
7203
|
+
buildOrderEvidence,
|
|
6503
7204
|
testEmail,
|
|
6504
7205
|
testOrderPaths,
|
|
6505
7206
|
testOrderPlans,
|