@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.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +244 -0
- package/README.md +86 -438
- package/agents/claude/CLAUDE.md +20 -0
- package/agents/codex/AGENTS.md +19 -0
- package/agents/copilot/copilot-instructions.md +19 -0
- package/agents/cursor/campaigns-os.mdc +21 -0
- package/campaign-spec/README.md +7 -0
- package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
- package/campaign-spec/dist/analytics-vendors.js +44 -0
- package/campaign-spec/dist/checkout-flow.d.ts +18 -0
- package/campaign-spec/dist/checkout-flow.js +28 -0
- package/campaign-spec/dist/index.d.ts +4 -2
- package/campaign-spec/dist/index.js +5 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
- package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
- package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
- package/campaign-spec/dist/rules/index.d.ts +2 -0
- package/campaign-spec/dist/rules/index.js +6 -0
- package/campaign-spec/dist/types.d.ts +43 -9
- package/compatibility.json +1 -1
- package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +68 -61
- package/contracts/effects.v1.json +549 -165
- package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
- package/contracts/release-ledger.json +1767 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
- package/contracts/supported-surface.json +10 -10
- package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +456 -36
- package/docs/campaign-build-brief.md +16 -0
- package/docs/campaign-standardization-report.md +4 -4
- package/docs/campaigns-os-build-flow.md +112 -2
- package/docs/campaignspec-authoring-examples.md +141 -0
- package/docs/design-source-package.md +64 -8
- package/docs/effects.md +17 -9
- package/docs/entry-points.md +188 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +42 -2
- package/docs/progress-snapshots.md +7 -4
- package/docs/qa-and-test-orders.md +197 -3
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +154 -6
- package/docs/runtime-readiness.md +6 -3
- package/docs/sdk-storage-compatibility.md +4 -2
- package/docs/skills-revision.md +10 -10
- package/docs/source-adapters.md +213 -0
- package/package.json +10 -3
- package/schemas/campaign-design-source-package.v0.schema.json +8 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
- package/schemas/campaign-spec.v4.schema.json +19 -3
- package/schemas/campaign-spec.v5.schema.json +19 -3
- package/schemas/campaigns-os-readback.v2.schema.json +238 -1
- package/schemas/source-html-manifest.v0.schema.json +3 -0
- 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 +5 -4
- package/skills/next-campaigns-os/SKILL.md +7 -4
- package/skills/next-campaigns-os-setup/SKILL.md +5 -3
- package/skills/next-campaigns-polish/SKILL.md +8 -10
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
- package/skills.json +11 -11
- package/src/brand-theme.mjs +415 -9
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +13 -3
- package/src/built-site-scope.mjs +2 -2
- package/src/campaign-workspace.mjs +288 -15
- package/src/change-scope.mjs +284 -0
- package/src/cli.mjs +358 -97
- package/src/commercial-journey.mjs +16 -3
- package/src/commercial-parity.mjs +15 -7
- package/src/content-residue.mjs +4 -2
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +91 -1
- package/src/design-source-publication.mjs +9 -6
- package/src/directory-lock.mjs +73 -19
- package/src/doctor/checks.mjs +87 -81
- package/src/doctor/ci.mjs +177 -0
- package/src/doctor/inspect.mjs +41 -6
- package/src/doctor/next-step.mjs +7 -0
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/doctor-sidecar.mjs +3 -2
- package/src/evidence-debt.mjs +159 -0
- package/src/finding-cause.mjs +12 -11
- package/src/input-refresh.mjs +339 -32
- package/src/invocation.mjs +7 -1
- package/src/live-campaign-refs.mjs +7 -2
- package/src/local-preview-policy.mjs +35 -2
- package/src/page-kit-analytics-vendors.mjs +132 -0
- package/src/parity/compare.mjs +316 -0
- package/src/parity/design-access.mjs +54 -0
- package/src/parity/element-parity-record.mjs +569 -0
- package/src/parity/probe.mjs +205 -0
- package/src/parity/snapshot.mjs +110 -0
- package/src/png-header.mjs +87 -0
- package/src/polish-browser.mjs +189 -10
- package/src/polish-captures-record.mjs +52 -0
- package/src/polish-deadline.mjs +14 -0
- package/src/polish-gate.mjs +25 -6
- package/src/polish-node.mjs +81 -7
- package/src/polish-readability.mjs +221 -9
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +613 -0
- package/src/progress-node.mjs +3 -2
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +111 -6
- package/src/qa-browser.mjs +40 -4
- package/src/qa-commercial-parity.mjs +50 -3
- package/src/qa-node.mjs +235 -22
- package/src/qa-publish.mjs +9 -8
- package/src/qa-sidecar.mjs +16 -4
- package/src/qa-vendor-beacons.mjs +232 -0
- package/src/qa-vendor-endpoints.mjs +306 -0
- package/src/qc-accept.mjs +176 -6
- package/src/qc-check-registry.mjs +42 -2
- package/src/qc-results.mjs +477 -42
- package/src/readback.mjs +113 -21
- package/src/route-identity.mjs +10 -0
- package/src/run-record.mjs +18 -10
- package/src/runtime-state-ignore.mjs +6 -2
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +115 -6
- package/src/source-html-intake.mjs +15 -1
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/spec-derive-campaign.mjs +216 -0
- package/src/spec-derive.mjs +473 -53
- package/src/spec-identity.mjs +14 -0
- package/src/stage-ledger.mjs +40 -1
- package/src/stage-record.mjs +100 -17
- package/src/target-lock.mjs +11 -1
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- package/src/visual-reference.mjs +126 -0
package/src/progress-node.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
123
|
-
|
|
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 =
|
|
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
|
-
|
|
684
|
-
|
|
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]) || []);
|
package/src/qa-browser.mjs
CHANGED
|
@@ -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
|
|
4906
|
+
function runCurrency(args) {
|
|
4904
4907
|
const code = args?.[QA_RUN_CURRENCY];
|
|
4905
|
-
|
|
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
|
-
|
|
8877
|
-
|
|
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
|
-
|
|
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,
|