@nextcommerce/campaigns-os 1.48.0 → 1.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +539 -0
- package/agents/claude/CLAUDE.md +6 -5
- package/agents/codex/AGENTS.md +6 -5
- package/agents/copilot/copilot-instructions.md +3 -3
- package/agents/cursor/campaigns-os.mdc +3 -3
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
- package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
- package/campaign-spec/dist/rules/design-source-shape.js +13 -3
- package/campaign-spec/dist/rules/sdk-version.js +2 -1
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +26 -46
- package/contracts/effects.v1.json +254 -2
- package/contracts/release-ledger.json +1239 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
- package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
- package/docs/brand-theme-bridge.md +12 -6
- package/docs/build-packet.md +101 -12
- package/docs/campaign-build-brief.md +25 -1
- package/docs/effects.md +6 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +10 -0
- package/docs/qa-and-test-orders.md +66 -11
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +8 -3
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +4 -4
- package/skills/next-campaigns-os/SKILL.md +4 -4
- package/skills/next-campaigns-os/references/session-intake.md +7 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +5 -4
- package/skills/next-campaigns-qa/SKILL.md +6 -5
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/brand-theme.mjs +25 -2
- package/src/build-brief.mjs +68 -21
- package/src/built-site-scope.mjs +39 -6
- package/src/built-smoke-qc.mjs +1117 -0
- package/src/campaign-identity.mjs +36 -2
- package/src/cart-placeholders.mjs +730 -0
- package/src/cli.mjs +320 -42
- package/src/commercial-journey.mjs +65 -4
- package/src/commercial-parity.mjs +6 -1
- package/src/doctor/checks.mjs +291 -24
- package/src/doctor/inspect.mjs +53 -2
- package/src/doctor/next-step.mjs +1 -1
- package/src/install-mode.mjs +0 -8
- package/src/invocation.mjs +5 -2
- package/src/local-preview-policy.mjs +1 -1
- package/src/local-proof.mjs +4 -1
- package/src/polish-browser.mjs +218 -1
- package/src/polish-capture.mjs +1 -1
- package/src/polish-media-weight.mjs +492 -0
- package/src/polish-node.mjs +96 -4
- package/src/progress-node.mjs +5 -1
- package/src/qa-binding-evidence.mjs +21 -0
- package/src/qa-browser.mjs +338 -97
- package/src/qa-content-params.mjs +889 -0
- package/src/qa-node.mjs +114 -14
- package/src/qa-order-bump.mjs +22 -1
- package/src/qa-policy-links.mjs +1019 -0
- package/src/qa-tracking-params.mjs +1389 -0
- package/src/qa-url-privacy.mjs +168 -0
- package/src/qc-accept.mjs +446 -0
- package/src/qc-check-registry.mjs +83 -0
- package/src/qc-results.mjs +1049 -0
- package/src/sdk-attribute-index.mjs +71 -0
- package/src/sdk-markup.mjs +2 -2
- package/src/sdk-storage-compatibility.mjs +63 -3
- package/src/source-prep.mjs +37 -7
- package/src/stage-record.mjs +356 -36
- package/src/theme-gate.mjs +3 -3
package/src/qa-browser.mjs
CHANGED
|
@@ -12,7 +12,10 @@ import {
|
|
|
12
12
|
} from "./qa-analytics-errors.mjs";
|
|
13
13
|
import { attachAnalyticsCapture, diffAnalyticsParity } from "./qa-analytics-parity.mjs";
|
|
14
14
|
import { assessAnalyticsInventory } from "./qa-analytics-correctness.mjs";
|
|
15
|
-
import { redactUrlQuery } from "./qa-url-privacy.mjs";
|
|
15
|
+
import { redactPersisted, redactUrlQueriesInText, redactUrlQuery } from "./qa-url-privacy.mjs";
|
|
16
|
+
import { TRACKING_ADDED_BOUND_MS, TRACKING_OBSERVATION, createTrackingRun, trackingQaAssertion } from "./qa-tracking-params.mjs";
|
|
17
|
+
import { runContentParamChecks } from "./qa-content-params.mjs";
|
|
18
|
+
import { createPolicyLinkBudget, hasPolicyLinkFields, readPageAnchors, runPolicyLinkChecks } from "./qa-policy-links.mjs";
|
|
16
19
|
import {
|
|
17
20
|
canonicalHttpUrl,
|
|
18
21
|
commonTestOrderPaths,
|
|
@@ -44,7 +47,8 @@ import {
|
|
|
44
47
|
} from "./qa-cart-entry.mjs";
|
|
45
48
|
import { ORDER_BUMP_PROBE_INPUT, orderBumpEvidenceScript } from "./qa-order-bump.mjs";
|
|
46
49
|
import { assessPurchaseDataLayer, expectedOrderReferences, purchaseDataLayerAssertion, purchaseDataLayerProbe } from "./qa-purchase-data-layer.mjs";
|
|
47
|
-
import {
|
|
50
|
+
import { declaredOrderBumps, declaredSelectorTiers } from "./commercial-journey.mjs";
|
|
51
|
+
import { bindingAssertion, isSdkApiRequest, sentBinding } from "./qa-binding-evidence.mjs";
|
|
48
52
|
import {
|
|
49
53
|
RESIDUE_PAGE_TYPES,
|
|
50
54
|
demoAssetConfig,
|
|
@@ -117,18 +121,45 @@ const CART_CREATE_RESPONSE_PATTERN = /\/api\/v1\/carts\/?(?:[?#].*)?$/i;
|
|
|
117
121
|
|
|
118
122
|
export async function runBrowserChecks(topologies, args = {}, options = {}) {
|
|
119
123
|
const browser = await launchChromium(args);
|
|
120
|
-
const
|
|
124
|
+
const contextOptions = {
|
|
121
125
|
viewport: viewportFromArgs(args),
|
|
122
126
|
extraHTTPHeaders: args["auth-cookie"] ? { Cookie: String(args["auth-cookie"]) } : undefined,
|
|
123
|
-
}
|
|
127
|
+
};
|
|
128
|
+
const context = await browser.newContext(contextOptions);
|
|
124
129
|
|
|
125
130
|
try {
|
|
126
131
|
const assertions = [];
|
|
132
|
+
// Policy links (options.spec campaign.store_*): one {read, anchors} entry
|
|
133
|
+
// per page visit, filled by the page checks, and the one run budget every
|
|
134
|
+
// anchor read and the probes draw on.
|
|
135
|
+
const policyLinks = Array.isArray(options.qcResults) && hasPolicyLinkFields(options.spec) ? { pages: [], budget: createPolicyLinkBudget() } : null;
|
|
127
136
|
for (const topology of topologies) {
|
|
128
137
|
for (const page of topology.pages) {
|
|
129
|
-
assertions.push(...await runPageBrowserChecks(context, page, args, options));
|
|
138
|
+
assertions.push(...await runPageBrowserChecks(context, page, args, options, policyLinks));
|
|
130
139
|
}
|
|
131
140
|
}
|
|
141
|
+
// Content parameters (options.spec analytics.params.content), after the
|
|
142
|
+
// page checks: every load in its own fresh context with the same options,
|
|
143
|
+
// closed after the load. The rows go to options.qcResults; their verdict
|
|
144
|
+
// assertions join the page checks'.
|
|
145
|
+
if (Array.isArray(options.qcResults)) {
|
|
146
|
+
const contentParams = await runContentParamChecks({
|
|
147
|
+
topologies,
|
|
148
|
+
spec: options.spec,
|
|
149
|
+
newContext: () => browser.newContext(contextOptions),
|
|
150
|
+
withQueryParam,
|
|
151
|
+
});
|
|
152
|
+
options.qcResults.push(...contentParams.rows);
|
|
153
|
+
assertions.push(...contentParams.assertions);
|
|
154
|
+
}
|
|
155
|
+
// Policy links, after the page checks: presence from the anchors each
|
|
156
|
+
// visit read, availability from one header-only probe per distinct
|
|
157
|
+
// configured URL.
|
|
158
|
+
if (policyLinks) {
|
|
159
|
+
const policyLinkResults = await runPolicyLinkChecks({ spec: options.spec, pages: policyLinks.pages, budget: policyLinks.budget });
|
|
160
|
+
options.qcResults.push(...policyLinkResults.rows);
|
|
161
|
+
assertions.push(...policyLinkResults.assertions);
|
|
162
|
+
}
|
|
132
163
|
return assertions;
|
|
133
164
|
} finally {
|
|
134
165
|
await context.close().catch(() => {});
|
|
@@ -164,6 +195,10 @@ export async function runBrowserTestOrders(topologies, args = {}, runId = "local
|
|
|
164
195
|
const orderPathPlan = isCommonTestOrderMode(args["test-order"]) ? commonOrderPathPlan(topologies, args) : null;
|
|
165
196
|
if (orderPathPlan) (options.warn || ((line) => process.stderr.write(`${line}\n`)))(describeCommonOrderPathPlan(orderPathPlan));
|
|
166
197
|
const creationBudget = createOrderCreationBudget({ plans, args });
|
|
198
|
+
// Synthetic tracking seeds for this run's attempts (options.spec carries
|
|
199
|
+
// analytics.params.tracking.preserve; trackingTestHooks is an in-process
|
|
200
|
+
// test seam only).
|
|
201
|
+
const tracking = createTrackingRun({ runId, spec: options.spec || null, hooks: options.trackingTestHooks || null });
|
|
167
202
|
|
|
168
203
|
const browser = await launchChromium(args);
|
|
169
204
|
const context = await browser.newContext({
|
|
@@ -180,13 +215,18 @@ export async function runBrowserTestOrders(topologies, args = {}, runId = "local
|
|
|
180
215
|
runId,
|
|
181
216
|
// The topologies travel with the options so each attempt can resolve the
|
|
182
217
|
// funnel's cart-entry page for the checkout it drives (campaigns-os#206).
|
|
183
|
-
options: { ...options, creationBudget, topologies, orderPathPlan },
|
|
218
|
+
options: { ...options, creationBudget, topologies, orderPathPlan, tracking },
|
|
184
219
|
});
|
|
185
220
|
return {
|
|
186
|
-
|
|
187
|
-
|
|
221
|
+
// The persisted exit: order URLs leave as origin+path. The in-memory
|
|
222
|
+
// orders stay raw, because recovery reloads the recorded receipt URL.
|
|
223
|
+
// Assertions and QC rows leave through the one persisted projection,
|
|
224
|
+
// so each qc.* assertion and its QC row stay identical.
|
|
225
|
+
orders: dispatched.orders.map(persistedTestOrder),
|
|
226
|
+
assertions: dispatched.assertions.map(redactPersisted),
|
|
188
227
|
receiptAnalytics: dispatched.receiptAnalytics,
|
|
189
228
|
journeyAnalytics: dispatched.journeyAnalytics,
|
|
229
|
+
qc_results: (dispatched.qcResults || []).map(redactPersisted),
|
|
190
230
|
};
|
|
191
231
|
} finally {
|
|
192
232
|
await context.close().catch(() => {});
|
|
@@ -220,6 +260,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
220
260
|
|
|
221
261
|
const assertions = [];
|
|
222
262
|
const orders = [];
|
|
263
|
+
const qcResults = [];
|
|
223
264
|
// The plan behind each entry in `orders`, index for index, so the upsell
|
|
224
265
|
// coverage row can tell which funnel an order ran through.
|
|
225
266
|
const orderPlans = [];
|
|
@@ -386,6 +427,19 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
386
427
|
if (renderedReceiptAssertion) assertions.push(renderedReceiptAssertion);
|
|
387
428
|
const dataLayerAssertion = purchaseDataLayerAssertion(pageForPlan, identifier, result.order);
|
|
388
429
|
if (dataLayerAssertion) assertions.push(dataLayerAssertion);
|
|
430
|
+
// Tracking parameter rows for this plan. They never decide the order's
|
|
431
|
+
// own assertion, and a failure here never breaks the run.
|
|
432
|
+
try {
|
|
433
|
+
const trackingRows = options.tracking
|
|
434
|
+
? options.tracking.rowsFor({ attempts: attemptsForPlan, recoveredAttempt: creationRecord?.action === "recovered" ? firstAttempt : null })
|
|
435
|
+
: [];
|
|
436
|
+
for (const row of trackingRows) {
|
|
437
|
+
qcResults.push(row);
|
|
438
|
+
assertions.push(trackingQaAssertion(row));
|
|
439
|
+
}
|
|
440
|
+
} catch {
|
|
441
|
+
// No row is recorded; the QC handoff lists the check as not captured.
|
|
442
|
+
}
|
|
389
443
|
}
|
|
390
444
|
} catch (error) {
|
|
391
445
|
// Convert runner-level surprises into a blocker assertion so the run still
|
|
@@ -411,7 +465,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
411
465
|
});
|
|
412
466
|
if (coverage) assertions.push(coverage);
|
|
413
467
|
|
|
414
|
-
return { orders, assertions, receiptAnalytics, journeyAnalytics, creationBudget };
|
|
468
|
+
return { orders, assertions, receiptAnalytics, journeyAnalytics, creationBudget, qcResults };
|
|
415
469
|
}
|
|
416
470
|
|
|
417
471
|
// Analytics-parity leg: capture the live dataLayer event stream + GTM/pixel
|
|
@@ -929,8 +983,11 @@ export async function captureAnalyticsForUrls(urls = {}, args = {}) {
|
|
|
929
983
|
}
|
|
930
984
|
}
|
|
931
985
|
|
|
932
|
-
async function runPageBrowserChecks(context, page, args, options = {}) {
|
|
986
|
+
async function runPageBrowserChecks(context, page, args, options = {}, policyLinks = null) {
|
|
933
987
|
const assertions = [];
|
|
988
|
+
// This visit's policy link read; it stays unread unless its anchors are read.
|
|
989
|
+
const policyLinkRead = { read: false, anchors: null };
|
|
990
|
+
policyLinks?.pages.push(policyLinkRead);
|
|
934
991
|
if (!page.url) {
|
|
935
992
|
assertions.push(assertion({
|
|
936
993
|
id: `browser-load:${page.page_id}`,
|
|
@@ -962,6 +1019,14 @@ async function runPageBrowserChecks(context, page, args, options = {}) {
|
|
|
962
1019
|
failure: request.failure()?.errorText || "request failed",
|
|
963
1020
|
});
|
|
964
1021
|
});
|
|
1022
|
+
// The key each SDK request carried. It stays in this function: sentBinding
|
|
1023
|
+
// keeps only whether it matched.
|
|
1024
|
+
const sentKeys = [];
|
|
1025
|
+
const keyReads = [];
|
|
1026
|
+
browserPage.on("request", (request) => {
|
|
1027
|
+
if (!isSdkApiRequest(request.url())) return;
|
|
1028
|
+
keyReads.push(request.headerValue("authorization").then((value) => { if (value !== null) sentKeys.push(value); }, () => {}));
|
|
1029
|
+
});
|
|
965
1030
|
|
|
966
1031
|
// Measurements describe the existing sequence; readiness never shortens it.
|
|
967
1032
|
const observationStarted = performance.now();
|
|
@@ -974,6 +1039,12 @@ async function runPageBrowserChecks(context, page, args, options = {}) {
|
|
|
974
1039
|
await browserPage.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
|
|
975
1040
|
observation.settle_ms = elapsedMilliseconds(settleStarted);
|
|
976
1041
|
const status = response?.status() ?? null;
|
|
1042
|
+
// One read of the rendered anchors, from an isolated world. A page that
|
|
1043
|
+
// was not served is not read.
|
|
1044
|
+
if (policyLinks && !(status && status >= 400)) {
|
|
1045
|
+
const anchors = await readPageAnchors(context, browserPage, { spec: options.spec, budget: policyLinks.budget });
|
|
1046
|
+
if (anchors) Object.assign(policyLinkRead, { read: true, anchors });
|
|
1047
|
+
}
|
|
977
1048
|
const title = await browserPage.title().catch(() => "");
|
|
978
1049
|
const bodyPresent = await browserPage.locator("body").count().then((count) => count > 0).catch(() => false);
|
|
979
1050
|
|
|
@@ -1017,6 +1088,9 @@ async function runPageBrowserChecks(context, page, args, options = {}) {
|
|
|
1017
1088
|
assertions.push(runtimeIssueAssertion(page, "browser-console-errors", actionableConsoleErrors));
|
|
1018
1089
|
}
|
|
1019
1090
|
observation.failed_requests_sampled_after_ms = elapsedMilliseconds(observationStarted);
|
|
1091
|
+
await Promise.all(keyReads);
|
|
1092
|
+
const sent = sentBinding(sentKeys, options.bindingExpected);
|
|
1093
|
+
if (sent) assertions.push(bindingAssertion(page, sent));
|
|
1020
1094
|
if (failedRequests.length) {
|
|
1021
1095
|
assertions.push(assertion({
|
|
1022
1096
|
id: `browser-request-failures:${page.page_id}`,
|
|
@@ -3481,6 +3555,12 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
|
|
|
3481
3555
|
let events = { requests: [], responses: [], failed: [], console: [], pageErrors: [] };
|
|
3482
3556
|
const email = testEmail(planArgs);
|
|
3483
3557
|
const entryPage = resolveCartEntryPage(options.topologies, checkoutPage);
|
|
3558
|
+
let tracking = null;
|
|
3559
|
+
try {
|
|
3560
|
+
tracking = options.tracking ? options.tracking.observe(planId(normalizedPlan)) : null;
|
|
3561
|
+
} catch {
|
|
3562
|
+
tracking = null;
|
|
3563
|
+
}
|
|
3484
3564
|
// Reserved immediately before the submit click, never reconciled afterwards:
|
|
3485
3565
|
// an accounting check that runs after the purchase is not a budget. The same
|
|
3486
3566
|
// call records that this attempt did submit, which is what lets a later
|
|
@@ -3555,6 +3635,13 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
|
|
|
3555
3635
|
// hand. Everything the result carries onwards has been through
|
|
3556
3636
|
// `sanitizedEvents`, which keeps the last 20 entries per stream.
|
|
3557
3637
|
result.order_creates = summarizeOrderCreateActivity(events);
|
|
3638
|
+
if (tracking) {
|
|
3639
|
+
try {
|
|
3640
|
+
result[TRACKING_OBSERVATION] = await tracking.finalize({ createActivity: result.order_creates });
|
|
3641
|
+
} catch {
|
|
3642
|
+
// The attempt gets no tracking rows; the order result is unchanged.
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
3558
3645
|
}
|
|
3559
3646
|
return stampTestOrderPlan(result, normalizedPlan);
|
|
3560
3647
|
};
|
|
@@ -3576,7 +3663,7 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
|
|
|
3576
3663
|
}
|
|
3577
3664
|
}
|
|
3578
3665
|
page.setDefaultTimeout(numberArg(planArgs["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS));
|
|
3579
|
-
events = captureCheckoutEvents(page);
|
|
3666
|
+
events = captureCheckoutEvents(page, tracking);
|
|
3580
3667
|
// The outer race is the hard guarantee: whatever hangs inside the path,
|
|
3581
3668
|
// this returns and the run writes a verdict instead of dying with nothing.
|
|
3582
3669
|
orderDeadline = Date.now() + orderTimeoutMs;
|
|
@@ -3594,6 +3681,7 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
|
|
|
3594
3681
|
deadline: orderDeadline,
|
|
3595
3682
|
reserveOrderCreation,
|
|
3596
3683
|
selectorProbeCache: options.selectorProbeCache,
|
|
3684
|
+
tracking,
|
|
3597
3685
|
}),
|
|
3598
3686
|
orderTimeoutMs + ORDER_TIMEOUT_GRACE_MS,
|
|
3599
3687
|
`order-path:${planId(normalizedPlan)}`,
|
|
@@ -3672,7 +3760,7 @@ function stablePrivateCaptureError(value) {
|
|
|
3672
3760
|
return projectAnalyticsCaptureError(value, { fallbackKind: "unreadable" });
|
|
3673
3761
|
}
|
|
3674
3762
|
|
|
3675
|
-
async function executeTestOrderPath({ page, events, email, ladder, checkoutPage, entryPage = null, topologyPlan, path, args, deadline, reserveOrderCreation = null, selectorProbeCache = null }) {
|
|
3763
|
+
async function executeTestOrderPath({ page, events, email, ladder, checkoutPage, entryPage = null, topologyPlan, path, args, deadline, reserveOrderCreation = null, selectorProbeCache = null, tracking = null }) {
|
|
3676
3764
|
const stepTimeoutMs = numberArg(args["step-timeout-ms"], DEFAULT_STEP_TIMEOUT_MS);
|
|
3677
3765
|
const budget = () => Math.min(stepTimeoutMs, deadline - Date.now());
|
|
3678
3766
|
const hostedNow = () => hostedRedirectInfo(safePageUrl(page), checkoutPage.url);
|
|
@@ -3692,7 +3780,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3692
3780
|
// page the probe left there rather than loading the same URL a second time,
|
|
3693
3781
|
// which would fire the SDK's page-view events twice into the same capture.
|
|
3694
3782
|
const entry = await ladder.run(CART_ENTRY_STEP, () => enterCartViaLanding({
|
|
3695
|
-
page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache,
|
|
3783
|
+
page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking,
|
|
3696
3784
|
}), { timeoutMs: budget() });
|
|
3697
3785
|
const enteredViaLanding = Boolean(entry && typeof entry === "object" && entry.entered);
|
|
3698
3786
|
// Responses captured from here on belong to the checkout the ladder drives.
|
|
@@ -3700,7 +3788,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3700
3788
|
// made before the hand-off.
|
|
3701
3789
|
const checkoutResponseOffset = events.responses.length;
|
|
3702
3790
|
|
|
3703
|
-
await ladder.run("opened_checkout", () => openCheckoutForPath({ page, checkoutPage, entry, args }), { timeoutMs: budget() });
|
|
3791
|
+
await ladder.run("opened_checkout", () => openCheckoutForPath({ page, checkoutPage, entry, args, tracking }), { timeoutMs: budget() });
|
|
3704
3792
|
await ladder.run("selected_bundle", async () => {
|
|
3705
3793
|
// The requested package was selected on the entry page, where the cards
|
|
3706
3794
|
// live; checkout renders none, so re-running strict selection here would
|
|
@@ -3753,6 +3841,9 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3753
3841
|
// runs BEFORE the reservation: nothing is clicked, nothing is spent, and
|
|
3754
3842
|
// the classifier reads the failure as `not_created`.
|
|
3755
3843
|
cartBeforeSubmit = await cartStateBeforeSubmit(page, events, { responseOffset: checkoutResponseOffset, budget });
|
|
3844
|
+
// The checkout's declared tags and inline page script, read after the
|
|
3845
|
+
// SDK is ready (bounded; never throws).
|
|
3846
|
+
await tracking?.readDocument(page);
|
|
3756
3847
|
if (cartBeforeSubmit.empty) {
|
|
3757
3848
|
throw codedError(CART_ENTRY_CODES.CART_EMPTY_BEFORE_SUBMIT, cartEmptyMessage(cartBeforeSubmit));
|
|
3758
3849
|
}
|
|
@@ -3982,7 +4073,7 @@ function createSelectorProbeCache() {
|
|
|
3982
4073
|
// loaded it and found a selection surface; either way a second load of the
|
|
3983
4074
|
// same URL is what is avoided. Anywhere else (a cached probe answer skipped the
|
|
3984
4075
|
// load; a fresh page) the checkout is opened here, once.
|
|
3985
|
-
async function openCheckoutForPath({ page, checkoutPage, entry, args }) {
|
|
4076
|
+
async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking = null }) {
|
|
3986
4077
|
const enteredViaLanding = Boolean(entry && typeof entry === "object" && entry.entered);
|
|
3987
4078
|
if (enteredViaLanding) {
|
|
3988
4079
|
await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
|
|
@@ -3991,14 +4082,14 @@ async function openCheckoutForPath({ page, checkoutPage, entry, args }) {
|
|
|
3991
4082
|
if (checkoutUrlPredicate(checkoutPage.url)(safePageUrl(page))) {
|
|
3992
4083
|
return "already on checkout from the selector probe; not re-opened";
|
|
3993
4084
|
}
|
|
3994
|
-
await gotoAndSettle(page, checkoutPage.url, args);
|
|
4085
|
+
await gotoAndSettle(page, checkoutPage.url, args, tracking);
|
|
3995
4086
|
return null;
|
|
3996
4087
|
}
|
|
3997
4088
|
|
|
3998
4089
|
// The entry step body. Resolves to `{ skip }` when the checkout selects for
|
|
3999
4090
|
// itself, to `{ entered: true, ... }` when the runner came in through the entry
|
|
4000
4091
|
// page, and throws a coded error (never a bare timeout) when it cannot.
|
|
4001
|
-
async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache = null }) {
|
|
4092
|
+
async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache = null, tracking = null }) {
|
|
4002
4093
|
// Probe the checkout first: whether it carries a selection surface is a fact
|
|
4003
4094
|
// about the rendered page, not about the spec (the spec cannot say it yet —
|
|
4004
4095
|
// that is the design half of #206). The probe's load is the checkout's only
|
|
@@ -4015,7 +4106,7 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4015
4106
|
let probe = cached ? "reused" : "loaded";
|
|
4016
4107
|
let probeError = null;
|
|
4017
4108
|
if (!surface) {
|
|
4018
|
-
await gotoAndSettle(page, checkoutPage.url, args);
|
|
4109
|
+
await gotoAndSettle(page, checkoutPage.url, args, tracking);
|
|
4019
4110
|
const probed = await page.evaluate(checkoutSelectionSurfaceScript())
|
|
4020
4111
|
.then((value) => ({ value }), (error) => ({ value: null, error: error?.message || String(error) }));
|
|
4021
4112
|
if (probed.value) {
|
|
@@ -4041,8 +4132,9 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4041
4132
|
);
|
|
4042
4133
|
}
|
|
4043
4134
|
|
|
4044
|
-
await gotoAndSettle(page, entryPage.url, args);
|
|
4135
|
+
await gotoAndSettle(page, entryPage.url, args, tracking);
|
|
4045
4136
|
const sdkReady = await waitForSdkReady(page, Math.min(budget(), DEFAULT_SETTLE_TIMEOUT_MS));
|
|
4137
|
+
await tracking?.readDocument(page);
|
|
4046
4138
|
const controls = await page.evaluate(cartEntryControlsScript(), { selector: CART_ENTRY_CONTROL_SELECTOR, checkoutUrl: checkoutPage.url }).catch(() => []);
|
|
4047
4139
|
const choice = chooseCartEntryControl(controls, selectedPackages);
|
|
4048
4140
|
if (!choice.control) {
|
|
@@ -4336,8 +4428,11 @@ function assessReceiptRendering(persistedLineCount, evidence = {}) {
|
|
|
4336
4428
|
};
|
|
4337
4429
|
}
|
|
4338
4430
|
|
|
4339
|
-
|
|
4340
|
-
|
|
4431
|
+
// With a tracking observer, the load is a runner navigation: before the
|
|
4432
|
+
// attempt's first page-initiated hop it carries the run's synthetic seeds.
|
|
4433
|
+
async function gotoAndSettle(page, url, args, tracking = null) {
|
|
4434
|
+
const target = tracking ? tracking.runnerUrl(url, withQueryParam) : url;
|
|
4435
|
+
await page.goto(target, { waitUntil: "domcontentloaded", timeout: numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS) });
|
|
4341
4436
|
await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
|
|
4342
4437
|
await page.waitForTimeout(750);
|
|
4343
4438
|
}
|
|
@@ -5207,11 +5302,30 @@ async function clickControl(locator, { timeout, forceFallback = true, perpetual
|
|
|
5207
5302
|
}
|
|
5208
5303
|
}
|
|
5209
5304
|
|
|
5305
|
+
// The control a shopper would click. A page may keep the SDK action hidden in
|
|
5306
|
+
// the offer and show a data-upsell-proxy button elsewhere that forwards its
|
|
5307
|
+
// click to it (the starter templates' closing-card decline on a box-style
|
|
5308
|
+
// single offer). Clicking the hidden action fails as not visible, so the
|
|
5309
|
+
// visible proxy is clicked instead, but only while the offer holds an SDK action
|
|
5310
|
+
// for it to forward to: otherwise the hidden action is clicked and fails as before.
|
|
5311
|
+
async function shopperUpsellControl(page, action) {
|
|
5312
|
+
const actions = page.locator(`[data-next-upsell-action="${action}"]`);
|
|
5313
|
+
if (!await actions.count().catch(() => 0)) return actions.first();
|
|
5314
|
+
const visibleAction = actions.filter({ visible: true }).first();
|
|
5315
|
+
if (await visibleAction.count().catch(() => 0)) return visibleAction;
|
|
5316
|
+
// The proxy forwards to `[data-next-upsell="offer"] [data-next-upsell-action]`
|
|
5317
|
+
// (upsells.js), so only an action inside the offer is a target.
|
|
5318
|
+
const inOffer = page.locator(`[data-next-upsell="offer"] [data-next-upsell-action="${action}"]`);
|
|
5319
|
+
const proxy = page.locator(`[data-upsell-proxy="${action}"]`).filter({ visible: true }).first();
|
|
5320
|
+
if (await inOffer.count().catch(() => 0) && await proxy.count().catch(() => 0)) return proxy;
|
|
5321
|
+
return actions.first();
|
|
5322
|
+
}
|
|
5323
|
+
|
|
5210
5324
|
async function clickUpsellPath(page, path, { trace = null } = {}) {
|
|
5211
5325
|
const offerUrl = safePageUrl(page);
|
|
5212
5326
|
const action = path === "accept" ? "add" : "skip";
|
|
5213
5327
|
const selector = `[data-next-upsell-action="${action}"]`;
|
|
5214
|
-
const control = page
|
|
5328
|
+
const control = await shopperUpsellControl(page, action);
|
|
5215
5329
|
if (!await control.count().catch(() => 0)) {
|
|
5216
5330
|
return { path, clicked: false, error: `Missing upsell control ${selector}` };
|
|
5217
5331
|
}
|
|
@@ -5485,6 +5599,22 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
|
|
|
5485
5599
|
const displayed = new Set(resolved.displayed_package_ids);
|
|
5486
5600
|
const summaryIds = new Set(resolved.summary_package_ids);
|
|
5487
5601
|
const matchedSummaryIds = new Set();
|
|
5602
|
+
// A checkout order bump persists with is_upsell: true (the platform's
|
|
5603
|
+
// reporting tag), yet the checkout summary displays it. Such a line charges
|
|
5604
|
+
// a displayed row; an is_upsell line the summary does not show is a
|
|
5605
|
+
// post-purchase upsell, out of scope here, never a stray charge.
|
|
5606
|
+
let bumpLineCount = 0;
|
|
5607
|
+
for (const line of (lines || []).filter((entry) => entry?.is_upsell)) {
|
|
5608
|
+
const resolution = events ? campaignPackageResolutionForLine(events, line, {
|
|
5609
|
+
selected_packages,
|
|
5610
|
+
preferred_refs: resolved.summary_package_ids,
|
|
5611
|
+
}) : null;
|
|
5612
|
+
const ref = resolution?.pkg?.ref_id == null ? null : String(resolution.pkg.ref_id);
|
|
5613
|
+
if (ref && summaryIds.has(ref)) {
|
|
5614
|
+
matchedSummaryIds.add(ref);
|
|
5615
|
+
bumpLineCount += 1;
|
|
5616
|
+
}
|
|
5617
|
+
}
|
|
5488
5618
|
const extra = [];
|
|
5489
5619
|
const unresolved = [];
|
|
5490
5620
|
const matchedQuantities = [];
|
|
@@ -5532,6 +5662,7 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
|
|
|
5532
5662
|
displayed_package_ids: [...displayed],
|
|
5533
5663
|
summary_package_ids: [...summaryIds],
|
|
5534
5664
|
non_upsell_line_count: nonUpsellLines.length,
|
|
5665
|
+
...(bumpLineCount ? { order_bump_line_count: bumpLineCount } : {}),
|
|
5535
5666
|
extra,
|
|
5536
5667
|
missing,
|
|
5537
5668
|
matched_quantities: matchedQuantities,
|
|
@@ -5601,7 +5732,7 @@ function orderDisplayParityAssertion(page, planIdentifier, order) {
|
|
|
5601
5732
|
return assertion({
|
|
5602
5733
|
...base,
|
|
5603
5734
|
status: STATUS.PASS,
|
|
5604
|
-
actual: `${reconciliation.non_upsell_line_count} non-upsell line(s) reconciled against ${reconciliation.summary_package_ids.length} displayed package(s)`,
|
|
5735
|
+
actual: `${reconciliation.non_upsell_line_count} non-upsell line(s)${reconciliation.order_bump_line_count ? ` and ${reconciliation.order_bump_line_count} order bump line(s)` : ""} reconciled against ${reconciliation.summary_package_ids.length} displayed package(s)`,
|
|
5605
5736
|
evidence: reconciliation,
|
|
5606
5737
|
});
|
|
5607
5738
|
}
|
|
@@ -6168,7 +6299,7 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
|
|
|
6168
6299
|
...retry,
|
|
6169
6300
|
...creation,
|
|
6170
6301
|
hosted_checkout_url: result.order?.hosted_checkout_url || null,
|
|
6171
|
-
final_url: result.order?.final_url,
|
|
6302
|
+
final_url: redactUrlQuery(result.order?.final_url),
|
|
6172
6303
|
steps: result.order?.evidence?.steps,
|
|
6173
6304
|
note: "Hosted checkout flow is platform-owned; verify the hosted completion manually.",
|
|
6174
6305
|
},
|
|
@@ -6215,13 +6346,13 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
|
|
|
6215
6346
|
...recovery,
|
|
6216
6347
|
ref_id: result.order.ref_id,
|
|
6217
6348
|
order_number: result.order.next_order_id,
|
|
6218
|
-
final_url: result.order.final_url,
|
|
6349
|
+
final_url: redactUrlQuery(result.order.final_url),
|
|
6219
6350
|
is_test: result.order.is_test,
|
|
6220
6351
|
line_count: result.order.receipt_line_items.length,
|
|
6221
6352
|
...(receiptProofEvidence(result.order) ? { receipt_proof: receiptProofEvidence(result.order) } : {}),
|
|
6222
6353
|
...(path === "accept" ? { accepted_upsell_line_present: result.order.verification?.accepted_upsell_line_present } : {}),
|
|
6223
6354
|
...(upsellUnverified ? { upsell_unverified: upsellUnverified } : {}),
|
|
6224
|
-
...(result.order.upsell ? { upsell_clicked: result.order.upsell.clicked, upsell_final_url: result.order.upsell.final_url } : {}),
|
|
6355
|
+
...(result.order.upsell ? { upsell_clicked: result.order.upsell.clicked, upsell_final_url: redactUrlQuery(result.order.upsell.final_url) } : {}),
|
|
6225
6356
|
...(result.order.upsell_steps ? { upsell_steps: result.order.upsell_steps.map(summarizeUpsellStep) } : {}),
|
|
6226
6357
|
...(result.order.verification?.accepted_upsell_matches ? { accepted_upsell_matches: result.order.verification.accepted_upsell_matches } : {}),
|
|
6227
6358
|
...(result.order.verification?.coupon ? { coupon: result.order.verification.coupon } : {}),
|
|
@@ -6232,7 +6363,7 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
|
|
|
6232
6363
|
...retry,
|
|
6233
6364
|
...creation,
|
|
6234
6365
|
...recovery,
|
|
6235
|
-
final_url: result.order?.final_url,
|
|
6366
|
+
final_url: redactUrlQuery(result.order?.final_url),
|
|
6236
6367
|
steps: result.order?.evidence?.steps,
|
|
6237
6368
|
...(receiptProofEvidence(result.order) ? { receipt_proof: receiptProofEvidence(result.order) } : {}),
|
|
6238
6369
|
...(result.order?.verification?.coupon ? { coupon: result.order.verification.coupon } : {}),
|
|
@@ -6309,11 +6440,20 @@ function responseRequestStartedAt(response) {
|
|
|
6309
6440
|
}
|
|
6310
6441
|
}
|
|
6311
6442
|
|
|
6312
|
-
function captureCheckoutEvents(page) {
|
|
6443
|
+
function captureCheckoutEvents(page, tracking = null) {
|
|
6313
6444
|
const events = { requests: [], responses: [], failed: [], console: [], pageErrors: [], navigations: [] };
|
|
6314
6445
|
const interesting = /\/api\/v1\/(?:orders|upsells|carts)\/?|\/transactions|spreedly|campaigns\.apps/i;
|
|
6446
|
+
const isMainFrame = (frame) => typeof page.mainFrame !== "function" || frame === page.mainFrame();
|
|
6315
6447
|
page.on("request", (request) => {
|
|
6316
6448
|
if (!interesting.test(request.url())) return;
|
|
6449
|
+
// Tracking equality reads the create body before it is summarized below.
|
|
6450
|
+
if (tracking && ORDER_CREATE_RESPONSE_PATTERN.test(request.url()) && request.method() === "POST") {
|
|
6451
|
+
try {
|
|
6452
|
+
tracking.onCreateRequest(() => request.postData());
|
|
6453
|
+
} catch {
|
|
6454
|
+
// The order is never held up by a tracking read.
|
|
6455
|
+
}
|
|
6456
|
+
}
|
|
6317
6457
|
events.requests.push({
|
|
6318
6458
|
method: request.method(),
|
|
6319
6459
|
url: request.url(),
|
|
@@ -6331,15 +6471,47 @@ function captureCheckoutEvents(page) {
|
|
|
6331
6471
|
const request = responseRequest(response);
|
|
6332
6472
|
let chainUrl = null;
|
|
6333
6473
|
try { chainUrl = request?.url() ?? null; } catch { /* identity only */ }
|
|
6474
|
+
// Tracking: a script the page received is scanned for page-script
|
|
6475
|
+
// attribution calls, and an accepted create marks the post-order point.
|
|
6476
|
+
let orderSource = null;
|
|
6477
|
+
if (tracking) {
|
|
6478
|
+
try {
|
|
6479
|
+
const method = response.request().method();
|
|
6480
|
+
if (response.request().resourceType() === "script") tracking.onScriptResponse(response);
|
|
6481
|
+
if (method === "POST" && ORDER_CREATE_RESPONSE_PATTERN.test(response.url())) {
|
|
6482
|
+
tracking.onCreateResponseStatus(response.status());
|
|
6483
|
+
orderSource = "create_response";
|
|
6484
|
+
} else if (method === "GET" && ORDER_DETAIL_RESPONSE_PATTERN.test(response.url())) {
|
|
6485
|
+
orderSource = "readback";
|
|
6486
|
+
}
|
|
6487
|
+
if (orderSource && !(response.status() >= 200 && response.status() < 300)) orderSource = null;
|
|
6488
|
+
} catch {
|
|
6489
|
+
orderSource = null;
|
|
6490
|
+
try { tracking.onListenerError("response"); } catch { /* never breaks the order */ }
|
|
6491
|
+
}
|
|
6492
|
+
}
|
|
6334
6493
|
if (!interesting.test(response.url()) && !(chainUrl && interesting.test(chainUrl))) return;
|
|
6335
6494
|
// Taken before the body read: an entry lands in the log when its body
|
|
6336
6495
|
// finishes, so its position says nothing about when it was requested.
|
|
6337
6496
|
const requestStartedAt = responseRequestStartedAt(response);
|
|
6497
|
+
// Equality on the in-memory body, before summarizeResponseBody. For an
|
|
6498
|
+
// order response the observer starts the read itself (through its
|
|
6499
|
+
// observeRead) before anything awaits it, so a body still in flight when
|
|
6500
|
+
// the attempt's observation is taken is a failed extractor there, never a
|
|
6501
|
+
// pass. The log entry awaits that same read.
|
|
6502
|
+
let bodyRead = null;
|
|
6503
|
+
const readBody = () => {
|
|
6504
|
+
bodyRead ??= readJsonResponseBodyWhenLoaded(response);
|
|
6505
|
+
return bodyRead;
|
|
6506
|
+
};
|
|
6507
|
+
if (orderSource) {
|
|
6508
|
+
try { tracking.orderResponseBody(orderSource, readBody); } catch { /* never breaks the order */ }
|
|
6509
|
+
}
|
|
6338
6510
|
const entry = {
|
|
6339
6511
|
status: response.status(),
|
|
6340
6512
|
url: response.url(),
|
|
6341
6513
|
request_started_at: requestStartedAt,
|
|
6342
|
-
body: await
|
|
6514
|
+
body: await readBody(),
|
|
6343
6515
|
};
|
|
6344
6516
|
if (request) entry[REQUEST_IDENTITY] = request;
|
|
6345
6517
|
events.responses.push(entry);
|
|
@@ -6356,6 +6528,42 @@ function captureCheckoutEvents(page) {
|
|
|
6356
6528
|
// Navigation evidence is diagnostic only; never break the order path.
|
|
6357
6529
|
}
|
|
6358
6530
|
});
|
|
6531
|
+
if (tracking) {
|
|
6532
|
+
// Tracking hops: a second listener beside the one above. Whether a hop
|
|
6533
|
+
// committed a new document is read only from the browser itself:
|
|
6534
|
+
// Playwright's main frame emits "navigated" with `newDocument` for a
|
|
6535
|
+
// document commit and without it for a same-document one, synchronously
|
|
6536
|
+
// just before "framenavigated" (pinned against the installed Playwright
|
|
6537
|
+
// in qa-tracking-params-hardening.test.mjs). It is an internal emitter;
|
|
6538
|
+
// where a page has none, every hop reads history. The signal is only held
|
|
6539
|
+
// here and handed to the observer with its hop.
|
|
6540
|
+
let commit = null;
|
|
6541
|
+
try {
|
|
6542
|
+
const emitter = typeof page.mainFrame === "function" ? page.mainFrame()?._eventEmitter : null;
|
|
6543
|
+
if (emitter && typeof emitter.on === "function") {
|
|
6544
|
+
emitter.on("navigated", (event) => {
|
|
6545
|
+
commit = event && !event.error ? { url: String(event.url), newDocument: Boolean(event.newDocument) } : null;
|
|
6546
|
+
});
|
|
6547
|
+
}
|
|
6548
|
+
} catch {
|
|
6549
|
+
// No signal: every hop reads history.
|
|
6550
|
+
}
|
|
6551
|
+
page.on("framenavigated", (frame) => {
|
|
6552
|
+
try {
|
|
6553
|
+
if (!isMainFrame(frame)) return;
|
|
6554
|
+
const signal = commit;
|
|
6555
|
+
commit = null;
|
|
6556
|
+
tracking.onFrameNavigated(frame.url(), signal);
|
|
6557
|
+
} catch {
|
|
6558
|
+
// The observer records the missed hop as a gap.
|
|
6559
|
+
try { tracking.onListenerError("hop"); } catch { /* never breaks the order */ }
|
|
6560
|
+
}
|
|
6561
|
+
});
|
|
6562
|
+
page.on("domcontentloaded", () => {
|
|
6563
|
+
try { tracking.readDocument(page, { awaited: false }); } catch { /* never breaks the order */ }
|
|
6564
|
+
});
|
|
6565
|
+
attachCreateResponseTap(page, tracking);
|
|
6566
|
+
}
|
|
6359
6567
|
page.on("console", (message) => {
|
|
6360
6568
|
if (["error", "warning"].includes(message.type())) events.console.push({ type: message.type(), text: trim(message.text()) });
|
|
6361
6569
|
});
|
|
@@ -6363,6 +6571,58 @@ function captureCheckoutEvents(page) {
|
|
|
6363
6571
|
return events;
|
|
6364
6572
|
}
|
|
6365
6573
|
|
|
6574
|
+
// The order create's response body for the tracking echo, read in memory
|
|
6575
|
+
// before the page can leave it: the SDK navigates as soon as the create
|
|
6576
|
+
// answers, and a body read after that navigation finds nothing. Only order
|
|
6577
|
+
// responses are paused, at the response stage, and every one is continued
|
|
6578
|
+
// unchanged; no request is added and nothing is persisted from the body.
|
|
6579
|
+
// An accepted create is held at most TRACKING_ADDED_BOUND_MS, whatever the
|
|
6580
|
+
// body read does: the observer's read is itself bounded by the attempt's
|
|
6581
|
+
// remaining tracking budget (an overrun records the extractor as failed),
|
|
6582
|
+
// and the timer here continues the response even if that bound misbehaves.
|
|
6583
|
+
function attachCreateResponseTap(page, tracking) {
|
|
6584
|
+
let session = null;
|
|
6585
|
+
const onPaused = (event) => {
|
|
6586
|
+
let continued = false;
|
|
6587
|
+
let timer = null;
|
|
6588
|
+
const proceed = () => {
|
|
6589
|
+
if (continued) return;
|
|
6590
|
+
continued = true;
|
|
6591
|
+
clearTimeout(timer);
|
|
6592
|
+
session.send("Fetch.continueRequest", { requestId: event.requestId }).catch(() => {});
|
|
6593
|
+
};
|
|
6594
|
+
try {
|
|
6595
|
+
const status = Number(event.responseStatusCode);
|
|
6596
|
+
const isAcceptedCreate = event.request?.method === "POST"
|
|
6597
|
+
&& ORDER_CREATE_RESPONSE_PATTERN.test(String(event.request?.url || ""))
|
|
6598
|
+
&& status >= 200 && status < 300;
|
|
6599
|
+
if (!isAcceptedCreate) {
|
|
6600
|
+
proceed();
|
|
6601
|
+
return;
|
|
6602
|
+
}
|
|
6603
|
+
timer = setTimeout(proceed, TRACKING_ADDED_BOUND_MS);
|
|
6604
|
+
const readBody = () => session.send("Fetch.getResponseBody", { requestId: event.requestId }).then((read) => {
|
|
6605
|
+
const text = read?.base64Encoded ? Buffer.from(String(read.body || ""), "base64").toString("utf8") : read?.body;
|
|
6606
|
+
return parseMaybeJson(redactSensitive(text));
|
|
6607
|
+
});
|
|
6608
|
+
Promise.resolve(tracking.tapCreateResponse(readBody)).catch(() => {}).finally(proceed);
|
|
6609
|
+
} catch {
|
|
6610
|
+
proceed();
|
|
6611
|
+
}
|
|
6612
|
+
};
|
|
6613
|
+
// The session setup is itself a read the observer holds, like every other
|
|
6614
|
+
// asynchronous step that feeds a tracking row.
|
|
6615
|
+
try {
|
|
6616
|
+
return tracking.attachTap(async () => {
|
|
6617
|
+
session = await page.context().newCDPSession(page);
|
|
6618
|
+
session.on("Fetch.requestPaused", onPaused);
|
|
6619
|
+
await session.send("Fetch.enable", { patterns: [{ urlPattern: "*/api/v1/orders*", requestStage: "Response" }] });
|
|
6620
|
+
});
|
|
6621
|
+
} catch {
|
|
6622
|
+
return Promise.resolve();
|
|
6623
|
+
}
|
|
6624
|
+
}
|
|
6625
|
+
|
|
6366
6626
|
// Playwright's response.text() waits for the body to finish loading. A page
|
|
6367
6627
|
// that navigates away as soon as the headers land (an SDK that reads only the
|
|
6368
6628
|
// status before redirecting) can leave that wait pending forever. Only a
|
|
@@ -6428,21 +6688,24 @@ function lastJsonResponse(events, pattern) {
|
|
|
6428
6688
|
return null;
|
|
6429
6689
|
}
|
|
6430
6690
|
|
|
6691
|
+
// A persisted exit: every URL is origin+path; bodies are summarized; then
|
|
6692
|
+
// every string and key goes through the persisted-value projection.
|
|
6431
6693
|
function sanitizedEvents(events) {
|
|
6432
|
-
return {
|
|
6433
|
-
requests: events.requests.slice(-20),
|
|
6694
|
+
return redactPersisted({
|
|
6695
|
+
requests: events.requests.slice(-20).map((request) => ({ ...request, url: redactUrlQuery(request.url) })),
|
|
6434
6696
|
responses: events.responses.slice(-20).map((response) => ({
|
|
6435
6697
|
status: response.status,
|
|
6436
|
-
url: response.url,
|
|
6698
|
+
url: redactUrlQuery(response.url),
|
|
6437
6699
|
body: summarizeResponseBody(response.body, { status: response.status }),
|
|
6438
6700
|
})),
|
|
6439
|
-
failed: events.failed.slice(-20),
|
|
6440
|
-
|
|
6441
|
-
|
|
6701
|
+
failed: events.failed.slice(-20).map((failure) => ({ ...failure, url: redactUrlQuery(failure.url), failure: redactUrlQueriesInText(failure.failure) })),
|
|
6702
|
+
// Console and page-error text can quote URLs; their queries are dropped.
|
|
6703
|
+
console: events.console.slice(-20).map((entry) => ({ ...entry, text: redactUrlQueriesInText(entry.text) })),
|
|
6704
|
+
pageErrors: events.pageErrors.slice(-20).map((text) => redactUrlQueriesInText(text)),
|
|
6442
6705
|
navigations: (events.navigations || []).slice(-20).map((navigation) => ({
|
|
6443
6706
|
url: redactUrlQuery(navigation.url),
|
|
6444
6707
|
})),
|
|
6445
|
-
};
|
|
6708
|
+
});
|
|
6446
6709
|
}
|
|
6447
6710
|
|
|
6448
6711
|
function summarizeRequestPostData(value) {
|
|
@@ -6461,23 +6724,26 @@ function summarizeRequestPostData(value) {
|
|
|
6461
6724
|
}
|
|
6462
6725
|
}
|
|
6463
6726
|
|
|
6727
|
+
const RESPONSE_BODY_MARKER = "[redacted-response-body]";
|
|
6728
|
+
|
|
6464
6729
|
function summarizeResponseBody(body, { status = null } = {}) {
|
|
6465
|
-
if (
|
|
6466
|
-
|
|
6730
|
+
if (body === null || body === undefined) return null;
|
|
6731
|
+
// Raw body content (text, an array, a bare scalar) is never persisted.
|
|
6732
|
+
if (typeof body !== "object" || Array.isArray(body)) return RESPONSE_BODY_MARKER;
|
|
6467
6733
|
return {
|
|
6468
6734
|
...(body.number ? { number: body.number } : {}),
|
|
6469
6735
|
...(body.ref_id ? { ref_id: body.ref_id } : {}),
|
|
6470
6736
|
...(body.is_test !== undefined ? { is_test: body.is_test } : {}),
|
|
6471
6737
|
...(body.total_incl_tax ? { total_incl_tax: body.total_incl_tax } : {}),
|
|
6472
6738
|
...(body.currency ? { currency: body.currency } : {}),
|
|
6473
|
-
...(body.checkout_url ? { checkout_url: body.checkout_url } : {}),
|
|
6739
|
+
...(body.checkout_url ? { checkout_url: redactUrlQuery(body.checkout_url) } : {}),
|
|
6474
6740
|
...(Array.isArray(body.lines) ? { lines: extractReceiptLines(body) } : {}),
|
|
6475
|
-
...(body.detail ? { detail: body.detail } : {}),
|
|
6741
|
+
...(body.detail ? { detail: redactPersisted(body.detail) } : {}),
|
|
6476
6742
|
// A rejected request names its reason here (for example the
|
|
6477
6743
|
// duplicate-order refusal). Kept only on an error response, so a
|
|
6478
6744
|
// successful create never carries payment_details into evidence.
|
|
6479
6745
|
...(Number(status) >= 400 && typeof body.payment_details === "string"
|
|
6480
|
-
? { payment_details: trim(body.payment_details).slice(0, 300) }
|
|
6746
|
+
? { payment_details: redactUrlQueriesInText(trim(body.payment_details).slice(0, 300)) }
|
|
6481
6747
|
: {}),
|
|
6482
6748
|
};
|
|
6483
6749
|
}
|
|
@@ -6721,7 +6987,7 @@ function specTierPlans(topologies, args, variant, { warn = (line) => process.std
|
|
|
6721
6987
|
const declaredTiers = declaredSelectorTiers(checkoutPage);
|
|
6722
6988
|
const bumps = declaredOrderBumps(checkoutPage);
|
|
6723
6989
|
if (bumps.length) {
|
|
6724
|
-
warn(`[qa:test-order] checkout page "${checkoutPage.page_id || checkoutPage.label || "(unnamed)"}" declares order bump package(s) ${bumps.join(", ")} (is_upsell) — not planned as selector tiers; bump coverage comes from --cart.`);
|
|
6990
|
+
warn(`[qa:test-order] checkout page "${checkoutPage.page_id || checkoutPage.label || "(unnamed)"}" declares order bump package(s) ${bumps.join(", ")} (is_order_bump or is_upsell) — not planned as selector tiers; bump coverage comes from --cart.`);
|
|
6725
6991
|
}
|
|
6726
6992
|
for (const tier of declaredTiers) declaredIdentities.add(selectorTierIdentity(tier));
|
|
6727
6993
|
for (const ref of bumps) bumpRefs.add(ref);
|
|
@@ -6801,7 +7067,7 @@ function specTierPlans(topologies, args, variant, { warn = (line) => process.std
|
|
|
6801
7067
|
const namedBumps = unmatched.filter((identity) => bumpRefs.has(identity.split(":")[0]));
|
|
6802
7068
|
throw new Error([
|
|
6803
7069
|
`--select-package ${unmatched.join(",")}: ${unmatched.length === 1 ? "is not a selector tier" : "are not selector tiers"} the CampaignSpec declares${declaredIdentities.size ? ` (declared tiers: ${[...declaredIdentities].join(", ")}` : " (no tiers declared"}${bumpRefs.size ? `; order bump ref(s) excluded from tiers: ${[...bumpRefs].join(", ")}` : ""}).`,
|
|
6804
|
-
...(namedBumps.length ? [`${namedBumps.join(",")} ${namedBumps.length === 1 ? "is an order bump (is_upsell)" : "are order bumps (is_upsell)"}, an add-on to a selected tier, not a tier; bump coverage comes from --cart.`] : []),
|
|
7070
|
+
...(namedBumps.length ? [`${namedBumps.join(",")} ${namedBumps.length === 1 ? "is an order bump (is_order_bump or is_upsell)" : "are order bumps (is_order_bump or is_upsell)"}, an add-on to a selected tier, not a tier; bump coverage comes from --cart.`] : []),
|
|
6805
7071
|
"Name declared tiers only, as ref or ref:qty, or drop --select-package to iterate every tier.",
|
|
6806
7072
|
].join(" "));
|
|
6807
7073
|
}
|
|
@@ -6845,59 +7111,6 @@ function selectorTierNarrowing(value) {
|
|
|
6845
7111
|
return identities.length ? new Set(identities) : null;
|
|
6846
7112
|
}
|
|
6847
7113
|
|
|
6848
|
-
// Order bumps declared on the checkout page (`is_upsell: true` rows — the
|
|
6849
|
-
// same marker the commercial-journey planner reads). They are add-ons to a
|
|
6850
|
-
// selected tier, not tiers, so the tier planner reports and skips them.
|
|
6851
|
-
function declaredOrderBumps(checkoutPage) {
|
|
6852
|
-
const refs = [];
|
|
6853
|
-
for (const pkg of Array.isArray(checkoutPage?.packages) ? checkoutPage.packages : []) {
|
|
6854
|
-
if (!pkg || typeof pkg !== "object" || !isBumpRow(pkg)) continue;
|
|
6855
|
-
const ref = [pkg.ref_id, pkg.package_id, pkg.id]
|
|
6856
|
-
.map((value) => (value == null ? "" : String(value).trim()))
|
|
6857
|
-
.find(Boolean);
|
|
6858
|
-
if (ref && !refs.includes(ref)) refs.push(ref);
|
|
6859
|
-
}
|
|
6860
|
-
return refs;
|
|
6861
|
-
}
|
|
6862
|
-
|
|
6863
|
-
// Selector tiers are the packages the spec declares on the checkout page —
|
|
6864
|
-
// same ref tolerance as the doctor's specPackageRecords (ref_id/package_id/id).
|
|
6865
|
-
// Order-bump rows (`is_upsell: true`) are add-ons offered alongside the
|
|
6866
|
-
// selected tier, not tiers of their own: they never become a plan.
|
|
6867
|
-
function declaredSelectorTiers(checkoutPage) {
|
|
6868
|
-
const records = [];
|
|
6869
|
-
const quantitiesByRef = new Map();
|
|
6870
|
-
for (const pkg of Array.isArray(checkoutPage?.packages) ? checkoutPage.packages : []) {
|
|
6871
|
-
if (!pkg || typeof pkg !== "object" || isBumpRow(pkg)) continue;
|
|
6872
|
-
const ref = [pkg.ref_id, pkg.package_id, pkg.id]
|
|
6873
|
-
.map((value) => (value == null ? "" : String(value).trim()))
|
|
6874
|
-
.find(Boolean);
|
|
6875
|
-
const declaredQuantity = Number(pkg.qty ?? pkg.quantity ?? 1);
|
|
6876
|
-
if (!ref || !Number.isInteger(declaredQuantity) || declaredQuantity < 1) continue;
|
|
6877
|
-
records.push({ pkg, ref, declaredQuantity });
|
|
6878
|
-
if (!quantitiesByRef.has(ref)) quantitiesByRef.set(ref, new Set());
|
|
6879
|
-
quantitiesByRef.get(ref).add(declaredQuantity);
|
|
6880
|
-
}
|
|
6881
|
-
|
|
6882
|
-
const tiers = [];
|
|
6883
|
-
const seen = new Set();
|
|
6884
|
-
for (const { pkg, ref, declaredQuantity } of records) {
|
|
6885
|
-
// A unique ref is a catalog package bought once, even when that package's
|
|
6886
|
-
// own composition is 3x. Only repeated declarations of the SAME ref at
|
|
6887
|
-
// different quantities express shopper purchase multipliers (Keer 1x/2x).
|
|
6888
|
-
const quantity = quantitiesByRef.get(ref).size > 1 ? declaredQuantity : 1;
|
|
6889
|
-
const identity = `${ref}:${quantity}`;
|
|
6890
|
-
if (seen.has(identity)) continue;
|
|
6891
|
-
seen.add(identity);
|
|
6892
|
-
tiers.push({
|
|
6893
|
-
ref,
|
|
6894
|
-
quantity,
|
|
6895
|
-
declared_by: stringArg(pkg.name) || stringArg(pkg.title) || undefined,
|
|
6896
|
-
});
|
|
6897
|
-
}
|
|
6898
|
-
return tiers;
|
|
6899
|
-
}
|
|
6900
|
-
|
|
6901
7114
|
// Declared coupons follow the repo's offer-surface rule (build-brief, cli
|
|
6902
7115
|
// exit-pop gates): a surface counts only when `enabled === true` and it maps
|
|
6903
7116
|
// an offer_code. Both surfaces mapping the same code collapse into one plan.
|
|
@@ -7649,7 +7862,7 @@ function summarizeUpsellStep(step) {
|
|
|
7649
7862
|
return {
|
|
7650
7863
|
path: step.path,
|
|
7651
7864
|
clicked: step.clicked,
|
|
7652
|
-
final_url: step.final_url,
|
|
7865
|
+
final_url: redactUrlQuery(step.final_url),
|
|
7653
7866
|
expected_items: step.expected_items,
|
|
7654
7867
|
api_response_seen: step.api_response_seen,
|
|
7655
7868
|
api_response_status: step.api_response_status,
|
|
@@ -7692,6 +7905,30 @@ function withQueryParam(value, key, paramValue) {
|
|
|
7692
7905
|
}
|
|
7693
7906
|
}
|
|
7694
7907
|
|
|
7908
|
+
// A test order as it is persisted: its URLs as origin+path, an upsell's raw
|
|
7909
|
+
// response body as its summary, and no URL query in any string or key (the
|
|
7910
|
+
// one persisted-value projection, redactPersisted). The order events were
|
|
7911
|
+
// already summarized where they were captured.
|
|
7912
|
+
function persistedTestOrder(order) {
|
|
7913
|
+
if (!order || typeof order !== "object") return order;
|
|
7914
|
+
const projected = { ...order };
|
|
7915
|
+
if (Object.hasOwn(order, "checkout_url")) projected.checkout_url = redactUrlQuery(order.checkout_url);
|
|
7916
|
+
if (Object.hasOwn(order, "final_url")) projected.final_url = redactUrlQuery(order.final_url);
|
|
7917
|
+
if (order.upsell && typeof order.upsell === "object") projected.upsell = persistedUpsellStep(order.upsell);
|
|
7918
|
+
if (Array.isArray(order.upsell_steps)) projected.upsell_steps = order.upsell_steps.map(persistedUpsellStep);
|
|
7919
|
+
return redactPersisted(projected);
|
|
7920
|
+
}
|
|
7921
|
+
|
|
7922
|
+
function persistedUpsellStep(step) {
|
|
7923
|
+
if (!step || typeof step !== "object") return step;
|
|
7924
|
+
const projected = { ...step };
|
|
7925
|
+
for (const field of ["offer_url", "final_url", "api_response_url"]) {
|
|
7926
|
+
if (Object.hasOwn(step, field)) projected[field] = redactUrlQuery(step[field]);
|
|
7927
|
+
}
|
|
7928
|
+
if (Object.hasOwn(step, "api_response_order_body")) projected.api_response_order_body = summarizeResponseBody(step.api_response_order_body);
|
|
7929
|
+
return projected;
|
|
7930
|
+
}
|
|
7931
|
+
|
|
7695
7932
|
function findPage(topologies, type) {
|
|
7696
7933
|
for (const topology of topologies || []) {
|
|
7697
7934
|
const page = (topology.pages || []).find((candidate) => candidate.page_type === type);
|
|
@@ -7900,6 +8137,10 @@ export const __qaBrowserTestHooks = Object.freeze({
|
|
|
7900
8137
|
createOrderCreationBudget,
|
|
7901
8138
|
dispatchTestOrderPlans,
|
|
7902
8139
|
recoverCreatedOrder,
|
|
8140
|
+
persistedTestOrder,
|
|
8141
|
+
sanitizedEvents,
|
|
8142
|
+
summarizeResponseBody,
|
|
8143
|
+
attachCreateResponseTap,
|
|
7903
8144
|
extractReceiptLines,
|
|
7904
8145
|
EXIT_INTENT_SURFACE_SELECTORS,
|
|
7905
8146
|
COUPON_INPUT_SELECTORS,
|