@nextcommerce/campaigns-os 1.43.2 → 1.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +648 -5103
- package/README.md +32 -11
- package/agents/claude/CLAUDE.md +1 -1
- package/agents/codex/AGENTS.md +1 -1
- package/agents/copilot/copilot-instructions.md +1 -1
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
- package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
- package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
- package/campaign-spec/dist/types.d.ts +2 -2
- package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
- package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
- package/contracts/effects.v1.json +1176 -113
- package/contracts/orientation-reason-codes.v1.json +7 -0
- package/contracts/release-ledger.json +2345 -6087
- package/contracts/supported-surface.json +7 -4
- package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
- package/docs/brand-theme-bridge.md +81 -0
- package/docs/build-packet.md +158 -21
- package/docs/campaigns-os-build-flow.md +3 -3
- package/docs/design-source-package.md +73 -0
- package/docs/effects.md +50 -8
- package/docs/gateway-login.md +3 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +42 -2
- package/docs/polish-evidence.md +74 -0
- package/docs/qa-and-test-orders.md +99 -13
- package/docs/release-ledger-authoring-guide.md +64 -4
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +2 -2
- package/docs/versioning.md +4 -1
- package/package.json +1 -1
- package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +7 -6
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +7 -6
- package/skills/next-campaigns-os/SKILL.md +7 -7
- package/skills/next-campaigns-os/references/session-intake.md +9 -3
- package/skills/next-campaigns-os-setup/SKILL.md +5 -5
- package/skills/next-campaigns-polish/SKILL.md +28 -9
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills.json +10 -10
- package/src/brand-theme.mjs +320 -20
- package/src/built-site-scope.mjs +16 -4
- package/src/cli.mjs +280 -46
- package/src/commercial-parity.mjs +48 -2
- package/src/deviation.mjs +13 -1
- package/src/diagnostic.mjs +5 -2
- package/src/doctor/checks.mjs +320 -81
- package/src/doctor/inspect.mjs +55 -13
- package/src/doctor/source-provenance.mjs +184 -0
- package/src/invocation.mjs +4 -0
- package/src/live-campaign-refs.mjs +466 -0
- package/src/login.mjs +2 -2
- package/src/page-kit-store-profile.mjs +69 -12
- package/src/page-kit-sync.mjs +31 -12
- package/src/progress-node.mjs +3 -1
- package/src/qa-browser.mjs +538 -28
- package/src/qa-commercial-parity.mjs +48 -5
- package/src/qa-node.mjs +122 -7
- package/src/qa-test-order-topology.mjs +148 -0
- package/src/sdk-markup.mjs +72 -8
- package/src/source-html-intake.mjs +116 -0
- package/src/stage-record.mjs +551 -0
- package/src/upsell-selector-scope.mjs +112 -2
|
@@ -11,7 +11,10 @@ import {
|
|
|
11
11
|
CommercialParityLimitError,
|
|
12
12
|
createCommercialParityReport,
|
|
13
13
|
extractCommercialClaims,
|
|
14
|
+
recurringClaimAbsences,
|
|
15
|
+
recurringClaimAbsentAssertions,
|
|
14
16
|
} from "./commercial-parity.mjs";
|
|
17
|
+
import { extractRenderedPackageRefs } from "./live-campaign-refs.mjs";
|
|
15
18
|
|
|
16
19
|
export const COMMERCIAL_QA_LIMITS = Object.freeze({
|
|
17
20
|
max_html_bytes: 2 * 1024 * 1024,
|
|
@@ -171,10 +174,15 @@ function captureFailure(page, code, error) {
|
|
|
171
174
|
|
|
172
175
|
export function captureCommercialClaims(page, html) {
|
|
173
176
|
try {
|
|
174
|
-
return
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
return {
|
|
178
|
+
...extractCommercialClaims(html, {
|
|
179
|
+
pageId: present(page?.page_id) ? String(page.page_id) : null,
|
|
180
|
+
url: page?.url,
|
|
181
|
+
}),
|
|
182
|
+
// Every package the page renders, by the refs doctor reads, so a
|
|
183
|
+
// subscription whose rebill copy yielded no claim is still seen (#533).
|
|
184
|
+
rendered_package_refs: [...extractRenderedPackageRefs(html)],
|
|
185
|
+
};
|
|
178
186
|
} catch (error) {
|
|
179
187
|
const code = error instanceof CommercialParityLimitError
|
|
180
188
|
? error.code
|
|
@@ -398,6 +406,29 @@ export function planCommercialParity(spec, { maxScenarios = COMMERCIAL_QA_LIMITS
|
|
|
398
406
|
};
|
|
399
407
|
}
|
|
400
408
|
|
|
409
|
+
// A package recurs when the spec says so, or carries a recurring price and a
|
|
410
|
+
// cadence. Page rows override the campaign catalog per ref, as the journey does.
|
|
411
|
+
function isSubscriptionPackage(pkg) {
|
|
412
|
+
return pkg?.is_recurring === true
|
|
413
|
+
|| (present(pkg?.price_recurring) && (present(pkg?.interval_count) || present(pkg?.interval)));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function subscriptionPackageRefsByPage(spec, pages) {
|
|
417
|
+
const packageRef = (pkg) => (present(pkg?.ref_id) ? String(pkg.ref_id) : present(pkg?.package_id) ? String(pkg.package_id) : null);
|
|
418
|
+
const catalog = new Map(array(spec?.packages).map((pkg) => [packageRef(pkg), pkg]).filter(([ref]) => ref));
|
|
419
|
+
const byPage = new Map();
|
|
420
|
+
array(pages).forEach((page) => {
|
|
421
|
+
if (!present(page?.id)) return;
|
|
422
|
+
const refs = new Set();
|
|
423
|
+
array(page.packages).forEach((pkg) => {
|
|
424
|
+
const ref = packageRef(pkg);
|
|
425
|
+
if (ref && isSubscriptionPackage({ ...(catalog.get(ref) || {}), ...pkg })) refs.add(ref);
|
|
426
|
+
});
|
|
427
|
+
if (refs.size) byPage.set(String(page.id), refs);
|
|
428
|
+
});
|
|
429
|
+
return byPage;
|
|
430
|
+
}
|
|
431
|
+
|
|
401
432
|
function countBy(values, keyFor) {
|
|
402
433
|
const counts = {};
|
|
403
434
|
values.forEach((value) => {
|
|
@@ -473,6 +504,7 @@ function compactReport(report, journey, plan, executed, issues, observedClaims,
|
|
|
473
504
|
unresolved_voucher_claims: report.unresolved_voucher_claims,
|
|
474
505
|
serialized_assertion_count: report.serialized_assertion_count,
|
|
475
506
|
omitted_assertion_count: report.omitted_assertion_count,
|
|
507
|
+
recurring_claim_absences: array(report.recurring_claim_absences),
|
|
476
508
|
observed_claims: observedClaims,
|
|
477
509
|
claim_limit: claimLimit,
|
|
478
510
|
finding_count: report.findings.length,
|
|
@@ -539,6 +571,7 @@ export function unavailableCommercialReport(code, { status = "not_run" } = {}) {
|
|
|
539
571
|
unresolved_voucher_claims: 0,
|
|
540
572
|
serialized_assertion_count: 0,
|
|
541
573
|
omitted_assertion_count: 0,
|
|
574
|
+
recurring_claim_absences: [],
|
|
542
575
|
observed_claims: 0,
|
|
543
576
|
claim_limit: COMMERCIAL_QA_LIMITS.max_aggregate_claims,
|
|
544
577
|
finding_count: 0,
|
|
@@ -625,8 +658,18 @@ export async function runCommercialParity({
|
|
|
625
658
|
maxAssertions,
|
|
626
659
|
countsOnly: aggregateClaimOverflow,
|
|
627
660
|
});
|
|
661
|
+
// Subscription packages a page renders with no recurring claim read for
|
|
662
|
+
// them: incomplete evidence (an issue), and a warning naming the package.
|
|
663
|
+
const absences = aggregateClaimOverflow
|
|
664
|
+
? []
|
|
665
|
+
: recurringClaimAbsences(captures, subscriptionPackageRefsByPage(planningSpec, commercialPlanning.pages));
|
|
666
|
+
absences.forEach(() => issues.push({ code: "recurring_claim_absent" }));
|
|
667
|
+
const absentAssertions = recurringClaimAbsentAssertions(absences, {
|
|
668
|
+
maxAssertions: Math.max(0, maxAssertions - parity.assertions.length),
|
|
669
|
+
});
|
|
670
|
+
parity.recurring_claim_absences = absences;
|
|
628
671
|
return {
|
|
629
|
-
assertions: parity.assertions,
|
|
672
|
+
assertions: [...parity.assertions, ...absentAssertions],
|
|
630
673
|
commercial: compactReport(
|
|
631
674
|
parity,
|
|
632
675
|
parityJourney,
|
package/src/qa-node.mjs
CHANGED
|
@@ -30,7 +30,7 @@ const PACKAGE_ROOT = installModeResolve(installModeDirname(installModeFileUrl(im
|
|
|
30
30
|
function cmd(verb, rest = "") {
|
|
31
31
|
return `${invocationPrefixFor(PACKAGE_ROOT)} ${verb}${rest ? ` ${rest}` : ""}`;
|
|
32
32
|
}
|
|
33
|
-
import { isSameAnalyticsCapturePage, runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, validatedOrderCreationLimit } from "./qa-browser.mjs";
|
|
33
|
+
import { isSameAnalyticsCapturePage, runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, upsellActionCoverageWithoutOrders, validatedOrderCreationLimit } from "./qa-browser.mjs";
|
|
34
34
|
import { assessReceiptPurchase } from "./qa-analytics-correctness.mjs";
|
|
35
35
|
import { createVerdict, isFindingAssertion, QA_ASSERTION_FAMILY_VOCABULARY, SESSION_ENDING_DISPOSITIONS, SEVERITY, STATUS, validateVerdict } from "./qa-verdict.mjs";
|
|
36
36
|
import { normalizeSdkMetaName, lookupSdkIgnoredMetaTag } from "./sdk-meta-tags.mjs";
|
|
@@ -98,6 +98,18 @@ import {
|
|
|
98
98
|
unavailableCommercialCapture,
|
|
99
99
|
unavailableCommercialReport,
|
|
100
100
|
} from "./qa-commercial-parity.mjs";
|
|
101
|
+
import {
|
|
102
|
+
LIVE_REF_CODES,
|
|
103
|
+
campaignDriftMessage,
|
|
104
|
+
disabledLiveRead,
|
|
105
|
+
evaluateLiveCampaignRefs,
|
|
106
|
+
extractRenderedRefs,
|
|
107
|
+
liveRefsDisabled,
|
|
108
|
+
liveRefFindingMessage,
|
|
109
|
+
liveRefsNotRunMessage,
|
|
110
|
+
readLiveCampaignForPacket,
|
|
111
|
+
} from "./live-campaign-refs.mjs";
|
|
112
|
+
import { specPackageRefs, specShippingRefs } from "./doctor/checks.mjs";
|
|
101
113
|
|
|
102
114
|
// The producing runtime identity on every verdict. Read from package.json so
|
|
103
115
|
// a verdict names the release that made it; a literal here outlived three
|
|
@@ -110,7 +122,7 @@ const HELP = `campaigns-os qa — Node/npm spec-aware QA
|
|
|
110
122
|
Usage:
|
|
111
123
|
campaigns-os qa parity --fixture <parity-fixture.json> --scenario <scenario-id> [--base-url <override>] [--baseline <url>] [--parity-order-json <file>] [--no-post-verdict]
|
|
112
124
|
campaigns-os qa resolve --packet <campaign-runtime.build.json> [--base-url <url>] [--no-probe] [--probe-timeout-ms <ms>] [--json]
|
|
113
|
-
campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--output-dir <dir>] [--no-remit] [--json]
|
|
125
|
+
campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--output-dir <dir>] [--no-remit] [--no-live-refs] [--json]
|
|
114
126
|
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]
|
|
115
127
|
campaigns-os qa waive --packet <campaign-runtime.build.json> --assertion analytics-correctness:purchase-fires --reason "<why>" [--waived-by <who>] [--report <assembly-report.json>] [--json]
|
|
116
128
|
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
|
|
@@ -130,7 +142,7 @@ Options:
|
|
|
130
142
|
Requires --base-url and --family. No Map ID / CampaignSpec needed.
|
|
131
143
|
--spec <path> Local exported CampaignSpec JSON for the non-packet Map ID flow.
|
|
132
144
|
Packet QA always uses packet.spec.local_path and rejects this override.
|
|
133
|
-
--proxy-base <url> Campaign Map proxy base for /api/spec, /api/price-preview, and verdict publishing.
|
|
145
|
+
--proxy-base <url> Campaign Map proxy base for /api/spec, /api/price-preview, /api/campaign, and verdict publishing.
|
|
134
146
|
--base-url <url> Deployed campaign root. Packet deploy URL is used when omitted.
|
|
135
147
|
Commercial pages are checked automatically against /api/price-preview;
|
|
136
148
|
no commercial sidecar or extra catalog flag is required.
|
|
@@ -166,6 +178,9 @@ Options:
|
|
|
166
178
|
Record is not stamped; a refusal still exits 2, a clean dry run exits 0
|
|
167
179
|
(--json: dry_run, would_publish, would_post).
|
|
168
180
|
--no-remit When an ambient run session is active, write the local Run Record but skip Run Telemetry remit.
|
|
181
|
+
--no-live-refs qa run: skip the one read-only GET of <proxy-base>/api/campaign that checks each
|
|
182
|
+
served page's shipping and package refs against the live campaign; the verdict
|
|
183
|
+
records the check not_run with reason disabled, never a pass.
|
|
169
184
|
--auth-cookie <cookie> Cookie header for protected previews.
|
|
170
185
|
--browser Run Playwright-rendered browser checks after static Node checks.
|
|
171
186
|
Requires one-time setup: campaigns-os qa install-browser
|
|
@@ -178,8 +193,11 @@ Options:
|
|
|
178
193
|
Create Playwright typed-card test orders through the tested checkout page.
|
|
179
194
|
Test cards bypass the gateway and create no transactions, so no permission
|
|
180
195
|
flags or packet policy are needed — just pick a mode. Default mode (bare
|
|
181
|
-
--test-order, or "common") runs
|
|
182
|
-
|
|
196
|
+
--test-order, or "common") runs every actual terminal path when they fit under
|
|
197
|
+
the cap (--max-test-orders, default 6). Above the cap it runs checkout,
|
|
198
|
+
first-offer accept/decline, and a deduplicated shortest real receipt path, then
|
|
199
|
+
adds one decline path per offer/downsell page not yet declined, up to the cap,
|
|
200
|
+
and names any page left out. "full" walks
|
|
183
201
|
every actual terminal path; cycles, missing routes, and reachable nonterminals
|
|
184
202
|
block before browser launch. The default cap is 6; overflow names the exact raise.
|
|
185
203
|
"tiers" is spec-driven: one strict-selection order per selector tier the
|
|
@@ -2165,7 +2183,7 @@ async function runQa(args, options = {}) {
|
|
|
2165
2183
|
// `runSessionActive` is threaded in from the CLI's single ambient-session read
|
|
2166
2184
|
// rather than re-discovered here, so the closeout command this run prints and
|
|
2167
2185
|
// the run_id the session will close under come from the same observation.
|
|
2168
|
-
async function runResolvedQa(args, resolved, { runSessionActive = false } = {}) {
|
|
2186
|
+
async function runResolvedQa(args, resolved, { runSessionActive = false, liveCampaign = undefined, liveCampaignFetch = globalThis.fetch } = {}) {
|
|
2169
2187
|
const startedAt = new Date().toISOString();
|
|
2170
2188
|
const runId = generateRunId();
|
|
2171
2189
|
const gate = resolved.themeGate;
|
|
@@ -2258,13 +2276,33 @@ async function runResolvedQa(args, resolved, { runSessionActive = false } = {})
|
|
|
2258
2276
|
const pages = resolved.topologies.flatMap(topology => topology.pages);
|
|
2259
2277
|
const pageResults = await mapConcurrent(pages, COMMERCIAL_QA_LIMITS.concurrency, page =>
|
|
2260
2278
|
runPageChecks(page, args, { sourceLoader, bindingExpected, bindingScriptLoader, captureCommercial: commercialIds.has(String(page.page_id)) }));
|
|
2279
|
+
const livePages = new Map();
|
|
2261
2280
|
for (const [index, page] of pages.entries()) {
|
|
2262
2281
|
const pageResult = pageResults[index];
|
|
2263
2282
|
assertions.push(...pageResult.assertions);
|
|
2283
|
+
if (pageResult.renderedRefs && !livePages.has(String(page.page_id))) {
|
|
2284
|
+
livePages.set(String(page.page_id), { ...page, page_id: String(page.page_id), ...pageResult.renderedRefs });
|
|
2285
|
+
}
|
|
2264
2286
|
if (commercialIds.has(String(page.page_id)) && pageResult.commercialCapture && !capturesByPageId.has(String(page.page_id))) {
|
|
2265
2287
|
capturesByPageId.set(String(page.page_id), pageResult.commercialCapture);
|
|
2266
2288
|
}
|
|
2267
2289
|
}
|
|
2290
|
+
// The live campaign read (#533): one GET of {proxy-base}/api/campaign under
|
|
2291
|
+
// the public campaign key, made only when a served page was read to compare;
|
|
2292
|
+
// --no-live-refs sends nothing and records the check not_run (`disabled`).
|
|
2293
|
+
const liveSpec = resolved.rawSpec || resolved.spec;
|
|
2294
|
+
const liveRead = liveRefsDisabled(args)
|
|
2295
|
+
? disabledLiveRead()
|
|
2296
|
+
: liveCampaign !== undefined || livePages.size === 0
|
|
2297
|
+
? liveCampaign
|
|
2298
|
+
: await readLiveCampaignForPacket({
|
|
2299
|
+
packet: resolved.packet,
|
|
2300
|
+
packetPath: resolved.packetPath || null,
|
|
2301
|
+
spec: liveSpec,
|
|
2302
|
+
fetchImpl: liveCampaignFetch,
|
|
2303
|
+
proxyBase: resolved.proxyBase,
|
|
2304
|
+
});
|
|
2305
|
+
assertions.push(...liveCampaignRefAssertions({ pages: [...livePages.values()], spec: liveSpec, liveCampaign: liveRead }));
|
|
2268
2306
|
if (args.browser === true) {
|
|
2269
2307
|
assertions.push(...await runBrowserChecks(resolved.topologies, args, {
|
|
2270
2308
|
brandContract: resolved.brandContract,
|
|
@@ -2884,7 +2922,77 @@ async function runPageChecks(page, args, {
|
|
|
2884
2922
|
}));
|
|
2885
2923
|
}
|
|
2886
2924
|
|
|
2887
|
-
|
|
2925
|
+
const rendered = extractRenderedRefs(html);
|
|
2926
|
+
return {
|
|
2927
|
+
assertions,
|
|
2928
|
+
commercialCapture,
|
|
2929
|
+
renderedRefs: { package_refs: [...rendered.package_refs], shipping_refs: [...rendered.shipping_refs] },
|
|
2930
|
+
};
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
// The live campaign ref check (#533) as QA assertions: the comparison doctor
|
|
2934
|
+
// runs over built pages, run here over the served pages this attempt read,
|
|
2935
|
+
// under the same codes. `liveCampaign` is a readLiveCampaign result the
|
|
2936
|
+
// caller made; absent or failed, the check is not_run with its reason.
|
|
2937
|
+
function liveCampaignRefAssertions({ pages, spec, liveCampaign }) {
|
|
2938
|
+
const campaignAssertion = (fields) => assertion({ family: "api-metadata", page: { page_id: "campaign" }, ...fields });
|
|
2939
|
+
if (!pages.length && liveCampaign?.reason_code !== "disabled") {
|
|
2940
|
+
return [campaignAssertion({
|
|
2941
|
+
id: "live-campaign-refs",
|
|
2942
|
+
status: STATUS.SKIPPED,
|
|
2943
|
+
expected: "every served page's shipping and package refs are served by the live campaign",
|
|
2944
|
+
actual: "not_run",
|
|
2945
|
+
evidence: { code: LIVE_REF_CODES.notRun, reason_code: "no_pages", reason: "No served page source was read to compare against the live campaign." },
|
|
2946
|
+
})];
|
|
2947
|
+
}
|
|
2948
|
+
const result = evaluateLiveCampaignRefs({
|
|
2949
|
+
pages,
|
|
2950
|
+
map: { package_refs: specPackageRefs(spec), shipping_refs: specShippingRefs(spec) },
|
|
2951
|
+
live: liveCampaign,
|
|
2952
|
+
});
|
|
2953
|
+
const keySource = liveCampaign?.key_source ? { key_source: liveCampaign.key_source } : {};
|
|
2954
|
+
if (result.status === "not_run") {
|
|
2955
|
+
// --no-live-refs: the verdict says how many served pages went unchecked.
|
|
2956
|
+
const eligible = result.reason_code === "disabled" ? { pages_eligible: result.checked_pages } : {};
|
|
2957
|
+
return [campaignAssertion({
|
|
2958
|
+
id: result.attempted ? LIVE_REF_CODES.notRun : "live-campaign-refs",
|
|
2959
|
+
status: result.attempted ? STATUS.WARN : STATUS.SKIPPED,
|
|
2960
|
+
...(result.attempted ? { severity: SEVERITY.WARN } : {}),
|
|
2961
|
+
expected: "every served page's shipping and package refs are served by the live campaign",
|
|
2962
|
+
actual: "not_run",
|
|
2963
|
+
evidence: { code: LIVE_REF_CODES.notRun, reason_code: result.reason_code, reason: result.attempted ? liveRefsNotRunMessage(result) : result.reason, ...eligible, ...keySource },
|
|
2964
|
+
})];
|
|
2965
|
+
}
|
|
2966
|
+
const out = result.page_findings.map((finding) => assertion({
|
|
2967
|
+
id: `${finding.code}:${finding.page_id}`,
|
|
2968
|
+
family: "api-metadata",
|
|
2969
|
+
page: pages.find((page) => String(page.page_id) === finding.page_id) || { page_id: finding.page_id },
|
|
2970
|
+
status: STATUS.FAIL,
|
|
2971
|
+
severity: SEVERITY.BLOCKER,
|
|
2972
|
+
expected: `every rendered ${finding.kind} ref is served by the live campaign`,
|
|
2973
|
+
actual: finding.refs.join(", "),
|
|
2974
|
+
evidence: { code: finding.code, refs: finding.refs, message: liveRefFindingMessage(finding) },
|
|
2975
|
+
}));
|
|
2976
|
+
if (result.drift) {
|
|
2977
|
+
out.push(campaignAssertion({
|
|
2978
|
+
id: LIVE_REF_CODES.drift,
|
|
2979
|
+
status: STATUS.WARN,
|
|
2980
|
+
severity: SEVERITY.WARN,
|
|
2981
|
+
expected: "the CampaignSpec lists the refs the live campaign serves",
|
|
2982
|
+
actual: "drift",
|
|
2983
|
+
evidence: { code: LIVE_REF_CODES.drift, drift: result.drift, message: campaignDriftMessage(result.drift) },
|
|
2984
|
+
}));
|
|
2985
|
+
}
|
|
2986
|
+
if (result.status === "pass") {
|
|
2987
|
+
out.push(campaignAssertion({
|
|
2988
|
+
id: "live-campaign-refs",
|
|
2989
|
+
status: STATUS.PASS,
|
|
2990
|
+
expected: "every served page's shipping and package refs are served by the live campaign",
|
|
2991
|
+
actual: `${result.checked_pages} page(s) checked`,
|
|
2992
|
+
evidence: { ...keySource },
|
|
2993
|
+
}));
|
|
2994
|
+
}
|
|
2995
|
+
return out;
|
|
2888
2996
|
}
|
|
2889
2997
|
|
|
2890
2998
|
async function maybeRunTestOrders(
|
|
@@ -2899,6 +3007,12 @@ async function maybeRunTestOrders(
|
|
|
2899
3007
|
const mode = String(args["test-order"] || "off").toLowerCase();
|
|
2900
3008
|
const legacyMode = String(args["legacy-api-test-order"] || "off").toLowerCase();
|
|
2901
3009
|
const emptyReceiptAnalytics = () => ({ plannedPlanIds: [], attempts: [] });
|
|
3010
|
+
if (!mode || mode === "off") {
|
|
3011
|
+
// No browser order clicks an upsell control, legacy API orders included,
|
|
3012
|
+
// so the coverage row says so instead of going missing (#530).
|
|
3013
|
+
const coverage = upsellActionCoverageWithoutOrders(resolved.topologies);
|
|
3014
|
+
if (coverage) assertions.push(coverage);
|
|
3015
|
+
}
|
|
2902
3016
|
if ((!mode || mode === "off") && (!legacyMode || legacyMode === "off")) {
|
|
2903
3017
|
return { orders: [], receiptAnalytics: emptyReceiptAnalytics() };
|
|
2904
3018
|
}
|
|
@@ -3888,6 +4002,7 @@ export const __qaNodeTestHooks = Object.freeze({
|
|
|
3888
4002
|
isRoutingMetaTag,
|
|
3889
4003
|
unsupportedSdkMetaHint,
|
|
3890
4004
|
reportCommercialRunnerError,
|
|
4005
|
+
liveCampaignRefAssertions,
|
|
3891
4006
|
browserSkippedByGate,
|
|
3892
4007
|
reportBrowserSkippedByGate,
|
|
3893
4008
|
gateClearingHint,
|
|
@@ -30,6 +30,19 @@ export function resolveTestOrderTopology(topology = {}, checkoutPage = null) {
|
|
|
30
30
|
...(entry?.kind === "terminal" ? [entry.terminal] : []),
|
|
31
31
|
...terminalPaths.map((candidate) => candidate.terminal),
|
|
32
32
|
]);
|
|
33
|
+
// Every declared offer page with the offer page each action leads to, so a
|
|
34
|
+
// planned path that stops short of a terminal can still be walked to the
|
|
35
|
+
// controls it clicks.
|
|
36
|
+
const offerEdges = [];
|
|
37
|
+
for (const [key, page] of pagesByUrl) {
|
|
38
|
+
if (!OFFER_PAGE_TYPES.has(pageType(page))) continue;
|
|
39
|
+
const edge = { key, page_id: page.page_id || null, page_type: page.page_type || null, url: page.url };
|
|
40
|
+
for (const [action, field] of ACTIONS) {
|
|
41
|
+
const target = targetNode(page?.[field], pagesByUrl, topologyOrigin);
|
|
42
|
+
edge[`${action}_key`] = target?.kind === "offer" ? canonicalHttpUrl(target.page.url) : null;
|
|
43
|
+
}
|
|
44
|
+
offerEdges.push(edge);
|
|
45
|
+
}
|
|
33
46
|
|
|
34
47
|
return {
|
|
35
48
|
topology_id: topology?.funnel_id || "default",
|
|
@@ -48,6 +61,8 @@ export function resolveTestOrderTopology(topology = {}, checkoutPage = null) {
|
|
|
48
61
|
terminal_paths: terminalPaths,
|
|
49
62
|
invalid_paths: invalidPaths,
|
|
50
63
|
recognized_terminals: recognizedTerminals,
|
|
64
|
+
entry_offer_key: entry?.kind === "offer" ? canonicalHttpUrl(entry.page.url) : null,
|
|
65
|
+
offer_edges: offerEdges,
|
|
51
66
|
};
|
|
52
67
|
}
|
|
53
68
|
|
|
@@ -95,6 +110,139 @@ export function commonTestOrderPaths(resolvedTopology) {
|
|
|
95
110
|
return paths;
|
|
96
111
|
}
|
|
97
112
|
|
|
113
|
+
// The default `common` depth (#530). The sample above never reached a
|
|
114
|
+
// downsell's decline, so a broken decline link passed QA. When every actual
|
|
115
|
+
// terminal path fits under the cap, `common` runs them all. Above the cap it
|
|
116
|
+
// keeps the sample and adds, for each offer page whose decline no planned path
|
|
117
|
+
// clicks yet, the shortest path that clicks it, until the cap is reached. A
|
|
118
|
+
// page counts as covered only when its decline is clicked: arriving at it, or
|
|
119
|
+
// clicking its accept, does not. Pages still uncovered are returned, not
|
|
120
|
+
// dropped. The sample is never trimmed, so a cap below it is still refused by
|
|
121
|
+
// the flood guard exactly as before.
|
|
122
|
+
export function commonTestOrderPlan(resolvedTopology, { cap } = {}) {
|
|
123
|
+
const baseline = commonTestOrderPaths(resolvedTopology);
|
|
124
|
+
let full = null;
|
|
125
|
+
try {
|
|
126
|
+
full = fullTestOrderPaths(resolvedTopology);
|
|
127
|
+
} catch {
|
|
128
|
+
full = null;
|
|
129
|
+
}
|
|
130
|
+
const plan = {
|
|
131
|
+
requested_depth: "common",
|
|
132
|
+
cap,
|
|
133
|
+
full_path_count: full ? full.length : null,
|
|
134
|
+
baseline_paths: baseline,
|
|
135
|
+
};
|
|
136
|
+
if (full && full.length <= cap) {
|
|
137
|
+
// Same set as `full`; the sample's paths keep their place at the front.
|
|
138
|
+
const paths = [...baseline.filter((path) => full.includes(path)), ...full.filter((path) => !baseline.includes(path))];
|
|
139
|
+
return { ...plan, effective_depth: "full", reason: "under_cap", paths, coverage_paths: [], uncovered_pages: [] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const paths = baseline.slice();
|
|
143
|
+
const coveragePaths = [];
|
|
144
|
+
const offers = offerPagesByReach(resolvedTopology);
|
|
145
|
+
for (const offer of offers) {
|
|
146
|
+
if (paths.length >= cap) break;
|
|
147
|
+
const declined = declinedOfferKeys(resolvedTopology, paths);
|
|
148
|
+
if (declined.has(offer.key) || !offer.reach) continue;
|
|
149
|
+
const candidate = shortestDeclinePath(resolvedTopology, offer, declined);
|
|
150
|
+
if (!candidate || paths.includes(candidate)) continue;
|
|
151
|
+
paths.push(candidate);
|
|
152
|
+
coveragePaths.push(candidate);
|
|
153
|
+
}
|
|
154
|
+
const declined = declinedOfferKeys(resolvedTopology, paths);
|
|
155
|
+
const uncovered = offers
|
|
156
|
+
.filter((offer) => !declined.has(offer.key))
|
|
157
|
+
.map((offer) => ({
|
|
158
|
+
page_id: offer.page_id,
|
|
159
|
+
page_type: offer.page_type,
|
|
160
|
+
reason: offer.reach ? "cap" : "unreachable",
|
|
161
|
+
}));
|
|
162
|
+
return {
|
|
163
|
+
...plan,
|
|
164
|
+
effective_depth: "common",
|
|
165
|
+
reason: full ? "over_cap" : "full_not_enumerable",
|
|
166
|
+
paths,
|
|
167
|
+
coverage_paths: coveragePaths,
|
|
168
|
+
uncovered_pages: uncovered,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The offer pages a planned path clicks, in click order: `checkout` clicks
|
|
173
|
+
// none, `decline-accept` clicks the entry offer's decline and then the accept
|
|
174
|
+
// on whichever offer that decline leads to. A walk stops where the topology
|
|
175
|
+
// leaves the offer graph, as the runner does.
|
|
176
|
+
export function testOrderPathClicks(resolvedTopology, path) {
|
|
177
|
+
const normalized = String(path || "").toLowerCase();
|
|
178
|
+
const steps = !normalized || normalized === "checkout" ? [] : normalized.split("-");
|
|
179
|
+
const edges = new Map((resolvedTopology?.offer_edges || []).map((edge) => [edge.key, edge]));
|
|
180
|
+
const clicks = [];
|
|
181
|
+
let current = edges.get(resolvedTopology?.entry_offer_key) || null;
|
|
182
|
+
for (const step of steps) {
|
|
183
|
+
if (!current) break;
|
|
184
|
+
clicks.push({ key: current.key, page_id: current.page_id, action: step });
|
|
185
|
+
current = edges.get(current[`${step}_key`]) || null;
|
|
186
|
+
}
|
|
187
|
+
return clicks;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function declinedOfferKeys(resolvedTopology, paths) {
|
|
191
|
+
const keys = new Set();
|
|
192
|
+
for (const path of paths) {
|
|
193
|
+
for (const click of testOrderPathClicks(resolvedTopology, path)) {
|
|
194
|
+
if (click.action === "decline") keys.add(click.key);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return keys;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Declared offer pages, shallowest first, each with the shortest action
|
|
201
|
+
// sequence that reaches it (accept before decline on a tie) or null when no
|
|
202
|
+
// path from the checkout reaches it.
|
|
203
|
+
function offerPagesByReach(resolvedTopology) {
|
|
204
|
+
const edges = resolvedTopology?.offer_edges || [];
|
|
205
|
+
const byKey = new Map(edges.map((edge) => [edge.key, edge]));
|
|
206
|
+
const reach = new Map();
|
|
207
|
+
const entry = resolvedTopology?.entry_offer_key;
|
|
208
|
+
if (entry && byKey.has(entry)) {
|
|
209
|
+
reach.set(entry, []);
|
|
210
|
+
const queue = [entry];
|
|
211
|
+
while (queue.length) {
|
|
212
|
+
const key = queue.shift();
|
|
213
|
+
for (const action of ["accept", "decline"]) {
|
|
214
|
+
const next = byKey.get(key)?.[`${action}_key`];
|
|
215
|
+
if (!next || reach.has(next) || !byKey.has(next)) continue;
|
|
216
|
+
reach.set(next, [...reach.get(key), action]);
|
|
217
|
+
queue.push(next);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const reached = [...reach.keys()].map((key) => ({ ...byKey.get(key), reach: reach.get(key) }));
|
|
222
|
+
const unreached = edges.filter((edge) => !reach.has(edge.key)).map((edge) => ({ ...edge, reach: null }));
|
|
223
|
+
return [...reached, ...unreached];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// The shortest actual terminal path that clicks this offer's decline. Among
|
|
227
|
+
// equally short paths, the one that also clicks the most still-undeclined
|
|
228
|
+
// offers wins, then accept before decline. Where no terminal path clicks it
|
|
229
|
+
// (the graph beyond is not enumerable), the path that reaches the offer and
|
|
230
|
+
// declines it.
|
|
231
|
+
function shortestDeclinePath(resolvedTopology, offer, declined) {
|
|
232
|
+
const newlyDeclined = (path) => new Set(testOrderPathClicks(resolvedTopology, path)
|
|
233
|
+
.filter((click) => click.action === "decline" && !declined.has(click.key))
|
|
234
|
+
.map((click) => click.key)).size;
|
|
235
|
+
const candidates = (resolvedTopology?.terminal_paths || [])
|
|
236
|
+
.filter((candidate) => testOrderPathClicks(resolvedTopology, candidate.path)
|
|
237
|
+
.some((click) => click.key === offer.key && click.action === "decline"))
|
|
238
|
+
.map((candidate) => ({ ...candidate, gain: newlyDeclined(candidate.path) }))
|
|
239
|
+
.sort((left, right) => (left.steps.length - right.steps.length)
|
|
240
|
+
|| (right.gain - left.gain)
|
|
241
|
+
|| compareActionSteps(left.steps, right.steps));
|
|
242
|
+
if (candidates.length) return candidates[0].path;
|
|
243
|
+
return [...offer.reach, "decline"].join("-");
|
|
244
|
+
}
|
|
245
|
+
|
|
98
246
|
function compareCommonReceiptPaths(left, right) {
|
|
99
247
|
const lengthDelta = (left?.steps?.length || 0) - (right?.steps?.length || 0);
|
|
100
248
|
if (lengthDelta) return lengthDelta;
|
package/src/sdk-markup.mjs
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
//
|
|
3
3
|
// `built_output.upsell_selector_scope` catches one shape of built markup that
|
|
4
4
|
// the Campaign Cart SDK binds without complaint and that then does the wrong
|
|
5
|
-
// thing to a shopper. This module is the rest of that family:
|
|
5
|
+
// thing to a shopper. This module is the rest of that family: seven more
|
|
6
6
|
// shapes, each a static read of built HTML, each producing either a silent
|
|
7
7
|
// no-op (the shopper fills a field that never reaches the order; a button
|
|
8
8
|
// that never enables) or a double cart write. A partner agency's Campaign
|
|
9
|
-
// Cart skill kit listed
|
|
10
|
-
// two vocabularies line up.
|
|
9
|
+
// Cart skill kit listed the first six as stable lint codes; the codes are
|
|
10
|
+
// kept so the two vocabularies line up. ORPHANED_UPSELL_ACTION is ours.
|
|
11
11
|
//
|
|
12
12
|
// Blockers (not waivable — the markup provably does not do what it says)
|
|
13
13
|
// SWAP_WITH_ADD_TO_CART a bundle selector in swap mode (explicit, or
|
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
// MISSING_SELECTOR_ID_MATCH an add-to-cart button whose data-next-selector-id
|
|
27
27
|
// names no selector on the page. The button waits
|
|
28
28
|
// for a selection that can never arrive.
|
|
29
|
+
// ORPHANED_UPSELL_ACTION data-next-upsell-action with no ancestor carrying
|
|
30
|
+
// data-next-upsell (#529). The upsell enhancer
|
|
31
|
+
// binds only the actions inside its container; an
|
|
32
|
+
// action outside it is a plain link, so a "No
|
|
33
|
+
// thanks" goes nowhere and the shopper is stuck.
|
|
34
|
+
// Every page type, not only upsell pages.
|
|
29
35
|
//
|
|
30
36
|
// Warnings (advisory)
|
|
31
37
|
// DOUBLE_SELECTED more than one data-next-selected="true" card in
|
|
@@ -34,6 +40,12 @@
|
|
|
34
40
|
// TEMPLATE_DOUBLE_BRACE `{{` inside an SDK-owned <template>. SDK tokens
|
|
35
41
|
// are single-brace and conditions are no-brace;
|
|
36
42
|
// a double brace renders literally.
|
|
43
|
+
// CHECKOUT_BUMP_IS_UPSELL data-next-is-upsell="true" on a checkout page
|
|
44
|
+
// (#535). A checkout bump is a pre-purchase
|
|
45
|
+
// add-on; the flag puts it on the initial order
|
|
46
|
+
// as an upsell line. The page type is a live,
|
|
47
|
+
// unambiguous next-page-type meta, since it is
|
|
48
|
+
// what the SDK reads; otherwise the route type.
|
|
37
49
|
//
|
|
38
50
|
// Info (advisory, one note per campaign, no code)
|
|
39
51
|
// unknown_attributes[] a data-next-* name the pinned SDK's attribute
|
|
@@ -43,10 +55,11 @@
|
|
|
43
55
|
// own data-next-* hooks, which is why it informs
|
|
44
56
|
// rather than warns.
|
|
45
57
|
//
|
|
46
|
-
// Parsed with parse5 rather than regex because
|
|
47
|
-
// containment (a card inside a selector, a field inside a form,
|
|
48
|
-
// content), and HTML nesting is not
|
|
49
|
-
// walked too: the SDK clones it into
|
|
58
|
+
// Parsed with parse5 rather than regex because five of the seven turn on
|
|
59
|
+
// containment (a card inside a selector, a field inside a form, an action
|
|
60
|
+
// inside its upsell container, a template's content), and HTML nesting is not
|
|
61
|
+
// a regular language. Template content is walked too: the SDK clones it into
|
|
62
|
+
// the live DOM.
|
|
50
63
|
//
|
|
51
64
|
// Pure: callers hand in built HTML. Both doctor entry points drive it.
|
|
52
65
|
|
|
@@ -57,6 +70,9 @@ import {
|
|
|
57
70
|
isIndexedSdkAttribute,
|
|
58
71
|
isKnownCheckoutFieldName,
|
|
59
72
|
} from "./sdk-attribute-index.mjs";
|
|
73
|
+
import { builtPageTypeMeta } from "./upsell-selector-scope.mjs";
|
|
74
|
+
|
|
75
|
+
const normalizedPageTypeValue = (type) => (type == null ? null : String(type).trim().toLowerCase());
|
|
60
76
|
|
|
61
77
|
export const SDK_MARKUP = "built_output.sdk_markup";
|
|
62
78
|
|
|
@@ -65,8 +81,10 @@ export const SDK_MARKUP_CODES = Object.freeze({
|
|
|
65
81
|
CHECKOUT_NOT_FORM: { code: `${SDK_MARKUP}.checkout_not_form`, severity: "error" },
|
|
66
82
|
WRONG_FIELD_NAME: { code: `${SDK_MARKUP}.wrong_field_name`, severity: "error" },
|
|
67
83
|
MISSING_SELECTOR_ID_MATCH: { code: `${SDK_MARKUP}.missing_selector_id_match`, severity: "error" },
|
|
84
|
+
ORPHANED_UPSELL_ACTION: { code: `${SDK_MARKUP}.orphaned_upsell_action`, severity: "error" },
|
|
68
85
|
DOUBLE_SELECTED: { code: `${SDK_MARKUP}.double_selected`, severity: "warning" },
|
|
69
86
|
TEMPLATE_DOUBLE_BRACE: { code: `${SDK_MARKUP}.template_double_brace`, severity: "warning" },
|
|
87
|
+
CHECKOUT_BUMP_IS_UPSELL: { code: `${SDK_MARKUP}.checkout_bump_is_upsell`, severity: "warning" },
|
|
70
88
|
// Unknown data-next-* names are not a finding and carry no code: they are
|
|
71
89
|
// information on the gate (unknown_attributes[]) and one advisory ready line.
|
|
72
90
|
});
|
|
@@ -142,11 +160,22 @@ function describe(entry) {
|
|
|
142
160
|
return `${bits.join("")}>`;
|
|
143
161
|
}
|
|
144
162
|
|
|
163
|
+
function describeBump(entry) {
|
|
164
|
+
const id = entry.attrs.get("id");
|
|
165
|
+
const packageId = entry.attrs.get("data-next-package-id");
|
|
166
|
+
const bits = [`<${entry.tag}`];
|
|
167
|
+
if (id) bits.push(` id="${id}"`);
|
|
168
|
+
if (packageId) bits.push(` data-next-package-id="${packageId}"`);
|
|
169
|
+
return `${bits.join("")}>`;
|
|
170
|
+
}
|
|
171
|
+
|
|
145
172
|
/**
|
|
146
173
|
* Scan one built page. Returns findings with { code_name, code, severity,
|
|
147
174
|
* page_id, file, message, detail } and the set of unknown data-next-* names.
|
|
175
|
+
* `page_type` is the route-inferred type, used only when the page declares no
|
|
176
|
+
* live, unambiguous next-page-type meta (builtPageTypeMeta).
|
|
148
177
|
*/
|
|
149
|
-
export function scanPageMarkup({ page_id, file = null, content = "" }) {
|
|
178
|
+
export function scanPageMarkup({ page_id, file = null, content = "", page_type = null }) {
|
|
150
179
|
const document = parse(String(content || ""));
|
|
151
180
|
const where = file || page_id;
|
|
152
181
|
const findings = [];
|
|
@@ -157,10 +186,13 @@ export function scanPageMarkup({ page_id, file = null, content = "" }) {
|
|
|
157
186
|
const selectorIds = new Set(); // ids of elements that are themselves a selector
|
|
158
187
|
const templates = []; // { entry, sdkOwned }
|
|
159
188
|
const referencedTemplateIds = new Set();
|
|
189
|
+
const upsellFlagged = []; // entries carrying data-next-is-upsell="true"
|
|
160
190
|
|
|
161
191
|
walkElements(document, (entry) => {
|
|
162
192
|
const { tag, attrs: a, ancestors } = entry;
|
|
163
193
|
|
|
194
|
+
if ((a.get("data-next-is-upsell") || "").trim().toLowerCase() === "true") upsellFlagged.push(entry);
|
|
195
|
+
|
|
164
196
|
for (const [name] of a) {
|
|
165
197
|
if (name.startsWith("data-next-") && !isIndexedSdkAttribute(name)) unknown.add(name);
|
|
166
198
|
if (TEMPLATE_ID_ATTRIBUTES.test(name) && a.get(name)) referencedTemplateIds.add(a.get(name).trim());
|
|
@@ -181,6 +213,18 @@ export function scanPageMarkup({ page_id, file = null, content = "" }) {
|
|
|
181
213
|
}
|
|
182
214
|
}
|
|
183
215
|
|
|
216
|
+
// An ancestor, not the element itself: the enhancer looks for actions
|
|
217
|
+
// inside the container it binds.
|
|
218
|
+
if (a.has("data-next-upsell-action") && !ancestors.some((anc) => anc.attrs.has("data-next-upsell"))) {
|
|
219
|
+
const value = a.get("data-next-upsell-action") ?? "";
|
|
220
|
+
// The SDK reads add/accept as accepting the offer and skip/decline as
|
|
221
|
+
// declining it; any other value does nothing even inside a container.
|
|
222
|
+
const verb = { add: "accept", accept: "accept", skip: "decline", decline: "decline" }[value.trim().toLowerCase()] || "act on";
|
|
223
|
+
findings.push(finding("ORPHANED_UPSELL_ACTION", page_id, where,
|
|
224
|
+
`${describe(entry)} data-next-upsell-action="${value}" on ${where} has no ancestor carrying data-next-upsell. The SDK binds upsell actions only inside that container, so this one never fires and the shopper cannot ${verb} the offer. Move it inside the data-next-upsell container it belongs to.`,
|
|
225
|
+
{ tag, action: value }));
|
|
226
|
+
}
|
|
227
|
+
|
|
184
228
|
const action = (a.get("data-next-action") || "").trim().toLowerCase();
|
|
185
229
|
if (action === "add-to-cart") {
|
|
186
230
|
addToCartButtons.push({ entry, selectorId: (a.get("data-next-selector-id") || "").trim() || null });
|
|
@@ -263,6 +307,26 @@ export function scanPageMarkup({ page_id, file = null, content = "" }) {
|
|
|
263
307
|
{ template_id: id || null, snippet }));
|
|
264
308
|
}
|
|
265
309
|
|
|
310
|
+
// One finding per page, naming every flagged element: the repair is the
|
|
311
|
+
// same for each (drop the flag from the bump include's markup; several
|
|
312
|
+
// starter includes write it unconditionally, so an is_upsell=false argument
|
|
313
|
+
// does not always clear it).
|
|
314
|
+
// The page type is read only when a flag is present, so a page without one
|
|
315
|
+
// is not parsed a second time.
|
|
316
|
+
const pageTypeMeta = upsellFlagged.length ? builtPageTypeMeta(content) : null;
|
|
317
|
+
// The live, unambiguous meta wins because it is what the SDK reads; the
|
|
318
|
+
// upsell gate's narrower oto-route rule (builtPageTypeOverRouteGuess) does
|
|
319
|
+
// not apply to a checkout bump.
|
|
320
|
+
const effectivePageType = upsellFlagged.length
|
|
321
|
+
? normalizedPageTypeValue(pageTypeMeta ?? page_type)
|
|
322
|
+
: null;
|
|
323
|
+
if (effectivePageType === "checkout") {
|
|
324
|
+
const elements = upsellFlagged.map(describeBump);
|
|
325
|
+
findings.push(finding("CHECKOUT_BUMP_IS_UPSELL", page_id, where,
|
|
326
|
+
`${elements.length > 1 ? `${elements.length} order bumps` : "An order bump"} on checkout page ${where} (${elements.join(", ")}) ${elements.length > 1 ? "carry" : "carries"} data-next-is-upsell="true". A checkout bump is a pre-purchase add-on; the flag puts it on the initial order as an upsell line. The flag comes from the bump include's markup, and several starter bump includes write it unconditionally, so remove data-next-is-upsell="true" from the include in this campaign unless the line really should be billed as an upsell.`,
|
|
327
|
+
{ page_type: effectivePageType, page_type_source: pageTypeMeta !== null ? "next-page-type" : "route", elements }));
|
|
328
|
+
}
|
|
329
|
+
|
|
266
330
|
return { page_id, file, findings, unknown_attributes: [...unknown].sort() };
|
|
267
331
|
}
|
|
268
332
|
|