@nextcommerce/campaigns-os 1.52.0 → 1.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/README.md +1 -0
- package/agents/claude/CLAUDE.md +2 -0
- package/agents/codex/AGENTS.md +2 -0
- package/agents/copilot/copilot-instructions.md +2 -0
- package/agents/cursor/campaigns-os.mdc +2 -0
- package/compatibility.json +1 -1
- package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -27
- package/contracts/effects.v1.json +295 -47
- package/contracts/release-ledger.json +660 -0
- package/contracts/supported-surface.json +3 -3
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/brand-theme-bridge.md +2 -0
- package/docs/build-packet.md +67 -19
- package/docs/campaign-build-brief.md +48 -17
- package/docs/campaign-standardization-report.md +32 -0
- package/docs/design-source-package.md +7 -3
- package/docs/effects.md +10 -1
- 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 +5 -5
- package/docs/qa-and-test-orders.md +30 -3
- package/docs/readback.md +19 -2
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +15 -0
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -0
- package/package.json +1 -1
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -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 +5 -3
- package/skills/next-campaigns-os/SKILL.md +10 -19
- package/skills/next-campaigns-os/references/session-intake.md +10 -6
- package/skills/next-campaigns-os-setup/SKILL.md +4 -3
- package/skills/next-campaigns-polish/SKILL.md +7 -5
- package/skills/next-campaigns-qa/SKILL.md +5 -3
- package/skills.json +10 -10
- package/src/brand-theme.mjs +21 -20
- package/src/brief-summary.mjs +275 -0
- package/src/build-brief.mjs +167 -15
- package/src/campaign-ecosystem.mjs +153 -31
- package/src/cli.mjs +182 -115
- package/src/contrast.mjs +649 -0
- package/src/design-source-publication.mjs +110 -3
- package/src/directory-lock.mjs +79 -7
- package/src/doctor/checks.mjs +141 -16
- package/src/doctor/inspect.mjs +13 -0
- package/src/doctor/next-step.mjs +15 -6
- package/src/input-currency.mjs +394 -0
- package/src/input-refresh.mjs +708 -0
- package/src/invocation.mjs +6 -2
- package/src/polish-browser.mjs +503 -201
- package/src/polish-deadline.mjs +7 -0
- package/src/polish-node.mjs +326 -46
- package/src/polish-readability.mjs +828 -0
- package/src/progress-node.mjs +18 -5
- package/src/qa-browser.mjs +211 -93
- package/src/qa-node.mjs +47 -7
- package/src/qc-accept.mjs +44 -8
- package/src/qc-check-registry.mjs +15 -7
- package/src/qc-results.mjs +326 -34
- package/src/readback.mjs +58 -10
- package/src/sdk-attribute-index.mjs +111 -23
- package/src/sdk-markup.mjs +49 -10
- package/src/sdk-repin.mjs +282 -0
- package/src/stage-ledger.mjs +235 -44
- package/src/stage-record.mjs +154 -30
- package/src/standardization-report.mjs +26 -1
- package/src/target-lock.mjs +9 -1
- package/src/tooling-setup.mjs +1 -1
package/src/progress-node.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import {mkdirSync,readFileSync,writeFileSync,renameSync,rmSync,readdirSync} from
|
|
|
5
5
|
import {join,resolve,dirname} from 'node:path';
|
|
6
6
|
import {PROGRESS_SCHEMA_VERSION,PROGRESS_STAGES,PROGRESS_STAGE_STATUSES,PROGRESS_CONTINUATIONS,PROGRESS_ACTION_IDS,PROGRESS_GATE_IDS,canonicalProgressJson,progressSnapshotId,verifyProgressSnapshot} from './progress.mjs';
|
|
7
7
|
import {specMaterialHash} from './spec-identity.mjs';
|
|
8
|
+
import {effectiveStageStatus} from './input-currency.mjs';
|
|
8
9
|
import {sameFile} from './fs-identity.mjs';
|
|
9
10
|
import {withDirectoryLock} from './directory-lock.mjs';
|
|
10
11
|
import {resolveConsent,CANONICAL_REMIT_SCOPE,normalizeConsentScope,announceDefaultOnTelemetry} from './consent.mjs';
|
|
@@ -17,7 +18,7 @@ const accepted = (value,values,fallback='unknown')=>values.includes(value)?value
|
|
|
17
18
|
// A next action that is a variant of a fixed action projects onto it: the
|
|
18
19
|
// order-bump QA run is a QA run. Without this, the closed v0 vocabulary would
|
|
19
20
|
// read it as unknown and mark a ready QA continuation blocked.
|
|
20
|
-
const PROGRESS_ACTION_ALIASES = new Map([['qa_run_bump','qa_run']]);
|
|
21
|
+
const PROGRESS_ACTION_ALIASES = new Map([['qa_run_bump','qa_run'],['refresh_inputs','recheck']]);
|
|
21
22
|
const hash = value=>typeof value==='string'&&/^(?:sha256:)?[0-9a-f]{64}$/i.test(value)?`sha256:${value.replace(/^sha256:/i,'').toLowerCase()}`:null;
|
|
22
23
|
const id = value=>typeof value==='string'&&/^[A-Za-z0-9_-]{1,64}$/.test(value)?value:null;
|
|
23
24
|
const digest = value=>`sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
@@ -54,7 +55,15 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
|
|
|
54
55
|
const aligned=contextBound&&mapId&&localMapId===mapId&&baseline?.map_id===mapId&&baseline?.algorithm==='map-store-v1'&&hash(baseline.hash)&&localHash&&localHash===hash(baseline.local_spec_material_hash);
|
|
55
56
|
const build=hash(doctor?.derived?.build_output_fingerprint?.value);
|
|
56
57
|
const recordedBuild=hash(report?.stages?.assembly?.build_fingerprint);
|
|
57
|
-
|
|
58
|
+
// The report belongs to this packet and spec: its stages can be read. A
|
|
59
|
+
// spec edit since the report was bound does not unbind it; the edit makes
|
|
60
|
+
// the stamped stages owed, which their effective status reports. The
|
|
61
|
+
// doctor read must have assessed the same spec material this read sees.
|
|
62
|
+
const inputCurrency=doctor?.derived?.input_currency;
|
|
63
|
+
const stagesBound=doctor?.derived?.prepare_build_gate?.binding_failure!==true&&contextBound&&campaignIdentitiesMatch(packet?.spec,campaignSpecIdentity(spec))&&campaignIdentitiesMatch(packet?.spec,report?.identity)&&localHash&&(!inputCurrency||hash(inputCurrency.spec?.current)===localHash);
|
|
64
|
+
// The report is also bound to the spec material on disk now, so output and
|
|
65
|
+
// verdict evidence recorded against it can match.
|
|
66
|
+
const reportBound=stagesBound&&localHash===hash(report?.identity?.spec_material_hash);
|
|
58
67
|
const qaSource=hash(report?.stages?.qa?.evidence?.source_build_fingerprint);
|
|
59
68
|
const verdict=qaResult?.verdict;
|
|
60
69
|
const qa=verdict?{
|
|
@@ -73,8 +82,11 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
|
|
|
73
82
|
return {
|
|
74
83
|
schema_version:PROGRESS_SCHEMA_VERSION,package_version:packageVersion,producer:qaResult?'qa':'next',
|
|
75
84
|
identity:{map_id:mapId,...localSpecIdentityFields(packet?.spec),map_revision_hash:mapId?(contextBound&&baseline?.map_id===mapId?hash(baseline?.hash):(localMapId===mapId?hash(spec?.spec_identity?.spec_hash||spec?.spec_hash):null)):null,map_revision_algorithm:'map-store-v1',saved_revision_alignment:aligned?'aligned':'unconfirmed',local_spec_material_hash:localHash,local_spec_material_algorithm:'campaign-spec-material-v1',build_fingerprint:build,build_fingerprint_algorithm:'sha256-manifest/v1'},
|
|
85
|
+
// Each stage's effective status: a stage owed again by a brief or
|
|
86
|
+
// CampaignSpec change reads required, one whose inputs cannot be
|
|
87
|
+
// confirmed reads unknown (src/input-currency.mjs).
|
|
76
88
|
stages:PROGRESS_STAGES.map(stage=>{
|
|
77
|
-
const status=
|
|
89
|
+
const status=stagesBound?accepted(effectiveStageStatus(stage,report?.stages?.[stage],inputCurrency),PROGRESS_STAGE_STATUSES):'unknown';
|
|
78
90
|
const source=stage==='assembly'?recordedBuild:stage==='qa'?qaSource:null;
|
|
79
91
|
return {stage,status,source_build_fingerprint:source,build_binding:reportBound&&build&&source===build&&doctor?.derived?.build_output_fingerprint?.status==='pass'?'matching':'unconfirmed'};
|
|
80
92
|
}),
|
|
@@ -84,7 +96,7 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
|
|
|
84
96
|
}
|
|
85
97
|
function lock(dir,fn,{budgetMs=1500}={}) {
|
|
86
98
|
const lockPath=join(dir,'.allocation-lock');
|
|
87
|
-
return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:()=>Object.assign(new Error('progress.lock_unavailable'),{lockPath})});
|
|
99
|
+
return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:(error)=>Object.assign(new Error('progress.lock_unavailable'),{lockPath,code:error?.code,obstruction:error?.obstruction})});
|
|
88
100
|
}
|
|
89
101
|
export async function persistProgressObservation(observation,{dir,now=()=>new Date(),historyLimit=32}={}) {
|
|
90
102
|
mkdirSync(dir,{recursive:true,mode:0o700});
|
|
@@ -152,7 +164,8 @@ export async function observeProgress(args,continuation,{qaResult=null,packageVe
|
|
|
152
164
|
if(remit.state==='failed')warn('[campaigns-os] Progress delivery pending; the local observation is retained. Lifecycle result is unchanged.');
|
|
153
165
|
return {...remit,snapshot_id:snapshot.snapshot_id,reused};
|
|
154
166
|
} catch(error) {
|
|
155
|
-
if(error?.message==='progress.lock_unavailable')warn(`[campaigns-os] Progress allocation lock
|
|
167
|
+
if(error?.message==='progress.lock_unavailable'&&error.obstruction)warn(`[campaigns-os] Progress allocation lock path ${error.lockPath} is occupied by ${error.obstruction}; it is never removed automatically. Move it out of the way. Lifecycle result is unchanged.`);
|
|
168
|
+
else if(error?.message==='progress.lock_unavailable')warn(`[campaigns-os] Progress allocation lock occupied${error.lockPath?` at ${error.lockPath}`:''}; wait for the current writer. If it stays occupied (an abandoned, ownerless or interrupted lock), stop all campaigns-os writers for this target, then remove that lock directory (docs/progress-snapshots.md). Lifecycle result is unchanged.`);
|
|
156
169
|
else warn('[campaigns-os] Progress observation unavailable; lifecycle result is unchanged.');
|
|
157
170
|
return {state:'failed',reason:'capture_unavailable'};
|
|
158
171
|
}
|
package/src/qa-browser.mjs
CHANGED
|
@@ -6,6 +6,8 @@ const PACKAGE_ROOT = installModeResolve(installModeDirname(installModeFileUrl(im
|
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import { runWithDeadline } from "./deadline.mjs";
|
|
8
8
|
import { PLACEHOLDER_TEXT_ASSERTION_SUFFIX, SEVERITY, STATUS } from "./qa-verdict.mjs";
|
|
9
|
+
import { contrastToolkit } from "./contrast.mjs";
|
|
10
|
+
import { generatedTextRenders } from "./polish-readability.mjs";
|
|
9
11
|
import {
|
|
10
12
|
analyticsCaptureError,
|
|
11
13
|
projectAnalyticsCaptureError,
|
|
@@ -75,6 +77,22 @@ const DEFAULT_TEST_CARD = "6011111111111117";
|
|
|
75
77
|
const DEFAULT_TEST_CVV = "123";
|
|
76
78
|
const DEFAULT_TEST_EXP_MONTH = "12";
|
|
77
79
|
const DEFAULT_TEST_EXP_YEAR = "2030";
|
|
80
|
+
// The card number and CVV iframes, matched by id prefix because each id ends
|
|
81
|
+
// in a per-load suffix. SDK 0.4.40 and earlier mount Spreedly's iFrame v1
|
|
82
|
+
// (spreedly-number-frame-1234); 0.4.41 mounts the Spreedly Checkout SDK fields
|
|
83
|
+
// that NEXT's payments.29next.com/js/v1/payment.js loads
|
|
84
|
+
// (spreedly-hosted-number-oujmii2uzjk). A page carries one generation or the
|
|
85
|
+
// other, and each frame holds a single text input. fillPaymentFields refuses
|
|
86
|
+
// by name unless exactly one number frame and one CVV frame match, so a page
|
|
87
|
+
// mounting both generations, or two forms, fails at the card step instead of
|
|
88
|
+
// on whichever frame happens to come first.
|
|
89
|
+
const CARD_NUMBER_FRAME = 'iframe[id^="spreedly-number-frame"], iframe[id^="spreedly-hosted-number"]';
|
|
90
|
+
const CARD_CVV_FRAME = 'iframe[id^="spreedly-cvv-frame"], iframe[id^="spreedly-hosted-cvv"]';
|
|
91
|
+
// Typing the card is read back and retried this many times in all before the
|
|
92
|
+
// card step fails by name.
|
|
93
|
+
const CARD_TYPE_ATTEMPTS = 3;
|
|
94
|
+
// How long the card step waits for the SDK to report the card fields ready.
|
|
95
|
+
const CARD_READY_TIMEOUT_MS = 15000;
|
|
78
96
|
const DEFAULT_MAX_TEST_ORDERS = 6;
|
|
79
97
|
// Planned-path ids listed in a refused --max-test-orders message before the
|
|
80
98
|
// remainder is counted rather than printed.
|
|
@@ -1300,9 +1318,7 @@ async function primaryCtaVisualAssertions(browserPage, page) {
|
|
|
1300
1318
|
}
|
|
1301
1319
|
|
|
1302
1320
|
function primaryCtaCheckEligible(page) {
|
|
1303
|
-
|
|
1304
|
-
const pageType = String(page.page_type || "").toLowerCase();
|
|
1305
|
-
return !["checkout", "upsell", "downsell", "thankyou", "receipt"].includes(pageType);
|
|
1321
|
+
return Boolean(page?.expected_next_url);
|
|
1306
1322
|
}
|
|
1307
1323
|
|
|
1308
1324
|
// Candidate CTAs: anything clickable, plus every SDK action control and the
|
|
@@ -1314,10 +1330,12 @@ const PRIMARY_CTA_SELECTOR = ["a[href]", "button", "[role='button']", "[data-nex
|
|
|
1314
1330
|
|
|
1315
1331
|
// The in-page half of the primary-CTA inspection, as the source text the
|
|
1316
1332
|
// page evaluates. The route rule it needs (cartEntryHrefFor, unit-tested in
|
|
1317
|
-
// qa-cart-entry)
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
1320
|
-
//
|
|
1333
|
+
// qa-cart-entry), the shared contrast helper (contrastToolkit) and the
|
|
1334
|
+
// generated-text rule Polish reads (generatedTextRenders) are handed in as
|
|
1335
|
+
// function values rather than closed over, so every function body must
|
|
1336
|
+
// stay free of module-scope references: the text is run in a fresh context by
|
|
1337
|
+
// a test (primary-CTA inspection script is self-contained) that would surface
|
|
1338
|
+
// a leaked identifier as a ReferenceError.
|
|
1321
1339
|
function primaryCtaInspectionScript(expectedUrl) {
|
|
1322
1340
|
const args = {
|
|
1323
1341
|
routeUrl: expectedUrl,
|
|
@@ -1326,7 +1344,7 @@ function primaryCtaInspectionScript(expectedUrl) {
|
|
|
1326
1344
|
cartEntryRouteAttribute: CART_ENTRY_ROUTE_ATTRIBUTE,
|
|
1327
1345
|
ignoredRouteAttributes: [...UNDECLARED_ROUTE_ATTRIBUTES],
|
|
1328
1346
|
};
|
|
1329
|
-
return `(${inspectPrimaryCtaScript.toString()})(${JSON.stringify(args)}, ${cartEntryHrefFor.toString()})`;
|
|
1347
|
+
return `(${inspectPrimaryCtaScript.toString()})(${JSON.stringify(args)}, ${cartEntryHrefFor.toString()}, ${contrastToolkit.toString()}, ${generatedTextRenders.toString()})`;
|
|
1330
1348
|
}
|
|
1331
1349
|
|
|
1332
1350
|
async function inspectPrimaryCta(browserPage, expectedUrl) {
|
|
@@ -1340,8 +1358,9 @@ async function inspectPrimaryCta(browserPage, expectedUrl) {
|
|
|
1340
1358
|
}));
|
|
1341
1359
|
}
|
|
1342
1360
|
|
|
1343
|
-
function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, cartEntryRouteAttribute, ignoredRouteAttributes }, hrefForImpl) {
|
|
1361
|
+
function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, cartEntryRouteAttribute, ignoredRouteAttributes }, hrefForImpl, contrastToolkitImpl, generatedTextImpl) {
|
|
1344
1362
|
const CTA_SELECTOR = ctaSelector;
|
|
1363
|
+
const toolkit = contrastToolkitImpl();
|
|
1345
1364
|
|
|
1346
1365
|
const trim = (value) => String(value || "").replace(/\s+/g, " ").trim();
|
|
1347
1366
|
const compactPath = (value) => String(value || "").replace(/\/+$/, "") || "/";
|
|
@@ -1352,42 +1371,6 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
|
|
|
1352
1371
|
return null;
|
|
1353
1372
|
}
|
|
1354
1373
|
})();
|
|
1355
|
-
const parseColor = (value) => {
|
|
1356
|
-
const raw = String(value || "").trim().toLowerCase();
|
|
1357
|
-
if (!raw || raw === "transparent") return null;
|
|
1358
|
-
const rgb = raw.match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*(\d?(?:\.\d+)?|1(?:\.0+)?))?\s*\)$/);
|
|
1359
|
-
if (!rgb) return null;
|
|
1360
|
-
const parts = rgb.slice(1, 4).map((part) => Number(part));
|
|
1361
|
-
if (parts.some((part) => !Number.isFinite(part) || part < 0 || part > 255)) return null;
|
|
1362
|
-
const alpha = rgb[4] === undefined ? 1 : Number(rgb[4]);
|
|
1363
|
-
return { r: parts[0], g: parts[1], b: parts[2], a: Number.isFinite(alpha) ? alpha : 1 };
|
|
1364
|
-
};
|
|
1365
|
-
const hex = (color) => color ? `#${[color.r, color.g, color.b].map((part) => Math.round(part).toString(16).padStart(2, "0")).join("")}` : null;
|
|
1366
|
-
const luminance = (color) => {
|
|
1367
|
-
const channel = (value) => {
|
|
1368
|
-
const normalized = value / 255;
|
|
1369
|
-
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
|
1370
|
-
};
|
|
1371
|
-
return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
|
|
1372
|
-
};
|
|
1373
|
-
const contrast = (a, b) => {
|
|
1374
|
-
if (!a || !b) return null;
|
|
1375
|
-
const light = Math.max(luminance(a), luminance(b));
|
|
1376
|
-
const dark = Math.min(luminance(a), luminance(b));
|
|
1377
|
-
return Math.round(((light + 0.05) / (dark + 0.05)) * 100) / 100;
|
|
1378
|
-
};
|
|
1379
|
-
const effectiveBackground = (element) => {
|
|
1380
|
-
let current = element;
|
|
1381
|
-
while (current && current.nodeType === Node.ELEMENT_NODE) {
|
|
1382
|
-
const style = getComputedStyle(current);
|
|
1383
|
-
const color = parseColor(style.backgroundColor);
|
|
1384
|
-
if (color && color.a > 0.05) {
|
|
1385
|
-
return { color, source: current === element ? "element" : current.tagName.toLowerCase() };
|
|
1386
|
-
}
|
|
1387
|
-
current = current.parentElement;
|
|
1388
|
-
}
|
|
1389
|
-
return { color: { r: 255, g: 255, b: 255, a: 1 }, source: "assumed_canvas" };
|
|
1390
|
-
};
|
|
1391
1374
|
const isVisible = (element) => {
|
|
1392
1375
|
const rect = element.getBoundingClientRect();
|
|
1393
1376
|
const style = getComputedStyle(element);
|
|
@@ -1440,56 +1423,124 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
|
|
|
1440
1423
|
// per-element detail for the listed candidates only.
|
|
1441
1424
|
const ignoredAttributes = Array.from(new Set(visibleElements.flatMap(ignoredAttributesOn))).sort();
|
|
1442
1425
|
|
|
1443
|
-
|
|
1426
|
+
// A route candidate's text, read through the shared contrast helper: each
|
|
1427
|
+
// text-bearing element inside it is compared, unrounded, with the
|
|
1428
|
+
// requirement for its own size. An element with a review trigger (or with
|
|
1429
|
+
// no ratio to compare) is a review member and is not compared; an element
|
|
1430
|
+
// whose control is disabled or loading has no colours read. Text whose
|
|
1431
|
+
// control is disabled is inactive: it is not rendered text the check can
|
|
1432
|
+
// read, so it never counts toward text_rendered or toward a pass. A
|
|
1433
|
+
// disabled candidate is not measured at all, whatever text it generates,
|
|
1434
|
+
// and never counts toward a pass. In an enabled candidate, text an element
|
|
1435
|
+
// inside it generates through ::before or ::after (generatedTextImpl) is
|
|
1436
|
+
// never measured, so the candidate is reviewed, never passed on its other
|
|
1437
|
+
// text.
|
|
1438
|
+
const measureCandidate = (element) => {
|
|
1439
|
+
if (element.matches(":disabled, [aria-disabled=\"true\"]")) {
|
|
1440
|
+
return { disabled: true, text_rendered: null, control_loading: null, contrast_ratio: null, elements: [], below: false, review: false, measured: false, inactive: false };
|
|
1441
|
+
}
|
|
1442
|
+
// Every element the page renders inside the candidate: its own subtree,
|
|
1443
|
+
// open shadow roots included.
|
|
1444
|
+
const subtree = toolkit.flatSubtree(element);
|
|
1445
|
+
const reads = subtree
|
|
1446
|
+
.filter((node) => toolkit.isTextBearing(node, window))
|
|
1447
|
+
.map((node) => toolkit.measureTextElement(node, window));
|
|
1448
|
+
const compared = [];
|
|
1449
|
+
let review = subtree.some((node) => generatedTextImpl(node, window));
|
|
1450
|
+
for (const read of reads) {
|
|
1451
|
+
if (read.disabled || read.control_loading) continue;
|
|
1452
|
+
if (read.review_reason !== null || typeof read.ratio !== "number") review = true;
|
|
1453
|
+
else compared.push(read);
|
|
1454
|
+
}
|
|
1455
|
+
const ratios = compared.map((read) => read.ratio);
|
|
1456
|
+
return {
|
|
1457
|
+
disabled: false,
|
|
1458
|
+
text_rendered: reads.some((read) => !read.disabled),
|
|
1459
|
+
control_loading: reads.some((read) => read.control_loading),
|
|
1460
|
+
contrast_ratio: ratios.length ? toolkit.displayRatio(Math.min(...ratios)) : null,
|
|
1461
|
+
elements: reads.map((read) => ({
|
|
1462
|
+
selector_path: read.selector_path,
|
|
1463
|
+
contrast_ratio_exact: read.ratio,
|
|
1464
|
+
required_ratio: read.required,
|
|
1465
|
+
size_class: read.size_class,
|
|
1466
|
+
review_reason: read.review_reason,
|
|
1467
|
+
})),
|
|
1468
|
+
below: compared.some((read) => !toolkit.meetsRequirement(read.ratio, read.required)),
|
|
1469
|
+
review,
|
|
1470
|
+
measured: compared.length > 0,
|
|
1471
|
+
inactive: reads.some((read) => read.disabled),
|
|
1472
|
+
};
|
|
1473
|
+
};
|
|
1474
|
+
|
|
1475
|
+
// Route candidates carry their measurement; other rows are listed for the
|
|
1476
|
+
// route-recognition view only and are not measured.
|
|
1477
|
+
const verdicts = new Map();
|
|
1478
|
+
const rows = visibleElements
|
|
1444
1479
|
.map((element) => {
|
|
1445
1480
|
const rect = element.getBoundingClientRect();
|
|
1446
|
-
const style = getComputedStyle(element);
|
|
1447
|
-
const fg = parseColor(style.color);
|
|
1448
|
-
const bg = effectiveBackground(element);
|
|
1449
|
-
const ratio = contrast(fg, bg.color);
|
|
1450
1481
|
const href = hrefFor(element);
|
|
1451
1482
|
const label = trim(element.innerText || element.textContent || element.getAttribute("aria-label"));
|
|
1452
1483
|
return {
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
readable: typeof ratio === "number" && ratio >= 4.5,
|
|
1465
|
-
size_ok: rect.width >= 40 && rect.height >= 20,
|
|
1484
|
+
element,
|
|
1485
|
+
row: {
|
|
1486
|
+
selector: selectorFor(element),
|
|
1487
|
+
text: label.slice(0, 120),
|
|
1488
|
+
href,
|
|
1489
|
+
route_matches: routeMatches(href),
|
|
1490
|
+
ignored_attributes: ignoredAttributesOn(element),
|
|
1491
|
+
width: Math.round(rect.width),
|
|
1492
|
+
height: Math.round(rect.height),
|
|
1493
|
+
size_ok: rect.width >= 40 && rect.height >= 20,
|
|
1494
|
+
},
|
|
1466
1495
|
};
|
|
1467
1496
|
})
|
|
1468
|
-
.filter((
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
return (b.contrast_ratio || 0) - (a.contrast_ratio || 0);
|
|
1497
|
+
.filter(({ row }) => row.text || row.href)
|
|
1498
|
+
.map(({ element, row }) => {
|
|
1499
|
+
if (!row.route_matches) return { ...row, disabled: null, text_rendered: null, control_loading: null, contrast_ratio: null, elements: null };
|
|
1500
|
+
const { below, review, measured, inactive, ...read } = measureCandidate(element);
|
|
1501
|
+
const candidate = { ...row, ...read };
|
|
1502
|
+
verdicts.set(candidate, { below, review, measured, inactive });
|
|
1503
|
+
return candidate;
|
|
1476
1504
|
});
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1505
|
+
|
|
1506
|
+
// Every route candidate counts, in document order; none is preferred.
|
|
1507
|
+
// The page outcome is the first that applies. An enabled candidate whose
|
|
1508
|
+
// only text is inactive (its control is disabled) has no rendered text;
|
|
1509
|
+
// cta_disabled is only for every candidate disabled. The page passes only
|
|
1510
|
+
// when every enabled candidate had at least one element compared, so text
|
|
1511
|
+
// that was skipped never stands in for a measurement.
|
|
1512
|
+
const routeCandidates = rows.filter((candidate) => candidate.route_matches);
|
|
1513
|
+
const enabled = routeCandidates.filter((candidate) => !candidate.disabled);
|
|
1514
|
+
const measurability = routeCandidates.length ? toolkit.documentMeasurability(document) : { measurable: true };
|
|
1515
|
+
const reason = !routeCandidates.length
|
|
1516
|
+
? "missing_route_cta"
|
|
1517
|
+
: !measurability.measurable
|
|
1518
|
+
? measurability.reason
|
|
1519
|
+
: enabled.some((candidate) => verdicts.get(candidate).below)
|
|
1520
|
+
? "low_contrast"
|
|
1521
|
+
: !routeCandidates.some((candidate) => candidate.size_ok)
|
|
1522
|
+
? "cta_too_small"
|
|
1523
|
+
: enabled.some((candidate) => !candidate.text_rendered)
|
|
1524
|
+
? "text_not_rendered"
|
|
1525
|
+
: enabled.some((candidate) => candidate.control_loading)
|
|
1526
|
+
? "control_loading"
|
|
1527
|
+
: enabled.some((candidate) => verdicts.get(candidate).review)
|
|
1528
|
+
? "contrast_review"
|
|
1529
|
+
: !enabled.length || enabled.some((candidate) => !verdicts.get(candidate).measured)
|
|
1530
|
+
? "cta_disabled"
|
|
1531
|
+
: "ok";
|
|
1532
|
+
|
|
1533
|
+
// Rows are capped at eight, except that every route candidate is listed.
|
|
1534
|
+
const listed = [];
|
|
1535
|
+
for (const row of rows) if (row.route_matches || listed.length < 8) listed.push(row);
|
|
1486
1536
|
|
|
1487
1537
|
return {
|
|
1488
|
-
ok,
|
|
1538
|
+
ok: reason === "ok",
|
|
1489
1539
|
reason,
|
|
1490
1540
|
expected_url: routeUrl,
|
|
1491
|
-
|
|
1492
|
-
|
|
1541
|
+
// The first route candidate in document order, for display only.
|
|
1542
|
+
primary: routeCandidates[0] || null,
|
|
1543
|
+
candidates: listed,
|
|
1493
1544
|
// Every route-shaped attribute seen on a visible CTA-shaped element and
|
|
1494
1545
|
// not consulted (see above), so a missing-route verdict on a page spelled
|
|
1495
1546
|
// that way reads as a vocabulary gap, not as a removed CTA.
|
|
@@ -1497,8 +1548,20 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
|
|
|
1497
1548
|
};
|
|
1498
1549
|
}
|
|
1499
1550
|
|
|
1551
|
+
// The outcomes the page itself earns as a failure. Every other outcome that
|
|
1552
|
+
// is not a pass could not be observed completely (the page was not
|
|
1553
|
+
// measurable, text or a control was not ready, a review trigger, every CTA
|
|
1554
|
+
// disabled, or an inspection the page rejected): manual review, never a pass
|
|
1555
|
+
// and never a contrast failure.
|
|
1556
|
+
const PRIMARY_CTA_FAIL_REASONS = Object.freeze([
|
|
1557
|
+
"missing_route_cta",
|
|
1558
|
+
"low_contrast",
|
|
1559
|
+
"cta_too_small",
|
|
1560
|
+
]);
|
|
1561
|
+
|
|
1500
1562
|
function primaryCtaAssertionFromEvidence(page, evidence) {
|
|
1501
1563
|
const ok = evidence?.ok === true;
|
|
1564
|
+
const review = !ok && !PRIMARY_CTA_FAIL_REASONS.includes(evidence?.reason);
|
|
1502
1565
|
const ignored = Array.isArray(evidence?.ignored_attributes) ? evidence.ignored_attributes.filter(Boolean) : [];
|
|
1503
1566
|
// Named on every verdict the page earns, passing or not: a passing page
|
|
1504
1567
|
// spelled with an undeclared route attribute is still one an operator
|
|
@@ -1509,7 +1572,7 @@ function primaryCtaAssertionFromEvidence(page, evidence) {
|
|
|
1509
1572
|
id: `browser-primary-cta:${page.page_id}`,
|
|
1510
1573
|
family: "browser-runtime",
|
|
1511
1574
|
page,
|
|
1512
|
-
status: ok ? STATUS.PASS : STATUS.FAIL,
|
|
1575
|
+
status: ok ? STATUS.PASS : review ? STATUS.MANUAL_REVIEW : STATUS.FAIL,
|
|
1513
1576
|
severity: ok ? undefined : SEVERITY.WARN,
|
|
1514
1577
|
expected: "visible readable primary CTA linked to the expected next route",
|
|
1515
1578
|
actual: ok
|
|
@@ -3821,7 +3884,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3821
3884
|
}, { timeoutMs: budget() });
|
|
3822
3885
|
await ladder.run("card_fields_filled", async () => {
|
|
3823
3886
|
ensurePageFillable(page, checkoutPage.url);
|
|
3824
|
-
|
|
3887
|
+
return fillPaymentFields(page, args);
|
|
3825
3888
|
}, { timeoutMs: budget() });
|
|
3826
3889
|
await ladder.run("cart_created", async () => {
|
|
3827
3890
|
const cart = cartCreationEvidence(events);
|
|
@@ -5036,14 +5099,69 @@ async function fillPaymentFields(page, args) {
|
|
|
5036
5099
|
|
|
5037
5100
|
const card = normalizeCard(stringArg(args["test-card"]) || DEFAULT_TEST_CARD);
|
|
5038
5101
|
const cvv = stringArg(args["test-cvv"]) || DEFAULT_TEST_CVV;
|
|
5039
|
-
const
|
|
5040
|
-
const
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
await
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
5102
|
+
const frames = await cardFrames(page);
|
|
5103
|
+
const readyWaitMs = await waitForCardFieldsReady(page);
|
|
5104
|
+
const numberInput = page.frameLocator(CARD_NUMBER_FRAME).locator("input").first();
|
|
5105
|
+
const cvvInput = page.frameLocator(CARD_CVV_FRAME).locator("input").first();
|
|
5106
|
+
const typed = async (input) => normalizeCard(await input.inputValue());
|
|
5107
|
+
let attempts = 0;
|
|
5108
|
+
let held = null;
|
|
5109
|
+
while (attempts < CARD_TYPE_ATTEMPTS) {
|
|
5110
|
+
attempts++;
|
|
5111
|
+
if (attempts > 1) {
|
|
5112
|
+
await numberInput.fill("");
|
|
5113
|
+
await cvvInput.fill("");
|
|
5114
|
+
}
|
|
5115
|
+
await numberInput.click();
|
|
5116
|
+
await numberInput.pressSequentially(card, { delay: 20 });
|
|
5117
|
+
await cvvInput.click();
|
|
5118
|
+
await cvvInput.pressSequentially(cvv, { delay: 20 });
|
|
5119
|
+
await page.locator("body").click({ position: { x: 20, y: 20 } }).catch(() => {});
|
|
5120
|
+
await page.waitForTimeout(500);
|
|
5121
|
+
held = { number: await typed(numberInput), cvv: await typed(cvvInput) };
|
|
5122
|
+
if (held.number === card && held.cvv === normalizeCard(cvv)) {
|
|
5123
|
+
return { evidence: { ...frames, ready_wait_ms: readyWaitMs, attempts } };
|
|
5124
|
+
}
|
|
5125
|
+
}
|
|
5126
|
+
throw new Error(`card fields did not keep the typed card after ${attempts} attempts: number field holds ${held.number.length} digit(s) ending ${held.number.slice(-4) || "(empty)"} (typed ${card.length} ending ${card.slice(-4)}), CVV field holds ${held.cvv.length} digit(s)`);
|
|
5127
|
+
}
|
|
5128
|
+
|
|
5129
|
+
// Typing into the card fields before the SDK reports them ready can lose
|
|
5130
|
+
// keystrokes: on SDK 0.4.41 the Spreedly-hosted number input exists before
|
|
5131
|
+
// its script has loaded, and digits typed in that window are dropped (seen
|
|
5132
|
+
// as a number missing its leading digits, which the SDK then refuses without
|
|
5133
|
+
// tokenizing, so the order never posts). The checkout form carries
|
|
5134
|
+
// next-loading-spreedly from before the card iframes mount until the fields
|
|
5135
|
+
// are ready, in 0.4.38 through 0.4.41, so wait for it to clear. A page whose
|
|
5136
|
+
// SDK never set it is ready at once. The SDK also clears it when the card
|
|
5137
|
+
// script fails to load, so a class still set after the wait means the card
|
|
5138
|
+
// script never answered; the step refuses by name rather than type into
|
|
5139
|
+
// fields that may drop the number.
|
|
5140
|
+
async function waitForCardFieldsReady(page) {
|
|
5141
|
+
const started = Date.now();
|
|
5142
|
+
await page.waitForFunction(() => !document.querySelector(".next-loading-spreedly"), null, { timeout: CARD_READY_TIMEOUT_MS }).catch((error) => {
|
|
5143
|
+
if (error?.name !== "TimeoutError") throw error;
|
|
5144
|
+
throw new Error(`card fields did not report ready within ${CARD_READY_TIMEOUT_MS / 1000}s: the checkout form still carries next-loading-spreedly, so the card script never finished loading`);
|
|
5145
|
+
});
|
|
5146
|
+
return Date.now() - started;
|
|
5147
|
+
}
|
|
5148
|
+
|
|
5149
|
+
// The card iframes the step is about to type into, once the SDK has mounted
|
|
5150
|
+
// them. Exactly one of each, of one generation, or the step fails by name.
|
|
5151
|
+
async function cardFrames(page) {
|
|
5152
|
+
await page.locator(CARD_NUMBER_FRAME).first().waitFor({ state: "attached" });
|
|
5153
|
+
await page.locator(CARD_CVV_FRAME).first().waitFor({ state: "attached" });
|
|
5154
|
+
const ids = async (selector) => page.locator(selector).evaluateAll((nodes) => nodes.map((node) => node.id));
|
|
5155
|
+
const number = await ids(CARD_NUMBER_FRAME);
|
|
5156
|
+
const cvv = await ids(CARD_CVV_FRAME);
|
|
5157
|
+
const all = [...number, ...cvv];
|
|
5158
|
+
const generation = all.every((id) => id.startsWith("spreedly-hosted-"))
|
|
5159
|
+
? "spreedly-hosted"
|
|
5160
|
+
: all.every((id) => /^spreedly-(number|cvv)-frame/.test(id)) ? "spreedly-iframe-v1" : "mixed";
|
|
5161
|
+
if (number.length !== 1 || cvv.length !== 1 || generation === "mixed") {
|
|
5162
|
+
throw new Error(`expected one card number iframe and one CVV iframe of one generation; found number=[${number.join(", ")}] cvv=[${cvv.join(", ")}]`);
|
|
5163
|
+
}
|
|
5164
|
+
return { generation, number_frame_id: number[0], cvv_frame_id: cvv[0] };
|
|
5047
5165
|
}
|
|
5048
5166
|
|
|
5049
5167
|
async function clickCreditPaymentMethod(page) {
|
package/src/qa-node.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch
|
|
|
2
2
|
import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD, starterResidueIsExpected } from "./local-preview-policy.mjs";
|
|
3
3
|
import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion, scriptParseAssertion } from './qa-binding-evidence.mjs';
|
|
4
4
|
import { shellToken } from "./shell-token.mjs";
|
|
5
|
+
import { currentBriefMaterial } from "./input-currency.mjs";
|
|
5
6
|
import { applyQaBuildScope, specForQaScope } from "./qa-build-scope.mjs";
|
|
6
7
|
import { requiredActionText } from "./gate-actions.mjs";
|
|
7
8
|
import { parseOrderPathDepthFlag } from "./proof-policy.mjs";
|
|
@@ -167,7 +168,9 @@ Options:
|
|
|
167
168
|
qa publish reads the same directory when looking up the sidecar's run.
|
|
168
169
|
--post-verdict (default) Publish the verdict to the QA portal at
|
|
169
170
|
<proxy-base>/api/qa/verdicts and print the QA portal link.
|
|
170
|
-
Publishing is automatic
|
|
171
|
+
Publishing is automatic, except for a run whose base URL is a local
|
|
172
|
+
address (localhost, *.localhost, 127.x.x.x, 0.0.0.0, [::1], an IPv4-mapped loopback): that verdict stays local unless
|
|
173
|
+
this flag is passed.
|
|
171
174
|
--no-post-verdict, --local-only Skip publishing; write only the local verdict copy (offline / dev / CI).
|
|
172
175
|
Publish it later, without a re-run, with qa publish.
|
|
173
176
|
--verdict <path> qa promote / qa publish: the full verdict file under qa-output/. qa publish
|
|
@@ -468,6 +471,9 @@ async function resolveQaInputs(args, {
|
|
|
468
471
|
routeRootNote: routeRootNotes[0] || null,
|
|
469
472
|
});
|
|
470
473
|
const specHash = computeSpecHash(rawSpec);
|
|
474
|
+
// The brief material this run is judged against, bound at run start beside
|
|
475
|
+
// the spec hash: the QA stage write stamps it, not the material at write time.
|
|
476
|
+
const sourceBriefMaterial = packet ? currentBriefMaterial({ packet, packetPath }) : null;
|
|
471
477
|
const templateFamily = stringArg(packet?.assembly?.template_family)
|
|
472
478
|
|| stringArg(normalized?.spec_identity?.preferred_template_family)
|
|
473
479
|
|| stringArg(normalized?.campaign?.preferred_template_family)
|
|
@@ -524,6 +530,7 @@ async function resolveQaInputs(args, {
|
|
|
524
530
|
spec: normalized,
|
|
525
531
|
specVersion: String(rawSpec.schema_version || rawSpec.schemaVersion || "unknown"),
|
|
526
532
|
specHash,
|
|
533
|
+
sourceBriefMaterial,
|
|
527
534
|
templateFamily,
|
|
528
535
|
commerceStructureContract,
|
|
529
536
|
topologies: qaScope.topologies,
|
|
@@ -583,7 +590,7 @@ function resolvePacketCheckpointPreflight(args, {
|
|
|
583
590
|
}
|
|
584
591
|
if (packet?.spec?.local_spec_id != null && (!reportMatchesPacketIdentity(report, packet)
|
|
585
592
|
|| (specStatus === "ok" && !specHashesMatch(report.identity?.spec_material_hash, computeSpecHash(rawSpec))))) {
|
|
586
|
-
throw new Error("Local-spec QA requires the matching Assembly Report and current spec material hash.
|
|
593
|
+
throw new Error("Local-spec QA requires the matching Assembly Report and current spec material hash. Run `record spec` after a material revision (it keeps stage history); do not reuse foreign or stale proof.");
|
|
587
594
|
}
|
|
588
595
|
const checkpointGates = [
|
|
589
596
|
evaluatePageKitStoreProfile({
|
|
@@ -1585,6 +1592,12 @@ function reportPathField(resolved) {
|
|
|
1585
1592
|
return reportPath ? { report_path: reportPath } : {};
|
|
1586
1593
|
}
|
|
1587
1594
|
|
|
1595
|
+
// The verdict with the brief material qa run bound at run start, when the
|
|
1596
|
+
// run had a packet to bind it from.
|
|
1597
|
+
function withSourceBriefMaterial(verdict, sourceBriefMaterial) {
|
|
1598
|
+
return sourceBriefMaterial ? { ...verdict, source_brief_material: sourceBriefMaterial } : verdict;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1588
1601
|
function resolvePayload(resolved, { routeProbe = null } = {}) {
|
|
1589
1602
|
const entryUrls = deriveEntryUrls(resolved.topologies);
|
|
1590
1603
|
const pageUrls = derivePageUrls(resolved.topologies);
|
|
@@ -2598,7 +2611,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2598
2611
|
// The verdict as it is persisted: the local file, the committed sidecar and
|
|
2599
2612
|
// the published copy all read this one projection (no URL query in any
|
|
2600
2613
|
// string or key), whichever runner, return path or field produced a value.
|
|
2601
|
-
const verdict = redactPersisted(createVerdict({
|
|
2614
|
+
const verdict = redactPersisted(withSourceBriefMaterial(createVerdict({
|
|
2602
2615
|
runId,
|
|
2603
2616
|
mapId: resolved.mapId,
|
|
2604
2617
|
localSpecId: resolved.localSpecId,
|
|
@@ -2619,7 +2632,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2619
2632
|
commercial,
|
|
2620
2633
|
causeSummary,
|
|
2621
2634
|
browser,
|
|
2622
|
-
}));
|
|
2635
|
+
}), resolved.sourceBriefMaterial));
|
|
2623
2636
|
|
|
2624
2637
|
const validationErrors = validateVerdict(verdict);
|
|
2625
2638
|
if (validationErrors.length) throw new Error(`QA verdict failed local validation:\n- ${validationErrors.join("\n- ")}`);
|
|
@@ -2657,7 +2670,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2657
2670
|
const consent = resolveConsent({ proxyBase: resolved.proxyBase });
|
|
2658
2671
|
const publishDecision = resolved.localSpecId
|
|
2659
2672
|
? { publish: false, reason: "local_spec", flag_invalid: false }
|
|
2660
|
-
: decidePublishVerdict({ args, portalManaged: resolved.portalManaged === true, consent });
|
|
2673
|
+
: decidePublishVerdict({ args, portalManaged: resolved.portalManaged === true, consent, baseUrl: resolved.baseUrl });
|
|
2661
2674
|
if (publishDecision.flag_invalid) {
|
|
2662
2675
|
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`);
|
|
2663
2676
|
}
|
|
@@ -3461,6 +3474,9 @@ function output(value, args) {
|
|
|
3461
3474
|
} else if (value.publish_skipped && value.publish_decision?.reason === "consent_off") {
|
|
3462
3475
|
console.log(`QA portal: publish skipped — telemetry consent is off, so this non-portal-managed verdict stays local.`);
|
|
3463
3476
|
console.log(` Destination would be ${value.publish_decision.destination}. Opt in for this run with --post-verdict, or enable with \`${cmd("telemetry")} on\`.`);
|
|
3477
|
+
} else if (value.publish_skipped && value.publish_decision?.reason === "loopback_base_url") {
|
|
3478
|
+
console.log(`QA portal: publish skipped — the base URL is a local address, so this verdict stays local.`);
|
|
3479
|
+
console.log(` Destination would be ${value.publish_decision.destination}. Publish this run with \`${cmd("qa")} publish\`, or pass --post-verdict on the next run.`);
|
|
3464
3480
|
} else if (value.publish_skipped) {
|
|
3465
3481
|
console.log(`QA portal: publish skipped (--no-post-verdict); local verdict only.`);
|
|
3466
3482
|
} else {
|
|
@@ -3746,8 +3762,13 @@ export function forcedAnalyticsCorrectness(args) {
|
|
|
3746
3762
|
// else (client projects, fixtures, local shakeouts on a local spec), consent
|
|
3747
3763
|
// off (CAMPAIGNS_OS_TELEMETRY=off / `campaigns-os telemetry off`) means the
|
|
3748
3764
|
// verdict stays local, with the destination and the opt-in flag named in the
|
|
3749
|
-
// run output. Publishing for the portal path is never weakened.
|
|
3750
|
-
|
|
3765
|
+
// run output. Publishing for the portal path is never weakened by consent.
|
|
3766
|
+
// #486: a run against a local-address base URL (localhost or any *.localhost
|
|
3767
|
+
// name, any 127.x.x.x, 0.0.0.0, [::1] or an IPv4-mapped loopback; see
|
|
3768
|
+
// isLocalAddressUrl below) is a local check, so by default its verdict stays
|
|
3769
|
+
// local whatever the spec source; the output names --post-verdict and qa
|
|
3770
|
+
// publish. An explicit --post-verdict still publishes.
|
|
3771
|
+
export function decidePublishVerdict({ args = {}, portalManaged = false, consent = null, baseUrl = null } = {}) {
|
|
3751
3772
|
if (args["no-post-verdict"] === true || args["local-only"] === true) {
|
|
3752
3773
|
return { publish: false, reason: "flag_opt_out" };
|
|
3753
3774
|
}
|
|
@@ -3764,11 +3785,30 @@ export function decidePublishVerdict({ args = {}, portalManaged = false, consent
|
|
|
3764
3785
|
flagInvalid = true;
|
|
3765
3786
|
}
|
|
3766
3787
|
const decorate = (decision) => (flagInvalid ? { ...decision, flag_invalid: true } : decision);
|
|
3788
|
+
if (isLocalAddressUrl(baseUrl)) return decorate({ publish: false, reason: "loopback_base_url" });
|
|
3767
3789
|
if (portalManaged) return decorate({ publish: true, reason: "portal_managed_default" });
|
|
3768
3790
|
if (consent?.state === "off") return decorate({ publish: false, reason: "consent_off" });
|
|
3769
3791
|
return decorate({ publish: true, reason: "default" });
|
|
3770
3792
|
}
|
|
3771
3793
|
|
|
3794
|
+
// The publish default (#486) reads "local" more widely than the shared
|
|
3795
|
+
// LOOPBACK_HOSTNAMES list, which also decides where plain http is allowed and
|
|
3796
|
+
// so stays narrow: any 127.0.0.0/8 address, 0.0.0.0, [::1], an IPv4-mapped
|
|
3797
|
+
// loopback, and localhost or any *.localhost name (with or without a trailing
|
|
3798
|
+
// dot). Keeping such a run local is the safe direction: --post-verdict and
|
|
3799
|
+
// qa publish still send it.
|
|
3800
|
+
function isLocalAddressUrl(value) {
|
|
3801
|
+
if (typeof value !== "string" || !value.trim()) return false;
|
|
3802
|
+
let hostname;
|
|
3803
|
+
try { hostname = new URL(value.trim()).hostname.toLowerCase(); } catch { return false; }
|
|
3804
|
+
const name = hostname.replace(/\.$/, "");
|
|
3805
|
+
if (name === "localhost" || name.endsWith(".localhost")) return true;
|
|
3806
|
+
if (/^127(?:\.\d{1,3}){3}$/.test(name) || name === "0.0.0.0") return true;
|
|
3807
|
+
if (name === "[::1]" || name === "[::]") return true;
|
|
3808
|
+
// WHATWG URL serializes ::ffff:127.x.y.z as [::ffff:7fxx:xxxx].
|
|
3809
|
+
return /^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/.test(name);
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3772
3812
|
function policySnapshot(packet) {
|
|
3773
3813
|
return {
|
|
3774
3814
|
campaign: {
|