@nextcommerce/campaigns-os 1.61.0 → 1.63.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/AGENTS.md +2 -1
- package/CHANGELOG.md +127 -0
- package/README.md +86 -438
- package/agents/claude/CLAUDE.md +9 -4
- package/agents/codex/AGENTS.md +8 -4
- package/agents/copilot/copilot-instructions.md +8 -4
- package/agents/cursor/campaigns-os.mdc +10 -4
- package/campaign-spec/README.md +7 -0
- package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
- package/campaign-spec/dist/analytics-vendors.js +44 -0
- package/campaign-spec/dist/checkout-flow.d.ts +18 -0
- package/campaign-spec/dist/checkout-flow.js +28 -0
- package/campaign-spec/dist/index.d.ts +4 -2
- package/campaign-spec/dist/index.js +5 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
- package/campaign-spec/dist/rules/campaign-currencies.d.ts +30 -0
- package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
- package/campaign-spec/dist/rules/index.d.ts +3 -0
- package/campaign-spec/dist/rules/index.js +9 -0
- package/campaign-spec/dist/rules/package-currency-prices.d.ts +29 -0
- package/campaign-spec/dist/rules/package-currency-prices.js +137 -0
- package/campaign-spec/dist/types.d.ts +59 -9
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +59 -53
- package/contracts/effects.v1.json +549 -165
- package/contracts/fixtures/runtime-recipe/accept/current.json +4 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +4 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +4 -0
- package/contracts/release-ledger.json +1260 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +4 -0
- package/contracts/supported-surface.json +8 -8
- package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
- package/docs/build-packet.md +430 -37
- package/docs/campaign-standardization-report.md +4 -4
- package/docs/campaigns-os-build-flow.md +54 -3
- package/docs/campaignspec-authoring-examples.md +59 -4
- package/docs/design-source-package.md +18 -6
- package/docs/effects.md +17 -9
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +2 -0
- package/docs/progress-snapshots.md +7 -4
- package/docs/qa-and-test-orders.md +147 -1
- package/docs/readback.md +40 -5
- package/docs/runtime-readiness.md +7 -3
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
- package/schemas/campaign-spec.v4.schema.json +34 -3
- package/schemas/campaign-spec.v5.schema.json +34 -3
- package/schemas/campaigns-os-readback.v2.schema.json +62 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- 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 +4 -3
- package/skills/next-campaigns-os/SKILL.md +6 -4
- package/skills/next-campaigns-os-setup/SKILL.md +5 -3
- package/skills/next-campaigns-polish/SKILL.md +5 -4
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
- package/skills.json +11 -11
- package/src/brand-theme.mjs +2 -1
- package/src/build-brief.mjs +4 -1
- package/src/campaign-workspace.mjs +288 -15
- package/src/change-scope.mjs +284 -0
- package/src/cli.mjs +308 -86
- package/src/commercial-journey.mjs +16 -3
- package/src/commercial-parity.mjs +15 -7
- package/src/content-residue.mjs +4 -2
- package/src/design-source-package.mjs +36 -0
- package/src/design-source-publication.mjs +9 -6
- package/src/directory-lock.mjs +73 -19
- package/src/doctor/checks.mjs +79 -7
- package/src/doctor/ci.mjs +75 -10
- package/src/doctor/inspect.mjs +23 -6
- package/src/doctor/next-step.mjs +7 -0
- package/src/doctor-sidecar.mjs +3 -2
- package/src/evidence-debt.mjs +47 -7
- package/src/finding-cause.mjs +12 -11
- package/src/input-refresh.mjs +339 -32
- package/src/invocation.mjs +7 -1
- package/src/live-campaign-refs.mjs +7 -2
- package/src/local-preview-policy.mjs +35 -2
- package/src/page-kit-analytics-vendors.mjs +132 -0
- package/src/polish-browser.mjs +3 -1
- package/src/polish-readability.mjs +221 -9
- package/src/polish-visual.mjs +9 -2
- package/src/progress-node.mjs +3 -2
- package/src/qa-analytics-parity.mjs +17 -1
- package/src/qa-browser.mjs +40 -4
- package/src/qa-commercial-parity.mjs +50 -3
- package/src/qa-node.mjs +196 -16
- package/src/qa-publish.mjs +9 -8
- package/src/qa-sidecar.mjs +16 -4
- package/src/qa-vendor-beacons.mjs +232 -0
- package/src/qa-vendor-endpoints.mjs +306 -0
- package/src/qc-accept.mjs +29 -1
- package/src/qc-check-registry.mjs +5 -1
- package/src/qc-results.mjs +73 -26
- package/src/readback.mjs +58 -10
- package/src/route-identity.mjs +10 -0
- package/src/run-record.mjs +18 -10
- package/src/runtime-state-ignore.mjs +6 -2
- package/src/sdk-storage-compatibility.mjs +21 -2
- package/src/source-html-intake.mjs +15 -1
- package/src/spec-derive-campaign.mjs +216 -0
- package/src/spec-derive.mjs +473 -53
- package/src/spec-identity.mjs +14 -0
- package/src/stage-ledger.mjs +40 -1
- package/src/stage-record.mjs +58 -13
- package/src/target-lock.mjs +11 -1
package/src/qa-browser.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { redactPersisted, redactUrlQueriesInText, redactUrlQuery } from "./qa-ur
|
|
|
19
19
|
import { TRACKING_ADDED_BOUND_MS, TRACKING_OBSERVATION, createTrackingRun, trackingQaAssertion } from "./qa-tracking-params.mjs";
|
|
20
20
|
import { CONTENT_PARAM_LIMITS, runContentParamChecks } from "./qa-content-params.mjs";
|
|
21
21
|
import { createPolicyLinkBudget, hasPolicyLinkFields, readPageAnchors, runPolicyLinkChecks } from "./qa-policy-links.mjs";
|
|
22
|
+
import { vendorBeaconLedgerFor, withVendorBeaconRouting } from "./qa-vendor-beacons.mjs";
|
|
22
23
|
import {
|
|
23
24
|
canonicalHttpUrl,
|
|
24
25
|
commonTestOrderPaths,
|
|
@@ -462,6 +463,8 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
462
463
|
if (displayParity) assertions.push(displayParity);
|
|
463
464
|
const totalParity = orderTotalParityAssertion(pageForPlan, identifier, result.order);
|
|
464
465
|
if (totalParity) assertions.push(totalParity);
|
|
466
|
+
const currencyMatch = orderCurrencyAssertion(pageForPlan, identifier, result.order, args);
|
|
467
|
+
if (currencyMatch) assertions.push(currencyMatch);
|
|
465
468
|
const renderedReceiptAssertion = receiptRenderingAssertion(pageForPlan, identifier, result.order);
|
|
466
469
|
if (renderedReceiptAssertion) assertions.push(renderedReceiptAssertion);
|
|
467
470
|
const dataLayerAssertion = purchaseDataLayerAssertion(pageForPlan, identifier, result.order);
|
|
@@ -4900,9 +4903,13 @@ export const QA_RUN_CURRENCY = Symbol("qa run --currency");
|
|
|
4900
4903
|
// checkout opened directly. Campaign Cart keeps a URL currency in session
|
|
4901
4904
|
// storage for the rest of the tab's session, so later loads in the tab are
|
|
4902
4905
|
// the plain URL; the receipt reload in recovery is not a path load at all.
|
|
4903
|
-
function
|
|
4906
|
+
function runCurrency(args) {
|
|
4904
4907
|
const code = args?.[QA_RUN_CURRENCY];
|
|
4905
|
-
|
|
4908
|
+
return typeof code === "string" && /^[A-Z]{3}$/.test(code) ? code : null;
|
|
4909
|
+
}
|
|
4910
|
+
|
|
4911
|
+
function pathCurrencyLoad(args) {
|
|
4912
|
+
let pending = runCurrency(args);
|
|
4906
4913
|
return (url) => {
|
|
4907
4914
|
if (!pending) return url;
|
|
4908
4915
|
const entered = withQueryParam(url, "currency", pending);
|
|
@@ -6638,6 +6645,31 @@ function orderTotalParityAssertion(page, planIdentifier, order) {
|
|
|
6638
6645
|
});
|
|
6639
6646
|
}
|
|
6640
6647
|
|
|
6648
|
+
// `qa run --currency <code>`: a created order must have been placed in the
|
|
6649
|
+
// run currency. Campaign Cart falls back to the default currency when the
|
|
6650
|
+
// requested one is not available, so an order recorded in any other currency
|
|
6651
|
+
// blocks, and so does a verified order whose currency was not recorded. No run
|
|
6652
|
+
// currency, or no order body read at all (the order's own row already says
|
|
6653
|
+
// why), gives no row.
|
|
6654
|
+
function orderCurrencyAssertion(page, planIdentifier, order, args) {
|
|
6655
|
+
const expected = runCurrency(args);
|
|
6656
|
+
if (!expected) return null;
|
|
6657
|
+
const recorded = stringArg(order?.verification?.currency);
|
|
6658
|
+
if (!recorded && order?.verification?.verified !== true) return null;
|
|
6659
|
+
const actual = recorded ? recorded.toUpperCase() : null;
|
|
6660
|
+
const ok = actual === expected;
|
|
6661
|
+
return assertion({
|
|
6662
|
+
id: `browser-order-currency:${planIdentifier}`,
|
|
6663
|
+
family: "browser-test-order",
|
|
6664
|
+
page,
|
|
6665
|
+
status: ok ? STATUS.PASS : STATUS.FAIL,
|
|
6666
|
+
severity: ok ? undefined : SEVERITY.BLOCKER,
|
|
6667
|
+
expected: `the order is placed in the run currency ${expected}`,
|
|
6668
|
+
actual: actual ? `order placed in ${actual}` : "the order's currency was not recorded",
|
|
6669
|
+
evidence: { requested: expected, order_currency: actual },
|
|
6670
|
+
});
|
|
6671
|
+
}
|
|
6672
|
+
|
|
6641
6673
|
// --- What a failed attempt did to the store ----------------------------------
|
|
6642
6674
|
//
|
|
6643
6675
|
// This replaces the old `shouldRetryTestOrder` boolean, which asked only whether
|
|
@@ -8873,8 +8905,11 @@ function trim(value) {
|
|
|
8873
8905
|
return String(value || "").replace(/\s+/g, " ").trim();
|
|
8874
8906
|
}
|
|
8875
8907
|
|
|
8876
|
-
|
|
8877
|
-
|
|
8908
|
+
// Every browser QA leg launches here, so on a test-order run every context it
|
|
8909
|
+
// opens answers the vendor beacon endpoints locally (qa-vendor-beacons.mjs).
|
|
8910
|
+
async function launchChromium(args) {
|
|
8911
|
+
const browser = await launchPackageChromium({ headed: args.headed, onMissing: qaBrowserMissing });
|
|
8912
|
+
return withVendorBeaconRouting(browser, vendorBeaconLedgerFor(args));
|
|
8878
8913
|
}
|
|
8879
8914
|
|
|
8880
8915
|
function qaBrowserMissing(kind, error) {
|
|
@@ -8893,6 +8928,7 @@ function qaBrowserMissing(kind, error) {
|
|
|
8893
8928
|
}
|
|
8894
8929
|
|
|
8895
8930
|
export const __qaBrowserTestHooks = Object.freeze({
|
|
8931
|
+
orderCurrencyAssertion,
|
|
8896
8932
|
qaBrowserMissing,
|
|
8897
8933
|
PRIMARY_CTA_SELECTOR,
|
|
8898
8934
|
COUPON_INPUT_SELECTORS,
|
|
@@ -379,7 +379,9 @@ export async function mapConcurrent(values, limit, operation) {
|
|
|
379
379
|
return results;
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
-
|
|
382
|
+
// `currency` is the run's currency (`qa run --currency`): every calculate
|
|
383
|
+
// request asks for it instead of the Map's campaign currency.
|
|
384
|
+
export function planCommercialParity(spec, { maxScenarios = COMMERCIAL_QA_LIMITS.max_scenarios, currency = null } = {}) {
|
|
383
385
|
const limit = positiveLimit(maxScenarios, COMMERCIAL_QA_LIMITS.max_scenarios);
|
|
384
386
|
const pages = commercialSpecPages(spec, { maxPages: limit + 1 });
|
|
385
387
|
const plan = [];
|
|
@@ -391,7 +393,7 @@ export function planCommercialParity(spec, { maxScenarios = COMMERCIAL_QA_LIMITS
|
|
|
391
393
|
break;
|
|
392
394
|
}
|
|
393
395
|
try {
|
|
394
|
-
plan.push(...planScenarios(page, spec, { maxScenarios: remaining }));
|
|
396
|
+
plan.push(...planScenarios(page, spec, { maxScenarios: remaining, currency }));
|
|
395
397
|
} catch (error) {
|
|
396
398
|
if (!(error instanceof CommercialJourneyLimitError)) throw error;
|
|
397
399
|
overflow = true;
|
|
@@ -502,6 +504,9 @@ function compactReport(report, journey, plan, executed, issues, observedClaims,
|
|
|
502
504
|
extracted_voucher_claims: report.extracted_voucher_claims,
|
|
503
505
|
compared_voucher_claims: report.compared_voucher_claims,
|
|
504
506
|
unresolved_voucher_claims: report.unresolved_voucher_claims,
|
|
507
|
+
...(present(report.unresolved_money_claims_reason)
|
|
508
|
+
? { unresolved_money_claims_reason: report.unresolved_money_claims_reason }
|
|
509
|
+
: {}),
|
|
505
510
|
serialized_assertion_count: report.serialized_assertion_count,
|
|
506
511
|
omitted_assertion_count: report.omitted_assertion_count,
|
|
507
512
|
recurring_claim_absences: array(report.recurring_claim_absences),
|
|
@@ -581,6 +586,31 @@ export function unavailableCommercialReport(code, { status = "not_run" } = {}) {
|
|
|
581
586
|
};
|
|
582
587
|
}
|
|
583
588
|
|
|
589
|
+
// One blocker row per currency the calculate endpoint answered in other than
|
|
590
|
+
// the run's: expected totals were asked for in `currency` and came back priced
|
|
591
|
+
// in another one. Answered codes are compared and named upper-cased, so `usd`
|
|
592
|
+
// and `USD` are one row. The rows are few (one per answered currency) and sit
|
|
593
|
+
// outside the assertion budget, so a full budget never hides the blocker.
|
|
594
|
+
function runCurrencyMismatchAssertions(journey, currency) {
|
|
595
|
+
const requested = String(currency).toUpperCase();
|
|
596
|
+
const answered = new Set();
|
|
597
|
+
array(journey?.warnings).forEach((warning) => {
|
|
598
|
+
if (warning?.type !== "currency_mismatch" || String(warning.requested).toUpperCase() !== requested) return;
|
|
599
|
+
const actual = String(warning.actual).toUpperCase();
|
|
600
|
+
if (actual !== requested) answered.add(actual);
|
|
601
|
+
});
|
|
602
|
+
return [...answered].map((actual) => ({
|
|
603
|
+
id: `commercial-currency:${requested}:${encodeURIComponent(actual)}`,
|
|
604
|
+
family: "pricing",
|
|
605
|
+
page: "campaign",
|
|
606
|
+
status: "fail",
|
|
607
|
+
severity: "blocker",
|
|
608
|
+
expected: `expected totals calculated in the run currency ${requested}`,
|
|
609
|
+
actual: `the calculate endpoint priced in ${actual}`,
|
|
610
|
+
evidence: { code: "currency_mismatch", requested, actual },
|
|
611
|
+
}));
|
|
612
|
+
}
|
|
613
|
+
|
|
584
614
|
/**
|
|
585
615
|
* Run the portable commercial planner against the public proxy contract and
|
|
586
616
|
* serialize proven mismatches into ordinary QA assertions.
|
|
@@ -592,6 +622,7 @@ export async function runCommercialParity({
|
|
|
592
622
|
limits: limitOverrides = {},
|
|
593
623
|
planning = null,
|
|
594
624
|
maxAssertions = Number.POSITIVE_INFINITY,
|
|
625
|
+
currency = null,
|
|
595
626
|
} = {}) {
|
|
596
627
|
const limits = {
|
|
597
628
|
...COMMERCIAL_QA_LIMITS,
|
|
@@ -603,6 +634,7 @@ export async function runCommercialParity({
|
|
|
603
634
|
const planningSpec = resolved?.rawSpec || resolved?.spec || {};
|
|
604
635
|
const commercialPlanning = planning || planCommercialParity(planningSpec, {
|
|
605
636
|
maxScenarios: limits.max_scenarios,
|
|
637
|
+
currency,
|
|
606
638
|
});
|
|
607
639
|
const plan = commercialPlanning.plan;
|
|
608
640
|
const scenarioOverflow = commercialPlanning.overflow || plan.length > limits.max_scenarios;
|
|
@@ -654,9 +686,24 @@ export async function runCommercialParity({
|
|
|
654
686
|
const parityJourney = scenarioOverflow
|
|
655
687
|
? withScenarioOverflowPages(journey, commercialPlanning.pages)
|
|
656
688
|
: journey;
|
|
689
|
+
// Under a run currency, a calculate response in another currency means the
|
|
690
|
+
// campaign does not price in it: a blocker, and the comparison below is
|
|
691
|
+
// against totals in the wrong currency, so the proof is incomplete. Without
|
|
692
|
+
// one, the journey's currency_mismatch warning stays where it is.
|
|
693
|
+
const currencyAssertions = present(currency) ? runCurrencyMismatchAssertions(journey, currency) : [];
|
|
694
|
+
currencyAssertions.forEach(() => issues.push({ code: "calculate_currency_mismatch" }));
|
|
695
|
+
// Captures read the page's source HTML, whose bound prices are the authored
|
|
696
|
+
// fallback text in the Map's campaign currency (the SDK replaces them at
|
|
697
|
+
// runtime). Under a run currency the Map does not name, those amounts cannot
|
|
698
|
+
// be compared with totals in the run currency: they stay unresolved, with
|
|
699
|
+
// the reason recorded, instead of reading as mismatches.
|
|
700
|
+
const sourceCurrency = planningSpec?.campaign?.currency ?? planningSpec?.currency;
|
|
701
|
+
const sourceNotInRunCurrency = present(currency)
|
|
702
|
+
&& String(sourceCurrency ?? "").toUpperCase() !== String(currency).toUpperCase();
|
|
657
703
|
const parity = createCommercialParityReport(captures, parityJourney, {
|
|
658
704
|
maxAssertions,
|
|
659
705
|
countsOnly: aggregateClaimOverflow,
|
|
706
|
+
...(sourceNotInRunCurrency ? { unresolvedMoneyClaims: "static_source_not_in_run_currency" } : {}),
|
|
660
707
|
});
|
|
661
708
|
// Subscription packages a page renders with no recurring claim read for
|
|
662
709
|
// them: incomplete evidence (an issue), and a warning naming the package.
|
|
@@ -669,7 +716,7 @@ export async function runCommercialParity({
|
|
|
669
716
|
});
|
|
670
717
|
parity.recurring_claim_absences = absences;
|
|
671
718
|
return {
|
|
672
|
-
assertions: [...parity.assertions, ...absentAssertions],
|
|
719
|
+
assertions: [...currencyAssertions, ...parity.assertions, ...absentAssertions],
|
|
673
720
|
commercial: compactReport(
|
|
674
721
|
parity,
|
|
675
722
|
parityJourney,
|
package/src/qa-node.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "./spec-source-identity.mjs";
|
|
2
|
-
import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD, ensureUrlTrailingSlash, normalizeQaBaseUrl, starterResidueIsExpected } from "./local-preview-policy.mjs";
|
|
2
|
+
import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, applyPageOnlyChangeToCheckpoint, applyPageOnlyChangeToPolishGate, carriedForwardMessage, CARRIED_FORWARD, ensureUrlTrailingSlash, isPageOnlyCarry, normalizeQaBaseUrl, starterResidueIsExpected } from "./local-preview-policy.mjs";
|
|
3
|
+
import { assessEvidenceDebt } from "./evidence-debt.mjs";
|
|
4
|
+
import { routePageIdForUrl } from "./change-scope.mjs";
|
|
3
5
|
import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion, scriptParseAssertion } from './qa-binding-evidence.mjs';
|
|
4
6
|
import { shellToken } from "./shell-token.mjs";
|
|
5
|
-
import { currentBriefMaterial } from "./input-currency.mjs";
|
|
7
|
+
import { currentBriefMaterial, deriveInputCurrency } from "./input-currency.mjs";
|
|
6
8
|
import { applyQaBuildScope, specForQaScope } from "./qa-build-scope.mjs";
|
|
7
9
|
import { requiredActionText } from "./gate-actions.mjs";
|
|
8
10
|
import { parseOrderPathDepthFlag } from "./proof-policy.mjs";
|
|
@@ -37,6 +39,7 @@ import { assessReceiptPurchase } from "./qa-analytics-correctness.mjs";
|
|
|
37
39
|
import { trackingQaAssertion, trackingRunScopeRows } from "./qa-tracking-params.mjs";
|
|
38
40
|
import { contentParamNotRequestedRows, contentParamQaAssertion } from "./qa-content-params.mjs";
|
|
39
41
|
import { policyLinkNotRequestedRows, policyLinkQaAssertion } from "./qa-policy-links.mjs";
|
|
42
|
+
import { createVendorBeaconLedger, QA_VENDOR_BEACONS, vendorBeaconAssertion, vendorBeaconMode } from "./qa-vendor-beacons.mjs";
|
|
40
43
|
import { createVerdict, formatTestOrderSummary, isFindingAssertion, QA_ASSERTION_FAMILY_VOCABULARY, SESSION_ENDING_DISPOSITIONS, SEVERITY, STATUS, validateVerdict } from "./qa-verdict.mjs";
|
|
41
44
|
import { normalizeSdkMetaName, lookupSdkIgnoredMetaTag } from "./sdk-meta-tags.mjs";
|
|
42
45
|
import { annotateQaAssertionCauses, formatCauseReportLines, formatCauseTag } from "./finding-cause.mjs";
|
|
@@ -78,12 +81,12 @@ import {
|
|
|
78
81
|
} from "./polish-capture.mjs";
|
|
79
82
|
import { resolveConsent } from "./consent.mjs";
|
|
80
83
|
import { markDoctorSidecarStale } from "./doctor-sidecar.mjs";
|
|
81
|
-
import { commitAssemblyReport } from "./stage-ledger.mjs";
|
|
84
|
+
import { commitAssemblyReport, qaExtendedBuildFingerprint } from "./stage-ledger.mjs";
|
|
82
85
|
// Tags a refusal raised before a qa handler runs, so the CLI's lifecycle
|
|
83
86
|
// persist step suppresses the journal append from one place. lifecycle.mjs
|
|
84
87
|
// imports nothing from this repository, so this cannot be circular.
|
|
85
88
|
import { refused, refusing } from "./lifecycle.mjs";
|
|
86
|
-
import { campaignSidecarPaths, explicitReportPath, resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
|
|
89
|
+
import { campaignSidecarPaths, explicitReportPath, packetRuntimeRelPath, resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
|
|
87
90
|
import { loadParityFixture } from "./qa-parity-fixture.mjs";
|
|
88
91
|
import { assessParityCapture, resolveParityScenario, runParityCapture } from "./qa-parity-capture.mjs";
|
|
89
92
|
import { loadPageKitCampaignEntry, PAGE_KIT_CAMPAIGNS_REL_PATH } from "./page-kit-campaign-config.mjs";
|
|
@@ -131,7 +134,7 @@ const HELP = `campaigns-os qa — Node/npm spec-aware QA
|
|
|
131
134
|
Usage:
|
|
132
135
|
campaigns-os qa parity --fixture <parity-fixture.json> --scenario <scenario-id> [--base-url <override>] [--baseline <url>] [--parity-order-json <file>] [--no-post-verdict]
|
|
133
136
|
campaigns-os qa resolve --packet <campaign-runtime.build.json> [--base-url <url>] [--no-probe] [--probe-timeout-ms <ms>] [--json]
|
|
134
|
-
campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--currency <code>] [--output-dir <dir>] [--no-remit] [--no-live-refs] [--json]
|
|
137
|
+
campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--currency <code>] [--page <page_id>]... [--output-dir <dir>] [--no-remit] [--no-live-refs] [--json]
|
|
135
138
|
campaigns-os qa policy set --packet <campaign-runtime.build.json> [--allowed-domains-confirmed true|false] [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--order-path-depth <off|common|full>] [--json]
|
|
136
139
|
campaigns-os qa waive --packet <campaign-runtime.build.json> --assertion analytics-correctness:purchase-fires --reason "<why>" [--waived-by <who>] [--report <assembly-report.json>] [--json]
|
|
137
140
|
campaigns-os qa promote --packet <campaign-runtime.build.json> --verdict <full-verdict.json> [--json] # project one explicit qa-output verdict to the committed .campaign-runtime/qa-verdict.json sidecar
|
|
@@ -147,6 +150,9 @@ Options:
|
|
|
147
150
|
--baseline <url> Optional live analytics baseline for qa parity.
|
|
148
151
|
--parity-order-json <path> Assessment-only saved order + analytics capture bundle; skips Playwright.
|
|
149
152
|
--packet <path> Read Map ID, local CampaignSpec, deploy URL, and QA metadata from a Build Packet.
|
|
153
|
+
--page <page_id> Check only this page; repeat for more pages. Pays the QA a page-only change
|
|
154
|
+
owes on those pages (doctor --ci and next name them). Packet QA only; places no
|
|
155
|
+
test orders, and refuses a change that touches commerce or leaves a changed page out.
|
|
150
156
|
--site <path> L7 non-packet QA: resolve scope (pages + funnel types) from a built page-kit _site/.
|
|
151
157
|
Requires --base-url and --family. No Map ID / CampaignSpec needed.
|
|
152
158
|
--spec <path> Local exported CampaignSpec JSON for the non-packet Map ID flow.
|
|
@@ -195,7 +201,10 @@ Options:
|
|
|
195
201
|
--currency <code> qa run: enter each funnel's entry page with ?currency=<CODE> (three letters, e.g. GBP),
|
|
196
202
|
keeping its existing query. The flag takes one code per run (given twice, the
|
|
197
203
|
last one is used); the verdict records it as evidence.currency on the entry
|
|
198
|
-
page's http:<page> row.
|
|
204
|
+
page's http:<page> row. Expected totals are calculated in the code, and a test
|
|
205
|
+
order or calculate response in another currency is a blocker (exit 4) on its
|
|
206
|
+
browser-order-currency:<path> or commercial-currency:<CODE>:<ANSWERED> row.
|
|
207
|
+
Not supported by qa parity or --legacy-api-test-order.
|
|
199
208
|
--auth-cookie <cookie> Cookie header for protected previews.
|
|
200
209
|
--browser Run Playwright-rendered browser checks after static Node checks.
|
|
201
210
|
Requires one-time setup: campaigns-os qa install-browser
|
|
@@ -229,6 +238,15 @@ Options:
|
|
|
229
238
|
--max-order-creations <n> Hard bound on REAL order creations in this run, reserved before each submit
|
|
230
239
|
click. Default: the planned path count. A path whose failure is confirmed to
|
|
231
240
|
have created an order is inspected read-only, never resubmitted.
|
|
241
|
+
--vendor-beacons live qa run --test-order: let the page's analytics vendor beacons reach the vendors.
|
|
242
|
+
By default a test-order run answers requests to the listed vendor beacon
|
|
243
|
+
endpoints (GA4, Meta, TikTok, Snap, Pinterest, Taboola, Triple Whale, AppLovin
|
|
244
|
+
Axon) locally in every browser context, so test purchases stay out of vendor
|
|
245
|
+
data; loader scripts still load. The verdict row browser-test-order:vendor-beacons
|
|
246
|
+
records the requests per vendor as evidence, never as purchase proof, and names
|
|
247
|
+
the vendors not intercepted (Northbeam; GTM, which sends nothing of its own).
|
|
248
|
+
live is the only value; any other is refused, as is the flag without
|
|
249
|
+
--test-order.
|
|
232
250
|
--allowed-domains-confirmed <bool>
|
|
233
251
|
qa policy set: persist non-localhost SDK-origin confirmation.
|
|
234
252
|
Localhost on any port is a global Development domain with analytics suppressed.
|
|
@@ -524,12 +542,25 @@ async function resolveQaInputs(args, {
|
|
|
524
542
|
hiddenEagerMediaGate,
|
|
525
543
|
packet,
|
|
526
544
|
baseUrl: stringArg(args["base-url"]),
|
|
545
|
+
evidenceDebt: checkpointPreflight?.evidenceDebt || null,
|
|
527
546
|
});
|
|
528
547
|
const qaWaivers = resolveQaWaivers({ packetPath, report: checkpointPreflight?.runtimeReport });
|
|
529
|
-
const
|
|
548
|
+
const buildScope = applyQaBuildScope(topologies, {
|
|
530
549
|
packet, report: checkpointPreflight?.runtimeReport,
|
|
531
550
|
targetRepo: checkpointPreflight?.targetRepo, publicRouteSlug,
|
|
532
551
|
});
|
|
552
|
+
const requestedPages = qaPageArgs(args);
|
|
553
|
+
const pageScope = applyQaPageScope(buildScope.topologies, {
|
|
554
|
+
pages: requestedPages,
|
|
555
|
+
baseUrl,
|
|
556
|
+
debt: checkpointPreflight?.evidenceDebt || null,
|
|
557
|
+
report: checkpointPreflight?.runtimeReport,
|
|
558
|
+
// Read against the spec this run judges, as doctor reads it.
|
|
559
|
+
qaInputCurrency: requestedPages
|
|
560
|
+
? deriveInputCurrency({ packet, packetPath, report: checkpointPreflight?.runtimeReport || null, spec: isPlainObject(rawSpec) ? rawSpec : null }).stages.qa
|
|
561
|
+
: undefined,
|
|
562
|
+
});
|
|
563
|
+
const qaScope = { ...buildScope, topologies: pageScope.topologies };
|
|
533
564
|
const brandContract = loadBrandContract(templateFamily);
|
|
534
565
|
const localServeAnalytics = resolveLocalServeAnalytics({
|
|
535
566
|
packet,
|
|
@@ -565,6 +596,7 @@ async function resolveQaInputs(args, {
|
|
|
565
596
|
commerceStructureContract,
|
|
566
597
|
topologies: qaScope.topologies,
|
|
567
598
|
excludedPages: qaScope.excludedPages,
|
|
599
|
+
pageScope: pageScope.scope,
|
|
568
600
|
checkpointGates: checkpointPreflight?.checkpointGates || nonPacketCheckpointGates(),
|
|
569
601
|
// The report the checkpoint gates were evaluated on, and the target repo
|
|
570
602
|
// whose default it may or may not be: what the printed remediation names.
|
|
@@ -637,13 +669,18 @@ function resolvePacketCheckpointPreflight(args, {
|
|
|
637
669
|
waivers: report?.waivers,
|
|
638
670
|
required: true,
|
|
639
671
|
}),
|
|
640
|
-
applyLocalPreviewToCheckpoint(
|
|
641
|
-
evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
|
|
642
|
-
{ packet, report, baseUrl: stringArg(args["base-url"]) },
|
|
643
|
-
),
|
|
644
672
|
];
|
|
645
673
|
const runtimeReport = reportMatchesPacketIdentity(report, packet) ? report : null;
|
|
674
|
+
// The evidence debt doctor and readback assess: a page-only change since
|
|
675
|
+
// Polish last reviewed the campaign makes Polish advisory (#720), and a
|
|
676
|
+
// page-scoped run (--page) pays the QA it owes.
|
|
677
|
+
const evidenceDebt = assessEvidenceDebt({ packet, packetPath, report: runtimeReport });
|
|
678
|
+
checkpointGates.push(applyPageOnlyChangeToCheckpoint(applyLocalPreviewToCheckpoint(
|
|
679
|
+
evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
|
|
680
|
+
{ packet, report, baseUrl: stringArg(args["base-url"]) },
|
|
681
|
+
), evidenceDebt));
|
|
646
682
|
return {
|
|
683
|
+
evidenceDebt,
|
|
647
684
|
packetPath,
|
|
648
685
|
packet,
|
|
649
686
|
specPath,
|
|
@@ -980,22 +1017,125 @@ function currentBuiltOutputFingerprint(packetPath) {
|
|
|
980
1017
|
}
|
|
981
1018
|
}
|
|
982
1019
|
|
|
1020
|
+
// qa run --page <page_id> (#720): repeat the flag or separate ids with
|
|
1021
|
+
// commas. Null when the run is not page-scoped.
|
|
1022
|
+
function qaPageArgs(args) {
|
|
1023
|
+
if (args.page === undefined) return null;
|
|
1024
|
+
const values = Array.isArray(args.page) ? args.page : [args.page];
|
|
1025
|
+
const ids = values.flatMap((value) => (typeof value === "string" ? value.split(",") : [""])).map((id) => id.trim());
|
|
1026
|
+
if (!ids.length || ids.some((id) => !id)) {
|
|
1027
|
+
throw refused("--page takes a page id: the id `doctor --ci` or `next` names for a changed page, or the CampaignSpec page id. Repeat --page for more pages.");
|
|
1028
|
+
}
|
|
1029
|
+
return [...new Set(ids)];
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// A page-scoped run checks pages, not purchases: it pays the QA a page-only
|
|
1033
|
+
// change owes, and a change that owes test orders owes a full run.
|
|
1034
|
+
function refuseBadPageScope(args) {
|
|
1035
|
+
if (qaPageArgs(args) === null) return;
|
|
1036
|
+
if (!stringArg(args.packet)) throw refused("--page needs --packet: a page-scoped run pays the QA a recorded build owes, which only a Build Packet's Assembly Report records.");
|
|
1037
|
+
for (const flag of ["test-order", "legacy-api-test-order"]) {
|
|
1038
|
+
if (args[flag] !== undefined && String(args[flag]).toLowerCase() !== "off") {
|
|
1039
|
+
throw refused(`--page cannot be combined with --${flag}: a page-scoped run checks the named pages and places no test orders. Run ${cmd("qa")} run without --page for purchase proof.`);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
if (args["vendor-beacons"] !== undefined) {
|
|
1043
|
+
throw refused("--page cannot be combined with --vendor-beacons: a page-scoped run places no test orders, so there are no vendor beacons to route. Drop --vendor-beacons.");
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Narrow the QA topology to the pages --page names, after checking that the
|
|
1048
|
+
// run can pay what QA owes: the output on disk is the recorded build, the
|
|
1049
|
+
// change since the build QA reviewed is page-only, and the named pages cover
|
|
1050
|
+
// every changed page. The run extends a completed QA, and QA's inputs (the
|
|
1051
|
+
// brief's qa_policy and the CampaignSpec) are current: `qaInputCurrency` is
|
|
1052
|
+
// derived.input_currency.stages.qa, and a QA an input change owes, or whose
|
|
1053
|
+
// inputs cannot be confirmed, owes a full run. Re-running a page-scoped
|
|
1054
|
+
// record for the same build is allowed too. Each requested id is a topology
|
|
1055
|
+
// page id or the page id of its route (the id the change lists use).
|
|
1056
|
+
export function applyQaPageScope(topologies, { pages, baseUrl, debt, report, qaInputCurrency = undefined } = {}) {
|
|
1057
|
+
if (!pages) return { topologies, scope: null };
|
|
1058
|
+
const all = topologies.flatMap((topology) => topology.pages || []);
|
|
1059
|
+
const idsOf = (page) => [String(page.page_id), page.url && baseUrl ? routePageIdForUrl(page.url, baseUrl) : null].filter(Boolean);
|
|
1060
|
+
const known = new Set(all.flatMap(idsOf));
|
|
1061
|
+
const unknown = pages.filter((id) => !known.has(id));
|
|
1062
|
+
if (unknown.length) {
|
|
1063
|
+
throw new Error(`qa run --page: ${unknown.map((id) => JSON.stringify(id)).join(", ")} is not a page of this campaign's QA topology. Pages: ${[...new Set(all.map((page) => String(page.page_id)))].join(", ") || "none"}.`);
|
|
1064
|
+
}
|
|
1065
|
+
const selected = new Set(pages);
|
|
1066
|
+
const isSelected = (page) => idsOf(page).some((id) => selected.has(id));
|
|
1067
|
+
const selectedRouteIds = new Set(all.filter(isSelected).flatMap(idsOf));
|
|
1068
|
+
const qa = debt?.qa;
|
|
1069
|
+
const changes = debt?.changes;
|
|
1070
|
+
if (!qa || !changes?.current_build_fingerprint) {
|
|
1071
|
+
throw new Error("qa run --page needs the Assembly Report and the built output under _site/<public_route_slug>/ to read what QA owes; run it without --page.");
|
|
1072
|
+
}
|
|
1073
|
+
if (changes.recorded_build_fingerprint !== changes.current_build_fingerprint) {
|
|
1074
|
+
throw new Error(`qa run --page: the built output is not the recorded build; run ${cmd("record")} build --packet <packet> first, so the page-scoped verdict is bound to the output it checked.`);
|
|
1075
|
+
}
|
|
1076
|
+
const extended = qaExtendedBuildFingerprint(report);
|
|
1077
|
+
const previousScope = report?.stages?.qa?.evidence?.page_scope;
|
|
1078
|
+
let base = qa.change?.since_build_fingerprint || qa.recorded_build_fingerprint;
|
|
1079
|
+
if (qa.status === "current") {
|
|
1080
|
+
const previousPages = Array.isArray(previousScope?.owed_pages) ? previousScope.owed_pages : null;
|
|
1081
|
+
if (!previousPages || !previousPages.every((id) => selectedRouteIds.has(id))) {
|
|
1082
|
+
throw new Error(`qa run --page: QA is already recorded for this build. Run ${cmd("qa")} run without --page to re-run it in full.`);
|
|
1083
|
+
}
|
|
1084
|
+
base = previousScope.base_build_fingerprint || base;
|
|
1085
|
+
} else if (qa.status !== "owed" || qa.scope !== "pages") {
|
|
1086
|
+
const kind = qa.change?.kind === "commerce_affecting" ? "commerce-affecting"
|
|
1087
|
+
: qa.status === "owed" && qa.change?.kind === "page_only"
|
|
1088
|
+
? extended ? "page-only, but QA is required again by an input change" : "page-only, but the QA recorded before it did not complete"
|
|
1089
|
+
: "not classified as page-only";
|
|
1090
|
+
throw new Error(`qa run --page: the change since the build QA reviewed is ${kind}${(qa.change?.reasons || []).length ? ` (${[...new Set(qa.change.reasons.map((reason) => reason.code))].join(", ")})` : ""}, so it owes a full QA run. Run ${cmd("qa")} run without --page.`);
|
|
1091
|
+
} else {
|
|
1092
|
+
const uncovered = qa.owed_pages.filter((page) => !selectedRouteIds.has(page.page_id));
|
|
1093
|
+
if (uncovered.length) {
|
|
1094
|
+
throw new Error(`qa run --page: QA is owed on ${qa.owed_pages.map((page) => page.page_id).join(", ")}; add ${uncovered.map((page) => `--page ${page.page_id}`).join(" ")}.`);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
if (qaInputCurrency === "owed" || qaInputCurrency === "unknown") {
|
|
1098
|
+
throw new Error(`qa run --page: ${qaInputCurrency === "owed" ? "an input change (the brief's qa_policy or the CampaignSpec) owes QA again" : "QA's inputs (the brief and the CampaignSpec) cannot be confirmed current"}, so it owes a full QA run. Run ${cmd("qa")} run without --page.`);
|
|
1099
|
+
}
|
|
1100
|
+
if (!extended) {
|
|
1101
|
+
throw new Error(`qa run --page: the QA recorded before this change did not complete${report?.stages?.qa?.status ? ` (status ${JSON.stringify(String(report.stages.qa.status))})` : ""}, so there is no passed QA to extend and it owes a full QA run. Run ${cmd("qa")} run without --page.`);
|
|
1102
|
+
}
|
|
1103
|
+
const excluded = new Map();
|
|
1104
|
+
const scoped = topologies.map((topology) => {
|
|
1105
|
+
const kept = (topology.pages || []).filter(isSelected);
|
|
1106
|
+
for (const page of topology.pages || []) if (!isSelected(page)) excluded.set(String(page.page_id), page);
|
|
1107
|
+
return kept.length === (topology.pages || []).length ? topology : { ...topology, partial_build_scope: true, pages: kept };
|
|
1108
|
+
}).filter((topology) => (topology.pages || []).length);
|
|
1109
|
+
const owedPages = qa.status === "current" ? previousScope.owed_pages : qa.owed_pages.map((page) => page.page_id);
|
|
1110
|
+
return {
|
|
1111
|
+
topologies: scoped,
|
|
1112
|
+
scope: {
|
|
1113
|
+
pages: [...new Set(all.filter(isSelected).map((page) => String(page.page_id)))],
|
|
1114
|
+
owed_pages: owedPages,
|
|
1115
|
+
base_build_fingerprint: base,
|
|
1116
|
+
build_fingerprint: changes.current_build_fingerprint,
|
|
1117
|
+
excluded: [...excluded.values()].filter((page) => !all.some((other) => isSelected(other) && String(other.page_id) === String(page.page_id))),
|
|
1118
|
+
},
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
|
|
983
1122
|
function resolvePolishGate({
|
|
984
1123
|
packetPath,
|
|
985
1124
|
report: reportOverride = undefined,
|
|
986
1125
|
hiddenEagerMediaGate = undefined,
|
|
987
1126
|
packet = null,
|
|
988
1127
|
baseUrl = null,
|
|
1128
|
+
evidenceDebt = null,
|
|
989
1129
|
}) {
|
|
990
1130
|
const report = reportOverride === undefined
|
|
991
1131
|
? loadRuntimeArtifact(packetPath, "assembly-report.json")
|
|
992
1132
|
: reportOverride;
|
|
993
|
-
const gate = applyLocalPreviewToPolishGate(evaluatePolishGate({
|
|
1133
|
+
const gate = applyPageOnlyChangeToPolishGate(applyLocalPreviewToPolishGate(evaluatePolishGate({
|
|
994
1134
|
report,
|
|
995
1135
|
required: true,
|
|
996
1136
|
hiddenEagerMediaGate,
|
|
997
1137
|
currentOutputFingerprint: currentBuiltOutputFingerprint(packetPath),
|
|
998
|
-
}), { packet, checkpointGate: hiddenEagerMediaGate, baseUrl });
|
|
1138
|
+
}), { packet, checkpointGate: hiddenEagerMediaGate, baseUrl }), { debt: evidenceDebt, checkpointGate: hiddenEagerMediaGate });
|
|
999
1139
|
gate.scope_source = report ? "assembly_report" : "missing_assembly_report";
|
|
1000
1140
|
return gate;
|
|
1001
1141
|
}
|
|
@@ -1101,6 +1241,19 @@ function polishGateAssertion(gate) {
|
|
|
1101
1241
|
},
|
|
1102
1242
|
});
|
|
1103
1243
|
}
|
|
1244
|
+
if (isPageOnlyCarry(gate)) {
|
|
1245
|
+
// Advisory for a page-only change (#720): reported, not a warning that
|
|
1246
|
+
// would hold the verdict to ready_with_exceptions.
|
|
1247
|
+
return assertion({
|
|
1248
|
+
id: gate.code,
|
|
1249
|
+
family: "polish_gate",
|
|
1250
|
+
page,
|
|
1251
|
+
status: STATUS.SKIPPED,
|
|
1252
|
+
expected: "current structured Polish evidence, or a page-only change since Polish last reviewed the campaign",
|
|
1253
|
+
actual: carriedForwardMessage(gate),
|
|
1254
|
+
evidence: { ...evidence, reason: gate.reason, carried_forward: gate.carried_forward },
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1104
1257
|
if (gate.status === CARRIED_FORWARD) {
|
|
1105
1258
|
return assertion({
|
|
1106
1259
|
id: gate.code,
|
|
@@ -1419,6 +1572,9 @@ function hiddenEagerMediaGateAssertion(gate) {
|
|
|
1419
1572
|
if (summary.status === "waived") {
|
|
1420
1573
|
return assertion({ ...common, status: STATUS.WARN, severity: SEVERITY.WARN, waiver: summary.waiver });
|
|
1421
1574
|
}
|
|
1575
|
+
if (isPageOnlyCarry(summary)) {
|
|
1576
|
+
return assertion({ ...common, status: STATUS.SKIPPED, actual: `${summary.code}: ${carriedForwardMessage(summary)}` });
|
|
1577
|
+
}
|
|
1422
1578
|
if (summary.status === CARRIED_FORWARD) {
|
|
1423
1579
|
return assertion({ ...common, status: STATUS.WARN, severity: SEVERITY.WARN, actual: `${summary.code}: ${carriedForwardMessage(summary)}` });
|
|
1424
1580
|
}
|
|
@@ -1624,7 +1780,7 @@ function serializeThrownValue(error) {
|
|
|
1624
1780
|
// `derived.assembly_report_path` handling — so the printed remediations can
|
|
1625
1781
|
// name it and a default-report campaign's output is unchanged.
|
|
1626
1782
|
function reportPathField(resolved) {
|
|
1627
|
-
const reportPath = explicitReportPath(resolved?.reportPath, resolved?.targetRepo);
|
|
1783
|
+
const reportPath = explicitReportPath(resolved?.reportPath, resolved?.targetRepo, packetRuntimeRelPath(resolved?.packet));
|
|
1628
1784
|
return reportPath ? { report_path: reportPath } : {};
|
|
1629
1785
|
}
|
|
1630
1786
|
|
|
@@ -1919,6 +2075,7 @@ function updateQaPolicy(args) {
|
|
|
1919
2075
|
// #171: packet edits change what doctor would conclude; the retained
|
|
1920
2076
|
// doctor sidecar (if any) now predates them.
|
|
1921
2077
|
markDoctorSidecarStale(targetRepoFor(packetPath, packet), {
|
|
2078
|
+
path: campaignSidecarPaths(targetRepoFor(packetPath, packet), packetRuntimeRelPath(packet)).doctorOutPath,
|
|
1922
2079
|
command: "qa policy set",
|
|
1923
2080
|
reason: staleReason,
|
|
1924
2081
|
});
|
|
@@ -2285,6 +2442,7 @@ async function runQa(args, options = {}) {
|
|
|
2285
2442
|
// Fail-fast before anything resolves or launches. The authoritative check
|
|
2286
2443
|
// lives on the creation budget itself, which every browser path builds.
|
|
2287
2444
|
refuseBadOrderCreationLimit(args);
|
|
2445
|
+
refuseBadPageScope(args);
|
|
2288
2446
|
warnCurrencyWithoutTestCountry(args);
|
|
2289
2447
|
// Match dispatch: an active browser mode takes precedence over legacy API
|
|
2290
2448
|
// diagnostics. Only the selected legacy path requires a cart and API mode.
|
|
@@ -2293,6 +2451,7 @@ async function runQa(args, options = {}) {
|
|
|
2293
2451
|
if (browserMode === "off" && legacyMode !== "off") {
|
|
2294
2452
|
refusing(() => legacyTestOrderInputs({ ...args, "test-order": legacyMode }));
|
|
2295
2453
|
}
|
|
2454
|
+
vendorBeaconMode(args);
|
|
2296
2455
|
const resolved = await resolveQaInputs(args);
|
|
2297
2456
|
return runResolvedQa(args, resolved, options);
|
|
2298
2457
|
}
|
|
@@ -2385,12 +2544,20 @@ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, l
|
|
|
2385
2544
|
actual: "out_of_build_scope",
|
|
2386
2545
|
evidence: { reason: "out_of_build_scope" },
|
|
2387
2546
|
})),
|
|
2547
|
+
...(resolved.pageScope?.excluded || []).map(page => assertion({
|
|
2548
|
+
id: `page-scope:${page.page_id}`, family: "funnel-flow", page,
|
|
2549
|
+
status: STATUS.SKIPPED,
|
|
2550
|
+
expected: "A page-scoped run (--page) checks only the named pages",
|
|
2551
|
+
actual: "outside_page_scope",
|
|
2552
|
+
evidence: { reason: "outside_page_scope" },
|
|
2553
|
+
})),
|
|
2388
2554
|
...(polishGate?.owned_checkpoint_only ? [] : [polishGateAssertion(polishGate)]),
|
|
2389
2555
|
themeGateAssertion(gate),
|
|
2390
2556
|
];
|
|
2391
2557
|
const contractAssertion = templateBrandContractAssertion(resolved);
|
|
2392
2558
|
if (contractAssertion) assertions.push(contractAssertion);
|
|
2393
|
-
|
|
2559
|
+
// Under --currency the expected totals are calculated in the run currency.
|
|
2560
|
+
const commercialPlanning = planCommercialParity(specForQaScope(resolved.rawSpec || resolved.spec, [...(resolved.excludedPages || []), ...(resolved.pageScope?.excluded || [])]), { currency });
|
|
2394
2561
|
const sourceLoader = createPageSourceLoader({ authCookie: args["auth-cookie"] });
|
|
2395
2562
|
const commercialIds = new Set(commercialPlanning.pages
|
|
2396
2563
|
.filter((page) => page?.id !== undefined && page?.id !== null)
|
|
@@ -2435,6 +2602,12 @@ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, l
|
|
|
2435
2602
|
proxyBase: resolved.proxyBase,
|
|
2436
2603
|
});
|
|
2437
2604
|
assertions.push(...liveCampaignRefAssertions({ pages: [...livePages.values()], spec: liveSpec, liveCampaign: liveRead }));
|
|
2605
|
+
// A test-order run answers vendor beacons locally in every browser context
|
|
2606
|
+
// it opens, unless --vendor-beacons live; the ledger rides on args to each
|
|
2607
|
+
// browser leg and becomes one evidence row (qa-vendor-beacons.mjs).
|
|
2608
|
+
const vendorBeaconRun = vendorBeaconMode(args);
|
|
2609
|
+
const vendorBeacons = vendorBeaconRun ? createVendorBeaconLedger({ mode: vendorBeaconRun, analytics: resolved.spec?.analytics }) : null;
|
|
2610
|
+
if (vendorBeacons) args = { ...args, [QA_VENDOR_BEACONS]: vendorBeacons };
|
|
2438
2611
|
const qcResults = [];
|
|
2439
2612
|
if (args.browser === true) {
|
|
2440
2613
|
const browserAssertions = await runBrowserChecks(resolved.topologies, args, {
|
|
@@ -2470,6 +2643,7 @@ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, l
|
|
|
2470
2643
|
}
|
|
2471
2644
|
|
|
2472
2645
|
const testOrders = await runAnalyticsOrderSequence({ args, resolved, runId, assertions, qcResults });
|
|
2646
|
+
if (vendorBeacons) assertions.push(vendorBeaconAssertion(vendorBeacons));
|
|
2473
2647
|
const remainingAssertionBudget = Math.max(0, QA_VERDICT_ASSERTION_LIMIT - assertions.length);
|
|
2474
2648
|
let commercialResult;
|
|
2475
2649
|
try {
|
|
@@ -2478,6 +2652,7 @@ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, l
|
|
|
2478
2652
|
captures: [...capturesByPageId.values()],
|
|
2479
2653
|
planning: commercialPlanning,
|
|
2480
2654
|
maxAssertions: remainingAssertionBudget,
|
|
2655
|
+
currency,
|
|
2481
2656
|
});
|
|
2482
2657
|
} catch (error) {
|
|
2483
2658
|
commercialResult = {
|
|
@@ -2692,6 +2867,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2692
2867
|
// `external:qa_verdict` whenever that is not the packet directory.
|
|
2693
2868
|
const causeSummary = annotateQaAssertionCauses(assertions, {
|
|
2694
2869
|
baseDir: resolved.packetPath ? dirname(resolved.packetPath) : null,
|
|
2870
|
+
runtimeRelPath: resolved.packetPath ? packetRuntimeRelPath(resolved.packet) : undefined,
|
|
2695
2871
|
targetRepo: resolved.packetPath ? targetRepoFor(resolved.packetPath, resolved.packet) : null,
|
|
2696
2872
|
mapId: resolved.mapId,
|
|
2697
2873
|
localSpecId: resolved.localSpecId,
|
|
@@ -2807,6 +2983,9 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2807
2983
|
// The QC rows the QA stage records; readers re-derive them from the qc.*
|
|
2808
2984
|
// assertions of the verdict written above.
|
|
2809
2985
|
qc_results: qcResults,
|
|
2986
|
+
// qa run --page: the pages this run checked and the change it pays for,
|
|
2987
|
+
// which the QA stage record keeps (cli.mjs recordQaStageOutcome).
|
|
2988
|
+
...(resolved.pageScope ? { page_scope: resolved.pageScope } : {}),
|
|
2810
2989
|
verdict,
|
|
2811
2990
|
};
|
|
2812
2991
|
}
|
|
@@ -2824,7 +3003,8 @@ function analyticsCaptureScope(resolved) {
|
|
|
2824
3003
|
const rootUrl = rootCaptureUrl(resolved?.analyticsCaptureTarget?.url);
|
|
2825
3004
|
const topologies = topologyList(resolved?.topologies);
|
|
2826
3005
|
const partial = topologies.some((topology) => topology?.partial_build_scope)
|
|
2827
|
-
|| (Array.isArray(resolved?.excludedPages) && resolved.excludedPages.length > 0)
|
|
3006
|
+
|| (Array.isArray(resolved?.excludedPages) && resolved.excludedPages.length > 0)
|
|
3007
|
+
|| Boolean(resolved?.pageScope?.excluded?.length);
|
|
2828
3008
|
const rootInScope = !rootUrl || !partial || topologies.some((topology) =>
|
|
2829
3009
|
(Array.isArray(topology?.pages) ? topology.pages : []).some((page) =>
|
|
2830
3010
|
typeof page?.url === "string" && isSameAnalyticsCapturePage(page.url, rootUrl)));
|
package/src/qa-publish.mjs
CHANGED
|
@@ -19,9 +19,9 @@ import { createHash } from "node:crypto";
|
|
|
19
19
|
import { existsSync, readFileSync } from "node:fs";
|
|
20
20
|
import { basename, dirname, join, resolve } from "node:path";
|
|
21
21
|
|
|
22
|
-
import { campaignSidecarPaths, targetRepoFor } from "./campaign-workspace.mjs";
|
|
22
|
+
import { campaignSidecarPaths, packetRuntimeRelPath, targetRepoFor } from "./campaign-workspace.mjs";
|
|
23
23
|
import { refused } from "./lifecycle.mjs";
|
|
24
|
-
import {
|
|
24
|
+
import { sidecarPathForPacket } from "./qa-sidecar.mjs";
|
|
25
25
|
import { qaVerdictIdentityMatch } from "./qa-verdict-discovery.mjs";
|
|
26
26
|
import { publishQaVerdict, qaPortalUrl, qaVerdictPublishBlock, QA_VERDICT_PUBLISH_ENDPOINT, QA_VERDICT_PUBLISHERS } from "./qa-verdict-publish.mjs";
|
|
27
27
|
import { validateVerdict } from "./qa-verdict.mjs";
|
|
@@ -108,9 +108,9 @@ export function resolveStoredVerdictSource({ args, packetPath, packet, readJsonF
|
|
|
108
108
|
if (explicit) {
|
|
109
109
|
const path = resolve(explicit);
|
|
110
110
|
if (!exists(path)) return { error: refusal(QA_PUBLISH_REFUSALS.verdict_missing, `No verdict file at ${path}.`) };
|
|
111
|
-
return { path, source_kind: path === sidecarPath(packetPath) ? "sidecar_projection" : "explicit" };
|
|
111
|
+
return { path, source_kind: path === sidecarPath(packetPath, packet) ? "sidecar_projection" : "explicit" };
|
|
112
112
|
}
|
|
113
|
-
const sidecar = sidecarPath(packetPath);
|
|
113
|
+
const sidecar = sidecarPath(packetPath, packet);
|
|
114
114
|
if (!exists(sidecar)) {
|
|
115
115
|
return {
|
|
116
116
|
error: refusal(
|
|
@@ -139,8 +139,8 @@ export function resolveStoredVerdictSource({ args, packetPath, packet, readJsonF
|
|
|
139
139
|
return { path: sidecar, source_kind: "sidecar_projection", sidecar_path: sidecar };
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
function sidecarPath(packetPath) {
|
|
143
|
-
return
|
|
142
|
+
function sidecarPath(packetPath, packet) {
|
|
143
|
+
return sidecarPathForPacket(packetPath, packet);
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
/**
|
|
@@ -282,7 +282,8 @@ async function attemptPublish(args, operations, dryRun) {
|
|
|
282
282
|
} catch {
|
|
283
283
|
verdictDigest = null;
|
|
284
284
|
}
|
|
285
|
-
const
|
|
285
|
+
const runtimeRelPath = packetRuntimeRelPath(packet);
|
|
286
|
+
const records = ops.readRecords(baseDir, runtimeRelPath);
|
|
286
287
|
const recordEntry = findRunRecordForVerdict({ records, packet, verdictRunId, verdictDigest });
|
|
287
288
|
const priorBlock = recordEntry?.record?.qa_verdict_publish?.verdict_run_id === verdictRunId ? recordEntry.record.qa_verdict_publish : null;
|
|
288
289
|
const republish = args.republish === true;
|
|
@@ -377,7 +378,7 @@ async function attemptPublish(args, operations, dryRun) {
|
|
|
377
378
|
} else {
|
|
378
379
|
try {
|
|
379
380
|
const record = { ...recordEntry.record, qa_verdict_publish: stamped };
|
|
380
|
-
const path = ops.writeRecord(record, { baseDir });
|
|
381
|
+
const path = ops.writeRecord(record, { baseDir, runtimeRelPath });
|
|
381
382
|
runRecord = { ...recordSummary({ path, record }, stamped), written: true, preserved: false };
|
|
382
383
|
} catch (error) {
|
|
383
384
|
runRecord = { ...recordSummary(recordEntry, priorBlock), written: false, preserved: false, error: singleLineFragment(error.message) };
|