@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.
- package/AGENTS.md +204 -0
- package/CHANGELOG.md +5002 -0
- package/CONTEXT.md +685 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +368 -0
- package/agents/claude/CLAUDE.md +32 -0
- package/agents/codex/AGENTS.md +27 -0
- package/agents/copilot/copilot-instructions.md +14 -0
- package/agents/cursor/campaigns-os.mdc +13 -0
- package/bin/campaigns-os.mjs +38 -0
- package/campaign-spec/README.md +138 -0
- package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
- package/campaign-spec/dist/analytics-vocabulary.js +74 -0
- package/campaign-spec/dist/index.d.ts +40 -0
- package/campaign-spec/dist/index.js +77 -0
- package/campaign-spec/dist/normalize.d.ts +22 -0
- package/campaign-spec/dist/normalize.js +41 -0
- package/campaign-spec/dist/routing.d.ts +190 -0
- package/campaign-spec/dist/routing.js +263 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
- package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
- package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
- package/campaign-spec/dist/rules/cycle-detection.js +141 -0
- package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
- package/campaign-spec/dist/rules/design-source-shape.js +142 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
- package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
- package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
- package/campaign-spec/dist/rules/funnel-count.js +37 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
- package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
- package/campaign-spec/dist/rules/funnel-identity.js +57 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
- package/campaign-spec/dist/rules/index.d.ts +69 -0
- package/campaign-spec/dist/rules/index.js +131 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
- package/campaign-spec/dist/rules/page-count.d.ts +11 -0
- package/campaign-spec/dist/rules/page-count.js +31 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
- package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
- package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
- package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
- package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
- package/campaign-spec/dist/rules/schema-version.js +53 -0
- package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
- package/campaign-spec/dist/rules/sdk-version.js +96 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
- package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
- package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
- package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
- package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
- package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
- package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
- package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
- package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
- package/campaign-spec/dist/sdk-version-parse.js +62 -0
- package/campaign-spec/dist/types.d.ts +674 -0
- package/campaign-spec/dist/types.js +40 -0
- package/campaign-spec/package.json +12 -0
- package/compatibility.json +25 -0
- package/contracts/agent-relevant-change-policy.v1.json +111 -0
- package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
- package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
- package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
- package/contracts/commerce-surface-catalog.json +2452 -0
- package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
- package/contracts/fixtures/orientation/envelope/current.json +95 -0
- package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
- package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
- package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
- package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
- package/contracts/fixtures/orientation/envelope/refused.json +100 -0
- package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
- package/contracts/fixtures/orientation/envelope/updated.json +135 -0
- package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
- package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
- package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
- package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
- package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
- package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
- package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
- package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
- package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
- package/contracts/migration-sidecar-bundle.v0.json +149 -0
- package/contracts/orientation-limits.v1.json +41 -0
- package/contracts/orientation-reason-codes.v1.json +196 -0
- package/contracts/private-template-sources.json +8 -0
- package/contracts/release-ledger.json +4598 -0
- package/contracts/reserved-skill-names.json +13 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
- package/contracts/supported-surface.json +180 -0
- package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.apollo.v0.json +27 -0
- package/contracts/template-brand-contract.demeter.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
- package/contracts/template-brand-contract.olympus.v0.json +27 -0
- package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
- package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
- package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.apollo.v0.json +12 -0
- package/contracts/template-slot-manifest.demeter.v0.json +30 -0
- package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
- package/contracts/template-slot-manifest.olympus.v0.json +12 -0
- package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
- package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
- package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
- package/docs/brand-theme-bridge.md +159 -0
- package/docs/build-packet.md +1300 -0
- package/docs/campaign-build-brief.md +145 -0
- package/docs/campaign-standardization-report.md +329 -0
- package/docs/campaigns-os-build-flow.md +117 -0
- package/docs/design-source-package.md +784 -0
- package/docs/legacy-migration.md +58 -0
- package/docs/migration-sidecar-bundle.md +139 -0
- package/docs/orientation-contract-reference.md +1220 -0
- package/docs/polish-evidence.md +502 -0
- package/docs/qa-and-test-orders.md +1691 -0
- package/docs/release-ledger-authoring-guide.md +274 -0
- package/docs/runtime-readiness.md +211 -0
- package/docs/supported-surface.md +83 -0
- package/docs/versioning.md +55 -0
- package/docs/workflow-findings-sidecar.md +588 -0
- package/package.json +135 -0
- package/prompts/first-build.md +27 -0
- package/prompts/friction-log.md +30 -0
- package/schemas/campaign-build-brief.v1.schema.json +149 -0
- package/schemas/campaign-design-source-package.v0.schema.json +697 -0
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
- package/schemas/campaign-spec.v4.schema.json +582 -0
- package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
- package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
- package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
- package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
- package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
- package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
- package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
- package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
- package/schemas/source-html-manifest.v0.schema.json +252 -0
- package/skills/next-campaigns-build/SKILL.md +73 -0
- package/skills/next-campaigns-os/SKILL.md +101 -0
- package/skills/next-campaigns-os/references/session-intake.md +160 -0
- package/skills/next-campaigns-os-setup/SKILL.md +22 -0
- package/skills/next-campaigns-polish/SKILL.md +145 -0
- package/skills/next-campaigns-qa/SKILL.md +92 -0
- package/skills.json +56 -0
- package/skills.sh +64 -0
- package/src/adapter-decision-contract.mjs +333 -0
- package/src/brand-theme.mjs +1151 -0
- package/src/browser-launch.mjs +79 -0
- package/src/build-brief.mjs +781 -0
- package/src/built-site-scope.mjs +312 -0
- package/src/campaign-ecosystem.mjs +734 -0
- package/src/campaign-identity.mjs +405 -0
- package/src/campaign-workspace.mjs +127 -0
- package/src/checkpoint-waiver.mjs +302 -0
- package/src/cli.mjs +13442 -0
- package/src/commercial-journey.mjs +1119 -0
- package/src/commercial-parity.mjs +965 -0
- package/src/consent.mjs +347 -0
- package/src/content-residue.mjs +322 -0
- package/src/deadline.mjs +81 -0
- package/src/design-source-package.mjs +2604 -0
- package/src/deviation.mjs +107 -0
- package/src/doctor-check-registry.mjs +49 -0
- package/src/doctor-sidecar.mjs +106 -0
- package/src/finding-cause.mjs +557 -0
- package/src/findings.mjs +326 -0
- package/src/fs-identity.mjs +68 -0
- package/src/gate-actions.mjs +105 -0
- package/src/html-scan.mjs +53 -0
- package/src/install-mode.mjs +272 -0
- package/src/legacy-migration.d.ts +128 -0
- package/src/legacy-migration.mjs +510 -0
- package/src/lifecycle.mjs +338 -0
- package/src/local-proof.mjs +401 -0
- package/src/map-pin-writeback.mjs +210 -0
- package/src/orchestration-stage-contract.mjs +81 -0
- package/src/package-install-fixture.mjs +33 -0
- package/src/page-kit-build-summary.mjs +175 -0
- package/src/page-kit-campaign-config.mjs +57 -0
- package/src/page-kit-sdk-version.mjs +392 -0
- package/src/page-kit-store-profile.mjs +369 -0
- package/src/page-kit-sync.mjs +162 -0
- package/src/polish-browser.mjs +867 -0
- package/src/polish-capture.mjs +1094 -0
- package/src/polish-deadline.mjs +51 -0
- package/src/polish-gate.mjs +739 -0
- package/src/polish-node.mjs +639 -0
- package/src/polish-page-load.mjs +1100 -0
- package/src/private-template-source.mjs +237 -0
- package/src/proof-policy.mjs +82 -0
- package/src/qa-analytics-correctness.mjs +307 -0
- package/src/qa-analytics-errors.mjs +38 -0
- package/src/qa-analytics-parity.mjs +699 -0
- package/src/qa-binding-evidence.mjs +140 -0
- package/src/qa-browser.mjs +6608 -0
- package/src/qa-cart-entry.mjs +406 -0
- package/src/qa-commercial-parity.mjs +641 -0
- package/src/qa-node.mjs +3620 -0
- package/src/qa-order-bump.mjs +381 -0
- package/src/qa-parity-capture.mjs +428 -0
- package/src/qa-parity-fixture.mjs +359 -0
- package/src/qa-publish.mjs +362 -0
- package/src/qa-purchase-data-layer.mjs +263 -0
- package/src/qa-route-probe.mjs +272 -0
- package/src/qa-sidecar.mjs +188 -0
- package/src/qa-test-order-topology.mjs +207 -0
- package/src/qa-url-privacy.mjs +13 -0
- package/src/qa-verdict-discovery.mjs +192 -0
- package/src/qa-verdict-publish.mjs +105 -0
- package/src/qa-verdict.mjs +287 -0
- package/src/remit.mjs +388 -0
- package/src/repo-scan.mjs +83 -0
- package/src/route-identity.mjs +133 -0
- package/src/run-record-closeout.mjs +229 -0
- package/src/run-record.mjs +839 -0
- package/src/run-session.mjs +226 -0
- package/src/runtime-state-ignore.mjs +113 -0
- package/src/sdk-attribute-index.mjs +212 -0
- package/src/sdk-markup.mjs +358 -0
- package/src/sdk-meta-tags.mjs +52 -0
- package/src/shell-token.mjs +7 -0
- package/src/sidecar-bundle.mjs +399 -0
- package/src/source-asset-crawl.mjs +469 -0
- package/src/source-html-intake.mjs +627 -0
- package/src/source-html-manifest.mjs +276 -0
- package/src/source-prep.mjs +284 -0
- package/src/spec-derive-store.mjs +431 -0
- package/src/spec-derive.mjs +514 -0
- package/src/spec-fetch.mjs +66 -0
- package/src/spec-hash.mjs +48 -0
- package/src/spec-identity.mjs +27 -0
- package/src/stage-ledger.mjs +509 -0
- package/src/standardization-report.mjs +1297 -0
- package/src/template-brand-contract.mjs +473 -0
- package/src/template-freshness.mjs +196 -0
- package/src/template-reference.mjs +81 -0
- package/src/template-slot-manifest.mjs +150 -0
- package/src/text-safety.mjs +62 -0
- package/src/theme-gate.mjs +185 -0
- 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
|
+
});
|