@nextcommerce/campaigns-os 1.33.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 (310) hide show
  1. package/AGENTS.md +204 -0
  2. package/CHANGELOG.md +5002 -0
  3. package/CONTEXT.md +685 -0
  4. package/LICENSE +202 -0
  5. package/NOTICE +4 -0
  6. package/README.md +368 -0
  7. package/agents/claude/CLAUDE.md +32 -0
  8. package/agents/codex/AGENTS.md +27 -0
  9. package/agents/copilot/copilot-instructions.md +14 -0
  10. package/agents/cursor/campaigns-os.mdc +13 -0
  11. package/bin/campaigns-os.mjs +38 -0
  12. package/campaign-spec/README.md +138 -0
  13. package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
  14. package/campaign-spec/dist/analytics-vocabulary.js +74 -0
  15. package/campaign-spec/dist/index.d.ts +40 -0
  16. package/campaign-spec/dist/index.js +77 -0
  17. package/campaign-spec/dist/normalize.d.ts +22 -0
  18. package/campaign-spec/dist/normalize.js +41 -0
  19. package/campaign-spec/dist/routing.d.ts +190 -0
  20. package/campaign-spec/dist/routing.js +263 -0
  21. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
  22. package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
  23. package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
  24. package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
  25. package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
  26. package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
  27. package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
  28. package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
  29. package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
  30. package/campaign-spec/dist/rules/cycle-detection.js +141 -0
  31. package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
  32. package/campaign-spec/dist/rules/design-source-shape.js +142 -0
  33. package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
  34. package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
  35. package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
  36. package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
  37. package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
  38. package/campaign-spec/dist/rules/funnel-count.js +37 -0
  39. package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
  40. package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
  41. package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
  42. package/campaign-spec/dist/rules/funnel-identity.js +57 -0
  43. package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
  44. package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
  45. package/campaign-spec/dist/rules/index.d.ts +69 -0
  46. package/campaign-spec/dist/rules/index.js +131 -0
  47. package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
  48. package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
  49. package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
  50. package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
  51. package/campaign-spec/dist/rules/page-count.d.ts +11 -0
  52. package/campaign-spec/dist/rules/page-count.js +31 -0
  53. package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
  54. package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
  55. package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
  56. package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
  57. package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
  58. package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
  59. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
  60. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
  61. package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
  62. package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
  63. package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
  64. package/campaign-spec/dist/rules/schema-version.js +53 -0
  65. package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
  66. package/campaign-spec/dist/rules/sdk-version.js +96 -0
  67. package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
  68. package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
  69. package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
  70. package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
  71. package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
  72. package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
  73. package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
  74. package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
  75. package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
  76. package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
  77. package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
  78. package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
  79. package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
  80. package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
  81. package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
  82. package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
  83. package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
  84. package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
  85. package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
  86. package/campaign-spec/dist/sdk-version-parse.js +62 -0
  87. package/campaign-spec/dist/types.d.ts +674 -0
  88. package/campaign-spec/dist/types.js +40 -0
  89. package/campaign-spec/package.json +12 -0
  90. package/compatibility.json +25 -0
  91. package/contracts/agent-relevant-change-policy.v1.json +111 -0
  92. package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
  93. package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
  94. package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
  95. package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
  96. package/contracts/commerce-surface-catalog.json +2452 -0
  97. package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
  98. package/contracts/fixtures/orientation/envelope/current.json +95 -0
  99. package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
  100. package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
  101. package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
  102. package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
  103. package/contracts/fixtures/orientation/envelope/refused.json +100 -0
  104. package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
  105. package/contracts/fixtures/orientation/envelope/updated.json +135 -0
  106. package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
  107. package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
  108. package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
  109. package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
  110. package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
  111. package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
  112. package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
  113. package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
  114. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
  115. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
  116. package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
  117. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
  118. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
  119. package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
  120. package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
  121. package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
  122. package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
  123. package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
  124. package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
  125. package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
  126. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
  127. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
  128. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
  129. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
  130. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
  131. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
  132. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
  133. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
  134. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
  135. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
  136. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
  137. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
  138. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
  139. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
  140. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
  141. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
  142. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
  143. package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
  144. package/contracts/migration-sidecar-bundle.v0.json +149 -0
  145. package/contracts/orientation-limits.v1.json +41 -0
  146. package/contracts/orientation-reason-codes.v1.json +196 -0
  147. package/contracts/private-template-sources.json +8 -0
  148. package/contracts/release-ledger.json +4598 -0
  149. package/contracts/reserved-skill-names.json +13 -0
  150. package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
  151. package/contracts/supported-surface.json +180 -0
  152. package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
  153. package/contracts/template-brand-contract.apollo.v0.json +27 -0
  154. package/contracts/template-brand-contract.demeter.v0.json +27 -0
  155. package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
  156. package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
  157. package/contracts/template-brand-contract.olympus.v0.json +27 -0
  158. package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
  159. package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
  160. package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
  161. package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
  162. package/contracts/template-slot-manifest.apollo.v0.json +12 -0
  163. package/contracts/template-slot-manifest.demeter.v0.json +30 -0
  164. package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
  165. package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
  166. package/contracts/template-slot-manifest.olympus.v0.json +12 -0
  167. package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
  168. package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
  169. package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
  170. package/docs/brand-theme-bridge.md +159 -0
  171. package/docs/build-packet.md +1300 -0
  172. package/docs/campaign-build-brief.md +145 -0
  173. package/docs/campaign-standardization-report.md +329 -0
  174. package/docs/campaigns-os-build-flow.md +117 -0
  175. package/docs/design-source-package.md +784 -0
  176. package/docs/legacy-migration.md +58 -0
  177. package/docs/migration-sidecar-bundle.md +139 -0
  178. package/docs/orientation-contract-reference.md +1220 -0
  179. package/docs/polish-evidence.md +502 -0
  180. package/docs/qa-and-test-orders.md +1691 -0
  181. package/docs/release-ledger-authoring-guide.md +274 -0
  182. package/docs/runtime-readiness.md +211 -0
  183. package/docs/supported-surface.md +83 -0
  184. package/docs/versioning.md +55 -0
  185. package/docs/workflow-findings-sidecar.md +588 -0
  186. package/package.json +135 -0
  187. package/prompts/first-build.md +27 -0
  188. package/prompts/friction-log.md +30 -0
  189. package/schemas/campaign-build-brief.v1.schema.json +149 -0
  190. package/schemas/campaign-design-source-package.v0.schema.json +697 -0
  191. package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
  192. package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
  193. package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
  194. package/schemas/campaign-spec.v4.schema.json +582 -0
  195. package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
  196. package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
  197. package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
  198. package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
  199. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
  200. package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
  201. package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
  202. package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
  203. package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
  204. package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
  205. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
  206. package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
  207. package/schemas/source-html-manifest.v0.schema.json +252 -0
  208. package/skills/next-campaigns-build/SKILL.md +73 -0
  209. package/skills/next-campaigns-os/SKILL.md +101 -0
  210. package/skills/next-campaigns-os/references/session-intake.md +160 -0
  211. package/skills/next-campaigns-os-setup/SKILL.md +22 -0
  212. package/skills/next-campaigns-polish/SKILL.md +145 -0
  213. package/skills/next-campaigns-qa/SKILL.md +92 -0
  214. package/skills.json +56 -0
  215. package/skills.sh +64 -0
  216. package/src/adapter-decision-contract.mjs +333 -0
  217. package/src/brand-theme.mjs +1151 -0
  218. package/src/browser-launch.mjs +79 -0
  219. package/src/build-brief.mjs +781 -0
  220. package/src/built-site-scope.mjs +312 -0
  221. package/src/campaign-ecosystem.mjs +734 -0
  222. package/src/campaign-identity.mjs +405 -0
  223. package/src/campaign-workspace.mjs +127 -0
  224. package/src/checkpoint-waiver.mjs +302 -0
  225. package/src/cli.mjs +13442 -0
  226. package/src/commercial-journey.mjs +1119 -0
  227. package/src/commercial-parity.mjs +965 -0
  228. package/src/consent.mjs +347 -0
  229. package/src/content-residue.mjs +322 -0
  230. package/src/deadline.mjs +81 -0
  231. package/src/design-source-package.mjs +2604 -0
  232. package/src/deviation.mjs +107 -0
  233. package/src/doctor-check-registry.mjs +49 -0
  234. package/src/doctor-sidecar.mjs +106 -0
  235. package/src/finding-cause.mjs +557 -0
  236. package/src/findings.mjs +326 -0
  237. package/src/fs-identity.mjs +68 -0
  238. package/src/gate-actions.mjs +105 -0
  239. package/src/html-scan.mjs +53 -0
  240. package/src/install-mode.mjs +272 -0
  241. package/src/legacy-migration.d.ts +128 -0
  242. package/src/legacy-migration.mjs +510 -0
  243. package/src/lifecycle.mjs +338 -0
  244. package/src/local-proof.mjs +401 -0
  245. package/src/map-pin-writeback.mjs +210 -0
  246. package/src/orchestration-stage-contract.mjs +81 -0
  247. package/src/package-install-fixture.mjs +33 -0
  248. package/src/page-kit-build-summary.mjs +175 -0
  249. package/src/page-kit-campaign-config.mjs +57 -0
  250. package/src/page-kit-sdk-version.mjs +392 -0
  251. package/src/page-kit-store-profile.mjs +369 -0
  252. package/src/page-kit-sync.mjs +162 -0
  253. package/src/polish-browser.mjs +867 -0
  254. package/src/polish-capture.mjs +1094 -0
  255. package/src/polish-deadline.mjs +51 -0
  256. package/src/polish-gate.mjs +739 -0
  257. package/src/polish-node.mjs +639 -0
  258. package/src/polish-page-load.mjs +1100 -0
  259. package/src/private-template-source.mjs +237 -0
  260. package/src/proof-policy.mjs +82 -0
  261. package/src/qa-analytics-correctness.mjs +307 -0
  262. package/src/qa-analytics-errors.mjs +38 -0
  263. package/src/qa-analytics-parity.mjs +699 -0
  264. package/src/qa-binding-evidence.mjs +140 -0
  265. package/src/qa-browser.mjs +6608 -0
  266. package/src/qa-cart-entry.mjs +406 -0
  267. package/src/qa-commercial-parity.mjs +641 -0
  268. package/src/qa-node.mjs +3620 -0
  269. package/src/qa-order-bump.mjs +381 -0
  270. package/src/qa-parity-capture.mjs +428 -0
  271. package/src/qa-parity-fixture.mjs +359 -0
  272. package/src/qa-publish.mjs +362 -0
  273. package/src/qa-purchase-data-layer.mjs +263 -0
  274. package/src/qa-route-probe.mjs +272 -0
  275. package/src/qa-sidecar.mjs +188 -0
  276. package/src/qa-test-order-topology.mjs +207 -0
  277. package/src/qa-url-privacy.mjs +13 -0
  278. package/src/qa-verdict-discovery.mjs +192 -0
  279. package/src/qa-verdict-publish.mjs +105 -0
  280. package/src/qa-verdict.mjs +287 -0
  281. package/src/remit.mjs +388 -0
  282. package/src/repo-scan.mjs +83 -0
  283. package/src/route-identity.mjs +133 -0
  284. package/src/run-record-closeout.mjs +229 -0
  285. package/src/run-record.mjs +839 -0
  286. package/src/run-session.mjs +226 -0
  287. package/src/runtime-state-ignore.mjs +113 -0
  288. package/src/sdk-attribute-index.mjs +212 -0
  289. package/src/sdk-markup.mjs +358 -0
  290. package/src/sdk-meta-tags.mjs +52 -0
  291. package/src/shell-token.mjs +7 -0
  292. package/src/sidecar-bundle.mjs +399 -0
  293. package/src/source-asset-crawl.mjs +469 -0
  294. package/src/source-html-intake.mjs +627 -0
  295. package/src/source-html-manifest.mjs +276 -0
  296. package/src/source-prep.mjs +284 -0
  297. package/src/spec-derive-store.mjs +431 -0
  298. package/src/spec-derive.mjs +514 -0
  299. package/src/spec-fetch.mjs +66 -0
  300. package/src/spec-hash.mjs +48 -0
  301. package/src/spec-identity.mjs +27 -0
  302. package/src/stage-ledger.mjs +509 -0
  303. package/src/standardization-report.mjs +1297 -0
  304. package/src/template-brand-contract.mjs +473 -0
  305. package/src/template-freshness.mjs +196 -0
  306. package/src/template-reference.mjs +81 -0
  307. package/src/template-slot-manifest.mjs +150 -0
  308. package/src/text-safety.mjs +62 -0
  309. package/src/theme-gate.mjs +185 -0
  310. package/src/upsell-selector-scope.mjs +299 -0
@@ -0,0 +1,428 @@
1
+ // Fixture-driven parity capture. The assessment core deliberately has no
2
+ // browser dependency: saved order/capture evidence can be replayed for
3
+ // regression tests and negative controls without touching a live campaign.
4
+
5
+ import { assessAnalyticsInventory } from "./qa-analytics-correctness.mjs";
6
+ import { projectAnalyticsCaptureError } from "./qa-analytics-errors.mjs";
7
+ import {
8
+ diffAnalyticsParity,
9
+ effectivePurchase,
10
+ normalizeCapture,
11
+ } from "./qa-analytics-parity.mjs";
12
+ import { captureOrigin } from "./polish-capture.mjs";
13
+ import { captureAnalyticsForUrls, runBrowserTestOrders } from "./qa-browser.mjs";
14
+ import { SEVERITY, STATUS } from "./qa-verdict.mjs";
15
+
16
+ const MONEY_EPSILON = 0.005;
17
+
18
+ function parityAssertion({ id, status, severity, expected, actual, evidence }) {
19
+ return {
20
+ id,
21
+ family: "parity-capture",
22
+ page: "parity",
23
+ status,
24
+ ...(severity ? { severity } : {}),
25
+ expected,
26
+ actual,
27
+ ...(evidence ? { evidence } : {}),
28
+ };
29
+ }
30
+
31
+ function normalizedCapture(capture) {
32
+ if (!capture || typeof capture !== "object") return normalizeCapture();
33
+ if (Array.isArray(capture.eventNames) && capture.inventory) return capture;
34
+ return normalizeCapture(capture);
35
+ }
36
+
37
+ // Money is a finite number or a plain decimal string ("45.00"): Number() would
38
+ // also coerce hex/exponent forms ("0x2d" → 45) and non-money types (true → 1,
39
+ // [45] → 45), which no real order readback or client purchase payload emits —
40
+ // treat those as unparseable rather than silently equal.
41
+ const DECIMAL_MONEY_PATTERN = /^-?\d+(\.\d+)?$/;
42
+
43
+ function moneyNumber(value) {
44
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
45
+ if (typeof value !== "string") return null;
46
+ const trimmed = value.trim();
47
+ if (!trimmed || !DECIMAL_MONEY_PATTERN.test(trimmed)) return null;
48
+ const parsed = Number(trimmed);
49
+ return Number.isFinite(parsed) ? parsed : null;
50
+ }
51
+
52
+ // Both sides go through the same parse: an expectation that is not itself
53
+ // readable money can never be "equal" to an observation.
54
+ function moneyEqual(actual, expected) {
55
+ const value = moneyNumber(actual);
56
+ const target = moneyNumber(expected);
57
+ return value !== null && target !== null && Math.abs(value - target) <= MONEY_EPSILON;
58
+ }
59
+
60
+ // Only an HTTP(S) origin can be "the same": two non-HTTP URLs share the opaque
61
+ // origin "null" under the URL standard and must not be read as one host.
62
+ function sameOrigin(a, b) {
63
+ const origin = captureOrigin(a);
64
+ return origin !== null && origin === captureOrigin(b);
65
+ }
66
+
67
+ // --auth-cookie is the CANDIDATE preview credential. An operator naming the
68
+ // baseline on the command line is authorizing that host; a baseline that came
69
+ // from fixture data is not, so it only carries the credential when it is
70
+ // same-origin with the candidate. Without this, fixture data alone could
71
+ // forward the preview cookie to an arbitrary host.
72
+ //
73
+ // Callers must pass the RETURN VALUE to the baseline capture: the withholding
74
+ // path returns a copy, so mutating or reusing the original `args` would put the
75
+ // credential back on the wire.
76
+ function baselineCaptureArgs(args, { baselineUrl, operatorBaseline, candidateBaseUrl }) {
77
+ if (!baselineUrl || operatorBaseline || sameOrigin(baselineUrl, candidateBaseUrl)) return args;
78
+ const { "auth-cookie": _withheld, ...withoutCredential } = args;
79
+ return withoutCredential;
80
+ }
81
+
82
+ function lineValue(line, priceField) {
83
+ return moneyNumber(line?.[priceField]);
84
+ }
85
+
86
+ function persistedLines(order) {
87
+ if (Array.isArray(order?.final_receipt_line_items)) {
88
+ return order.final_receipt_line_items;
89
+ }
90
+ return Array.isArray(order?.receipt_line_items) ? order.receipt_line_items : [];
91
+ }
92
+
93
+ function matchingPersistedLines(order, expectedLine) {
94
+ const expectedTitle = String(expectedLine.title || "").trim().toLowerCase();
95
+ return persistedLines(order).filter((line) => {
96
+ const title = String(line?.title || line?.name || "").trim().toLowerCase();
97
+ // Persisted lines append the variant to the product title
98
+ // ("Example Accessory - Blue / S"), so the fixture's product title
99
+ // matches exact-or-prefix; quantity + is_upsell stay exact.
100
+ const titleMatches = title === expectedTitle || title.startsWith(`${expectedTitle} -`);
101
+ return titleMatches
102
+ && Number(line?.quantity || 0) === Number(expectedLine.quantity)
103
+ && Boolean(line?.is_upsell) === Boolean(expectedLine.is_upsell);
104
+ });
105
+ }
106
+
107
+ function assessPersistedLine(scenario, order) {
108
+ const expectedLine = scenario.expected_order_readback?.line_item || {};
109
+ const matches = matchingPersistedLines(order, expectedLine);
110
+ const values = matches.map((line) => lineValue(line, expectedLine.price_field));
111
+ const declaredFieldAbsent = matches.length > 0
112
+ && matches.every((line) => line?.[expectedLine.price_field] === null
113
+ || line?.[expectedLine.price_field] === undefined
114
+ || line?.[expectedLine.price_field] === "");
115
+ const expectedRoute = scenario.upsell_route || scenario.expected_upsell_path || null;
116
+ const observedRoute = observedUpsellRoute(order, expectedRoute);
117
+ const routeMatches = !observedRoute || routePathMatches(observedRoute, expectedRoute);
118
+ const ok = values.length > 0
119
+ && !declaredFieldAbsent
120
+ && routeMatches
121
+ && values.every((value) => moneyEqual(value, expectedLine.expected_line_total));
122
+ return parityAssertion({
123
+ id: `parity-capture:${scenario.scenario_id}:persisted-line`,
124
+ status: ok ? STATUS.PASS : STATUS.FAIL,
125
+ severity: SEVERITY.BLOCKER,
126
+ expected: `persisted ${expectedLine.is_upsell ? "upsell" : "order"} line total ${expectedLine.expected_line_total} ${scenario.currency}`,
127
+ actual: !matches.length
128
+ ? "matching persisted order line absent"
129
+ : declaredFieldAbsent
130
+ ? `declared price field ${expectedLine.price_field} was absent from matching persisted evidence`
131
+ : !routeMatches
132
+ ? `persisted line observed after unexpected offer page ${observedRoute}; expected ${expectedRoute}`
133
+ : `matching persisted ${expectedLine.price_field} total(s): ${values.map((value, index) => (
134
+ value ?? `unparseable(${JSON.stringify(matches[index]?.[expectedLine.price_field] ?? null)})`
135
+ )).join(", ")}`,
136
+ evidence: {
137
+ source: Array.isArray(order?.final_receipt_line_items)
138
+ ? "final_receipt_line_items"
139
+ : "receipt_line_items",
140
+ expected_title: expectedLine.title || null,
141
+ expected_quantity: expectedLine.quantity ?? null,
142
+ expected_is_upsell: expectedLine.is_upsell ?? null,
143
+ expected_line_total: expectedLine.expected_line_total ?? null,
144
+ declared_price_field: expectedLine.price_field || null,
145
+ declared_price_field_absent: declaredFieldAbsent,
146
+ observed_line_totals: values,
147
+ expected_upsell_route: expectedRoute,
148
+ observed_upsell_route: observedRoute,
149
+ upsell_route_matches: routeMatches,
150
+ persisted_order_total_incl_tax: order?.verification?.total_incl_tax ?? null,
151
+ persisted_order_currency: order?.verification?.currency ?? null,
152
+ },
153
+ });
154
+ }
155
+
156
+ function observedUpsellRoute(order, expectedRoute) {
157
+ const explicit = [
158
+ ...(Array.isArray(order?.evidence?.upsell_page_urls) ? order.evidence.upsell_page_urls : []),
159
+ order?.evidence?.upsell_page_url,
160
+ ...(Array.isArray(order?.upsell_steps) ? order.upsell_steps.map((step) => step?.offer_url) : []),
161
+ order?.upsell?.offer_url,
162
+ ].find((value) => typeof value === "string" && value.trim());
163
+ if (explicit) return explicit;
164
+ const finalUrl = typeof order?.final_url === "string" ? order.final_url : null;
165
+ if (!finalUrl || !expectedRoute) return null;
166
+ try {
167
+ const path = new URL(finalUrl, "https://parity.invalid/").pathname;
168
+ return /(?:oto|upsell)/i.test(path) ? finalUrl : null;
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ function routePathMatches(observed, expected) {
175
+ if (!expected) return true;
176
+ // Hosts may serve pretty URLs ("/campaign/oto-accessory") for fixture
177
+ // routes authored as files ("oto-accessory.html"), and the observed URL
178
+ // carries the deploy prefix — so compare .html-stripped paths suffix-wise.
179
+ const normalize = (value) => {
180
+ const path = new URL(value, "https://parity.invalid/").pathname
181
+ .replace(/\/+$/, "")
182
+ .replace(/\.html$/i, "");
183
+ return path.startsWith("/") ? path : `/${path}`;
184
+ };
185
+ try {
186
+ const observedPath = normalize(observed);
187
+ const expectedPath = normalize(expected);
188
+ return observedPath === expectedPath || observedPath.endsWith(expectedPath);
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+
194
+ function assessPurchase(scenario, capture) {
195
+ const expected = scenario.expected_purchase || {};
196
+ const effective = effectivePurchase(capture);
197
+ const eventPresent = (capture.eventNames || []).includes(expected.event);
198
+ // The scenario names WHICH purchase-shaped event carries the offer's value:
199
+ // an upsell scenario checks dl_upsell_purchase, a main-order scenario
200
+ // dl_purchase. Values are read per-event so the whole-cart main purchase
201
+ // never masks or pollutes the offer-level expectation.
202
+ const observed = (capture.purchasesByEvent || {})[expected.event]
203
+ || (expected.event === "dl_purchase" && capture.purchase?.present ? capture.purchase : null);
204
+ // expected.value null = "a finite client value must be present" without
205
+ // pinning the amount (a scenario that doesn't pin the checkout cart can't
206
+ // honestly pin the main-order purchase value; the offer amount is proven by
207
+ // the persisted-line check instead).
208
+ const valueOk = observed !== null && (expected.value === null
209
+ ? moneyNumber(observed.value) !== null
210
+ : moneyEqual(observed.value, expected.value));
211
+ const ok = eventPresent
212
+ && valueOk
213
+ && observed.currency === expected.currency;
214
+ return parityAssertion({
215
+ id: `parity-capture:${scenario.scenario_id}:purchase-value`,
216
+ status: ok ? STATUS.PASS : STATUS.FAIL,
217
+ severity: SEVERITY.BLOCKER,
218
+ expected: `${expected.event} client-fired value ${expected.value} ${expected.currency}`,
219
+ actual: eventPresent
220
+ ? `${observed?.value ?? "missing"} ${observed?.currency || "missing"} (${expected.event})`
221
+ : `${expected.event} absent`,
222
+ evidence: {
223
+ event_present: eventPresent,
224
+ observed_events: capture.eventNames || [],
225
+ observed_purchase_events: Object.keys(capture.purchasesByEvent || {}),
226
+ purchase_via: effective.via,
227
+ client_value: observed?.value ?? null,
228
+ client_currency: observed?.currency ?? null,
229
+ note: "client-fired purchase value only; never compared with backend total_incl_tax",
230
+ },
231
+ });
232
+ }
233
+
234
+ // Pure assessment core. The persisted order readback and the client analytics
235
+ // capture are independent proof legs; the paired summary only passes when both
236
+ // have no blocker failure.
237
+ export function assessParityCapture({ fixture, scenario, order, capture, baselineCapture = null }) {
238
+ if (!fixture || !scenario) throw new Error("Parity assessment requires fixture and scenario data.");
239
+ if (scenario.scenario_type !== "funnel_offer") {
240
+ throw new Error(`Parity capture scenario ${scenario.scenario_id} must be a funnel_offer.`);
241
+ }
242
+
243
+ const candidate = normalizedCapture(capture);
244
+ const assertions = [];
245
+ const persisted = assessPersistedLine(scenario, order || {});
246
+ const purchase = assessPurchase(scenario, candidate);
247
+ assertions.push(persisted, purchase);
248
+
249
+ // Unexpected extra upsell lines are surfaced for review: the blocking check
250
+ // scopes to the declared offer line, so a stray persisted upsell charge
251
+ // would otherwise ride along invisibly.
252
+ const expectedLine = scenario.expected_order_readback?.line_item || {};
253
+ const matched = new Set(matchingPersistedLines(order || {}, expectedLine));
254
+ const strayUpsells = persistedLines(order || {}).filter((line) => line?.is_upsell && !matched.has(line));
255
+ if (strayUpsells.length) {
256
+ assertions.push(parityAssertion({
257
+ id: `parity-capture:${scenario.scenario_id}:unexpected-upsell-lines`,
258
+ status: STATUS.MANUAL_REVIEW,
259
+ severity: SEVERITY.WARN,
260
+ expected: "no persisted upsell lines beyond the scenario's declared offer",
261
+ actual: `${strayUpsells.length} unmatched persisted upsell line(s)`,
262
+ evidence: {
263
+ unmatched_upsell_lines: strayUpsells.map((line) => ({
264
+ title: line?.title || line?.name || null,
265
+ quantity: line?.quantity ?? null,
266
+ // Stable keys regardless of the scenario's declared price_field, so a
267
+ // consumer can read the value without knowing which field was declared
268
+ // (avoids an inconsistent computed JSON key across scenarios).
269
+ declared_price_field: expectedLine.price_field || "price",
270
+ declared_price_field_value: line?.[expectedLine.price_field || "price"] ?? null,
271
+ })),
272
+ },
273
+ }));
274
+ }
275
+
276
+ const correctness = assessAnalyticsInventory(
277
+ candidate,
278
+ fixture.analytics_contract || {},
279
+ ).map((assertion) => ({ ...assertion, family: "parity-capture" }));
280
+ assertions.push(...correctness);
281
+
282
+ if (baselineCapture) {
283
+ assertions.push(...diffAnalyticsParity(normalizedCapture(baselineCapture), candidate));
284
+ }
285
+
286
+ const orderBlocked = persisted.status === STATUS.FAIL && persisted.severity === SEVERITY.BLOCKER;
287
+ const analyticsBlocked = assertions.some((assertion) => (
288
+ assertion !== persisted
289
+ && assertion.status === STATUS.FAIL
290
+ && assertion.severity === SEVERITY.BLOCKER
291
+ ));
292
+ assertions.push(parityAssertion({
293
+ id: `parity-capture:${scenario.scenario_id}:paired-summary`,
294
+ status: !orderBlocked && !analyticsBlocked ? STATUS.PASS : STATUS.FAIL,
295
+ severity: SEVERITY.BLOCKER,
296
+ expected: "persisted order proof and analytics proof both pass without blocker failures",
297
+ actual: `order=${orderBlocked ? "blocked" : "pass"}, analytics=${analyticsBlocked ? "blocked" : "pass"}`,
298
+ evidence: { order_blocked: orderBlocked, analytics_blocked: analyticsBlocked },
299
+ }));
300
+
301
+ return assertions;
302
+ }
303
+
304
+ function scenarioCart(scenario, args) {
305
+ if (args.cart) return String(args.cart);
306
+ if (typeof scenario.cart === "string") return scenario.cart;
307
+ if (!Array.isArray(scenario.cart)) return undefined;
308
+ const parts = scenario.cart.map((item) => {
309
+ const packageId = item?.package_id ?? item?.packageId ?? item?.ref_id;
310
+ return packageId == null ? null : `${packageId}:${Number(item.quantity || 1)}`;
311
+ }).filter(Boolean);
312
+ return parts.length ? parts.join(",") : undefined;
313
+ }
314
+
315
+ function scenarioUrl(baseUrl, scenario) {
316
+ const route = scenario.checkout_path || scenario.checkout_url || "";
317
+ if (!route) return baseUrl;
318
+ return new URL(route, `${baseUrl.replace(/\/+$/, "")}/`).toString();
319
+ }
320
+
321
+ function scenarioTopology(baseUrl, scenario) {
322
+ const checkoutUrl = scenarioUrl(baseUrl, scenario);
323
+ const depth = String(scenario.funnel_path || "").split("-").filter((step) => step === "accept" || step === "decline").length;
324
+ return [{
325
+ topology_id: `parity-${scenario.scenario_id}`,
326
+ pages: [
327
+ { page_id: "checkout", page_type: "checkout", url: checkoutUrl },
328
+ ...Array.from({ length: depth }, (_, index) => ({
329
+ page_id: `offer-${index + 1}`,
330
+ page_type: "upsell",
331
+ ...(index === 0 && scenario.upsell_route
332
+ ? { url: new URL(scenario.upsell_route, `${baseUrl.replace(/\/+$/, "")}/`).toString() }
333
+ : {}),
334
+ })),
335
+ ],
336
+ }];
337
+ }
338
+
339
+ export function resolveParityScenario(fixture, scenarioId) {
340
+ const matches = (fixture?.scenarios || []).filter((scenario) => (
341
+ scenario.scenario_id === scenarioId || scenario.offer === scenarioId
342
+ ));
343
+ if (matches.length !== 1) {
344
+ throw new Error(matches.length
345
+ ? `Parity scenario selector "${scenarioId}" is ambiguous.`
346
+ : `Parity scenario "${scenarioId}" was not found in the fixture.`);
347
+ }
348
+ return matches[0];
349
+ }
350
+
351
+ function parityJourneyAttempt(orderResult, expectedPlanId = null) {
352
+ const attempts = Array.isArray(orderResult?.journeyAnalytics?.attempts)
353
+ ? orderResult.journeyAnalytics.attempts
354
+ : [];
355
+ const normalized = expectedPlanId == null ? null : String(expectedPlanId);
356
+ if (normalized == null) return attempts[0] || null;
357
+ return attempts.find((attempt) => String(attempt?.planId || "") === normalized) || null;
358
+ }
359
+
360
+ function parityCaptureFailureAssertion(scenario, attempt) {
361
+ const error = projectAnalyticsCaptureError(attempt?.captureError);
362
+ return parityAssertion({
363
+ id: `parity-capture:${scenario.scenario_id}:analytics-capture`,
364
+ status: STATUS.FAIL,
365
+ severity: SEVERITY.BLOCKER,
366
+ expected: "analytics capture from the canonical typed-card traversal",
367
+ actual: error?.message || "capture evidence missing",
368
+ evidence: {
369
+ plan_id: attempt?.planId || scenario.funnel_path || null,
370
+ ...(error ? { error_code: error.code } : {}),
371
+ },
372
+ });
373
+ }
374
+
375
+ // Thin live driver: use the existing typed-card traversal/readback while the
376
+ // shared analytics hooks observe the candidate funnel; capture an optional
377
+ // baseline URL through the same browser helper.
378
+ export async function runParityCapture({ fixture, scenarioId, args = {} }) {
379
+ const scenario = resolveParityScenario(fixture, scenarioId);
380
+ const baseUrl = String(args["base-url"] || fixture.candidate_base_url || "").replace(/\/+$/, "");
381
+ if (!baseUrl) throw new Error("Parity capture requires a candidate base URL.");
382
+
383
+ const driverArgs = {
384
+ ...args,
385
+ "test-order": scenario.funnel_path,
386
+ ...(scenarioCart(scenario, args) ? { cart: scenarioCart(scenario, args) } : {}),
387
+ };
388
+ const orderResult = await runBrowserTestOrders(
389
+ scenarioTopology(baseUrl, scenario),
390
+ driverArgs,
391
+ args.run_id || "parity-capture",
392
+ { captureAnalytics: true },
393
+ );
394
+ const order = orderResult.orders[0] || {};
395
+ const operatorBaseline = args.baseline || args["analytics-baseline"] || null;
396
+ const baselineUrl = operatorBaseline || fixture.baseline_url || null;
397
+ const baselineArgs = baselineCaptureArgs(driverArgs, { baselineUrl, operatorBaseline, candidateBaseUrl: baseUrl });
398
+ const candidateAttempt = parityJourneyAttempt(
399
+ orderResult,
400
+ order?.plan_id || scenario.funnel_path || orderResult.journeyAnalytics?.plannedPlanIds?.[0],
401
+ );
402
+ const candidateCapture = candidateAttempt?.capture || null;
403
+ const captures = {
404
+ ...(candidateCapture ? { candidate: candidateCapture } : {}),
405
+ ...(baselineUrl ? await captureAnalyticsForUrls({ baseline: baselineUrl }, baselineArgs) : {}),
406
+ };
407
+ const captureAssertions = candidateCapture
408
+ ? assessParityCapture({
409
+ fixture,
410
+ scenario,
411
+ order,
412
+ capture: candidateCapture,
413
+ baselineCapture: captures.baseline || null,
414
+ })
415
+ : [parityCaptureFailureAssertion(scenario, candidateAttempt)];
416
+ const assertions = [
417
+ ...orderResult.assertions,
418
+ ...captureAssertions,
419
+ ];
420
+ return { assertions, orders: orderResult.orders, captures };
421
+ }
422
+
423
+ export const __qaParityCaptureTestHooks = Object.freeze({
424
+ scenarioTopology,
425
+ baselineCaptureArgs,
426
+ parityJourneyAttempt,
427
+ parityCaptureFailureAssertion,
428
+ });