@nextcommerce/campaigns-os 1.43.1 → 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 +451 -0
- package/README.md +2 -2
- package/contracts/agent-relevant-change-policy.v1.json +5 -0
- package/contracts/effects.v1.json +8 -8
- package/contracts/release-ledger.json +672 -0
- package/contracts/supported-surface.json +2 -2
- package/docs/build-packet.md +64 -2
- package/docs/campaigns-os-build-flow.md +1 -0
- package/docs/design-source-package.md +89 -15
- package/docs/effects.md +16 -4
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/progress-snapshots.md +10 -6
- package/docs/qa-and-test-orders.md +131 -7
- 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 +1 -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 -4
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills.json +10 -10
- 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 +1211 -7495
- 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/install-invocation.mjs +29 -0
- package/src/invocation.mjs +179 -0
- package/src/private-template-source.mjs +1 -1
- package/src/progress-node.mjs +6 -35
- 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 +218 -13
- package/src/source-html-intake.mjs +1 -1
- package/src/source-html-manifest.mjs +9 -2
- package/src/stage-ledger.mjs +28 -0
- package/src/target-lock.mjs +54 -0
- package/src/template-brand-contract.mjs +17 -1
|
@@ -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,6 +1,7 @@
|
|
|
1
1
|
import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "./spec-source-identity.mjs";
|
|
2
|
-
import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion } from './qa-binding-evidence.mjs';
|
|
2
|
+
import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion, scriptParseAssertion } from './qa-binding-evidence.mjs';
|
|
3
3
|
import { shellToken } from "./shell-token.mjs";
|
|
4
|
+
import { applyQaBuildScope, specForQaScope } from "./qa-build-scope.mjs";
|
|
4
5
|
import { requiredActionText } from "./gate-actions.mjs";
|
|
5
6
|
import { parseOrderPathDepthFlag } from "./proof-policy.mjs";
|
|
6
7
|
import {
|
|
@@ -29,13 +30,22 @@ const PACKAGE_ROOT = installModeResolve(installModeDirname(installModeFileUrl(im
|
|
|
29
30
|
function cmd(verb, rest = "") {
|
|
30
31
|
return `${invocationPrefixFor(PACKAGE_ROOT)} ${verb}${rest ? ` ${rest}` : ""}`;
|
|
31
32
|
}
|
|
32
|
-
import { runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, validatedOrderCreationLimit } from "./qa-browser.mjs";
|
|
33
|
+
import { isSameAnalyticsCapturePage, runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, validatedOrderCreationLimit } from "./qa-browser.mjs";
|
|
33
34
|
import { assessReceiptPurchase } from "./qa-analytics-correctness.mjs";
|
|
34
35
|
import { createVerdict, isFindingAssertion, QA_ASSERTION_FAMILY_VOCABULARY, SESSION_ENDING_DISPOSITIONS, SEVERITY, STATUS, validateVerdict } from "./qa-verdict.mjs";
|
|
35
36
|
import { normalizeSdkMetaName, lookupSdkIgnoredMetaTag } from "./sdk-meta-tags.mjs";
|
|
36
37
|
import { annotateQaAssertionCauses, formatCauseReportLines, formatCauseTag } from "./finding-cause.mjs";
|
|
37
38
|
import { promoteQaVerdict, writeQaSidecar } from "./qa-sidecar.mjs";
|
|
38
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";
|
|
39
49
|
import { publishStoredVerdict, qaPublishTextLines, QA_PUBLISH_EXIT_CODES } from "./qa-publish.mjs";
|
|
40
50
|
// Shared outgoing-edge resolver, so QA expectations and build-time wiring
|
|
41
51
|
// cannot drift on which declared routing field wins.
|
|
@@ -455,12 +465,22 @@ async function resolveQaInputs(args, {
|
|
|
455
465
|
hiddenEagerMediaGate,
|
|
456
466
|
});
|
|
457
467
|
const qaWaivers = resolveQaWaivers({ packetPath, report: checkpointPreflight?.runtimeReport });
|
|
468
|
+
const qaScope = applyQaBuildScope(topologies, {
|
|
469
|
+
packet, report: checkpointPreflight?.runtimeReport,
|
|
470
|
+
targetRepo: checkpointPreflight?.targetRepo, publicRouteSlug,
|
|
471
|
+
});
|
|
458
472
|
const brandContract = loadBrandContract(templateFamily);
|
|
473
|
+
const localServeAnalytics = resolveLocalServeAnalytics({
|
|
474
|
+
packet,
|
|
475
|
+
report: checkpointPreflight?.runtimeReport,
|
|
476
|
+
captureUrl: analyticsCaptureTarget.url || baseUrl,
|
|
477
|
+
});
|
|
459
478
|
return {
|
|
460
479
|
themeGate,
|
|
461
480
|
polishGate,
|
|
462
481
|
qaWaivers,
|
|
463
482
|
analyticsCaptureTarget,
|
|
483
|
+
localServeAnalytics,
|
|
464
484
|
brandContract: brandContract.contract,
|
|
465
485
|
brandContractStatus: brandContract.status,
|
|
466
486
|
packetPath,
|
|
@@ -481,7 +501,8 @@ async function resolveQaInputs(args, {
|
|
|
481
501
|
specHash,
|
|
482
502
|
templateFamily,
|
|
483
503
|
commerceStructureContract,
|
|
484
|
-
topologies,
|
|
504
|
+
topologies: qaScope.topologies,
|
|
505
|
+
excludedPages: qaScope.excludedPages,
|
|
485
506
|
checkpointGates: checkpointPreflight?.checkpointGates || nonPacketCheckpointGates(),
|
|
486
507
|
// The report the checkpoint gates were evaluated on, and the target repo
|
|
487
508
|
// whose default it may or may not be: what the printed remediation names.
|
|
@@ -2130,6 +2151,13 @@ async function runQa(args, options = {}) {
|
|
|
2130
2151
|
// Fail-fast before anything resolves or launches. The authoritative check
|
|
2131
2152
|
// lives on the creation budget itself, which every browser path builds.
|
|
2132
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
|
+
}
|
|
2133
2161
|
const resolved = await resolveQaInputs(args);
|
|
2134
2162
|
return runResolvedQa(args, resolved, options);
|
|
2135
2163
|
}
|
|
@@ -2207,12 +2235,19 @@ async function runResolvedQa(args, resolved, { runSessionActive = false } = {})
|
|
|
2207
2235
|
|
|
2208
2236
|
const assertions = [
|
|
2209
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
|
+
})),
|
|
2210
2245
|
...(polishGate?.owned_checkpoint_only ? [] : [polishGateAssertion(polishGate)]),
|
|
2211
2246
|
themeGateAssertion(gate),
|
|
2212
2247
|
];
|
|
2213
2248
|
const contractAssertion = templateBrandContractAssertion(resolved);
|
|
2214
2249
|
if (contractAssertion) assertions.push(contractAssertion);
|
|
2215
|
-
const commercialPlanning = planCommercialParity(resolved.rawSpec || resolved.spec);
|
|
2250
|
+
const commercialPlanning = planCommercialParity(specForQaScope(resolved.rawSpec || resolved.spec, resolved.excludedPages));
|
|
2216
2251
|
const sourceLoader = createPageSourceLoader({ authCookie: args["auth-cookie"] });
|
|
2217
2252
|
const commercialIds = new Set(commercialPlanning.pages
|
|
2218
2253
|
.filter((page) => page?.id !== undefined && page?.id !== null)
|
|
@@ -2297,13 +2332,17 @@ async function runAnalyticsOrderSequence({ args, resolved, runId, assertions },
|
|
|
2297
2332
|
} else if (analyticsLeg === "run") {
|
|
2298
2333
|
assertions.push(...await operations.runInventory(args, analyticsContract || {}, {
|
|
2299
2334
|
target: resolved.analyticsCaptureTarget,
|
|
2335
|
+
...analyticsCaptureScope(resolved),
|
|
2300
2336
|
}));
|
|
2301
2337
|
}
|
|
2302
2338
|
|
|
2303
|
-
// Analytics parity
|
|
2304
|
-
//
|
|
2339
|
+
// Analytics parity remains opt-in between root inventory and typed-card
|
|
2340
|
+
// receipt capture. #503: it gets the same partial-scope capture options.
|
|
2305
2341
|
if (stringArg(args["analytics-baseline"])) {
|
|
2306
|
-
assertions.push(...await operations.runParity(args, {
|
|
2342
|
+
assertions.push(...await operations.runParity(args, {
|
|
2343
|
+
target: resolved.analyticsCaptureTarget,
|
|
2344
|
+
...analyticsCaptureScope(resolved),
|
|
2345
|
+
}));
|
|
2307
2346
|
}
|
|
2308
2347
|
|
|
2309
2348
|
const result = await operations.runOrders({
|
|
@@ -2316,9 +2355,130 @@ async function runAnalyticsOrderSequence({ args, resolved, runId, assertions },
|
|
|
2316
2355
|
if (analyticsLeg === "run") {
|
|
2317
2356
|
assertions.push(operations.assessReceipt(result.receiptAnalytics, { waivers: resolved.qaWaivers }));
|
|
2318
2357
|
}
|
|
2358
|
+
applyLocalServeAnalyticsReview(assertions, resolved.localServeAnalytics);
|
|
2319
2359
|
return result.orders;
|
|
2320
2360
|
}
|
|
2321
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
|
+
|
|
2322
2482
|
async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, testOrders, commercial = null, runSessionActive = false, browser = null }) {
|
|
2323
2483
|
const entryUrls = deriveEntryUrls(resolved.topologies);
|
|
2324
2484
|
const pageUrls = derivePageUrls(resolved.topologies);
|
|
@@ -2447,6 +2607,38 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2447
2607
|
};
|
|
2448
2608
|
}
|
|
2449
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
|
+
|
|
2450
2642
|
const ENTRY_PAGE_TYPES = new Set([
|
|
2451
2643
|
"entry",
|
|
2452
2644
|
"presell",
|
|
@@ -2464,7 +2656,9 @@ function deriveEntryUrls(topologies) {
|
|
|
2464
2656
|
for (const topology of topologyList(topologies)) {
|
|
2465
2657
|
const pages = Array.isArray(topology?.pages) ? topology.pages.filter((page) => page?.url) : [];
|
|
2466
2658
|
if (!pages.length) continue;
|
|
2467
|
-
|
|
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];
|
|
2468
2662
|
entries.push({
|
|
2469
2663
|
funnel_id: topology.funnel_id || "default",
|
|
2470
2664
|
funnel_name: topology.funnel_name || topology.funnel_id || "default",
|
|
@@ -2558,7 +2752,10 @@ async function runPageChecks(page, args, {
|
|
|
2558
2752
|
}
|
|
2559
2753
|
|
|
2560
2754
|
const source = await sourceLoader(page);
|
|
2561
|
-
|
|
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 }));
|
|
2562
2759
|
if (!source.ok) {
|
|
2563
2760
|
const isHttpStatus = source.error_code === "http_status";
|
|
2564
2761
|
assertions.push(assertion({
|
|
@@ -2728,15 +2925,12 @@ async function maybeRunLegacyApiTestOrders({ args, resolved, runId, assertions }
|
|
|
2728
2925
|
const apiKey = stringArg(args["api-key"]) || process.env.QA_CAMPAIGNS_API_KEY;
|
|
2729
2926
|
const apiBase = stringArg(args["campaigns-api-base"]) || process.env.CAMPAIGNS_API_BASE;
|
|
2730
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.");
|
|
2731
|
-
const cart =
|
|
2732
|
-
if (!cart.length) throw new Error("--test-order requires --cart package_id:quantity pairs.");
|
|
2928
|
+
const { cart, paths } = legacyTestOrderInputs(args);
|
|
2733
2929
|
const checkout = findPage(resolved.topologies, "checkout");
|
|
2734
2930
|
if (!checkout?.url) throw new Error("--test-order requires a checkout page URL.");
|
|
2735
2931
|
const upsell = findPage(resolved.topologies, "upsell");
|
|
2736
|
-
const paths = mode === "both" ? ["accept", "decline"] : [mode];
|
|
2737
2932
|
const orders = [];
|
|
2738
2933
|
for (const path of paths) {
|
|
2739
|
-
if (!["accept", "decline"].includes(path)) throw new Error(`Unknown --test-order mode: ${mode}`);
|
|
2740
2934
|
const create = await createTestOrder({ apiBase, apiKey, cart, runId, successUrl: checkout.expected_next_url || upsell?.url || checkout.url, spec: resolved.spec, args });
|
|
2741
2935
|
const verification = { expected_line_count: cart.length, actual_line_count: 0, diff: [], verified: false };
|
|
2742
2936
|
if (!create.ok) {
|
|
@@ -3604,6 +3798,14 @@ function decodeHtml(value) {
|
|
|
3604
3798
|
.replace(/>/g, ">");
|
|
3605
3799
|
}
|
|
3606
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
|
+
|
|
3607
3809
|
function parseCart(value) {
|
|
3608
3810
|
if (!value) return [];
|
|
3609
3811
|
return String(value).split(",").map((part) => {
|
|
@@ -3649,6 +3851,9 @@ export const __qaNodeTestHooks = Object.freeze({
|
|
|
3649
3851
|
resolveQaInputs,
|
|
3650
3852
|
runResolvedQa,
|
|
3651
3853
|
runPageChecks,
|
|
3854
|
+
analyticsCaptureScope,
|
|
3855
|
+
resolveLocalServeAnalytics,
|
|
3856
|
+
applyLocalServeAnalyticsReview,
|
|
3652
3857
|
analyticsCorrectnessLegDecision,
|
|
3653
3858
|
analyticsCorrectnessDisabledAssertion,
|
|
3654
3859
|
runAnalyticsOrderSequence,
|
|
@@ -106,7 +106,7 @@ function declaredScopeSkip(page, { skipEntry = null, buildScope = null, manifest
|
|
|
106
106
|
id: `dec_page_scope_${page.id}`,
|
|
107
107
|
stage: "prepare_build",
|
|
108
108
|
decision_type: "deterministic_derivation",
|
|
109
|
-
decision: `recorded CampaignSpec page "${page.id}" as template stock, declared out of source scope (${skipEntry ? "explicit source-html manifest skip entry" : 'CampaignSpec build_scope mode "partial"'}); the
|
|
109
|
+
decision: `recorded CampaignSpec page "${page.id}" as template stock, declared out of source scope (${skipEntry ? "explicit source-html manifest skip entry" : 'CampaignSpec build_scope mode "partial"'}); keep the route unbuilt unless the operator opts in to materialising it from ${familyLabel}'s stock page; intake demands no design source for it`,
|
|
110
110
|
confidence: "high",
|
|
111
111
|
template_stock: true,
|
|
112
112
|
template_family: family,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
3
|
import { resolve } from "node:path";
|
|
3
4
|
|
|
@@ -95,10 +96,16 @@ export function readSourceHtmlManifestFile(sourceRoot, { manifestPath = null } =
|
|
|
95
96
|
return { ...readManifestAt(resolvedPath), explicit: Boolean(explicit) };
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
// One read: the manifest is parsed from, and hashed over, the same bytes. An
|
|
100
|
+
// edit that lands on disk after the read changes neither, so the sha256 a
|
|
101
|
+
// consumer records always describes what was parsed (#501).
|
|
98
102
|
function readManifestAt(manifestPath) {
|
|
99
103
|
let manifest;
|
|
104
|
+
let sha256;
|
|
100
105
|
try {
|
|
101
|
-
|
|
106
|
+
const bytes = readFileSync(manifestPath);
|
|
107
|
+
sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
108
|
+
manifest = JSON.parse(bytes.toString("utf8"));
|
|
102
109
|
} catch (error) {
|
|
103
110
|
return {
|
|
104
111
|
manifest: null,
|
|
@@ -125,7 +132,7 @@ function readManifestAt(manifestPath) {
|
|
|
125
132
|
const warnings = (validation.warnings || []).map(
|
|
126
133
|
(entry) => `Source-html manifest at ${manifestPath}: [${entry.code}] ${entry.message}`,
|
|
127
134
|
);
|
|
128
|
-
return { manifest, path: manifestPath, warning: null, warnings, validation };
|
|
135
|
+
return { manifest, path: manifestPath, sha256, warning: null, warnings, validation };
|
|
129
136
|
}
|
|
130
137
|
|
|
131
138
|
function validateManifestPage(entry, index, add, addWarning = () => {}) {
|
package/src/stage-ledger.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { markDoctorSidecarStale, writeDoctorSidecar, writeJsonAtomic } from "./doctor-sidecar.mjs";
|
|
4
4
|
import { STATUS as QA_STATUS } from "./qa-verdict.mjs";
|
|
5
5
|
import { isPlainObject, normalizeString as optionalString } from "./repo-scan.mjs";
|
|
6
|
+
import { withTargetLockSync } from "./target-lock.mjs";
|
|
6
7
|
import {
|
|
7
8
|
ASSEMBLY_REPORT_STAGE_KEYS,
|
|
8
9
|
NEXT_STAGE_CONTRACTS,
|
|
@@ -425,6 +426,16 @@ export function assemblyReportMatchesPacket(report, packet) {
|
|
|
425
426
|
* (waivers, evidence merges) pass no `stage`: they require the report to
|
|
426
427
|
* exist and bind its identity themselves.
|
|
427
428
|
*
|
|
429
|
+
* The read-modify-write runs under the per-target writer lock that
|
|
430
|
+
* prepare-build holds (src/target-lock.mjs, #501), so a stage producer's edit
|
|
431
|
+
* never lands between prepare-build's pre-publish evidence re-check and its
|
|
432
|
+
* publication; inside prepare-build's own critical section it enters
|
|
433
|
+
* directly. A workspace without `targetRepo` (only possible with an explicit
|
|
434
|
+
* refreshDoctor and no stale stamp) names no target to lock and runs as is.
|
|
435
|
+
* `lockBudgetMs` bounds the wait (default: the target lock budget). A caller
|
|
436
|
+
* whose mutate always returns null (a preview) passes `lock: false`: it
|
|
437
|
+
* writes nothing, so it takes no lock and creates no lock files.
|
|
438
|
+
*
|
|
428
439
|
* Returns `{ written, skipped, report, reportPath, doctorOutPath }` where
|
|
429
440
|
* `skipped` is `null`, `"absent"`, `"identity"` or `"unchanged"` and `report`
|
|
430
441
|
* is what is now on disk (the mutated report when written, else the one read,
|
|
@@ -435,6 +446,10 @@ export function commitAssemblyReport(workspace, mutate, {
|
|
|
435
446
|
staleReason = null,
|
|
436
447
|
command = null,
|
|
437
448
|
stage = null,
|
|
449
|
+
lockBudgetMs,
|
|
450
|
+
// lock: false skips the target lock entirely; only for callers that write
|
|
451
|
+
// nothing (the waiver dry-run preview). A real commit must take the lock.
|
|
452
|
+
lock = true,
|
|
438
453
|
} = {}) {
|
|
439
454
|
const hasRefresh = typeof refreshDoctor === "function";
|
|
440
455
|
const hasStale = typeof staleReason === "string" && staleReason.trim();
|
|
@@ -453,6 +468,19 @@ export function commitAssemblyReport(workspace, mutate, {
|
|
|
453
468
|
if (hasRefresh && !doctorOutPath) throw new TypeError("commitAssemblyReport requires a workspace with doctorOutPath to refresh the doctor sidecar.");
|
|
454
469
|
if (hasStale && !targetRepo) throw new TypeError("commitAssemblyReport requires a workspace with targetRepo to stamp the doctor sidecar stale.");
|
|
455
470
|
|
|
471
|
+
const commit = () => commitAssemblyReportUnderLock(workspace, mutate, {
|
|
472
|
+
refreshDoctor, staleReason, command, stage, hasRefresh, reportPath, doctorOutPath, targetRepo,
|
|
473
|
+
});
|
|
474
|
+
if (!targetRepo || lock === false) return commit();
|
|
475
|
+
return withTargetLockSync(targetRepo, commit, {
|
|
476
|
+
command: command.trim(),
|
|
477
|
+
...(lockBudgetMs === undefined ? {} : { budgetMs: lockBudgetMs }),
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function commitAssemblyReportUnderLock(workspace, mutate, {
|
|
482
|
+
refreshDoctor, staleReason, command, stage, hasRefresh, reportPath, doctorOutPath, targetRepo,
|
|
483
|
+
}) {
|
|
456
484
|
const outcome = { written: false, skipped: null, report: null, reportPath, doctorOutPath };
|
|
457
485
|
const finish = () => {
|
|
458
486
|
if (hasRefresh) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The per-target writer lock (#496, #501). prepare-build holds it from reading
|
|
2
|
+
// its inputs through publishing the packet, context and report; the stage
|
|
3
|
+
// writers (every commitAssemblyReport) hold it for their read-modify-write of
|
|
4
|
+
// the Assembly Report, so no stage evidence lands between prepare-build's
|
|
5
|
+
// pre-publish re-check and its rename. It lives beside the Design Source
|
|
6
|
+
// Package, inside the input directory prepare-build's writes already cover.
|
|
7
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
9
|
+
import { DESIGN_SOURCE_PACKAGE_REL_PATH } from "./design-source-package.mjs";
|
|
10
|
+
import { withDirectoryLock, withDirectoryLockSync } from "./directory-lock.mjs";
|
|
11
|
+
|
|
12
|
+
// Generous: a live holder is doing ordinary local work, and a holder that
|
|
13
|
+
// died is recovered by pid.
|
|
14
|
+
export const TARGET_LOCK_BUDGET_MS = 60000;
|
|
15
|
+
|
|
16
|
+
export function targetLockPath(targetRepo) {
|
|
17
|
+
const designSourcePackagePath = resolve(targetRepo, DESIGN_SOURCE_PACKAGE_REL_PATH);
|
|
18
|
+
return join(dirname(designSourcePackagePath), `.${basename(designSourcePackagePath)}.lock`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// `command` names the waiting command. The lock does not record which command
|
|
22
|
+
// holds it, only a pid, so the holder is described generically. A lock with
|
|
23
|
+
// no owner record is called out on its own: it is never taken over, and the
|
|
24
|
+
// operator needs to know it will not clear by waiting.
|
|
25
|
+
function unavailable(targetRepo, lockPath, command) {
|
|
26
|
+
return (error) => {
|
|
27
|
+
if (error?.code !== "EEXIST") {
|
|
28
|
+
return new Error(`${command} could not take the target lock at ${lockPath}${error?.code ? ` (${error.code})` : ""}: ${error?.message}`, { cause: error });
|
|
29
|
+
}
|
|
30
|
+
if (existsSync(lockPath) && !existsSync(join(lockPath, "owner.json"))) {
|
|
31
|
+
return new Error(
|
|
32
|
+
`${command}: the target lock at ${lockPath} has no owner record, so it is never taken over automatically `
|
|
33
|
+
+ "(an older campaigns-os release or an interrupted run left it). "
|
|
34
|
+
+ `Confirm no campaigns-os process is working on ${targetRepo}, then remove that lock directory and retry.`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return new Error(
|
|
38
|
+
`${command}: another campaigns-os command is writing ${targetRepo} (lock ${lockPath}). `
|
|
39
|
+
+ "Retry after it finishes. If a run was interrupted, confirm no campaigns-os process is working on this target before removing that lock directory.",
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function withTargetLock(targetRepo, fn, { command = "campaigns-os", budgetMs = TARGET_LOCK_BUDGET_MS } = {}) {
|
|
45
|
+
const lockPath = targetLockPath(targetRepo);
|
|
46
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
47
|
+
return withDirectoryLock(lockPath, fn, { budgetMs, unavailable: unavailable(targetRepo, lockPath, command) });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function withTargetLockSync(targetRepo, fn, { command = "campaigns-os", budgetMs = TARGET_LOCK_BUDGET_MS } = {}) {
|
|
51
|
+
const lockPath = targetLockPath(targetRepo);
|
|
52
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
53
|
+
return withDirectoryLockSync(lockPath, fn, { budgetMs, unavailable: unavailable(targetRepo, lockPath, command) });
|
|
54
|
+
}
|
|
@@ -327,6 +327,22 @@ export function paymentChromeAssetHashes(chrome, { label = "template brand contr
|
|
|
327
327
|
return byBasename;
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
+
// The starter templates' payment-logos.html partial renders one
|
|
331
|
+
// <img data-payment-logo="<method>"> per method and keeps it `hidden` until the
|
|
332
|
+
// campaign offers that method (server render, then payment-logos.js on
|
|
333
|
+
// next:initialized; payment_flags.show_<method>: true forces it visible). A
|
|
334
|
+
// hidden logo is the template gating the method, not residue, so both the
|
|
335
|
+
// static scan and browser QA drop those tags before matching. A visible one
|
|
336
|
+
// (forced on, or revealed at runtime) stays in and is judged like any chrome.
|
|
337
|
+
const PAYMENT_LOGO_IMG_TAG = /<img\b[^>]*\sdata-payment-logo\s*=[^>]*>/gi;
|
|
338
|
+
// Boolean attribute: present with any value (hidden, hidden="", hidden="true", …) means hidden.
|
|
339
|
+
const HIDDEN_ATTRIBUTE = /\shidden(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?(?=[\s/>])/i;
|
|
340
|
+
|
|
341
|
+
export function withoutHiddenPaymentLogos(html) {
|
|
342
|
+
const text = typeof html === "string" ? html : "";
|
|
343
|
+
return text.replace(PAYMENT_LOGO_IMG_TAG, (tag) => (HIDDEN_ATTRIBUTE.test(tag) ? "" : tag));
|
|
344
|
+
}
|
|
345
|
+
|
|
330
346
|
// Pure, static: the markers in rendered checkout HTML that say a payment method
|
|
331
347
|
// shipped. Three sources, in order of authority: the SDK-owned
|
|
332
348
|
// data-next-payment-method attribute every starter-template payment-methods
|
|
@@ -338,7 +354,7 @@ export function paymentChromeAssetHashes(chrome, { label = "template brand contr
|
|
|
338
354
|
// browser QA, which fetches them to attribute the mark; a static scan cannot
|
|
339
355
|
// tell a paypal strip from a card-only one by its filename.
|
|
340
356
|
export function paymentMethodMarkupMatches(html, method, chrome = null) {
|
|
341
|
-
const text =
|
|
357
|
+
const text = withoutHiddenPaymentLogos(html);
|
|
342
358
|
const canonical = String(method || "").toLowerCase().replace(/[\s-]+/g, "_");
|
|
343
359
|
if (!canonical) return [];
|
|
344
360
|
const matches = [];
|