@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,272 @@
|
|
|
1
|
+
import { normalizePublicRouteSlug } from "./route-identity.mjs";
|
|
2
|
+
|
|
3
|
+
// Route probe: the reachability half of `qa resolve`.
|
|
4
|
+
//
|
|
5
|
+
// Doctrine: `qa resolve` derives every route URL from the packet — the packet
|
|
6
|
+
// is the authority on where a campaign is served — and until #273 it reported
|
|
7
|
+
// `ready` on that derivation alone, never asking the deployment whether the
|
|
8
|
+
// URLs it just printed exist. Pointed at a host serving the same campaign under
|
|
9
|
+
// a different route root it printed nine entry URLs and `ready`; all nine were
|
|
10
|
+
// 404, and the three checkpoints below them also said `pass` because they read
|
|
11
|
+
// the packet rather than the deployment. The existing operator guidance —
|
|
12
|
+
// "empty Entry URLs mean a dead preview or a wrong --base-url" — does not cover
|
|
13
|
+
// that case, because the URLs are non-empty and all wrong.
|
|
14
|
+
//
|
|
15
|
+
// So resolve probes what it derived. Two rules shape the module:
|
|
16
|
+
//
|
|
17
|
+
// 1. A derivation is not a verification. `ready` now means the routes were
|
|
18
|
+
// probed AND resolved; a route set that does not resolve gets its own
|
|
19
|
+
// terminal status naming the first URL that failed.
|
|
20
|
+
// 2. Probing must never make resolve unusable. An HTTP response saying 404 is
|
|
21
|
+
// evidence about the deployment; a transport error is evidence about this
|
|
22
|
+
// machine's network, which is not the deployment's fault. The first fails
|
|
23
|
+
// the probe, the second degrades to a named `not_probed` state that stays
|
|
24
|
+
// usable offline and in CI.
|
|
25
|
+
|
|
26
|
+
export const ROUTE_PROBE_DEFAULT_TIMEOUT_MS = 5000;
|
|
27
|
+
// Entry URLs are one per funnel, so a real campaign is well inside this. The
|
|
28
|
+
// cap exists so a pathological topology cannot turn a diagnostic command into
|
|
29
|
+
// an unbounded network sweep; anything past it is reported as skipped rather
|
|
30
|
+
// than silently dropped.
|
|
31
|
+
export const ROUTE_PROBE_MAX_URLS = 25;
|
|
32
|
+
const ROUTE_PROBE_CONCURRENCY = 6;
|
|
33
|
+
// A static host that refuses HEAD is answering about the method, not the route.
|
|
34
|
+
const METHOD_NOT_SUPPORTED = new Set([405, 501]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Probe one URL. A response — any response — is evidence about the deployment;
|
|
38
|
+
* a throw is evidence about the transport between here and it.
|
|
39
|
+
*/
|
|
40
|
+
export async function probeOneUrl(url, { timeoutMs = ROUTE_PROBE_DEFAULT_TIMEOUT_MS, fetchImpl = fetch } = {}) {
|
|
41
|
+
const attempt = async (method) => fetchImpl(url, {
|
|
42
|
+
method,
|
|
43
|
+
redirect: "follow",
|
|
44
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
45
|
+
});
|
|
46
|
+
try {
|
|
47
|
+
let method = "HEAD";
|
|
48
|
+
let response = await attempt(method);
|
|
49
|
+
if (METHOD_NOT_SUPPORTED.has(Number(response?.status))) {
|
|
50
|
+
method = "GET";
|
|
51
|
+
response = await attempt(method);
|
|
52
|
+
}
|
|
53
|
+
const httpStatus = Number(response?.status);
|
|
54
|
+
return {
|
|
55
|
+
url,
|
|
56
|
+
outcome: httpStatus >= 400 ? "unresolved" : "resolved",
|
|
57
|
+
method,
|
|
58
|
+
http_status: Number.isFinite(httpStatus) ? httpStatus : null,
|
|
59
|
+
error: null,
|
|
60
|
+
};
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return {
|
|
63
|
+
url,
|
|
64
|
+
outcome: "unreachable",
|
|
65
|
+
method: "HEAD",
|
|
66
|
+
http_status: null,
|
|
67
|
+
error: transportErrorText(error, timeoutMs),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function transportErrorText(error, timeoutMs) {
|
|
73
|
+
if (error?.name === "TimeoutError") return `no response within ${timeoutMs}ms`;
|
|
74
|
+
const cause = error?.cause?.code || error?.code || null;
|
|
75
|
+
const message = String(error?.message || error || "transport error");
|
|
76
|
+
return cause ? `${message} (${cause})` : message;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Bounded-concurrency probe that preserves INPUT order. Workers pull indices
|
|
80
|
+
// off a shared cursor and complete in whatever order the network allows, but
|
|
81
|
+
// each writes to `results[index]` — its own input slot — so `results` reads back
|
|
82
|
+
// in the order the entry URLs were derived, not the order the responses landed.
|
|
83
|
+
// `first_failure` and the printed "First failure:" line depend on that, so a
|
|
84
|
+
// future refactor to `push()` or `Promise.all(map(...))`-with-append would
|
|
85
|
+
// silently change what "first" means.
|
|
86
|
+
async function probeAll(urls, options) {
|
|
87
|
+
const results = new Array(urls.length);
|
|
88
|
+
let cursor = 0;
|
|
89
|
+
const worker = async () => {
|
|
90
|
+
for (let index = cursor++; index < urls.length; index = cursor++) {
|
|
91
|
+
results[index] = await probeOneUrl(urls[index], options);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
await Promise.all(
|
|
95
|
+
Array.from({ length: Math.min(ROUTE_PROBE_CONCURRENCY, urls.length) }, worker),
|
|
96
|
+
);
|
|
97
|
+
return results;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The route root this resolve derived, with the packet's public_route_slug
|
|
102
|
+
* stripped back off.
|
|
103
|
+
*
|
|
104
|
+
* `resolve` appends `public_route_slug` unconditionally, so no documented input
|
|
105
|
+
* points the harness at a deployment served under a different route root — the
|
|
106
|
+
* packet is the authority, and that is intended. The failure mode is what was
|
|
107
|
+
* not intended: the operator saw `ready` rather than a message saying so. When
|
|
108
|
+
* every derived route is dead, one extra probe of the host without the slug
|
|
109
|
+
* separates "this deployment is down" from "this deployment does not serve the
|
|
110
|
+
* slug this packet declares", which is the actionable half.
|
|
111
|
+
*/
|
|
112
|
+
export function baseUrlWithoutSlug(baseUrl, publicRouteSlug) {
|
|
113
|
+
const slug = normalizePublicRouteSlug(publicRouteSlug);
|
|
114
|
+
if (!slug || !baseUrl) return null;
|
|
115
|
+
try {
|
|
116
|
+
const url = new URL(baseUrl);
|
|
117
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
118
|
+
if (segments.at(-1) !== slug) return null;
|
|
119
|
+
url.pathname = `/${segments.slice(0, -1).join("/")}${segments.length > 1 ? "/" : ""}`;
|
|
120
|
+
return url.toString();
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function summarize(results) {
|
|
127
|
+
const counts = { resolved: 0, unresolved: 0, unreachable: 0, skipped: 0 };
|
|
128
|
+
for (const result of results) counts[result.outcome] = (counts[result.outcome] || 0) + 1;
|
|
129
|
+
return counts;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function describeFailure(result) {
|
|
133
|
+
if (!result) return "";
|
|
134
|
+
return result.outcome === "unresolved"
|
|
135
|
+
? `${result.url} (HTTP ${result.http_status})`
|
|
136
|
+
: `${result.url} (${result.error})`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Probe the entry URLs resolve just derived.
|
|
141
|
+
*
|
|
142
|
+
* @returns {{
|
|
143
|
+
* status: "pass"|"failed"|"not_probed",
|
|
144
|
+
* code: string,
|
|
145
|
+
* reason: string,
|
|
146
|
+
* counts: { resolved: number, unresolved: number, unreachable: number, skipped: number },
|
|
147
|
+
* first_failure: object|null, // first result that did not cleanly resolve, in the order the
|
|
148
|
+
* // entry URLs were derived; null only when every probed URL
|
|
149
|
+
* // resolved, or when nothing was probed at all.
|
|
150
|
+
* results: object[],
|
|
151
|
+
* route_root_hint: object|null,
|
|
152
|
+
* }}
|
|
153
|
+
*/
|
|
154
|
+
export async function probeRouteUrls({
|
|
155
|
+
entryUrls = [],
|
|
156
|
+
baseUrl = null,
|
|
157
|
+
publicRouteSlug = null,
|
|
158
|
+
enabled = true,
|
|
159
|
+
timeoutMs = ROUTE_PROBE_DEFAULT_TIMEOUT_MS,
|
|
160
|
+
fetchImpl = fetch,
|
|
161
|
+
skippedReason = null,
|
|
162
|
+
} = {}) {
|
|
163
|
+
const urls = [];
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
for (const entry of Array.isArray(entryUrls) ? entryUrls : []) {
|
|
166
|
+
const url = typeof entry === "string" ? entry : entry?.url;
|
|
167
|
+
if (typeof url !== "string" || !url.trim() || seen.has(url)) continue;
|
|
168
|
+
seen.add(url);
|
|
169
|
+
urls.push(url);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const notProbed = (code, reason, results = []) => ({
|
|
173
|
+
status: "not_probed",
|
|
174
|
+
code,
|
|
175
|
+
reason,
|
|
176
|
+
counts: summarize(results),
|
|
177
|
+
first_failure: null,
|
|
178
|
+
results,
|
|
179
|
+
route_root_hint: null,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (!urls.length) {
|
|
183
|
+
return notProbed(
|
|
184
|
+
"route_probe.no_routes",
|
|
185
|
+
"No entry URLs were derived, so there was nothing to probe. An empty Entry URL list means a dead preview or a missing --base-url.",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (!enabled) {
|
|
189
|
+
return notProbed(
|
|
190
|
+
"route_probe.disabled",
|
|
191
|
+
`Route probing is off${skippedReason ? ` (${skippedReason})` : ""}; the ${urls.length} derived entry URL(s) were not checked against the deployment.`,
|
|
192
|
+
urls.map((url) => ({ url, outcome: "skipped", method: null, http_status: null, error: null })),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const probeUrls = urls.slice(0, ROUTE_PROBE_MAX_URLS);
|
|
197
|
+
const skipped = urls.slice(ROUTE_PROBE_MAX_URLS)
|
|
198
|
+
.map((url) => ({ url, outcome: "skipped", method: null, http_status: null, error: null }));
|
|
199
|
+
const results = [...await probeAll(probeUrls, { timeoutMs, fetchImpl }), ...skipped];
|
|
200
|
+
const counts = summarize(results);
|
|
201
|
+
|
|
202
|
+
if (counts.unresolved > 0) {
|
|
203
|
+
const firstFailure = results.find((result) => result.outcome === "unresolved");
|
|
204
|
+
const hint = await routeRootHint({ results, baseUrl, publicRouteSlug, timeoutMs, fetchImpl });
|
|
205
|
+
const scope = counts.resolved === 0
|
|
206
|
+
? `All ${counts.unresolved} derived entry URL(s) are dead on this host`
|
|
207
|
+
: `${counts.unresolved} of ${counts.unresolved + counts.resolved} derived entry URL(s) are dead on this host`;
|
|
208
|
+
return {
|
|
209
|
+
status: "failed",
|
|
210
|
+
code: "route_probe.routes_unresolved",
|
|
211
|
+
// The route-root diagnosis is long and belongs in exactly one place, so
|
|
212
|
+
// it stays on route_root_hint.reason rather than being inlined here too.
|
|
213
|
+
reason: `${scope}. First failure: ${describeFailure(firstFailure)}.`,
|
|
214
|
+
counts,
|
|
215
|
+
first_failure: firstFailure,
|
|
216
|
+
results,
|
|
217
|
+
route_root_hint: hint,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (counts.resolved === 0) {
|
|
222
|
+
const firstFailure = results.find((result) => result.outcome === "unreachable");
|
|
223
|
+
return {
|
|
224
|
+
...notProbed(
|
|
225
|
+
"route_probe.unreachable",
|
|
226
|
+
`No entry URL could be reached, so the route set is unverified rather than proven dead — a transport failure is evidence about this machine's network, not about the deployment. First failure: ${describeFailure(firstFailure)}.`,
|
|
227
|
+
results,
|
|
228
|
+
),
|
|
229
|
+
first_failure: firstFailure,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// A pass reached over some unreachable URLs is partial reachability, not a
|
|
234
|
+
// clean sweep. `first_failure` carries the first of them so the structured
|
|
235
|
+
// field means the same thing on every branch, and the reason names it so an
|
|
236
|
+
// operator does not have to infer which URL from a bare count.
|
|
237
|
+
const firstUnreached = results.find((result) => result.outcome === "unreachable") || null;
|
|
238
|
+
return {
|
|
239
|
+
status: "pass",
|
|
240
|
+
code: "route_probe.all_resolved",
|
|
241
|
+
reason: firstUnreached
|
|
242
|
+
? `${counts.resolved} entry URL(s) resolved; ${counts.unreachable} could not be reached from this machine. First unreached: ${describeFailure(firstUnreached)}.`
|
|
243
|
+
: `All ${counts.resolved} derived entry URL(s) resolved on this host.`,
|
|
244
|
+
counts,
|
|
245
|
+
first_failure: firstUnreached,
|
|
246
|
+
results,
|
|
247
|
+
route_root_hint: null,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function routeRootHint({ results, baseUrl, publicRouteSlug, timeoutMs, fetchImpl }) {
|
|
252
|
+
// Only worth asking when the slug-rooted derivation is dead across the board.
|
|
253
|
+
if (results.some((result) => result.outcome === "resolved")) return null;
|
|
254
|
+
const withoutSlug = baseUrlWithoutSlug(baseUrl, publicRouteSlug);
|
|
255
|
+
if (!withoutSlug) return null;
|
|
256
|
+
const probe = await probeOneUrl(withoutSlug, { timeoutMs, fetchImpl });
|
|
257
|
+
const slug = normalizePublicRouteSlug(publicRouteSlug);
|
|
258
|
+
if (probe.outcome !== "resolved") {
|
|
259
|
+
return {
|
|
260
|
+
code: "route_probe.host_also_dead",
|
|
261
|
+
probed_url: withoutSlug,
|
|
262
|
+
result: probe,
|
|
263
|
+
reason: `${withoutSlug} does not resolve either, so the preview itself is likely dead rather than served under a different route root.`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
code: "route_probe.route_root_mismatch",
|
|
268
|
+
probed_url: withoutSlug,
|
|
269
|
+
result: probe,
|
|
270
|
+
reason: `${withoutSlug} resolves but ${baseUrl} does not, so this host does not serve the campaign under the packet's public_route_slug "${slug}". resolve appends that slug from the packet on purpose — the packet is the authority on where the campaign is served — so the fix is to correct campaign.public_route_slug (or declare campaign.route_root) in the packet, not to pass a different --base-url.`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { deriveExceptions, validateVerdict } from "./qa-verdict.mjs";
|
|
6
|
+
|
|
7
|
+
// The committed QA verdict sidecar: `.campaign-runtime/qa-verdict.json` beside
|
|
8
|
+
// the Build Packet, the artifact campaigns-agent's readback projects. Full
|
|
9
|
+
// verdicts under qa-output/ are gitignored because they carry live storefront
|
|
10
|
+
// URLs, request evidence, and order references; the sidecar is committed to
|
|
11
|
+
// merchant repos, so it is an allowlist PROJECTION of one verdict, never a
|
|
12
|
+
// copy. Same schema ("1.0") — a second schema would fork the read end.
|
|
13
|
+
|
|
14
|
+
export const SIDECAR_RELATIVE_PATH = ".campaign-runtime/qa-verdict.json";
|
|
15
|
+
|
|
16
|
+
// Assertion fields that survive projection. Everything else — url, expected,
|
|
17
|
+
// actual, evidence, request/response captures — stays in the full verdict.
|
|
18
|
+
// `cause`/`cause_reason` survive projection: they are a short enum plus a
|
|
19
|
+
// mechanical reason code — no URL, no order reference, no capture body — and
|
|
20
|
+
// the committed sidecar is the artifact a readback reads, so stripping them
|
|
21
|
+
// would leave the one consumer that cannot re-run QA unable to tell a
|
|
22
|
+
// pre-existing finding from a regression.
|
|
23
|
+
const ASSERTION_FIELDS = ["id", "family", "page", "status", "severity", "blocked_by", "cause", "cause_reason"];
|
|
24
|
+
const EXCEPTION_FIELDS = ["id", "family", "page", "status", "severity", "cause", "cause_reason"];
|
|
25
|
+
|
|
26
|
+
function pick(source, fields) {
|
|
27
|
+
const out = {};
|
|
28
|
+
for (const field of fields) {
|
|
29
|
+
if (source?.[field] !== undefined) out[field] = source[field];
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Project one full QA verdict into its committable sidecar form. Pure:
|
|
36
|
+
* `generatedAt` is the promotion instant the caller stamps (writers pass the
|
|
37
|
+
* wall clock; tests pass a fixture). The projection must itself pass
|
|
38
|
+
* validateVerdict — the readback recognizes the sidecar by the same minimal
|
|
39
|
+
* schema as the full verdict.
|
|
40
|
+
*/
|
|
41
|
+
export function projectVerdictForSidecar(verdict, { generatedAt }) {
|
|
42
|
+
const errors = validateVerdict(verdict);
|
|
43
|
+
if (errors.length) {
|
|
44
|
+
throw new Error(`QA verdict failed validation before sidecar projection:\n- ${errors.join("\n- ")}`);
|
|
45
|
+
}
|
|
46
|
+
// Trust segregation (the readback chokepoint): the QA verdict receiver
|
|
47
|
+
// stamps `trusted: false` onto records that arrived without the ingest
|
|
48
|
+
// credential — anonymous submissions. Shape validity is NOT trust: a forged
|
|
49
|
+
// verdict passes validateVerdict by design. A record carrying that stamp
|
|
50
|
+
// must never be laundered into the committed sidecar campaigns-agent's
|
|
51
|
+
// readback consumes, so the projection refuses it outright instead of
|
|
52
|
+
// silently stripping the stamp. Fresh `qa run` verdicts never carry the
|
|
53
|
+
// field (it is server-stamped, not CLI-emitted), so this only ever fires on
|
|
54
|
+
// an explicit `qa promote` of a fetched, untrusted record. See
|
|
55
|
+
// docs/qa-and-test-orders.md (Verdict schema and trust semantics).
|
|
56
|
+
if (verdict.trusted === false) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
"QA verdict is stamped trusted: false (an anonymous submission classified by the QA verdict receiver); "
|
|
59
|
+
+ "refusing to project it into the committed sidecar. Promote a verdict this runner produced, or re-run QA.",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
// Strict RFC3339 UTC with a Z suffix, not bare Date.parse: downstream
|
|
63
|
+
// freshness (readback staleness) documents the Z-suffixed form, and
|
|
64
|
+
// Date.parse would admit zone-less or locale strings that consumers can
|
|
65
|
+
// mis-parse.
|
|
66
|
+
if (typeof generatedAt !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(generatedAt)) {
|
|
67
|
+
throw new Error("Sidecar projection requires an ISO-8601 UTC generatedAt with a Z suffix.");
|
|
68
|
+
}
|
|
69
|
+
const assertions = (verdict.assertions || [])
|
|
70
|
+
.filter((assertion) => assertion && typeof assertion === "object")
|
|
71
|
+
.map((assertion) => pick(assertion, ASSERTION_FIELDS));
|
|
72
|
+
// Exceptions are re-derived from the PROJECTED assertions — which no longer
|
|
73
|
+
// carry url/expected/actual, so deriveExceptions can only emit null/absent
|
|
74
|
+
// for those keys — then pick() drops the null-valued keys themselves.
|
|
75
|
+
const exceptions = deriveExceptions(assertions).map((exception) => pick(exception, EXCEPTION_FIELDS));
|
|
76
|
+
return {
|
|
77
|
+
schema_version: verdict.schema_version,
|
|
78
|
+
run_id: verdict.run_id,
|
|
79
|
+
campaign_slug: verdict.campaign_slug,
|
|
80
|
+
...(verdict.public_route_slug != null ? { public_route_slug: verdict.public_route_slug } : {}),
|
|
81
|
+
...(verdict.campaign_ref_id != null ? { campaign_ref_id: verdict.campaign_ref_id } : {}),
|
|
82
|
+
spec_version: verdict.spec_version,
|
|
83
|
+
spec_hash: verdict.spec_hash,
|
|
84
|
+
started_at: verdict.started_at,
|
|
85
|
+
completed_at: verdict.completed_at,
|
|
86
|
+
generated_at: generatedAt,
|
|
87
|
+
runtime: verdict.runtime,
|
|
88
|
+
disposition: verdict.disposition,
|
|
89
|
+
entry_urls: [],
|
|
90
|
+
page_urls: [],
|
|
91
|
+
tested_urls: [],
|
|
92
|
+
assertions,
|
|
93
|
+
test_orders: [],
|
|
94
|
+
exceptions,
|
|
95
|
+
...(verdict.cause_summary && typeof verdict.cause_summary === "object" && !Array.isArray(verdict.cause_summary)
|
|
96
|
+
? { cause_summary: verdict.cause_summary }
|
|
97
|
+
: {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function sidecarPathForPacket(packetPath) {
|
|
102
|
+
return join(dirname(resolve(packetPath)), SIDECAR_RELATIVE_PATH);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const PACKET_SCHEMA = "campaign-runtime-build-packet/v0";
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Assert the sidecar anchor is a real Build Packet before any write. A typo'd
|
|
109
|
+
* or stale --packet path would otherwise scatter qa-verdict.json sidecars
|
|
110
|
+
* into unrelated trees — exactly the artifact sprawl the contracted home
|
|
111
|
+
* exists to prevent.
|
|
112
|
+
*/
|
|
113
|
+
function assertPacketAnchor(packetPath) {
|
|
114
|
+
let raw;
|
|
115
|
+
try {
|
|
116
|
+
raw = readFileSync(resolve(packetPath), "utf8");
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// error.code only, never error.message: raw fs messages can carry local
|
|
119
|
+
// uid/path detail that does not belong in surfaced output.
|
|
120
|
+
throw new Error(`Sidecar anchor ${packetPath} is not a readable Build Packet (${error.code || "unreadable"}).`);
|
|
121
|
+
}
|
|
122
|
+
let packet;
|
|
123
|
+
try {
|
|
124
|
+
packet = JSON.parse(raw);
|
|
125
|
+
} catch {
|
|
126
|
+
throw new Error(`Sidecar anchor ${packetPath} is not a readable Build Packet (not valid JSON).`);
|
|
127
|
+
}
|
|
128
|
+
if (packet?.schema_version !== PACKET_SCHEMA) {
|
|
129
|
+
throw new Error(`Sidecar anchor ${packetPath} is not a Build Packet (expected schema_version ${PACKET_SCHEMA}).`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function writeJsonAtomicAt(path, value) {
|
|
134
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
135
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
136
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
|
|
137
|
+
renameSync(tmp, path);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Write the sidecar for a just-finalized verdict object. Called by `qa run`
|
|
142
|
+
* after local validation, before the exit-code decision, so blocked runs get
|
|
143
|
+
* a sidecar too (disposition is a recorded fact, not a write gate) and a run
|
|
144
|
+
* that dies before finalization never touches an existing sidecar.
|
|
145
|
+
*/
|
|
146
|
+
export function writeQaSidecar({ verdict, packetPath, now = () => new Date().toISOString() }) {
|
|
147
|
+
assertPacketAnchor(packetPath);
|
|
148
|
+
const projected = projectVerdictForSidecar(verdict, { generatedAt: now() });
|
|
149
|
+
const destination = sidecarPathForPacket(packetPath);
|
|
150
|
+
writeJsonAtomicAt(destination, projected);
|
|
151
|
+
return { path: destination, run_id: projected.run_id, disposition: projected.disposition, generated_at: projected.generated_at };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Explicit backfill: promote one named full verdict file to the sidecar.
|
|
156
|
+
* Both paths are required; nothing is selected by mtime or filename order —
|
|
157
|
+
* "latest" scans are how a wrong run gets promoted. The destination sidecar
|
|
158
|
+
* is refused as a source so CI cannot launder an old projection into a fresh
|
|
159
|
+
* timestamp. Validation happens before any write; on failure the prior
|
|
160
|
+
* sidecar bytes are untouched.
|
|
161
|
+
*/
|
|
162
|
+
export function promoteQaVerdict({ verdictPath, packetPath, now = () => new Date().toISOString() }) {
|
|
163
|
+
if (!verdictPath || !packetPath) {
|
|
164
|
+
throw new Error("qa promote requires both --verdict <full-verdict.json> and --packet <campaign-runtime.build.json>.");
|
|
165
|
+
}
|
|
166
|
+
assertPacketAnchor(packetPath);
|
|
167
|
+
const source = resolve(verdictPath);
|
|
168
|
+
const destination = sidecarPathForPacket(packetPath);
|
|
169
|
+
if (source === destination) {
|
|
170
|
+
throw new Error("qa promote refuses the destination sidecar as its own source: promote from the full verdict under qa-output/, not from a prior projection.");
|
|
171
|
+
}
|
|
172
|
+
let verdict;
|
|
173
|
+
try {
|
|
174
|
+
verdict = JSON.parse(readFileSync(source, "utf8"));
|
|
175
|
+
} catch (error) {
|
|
176
|
+
throw new Error(`qa promote could not read the source verdict at ${source}: ${error.message}`);
|
|
177
|
+
}
|
|
178
|
+
const projected = projectVerdictForSidecar(verdict, { generatedAt: now() });
|
|
179
|
+
writeJsonAtomicAt(destination, projected);
|
|
180
|
+
return {
|
|
181
|
+
ok: true,
|
|
182
|
+
source: source,
|
|
183
|
+
destination,
|
|
184
|
+
run_id: projected.run_id,
|
|
185
|
+
disposition: projected.disposition,
|
|
186
|
+
generated_at: projected.generated_at,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
export const OFFER_PAGE_TYPES = new Set(["upsell", "downsell"]);
|
|
2
|
+
export const RECEIPT_PAGE_TYPES = new Set(["receipt", "thankyou"]);
|
|
3
|
+
const ACTIONS = Object.freeze([
|
|
4
|
+
["decline", "expected_decline_url"],
|
|
5
|
+
["accept", "expected_accept_url"],
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
export function resolveTestOrderTopology(topology = {}, checkoutPage = null) {
|
|
9
|
+
const pages = Array.isArray(topology?.pages) ? topology.pages : [];
|
|
10
|
+
const checkout = checkoutPage || pages.find((page) => pageType(page) === "checkout") || null;
|
|
11
|
+
const pagesByUrl = new Map();
|
|
12
|
+
for (const page of pages) {
|
|
13
|
+
const key = canonicalHttpUrl(page?.url);
|
|
14
|
+
if (key && !pagesByUrl.has(key)) pagesByUrl.set(key, page);
|
|
15
|
+
}
|
|
16
|
+
const topologyOrigin = httpOrigin(checkout?.url) || pages.map((page) => httpOrigin(page?.url)).find(Boolean) || null;
|
|
17
|
+
|
|
18
|
+
const terminalPaths = [];
|
|
19
|
+
const invalidPaths = [];
|
|
20
|
+
const entry = checkout ? targetNode(checkout.expected_next_url, pagesByUrl, topologyOrigin) : null;
|
|
21
|
+
if (entry?.kind === "offer") {
|
|
22
|
+
walkOffer(entry.page, [], new Set(), pagesByUrl, topologyOrigin, terminalPaths, invalidPaths);
|
|
23
|
+
} else if (entry?.kind === "invalid") {
|
|
24
|
+
invalidPaths.push({ path: "checkout", reason: entry.reason, target: entry.target });
|
|
25
|
+
}
|
|
26
|
+
const recognizedTerminals = dedupeTerminals([
|
|
27
|
+
...pages
|
|
28
|
+
.filter((page) => RECEIPT_PAGE_TYPES.has(pageType(page)) && canonicalHttpUrl(page?.url))
|
|
29
|
+
.map((page) => ({ kind: "receipt", page_id: page.page_id || null, url: page.url })),
|
|
30
|
+
...(entry?.kind === "terminal" ? [entry.terminal] : []),
|
|
31
|
+
...terminalPaths.map((candidate) => candidate.terminal),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
topology_id: topology?.funnel_id || "default",
|
|
36
|
+
checkout_page_id: checkout?.page_id || null,
|
|
37
|
+
checkout_url: checkout?.url || null,
|
|
38
|
+
// Retain every declared page for diagnostics, including malformed rows.
|
|
39
|
+
// pageAtUrl still matches only canonical HTTP URLs, but the resolved plan
|
|
40
|
+
// does not silently erase the topology evidence that explains a miss.
|
|
41
|
+
route_pages: pages.map((page) => ({
|
|
42
|
+
page_id: page.page_id || null,
|
|
43
|
+
page_type: page.page_type || null,
|
|
44
|
+
url: page.url || null,
|
|
45
|
+
})),
|
|
46
|
+
has_offer_entry: entry?.kind === "offer",
|
|
47
|
+
full_paths: terminalPaths.map((candidate) => candidate.path),
|
|
48
|
+
terminal_paths: terminalPaths,
|
|
49
|
+
invalid_paths: invalidPaths,
|
|
50
|
+
recognized_terminals: recognizedTerminals,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function terminalAtUrl(resolvedTopology, value) {
|
|
55
|
+
const key = canonicalHttpUrl(value);
|
|
56
|
+
if (!key) return null;
|
|
57
|
+
return (resolvedTopology?.recognized_terminals || []).find((terminal) => canonicalHttpUrl(terminal?.url) === key) || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function pageAtUrl(resolvedTopology, value) {
|
|
61
|
+
const key = canonicalHttpUrl(value);
|
|
62
|
+
if (!key) return null;
|
|
63
|
+
return (resolvedTopology?.route_pages || []).find((page) => canonicalHttpUrl(page?.url) === key) || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function remainingActionDisposition(resolvedTopology, value, remainingActions = []) {
|
|
67
|
+
if (!Array.isArray(remainingActions) || !remainingActions.length) return { stop: false };
|
|
68
|
+
const terminal = terminalAtUrl(resolvedTopology, value);
|
|
69
|
+
if (!terminal) return { stop: false };
|
|
70
|
+
return { stop: true, terminal, remaining_actions: [...remainingActions] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function fullTestOrderPaths(resolvedTopology) {
|
|
74
|
+
const invalid = Array.isArray(resolvedTopology?.invalid_paths) ? resolvedTopology.invalid_paths : [];
|
|
75
|
+
if (invalid.length) {
|
|
76
|
+
const details = invalid
|
|
77
|
+
.map((candidate) => `${candidate.path || "checkout"}: ${candidate.reason}${candidate.target ? ` (${candidate.target})` : ""}`)
|
|
78
|
+
.join("; ");
|
|
79
|
+
throw new Error(
|
|
80
|
+
`--test-order full cannot enumerate actual terminal paths for funnel ${resolvedTopology?.topology_id || "default"}: ${details}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return ["checkout", ...(resolvedTopology?.full_paths || [])];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function commonTestOrderPaths(resolvedTopology) {
|
|
87
|
+
if (resolvedTopology?.has_offer_entry !== true) return ["checkout"];
|
|
88
|
+
const paths = ["checkout", "accept", "decline"];
|
|
89
|
+
const receiptPaths = (resolvedTopology?.terminal_paths || [])
|
|
90
|
+
.filter((candidate) => candidate?.terminal?.kind === "receipt")
|
|
91
|
+
.slice()
|
|
92
|
+
.sort(compareCommonReceiptPaths);
|
|
93
|
+
const shortest = receiptPaths[0]?.path;
|
|
94
|
+
if (shortest && !paths.includes(shortest)) paths.push(shortest);
|
|
95
|
+
return paths;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function compareCommonReceiptPaths(left, right) {
|
|
99
|
+
const lengthDelta = (left?.steps?.length || 0) - (right?.steps?.length || 0);
|
|
100
|
+
if (lengthDelta) return lengthDelta;
|
|
101
|
+
if (left?.path === "accept-decline" && right?.path !== "accept-decline") return -1;
|
|
102
|
+
if (right?.path === "accept-decline" && left?.path !== "accept-decline") return 1;
|
|
103
|
+
return compareActionSteps(left?.steps || [], right?.steps || []);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function compareActionSteps(left, right) {
|
|
107
|
+
const rank = { accept: 0, decline: 1 };
|
|
108
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
109
|
+
if (left[index] === right[index]) continue;
|
|
110
|
+
return (rank[left[index]] ?? 2) - (rank[right[index]] ?? 2);
|
|
111
|
+
}
|
|
112
|
+
return left.length - right.length;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function walkOffer(page, steps, visited, pagesByUrl, topologyOrigin, terminalPaths, invalidPaths) {
|
|
116
|
+
const pageKey = canonicalHttpUrl(page?.url) || `page:${page?.page_id || "unknown"}`;
|
|
117
|
+
if (visited.has(pageKey)) {
|
|
118
|
+
invalidPaths.push({ path: steps.join("-"), reason: "cycle", target: page?.url || page?.page_id || null });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const nextVisited = new Set(visited).add(pageKey);
|
|
122
|
+
|
|
123
|
+
for (const [action, field] of ACTIONS) {
|
|
124
|
+
const nextSteps = [...steps, action];
|
|
125
|
+
const target = targetNode(page?.[field], pagesByUrl, topologyOrigin);
|
|
126
|
+
if (target?.kind === "terminal") {
|
|
127
|
+
terminalPaths.push({
|
|
128
|
+
path: nextSteps.join("-"),
|
|
129
|
+
steps: nextSteps,
|
|
130
|
+
terminal: target.terminal,
|
|
131
|
+
});
|
|
132
|
+
} else if (target?.kind === "offer") {
|
|
133
|
+
walkOffer(target.page, nextSteps, nextVisited, pagesByUrl, topologyOrigin, terminalPaths, invalidPaths);
|
|
134
|
+
} else if (target?.kind === "invalid") {
|
|
135
|
+
invalidPaths.push({ path: nextSteps.join("-"), reason: target.reason, target: target.target });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function targetNode(value, pagesByUrl, topologyOrigin) {
|
|
141
|
+
const key = canonicalHttpUrl(value);
|
|
142
|
+
if (!key) {
|
|
143
|
+
return value == null || String(value).trim() === ""
|
|
144
|
+
? { kind: "invalid", reason: "missing_route", target: null }
|
|
145
|
+
: { kind: "invalid", reason: "unresolved_target", target: String(value) };
|
|
146
|
+
}
|
|
147
|
+
const page = pagesByUrl.get(key);
|
|
148
|
+
if (page) {
|
|
149
|
+
const type = pageType(page);
|
|
150
|
+
if (RECEIPT_PAGE_TYPES.has(type)) {
|
|
151
|
+
return {
|
|
152
|
+
kind: "terminal",
|
|
153
|
+
terminal: { kind: "receipt", page_id: page.page_id || null, url: page.url || value },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (OFFER_PAGE_TYPES.has(type)) return { kind: "offer", page };
|
|
157
|
+
return { kind: "invalid", reason: "nonterminal_target", target: page.url || value };
|
|
158
|
+
}
|
|
159
|
+
if (topologyOrigin && httpOrigin(key) !== topologyOrigin) {
|
|
160
|
+
return {
|
|
161
|
+
kind: "terminal",
|
|
162
|
+
terminal: { kind: "external_handoff", page_id: null, url: value },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return { kind: "invalid", reason: "unresolved_target", target: value };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function dedupeTerminals(terminals) {
|
|
169
|
+
const seen = new Set();
|
|
170
|
+
const deduped = [];
|
|
171
|
+
for (const terminal of terminals) {
|
|
172
|
+
const key = canonicalHttpUrl(terminal?.url);
|
|
173
|
+
if (!key || seen.has(key)) continue;
|
|
174
|
+
seen.add(key);
|
|
175
|
+
deduped.push(terminal);
|
|
176
|
+
}
|
|
177
|
+
return deduped;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function canonicalHttpUrl(value) {
|
|
181
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
182
|
+
try {
|
|
183
|
+
const url = new URL(value.trim());
|
|
184
|
+
if (!/^https?:$/.test(url.protocol)) return null;
|
|
185
|
+
url.search = "";
|
|
186
|
+
url.hash = "";
|
|
187
|
+
url.pathname = url.pathname
|
|
188
|
+
.replace(/\/index\.html$/i, "/")
|
|
189
|
+
.replace(/\/+$/, "") || "/";
|
|
190
|
+
return url.toString();
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function httpOrigin(value) {
|
|
197
|
+
try {
|
|
198
|
+
const url = new URL(String(value || ""));
|
|
199
|
+
return /^https?:$/.test(url.protocol) ? url.origin : null;
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function pageType(page) {
|
|
206
|
+
return String(page?.page_type || "").toLowerCase().replace(/[-_]/g, "");
|
|
207
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Canonical URL projection for QA evidence and private capture attribution.
|
|
2
|
+
// Query strings and fragments may contain ref/order identifiers, so every
|
|
3
|
+
// caller gets the same conservative projection, including malformed inputs.
|
|
4
|
+
export function redactUrlQuery(value) {
|
|
5
|
+
if (value === null || value === undefined) return null;
|
|
6
|
+
const text = String(value);
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(text);
|
|
9
|
+
return `${url.origin}${url.pathname}`;
|
|
10
|
+
} catch {
|
|
11
|
+
return text.split(/[?#]/)[0] || null;
|
|
12
|
+
}
|
|
13
|
+
}
|