@nextcommerce/campaigns-os 1.57.0 → 1.60.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 (55) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/README.md +6 -5
  3. package/campaign-spec/dist/checkout-flow.d.ts +7 -0
  4. package/campaign-spec/dist/checkout-flow.js +17 -1
  5. package/compatibility.json +1 -1
  6. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  7. package/contracts/commerce-surface-catalog.json +27 -28
  8. package/contracts/effects.v1.json +5 -5
  9. package/contracts/release-ledger.json +975 -0
  10. package/contracts/supported-surface.json +4 -4
  11. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  12. package/docs/build-packet.md +35 -19
  13. package/docs/campaign-build-brief.md +1 -1
  14. package/docs/campaigns-os-build-flow.md +1 -1
  15. package/docs/local-setup.md +1 -1
  16. package/docs/orientation-contract-reference.md +1 -1
  17. package/docs/polish-evidence.md +1 -1
  18. package/docs/qa-and-test-orders.md +54 -12
  19. package/docs/runtime-readiness.md +1 -1
  20. package/docs/skills-revision.md +10 -10
  21. package/package.json +1 -1
  22. package/schemas/campaign-runtime-build-context.v0.schema.json +2 -1
  23. package/schemas/campaign-runtime-build-packet.v0.schema.json +2 -1
  24. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  25. package/skills/campaign-readback-classification/SKILL.md +3 -3
  26. package/skills/campaign-run-evidence/SKILL.md +3 -3
  27. package/skills/contribution-intake/SKILL.md +3 -3
  28. package/skills/next-campaigns-build/SKILL.md +7 -7
  29. package/skills/next-campaigns-os/SKILL.md +3 -3
  30. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  31. package/skills/next-campaigns-polish/SKILL.md +9 -10
  32. package/skills/next-campaigns-qa/SKILL.md +3 -3
  33. package/skills/next-campaigns-sdk-bump/SKILL.md +193 -0
  34. package/skills.json +18 -10
  35. package/src/built-site-scope.mjs +96 -12
  36. package/src/campaign-identity.mjs +6 -4
  37. package/src/cli.mjs +50 -81
  38. package/src/doctor/checks.mjs +79 -6
  39. package/src/doctor/inspect.mjs +2 -8
  40. package/src/doctor/next-step.mjs +9 -17
  41. package/src/orchestration-stage-contract.mjs +6 -3
  42. package/src/page-kit-build-summary.mjs +21 -1
  43. package/src/page-kit-campaign-config.mjs +19 -0
  44. package/src/polish-gate.mjs +1 -5
  45. package/src/polish-node.mjs +5 -4
  46. package/src/progress-node.mjs +2 -1
  47. package/src/qa-browser.mjs +137 -2
  48. package/src/qa-cart-entry.mjs +105 -0
  49. package/src/qa-node.mjs +3 -1
  50. package/src/qa-verdict.mjs +24 -1
  51. package/src/sdk-attribute-index.mjs +8 -7
  52. package/src/source-html-intake.mjs +27 -18
  53. package/src/spec-derive.mjs +24 -9
  54. package/src/stage-ledger.mjs +27 -1
  55. package/src/stage-record.mjs +218 -35
@@ -165,9 +165,29 @@ export function evaluatePageKitBuildSummary({
165
165
  }
166
166
  }
167
167
 
168
+ // page-kit >= 0.3.0 neither builds nor copies .html under assets/ and lists
169
+ // each such file in a top-level assetWarnings array (ASSET_IGNORED), never
170
+ // in pages[].warnings. A link or iframe to one 404s on the built site, and
171
+ // this is the only signal, so it is surfaced, but never escalated: removing
172
+ // or moving the file is the author's call. Entries carry no campaignSlug;
173
+ // the file path (src/<slug>/assets/...) names the campaign.
174
+ const assetWarnings = Array.isArray(summary.assetWarnings)
175
+ ? summary.assetWarnings.filter((warning) => typeof warning?.file === "string" && `/${warning.file.replace(/\\/g, "/")}`.includes(`/${publicRouteSlug}/assets/`))
176
+ : [];
177
+ for (const warning of assetWarnings) {
178
+ flagged += 1;
179
+ warnings.push({
180
+ code: "built_output.build_asset_warning",
181
+ message:
182
+ `Page Kit did not build or copy "${warning.file}": [${warning.code}] ${warning.message}. ` +
183
+ `A link or iframe to it 404s on the built site; move it out of assets/ if it is a page, or remove it.`,
184
+ detail: { file: warning.file, warning_code: warning.code },
185
+ });
186
+ }
187
+
168
188
  if (failed === 0 && flagged === 0) {
169
189
  ready.push(
170
- `Page Kit build summary verified for "${publicRouteSlug}": ${campaignPages.length} page(s) built, no build errors or shape warnings`
190
+ `Page Kit build summary verified for "${publicRouteSlug}": ${campaignPages.length} page(s) built, no build errors, shape warnings or asset warnings`
171
191
  );
172
192
  }
173
193
 
@@ -1,9 +1,28 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { compareVersions, extractVersion } from "./repo-scan.mjs";
3
4
  import { normalizePublicRouteSlug } from "./route-identity.mjs";
4
5
 
5
6
  export const PAGE_KIT_CAMPAIGNS_REL_PATH = "_data/campaigns.json";
6
7
 
8
+ // next-campaign-page-kit 0.3.0 stopped building or copying .html under a
9
+ // campaign's top-level assets/. Source-page discovery follows the target's
10
+ // installed page-kit, read from node_modules the way tooling setup reads it;
11
+ // a missing, unreadable or malformed install keeps the 0.2.x rule. That is
12
+ // deliberate: doctor's built-page lookup calls this from about ten checks, so
13
+ // throwing would take doctor down over a broken install that the build itself
14
+ // fails on loudly, and tooling setup already refuses a declared page-kit that
15
+ // is not installed.
16
+ export const PAGE_KIT_ASSETS_HTML_IGNORED_FROM = "0.3.0";
17
+ export function pageKitIgnoresAssetsHtml(targetRepo) {
18
+ try {
19
+ const { version } = JSON.parse(readFileSync(join(targetRepo, "node_modules", "next-campaign-page-kit", "package.json"), "utf8"));
20
+ return extractVersion(version) !== null && compareVersions(version, PAGE_KIT_ASSETS_HTML_IGNORED_FROM) >= 0;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
7
26
  // Public doctor/next/verdict evidence must never inherit arbitrary merchant
8
27
  // configuration from campaigns.json. The raw entry is validation-local; this
9
28
  // fixed projection is the only loader metadata safe to retain in artifacts.
@@ -45,11 +45,7 @@ function terminalAssembly(report) {
45
45
  }
46
46
 
47
47
  export function currentBuildFingerprint(report) {
48
- return normalizeString(report?.stages?.assembly?.build_fingerprint)
49
- || normalizeString(report?.stages?.assembly?.artifact_fingerprint)
50
- || normalizeString(report?.build_fingerprint)
51
- || normalizeString(report?.artifact_fingerprint)
52
- || null;
48
+ return normalizeString(report?.stages?.assembly?.build_fingerprint) || null;
53
49
  }
54
50
 
55
51
  export function currentSourcePackageMaterialFingerprint(report) {
@@ -3,7 +3,7 @@ import { NO_CAPTURABLE_ROUTES_CODE } from "./local-preview-policy.mjs";
3
3
  import { createHash } from "node:crypto";
4
4
  import { HIDDEN_EAGER_MEDIA_ACTIONS } from "./gate-actions.mjs";
5
5
  import { dirname, join, resolve } from "node:path";
6
- import { computeBuildFingerprint } from "./built-site-scope.mjs";
6
+ import { computeBuildFingerprint, describeBuildOutputDrift } from "./built-site-scope.mjs";
7
7
  import {
8
8
  buildPageLoadCapture,
9
9
  MAX_POLISH_CAPTURE_URL_LENGTH,
@@ -402,7 +402,7 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
402
402
  // an output that has drifted from what build recorded would be evidence
403
403
  // about a build that no longer exists, and an output that changes during the
404
404
  // browser pass fails the unchanged-binding assertion after it.
405
- const outputFingerprint = boundOutputFingerprint(join(resolve(targetRepo), "_site", slug), slug, buildFingerprint);
405
+ const outputFingerprint = boundOutputFingerprint(join(resolve(targetRepo), "_site", slug), slug, buildFingerprint, report.stages.assembly);
406
406
  const runId = nonemptyString(report.run_id);
407
407
  const reportPacketPath = nonemptyString(report?.inputs?.packet_path);
408
408
  if (!runId || !reportPacketPath) {
@@ -585,7 +585,7 @@ function uncapturedPages(packet, plan) {
585
585
  // (permissions, a file vanishing mid-walk), or an output that no longer
586
586
  // matches what build recorded. None of them may surface as an uncaught
587
587
  // filesystem error, and none may bind as a null.
588
- function boundOutputFingerprint(outputRoot, slug, buildFingerprint) {
588
+ function boundOutputFingerprint(outputRoot, slug, buildFingerprint, assembly = null) {
589
589
  let current;
590
590
  try {
591
591
  current = computeBuildFingerprint(outputRoot);
@@ -604,7 +604,8 @@ function boundOutputFingerprint(outputRoot, slug, buildFingerprint) {
604
604
  if (current.fingerprint !== buildFingerprint) {
605
605
  throw new Error(
606
606
  `polish capture refuses: built output under _site/${slug}/ no longer matches stages.assembly.build_fingerprint `
607
- + `(recorded ${buildFingerprint}, current ${current.fingerprint}). Re-run build and record the current fingerprint first.`,
607
+ + `(recorded ${buildFingerprint}, current ${current.fingerprint}). ${describeBuildOutputDrift(assembly, current).summary} `
608
+ + "Re-run build and record the current fingerprint first.",
608
609
  );
609
610
  }
610
611
  return current.fingerprint;
@@ -6,6 +6,7 @@ import {join,resolve,dirname} from 'node:path';
6
6
  import {PROGRESS_SCHEMA_VERSION,PROGRESS_STAGES,PROGRESS_STAGE_STATUSES,PROGRESS_CONTINUATIONS,PROGRESS_ACTION_IDS,PROGRESS_GATE_IDS,canonicalProgressJson,progressSnapshotId,verifyProgressSnapshot} from './progress.mjs';
7
7
  import {specMaterialHash} from './spec-identity.mjs';
8
8
  import {effectiveStageStatus} from './input-currency.mjs';
9
+ import {qaRecordedBuildFingerprint} from './stage-ledger.mjs';
9
10
  import {sameFile} from './fs-identity.mjs';
10
11
  import {withDirectoryLock} from './directory-lock.mjs';
11
12
  import {resolveConsent,CANONICAL_REMIT_SCOPE,normalizeConsentScope,announceDefaultOnTelemetry} from './consent.mjs';
@@ -64,7 +65,7 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
64
65
  // The report is also bound to the spec material on disk now, so output and
65
66
  // verdict evidence recorded against it can match.
66
67
  const reportBound=stagesBound&&localHash===hash(report?.identity?.spec_material_hash);
67
- const qaSource=hash(report?.stages?.qa?.evidence?.source_build_fingerprint);
68
+ const qaSource=hash(qaRecordedBuildFingerprint(report));
68
69
  const verdict=qaResult?.verdict;
69
70
  const qa=verdict?{
70
71
  verdict_id:id(verdict.run_id),disposition:accepted(verdict.disposition,['ready','ready_with_exceptions','blocked']),
@@ -52,6 +52,13 @@ import {
52
52
  isCartEntryCode,
53
53
  resolveCartEntryPage,
54
54
  sdkCartSnapshotScript,
55
+ SLOT_VARIANT_DROPDOWN_ROW_SELECTOR,
56
+ SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR,
57
+ SLOT_VARIANT_FIELD_SELECTOR,
58
+ SLOT_VARIANT_SELECT_SELECTOR,
59
+ slotVariantKey,
60
+ slotVariantLabel,
61
+ slotVariantStepScript,
55
62
  summarizeSelectionSurface,
56
63
  UNDECLARED_ROUTE_ATTRIBUTES,
57
64
  } from "./qa-cart-entry.mjs";
@@ -304,7 +311,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
304
311
  const identifier = planId(plan);
305
312
  const pageForPlan = (typeof plan === "object" && plan?.checkout_page?.url) ? plan.checkout_page : checkoutPage;
306
313
  const firstAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
307
- orders.push(firstAttempt.order);
314
+ orders.push(testOrderEntry(firstAttempt.order, identifier, 1));
308
315
  orderPlans.push(plan);
309
316
  // Every attempt this path actually SUBMITTED, in order. The confirmed
310
317
  // creation count is read from these and never from the deciding result
@@ -364,7 +371,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
364
371
  const rerunAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
365
372
  attemptsForPlan.push(rerunAttempt);
366
373
  if (rerunAttempt.order) {
367
- orders.push(rerunAttempt.order);
374
+ orders.push(testOrderEntry(rerunAttempt.order, identifier, 2));
368
375
  orderPlans.push(plan);
369
376
  }
370
377
  if (rerunAttempt.budget_exhausted) {
@@ -500,6 +507,17 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
500
507
  return { orders, assertions, receiptAnalytics, journeyAnalytics, creationBudget, qcResults };
501
508
  }
502
509
 
510
+ // One test_orders[] entry per attempt, never one per planned path: a re-run
511
+ // places its own real order and the operator cleaning up test orders needs both.
512
+ // So each entry names the planned path it belongs to (`plan_id`, the same id the
513
+ // creation budget charges) and which attempt it was, 1-based, so a reader can
514
+ // tell 5 paths with one retry each from 10 paths (#661). A copy, so the attempt
515
+ // result the assertions read is left as the runner returned it.
516
+ function testOrderEntry(order, identifier, attempt) {
517
+ if (!order || typeof order !== "object") return order;
518
+ return { ...order, plan_id: order.plan_id ?? identifier, attempt };
519
+ }
520
+
503
521
  // Analytics-parity leg: capture the live dataLayer event stream + GTM/pixel
504
522
  // tag-fires on a baseline (legacy) URL and a candidate (migrated) URL, then
505
523
  // diff them into parity assertions. Highest-value target is the thank-you /
@@ -4378,6 +4396,11 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4378
4396
  cardSelection = choice.select_card ? await selectPackageCard(page, choice.select_card, []) : null;
4379
4397
  control.package_id = choice.select_card?.packageId || await selectedBundleCardPackage(page);
4380
4398
  }
4399
+ // Each bundle slot may need a variant (size, colour) before the page lets
4400
+ // Next through (campaigns-os#667). Fill every visible empty slot variant
4401
+ // select with its first in-stock option now, after the card that decides
4402
+ // the slots, and refuse by name when one has nothing to choose.
4403
+ const variantSelections = control.kind === "checkout_button" ? await fillSlotVariants(page) : [];
4381
4404
  // The index is the control's position among what its own locator matches,
4382
4405
  // so it replays with nth(); no selector is rebuilt from an attribute value.
4383
4406
  const controlSelector = control.kind === "add_to_cart"
@@ -4414,6 +4437,7 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4414
4437
  control_kind: control.kind,
4415
4438
  package_id: control.package_id || null,
4416
4439
  ...(cardSelection ? { bundle_card_selection: cardSelection } : {}),
4440
+ ...(variantSelections.length ? { variant_selections: variantSelections } : {}),
4417
4441
  sdk_ready: sdkReady,
4418
4442
  arrived_url: redactUrlQuery(safePageUrl(page)),
4419
4443
  checkout_selection_surface: surface,
@@ -4422,6 +4446,117 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4422
4446
  };
4423
4447
  }
4424
4448
 
4449
+ // Chooses a select page's slot variants one at a time (a choice can
4450
+ // re-render its slot) and returns the choices. A field showing a dropdown UI
4451
+ // is picked like a shopper, toggle then row, even when its select is
4452
+ // pre-filled, because a page may count a field as chosen only when a row was
4453
+ // clicked (campaigns-os#667). A select with no dropdown UI is filled only when
4454
+ // empty; a pre-filled native-only page returns [] untouched. Throws
4455
+ // ENTRY_VARIANT_UNFILLED, before any click on the checkout control, when a
4456
+ // field has no in-stock row or option, a pick cannot be clicked, or the page
4457
+ // keeps asking for more choices.
4458
+ const SLOT_VARIANT_FILL_LIMIT = 40;
4459
+ const SLOT_VARIANT_ROW_WAIT_MS = 2000;
4460
+
4461
+ // The slot variant field a pick names, found again by its slot identity
4462
+ // (data-next-bundle-id, data-next-slot-index, variant code) so a re-render
4463
+ // since the pick was planned does not leave a stale index; the planned index
4464
+ // is the fallback for a field that carries no slot index.
4465
+ async function slotVariantField(page, pick) {
4466
+ const fields = page.locator(SLOT_VARIANT_FIELD_SELECTOR);
4467
+ const index = await fields.evaluateAll((nodes, want) => {
4468
+ const clean = (value) => String(value || "").replace(/\s+/g, " ").trim();
4469
+ const found = nodes.findIndex((node) => {
4470
+ const slotIndex = Number.parseInt(node.getAttribute("data-next-slot-index") ?? "", 10);
4471
+ if (!Number.isFinite(slotIndex) || slotIndex + 1 !== want.slot) return false;
4472
+ if ((clean(node.getAttribute("data-next-bundle-id")) || null) !== want.bundle_id) return false;
4473
+ const select = node.querySelector("select");
4474
+ const code = clean(select?.getAttribute("data-next-variant-code") || select?.getAttribute("data-variant-code") || node.getAttribute("data-next-variant-code") || select?.name) || null;
4475
+ return code === want.variant_code;
4476
+ });
4477
+ return found >= 0 ? found : want.field_index;
4478
+ }, pick).catch(() => pick.field_index);
4479
+ return fields.nth(index);
4480
+ }
4481
+
4482
+ // The pick's row, matched by its value among the field's rows (no selector is
4483
+ // built from the value), once it is visible; null if it does not show in time.
4484
+ async function visibleSlotVariantRow(page, pick, timeoutMs) {
4485
+ const deadline = Date.now() + timeoutMs;
4486
+ for (;;) {
4487
+ const field = await slotVariantField(page, pick);
4488
+ const rows = field.locator(SLOT_VARIANT_DROPDOWN_ROW_SELECTOR);
4489
+ const index = await rows.evaluateAll((nodes, value) => nodes.findIndex((node) => {
4490
+ if (String(node.getAttribute("value") || "").replace(/\s+/g, " ").trim() !== value) return false;
4491
+ const rect = node.getBoundingClientRect();
4492
+ const style = getComputedStyle(node);
4493
+ return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
4494
+ }), pick.value).catch(() => -1);
4495
+ if (index >= 0) return rows.nth(index);
4496
+ if (Date.now() >= deadline) return null;
4497
+ await page.waitForTimeout(100);
4498
+ }
4499
+ }
4500
+ async function fillSlotVariants(page) {
4501
+ const selections = [];
4502
+ const picked = [];
4503
+ const step = () => page.evaluate(slotVariantStepScript(), {
4504
+ selector: SLOT_VARIANT_SELECT_SELECTOR,
4505
+ fieldSelector: SLOT_VARIANT_FIELD_SELECTOR,
4506
+ toggleSelector: SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR,
4507
+ rowSelector: SLOT_VARIANT_DROPDOWN_ROW_SELECTOR,
4508
+ picked,
4509
+ }).catch(() => ({ done: true }));
4510
+ for (let pass = 0; pass < SLOT_VARIANT_FILL_LIMIT; pass += 1) {
4511
+ const result = await step();
4512
+ if (result?.done) {
4513
+ if (selections.length) await page.waitForLoadState("networkidle", { timeout: 3000 }).catch(() => {});
4514
+ return selections;
4515
+ }
4516
+ if (result?.unfillable?.detail) {
4517
+ throw codedError(CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED, `${slotVariantLabel(result.unfillable)}: ${result.unfillable.detail}; the page would refuse Next`);
4518
+ }
4519
+ if (result?.unfillable) {
4520
+ throw codedError(
4521
+ CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
4522
+ `${slotVariantLabel(result.unfillable)} has no in-stock ${result.unfillable.via === "dropdown" ? "dropdown row" : "option"} to choose (${result.unfillable.options} ${result.unfillable.via === "dropdown" ? "row" : "option"}(s), all disabled or placeholder); the page would refuse Next`,
4523
+ );
4524
+ }
4525
+ const choice = result.pick || result.filled;
4526
+ if (result.pick) {
4527
+ try {
4528
+ const field = await slotVariantField(page, result.pick);
4529
+ await clickControl(field.locator(SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR).filter({ visible: true }).first(), { timeout: 5000 });
4530
+ // The menu may open late or re-render on toggle: re-resolve the field
4531
+ // and the row by its value, and wait for that row to show.
4532
+ const row = await visibleSlotVariantRow(page, result.pick, SLOT_VARIANT_ROW_WAIT_MS);
4533
+ if (!row) {
4534
+ throw codedError(
4535
+ CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
4536
+ `${slotVariantLabel(choice)} dropdown row "${choice.value}" did not show within ${SLOT_VARIANT_ROW_WAIT_MS}ms of opening the dropdown; the page would refuse Next`,
4537
+ );
4538
+ }
4539
+ await clickControl(row, { timeout: 5000 });
4540
+ } catch (error) {
4541
+ if (error?.code === CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED) throw error;
4542
+ throw codedError(
4543
+ CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
4544
+ `${slotVariantLabel(choice)} dropdown row "${choice.value}" could not be clicked (${String(error?.message || error).split("\n")[0]}); the page would refuse Next`,
4545
+ );
4546
+ }
4547
+ picked.push(slotVariantKey(choice));
4548
+ }
4549
+ selections.push({ bundle_id: choice.bundle_id, slot: choice.slot, variant_code: choice.variant_code, value: choice.value, via: result.pick ? "dropdown" : "select" });
4550
+ await page.waitForTimeout(250);
4551
+ }
4552
+ const pending = await step();
4553
+ if (pending?.done) return selections;
4554
+ throw codedError(
4555
+ CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
4556
+ `${slotVariantLabel(pending.pick || pending.filled || pending.unfillable)} still needed a choice after ${SLOT_VARIANT_FILL_LIMIT} variant choices; the page keeps clearing them`,
4557
+ );
4558
+ }
4559
+
4425
4560
  // The package of the bundle card a select page shows as selected, or null.
4426
4561
  async function selectedBundleCardPackage(page) {
4427
4562
  return page.evaluate((cardSelector) => {
@@ -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",
@@ -427,6 +431,107 @@ export function chooseCartEntryControl(controls = [], requested = []) {
427
431
  // returns the store's `totalQuantity` — and the debugger's cart store second
428
432
  // (`window.nextDebug.stores.cart`, present with `?debugger=true`), which is
429
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
+
430
535
  // The enriched line list on `getCartData()` is deliberately NOT read: it is
431
536
  // always empty on the shipped SDK (campaign-cart#36; see the cart-state
432
537
  // verification section of docs/qa-and-test-orders.md), so it would call every
package/src/qa-node.mjs CHANGED
@@ -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";
@@ -3577,6 +3577,8 @@ function output(value, args) {
3577
3577
  console.log("This is a passing proof with exceptions. Report these exceptions to the operator; do not clear or waive them, or change markup just to make them pass.");
3578
3578
  }
3579
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);
3580
3582
  printCauseLines(value.verdict);
3581
3583
  printThemeGateLines(value.theme_gate, value.packet_path, value.report_path);
3582
3584
  if (value.commercial) {
@@ -256,7 +256,8 @@ export function summarizePurchaseProof({ verdict = null, proofPolicy = null } =
256
256
  return {
257
257
  declared_order_path_depth: optionalString(proofPolicy?.order_path_depth),
258
258
  declared_typed_card_depth: optionalString(proofPolicy?.typed_card_depth),
259
- order_paths_executed: orders.length,
259
+ order_paths_executed: orders.filter((order) => !isTestOrderRetry(order)).length,
260
+ test_order_attempts: orders.length,
260
261
  orders_created: created.length,
261
262
  orders_verified: orders.filter((order) => order.verification?.verified === true).length,
262
263
  // null, not false, when nothing ran: "no order was out of test mode" and
@@ -265,6 +266,28 @@ export function summarizePurchaseProof({ verdict = null, proofPolicy = null } =
265
266
  };
266
267
  }
267
268
 
269
+ // A re-run of a planned path is its own test_orders[] entry with `attempt` 2;
270
+ // it is an attempt, not another path. Entries written before `attempt` existed
271
+ // each count as a path, which is what they were counted as then.
272
+ function isTestOrderRetry(order) {
273
+ const attempt = Number(order?.attempt);
274
+ return Number.isInteger(attempt) && attempt > 1;
275
+ }
276
+
277
+ function plural(count, noun) {
278
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
279
+ }
280
+
281
+ // The human summary line for a run that drove typed-card test orders: planned
282
+ // paths, attempts (a re-run is a second attempt at the same path) and orders
283
+ // the platform created, kept apart so a path that was retried is not read as
284
+ // two paths against the --max-test-orders cap. null when no order path ran.
285
+ export function formatTestOrderSummary(verdict) {
286
+ const summary = summarizePurchaseProof({ verdict });
287
+ if (!summary.test_order_attempts) return null;
288
+ return `Test orders: ${plural(summary.order_paths_executed, "path")}, ${plural(summary.test_order_attempts, "attempt")}, ${plural(summary.orders_created, "order")} created`;
289
+ }
290
+
268
291
  // The browser placeholder-text residue gate (H3.1) emits one assertion per
269
292
  // page as `template-residue:<page_id>:placeholder-text`. Both the emitter
270
293
  // (qa-browser.mjs) and the stage-ledger summary below build the id from this
@@ -2,7 +2,7 @@
2
2
  // checks (#303).
3
3
  //
4
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
5
+ // v0.4.42 (the SDK pin every certified starter family ships today): every
6
6
  // data-next-* name the SDK's non-test TypeScript names as a string, plus every
7
7
  // `dataset.next*` property it reads or writes, turned back into its
8
8
  // attribute. Only the data-next-* names are kept: the checks that read this
@@ -15,8 +15,8 @@
15
15
  // whole from the source at a named tag, never edited by hand.
16
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[@]}" \
18
+ // { git -C ../campaign-cart grep -h -oP 'data-next-[a-z0-9]+(?:-[a-z0-9]+)*-?' v0.4.42 -- "${paths[@]}"
19
+ // git -C ../campaign-cart grep -h -oP 'dataset\.next[A-Z][A-Za-z0-9]*' v0.4.42 -- "${paths[@]}" \
20
20
  // | sed 's/^dataset\.//' | perl -pe 's/([A-Z])/-\L$1/g; s/^/data-/'
21
21
  // } | sort -u
22
22
  //
@@ -30,14 +30,15 @@
30
30
  // manifests never did (the address block's, data-next-i18n,
31
31
  // data-next-bump-section, data-next-button-text, the variant option state).
32
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.
33
+ // those and data-next-phone-e164 / data-next-phone-country. At v0.4.42 it
34
+ // returns the same 149 names as at v0.4.41.
34
35
  //
35
36
  // A name ending in "-" (data-next-class-) is a prefix the SDK reads with any
36
37
  // suffix.
37
38
 
38
39
  import { RELEASED_SDK_VERSION_PATTERN } from "../campaign-spec/dist/index.js";
39
40
 
40
- export const SDK_ATTRIBUTE_INDEX_VERSION = "0.4.41";
41
+ export const SDK_ATTRIBUTE_INDEX_VERSION = "0.4.42";
41
42
 
42
43
  export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
43
44
  "data-next-accordion",
@@ -364,8 +365,8 @@ export const SDK_TEMPLATE_PLACEHOLDERS = Object.freeze({
364
365
  });
365
366
 
366
367
  // The SDK versions whose renderer files above were verified unchanged from
367
- // v0.4.38 (git blob ids equal at v0.4.38, v0.4.39, v0.4.40 and v0.4.41, for
368
+ // v0.4.38 (git blob ids equal at v0.4.38, v0.4.39, v0.4.40, v0.4.41 and v0.4.42, for
368
369
  // the eight renderer files cited above plus cart-item-list.enhancer.ts and
369
370
  // order-item-list.enhancer.ts). A page whose loader pins another version
370
371
  // 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"]);
372
+ export const SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS = Object.freeze(["0.4.38", "0.4.39", "0.4.40", "0.4.41", "0.4.42"]);
@@ -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 { checkoutPathFrom, declineRouteTarget, forwardRouteTarget, forwardTargetPage, isCheckoutStepPage } from "../campaign-spec/dist/index.js";
7
+ import { checkoutPathFrom, declineRouteTarget, forwardRouteTarget, forwardTargetPage, isCheckoutStepPage, isPrePaymentPage } 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"]);
@@ -570,11 +570,12 @@ function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir,
570
570
  const frontmatter = { page_type: pageType };
571
571
  if (permalinkRequired) frontmatter.permalink = publicRoute;
572
572
 
573
- const nextUrl = nextUrlForPage(page, pageById, publicRouteSlug);
574
- if (nextUrl) frontmatter.next_url = nextUrl;
573
+ const forwardUrl = nextUrlForPage(page, pageById, publicRouteSlug);
574
+ const checkoutFlow = checkoutFlowFrontmatter(page, forwardUrl, { specPages, pageById, publicRouteSlug });
575
+ if (checkoutFlow) Object.assign(frontmatter, checkoutFlow);
576
+ else if (forwardUrl) frontmatter.next_url = forwardUrl;
575
577
  const declineUrl = declineUrlForPage(page, pageById, publicRouteSlug);
576
- if (declineUrl && declineUrl !== nextUrl) frontmatter.decline_url = declineUrl;
577
- Object.assign(frontmatter, checkoutFlowFrontmatter(page, { specPages, pageById, publicRouteSlug }));
578
+ if (declineUrl && declineUrl !== forwardUrl) frontmatter.decline_url = declineUrl;
578
579
 
579
580
  return {
580
581
  target_path: targetPath,
@@ -587,24 +588,32 @@ function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir,
587
588
  };
588
589
  }
589
590
 
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.
591
+ // Multi-step checkout wiring (campaigns-os#641) for a select or checkout_step
592
+ // page on a path to a Checkout. The keys follow the starter-template contract
593
+ // (campaign-cart-starter-templates#223). Null for any other page, whose
594
+ // next_url stays its own forward link: a landing page's call-to-action reads
595
+ // next_url, so pointing it past the Checkout would skip payment.
596
+ // next_url where a completed order goes: the Checkout's post-payment
597
+ // destination (its own next_url), the meaning next_url has on a
598
+ // single-page checkout. The page's next-success-url meta tag
599
+ // carries it, because the SDK reads that tag for express orders
600
+ // placed on this page; an express order on step 1 must land on
601
+ // the first upsell, not on step 2.
602
+ // next_step this page's forward link: a step form's
603
+ // data-next-checkout-step, a select page's checkout link.
599
604
  // step_number on a checkout_step only: its position among the steps of its
600
605
  // 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 {};
606
+ function checkoutFlowFrontmatter(page, forwardUrl, { specPages, pageById, publicRouteSlug }) {
607
+ // A path of two or more pages means the page has a resolvable forward link,
608
+ // so forwardUrl is set; the guard keeps the pair together regardless, never
609
+ // emitting the Checkout's destination without a next_step to navigate by.
610
+ if (!isPrePaymentPage(page) || !forwardUrl) return null;
603
611
  const path = checkoutPathFrom(specPages, page);
604
- if (!path || path.length < 2) return {};
612
+ if (!path || path.length < 2) return null;
605
613
  const out = {};
606
614
  const successUrl = nextUrlForPage(path[path.length - 1], pageById, publicRouteSlug);
607
- if (successUrl) out.success_url = successUrl;
615
+ if (successUrl) out.next_url = successUrl;
616
+ out.next_step = forwardUrl;
608
617
  if (isCheckoutStepPage(page)) out.step_number = checkoutStepNumber(page, specPages);
609
618
  return out;
610
619
  }