@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.
- package/CHANGELOG.md +124 -0
- package/CONTEXT.md +11 -0
- package/campaign-spec/README.md +15 -1
- package/campaign-spec/dist/checkout-flow.d.ts +97 -0
- package/campaign-spec/dist/checkout-flow.js +245 -0
- package/campaign-spec/dist/index.d.ts +1 -0
- package/campaign-spec/dist/index.js +6 -1
- package/campaign-spec/dist/normalize.d.ts +3 -2
- package/campaign-spec/dist/normalize.js +7 -5
- package/campaign-spec/dist/routing.d.ts +6 -3
- package/campaign-spec/dist/routing.js +6 -3
- package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
- package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
- package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
- package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
- package/campaign-spec/dist/rules/index.d.ts +4 -0
- package/campaign-spec/dist/rules/index.js +12 -0
- package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
- package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
- package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
- package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
- package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
- package/campaign-spec/dist/rules/schema-version.js +10 -5
- package/campaign-spec/dist/types.d.ts +20 -3
- package/campaign-spec/dist/types.js +3 -2
- package/compatibility.json +2 -2
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +26 -27
- package/contracts/effects.v1.json +5 -5
- package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
- package/contracts/release-ledger.json +1137 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
- package/contracts/supported-surface.json +9 -5
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/build-packet.md +68 -10
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +124 -22
- package/docs/runtime-readiness.md +8 -3
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -1
- package/docs/versioning.md +4 -1
- package/package.json +2 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +4 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -1
- package/schemas/campaign-spec.v5.schema.json +586 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +6 -4
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +21 -6
- package/skills/next-campaigns-qa/SKILL.md +4 -3
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/build-brief.mjs +14 -5
- package/src/built-site-scope.mjs +91 -0
- package/src/cli-helpers.mjs +129 -0
- package/src/cli.mjs +117 -57
- package/src/commercial-journey.mjs +25 -2
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +7 -7
- package/src/doctor/checks.mjs +269 -16
- package/src/doctor/inspect.mjs +4 -2
- package/src/doctor/next-step.mjs +18 -9
- package/src/doctor-sidecar.mjs +5 -3
- package/src/gate-actions.mjs +21 -1
- package/src/input-refresh.mjs +6 -6
- package/src/local-preview-policy.mjs +110 -12
- package/src/polish-node.mjs +8 -7
- package/src/qa-browser.mjs +363 -36
- package/src/qa-cart-entry.mjs +183 -14
- package/src/qa-node.mjs +38 -34
- package/src/qa-test-order-topology.mjs +118 -1
- package/src/qa-verdict-discovery.mjs +21 -4
- package/src/qa-verdict.mjs +24 -1
- package/src/runtime-state-ignore.mjs +7 -5
- package/src/sdk-attribute-index.mjs +8 -7
- package/src/source-html-intake.mjs +58 -7
- package/src/stage-ledger.mjs +4 -2
- package/src/stage-record.mjs +203 -36
package/src/qa-cart-entry.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
238
|
-
// add_to_cart
|
|
239
|
-
//
|
|
240
|
-
// checkout_link
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
?
|
|
336
|
-
:
|
|
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
|
|
490
|
+
const targetRepo = checkpointPreflight?.targetRepo || (packetPath && packet ? targetRepoFor(packetPath, packet) : null);
|
|
491
|
+
const targetOutputDir = targetRepo && packet?.assembly?.output_dir ? resolve(targetRepo, packet.assembly.output_dir) : null;
|
|
492
|
+
const mappingById = new Map((packet?.source_html?.pages || []).map((page) => [page.page_id, page]));
|
|
493
|
+
const topologies = extractTopologies(normalized, {
|
|
494
|
+
baseUrl, publicRouteSlug, templateFamily, commerceStructureContract,
|
|
495
|
+
entryServingRoute: (page) => entryPageKitServingRoute(targetRepo, publicRouteSlug, page, {
|
|
496
|
+
targetOutputDir,
|
|
497
|
+
mapping: mappingById.get(page.id),
|
|
498
|
+
}),
|
|
499
|
+
});
|
|
488
500
|
const themeGate = resolveThemeGate({
|
|
489
501
|
packetPath,
|
|
490
502
|
topologies,
|
|
@@ -971,7 +983,9 @@ function resolvePolishGate({
|
|
|
971
983
|
return gate;
|
|
972
984
|
}
|
|
973
985
|
|
|
974
|
-
|
|
986
|
+
// select and checkout_step pages render the family's checkout surface without
|
|
987
|
+
// taking payment (campaigns-os#641); the brand theme applies to them as well.
|
|
988
|
+
const THEME_GATE_COMMERCE_TYPES = new Set(["select", "checkout_step", "checkout", "upsell", "downsell", "receipt", "thankyou"]);
|
|
975
989
|
|
|
976
990
|
function themeGateScopeFromTopologies(topologies = []) {
|
|
977
991
|
const built_pages = [];
|
|
@@ -3234,7 +3248,10 @@ async function maybeRunLegacyApiTestOrders({ args, resolved, runId, assertions }
|
|
|
3234
3248
|
const apiBase = stringArg(args["campaigns-api-base"]) || process.env.CAMPAIGNS_API_BASE;
|
|
3235
3249
|
if (!apiKey || !apiBase) throw new Error("Legacy direct API test orders require --api-key/QA_CAMPAIGNS_API_KEY and --campaigns-api-base/CAMPAIGNS_API_BASE.");
|
|
3236
3250
|
const { cart, paths } = legacyTestOrderInputs(args);
|
|
3237
|
-
|
|
3251
|
+
// The Checkout on the tested path, never the first page typed checkout: on a
|
|
3252
|
+
// split checkout that was the first step, whose forward link is the next
|
|
3253
|
+
// step, so the order's success URL pointed back into checkout (#641).
|
|
3254
|
+
const checkout = findPaymentPage(resolved.topologies);
|
|
3238
3255
|
if (!checkout?.url) throw new Error("--test-order requires a checkout page URL.");
|
|
3239
3256
|
const upsell = findPage(resolved.topologies, "upsell");
|
|
3240
3257
|
const orders = [];
|
|
@@ -3318,22 +3335,25 @@ async function createTestOrder({ apiBase, apiKey, cart, runId, successUrl, spec,
|
|
|
3318
3335
|
}
|
|
3319
3336
|
}
|
|
3320
3337
|
|
|
3338
|
+
// QA reads the v5 page vocabulary: a v4 spec's `checkout → checkout` chain is
|
|
3339
|
+
// retyped `checkout_step` on read (campaigns-os#641). The raw spec is untouched,
|
|
3340
|
+
// so the spec hash still covers the bytes as written.
|
|
3321
3341
|
function normalizeSpec(raw) {
|
|
3322
|
-
if (Array.isArray(raw?.funnels)) return raw;
|
|
3342
|
+
if (Array.isArray(raw?.funnels)) return upgradeCampaignSpec(raw);
|
|
3323
3343
|
if (Array.isArray(raw?.funnel_pages)) {
|
|
3324
3344
|
return { ...raw, funnels: [{ id: "default", name: "Default", weight: 100, pages: raw.funnel_pages }] };
|
|
3325
3345
|
}
|
|
3326
3346
|
return { ...raw, funnels: [] };
|
|
3327
3347
|
}
|
|
3328
3348
|
|
|
3329
|
-
function extractTopologies(spec, { baseUrl = null, publicRouteSlug = null, templateFamily = null, commerceStructureContract = null } = {}) {
|
|
3349
|
+
function extractTopologies(spec, { baseUrl = null, publicRouteSlug = null, templateFamily = null, commerceStructureContract = null, entryServingRoute = null } = {}) {
|
|
3330
3350
|
const pageById = new Map();
|
|
3331
3351
|
for (const funnel of spec.funnels || []) {
|
|
3332
3352
|
for (const page of funnel.pages || []) pageById.set(page.id, page);
|
|
3333
3353
|
}
|
|
3334
3354
|
const urlById = new Map();
|
|
3335
3355
|
for (const [id, page] of pageById) {
|
|
3336
|
-
urlById.set(id, resolvePageUrl(page, baseUrl, publicRouteSlug));
|
|
3356
|
+
urlById.set(id, resolvePageUrl(page, baseUrl, publicRouteSlug, entryServingRoute?.(page)));
|
|
3337
3357
|
}
|
|
3338
3358
|
return (spec.funnels || []).map((funnel) => ({
|
|
3339
3359
|
funnel_id: funnel.id || "default",
|
|
@@ -3388,13 +3408,13 @@ function extractTopologies(spec, { baseUrl = null, publicRouteSlug = null, templ
|
|
|
3388
3408
|
}));
|
|
3389
3409
|
}
|
|
3390
3410
|
|
|
3391
|
-
function resolvePageUrl(page, baseUrl, publicRouteSlug = null) {
|
|
3411
|
+
function resolvePageUrl(page, baseUrl, publicRouteSlug = null, entryServingRoute = null) {
|
|
3392
3412
|
if (typeof page.url === "string" && page.url.trim()) return page.url.trim();
|
|
3393
3413
|
if (!baseUrl) return null;
|
|
3394
|
-
const route =
|
|
3395
|
-
?
|
|
3396
|
-
: page.
|
|
3397
|
-
?
|
|
3414
|
+
const route = isRootRoutedEntryPage(page, publicRouteSlug)
|
|
3415
|
+
? entryServingRoute?.route || ""
|
|
3416
|
+
: typeof page.page_url === "string" && page.page_url.trim()
|
|
3417
|
+
? runtimeRelativeRouteForSpecValue(page.page_url, publicRouteSlug)
|
|
3398
3418
|
: defaultRouteForType(page.type);
|
|
3399
3419
|
if (isAbsoluteHttpUrl(route)) return route;
|
|
3400
3420
|
try {
|
|
@@ -3406,6 +3426,7 @@ function resolvePageUrl(page, baseUrl, publicRouteSlug = null) {
|
|
|
3406
3426
|
|
|
3407
3427
|
function defaultRouteForType(type) {
|
|
3408
3428
|
if (type === "thankyou") return "receipt/";
|
|
3429
|
+
if (type === "checkout_step") return "checkout-step/";
|
|
3409
3430
|
if (["presell", "landing", "checkout", "upsell", "downsell"].includes(type)) return `${type}/`;
|
|
3410
3431
|
return `${type || "page"}/`;
|
|
3411
3432
|
}
|
|
@@ -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
|
-
|
|
121
|
+
// A caller may hand in a select or checkout_step page; the path's Checkout is
|
|
122
|
+
// the page whose forward link leads to the offers. Starting from a step made
|
|
123
|
+
// its next step a `nonterminal_target` and hid every offer path.
|
|
124
|
+
const given = checkoutPage && !isPaymentTopologyPage(checkoutPage) && PRE_PAYMENT_PAGE_TYPES.has(pageType(checkoutPage))
|
|
125
|
+
? paymentPageFrom(topology, checkoutPage) || checkoutPage
|
|
126
|
+
: checkoutPage;
|
|
127
|
+
const checkout = given || paymentPagesForTopology(topology)[0] || null;
|
|
11
128
|
const pagesByUrl = new Map();
|
|
12
129
|
for (const page of pages) {
|
|
13
130
|
const key = canonicalHttpUrl(page?.url);
|
|
@@ -13,7 +13,7 @@ import { resolveCampaignIdentity, localQaIdentifier } from "./spec-source-identi
|
|
|
13
13
|
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
15
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
16
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
16
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
17
17
|
import { QA_OUTPUT_REL_PATH } from "./campaign-workspace.mjs";
|
|
18
18
|
import { absentOrMalformed } from "./fs-identity.mjs";
|
|
19
19
|
import { isPlainObject as isObject, normalizeString as optionalString } from "./repo-scan.mjs";
|
|
@@ -53,7 +53,8 @@ export function qaVerdictDir(root, identifier) {
|
|
|
53
53
|
|
|
54
54
|
// The verdict paths the Assembly Report's qa stage records (any `*.json`
|
|
55
55
|
// string under a path/file/verdict/output-named key, or inside outputs[] /
|
|
56
|
-
// artifacts[] / qa), relative to the
|
|
56
|
+
// artifacts[] / qa), relative to the target root unless absolute. Older
|
|
57
|
+
// report-relative hints remain readable.
|
|
57
58
|
export function qaVerdictPathHints(report) {
|
|
58
59
|
const paths = [];
|
|
59
60
|
const visit = (value) => {
|
|
@@ -90,7 +91,8 @@ function sha256File(path) {
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
// Every verdict candidate for a campaign: the files the report records
|
|
93
|
-
// (source "assembly_report", resolved against the report
|
|
94
|
+
// (source "assembly_report", resolved against the report first, then the target)
|
|
95
|
+
// and every `*.json`
|
|
94
96
|
// under `qa-output/<identifier>/` beneath each root (source "qa_output").
|
|
95
97
|
// { path, repoRelPath, source, verdict, sha256, mtimeMs, identityMatch, trusted }
|
|
96
98
|
// `verdict` is null for a recorded path that exists but is not a JSON object
|
|
@@ -141,8 +143,14 @@ export function* iterateQaVerdicts({ packet = null, report = null, reportPath =
|
|
|
141
143
|
};
|
|
142
144
|
|
|
143
145
|
const reportBase = reportPath ? dirname(resolve(reportPath)) : null;
|
|
146
|
+
const targetRoot = uniqueTargetRoot(roots, reportBase);
|
|
144
147
|
for (const hint of qaVerdictPathHints(report)) {
|
|
145
|
-
const
|
|
148
|
+
const reportRelative = (isAbsolute(hint) || reportBase)
|
|
149
|
+
? candidate(isAbsolute(hint) ? hint : resolve(reportBase, hint), "assembly_report", null)
|
|
150
|
+
: null;
|
|
151
|
+
const found = reportRelative || (!isAbsolute(hint) && targetRoot
|
|
152
|
+
? candidate(resolve(targetRoot, hint), "assembly_report", null)
|
|
153
|
+
: null);
|
|
146
154
|
if (found) yield found;
|
|
147
155
|
}
|
|
148
156
|
|
|
@@ -172,6 +180,15 @@ export function* iterateQaVerdicts({ packet = null, report = null, reportPath =
|
|
|
172
180
|
}
|
|
173
181
|
}
|
|
174
182
|
|
|
183
|
+
function uniqueTargetRoot(roots, reportBase) {
|
|
184
|
+
for (let directory = reportBase; directory; directory = dirname(directory)) {
|
|
185
|
+
if (basename(directory) === ".campaign-runtime") return dirname(directory);
|
|
186
|
+
if (dirname(directory) === directory) break;
|
|
187
|
+
}
|
|
188
|
+
const candidates = [...new Set(roots.filter((root) => typeof root === "string" && root).map((root) => resolve(root)))];
|
|
189
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
175
192
|
export function discoverQaVerdicts(options = {}) {
|
|
176
193
|
return [...iterateQaVerdicts(options)];
|
|
177
194
|
}
|