@nextcommerce/campaigns-os 1.50.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +426 -0
  2. package/agents/claude/CLAUDE.md +2 -2
  3. package/agents/codex/AGENTS.md +1 -1
  4. package/agents/copilot/copilot-instructions.md +1 -1
  5. package/agents/cursor/campaigns-os.mdc +1 -1
  6. package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
  7. package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
  8. package/campaign-spec/dist/rules/design-source-shape.js +13 -3
  9. package/campaign-spec/dist/rules/sdk-version.js +2 -1
  10. package/compatibility.json +1 -1
  11. package/contracts/commerce-surface-catalog.json +26 -46
  12. package/contracts/effects.v1.json +81 -2
  13. package/contracts/release-ledger.json +906 -0
  14. package/contracts/supported-surface.json +2 -2
  15. package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
  16. package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
  17. package/docs/build-packet.md +93 -9
  18. package/docs/campaign-build-brief.md +25 -1
  19. package/docs/effects.md +6 -0
  20. package/docs/local-setup.md +1 -1
  21. package/docs/orientation-contract-reference.md +1 -1
  22. package/docs/polish-evidence.md +10 -0
  23. package/docs/qa-and-test-orders.md +45 -4
  24. package/docs/runtime-readiness.md +1 -1
  25. package/docs/sdk-storage-compatibility.md +1 -1
  26. package/docs/skills-revision.md +10 -10
  27. package/package.json +1 -1
  28. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  29. package/skills/campaign-readback-classification/SKILL.md +3 -3
  30. package/skills/campaign-run-evidence/SKILL.md +3 -3
  31. package/skills/contribution-intake/SKILL.md +3 -3
  32. package/skills/next-campaigns-build/SKILL.md +3 -3
  33. package/skills/next-campaigns-os/SKILL.md +4 -4
  34. package/skills/next-campaigns-os/references/session-intake.md +7 -3
  35. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  36. package/skills/next-campaigns-polish/SKILL.md +3 -3
  37. package/skills/next-campaigns-qa/SKILL.md +6 -5
  38. package/skills.json +10 -10
  39. package/src/adapter-decision-contract.mjs +1 -1
  40. package/src/brand-theme.mjs +12 -0
  41. package/src/build-brief.mjs +68 -21
  42. package/src/built-site-scope.mjs +39 -6
  43. package/src/built-smoke-qc.mjs +1117 -0
  44. package/src/campaign-identity.mjs +36 -2
  45. package/src/cart-placeholders.mjs +730 -0
  46. package/src/cli.mjs +310 -34
  47. package/src/commercial-journey.mjs +65 -4
  48. package/src/commercial-parity.mjs +6 -1
  49. package/src/doctor/checks.mjs +291 -24
  50. package/src/doctor/inspect.mjs +53 -2
  51. package/src/doctor/next-step.mjs +1 -1
  52. package/src/invocation.mjs +2 -1
  53. package/src/local-preview-policy.mjs +1 -1
  54. package/src/local-proof.mjs +4 -1
  55. package/src/polish-browser.mjs +218 -1
  56. package/src/polish-capture.mjs +1 -1
  57. package/src/polish-media-weight.mjs +492 -0
  58. package/src/polish-node.mjs +96 -4
  59. package/src/progress-node.mjs +5 -1
  60. package/src/qa-browser.mjs +308 -96
  61. package/src/qa-content-params.mjs +889 -0
  62. package/src/qa-node.mjs +104 -12
  63. package/src/qa-order-bump.mjs +22 -1
  64. package/src/qa-policy-links.mjs +1019 -0
  65. package/src/qa-tracking-params.mjs +1389 -0
  66. package/src/qa-url-privacy.mjs +168 -0
  67. package/src/qc-accept.mjs +446 -0
  68. package/src/qc-check-registry.mjs +83 -0
  69. package/src/qc-results.mjs +1049 -0
  70. package/src/sdk-attribute-index.mjs +71 -0
  71. package/src/sdk-markup.mjs +2 -2
  72. package/src/sdk-storage-compatibility.mjs +63 -3
  73. package/src/source-prep.mjs +37 -7
  74. package/src/stage-record.mjs +56 -17
@@ -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,7 @@ 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 { isBumpRow } from "./commercial-journey.mjs";
50
+ import { declaredOrderBumps, declaredSelectorTiers } from "./commercial-journey.mjs";
48
51
  import { bindingAssertion, isSdkApiRequest, sentBinding } from "./qa-binding-evidence.mjs";
49
52
  import {
50
53
  RESIDUE_PAGE_TYPES,
@@ -118,18 +121,45 @@ const CART_CREATE_RESPONSE_PATTERN = /\/api\/v1\/carts\/?(?:[?#].*)?$/i;
118
121
 
119
122
  export async function runBrowserChecks(topologies, args = {}, options = {}) {
120
123
  const browser = await launchChromium(args);
121
- const context = await browser.newContext({
124
+ const contextOptions = {
122
125
  viewport: viewportFromArgs(args),
123
126
  extraHTTPHeaders: args["auth-cookie"] ? { Cookie: String(args["auth-cookie"]) } : undefined,
124
- });
127
+ };
128
+ const context = await browser.newContext(contextOptions);
125
129
 
126
130
  try {
127
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;
128
136
  for (const topology of topologies) {
129
137
  for (const page of topology.pages) {
130
- assertions.push(...await runPageBrowserChecks(context, page, args, options));
138
+ assertions.push(...await runPageBrowserChecks(context, page, args, options, policyLinks));
131
139
  }
132
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
+ }
133
163
  return assertions;
134
164
  } finally {
135
165
  await context.close().catch(() => {});
@@ -165,6 +195,10 @@ export async function runBrowserTestOrders(topologies, args = {}, runId = "local
165
195
  const orderPathPlan = isCommonTestOrderMode(args["test-order"]) ? commonOrderPathPlan(topologies, args) : null;
166
196
  if (orderPathPlan) (options.warn || ((line) => process.stderr.write(`${line}\n`)))(describeCommonOrderPathPlan(orderPathPlan));
167
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 });
168
202
 
169
203
  const browser = await launchChromium(args);
170
204
  const context = await browser.newContext({
@@ -181,13 +215,18 @@ export async function runBrowserTestOrders(topologies, args = {}, runId = "local
181
215
  runId,
182
216
  // The topologies travel with the options so each attempt can resolve the
183
217
  // funnel's cart-entry page for the checkout it drives (campaigns-os#206).
184
- options: { ...options, creationBudget, topologies, orderPathPlan },
218
+ options: { ...options, creationBudget, topologies, orderPathPlan, tracking },
185
219
  });
186
220
  return {
187
- orders: dispatched.orders,
188
- assertions: dispatched.assertions,
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),
189
227
  receiptAnalytics: dispatched.receiptAnalytics,
190
228
  journeyAnalytics: dispatched.journeyAnalytics,
229
+ qc_results: (dispatched.qcResults || []).map(redactPersisted),
191
230
  };
192
231
  } finally {
193
232
  await context.close().catch(() => {});
@@ -221,6 +260,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
221
260
 
222
261
  const assertions = [];
223
262
  const orders = [];
263
+ const qcResults = [];
224
264
  // The plan behind each entry in `orders`, index for index, so the upsell
225
265
  // coverage row can tell which funnel an order ran through.
226
266
  const orderPlans = [];
@@ -387,6 +427,19 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
387
427
  if (renderedReceiptAssertion) assertions.push(renderedReceiptAssertion);
388
428
  const dataLayerAssertion = purchaseDataLayerAssertion(pageForPlan, identifier, result.order);
389
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
+ }
390
443
  }
391
444
  } catch (error) {
392
445
  // Convert runner-level surprises into a blocker assertion so the run still
@@ -412,7 +465,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
412
465
  });
413
466
  if (coverage) assertions.push(coverage);
414
467
 
415
- return { orders, assertions, receiptAnalytics, journeyAnalytics, creationBudget };
468
+ return { orders, assertions, receiptAnalytics, journeyAnalytics, creationBudget, qcResults };
416
469
  }
417
470
 
418
471
  // Analytics-parity leg: capture the live dataLayer event stream + GTM/pixel
@@ -930,8 +983,11 @@ export async function captureAnalyticsForUrls(urls = {}, args = {}) {
930
983
  }
931
984
  }
932
985
 
933
- async function runPageBrowserChecks(context, page, args, options = {}) {
986
+ async function runPageBrowserChecks(context, page, args, options = {}, policyLinks = null) {
934
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);
935
991
  if (!page.url) {
936
992
  assertions.push(assertion({
937
993
  id: `browser-load:${page.page_id}`,
@@ -983,6 +1039,12 @@ async function runPageBrowserChecks(context, page, args, options = {}) {
983
1039
  await browserPage.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
984
1040
  observation.settle_ms = elapsedMilliseconds(settleStarted);
985
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
+ }
986
1048
  const title = await browserPage.title().catch(() => "");
987
1049
  const bodyPresent = await browserPage.locator("body").count().then((count) => count > 0).catch(() => false);
988
1050
 
@@ -3493,6 +3555,12 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
3493
3555
  let events = { requests: [], responses: [], failed: [], console: [], pageErrors: [] };
3494
3556
  const email = testEmail(planArgs);
3495
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
+ }
3496
3564
  // Reserved immediately before the submit click, never reconciled afterwards:
3497
3565
  // an accounting check that runs after the purchase is not a budget. The same
3498
3566
  // call records that this attempt did submit, which is what lets a later
@@ -3567,6 +3635,13 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
3567
3635
  // hand. Everything the result carries onwards has been through
3568
3636
  // `sanitizedEvents`, which keeps the last 20 entries per stream.
3569
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
+ }
3570
3645
  }
3571
3646
  return stampTestOrderPlan(result, normalizedPlan);
3572
3647
  };
@@ -3588,7 +3663,7 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
3588
3663
  }
3589
3664
  }
3590
3665
  page.setDefaultTimeout(numberArg(planArgs["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS));
3591
- events = captureCheckoutEvents(page);
3666
+ events = captureCheckoutEvents(page, tracking);
3592
3667
  // The outer race is the hard guarantee: whatever hangs inside the path,
3593
3668
  // this returns and the run writes a verdict instead of dying with nothing.
3594
3669
  orderDeadline = Date.now() + orderTimeoutMs;
@@ -3606,6 +3681,7 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
3606
3681
  deadline: orderDeadline,
3607
3682
  reserveOrderCreation,
3608
3683
  selectorProbeCache: options.selectorProbeCache,
3684
+ tracking,
3609
3685
  }),
3610
3686
  orderTimeoutMs + ORDER_TIMEOUT_GRACE_MS,
3611
3687
  `order-path:${planId(normalizedPlan)}`,
@@ -3684,7 +3760,7 @@ function stablePrivateCaptureError(value) {
3684
3760
  return projectAnalyticsCaptureError(value, { fallbackKind: "unreadable" });
3685
3761
  }
3686
3762
 
3687
- 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 }) {
3688
3764
  const stepTimeoutMs = numberArg(args["step-timeout-ms"], DEFAULT_STEP_TIMEOUT_MS);
3689
3765
  const budget = () => Math.min(stepTimeoutMs, deadline - Date.now());
3690
3766
  const hostedNow = () => hostedRedirectInfo(safePageUrl(page), checkoutPage.url);
@@ -3704,7 +3780,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3704
3780
  // page the probe left there rather than loading the same URL a second time,
3705
3781
  // which would fire the SDK's page-view events twice into the same capture.
3706
3782
  const entry = await ladder.run(CART_ENTRY_STEP, () => enterCartViaLanding({
3707
- page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache,
3783
+ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking,
3708
3784
  }), { timeoutMs: budget() });
3709
3785
  const enteredViaLanding = Boolean(entry && typeof entry === "object" && entry.entered);
3710
3786
  // Responses captured from here on belong to the checkout the ladder drives.
@@ -3712,7 +3788,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3712
3788
  // made before the hand-off.
3713
3789
  const checkoutResponseOffset = events.responses.length;
3714
3790
 
3715
- 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() });
3716
3792
  await ladder.run("selected_bundle", async () => {
3717
3793
  // The requested package was selected on the entry page, where the cards
3718
3794
  // live; checkout renders none, so re-running strict selection here would
@@ -3765,6 +3841,9 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3765
3841
  // runs BEFORE the reservation: nothing is clicked, nothing is spent, and
3766
3842
  // the classifier reads the failure as `not_created`.
3767
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);
3768
3847
  if (cartBeforeSubmit.empty) {
3769
3848
  throw codedError(CART_ENTRY_CODES.CART_EMPTY_BEFORE_SUBMIT, cartEmptyMessage(cartBeforeSubmit));
3770
3849
  }
@@ -3994,7 +4073,7 @@ function createSelectorProbeCache() {
3994
4073
  // loaded it and found a selection surface; either way a second load of the
3995
4074
  // same URL is what is avoided. Anywhere else (a cached probe answer skipped the
3996
4075
  // load; a fresh page) the checkout is opened here, once.
3997
- async function openCheckoutForPath({ page, checkoutPage, entry, args }) {
4076
+ async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking = null }) {
3998
4077
  const enteredViaLanding = Boolean(entry && typeof entry === "object" && entry.entered);
3999
4078
  if (enteredViaLanding) {
4000
4079
  await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
@@ -4003,14 +4082,14 @@ async function openCheckoutForPath({ page, checkoutPage, entry, args }) {
4003
4082
  if (checkoutUrlPredicate(checkoutPage.url)(safePageUrl(page))) {
4004
4083
  return "already on checkout from the selector probe; not re-opened";
4005
4084
  }
4006
- await gotoAndSettle(page, checkoutPage.url, args);
4085
+ await gotoAndSettle(page, checkoutPage.url, args, tracking);
4007
4086
  return null;
4008
4087
  }
4009
4088
 
4010
4089
  // The entry step body. Resolves to `{ skip }` when the checkout selects for
4011
4090
  // itself, to `{ entered: true, ... }` when the runner came in through the entry
4012
4091
  // page, and throws a coded error (never a bare timeout) when it cannot.
4013
- 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 }) {
4014
4093
  // Probe the checkout first: whether it carries a selection surface is a fact
4015
4094
  // about the rendered page, not about the spec (the spec cannot say it yet —
4016
4095
  // that is the design half of #206). The probe's load is the checkout's only
@@ -4027,7 +4106,7 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4027
4106
  let probe = cached ? "reused" : "loaded";
4028
4107
  let probeError = null;
4029
4108
  if (!surface) {
4030
- await gotoAndSettle(page, checkoutPage.url, args);
4109
+ await gotoAndSettle(page, checkoutPage.url, args, tracking);
4031
4110
  const probed = await page.evaluate(checkoutSelectionSurfaceScript())
4032
4111
  .then((value) => ({ value }), (error) => ({ value: null, error: error?.message || String(error) }));
4033
4112
  if (probed.value) {
@@ -4053,8 +4132,9 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4053
4132
  );
4054
4133
  }
4055
4134
 
4056
- await gotoAndSettle(page, entryPage.url, args);
4135
+ await gotoAndSettle(page, entryPage.url, args, tracking);
4057
4136
  const sdkReady = await waitForSdkReady(page, Math.min(budget(), DEFAULT_SETTLE_TIMEOUT_MS));
4137
+ await tracking?.readDocument(page);
4058
4138
  const controls = await page.evaluate(cartEntryControlsScript(), { selector: CART_ENTRY_CONTROL_SELECTOR, checkoutUrl: checkoutPage.url }).catch(() => []);
4059
4139
  const choice = chooseCartEntryControl(controls, selectedPackages);
4060
4140
  if (!choice.control) {
@@ -4348,8 +4428,11 @@ function assessReceiptRendering(persistedLineCount, evidence = {}) {
4348
4428
  };
4349
4429
  }
4350
4430
 
4351
- async function gotoAndSettle(page, url, args) {
4352
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS) });
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) });
4353
4436
  await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
4354
4437
  await page.waitForTimeout(750);
4355
4438
  }
@@ -5219,11 +5302,30 @@ async function clickControl(locator, { timeout, forceFallback = true, perpetual
5219
5302
  }
5220
5303
  }
5221
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
+
5222
5324
  async function clickUpsellPath(page, path, { trace = null } = {}) {
5223
5325
  const offerUrl = safePageUrl(page);
5224
5326
  const action = path === "accept" ? "add" : "skip";
5225
5327
  const selector = `[data-next-upsell-action="${action}"]`;
5226
- const control = page.locator(selector).first();
5328
+ const control = await shopperUpsellControl(page, action);
5227
5329
  if (!await control.count().catch(() => 0)) {
5228
5330
  return { path, clicked: false, error: `Missing upsell control ${selector}` };
5229
5331
  }
@@ -6197,7 +6299,7 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
6197
6299
  ...retry,
6198
6300
  ...creation,
6199
6301
  hosted_checkout_url: result.order?.hosted_checkout_url || null,
6200
- final_url: result.order?.final_url,
6302
+ final_url: redactUrlQuery(result.order?.final_url),
6201
6303
  steps: result.order?.evidence?.steps,
6202
6304
  note: "Hosted checkout flow is platform-owned; verify the hosted completion manually.",
6203
6305
  },
@@ -6244,13 +6346,13 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
6244
6346
  ...recovery,
6245
6347
  ref_id: result.order.ref_id,
6246
6348
  order_number: result.order.next_order_id,
6247
- final_url: result.order.final_url,
6349
+ final_url: redactUrlQuery(result.order.final_url),
6248
6350
  is_test: result.order.is_test,
6249
6351
  line_count: result.order.receipt_line_items.length,
6250
6352
  ...(receiptProofEvidence(result.order) ? { receipt_proof: receiptProofEvidence(result.order) } : {}),
6251
6353
  ...(path === "accept" ? { accepted_upsell_line_present: result.order.verification?.accepted_upsell_line_present } : {}),
6252
6354
  ...(upsellUnverified ? { upsell_unverified: upsellUnverified } : {}),
6253
- ...(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) } : {}),
6254
6356
  ...(result.order.upsell_steps ? { upsell_steps: result.order.upsell_steps.map(summarizeUpsellStep) } : {}),
6255
6357
  ...(result.order.verification?.accepted_upsell_matches ? { accepted_upsell_matches: result.order.verification.accepted_upsell_matches } : {}),
6256
6358
  ...(result.order.verification?.coupon ? { coupon: result.order.verification.coupon } : {}),
@@ -6261,7 +6363,7 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
6261
6363
  ...retry,
6262
6364
  ...creation,
6263
6365
  ...recovery,
6264
- final_url: result.order?.final_url,
6366
+ final_url: redactUrlQuery(result.order?.final_url),
6265
6367
  steps: result.order?.evidence?.steps,
6266
6368
  ...(receiptProofEvidence(result.order) ? { receipt_proof: receiptProofEvidence(result.order) } : {}),
6267
6369
  ...(result.order?.verification?.coupon ? { coupon: result.order.verification.coupon } : {}),
@@ -6338,11 +6440,20 @@ function responseRequestStartedAt(response) {
6338
6440
  }
6339
6441
  }
6340
6442
 
6341
- function captureCheckoutEvents(page) {
6443
+ function captureCheckoutEvents(page, tracking = null) {
6342
6444
  const events = { requests: [], responses: [], failed: [], console: [], pageErrors: [], navigations: [] };
6343
6445
  const interesting = /\/api\/v1\/(?:orders|upsells|carts)\/?|\/transactions|spreedly|campaigns\.apps/i;
6446
+ const isMainFrame = (frame) => typeof page.mainFrame !== "function" || frame === page.mainFrame();
6344
6447
  page.on("request", (request) => {
6345
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
+ }
6346
6457
  events.requests.push({
6347
6458
  method: request.method(),
6348
6459
  url: request.url(),
@@ -6360,15 +6471,47 @@ function captureCheckoutEvents(page) {
6360
6471
  const request = responseRequest(response);
6361
6472
  let chainUrl = null;
6362
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
+ }
6363
6493
  if (!interesting.test(response.url()) && !(chainUrl && interesting.test(chainUrl))) return;
6364
6494
  // Taken before the body read: an entry lands in the log when its body
6365
6495
  // finishes, so its position says nothing about when it was requested.
6366
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
+ }
6367
6510
  const entry = {
6368
6511
  status: response.status(),
6369
6512
  url: response.url(),
6370
6513
  request_started_at: requestStartedAt,
6371
- body: await readJsonResponseBodyWhenLoaded(response),
6514
+ body: await readBody(),
6372
6515
  };
6373
6516
  if (request) entry[REQUEST_IDENTITY] = request;
6374
6517
  events.responses.push(entry);
@@ -6385,6 +6528,42 @@ function captureCheckoutEvents(page) {
6385
6528
  // Navigation evidence is diagnostic only; never break the order path.
6386
6529
  }
6387
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
+ }
6388
6567
  page.on("console", (message) => {
6389
6568
  if (["error", "warning"].includes(message.type())) events.console.push({ type: message.type(), text: trim(message.text()) });
6390
6569
  });
@@ -6392,6 +6571,58 @@ function captureCheckoutEvents(page) {
6392
6571
  return events;
6393
6572
  }
6394
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
+
6395
6626
  // Playwright's response.text() waits for the body to finish loading. A page
6396
6627
  // that navigates away as soon as the headers land (an SDK that reads only the
6397
6628
  // status before redirecting) can leave that wait pending forever. Only a
@@ -6457,21 +6688,24 @@ function lastJsonResponse(events, pattern) {
6457
6688
  return null;
6458
6689
  }
6459
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.
6460
6693
  function sanitizedEvents(events) {
6461
- return {
6462
- requests: events.requests.slice(-20),
6694
+ return redactPersisted({
6695
+ requests: events.requests.slice(-20).map((request) => ({ ...request, url: redactUrlQuery(request.url) })),
6463
6696
  responses: events.responses.slice(-20).map((response) => ({
6464
6697
  status: response.status,
6465
- url: response.url,
6698
+ url: redactUrlQuery(response.url),
6466
6699
  body: summarizeResponseBody(response.body, { status: response.status }),
6467
6700
  })),
6468
- failed: events.failed.slice(-20),
6469
- console: events.console.slice(-20),
6470
- pageErrors: events.pageErrors.slice(-20),
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)),
6471
6705
  navigations: (events.navigations || []).slice(-20).map((navigation) => ({
6472
6706
  url: redactUrlQuery(navigation.url),
6473
6707
  })),
6474
- };
6708
+ });
6475
6709
  }
6476
6710
 
6477
6711
  function summarizeRequestPostData(value) {
@@ -6490,23 +6724,26 @@ function summarizeRequestPostData(value) {
6490
6724
  }
6491
6725
  }
6492
6726
 
6727
+ const RESPONSE_BODY_MARKER = "[redacted-response-body]";
6728
+
6493
6729
  function summarizeResponseBody(body, { status = null } = {}) {
6494
- if (typeof body === "string") return trim(body).slice(0, 1000);
6495
- if (!body || typeof body !== "object" || Array.isArray(body)) return body;
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;
6496
6733
  return {
6497
6734
  ...(body.number ? { number: body.number } : {}),
6498
6735
  ...(body.ref_id ? { ref_id: body.ref_id } : {}),
6499
6736
  ...(body.is_test !== undefined ? { is_test: body.is_test } : {}),
6500
6737
  ...(body.total_incl_tax ? { total_incl_tax: body.total_incl_tax } : {}),
6501
6738
  ...(body.currency ? { currency: body.currency } : {}),
6502
- ...(body.checkout_url ? { checkout_url: body.checkout_url } : {}),
6739
+ ...(body.checkout_url ? { checkout_url: redactUrlQuery(body.checkout_url) } : {}),
6503
6740
  ...(Array.isArray(body.lines) ? { lines: extractReceiptLines(body) } : {}),
6504
- ...(body.detail ? { detail: body.detail } : {}),
6741
+ ...(body.detail ? { detail: redactPersisted(body.detail) } : {}),
6505
6742
  // A rejected request names its reason here (for example the
6506
6743
  // duplicate-order refusal). Kept only on an error response, so a
6507
6744
  // successful create never carries payment_details into evidence.
6508
6745
  ...(Number(status) >= 400 && typeof body.payment_details === "string"
6509
- ? { payment_details: trim(body.payment_details).slice(0, 300) }
6746
+ ? { payment_details: redactUrlQueriesInText(trim(body.payment_details).slice(0, 300)) }
6510
6747
  : {}),
6511
6748
  };
6512
6749
  }
@@ -6750,7 +6987,7 @@ function specTierPlans(topologies, args, variant, { warn = (line) => process.std
6750
6987
  const declaredTiers = declaredSelectorTiers(checkoutPage);
6751
6988
  const bumps = declaredOrderBumps(checkoutPage);
6752
6989
  if (bumps.length) {
6753
- 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.`);
6754
6991
  }
6755
6992
  for (const tier of declaredTiers) declaredIdentities.add(selectorTierIdentity(tier));
6756
6993
  for (const ref of bumps) bumpRefs.add(ref);
@@ -6830,7 +7067,7 @@ function specTierPlans(topologies, args, variant, { warn = (line) => process.std
6830
7067
  const namedBumps = unmatched.filter((identity) => bumpRefs.has(identity.split(":")[0]));
6831
7068
  throw new Error([
6832
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(", ")}` : ""}).`,
6833
- ...(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.`] : []),
6834
7071
  "Name declared tiers only, as ref or ref:qty, or drop --select-package to iterate every tier.",
6835
7072
  ].join(" "));
6836
7073
  }
@@ -6874,59 +7111,6 @@ function selectorTierNarrowing(value) {
6874
7111
  return identities.length ? new Set(identities) : null;
6875
7112
  }
6876
7113
 
6877
- // Order bumps declared on the checkout page (`is_upsell: true` rows — the
6878
- // same marker the commercial-journey planner reads). They are add-ons to a
6879
- // selected tier, not tiers, so the tier planner reports and skips them.
6880
- function declaredOrderBumps(checkoutPage) {
6881
- const refs = [];
6882
- for (const pkg of Array.isArray(checkoutPage?.packages) ? checkoutPage.packages : []) {
6883
- if (!pkg || typeof pkg !== "object" || !isBumpRow(pkg)) continue;
6884
- const ref = [pkg.ref_id, pkg.package_id, pkg.id]
6885
- .map((value) => (value == null ? "" : String(value).trim()))
6886
- .find(Boolean);
6887
- if (ref && !refs.includes(ref)) refs.push(ref);
6888
- }
6889
- return refs;
6890
- }
6891
-
6892
- // Selector tiers are the packages the spec declares on the checkout page —
6893
- // same ref tolerance as the doctor's specPackageRecords (ref_id/package_id/id).
6894
- // Order-bump rows (`is_upsell: true`) are add-ons offered alongside the
6895
- // selected tier, not tiers of their own: they never become a plan.
6896
- function declaredSelectorTiers(checkoutPage) {
6897
- const records = [];
6898
- const quantitiesByRef = new Map();
6899
- for (const pkg of Array.isArray(checkoutPage?.packages) ? checkoutPage.packages : []) {
6900
- if (!pkg || typeof pkg !== "object" || isBumpRow(pkg)) continue;
6901
- const ref = [pkg.ref_id, pkg.package_id, pkg.id]
6902
- .map((value) => (value == null ? "" : String(value).trim()))
6903
- .find(Boolean);
6904
- const declaredQuantity = Number(pkg.qty ?? pkg.quantity ?? 1);
6905
- if (!ref || !Number.isInteger(declaredQuantity) || declaredQuantity < 1) continue;
6906
- records.push({ pkg, ref, declaredQuantity });
6907
- if (!quantitiesByRef.has(ref)) quantitiesByRef.set(ref, new Set());
6908
- quantitiesByRef.get(ref).add(declaredQuantity);
6909
- }
6910
-
6911
- const tiers = [];
6912
- const seen = new Set();
6913
- for (const { pkg, ref, declaredQuantity } of records) {
6914
- // A unique ref is a catalog package bought once, even when that package's
6915
- // own composition is 3x. Only repeated declarations of the SAME ref at
6916
- // different quantities express shopper purchase multipliers (Keer 1x/2x).
6917
- const quantity = quantitiesByRef.get(ref).size > 1 ? declaredQuantity : 1;
6918
- const identity = `${ref}:${quantity}`;
6919
- if (seen.has(identity)) continue;
6920
- seen.add(identity);
6921
- tiers.push({
6922
- ref,
6923
- quantity,
6924
- declared_by: stringArg(pkg.name) || stringArg(pkg.title) || undefined,
6925
- });
6926
- }
6927
- return tiers;
6928
- }
6929
-
6930
7114
  // Declared coupons follow the repo's offer-surface rule (build-brief, cli
6931
7115
  // exit-pop gates): a surface counts only when `enabled === true` and it maps
6932
7116
  // an offer_code. Both surfaces mapping the same code collapse into one plan.
@@ -7678,7 +7862,7 @@ function summarizeUpsellStep(step) {
7678
7862
  return {
7679
7863
  path: step.path,
7680
7864
  clicked: step.clicked,
7681
- final_url: step.final_url,
7865
+ final_url: redactUrlQuery(step.final_url),
7682
7866
  expected_items: step.expected_items,
7683
7867
  api_response_seen: step.api_response_seen,
7684
7868
  api_response_status: step.api_response_status,
@@ -7721,6 +7905,30 @@ function withQueryParam(value, key, paramValue) {
7721
7905
  }
7722
7906
  }
7723
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
+
7724
7932
  function findPage(topologies, type) {
7725
7933
  for (const topology of topologies || []) {
7726
7934
  const page = (topology.pages || []).find((candidate) => candidate.page_type === type);
@@ -7929,6 +8137,10 @@ export const __qaBrowserTestHooks = Object.freeze({
7929
8137
  createOrderCreationBudget,
7930
8138
  dispatchTestOrderPlans,
7931
8139
  recoverCreatedOrder,
8140
+ persistedTestOrder,
8141
+ sanitizedEvents,
8142
+ summarizeResponseBody,
8143
+ attachCreateResponseTap,
7932
8144
  extractReceiptLines,
7933
8145
  EXIT_INTENT_SURFACE_SELECTORS,
7934
8146
  COUPON_INPUT_SELECTORS,