@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.
- package/CHANGELOG.md +145 -0
- package/CONTEXT.md +8 -5
- package/README.md +26 -11
- package/compatibility.json +1 -1
- package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -27
- package/contracts/effects.v1.json +96 -37
- package/contracts/release-ledger.json +774 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/brand-theme-bridge.md +10 -0
- package/docs/build-packet.md +45 -22
- package/docs/campaign-standardization-report.md +32 -0
- package/docs/campaigns-os-build-flow.md +1 -1
- package/docs/design-source-package.md +7 -3
- package/docs/effects.md +4 -6
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/progress-snapshots.md +3 -1
- package/docs/qa-and-test-orders.md +83 -14
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +15 -0
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +2 -1
- package/docs/workflow-findings-sidecar.md +57 -2
- package/package.json +3 -1
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
- package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +3 -3
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +4 -4
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +26 -12
- package/src/brand-theme.mjs +44 -8
- package/src/campaign-ecosystem.mjs +153 -31
- package/src/campaign-identity.mjs +33 -4
- package/src/cli.mjs +58 -62
- package/src/consent.mjs +3 -2
- package/src/design-source-publication.mjs +1 -1
- package/src/deviation.mjs +2 -1
- package/src/directory-lock.mjs +79 -7
- package/src/doctor/checks.mjs +28 -8
- package/src/doctor/inspect.mjs +13 -0
- package/src/doctor/next-step.mjs +16 -6
- package/src/invocation.mjs +7 -4
- package/src/lifecycle.mjs +88 -6
- package/src/local-preview-policy.mjs +6 -0
- package/src/login.mjs +2 -1
- package/src/polish-browser.mjs +8 -2
- package/src/polish-node.mjs +6 -1
- package/src/polish-readability.mjs +2 -2
- package/src/progress-node.mjs +3 -2
- package/src/qa-browser.mjs +595 -95
- package/src/qa-cart-entry.mjs +29 -6
- package/src/qa-node.mjs +177 -43
- package/src/qa-verdict-publish.mjs +2 -1
- package/src/run-record.mjs +46 -34
- package/src/sdk-attribute-index.mjs +111 -23
- package/src/sdk-markup.mjs +89 -10
- package/src/sdk-repin.mjs +282 -0
- package/src/stage-ledger.mjs +15 -3
- package/src/stage-record.mjs +85 -17
- package/src/standardization-report.mjs +26 -1
- package/src/target-lock.mjs +9 -1
package/src/qa-cart-entry.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
77
|
-
// the SDK
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
201
|
-
|
|
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
|
|
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,
|
|
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, {
|
|
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
|
|
2770
|
-
if (!
|
|
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
|
|
3119
|
-
// no transactions,
|
|
3120
|
-
// <mode>`
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
3643
|
-
: "Typed-card test
|
|
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
|
-
|
|
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
|
|
3998
|
-
// offer. With no such action, the route's URL can still sit
|
|
3999
|
-
// tags, so the reference match would pass a control that
|
|
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
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
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
|
-
|
|
4037
|
-
|
|
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,
|