@nextcommerce/campaigns-os 1.41.2 → 1.43.2
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 +4 -2
- package/CHANGELOG.md +629 -0
- package/README.md +8 -6
- package/agents/claude/CLAUDE.md +5 -1
- package/campaign-spec/dist/types.d.ts +2 -0
- package/contracts/agent-relevant-change-policy.v1.json +5 -0
- package/contracts/effects.v1.json +118 -25
- package/contracts/migration-sidecar-bundle.v0.json +9 -0
- package/contracts/release-ledger.json +1424 -0
- package/contracts/supported-surface.json +12 -11
- package/docs/build-packet.md +120 -8
- package/docs/campaigns-os-build-flow.md +3 -2
- package/docs/design-source-package.md +89 -15
- package/docs/effects.md +83 -2
- package/docs/local-setup.md +51 -0
- package/docs/migration-sidecar-bundle.md +6 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/progress-snapshots.md +16 -6
- package/docs/qa-and-test-orders.md +157 -17
- package/docs/release-ledger-authoring-guide.md +6 -4
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +3 -2
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +6 -1
- package/schemas/campaign-spec.v4.schema.json +4 -0
- package/schemas/campaigns-os-progress-snapshot.v0.schema.json +1 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +1 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +1 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +13 -8
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +8 -6
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +4 -4
- package/skills/next-campaigns-os/SKILL.md +17 -4
- package/skills/next-campaigns-os/references/session-intake.md +4 -4
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +10 -9
- package/skills.json +11 -11
- package/src/build-brief.mjs +6 -4
- package/src/built-script-syntax.mjs +480 -0
- package/src/campaigns-api-key.mjs +99 -0
- package/src/cli-helpers.mjs +118 -0
- package/src/cli.mjs +796 -6963
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +898 -0
- package/src/diagnostic.mjs +2 -1
- package/src/directory-lock.mjs +270 -0
- package/src/doctor/checks.mjs +4415 -0
- package/src/doctor/inspect.mjs +636 -0
- package/src/doctor/next-step.mjs +731 -0
- package/src/finding-cause.mjs +14 -10
- package/src/install-invocation.mjs +29 -0
- package/src/invocation.mjs +179 -0
- package/src/lifecycle.mjs +5 -4
- package/src/polish-node.mjs +5 -2
- package/src/private-template-source.mjs +1 -1
- package/src/progress-node.mjs +9 -37
- package/src/progress.mjs +5 -3
- package/src/proof-policy.mjs +1 -1
- package/src/qa-analytics-correctness.mjs +3 -0
- package/src/qa-binding-evidence.mjs +76 -11
- package/src/qa-browser.mjs +778 -77
- package/src/qa-build-scope.mjs +47 -0
- package/src/qa-node.mjs +276 -39
- package/src/qa-publish.mjs +4 -0
- package/src/qa-sidecar.mjs +2 -0
- package/src/qa-verdict-discovery.mjs +11 -0
- package/src/qa-verdict-publish.mjs +1 -0
- package/src/qa-verdict.mjs +8 -1
- package/src/readback.mjs +2 -1
- package/src/run-record-closeout.mjs +3 -4
- package/src/run-record.mjs +4 -0
- package/src/sidecar-bundle.mjs +21 -0
- package/src/source-html-intake.mjs +1 -1
- package/src/source-html-manifest.mjs +9 -2
- package/src/spec-source-identity.mjs +44 -0
- package/src/stage-ledger.mjs +32 -1
- package/src/target-lock.mjs +54 -0
- package/src/template-brand-contract.mjs +17 -1
- package/src/tooling-setup.mjs +160 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { resolveBuiltSiteScope } from "./built-site-scope.mjs";
|
|
2
|
+
import { runtimeRelativeRouteForSpecValue } from "./route-identity.mjs";
|
|
3
|
+
|
|
4
|
+
// Use the recorded declaration, not a doctor's cached derived scope: failed
|
|
5
|
+
// intake also writes skip_reason mappings, which must never hide missing pages.
|
|
6
|
+
export function applyQaBuildScope(topologies, { packet, report, targetRepo, publicRouteSlug } = {}) {
|
|
7
|
+
const declared = report?.stages?.prepare_build?.declared_out_of_scope;
|
|
8
|
+
if (!Array.isArray(declared) || !packet) return { topologies, excludedPages: [] };
|
|
9
|
+
const declaredIds = new Set(declared.map(page => page?.page_id));
|
|
10
|
+
const skippedIds = new Set((packet.source_html?.pages || [])
|
|
11
|
+
.filter(page => declaredIds.has(page.page_id) && !page.path && typeof page.skip_reason === "string" && page.skip_reason.trim())
|
|
12
|
+
.map(page => page.page_id));
|
|
13
|
+
if (!skippedIds.size) return { topologies, excludedPages: [] };
|
|
14
|
+
const built = targetRepo ? resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug }) : null;
|
|
15
|
+
const normalizeRoute = route => runtimeRelativeRouteForSpecValue(route, publicRouteSlug).replace(/^\/+|\/+$/g, "").replace(/(?:^|\/)index\.html$/, "").replace(/\/$/, "");
|
|
16
|
+
const builtRoutes = new Set((built?.pages || []).map(page => normalizeRoute(page.route)));
|
|
17
|
+
const excludedPages = [];
|
|
18
|
+
const scoped = topologies.map(topology => ({
|
|
19
|
+
...topology,
|
|
20
|
+
partial_build_scope: topology.pages.some(page => skippedIds.has(page.page_id)),
|
|
21
|
+
pages: topology.pages.filter(page => {
|
|
22
|
+
if (!skippedIds.has(page.page_id)) return true;
|
|
23
|
+
// Without a resolved URL we cannot prove which output file represents
|
|
24
|
+
// this page. Keep it in QA so unresolved/materialized pages are not
|
|
25
|
+
// silently hidden as unbuilt declarations.
|
|
26
|
+
if (!page.url) return true;
|
|
27
|
+
// An explicitly materialized stock page rejoins QA. A declaration alone
|
|
28
|
+
// is not an instruction to build it, nor proof that it exists.
|
|
29
|
+
let route;
|
|
30
|
+
try { route = new URL(page.url).pathname; } catch { return true; }
|
|
31
|
+
if (builtRoutes.has(normalizeRoute(route))) return true;
|
|
32
|
+
excludedPages.push(page);
|
|
33
|
+
return false;
|
|
34
|
+
}),
|
|
35
|
+
}));
|
|
36
|
+
return { topologies: scoped, excludedPages };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function specForQaScope(spec, excludedPages = []) {
|
|
40
|
+
const excluded = new Set(excludedPages.map(page => page.page_id));
|
|
41
|
+
if (Array.isArray(spec.funnel_pages) && !spec.funnels?.length) {
|
|
42
|
+
return { ...spec, funnel_pages: spec.funnel_pages.filter(page => !excluded.has(page.id)) };
|
|
43
|
+
}
|
|
44
|
+
return { ...spec, funnels: (spec.funnels || []).map(funnel => ({
|
|
45
|
+
...funnel, pages: (funnel.pages || []).filter(page => !excluded.has(page.id)),
|
|
46
|
+
})) };
|
|
47
|
+
}
|
package/src/qa-node.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "./spec-source-identity.mjs";
|
|
2
|
+
import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion, scriptParseAssertion } from './qa-binding-evidence.mjs';
|
|
2
3
|
import { shellToken } from "./shell-token.mjs";
|
|
4
|
+
import { applyQaBuildScope, specForQaScope } from "./qa-build-scope.mjs";
|
|
3
5
|
import { requiredActionText } from "./gate-actions.mjs";
|
|
4
6
|
import { parseOrderPathDepthFlag } from "./proof-policy.mjs";
|
|
5
7
|
import {
|
|
@@ -13,7 +15,7 @@ import {
|
|
|
13
15
|
import { singleLineFragment } from "./text-safety.mjs";
|
|
14
16
|
import { absentOrMalformed } from "./fs-identity.mjs";
|
|
15
17
|
import { DEFAULT_PROXY_BASE, fetchSpecByMapId } from "./spec-fetch.mjs";
|
|
16
|
-
import { specMaterialHash } from "./spec-identity.mjs";
|
|
18
|
+
import { specMaterialHash, specHashesMatch } from "./spec-identity.mjs";
|
|
17
19
|
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
18
20
|
import { PLAYWRIGHT_INSTALL_HINT } from "./browser-launch.mjs";
|
|
19
21
|
import { dirname, join, relative, resolve, isAbsolute } from "node:path";
|
|
@@ -28,13 +30,22 @@ const PACKAGE_ROOT = installModeResolve(installModeDirname(installModeFileUrl(im
|
|
|
28
30
|
function cmd(verb, rest = "") {
|
|
29
31
|
return `${invocationPrefixFor(PACKAGE_ROOT)} ${verb}${rest ? ` ${rest}` : ""}`;
|
|
30
32
|
}
|
|
31
|
-
import { runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, validatedOrderCreationLimit } from "./qa-browser.mjs";
|
|
33
|
+
import { isSameAnalyticsCapturePage, runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, validatedOrderCreationLimit } from "./qa-browser.mjs";
|
|
32
34
|
import { assessReceiptPurchase } from "./qa-analytics-correctness.mjs";
|
|
33
35
|
import { createVerdict, isFindingAssertion, QA_ASSERTION_FAMILY_VOCABULARY, SESSION_ENDING_DISPOSITIONS, SEVERITY, STATUS, validateVerdict } from "./qa-verdict.mjs";
|
|
34
36
|
import { normalizeSdkMetaName, lookupSdkIgnoredMetaTag } from "./sdk-meta-tags.mjs";
|
|
35
37
|
import { annotateQaAssertionCauses, formatCauseReportLines, formatCauseTag } from "./finding-cause.mjs";
|
|
36
38
|
import { promoteQaVerdict, writeQaSidecar } from "./qa-sidecar.mjs";
|
|
37
39
|
import { publishQaVerdict, qaPortalUrl, qaVerdictPublishBlock, QA_VERDICT_PUBLISHERS, skippedQaVerdictPublish } from "./qa-verdict-publish.mjs";
|
|
40
|
+
import {
|
|
41
|
+
isLocalServePacket,
|
|
42
|
+
LOCAL_PROOF_BUILD_ENVIRONMENT,
|
|
43
|
+
LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD,
|
|
44
|
+
LOCAL_PROOF_PARITY_FIELD,
|
|
45
|
+
recordedBuildEnvironment,
|
|
46
|
+
recordedProductionParity,
|
|
47
|
+
} from "./local-proof.mjs";
|
|
48
|
+
import { isLoopbackHostname } from "./remit.mjs";
|
|
38
49
|
import { publishStoredVerdict, qaPublishTextLines, QA_PUBLISH_EXIT_CODES } from "./qa-publish.mjs";
|
|
39
50
|
// Shared outgoing-edge resolver, so QA expectations and build-time wiring
|
|
40
51
|
// cannot drift on which declared routing field wins.
|
|
@@ -345,9 +356,20 @@ async function resolveQaInputs(args, {
|
|
|
345
356
|
readJsonFile = readJson,
|
|
346
357
|
loadCampaignEntry = loadPageKitCampaignEntry,
|
|
347
358
|
} = {}) {
|
|
359
|
+
// Selector flags without a non-empty value are argv-only refusals for both
|
|
360
|
+
// qa run and qa resolve. Check them before checkpoint preflight or site reads.
|
|
361
|
+
for (const flag of ["packet", "site", "built", "map-id"]) {
|
|
362
|
+
if (Object.hasOwn(args, flag) && !stringArg(args[flag])) {
|
|
363
|
+
throw refused(`Missing value for --${flag}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
348
366
|
if (args.packet && args.spec) {
|
|
349
367
|
throw refused("Packet QA does not accept --spec; it always uses packet.spec.local_path.");
|
|
350
368
|
}
|
|
369
|
+
// No non-empty campaign selector in argv is also decided before any read.
|
|
370
|
+
if (![args.packet, args.site, args.built, args._[2], args["map-id"]].some(stringArg)) {
|
|
371
|
+
throw refused("QA requires a Map ID. Provide --packet or positional <map-id>.");
|
|
372
|
+
}
|
|
351
373
|
// Non-packet mode (learnings L7): QA a `campaign-build`'d page-kit campaign
|
|
352
374
|
// that has only a built _site/ and a served URL — no Build Packet, no Map ID,
|
|
353
375
|
// no CampaignSpec. Scope (pages + funnel types) is resolved from the built
|
|
@@ -371,15 +393,13 @@ async function resolveQaInputs(args, {
|
|
|
371
393
|
const mapId = stringArg(args["map-id"])
|
|
372
394
|
|| stringArg(args._[2])
|
|
373
395
|
|| stringArg(packet?.spec?.map_id);
|
|
374
|
-
// A
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
// for the same identity question, and one message cannot be two verdicts.)
|
|
382
|
-
if (!mapId) throw refused("QA requires a Map ID. Provide --packet or positional <map-id>.");
|
|
396
|
+
// A named packet has been read by checkpoint preflight. Its missing or
|
|
397
|
+
// conflicting identity is a handler failure, not an argv-only refusal.
|
|
398
|
+
const localSpecId = packet?.spec?.local_spec_id ?? null;
|
|
399
|
+
if (!mapId && !localSpecId) throw new Error("QA requires a Map ID or a local-spec packet. The named Build Packet has no campaign identity.");
|
|
400
|
+
if (packet && localSpecId != null && (!resolveCampaignIdentity(packet.spec) || mapId)) {
|
|
401
|
+
throw new Error("Local-spec packet QA cannot use a Map ID override or an ambiguous identity.");
|
|
402
|
+
}
|
|
383
403
|
|
|
384
404
|
const proxyBase = stringArg(args["proxy-base"]) || DEFAULT_PROXY_BASE;
|
|
385
405
|
const inputBaseUrl = normalizeBaseUrl(stringArg(args["base-url"]) || packet?.deploy?.preview_url || packet?.deploy?.production_url || null);
|
|
@@ -404,6 +424,13 @@ async function resolveQaInputs(args, {
|
|
|
404
424
|
specSource = `${proxyBase.replace(/\/+$/, "")}/api/spec/${encodeURIComponent(mapId)}`;
|
|
405
425
|
}
|
|
406
426
|
|
|
427
|
+
if (!packet && rawSpec?.spec_identity?.local_spec_id != null) {
|
|
428
|
+
throw new Error("Local-spec QA requires --packet; a local spec cannot be published under a Map ID.");
|
|
429
|
+
}
|
|
430
|
+
if (packet && (localSpecId != null || rawSpec?.spec_identity?.local_spec_id != null)
|
|
431
|
+
&& !campaignIdentitiesMatch(packet.spec, campaignSpecIdentity(rawSpec))) {
|
|
432
|
+
throw new Error("Packet local_spec_id does not match the local CampaignSpec identity. Re-run prepare-build from the intended spec.");
|
|
433
|
+
}
|
|
407
434
|
const normalized = normalizeSpec(rawSpec);
|
|
408
435
|
const publicRouteSlug = resolvePublicRouteSlug({ packet, spec: normalized, rawSpec });
|
|
409
436
|
const baseUrl = normalizeQaBaseUrl(inputBaseUrl, publicRouteSlug);
|
|
@@ -438,17 +465,28 @@ async function resolveQaInputs(args, {
|
|
|
438
465
|
hiddenEagerMediaGate,
|
|
439
466
|
});
|
|
440
467
|
const qaWaivers = resolveQaWaivers({ packetPath, report: checkpointPreflight?.runtimeReport });
|
|
468
|
+
const qaScope = applyQaBuildScope(topologies, {
|
|
469
|
+
packet, report: checkpointPreflight?.runtimeReport,
|
|
470
|
+
targetRepo: checkpointPreflight?.targetRepo, publicRouteSlug,
|
|
471
|
+
});
|
|
441
472
|
const brandContract = loadBrandContract(templateFamily);
|
|
473
|
+
const localServeAnalytics = resolveLocalServeAnalytics({
|
|
474
|
+
packet,
|
|
475
|
+
report: checkpointPreflight?.runtimeReport,
|
|
476
|
+
captureUrl: analyticsCaptureTarget.url || baseUrl,
|
|
477
|
+
});
|
|
442
478
|
return {
|
|
443
479
|
themeGate,
|
|
444
480
|
polishGate,
|
|
445
481
|
qaWaivers,
|
|
446
482
|
analyticsCaptureTarget,
|
|
483
|
+
localServeAnalytics,
|
|
447
484
|
brandContract: brandContract.contract,
|
|
448
485
|
brandContractStatus: brandContract.status,
|
|
449
486
|
packetPath,
|
|
450
487
|
packet,
|
|
451
488
|
mapId,
|
|
489
|
+
localSpecId,
|
|
452
490
|
publicRouteSlug,
|
|
453
491
|
proxyBase,
|
|
454
492
|
baseUrl,
|
|
@@ -463,7 +501,8 @@ async function resolveQaInputs(args, {
|
|
|
463
501
|
specHash,
|
|
464
502
|
templateFamily,
|
|
465
503
|
commerceStructureContract,
|
|
466
|
-
topologies,
|
|
504
|
+
topologies: qaScope.topologies,
|
|
505
|
+
excludedPages: qaScope.excludedPages,
|
|
467
506
|
checkpointGates: checkpointPreflight?.checkpointGates || nonPacketCheckpointGates(),
|
|
468
507
|
// The report the checkpoint gates were evaluated on, and the target repo
|
|
469
508
|
// whose default it may or may not be: what the printed remediation names.
|
|
@@ -478,6 +517,10 @@ function resolvePacketCheckpointPreflight(args, {
|
|
|
478
517
|
} = {}) {
|
|
479
518
|
const packetPath = resolve(String(args.packet));
|
|
480
519
|
const packet = readJsonFile(packetPath);
|
|
520
|
+
if (packet?.spec?.local_spec_id != null && (!resolveCampaignIdentity(packet.spec)
|
|
521
|
+
|| stringArg(args["map-id"]) || stringArg(args._?.[2]))) {
|
|
522
|
+
throw new Error("Local-spec packet QA cannot use a Map ID override or an ambiguous identity.");
|
|
523
|
+
}
|
|
481
524
|
const specPath = stringArg(args.spec)
|
|
482
525
|
? resolve(String(args.spec))
|
|
483
526
|
: stringArg(packet?.spec?.local_path)
|
|
@@ -513,6 +556,10 @@ function resolvePacketCheckpointPreflight(args, {
|
|
|
513
556
|
report = null;
|
|
514
557
|
}
|
|
515
558
|
}
|
|
559
|
+
if (packet?.spec?.local_spec_id != null && (!reportMatchesPacketIdentity(report, packet)
|
|
560
|
+
|| (specStatus === "ok" && !specHashesMatch(report.identity?.spec_material_hash, computeSpecHash(rawSpec))))) {
|
|
561
|
+
throw new Error("Local-spec QA requires the matching Assembly Report and current spec material hash. Re-run prepare-build after a material revision; do not reuse foreign or stale proof.");
|
|
562
|
+
}
|
|
516
563
|
const checkpointGates = [
|
|
517
564
|
evaluatePageKitStoreProfile({
|
|
518
565
|
specCampaign: rawSpec?.campaign || null,
|
|
@@ -548,12 +595,9 @@ function resolvePacketCheckpointPreflight(args, {
|
|
|
548
595
|
|
|
549
596
|
function reportMatchesPacketIdentity(report, packet) {
|
|
550
597
|
if (!isPlainObject(report) || !isPlainObject(report.identity)) return false;
|
|
551
|
-
const packetMapId = stringArg(packet?.spec?.map_id);
|
|
552
|
-
const reportMapId = stringArg(report.identity.map_id);
|
|
553
598
|
const packetSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
554
599
|
const reportSlug = normalizePublicRouteSlug(report.identity.public_route_slug);
|
|
555
|
-
return
|
|
556
|
-
&& packetMapId === reportMapId
|
|
600
|
+
return campaignIdentitiesMatch(packet?.spec, report.identity)
|
|
557
601
|
&& !!packetSlug
|
|
558
602
|
&& packetSlug === reportSlug;
|
|
559
603
|
}
|
|
@@ -640,7 +684,8 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
|
|
|
640
684
|
brandContractStatus: "not_evaluated",
|
|
641
685
|
packetPath: preflight.packetPath,
|
|
642
686
|
packet: preflight.packet,
|
|
643
|
-
mapId: stringArg(preflight.packet?.spec?.map_id) || "unknown-map",
|
|
687
|
+
mapId: stringArg(preflight.packet?.spec?.map_id) || (preflight.packet?.spec?.local_spec_id ? null : "unknown-map"),
|
|
688
|
+
localSpecId: preflight.packet?.spec?.local_spec_id ?? null,
|
|
644
689
|
publicRouteSlug,
|
|
645
690
|
proxyBase: stringArg(args["proxy-base"]) || DEFAULT_PROXY_BASE,
|
|
646
691
|
baseUrl: inputBaseUrl,
|
|
@@ -666,18 +711,18 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
|
|
|
666
711
|
// yields "not_applicable" rather than blocking, so browser QA still runs the
|
|
667
712
|
// residue/placeholder/demo gates. Test orders are not attempted (no policy).
|
|
668
713
|
export function resolveQaInputsFromSite(args) {
|
|
669
|
-
const targetRepo = resolve(String(args.site || args.built));
|
|
670
|
-
const scope = resolveBuiltSiteScope(targetRepo, { slug: stringArg(args.slug) });
|
|
671
|
-
if (!scope.ok) {
|
|
672
|
-
throw new Error(scope.error || `Could not resolve a built campaign from ${targetRepo}.`);
|
|
673
|
-
}
|
|
674
714
|
const baseUrl = normalizeBaseUrl(stringArg(args["base-url"]));
|
|
675
715
|
if (!baseUrl) {
|
|
676
|
-
throw
|
|
716
|
+
throw refused("Non-packet site QA requires --base-url <served-campaign-root> so built pages have a fetchable URL.");
|
|
677
717
|
}
|
|
678
718
|
const templateFamily = stringArg(args.family);
|
|
679
719
|
if (!templateFamily) {
|
|
680
|
-
throw
|
|
720
|
+
throw refused("Non-packet site QA requires --family <template-family> so residue, placeholder, and demo-asset gates can load the family brand contract.");
|
|
721
|
+
}
|
|
722
|
+
const targetRepo = resolve(String(args.site || args.built));
|
|
723
|
+
const scope = resolveBuiltSiteScope(targetRepo, { slug: stringArg(args.slug) });
|
|
724
|
+
if (!scope.ok) {
|
|
725
|
+
throw new Error(scope.error || `Could not resolve a built campaign from ${targetRepo}.`);
|
|
681
726
|
}
|
|
682
727
|
const brandContract = loadBrandContract(templateFamily);
|
|
683
728
|
if (brandContract.status !== "loaded" || !brandContract.contract) {
|
|
@@ -1506,6 +1551,7 @@ function resolvePayload(resolved, { routeProbe = null } = {}) {
|
|
|
1506
1551
|
ok: status !== "blocked" && status !== "routes_unresolved",
|
|
1507
1552
|
status,
|
|
1508
1553
|
map_id: resolved.mapId,
|
|
1554
|
+
...(resolved.localSpecId ? { local_spec_id: resolved.localSpecId } : {}),
|
|
1509
1555
|
...(resolved.packetPath ? { packet_path: resolved.packetPath } : {}),
|
|
1510
1556
|
...reportPathField(resolved),
|
|
1511
1557
|
...(resolved.proxyBase && resolved.proxyBase !== DEFAULT_PROXY_BASE ? { proxy_base: resolved.proxyBase } : {}),
|
|
@@ -2105,6 +2151,13 @@ async function runQa(args, options = {}) {
|
|
|
2105
2151
|
// Fail-fast before anything resolves or launches. The authoritative check
|
|
2106
2152
|
// lives on the creation budget itself, which every browser path builds.
|
|
2107
2153
|
refuseBadOrderCreationLimit(args);
|
|
2154
|
+
// Match dispatch: an active browser mode takes precedence over legacy API
|
|
2155
|
+
// diagnostics. Only the selected legacy path requires a cart and API mode.
|
|
2156
|
+
const browserMode = String(args["test-order"] || "off").toLowerCase();
|
|
2157
|
+
const legacyMode = String(args["legacy-api-test-order"] || "off").toLowerCase();
|
|
2158
|
+
if (browserMode === "off" && legacyMode !== "off") {
|
|
2159
|
+
refusing(() => legacyTestOrderInputs({ ...args, "test-order": legacyMode }));
|
|
2160
|
+
}
|
|
2108
2161
|
const resolved = await resolveQaInputs(args);
|
|
2109
2162
|
return runResolvedQa(args, resolved, options);
|
|
2110
2163
|
}
|
|
@@ -2182,12 +2235,19 @@ async function runResolvedQa(args, resolved, { runSessionActive = false } = {})
|
|
|
2182
2235
|
|
|
2183
2236
|
const assertions = [
|
|
2184
2237
|
...checkpointAssertions,
|
|
2238
|
+
...(resolved.excludedPages || []).map(page => assertion({
|
|
2239
|
+
id: `build-scope:${page.page_id}`, family: "funnel-flow", page,
|
|
2240
|
+
status: STATUS.SKIPPED,
|
|
2241
|
+
expected: "Only built routes are preview-QA targets",
|
|
2242
|
+
actual: "out_of_build_scope",
|
|
2243
|
+
evidence: { reason: "out_of_build_scope" },
|
|
2244
|
+
})),
|
|
2185
2245
|
...(polishGate?.owned_checkpoint_only ? [] : [polishGateAssertion(polishGate)]),
|
|
2186
2246
|
themeGateAssertion(gate),
|
|
2187
2247
|
];
|
|
2188
2248
|
const contractAssertion = templateBrandContractAssertion(resolved);
|
|
2189
2249
|
if (contractAssertion) assertions.push(contractAssertion);
|
|
2190
|
-
const commercialPlanning = planCommercialParity(resolved.rawSpec || resolved.spec);
|
|
2250
|
+
const commercialPlanning = planCommercialParity(specForQaScope(resolved.rawSpec || resolved.spec, resolved.excludedPages));
|
|
2191
2251
|
const sourceLoader = createPageSourceLoader({ authCookie: args["auth-cookie"] });
|
|
2192
2252
|
const commercialIds = new Set(commercialPlanning.pages
|
|
2193
2253
|
.filter((page) => page?.id !== undefined && page?.id !== null)
|
|
@@ -2272,13 +2332,17 @@ async function runAnalyticsOrderSequence({ args, resolved, runId, assertions },
|
|
|
2272
2332
|
} else if (analyticsLeg === "run") {
|
|
2273
2333
|
assertions.push(...await operations.runInventory(args, analyticsContract || {}, {
|
|
2274
2334
|
target: resolved.analyticsCaptureTarget,
|
|
2335
|
+
...analyticsCaptureScope(resolved),
|
|
2275
2336
|
}));
|
|
2276
2337
|
}
|
|
2277
2338
|
|
|
2278
|
-
// Analytics parity
|
|
2279
|
-
//
|
|
2339
|
+
// Analytics parity remains opt-in between root inventory and typed-card
|
|
2340
|
+
// receipt capture. #503: it gets the same partial-scope capture options.
|
|
2280
2341
|
if (stringArg(args["analytics-baseline"])) {
|
|
2281
|
-
assertions.push(...await operations.runParity(args, {
|
|
2342
|
+
assertions.push(...await operations.runParity(args, {
|
|
2343
|
+
target: resolved.analyticsCaptureTarget,
|
|
2344
|
+
...analyticsCaptureScope(resolved),
|
|
2345
|
+
}));
|
|
2282
2346
|
}
|
|
2283
2347
|
|
|
2284
2348
|
const result = await operations.runOrders({
|
|
@@ -2291,9 +2355,130 @@ async function runAnalyticsOrderSequence({ args, resolved, runId, assertions },
|
|
|
2291
2355
|
if (analyticsLeg === "run") {
|
|
2292
2356
|
assertions.push(operations.assessReceipt(result.receiptAnalytics, { waivers: resolved.qaWaivers }));
|
|
2293
2357
|
}
|
|
2358
|
+
applyLocalServeAnalyticsReview(assertions, resolved.localServeAnalytics);
|
|
2294
2359
|
return result.orders;
|
|
2295
2360
|
}
|
|
2296
2361
|
|
|
2362
|
+
// #483: local proof mode (deploy.target local-serve) renders the DEVELOPMENT
|
|
2363
|
+
// environment on purpose, and the starter templates gate every vendor loader
|
|
2364
|
+
// on it. A pixel that did not fire on that render is the render's design, not
|
|
2365
|
+
// the campaign's defect, so fire-dependent analytics checks become manual
|
|
2366
|
+
// review there instead of blockers. Only a run whose capture is actually
|
|
2367
|
+
// served from loopback qualifies: the same packet QA'd against the PR preview
|
|
2368
|
+
// (--base-url <preview>) is a production render and keeps its blockers, which
|
|
2369
|
+
// is the follow-up every downgraded assertion names.
|
|
2370
|
+
// The render itself must be on record, too: the exception applies only when
|
|
2371
|
+
// stages.assembly.evidence.build_environment says "development". A production
|
|
2372
|
+
// build served on localhost, or a build whose environment was never recorded,
|
|
2373
|
+
// keeps its blockers; the build-environment preflight only warns about those
|
|
2374
|
+
// states, so this gate cannot lean on it.
|
|
2375
|
+
// data-layer-purchase is deliberately not downgraded: it counts the SDK's own
|
|
2376
|
+
// dl_purchase, which the development render still pushes, so a miss on
|
|
2377
|
+
// localhost can be a real defect and keeps blocking.
|
|
2378
|
+
// A failure that is a capture or runner error, not "did not fire", is never
|
|
2379
|
+
// downgraded either: the environment gate explains a silent pixel, not an
|
|
2380
|
+
// unmeasured one. tag:* and oob:* are only emitted from a completed capture (a
|
|
2381
|
+
// failed capture is analytics-correctness:runner, which is outside this set);
|
|
2382
|
+
// purchase-fires names its unmeasured receipts in capture_error_plan_ids.
|
|
2383
|
+
const LOCAL_SERVE_ANALYTICS_REASON = "local_serve_development_render";
|
|
2384
|
+
const FIRE_DEPENDENT_ANALYTICS_ID = /^analytics-correctness:(?:tag:|oob:|purchase-fires(?::|$))/;
|
|
2385
|
+
|
|
2386
|
+
function isCaptureErrorFailure(item) {
|
|
2387
|
+
const evidence = item?.evidence;
|
|
2388
|
+
if (evidence && typeof evidence === "object" && evidence.error_code) return true;
|
|
2389
|
+
if (/^analytics-correctness:purchase-fires(?::|$)/.test(String(item?.id || ""))) {
|
|
2390
|
+
// Downgrade only a Purchase reading that positively records no capture
|
|
2391
|
+
// error; a missing list is an unknown, not a clean measurement.
|
|
2392
|
+
const errors = evidence?.capture_error_plan_ids;
|
|
2393
|
+
return !Array.isArray(errors) || errors.length > 0;
|
|
2394
|
+
}
|
|
2395
|
+
return false;
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
function isLoopbackUrl(value) {
|
|
2399
|
+
if (typeof value !== "string" || !value.trim()) return false;
|
|
2400
|
+
let hostname = null;
|
|
2401
|
+
try { hostname = new URL(value).hostname; } catch { hostname = null; }
|
|
2402
|
+
return !!hostname && isLoopbackHostname(hostname);
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
// #500: run-level eligibility is computed from the campaign-root URL, but the
|
|
2406
|
+
// page a failing check measured is not always that root. The #493 inventory
|
|
2407
|
+
// fallback can capture a built entry whose page.url is a remote production
|
|
2408
|
+
// preview, and a localhost root can redirect to a production host. So each
|
|
2409
|
+
// failing check is downgraded only when the page it measured is on record as
|
|
2410
|
+
// loopback:
|
|
2411
|
+
// - tag:* / oob:* — the check's own url and the inventory capture's
|
|
2412
|
+
// capture_page.url (both the URL requested) AND the capture's final_url
|
|
2413
|
+
// (page.url() after redirects and settling) must all be loopback;
|
|
2414
|
+
// - purchase-fires — every judged receipt's receipt_url (the order's final
|
|
2415
|
+
// page URL, recorded before analytics settle) AND its receipt_document_url
|
|
2416
|
+
// (page.url() read after the receipt analytics settled and were collected)
|
|
2417
|
+
// must be loopback, and there must be one. A receipt that redirects to a
|
|
2418
|
+
// hosted page during the settle window measured that host, not loopback.
|
|
2419
|
+
// A measured location that is missing or unparseable keeps the blocker.
|
|
2420
|
+
function localServeMeasuredOnLoopback(item, assertions) {
|
|
2421
|
+
const id = String(item?.id || "");
|
|
2422
|
+
if (/^analytics-correctness:purchase-fires(?::|$)/.test(id)) {
|
|
2423
|
+
const receipts = item?.evidence?.receipts;
|
|
2424
|
+
return Array.isArray(receipts) && receipts.length > 0
|
|
2425
|
+
&& receipts.every((receipt) => isLoopbackUrl(receipt?.receipt_url)
|
|
2426
|
+
&& isLoopbackUrl(receipt?.receipt_document_url));
|
|
2427
|
+
}
|
|
2428
|
+
const ownUrl = item?.evidence?.url ?? item?.url;
|
|
2429
|
+
if (!isLoopbackUrl(ownUrl)) return false;
|
|
2430
|
+
const capture = assertions.find((entry) => entry?.id === "analytics-correctness:capture" && entry.status === STATUS.PASS);
|
|
2431
|
+
const capturePage = capture?.evidence?.capture_page;
|
|
2432
|
+
return isLoopbackUrl(capturePage?.url) && isLoopbackUrl(capture?.evidence?.final_url);
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
function resolveLocalServeAnalytics({ packet, report, captureUrl }) {
|
|
2436
|
+
if (!isLocalServePacket(packet)) return null;
|
|
2437
|
+
if (!isLoopbackUrl(String(captureUrl ?? ""))) return null;
|
|
2438
|
+
if (recordedBuildEnvironment(report) !== LOCAL_PROOF_BUILD_ENVIRONMENT) return null;
|
|
2439
|
+
const parity = recordedProductionParity(report);
|
|
2440
|
+
return {
|
|
2441
|
+
deploy_target: "local-serve",
|
|
2442
|
+
build_environment: { field: LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD, value: LOCAL_PROOF_BUILD_ENVIRONMENT },
|
|
2443
|
+
production_parity: parity
|
|
2444
|
+
? {
|
|
2445
|
+
field: LOCAL_PROOF_PARITY_FIELD,
|
|
2446
|
+
status: typeof parity.status === "string" ? parity.status : null,
|
|
2447
|
+
checked_at: typeof parity.checked_at === "string" ? parity.checked_at : null,
|
|
2448
|
+
page_count: Number.isFinite(parity.page_count) ? parity.page_count : null,
|
|
2449
|
+
gated_hosts: [...new Set((Array.isArray(parity.pages) ? parity.pages : [])
|
|
2450
|
+
.flatMap((page) => (Array.isArray(page?.gated_hosts) ? page.gated_hosts : []))
|
|
2451
|
+
.filter((host) => typeof host === "string"))].sort(),
|
|
2452
|
+
}
|
|
2453
|
+
: { field: LOCAL_PROOF_PARITY_FIELD, status: "not_recorded" },
|
|
2454
|
+
};
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
function applyLocalServeAnalyticsReview(assertions, localServe) {
|
|
2458
|
+
if (!localServe) return;
|
|
2459
|
+
const parityPassed = localServe.production_parity?.status === "pass";
|
|
2460
|
+
for (const [index, item] of assertions.entries()) {
|
|
2461
|
+
if (item?.status !== STATUS.FAIL || !FIRE_DEPENDENT_ANALYTICS_ID.test(String(item.id || ""))) continue;
|
|
2462
|
+
if (isCaptureErrorFailure(item)) continue;
|
|
2463
|
+
if (!localServeMeasuredOnLoopback(item, assertions)) continue;
|
|
2464
|
+
assertions[index] = {
|
|
2465
|
+
...item,
|
|
2466
|
+
status: STATUS.MANUAL_REVIEW,
|
|
2467
|
+
severity: SEVERITY.WARN,
|
|
2468
|
+
actual: `${item.actual ?? "did not fire"}; local-serve development render gates vendor loaders out, so re-run against the PR preview with --base-url <preview-url>`,
|
|
2469
|
+
evidence: {
|
|
2470
|
+
...(item.evidence || {}),
|
|
2471
|
+
reason: LOCAL_SERVE_ANALYTICS_REASON,
|
|
2472
|
+
local_serve_status: STATUS.FAIL,
|
|
2473
|
+
build_environment: localServe.build_environment,
|
|
2474
|
+
follow_up: "Re-run qa run against the PR preview (a production render) with --base-url <preview-url>; that run gates these checks.",
|
|
2475
|
+
production_parity: localServe.production_parity,
|
|
2476
|
+
...(parityPassed ? {} : { production_parity_note: "No passing page-kit parity is recorded, so nothing yet shows the production render carries these loaders." }),
|
|
2477
|
+
},
|
|
2478
|
+
};
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2297
2482
|
async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, testOrders, commercial = null, runSessionActive = false, browser = null }) {
|
|
2298
2483
|
const entryUrls = deriveEntryUrls(resolved.topologies);
|
|
2299
2484
|
const pageUrls = derivePageUrls(resolved.topologies);
|
|
@@ -2311,12 +2496,14 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2311
2496
|
baseDir: resolved.packetPath ? dirname(resolved.packetPath) : null,
|
|
2312
2497
|
targetRepo: resolved.packetPath ? targetRepoFor(resolved.packetPath, resolved.packet) : null,
|
|
2313
2498
|
mapId: resolved.mapId,
|
|
2499
|
+
localSpecId: resolved.localSpecId,
|
|
2314
2500
|
currentRunId: runId,
|
|
2315
2501
|
isFinding: isFindingAssertion,
|
|
2316
2502
|
});
|
|
2317
2503
|
const verdict = createVerdict({
|
|
2318
2504
|
runId,
|
|
2319
2505
|
mapId: resolved.mapId,
|
|
2506
|
+
localSpecId: resolved.localSpecId,
|
|
2320
2507
|
publicRouteSlug: resolved.publicRouteSlug || null,
|
|
2321
2508
|
campaignRefId: resolved.spec.campaign?.ref_id || null,
|
|
2322
2509
|
specVersion: resolved.specVersion,
|
|
@@ -2370,7 +2557,9 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2370
2557
|
// config, or scope mismatch should be visible on the QA path too, not just
|
|
2371
2558
|
// on remit (Kilo review, PR #177).
|
|
2372
2559
|
const consent = resolveConsent({ proxyBase: resolved.proxyBase });
|
|
2373
|
-
const publishDecision =
|
|
2560
|
+
const publishDecision = resolved.localSpecId
|
|
2561
|
+
? { publish: false, reason: "local_spec", flag_invalid: false }
|
|
2562
|
+
: decidePublishVerdict({ args, portalManaged: resolved.portalManaged === true, consent });
|
|
2374
2563
|
if (publishDecision.flag_invalid) {
|
|
2375
2564
|
process.stderr.write(`[campaigns-os] --post-verdict "${args["post-verdict"]}" is not a recognized value (use true|1|yes|y|on or false|0|no|n|off); the flag was ignored and the default publish decision applied.\n`);
|
|
2376
2565
|
}
|
|
@@ -2390,6 +2579,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2390
2579
|
status: verdict.disposition,
|
|
2391
2580
|
run_id: verdict.run_id,
|
|
2392
2581
|
map_id: resolved.mapId,
|
|
2582
|
+
...(resolved.localSpecId ? { local_spec_id: resolved.localSpecId } : {}),
|
|
2393
2583
|
public_route_slug: resolved.publicRouteSlug || null,
|
|
2394
2584
|
...reportPathField(resolved),
|
|
2395
2585
|
base_url: resolved.baseUrl,
|
|
@@ -2417,6 +2607,38 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2417
2607
|
};
|
|
2418
2608
|
}
|
|
2419
2609
|
|
|
2610
|
+
// #493: a full build captures the campaign root. A partial build captures it
|
|
2611
|
+
// only when a built, in-scope page is served there; otherwise the root is
|
|
2612
|
+
// whatever the host answers (a directory index, a generic fallback) and must
|
|
2613
|
+
// not be measured. Either way the built entry pages (the same first in-scope
|
|
2614
|
+
// entry #482 selects) are the fallback when the root cannot be captured.
|
|
2615
|
+
// #503: "served there" is the page identity the capture leg deduplicates by
|
|
2616
|
+
// (isSameAnalyticsCapturePage): a query-routed page on the root's path
|
|
2617
|
+
// (`/campaign/?step=checkout`) is not the root, so it cannot put the root in
|
|
2618
|
+
// scope.
|
|
2619
|
+
function analyticsCaptureScope(resolved) {
|
|
2620
|
+
const rootUrl = rootCaptureUrl(resolved?.analyticsCaptureTarget?.url);
|
|
2621
|
+
const topologies = topologyList(resolved?.topologies);
|
|
2622
|
+
const partial = topologies.some((topology) => topology?.partial_build_scope)
|
|
2623
|
+
|| (Array.isArray(resolved?.excludedPages) && resolved.excludedPages.length > 0);
|
|
2624
|
+
const rootInScope = !rootUrl || !partial || topologies.some((topology) =>
|
|
2625
|
+
(Array.isArray(topology?.pages) ? topology.pages : []).some((page) =>
|
|
2626
|
+
typeof page?.url === "string" && isSameAnalyticsCapturePage(page.url, rootUrl)));
|
|
2627
|
+
return {
|
|
2628
|
+
rootInScope,
|
|
2629
|
+
fallbackTargets: deriveEntryUrls(resolved?.topologies),
|
|
2630
|
+
};
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
function rootCaptureUrl(value) {
|
|
2634
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
2635
|
+
try {
|
|
2636
|
+
return new URL(value).toString();
|
|
2637
|
+
} catch {
|
|
2638
|
+
return null;
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2420
2642
|
const ENTRY_PAGE_TYPES = new Set([
|
|
2421
2643
|
"entry",
|
|
2422
2644
|
"presell",
|
|
@@ -2434,7 +2656,9 @@ function deriveEntryUrls(topologies) {
|
|
|
2434
2656
|
for (const topology of topologyList(topologies)) {
|
|
2435
2657
|
const pages = Array.isArray(topology?.pages) ? topology.pages.filter((page) => page?.url) : [];
|
|
2436
2658
|
if (!pages.length) continue;
|
|
2437
|
-
|
|
2659
|
+
// #482: a partial build enters at its first in-scope page, which can be
|
|
2660
|
+
// an opted-in select or checkout rather than a later landing/presell.
|
|
2661
|
+
const page = topology.partial_build_scope ? pages[0] : pages.find(isEntryLikePage) || pages[0];
|
|
2438
2662
|
entries.push({
|
|
2439
2663
|
funnel_id: topology.funnel_id || "default",
|
|
2440
2664
|
funnel_name: topology.funnel_name || topology.funnel_id || "default",
|
|
@@ -2528,7 +2752,10 @@ async function runPageChecks(page, args, {
|
|
|
2528
2752
|
}
|
|
2529
2753
|
|
|
2530
2754
|
const source = await sourceLoader(page);
|
|
2531
|
-
|
|
2755
|
+
const scriptParseFailures = [];
|
|
2756
|
+
assertions.push(bindingAssertion(page, await observeBinding({ source, page, expected: bindingExpected, scriptLoader: bindingScriptLoader, parseFailures: scriptParseFailures })));
|
|
2757
|
+
const scriptParse = scriptParseAssertion(page, scriptParseFailures);
|
|
2758
|
+
if (scriptParse) assertions.push(assertion({ ...scriptParse, page }));
|
|
2532
2759
|
if (!source.ok) {
|
|
2533
2760
|
const isHttpStatus = source.error_code === "http_status";
|
|
2534
2761
|
assertions.push(assertion({
|
|
@@ -2698,15 +2925,12 @@ async function maybeRunLegacyApiTestOrders({ args, resolved, runId, assertions }
|
|
|
2698
2925
|
const apiKey = stringArg(args["api-key"]) || process.env.QA_CAMPAIGNS_API_KEY;
|
|
2699
2926
|
const apiBase = stringArg(args["campaigns-api-base"]) || process.env.CAMPAIGNS_API_BASE;
|
|
2700
2927
|
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.");
|
|
2701
|
-
const cart =
|
|
2702
|
-
if (!cart.length) throw new Error("--test-order requires --cart package_id:quantity pairs.");
|
|
2928
|
+
const { cart, paths } = legacyTestOrderInputs(args);
|
|
2703
2929
|
const checkout = findPage(resolved.topologies, "checkout");
|
|
2704
2930
|
if (!checkout?.url) throw new Error("--test-order requires a checkout page URL.");
|
|
2705
2931
|
const upsell = findPage(resolved.topologies, "upsell");
|
|
2706
|
-
const paths = mode === "both" ? ["accept", "decline"] : [mode];
|
|
2707
2932
|
const orders = [];
|
|
2708
2933
|
for (const path of paths) {
|
|
2709
|
-
if (!["accept", "decline"].includes(path)) throw new Error(`Unknown --test-order mode: ${mode}`);
|
|
2710
2934
|
const create = await createTestOrder({ apiBase, apiKey, cart, runId, successUrl: checkout.expected_next_url || upsell?.url || checkout.url, spec: resolved.spec, args });
|
|
2711
2935
|
const verification = { expected_line_count: cart.length, actual_line_count: 0, diff: [], verified: false };
|
|
2712
2936
|
if (!create.ok) {
|
|
@@ -3015,7 +3239,7 @@ function output(value, args) {
|
|
|
3015
3239
|
}
|
|
3016
3240
|
if (value.verdict) {
|
|
3017
3241
|
console.log(`QA run complete.`);
|
|
3018
|
-
console.log(
|
|
3242
|
+
console.log(`${value.local_spec_id ? "Local spec ID" : "Map ID"}: ${value.local_spec_id || value.map_id}`);
|
|
3019
3243
|
console.log(`Base URL: ${value.base_url || "(missing)"}`);
|
|
3020
3244
|
printEntryUrlLines(value.entry_urls);
|
|
3021
3245
|
console.log(`Run ID: ${value.run_id}`);
|
|
@@ -3048,7 +3272,7 @@ function output(value, args) {
|
|
|
3048
3272
|
}
|
|
3049
3273
|
console.log(`QA resolve complete.`);
|
|
3050
3274
|
console.log(`Status: ${value.status}`);
|
|
3051
|
-
console.log(
|
|
3275
|
+
console.log(`${value.local_spec_id ? "Local spec ID" : "Map ID"}: ${value.local_spec_id || value.map_id}`);
|
|
3052
3276
|
console.log(`Spec: ${value.spec_source}`);
|
|
3053
3277
|
console.log(`Base URL: ${value.base_url || "(missing)"}`);
|
|
3054
3278
|
printEntryUrlLines(value.entry_urls);
|
|
@@ -3198,7 +3422,9 @@ export function qaResolveNextProofLines(value) {
|
|
|
3198
3422
|
return [
|
|
3199
3423
|
`Next expected proof: ${qaRunCommandFromResolve(value)}`,
|
|
3200
3424
|
`Entry URL(s) resolved: ${formatEntryUrlsForProof(value.entry_urls)}`,
|
|
3201
|
-
|
|
3425
|
+
value.local_spec_id
|
|
3426
|
+
? "Typed-card test orders use global test cards (no transactions/no permission gate); local-spec QA stays in the repository."
|
|
3427
|
+
: "Typed-card test orders use global test cards (no transactions/no permission gate); QA publishes to the portal by default.",
|
|
3202
3428
|
];
|
|
3203
3429
|
}
|
|
3204
3430
|
|
|
@@ -3572,6 +3798,14 @@ function decodeHtml(value) {
|
|
|
3572
3798
|
.replace(/>/g, ">");
|
|
3573
3799
|
}
|
|
3574
3800
|
|
|
3801
|
+
function legacyTestOrderInputs(args) {
|
|
3802
|
+
const cart = typeof args.cart === "string" ? parseCart(args.cart) : [];
|
|
3803
|
+
if (!cart.length) throw new Error("--test-order requires --cart package_id:quantity pairs.");
|
|
3804
|
+
const mode = String(args["test-order"] || "off").toLowerCase();
|
|
3805
|
+
if (!["accept", "decline", "both"].includes(mode)) throw new Error(`Unknown --test-order mode: ${mode}`);
|
|
3806
|
+
return { cart, paths: mode === "both" ? ["accept", "decline"] : [mode] };
|
|
3807
|
+
}
|
|
3808
|
+
|
|
3575
3809
|
function parseCart(value) {
|
|
3576
3810
|
if (!value) return [];
|
|
3577
3811
|
return String(value).split(",").map((part) => {
|
|
@@ -3617,6 +3851,9 @@ export const __qaNodeTestHooks = Object.freeze({
|
|
|
3617
3851
|
resolveQaInputs,
|
|
3618
3852
|
runResolvedQa,
|
|
3619
3853
|
runPageChecks,
|
|
3854
|
+
analyticsCaptureScope,
|
|
3855
|
+
resolveLocalServeAnalytics,
|
|
3856
|
+
applyLocalServeAnalyticsReview,
|
|
3620
3857
|
analyticsCorrectnessLegDecision,
|
|
3621
3858
|
analyticsCorrectnessDisabledAssertion,
|
|
3622
3859
|
runAnalyticsOrderSequence,
|
package/src/qa-publish.mjs
CHANGED
|
@@ -46,6 +46,7 @@ export const QA_PUBLISH_STATUSES = Object.freeze({
|
|
|
46
46
|
// thrown error, so --json readers get a code to branch on.
|
|
47
47
|
export const QA_PUBLISH_REFUSALS = Object.freeze({
|
|
48
48
|
packet_required: "packet_required",
|
|
49
|
+
local_spec: "local_spec",
|
|
49
50
|
order_flags_refused: "order_flags_refused",
|
|
50
51
|
verdict_missing: "verdict_missing",
|
|
51
52
|
verdict_unreadable: "verdict_unreadable",
|
|
@@ -218,6 +219,9 @@ async function attemptPublish(args, operations, dryRun) {
|
|
|
218
219
|
return refusal(QA_PUBLISH_REFUSALS.packet_required, `Build Packet ${packetPath} is not readable (${error.code || error.message}).`);
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
if (packet?.spec?.local_spec_id != null) {
|
|
223
|
+
return refusal(QA_PUBLISH_REFUSALS.local_spec, "Local-spec QA has no saved Map destination; keep its verdict in the repository. Portal publication requires a saved Map and fresh evidence.");
|
|
224
|
+
}
|
|
221
225
|
const source = resolveStoredVerdictSource({ args, packetPath, packet, readJsonFile: ops.readJsonFile, exists: ops.exists });
|
|
222
226
|
if (source.error) return source.error;
|
|
223
227
|
let verdict;
|
package/src/qa-sidecar.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { localSpecIdentityFields } from "./spec-source-identity.mjs";
|
|
1
2
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -77,6 +78,7 @@ export function projectVerdictForSidecar(verdict, { generatedAt }) {
|
|
|
77
78
|
schema_version: verdict.schema_version,
|
|
78
79
|
run_id: verdict.run_id,
|
|
79
80
|
campaign_slug: verdict.campaign_slug,
|
|
81
|
+
...localSpecIdentityFields(verdict),
|
|
80
82
|
...(verdict.public_route_slug != null ? { public_route_slug: verdict.public_route_slug } : {}),
|
|
81
83
|
...(verdict.campaign_ref_id != null ? { campaign_ref_id: verdict.campaign_ref_id } : {}),
|
|
82
84
|
spec_version: verdict.spec_version,
|