@nextcommerce/campaigns-os 1.61.0 → 1.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +127 -0
- package/README.md +86 -438
- package/agents/claude/CLAUDE.md +9 -4
- package/agents/codex/AGENTS.md +8 -4
- package/agents/copilot/copilot-instructions.md +8 -4
- package/agents/cursor/campaigns-os.mdc +10 -4
- package/campaign-spec/README.md +7 -0
- package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
- package/campaign-spec/dist/analytics-vendors.js +44 -0
- package/campaign-spec/dist/checkout-flow.d.ts +18 -0
- package/campaign-spec/dist/checkout-flow.js +28 -0
- package/campaign-spec/dist/index.d.ts +4 -2
- package/campaign-spec/dist/index.js +5 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
- package/campaign-spec/dist/rules/campaign-currencies.d.ts +30 -0
- package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
- package/campaign-spec/dist/rules/index.d.ts +3 -0
- package/campaign-spec/dist/rules/index.js +9 -0
- package/campaign-spec/dist/rules/package-currency-prices.d.ts +29 -0
- package/campaign-spec/dist/rules/package-currency-prices.js +137 -0
- package/campaign-spec/dist/types.d.ts +59 -9
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +59 -53
- package/contracts/effects.v1.json +549 -165
- package/contracts/fixtures/runtime-recipe/accept/current.json +4 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +4 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +4 -0
- package/contracts/release-ledger.json +1260 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +4 -0
- package/contracts/supported-surface.json +8 -8
- package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
- package/docs/build-packet.md +430 -37
- package/docs/campaign-standardization-report.md +4 -4
- package/docs/campaigns-os-build-flow.md +54 -3
- package/docs/campaignspec-authoring-examples.md +59 -4
- package/docs/design-source-package.md +18 -6
- package/docs/effects.md +17 -9
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +2 -0
- package/docs/progress-snapshots.md +7 -4
- package/docs/qa-and-test-orders.md +147 -1
- package/docs/readback.md +40 -5
- package/docs/runtime-readiness.md +7 -3
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
- package/schemas/campaign-spec.v4.schema.json +34 -3
- package/schemas/campaign-spec.v5.schema.json +34 -3
- package/schemas/campaigns-os-readback.v2.schema.json +62 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +4 -3
- package/skills/next-campaigns-os/SKILL.md +6 -4
- package/skills/next-campaigns-os-setup/SKILL.md +5 -3
- package/skills/next-campaigns-polish/SKILL.md +5 -4
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
- package/skills.json +11 -11
- package/src/brand-theme.mjs +2 -1
- package/src/build-brief.mjs +4 -1
- package/src/campaign-workspace.mjs +288 -15
- package/src/change-scope.mjs +284 -0
- package/src/cli.mjs +308 -86
- package/src/commercial-journey.mjs +16 -3
- package/src/commercial-parity.mjs +15 -7
- package/src/content-residue.mjs +4 -2
- package/src/design-source-package.mjs +36 -0
- package/src/design-source-publication.mjs +9 -6
- package/src/directory-lock.mjs +73 -19
- package/src/doctor/checks.mjs +79 -7
- package/src/doctor/ci.mjs +75 -10
- package/src/doctor/inspect.mjs +23 -6
- package/src/doctor/next-step.mjs +7 -0
- package/src/doctor-sidecar.mjs +3 -2
- package/src/evidence-debt.mjs +47 -7
- package/src/finding-cause.mjs +12 -11
- package/src/input-refresh.mjs +339 -32
- package/src/invocation.mjs +7 -1
- package/src/live-campaign-refs.mjs +7 -2
- package/src/local-preview-policy.mjs +35 -2
- package/src/page-kit-analytics-vendors.mjs +132 -0
- package/src/polish-browser.mjs +3 -1
- package/src/polish-readability.mjs +221 -9
- package/src/polish-visual.mjs +9 -2
- package/src/progress-node.mjs +3 -2
- package/src/qa-analytics-parity.mjs +17 -1
- package/src/qa-browser.mjs +40 -4
- package/src/qa-commercial-parity.mjs +50 -3
- package/src/qa-node.mjs +196 -16
- package/src/qa-publish.mjs +9 -8
- package/src/qa-sidecar.mjs +16 -4
- package/src/qa-vendor-beacons.mjs +232 -0
- package/src/qa-vendor-endpoints.mjs +306 -0
- package/src/qc-accept.mjs +29 -1
- package/src/qc-check-registry.mjs +5 -1
- package/src/qc-results.mjs +73 -26
- package/src/readback.mjs +58 -10
- package/src/route-identity.mjs +10 -0
- package/src/run-record.mjs +18 -10
- package/src/runtime-state-ignore.mjs +6 -2
- package/src/sdk-storage-compatibility.mjs +21 -2
- package/src/source-html-intake.mjs +15 -1
- package/src/spec-derive-campaign.mjs +216 -0
- package/src/spec-derive.mjs +473 -53
- package/src/spec-identity.mjs +14 -0
- package/src/stage-ledger.mjs +40 -1
- package/src/stage-record.mjs +58 -13
- package/src/target-lock.mjs +11 -1
|
@@ -22,6 +22,9 @@ import { normalizePublicRouteSlug } from "./route-identity.mjs";
|
|
|
22
22
|
|
|
23
23
|
export const LOCAL_PREVIEW_POLICY = "local_preview";
|
|
24
24
|
export const HOSTED_TEMPLATE_PREVIEW_POLICY = "hosted_template_preview";
|
|
25
|
+
// A page-only change since Polish last reviewed the campaign (#720): Polish
|
|
26
|
+
// is advisory for it, on any target, so its stale evidence is carried.
|
|
27
|
+
export const PAGE_ONLY_CHANGE_POLICY = "page_only_change";
|
|
25
28
|
export const CARRIED_FORWARD = "carried_forward";
|
|
26
29
|
|
|
27
30
|
// The checks carried forward, one by one. Each is evidence the campaign does
|
|
@@ -136,7 +139,9 @@ export function carried(gate, policy = LOCAL_PREVIEW_POLICY, packet = null) {
|
|
|
136
139
|
...(policy === HOSTED_TEMPLATE_PREVIEW_POLICY
|
|
137
140
|
? { required_actions: [hostedTemplateQaAction(previewUrl)] }
|
|
138
141
|
: {}),
|
|
139
|
-
|
|
142
|
+
// A page-only carry stands on a review of an older build, so its evidence
|
|
143
|
+
// is stale, except where no Polish was ever recorded.
|
|
144
|
+
carried_forward: { policy, from_status: gate.status, evidence: policy === PAGE_ONLY_CHANGE_POLICY && gate.code !== "polish.evidence_missing" ? "stale" : "missing" },
|
|
140
145
|
};
|
|
141
146
|
}
|
|
142
147
|
|
|
@@ -156,13 +161,38 @@ export function applyLocalPreviewToCheckpoint(gate, { packet, report, baseUrl =
|
|
|
156
161
|
}
|
|
157
162
|
|
|
158
163
|
// The polish gate folds the checkpoint in, so it is carried forward only when
|
|
159
|
-
// whatever it owns from the checkpoint was carried forward too.
|
|
164
|
+
// whatever it owns from the checkpoint was carried forward too. Advisory
|
|
165
|
+
// Polish for a page-only change is never a ladder step.
|
|
160
166
|
export function polishCarriedForwardForLadder(report, gate) {
|
|
167
|
+
if (gate?.status === CARRIED_FORWARD && gate.carried_forward?.policy === PAGE_ONLY_CHANGE_POLICY) return true;
|
|
161
168
|
return gate?.status === CARRIED_FORWARD
|
|
162
169
|
&& !report?.theme?.waiver
|
|
163
170
|
&& !report?.stages?.polish?.evidence?.visual_review?.page_load;
|
|
164
171
|
}
|
|
165
172
|
|
|
173
|
+
// The Polish gate codes and checkpoint codes that mean "evidence for an older
|
|
174
|
+
// build": the only blocks a page-only change carries. A Polish recorded as
|
|
175
|
+
// blocked, self-certified or incomplete stays a block.
|
|
176
|
+
const PAGE_ONLY_POLISH_CODES = Object.freeze(new Set(["polish.evidence_missing", "polish.stale", "polish.output_drift"]));
|
|
177
|
+
const PAGE_ONLY_CHECKPOINT_CODES = Object.freeze(new Set(["polish.hidden_eager_media.capture_stale"]));
|
|
178
|
+
|
|
179
|
+
export function isPageOnlyCarry(gate) {
|
|
180
|
+
return gate?.status === CARRIED_FORWARD && gate.carried_forward?.policy === PAGE_ONLY_CHANGE_POLICY;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// `debt` is assessEvidenceDebt's result for the same packet and report.
|
|
184
|
+
export function applyPageOnlyChangeToCheckpoint(gate, debt) {
|
|
185
|
+
if (gate?.status !== "blocked" || debt?.polish?.advisory !== true || !PAGE_ONLY_CHECKPOINT_CODES.has(gate.code)) return gate;
|
|
186
|
+
return carried(gate, PAGE_ONLY_CHANGE_POLICY);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function applyPageOnlyChangeToPolishGate(gate, { debt, checkpointGate = null } = {}) {
|
|
190
|
+
if (gate?.status !== "blocked" || debt?.polish?.advisory !== true) return gate;
|
|
191
|
+
if (gate.owned_checkpoint_only) return isPageOnlyCarry(checkpointGate) ? carried(gate, PAGE_ONLY_CHANGE_POLICY) : gate;
|
|
192
|
+
if (gate.owned_checkpoint_status === "blocked" && !isPageOnlyCarry(checkpointGate) && checkpointGate?.status !== CARRIED_FORWARD) return gate;
|
|
193
|
+
return PAGE_ONLY_POLISH_CODES.has(gate.code) ? carried(gate, PAGE_ONLY_CHANGE_POLICY) : gate;
|
|
194
|
+
}
|
|
195
|
+
|
|
166
196
|
export function applyLocalPreviewToPolishGate(gate, { packet, checkpointGate = null, baseUrl = null } = {}) {
|
|
167
197
|
if (gate?.status !== "blocked") return gate;
|
|
168
198
|
// A template-stock map has no route that capture can measure, even when
|
|
@@ -189,6 +219,9 @@ export function starterResidueIsExpected(themeGate, { packet, baseUrl = null } =
|
|
|
189
219
|
}
|
|
190
220
|
|
|
191
221
|
export function carriedForwardMessage(gate) {
|
|
222
|
+
if (gate?.carried_forward?.policy === PAGE_ONLY_CHANGE_POLICY) {
|
|
223
|
+
return `Advisory for a page-only change: ${gate.reason} The change since Polish last reviewed this campaign touches only page copy, images or styling, so Polish is reported and not required; run it when the change needs a visual review.`;
|
|
224
|
+
}
|
|
192
225
|
if (gate?.carried_forward?.policy === HOSTED_TEMPLATE_PREVIEW_POLICY) {
|
|
193
226
|
return `Carried forward on the hosted preview: ${gate.reason} This evidence is missing, not passed; production QA still requires it.`;
|
|
194
227
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Doctor's advisory read of the starter-template vendor keys (#689): whether
|
|
2
|
+
// the vendors the CampaignSpec declares under analytics.vendors and the
|
|
3
|
+
// vendors the target's _data/campaigns.json entry switches on agree.
|
|
4
|
+
//
|
|
5
|
+
// A vendor adapter loads when its id key in the entry is non-empty (absent or
|
|
6
|
+
// empty is off), so the entry is what ships. The spec is what the Map says
|
|
7
|
+
// should ship, and spec derive mirrors the id from the entry back into it.
|
|
8
|
+
// Every finding is a warning: a vendor that is off, on by accident or pointed
|
|
9
|
+
// at a placeholder account loses or pollutes attribution, but never an order,
|
|
10
|
+
// and the operator may have a reason. Read-only: nothing is written.
|
|
11
|
+
import { ANALYTICS_VENDORS, isPlaceholderVendorId } from "../campaign-spec/dist/index.js";
|
|
12
|
+
import { PAGE_KIT_CAMPAIGNS_REL_PATH } from "./page-kit-campaign-config.mjs";
|
|
13
|
+
import { SPEC_DERIVE_COMMAND } from "./page-kit-sdk-version.mjs";
|
|
14
|
+
|
|
15
|
+
export const PAGE_KIT_ANALYTICS_VENDORS_SCOPE = "page_kit.analytics_vendors";
|
|
16
|
+
|
|
17
|
+
function isPlainObject(value) {
|
|
18
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The analytics kill switch the Map Builder honours: with it set, the spec
|
|
22
|
+
// compiler writes no vendor key, so the spec declares no vendor.
|
|
23
|
+
function analyticsDisabled(analytics) {
|
|
24
|
+
return isPlainObject(analytics) && (analytics.enabled === false || analytics.mode === "disabled");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The template gate is `campaign.<key> and campaign.<key> != ""`: any value
|
|
28
|
+
// but an absent, null, false or blank one switches the vendor on.
|
|
29
|
+
function keySwitchesVendorOn(value) {
|
|
30
|
+
if (value === undefined || value === null || value === false) return false;
|
|
31
|
+
return typeof value !== "string" || value.trim() !== "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The vendors the spec declares, keyed by vendor: an object entry not turned
|
|
35
|
+
// off with enabled:false. A declared vendor with no id is still declared (the
|
|
36
|
+
// AnalyticsContractShape rule names the missing id).
|
|
37
|
+
function declaredVendors(analytics) {
|
|
38
|
+
const declared = new Map();
|
|
39
|
+
if (!isPlainObject(analytics) || analyticsDisabled(analytics) || !isPlainObject(analytics.vendors)) return declared;
|
|
40
|
+
for (const { key } of ANALYTICS_VENDORS) {
|
|
41
|
+
const vendor = analytics.vendors[key];
|
|
42
|
+
if (isPlainObject(vendor) && vendor.enabled !== false) declared.set(key, vendor);
|
|
43
|
+
}
|
|
44
|
+
return declared;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Compare the spec's vendors with the target entry. `targetLoad` is the
|
|
48
|
+
// loadPageKitCampaignEntry result doctor already holds. Returns the vendors
|
|
49
|
+
// either side switches on and one finding per disagreement; findings name keys
|
|
50
|
+
// and vendors only, never a value read from campaigns.json.
|
|
51
|
+
export function evaluatePageKitAnalyticsVendors({ spec, targetLoad }) {
|
|
52
|
+
// Without a readable spec there is nothing to compare the entry with; the
|
|
53
|
+
// spec checks report why it is unreadable.
|
|
54
|
+
if (!isPlainObject(spec) || targetLoad?.status !== "ok" || !isPlainObject(targetLoad.entry)) {
|
|
55
|
+
return { scope: PAGE_KIT_ANALYTICS_VENDORS_SCOPE, status: "not_applicable", vendors: [], findings: [] };
|
|
56
|
+
}
|
|
57
|
+
const entry = targetLoad.entry;
|
|
58
|
+
const slug = targetLoad.public_route_slug;
|
|
59
|
+
const source = (key) => `${PAGE_KIT_CAMPAIGNS_REL_PATH}[${slug}].${key}`;
|
|
60
|
+
const analytics = spec?.analytics;
|
|
61
|
+
const disabled = analyticsDisabled(analytics);
|
|
62
|
+
const declared = declaredVendors(analytics);
|
|
63
|
+
const vendors = [];
|
|
64
|
+
const findings = [];
|
|
65
|
+
const finding = (code, def, message, repair) => findings.push({
|
|
66
|
+
code: `${PAGE_KIT_ANALYTICS_VENDORS_SCOPE}.${code}`,
|
|
67
|
+
vendor: def.key,
|
|
68
|
+
id_key: def.idKey,
|
|
69
|
+
message,
|
|
70
|
+
repair,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
for (const def of ANALYTICS_VENDORS) {
|
|
74
|
+
const field = `analytics.vendors.${def.key}`;
|
|
75
|
+
const raw = Object.hasOwn(entry, def.idKey) ? entry[def.idKey] : undefined;
|
|
76
|
+
const on = keySwitchesVendorOn(raw);
|
|
77
|
+
const specVendor = declared.get(def.key);
|
|
78
|
+
if (on || specVendor) vendors.push(def.key);
|
|
79
|
+
if (specVendor && !on) {
|
|
80
|
+
finding(
|
|
81
|
+
"key_missing",
|
|
82
|
+
def,
|
|
83
|
+
`The CampaignSpec declares ${field}, but ${source(def.idKey)} is absent or empty, so the template never loads ${def.label} and it receives no events. Set the key (the Map's spec compiler writes it), or remove the vendor from the Map if the campaign should not send to ${def.label}.`,
|
|
84
|
+
{ kind: "edit", target: source(def.idKey) },
|
|
85
|
+
);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!specVendor && on) {
|
|
89
|
+
// spec derive can record an undeclared vendor; it cannot undo an
|
|
90
|
+
// authored "off", so that case is pointed at the Map instead.
|
|
91
|
+
const turnedOff = disabled
|
|
92
|
+
? "turns analytics off (analytics.enabled false or mode disabled)"
|
|
93
|
+
: isPlainObject(analytics?.vendors?.[def.key]) ? `turns ${field} off (enabled: false)` : null;
|
|
94
|
+
finding(
|
|
95
|
+
"undeclared",
|
|
96
|
+
def,
|
|
97
|
+
turnedOff
|
|
98
|
+
? `${source(def.idKey)} switches ${def.label} on, but the CampaignSpec ${turnedOff}. The template loads ${def.label} on every page regardless. Clear the key if ${def.label} should be off, or turn it back on in the Map.`
|
|
99
|
+
: `${source(def.idKey)} switches ${def.label} on, but the CampaignSpec does not declare ${field}. The template loads ${def.label} on every page. Record it in the spec with \`${SPEC_DERIVE_COMMAND}\` (or in the Map), or clear the key if ${def.label} should be off.`,
|
|
100
|
+
turnedOff ? { kind: "edit", target: source(def.idKey) } : { kind: "command", command: SPEC_DERIVE_COMMAND },
|
|
101
|
+
);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!specVendor) continue;
|
|
105
|
+
const value = typeof raw === "string" ? raw.trim() : null;
|
|
106
|
+
if (value !== null && isPlaceholderVendorId(value)) {
|
|
107
|
+
finding(
|
|
108
|
+
"key_placeholder",
|
|
109
|
+
def,
|
|
110
|
+
`${source(def.idKey)} holds a placeholder, not a real ${def.label} id. The template loads ${def.label} for any non-empty value, so events go to an account that does not exist. Replace it with the merchant's id.`,
|
|
111
|
+
{ kind: "edit", target: source(def.idKey) },
|
|
112
|
+
);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const specId = typeof specVendor.id === "string" ? specVendor.id.trim() : "";
|
|
116
|
+
if (specId && value !== null && value !== specId) {
|
|
117
|
+
finding(
|
|
118
|
+
"id_mismatch",
|
|
119
|
+
def,
|
|
120
|
+
`${source(def.idKey)} and ${field}.id name different ${def.label} ids. The campaigns.json value is what ships; \`${SPEC_DERIVE_COMMAND}\` mirrors it into the spec, or correct the key if the spec is right.`,
|
|
121
|
+
{ kind: "command", command: SPEC_DERIVE_COMMAND },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
scope: PAGE_KIT_ANALYTICS_VENDORS_SCOPE,
|
|
128
|
+
status: findings.length ? "advisory" : "ok",
|
|
129
|
+
vendors,
|
|
130
|
+
findings,
|
|
131
|
+
};
|
|
132
|
+
}
|
package/src/polish-browser.mjs
CHANGED
|
@@ -849,7 +849,8 @@ async function runImageProbe({ session, mainFrame, documentContextChanged, probe
|
|
|
849
849
|
// screenshot failed, crop_unavailable; so does every crop taken when the
|
|
850
850
|
// bound ends before the re-read. crop_ms runs from the first crop step to the
|
|
851
851
|
// end of the re-read.
|
|
852
|
-
// Returns { status, capped, coverage_gaps, elements, crops, probe_ms,
|
|
852
|
+
// Returns { status, capped, coverage_gaps, elements, crops, layout, probe_ms,
|
|
853
|
+
// crop_ms }; layout only when the probe read it.
|
|
853
854
|
async function probeReadabilityDocument(options) {
|
|
854
855
|
const token = { cancelled: false };
|
|
855
856
|
const cropToken = { cancelled: false };
|
|
@@ -946,6 +947,7 @@ async function runReadabilityProbe({ session, mainFrame, documentContextChanged,
|
|
|
946
947
|
coverage_gaps: read.coverage_gaps,
|
|
947
948
|
elements,
|
|
948
949
|
crops,
|
|
950
|
+
...(read.layout ? { layout: read.layout } : {}),
|
|
949
951
|
probe_ms: probeMs,
|
|
950
952
|
crop_ms: cropMs,
|
|
951
953
|
};
|
|
@@ -17,14 +17,19 @@
|
|
|
17
17
|
// the fields the helper derives from them;
|
|
18
18
|
// - coverage_gaps[]: what the probe could not measure: closed shadow roots,
|
|
19
19
|
// cross-origin frames, loading, hidden or generated-text role elements, and
|
|
20
|
-
// selected, active or expanded states not present at load
|
|
20
|
+
// selected, active or expanded states not present at load;
|
|
21
|
+
// - layout (cells of a record that carries layout_conventions, measured cells
|
|
22
|
+
// only): the viewport size and the floating CTA containers the probe found,
|
|
23
|
+
// read by the layout.conventions check (LAYOUT_QC_RULES).
|
|
21
24
|
import { createHash } from "node:crypto";
|
|
22
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
23
|
-
import { join, relative, sep } from "node:path";
|
|
25
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
24
28
|
|
|
25
29
|
import { resolveBuiltSiteScope } from "./built-site-scope.mjs";
|
|
26
30
|
import { CART_PLACEHOLDERS_LIMITS } from "./cart-placeholders.mjs";
|
|
27
31
|
import { contrastToolkit } from "./contrast.mjs";
|
|
32
|
+
import { canonicalJson } from "./polish-capture.mjs";
|
|
28
33
|
import {
|
|
29
34
|
READABILITY_ADDED_RUN_MS,
|
|
30
35
|
READABILITY_CROP_RUN_MS,
|
|
@@ -35,6 +40,7 @@ import { MAX_POLISH_CAPTURE_ROUTES, POLISH_CAPTURE_VIEWPORTS } from "./polish-no
|
|
|
35
40
|
import { QC_PRODUCERS, aggregateQcResults, polishRecordIntegrity } from "./qc-results.mjs";
|
|
36
41
|
|
|
37
42
|
export const READABILITY_CHECK = "readability.contrast";
|
|
43
|
+
export const LAYOUT_CHECK = "layout.conventions";
|
|
38
44
|
export const READABILITY_SCHEMA = "campaigns-os-polish-readability/v0";
|
|
39
45
|
export const READABILITY_PRODUCER = QC_PRODUCERS.polish;
|
|
40
46
|
export const READABILITY_ROUTE_SOURCE = "built_site";
|
|
@@ -402,6 +408,162 @@ export const READABILITY_QC_RULES = Object.freeze({
|
|
|
402
408
|
},
|
|
403
409
|
});
|
|
404
410
|
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Layout conventions
|
|
413
|
+
|
|
414
|
+
// The layout_conventions block of the shared commerce brand contract, without
|
|
415
|
+
// its prose (`rule` fields): the selectors the probe reads with and the
|
|
416
|
+
// thresholds the layout.conventions check judges with. A record stores the
|
|
417
|
+
// block it was measured with; a reader re-derives only with the same block.
|
|
418
|
+
const SHARED_COMMERCE_CONTRACT = join(dirname(fileURLToPath(import.meta.url)), "..", "contracts", "template-brand-contract.shared-commerce.v0.json");
|
|
419
|
+
const withoutProse = (value) => (isPlainObject(value)
|
|
420
|
+
? Object.freeze(Object.fromEntries(Object.entries(value).filter(([field]) => field !== "rule").map(([field, entry]) => [field, withoutProse(entry)])))
|
|
421
|
+
: Array.isArray(value) ? Object.freeze(value.map(withoutProse)) : value);
|
|
422
|
+
export const LAYOUT_CONVENTIONS = withoutProse(JSON.parse(readFileSync(SHARED_COMMERCE_CONTRACT, "utf8")).layout_conventions);
|
|
423
|
+
|
|
424
|
+
// What the probe needs from the block: the floating CTA selector contract.
|
|
425
|
+
const layoutProbeArgs = () => {
|
|
426
|
+
const { positions, control_selector, exclude_selector, form_field_selector } = LAYOUT_CONVENTIONS.floating_cta;
|
|
427
|
+
return { positions, control_selector, exclude_selector, form_field_selector };
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const isRect = (value) => isPlainObject(value) && ["x", "y", "width", "height"].every((field) => Number.isFinite(value[field]));
|
|
431
|
+
const isPathList = (value) => Array.isArray(value) && value.every(isNonEmptyString);
|
|
432
|
+
|
|
433
|
+
// The layout a measured cell records: the probe's viewport and one entry per
|
|
434
|
+
// floating CTA container, with its rect, its controls (each with its rect)
|
|
435
|
+
// and the selector paths of the visible text outside them.
|
|
436
|
+
export function layoutShapeOk(layout) {
|
|
437
|
+
return isPlainObject(layout)
|
|
438
|
+
&& isPlainObject(layout.viewport) && Number.isFinite(layout.viewport.width) && Number.isFinite(layout.viewport.height)
|
|
439
|
+
&& Array.isArray(layout.floating_ctas)
|
|
440
|
+
&& layout.floating_ctas.every((entry) => isPlainObject(entry)
|
|
441
|
+
&& isNonEmptyString(entry.selector_path)
|
|
442
|
+
&& LAYOUT_CONVENTIONS.floating_cta.positions.includes(entry.position)
|
|
443
|
+
&& isRect(entry.rect)
|
|
444
|
+
&& Array.isArray(entry.controls) && entry.controls.every((control) => isPlainObject(control) && isNonEmptyString(control.selector_path) && isRect(control.rect))
|
|
445
|
+
&& isPathList(entry.other_text)
|
|
446
|
+
&& Number.isInteger(entry.other_text_count) && entry.other_text_count >= entry.other_text.length);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const FLOATING_FIELDS = Object.freeze(["selector_path", "position", "rect", "controls", "other_text", "other_text_count"]);
|
|
450
|
+
|
|
451
|
+
// The layout a cell keeps from the probe's observation, or null when the
|
|
452
|
+
// observation carries none.
|
|
453
|
+
function cellLayout(observation) {
|
|
454
|
+
const layout = observation?.layout;
|
|
455
|
+
if (!isPlainObject(layout)) return null;
|
|
456
|
+
return {
|
|
457
|
+
viewport: { width: layout.viewport?.width, height: layout.viewport?.height },
|
|
458
|
+
floating_ctas: (Array.isArray(layout.floating_ctas) ? layout.floating_ctas : []).map((entry) => ({
|
|
459
|
+
...pick(entry, FLOATING_FIELDS),
|
|
460
|
+
controls: (Array.isArray(entry?.controls) ? entry.controls : []).map((control) => pick(control, ["selector_path", "rect"])),
|
|
461
|
+
})),
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// The row keys the layout check names for a cell at its viewport, and
|
|
466
|
+
// `cell`; none at a viewport no convention applies to. Read without checking
|
|
467
|
+
// the cell, so an accept of a row of a cell that fails its checks lapses
|
|
468
|
+
// rather than orphans. Never throws.
|
|
469
|
+
export function layoutRowKeys(cell) {
|
|
470
|
+
const viewport = isPlainObject(cell) ? cell.viewport : null;
|
|
471
|
+
const keys = Object.values(LAYOUT_CONVENTIONS).filter((convention) => convention.viewports.includes(viewport)).map((convention) => convention.key);
|
|
472
|
+
return keys.length ? [...keys, "cell"].sort(byCodeUnit) : [];
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// The layout.conventions results of one cell of the readability record,
|
|
476
|
+
// judged with `conventions`, one per convention that applies at the cell's
|
|
477
|
+
// viewport:
|
|
478
|
+
// - `floating_cta`: one member per floating CTA container no taller than
|
|
479
|
+
// max_viewport_height_share of the viewport, a warning when it holds other
|
|
480
|
+
// than one control or holds visible text outside its control; a cell with
|
|
481
|
+
// none reads pass;
|
|
482
|
+
// - `cta_label_size`: one member per rendered, enabled text element of the
|
|
483
|
+
// CTA roles, a warning under min_font_size_px; a cell with none reads pass.
|
|
484
|
+
// The elements are the readability record's, so a capped cell adds the
|
|
485
|
+
// page_coverage member.
|
|
486
|
+
// A cell that measured nothing reads `cell` unexercised with its status.
|
|
487
|
+
export function evaluateLayoutConventions(cell, conventions = LAYOUT_CONVENTIONS) {
|
|
488
|
+
if (!isPlainObject(cell)) invalid("is not an object");
|
|
489
|
+
if (canonicalJson(conventions) !== canonicalJson(LAYOUT_CONVENTIONS)) invalid("layout conventions are not the shared contract's");
|
|
490
|
+
if (!readabilityCellShapeOk(cell)) invalid("is not in the record vocabulary");
|
|
491
|
+
const { floating_cta: floating, cta_label: label } = LAYOUT_CONVENTIONS;
|
|
492
|
+
const applies = (convention) => convention.viewports.includes(cell.viewport);
|
|
493
|
+
if (!applies(floating) && !applies(label)) return [];
|
|
494
|
+
const results = [];
|
|
495
|
+
const push = (key, members, { state, observation = {}, capReason = null }) => {
|
|
496
|
+
const aggregated = aggregateQcResults(members, { capReason });
|
|
497
|
+
const result = aggregated.result;
|
|
498
|
+
results.push({
|
|
499
|
+
check: LAYOUT_CHECK,
|
|
500
|
+
subject: { check: LAYOUT_CHECK, page: cell.route, viewport: cell.viewport, key },
|
|
501
|
+
result,
|
|
502
|
+
reason_code: result === "pass" ? null : aggregated.reason_code,
|
|
503
|
+
members: aggregated.members,
|
|
504
|
+
accept_eligible: aggregated.accept_eligible,
|
|
505
|
+
coverage: result === "unexercised" ? { observed: 0, expected: 1, limits: [aggregated.reason_code] } : { observed: 1, expected: 1, limits: [] },
|
|
506
|
+
state,
|
|
507
|
+
observation,
|
|
508
|
+
});
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
if (cell.cell_status !== "measured") {
|
|
512
|
+
push("cell", [{ key: "cell_status", result: "unexercised", reason_code: cell.cell_status }], { state: { reason_code: cell.cell_status } });
|
|
513
|
+
return results;
|
|
514
|
+
}
|
|
515
|
+
if (!layoutShapeOk(cell.layout)) invalid("layout is not in the record vocabulary");
|
|
516
|
+
|
|
517
|
+
if (applies(floating)) {
|
|
518
|
+
const tallest = floating.max_viewport_height_share * cell.layout.viewport.height;
|
|
519
|
+
const containers = cell.layout.floating_ctas.filter((entry) => entry.rect.height <= tallest);
|
|
520
|
+
const holdsControlOnly = (entry) => entry.controls.length === 1 && entry.other_text_count === 0;
|
|
521
|
+
const members = containers.length
|
|
522
|
+
? containers.map((entry) => (holdsControlOnly(entry)
|
|
523
|
+
? { key: entry.selector_path, result: "pass", reason_code: null }
|
|
524
|
+
: { key: entry.selector_path, result: "warning", reason_code: floating.reason_code }))
|
|
525
|
+
: [{ key: "no_floating_cta", result: "pass", reason_code: null }];
|
|
526
|
+
push(floating.key, members, {
|
|
527
|
+
state: { containers: sortedUnique(containers.map(({ selector_path, controls, other_text, other_text_count }) => ({ selector_path, controls: controls.map((control) => control.selector_path), other_text, other_text_count }))) },
|
|
528
|
+
observation: {
|
|
529
|
+
max_height_px: tallest,
|
|
530
|
+
containers: containers.map(({ selector_path, position, rect, controls, other_text, other_text_count }) => ({ selector_path, position, rect, controls, other_text, other_text_count })),
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (applies(label)) {
|
|
536
|
+
const labels = cell.elements.filter((element) => label.roles.includes(element.role) && element.rendered && !element.disabled);
|
|
537
|
+
const members = labels.length
|
|
538
|
+
? labels.map((element) => (element.font_size_px >= label.min_font_size_px
|
|
539
|
+
? { key: element.selector_path, result: "pass", reason_code: null }
|
|
540
|
+
: { key: element.selector_path, result: "warning", reason_code: label.reason_code }))
|
|
541
|
+
: [{ key: "no_cta_label", result: "pass", reason_code: null }];
|
|
542
|
+
push(label.key, members, {
|
|
543
|
+
capReason: cell.capped ? "element_cap_reached" : null,
|
|
544
|
+
state: { min_font_size_px: label.min_font_size_px, members: sortedUnique(labels.map(({ role, selector_path, font_size_px }) => ({ role, selector_path, font_size_px }))) },
|
|
545
|
+
observation: {
|
|
546
|
+
min_font_size_px: label.min_font_size_px,
|
|
547
|
+
smallest_px: labels.length ? Math.min(...labels.map((element) => element.font_size_px)) : null,
|
|
548
|
+
members: labels.map(({ role, selector_path, font_size_px }) => ({ role, selector_path, font_size_px })),
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
return results;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// The layout.conventions rules, read from the readability record
|
|
556
|
+
// (src/qc-check-registry.mjs): `thresholds` is the conventions block a record
|
|
557
|
+
// must store to be judged. The reader re-derives the check's rows only
|
|
558
|
+
// for a record that stores `layout_conventions`; an earlier record holds no
|
|
559
|
+
// layout and the check reads not_captured_by_this_version.
|
|
560
|
+
export const LAYOUT_QC_RULES = Object.freeze({
|
|
561
|
+
thresholds: LAYOUT_CONVENTIONS,
|
|
562
|
+
vocabulary: Object.freeze({ key: Object.freeze([...Object.values(LAYOUT_CONVENTIONS).map((convention) => convention.key), "cell"]) }),
|
|
563
|
+
evaluate: evaluateLayoutConventions,
|
|
564
|
+
rowKeys: layoutRowKeys,
|
|
565
|
+
});
|
|
566
|
+
|
|
405
567
|
// ---------------------------------------------------------------------------
|
|
406
568
|
// Probe
|
|
407
569
|
|
|
@@ -419,7 +581,8 @@ export const READABILITY_QC_RULES = Object.freeze({
|
|
|
419
581
|
// (or, for one inside a frame, that frame's element); `generatedText` is
|
|
420
582
|
// generatedTextRenders. Every element carries its rectangle in the main
|
|
421
583
|
// frame's viewport (`rect`, clipped to its frames) for crops, which the
|
|
422
|
-
// record does not keep.
|
|
584
|
+
// record does not keep. With `limits.layout` it also returns `layout`: the
|
|
585
|
+
// viewport and the floating CTA containers, whose rects the record keeps.
|
|
423
586
|
export function readabilityProbe(toolkit, limits, closedRoots, generatedText) {
|
|
424
587
|
const win = window;
|
|
425
588
|
const doc = document;
|
|
@@ -684,14 +847,56 @@ export function readabilityProbe(toolkit, limits, closedRoots, generatedText) {
|
|
|
684
847
|
if (isVisible(frame) && !readable) gap("cross_origin_text", null, frame);
|
|
685
848
|
}
|
|
686
849
|
|
|
687
|
-
|
|
850
|
+
// Floating CTA containers (limits.layout, the layout conventions' selector
|
|
851
|
+
// contract), in the main frame's trees: for each visible control, the
|
|
852
|
+
// nearest box at or around it whose computed position is one of
|
|
853
|
+
// `positions`, unless that box is hidden, matches or sits inside
|
|
854
|
+
// `exclude_selector`, or holds a visible form field. Each container keeps
|
|
855
|
+
// its rect, its outermost visible controls with their rects, and the
|
|
856
|
+
// selector paths of the visible text outside them (rendered or generated),
|
|
857
|
+
// the first ten listed and all counted.
|
|
858
|
+
let layout = null;
|
|
859
|
+
if (limits.layout) {
|
|
860
|
+
const { positions, control_selector: controlSelector, exclude_selector: excludeSelector, form_field_selector: fieldSelector } = limits.layout;
|
|
861
|
+
const main = { offset: { x: 0, y: 0 }, clip: null };
|
|
862
|
+
const positioned = (el) => positions.includes(win.getComputedStyle(el).getPropertyValue("position"));
|
|
863
|
+
const boxes = [];
|
|
864
|
+
for (const [el, scope] of inOrder) {
|
|
865
|
+
if (scope.win !== win || !el.matches(controlSelector) || !isVisible(el)) continue;
|
|
866
|
+
let box = el;
|
|
867
|
+
while (box && box.nodeType === 1 && !positioned(box)) box = flatParent(box);
|
|
868
|
+
if (box && box.nodeType === 1 && !boxes.includes(box)) boxes.push(box);
|
|
869
|
+
}
|
|
870
|
+
const floating_ctas = [];
|
|
871
|
+
for (const box of boxes) {
|
|
872
|
+
if (!isVisible(box) || flatClosest(box, excludeSelector)) continue;
|
|
873
|
+
const inside = flatSubtree(box);
|
|
874
|
+
if (inside.some((node) => node.matches(fieldSelector) && isVisible(node))) continue;
|
|
875
|
+
const visibleControls = inside.filter((node) => node.matches(controlSelector) && isVisible(node));
|
|
876
|
+
const controls = visibleControls.filter((node) => !visibleControls.some((other) => other !== node && flatContains(other, node)));
|
|
877
|
+
const other = inside.filter((node) => !controls.some((control) => flatContains(control, node))
|
|
878
|
+
&& (toolkit.isTextBearing(node, windowOf(node)) || generatedText(node, windowOf(node))));
|
|
879
|
+
floating_ctas.push({
|
|
880
|
+
selector_path: toolkit.selectorPath(box),
|
|
881
|
+
position: win.getComputedStyle(box).getPropertyValue("position"),
|
|
882
|
+
rect: rectOf(box, main),
|
|
883
|
+
controls: controls.map((control) => ({ selector_path: toolkit.selectorPath(control), rect: rectOf(control, main) })),
|
|
884
|
+
other_text: other.slice(0, 10).map((node) => toolkit.selectorPath(node)),
|
|
885
|
+
other_text_count: other.length,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
layout = { viewport, floating_ctas };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
return { status: "measured", capped, coverage_gaps: gaps, elements, viewport, ...(layout ? { layout } : {}) };
|
|
688
892
|
}
|
|
689
893
|
|
|
690
894
|
// The function declaration the adapter calls in the isolated world: the
|
|
691
895
|
// probe, composed with the shared contrast helper and generatedTextRenders
|
|
692
|
-
// by source text
|
|
896
|
+
// by source text, its limits carrying the layout conventions' selector
|
|
897
|
+
// contract.
|
|
693
898
|
export function readabilityProbeSource() {
|
|
694
|
-
return `function (limits, ...closedRoots) { return (${readabilityProbe.toString()})((${contrastToolkit.toString()})(), limits, closedRoots, ${generatedTextRenders.toString()}); }`;
|
|
899
|
+
return `function (limits, ...closedRoots) { return (${readabilityProbe.toString()})((${contrastToolkit.toString()})(), Object.assign({ layout: ${JSON.stringify(layoutProbeArgs())} }, limits), closedRoots, ${generatedTextRenders.toString()}); }`;
|
|
695
900
|
}
|
|
696
901
|
|
|
697
902
|
// Whether an element's ::before or ::after renders text: its computed
|
|
@@ -760,6 +965,7 @@ const pick = (value, fields) => Object.fromEntries(fields.map((field) => [field,
|
|
|
760
965
|
export function readabilityObservationOk(observation) {
|
|
761
966
|
return isPlainObject(observation)
|
|
762
967
|
&& inVocabulary("cell_status", observation.status)
|
|
968
|
+
&& (observation.layout === undefined || isPlainObject(observation.layout))
|
|
763
969
|
&& (observation.status !== "measured"
|
|
764
970
|
|| (Array.isArray(observation.elements) && observation.elements.every(isPlainObject)
|
|
765
971
|
&& Array.isArray(observation.coverage_gaps) && observation.coverage_gaps.every(isPlainObject)
|
|
@@ -768,7 +974,9 @@ export function readabilityObservationOk(observation) {
|
|
|
768
974
|
|
|
769
975
|
// One cell of the record. `status` is the cell status; a measured cell's
|
|
770
976
|
// `observation` is the adapter's probe read ({capped, coverage_gaps,
|
|
771
|
-
// elements, crops}). Only the record's fields are kept
|
|
977
|
+
// elements, crops, layout}). Only the record's fields are kept; a layout
|
|
978
|
+
// outside the record vocabulary is dropped, which the layout check reads as
|
|
979
|
+
// not reproducible. Each crop the adapter
|
|
772
980
|
// took (crops[] of {element, data}, base64 PNG) is written by `writeCrop`
|
|
773
981
|
// and referenced by path and sha256; one that cannot be written reads
|
|
774
982
|
// crop_unavailable. A measured cell outside the record vocabulary reads
|
|
@@ -789,6 +997,7 @@ export function buildReadabilityCell({ route, viewport, pageLoadIntegrity = null
|
|
|
789
997
|
element.crop_reason = "crop_unavailable";
|
|
790
998
|
}
|
|
791
999
|
}
|
|
1000
|
+
const layout = cellLayout(observation);
|
|
792
1001
|
const cell = {
|
|
793
1002
|
route,
|
|
794
1003
|
viewport,
|
|
@@ -797,13 +1006,15 @@ export function buildReadabilityCell({ route, viewport, pageLoadIntegrity = null
|
|
|
797
1006
|
capped: observation.capped,
|
|
798
1007
|
coverage_gaps: observation.coverage_gaps.map((entry) => pick(entry, GAP_FIELDS)),
|
|
799
1008
|
elements,
|
|
1009
|
+
...(layout && layoutShapeOk(layout) ? { layout } : {}),
|
|
800
1010
|
};
|
|
801
1011
|
return readabilityCellShapeOk(cell) ? cell : unmeasured("document_changed");
|
|
802
1012
|
}
|
|
803
1013
|
|
|
804
1014
|
// The record for one Polish run: its cells (one per captured route ×
|
|
805
1015
|
// viewport), bound to the current build and source package; the built routes
|
|
806
|
-
// over the route cap; whether the enumeration ceiling was reached
|
|
1016
|
+
// over the route cap; whether the enumeration ceiling was reached; and, when
|
|
1017
|
+
// a cell recorded layout, the layout conventions it was measured with.
|
|
807
1018
|
export function buildReadabilityRecord({ buildFingerprint, sourceFingerprint = null, slug, routes, uncapturedRoutes = [], routeEnumerationCapped = false, cells, measuredAt = new Date().toISOString() }) {
|
|
808
1019
|
const record = {
|
|
809
1020
|
schema_version: READABILITY_SCHEMA,
|
|
@@ -819,6 +1030,7 @@ export function buildReadabilityRecord({ buildFingerprint, sourceFingerprint = n
|
|
|
819
1030
|
},
|
|
820
1031
|
thresholds: { ...READABILITY_THRESHOLDS },
|
|
821
1032
|
limits: { ...readabilityLimits() },
|
|
1033
|
+
...(cells.some((cell) => isPlainObject(cell?.layout)) ? { layout_conventions: structuredClone(LAYOUT_CONVENTIONS) } : {}),
|
|
822
1034
|
cells,
|
|
823
1035
|
uncaptured_routes: [...uncapturedRoutes],
|
|
824
1036
|
route_enumeration_capped: routeEnumerationCapped === true,
|
package/src/polish-visual.mjs
CHANGED
|
@@ -477,10 +477,17 @@ export function evaluateVisualReference(reference) {
|
|
|
477
477
|
: reference.provenance?.class === "declared_source" ? member("provenance", null, "pass") : member("provenance", "provenance_unknown", "review"));
|
|
478
478
|
if (reference.reference_cap_reached) members.push(member("reference_cap", "reference_cap_reached"));
|
|
479
479
|
// A file refused for where it is, not what it is, names that location.
|
|
480
|
-
|
|
480
|
+
members.push(...referenceLocationMembers(reference));
|
|
481
481
|
return aggregateQcResults(members);
|
|
482
482
|
}
|
|
483
483
|
|
|
484
|
+
// The location member of a reference refused for where it is. It does not
|
|
485
|
+
// depend on the build, so a stale-bound row keeps it (#707).
|
|
486
|
+
function referenceLocationMembers(reference) {
|
|
487
|
+
return reference?.verification?.level !== "decoded" && reference?.verification?.reason === "path_outside_source_root"
|
|
488
|
+
? [member("location", "path_outside_source_root")] : [];
|
|
489
|
+
}
|
|
490
|
+
|
|
484
491
|
// The only diff decision table. Scores are intentionally absent from its predicates.
|
|
485
492
|
export function evaluateVisualDiff(comparison, { record, reference, build, basis, readerFailure = null,
|
|
486
493
|
staleBinding = false, pageMissing = false, fileAbsent = false, budgetExhausted = false } = {}) {
|
|
@@ -590,7 +597,7 @@ function evaluate(record, context = {}) {
|
|
|
590
597
|
? { ...original, reference_cap_reached: true } : original;
|
|
591
598
|
row("visual.reference", ref, failure
|
|
592
599
|
? { result: "unexercised", reason_code: "evidence_not_reproducible", members: [member("evidence", failure)] }
|
|
593
|
-
: context.staleBinding ? { result: "unexercised", reason_code: "stale_binding" } : evaluateVisualReference(ref));
|
|
600
|
+
: context.staleBinding ? { result: "unexercised", reason_code: "stale_binding", members: referenceLocationMembers(ref) } : evaluateVisualReference(ref));
|
|
594
601
|
}
|
|
595
602
|
for (const comparison of record.comparisons) {
|
|
596
603
|
if (comparison.basis === "reference" && cappedReferences.has(referenceKey(comparison))) continue;
|
package/src/progress-node.mjs
CHANGED
|
@@ -97,7 +97,7 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
|
|
|
97
97
|
}
|
|
98
98
|
function lock(dir,fn,{budgetMs=1500}={}) {
|
|
99
99
|
const lockPath=join(dir,'.allocation-lock');
|
|
100
|
-
return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:(error)=>Object.assign(new Error('progress.lock_unavailable'),{lockPath,code:error?.code,obstruction:error?.obstruction})});
|
|
100
|
+
return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:(error)=>Object.assign(new Error('progress.lock_unavailable'),{lockPath,code:error?.code,obstruction:error?.obstruction,holderPid:error?.holderPid})});
|
|
101
101
|
}
|
|
102
102
|
export async function persistProgressObservation(observation,{dir,now=()=>new Date(),historyLimit=32}={}) {
|
|
103
103
|
mkdirSync(dir,{recursive:true,mode:0o700});
|
|
@@ -165,7 +165,8 @@ export async function observeProgress(args,continuation,{qaResult=null,packageVe
|
|
|
165
165
|
if(remit.state==='failed')warn('[campaigns-os] Progress delivery pending; the local observation is retained. Lifecycle result is unchanged.');
|
|
166
166
|
return {...remit,snapshot_id:snapshot.snapshot_id,reused};
|
|
167
167
|
} catch(error) {
|
|
168
|
-
if(error?.message==='progress.lock_unavailable'&&error.
|
|
168
|
+
if(error?.message==='progress.lock_unavailable'&&error.code==='ELOCKFORMAT')warn(`[campaigns-os] Progress allocation lock at ${error.lockPath} is held by process ${error.holderPid} of a different campaigns-os release; capture stops instead of waiting. Run one campaigns-os release against this target at a time. Lifecycle result is unchanged.`);
|
|
169
|
+
else 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.`);
|
|
169
170
|
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.`);
|
|
170
171
|
else warn('[campaigns-os] Progress observation unavailable; lifecycle result is unchanged.');
|
|
171
172
|
return {state:'failed',reason:'capture_unavailable'};
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import { SEVERITY, STATUS } from "./qa-verdict.mjs";
|
|
29
29
|
import { redactUrlQuery } from "./qa-url-privacy.mjs";
|
|
30
|
+
import { readVendorBeacon, VENDOR_KEYS, vendorBeaconFor } from "./qa-vendor-endpoints.mjs";
|
|
30
31
|
|
|
31
32
|
// Data layers the SDK and legacy funnels push through. The SDK's GTMAdapter
|
|
32
33
|
// mirrors every `dl_*` event to window.dataLayer AND window.ElevarDataLayer;
|
|
@@ -280,6 +281,12 @@ export async function attachAnalyticsCapture(page, options = {}) {
|
|
|
280
281
|
const url = typeof request.url === "function" ? request.url() : request.url;
|
|
281
282
|
const fire = classifyTagFire(url, extraHosts);
|
|
282
283
|
if (!fire) return;
|
|
284
|
+
// A listed vendor beacon can carry its id in the body (Snap, TikTok).
|
|
285
|
+
if (!fire.id) {
|
|
286
|
+
let body = null;
|
|
287
|
+
try { body = typeof request.postData === "function" ? request.postData() : null; } catch { /* no body */ }
|
|
288
|
+
fire.id = readVendorBeacon(url, body)?.ids[0] || null;
|
|
289
|
+
}
|
|
283
290
|
let documentUrl = safePageUrl();
|
|
284
291
|
try {
|
|
285
292
|
let frame = typeof request.frame === "function" ? request.frame() : null;
|
|
@@ -420,6 +427,14 @@ export function classifyTagFire(urlString, extraHostSubstrings = []) {
|
|
|
420
427
|
const fire = (kind, id) => ({ kind, id: id ? String(id) : null, host, params });
|
|
421
428
|
const subs = [...DEFAULT_EXTRA_ANALYTICS_HOST_SUBSTRINGS, ...extraHostSubstrings];
|
|
422
429
|
|
|
430
|
+
// A listed vendor beacon (qa-vendor-endpoints.mjs) is that vendor's fire,
|
|
431
|
+
// answered locally or not, with the id from its query or path when it has one.
|
|
432
|
+
const beacon = vendorBeaconFor(url.href);
|
|
433
|
+
if (beacon) {
|
|
434
|
+
const pathId = beacon.idPath ? path.match(beacon.idPath)?.[1] : null;
|
|
435
|
+
return fire(beacon.vendor, pathId || (beacon.id && !beacon.id.includes(".") ? params[beacon.id] : null) || null);
|
|
436
|
+
}
|
|
437
|
+
|
|
423
438
|
if (hostMatches(host, "googletagmanager.com")) {
|
|
424
439
|
const id = params.id || null;
|
|
425
440
|
if (id && /^GTM-/i.test(id)) return fire("gtm", id);
|
|
@@ -539,6 +554,7 @@ export function normalizeCapture({ events = [], tagFires = [] } = {}) {
|
|
|
539
554
|
}
|
|
540
555
|
|
|
541
556
|
const inventory = { gtm: [], ga4: [], google_ads: [], meta: [], tiktok: [], everflow: [], other: [] };
|
|
557
|
+
for (const vendor of VENDOR_KEYS) inventory[vendor] ||= [];
|
|
542
558
|
// Pixel ids seen on a facebook.com request, apart from in-page fbq calls.
|
|
543
559
|
const metaRequestIds = [];
|
|
544
560
|
let metaPurchaseEventId = null;
|
|
@@ -787,7 +803,7 @@ export function diffAnalyticsParity(baseline, candidate, options = {}) {
|
|
|
787
803
|
|
|
788
804
|
// 6. Carried-over tag regression — any baseline container/pixel absent on the
|
|
789
805
|
// candidate is a WARN (likely attribution regression; human confirms drop).
|
|
790
|
-
const kinds = ["gtm", "ga4", "google_ads", "meta", "tiktok", "everflow", "other"];
|
|
806
|
+
const kinds = [...new Set(["gtm", "ga4", "google_ads", "meta", "tiktok", "everflow", "other", ...VENDOR_KEYS])];
|
|
791
807
|
for (const kind of kinds) {
|
|
792
808
|
const baselineIds = (b.inventory?.[kind]) || [];
|
|
793
809
|
const candidateIds = new Set((c.inventory?.[kind]) || []);
|