@nextcommerce/campaigns-os 1.55.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 +64 -0
- package/CONTEXT.md +11 -0
- 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 +705 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
- package/contracts/supported-surface.json +8 -4
- package/docs/build-packet.md +52 -8
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +78 -18
- 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/package.json +2 -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/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 +4 -3
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/build-brief.mjs +14 -5
- package/src/cli-helpers.mjs +129 -0
- package/src/cli.mjs +121 -53
- package/src/commercial-journey.mjs +25 -2
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +7 -7
- package/src/doctor/checks.mjs +196 -13
- package/src/doctor/inspect.mjs +4 -2
- package/src/doctor/next-step.mjs +31 -11
- package/src/doctor-sidecar.mjs +5 -3
- package/src/gate-actions.mjs +21 -1
- package/src/input-refresh.mjs +6 -6
- package/src/local-preview-policy.mjs +110 -12
- package/src/polish-node.mjs +3 -3
- package/src/qa-browser.mjs +226 -34
- package/src/qa-cart-entry.mjs +78 -14
- package/src/qa-node.mjs +35 -33
- package/src/qa-test-order-topology.mjs +118 -1
- package/src/qa-verdict-discovery.mjs +21 -4
- package/src/runtime-state-ignore.mjs +7 -5
- package/src/source-html-intake.mjs +46 -4
- package/src/stage-ledger.mjs +4 -2
- package/src/stage-record.mjs +3 -3
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";
|
|
@@ -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
|
|
@@ -464,7 +467,6 @@ async function resolveQaInputs(args, {
|
|
|
464
467
|
}
|
|
465
468
|
const normalized = normalizeSpec(rawSpec);
|
|
466
469
|
const publicRouteSlug = resolvePublicRouteSlug({ packet, spec: normalized, rawSpec });
|
|
467
|
-
const baseUrl = normalizeQaBaseUrl(inputBaseUrl, publicRouteSlug);
|
|
468
470
|
// Packet 01: the analytics legs capture ONE URL derived from resolved
|
|
469
471
|
// identity (public_route_slug + route_root), composed here where the raw
|
|
470
472
|
// operator/deploy base is still in hand.
|
|
@@ -475,6 +477,7 @@ async function resolveQaInputs(args, {
|
|
|
475
477
|
routeRoot: resolveCampaignRouteRoot({ packet, spec: normalized, rawSpec, publicRouteSlug, notes: routeRootNotes }),
|
|
476
478
|
routeRootNote: routeRootNotes[0] || null,
|
|
477
479
|
});
|
|
480
|
+
const baseUrl = normalizeQaBaseUrl(inputBaseUrl, publicRouteSlug);
|
|
478
481
|
const specHash = computeSpecHash(rawSpec);
|
|
479
482
|
// The brief material this run is judged against, bound at run start beside
|
|
480
483
|
// the spec hash: the QA stage write stamps it, not the material at write time.
|
|
@@ -484,7 +487,16 @@ async function resolveQaInputs(args, {
|
|
|
484
487
|
|| stringArg(normalized?.campaign?.preferred_template_family)
|
|
485
488
|
|| null;
|
|
486
489
|
const commerceStructureContract = loadCommerceStructureContract({ packet, packetPath, templateFamily });
|
|
487
|
-
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
|
+
});
|
|
488
500
|
const themeGate = resolveThemeGate({
|
|
489
501
|
packetPath,
|
|
490
502
|
topologies,
|
|
@@ -971,7 +983,9 @@ function resolvePolishGate({
|
|
|
971
983
|
return gate;
|
|
972
984
|
}
|
|
973
985
|
|
|
974
|
-
|
|
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"]);
|
|
975
989
|
|
|
976
990
|
function themeGateScopeFromTopologies(topologies = []) {
|
|
977
991
|
const built_pages = [];
|
|
@@ -3234,7 +3248,10 @@ async function maybeRunLegacyApiTestOrders({ args, resolved, runId, assertions }
|
|
|
3234
3248
|
const apiBase = stringArg(args["campaigns-api-base"]) || process.env.CAMPAIGNS_API_BASE;
|
|
3235
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.");
|
|
3236
3250
|
const { cart, paths } = legacyTestOrderInputs(args);
|
|
3237
|
-
|
|
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);
|
|
3238
3255
|
if (!checkout?.url) throw new Error("--test-order requires a checkout page URL.");
|
|
3239
3256
|
const upsell = findPage(resolved.topologies, "upsell");
|
|
3240
3257
|
const orders = [];
|
|
@@ -3318,22 +3335,25 @@ async function createTestOrder({ apiBase, apiKey, cart, runId, successUrl, spec,
|
|
|
3318
3335
|
}
|
|
3319
3336
|
}
|
|
3320
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.
|
|
3321
3341
|
function normalizeSpec(raw) {
|
|
3322
|
-
if (Array.isArray(raw?.funnels)) return raw;
|
|
3342
|
+
if (Array.isArray(raw?.funnels)) return upgradeCampaignSpec(raw);
|
|
3323
3343
|
if (Array.isArray(raw?.funnel_pages)) {
|
|
3324
3344
|
return { ...raw, funnels: [{ id: "default", name: "Default", weight: 100, pages: raw.funnel_pages }] };
|
|
3325
3345
|
}
|
|
3326
3346
|
return { ...raw, funnels: [] };
|
|
3327
3347
|
}
|
|
3328
3348
|
|
|
3329
|
-
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 } = {}) {
|
|
3330
3350
|
const pageById = new Map();
|
|
3331
3351
|
for (const funnel of spec.funnels || []) {
|
|
3332
3352
|
for (const page of funnel.pages || []) pageById.set(page.id, page);
|
|
3333
3353
|
}
|
|
3334
3354
|
const urlById = new Map();
|
|
3335
3355
|
for (const [id, page] of pageById) {
|
|
3336
|
-
urlById.set(id, resolvePageUrl(page, baseUrl, publicRouteSlug));
|
|
3356
|
+
urlById.set(id, resolvePageUrl(page, baseUrl, publicRouteSlug, entryServingRoute?.(page)));
|
|
3337
3357
|
}
|
|
3338
3358
|
return (spec.funnels || []).map((funnel) => ({
|
|
3339
3359
|
funnel_id: funnel.id || "default",
|
|
@@ -3388,13 +3408,13 @@ function extractTopologies(spec, { baseUrl = null, publicRouteSlug = null, templ
|
|
|
3388
3408
|
}));
|
|
3389
3409
|
}
|
|
3390
3410
|
|
|
3391
|
-
function resolvePageUrl(page, baseUrl, publicRouteSlug = null) {
|
|
3411
|
+
function resolvePageUrl(page, baseUrl, publicRouteSlug = null, entryServingRoute = null) {
|
|
3392
3412
|
if (typeof page.url === "string" && page.url.trim()) return page.url.trim();
|
|
3393
3413
|
if (!baseUrl) return null;
|
|
3394
|
-
const route =
|
|
3395
|
-
?
|
|
3396
|
-
: page.
|
|
3397
|
-
?
|
|
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)
|
|
3398
3418
|
: defaultRouteForType(page.type);
|
|
3399
3419
|
if (isAbsoluteHttpUrl(route)) return route;
|
|
3400
3420
|
try {
|
|
@@ -3406,6 +3426,7 @@ function resolvePageUrl(page, baseUrl, publicRouteSlug = null) {
|
|
|
3406
3426
|
|
|
3407
3427
|
function defaultRouteForType(type) {
|
|
3408
3428
|
if (type === "thankyou") return "receipt/";
|
|
3429
|
+
if (type === "checkout_step") return "checkout-step/";
|
|
3409
3430
|
if (["presell", "landing", "checkout", "upsell", "downsell"].includes(type)) return `${type}/`;
|
|
3410
3431
|
return `${type || "page"}/`;
|
|
3411
3432
|
}
|
|
@@ -3929,21 +3950,6 @@ function normalizeBaseUrl(value) {
|
|
|
3929
3950
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
3930
3951
|
}
|
|
3931
3952
|
|
|
3932
|
-
function normalizeQaBaseUrl(value, publicRouteSlug) {
|
|
3933
|
-
const baseUrl = normalizeBaseUrl(value);
|
|
3934
|
-
if (!baseUrl) return null;
|
|
3935
|
-
const slug = normalizePublicRouteSlug(publicRouteSlug);
|
|
3936
|
-
if (!slug) return ensureUrlTrailingSlash(baseUrl);
|
|
3937
|
-
try {
|
|
3938
|
-
const url = new URL(ensureUrlTrailingSlash(baseUrl));
|
|
3939
|
-
const segments = url.pathname.split("/").filter(Boolean);
|
|
3940
|
-
if (segments.at(-1) === slug) return ensureUrlTrailingSlash(url.toString());
|
|
3941
|
-
return new URL(`${slug}/`, url).toString();
|
|
3942
|
-
} catch {
|
|
3943
|
-
return ensureUrlTrailingSlash(baseUrl);
|
|
3944
|
-
}
|
|
3945
|
-
}
|
|
3946
|
-
|
|
3947
3953
|
// The campaign's served route root — "/", "/<slug>/", or null — read by the
|
|
3948
3954
|
// one rule every stage shares (route-identity.mjs): the packet under its exact
|
|
3949
3955
|
// canonical form, the spec under prepare-build's intake form. A declaration
|
|
@@ -4051,10 +4057,6 @@ function resolvePublicRouteSlug({ packet, spec, rawSpec }) {
|
|
|
4051
4057
|
|| null;
|
|
4052
4058
|
}
|
|
4053
4059
|
|
|
4054
|
-
function ensureUrlTrailingSlash(value) {
|
|
4055
|
-
return value.endsWith("/") ? value : `${value}/`;
|
|
4056
|
-
}
|
|
4057
|
-
|
|
4058
4060
|
function stripOrigin(value) {
|
|
4059
4061
|
try {
|
|
4060
4062
|
const url = new URL(value);
|
|
@@ -5,9 +5,126 @@ const ACTIONS = Object.freeze([
|
|
|
5
5
|
["accept", "expected_accept_url"],
|
|
6
6
|
]);
|
|
7
7
|
|
|
8
|
+
// The page types of a checkout path, as pageType() spells them (lowercase, `_`
|
|
9
|
+
// and `-` dropped). `checkout` is the page that takes payment; `select` and
|
|
10
|
+
// `checkout_step` lead into it without placing an order (campaigns-os#641).
|
|
11
|
+
const PAYMENT_PAGE_TYPE = "checkout";
|
|
12
|
+
const PRE_PAYMENT_PAGE_TYPES = new Set(["select", "checkoutstep"]);
|
|
13
|
+
|
|
14
|
+
export function isPaymentTopologyPage(page) {
|
|
15
|
+
return pageType(page) === PAYMENT_PAGE_TYPE;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isCheckoutStepTopologyPage(page) {
|
|
19
|
+
return pageType(page) === "checkoutstep";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Follow forward links (expected_next_url) from `start` to the page that takes
|
|
23
|
+
// payment. Null when the walk leaves the topology, loops, or reaches any page
|
|
24
|
+
// that is neither a pre-payment page nor the Checkout.
|
|
25
|
+
export function paymentPageFrom(topology, start) {
|
|
26
|
+
const pages = Array.isArray(topology?.pages) ? topology.pages : [];
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
let page = start || null;
|
|
29
|
+
while (page && !seen.has(page)) {
|
|
30
|
+
seen.add(page);
|
|
31
|
+
if (isPaymentTopologyPage(page)) return page;
|
|
32
|
+
const key = canonicalHttpUrl(page.expected_next_url);
|
|
33
|
+
const next = key ? pages.find((candidate) => canonicalHttpUrl(candidate?.url) === key) : null;
|
|
34
|
+
if (!next) return null;
|
|
35
|
+
if (!isPaymentTopologyPage(next) && !PRE_PAYMENT_PAGE_TYPES.has(pageType(next))) return null;
|
|
36
|
+
page = next;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The topology's entry pages: flagged is_entry, else those no page links to
|
|
42
|
+
// (offer and receipt pages excluded: an orphaned upsell is not where a shopper
|
|
43
|
+
// starts), else the first page. Never decided by page names.
|
|
44
|
+
function topologyEntryPages(pages) {
|
|
45
|
+
const flagged = pages.filter((page) => page?.is_entry === true);
|
|
46
|
+
if (flagged.length) return flagged;
|
|
47
|
+
const targeted = new Set();
|
|
48
|
+
for (const page of pages) {
|
|
49
|
+
for (const field of ["expected_next_url", "expected_accept_url", "expected_decline_url"]) {
|
|
50
|
+
const key = canonicalHttpUrl(page?.[field]);
|
|
51
|
+
if (key && key !== canonicalHttpUrl(page?.url)) targeted.add(key);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const roots = pages.filter((page) => {
|
|
55
|
+
const key = canonicalHttpUrl(page?.url);
|
|
56
|
+
const type = pageType(page);
|
|
57
|
+
return !(key && targeted.has(key)) && !OFFER_PAGE_TYPES.has(type) && !RECEIPT_PAGE_TYPES.has(type);
|
|
58
|
+
});
|
|
59
|
+
return roots.length ? roots : pages.slice(0, 1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Every Checkout the topology's entry paths reach, in entry order, then any
|
|
63
|
+
// Checkout no entry path reaches (a landing CTA into checkout declares no
|
|
64
|
+
// forward field). "The first page typed checkout" is never the answer on its
|
|
65
|
+
// own: on a multi-step path it is a step, and with split-test paths there are
|
|
66
|
+
// several (campaigns-os#641).
|
|
67
|
+
export function paymentPagesForTopology(topology) {
|
|
68
|
+
const pages = Array.isArray(topology?.pages) ? topology.pages : [];
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const entry of topologyEntryPages(pages)) {
|
|
71
|
+
const checkout = paymentPageFrom(topology, entry);
|
|
72
|
+
if (checkout && !out.includes(checkout)) out.push(checkout);
|
|
73
|
+
}
|
|
74
|
+
for (const page of pages) if (isPaymentTopologyPage(page) && !out.includes(page)) out.push(page);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The Checkout of the first entry path of the first topology that has one.
|
|
79
|
+
export function findPaymentPage(topologies = []) {
|
|
80
|
+
for (const topology of Array.isArray(topologies) ? topologies : []) {
|
|
81
|
+
const page = paymentPagesForTopology(topology)[0];
|
|
82
|
+
if (page) return page;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The pages a shopper passes through to reach `checkoutPage`, in order and
|
|
88
|
+
// ending with it: an optional select page, then each checkout_step, then the
|
|
89
|
+
// Checkout. Walks backwards over forward links, so it holds whichever path the
|
|
90
|
+
// Checkout sits on; where two pages lead into the same page a checkout_step is
|
|
91
|
+
// preferred over a select page, which can only start the chain.
|
|
92
|
+
export function checkoutPathPages(topology, checkoutPage) {
|
|
93
|
+
const pages = Array.isArray(topology?.pages) ? topology.pages : [];
|
|
94
|
+
if (!checkoutPage) return [];
|
|
95
|
+
const chain = [checkoutPage];
|
|
96
|
+
const seen = new Set(chain);
|
|
97
|
+
let current = checkoutPage;
|
|
98
|
+
for (;;) {
|
|
99
|
+
const key = canonicalHttpUrl(current?.url);
|
|
100
|
+
if (!key) break;
|
|
101
|
+
const leading = pages.filter((page) => !seen.has(page)
|
|
102
|
+
&& PRE_PAYMENT_PAGE_TYPES.has(pageType(page))
|
|
103
|
+
&& canonicalHttpUrl(page?.expected_next_url) === key);
|
|
104
|
+
const previous = leading.find(isCheckoutStepTopologyPage) || leading[0] || null;
|
|
105
|
+
if (!previous) break;
|
|
106
|
+
chain.unshift(previous);
|
|
107
|
+
seen.add(previous);
|
|
108
|
+
if (!isCheckoutStepTopologyPage(previous)) break;
|
|
109
|
+
current = previous;
|
|
110
|
+
}
|
|
111
|
+
return chain;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Only the checkout_step pages of that path, in order.
|
|
115
|
+
export function checkoutStepPages(topology, checkoutPage) {
|
|
116
|
+
return checkoutPathPages(topology, checkoutPage).filter(isCheckoutStepTopologyPage);
|
|
117
|
+
}
|
|
118
|
+
|
|
8
119
|
export function resolveTestOrderTopology(topology = {}, checkoutPage = null) {
|
|
9
120
|
const pages = Array.isArray(topology?.pages) ? topology.pages : [];
|
|
10
|
-
|
|
121
|
+
// A caller may hand in a select or checkout_step page; the path's Checkout is
|
|
122
|
+
// the page whose forward link leads to the offers. Starting from a step made
|
|
123
|
+
// its next step a `nonterminal_target` and hid every offer path.
|
|
124
|
+
const given = checkoutPage && !isPaymentTopologyPage(checkoutPage) && PRE_PAYMENT_PAGE_TYPES.has(pageType(checkoutPage))
|
|
125
|
+
? paymentPageFrom(topology, checkoutPage) || checkoutPage
|
|
126
|
+
: checkoutPage;
|
|
127
|
+
const checkout = given || paymentPagesForTopology(topology)[0] || null;
|
|
11
128
|
const pagesByUrl = new Map();
|
|
12
129
|
for (const page of pages) {
|
|
13
130
|
const key = canonicalHttpUrl(page?.url);
|
|
@@ -13,7 +13,7 @@ import { resolveCampaignIdentity, localQaIdentifier } from "./spec-source-identi
|
|
|
13
13
|
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
15
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
16
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
16
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
17
17
|
import { QA_OUTPUT_REL_PATH } from "./campaign-workspace.mjs";
|
|
18
18
|
import { absentOrMalformed } from "./fs-identity.mjs";
|
|
19
19
|
import { isPlainObject as isObject, normalizeString as optionalString } from "./repo-scan.mjs";
|
|
@@ -53,7 +53,8 @@ export function qaVerdictDir(root, identifier) {
|
|
|
53
53
|
|
|
54
54
|
// The verdict paths the Assembly Report's qa stage records (any `*.json`
|
|
55
55
|
// string under a path/file/verdict/output-named key, or inside outputs[] /
|
|
56
|
-
// artifacts[] / qa), relative to the
|
|
56
|
+
// artifacts[] / qa), relative to the target root unless absolute. Older
|
|
57
|
+
// report-relative hints remain readable.
|
|
57
58
|
export function qaVerdictPathHints(report) {
|
|
58
59
|
const paths = [];
|
|
59
60
|
const visit = (value) => {
|
|
@@ -90,7 +91,8 @@ function sha256File(path) {
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
// Every verdict candidate for a campaign: the files the report records
|
|
93
|
-
// (source "assembly_report", resolved against the report
|
|
94
|
+
// (source "assembly_report", resolved against the report first, then the target)
|
|
95
|
+
// and every `*.json`
|
|
94
96
|
// under `qa-output/<identifier>/` beneath each root (source "qa_output").
|
|
95
97
|
// { path, repoRelPath, source, verdict, sha256, mtimeMs, identityMatch, trusted }
|
|
96
98
|
// `verdict` is null for a recorded path that exists but is not a JSON object
|
|
@@ -141,8 +143,14 @@ export function* iterateQaVerdicts({ packet = null, report = null, reportPath =
|
|
|
141
143
|
};
|
|
142
144
|
|
|
143
145
|
const reportBase = reportPath ? dirname(resolve(reportPath)) : null;
|
|
146
|
+
const targetRoot = uniqueTargetRoot(roots, reportBase);
|
|
144
147
|
for (const hint of qaVerdictPathHints(report)) {
|
|
145
|
-
const
|
|
148
|
+
const reportRelative = (isAbsolute(hint) || reportBase)
|
|
149
|
+
? candidate(isAbsolute(hint) ? hint : resolve(reportBase, hint), "assembly_report", null)
|
|
150
|
+
: null;
|
|
151
|
+
const found = reportRelative || (!isAbsolute(hint) && targetRoot
|
|
152
|
+
? candidate(resolve(targetRoot, hint), "assembly_report", null)
|
|
153
|
+
: null);
|
|
146
154
|
if (found) yield found;
|
|
147
155
|
}
|
|
148
156
|
|
|
@@ -172,6 +180,15 @@ export function* iterateQaVerdicts({ packet = null, report = null, reportPath =
|
|
|
172
180
|
}
|
|
173
181
|
}
|
|
174
182
|
|
|
183
|
+
function uniqueTargetRoot(roots, reportBase) {
|
|
184
|
+
for (let directory = reportBase; directory; directory = dirname(directory)) {
|
|
185
|
+
if (basename(directory) === ".campaign-runtime") return dirname(directory);
|
|
186
|
+
if (dirname(directory) === directory) break;
|
|
187
|
+
}
|
|
188
|
+
const candidates = [...new Set(roots.filter((root) => typeof root === "string" && root).map((root) => resolve(root)))];
|
|
189
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
175
192
|
export function discoverQaVerdicts(options = {}) {
|
|
176
193
|
return [...iterateQaVerdicts(options)];
|
|
177
194
|
}
|
|
@@ -29,6 +29,7 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
|
|
|
29
29
|
".campaign-runtime/workflow-findings.jsonl",
|
|
30
30
|
".campaign-runtime/run-records/",
|
|
31
31
|
".campaign-runtime/fetched-specs/",
|
|
32
|
+
".campaign-runtime/progress/",
|
|
32
33
|
".campaign-runtime/polish-evidence/",
|
|
33
34
|
".campaign-runtime/evidence/",
|
|
34
35
|
// Full QA verdicts carry live storefront URLs and order references; the
|
|
@@ -41,11 +42,12 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
|
|
|
41
42
|
|
|
42
43
|
// Entries the toolkit adds to an existing managed block when they are
|
|
43
44
|
// missing from the file. The block is otherwise the operator's to edit, but
|
|
44
|
-
// these name directories the toolkit itself fills with
|
|
45
|
-
//
|
|
46
|
-
// next setup command rather than on the next accidental
|
|
47
|
-
// operator has placed anywhere else in the file counts as
|
|
48
|
-
|
|
45
|
+
// these name directories the toolkit itself fills with campaign observations,
|
|
46
|
+
// live URLs, or order references. A target set up before the entry existed
|
|
47
|
+
// must gain it on the next setup command rather than on the next accidental
|
|
48
|
+
// commit. An entry an operator has placed anywhere else in the file counts as
|
|
49
|
+
// present.
|
|
50
|
+
export const RUNTIME_STATE_REQUIRED_IGNORED_PATHS = ["qa-output/", ".campaign-runtime/progress/"];
|
|
49
51
|
|
|
50
52
|
export const RUNTIME_STATE_IGNORE_BLOCK = [
|
|
51
53
|
RUNTIME_STATE_IGNORE_MARKER,
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
} from "./source-html-manifest.mjs";
|
|
5
5
|
// Built output of campaign-spec (same import shape as src/page-kit-sdk-version.mjs),
|
|
6
6
|
// so build-time wiring and spec-time analysis share one edge resolver.
|
|
7
|
-
import { declineRouteTarget, forwardRouteTarget } from "../campaign-spec/dist/index.js";
|
|
7
|
+
import { checkoutPathFrom, declineRouteTarget, forwardRouteTarget, forwardTargetPage, isCheckoutStepPage } from "../campaign-spec/dist/index.js";
|
|
8
8
|
import { isAbsoluteHttpUrl, normalizePageKitRoute, normalizePublicRouteSlug, stripPublicRoutePrefix } from "./route-identity.mjs";
|
|
9
9
|
|
|
10
10
|
const CPK_PAGE_TYPES = new Set(["product", "checkout", "upsell", "receipt"]);
|
|
@@ -30,7 +30,7 @@ export function createSourceHtmlIntake({
|
|
|
30
30
|
const mappings = matched.mappings.map((mapping) => {
|
|
31
31
|
const specPage = pageById.get(mapping.page_id);
|
|
32
32
|
if (!specPage || !mapping.path) return mapping;
|
|
33
|
-
const pageKit = pageKitProjectionForPage(specPage, { pageById, publicRouteSlug, outputDir });
|
|
33
|
+
const pageKit = pageKitProjectionForPage(specPage, { pageById, publicRouteSlug, outputDir, specPages: specPages || [] });
|
|
34
34
|
projectionDecisions.push({
|
|
35
35
|
id: `dec_page_kit_target_${specPage.id}`,
|
|
36
36
|
stage: "prepare_build",
|
|
@@ -559,7 +559,7 @@ function addSpecHints(mapping, page) {
|
|
|
559
559
|
if (variantLabels) mapping.variant_labels = variantLabels;
|
|
560
560
|
}
|
|
561
561
|
|
|
562
|
-
function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir }) {
|
|
562
|
+
function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir, specPages = [] }) {
|
|
563
563
|
const specRoute = publicRouteForPage(page);
|
|
564
564
|
const relativeRoute = stripPublicRoutePrefix(specRoute, publicRouteSlug);
|
|
565
565
|
const publicRoute = rootedCampaignRoute(relativeRoute, publicRouteSlug);
|
|
@@ -574,6 +574,7 @@ function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir }
|
|
|
574
574
|
if (nextUrl) frontmatter.next_url = nextUrl;
|
|
575
575
|
const declineUrl = declineUrlForPage(page, pageById, publicRouteSlug);
|
|
576
576
|
if (declineUrl && declineUrl !== nextUrl) frontmatter.decline_url = declineUrl;
|
|
577
|
+
Object.assign(frontmatter, checkoutFlowFrontmatter(page, { specPages, pageById, publicRouteSlug }));
|
|
577
578
|
|
|
578
579
|
return {
|
|
579
580
|
target_path: targetPath,
|
|
@@ -586,6 +587,43 @@ function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir }
|
|
|
586
587
|
};
|
|
587
588
|
}
|
|
588
589
|
|
|
590
|
+
// Multi-step checkout wiring (campaigns-os#641), for any page that leads into a
|
|
591
|
+
// Checkout without being it: a select page, a checkout_step, or a landing page
|
|
592
|
+
// whose forward links reach a Checkout.
|
|
593
|
+
// success_url the Checkout's post-payment destination (its own next_url).
|
|
594
|
+
// The page's next-success-url meta tag carries it, because the
|
|
595
|
+
// SDK reads that tag for express orders placed on this page; an
|
|
596
|
+
// express order on step 1 must land on the first upsell, not on
|
|
597
|
+
// step 2. Forward navigation never uses that tag: a step form
|
|
598
|
+
// navigates by data-next-checkout-step, a select page by a link.
|
|
599
|
+
// step_number on a checkout_step only: its position among the steps of its
|
|
600
|
+
// path, from 1, for the form's data-next-step-number.
|
|
601
|
+
function checkoutFlowFrontmatter(page, { specPages, pageById, publicRouteSlug }) {
|
|
602
|
+
if (!page || page.type === "checkout") return {};
|
|
603
|
+
const path = checkoutPathFrom(specPages, page);
|
|
604
|
+
if (!path || path.length < 2) return {};
|
|
605
|
+
const out = {};
|
|
606
|
+
const successUrl = nextUrlForPage(path[path.length - 1], pageById, publicRouteSlug);
|
|
607
|
+
if (successUrl) out.success_url = successUrl;
|
|
608
|
+
if (isCheckoutStepPage(page)) out.step_number = checkoutStepNumber(page, specPages);
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function checkoutStepNumber(page, specPages) {
|
|
613
|
+
let number = 1;
|
|
614
|
+
const seen = new Set([page]);
|
|
615
|
+
let current = page;
|
|
616
|
+
for (;;) {
|
|
617
|
+
const previous = specPages.find((candidate) => !seen.has(candidate)
|
|
618
|
+
&& isCheckoutStepPage(candidate)
|
|
619
|
+
&& forwardTargetPage(specPages, candidate) === current);
|
|
620
|
+
if (!previous) return number;
|
|
621
|
+
number += 1;
|
|
622
|
+
seen.add(previous);
|
|
623
|
+
current = previous;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
589
627
|
function pageKitTargetPrompts(mappings) {
|
|
590
628
|
const prompts = [];
|
|
591
629
|
const byOutputPath = new Map();
|
|
@@ -664,12 +702,15 @@ function cpkPageTypeForSpecType(type) {
|
|
|
664
702
|
if (type === "presell" || type === "landing") return "product";
|
|
665
703
|
if (type === "thankyou" || type === "receipt") return "receipt";
|
|
666
704
|
if (type === "upsell" || type === "downsell") return "upsell";
|
|
667
|
-
|
|
705
|
+
// select, checkout_step and checkout all render the SDK checkout surface
|
|
706
|
+
// (campaigns-os#641); only the Checkout places the order.
|
|
707
|
+
if (type === "checkout" || type === "checkout_step" || type === "select") return "checkout";
|
|
668
708
|
return CPK_PAGE_TYPES.has(type) ? type : "product";
|
|
669
709
|
}
|
|
670
710
|
|
|
671
711
|
function defaultRouteForSpecType(type) {
|
|
672
712
|
if (type === "thankyou" || type === "receipt") return "receipt/";
|
|
713
|
+
if (type === "checkout_step") return "checkout-step/";
|
|
673
714
|
if (["presell", "landing", "checkout", "upsell", "downsell"].includes(type)) return `${type}/`;
|
|
674
715
|
return `${type || "page"}/`;
|
|
675
716
|
}
|
|
@@ -696,6 +737,7 @@ function pageMatchKeys(page, ordinal) {
|
|
|
696
737
|
}
|
|
697
738
|
if (page.type === "landing" || page.type === "presell") keys.add("index");
|
|
698
739
|
if (page.type === "checkout") keys.add("checkout");
|
|
740
|
+
if (page.type === "checkout_step") keys.add("checkout-step");
|
|
699
741
|
if (page.type === "upsell") keys.add("upsell");
|
|
700
742
|
if (page.type === "downsell") keys.add("downsell");
|
|
701
743
|
return [...keys];
|
package/src/stage-ledger.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { STATUS as QA_STATUS } from "./qa-verdict.mjs";
|
|
|
5
5
|
import { assessInputCurrency, currentPacketInputs, effectiveStageStatus, effectiveStatusIsTerminal, wellFormedBriefMaterial } from "./input-currency.mjs";
|
|
6
6
|
import { isPlainObject, normalizeString as optionalString } from "./repo-scan.mjs";
|
|
7
7
|
import { withTargetLockSync } from "./target-lock.mjs";
|
|
8
|
+
import { portableArtifactPaths } from "./cli-helpers.mjs";
|
|
8
9
|
import {
|
|
9
10
|
ASSEMBLY_REPORT_STAGE_KEYS,
|
|
10
11
|
NEXT_STAGE_CONTRACTS,
|
|
@@ -687,7 +688,7 @@ function commitAssemblyReportUnderLock(workspace, mutate, {
|
|
|
687
688
|
const finish = () => {
|
|
688
689
|
if (hasRefresh) {
|
|
689
690
|
const doctor = refreshDoctor(outcome);
|
|
690
|
-
if (doctor !== null && doctor !== undefined) writeDoctorSidecar(doctorOutPath, doctor, { command: command.trim() });
|
|
691
|
+
if (doctor !== null && doctor !== undefined) writeDoctorSidecar(doctorOutPath, doctor, { command: command.trim(), targetRepo });
|
|
691
692
|
} else if (outcome.written) {
|
|
692
693
|
markDoctorSidecarStale(targetRepo, { command: command.trim(), reason: staleReason.trim() });
|
|
693
694
|
}
|
|
@@ -731,7 +732,8 @@ function commitAssemblyReportUnderLock(workspace, mutate, {
|
|
|
731
732
|
// (every mutator in this repo returns a copy), so the restatement is in
|
|
732
733
|
// place rather than a second deep clone. Stages are read at their effective
|
|
733
734
|
// status against the inputs the workspace's packet names now.
|
|
734
|
-
const
|
|
735
|
+
const nextReport = applyDerivedAssemblyReportSummary(mutated, workspaceInputs(workspace));
|
|
736
|
+
const next = targetRepo ? portableArtifactPaths(nextReport, targetRepo, { artifactPath: reportPath }) : nextReport;
|
|
735
737
|
if (stage && producerStageOutcomeUnchanged(report, next, stage)) {
|
|
736
738
|
outcome.skipped = "unchanged";
|
|
737
739
|
return finish();
|
package/src/stage-record.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import { BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
|
|
|
29
29
|
import { ADAPTER_DECISION_SCALAR_VALUES } from "./adapter-decision-contract.mjs";
|
|
30
30
|
import { computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
|
|
31
31
|
import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
|
|
32
|
-
import { isObject, optionalString, readJsonIfExists, requireArg } from "./cli-helpers.mjs";
|
|
32
|
+
import { isObject, optionalString, portableArtifactPaths, readJsonIfExists, requireArg } from "./cli-helpers.mjs";
|
|
33
33
|
import { LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT, isLocalServePacket } from "./local-proof.mjs";
|
|
34
34
|
import { polishCarriedForwardForLadder } from "./local-preview-policy.mjs";
|
|
35
35
|
import { isLoopbackHostname } from "./remit.mjs";
|
|
@@ -1017,8 +1017,8 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
|
|
|
1017
1017
|
if (dryRun) return null;
|
|
1018
1018
|
// Written inside the report's critical section, after every check and
|
|
1019
1019
|
// before the report itself, so the files move together.
|
|
1020
|
-
if (next.context) writeJsonAtomic(contextPath, next.context);
|
|
1021
|
-
if (next.packet) writeJsonAtomic(packetPath, next.packet);
|
|
1020
|
+
if (next.context) writeJsonAtomic(contextPath, portableArtifactPaths(next.context, workspace.targetRepo, { artifactPath: contextPath }));
|
|
1021
|
+
if (next.packet) writeJsonAtomic(packetPath, portableArtifactPaths(next.packet, workspace.targetRepo, { artifactPath: packetPath }));
|
|
1022
1022
|
return next.report;
|
|
1023
1023
|
};
|
|
1024
1024
|
// Already inside the target lock, which commitAssemblyReport re-enters.
|