@nextcommerce/campaigns-os 1.60.0 → 1.62.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 (161) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +20 -0
  5. package/agents/codex/AGENTS.md +19 -0
  6. package/agents/copilot/copilot-instructions.md +19 -0
  7. package/agents/cursor/campaigns-os.mdc +21 -0
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
  18. package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
  19. package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
  20. package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
  21. package/campaign-spec/dist/rules/index.d.ts +2 -0
  22. package/campaign-spec/dist/rules/index.js +6 -0
  23. package/campaign-spec/dist/types.d.ts +43 -9
  24. package/compatibility.json +1 -1
  25. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  26. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  27. package/contracts/commerce-surface-catalog.json +68 -61
  28. package/contracts/effects.v1.json +549 -165
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  44. package/contracts/release-ledger.json +1767 -0
  45. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  46. package/contracts/supported-surface.json +10 -10
  47. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  48. package/docs/access-model.md +20 -0
  49. package/docs/brand-theme-bridge.md +62 -3
  50. package/docs/build-packet.md +456 -36
  51. package/docs/campaign-build-brief.md +16 -0
  52. package/docs/campaign-standardization-report.md +4 -4
  53. package/docs/campaigns-os-build-flow.md +112 -2
  54. package/docs/campaignspec-authoring-examples.md +141 -0
  55. package/docs/design-source-package.md +64 -8
  56. package/docs/effects.md +17 -9
  57. package/docs/entry-points.md +188 -0
  58. package/docs/local-setup.md +1 -1
  59. package/docs/orientation-contract-reference.md +1 -1
  60. package/docs/polish-evidence.md +42 -2
  61. package/docs/progress-snapshots.md +7 -4
  62. package/docs/qa-and-test-orders.md +197 -3
  63. package/docs/quickstart.md +438 -0
  64. package/docs/readback.md +154 -6
  65. package/docs/runtime-readiness.md +6 -3
  66. package/docs/sdk-storage-compatibility.md +4 -2
  67. package/docs/skills-revision.md +10 -10
  68. package/docs/source-adapters.md +213 -0
  69. package/package.json +10 -3
  70. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  71. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  72. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  73. package/schemas/campaign-spec.v4.schema.json +19 -3
  74. package/schemas/campaign-spec.v5.schema.json +19 -3
  75. package/schemas/campaigns-os-readback.v2.schema.json +238 -1
  76. package/schemas/source-html-manifest.v0.schema.json +3 -0
  77. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  78. package/skills/campaign-readback-classification/SKILL.md +3 -3
  79. package/skills/campaign-run-evidence/SKILL.md +3 -3
  80. package/skills/contribution-intake/SKILL.md +3 -3
  81. package/skills/next-campaigns-build/SKILL.md +5 -4
  82. package/skills/next-campaigns-os/SKILL.md +7 -4
  83. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  84. package/skills/next-campaigns-polish/SKILL.md +8 -10
  85. package/skills/next-campaigns-qa/SKILL.md +7 -4
  86. package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
  87. package/skills.json +11 -11
  88. package/src/brand-theme.mjs +415 -9
  89. package/src/brief-summary.mjs +6 -2
  90. package/src/browser-launch.mjs +2 -1
  91. package/src/build-brief.mjs +13 -3
  92. package/src/built-site-scope.mjs +2 -2
  93. package/src/campaign-workspace.mjs +288 -15
  94. package/src/change-scope.mjs +284 -0
  95. package/src/cli.mjs +358 -97
  96. package/src/commercial-journey.mjs +16 -3
  97. package/src/commercial-parity.mjs +15 -7
  98. package/src/content-residue.mjs +4 -2
  99. package/src/design-file-reference.mjs +93 -0
  100. package/src/design-source-package.mjs +91 -1
  101. package/src/design-source-publication.mjs +9 -6
  102. package/src/directory-lock.mjs +73 -19
  103. package/src/doctor/checks.mjs +87 -81
  104. package/src/doctor/ci.mjs +177 -0
  105. package/src/doctor/inspect.mjs +41 -6
  106. package/src/doctor/next-step.mjs +7 -0
  107. package/src/doctor/source-provenance.mjs +74 -0
  108. package/src/doctor-sidecar.mjs +3 -2
  109. package/src/evidence-debt.mjs +159 -0
  110. package/src/finding-cause.mjs +12 -11
  111. package/src/input-refresh.mjs +339 -32
  112. package/src/invocation.mjs +7 -1
  113. package/src/live-campaign-refs.mjs +7 -2
  114. package/src/local-preview-policy.mjs +35 -2
  115. package/src/page-kit-analytics-vendors.mjs +132 -0
  116. package/src/parity/compare.mjs +316 -0
  117. package/src/parity/design-access.mjs +54 -0
  118. package/src/parity/element-parity-record.mjs +569 -0
  119. package/src/parity/probe.mjs +205 -0
  120. package/src/parity/snapshot.mjs +110 -0
  121. package/src/png-header.mjs +87 -0
  122. package/src/polish-browser.mjs +189 -10
  123. package/src/polish-captures-record.mjs +52 -0
  124. package/src/polish-deadline.mjs +14 -0
  125. package/src/polish-gate.mjs +25 -6
  126. package/src/polish-node.mjs +81 -7
  127. package/src/polish-readability.mjs +221 -9
  128. package/src/polish-visual-fixtures.mjs +74 -0
  129. package/src/polish-visual.mjs +613 -0
  130. package/src/progress-node.mjs +3 -2
  131. package/src/qa-analytics-correctness.mjs +14 -2
  132. package/src/qa-analytics-parity.mjs +111 -6
  133. package/src/qa-browser.mjs +40 -4
  134. package/src/qa-commercial-parity.mjs +50 -3
  135. package/src/qa-node.mjs +235 -22
  136. package/src/qa-publish.mjs +9 -8
  137. package/src/qa-sidecar.mjs +16 -4
  138. package/src/qa-vendor-beacons.mjs +232 -0
  139. package/src/qa-vendor-endpoints.mjs +306 -0
  140. package/src/qc-accept.mjs +176 -6
  141. package/src/qc-check-registry.mjs +42 -2
  142. package/src/qc-results.mjs +477 -42
  143. package/src/readback.mjs +113 -21
  144. package/src/route-identity.mjs +10 -0
  145. package/src/run-record.mjs +18 -10
  146. package/src/runtime-state-ignore.mjs +6 -2
  147. package/src/sdk-attribute-index.mjs +11 -11
  148. package/src/sdk-markup.mjs +77 -2
  149. package/src/sdk-storage-compatibility.mjs +115 -6
  150. package/src/source-html-intake.mjs +15 -1
  151. package/src/source-html-manifest.mjs +7 -0
  152. package/src/source-html-server.mjs +129 -0
  153. package/src/spec-derive-campaign.mjs +216 -0
  154. package/src/spec-derive.mjs +473 -53
  155. package/src/spec-identity.mjs +14 -0
  156. package/src/stage-ledger.mjs +40 -1
  157. package/src/stage-record.mjs +100 -17
  158. package/src/target-lock.mjs +11 -1
  159. package/src/theme-gate.mjs +2 -2
  160. package/src/visual-diff.mjs +296 -0
  161. package/src/visual-reference.mjs +126 -0
@@ -97,7 +97,7 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
97
97
  }
98
98
  function lock(dir,fn,{budgetMs=1500}={}) {
99
99
  const lockPath=join(dir,'.allocation-lock');
100
- return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:(error)=>Object.assign(new Error('progress.lock_unavailable'),{lockPath,code:error?.code,obstruction:error?.obstruction})});
100
+ return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:(error)=>Object.assign(new Error('progress.lock_unavailable'),{lockPath,code:error?.code,obstruction:error?.obstruction,holderPid:error?.holderPid})});
101
101
  }
102
102
  export async function persistProgressObservation(observation,{dir,now=()=>new Date(),historyLimit=32}={}) {
103
103
  mkdirSync(dir,{recursive:true,mode:0o700});
@@ -165,7 +165,8 @@ export async function observeProgress(args,continuation,{qaResult=null,packageVe
165
165
  if(remit.state==='failed')warn('[campaigns-os] Progress delivery pending; the local observation is retained. Lifecycle result is unchanged.');
166
166
  return {...remit,snapshot_id:snapshot.snapshot_id,reused};
167
167
  } catch(error) {
168
- if(error?.message==='progress.lock_unavailable'&&error.obstruction)warn(`[campaigns-os] Progress allocation lock path ${error.lockPath} is occupied by ${error.obstruction}; it is never removed automatically. Move it out of the way. Lifecycle result is unchanged.`);
168
+ if(error?.message==='progress.lock_unavailable'&&error.code==='ELOCKFORMAT')warn(`[campaigns-os] Progress allocation lock at ${error.lockPath} is held by process ${error.holderPid} of a different campaigns-os release; capture stops instead of waiting. Run one campaigns-os release against this target at a time. Lifecycle result is unchanged.`);
169
+ else if(error?.message==='progress.lock_unavailable'&&error.obstruction)warn(`[campaigns-os] Progress allocation lock path ${error.lockPath} is occupied by ${error.obstruction}; it is never removed automatically. Move it out of the way. Lifecycle result is unchanged.`);
169
170
  else if(error?.message==='progress.lock_unavailable')warn(`[campaigns-os] Progress allocation lock occupied${error.lockPath?` at ${error.lockPath}`:''}; wait for the current writer. If it stays occupied (an abandoned, ownerless or interrupted lock), stop all campaigns-os writers for this target, then remove that lock directory (docs/progress-snapshots.md). Lifecycle result is unchanged.`);
170
171
  else warn('[campaigns-os] Progress observation unavailable; lifecycle result is unchanged.');
171
172
  return {state:'failed',reason:'capture_unavailable'};
@@ -114,13 +114,25 @@ export function assessAnalyticsInventory(capture = {}, contract = {}, options =
114
114
  if (providers.facebook && providers.facebook.enabled !== false) {
115
115
  const id = providers.facebook.pixelId;
116
116
  const present = inventoryHas(inventory, "meta", id);
117
+ // #686: page code replaced window.fbq, so in-page calls after that were
118
+ // not observed. Without a matching pixel request the row is unmeasured,
119
+ // not a clean absence.
120
+ const unmeasured = !present && capture.metaObserver === "disarmed";
117
121
  assertions.push(emit({
118
122
  id: "analytics-correctness:tag:meta",
119
123
  status: present ? STATUS.PASS : STATUS.FAIL,
120
124
  severity: SEVERITY.BLOCKER,
121
125
  expected: `Meta pixel ${id || ""} fires on the page`.trim(),
122
- actual: present ? "present" : `absent (${(inventory.meta || []).length} meta pixel(s) fired, none matching)`,
123
- evidence: { declared: id || null, observed_count: (inventory.meta || []).length },
126
+ actual: present
127
+ ? "present"
128
+ : unmeasured
129
+ ? `unmeasured: the page replaced window.fbq, so in-page pixel calls could not be observed (${(inventory.meta || []).length} meta pixel(s) seen, none matching)`
130
+ : `absent (${(inventory.meta || []).length} meta pixel(s) fired, none matching)`,
131
+ evidence: {
132
+ declared: id || null,
133
+ observed_count: (inventory.meta || []).length,
134
+ ...(unmeasured ? { reason: "fbq_observer_disarmed" } : {}),
135
+ },
124
136
  }));
125
137
  }
126
138
 
@@ -27,6 +27,7 @@
27
27
 
28
28
  import { SEVERITY, STATUS } from "./qa-verdict.mjs";
29
29
  import { redactUrlQuery } from "./qa-url-privacy.mjs";
30
+ import { readVendorBeacon, VENDOR_KEYS, vendorBeaconFor } from "./qa-vendor-endpoints.mjs";
30
31
 
31
32
  // Data layers the SDK and legacy funnels push through. The SDK's GTMAdapter
32
33
  // mirrors every `dl_*` event to window.dataLayer AND window.ElevarDataLayer;
@@ -107,6 +108,53 @@ export function analyticsInitScript(layers = HOOKED_DATA_LAYERS) {
107
108
  set(next) { backing = wrap(name, next || []); },
108
109
  });
109
110
  }
111
+ // Meta pixel calls (#686). Headless Chromium can run fbq init/track without
112
+ // fbevents.js ever requesting facebook.com/tr, so the pixel is also observed
113
+ // in-page: a pixel id counts once it is initialised and a track call follows
114
+ // in the same document. Only the id and event name leave the page.
115
+ const metaInits = [];
116
+ // Mirrored into the in-page store as well, like record(), for pages where
117
+ // the binding could not attach.
118
+ store.metaCalls = [];
119
+ const metaFire = (id, ev) => {
120
+ const call = { id: String(id), ev: ev ? String(ev) : null };
121
+ store.metaCalls.push(call);
122
+ emit({ type: "meta_call", call, document: documentState });
123
+ };
124
+ const pixelId = (value) => (typeof value === "string" || typeof value === "number") && String(value) ? String(value) : null;
125
+ const recordFbq = (args) => {
126
+ try {
127
+ const method = String(args[0] || "");
128
+ // fbq('init', pixelId[, userData]): the pixel id is always args[1].
129
+ if (method === "init" && pixelId(args[1])) {
130
+ const id = pixelId(args[1]);
131
+ if (!metaInits.includes(id)) metaInits.push(id);
132
+ } else if (method === "track" || method === "trackCustom") {
133
+ for (const id of metaInits) metaFire(id, args[1]);
134
+ } else if ((method === "trackSingle" || method === "trackSingleCustom") && metaInits.includes(pixelId(args[1]))) {
135
+ metaFire(args[1], args[2]);
136
+ }
137
+ } catch (_) {}
138
+ };
139
+ const wrapFbq = (fn) => {
140
+ if (typeof fn !== "function" || fn.__nextQaWrapped || typeof Proxy !== "function") return fn;
141
+ // A Proxy keeps fbq's own properties (queue, callMethod, loaded) on the
142
+ // real function, so fbevents.js sees the object it installed.
143
+ return new Proxy(fn, {
144
+ apply(target, self, args) { recordFbq(args); return Reflect.apply(target, self, args); },
145
+ get(target, key, receiver) { return key === "__nextQaWrapped" ? true : Reflect.get(target, key, receiver); },
146
+ });
147
+ };
148
+ let fbqBacking = wrapFbq(window.fbq);
149
+ // The getter is marked so a read can tell this accessor from one page code
150
+ // installed, or from a deleted property.
151
+ const fbqGet = function () { return fbqBacking; };
152
+ Object.defineProperty(fbqGet, "__nextQaFbqAccessor", { value: true });
153
+ Object.defineProperty(window, "fbq", {
154
+ configurable: true,
155
+ get: fbqGet,
156
+ set(next) { fbqBacking = wrapFbq(next); },
157
+ });
110
158
  })();`;
111
159
  }
112
160
 
@@ -209,6 +257,18 @@ export async function attachAnalyticsCapture(page, options = {}) {
209
257
  }
210
258
  return;
211
259
  }
260
+ if (payload?.type === "meta_call") {
261
+ // An in-page fbq call is pixel inventory only. Its event name stays
262
+ // out of `params`, so it never counts as a Meta Purchase fire or
263
+ // supplies a CAPI dedup key; those still need the /tr request.
264
+ const id = payload.call?.id;
265
+ if (!id) return;
266
+ const document = payload.document
267
+ ? lookupDocument(payload.document, { fallbackUrl: sourceUrl, mainFrame })
268
+ : (mainFrame ? activeMainDocument : null) || lookupDocument({}, { fallbackUrl: sourceUrl, mainFrame });
269
+ tagFires.push({ kind: "meta", id: String(id), host: null, params: {}, source: "fbq", event: payload.call?.ev || null, document });
270
+ return;
271
+ }
212
272
  const entry = payload?.type === "event" ? payload.entry : payload;
213
273
  const attributed = attributeEvent(entry, { mainFrame, fallbackUrl: sourceUrl });
214
274
  if (attributed) accumulatedEvents.push(attributed);
@@ -221,6 +281,12 @@ export async function attachAnalyticsCapture(page, options = {}) {
221
281
  const url = typeof request.url === "function" ? request.url() : request.url;
222
282
  const fire = classifyTagFire(url, extraHosts);
223
283
  if (!fire) return;
284
+ // A listed vendor beacon can carry its id in the body (Snap, TikTok).
285
+ if (!fire.id) {
286
+ let body = null;
287
+ try { body = typeof request.postData === "function" ? request.postData() : null; } catch { /* no body */ }
288
+ fire.id = readVendorBeacon(url, body)?.ids[0] || null;
289
+ }
224
290
  let documentUrl = safePageUrl();
225
291
  try {
226
292
  let frame = typeof request.frame === "function" ? request.frame() : null;
@@ -243,6 +309,19 @@ export async function attachAnalyticsCapture(page, options = {}) {
243
309
  try {
244
310
  const raw = await page.evaluate(() => ({
245
311
  events: window.__nextQaAnalytics?.events || [],
312
+ metaCalls: window.__nextQaAnalytics?.metaCalls || [],
313
+ // Whether in-page fbq calls were still observable when read: page code
314
+ // that redefines window.fbq removes the wrapper (#686).
315
+ // Deleting or redefining the property disarms it too; "absent" means
316
+ // the QA accessor is still installed and no pixel was ever set.
317
+ metaObserver: (() => {
318
+ if (!window.__nextQaAnalytics) return null;
319
+ const descriptor = Object.getOwnPropertyDescriptor(window, "fbq");
320
+ if (!descriptor || !descriptor.get || descriptor.get.__nextQaFbqAccessor !== true) return "disarmed";
321
+ const fbq = window.fbq;
322
+ if (fbq === undefined || fbq === null) return "absent";
323
+ return fbq.__nextQaWrapped === true ? "armed" : "disarmed";
324
+ })(),
246
325
  document: window.__nextQaAnalytics?.document || { url: String(location.href || "") },
247
326
  }));
248
327
  snapshot = Array.isArray(raw) ? { events: raw, document: null } : (raw || snapshot);
@@ -272,18 +351,28 @@ export async function attachAnalyticsCapture(page, options = {}) {
272
351
  const journeyEvents = bindingAttached
273
352
  ? [...accumulatedEvents, ...snapshotEvents]
274
353
  : snapshotEvents;
354
+ // Without the binding, in-page fbq calls come only from this document's
355
+ // store; with it, they are already in tagFires.
356
+ const snapshotMetaFires = !bindingAttached && Array.isArray(snapshot.metaCalls)
357
+ ? snapshot.metaCalls.filter((call) => call?.id).map((call) => ({
358
+ kind: "meta", id: String(call.id), host: null, params: {}, source: "fbq", event: call.ev || null, document: snapshotDocument,
359
+ }))
360
+ : [];
361
+ const journeyTagFires = [...tagFires, ...snapshotMetaFires];
275
362
  const currentEvents = journeyEvents.filter(
276
363
  (entry) => entry.document?.mainFrame === true
277
364
  && entry.document.generation === currentDocument.generation,
278
365
  );
279
- const currentTagFires = tagFires.filter(
366
+ const currentTagFires = journeyTagFires.filter(
280
367
  (fire) => fire.document?.mainFrame === true
281
368
  && fire.document.generation === currentDocument.generation,
282
369
  );
370
+ const metaObserver = typeof snapshot.metaObserver === "string" ? snapshot.metaObserver : null;
283
371
  return {
284
- journey: normalizeCapture({ events: journeyEvents, tagFires }),
372
+ journey: { ...normalizeCapture({ events: journeyEvents, tagFires: journeyTagFires }), metaObserver },
285
373
  currentDocument: {
286
374
  ...normalizeCapture({ events: currentEvents, tagFires: currentTagFires }),
375
+ metaObserver,
287
376
  document: publicDocument(currentDocument),
288
377
  },
289
378
  };
@@ -338,6 +427,14 @@ export function classifyTagFire(urlString, extraHostSubstrings = []) {
338
427
  const fire = (kind, id) => ({ kind, id: id ? String(id) : null, host, params });
339
428
  const subs = [...DEFAULT_EXTRA_ANALYTICS_HOST_SUBSTRINGS, ...extraHostSubstrings];
340
429
 
430
+ // A listed vendor beacon (qa-vendor-endpoints.mjs) is that vendor's fire,
431
+ // answered locally or not, with the id from its query or path when it has one.
432
+ const beacon = vendorBeaconFor(url.href);
433
+ if (beacon) {
434
+ const pathId = beacon.idPath ? path.match(beacon.idPath)?.[1] : null;
435
+ return fire(beacon.vendor, pathId || (beacon.id && !beacon.id.includes(".") ? params[beacon.id] : null) || null);
436
+ }
437
+
341
438
  if (hostMatches(host, "googletagmanager.com")) {
342
439
  const id = params.id || null;
343
440
  if (id && /^GTM-/i.test(id)) return fire("gtm", id);
@@ -457,13 +554,17 @@ export function normalizeCapture({ events = [], tagFires = [] } = {}) {
457
554
  }
458
555
 
459
556
  const inventory = { gtm: [], ga4: [], google_ads: [], meta: [], tiktok: [], everflow: [], other: [] };
557
+ for (const vendor of VENDOR_KEYS) inventory[vendor] ||= [];
558
+ // Pixel ids seen on a facebook.com request, apart from in-page fbq calls.
559
+ const metaRequestIds = [];
460
560
  let metaPurchaseEventId = null;
461
561
  let metaPurchaseFired = false;
462
562
  let ga4PurchaseFired = false;
463
563
  for (const fire of tagFires) {
464
564
  if (!fire || !inventory[fire.kind]) continue;
465
565
  if (fire.id && !inventory[fire.kind].includes(fire.id)) inventory[fire.kind].push(fire.id);
466
- if (fire.kind === "meta") {
566
+ if (fire.kind === "meta" && fire.source !== "fbq") {
567
+ if (fire.id && !metaRequestIds.includes(fire.id)) metaRequestIds.push(fire.id);
467
568
  const ev = fire.params?.ev || fire.params?.event;
468
569
  if (ev && String(ev).toLowerCase() === "purchase") {
469
570
  metaPurchaseFired = true;
@@ -483,6 +584,7 @@ export function normalizeCapture({ events = [], tagFires = [] } = {}) {
483
584
  purchase: purchase ? { present: true, ...purchase } : { present: false },
484
585
  purchasesByEvent,
485
586
  inventory,
587
+ metaRequestIds,
486
588
  metaPurchaseEventId,
487
589
  // Purchase can fire from any of three sources (dataLayer event, Meta pixel,
488
590
  // GA4) — campaigns that block the SDK event and fire Meta/GA4 manually still
@@ -680,8 +782,11 @@ export function diffAnalyticsParity(baseline, candidate, options = {}) {
680
782
 
681
783
  // 5. Meta CAPI dedup — candidate's Meta Purchase fire carries an eventID,
682
784
  // consistent with the order id so client + server events dedup.
683
- const baselineHasMeta = (b.inventory?.meta || []).length > 0;
684
- const candidateHasMeta = (c.inventory?.meta || []).length > 0;
785
+ // Keyed on pixel requests: an in-page fbq call with no /tr request has
786
+ // no eventID to compare (#686).
787
+ const metaRequests = (cap) => cap.metaRequestIds || cap.inventory?.meta || [];
788
+ const baselineHasMeta = metaRequests(b).length > 0;
789
+ const candidateHasMeta = metaRequests(c).length > 0;
685
790
  if (baselineHasMeta || candidateHasMeta) {
686
791
  const eid = c.metaPurchaseEventId;
687
792
  const consistent = eid && cp.transactionId && String(eid) === String(cp.transactionId);
@@ -698,7 +803,7 @@ export function diffAnalyticsParity(baseline, candidate, options = {}) {
698
803
 
699
804
  // 6. Carried-over tag regression — any baseline container/pixel absent on the
700
805
  // candidate is a WARN (likely attribution regression; human confirms drop).
701
- const kinds = ["gtm", "ga4", "google_ads", "meta", "tiktok", "everflow", "other"];
806
+ const kinds = [...new Set(["gtm", "ga4", "google_ads", "meta", "tiktok", "everflow", "other", ...VENDOR_KEYS])];
702
807
  for (const kind of kinds) {
703
808
  const baselineIds = (b.inventory?.[kind]) || [];
704
809
  const candidateIds = new Set((c.inventory?.[kind]) || []);
@@ -19,6 +19,7 @@ import { redactPersisted, redactUrlQueriesInText, redactUrlQuery } from "./qa-ur
19
19
  import { TRACKING_ADDED_BOUND_MS, TRACKING_OBSERVATION, createTrackingRun, trackingQaAssertion } from "./qa-tracking-params.mjs";
20
20
  import { CONTENT_PARAM_LIMITS, runContentParamChecks } from "./qa-content-params.mjs";
21
21
  import { createPolicyLinkBudget, hasPolicyLinkFields, readPageAnchors, runPolicyLinkChecks } from "./qa-policy-links.mjs";
22
+ import { vendorBeaconLedgerFor, withVendorBeaconRouting } from "./qa-vendor-beacons.mjs";
22
23
  import {
23
24
  canonicalHttpUrl,
24
25
  commonTestOrderPaths,
@@ -462,6 +463,8 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
462
463
  if (displayParity) assertions.push(displayParity);
463
464
  const totalParity = orderTotalParityAssertion(pageForPlan, identifier, result.order);
464
465
  if (totalParity) assertions.push(totalParity);
466
+ const currencyMatch = orderCurrencyAssertion(pageForPlan, identifier, result.order, args);
467
+ if (currencyMatch) assertions.push(currencyMatch);
465
468
  const renderedReceiptAssertion = receiptRenderingAssertion(pageForPlan, identifier, result.order);
466
469
  if (renderedReceiptAssertion) assertions.push(renderedReceiptAssertion);
467
470
  const dataLayerAssertion = purchaseDataLayerAssertion(pageForPlan, identifier, result.order);
@@ -4900,9 +4903,13 @@ export const QA_RUN_CURRENCY = Symbol("qa run --currency");
4900
4903
  // checkout opened directly. Campaign Cart keeps a URL currency in session
4901
4904
  // storage for the rest of the tab's session, so later loads in the tab are
4902
4905
  // the plain URL; the receipt reload in recovery is not a path load at all.
4903
- function pathCurrencyLoad(args) {
4906
+ function runCurrency(args) {
4904
4907
  const code = args?.[QA_RUN_CURRENCY];
4905
- let pending = typeof code === "string" && /^[A-Z]{3}$/.test(code) ? code : null;
4908
+ return typeof code === "string" && /^[A-Z]{3}$/.test(code) ? code : null;
4909
+ }
4910
+
4911
+ function pathCurrencyLoad(args) {
4912
+ let pending = runCurrency(args);
4906
4913
  return (url) => {
4907
4914
  if (!pending) return url;
4908
4915
  const entered = withQueryParam(url, "currency", pending);
@@ -6638,6 +6645,31 @@ function orderTotalParityAssertion(page, planIdentifier, order) {
6638
6645
  });
6639
6646
  }
6640
6647
 
6648
+ // `qa run --currency <code>`: a created order must have been placed in the
6649
+ // run currency. Campaign Cart falls back to the default currency when the
6650
+ // requested one is not available, so an order recorded in any other currency
6651
+ // blocks, and so does a verified order whose currency was not recorded. No run
6652
+ // currency, or no order body read at all (the order's own row already says
6653
+ // why), gives no row.
6654
+ function orderCurrencyAssertion(page, planIdentifier, order, args) {
6655
+ const expected = runCurrency(args);
6656
+ if (!expected) return null;
6657
+ const recorded = stringArg(order?.verification?.currency);
6658
+ if (!recorded && order?.verification?.verified !== true) return null;
6659
+ const actual = recorded ? recorded.toUpperCase() : null;
6660
+ const ok = actual === expected;
6661
+ return assertion({
6662
+ id: `browser-order-currency:${planIdentifier}`,
6663
+ family: "browser-test-order",
6664
+ page,
6665
+ status: ok ? STATUS.PASS : STATUS.FAIL,
6666
+ severity: ok ? undefined : SEVERITY.BLOCKER,
6667
+ expected: `the order is placed in the run currency ${expected}`,
6668
+ actual: actual ? `order placed in ${actual}` : "the order's currency was not recorded",
6669
+ evidence: { requested: expected, order_currency: actual },
6670
+ });
6671
+ }
6672
+
6641
6673
  // --- What a failed attempt did to the store ----------------------------------
6642
6674
  //
6643
6675
  // This replaces the old `shouldRetryTestOrder` boolean, which asked only whether
@@ -8873,8 +8905,11 @@ function trim(value) {
8873
8905
  return String(value || "").replace(/\s+/g, " ").trim();
8874
8906
  }
8875
8907
 
8876
- function launchChromium(args) {
8877
- return launchPackageChromium({ headed: args.headed, onMissing: qaBrowserMissing });
8908
+ // Every browser QA leg launches here, so on a test-order run every context it
8909
+ // opens answers the vendor beacon endpoints locally (qa-vendor-beacons.mjs).
8910
+ async function launchChromium(args) {
8911
+ const browser = await launchPackageChromium({ headed: args.headed, onMissing: qaBrowserMissing });
8912
+ return withVendorBeaconRouting(browser, vendorBeaconLedgerFor(args));
8878
8913
  }
8879
8914
 
8880
8915
  function qaBrowserMissing(kind, error) {
@@ -8893,6 +8928,7 @@ function qaBrowserMissing(kind, error) {
8893
8928
  }
8894
8929
 
8895
8930
  export const __qaBrowserTestHooks = Object.freeze({
8931
+ orderCurrencyAssertion,
8896
8932
  qaBrowserMissing,
8897
8933
  PRIMARY_CTA_SELECTOR,
8898
8934
  COUPON_INPUT_SELECTORS,
@@ -379,7 +379,9 @@ export async function mapConcurrent(values, limit, operation) {
379
379
  return results;
380
380
  }
381
381
 
382
- export function planCommercialParity(spec, { maxScenarios = COMMERCIAL_QA_LIMITS.max_scenarios } = {}) {
382
+ // `currency` is the run's currency (`qa run --currency`): every calculate
383
+ // request asks for it instead of the Map's campaign currency.
384
+ export function planCommercialParity(spec, { maxScenarios = COMMERCIAL_QA_LIMITS.max_scenarios, currency = null } = {}) {
383
385
  const limit = positiveLimit(maxScenarios, COMMERCIAL_QA_LIMITS.max_scenarios);
384
386
  const pages = commercialSpecPages(spec, { maxPages: limit + 1 });
385
387
  const plan = [];
@@ -391,7 +393,7 @@ export function planCommercialParity(spec, { maxScenarios = COMMERCIAL_QA_LIMITS
391
393
  break;
392
394
  }
393
395
  try {
394
- plan.push(...planScenarios(page, spec, { maxScenarios: remaining }));
396
+ plan.push(...planScenarios(page, spec, { maxScenarios: remaining, currency }));
395
397
  } catch (error) {
396
398
  if (!(error instanceof CommercialJourneyLimitError)) throw error;
397
399
  overflow = true;
@@ -502,6 +504,9 @@ function compactReport(report, journey, plan, executed, issues, observedClaims,
502
504
  extracted_voucher_claims: report.extracted_voucher_claims,
503
505
  compared_voucher_claims: report.compared_voucher_claims,
504
506
  unresolved_voucher_claims: report.unresolved_voucher_claims,
507
+ ...(present(report.unresolved_money_claims_reason)
508
+ ? { unresolved_money_claims_reason: report.unresolved_money_claims_reason }
509
+ : {}),
505
510
  serialized_assertion_count: report.serialized_assertion_count,
506
511
  omitted_assertion_count: report.omitted_assertion_count,
507
512
  recurring_claim_absences: array(report.recurring_claim_absences),
@@ -581,6 +586,31 @@ export function unavailableCommercialReport(code, { status = "not_run" } = {}) {
581
586
  };
582
587
  }
583
588
 
589
+ // One blocker row per currency the calculate endpoint answered in other than
590
+ // the run's: expected totals were asked for in `currency` and came back priced
591
+ // in another one. Answered codes are compared and named upper-cased, so `usd`
592
+ // and `USD` are one row. The rows are few (one per answered currency) and sit
593
+ // outside the assertion budget, so a full budget never hides the blocker.
594
+ function runCurrencyMismatchAssertions(journey, currency) {
595
+ const requested = String(currency).toUpperCase();
596
+ const answered = new Set();
597
+ array(journey?.warnings).forEach((warning) => {
598
+ if (warning?.type !== "currency_mismatch" || String(warning.requested).toUpperCase() !== requested) return;
599
+ const actual = String(warning.actual).toUpperCase();
600
+ if (actual !== requested) answered.add(actual);
601
+ });
602
+ return [...answered].map((actual) => ({
603
+ id: `commercial-currency:${requested}:${encodeURIComponent(actual)}`,
604
+ family: "pricing",
605
+ page: "campaign",
606
+ status: "fail",
607
+ severity: "blocker",
608
+ expected: `expected totals calculated in the run currency ${requested}`,
609
+ actual: `the calculate endpoint priced in ${actual}`,
610
+ evidence: { code: "currency_mismatch", requested, actual },
611
+ }));
612
+ }
613
+
584
614
  /**
585
615
  * Run the portable commercial planner against the public proxy contract and
586
616
  * serialize proven mismatches into ordinary QA assertions.
@@ -592,6 +622,7 @@ export async function runCommercialParity({
592
622
  limits: limitOverrides = {},
593
623
  planning = null,
594
624
  maxAssertions = Number.POSITIVE_INFINITY,
625
+ currency = null,
595
626
  } = {}) {
596
627
  const limits = {
597
628
  ...COMMERCIAL_QA_LIMITS,
@@ -603,6 +634,7 @@ export async function runCommercialParity({
603
634
  const planningSpec = resolved?.rawSpec || resolved?.spec || {};
604
635
  const commercialPlanning = planning || planCommercialParity(planningSpec, {
605
636
  maxScenarios: limits.max_scenarios,
637
+ currency,
606
638
  });
607
639
  const plan = commercialPlanning.plan;
608
640
  const scenarioOverflow = commercialPlanning.overflow || plan.length > limits.max_scenarios;
@@ -654,9 +686,24 @@ export async function runCommercialParity({
654
686
  const parityJourney = scenarioOverflow
655
687
  ? withScenarioOverflowPages(journey, commercialPlanning.pages)
656
688
  : journey;
689
+ // Under a run currency, a calculate response in another currency means the
690
+ // campaign does not price in it: a blocker, and the comparison below is
691
+ // against totals in the wrong currency, so the proof is incomplete. Without
692
+ // one, the journey's currency_mismatch warning stays where it is.
693
+ const currencyAssertions = present(currency) ? runCurrencyMismatchAssertions(journey, currency) : [];
694
+ currencyAssertions.forEach(() => issues.push({ code: "calculate_currency_mismatch" }));
695
+ // Captures read the page's source HTML, whose bound prices are the authored
696
+ // fallback text in the Map's campaign currency (the SDK replaces them at
697
+ // runtime). Under a run currency the Map does not name, those amounts cannot
698
+ // be compared with totals in the run currency: they stay unresolved, with
699
+ // the reason recorded, instead of reading as mismatches.
700
+ const sourceCurrency = planningSpec?.campaign?.currency ?? planningSpec?.currency;
701
+ const sourceNotInRunCurrency = present(currency)
702
+ && String(sourceCurrency ?? "").toUpperCase() !== String(currency).toUpperCase();
657
703
  const parity = createCommercialParityReport(captures, parityJourney, {
658
704
  maxAssertions,
659
705
  countsOnly: aggregateClaimOverflow,
706
+ ...(sourceNotInRunCurrency ? { unresolvedMoneyClaims: "static_source_not_in_run_currency" } : {}),
660
707
  });
661
708
  // Subscription packages a page renders with no recurring claim read for
662
709
  // them: incomplete evidence (an issue), and a warning naming the package.
@@ -669,7 +716,7 @@ export async function runCommercialParity({
669
716
  });
670
717
  parity.recurring_claim_absences = absences;
671
718
  return {
672
- assertions: [...parity.assertions, ...absentAssertions],
719
+ assertions: [...currencyAssertions, ...parity.assertions, ...absentAssertions],
673
720
  commercial: compactReport(
674
721
  parity,
675
722
  parityJourney,