@nextcommerce/campaigns-os 1.53.0 → 1.54.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 +61 -0
- package/README.md +1 -0
- 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 +333 -0
- package/contracts/supported-surface.json +3 -3
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/build-packet.md +11 -4
- package/docs/campaign-standardization-report.md +32 -0
- package/docs/design-source-package.md +7 -3
- package/docs/effects.md +2 -1
- 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 +28 -3
- 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 +1 -0
- package/package.json +1 -1
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
- 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 +3 -3
- package/skills.json +10 -10
- package/src/campaign-ecosystem.mjs +153 -31
- package/src/cli.mjs +22 -3
- package/src/directory-lock.mjs +79 -7
- package/src/doctor/checks.mjs +12 -2
- package/src/doctor/inspect.mjs +13 -0
- package/src/invocation.mjs +3 -1
- package/src/progress-node.mjs +3 -2
- package/src/qa-browser.mjs +80 -9
- package/src/qa-node.mjs +33 -4
- package/src/sdk-attribute-index.mjs +111 -23
- package/src/sdk-markup.mjs +49 -10
- package/src/sdk-repin.mjs +282 -0
- package/src/stage-ledger.mjs +1 -1
- package/src/stage-record.mjs +37 -6
- package/src/standardization-report.mjs +26 -1
- package/src/target-lock.mjs +9 -1
package/src/qa-browser.mjs
CHANGED
|
@@ -77,6 +77,22 @@ const DEFAULT_TEST_CARD = "6011111111111117";
|
|
|
77
77
|
const DEFAULT_TEST_CVV = "123";
|
|
78
78
|
const DEFAULT_TEST_EXP_MONTH = "12";
|
|
79
79
|
const DEFAULT_TEST_EXP_YEAR = "2030";
|
|
80
|
+
// The card number and CVV iframes, matched by id prefix because each id ends
|
|
81
|
+
// in a per-load suffix. SDK 0.4.40 and earlier mount Spreedly's iFrame v1
|
|
82
|
+
// (spreedly-number-frame-1234); 0.4.41 mounts the Spreedly Checkout SDK fields
|
|
83
|
+
// that NEXT's payments.29next.com/js/v1/payment.js loads
|
|
84
|
+
// (spreedly-hosted-number-oujmii2uzjk). A page carries one generation or the
|
|
85
|
+
// other, and each frame holds a single text input. fillPaymentFields refuses
|
|
86
|
+
// by name unless exactly one number frame and one CVV frame match, so a page
|
|
87
|
+
// mounting both generations, or two forms, fails at the card step instead of
|
|
88
|
+
// on whichever frame happens to come first.
|
|
89
|
+
const CARD_NUMBER_FRAME = 'iframe[id^="spreedly-number-frame"], iframe[id^="spreedly-hosted-number"]';
|
|
90
|
+
const CARD_CVV_FRAME = 'iframe[id^="spreedly-cvv-frame"], iframe[id^="spreedly-hosted-cvv"]';
|
|
91
|
+
// Typing the card is read back and retried this many times in all before the
|
|
92
|
+
// card step fails by name.
|
|
93
|
+
const CARD_TYPE_ATTEMPTS = 3;
|
|
94
|
+
// How long the card step waits for the SDK to report the card fields ready.
|
|
95
|
+
const CARD_READY_TIMEOUT_MS = 15000;
|
|
80
96
|
const DEFAULT_MAX_TEST_ORDERS = 6;
|
|
81
97
|
// Planned-path ids listed in a refused --max-test-orders message before the
|
|
82
98
|
// remainder is counted rather than printed.
|
|
@@ -3868,7 +3884,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3868
3884
|
}, { timeoutMs: budget() });
|
|
3869
3885
|
await ladder.run("card_fields_filled", async () => {
|
|
3870
3886
|
ensurePageFillable(page, checkoutPage.url);
|
|
3871
|
-
|
|
3887
|
+
return fillPaymentFields(page, args);
|
|
3872
3888
|
}, { timeoutMs: budget() });
|
|
3873
3889
|
await ladder.run("cart_created", async () => {
|
|
3874
3890
|
const cart = cartCreationEvidence(events);
|
|
@@ -5083,14 +5099,69 @@ async function fillPaymentFields(page, args) {
|
|
|
5083
5099
|
|
|
5084
5100
|
const card = normalizeCard(stringArg(args["test-card"]) || DEFAULT_TEST_CARD);
|
|
5085
5101
|
const cvv = stringArg(args["test-cvv"]) || DEFAULT_TEST_CVV;
|
|
5086
|
-
const
|
|
5087
|
-
const
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
await
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5102
|
+
const frames = await cardFrames(page);
|
|
5103
|
+
const readyWaitMs = await waitForCardFieldsReady(page);
|
|
5104
|
+
const numberInput = page.frameLocator(CARD_NUMBER_FRAME).locator("input").first();
|
|
5105
|
+
const cvvInput = page.frameLocator(CARD_CVV_FRAME).locator("input").first();
|
|
5106
|
+
const typed = async (input) => normalizeCard(await input.inputValue());
|
|
5107
|
+
let attempts = 0;
|
|
5108
|
+
let held = null;
|
|
5109
|
+
while (attempts < CARD_TYPE_ATTEMPTS) {
|
|
5110
|
+
attempts++;
|
|
5111
|
+
if (attempts > 1) {
|
|
5112
|
+
await numberInput.fill("");
|
|
5113
|
+
await cvvInput.fill("");
|
|
5114
|
+
}
|
|
5115
|
+
await numberInput.click();
|
|
5116
|
+
await numberInput.pressSequentially(card, { delay: 20 });
|
|
5117
|
+
await cvvInput.click();
|
|
5118
|
+
await cvvInput.pressSequentially(cvv, { delay: 20 });
|
|
5119
|
+
await page.locator("body").click({ position: { x: 20, y: 20 } }).catch(() => {});
|
|
5120
|
+
await page.waitForTimeout(500);
|
|
5121
|
+
held = { number: await typed(numberInput), cvv: await typed(cvvInput) };
|
|
5122
|
+
if (held.number === card && held.cvv === normalizeCard(cvv)) {
|
|
5123
|
+
return { evidence: { ...frames, ready_wait_ms: readyWaitMs, attempts } };
|
|
5124
|
+
}
|
|
5125
|
+
}
|
|
5126
|
+
throw new Error(`card fields did not keep the typed card after ${attempts} attempts: number field holds ${held.number.length} digit(s) ending ${held.number.slice(-4) || "(empty)"} (typed ${card.length} ending ${card.slice(-4)}), CVV field holds ${held.cvv.length} digit(s)`);
|
|
5127
|
+
}
|
|
5128
|
+
|
|
5129
|
+
// Typing into the card fields before the SDK reports them ready can lose
|
|
5130
|
+
// keystrokes: on SDK 0.4.41 the Spreedly-hosted number input exists before
|
|
5131
|
+
// its script has loaded, and digits typed in that window are dropped (seen
|
|
5132
|
+
// as a number missing its leading digits, which the SDK then refuses without
|
|
5133
|
+
// tokenizing, so the order never posts). The checkout form carries
|
|
5134
|
+
// next-loading-spreedly from before the card iframes mount until the fields
|
|
5135
|
+
// are ready, in 0.4.38 through 0.4.41, so wait for it to clear. A page whose
|
|
5136
|
+
// SDK never set it is ready at once. The SDK also clears it when the card
|
|
5137
|
+
// script fails to load, so a class still set after the wait means the card
|
|
5138
|
+
// script never answered; the step refuses by name rather than type into
|
|
5139
|
+
// fields that may drop the number.
|
|
5140
|
+
async function waitForCardFieldsReady(page) {
|
|
5141
|
+
const started = Date.now();
|
|
5142
|
+
await page.waitForFunction(() => !document.querySelector(".next-loading-spreedly"), null, { timeout: CARD_READY_TIMEOUT_MS }).catch((error) => {
|
|
5143
|
+
if (error?.name !== "TimeoutError") throw error;
|
|
5144
|
+
throw new Error(`card fields did not report ready within ${CARD_READY_TIMEOUT_MS / 1000}s: the checkout form still carries next-loading-spreedly, so the card script never finished loading`);
|
|
5145
|
+
});
|
|
5146
|
+
return Date.now() - started;
|
|
5147
|
+
}
|
|
5148
|
+
|
|
5149
|
+
// The card iframes the step is about to type into, once the SDK has mounted
|
|
5150
|
+
// them. Exactly one of each, of one generation, or the step fails by name.
|
|
5151
|
+
async function cardFrames(page) {
|
|
5152
|
+
await page.locator(CARD_NUMBER_FRAME).first().waitFor({ state: "attached" });
|
|
5153
|
+
await page.locator(CARD_CVV_FRAME).first().waitFor({ state: "attached" });
|
|
5154
|
+
const ids = async (selector) => page.locator(selector).evaluateAll((nodes) => nodes.map((node) => node.id));
|
|
5155
|
+
const number = await ids(CARD_NUMBER_FRAME);
|
|
5156
|
+
const cvv = await ids(CARD_CVV_FRAME);
|
|
5157
|
+
const all = [...number, ...cvv];
|
|
5158
|
+
const generation = all.every((id) => id.startsWith("spreedly-hosted-"))
|
|
5159
|
+
? "spreedly-hosted"
|
|
5160
|
+
: all.every((id) => /^spreedly-(number|cvv)-frame/.test(id)) ? "spreedly-iframe-v1" : "mixed";
|
|
5161
|
+
if (number.length !== 1 || cvv.length !== 1 || generation === "mixed") {
|
|
5162
|
+
throw new Error(`expected one card number iframe and one CVV iframe of one generation; found number=[${number.join(", ")}] cvv=[${cvv.join(", ")}]`);
|
|
5163
|
+
}
|
|
5164
|
+
return { generation, number_frame_id: number[0], cvv_frame_id: cvv[0] };
|
|
5094
5165
|
}
|
|
5095
5166
|
|
|
5096
5167
|
async function clickCreditPaymentMethod(page) {
|
package/src/qa-node.mjs
CHANGED
|
@@ -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
|
|
@@ -2668,7 +2670,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2668
2670
|
const consent = resolveConsent({ proxyBase: resolved.proxyBase });
|
|
2669
2671
|
const publishDecision = resolved.localSpecId
|
|
2670
2672
|
? { publish: false, reason: "local_spec", flag_invalid: false }
|
|
2671
|
-
: decidePublishVerdict({ args, portalManaged: resolved.portalManaged === true, consent });
|
|
2673
|
+
: decidePublishVerdict({ args, portalManaged: resolved.portalManaged === true, consent, baseUrl: resolved.baseUrl });
|
|
2672
2674
|
if (publishDecision.flag_invalid) {
|
|
2673
2675
|
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
2676
|
}
|
|
@@ -3472,6 +3474,9 @@ function output(value, args) {
|
|
|
3472
3474
|
} else if (value.publish_skipped && value.publish_decision?.reason === "consent_off") {
|
|
3473
3475
|
console.log(`QA portal: publish skipped — telemetry consent is off, so this non-portal-managed verdict stays local.`);
|
|
3474
3476
|
console.log(` Destination would be ${value.publish_decision.destination}. Opt in for this run with --post-verdict, or enable with \`${cmd("telemetry")} on\`.`);
|
|
3477
|
+
} else if (value.publish_skipped && value.publish_decision?.reason === "loopback_base_url") {
|
|
3478
|
+
console.log(`QA portal: publish skipped — the base URL is a local address, so this verdict stays local.`);
|
|
3479
|
+
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
3480
|
} else if (value.publish_skipped) {
|
|
3476
3481
|
console.log(`QA portal: publish skipped (--no-post-verdict); local verdict only.`);
|
|
3477
3482
|
} else {
|
|
@@ -3757,8 +3762,13 @@ export function forcedAnalyticsCorrectness(args) {
|
|
|
3757
3762
|
// else (client projects, fixtures, local shakeouts on a local spec), consent
|
|
3758
3763
|
// off (CAMPAIGNS_OS_TELEMETRY=off / `campaigns-os telemetry off`) means the
|
|
3759
3764
|
// 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
|
-
|
|
3765
|
+
// run output. Publishing for the portal path is never weakened by consent.
|
|
3766
|
+
// #486: a run against a local-address base URL (localhost or any *.localhost
|
|
3767
|
+
// name, any 127.x.x.x, 0.0.0.0, [::1] or an IPv4-mapped loopback; see
|
|
3768
|
+
// isLocalAddressUrl below) is a local check, so by default its verdict stays
|
|
3769
|
+
// local whatever the spec source; the output names --post-verdict and qa
|
|
3770
|
+
// publish. An explicit --post-verdict still publishes.
|
|
3771
|
+
export function decidePublishVerdict({ args = {}, portalManaged = false, consent = null, baseUrl = null } = {}) {
|
|
3762
3772
|
if (args["no-post-verdict"] === true || args["local-only"] === true) {
|
|
3763
3773
|
return { publish: false, reason: "flag_opt_out" };
|
|
3764
3774
|
}
|
|
@@ -3775,11 +3785,30 @@ export function decidePublishVerdict({ args = {}, portalManaged = false, consent
|
|
|
3775
3785
|
flagInvalid = true;
|
|
3776
3786
|
}
|
|
3777
3787
|
const decorate = (decision) => (flagInvalid ? { ...decision, flag_invalid: true } : decision);
|
|
3788
|
+
if (isLocalAddressUrl(baseUrl)) return decorate({ publish: false, reason: "loopback_base_url" });
|
|
3778
3789
|
if (portalManaged) return decorate({ publish: true, reason: "portal_managed_default" });
|
|
3779
3790
|
if (consent?.state === "off") return decorate({ publish: false, reason: "consent_off" });
|
|
3780
3791
|
return decorate({ publish: true, reason: "default" });
|
|
3781
3792
|
}
|
|
3782
3793
|
|
|
3794
|
+
// The publish default (#486) reads "local" more widely than the shared
|
|
3795
|
+
// LOOPBACK_HOSTNAMES list, which also decides where plain http is allowed and
|
|
3796
|
+
// so stays narrow: any 127.0.0.0/8 address, 0.0.0.0, [::1], an IPv4-mapped
|
|
3797
|
+
// loopback, and localhost or any *.localhost name (with or without a trailing
|
|
3798
|
+
// dot). Keeping such a run local is the safe direction: --post-verdict and
|
|
3799
|
+
// qa publish still send it.
|
|
3800
|
+
function isLocalAddressUrl(value) {
|
|
3801
|
+
if (typeof value !== "string" || !value.trim()) return false;
|
|
3802
|
+
let hostname;
|
|
3803
|
+
try { hostname = new URL(value.trim()).hostname.toLowerCase(); } catch { return false; }
|
|
3804
|
+
const name = hostname.replace(/\.$/, "");
|
|
3805
|
+
if (name === "localhost" || name.endsWith(".localhost")) return true;
|
|
3806
|
+
if (/^127(?:\.\d{1,3}){3}$/.test(name) || name === "0.0.0.0") return true;
|
|
3807
|
+
if (name === "[::1]" || name === "[::]") return true;
|
|
3808
|
+
// WHATWG URL serializes ::ffff:127.x.y.z as [::ffff:7fxx:xxxx].
|
|
3809
|
+
return /^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/.test(name);
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3783
3812
|
function policySnapshot(packet) {
|
|
3784
3813
|
return {
|
|
3785
3814
|
campaign: {
|
|
@@ -1,25 +1,43 @@
|
|
|
1
1
|
// The Campaign Cart SDK attribute index, vendored for the static SDK markup
|
|
2
2
|
// checks (#303).
|
|
3
3
|
//
|
|
4
|
-
// Source:
|
|
5
|
-
// v0.4.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// attribute
|
|
9
|
-
//
|
|
4
|
+
// Source: the SDK's own source in NextCommerceCo/campaign-cart at tag
|
|
5
|
+
// v0.4.41 (the SDK pin every certified starter family ships today): every
|
|
6
|
+
// data-next-* name the SDK's non-test TypeScript names as a string, plus every
|
|
7
|
+
// `dataset.next*` property it reads or writes, turned back into its
|
|
8
|
+
// attribute. Only the data-next-* names are kept: the checks that read this
|
|
9
|
+
// list ask "does the SDK read this data-next-* attribute at all", and the
|
|
10
|
+
// SDK's other attribute families (data-item-*, data-package-*, plain data-*)
|
|
11
|
+
// are not what an agent kit misspells.
|
|
10
12
|
//
|
|
11
13
|
// This is a hardcoded copy and says so, rather than restating the SDK in
|
|
12
14
|
// prose that then drifts (the partner kit's mistake): the list is regenerated
|
|
13
|
-
// whole from the
|
|
15
|
+
// whole from the source at a named tag, never edited by hand.
|
|
14
16
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
17
|
+
// paths=('src/**/*.ts' ':!src/**/*.test.ts' ':!src/**/tests/**' ':!src/docs/**')
|
|
18
|
+
// { git -C ../campaign-cart grep -h -oP 'data-next-[a-z0-9]+(?:-[a-z0-9]+)*-?' v0.4.41 -- "${paths[@]}"
|
|
19
|
+
// git -C ../campaign-cart grep -h -oP 'dataset\.next[A-Z][A-Za-z0-9]*' v0.4.41 -- "${paths[@]}" \
|
|
20
|
+
// | sed 's/^dataset\.//' | perl -pe 's/([A-Z])/-\L$1/g; s/^/data-/'
|
|
21
|
+
// } | sort -u
|
|
22
|
+
//
|
|
23
|
+
// Up to v0.4.40 the list came from docs/attribute-index.md, which the SDK
|
|
24
|
+
// generated from its feature manifests. v0.4.41 removed the manifests and the
|
|
25
|
+
// index (campaign-cart 5d81b4eb), and its hand-written attribute docs
|
|
26
|
+
// (docs/sdk-attributes.md, docs/guides/reference/data-attributes.md, each
|
|
27
|
+
// feature's guide/) leave out names the source still reads, such as
|
|
28
|
+
// data-next-package-selector and data-next-remove-item. Run at v0.4.40, the
|
|
29
|
+
// source recipe returns every name the v0.4.40 index listed, plus names the
|
|
30
|
+
// manifests never did (the address block's, data-next-i18n,
|
|
31
|
+
// data-next-bump-section, data-next-button-text, the variant option state).
|
|
32
|
+
// At v0.4.41 it returns every name of the v0.4.38 list this replaces, plus
|
|
33
|
+
// those and data-next-phone-e164 / data-next-phone-country.
|
|
18
34
|
//
|
|
19
35
|
// A name ending in "-" (data-next-class-) is a prefix the SDK reads with any
|
|
20
36
|
// suffix.
|
|
21
37
|
|
|
22
|
-
|
|
38
|
+
import { RELEASED_SDK_VERSION_PATTERN } from "../campaign-spec/dist/index.js";
|
|
39
|
+
|
|
40
|
+
export const SDK_ATTRIBUTE_INDEX_VERSION = "0.4.41";
|
|
23
41
|
|
|
24
42
|
export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
|
|
25
43
|
"data-next-accordion",
|
|
@@ -28,8 +46,15 @@ export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
|
|
|
28
46
|
"data-next-accordion-trigger",
|
|
29
47
|
"data-next-action",
|
|
30
48
|
"data-next-active",
|
|
49
|
+
"data-next-address",
|
|
50
|
+
"data-next-address-api",
|
|
51
|
+
"data-next-address-field",
|
|
52
|
+
"data-next-address-lang",
|
|
53
|
+
"data-next-address-row",
|
|
54
|
+
"data-next-address-state",
|
|
31
55
|
"data-next-await",
|
|
32
56
|
"data-next-bump",
|
|
57
|
+
"data-next-bump-section",
|
|
33
58
|
"data-next-bundle-card",
|
|
34
59
|
"data-next-bundle-display",
|
|
35
60
|
"data-next-bundle-id",
|
|
@@ -47,6 +72,7 @@ export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
|
|
|
47
72
|
"data-next-bundle-template-id",
|
|
48
73
|
"data-next-bundle-vouchers",
|
|
49
74
|
"data-next-bundles",
|
|
75
|
+
"data-next-button-text",
|
|
50
76
|
"data-next-cart-item-id",
|
|
51
77
|
"data-next-cart-items",
|
|
52
78
|
"data-next-cart-selector",
|
|
@@ -74,6 +100,7 @@ export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
|
|
|
74
100
|
"data-next-fallback",
|
|
75
101
|
"data-next-format",
|
|
76
102
|
"data-next-hide",
|
|
103
|
+
"data-next-i18n",
|
|
77
104
|
"data-next-id",
|
|
78
105
|
"data-next-in-cart",
|
|
79
106
|
"data-next-include-shipping",
|
|
@@ -98,6 +125,8 @@ export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
|
|
|
98
125
|
"data-next-payment-form",
|
|
99
126
|
"data-next-payment-method",
|
|
100
127
|
"data-next-payment-state",
|
|
128
|
+
"data-next-phone-country",
|
|
129
|
+
"data-next-phone-e164",
|
|
101
130
|
"data-next-product-sync",
|
|
102
131
|
"data-next-property",
|
|
103
132
|
"data-next-property-container",
|
|
@@ -138,6 +167,7 @@ export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
|
|
|
138
167
|
"data-next-tooltip-offset",
|
|
139
168
|
"data-next-tooltip-placement",
|
|
140
169
|
"data-next-tracking-tag",
|
|
170
|
+
"data-next-unavailable",
|
|
141
171
|
"data-next-upsell",
|
|
142
172
|
"data-next-upsell-action",
|
|
143
173
|
"data-next-upsell-action-for",
|
|
@@ -152,17 +182,21 @@ export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
|
|
|
152
182
|
"data-next-url",
|
|
153
183
|
"data-next-validate",
|
|
154
184
|
"data-next-variant-code",
|
|
185
|
+
"data-next-variant-name",
|
|
155
186
|
"data-next-variant-option",
|
|
156
187
|
"data-next-variant-option-template-id",
|
|
157
188
|
"data-next-variant-options",
|
|
158
189
|
"data-next-variant-selector-template-id",
|
|
159
190
|
"data-next-variant-selectors",
|
|
191
|
+
"data-next-variant-value",
|
|
160
192
|
]);
|
|
161
193
|
|
|
162
|
-
// The fixed data-next-checkout-field names
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
// v0.4.
|
|
194
|
+
// The fixed data-next-checkout-field names. The SDK maps a field to the order
|
|
195
|
+
// by this value, so an unlisted name is a silent no-op: the input renders, the
|
|
196
|
+
// shopper fills it, nothing reaches the order. Taken at v0.4.38 and unchanged
|
|
197
|
+
// through v0.4.41 (the README's "Standard field names" line is the same; the
|
|
198
|
+
// names v0.4.39 and v0.4.41 added are the version-gated aliases below).
|
|
199
|
+
// Sources: src/features/checkout/README.md ("Standard field names"), the
|
|
166
200
|
// checkout-form enhancer and its validation/persistence tables, the hosted
|
|
167
201
|
// payment field slots (cc-number, cvv, cc-month/cc-year and their exp-*
|
|
168
202
|
// spellings), payment-method, and accepts_marketing. Billing fields are the
|
|
@@ -188,7 +222,7 @@ export const SDK_CHECKOUT_FIELD_NAMES = Object.freeze([
|
|
|
188
222
|
"exp-month",
|
|
189
223
|
"exp-year",
|
|
190
224
|
"cvv",
|
|
191
|
-
// Legacy spellings the README at v0.4.
|
|
225
|
+
// Legacy spellings the README still lists at v0.4.41.
|
|
192
226
|
"card-number",
|
|
193
227
|
"card-expiry",
|
|
194
228
|
"card-cvv",
|
|
@@ -197,6 +231,53 @@ export const SDK_CHECKOUT_FIELD_NAMES = Object.freeze([
|
|
|
197
231
|
|
|
198
232
|
export const SDK_CHECKOUT_FIELD_PREFIXES = Object.freeze(["billing-"]);
|
|
199
233
|
|
|
234
|
+
// Field names a later SDK added, each with the first version that maps it.
|
|
235
|
+
// They are the orders API's own names, which the SDK reads as another
|
|
236
|
+
// spelling of its older one (src/utils/checkout-field-names.ts, SDK_NAME):
|
|
237
|
+
// first_name and last_name from v0.4.39 (v0.4.40 is the same SDK), and
|
|
238
|
+
// phone_number from v0.4.41, whose data-attributes reference ("Field names")
|
|
239
|
+
// makes these the names to write and says fname/lname/phone still work. The
|
|
240
|
+
// older names stay in SDK_CHECKOUT_FIELD_NAMES and are valid on every
|
|
241
|
+
// version. A page whose SDK version is unknown is judged as an earlier SDK:
|
|
242
|
+
// the older name works on every version, so asking for it is always safe,
|
|
243
|
+
// and accepting the newer name there could pass a field that never reaches
|
|
244
|
+
// the order. Kept in step with version_gated_aliases in
|
|
245
|
+
// contracts/campaign-cart-checkout-field-contract.v0.json (a test holds the
|
|
246
|
+
// two equal).
|
|
247
|
+
export const SDK_CHECKOUT_FIELD_NAMES_SINCE = Object.freeze({
|
|
248
|
+
first_name: Object.freeze({ since: "0.4.39", sdk_name: "fname" }),
|
|
249
|
+
last_name: Object.freeze({ since: "0.4.39", sdk_name: "lname" }),
|
|
250
|
+
phone_number: Object.freeze({ since: "0.4.41", sdk_name: "phone" }),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Whether `version` is an exact released SDK version at or after `since`.
|
|
254
|
+
// "Exact released" is campaign-spec's RELEASED_SDK_VERSION_PATTERN, the one
|
|
255
|
+
// doctor's campaigns.json pin check uses, so doctor and standardize agree.
|
|
256
|
+
// Anything else (null, a range, a prerelease, @latest, a v prefix) is not: a
|
|
257
|
+
// prerelease sorts before its release, so 0.4.39-beta.1 does not carry
|
|
258
|
+
// 0.4.39's names.
|
|
259
|
+
export function sdkVersionAtLeast(version, since) {
|
|
260
|
+
const have = typeof version === "string" ? RELEASED_SDK_VERSION_PATTERN.exec(version) : null;
|
|
261
|
+
const need = typeof since === "string" ? RELEASED_SDK_VERSION_PATTERN.exec(since) : null;
|
|
262
|
+
if (!have || !need) return false;
|
|
263
|
+
for (let index = 1; index <= 3; index += 1) {
|
|
264
|
+
const diff = Number(have[index]) - Number(need[index]);
|
|
265
|
+
if (diff !== 0) return diff > 0;
|
|
266
|
+
}
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The version-gated entry for a field name, billing- prefix included
|
|
271
|
+
// ("billing-first_name" → first_name's entry), or null.
|
|
272
|
+
export function checkoutFieldNameSince(value) {
|
|
273
|
+
let name = String(value ?? "");
|
|
274
|
+
const prefix = SDK_CHECKOUT_FIELD_PREFIXES.find((candidate) => name.startsWith(candidate));
|
|
275
|
+
if (prefix) name = name.slice(prefix.length);
|
|
276
|
+
if (!Object.hasOwn(SDK_CHECKOUT_FIELD_NAMES_SINCE, name)) return null;
|
|
277
|
+
const entry = SDK_CHECKOUT_FIELD_NAMES_SINCE[name];
|
|
278
|
+
return { name, since: entry.since, sdk_name: `${prefix || ""}${entry.sdk_name}` };
|
|
279
|
+
}
|
|
280
|
+
|
|
200
281
|
const exact = new Set(SDK_DATA_NEXT_ATTRIBUTES.filter((name) => !name.endsWith("-")));
|
|
201
282
|
const prefixes = SDK_DATA_NEXT_ATTRIBUTES.filter((name) => name.endsWith("-"));
|
|
202
283
|
|
|
@@ -205,15 +286,20 @@ export function isIndexedSdkAttribute(name) {
|
|
|
205
286
|
return exact.has(value) || prefixes.some((prefix) => value.startsWith(prefix) && value.length > prefix.length);
|
|
206
287
|
}
|
|
207
288
|
|
|
208
|
-
|
|
289
|
+
// Whether the SDK maps `value`. `sdkVersion` is the page's exact SDK
|
|
290
|
+
// version; without one, a version-gated name is not known (see
|
|
291
|
+
// SDK_CHECKOUT_FIELD_NAMES_SINCE).
|
|
292
|
+
export function isKnownCheckoutFieldName(value, sdkVersion = null) {
|
|
209
293
|
const name = String(value ?? "");
|
|
210
|
-
|
|
211
|
-
|| SDK_CHECKOUT_FIELD_PREFIXES.some((prefix) => name.startsWith(prefix) && name.length > prefix.length);
|
|
294
|
+
if (SDK_CHECKOUT_FIELD_NAMES.includes(name)
|
|
295
|
+
|| SDK_CHECKOUT_FIELD_PREFIXES.some((prefix) => name.startsWith(prefix) && name.length > prefix.length)) return true;
|
|
296
|
+
const gated = checkoutFieldNameSince(name);
|
|
297
|
+
return Boolean(gated) && sdkVersionAtLeast(sdkVersion, gated.since);
|
|
212
298
|
}
|
|
213
299
|
|
|
214
300
|
// The Campaign Cart template placeholders, for the raw cart placeholder check
|
|
215
301
|
// (built_output.cart_placeholders). Generated from the SDK renderers at the
|
|
216
|
-
//
|
|
302
|
+
// v0.4.38 (line numbers are at that tag):
|
|
217
303
|
//
|
|
218
304
|
// src/features/cart/cart-summary/cart-summary.renderer.ts:46-83 (bare vars)
|
|
219
305
|
// src/features/cart/cart-summary/cart-summary.line-renderer.ts:150-173,
|
|
@@ -278,6 +364,8 @@ export const SDK_TEMPLATE_PLACEHOLDERS = Object.freeze({
|
|
|
278
364
|
});
|
|
279
365
|
|
|
280
366
|
// The SDK versions whose renderer files above were verified unchanged from
|
|
281
|
-
// v0.4.38 (git blob ids equal at v0.4.38, v0.4.39
|
|
282
|
-
//
|
|
283
|
-
|
|
367
|
+
// v0.4.38 (git blob ids equal at v0.4.38, v0.4.39, v0.4.40 and v0.4.41, for
|
|
368
|
+
// the eight renderer files cited above plus cart-item-list.enhancer.ts and
|
|
369
|
+
// order-item-list.enhancer.ts). A page whose loader pins another version
|
|
370
|
+
// cannot pass the placeholder check.
|
|
371
|
+
export const SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS = Object.freeze(["0.4.38", "0.4.39", "0.4.40", "0.4.41"]);
|
package/src/sdk-markup.mjs
CHANGED
|
@@ -22,7 +22,12 @@
|
|
|
22
22
|
// WRONG_FIELD_NAME data-next-checkout-field with a value the SDK
|
|
23
23
|
// does not map (firstName, lastName, zip …). The
|
|
24
24
|
// input renders, the value never reaches the
|
|
25
|
-
// order.
|
|
25
|
+
// order. first_name, last_name and phone_number
|
|
26
|
+
// are judged against the page's SDK version
|
|
27
|
+
// (SDK_CHECKOUT_FIELD_NAMES_SINCE): the page's
|
|
28
|
+
// own exact loader pin, else the campaign's
|
|
29
|
+
// campaigns.json sdk_version, else unknown, which
|
|
30
|
+
// is judged as an earlier SDK.
|
|
26
31
|
// MISSING_SELECTOR_ID_MATCH an add-to-cart button whose data-next-selector-id
|
|
27
32
|
// names no selector on the page. The button waits
|
|
28
33
|
// for a selection that can never arrive.
|
|
@@ -64,8 +69,13 @@
|
|
|
64
69
|
|
|
65
70
|
import { parse } from "parse5";
|
|
66
71
|
|
|
72
|
+
// cart-placeholders.mjs imports this module's template constants as well;
|
|
73
|
+
// both sides read the other's bindings only inside functions, so the cycle
|
|
74
|
+
// is safe at load.
|
|
75
|
+
import { readLoaderPin } from "./cart-placeholders.mjs";
|
|
67
76
|
import {
|
|
68
77
|
SDK_ATTRIBUTE_INDEX_VERSION,
|
|
78
|
+
checkoutFieldNameSince,
|
|
69
79
|
isIndexedSdkAttribute,
|
|
70
80
|
isKnownCheckoutFieldName,
|
|
71
81
|
} from "./sdk-attribute-index.mjs";
|
|
@@ -158,10 +168,13 @@ function describe(entry) {
|
|
|
158
168
|
/**
|
|
159
169
|
* Scan one built page. Returns findings with { code_name, code, severity,
|
|
160
170
|
* page_id, file, message, detail } and the set of unknown data-next-* names.
|
|
171
|
+
* `sdk_version` is the campaign's SDK pin, used when the page carries no
|
|
172
|
+
* exact loader pin of its own.
|
|
161
173
|
*/
|
|
162
|
-
export function scanPageMarkup({ page_id, file = null, content = "" }) {
|
|
174
|
+
export function scanPageMarkup({ page_id, file = null, content = "", sdk_version = null }) {
|
|
163
175
|
const document = parse(String(content || ""));
|
|
164
176
|
const where = file || page_id;
|
|
177
|
+
const sdk = resolvePageSdkVersion(document, sdk_version);
|
|
165
178
|
const findings = [];
|
|
166
179
|
const unknown = new Set();
|
|
167
180
|
|
|
@@ -187,10 +200,8 @@ export function scanPageMarkup({ page_id, file = null, content = "" }) {
|
|
|
187
200
|
|
|
188
201
|
if (a.has("data-next-checkout-field")) {
|
|
189
202
|
const value = a.get("data-next-checkout-field") ?? "";
|
|
190
|
-
if (!isKnownCheckoutFieldName(value)) {
|
|
191
|
-
findings.push(
|
|
192
|
-
`data-next-checkout-field="${value}" on ${where} is not a field name the SDK ${SDK_ATTRIBUTE_INDEX_VERSION} maps (${suggestFieldName(value)}). The input renders and the value never reaches the order.`,
|
|
193
|
-
{ value, suggestion: suggestFieldName(value, true) }));
|
|
203
|
+
if (!isKnownCheckoutFieldName(value, sdk.version)) {
|
|
204
|
+
findings.push(wrongFieldName(page_id, where, value, sdk));
|
|
194
205
|
}
|
|
195
206
|
}
|
|
196
207
|
|
|
@@ -291,6 +302,32 @@ export function scanPageMarkup({ page_id, file = null, content = "" }) {
|
|
|
291
302
|
return { page_id, file, findings, unknown_attributes: [...unknown].sort() };
|
|
292
303
|
}
|
|
293
304
|
|
|
305
|
+
// The SDK version a page runs: its own exact loader pin, else the campaign
|
|
306
|
+
// pin handed in, else null (unknown).
|
|
307
|
+
function resolvePageSdkVersion(document, campaignVersion) {
|
|
308
|
+
const page = readLoaderPin(document);
|
|
309
|
+
if (page) return { version: page, source: "loader" };
|
|
310
|
+
const campaign = typeof campaignVersion === "string" && campaignVersion.trim() ? campaignVersion.trim() : null;
|
|
311
|
+
if (campaign) return { version: campaign, source: "campaigns_json" };
|
|
312
|
+
return { version: null, source: null };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function wrongFieldName(page_id, where, value, sdk) {
|
|
316
|
+
const gated = checkoutFieldNameSince(value);
|
|
317
|
+
const versionDetail = { sdk_version: sdk.version, sdk_version_source: sdk.source };
|
|
318
|
+
if (!gated) {
|
|
319
|
+
return finding("WRONG_FIELD_NAME", page_id, where,
|
|
320
|
+
`data-next-checkout-field="${value}" on ${where} is not a field name the SDK ${SDK_ATTRIBUTE_INDEX_VERSION} maps (${suggestFieldName(value)}). The input renders and the value never reaches the order.`,
|
|
321
|
+
{ value, suggestion: suggestFieldName(value, true), ...versionDetail });
|
|
322
|
+
}
|
|
323
|
+
const running = sdk.version
|
|
324
|
+
? `this page loads SDK ${sdk.version} (${sdk.source === "loader" ? "its loader pin" : "the campaigns.json sdk_version"})`
|
|
325
|
+
: "this page's SDK version could not be read (no exact loader pin on the page and no sdk_version in campaigns.json), so it is judged as an earlier SDK";
|
|
326
|
+
return finding("WRONG_FIELD_NAME", page_id, where,
|
|
327
|
+
`data-next-checkout-field="${value}" on ${where} is a field name the SDK maps only from ${gated.since}, and ${running}. The input renders and the value never reaches the order. Use "${gated.sdk_name}", which every SDK version maps, or pin the SDK to ${gated.since} or later.`,
|
|
328
|
+
{ value, suggestion: gated.sdk_name, since: gated.since, ...versionDetail });
|
|
329
|
+
}
|
|
330
|
+
|
|
294
331
|
function finding(codeName, page_id, file, message, detail = {}) {
|
|
295
332
|
const { code, severity } = SDK_MARKUP_CODES[codeName];
|
|
296
333
|
return { code_name: codeName, code, severity, page_id, file, message: `${codeName}: ${message}`, detail };
|
|
@@ -318,11 +355,13 @@ function suggestFieldName(value, bare = false) {
|
|
|
318
355
|
/**
|
|
319
356
|
* Evaluate the SDK markup family over built pages.
|
|
320
357
|
*
|
|
321
|
-
* @param {{ subject: object, pages: Array<{ page_id: string, file?: string|null, content: string }
|
|
358
|
+
* @param {{ subject: object, pages: Array<{ page_id: string, file?: string|null, content: string }>, sdkVersion?: string|null }} input
|
|
359
|
+
* `sdkVersion` is the campaign's SDK pin (campaigns.json sdk_version), the
|
|
360
|
+
* fallback for a page with no exact loader pin of its own.
|
|
322
361
|
*/
|
|
323
|
-
export function evaluateSdkMarkup({ subject, pages = [] } = {}) {
|
|
362
|
+
export function evaluateSdkMarkup({ subject, pages = [], sdkVersion = null } = {}) {
|
|
324
363
|
const resolvedSubject = subject && typeof subject === "object" ? subject : {};
|
|
325
|
-
const base = { id: SDK_MARKUP, scope: SDK_MARKUP, waivable: false, subject: resolvedSubject, waiver: null, sdk_attribute_index_version: SDK_ATTRIBUTE_INDEX_VERSION };
|
|
364
|
+
const base = { id: SDK_MARKUP, scope: SDK_MARKUP, waivable: false, subject: resolvedSubject, waiver: null, sdk_attribute_index_version: SDK_ATTRIBUTE_INDEX_VERSION, campaign_sdk_version: typeof sdkVersion === "string" && sdkVersion.trim() ? sdkVersion.trim() : null };
|
|
326
365
|
const list = Array.isArray(pages) ? pages : [];
|
|
327
366
|
|
|
328
367
|
if (list.length === 0) {
|
|
@@ -333,7 +372,7 @@ export function evaluateSdkMarkup({ subject, pages = [] } = {}) {
|
|
|
333
372
|
const warned = [];
|
|
334
373
|
const unknown = new Map();
|
|
335
374
|
for (const page of list) {
|
|
336
|
-
const scan = scanPageMarkup(page);
|
|
375
|
+
const scan = scanPageMarkup({ ...page, sdk_version: page?.sdk_version ?? sdkVersion });
|
|
337
376
|
for (const item of scan.findings) (item.severity === "error" ? findings : warned).push(item);
|
|
338
377
|
for (const name of scan.unknown_attributes) {
|
|
339
378
|
if (!unknown.has(name)) unknown.set(name, []);
|