@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.
- package/CHANGELOG.md +148 -0
- package/CONTEXT.md +19 -5
- package/README.md +26 -12
- package/campaign-spec/README.md +15 -1
- package/campaign-spec/dist/checkout-flow.d.ts +90 -0
- package/campaign-spec/dist/checkout-flow.js +229 -0
- package/campaign-spec/dist/index.d.ts +1 -0
- package/campaign-spec/dist/index.js +6 -1
- package/campaign-spec/dist/normalize.d.ts +3 -2
- package/campaign-spec/dist/normalize.js +7 -5
- package/campaign-spec/dist/routing.d.ts +6 -3
- package/campaign-spec/dist/routing.js +6 -3
- package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
- package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
- package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
- package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
- package/campaign-spec/dist/rules/index.d.ts +4 -0
- package/campaign-spec/dist/rules/index.js +12 -0
- package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
- package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
- package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
- package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
- package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
- package/campaign-spec/dist/rules/schema-version.js +10 -5
- package/campaign-spec/dist/types.d.ts +20 -3
- package/campaign-spec/dist/types.js +3 -2
- package/compatibility.json +2 -2
- package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
- package/contracts/release-ledger.json +1146 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
- package/contracts/supported-surface.json +9 -5
- package/docs/brand-theme-bridge.md +10 -0
- package/docs/build-packet.md +86 -26
- package/docs/campaigns-os-build-flow.md +1 -1
- package/docs/effects.md +2 -5
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +133 -29
- package/docs/runtime-readiness.md +8 -3
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -1
- package/docs/versioning.md +4 -1
- package/docs/workflow-findings-sidecar.md +57 -2
- package/package.json +4 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
- package/schemas/campaign-spec.v5.schema.json +586 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
- package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
- 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 +6 -4
- 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 +21 -6
- package/skills/next-campaigns-qa/SKILL.md +5 -4
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +27 -13
- package/src/brand-theme.mjs +44 -8
- package/src/build-brief.mjs +14 -5
- package/src/campaign-identity.mjs +33 -4
- package/src/cli-helpers.mjs +129 -0
- package/src/cli.mjs +156 -111
- package/src/commercial-journey.mjs +25 -2
- package/src/consent.mjs +3 -2
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +8 -8
- package/src/deviation.mjs +2 -1
- package/src/doctor/checks.mjs +212 -19
- package/src/doctor/inspect.mjs +4 -2
- package/src/doctor/next-step.mjs +46 -16
- package/src/doctor-sidecar.mjs +5 -3
- package/src/gate-actions.mjs +21 -1
- package/src/input-refresh.mjs +6 -6
- package/src/invocation.mjs +4 -3
- package/src/lifecycle.mjs +88 -6
- package/src/local-preview-policy.mjs +116 -12
- package/src/login.mjs +2 -1
- package/src/polish-browser.mjs +8 -2
- package/src/polish-node.mjs +9 -4
- package/src/polish-readability.mjs +2 -2
- package/src/qa-browser.mjs +741 -120
- package/src/qa-cart-entry.mjs +107 -20
- package/src/qa-node.mjs +179 -72
- package/src/qa-test-order-topology.mjs +118 -1
- package/src/qa-verdict-discovery.mjs +21 -4
- package/src/qa-verdict-publish.mjs +2 -1
- package/src/run-record.mjs +46 -34
- package/src/runtime-state-ignore.mjs +7 -5
- package/src/sdk-markup.mjs +41 -1
- package/src/source-html-intake.mjs +46 -4
- package/src/stage-ledger.mjs +18 -4
- 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,
|
|
203
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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, {
|
|
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
|
|
2772
|
-
if (!
|
|
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
|
|
3121
|
-
// no transactions,
|
|
3122
|
-
// <mode>`
|
|
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
|
-
|
|
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 =
|
|
3305
|
-
?
|
|
3306
|
-
: page.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
3648
|
-
: "Typed-card test
|
|
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
|
|
4027
|
-
// offer. With no such action, the route's URL can still sit
|
|
4028
|
-
// tags, so the reference match would pass a control that
|
|
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
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
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
|
-
|
|
4066
|
-
|
|
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,
|