@nextcommerce/campaigns-os 1.47.0 → 1.50.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 (53) hide show
  1. package/CHANGELOG.md +307 -0
  2. package/README.md +30 -3
  3. package/agents/claude/CLAUDE.md +6 -3
  4. package/agents/codex/AGENTS.md +6 -4
  5. package/agents/copilot/copilot-instructions.md +3 -2
  6. package/agents/cursor/campaigns-os.mdc +3 -3
  7. package/compatibility.json +1 -1
  8. package/contracts/commerce-surface-catalog.json +17 -17
  9. package/contracts/effects.v1.json +173 -0
  10. package/contracts/release-ledger.json +798 -0
  11. package/contracts/supported-surface.json +4 -4
  12. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  13. package/docs/brand-theme-bridge.md +12 -6
  14. package/docs/build-packet.md +9 -4
  15. package/docs/campaign-build-brief.md +25 -28
  16. package/docs/local-setup.md +7 -2
  17. package/docs/orientation-contract-reference.md +1 -1
  18. package/docs/qa-and-test-orders.md +70 -9
  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-packet.v0.schema.json +4 -0
  23. package/schemas/campaigns-os-qa-verdict.v0.schema.json +8 -3
  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 +9 -4
  27. package/skills/contribution-intake/SKILL.md +3 -3
  28. package/skills/next-campaigns-build/SKILL.md +5 -4
  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 +6 -5
  32. package/skills/next-campaigns-qa/SKILL.md +3 -3
  33. package/skills.json +10 -10
  34. package/src/brand-theme.mjs +13 -2
  35. package/src/cli.mjs +17 -12
  36. package/src/content-residue.mjs +18 -90
  37. package/src/doctor/checks.mjs +21 -29
  38. package/src/doctor/inspect.mjs +13 -3
  39. package/src/doctor/next-step.mjs +4 -0
  40. package/src/gate-actions.mjs +8 -0
  41. package/src/install-mode.mjs +0 -8
  42. package/src/invocation.mjs +3 -1
  43. package/src/local-preview-policy.mjs +92 -0
  44. package/src/page-kit-sdk-version.mjs +8 -1
  45. package/src/polish-node.mjs +26 -2
  46. package/src/progress-node.mjs +5 -1
  47. package/src/qa-binding-evidence.mjs +22 -1
  48. package/src/qa-browser.mjs +92 -14
  49. package/src/qa-node.mjs +43 -6
  50. package/src/readback.mjs +19 -10
  51. package/src/source-prep.mjs +1 -1
  52. package/src/stage-record.mjs +303 -22
  53. package/src/theme-gate.mjs +3 -3
@@ -62,6 +62,14 @@ export const HIDDEN_EAGER_MEDIA_ACTIONS = Object.freeze({
62
62
  command: null,
63
63
  description: localProofRebuildText(),
64
64
  }),
65
+ // Every mapped page is template stock: capture has no design route, so
66
+ // neither a recapture nor an authority repair can produce the evidence.
67
+ map_design_route: Object.freeze({
68
+ id: "polish.hidden_eager_media.map_design_route",
69
+ kind: "manual",
70
+ command: null,
71
+ description: "Every mapped page is template stock, so polish capture has nothing to measure. Map at least one page to its design source HTML (source_html.pages[].path) and rerun intake, or prove the campaign on the local preview (deploy.target local-serve served from localhost), where this missing evidence is carried forward as a warning.",
72
+ }),
65
73
  });
66
74
 
67
75
  const PACKET_PLACEHOLDER = "--packet <packet>";
@@ -282,11 +282,3 @@ export function invocationPrefixFor(root, pkg = null) {
282
282
  prefixCache.set(root, prefix);
283
283
  return prefix;
284
284
  }
285
-
286
- // Rewrites every command spelled with the canonical bare `campaigns-os <verb>`
287
- // into the given prefix. Internal bookkeeping (deviation tracking, gate
288
- // registries, tests) keeps the canonical spelling; only what is printed or
289
- // emitted for an operator or agent to copy is rewritten. Skill names such as
290
- // next-campaigns-os-setup, file names (campaigns-os.mjs), and already-prefixed
291
- // forms (`npx --no-install campaigns-os`, `npm run campaigns-os --`) are left
292
- // alone.
@@ -65,7 +65,7 @@ const COMMANDS = frozen({
65
65
  theme: { subcommands: ["generate", "inspect", "waive"] },
66
66
  checkpoint: { subcommands: ["waive"] },
67
67
  polish: { subcommands: ["capture"] },
68
- record: { subcommands: ["build", "polish", "setup"] },
68
+ record: { subcommands: ["build", "deploy", "polish", "setup", "theme"] },
69
69
  "validate-assembly-report": {},
70
70
  "install-agent-context": { dryRun: true },
71
71
  "install-skills": { dryRun: true },
@@ -92,8 +92,10 @@ const SUBCOMMAND_OVERRIDES = frozen({
92
92
  "spec derive": { dryRun: true },
93
93
  "qa publish": { dryRun: true },
94
94
  "record build": { dryRun: true },
95
+ "record deploy": { dryRun: true },
95
96
  "record polish": { dryRun: true },
96
97
  "record setup": { dryRun: true },
98
+ "record theme": { dryRun: true },
97
99
  "qa run": { autoEnd: true },
98
100
  "run start": { sweepRoot: "session" },
99
101
  "run end": { sweepRoot: "session", dryRun: true },
@@ -0,0 +1,92 @@
1
+ // The local preview policy: one place that decides which missing evidence a
2
+ // localhost preview carries forward as a warning instead of a blocker.
3
+ //
4
+ // The local preview is a packet whose deploy target is `local-serve`, served
5
+ // from a loopback host. There a campaign must still prove its commerce (store
6
+ // and campaign binding, routes, SDK loading, prices, a typed-card order), but
7
+ // evidence that only design work or launch produces may be missing. Missing
8
+ // evidence is reported as missing, never as passed: a carried-forward gate is
9
+ // a warning in doctor, `next` does not stop on it, and QA records it as a
10
+ // `warn` row, so the verdict is at best `ready_with_exceptions`. A hosted
11
+ // preview or production packet never takes this path, and every check not
12
+ // named below keeps its meaning on every path.
13
+ //
14
+ // Doctor and QA apply this to the gates they evaluate; `next` reads doctor's
15
+ // gates. Recording a stage (`record polish`) and the waiver commands keep the
16
+ // strict gates.
17
+ import { isLocalServePacket } from "./local-proof.mjs";
18
+ import { isLoopbackHostname } from "./remit.mjs";
19
+
20
+ export const LOCAL_PREVIEW_POLICY = "local_preview";
21
+ export const CARRIED_FORWARD = "carried_forward";
22
+
23
+ // The checks carried forward, one by one. Each is evidence the campaign does
24
+ // not have yet, not evidence that something is wrong:
25
+ // - polish was never recorded for this build;
26
+ // - no page-load capture exists, either because polish capture never ran or
27
+ // because every mapped page is template stock and there is no design route
28
+ // to capture.
29
+ const CARRIED_POLISH_CODES = Object.freeze(new Set([
30
+ "polish.report_missing",
31
+ "polish.evidence_missing",
32
+ ]));
33
+ export const NO_CAPTURABLE_ROUTES_CODE = "polish.hidden_eager_media.no_capturable_routes";
34
+ const MISSING_CAPTURE_CODE = "polish.hidden_eager_media.capture_malformed";
35
+
36
+ // With no generatable brand theme the starter template is the design, so its
37
+ // palette and chrome are expected on the local preview: residue findings are
38
+ // warnings there, as they are under a waived theme gate.
39
+ const STARTER_IS_THE_DESIGN_THEME_CODE = "theme_gate.nothing_generatable";
40
+
41
+ function loopbackOrAbsent(url) {
42
+ if (url === undefined || url === null || url === "") return true;
43
+ try {
44
+ return isLoopbackHostname(new URL(String(url)).hostname);
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ export function isLocalPreview(packet, { baseUrl = null } = {}) {
51
+ return isLocalServePacket(packet)
52
+ && loopbackOrAbsent(packet?.deploy?.preview_url)
53
+ && loopbackOrAbsent(baseUrl);
54
+ }
55
+
56
+ function pageLoadRecorded(report) {
57
+ return report?.stages?.polish?.evidence?.visual_review?.page_load != null;
58
+ }
59
+
60
+ function carried(gate) {
61
+ return {
62
+ ...gate,
63
+ status: CARRIED_FORWARD,
64
+ carried_forward: { policy: LOCAL_PREVIEW_POLICY, from_status: gate.status, evidence: "missing" },
65
+ };
66
+ }
67
+
68
+ // The hidden eager-media checkpoint, evaluated on its own.
69
+ export function applyLocalPreviewToCheckpoint(gate, { packet, report, baseUrl = null } = {}) {
70
+ if (gate?.status !== "blocked" || !isLocalPreview(packet, { baseUrl })) return gate;
71
+ const missing = gate.code === NO_CAPTURABLE_ROUTES_CODE
72
+ || (gate.code === MISSING_CAPTURE_CODE && !pageLoadRecorded(report));
73
+ return missing ? carried(gate) : gate;
74
+ }
75
+
76
+ // The polish gate folds the checkpoint in, so it is carried forward only when
77
+ // whatever it owns from the checkpoint was carried forward too.
78
+ export function applyLocalPreviewToPolishGate(gate, { packet, checkpointGate = null, baseUrl = null } = {}) {
79
+ if (gate?.status !== "blocked" || !isLocalPreview(packet, { baseUrl })) return gate;
80
+ const checkpointCarried = checkpointGate?.status === CARRIED_FORWARD;
81
+ if (gate.owned_checkpoint_status === "blocked" && !checkpointCarried) return gate;
82
+ if (gate.owned_checkpoint_only) return checkpointCarried ? carried(gate) : gate;
83
+ return CARRIED_POLISH_CODES.has(gate.code) ? carried(gate) : gate;
84
+ }
85
+
86
+ export function starterResidueIsExpected(themeGate, { packet, baseUrl = null } = {}) {
87
+ return themeGate?.code === STARTER_IS_THE_DESIGN_THEME_CODE && isLocalPreview(packet, { baseUrl });
88
+ }
89
+
90
+ export function carriedForwardMessage(gate) {
91
+ return `Carried forward on the local preview: ${gate.reason} This evidence is missing, not passed; a hosted preview or production run requires it.`;
92
+ }
@@ -92,6 +92,10 @@ export function evaluatePageKitSdkVersion({
92
92
  waivers = [],
93
93
  required = true,
94
94
  now = new Date().toISOString(),
95
+ // { family, sdk_version } of the selected certified template family, when
96
+ // the caller knows it: the missing-pin reason names that verified SDK so the
97
+ // pin is not chosen by searching docs.
98
+ familyVerification = null,
95
99
  } = {}) {
96
100
  const pin = resolveSpecSdkPin(spec);
97
101
  const { has_canonical: hasCanonical, has_alias: hasAlias } = pin;
@@ -139,7 +143,10 @@ export function evaluatePageKitSdkVersion({
139
143
  scope: PAGE_KIT_SDK_VERSION_SCOPE,
140
144
  status: "blocked",
141
145
  code: "page_kit.sdk_version.spec_missing",
142
- reason: "CampaignSpec is missing global_config.sdk_version (and the runtime.sdk_version alias); add an explicit released SDK pin before build or QA.",
146
+ reason: "CampaignSpec is missing global_config.sdk_version (and the runtime.sdk_version alias); add an explicit released SDK pin before build or QA."
147
+ + (isReleasedSdkVersion(familyVerification?.sdk_version) && typeof familyVerification?.family === "string"
148
+ ? ` The "${familyVerification.family}" template family was last verified against ${familyVerification.sdk_version}.`
149
+ : ""),
143
150
  waivable: false,
144
151
  subject,
145
152
  state: { spec_status: "missing" },
@@ -1,4 +1,5 @@
1
1
  import { campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
2
+ import { NO_CAPTURABLE_ROUTES_CODE } from "./local-preview-policy.mjs";
2
3
  import { createHash } from "node:crypto";
3
4
  import { HIDDEN_EAGER_MEDIA_ACTIONS } from "./gate-actions.mjs";
4
5
  import { dirname, join, resolve } from "node:path";
@@ -93,6 +94,8 @@ function mappedSpecRoute(value, pageId) {
93
94
  return route;
94
95
  }
95
96
 
97
+ const NO_CAPTURABLE_ROUTES_ERROR = "no_capturable_routes";
98
+
96
99
  export function planPolishCapture({ packet, baseUrl } = {}) {
97
100
  if (!isPlainObject(packet) || !Array.isArray(packet?.source_html?.pages) || packet.source_html.pages.length === 0) {
98
101
  throw new Error("polish capture requires packet.source_html.pages mappings.");
@@ -129,7 +132,14 @@ export function planPolishCapture({ packet, baseUrl } = {}) {
129
132
  });
130
133
  }
131
134
 
132
- if (routes.length === 0) throw new Error("polish capture has no mapped non-skipped routes to capture.");
135
+ if (routes.length === 0) {
136
+ const error = new Error(
137
+ "polish capture has no mapped non-skipped routes to capture: every mapped page is template stock (skip_reason), so there is no design route to compare."
138
+ + " On a local-serve preview, missing polish evidence is carried forward as a warning; run `next` for the next stage.",
139
+ );
140
+ error.code = NO_CAPTURABLE_ROUTES_ERROR;
141
+ throw error;
142
+ }
133
143
  routes.sort((a, b) => a.requested_route.localeCompare(b.requested_route) || a.page_id.localeCompare(b.page_id));
134
144
  for (let index = 1; index < routes.length; index += 1) {
135
145
  if (routes[index - 1].requested_route === routes[index].requested_route) {
@@ -189,6 +199,19 @@ function recordedAuthorityBlock({ packet, report, plan = null, now } = {}) {
189
199
  };
190
200
  }
191
201
 
202
+ // Every mapped page is template stock: there is nothing for polish capture to
203
+ // measure, which is missing evidence rather than a malformed packet. It stays
204
+ // a non-waivable block; the local preview policy carries it forward.
205
+ function noCapturableRoutesBlock({ packet, report, now } = {}) {
206
+ const block = recordedAuthorityBlock({ packet, report, now });
207
+ return {
208
+ ...block,
209
+ code: NO_CAPTURABLE_ROUTES_CODE,
210
+ reason: "Every mapped page is template stock (skip_reason), so polish capture has no design route to capture and this build has no page-load evidence.",
211
+ required_actions: [HIDDEN_EAGER_MEDIA_ACTIONS.map_design_route],
212
+ };
213
+ }
214
+
192
215
  function recordedCheckpointNotApplicable() {
193
216
  return {
194
217
  id: HIDDEN_EAGER_MEDIA_SCOPE,
@@ -219,7 +242,8 @@ export function evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report, now
219
242
  let plan;
220
243
  try {
221
244
  plan = planPolishCapture({ packet, baseUrl: "https://polish-capture.invalid" });
222
- } catch {
245
+ } catch (error) {
246
+ if (error?.code === NO_CAPTURABLE_ROUTES_ERROR) return noCapturableRoutesBlock({ packet, report, now });
223
247
  return recordedAuthorityBlock({ packet, report, now });
224
248
  }
225
249
 
@@ -58,7 +58,11 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
58
58
  binding:reportBound&&id(verdict.run_id)&&id(verdict.run_id)===id(report?.stages?.qa?.verdict_run_id)&&build&&qaSource===build&&doctor?.derived?.build_output_fingerprint?.status==='pass'&&hash(verdict.spec_hash)===localHash?'matching':'unconfirmed',
59
59
  publish_state:accepted(qaResult?.qa_verdict_publish?.state,['skipped','ok','failed']),
60
60
  }:null;
61
- const gates=(Array.isArray(continuation?.gates)?continuation.gates:[]).slice(0,PROGRESS_GATE_LIMIT).map(gate=>({id:accepted(gate?.id,PROGRESS_GATE_IDS),state:PROGRESS_GATE_IDS.includes(gate?.id)?accepted(gate?.status,['pass','blocked','waived','not_applicable']):'unknown'}));
61
+ // The snapshot schema has no carried-forward state: a gate the local preview
62
+ // policy carried forward does not apply on this path, so it is not_applicable
63
+ // here (never pass).
64
+ const gateState=status=>accepted(status==='carried_forward'?'not_applicable':status,['pass','blocked','waived','not_applicable']);
65
+ const gates=(Array.isArray(continuation?.gates)?continuation.gates:[]).slice(0,PROGRESS_GATE_LIMIT).map(gate=>({id:accepted(gate?.id,PROGRESS_GATE_IDS),state:PROGRESS_GATE_IDS.includes(gate?.id)?gateState(gate?.status):'unknown'}));
62
66
  const actions=[...new Set((Array.isArray(continuation?.next_actions)?continuation.next_actions:[]).slice(0,64).map(action=>accepted(action?.id,PROGRESS_ACTION_IDS)))];
63
67
  const stage=accepted(continuation?.stage,PROGRESS_CONTINUATIONS);
64
68
  const preview=typeof packet?.deploy?.preview_url==='string'&&packet.deploy.preview_url?packet.deploy.preview_url:null;
@@ -5,7 +5,7 @@ import { HTML_NAMESPACE, baseInEffect, documentBases, endsUnclosed, frozenBaseUr
5
5
 
6
6
  export const BINDING_SCHEMA = 'campaigns-os-page-binding/v0';
7
7
  export const BINDING_LIMITS = Object.freeze({ scripts_per_page: 6, scripts_per_run: 24, script_bytes: 262144, timeout_ms: 5000 });
8
- const SDK = /^https:\/\/cdn\.jsdelivr\.net\/gh\/NextCommerceCo\/campaign-cart@[^/]+\/(?:dist\/index\.js|public\/loader\.js)(?:\?[^#]*)?$/;
8
+ const SDK = /^https:\/\/cdn\.jsdelivr\.net\/gh\/NextCommerceCo\/campaign-cart@[^/]+\/(?:dist\/(?:index|loader)\.js|public\/loader\.js)(?:\?[^#]*)?$/;
9
9
  const str = value => typeof value === 'string' && value.length ? value : null;
10
10
 
11
11
  // The expected credential uses the existing producer resolver. Disagreement
@@ -188,6 +188,27 @@ export async function observeBinding({ source, page, expected, scriptLoader, par
188
188
  return result(values[0] === expected.value ? 'match' : 'mismatch', 'credential_comparison');
189
189
  }
190
190
 
191
+ // The Campaign Cart SDK sends the page's raw key, with no scheme, as
192
+ // `Authorization` on every Campaigns API call, so the browser pass can see the
193
+ // key a page actually uses, whatever its scripts look like. The value is
194
+ // compared as sent: if the SDK ever adds a scheme, every page reads mismatch
195
+ // rather than passing. The API host is matched by its shape,
196
+ // campaigns.apps.<name>.com, without naming it.
197
+ const SDK_API_HOST = /^campaigns\.apps\.[a-z0-9-]+\.com$/;
198
+ export function isSdkApiRequest(url) {
199
+ try { const parsed = new URL(url); return parsed.protocol === 'https:' && SDK_API_HOST.test(parsed.hostname) && parsed.pathname.startsWith('/api/'); } catch { return false; }
200
+ }
201
+
202
+ // The keys a page sent are compared here and dropped; only the outcome is
203
+ // kept. With nothing sent, or no single expected key, the static read stands
204
+ // (null).
205
+ export function sentBinding(sentKeys, expected) {
206
+ if (!sentKeys.length || expected?.conflict || !expected?.value) return null;
207
+ return { schema_version: BINDING_SCHEMA, observation: 'sdk_request',
208
+ outcome: sentKeys.every(value => value === expected.value) ? 'match' : 'mismatch',
209
+ reason: 'credential_comparison', source_kinds: ['sdk_request'], identity: 'not_verified' };
210
+ }
211
+
191
212
  export function bindingAssertion(page, evidence) {
192
213
  // No source URLs, values, hashes, masked fragments, or inferred App IDs.
193
214
  return { id: `page-binding:${page.page_id}`, family: 'api-metadata', page: page.page_id,
@@ -45,6 +45,7 @@ import {
45
45
  import { ORDER_BUMP_PROBE_INPUT, orderBumpEvidenceScript } from "./qa-order-bump.mjs";
46
46
  import { assessPurchaseDataLayer, expectedOrderReferences, purchaseDataLayerAssertion, purchaseDataLayerProbe } from "./qa-purchase-data-layer.mjs";
47
47
  import { isBumpRow } from "./commercial-journey.mjs";
48
+ import { bindingAssertion, isSdkApiRequest, sentBinding } from "./qa-binding-evidence.mjs";
48
49
  import {
49
50
  RESIDUE_PAGE_TYPES,
50
51
  demoAssetConfig,
@@ -962,6 +963,14 @@ async function runPageBrowserChecks(context, page, args, options = {}) {
962
963
  failure: request.failure()?.errorText || "request failed",
963
964
  });
964
965
  });
966
+ // The key each SDK request carried. It stays in this function: sentBinding
967
+ // keeps only whether it matched.
968
+ const sentKeys = [];
969
+ const keyReads = [];
970
+ browserPage.on("request", (request) => {
971
+ if (!isSdkApiRequest(request.url())) return;
972
+ keyReads.push(request.headerValue("authorization").then((value) => { if (value !== null) sentKeys.push(value); }, () => {}));
973
+ });
965
974
 
966
975
  // Measurements describe the existing sequence; readiness never shortens it.
967
976
  const observationStarted = performance.now();
@@ -1017,6 +1026,9 @@ async function runPageBrowserChecks(context, page, args, options = {}) {
1017
1026
  assertions.push(runtimeIssueAssertion(page, "browser-console-errors", actionableConsoleErrors));
1018
1027
  }
1019
1028
  observation.failed_requests_sampled_after_ms = elapsedMilliseconds(observationStarted);
1029
+ await Promise.all(keyReads);
1030
+ const sent = sentBinding(sentKeys, options.bindingExpected);
1031
+ if (sent) assertions.push(bindingAssertion(page, sent));
1020
1032
  if (failedRequests.length) {
1021
1033
  assertions.push(assertion({
1022
1034
  id: `browser-request-failures:${page.page_id}`,
@@ -2822,7 +2834,22 @@ async function pricingVisibilityAssertions(browserPage, page, options = {}) {
2822
2834
  selectors.length ? countVisiblePriceRows(browserPage, selectors) : 0,
2823
2835
  countVisiblePriceRows(browserPage, totalSelectors),
2824
2836
  ]);
2825
- return [checkoutPriceVisibilityAssertion({ page, selectors, visibleCount, totalSelectors, totalVisibleCount })];
2837
+ // Nothing priced is visible. When the checkout also has no package
2838
+ // selection of its own and the SDK reports an empty cart, the cart is
2839
+ // filled on an earlier page (a landing link carrying forcePackageId), and
2840
+ // opening the checkout directly shows a state no shopper reaches. The
2841
+ // test-order path enters that cart from the landing page instead.
2842
+ const emptyEntry = visibleCount === 0 && totalVisibleCount === 0
2843
+ ? await Promise.all([
2844
+ browserPage.evaluate(() => (typeof window.next?.getCartCount === "function" ? window.next.getCartCount() : null)),
2845
+ browserPage.evaluate(checkoutSelectionSurfaceScript()),
2846
+ ]).then(
2847
+ ([cart_count, selection_surface]) => ({ cart_count, selection_surface }),
2848
+ // A failed probe leaves the row failed, and says why on the row.
2849
+ (error) => ({ probe_error: trim(error?.message || String(error)).slice(0, 200) }),
2850
+ )
2851
+ : null;
2852
+ return [checkoutPriceVisibilityAssertion({ page, selectors, visibleCount, totalSelectors, totalVisibleCount, emptyEntry })];
2826
2853
  }
2827
2854
  return [];
2828
2855
  }
@@ -2896,25 +2923,37 @@ function upsellPriceVisibilityAssertion({ page, selectors, visibleCount }) {
2896
2923
  // no `totalSelectors` ran the bundle-row check alone, and the row says so
2897
2924
  // rather than reporting an empty selector list that reads like a check that
2898
2925
  // ran and found nothing.
2899
- function checkoutPriceVisibilityAssertion({ page, selectors, visibleCount, totalSelectors, totalVisibleCount = 0 }) {
2926
+ //
2927
+ // `emptyEntry` ({ cart_count, selection_surface }, or { probe_error }) is read
2928
+ // only when nothing priced is visible. An empty SDK cart (cart_count 0, never
2929
+ // null: null means no SDK was read) on a checkout with no package selection of
2930
+ // its own is not a price the shopper fails to see: the row is skipped with
2931
+ // that reason rather than failed, and only that row carries the probe values.
2932
+ function checkoutPriceVisibilityAssertion({ page, selectors, visibleCount, totalSelectors, totalVisibleCount = 0, emptyEntry = null }) {
2900
2933
  const totalChecked = Array.isArray(totalSelectors);
2901
2934
  const ok = visibleCount >= 1 || (totalChecked && totalVisibleCount >= 1);
2935
+ const enteredUpstream = !ok && emptyEntry?.cart_count === 0 && emptyEntry?.selection_surface?.count === 0;
2936
+ const counts = totalChecked
2937
+ ? `${visibleCount} visible price row(s); ${totalVisibleCount} visible cart-summary total(s)`
2938
+ : `${visibleCount} visible price row(s)`;
2902
2939
  return assertion({
2903
2940
  id: "pricing.checkout_price_visible",
2904
2941
  family: "pricing",
2905
2942
  page,
2906
- status: ok ? STATUS.PASS : STATUS.FAIL,
2907
- severity: ok ? undefined : SEVERITY.WARN,
2943
+ status: ok ? STATUS.PASS : enteredUpstream ? STATUS.SKIPPED : STATUS.FAIL,
2944
+ severity: ok || enteredUpstream ? undefined : SEVERITY.WARN,
2908
2945
  expected: totalChecked
2909
2946
  ? "at least one visible checkout bundle price row or a visible cart-summary total"
2910
2947
  : "at least one visible checkout bundle price row",
2911
- actual: totalChecked
2912
- ? `${visibleCount} visible price row(s); ${totalVisibleCount} visible cart-summary total(s)`
2913
- : `${visibleCount} visible price row(s)`,
2948
+ actual: enteredUpstream
2949
+ ? `${counts}: opened directly, the checkout has an empty cart and no package selection of its own; its cart is filled on an earlier page, and the test order enters it from there`
2950
+ : counts,
2914
2951
  evidence: {
2915
2952
  selectors,
2916
2953
  visible_count: visibleCount,
2917
2954
  ...(totalChecked ? { total_selectors: totalSelectors, total_visible_count: totalVisibleCount } : {}),
2955
+ ...(enteredUpstream ? { cart_count: emptyEntry.cart_count, checkout_selection_surface: emptyEntry.selection_surface } : {}),
2956
+ ...(emptyEntry?.probe_error ? { empty_cart_probe_error: emptyEntry.probe_error } : {}),
2918
2957
  page_url: page.url,
2919
2958
  },
2920
2959
  });
@@ -4962,10 +5001,7 @@ async function waitForCheckoutResult(page, events = null) {
4962
5001
  if (outcomeUrl.test(String(safePageUrl(page) || ""))) break;
4963
5002
  if (events) {
4964
5003
  const rejected = rejectedOrderCreateResponse(events);
4965
- if (rejected) {
4966
- const detail = typeof rejected.body?.detail === "string" ? `: ${trim(rejected.body.detail)}` : "";
4967
- throw new Error(`order create rejected: HTTP ${rejected.status}${detail}`);
4968
- }
5004
+ if (rejected) throw new Error(orderCreateRejectionMessage(rejected));
4969
5005
  const failed = failedOrderCreateRequest(events);
4970
5006
  if (failed) throw new Error(`order create request failed: ${failed.failure || "network failure"}`);
4971
5007
  }
@@ -4979,6 +5015,23 @@ async function waitForCheckoutResult(page, events = null) {
4979
5015
  // The MOST RECENT create response decides the outcome: SDKs/platforms retry
4980
5016
  // transient create failures, so [..., 400, 201] means the retry succeeded and
4981
5017
  // the earlier rejection is history, not the result.
5018
+ // The platform refuses an order whose customer, items and total match one it
5019
+ // accepted or is still processing in the last 30 minutes, and reports it in
5020
+ // `payment_details`. QA reuses one test customer, so two runs against the same
5021
+ // campaign at once, or a rerun after an attempt that died mid-submit, trip it.
5022
+ const DUPLICATE_ORDER_PATTERN = /duplicate order/i;
5023
+ const DUPLICATE_ORDER_REMEDY =
5024
+ "duplicate_order: the platform refused an order matching a recent one from the same test customer with the same items and total, " +
5025
+ "for up to 30 minutes. Concurrent QA runs that share a test customer collide. Re-run with a different --test-email-prefix " +
5026
+ "(or --test-email), or wait. The shipping address is not part of the match";
5027
+
5028
+ function orderCreateRejectionMessage(rejected) {
5029
+ const body = rejected?.body;
5030
+ const reason = [body?.detail, body?.payment_details].find((value) => typeof value === "string" && value.trim());
5031
+ const base = `order create rejected: HTTP ${rejected?.status}${reason ? `: ${trim(reason)}` : ""}`;
5032
+ return reason && DUPLICATE_ORDER_PATTERN.test(reason) ? `${base} (${DUPLICATE_ORDER_REMEDY})` : base;
5033
+ }
5034
+
4982
5035
  function rejectedOrderCreateResponse(events) {
4983
5036
  for (let index = events.responses.length - 1; index >= 0; index -= 1) {
4984
5037
  const response = events.responses[index];
@@ -5444,6 +5497,22 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5444
5497
  const displayed = new Set(resolved.displayed_package_ids);
5445
5498
  const summaryIds = new Set(resolved.summary_package_ids);
5446
5499
  const matchedSummaryIds = new Set();
5500
+ // A checkout order bump persists with is_upsell: true (the platform's
5501
+ // reporting tag), yet the checkout summary displays it. Such a line charges
5502
+ // a displayed row; an is_upsell line the summary does not show is a
5503
+ // post-purchase upsell, out of scope here, never a stray charge.
5504
+ let bumpLineCount = 0;
5505
+ for (const line of (lines || []).filter((entry) => entry?.is_upsell)) {
5506
+ const resolution = events ? campaignPackageResolutionForLine(events, line, {
5507
+ selected_packages,
5508
+ preferred_refs: resolved.summary_package_ids,
5509
+ }) : null;
5510
+ const ref = resolution?.pkg?.ref_id == null ? null : String(resolution.pkg.ref_id);
5511
+ if (ref && summaryIds.has(ref)) {
5512
+ matchedSummaryIds.add(ref);
5513
+ bumpLineCount += 1;
5514
+ }
5515
+ }
5447
5516
  const extra = [];
5448
5517
  const unresolved = [];
5449
5518
  const matchedQuantities = [];
@@ -5491,6 +5560,7 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5491
5560
  displayed_package_ids: [...displayed],
5492
5561
  summary_package_ids: [...summaryIds],
5493
5562
  non_upsell_line_count: nonUpsellLines.length,
5563
+ ...(bumpLineCount ? { order_bump_line_count: bumpLineCount } : {}),
5494
5564
  extra,
5495
5565
  missing,
5496
5566
  matched_quantities: matchedQuantities,
@@ -5560,7 +5630,7 @@ function orderDisplayParityAssertion(page, planIdentifier, order) {
5560
5630
  return assertion({
5561
5631
  ...base,
5562
5632
  status: STATUS.PASS,
5563
- actual: `${reconciliation.non_upsell_line_count} non-upsell line(s) reconciled against ${reconciliation.summary_package_ids.length} displayed package(s)`,
5633
+ actual: `${reconciliation.non_upsell_line_count} non-upsell line(s)${reconciliation.order_bump_line_count ? ` and ${reconciliation.order_bump_line_count} order bump line(s)` : ""} reconciled against ${reconciliation.summary_package_ids.length} displayed package(s)`,
5564
5634
  evidence: reconciliation,
5565
5635
  });
5566
5636
  }
@@ -6393,7 +6463,7 @@ function sanitizedEvents(events) {
6393
6463
  responses: events.responses.slice(-20).map((response) => ({
6394
6464
  status: response.status,
6395
6465
  url: response.url,
6396
- body: summarizeResponseBody(response.body),
6466
+ body: summarizeResponseBody(response.body, { status: response.status }),
6397
6467
  })),
6398
6468
  failed: events.failed.slice(-20),
6399
6469
  console: events.console.slice(-20),
@@ -6420,7 +6490,7 @@ function summarizeRequestPostData(value) {
6420
6490
  }
6421
6491
  }
6422
6492
 
6423
- function summarizeResponseBody(body) {
6493
+ function summarizeResponseBody(body, { status = null } = {}) {
6424
6494
  if (typeof body === "string") return trim(body).slice(0, 1000);
6425
6495
  if (!body || typeof body !== "object" || Array.isArray(body)) return body;
6426
6496
  return {
@@ -6432,6 +6502,12 @@ function summarizeResponseBody(body) {
6432
6502
  ...(body.checkout_url ? { checkout_url: body.checkout_url } : {}),
6433
6503
  ...(Array.isArray(body.lines) ? { lines: extractReceiptLines(body) } : {}),
6434
6504
  ...(body.detail ? { detail: body.detail } : {}),
6505
+ // A rejected request names its reason here (for example the
6506
+ // duplicate-order refusal). Kept only on an error response, so a
6507
+ // successful create never carries payment_details into evidence.
6508
+ ...(Number(status) >= 400 && typeof body.payment_details === "string"
6509
+ ? { payment_details: trim(body.payment_details).slice(0, 300) }
6510
+ : {}),
6435
6511
  };
6436
6512
  }
6437
6513
 
@@ -7789,6 +7865,8 @@ export const __qaBrowserTestHooks = Object.freeze({
7789
7865
  linePriceDeltaEvidence,
7790
7866
  packageMatchesLine,
7791
7867
  rejectedOrderCreateResponse,
7868
+ orderCreateRejectionMessage,
7869
+ summarizeResponseBody,
7792
7870
  failedOrderCreateRequest,
7793
7871
  extractOrderVouchers,
7794
7872
  orderDiscountTotal,
package/src/qa-node.mjs CHANGED
@@ -1,4 +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
3
  import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion, scriptParseAssertion } from './qa-binding-evidence.mjs';
3
4
  import { shellToken } from "./shell-token.mjs";
4
5
  import { applyQaBuildScope, specForQaScope } from "./qa-build-scope.mjs";
@@ -481,6 +482,8 @@ async function resolveQaInputs(args, {
481
482
  packetPath,
482
483
  report: checkpointPreflight?.runtimeReport,
483
484
  hiddenEagerMediaGate,
485
+ packet,
486
+ baseUrl: stringArg(args["base-url"]),
484
487
  });
485
488
  const qaWaivers = resolveQaWaivers({ packetPath, report: checkpointPreflight?.runtimeReport });
486
489
  const qaScope = applyQaBuildScope(topologies, {
@@ -593,7 +596,10 @@ function resolvePacketCheckpointPreflight(args, {
593
596
  waivers: report?.waivers,
594
597
  required: true,
595
598
  }),
596
- evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
599
+ applyLocalPreviewToCheckpoint(
600
+ evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
601
+ { packet, report, baseUrl: stringArg(args["base-url"]) },
602
+ ),
597
603
  ];
598
604
  const runtimeReport = reportMatchesPacketIdentity(report, packet) ? report : null;
599
605
  return {
@@ -692,6 +698,8 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
692
698
  packetPath: preflight.packetPath,
693
699
  report: preflight.runtimeReport,
694
700
  hiddenEagerMediaGate,
701
+ packet: preflight.packet,
702
+ baseUrl: stringArg(args["base-url"]),
695
703
  });
696
704
  return {
697
705
  themeGate,
@@ -931,16 +939,18 @@ function resolvePolishGate({
931
939
  packetPath,
932
940
  report: reportOverride = undefined,
933
941
  hiddenEagerMediaGate = undefined,
942
+ packet = null,
943
+ baseUrl = null,
934
944
  }) {
935
945
  const report = reportOverride === undefined
936
946
  ? loadRuntimeArtifact(packetPath, "assembly-report.json")
937
947
  : reportOverride;
938
- const gate = evaluatePolishGate({
948
+ const gate = applyLocalPreviewToPolishGate(evaluatePolishGate({
939
949
  report,
940
950
  required: true,
941
951
  hiddenEagerMediaGate,
942
952
  currentOutputFingerprint: currentBuiltOutputFingerprint(packetPath),
943
- });
953
+ }), { packet, checkpointGate: hiddenEagerMediaGate, baseUrl });
944
954
  gate.scope_source = report ? "assembly_report" : "missing_assembly_report";
945
955
  return gate;
946
956
  }
@@ -1044,6 +1054,18 @@ function polishGateAssertion(gate) {
1044
1054
  },
1045
1055
  });
1046
1056
  }
1057
+ if (gate.status === CARRIED_FORWARD) {
1058
+ return assertion({
1059
+ id: gate.code,
1060
+ family: "polish_gate",
1061
+ page,
1062
+ status: STATUS.WARN,
1063
+ severity: SEVERITY.WARN,
1064
+ expected: "current structured Polish evidence produced by next-campaigns-polish",
1065
+ actual: carriedForwardMessage(gate),
1066
+ evidence: { ...evidence, reason: gate.reason, carried_forward: gate.carried_forward },
1067
+ });
1068
+ }
1047
1069
  if (gate.status === "not_applicable") {
1048
1070
  return assertion({
1049
1071
  id: gate.code,
@@ -1350,6 +1372,9 @@ function hiddenEagerMediaGateAssertion(gate) {
1350
1372
  if (summary.status === "waived") {
1351
1373
  return assertion({ ...common, status: STATUS.WARN, severity: SEVERITY.WARN, waiver: summary.waiver });
1352
1374
  }
1375
+ if (summary.status === CARRIED_FORWARD) {
1376
+ return assertion({ ...common, status: STATUS.WARN, severity: SEVERITY.WARN, actual: `${summary.code}: ${carriedForwardMessage(summary)}` });
1377
+ }
1353
1378
  if (summary.status === "not_applicable") {
1354
1379
  return assertion({ ...common, status: STATUS.SKIPPED });
1355
1380
  }
@@ -2304,11 +2329,23 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
2304
2329
  });
2305
2330
  assertions.push(...liveCampaignRefAssertions({ pages: [...livePages.values()], spec: liveSpec, liveCampaign: liveRead }));
2306
2331
  if (args.browser === true) {
2307
- assertions.push(...await runBrowserChecks(resolved.topologies, args, {
2332
+ const browserAssertions = await runBrowserChecks(resolved.topologies, args, {
2308
2333
  brandContract: resolved.brandContract,
2309
- residueSeverity: residueSeverityForThemeGate(gate.status),
2334
+ // With no generatable brand theme on the local preview, the starter
2335
+ // template is the design: its residue is a warning (local-preview-policy.mjs).
2336
+ residueSeverity: starterResidueIsExpected(gate, { packet: resolved.packet, baseUrl: resolved.baseUrl })
2337
+ ? SEVERITY.WARN
2338
+ : residueSeverityForThemeGate(gate.status),
2310
2339
  supportedPaymentMethods: supportedPaymentMethodsFromSpec(resolved.spec),
2311
- }));
2340
+ bindingExpected,
2341
+ });
2342
+ // A page-binding row from the browser is the key the SDK actually sent;
2343
+ // it takes the place of that page's static read.
2344
+ for (const observed of browserAssertions) {
2345
+ const at = observed.id.startsWith("page-binding:") ? assertions.findIndex((entry) => entry.id === observed.id) : -1;
2346
+ if (at >= 0) assertions[at] = observed;
2347
+ else assertions.push(observed);
2348
+ }
2312
2349
  }
2313
2350
 
2314
2351
  const testOrders = await runAnalyticsOrderSequence({ args, resolved, runId, assertions });