@nextcommerce/campaigns-os 1.55.0 → 1.59.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/CONTEXT.md +11 -0
  3. package/campaign-spec/README.md +15 -1
  4. package/campaign-spec/dist/checkout-flow.d.ts +97 -0
  5. package/campaign-spec/dist/checkout-flow.js +245 -0
  6. package/campaign-spec/dist/index.d.ts +1 -0
  7. package/campaign-spec/dist/index.js +6 -1
  8. package/campaign-spec/dist/normalize.d.ts +3 -2
  9. package/campaign-spec/dist/normalize.js +7 -5
  10. package/campaign-spec/dist/routing.d.ts +6 -3
  11. package/campaign-spec/dist/routing.js +6 -3
  12. package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
  13. package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
  14. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
  15. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
  16. package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
  17. package/campaign-spec/dist/rules/index.d.ts +4 -0
  18. package/campaign-spec/dist/rules/index.js +12 -0
  19. package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
  20. package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
  21. package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
  22. package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
  23. package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
  24. package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
  25. package/campaign-spec/dist/rules/schema-version.js +10 -5
  26. package/campaign-spec/dist/types.d.ts +20 -3
  27. package/campaign-spec/dist/types.js +3 -2
  28. package/compatibility.json +2 -2
  29. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  30. package/contracts/commerce-surface-catalog.json +26 -27
  31. package/contracts/effects.v1.json +5 -5
  32. package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
  33. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  35. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  38. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  39. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  43. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  45. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  46. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  47. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  48. package/contracts/release-ledger.json +1137 -0
  49. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  50. package/contracts/supported-surface.json +9 -5
  51. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  52. package/docs/build-packet.md +68 -10
  53. package/docs/local-setup.md +1 -1
  54. package/docs/orientation-contract-reference.md +1 -1
  55. package/docs/qa-and-test-orders.md +124 -22
  56. package/docs/runtime-readiness.md +8 -3
  57. package/docs/skills-revision.md +10 -10
  58. package/docs/supported-surface.md +1 -1
  59. package/docs/versioning.md +4 -1
  60. package/package.json +2 -1
  61. package/schemas/campaign-runtime-build-context.v0.schema.json +4 -1
  62. package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -1
  63. package/schemas/campaign-spec.v5.schema.json +586 -0
  64. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  65. package/skills/campaign-readback-classification/SKILL.md +3 -3
  66. package/skills/campaign-run-evidence/SKILL.md +3 -3
  67. package/skills/contribution-intake/SKILL.md +3 -3
  68. package/skills/next-campaigns-build/SKILL.md +6 -4
  69. package/skills/next-campaigns-os/SKILL.md +3 -3
  70. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  71. package/skills/next-campaigns-polish/SKILL.md +21 -6
  72. package/skills/next-campaigns-qa/SKILL.md +4 -3
  73. package/skills.json +10 -10
  74. package/src/adapter-decision-contract.mjs +1 -1
  75. package/src/build-brief.mjs +14 -5
  76. package/src/built-site-scope.mjs +91 -0
  77. package/src/cli-helpers.mjs +129 -0
  78. package/src/cli.mjs +117 -57
  79. package/src/commercial-journey.mjs +25 -2
  80. package/src/design-source-package.mjs +1 -1
  81. package/src/design-source-publication.mjs +7 -7
  82. package/src/doctor/checks.mjs +269 -16
  83. package/src/doctor/inspect.mjs +4 -2
  84. package/src/doctor/next-step.mjs +18 -9
  85. package/src/doctor-sidecar.mjs +5 -3
  86. package/src/gate-actions.mjs +21 -1
  87. package/src/input-refresh.mjs +6 -6
  88. package/src/local-preview-policy.mjs +110 -12
  89. package/src/polish-node.mjs +8 -7
  90. package/src/qa-browser.mjs +363 -36
  91. package/src/qa-cart-entry.mjs +183 -14
  92. package/src/qa-node.mjs +38 -34
  93. package/src/qa-test-order-topology.mjs +118 -1
  94. package/src/qa-verdict-discovery.mjs +21 -4
  95. package/src/qa-verdict.mjs +24 -1
  96. package/src/runtime-state-ignore.mjs +7 -5
  97. package/src/sdk-attribute-index.mjs +8 -7
  98. package/src/source-html-intake.mjs +58 -7
  99. package/src/stage-ledger.mjs +4 -2
  100. package/src/stage-record.mjs +203 -36
@@ -35,6 +35,10 @@ export const CART_ENTRY_CODES = Object.freeze({
35
35
  ENTRY_CONTROL_MISSING: "cart_entry_control_missing",
36
36
  // The control was clicked but the page never reached the checkout URL.
37
37
  ENTRY_NO_NAVIGATION: "cart_entry_no_navigation",
38
+ // A select page's bundle slot shows an empty variant control (size,
39
+ // colour) with no in-stock option to choose, so its Next would refuse.
40
+ // Fired before the checkout control is clicked (campaigns-os#667).
41
+ ENTRY_VARIANT_UNFILLED: "cart_entry_variant_unfilled",
38
42
  // The SDK cart held zero items at submit time. Fired before the budget
39
43
  // reservation and before the submit click.
40
44
  CART_EMPTY_BEFORE_SUBMIT: "cart_empty_before_submit",
@@ -127,6 +131,17 @@ export function cartEntryHrefFor(element, { cartEntrySelector, cartEntryRouteAtt
127
131
  }
128
132
  }
129
133
 
134
+ // A select page (campaigns-os#641) has no add-to-cart control: its swap-mode
135
+ // bundle selector writes the cart when a card is chosen, the SDK persists the
136
+ // cart between pages, and a checkout button or link moves on. The runner
137
+ // treats "bundle cards plus one of these" as a cart-entry control: the SDK's
138
+ // checkout action, or a plain link into the next page. Matched by this one
139
+ // selector so the chosen control replays with nth().
140
+ export const CART_ENTRY_CHECKOUT_BUTTON_SELECTOR = '[data-next-action="checkout"], a[href]';
141
+
142
+ // The bundle cards a select page renders, outside the cart summary.
143
+ export const CART_ENTRY_BUNDLE_CARD_SELECTOR = "[data-next-bundle-card]";
144
+
130
145
  // Route-shaped spellings the runner used to consult and no longer does,
131
146
  // because the SDK never declares them. They are not routes, but a page that
132
147
  // carries one is reported so an operator can tell "the runner narrowed its
@@ -138,8 +153,10 @@ export const UNDECLARED_ROUTE_ATTRIBUTES = Object.freeze(["data-next-href", "dat
138
153
  // shop-single-step landing declares; the rest are the entry-like types
139
154
  // `deriveEntryUrls` already recognises.
140
155
  // Pages that can only follow the checkout: the checkout itself plus the
141
- // topology module's own offer and receipt sets, so the two never drift.
142
- const POST_CHECKOUT_PAGE_TYPES = new Set(["checkout", ...OFFER_PAGE_TYPES, ...RECEIPT_PAGE_TYPES]);
156
+ // topology module's own offer and receipt sets, so the two never drift. A
157
+ // checkout_step is never a cart entry either: it is a form page of the
158
+ // checkout path itself (campaigns-os#641).
159
+ const POST_CHECKOUT_PAGE_TYPES = new Set(["checkout", "checkout_step", ...OFFER_PAGE_TYPES, ...RECEIPT_PAGE_TYPES]);
143
160
  const PREFERRED_ENTRY_PAGE_TYPES = ["select", "landing", "product", "presell", "entry", "lander", "advertorial", "listicle", "review", "opt-in", "optin"];
144
161
 
145
162
  export function codedError(code, message) {
@@ -234,18 +251,22 @@ export function summarizeSelectionSurface(surface) {
234
251
  return parts.length ? parts.join(", ") : "none";
235
252
  }
236
253
 
237
- // evaluate() body: the cart-entry controls the entry page renders. Two kinds:
238
- // add_to_cart the SDK action controls (CART_ENTRY_CONTROL_SELECTOR); the
239
- // SDK adds the package and navigates via data-next-url;
240
- // checkout_link a plain link into the checkout URL carrying
241
- // `?forcePackageId=`, which the SDK reads on the checkout page
242
- // to pre-load the cart. This is what the certified
243
- // shop-single-step landing renders today.
254
+ // evaluate() body: the cart-entry controls the entry page renders. Three kinds:
255
+ // add_to_cart the SDK action controls (CART_ENTRY_CONTROL_SELECTOR); the
256
+ // SDK adds the package and navigates via data-next-url;
257
+ // checkout_link a plain link into the checkout URL carrying
258
+ // `?forcePackageId=`, which the SDK reads on the checkout
259
+ // page to pre-load the cart. This is what the certified
260
+ // shop-single-step landing renders today;
261
+ // checkout_button on a page with bundle cards only: the SDK checkout action
262
+ // or a plain link into the checkout URL. The selected card
263
+ // is the cart; `bundle_package_ids` lists what the cards
264
+ // carry so --select-package can name one (campaigns-os#641).
244
265
  // Each control reports its visibility, text, and the package id it carries
245
266
  // (own attribute, nearest card, or the forcePackageId ref), so the caller can
246
267
  // prefer a visible SDK control and honour --select-package in one round trip.
247
268
  export function cartEntryControlsScript() {
248
- return ({ selector, checkoutUrl }) => {
269
+ return ({ selector, checkoutUrl, checkoutButtonSelector = null, bundleCardSelector = null }) => {
249
270
  const clean = (value) => String(value || "").replace(/\s+/g, " ").trim();
250
271
  const isVisible = (element) => {
251
272
  const rect = element.getBoundingClientRect();
@@ -313,7 +334,39 @@ export function cartEntryControlsScript() {
313
334
  }];
314
335
  })
315
336
  : [];
316
- return [...actions, ...links];
337
+ const cards = Array.from(document.querySelectorAll(bundleCardSelector || "[data-next-bundle-card]"))
338
+ .filter((card) => !card.closest("[data-next-cart-summary], [data-next-upsell-context], [data-next-upsell], template"));
339
+ const bundlePackageIds = [...new Set(cards.flatMap((card) => {
340
+ const own = clean(card.getAttribute("data-next-package-id"));
341
+ const nested = Array.from(card.querySelectorAll("[data-next-package-id]")).map((node) => clean(node.getAttribute("data-next-package-id")));
342
+ return [own, ...nested].filter(Boolean);
343
+ }))];
344
+ const buttons = cards.length && checkoutButtonSelector
345
+ ? Array.from(document.querySelectorAll(checkoutButtonSelector)).flatMap((element, index) => {
346
+ const isAction = element.getAttribute("data-next-action") === "checkout";
347
+ if (!isAction) {
348
+ const href = element.getAttribute("href");
349
+ if (!checkout || canonical(href) !== checkout) return [];
350
+ try {
351
+ if (new URL(href, document.baseURI).searchParams.get("forcePackageId")) return [];
352
+ } catch {
353
+ return [];
354
+ }
355
+ }
356
+ return [{
357
+ kind: "checkout_button",
358
+ index,
359
+ visible: isVisible(element),
360
+ text: label(element),
361
+ package_id: null,
362
+ bundle_package_ids: bundlePackageIds,
363
+ quantity: 1,
364
+ next_url: isAction ? null : element.getAttribute("href"),
365
+ tag: element.tagName.toLowerCase(),
366
+ }];
367
+ })
368
+ : [];
369
+ return [...actions, ...links, ...buttons];
317
370
  };
318
371
  }
319
372
 
@@ -323,7 +376,7 @@ export function cartEntryControlsScript() {
323
376
  // control is only used when nothing is visible.
324
377
  export function chooseCartEntryControl(controls = [], requested = []) {
325
378
  const list = Array.isArray(controls) ? controls : [];
326
- if (!list.length) return { control: null, reason: "no add-to-cart control or forcePackageId checkout link rendered on the entry page" };
379
+ if (!list.length) return { control: null, reason: "no add-to-cart control, forcePackageId checkout link, or bundle cards with a checkout button rendered on the entry page" };
327
380
  const wanted = Array.isArray(requested) ? requested.filter((item) => item?.packageId) : [];
328
381
  if (wanted.length > 1) {
329
382
  return {
@@ -331,9 +384,24 @@ export function chooseCartEntryControl(controls = [], requested = []) {
331
384
  reason: `--select-package requested ${wanted.length} refs, but a landing-page entry can select at most one package before the SDK navigates to checkout`,
332
385
  };
333
386
  }
387
+ // A select page's checkout button carries no package of its own: the
388
+ // requested package is the bundle card the runner clicks before it. Used
389
+ // only when no add-to-cart control or forcePackageId link carries the ref.
390
+ const buttons = list.filter((control) => control.kind === "checkout_button");
391
+ const carriers = list.filter((control) => control.kind !== "checkout_button");
334
392
  const byRef = wanted.length
335
- ? list.filter((control) => String(control.package_id) === String(wanted[0].packageId))
336
- : list;
393
+ ? carriers.filter((control) => String(control.package_id) === String(wanted[0].packageId))
394
+ : carriers;
395
+ if (!byRef.length && buttons.length) {
396
+ const pick = (pool) => pool.find((control) => control.visible) || pool[0];
397
+ if (!wanted.length) return { control: pick(buttons), reason: null };
398
+ const withCard = buttons.filter((control) => (control.bundle_package_ids || []).map(String).includes(String(wanted[0].packageId)));
399
+ if (withCard.length) return { control: pick(withCard), select_card: wanted[0], reason: null };
400
+ return {
401
+ control: null,
402
+ reason: `--select-package ${wanted[0].packageId}: no bundle card on the entry page carries package ${wanted[0].packageId} (rendered: ${[...new Set(buttons.flatMap((control) => control.bundle_package_ids || []))].join(", ") || "(none)"})`,
403
+ };
404
+ }
337
405
  if (!byRef.length) {
338
406
  return {
339
407
  control: null,
@@ -363,6 +431,107 @@ export function chooseCartEntryControl(controls = [], requested = []) {
363
431
  // returns the store's `totalQuantity` — and the debugger's cart store second
364
432
  // (`window.nextDebug.stores.cart`, present with `?debugger=true`), which is
365
433
  // the only public place the line items and their package ids are readable.
434
+ // The variant controls a select page renders per bundle slot: the
435
+ // campaign-cart SDK injects a native select into each slot's
436
+ // [data-next-variant-selectors] (inside a .next-slot-variant-field). The
437
+ // olympus-mv-two-step starter, and pages built from it, hide that select behind
438
+ // a visible os-dropdown (a toggle button plus os-dropdown-item rows carrying
439
+ // `value`); a row click is what sets the select and dispatches `change`.
440
+ export const SLOT_VARIANT_SELECT_SELECTOR = "[data-next-variant-selectors] select, .next-slot-variant-field select";
441
+ export const SLOT_VARIANT_FIELD_SELECTOR = ".next-slot-variant-field";
442
+ export const SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR = "os-dropdown .os-card__variant-dropdown-toggle";
443
+ export const SLOT_VARIANT_DROPDOWN_ROW_SELECTOR = "os-dropdown os-dropdown-item";
444
+
445
+ export const slotVariantKey = ({ bundle_id: bundleId, slot, variant_code: code } = {}) => `${bundleId ?? ""}|${slot}|${code ?? ""}`;
446
+
447
+ // evaluate() body: the next slot variant action, one per call because a
448
+ // choice can re-render its slot. In document order over the visible slot
449
+ // variant selects (a select counts as visible when it or its field is):
450
+ // - a field showing a dropdown UI whose key is not in `picked` gets a pick:
451
+ // the row matching the select's current value when that row is in stock
452
+ // (so the cart stays what the SDK chose), else the first in-stock row. The
453
+ // caller clicks the toggle and the row like a shopper, because a page may
454
+ // count a field as chosen only when a row was clicked. Pre-filled selects
455
+ // are picked too. Returned as { pick } with the field's index among
456
+ // SLOT_VARIANT_FIELD_SELECTOR matches and the row's index among the
457
+ // field's SLOT_VARIANT_DROPDOWN_ROW_SELECTOR matches;
458
+ // - a select with no dropdown UI is filled here only when it is empty, with
459
+ // its first in-stock option and input + change dispatched ({ filled });
460
+ // a pre-filled one is left alone.
461
+ // In stock: enabled, not hidden, non-empty value; for a row also not
462
+ // [disabled], aria-disabled, .next-oos, .next-variant-unavailable or
463
+ // data-available="false", and its native option not disabled. Nothing to
464
+ // choose returns { unfillable } naming the slot; nothing left returns
465
+ // { done: true }.
466
+ export function slotVariantStepScript() {
467
+ return ({ selector, fieldSelector, toggleSelector, rowSelector, picked = [] }) => {
468
+ const clean = (value) => String(value || "").replace(/\s+/g, " ").trim();
469
+ const isVisible = (element) => {
470
+ if (!element) return false;
471
+ const rect = element.getBoundingClientRect();
472
+ const style = getComputedStyle(element);
473
+ return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
474
+ };
475
+ const holderOf = (select) => select.closest(fieldSelector) || select.closest("[data-next-variant-selectors]");
476
+ const visible = Array.from(new Set(document.querySelectorAll(selector)))
477
+ .filter((select) => !select.disabled && (isVisible(select) || isVisible(holderOf(select))));
478
+ const slotRoots = Array.from(new Set(visible.map((select) => select.closest("[data-next-variant-selectors]") || holderOf(select))));
479
+ const fields = Array.from(document.querySelectorAll(fieldSelector));
480
+ const describe = (select) => {
481
+ const field = select.closest(fieldSelector);
482
+ const index = Number.parseInt(field?.getAttribute("data-next-slot-index") ?? "", 10);
483
+ const root = select.closest("[data-next-variant-selectors]") || holderOf(select);
484
+ return {
485
+ bundle_id: clean(field?.getAttribute("data-next-bundle-id")) || null,
486
+ slot: Number.isFinite(index) ? index + 1 : slotRoots.indexOf(root) + 1,
487
+ variant_code: clean(select.getAttribute("data-next-variant-code") || select.getAttribute("data-variant-code") || field?.getAttribute("data-next-variant-code") || select.name) || null,
488
+ };
489
+ };
490
+ const keyOf = (info) => `${info.bundle_id ?? ""}|${info.slot}|${info.variant_code ?? ""}`;
491
+ const optionInStock = (option) => !option.disabled && !option.hidden && clean(option.value) !== "";
492
+ for (const select of visible) {
493
+ const info = describe(select);
494
+ const field = select.closest(fieldSelector);
495
+ const toggle = field ? Array.from(field.querySelectorAll(toggleSelector)).find(isVisible) : null;
496
+ if (toggle) {
497
+ if (picked.includes(keyOf(info))) continue;
498
+ const rows = Array.from(field.querySelectorAll(rowSelector));
499
+ // One in-stock rule: a row is in stock when its own markup says so and
500
+ // its native option (if any) passes optionInStock.
501
+ const optionsByValue = new Map(Array.from(select.options).map((option) => [clean(option.value), option]));
502
+ const rowInStock = (row) => {
503
+ const value = clean(row.getAttribute("value"));
504
+ if (!value || row.hidden || row.hasAttribute("disabled") || row.getAttribute("aria-disabled") === "true") return false;
505
+ if (row.classList.contains("next-oos") || row.classList.contains("next-variant-unavailable") || row.getAttribute("data-available") === "false") return false;
506
+ const option = optionsByValue.get(value);
507
+ return !option || optionInStock(option);
508
+ };
509
+ const current = clean(select.value);
510
+ const row = (current && rows.find((candidate) => clean(candidate.getAttribute("value")) === current && rowInStock(candidate))) || rows.find(rowInStock);
511
+ if (!row) return { unfillable: { ...info, options: rows.length, via: "dropdown" } };
512
+ const fieldIndex = fields.indexOf(field);
513
+ const rowIndex = rows.indexOf(row);
514
+ if (fieldIndex < 0 || rowIndex < 0) {
515
+ return { unfillable: { ...info, options: rows.length, via: "dropdown", detail: `the dropdown ${fieldIndex < 0 ? "field" : "row"} could not be located` } };
516
+ }
517
+ return { pick: { ...info, value: clean(row.getAttribute("value")), field_index: fieldIndex, row_index: rowIndex } };
518
+ }
519
+ if (clean(select.value) !== "") continue;
520
+ const option = Array.from(select.options).find(optionInStock);
521
+ if (!option) return { unfillable: { ...info, options: select.options.length, via: "select" } };
522
+ select.value = option.value;
523
+ select.dispatchEvent(new Event("input", { bubbles: true }));
524
+ select.dispatchEvent(new Event("change", { bubbles: true }));
525
+ return { filled: { ...info, value: option.value } };
526
+ }
527
+ return { done: true };
528
+ };
529
+ }
530
+
531
+ export function slotVariantLabel({ bundle_id: bundleId, slot, variant_code: code } = {}) {
532
+ return `slot ${slot}${bundleId ? ` (bundle "${bundleId}")` : ""}${code ? ` ${code}` : " variant"}`;
533
+ }
534
+
366
535
  // The enriched line list on `getCartData()` is deliberately NOT read: it is
367
536
  // always empty on the shipped SDK (campaign-cart#36; see the cart-state
368
537
  // verification section of docs/qa-and-test-orders.md), so it would call every
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";
@@ -37,7 +37,7 @@ import { assessReceiptPurchase } from "./qa-analytics-correctness.mjs";
37
37
  import { trackingQaAssertion, trackingRunScopeRows } from "./qa-tracking-params.mjs";
38
38
  import { contentParamNotRequestedRows, contentParamQaAssertion } from "./qa-content-params.mjs";
39
39
  import { policyLinkNotRequestedRows, policyLinkQaAssertion } from "./qa-policy-links.mjs";
40
- import { createVerdict, isFindingAssertion, QA_ASSERTION_FAMILY_VOCABULARY, SESSION_ENDING_DISPOSITIONS, SEVERITY, STATUS, validateVerdict } from "./qa-verdict.mjs";
40
+ import { createVerdict, formatTestOrderSummary, isFindingAssertion, QA_ASSERTION_FAMILY_VOCABULARY, SESSION_ENDING_DISPOSITIONS, SEVERITY, STATUS, validateVerdict } from "./qa-verdict.mjs";
41
41
  import { normalizeSdkMetaName, lookupSdkIgnoredMetaTag } from "./sdk-meta-tags.mjs";
42
42
  import { annotateQaAssertionCauses, formatCauseReportLines, formatCauseTag } from "./finding-cause.mjs";
43
43
  import { promoteQaVerdict, writeQaSidecar } from "./qa-sidecar.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 topologies = extractTopologies(normalized, { baseUrl, publicRouteSlug, templateFamily, commerceStructureContract });
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
- const THEME_GATE_COMMERCE_TYPES = new Set(["checkout", "upsell", "downsell", "receipt", "thankyou"]);
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
- const checkout = findPage(resolved.topologies, "checkout");
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 = typeof page.page_url === "string" && page.page_url.trim()
3395
- ? runtimeRelativeRouteForSpecValue(page.page_url, publicRouteSlug)
3396
- : page.is_entry
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
  }
@@ -3556,6 +3577,8 @@ function output(value, args) {
3556
3577
  console.log("This is a passing proof with exceptions. Report these exceptions to the operator; do not clear or waive them, or change markup just to make them pass.");
3557
3578
  }
3558
3579
  console.log(`Counts: ${Object.entries(value.counts).map(([status, count]) => `${count} ${status}`).join(", ")}`);
3580
+ const testOrderSummary = formatTestOrderSummary(value.verdict);
3581
+ if (testOrderSummary) console.log(testOrderSummary);
3559
3582
  printCauseLines(value.verdict);
3560
3583
  printThemeGateLines(value.theme_gate, value.packet_path, value.report_path);
3561
3584
  if (value.commercial) {
@@ -3929,21 +3952,6 @@ function normalizeBaseUrl(value) {
3929
3952
  return typeof value === "string" && value.trim() ? value.trim() : null;
3930
3953
  }
3931
3954
 
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
3955
  // The campaign's served route root — "/", "/<slug>/", or null — read by the
3948
3956
  // one rule every stage shares (route-identity.mjs): the packet under its exact
3949
3957
  // canonical form, the spec under prepare-build's intake form. A declaration
@@ -4051,10 +4059,6 @@ function resolvePublicRouteSlug({ packet, spec, rawSpec }) {
4051
4059
  || null;
4052
4060
  }
4053
4061
 
4054
- function ensureUrlTrailingSlash(value) {
4055
- return value.endsWith("/") ? value : `${value}/`;
4056
- }
4057
-
4058
4062
  function stripOrigin(value) {
4059
4063
  try {
4060
4064
  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
- const checkout = checkoutPage || pages.find((page) => pageType(page) === "checkout") || null;
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 report unless absolute.
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) and every `*.json`
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 found = candidate(reportBase ? resolve(reportBase, hint) : hint, "assembly_report", null);
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
  }