@nextcommerce/campaigns-os 1.53.0 → 1.55.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 (71) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/CONTEXT.md +8 -5
  3. package/README.md +26 -11
  4. package/compatibility.json +1 -1
  5. package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
  6. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  7. package/contracts/commerce-surface-catalog.json +27 -27
  8. package/contracts/effects.v1.json +96 -37
  9. package/contracts/release-ledger.json +774 -0
  10. package/contracts/supported-surface.json +4 -4
  11. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  12. package/docs/brand-theme-bridge.md +10 -0
  13. package/docs/build-packet.md +45 -22
  14. package/docs/campaign-standardization-report.md +32 -0
  15. package/docs/campaigns-os-build-flow.md +1 -1
  16. package/docs/design-source-package.md +7 -3
  17. package/docs/effects.md +4 -6
  18. package/docs/local-setup.md +1 -1
  19. package/docs/orientation-contract-reference.md +1 -1
  20. package/docs/progress-snapshots.md +3 -1
  21. package/docs/qa-and-test-orders.md +83 -14
  22. package/docs/runtime-readiness.md +1 -1
  23. package/docs/sdk-storage-compatibility.md +15 -0
  24. package/docs/skills-revision.md +10 -10
  25. package/docs/supported-surface.md +2 -1
  26. package/docs/workflow-findings-sidecar.md +57 -2
  27. package/package.json +3 -1
  28. package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
  29. package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
  30. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  31. package/skills/campaign-readback-classification/SKILL.md +3 -3
  32. package/skills/campaign-run-evidence/SKILL.md +3 -3
  33. package/skills/contribution-intake/SKILL.md +3 -3
  34. package/skills/next-campaigns-build/SKILL.md +3 -3
  35. package/skills/next-campaigns-os/SKILL.md +3 -3
  36. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  37. package/skills/next-campaigns-polish/SKILL.md +3 -3
  38. package/skills/next-campaigns-qa/SKILL.md +4 -4
  39. package/skills.json +10 -10
  40. package/src/adapter-decision-contract.mjs +26 -12
  41. package/src/brand-theme.mjs +44 -8
  42. package/src/campaign-ecosystem.mjs +153 -31
  43. package/src/campaign-identity.mjs +33 -4
  44. package/src/cli.mjs +58 -62
  45. package/src/consent.mjs +3 -2
  46. package/src/design-source-publication.mjs +1 -1
  47. package/src/deviation.mjs +2 -1
  48. package/src/directory-lock.mjs +79 -7
  49. package/src/doctor/checks.mjs +28 -8
  50. package/src/doctor/inspect.mjs +13 -0
  51. package/src/doctor/next-step.mjs +16 -6
  52. package/src/invocation.mjs +7 -4
  53. package/src/lifecycle.mjs +88 -6
  54. package/src/local-preview-policy.mjs +6 -0
  55. package/src/login.mjs +2 -1
  56. package/src/polish-browser.mjs +8 -2
  57. package/src/polish-node.mjs +6 -1
  58. package/src/polish-readability.mjs +2 -2
  59. package/src/progress-node.mjs +3 -2
  60. package/src/qa-browser.mjs +595 -95
  61. package/src/qa-cart-entry.mjs +29 -6
  62. package/src/qa-node.mjs +177 -43
  63. package/src/qa-verdict-publish.mjs +2 -1
  64. package/src/run-record.mjs +46 -34
  65. package/src/sdk-attribute-index.mjs +111 -23
  66. package/src/sdk-markup.mjs +89 -10
  67. package/src/sdk-repin.mjs +282 -0
  68. package/src/stage-ledger.mjs +15 -3
  69. package/src/stage-record.mjs +85 -17
  70. package/src/standardization-report.mjs +26 -1
  71. package/src/target-lock.mjs +9 -1
@@ -70,12 +70,21 @@ export const CART_ENTRY_ROUTE_ATTRIBUTE = "data-next-url";
70
70
  // preventDefault() unconditionally, so its href never navigates, and it
71
71
  // resolves the attribute against the origin (campaign-cart url-utils), not the
72
72
  // document base. Without the attribute such a control adds to the cart and
73
- // stays put — no route. Anywhere else the SDK attribute has no navigation
73
+ // stays put — no route. A control matching one of `declaredRoutes` (each
74
+ // `{ selector, url }`, a route the page declares rather than one the control
75
+ // carries: the SDK checkout form's submit button and an upsell's accept /
76
+ // decline actions) goes to that url and nothing else, because the SDK
77
+ // handles the submission or the click itself, so an href="#" or the form's
78
+ // action never navigates. Anywhere else the SDK attribute has no navigation
74
79
  // semantics: an HTML anchor's own resolved href (native, so a <base href> is
75
80
  // honoured), then an href attribute, then `data-href`, then a wrapping form's
76
- // action. Only spellings with real navigation semantics count — an attribute
77
- // the SDK does not declare is not a route.
78
- export function cartEntryHrefFor(element, { cartEntrySelector, cartEntryRouteAttribute, origin, baseHref }) {
81
+ // action — for that form's submit button only, and never inside a form
82
+ // matching `checkoutFormSelector`: the SDK checkout form, which the SDK
83
+ // submits itself, or, on a checkout or upsell / downsell page, every form,
84
+ // since there the only form-borne route is the one in `declaredRoutes`. Only
85
+ // spellings with real navigation semantics count — an attribute the SDK does
86
+ // not declare is not a route.
87
+ export function cartEntryHrefFor(element, { cartEntrySelector, cartEntryRouteAttribute, declaredRoutes = [], checkoutFormSelector = null, origin, baseHref }) {
79
88
  if (!element || typeof element.getAttribute !== "function") return null;
80
89
  if (typeof element.matches === "function" && element.matches(cartEntrySelector)) {
81
90
  const sdkRoute = String(element.getAttribute(cartEntryRouteAttribute) || "").trim();
@@ -88,12 +97,26 @@ export function cartEntryHrefFor(element, { cartEntrySelector, cartEntryRouteAtt
88
97
  return null;
89
98
  }
90
99
  }
100
+ const declared = typeof element.matches === "function"
101
+ ? (declaredRoutes || []).find((route) => route?.selector && element.matches(route.selector))
102
+ : null;
103
+ if (declared) {
104
+ try {
105
+ return new URL(declared.url, baseHref).href;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
91
110
  // An HTML anchor resolves its own href (an SVG <a> exposes an object, not a
92
111
  // string, and falls through to the attribute reading).
93
- if (String(element.tagName || "").toUpperCase() === "A" && typeof element.href === "string" && element.href) return element.href;
112
+ const tag = String(element.tagName || "").toUpperCase();
113
+ if (tag === "A" && typeof element.href === "string" && element.href) return element.href;
114
+ // The reflected type, so a <button> with no type attribute submits too.
115
+ const submits = (tag === "BUTTON" || tag === "INPUT") && String(element.type || "").toLowerCase() === "submit";
116
+ const insideRoutelessForm = Boolean(checkoutFormSelector) && typeof element.closest === "function" && Boolean(element.closest(checkoutFormSelector));
94
117
  const attr = element.getAttribute("href")
95
118
  || element.getAttribute("data-href")
96
- || (typeof element.closest === "function" ? element.closest("form")?.getAttribute("action") : null);
119
+ || (submits && !insideRoutelessForm && typeof element.closest === "function" ? element.closest("form")?.getAttribute("action") : null);
97
120
  if (!attr) return null;
98
121
  try {
99
122
  return new URL(attr, baseHref).href;
package/src/qa-node.mjs CHANGED
@@ -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";
@@ -128,7 +128,7 @@ const HELP = `campaigns-os qa — Node/npm spec-aware QA
128
128
  Usage:
129
129
  campaigns-os qa parity --fixture <parity-fixture.json> --scenario <scenario-id> [--base-url <override>] [--baseline <url>] [--parity-order-json <file>] [--no-post-verdict]
130
130
  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]
131
+ campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--currency <code>] [--output-dir <dir>] [--no-remit] [--no-live-refs] [--json]
132
132
  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
133
  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
134
  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
@@ -168,7 +168,9 @@ Options:
168
168
  qa publish reads the same directory when looking up the sidecar's run.
169
169
  --post-verdict (default) Publish the verdict to the QA portal at
170
170
  <proxy-base>/api/qa/verdicts and print the QA portal link.
171
- Publishing is automatic; this flag is retained for clarity.
171
+ Publishing is automatic, except for a run whose base URL is a local
172
+ address (localhost, *.localhost, 127.x.x.x, 0.0.0.0, [::1], an IPv4-mapped loopback): that verdict stays local unless
173
+ this flag is passed.
172
174
  --no-post-verdict, --local-only Skip publishing; write only the local verdict copy (offline / dev / CI).
173
175
  Publish it later, without a re-run, with qa publish.
174
176
  --verdict <path> qa promote / qa publish: the full verdict file under qa-output/. qa publish
@@ -187,6 +189,10 @@ Options:
187
189
  --no-live-refs qa run: skip the one read-only GET of <proxy-base>/api/campaign that checks each
188
190
  served page's shipping and package refs against the live campaign; the verdict
189
191
  records the check not_run with reason disabled, never a pass.
192
+ --currency <code> qa run: enter each funnel's entry page with ?currency=<CODE> (three letters, e.g. GBP),
193
+ keeping its existing query. The flag takes one code per run (given twice, the
194
+ last one is used); the verdict records it as evidence.currency on the entry
195
+ page's http:<page> row. Not supported by qa parity or --legacy-api-test-order.
190
196
  --auth-cookie <cookie> Cookie header for protected previews.
191
197
  --browser Run Playwright-rendered browser checks after static Node checks.
192
198
  Requires one-time setup: campaigns-os qa install-browser
@@ -197,8 +203,9 @@ Options:
197
203
  --browser-timeout <ms> Browser navigation timeout. Default: 30000.
198
204
  --test-order <off|common|checkout|accept|decline|both|full|tiers[:checkout|common|full]|accept-decline[-accept...]>
199
205
  Create Playwright typed-card test orders through the tested checkout page.
200
- Test cards bypass the gateway and create no transactions, so no permission
201
- flags or packet policy are needed — just pick a mode. Default mode (bare
206
+ Test cards bypass the gateway and create no transactions, but each creates a real
207
+ store order record. Unless the operator has already said test orders are fine for this campaign, ask once, up front, before placing orders.
208
+ No permission flag or packet policy is needed. Default mode (bare
202
209
  --test-order, or "common") runs every actual terminal path when they fit under
203
210
  the cap (--max-test-orders, default 6). Above the cap it runs checkout,
204
211
  first-offer accept/decline, and a deduplicated shortest real receipt path, then
@@ -1859,7 +1866,7 @@ function updateQaPolicy(args) {
1859
1866
  // the packet and before the packet write: refusals, so they journal nothing.
1860
1867
  const removedFlags = REMOVED_QA_POLICY_FLAGS.filter((flag) => flag in args);
1861
1868
  if (removedFlags.length) {
1862
- 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.`);
1869
+ 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.`);
1863
1870
  }
1864
1871
  // Validated with the other argv checks, before anything is written: a
1865
1872
  // refusal at this call site, like the removed-flag check above.
@@ -2134,11 +2141,34 @@ function parityReplayEvidence(bundle) {
2134
2141
  // validator's own.
2135
2142
  const refuseBadOrderCreationLimit = (args) => refusing(() => validatedOrderCreationLimit(args));
2136
2143
 
2144
+ // `qa run --currency <code>`: one run proves one currency. The code is three
2145
+ // ASCII letters, upper-cased; anything else (no value, a list, a symbol) is
2146
+ // refused here, before anything is resolved, fetched or launched. So is a run
2147
+ // whose orders are legacy direct API orders: that request carries no
2148
+ // currency, so the order would be placed in the default one.
2149
+ function qaRunCurrency(args) {
2150
+ if (!Object.hasOwn(args, "currency")) return null;
2151
+ const value = args.currency;
2152
+ if (typeof value !== "string" || !/^[A-Za-z]{3}$/.test(value)) {
2153
+ const got = typeof value === "string" && value !== "" ? JSON.stringify(value) : "no value";
2154
+ throw refused(`--currency takes one three-letter currency code, such as GBP or EUR (got ${got}). Run qa run once per currency.`);
2155
+ }
2156
+ const browserMode = String(args["test-order"] || "off").toLowerCase();
2157
+ const legacyMode = String(args["legacy-api-test-order"] || "off").toLowerCase();
2158
+ if (browserMode === "off" && legacyMode !== "off") {
2159
+ 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.");
2160
+ }
2161
+ return value.toUpperCase();
2162
+ }
2163
+
2137
2164
  async function runParityQa(args) {
2138
2165
  // Checked here as well as on the budget itself: the budget is built after a
2139
2166
  // browser has launched, and a flag the operator typed wrong should cost them
2140
2167
  // nothing. The budget stays the authority — this is fail-fast, not the gate.
2141
2168
  refuseBadOrderCreationLimit(args);
2169
+ if (Object.hasOwn(args, "currency")) {
2170
+ 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.");
2171
+ }
2142
2172
  const fixturePath = stringArg(args.fixture);
2143
2173
  const scenarioId = stringArg(args.scenario) || stringArg(args._[2]);
2144
2174
  if (!fixturePath) throw refused("QA parity requires --fixture <parity-fixture.json>.");
@@ -2209,6 +2239,7 @@ async function runQa(args, options = {}) {
2209
2239
  // Fail-fast before anything resolves or launches. The authoritative check
2210
2240
  // lives on the creation budget itself, which every browser path builds.
2211
2241
  refuseBadOrderCreationLimit(args);
2242
+ qaRunCurrency(args);
2212
2243
  // Match dispatch: an active browser mode takes precedence over legacy API
2213
2244
  // diagnostics. Only the selected legacy path requires a cart and API mode.
2214
2245
  const browserMode = String(args["test-order"] || "off").toLowerCase();
@@ -2223,7 +2254,15 @@ async function runQa(args, options = {}) {
2223
2254
  // `runSessionActive` is threaded in from the CLI's single ambient-session read
2224
2255
  // rather than re-discovered here, so the closeout command this run prints and
2225
2256
  // the run_id the session will close under come from the same observation.
2226
- async function runResolvedQa(args, resolved, { runSessionActive = false, liveCampaign = undefined, liveCampaignFetch = globalThis.fetch } = {}) {
2257
+ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, liveCampaign = undefined, liveCampaignFetch = globalThis.fetch } = {}) {
2258
+ // --currency: every leg below (HTTP rows, browser loads, test orders) reads
2259
+ // the entry page's URL from these topologies, so it is tagged once here;
2260
+ // the validated code rides on args, under QA_RUN_CURRENCY, for the order
2261
+ // runner's own first load of each path.
2262
+ const currency = qaRunCurrency(args);
2263
+ if (currency) args = { ...args, [QA_RUN_CURRENCY]: currency };
2264
+ const entryCurrency = withEntryCurrency(resolvedInputs.topologies, currency);
2265
+ const resolved = currency ? { ...resolvedInputs, topologies: entryCurrency.topologies } : resolvedInputs;
2227
2266
  const startedAt = new Date().toISOString();
2228
2267
  const runId = generateRunId();
2229
2268
  const gate = resolved.themeGate;
@@ -2315,7 +2354,14 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
2315
2354
  const bindingScriptLoader = createBindingScriptLoader();
2316
2355
  const pages = resolved.topologies.flatMap(topology => topology.pages);
2317
2356
  const pageResults = await mapConcurrent(pages, COMMERCIAL_QA_LIMITS.concurrency, page =>
2318
- runPageChecks(page, args, { sourceLoader, bindingExpected, bindingScriptLoader, captureCommercial: commercialIds.has(String(page.page_id)) }));
2357
+ runPageChecks(page, args, {
2358
+ sourceLoader,
2359
+ bindingExpected,
2360
+ bindingScriptLoader,
2361
+ captureCommercial: commercialIds.has(String(page.page_id)),
2362
+ currency: entryCurrency.entryPages.has(page) ? currency : null,
2363
+ currencyNotApplied: entryCurrency.notApplied.get(page) || null,
2364
+ }));
2319
2365
  const livePages = new Map();
2320
2366
  for (const [index, page] of pages.entries()) {
2321
2367
  const pageResult = pageResults[index];
@@ -2668,7 +2714,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2668
2714
  const consent = resolveConsent({ proxyBase: resolved.proxyBase });
2669
2715
  const publishDecision = resolved.localSpecId
2670
2716
  ? { publish: false, reason: "local_spec", flag_invalid: false }
2671
- : decidePublishVerdict({ args, portalManaged: resolved.portalManaged === true, consent });
2717
+ : decidePublishVerdict({ args, portalManaged: resolved.portalManaged === true, consent, baseUrl: resolved.baseUrl });
2672
2718
  if (publishDecision.flag_invalid) {
2673
2719
  process.stderr.write(`[campaigns-os] --post-verdict "${args["post-verdict"]}" is not a recognized value (use true|1|yes|y|on or false|0|no|n|off); the flag was ignored and the default publish decision applied.\n`);
2674
2720
  }
@@ -2704,7 +2750,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2704
2750
  // The classified outcome (attempted, ok, error, endpoint, result,
2705
2751
  // http_status, base_kind) and the Run Record block derived from it.
2706
2752
  publish: { ...publishOutcome, response: undefined },
2707
- qa_verdict_publish: qaVerdictPublishBlock(publishOutcome, { verdictRunId: verdict.run_id, publisher: QA_VERDICT_PUBLISHERS.run, publishedAt: new Date().toISOString() }),
2753
+ qa_verdict_publish: qaVerdictPublishBlock(publishOutcome, { verdictRunId: verdict.run_id, publisher: QA_VERDICT_PUBLISHERS.run, publishedAt: new Date().toISOString(), reason: publishDecision.reason }),
2708
2754
  publish_decision: { ...publishDecision, destination: publishDestination, consent_state: consent?.state ?? null },
2709
2755
  counts: countAssertions(verdict.assertions),
2710
2756
  theme_gate: themeGateSummary(resolved.themeGate),
@@ -2763,14 +2809,50 @@ const ENTRY_PAGE_TYPES = new Set([
2763
2809
  "review",
2764
2810
  ]);
2765
2811
 
2812
+ function entryPageOf(topology) {
2813
+ const pages = Array.isArray(topology?.pages) ? topology.pages.filter((page) => page?.url) : [];
2814
+ if (!pages.length) return null;
2815
+ // #482: a partial build enters at its first in-scope page, which can be
2816
+ // an opted-in select or checkout rather than a later landing/presell.
2817
+ return topology.partial_build_scope ? pages[0] : pages.find(isEntryLikePage) || pages[0];
2818
+ }
2819
+
2820
+ // Each funnel's entry page gets ?currency=<CODE> through the URL API, so the
2821
+ // query string and tracking params it already carries are kept. Returns the
2822
+ // topologies unchanged without a currency; `entryPages` holds the tagged pages
2823
+ // and `notApplied` the entry pages whose URL did not parse, with the reason
2824
+ // their http row records.
2825
+ function withEntryCurrency(topologies, currency) {
2826
+ const entryPages = new Set();
2827
+ const notApplied = new Map();
2828
+ if (!currency) return { topologies, entryPages, notApplied };
2829
+ const tagged = topologyList(topologies).map((topology) => {
2830
+ const entry = entryPageOf(topology);
2831
+ // No URL at all: the page gets the route-url row, not an http row.
2832
+ if (!entry?.url) return topology;
2833
+ let url = null;
2834
+ try {
2835
+ url = new URL(entry.url);
2836
+ } catch {
2837
+ url = null;
2838
+ }
2839
+ if (!url) {
2840
+ notApplied.set(entry, { currency, reason: "entry URL does not parse as an absolute URL" });
2841
+ return topology;
2842
+ }
2843
+ url.searchParams.set("currency", currency);
2844
+ const page = { ...entry, url: url.toString() };
2845
+ entryPages.add(page);
2846
+ return { ...topology, pages: topology.pages.map((item) => (item === entry ? page : item)) };
2847
+ });
2848
+ return { topologies: tagged, entryPages, notApplied };
2849
+ }
2850
+
2766
2851
  function deriveEntryUrls(topologies) {
2767
2852
  const entries = [];
2768
2853
  for (const topology of topologyList(topologies)) {
2769
- const pages = Array.isArray(topology?.pages) ? topology.pages.filter((page) => page?.url) : [];
2770
- if (!pages.length) continue;
2771
- // #482: a partial build enters at its first in-scope page, which can be
2772
- // an opted-in select or checkout rather than a later landing/presell.
2773
- const page = topology.partial_build_scope ? pages[0] : pages.find(isEntryLikePage) || pages[0];
2854
+ const page = entryPageOf(topology);
2855
+ if (!page) continue;
2774
2856
  entries.push({
2775
2857
  funnel_id: topology.funnel_id || "default",
2776
2858
  funnel_name: topology.funnel_name || topology.funnel_id || "default",
@@ -2841,8 +2923,17 @@ async function runPageChecks(page, args, {
2841
2923
  captureCommercial = false,
2842
2924
  bindingExpected = { value: null },
2843
2925
  bindingScriptLoader = createBindingScriptLoader(),
2926
+ currency = null,
2927
+ currencyNotApplied = null,
2844
2928
  } = {}) {
2845
2929
  const assertions = [];
2930
+ // --currency is recorded on the entry page's HTTP row: the persisted
2931
+ // verdict drops every URL query, so the fetched URL cannot carry it. An
2932
+ // entry URL the currency could not be added to says so on the same row.
2933
+ const currencyEvidence = {
2934
+ ...(currency ? { currency } : {}),
2935
+ ...(currencyNotApplied ? { currency_not_applied: currencyNotApplied } : {}),
2936
+ };
2846
2937
  if (!page.url) {
2847
2938
  assertions.push(bindingAssertion(page, await observeBinding({ source: null, page, expected: bindingExpected, scriptLoader: bindingScriptLoader })));
2848
2939
  assertions.push(assertion({
@@ -2878,7 +2969,7 @@ async function runPageChecks(page, args, {
2878
2969
  severity: SEVERITY.BLOCKER,
2879
2970
  expected: isHttpStatus ? "2xx HTTP response" : "fetchable bounded deployed page",
2880
2971
  actual: isHttpStatus ? `${source.status} ${source.status_text}`.trim() : null,
2881
- evidence: { transport_error: { code: source.error_code, message: source.error } },
2972
+ evidence: { transport_error: { code: source.error_code, message: source.error }, ...currencyEvidence },
2882
2973
  }));
2883
2974
  return {
2884
2975
  assertions,
@@ -2896,6 +2987,7 @@ async function runPageChecks(page, args, {
2896
2987
  status: STATUS.PASS,
2897
2988
  expected: "2xx HTTP response",
2898
2989
  actual: `${source.status} ${source.status_text}`.trim(),
2990
+ evidence: Object.keys(currencyEvidence).length ? currencyEvidence : undefined,
2899
2991
  }));
2900
2992
 
2901
2993
  const expectedMeta = page.expected_meta_tags || {};
@@ -3115,9 +3207,9 @@ async function maybeRunTestOrders(
3115
3207
  return { orders: [], receiptAnalytics: emptyReceiptAnalytics(), qc_results: notRequestedRows() };
3116
3208
  }
3117
3209
  if (mode && mode !== "off") {
3118
- // Test Orders use global test cards: they bypass the payment gateway, create
3119
- // no transactions, and need no merchant setup or approval. `--test-order
3120
- // <mode>` is sufficient intent — no permission flags or packet policy gate.
3210
+ // Test Orders use global test cards: they bypass the payment gateway and create
3211
+ // no transactions, but each leaves a real store order record. Ask the operator
3212
+ // once in the first turn; `--test-order <mode>` has no permission flag or packet policy gate.
3121
3213
  const result = await operations.runBrowser(resolved.topologies, args, runId, { captureAnalytics, spec: resolved.spec });
3122
3214
  assertions.push(...result.assertions);
3123
3215
  return {
@@ -3460,6 +3552,9 @@ function output(value, args) {
3460
3552
  printEntryUrlLines(value.entry_urls);
3461
3553
  console.log(`Run ID: ${value.run_id}`);
3462
3554
  console.log(`Disposition: ${value.verdict.disposition}`);
3555
+ if (value.verdict.disposition === "ready_with_exceptions") {
3556
+ 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.");
3557
+ }
3463
3558
  console.log(`Counts: ${Object.entries(value.counts).map(([status, count]) => `${count} ${status}`).join(", ")}`);
3464
3559
  printCauseLines(value.verdict);
3465
3560
  printThemeGateLines(value.theme_gate, value.packet_path, value.report_path);
@@ -3472,6 +3567,9 @@ function output(value, args) {
3472
3567
  } else if (value.publish_skipped && value.publish_decision?.reason === "consent_off") {
3473
3568
  console.log(`QA portal: publish skipped — telemetry consent is off, so this non-portal-managed verdict stays local.`);
3474
3569
  console.log(` Destination would be ${value.publish_decision.destination}. Opt in for this run with --post-verdict, or enable with \`${cmd("telemetry")} on\`.`);
3570
+ } else if (value.publish_skipped && value.publish_decision?.reason === "loopback_base_url") {
3571
+ console.log(`QA portal: publish skipped — the base URL is a local address, so this verdict stays local.`);
3572
+ console.log(` Destination would be ${value.publish_decision.destination}. Publish this run with \`${cmd("qa")} publish\`, or pass --post-verdict on the next run.`);
3475
3573
  } else if (value.publish_skipped) {
3476
3574
  console.log(`QA portal: publish skipped (--no-post-verdict); local verdict only.`);
3477
3575
  } else {
@@ -3579,14 +3677,21 @@ function printCauseLines(verdict) {
3579
3677
  // One formatter, shared with the doctor report: a prior record that exists
3580
3678
  // but has no usable QA verdict is not the same state as no prior record, and
3581
3679
  // the two commands must not describe it differently.
3582
- for (const line of formatCauseReportLines(verdict?.cause_summary)) console.log(line);
3680
+ if (verdict?.disposition !== "ready_with_exceptions") {
3681
+ for (const line of formatCauseReportLines(verdict?.cause_summary)) console.log(line);
3682
+ }
3583
3683
  const exceptions = Array.isArray(verdict.exceptions) ? verdict.exceptions : [];
3584
3684
  if (!exceptions.length) return;
3585
3685
  console.log("Findings:");
3586
3686
  for (const exception of exceptions) {
3587
- const identity = [exception.id, exception.page].filter(Boolean).join(" @ ") || "(unidentified finding)";
3588
3687
  const tag = formatCauseTag(exception);
3589
- console.log(`- ${identity} (${exception.status || "unknown"})${tag ? ` ${tag}` : ""}`);
3688
+ if (verdict.disposition === "ready_with_exceptions") {
3689
+ const severity = exception.severity ? `; severity: ${exception.severity}` : "";
3690
+ console.log(`- id: ${exception.id || "(none)"}; page: ${exception.page || "(none)"} (${exception.status || "unknown"}${severity})${tag ? ` ${tag}` : ""}`);
3691
+ } else {
3692
+ const identity = [exception.id, exception.page].filter(Boolean).join(" @ ") || "(unidentified finding)";
3693
+ console.log(`- ${identity} (${exception.status || "unknown"})${tag ? ` ${tag}` : ""}`);
3694
+ }
3590
3695
  }
3591
3696
  }
3592
3697
 
@@ -3601,7 +3706,7 @@ export function themeGateLines(themeGate, packetPath = null, reportPath = null)
3601
3706
  for (const action of themeGate.required_actions || []) {
3602
3707
  lines.push(` - ${requiredActionText(action, { packetPath, reportPath })}`);
3603
3708
  }
3604
- lines.push("Or rerun with --theme-waive \"<reason>\" to record an ephemeral waiver for this run.");
3709
+ 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>".');
3605
3710
  return lines;
3606
3711
  }
3607
3712
 
@@ -3639,8 +3744,8 @@ export function qaResolveNextProofLines(value) {
3639
3744
  `Next expected proof: ${qaRunCommandFromResolve(value)}`,
3640
3745
  `Entry URL(s) resolved: ${formatEntryUrlsForProof(value.entry_urls)}`,
3641
3746
  value.local_spec_id
3642
- ? "Typed-card test orders use global test cards (no transactions/no permission gate); local-spec QA stays in the repository."
3643
- : "Typed-card test orders use global test cards (no transactions/no permission gate); QA publishes to the portal by default.",
3747
+ ? "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."
3748
+ : "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.",
3644
3749
  ];
3645
3750
  }
3646
3751
 
@@ -3757,8 +3862,13 @@ export function forcedAnalyticsCorrectness(args) {
3757
3862
  // else (client projects, fixtures, local shakeouts on a local spec), consent
3758
3863
  // off (CAMPAIGNS_OS_TELEMETRY=off / `campaigns-os telemetry off`) means the
3759
3864
  // verdict stays local, with the destination and the opt-in flag named in the
3760
- // run output. Publishing for the portal path is never weakened.
3761
- export function decidePublishVerdict({ args = {}, portalManaged = false, consent = null } = {}) {
3865
+ // run output. Publishing for the portal path is never weakened by consent.
3866
+ // #486: a run against a local-address base URL (localhost or any *.localhost
3867
+ // name, any 127.x.x.x, 0.0.0.0, [::1] or an IPv4-mapped loopback; see
3868
+ // isLocalAddressUrl below) is a local check, so by default its verdict stays
3869
+ // local whatever the spec source; the output names --post-verdict and qa
3870
+ // publish. An explicit --post-verdict still publishes.
3871
+ export function decidePublishVerdict({ args = {}, portalManaged = false, consent = null, baseUrl = null } = {}) {
3762
3872
  if (args["no-post-verdict"] === true || args["local-only"] === true) {
3763
3873
  return { publish: false, reason: "flag_opt_out" };
3764
3874
  }
@@ -3775,11 +3885,30 @@ export function decidePublishVerdict({ args = {}, portalManaged = false, consent
3775
3885
  flagInvalid = true;
3776
3886
  }
3777
3887
  const decorate = (decision) => (flagInvalid ? { ...decision, flag_invalid: true } : decision);
3888
+ if (isLocalAddressUrl(baseUrl)) return decorate({ publish: false, reason: "loopback_base_url" });
3778
3889
  if (portalManaged) return decorate({ publish: true, reason: "portal_managed_default" });
3779
3890
  if (consent?.state === "off") return decorate({ publish: false, reason: "consent_off" });
3780
3891
  return decorate({ publish: true, reason: "default" });
3781
3892
  }
3782
3893
 
3894
+ // The publish default (#486) reads "local" more widely than the shared
3895
+ // LOOPBACK_HOSTNAMES list, which also decides where plain http is allowed and
3896
+ // so stays narrow: any 127.0.0.0/8 address, 0.0.0.0, [::1], an IPv4-mapped
3897
+ // loopback, and localhost or any *.localhost name (with or without a trailing
3898
+ // dot). Keeping such a run local is the safe direction: --post-verdict and
3899
+ // qa publish still send it.
3900
+ function isLocalAddressUrl(value) {
3901
+ if (typeof value !== "string" || !value.trim()) return false;
3902
+ let hostname;
3903
+ try { hostname = new URL(value.trim()).hostname.toLowerCase(); } catch { return false; }
3904
+ const name = hostname.replace(/\.$/, "");
3905
+ if (name === "localhost" || name.endsWith(".localhost")) return true;
3906
+ if (/^127(?:\.\d{1,3}){3}$/.test(name) || name === "0.0.0.0") return true;
3907
+ if (name === "[::1]" || name === "[::]") return true;
3908
+ // WHATWG URL serializes ::ffff:127.x.y.z as [::ffff:7fxx:xxxx].
3909
+ return /^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/.test(name);
3910
+ }
3911
+
3783
3912
  function policySnapshot(packet) {
3784
3913
  return {
3785
3914
  campaign: {
@@ -3994,16 +4123,23 @@ function htmlIncludesRouteReference(html, expectedUrl) {
3994
4123
  return html.includes(expectedUrl) || html.includes(path);
3995
4124
  }
3996
4125
 
3997
- // A data-upsell-proxy button forwards its click to the SDK action inside the
3998
- // offer. With no such action, the route's URL can still sit in the page's meta
3999
- // tags, so the reference match would pass a control that does nothing.
4126
+ // A data-upsell-proxy button forwards its click to the SDK action of its own
4127
+ // spelling inside the offer. With no such action, the route's URL can still sit
4128
+ // in the page's meta tags, so the reference match would pass a control that
4129
+ // does nothing.
4000
4130
  function findDeadUpsellProxy(html, kind, page) {
4001
4131
  if (page.page_type !== "upsell" || kind === "next") return null;
4002
- const action = kind === "accept" ? "add" : "skip";
4003
- if (!new RegExp(`\\bdata-upsell-proxy\\s*=\\s*["']${action}["']`, "i").test(html)) return null;
4004
- const target = new RegExp(`\\bdata-next-upsell-action\\s*=\\s*["']${action}["']`, "i");
4005
- if (upsellOfferElements(html).some((offer) => target.test(offer))) return null;
4006
- return `data-upsell-proxy="${action}" with no data-next-upsell-action="${action}" inside the offer to forward to`;
4132
+ for (const action of UPSELL_ACTION_SPELLINGS[kind]) {
4133
+ if (!new RegExp(`\\bdata-upsell-proxy\\s*=\\s*["']${action}["']`, "i").test(html)) continue;
4134
+ const target = upsellActionPattern(action);
4135
+ if (upsellOfferElements(html).some((offer) => target.test(offer))) continue;
4136
+ return `data-upsell-proxy="${action}" with no data-next-upsell-action="${action}" inside the offer to forward to`;
4137
+ }
4138
+ return null;
4139
+ }
4140
+
4141
+ function upsellActionPattern(action) {
4142
+ return new RegExp(`\\bdata-next-upsell-action\\s*=\\s*["']${action}["']`, "i");
4007
4143
  }
4008
4144
 
4009
4145
  // The markup of each [data-next-upsell="offer"] element, read to its closing
@@ -4032,14 +4168,9 @@ function upsellOfferElements(html) {
4032
4168
  }
4033
4169
 
4034
4170
  function findSdkRouteAction(html, kind, page) {
4035
- if (page.page_type !== "upsell") return null;
4036
- if (kind === "accept" && /\bdata-next-upsell-action\s*=\s*["']add["']/i.test(html)) {
4037
- return 'SDK upsell accept control: data-next-upsell-action="add"';
4038
- }
4039
- if (kind === "decline" && /\bdata-next-upsell-action\s*=\s*["']skip["']/i.test(html)) {
4040
- return 'SDK upsell decline control: data-next-upsell-action="skip"';
4041
- }
4042
- return null;
4171
+ if (page.page_type !== "upsell" || kind === "next") return null;
4172
+ const action = UPSELL_ACTION_SPELLINGS[kind].find((spelling) => upsellActionPattern(spelling).test(html));
4173
+ return action ? `SDK upsell ${kind} control: data-next-upsell-action="${action}"` : null;
4043
4174
  }
4044
4175
 
4045
4176
  function decodeHtml(value) {
@@ -4099,6 +4230,7 @@ function extractApiError(raw) {
4099
4230
  }
4100
4231
 
4101
4232
  export const __qaNodeTestHooks = Object.freeze({
4233
+ output,
4102
4234
  extractTopologies,
4103
4235
  validatedOrderCreationLimit,
4104
4236
  resolveQaInputs,
@@ -4123,6 +4255,8 @@ export const __qaNodeTestHooks = Object.freeze({
4123
4255
  themeGateSummary,
4124
4256
  templateBrandContractAssertion,
4125
4257
  deriveEntryUrls,
4258
+ qaRunCurrency,
4259
+ withEntryCurrency,
4126
4260
  derivePageUrls,
4127
4261
  deriveTestedUrlsFromAssertions,
4128
4262
  resolvePayload,
@@ -83,7 +83,7 @@ export function skippedQaVerdictPublish() {
83
83
  * remit (`ok` when the receiver's answer counts as stored, `failed` when it
84
84
  * refused or never answered, `skipped` when nothing was sent).
85
85
  */
86
- export function qaVerdictPublishBlock(outcome, { verdictRunId, publisher, publishedAt = null }) {
86
+ export function qaVerdictPublishBlock(outcome, { verdictRunId, publisher, publishedAt = null, reason = null }) {
87
87
  const attempted = Boolean(outcome?.attempted);
88
88
  const ok = attempted ? outcome.ok === true : null;
89
89
  return {
@@ -94,6 +94,7 @@ export function qaVerdictPublishBlock(outcome, { verdictRunId, publisher, publis
94
94
  error: attempted && typeof outcome.error === "string" ? outcome.error : null,
95
95
  endpoint: attempted ? outcome.endpoint ?? QA_VERDICT_PUBLISH_ENDPOINT : null,
96
96
  state: attempted ? (ok ? "ok" : "failed") : "skipped",
97
+ reason: reason ?? outcome?.reason ?? null,
97
98
  result: attempted ? outcome.result ?? null : null,
98
99
  base_kind: attempted ? outcome.base_kind ?? null : null,
99
100
  published_at: attempted && ok ? publishedAt : null,