@nextcommerce/campaigns-os 1.54.0 → 1.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/CONTEXT.md +19 -5
  3. package/README.md +26 -12
  4. package/campaign-spec/README.md +15 -1
  5. package/campaign-spec/dist/checkout-flow.d.ts +90 -0
  6. package/campaign-spec/dist/checkout-flow.js +229 -0
  7. package/campaign-spec/dist/index.d.ts +1 -0
  8. package/campaign-spec/dist/index.js +6 -1
  9. package/campaign-spec/dist/normalize.d.ts +3 -2
  10. package/campaign-spec/dist/normalize.js +7 -5
  11. package/campaign-spec/dist/routing.d.ts +6 -3
  12. package/campaign-spec/dist/routing.js +6 -3
  13. package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
  14. package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
  15. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
  16. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
  17. package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
  18. package/campaign-spec/dist/rules/index.d.ts +4 -0
  19. package/campaign-spec/dist/rules/index.js +12 -0
  20. package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
  21. package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
  22. package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
  23. package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
  24. package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
  25. package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
  26. package/campaign-spec/dist/rules/schema-version.js +10 -5
  27. package/campaign-spec/dist/types.d.ts +20 -3
  28. package/campaign-spec/dist/types.js +3 -2
  29. package/compatibility.json +2 -2
  30. package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
  31. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  32. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  33. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  35. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  38. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  45. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  46. package/contracts/release-ledger.json +1146 -0
  47. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  48. package/contracts/supported-surface.json +9 -5
  49. package/docs/brand-theme-bridge.md +10 -0
  50. package/docs/build-packet.md +86 -26
  51. package/docs/campaigns-os-build-flow.md +1 -1
  52. package/docs/effects.md +2 -5
  53. package/docs/local-setup.md +1 -1
  54. package/docs/orientation-contract-reference.md +1 -1
  55. package/docs/qa-and-test-orders.md +133 -29
  56. package/docs/runtime-readiness.md +8 -3
  57. package/docs/skills-revision.md +10 -10
  58. package/docs/supported-surface.md +1 -1
  59. package/docs/versioning.md +4 -1
  60. package/docs/workflow-findings-sidecar.md +57 -2
  61. package/package.json +4 -1
  62. package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
  63. package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
  64. package/schemas/campaign-spec.v5.schema.json +586 -0
  65. package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
  66. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  67. package/skills/campaign-readback-classification/SKILL.md +3 -3
  68. package/skills/campaign-run-evidence/SKILL.md +3 -3
  69. package/skills/contribution-intake/SKILL.md +3 -3
  70. package/skills/next-campaigns-build/SKILL.md +6 -4
  71. package/skills/next-campaigns-os/SKILL.md +3 -3
  72. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  73. package/skills/next-campaigns-polish/SKILL.md +21 -6
  74. package/skills/next-campaigns-qa/SKILL.md +5 -4
  75. package/skills.json +10 -10
  76. package/src/adapter-decision-contract.mjs +27 -13
  77. package/src/brand-theme.mjs +44 -8
  78. package/src/build-brief.mjs +14 -5
  79. package/src/campaign-identity.mjs +33 -4
  80. package/src/cli-helpers.mjs +129 -0
  81. package/src/cli.mjs +156 -111
  82. package/src/commercial-journey.mjs +25 -2
  83. package/src/consent.mjs +3 -2
  84. package/src/design-source-package.mjs +1 -1
  85. package/src/design-source-publication.mjs +8 -8
  86. package/src/deviation.mjs +2 -1
  87. package/src/doctor/checks.mjs +212 -19
  88. package/src/doctor/inspect.mjs +4 -2
  89. package/src/doctor/next-step.mjs +46 -16
  90. package/src/doctor-sidecar.mjs +5 -3
  91. package/src/gate-actions.mjs +21 -1
  92. package/src/input-refresh.mjs +6 -6
  93. package/src/invocation.mjs +4 -3
  94. package/src/lifecycle.mjs +88 -6
  95. package/src/local-preview-policy.mjs +116 -12
  96. package/src/login.mjs +2 -1
  97. package/src/polish-browser.mjs +8 -2
  98. package/src/polish-node.mjs +9 -4
  99. package/src/polish-readability.mjs +2 -2
  100. package/src/qa-browser.mjs +741 -120
  101. package/src/qa-cart-entry.mjs +107 -20
  102. package/src/qa-node.mjs +179 -72
  103. package/src/qa-test-order-topology.mjs +118 -1
  104. package/src/qa-verdict-discovery.mjs +21 -4
  105. package/src/qa-verdict-publish.mjs +2 -1
  106. package/src/run-record.mjs +46 -34
  107. package/src/runtime-state-ignore.mjs +7 -5
  108. package/src/sdk-markup.mjs +41 -1
  109. package/src/source-html-intake.mjs +46 -4
  110. package/src/stage-ledger.mjs +18 -4
  111. package/src/stage-record.mjs +53 -16
package/src/qa-node.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "./spec-source-identity.mjs";
2
- import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD, starterResidueIsExpected } from "./local-preview-policy.mjs";
2
+ import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD, ensureUrlTrailingSlash, normalizeQaBaseUrl, starterResidueIsExpected } from "./local-preview-policy.mjs";
3
3
  import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion, scriptParseAssertion } from './qa-binding-evidence.mjs';
4
4
  import { shellToken } from "./shell-token.mjs";
5
5
  import { currentBriefMaterial } from "./input-currency.mjs";
@@ -32,7 +32,7 @@ const PACKAGE_ROOT = installModeResolve(installModeDirname(installModeFileUrl(im
32
32
  function cmd(verb, rest = "") {
33
33
  return `${invocationPrefixFor(PACKAGE_ROOT)} ${verb}${rest ? ` ${rest}` : ""}`;
34
34
  }
35
- import { isSameAnalyticsCapturePage, runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, upsellActionCoverageWithoutOrders, validatedOrderCreationLimit } from "./qa-browser.mjs";
35
+ import { QA_RUN_CURRENCY, isSameAnalyticsCapturePage, runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, UPSELL_ACTION_SPELLINGS, upsellActionCoverageWithoutOrders, validatedOrderCreationLimit } from "./qa-browser.mjs";
36
36
  import { assessReceiptPurchase } from "./qa-analytics-correctness.mjs";
37
37
  import { trackingQaAssertion, trackingRunScopeRows } from "./qa-tracking-params.mjs";
38
38
  import { contentParamNotRequestedRows, contentParamQaAssertion } from "./qa-content-params.mjs";
@@ -61,8 +61,10 @@ import {
61
61
  declineRouteTarget,
62
62
  forwardRouteTarget,
63
63
  inapplicableForwardFields,
64
+ upgradeCampaignSpec,
64
65
  } from "../campaign-spec/dist/index.js";
65
66
  import { evaluateThemeGate } from "./theme-gate.mjs";
67
+ import { findPaymentPage } from "./qa-test-order-topology.mjs";
66
68
  import { probeRouteUrls, ROUTE_PROBE_DEFAULT_TIMEOUT_MS } from "./qa-route-probe.mjs";
67
69
  import { resolveCommerceCatalog, resolvePacketCommerceCatalogPath, resolveTemplateBrandContract } from "./private-template-source.mjs";
68
70
  import { computeBuildFingerprint, resolveBuiltSiteScope, topologiesFromBuiltSiteScope } from "./built-site-scope.mjs";
@@ -85,6 +87,7 @@ import { campaignSidecarPaths, explicitReportPath, resolveCampaignWorkspace, tar
85
87
  import { loadParityFixture } from "./qa-parity-fixture.mjs";
86
88
  import { assessParityCapture, resolveParityScenario, runParityCapture } from "./qa-parity-capture.mjs";
87
89
  import { loadPageKitCampaignEntry, PAGE_KIT_CAMPAIGNS_REL_PATH } from "./page-kit-campaign-config.mjs";
90
+ import { publicRouteForPage } from "./source-html-intake.mjs";
88
91
  import {
89
92
  evaluatePageKitStoreProfile,
90
93
  PAGE_KIT_STORE_PROFILE_FIELDS,
@@ -115,7 +118,7 @@ import {
115
118
  liveRefsNotRunMessage,
116
119
  readLiveCampaignForPacket,
117
120
  } from "./live-campaign-refs.mjs";
118
- import { specPackageRefs, specShippingRefs } from "./doctor/checks.mjs";
121
+ import { entryPageKitServingRoute, isRootRoutedEntryPage, specPackageRefs, specShippingRefs } from "./doctor/checks.mjs";
119
122
 
120
123
  // The producing runtime identity on every verdict. Read from package.json so
121
124
  // a verdict names the release that made it; a literal here outlived three
@@ -128,7 +131,7 @@ const HELP = `campaigns-os qa — Node/npm spec-aware QA
128
131
  Usage:
129
132
  campaigns-os qa parity --fixture <parity-fixture.json> --scenario <scenario-id> [--base-url <override>] [--baseline <url>] [--parity-order-json <file>] [--no-post-verdict]
130
133
  campaigns-os qa resolve --packet <campaign-runtime.build.json> [--base-url <url>] [--no-probe] [--probe-timeout-ms <ms>] [--json]
131
- campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--output-dir <dir>] [--no-remit] [--no-live-refs] [--json]
134
+ campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--currency <code>] [--output-dir <dir>] [--no-remit] [--no-live-refs] [--json]
132
135
  campaigns-os qa policy set --packet <campaign-runtime.build.json> [--allowed-domains-confirmed true|false] [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--order-path-depth <off|common|full>] [--json]
133
136
  campaigns-os qa waive --packet <campaign-runtime.build.json> --assertion analytics-correctness:purchase-fires --reason "<why>" [--waived-by <who>] [--report <assembly-report.json>] [--json]
134
137
  campaigns-os qa promote --packet <campaign-runtime.build.json> --verdict <full-verdict.json> [--json] # project one explicit qa-output verdict to the committed .campaign-runtime/qa-verdict.json sidecar
@@ -189,6 +192,10 @@ Options:
189
192
  --no-live-refs qa run: skip the one read-only GET of <proxy-base>/api/campaign that checks each
190
193
  served page's shipping and package refs against the live campaign; the verdict
191
194
  records the check not_run with reason disabled, never a pass.
195
+ --currency <code> qa run: enter each funnel's entry page with ?currency=<CODE> (three letters, e.g. GBP),
196
+ keeping its existing query. The flag takes one code per run (given twice, the
197
+ last one is used); the verdict records it as evidence.currency on the entry
198
+ page's http:<page> row. Not supported by qa parity or --legacy-api-test-order.
192
199
  --auth-cookie <cookie> Cookie header for protected previews.
193
200
  --browser Run Playwright-rendered browser checks after static Node checks.
194
201
  Requires one-time setup: campaigns-os qa install-browser
@@ -199,8 +206,9 @@ Options:
199
206
  --browser-timeout <ms> Browser navigation timeout. Default: 30000.
200
207
  --test-order <off|common|checkout|accept|decline|both|full|tiers[:checkout|common|full]|accept-decline[-accept...]>
201
208
  Create Playwright typed-card test orders through the tested checkout page.
202
- Test cards bypass the gateway and create no transactions, so no permission
203
- flags or packet policy are needed — just pick a mode. Default mode (bare
209
+ Test cards bypass the gateway and create no transactions, but each creates a real
210
+ store order record. Unless the operator has already said test orders are fine for this campaign, ask once, up front, before placing orders.
211
+ No permission flag or packet policy is needed. Default mode (bare
204
212
  --test-order, or "common") runs every actual terminal path when they fit under
205
213
  the cap (--max-test-orders, default 6). Above the cap it runs checkout,
206
214
  first-offer accept/decline, and a deduplicated shortest real receipt path, then
@@ -459,7 +467,6 @@ async function resolveQaInputs(args, {
459
467
  }
460
468
  const normalized = normalizeSpec(rawSpec);
461
469
  const publicRouteSlug = resolvePublicRouteSlug({ packet, spec: normalized, rawSpec });
462
- const baseUrl = normalizeQaBaseUrl(inputBaseUrl, publicRouteSlug);
463
470
  // Packet 01: the analytics legs capture ONE URL derived from resolved
464
471
  // identity (public_route_slug + route_root), composed here where the raw
465
472
  // operator/deploy base is still in hand.
@@ -470,6 +477,7 @@ async function resolveQaInputs(args, {
470
477
  routeRoot: resolveCampaignRouteRoot({ packet, spec: normalized, rawSpec, publicRouteSlug, notes: routeRootNotes }),
471
478
  routeRootNote: routeRootNotes[0] || null,
472
479
  });
480
+ const baseUrl = normalizeQaBaseUrl(inputBaseUrl, publicRouteSlug);
473
481
  const specHash = computeSpecHash(rawSpec);
474
482
  // The brief material this run is judged against, bound at run start beside
475
483
  // the spec hash: the QA stage write stamps it, not the material at write time.
@@ -479,7 +487,16 @@ async function resolveQaInputs(args, {
479
487
  || stringArg(normalized?.campaign?.preferred_template_family)
480
488
  || null;
481
489
  const commerceStructureContract = loadCommerceStructureContract({ packet, packetPath, templateFamily });
482
- const topologies = extractTopologies(normalized, { baseUrl, publicRouteSlug, templateFamily, commerceStructureContract });
490
+ const targetRepo = checkpointPreflight?.targetRepo || (packetPath && packet ? targetRepoFor(packetPath, packet) : null);
491
+ const targetOutputDir = targetRepo && packet?.assembly?.output_dir ? resolve(targetRepo, packet.assembly.output_dir) : null;
492
+ const mappingById = new Map((packet?.source_html?.pages || []).map((page) => [page.page_id, page]));
493
+ const topologies = extractTopologies(normalized, {
494
+ baseUrl, publicRouteSlug, templateFamily, commerceStructureContract,
495
+ entryServingRoute: (page) => entryPageKitServingRoute(targetRepo, publicRouteSlug, page, {
496
+ targetOutputDir,
497
+ mapping: mappingById.get(page.id),
498
+ }),
499
+ });
483
500
  const themeGate = resolveThemeGate({
484
501
  packetPath,
485
502
  topologies,
@@ -966,7 +983,9 @@ function resolvePolishGate({
966
983
  return gate;
967
984
  }
968
985
 
969
- const THEME_GATE_COMMERCE_TYPES = new Set(["checkout", "upsell", "downsell", "receipt", "thankyou"]);
986
+ // select and checkout_step pages render the family's checkout surface without
987
+ // taking payment (campaigns-os#641); the brand theme applies to them as well.
988
+ const THEME_GATE_COMMERCE_TYPES = new Set(["select", "checkout_step", "checkout", "upsell", "downsell", "receipt", "thankyou"]);
970
989
 
971
990
  function themeGateScopeFromTopologies(topologies = []) {
972
991
  const built_pages = [];
@@ -1861,7 +1880,7 @@ function updateQaPolicy(args) {
1861
1880
  // the packet and before the packet write: refusals, so they journal nothing.
1862
1881
  const removedFlags = REMOVED_QA_POLICY_FLAGS.filter((flag) => flag in args);
1863
1882
  if (removedFlags.length) {
1864
- throw refused(`qa policy set: ${removedFlags.map((flag) => `--${flag}`).join(" and ")} ${removedFlags.length > 1 ? "were" : "was"} removed in supported surface 1.28.0 (test orders run from --test-order <mode> alone; there is no permission flag). Drop the flag${removedFlags.length > 1 ? "s" : ""}. Accepted: --allowed-domains-confirmed, --deploy-target, --preview-url, --production-url, --order-path-depth.`);
1883
+ throw refused(`qa policy set: ${removedFlags.map((flag) => `--${flag}`).join(" and ")} ${removedFlags.length > 1 ? "were" : "was"} removed in supported surface 1.28.0 (test orders use --test-order <mode>, with no permission flag; unless the operator has already said test orders are fine for this campaign, ask once, up front, before placing these real store order records). Drop the flag${removedFlags.length > 1 ? "s" : ""}. Accepted: --allowed-domains-confirmed, --deploy-target, --preview-url, --production-url, --order-path-depth.`);
1865
1884
  }
1866
1885
  // Validated with the other argv checks, before anything is written: a
1867
1886
  // refusal at this call site, like the removed-flag check above.
@@ -2136,11 +2155,34 @@ function parityReplayEvidence(bundle) {
2136
2155
  // validator's own.
2137
2156
  const refuseBadOrderCreationLimit = (args) => refusing(() => validatedOrderCreationLimit(args));
2138
2157
 
2158
+ // `qa run --currency <code>`: one run proves one currency. The code is three
2159
+ // ASCII letters, upper-cased; anything else (no value, a list, a symbol) is
2160
+ // refused here, before anything is resolved, fetched or launched. So is a run
2161
+ // whose orders are legacy direct API orders: that request carries no
2162
+ // currency, so the order would be placed in the default one.
2163
+ function qaRunCurrency(args) {
2164
+ if (!Object.hasOwn(args, "currency")) return null;
2165
+ const value = args.currency;
2166
+ if (typeof value !== "string" || !/^[A-Za-z]{3}$/.test(value)) {
2167
+ const got = typeof value === "string" && value !== "" ? JSON.stringify(value) : "no value";
2168
+ throw refused(`--currency takes one three-letter currency code, such as GBP or EUR (got ${got}). Run qa run once per currency.`);
2169
+ }
2170
+ const browserMode = String(args["test-order"] || "off").toLowerCase();
2171
+ const legacyMode = String(args["legacy-api-test-order"] || "off").toLowerCase();
2172
+ if (browserMode === "off" && legacyMode !== "off") {
2173
+ throw refused("--currency is not supported with --legacy-api-test-order: the direct API order carries no currency and would be placed in the default one. Use --test-order <mode> for orders in that currency, or drop --currency.");
2174
+ }
2175
+ return value.toUpperCase();
2176
+ }
2177
+
2139
2178
  async function runParityQa(args) {
2140
2179
  // Checked here as well as on the budget itself: the budget is built after a
2141
2180
  // browser has launched, and a flag the operator typed wrong should cost them
2142
2181
  // nothing. The budget stays the authority — this is fail-fast, not the gate.
2143
2182
  refuseBadOrderCreationLimit(args);
2183
+ if (Object.hasOwn(args, "currency")) {
2184
+ throw refused("qa parity does not support --currency: the scenario's fixture sets the currency it checks. Use qa run --currency <code> to prove a currency.");
2185
+ }
2144
2186
  const fixturePath = stringArg(args.fixture);
2145
2187
  const scenarioId = stringArg(args.scenario) || stringArg(args._[2]);
2146
2188
  if (!fixturePath) throw refused("QA parity requires --fixture <parity-fixture.json>.");
@@ -2211,6 +2253,7 @@ async function runQa(args, options = {}) {
2211
2253
  // Fail-fast before anything resolves or launches. The authoritative check
2212
2254
  // lives on the creation budget itself, which every browser path builds.
2213
2255
  refuseBadOrderCreationLimit(args);
2256
+ qaRunCurrency(args);
2214
2257
  // Match dispatch: an active browser mode takes precedence over legacy API
2215
2258
  // diagnostics. Only the selected legacy path requires a cart and API mode.
2216
2259
  const browserMode = String(args["test-order"] || "off").toLowerCase();
@@ -2225,7 +2268,15 @@ async function runQa(args, options = {}) {
2225
2268
  // `runSessionActive` is threaded in from the CLI's single ambient-session read
2226
2269
  // rather than re-discovered here, so the closeout command this run prints and
2227
2270
  // the run_id the session will close under come from the same observation.
2228
- async function runResolvedQa(args, resolved, { runSessionActive = false, liveCampaign = undefined, liveCampaignFetch = globalThis.fetch } = {}) {
2271
+ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, liveCampaign = undefined, liveCampaignFetch = globalThis.fetch } = {}) {
2272
+ // --currency: every leg below (HTTP rows, browser loads, test orders) reads
2273
+ // the entry page's URL from these topologies, so it is tagged once here;
2274
+ // the validated code rides on args, under QA_RUN_CURRENCY, for the order
2275
+ // runner's own first load of each path.
2276
+ const currency = qaRunCurrency(args);
2277
+ if (currency) args = { ...args, [QA_RUN_CURRENCY]: currency };
2278
+ const entryCurrency = withEntryCurrency(resolvedInputs.topologies, currency);
2279
+ const resolved = currency ? { ...resolvedInputs, topologies: entryCurrency.topologies } : resolvedInputs;
2229
2280
  const startedAt = new Date().toISOString();
2230
2281
  const runId = generateRunId();
2231
2282
  const gate = resolved.themeGate;
@@ -2317,7 +2368,14 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
2317
2368
  const bindingScriptLoader = createBindingScriptLoader();
2318
2369
  const pages = resolved.topologies.flatMap(topology => topology.pages);
2319
2370
  const pageResults = await mapConcurrent(pages, COMMERCIAL_QA_LIMITS.concurrency, page =>
2320
- runPageChecks(page, args, { sourceLoader, bindingExpected, bindingScriptLoader, captureCommercial: commercialIds.has(String(page.page_id)) }));
2371
+ runPageChecks(page, args, {
2372
+ sourceLoader,
2373
+ bindingExpected,
2374
+ bindingScriptLoader,
2375
+ captureCommercial: commercialIds.has(String(page.page_id)),
2376
+ currency: entryCurrency.entryPages.has(page) ? currency : null,
2377
+ currencyNotApplied: entryCurrency.notApplied.get(page) || null,
2378
+ }));
2321
2379
  const livePages = new Map();
2322
2380
  for (const [index, page] of pages.entries()) {
2323
2381
  const pageResult = pageResults[index];
@@ -2706,7 +2764,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2706
2764
  // The classified outcome (attempted, ok, error, endpoint, result,
2707
2765
  // http_status, base_kind) and the Run Record block derived from it.
2708
2766
  publish: { ...publishOutcome, response: undefined },
2709
- qa_verdict_publish: qaVerdictPublishBlock(publishOutcome, { verdictRunId: verdict.run_id, publisher: QA_VERDICT_PUBLISHERS.run, publishedAt: new Date().toISOString() }),
2767
+ qa_verdict_publish: qaVerdictPublishBlock(publishOutcome, { verdictRunId: verdict.run_id, publisher: QA_VERDICT_PUBLISHERS.run, publishedAt: new Date().toISOString(), reason: publishDecision.reason }),
2710
2768
  publish_decision: { ...publishDecision, destination: publishDestination, consent_state: consent?.state ?? null },
2711
2769
  counts: countAssertions(verdict.assertions),
2712
2770
  theme_gate: themeGateSummary(resolved.themeGate),
@@ -2765,14 +2823,50 @@ const ENTRY_PAGE_TYPES = new Set([
2765
2823
  "review",
2766
2824
  ]);
2767
2825
 
2826
+ function entryPageOf(topology) {
2827
+ const pages = Array.isArray(topology?.pages) ? topology.pages.filter((page) => page?.url) : [];
2828
+ if (!pages.length) return null;
2829
+ // #482: a partial build enters at its first in-scope page, which can be
2830
+ // an opted-in select or checkout rather than a later landing/presell.
2831
+ return topology.partial_build_scope ? pages[0] : pages.find(isEntryLikePage) || pages[0];
2832
+ }
2833
+
2834
+ // Each funnel's entry page gets ?currency=<CODE> through the URL API, so the
2835
+ // query string and tracking params it already carries are kept. Returns the
2836
+ // topologies unchanged without a currency; `entryPages` holds the tagged pages
2837
+ // and `notApplied` the entry pages whose URL did not parse, with the reason
2838
+ // their http row records.
2839
+ function withEntryCurrency(topologies, currency) {
2840
+ const entryPages = new Set();
2841
+ const notApplied = new Map();
2842
+ if (!currency) return { topologies, entryPages, notApplied };
2843
+ const tagged = topologyList(topologies).map((topology) => {
2844
+ const entry = entryPageOf(topology);
2845
+ // No URL at all: the page gets the route-url row, not an http row.
2846
+ if (!entry?.url) return topology;
2847
+ let url = null;
2848
+ try {
2849
+ url = new URL(entry.url);
2850
+ } catch {
2851
+ url = null;
2852
+ }
2853
+ if (!url) {
2854
+ notApplied.set(entry, { currency, reason: "entry URL does not parse as an absolute URL" });
2855
+ return topology;
2856
+ }
2857
+ url.searchParams.set("currency", currency);
2858
+ const page = { ...entry, url: url.toString() };
2859
+ entryPages.add(page);
2860
+ return { ...topology, pages: topology.pages.map((item) => (item === entry ? page : item)) };
2861
+ });
2862
+ return { topologies: tagged, entryPages, notApplied };
2863
+ }
2864
+
2768
2865
  function deriveEntryUrls(topologies) {
2769
2866
  const entries = [];
2770
2867
  for (const topology of topologyList(topologies)) {
2771
- const pages = Array.isArray(topology?.pages) ? topology.pages.filter((page) => page?.url) : [];
2772
- if (!pages.length) continue;
2773
- // #482: a partial build enters at its first in-scope page, which can be
2774
- // an opted-in select or checkout rather than a later landing/presell.
2775
- const page = topology.partial_build_scope ? pages[0] : pages.find(isEntryLikePage) || pages[0];
2868
+ const page = entryPageOf(topology);
2869
+ if (!page) continue;
2776
2870
  entries.push({
2777
2871
  funnel_id: topology.funnel_id || "default",
2778
2872
  funnel_name: topology.funnel_name || topology.funnel_id || "default",
@@ -2843,8 +2937,17 @@ async function runPageChecks(page, args, {
2843
2937
  captureCommercial = false,
2844
2938
  bindingExpected = { value: null },
2845
2939
  bindingScriptLoader = createBindingScriptLoader(),
2940
+ currency = null,
2941
+ currencyNotApplied = null,
2846
2942
  } = {}) {
2847
2943
  const assertions = [];
2944
+ // --currency is recorded on the entry page's HTTP row: the persisted
2945
+ // verdict drops every URL query, so the fetched URL cannot carry it. An
2946
+ // entry URL the currency could not be added to says so on the same row.
2947
+ const currencyEvidence = {
2948
+ ...(currency ? { currency } : {}),
2949
+ ...(currencyNotApplied ? { currency_not_applied: currencyNotApplied } : {}),
2950
+ };
2848
2951
  if (!page.url) {
2849
2952
  assertions.push(bindingAssertion(page, await observeBinding({ source: null, page, expected: bindingExpected, scriptLoader: bindingScriptLoader })));
2850
2953
  assertions.push(assertion({
@@ -2880,7 +2983,7 @@ async function runPageChecks(page, args, {
2880
2983
  severity: SEVERITY.BLOCKER,
2881
2984
  expected: isHttpStatus ? "2xx HTTP response" : "fetchable bounded deployed page",
2882
2985
  actual: isHttpStatus ? `${source.status} ${source.status_text}`.trim() : null,
2883
- evidence: { transport_error: { code: source.error_code, message: source.error } },
2986
+ evidence: { transport_error: { code: source.error_code, message: source.error }, ...currencyEvidence },
2884
2987
  }));
2885
2988
  return {
2886
2989
  assertions,
@@ -2898,6 +3001,7 @@ async function runPageChecks(page, args, {
2898
3001
  status: STATUS.PASS,
2899
3002
  expected: "2xx HTTP response",
2900
3003
  actual: `${source.status} ${source.status_text}`.trim(),
3004
+ evidence: Object.keys(currencyEvidence).length ? currencyEvidence : undefined,
2901
3005
  }));
2902
3006
 
2903
3007
  const expectedMeta = page.expected_meta_tags || {};
@@ -3117,9 +3221,9 @@ async function maybeRunTestOrders(
3117
3221
  return { orders: [], receiptAnalytics: emptyReceiptAnalytics(), qc_results: notRequestedRows() };
3118
3222
  }
3119
3223
  if (mode && mode !== "off") {
3120
- // Test Orders use global test cards: they bypass the payment gateway, create
3121
- // no transactions, and need no merchant setup or approval. `--test-order
3122
- // <mode>` is sufficient intent — no permission flags or packet policy gate.
3224
+ // Test Orders use global test cards: they bypass the payment gateway and create
3225
+ // no transactions, but each leaves a real store order record. Ask the operator
3226
+ // once in the first turn; `--test-order <mode>` has no permission flag or packet policy gate.
3123
3227
  const result = await operations.runBrowser(resolved.topologies, args, runId, { captureAnalytics, spec: resolved.spec });
3124
3228
  assertions.push(...result.assertions);
3125
3229
  return {
@@ -3144,7 +3248,10 @@ async function maybeRunLegacyApiTestOrders({ args, resolved, runId, assertions }
3144
3248
  const apiBase = stringArg(args["campaigns-api-base"]) || process.env.CAMPAIGNS_API_BASE;
3145
3249
  if (!apiKey || !apiBase) throw new Error("Legacy direct API test orders require --api-key/QA_CAMPAIGNS_API_KEY and --campaigns-api-base/CAMPAIGNS_API_BASE.");
3146
3250
  const { cart, paths } = legacyTestOrderInputs(args);
3147
- const checkout = findPage(resolved.topologies, "checkout");
3251
+ // The Checkout on the tested path, never the first page typed checkout: on a
3252
+ // split checkout that was the first step, whose forward link is the next
3253
+ // step, so the order's success URL pointed back into checkout (#641).
3254
+ const checkout = findPaymentPage(resolved.topologies);
3148
3255
  if (!checkout?.url) throw new Error("--test-order requires a checkout page URL.");
3149
3256
  const upsell = findPage(resolved.topologies, "upsell");
3150
3257
  const orders = [];
@@ -3228,22 +3335,25 @@ async function createTestOrder({ apiBase, apiKey, cart, runId, successUrl, spec,
3228
3335
  }
3229
3336
  }
3230
3337
 
3338
+ // QA reads the v5 page vocabulary: a v4 spec's `checkout → checkout` chain is
3339
+ // retyped `checkout_step` on read (campaigns-os#641). The raw spec is untouched,
3340
+ // so the spec hash still covers the bytes as written.
3231
3341
  function normalizeSpec(raw) {
3232
- if (Array.isArray(raw?.funnels)) return raw;
3342
+ if (Array.isArray(raw?.funnels)) return upgradeCampaignSpec(raw);
3233
3343
  if (Array.isArray(raw?.funnel_pages)) {
3234
3344
  return { ...raw, funnels: [{ id: "default", name: "Default", weight: 100, pages: raw.funnel_pages }] };
3235
3345
  }
3236
3346
  return { ...raw, funnels: [] };
3237
3347
  }
3238
3348
 
3239
- function extractTopologies(spec, { baseUrl = null, publicRouteSlug = null, templateFamily = null, commerceStructureContract = null } = {}) {
3349
+ function extractTopologies(spec, { baseUrl = null, publicRouteSlug = null, templateFamily = null, commerceStructureContract = null, entryServingRoute = null } = {}) {
3240
3350
  const pageById = new Map();
3241
3351
  for (const funnel of spec.funnels || []) {
3242
3352
  for (const page of funnel.pages || []) pageById.set(page.id, page);
3243
3353
  }
3244
3354
  const urlById = new Map();
3245
3355
  for (const [id, page] of pageById) {
3246
- urlById.set(id, resolvePageUrl(page, baseUrl, publicRouteSlug));
3356
+ urlById.set(id, resolvePageUrl(page, baseUrl, publicRouteSlug, entryServingRoute?.(page)));
3247
3357
  }
3248
3358
  return (spec.funnels || []).map((funnel) => ({
3249
3359
  funnel_id: funnel.id || "default",
@@ -3298,13 +3408,13 @@ function extractTopologies(spec, { baseUrl = null, publicRouteSlug = null, templ
3298
3408
  }));
3299
3409
  }
3300
3410
 
3301
- function resolvePageUrl(page, baseUrl, publicRouteSlug = null) {
3411
+ function resolvePageUrl(page, baseUrl, publicRouteSlug = null, entryServingRoute = null) {
3302
3412
  if (typeof page.url === "string" && page.url.trim()) return page.url.trim();
3303
3413
  if (!baseUrl) return null;
3304
- const route = typeof page.page_url === "string" && page.page_url.trim()
3305
- ? runtimeRelativeRouteForSpecValue(page.page_url, publicRouteSlug)
3306
- : page.is_entry
3307
- ? ""
3414
+ const route = isRootRoutedEntryPage(page, publicRouteSlug)
3415
+ ? entryServingRoute?.route || ""
3416
+ : typeof page.page_url === "string" && page.page_url.trim()
3417
+ ? runtimeRelativeRouteForSpecValue(page.page_url, publicRouteSlug)
3308
3418
  : defaultRouteForType(page.type);
3309
3419
  if (isAbsoluteHttpUrl(route)) return route;
3310
3420
  try {
@@ -3316,6 +3426,7 @@ function resolvePageUrl(page, baseUrl, publicRouteSlug = null) {
3316
3426
 
3317
3427
  function defaultRouteForType(type) {
3318
3428
  if (type === "thankyou") return "receipt/";
3429
+ if (type === "checkout_step") return "checkout-step/";
3319
3430
  if (["presell", "landing", "checkout", "upsell", "downsell"].includes(type)) return `${type}/`;
3320
3431
  return `${type || "page"}/`;
3321
3432
  }
@@ -3462,6 +3573,9 @@ function output(value, args) {
3462
3573
  printEntryUrlLines(value.entry_urls);
3463
3574
  console.log(`Run ID: ${value.run_id}`);
3464
3575
  console.log(`Disposition: ${value.verdict.disposition}`);
3576
+ if (value.verdict.disposition === "ready_with_exceptions") {
3577
+ console.log("This is a passing proof with exceptions. Report these exceptions to the operator; do not clear or waive them, or change markup just to make them pass.");
3578
+ }
3465
3579
  console.log(`Counts: ${Object.entries(value.counts).map(([status, count]) => `${count} ${status}`).join(", ")}`);
3466
3580
  printCauseLines(value.verdict);
3467
3581
  printThemeGateLines(value.theme_gate, value.packet_path, value.report_path);
@@ -3584,14 +3698,21 @@ function printCauseLines(verdict) {
3584
3698
  // One formatter, shared with the doctor report: a prior record that exists
3585
3699
  // but has no usable QA verdict is not the same state as no prior record, and
3586
3700
  // the two commands must not describe it differently.
3587
- for (const line of formatCauseReportLines(verdict?.cause_summary)) console.log(line);
3701
+ if (verdict?.disposition !== "ready_with_exceptions") {
3702
+ for (const line of formatCauseReportLines(verdict?.cause_summary)) console.log(line);
3703
+ }
3588
3704
  const exceptions = Array.isArray(verdict.exceptions) ? verdict.exceptions : [];
3589
3705
  if (!exceptions.length) return;
3590
3706
  console.log("Findings:");
3591
3707
  for (const exception of exceptions) {
3592
- const identity = [exception.id, exception.page].filter(Boolean).join(" @ ") || "(unidentified finding)";
3593
3708
  const tag = formatCauseTag(exception);
3594
- console.log(`- ${identity} (${exception.status || "unknown"})${tag ? ` ${tag}` : ""}`);
3709
+ if (verdict.disposition === "ready_with_exceptions") {
3710
+ const severity = exception.severity ? `; severity: ${exception.severity}` : "";
3711
+ console.log(`- id: ${exception.id || "(none)"}; page: ${exception.page || "(none)"} (${exception.status || "unknown"}${severity})${tag ? ` ${tag}` : ""}`);
3712
+ } else {
3713
+ const identity = [exception.id, exception.page].filter(Boolean).join(" @ ") || "(unidentified finding)";
3714
+ console.log(`- ${identity} (${exception.status || "unknown"})${tag ? ` ${tag}` : ""}`);
3715
+ }
3595
3716
  }
3596
3717
  }
3597
3718
 
@@ -3606,7 +3727,7 @@ export function themeGateLines(themeGate, packetPath = null, reportPath = null)
3606
3727
  for (const action of themeGate.required_actions || []) {
3607
3728
  lines.push(` - ${requiredActionText(action, { packetPath, reportPath })}`);
3608
3729
  }
3609
- lines.push("Or rerun with --theme-waive \"<reason>\" to record an ephemeral waiver for this run.");
3730
+ lines.push('Or rerun once with --theme-waive "<reason>"; this waiver applies only to that run. A recorded theme waive requires --waived-by "<named human>".');
3610
3731
  return lines;
3611
3732
  }
3612
3733
 
@@ -3644,8 +3765,8 @@ export function qaResolveNextProofLines(value) {
3644
3765
  `Next expected proof: ${qaRunCommandFromResolve(value)}`,
3645
3766
  `Entry URL(s) resolved: ${formatEntryUrlsForProof(value.entry_urls)}`,
3646
3767
  value.local_spec_id
3647
- ? "Typed-card test orders use global test cards (no transactions/no permission gate); local-spec QA stays in the repository."
3648
- : "Typed-card test orders use global test cards (no transactions/no permission gate); QA publishes to the portal by default.",
3768
+ ? "Typed-card test cards create no transactions but leave real store order records. Unless the operator has already said test orders are fine for this campaign, ask once, up front, before placing them; there is no permission flag. Local-spec QA stays in the repository."
3769
+ : "Typed-card test cards create no transactions but leave real store order records. Unless the operator has already said test orders are fine for this campaign, ask once, up front, before placing them; there is no permission flag. QA publishes to the portal by default.",
3649
3770
  ];
3650
3771
  }
3651
3772
 
@@ -3829,21 +3950,6 @@ function normalizeBaseUrl(value) {
3829
3950
  return typeof value === "string" && value.trim() ? value.trim() : null;
3830
3951
  }
3831
3952
 
3832
- function normalizeQaBaseUrl(value, publicRouteSlug) {
3833
- const baseUrl = normalizeBaseUrl(value);
3834
- if (!baseUrl) return null;
3835
- const slug = normalizePublicRouteSlug(publicRouteSlug);
3836
- if (!slug) return ensureUrlTrailingSlash(baseUrl);
3837
- try {
3838
- const url = new URL(ensureUrlTrailingSlash(baseUrl));
3839
- const segments = url.pathname.split("/").filter(Boolean);
3840
- if (segments.at(-1) === slug) return ensureUrlTrailingSlash(url.toString());
3841
- return new URL(`${slug}/`, url).toString();
3842
- } catch {
3843
- return ensureUrlTrailingSlash(baseUrl);
3844
- }
3845
- }
3846
-
3847
3953
  // The campaign's served route root — "/", "/<slug>/", or null — read by the
3848
3954
  // one rule every stage shares (route-identity.mjs): the packet under its exact
3849
3955
  // canonical form, the spec under prepare-build's intake form. A declaration
@@ -3951,10 +4057,6 @@ function resolvePublicRouteSlug({ packet, spec, rawSpec }) {
3951
4057
  || null;
3952
4058
  }
3953
4059
 
3954
- function ensureUrlTrailingSlash(value) {
3955
- return value.endsWith("/") ? value : `${value}/`;
3956
- }
3957
-
3958
4060
  function stripOrigin(value) {
3959
4061
  try {
3960
4062
  const url = new URL(value);
@@ -4023,16 +4125,23 @@ function htmlIncludesRouteReference(html, expectedUrl) {
4023
4125
  return html.includes(expectedUrl) || html.includes(path);
4024
4126
  }
4025
4127
 
4026
- // A data-upsell-proxy button forwards its click to the SDK action inside the
4027
- // offer. With no such action, the route's URL can still sit in the page's meta
4028
- // tags, so the reference match would pass a control that does nothing.
4128
+ // A data-upsell-proxy button forwards its click to the SDK action of its own
4129
+ // spelling inside the offer. With no such action, the route's URL can still sit
4130
+ // in the page's meta tags, so the reference match would pass a control that
4131
+ // does nothing.
4029
4132
  function findDeadUpsellProxy(html, kind, page) {
4030
4133
  if (page.page_type !== "upsell" || kind === "next") return null;
4031
- const action = kind === "accept" ? "add" : "skip";
4032
- if (!new RegExp(`\\bdata-upsell-proxy\\s*=\\s*["']${action}["']`, "i").test(html)) return null;
4033
- const target = new RegExp(`\\bdata-next-upsell-action\\s*=\\s*["']${action}["']`, "i");
4034
- if (upsellOfferElements(html).some((offer) => target.test(offer))) return null;
4035
- return `data-upsell-proxy="${action}" with no data-next-upsell-action="${action}" inside the offer to forward to`;
4134
+ for (const action of UPSELL_ACTION_SPELLINGS[kind]) {
4135
+ if (!new RegExp(`\\bdata-upsell-proxy\\s*=\\s*["']${action}["']`, "i").test(html)) continue;
4136
+ const target = upsellActionPattern(action);
4137
+ if (upsellOfferElements(html).some((offer) => target.test(offer))) continue;
4138
+ return `data-upsell-proxy="${action}" with no data-next-upsell-action="${action}" inside the offer to forward to`;
4139
+ }
4140
+ return null;
4141
+ }
4142
+
4143
+ function upsellActionPattern(action) {
4144
+ return new RegExp(`\\bdata-next-upsell-action\\s*=\\s*["']${action}["']`, "i");
4036
4145
  }
4037
4146
 
4038
4147
  // The markup of each [data-next-upsell="offer"] element, read to its closing
@@ -4061,14 +4170,9 @@ function upsellOfferElements(html) {
4061
4170
  }
4062
4171
 
4063
4172
  function findSdkRouteAction(html, kind, page) {
4064
- if (page.page_type !== "upsell") return null;
4065
- if (kind === "accept" && /\bdata-next-upsell-action\s*=\s*["']add["']/i.test(html)) {
4066
- return 'SDK upsell accept control: data-next-upsell-action="add"';
4067
- }
4068
- if (kind === "decline" && /\bdata-next-upsell-action\s*=\s*["']skip["']/i.test(html)) {
4069
- return 'SDK upsell decline control: data-next-upsell-action="skip"';
4070
- }
4071
- return null;
4173
+ if (page.page_type !== "upsell" || kind === "next") return null;
4174
+ const action = UPSELL_ACTION_SPELLINGS[kind].find((spelling) => upsellActionPattern(spelling).test(html));
4175
+ return action ? `SDK upsell ${kind} control: data-next-upsell-action="${action}"` : null;
4072
4176
  }
4073
4177
 
4074
4178
  function decodeHtml(value) {
@@ -4128,6 +4232,7 @@ function extractApiError(raw) {
4128
4232
  }
4129
4233
 
4130
4234
  export const __qaNodeTestHooks = Object.freeze({
4235
+ output,
4131
4236
  extractTopologies,
4132
4237
  validatedOrderCreationLimit,
4133
4238
  resolveQaInputs,
@@ -4152,6 +4257,8 @@ export const __qaNodeTestHooks = Object.freeze({
4152
4257
  themeGateSummary,
4153
4258
  templateBrandContractAssertion,
4154
4259
  deriveEntryUrls,
4260
+ qaRunCurrency,
4261
+ withEntryCurrency,
4155
4262
  derivePageUrls,
4156
4263
  deriveTestedUrlsFromAssertions,
4157
4264
  resolvePayload,