@nextcommerce/campaigns-os 1.48.0 → 1.52.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 +539 -0
- package/agents/claude/CLAUDE.md +6 -5
- package/agents/codex/AGENTS.md +6 -5
- package/agents/copilot/copilot-instructions.md +3 -3
- package/agents/cursor/campaigns-os.mdc +3 -3
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
- package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
- package/campaign-spec/dist/rules/design-source-shape.js +13 -3
- package/campaign-spec/dist/rules/sdk-version.js +2 -1
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +26 -46
- package/contracts/effects.v1.json +254 -2
- package/contracts/release-ledger.json +1239 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
- package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
- package/docs/brand-theme-bridge.md +12 -6
- package/docs/build-packet.md +101 -12
- package/docs/campaign-build-brief.md +25 -1
- package/docs/effects.md +6 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +10 -0
- package/docs/qa-and-test-orders.md +66 -11
- package/docs/runtime-readiness.md +1 -1
- 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-packet.v0.schema.json +4 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +8 -3
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +4 -4
- package/skills/next-campaigns-os/SKILL.md +4 -4
- package/skills/next-campaigns-os/references/session-intake.md +7 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +5 -4
- package/skills/next-campaigns-qa/SKILL.md +6 -5
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/brand-theme.mjs +25 -2
- package/src/build-brief.mjs +68 -21
- package/src/built-site-scope.mjs +39 -6
- package/src/built-smoke-qc.mjs +1117 -0
- package/src/campaign-identity.mjs +36 -2
- package/src/cart-placeholders.mjs +730 -0
- package/src/cli.mjs +320 -42
- package/src/commercial-journey.mjs +65 -4
- package/src/commercial-parity.mjs +6 -1
- package/src/doctor/checks.mjs +291 -24
- package/src/doctor/inspect.mjs +53 -2
- package/src/doctor/next-step.mjs +1 -1
- package/src/install-mode.mjs +0 -8
- package/src/invocation.mjs +5 -2
- package/src/local-preview-policy.mjs +1 -1
- package/src/local-proof.mjs +4 -1
- package/src/polish-browser.mjs +218 -1
- package/src/polish-capture.mjs +1 -1
- package/src/polish-media-weight.mjs +492 -0
- package/src/polish-node.mjs +96 -4
- package/src/progress-node.mjs +5 -1
- package/src/qa-binding-evidence.mjs +21 -0
- package/src/qa-browser.mjs +338 -97
- package/src/qa-content-params.mjs +889 -0
- package/src/qa-node.mjs +114 -14
- package/src/qa-order-bump.mjs +22 -1
- package/src/qa-policy-links.mjs +1019 -0
- package/src/qa-tracking-params.mjs +1389 -0
- package/src/qa-url-privacy.mjs +168 -0
- package/src/qc-accept.mjs +446 -0
- package/src/qc-check-registry.mjs +83 -0
- package/src/qc-results.mjs +1049 -0
- package/src/sdk-attribute-index.mjs +71 -0
- package/src/sdk-markup.mjs +2 -2
- package/src/sdk-storage-compatibility.mjs +63 -3
- package/src/source-prep.mjs +37 -7
- package/src/stage-record.mjs +356 -36
- package/src/theme-gate.mjs +3 -3
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
// The shared QC results: one result shape,
|
|
2
|
+
// one storage rule per leg, and the readers that re-derive every stored result
|
|
3
|
+
// from its raw package capture on every read.
|
|
4
|
+
//
|
|
5
|
+
// Storage per leg:
|
|
6
|
+
// - doctor: derived.qc_results[], recomputed from built HTML on every run;
|
|
7
|
+
// - polish: stages.polish.evidence.visual_review.media_weight, re-derived from
|
|
8
|
+
// its cells and cross-checked against the page_load capture;
|
|
9
|
+
// - qa: stages.qa.evidence.qc_results[] beside qc_build_fingerprint,
|
|
10
|
+
// re-derived from the qc.* assertions of the full QA verdict.
|
|
11
|
+
//
|
|
12
|
+
// A reader never reports `pass` for an input it could not re-derive: a failed
|
|
13
|
+
// check reads `unexercised` with stale_binding or evidence_not_reproducible,
|
|
14
|
+
// and a leg with no rows for an applicable check is listed in the handoff
|
|
15
|
+
// coverage (silence is not a result). The committed QA sidecar,
|
|
16
|
+
// stages.qa.waivers and report.waivers[] are never read here.
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
19
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
20
|
+
|
|
21
|
+
import { campaignSidecarPaths } from "./campaign-workspace.mjs";
|
|
22
|
+
import { checkpointStateFingerprint } from "./checkpoint-waiver.mjs";
|
|
23
|
+
import { sameFile } from "./fs-identity.mjs";
|
|
24
|
+
import { buildPolishCaptureIntegrity, canonicalJson, captureOrigin, mediaFetchedResources } from "./polish-capture.mjs";
|
|
25
|
+
import { currentBuildFingerprint } from "./polish-gate.mjs";
|
|
26
|
+
import { sidecarPathForPacket } from "./qa-sidecar.mjs";
|
|
27
|
+
import { QA_ASSERTION_FAMILY_VOCABULARY, QA_SCHEMA_VERSION, SEVERITY, STATUS } from "./qa-verdict.mjs";
|
|
28
|
+
import { QC_CHECK_REGISTRY, loadedQcRederivers, qcChecksForLeg } from "./qc-check-registry.mjs";
|
|
29
|
+
import { STORE_PAGE_MATCHERS } from "./spec-derive-store.mjs";
|
|
30
|
+
|
|
31
|
+
export const QC_RESULT_SCHEMA = "campaigns-os-qc-result/v0";
|
|
32
|
+
export const QC_RESULTS = Object.freeze(["pass", "warning", "review", "unexercised", "excluded"]);
|
|
33
|
+
export const QC_LEGS = Object.freeze(["doctor", "polish", "qa"]);
|
|
34
|
+
export const QC_REASON = Object.freeze({
|
|
35
|
+
STALE_BINDING: "stale_binding",
|
|
36
|
+
EVIDENCE_NOT_REPRODUCIBLE: "evidence_not_reproducible",
|
|
37
|
+
LEG_NOT_RUN: "leg_not_run",
|
|
38
|
+
NOT_CAPTURED: "not_captured_by_this_version",
|
|
39
|
+
});
|
|
40
|
+
export const QC_PRODUCERS = Object.freeze({
|
|
41
|
+
doctor: "campaigns-os doctor",
|
|
42
|
+
polish: "campaigns-os polish capture",
|
|
43
|
+
qa: "campaigns-os qa run",
|
|
44
|
+
});
|
|
45
|
+
export const MEDIA_WEIGHT_SCHEMA = "campaigns-os-polish-media-weight/v0";
|
|
46
|
+
const PAGE_LOAD_SCHEMA = "campaigns-os-polish-page-load/v0";
|
|
47
|
+
const ROUTE_CAPTURE_SCHEMA = "campaigns-os-polish-route-capture/v0";
|
|
48
|
+
// The binding fields of the page_load subject (pageLoadCheckpointSubject) and
|
|
49
|
+
// of one route capture's subject (captureSubject), exactly, besides
|
|
50
|
+
// build_fingerprint. The build binding is read apart: absent, null or another
|
|
51
|
+
// build is staleness (stale_binding), never a malformed subject.
|
|
52
|
+
const PAGE_LOAD_SUBJECT_FIELDS = Object.freeze(["campaign_slug", "route_scope", "routes", "viewports"]);
|
|
53
|
+
const CAPTURE_SUBJECT_FIELDS = Object.freeze(["campaign_slug", "requested_route", "final_document_route", "viewport"]);
|
|
54
|
+
const QA_RUNTIME_PREFIX = "campaigns-os-node-qa@";
|
|
55
|
+
const FINGERPRINT = /^sha256:[a-f0-9]{64}$/;
|
|
56
|
+
const E = QC_REASON.EVIDENCE_NOT_REPRODUCIBLE;
|
|
57
|
+
|
|
58
|
+
// Result precedence when members aggregate into one row (excluded is ignored).
|
|
59
|
+
const PRECEDENCE = Object.freeze(["warning", "review", "unexercised", "pass"]);
|
|
60
|
+
|
|
61
|
+
// QA verdict mapping per result. unexercised is
|
|
62
|
+
// manual_review + warn, after src/qa-browser.mjs's unexercised-path precedent,
|
|
63
|
+
// never skipped: skipped is invisible to computeDisposition and exceptions[].
|
|
64
|
+
export const QC_QA_ASSERTION_STATUS = Object.freeze({
|
|
65
|
+
pass: Object.freeze({ status: STATUS.PASS, severity: SEVERITY.INFO }),
|
|
66
|
+
warning: Object.freeze({ status: STATUS.WARN, severity: SEVERITY.WARN }),
|
|
67
|
+
review: Object.freeze({ status: STATUS.MANUAL_REVIEW, severity: SEVERITY.WARN }),
|
|
68
|
+
unexercised: Object.freeze({ status: STATUS.MANUAL_REVIEW, severity: SEVERITY.WARN }),
|
|
69
|
+
excluded: Object.freeze({ status: STATUS.SKIPPED, severity: SEVERITY.INFO }),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const isPlainObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
73
|
+
const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
|
|
74
|
+
const sameJson = (a, b) => canonicalJson(a ?? null) === canonicalJson(b ?? null);
|
|
75
|
+
const validTime = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Result shape
|
|
79
|
+
|
|
80
|
+
export function qcResultId(subject) {
|
|
81
|
+
return [subject?.check, subject?.page, ...(subject?.viewport == null ? [] : [subject.viewport]), subject?.key].map((part) => String(part ?? "")).join(":");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function qcStateFingerprint({ subject, state }) {
|
|
85
|
+
return checkpointStateFingerprint({ scope: "qc", subject, state });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const fingerprint12 = (fingerprint) => String(fingerprint || "").replace(/^sha256:/, "").slice(0, 12);
|
|
89
|
+
export const qcResultRef = (result) => `${result.id}@${fingerprint12(result.state_fingerprint)}`;
|
|
90
|
+
|
|
91
|
+
// Only a warning whose members are all resolved can be accepted.
|
|
92
|
+
export function qcAcceptEligible(result, members = []) {
|
|
93
|
+
return result === "warning" && !(Array.isArray(members) ? members : []).some((member) => member?.result === "review" || member?.result === "unexercised");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validMember(member) {
|
|
97
|
+
return isPlainObject(member)
|
|
98
|
+
&& member.key != null
|
|
99
|
+
&& QC_RESULTS.includes(member.result)
|
|
100
|
+
&& (member.result === "pass" ? member.reason_code == null : isNonEmptyString(member.reason_code));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function buildQcResult({ check, leg, subject, result, reason_code = null, state, observation = {}, members = [], accept_eligible = undefined, coverage, measured_at, producer = QC_PRODUCERS[leg] }) {
|
|
104
|
+
if (!isNonEmptyString(check) || !isPlainObject(subject) || subject.check !== check) throw new TypeError("buildQcResult needs a check and a subject naming it.");
|
|
105
|
+
if (!QC_LEGS.includes(leg)) throw new TypeError(`buildQcResult leg must be one of ${QC_LEGS.join(", ")}.`);
|
|
106
|
+
if (!QC_RESULTS.includes(result)) throw new TypeError(`buildQcResult result must be one of ${QC_RESULTS.join(", ")}.`);
|
|
107
|
+
if (result !== "pass" && !isNonEmptyString(reason_code)) throw new TypeError("buildQcResult needs a reason_code unless the result is pass.");
|
|
108
|
+
if (!Array.isArray(members) || !members.every(validMember)) throw new TypeError("buildQcResult members must be {key, result, reason_code} entries.");
|
|
109
|
+
const eligible = qcAcceptEligible(result, members) && accept_eligible !== false;
|
|
110
|
+
return {
|
|
111
|
+
schema: QC_RESULT_SCHEMA,
|
|
112
|
+
id: qcResultId(subject),
|
|
113
|
+
check,
|
|
114
|
+
leg,
|
|
115
|
+
result,
|
|
116
|
+
reason_code: result === "pass" ? null : reason_code,
|
|
117
|
+
subject,
|
|
118
|
+
state_fingerprint: qcStateFingerprint({ subject, state }),
|
|
119
|
+
observation,
|
|
120
|
+
members,
|
|
121
|
+
accept_eligible: eligible,
|
|
122
|
+
coverage: coverage ?? { observed: 1, expected: 1, limits: [] },
|
|
123
|
+
measured_at,
|
|
124
|
+
producer,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Members aggregate as warning > review > unexercised > pass; excluded members
|
|
129
|
+
// are ignored, and a row of only excluded members is excluded. A capped page
|
|
130
|
+
// adds the page_coverage member, so its kept results are never accept-eligible.
|
|
131
|
+
export function aggregateQcResults(members, { capReason = null } = {}) {
|
|
132
|
+
const all = [...(Array.isArray(members) ? members : []), ...(capReason ? [{ key: "page_coverage", result: "unexercised", reason_code: capReason }] : [])];
|
|
133
|
+
const counted = all.filter((member) => member.result !== "excluded");
|
|
134
|
+
const result = counted.length ? PRECEDENCE.find((value) => counted.some((member) => member.result === value)) || "unexercised" : "excluded";
|
|
135
|
+
const lead = result === "pass" ? null : all.find((member) => member.result === result);
|
|
136
|
+
return { result, reason_code: lead ? lead.reason_code : null, members: all, accept_eligible: qcAcceptEligible(result, all) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A QA verdict assertion for a result, in an existing family, id prefixed qc.
|
|
140
|
+
export function toQaAssertion(row, { family = "browser-runtime", expected = null, actual = null } = {}) {
|
|
141
|
+
if (!QA_ASSERTION_FAMILY_VOCABULARY.includes(family)) throw new TypeError(`toQaAssertion family must be an existing QA assertion family (got ${family}).`);
|
|
142
|
+
const { status, severity } = QC_QA_ASSERTION_STATUS[row.result];
|
|
143
|
+
return {
|
|
144
|
+
id: `qc.${row.id}`,
|
|
145
|
+
family,
|
|
146
|
+
page: row.subject?.page ?? null,
|
|
147
|
+
status,
|
|
148
|
+
severity,
|
|
149
|
+
...(expected == null ? {} : { expected }),
|
|
150
|
+
actual: actual ?? `${row.check} ${row.result}${row.reason_code ? ` (${row.reason_code})` : ""}`,
|
|
151
|
+
evidence: { qc: { result_id: row.id, observation: row.observation, ...(row.result === "excluded" ? { reason_code: row.reason_code } : {}) } },
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function toDoctorIssue(row) {
|
|
156
|
+
return {
|
|
157
|
+
code: `built_output.${row.check}.${row.reason_code}`,
|
|
158
|
+
message: `${row.check} ${row.result} on ${row.subject?.page ?? "(no page)"} (${row.subject?.key ?? "(no key)"}): ${row.reason_code}.`,
|
|
159
|
+
detail: { qc_result: row },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The single doctor wiring point: every result lands in derived.qc_results;
|
|
164
|
+
// warning and review results also become warnings[] issues. Doctor status
|
|
165
|
+
// rules are unchanged, and unexercised/excluded stay in derived only.
|
|
166
|
+
export function recordQcResults({ derived, warnings, results }) {
|
|
167
|
+
if (!Array.isArray(derived.qc_results)) derived.qc_results = [];
|
|
168
|
+
for (const row of Array.isArray(results) ? results : []) {
|
|
169
|
+
derived.qc_results.push(row);
|
|
170
|
+
if (row.result === "warning" || row.result === "review") warnings.push(toDoctorIssue(row));
|
|
171
|
+
}
|
|
172
|
+
return derived.qc_results;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Rederivers: an in-process stand-in (tests only) or the registry module.
|
|
177
|
+
|
|
178
|
+
function qaRederiver(check, { qcStandIns, rederivers }) {
|
|
179
|
+
const standIn = qcStandIns?.qa?.[check];
|
|
180
|
+
if (typeof standIn === "function") return { status: "loaded", rederive: standIn };
|
|
181
|
+
const entry = (rederivers || loadedQcRederivers())?.[check];
|
|
182
|
+
if (!entry) return { status: Object.hasOwn(QC_CHECK_REGISTRY, check) ? "unresolved" : "unknown" };
|
|
183
|
+
if (entry.status === "loaded" && typeof entry.rederive !== "function") return { status: "failed" };
|
|
184
|
+
return entry;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function resolvePolishRules({ qcStandIns, rederivers } = {}) {
|
|
188
|
+
const standIn = qcStandIns?.polish;
|
|
189
|
+
if (isPlainObject(standIn)) return { status: "loaded", rules: standIn };
|
|
190
|
+
const entry = (rederivers || loadedQcRederivers())?.["media.weight"];
|
|
191
|
+
if (!entry) return { status: "unresolved" };
|
|
192
|
+
if (entry.status !== "loaded") return entry;
|
|
193
|
+
const rules = entry.rederive;
|
|
194
|
+
return isPlainObject(rules) && typeof rules.evaluate === "function" && isPlainObject(rules.thresholds) && isPlainObject(rules.vocabulary)
|
|
195
|
+
? { status: "loaded", rules }
|
|
196
|
+
: { status: "failed" };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// A result the reader could not re-derive. What the stored row claims is kept
|
|
200
|
+
// only to name it (id, subject, fingerprint); its result never survives.
|
|
201
|
+
function unreproducedRow(base, reasonCode, { leg, measuredAt = null }) {
|
|
202
|
+
const subject = isPlainObject(base?.subject) ? base.subject : { check: base?.check ?? null, page: base?.page ?? null, key: base?.key ?? null };
|
|
203
|
+
const check = isNonEmptyString(base?.check) ? base.check : subject.check ?? null;
|
|
204
|
+
return {
|
|
205
|
+
schema: QC_RESULT_SCHEMA,
|
|
206
|
+
id: isNonEmptyString(base?.id) ? base.id : qcResultId(subject),
|
|
207
|
+
check,
|
|
208
|
+
leg,
|
|
209
|
+
result: "unexercised",
|
|
210
|
+
reason_code: reasonCode,
|
|
211
|
+
subject,
|
|
212
|
+
state_fingerprint: FINGERPRINT.test(base?.state_fingerprint || "") ? base.state_fingerprint : qcStateFingerprint({ subject, state: { reason_code: reasonCode } }),
|
|
213
|
+
observation: {},
|
|
214
|
+
members: [],
|
|
215
|
+
accept_eligible: false,
|
|
216
|
+
coverage: { observed: 0, expected: null, limits: [reasonCode] },
|
|
217
|
+
measured_at: validTime(measuredAt) ? measuredAt : validTime(base?.measured_at) ? base.measured_at : null,
|
|
218
|
+
producer: QC_PRODUCERS[leg],
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const staleRow = (row) => ({
|
|
223
|
+
...row,
|
|
224
|
+
result: "unexercised",
|
|
225
|
+
reason_code: QC_REASON.STALE_BINDING,
|
|
226
|
+
accept_eligible: false,
|
|
227
|
+
coverage: { ...(isPlainObject(row.coverage) ? row.coverage : {}), limits: [QC_REASON.STALE_BINDING] },
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// A check re-deriver's output, checked against the QC result vocabulary.
|
|
231
|
+
function validDerived(derived, check) {
|
|
232
|
+
return isPlainObject(derived)
|
|
233
|
+
&& derived.check === check
|
|
234
|
+
&& isPlainObject(derived.subject)
|
|
235
|
+
&& derived.subject.check === check
|
|
236
|
+
&& QC_RESULTS.includes(derived.result)
|
|
237
|
+
&& (derived.result === "pass" ? derived.reason_code == null : isNonEmptyString(derived.reason_code))
|
|
238
|
+
&& (derived.members === undefined || (Array.isArray(derived.members) && derived.members.every(validMember)))
|
|
239
|
+
&& typeof derived.accept_eligible === "boolean"
|
|
240
|
+
&& (derived.coverage === undefined || isPlainObject(derived.coverage))
|
|
241
|
+
&& Object.hasOwn(derived, "state");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// QA reader site
|
|
246
|
+
|
|
247
|
+
// The Inputs checks on the full QA verdict. Returns null when it passes.
|
|
248
|
+
// Every qc.* assertion takes part in pairing, so none may be skipped: an
|
|
249
|
+
// entry that is not an assertion object, a qc.* assertion whose evidence.qc
|
|
250
|
+
// is not {result_id: non-empty string, observation: object}, or QC evidence
|
|
251
|
+
// on an assertion whose id is not qc.* fails the whole verdict.
|
|
252
|
+
export function qaFullVerdictProblem(verdict, stage) {
|
|
253
|
+
if (!isPlainObject(verdict)) return "full_verdict_absent";
|
|
254
|
+
if (verdict.schema_version !== QA_SCHEMA_VERSION) return "verdict_schema";
|
|
255
|
+
if (typeof verdict.runtime !== "string" || !verdict.runtime.startsWith(QA_RUNTIME_PREFIX)) return "verdict_runtime";
|
|
256
|
+
const runId = stage?.identity?.verdict_run_id;
|
|
257
|
+
if (!isNonEmptyString(runId) || verdict.run_id !== runId) return "verdict_run_id";
|
|
258
|
+
if (!Array.isArray(verdict.assertions)) return "verdict_assertions";
|
|
259
|
+
for (const assertion of verdict.assertions) {
|
|
260
|
+
if (!isPlainObject(assertion)) return "verdict_assertions";
|
|
261
|
+
const isQc = typeof assertion.id === "string" && assertion.id.startsWith("qc.");
|
|
262
|
+
const carriesQc = isPlainObject(assertion.evidence) && Object.hasOwn(assertion.evidence, "qc");
|
|
263
|
+
if (!isQc && !carriesQc) continue;
|
|
264
|
+
const qc = assertion.evidence?.qc;
|
|
265
|
+
if (!isQc || !isPlainObject(qc) || !isNonEmptyString(qc.result_id) || !isPlainObject(qc.observation)) return "qc_assertion_malformed";
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The full verdict the QA stage names: its outputs[] entry under
|
|
271
|
+
// {target}/qa-output/. Evidence refers to itself when that file is the
|
|
272
|
+
// committed QA sidecar or the Assembly Report. That is decided on the file
|
|
273
|
+
// itself, after every symlink and directory alias resolves (a symlinked
|
|
274
|
+
// qa-output/ included), and on its device and inode (a hard link), whatever
|
|
275
|
+
// the stage's other outputs[] say; such a verdict reads as absent. This is the
|
|
276
|
+
// only file a QC reader opens from a path held in a record.
|
|
277
|
+
export function readQaFullVerdict({ stage, targetRepo, packetPath = null, reportPath = null }) {
|
|
278
|
+
if (!isPlainObject(stage) || !isNonEmptyString(targetRepo)) return null;
|
|
279
|
+
const outputDir = resolve(targetRepo, "qa-output");
|
|
280
|
+
const inside = (root, path) => {
|
|
281
|
+
const rel = relative(root, path);
|
|
282
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
283
|
+
};
|
|
284
|
+
const outputs = (Array.isArray(stage.outputs) ? stage.outputs : []).filter(isNonEmptyString).map((path) => resolve(targetRepo, path));
|
|
285
|
+
const candidates = outputs.filter((path) => inside(outputDir, path));
|
|
286
|
+
if (candidates.length !== 1 || !existsSync(candidates[0]) || !existsSync(outputDir)) return null;
|
|
287
|
+
try {
|
|
288
|
+
const real = realpathSync(candidates[0]);
|
|
289
|
+
const stats = statSync(real);
|
|
290
|
+
if (!stats.isFile()) return null;
|
|
291
|
+
if (!inside(realpathSync(outputDir), real)) return null;
|
|
292
|
+
const forbidden = [
|
|
293
|
+
reportPath,
|
|
294
|
+
campaignSidecarPaths(targetRepo).reportPath,
|
|
295
|
+
isNonEmptyString(packetPath) ? sidecarPathForPacket(packetPath) : null,
|
|
296
|
+
...outputs.filter((path) => path !== candidates[0]),
|
|
297
|
+
].filter(isNonEmptyString);
|
|
298
|
+
for (const path of forbidden) {
|
|
299
|
+
if (sameFile(real, path)) return null;
|
|
300
|
+
if (!existsSync(path)) continue;
|
|
301
|
+
const other = statSync(path);
|
|
302
|
+
if (other.dev === stats.dev && other.ino === stats.ino) return null;
|
|
303
|
+
}
|
|
304
|
+
return JSON.parse(readFileSync(real, "utf8"));
|
|
305
|
+
} catch {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function readQaResults({ stageEvidence, stage = null, fullVerdict = null, currentBuild = null, qcStandIns = null, rederivers = null } = {}) {
|
|
311
|
+
const stored = Array.isArray(stageEvidence?.qc_results) ? stageEvidence.qc_results : null;
|
|
312
|
+
if (!stored) return [];
|
|
313
|
+
const verdictOk = qaFullVerdictProblem(fullVerdict, stage) === null;
|
|
314
|
+
const measuredAt = verdictOk ? (validTime(fullVerdict.completed_at) ? fullVerdict.completed_at : fullVerdict.started_at) : null;
|
|
315
|
+
const bound = isNonEmptyString(currentBuild) && stageEvidence.qc_build_fingerprint === currentBuild;
|
|
316
|
+
|
|
317
|
+
// Rows pair with assertions by evidence.qc.result_id, never by assertion id.
|
|
318
|
+
// A passing verdict holds only well-formed QC evidence (qaFullVerdictProblem),
|
|
319
|
+
// so the only assertions passed over here carry none.
|
|
320
|
+
const paired = new Map();
|
|
321
|
+
const duplicated = new Set();
|
|
322
|
+
for (const assertion of verdictOk ? fullVerdict.assertions : []) {
|
|
323
|
+
if (typeof assertion.id !== "string" || !assertion.id.startsWith("qc.")) continue;
|
|
324
|
+
const resultId = assertion.evidence.qc.result_id;
|
|
325
|
+
if (paired.has(resultId)) duplicated.add(resultId);
|
|
326
|
+
paired.set(resultId, assertion);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const read = new Map();
|
|
330
|
+
stored.forEach((row, index) => {
|
|
331
|
+
const id = isPlainObject(row) && isNonEmptyString(row.id) ? row.id : `qc_results[${index}]`;
|
|
332
|
+
if (read.has(id)) {
|
|
333
|
+
read.set(id, unreproducedRow(read.get(id), E, { leg: "qa", measuredAt }));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
read.set(id, readQaRow(isPlainObject(row) ? row : { id }, { verdictOk, assertion: duplicated.has(id) ? null : paired.get(id), measuredAt, qcStandIns, rederivers, bound }));
|
|
337
|
+
});
|
|
338
|
+
// A verdict result_id with no row: synthesized, never silent.
|
|
339
|
+
for (const [resultId, assertion] of paired) {
|
|
340
|
+
if (read.has(resultId)) continue;
|
|
341
|
+
const observation = assertion.evidence.qc.observation;
|
|
342
|
+
const check = isNonEmptyString(observation?.check) ? observation.check : resultId.split(":")[0];
|
|
343
|
+
read.set(resultId, unreproducedRow({ id: resultId, check, subject: { check, page: assertion.page ?? null, key: null } }, E, { leg: "qa", measuredAt }));
|
|
344
|
+
}
|
|
345
|
+
return [...read.values()];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function readQaRow(row, { verdictOk, assertion, measuredAt, qcStandIns, rederivers, bound }) {
|
|
349
|
+
const fail = () => unreproducedRow(row, E, { leg: "qa", measuredAt });
|
|
350
|
+
if (!verdictOk || !assertion) return fail();
|
|
351
|
+
if (row.schema !== QC_RESULT_SCHEMA || row.producer !== QC_PRODUCERS.qa || row.leg !== "qa" || !isNonEmptyString(row.check)) return fail();
|
|
352
|
+
const observation = assertion.evidence.qc.observation;
|
|
353
|
+
if (!sameJson(row.observation, observation)) return fail();
|
|
354
|
+
if (!QA_ASSERTION_FAMILY_VOCABULARY.includes(assertion.family)) return fail();
|
|
355
|
+
const rederiver = qaRederiver(row.check, { qcStandIns, rederivers });
|
|
356
|
+
if (rederiver.status === "missing") {
|
|
357
|
+
const notCaptured = unreproducedRow(row, QC_REASON.NOT_CAPTURED, { leg: "qa", measuredAt });
|
|
358
|
+
return bound ? notCaptured : staleRow(notCaptured);
|
|
359
|
+
}
|
|
360
|
+
if (rederiver.status !== "loaded") return fail();
|
|
361
|
+
let derived = null;
|
|
362
|
+
try {
|
|
363
|
+
derived = rederiver.rederive(observation);
|
|
364
|
+
} catch {
|
|
365
|
+
derived = null;
|
|
366
|
+
}
|
|
367
|
+
if (!validDerived(derived, row.check)) return fail();
|
|
368
|
+
const members = derived.members ?? [];
|
|
369
|
+
const coverage = derived.coverage ?? { observed: 1, expected: 1, limits: [] };
|
|
370
|
+
const stateFingerprint = qcStateFingerprint({ subject: derived.subject, state: derived.state });
|
|
371
|
+
const reasonCode = derived.result === "pass" ? null : derived.reason_code;
|
|
372
|
+
const expected = QC_QA_ASSERTION_STATUS[derived.result];
|
|
373
|
+
if (row.id !== qcResultId(derived.subject)
|
|
374
|
+
|| !sameJson(row.subject, derived.subject)
|
|
375
|
+
|| row.result !== derived.result
|
|
376
|
+
|| (row.reason_code ?? null) !== reasonCode
|
|
377
|
+
|| !sameJson(row.members ?? [], members)
|
|
378
|
+
|| row.accept_eligible !== derived.accept_eligible
|
|
379
|
+
|| !sameJson(row.coverage, coverage)
|
|
380
|
+
|| row.state_fingerprint !== stateFingerprint
|
|
381
|
+
|| assertion.status !== expected.status
|
|
382
|
+
|| assertion.severity !== expected.severity) return fail();
|
|
383
|
+
const result = {
|
|
384
|
+
schema: QC_RESULT_SCHEMA,
|
|
385
|
+
id: row.id,
|
|
386
|
+
check: row.check,
|
|
387
|
+
leg: "qa",
|
|
388
|
+
result: derived.result,
|
|
389
|
+
reason_code: reasonCode,
|
|
390
|
+
subject: derived.subject,
|
|
391
|
+
state_fingerprint: stateFingerprint,
|
|
392
|
+
observation,
|
|
393
|
+
members,
|
|
394
|
+
accept_eligible: derived.accept_eligible && qcAcceptEligible(derived.result, members),
|
|
395
|
+
coverage,
|
|
396
|
+
measured_at: measuredAt,
|
|
397
|
+
producer: QC_PRODUCERS.qa,
|
|
398
|
+
};
|
|
399
|
+
return bound ? result : staleRow(result);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// Polish reader site (media_weight against its page_load capture)
|
|
404
|
+
|
|
405
|
+
// Unkeyed tamper evidence over every field but `integrity`, the canonical-JSON
|
|
406
|
+
// + sha256 pattern of buildPolishCaptureIntegrity.
|
|
407
|
+
export function mediaWeightIntegrity(record) {
|
|
408
|
+
const { integrity: _ignored, ...rest } = record || {};
|
|
409
|
+
return `sha256:${createHash("sha256").update(canonicalJson(rest)).digest("hex")}`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const inVocabulary = (vocabulary, field, value) => Array.isArray(vocabulary?.[field]) && vocabulary[field].includes(value);
|
|
413
|
+
const isPair = (value) => Array.isArray(value) && value.length === 2 && value.every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0);
|
|
414
|
+
|
|
415
|
+
// The subjects a cell lists whatever happens to it: one weight result per
|
|
416
|
+
// resource, one per unfetched <video>, one per probed <img> whose currentSrc
|
|
417
|
+
// binds to no resource (mediaChainBinder: "img:<element_path>"), one oversize result per
|
|
418
|
+
// <img>, and one oversize result keyed "cell" for a cell that lists no <img>
|
|
419
|
+
// (so an image-free page is never silent).
|
|
420
|
+
function cellSubjects(cell) {
|
|
421
|
+
const route = cell?.route ?? null;
|
|
422
|
+
const viewport = cell?.viewport ?? null;
|
|
423
|
+
const subjects = [];
|
|
424
|
+
const resources = Array.isArray(cell?.resources) ? cell.resources : [];
|
|
425
|
+
const images = Array.isArray(cell?.images) ? cell.images : [];
|
|
426
|
+
for (const resource of resources) {
|
|
427
|
+
subjects.push({ check: "media.weight", page: route, viewport, key: resource?.resource_id ?? null });
|
|
428
|
+
}
|
|
429
|
+
for (const video of Array.isArray(cell?.videos) ? cell.videos : []) {
|
|
430
|
+
if (Array.isArray(video?.resource_ids) && video.resource_ids.length) continue;
|
|
431
|
+
subjects.push({ check: "media.weight", page: route, viewport, key: `video:${video?.element_index}` });
|
|
432
|
+
}
|
|
433
|
+
const bind = mediaChainBinder(resources);
|
|
434
|
+
const unledgered = new Set();
|
|
435
|
+
for (const image of images) {
|
|
436
|
+
if (bind(image?.resource_id).status !== "absent") continue;
|
|
437
|
+
const key = `img:${image?.element_path}`;
|
|
438
|
+
if (unledgered.has(key)) continue;
|
|
439
|
+
unledgered.add(key);
|
|
440
|
+
subjects.push({ check: "media.weight", page: route, viewport, key });
|
|
441
|
+
}
|
|
442
|
+
for (const image of images) {
|
|
443
|
+
subjects.push({ check: "media.oversize", page: route, viewport, key: `${image?.resource_id}:${image?.element_path}` });
|
|
444
|
+
}
|
|
445
|
+
if (!images.length) subjects.push({ check: "media.oversize", page: route, viewport, key: "cell" });
|
|
446
|
+
return subjects;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// A value the image probe observed, or null where the cell's probe did not
|
|
450
|
+
// complete and so observed nothing (dpr and image geometry have no raw
|
|
451
|
+
// counterpart; only their vocabulary is checked).
|
|
452
|
+
const observedOrNull = (cell, value, check) => check(value) || (cell.probe_status !== "complete" && value === null);
|
|
453
|
+
|
|
454
|
+
function recordVocabularyOk(record, vocabulary) {
|
|
455
|
+
return record.cells.every((cell) => isPlainObject(cell)
|
|
456
|
+
&& isNonEmptyString(cell.route)
|
|
457
|
+
&& isNonEmptyString(cell.viewport)
|
|
458
|
+
&& Array.isArray(record.subject?.routes) && record.subject.routes.includes(cell.route)
|
|
459
|
+
&& Array.isArray(record.subject?.viewports) && record.subject.viewports.includes(cell.viewport)
|
|
460
|
+
&& observedOrNull(cell, cell.dpr, (dpr) => typeof dpr === "number" && dpr > 0)
|
|
461
|
+
&& isNonEmptyString(cell.page_load_integrity)
|
|
462
|
+
&& inVocabulary(vocabulary, "capture_status", cell.capture_status)
|
|
463
|
+
&& inVocabulary(vocabulary, "probe_status", cell.probe_status)
|
|
464
|
+
&& Array.isArray(cell.resources) && cell.resources.every((resource) => isPlainObject(resource)
|
|
465
|
+
&& isNonEmptyString(resource.resource_id)
|
|
466
|
+
&& inVocabulary(vocabulary, "measurement", resource.measurement)
|
|
467
|
+
&& typeof resource.failed === "boolean"
|
|
468
|
+
&& typeof resource.final_origin_equal === "boolean"
|
|
469
|
+
&& Number.isFinite(resource.transferred_bytes)
|
|
470
|
+
&& (resource.declared_bytes === null || Number.isFinite(resource.declared_bytes))
|
|
471
|
+
&& Array.isArray(resource.chain) && resource.chain.length > 0 && resource.chain.every(isPlainObject))
|
|
472
|
+
&& Array.isArray(cell.images) && cell.images.every((image) => isPlainObject(image)
|
|
473
|
+
&& (image.resource_id === null || isNonEmptyString(image.resource_id))
|
|
474
|
+
&& isNonEmptyString(image.element_path)
|
|
475
|
+
&& observedOrNull(cell, image.complete, (value) => typeof value === "boolean")
|
|
476
|
+
&& observedOrNull(cell, image.hidden, (value) => typeof value === "boolean")
|
|
477
|
+
&& observedOrNull(cell, image.natural, isPair)
|
|
478
|
+
&& observedOrNull(cell, image.rendered, isPair)
|
|
479
|
+
&& observedOrNull(cell, image.object_fit, (value) => inVocabulary(vocabulary, "object_fit", value))
|
|
480
|
+
&& inVocabulary(vocabulary, "loading", image.loading))
|
|
481
|
+
&& Array.isArray(cell.videos) && cell.videos.every((video) => isPlainObject(video)
|
|
482
|
+
&& Number.isInteger(video.element_index)
|
|
483
|
+
&& Array.isArray(video.resource_ids) && video.resource_ids.every(isNonEmptyString)
|
|
484
|
+
&& typeof video.declared_origin_equal === "boolean"));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// The page_load fields the cell checks read, each required present with the
|
|
488
|
+
// producer's type. A missing or ill-typed field never stands in for 0, false,
|
|
489
|
+
// complete or an empty list: the cell does not re-derive.
|
|
490
|
+
const isCount = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
491
|
+
const LEDGER_COUNTS = Object.freeze([
|
|
492
|
+
"transferred_bytes",
|
|
493
|
+
"declared_bytes",
|
|
494
|
+
"request_count",
|
|
495
|
+
"declared_request_count",
|
|
496
|
+
"failed_request_count",
|
|
497
|
+
"cache_request_count",
|
|
498
|
+
"unmeasured_request_count",
|
|
499
|
+
"canceled_request_count",
|
|
500
|
+
"partial_request_count",
|
|
501
|
+
"cross_origin_request_count",
|
|
502
|
+
]);
|
|
503
|
+
const MEDIA_TAGS = Object.freeze(["video", "audio"]);
|
|
504
|
+
const SOURCE_KINDS = Object.freeze(["current_src", "src_attribute", "source_src_attribute", "observed_source"]);
|
|
505
|
+
|
|
506
|
+
// A resource_ledger entry: its identity, URL and type, every byte count and
|
|
507
|
+
// request counter (mediaFetchedResources copies request_count and
|
|
508
|
+
// declared_bytes too), its statuses, and the identity set it matched (which
|
|
509
|
+
// always holds its own resource_id).
|
|
510
|
+
function ledgerEntryOk(entry) {
|
|
511
|
+
return isPlainObject(entry)
|
|
512
|
+
&& isNonEmptyString(entry.resource_id)
|
|
513
|
+
&& isNonEmptyString(entry.url)
|
|
514
|
+
&& isNonEmptyString(entry.resource_type)
|
|
515
|
+
&& LEDGER_COUNTS.every((field) => isCount(entry[field]))
|
|
516
|
+
&& Array.isArray(entry.statuses) && entry.statuses.every(Number.isInteger)
|
|
517
|
+
&& Array.isArray(entry.match_resource_ids) && entry.match_resource_ids.every(isNonEmptyString)
|
|
518
|
+
&& entry.match_resource_ids.includes(entry.resource_id);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// A media[] element: its tag, its index, every source reference's kind, index,
|
|
522
|
+
// URL and resolved resource_id (null when the ledger cannot identify the
|
|
523
|
+
// source), and every fetched resource's identity and the source identities it
|
|
524
|
+
// matched. The capture checksum covers the source and fetched-resource
|
|
525
|
+
// associations, so each of those fields is read too.
|
|
526
|
+
function mediaElementOk(element) {
|
|
527
|
+
return isPlainObject(element)
|
|
528
|
+
&& MEDIA_TAGS.includes(element.tag_name)
|
|
529
|
+
&& isCount(element.element_index)
|
|
530
|
+
&& Array.isArray(element.source_references)
|
|
531
|
+
&& element.source_references.every((reference) => isPlainObject(reference)
|
|
532
|
+
&& SOURCE_KINDS.includes(reference.source_kind)
|
|
533
|
+
&& isCount(reference.source_index)
|
|
534
|
+
&& isNonEmptyString(reference.url)
|
|
535
|
+
&& Object.hasOwn(reference, "resource_id")
|
|
536
|
+
&& (reference.resource_id === null || isNonEmptyString(reference.resource_id)))
|
|
537
|
+
&& Array.isArray(element.fetched_resources)
|
|
538
|
+
&& element.fetched_resources.every((resource) => isPlainObject(resource)
|
|
539
|
+
&& isNonEmptyString(resource.resource_id)
|
|
540
|
+
&& Array.isArray(resource.matched_source_resource_ids)
|
|
541
|
+
&& resource.matched_source_resource_ids.every(isNonEmptyString));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// A cell resource's own URL and type, and each chain hop's identity, URL and
|
|
545
|
+
// status, which the cell checks compare with the ledger. A null status is
|
|
546
|
+
// left to the chain check, which allows it only on a final hop that failed
|
|
547
|
+
// with no HTTP response.
|
|
548
|
+
function cellResourceShapeOk(resource) {
|
|
549
|
+
return isNonEmptyString(resource.url)
|
|
550
|
+
&& isNonEmptyString(resource.type)
|
|
551
|
+
&& resource.chain.every((hop) => isNonEmptyString(hop.resource_id) && isNonEmptyString(hop.url) && (Number.isInteger(hop.status) || hop.status === null));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function ledgerMeasurement(entry) {
|
|
555
|
+
if (entry.cache_request_count > 0) return "cached";
|
|
556
|
+
if (entry.unmeasured_request_count > 0) return "unmeasured";
|
|
557
|
+
if (entry.canceled_request_count > 0 || entry.partial_request_count > 0) return "lower_bound";
|
|
558
|
+
return "complete";
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// A redirect answer: the hop continues to another URL. Every other status
|
|
562
|
+
// (a 2xx, a 304, an error) ends the chain.
|
|
563
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
564
|
+
const isRedirectEntry = (entry) => Array.isArray(entry.statuses) && entry.statuses.length > 0 && entry.statuses.every((status) => REDIRECT_STATUSES.has(status));
|
|
565
|
+
const mixesRedirect = (entry) => Array.isArray(entry.statuses) && entry.statuses.some((status) => REDIRECT_STATUSES.has(status)) && !isRedirectEntry(entry);
|
|
566
|
+
|
|
567
|
+
// The one binding of a requested href (the identity an element used) to a
|
|
568
|
+
// request chain, read from the page_load ledger alone, so it is the same
|
|
569
|
+
// whatever order the requests came in. The producer builds every resource's
|
|
570
|
+
// chain with it and the reader checks every resource's chain against it.
|
|
571
|
+
// - "bound": the ledger singles out one chain. Its final hop is the one entry
|
|
572
|
+
// that names the requested href in its match_resource_ids and answered
|
|
573
|
+
// with a non-redirect status (the requested entry itself when it was not
|
|
574
|
+
// redirected); its hops are that final entry plus every redirect entry it
|
|
575
|
+
// names. The ledger keeps no Location, so it fixes the hop set and both
|
|
576
|
+
// ends; it cannot order intermediate hops.
|
|
577
|
+
// - "ambiguous": the href stands for more than one chain, or a hop of its
|
|
578
|
+
// chain also belongs to another chain. No result may be read from any one
|
|
579
|
+
// of those chains. That is the case when:
|
|
580
|
+
// - any entry of the chain, or any entry its final hop names, mixes a
|
|
581
|
+
// redirect and a non-redirect status (one request through that URL was
|
|
582
|
+
// answered, another redirected);
|
|
583
|
+
// - it only redirected and no single final hop names it;
|
|
584
|
+
// - it was answered directly and a redirect entry names it too (it is one
|
|
585
|
+
// chain's final hop and another chain's start);
|
|
586
|
+
// - a hop answered more requests than the requested href did (that hop
|
|
587
|
+
// also started a chain of its own).
|
|
588
|
+
// - "absent": no ledger entry has that id.
|
|
589
|
+
export function bindLedgerChain(requestedId, entries, byId = new Map(entries.map((entry) => [entry?.resource_id, entry]))) {
|
|
590
|
+
const requested = byId.get(requestedId);
|
|
591
|
+
if (!requested) return { status: "absent" };
|
|
592
|
+
if (mixesRedirect(requested)) return { status: "ambiguous" };
|
|
593
|
+
let final = requested;
|
|
594
|
+
if (isRedirectEntry(requested)) {
|
|
595
|
+
const finals = entries.filter((entry) => !isRedirectEntry(entry) && Array.isArray(entry.match_resource_ids) && entry.match_resource_ids.includes(requestedId));
|
|
596
|
+
if (finals.length !== 1) return { status: "ambiguous" };
|
|
597
|
+
[final] = finals;
|
|
598
|
+
}
|
|
599
|
+
if (!Array.isArray(final.match_resource_ids)) return { status: "ambiguous" };
|
|
600
|
+
const hops = new Set([final.resource_id, requestedId]);
|
|
601
|
+
for (const id of final.match_resource_ids) {
|
|
602
|
+
const entry = byId.get(id);
|
|
603
|
+
if (entry && mixesRedirect(entry)) return { status: "ambiguous" };
|
|
604
|
+
if (entry && entry !== final && isRedirectEntry(entry)) hops.add(id);
|
|
605
|
+
}
|
|
606
|
+
if (final === requested && hops.size > 1) return { status: "ambiguous" };
|
|
607
|
+
if ([...hops].some((id) => byId.get(id)?.request_count !== requested.request_count)) return { status: "ambiguous" };
|
|
608
|
+
return { status: "bound", final, hops };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// The one binding of a cell's elements to its resources, read from the
|
|
612
|
+
// record: an element identity (an <img>'s currentSrc id, a <video>'s fetched
|
|
613
|
+
// ledger ids) binds to the resource whose chain holds it. The 1.3 rules
|
|
614
|
+
// (src/polish-media-weight.mjs) and the reader's subject list both bind
|
|
615
|
+
// through it. Returns { status: "bound", resource } when exactly one
|
|
616
|
+
// resource's chain holds the id, { status: "ambiguous" } when more than one
|
|
617
|
+
// does, and { status: "absent" } when none does.
|
|
618
|
+
export function mediaChainBinder(resources) {
|
|
619
|
+
const holders = new Map();
|
|
620
|
+
for (const resource of Array.isArray(resources) ? resources : []) {
|
|
621
|
+
for (const hop of Array.isArray(resource?.chain) ? resource.chain : []) {
|
|
622
|
+
const id = hop?.resource_id;
|
|
623
|
+
if (!holders.has(id)) holders.set(id, new Set());
|
|
624
|
+
holders.get(id).add(resource);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return (id) => {
|
|
628
|
+
const found = id === null || id === undefined ? undefined : holders.get(id);
|
|
629
|
+
if (!found) return { status: "absent" };
|
|
630
|
+
return found.size === 1 ? { status: "bound", resource: [...found][0] } : { status: "ambiguous" };
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// The ledger types an <img> request can be recorded under: its own (image),
|
|
635
|
+
// a type merged with another load of the same URL (unknown), or a hint that
|
|
636
|
+
// fetched it first (other, prefetch). An <img> bound to a document, script,
|
|
637
|
+
// stylesheet or any other entry did not load from it.
|
|
638
|
+
const IMAGE_REQUEST_TYPES = new Set(["image", "other", "prefetch", "unknown"]);
|
|
639
|
+
|
|
640
|
+
// Every field of a cell that has a raw counterpart, checked against the
|
|
641
|
+
// page_load capture for the same route and viewport. True when it re-derives.
|
|
642
|
+
// Every set the reader consumes is derived from the capture and required to
|
|
643
|
+
// match exactly: each resource's hop set and final hop (bindLedgerChain; a
|
|
644
|
+
// requested href the ledger binds to more than one chain, or whose chain
|
|
645
|
+
// shares a hop with another chain, does not re-derive, whatever the request
|
|
646
|
+
// order), the cell's resource set (the chains partition
|
|
647
|
+
// the capture's ledger: every entry in exactly one resource's chain), each
|
|
648
|
+
// <img> identity (null, or one resource's requested href), and the cell's
|
|
649
|
+
// video set (every <video> media element). A truncated chain, a dropped resource or a dropped video
|
|
650
|
+
// does not re-derive. Every raw field read is first required present and well
|
|
651
|
+
// typed (ledgerEntryOk, mediaElementOk): a ledger, media list, counter, byte
|
|
652
|
+
// count, status list, origin or identity set that is missing or ill typed
|
|
653
|
+
// does not re-derive either.
|
|
654
|
+
function cellReproduces(cell, capture) {
|
|
655
|
+
if (!isPlainObject(capture) || !isPlainObject(capture.integrity)) return false;
|
|
656
|
+
if (!sameJson(buildPolishCaptureIntegrity(capture), capture.integrity)) return false;
|
|
657
|
+
if (cell.page_load_integrity !== capture.integrity.projection_fingerprint) return false;
|
|
658
|
+
if (!isNonEmptyString(capture.measurement_status) || cell.capture_status !== capture.measurement_status) return false;
|
|
659
|
+
const finalOrigin = isPlainObject(capture.document_response) ? capture.document_response.final_origin : null;
|
|
660
|
+
if (!isNonEmptyString(finalOrigin) || captureOrigin(finalOrigin) !== finalOrigin || cell.document_origin !== finalOrigin) return false;
|
|
661
|
+
if (!isPlainObject(capture.resource_ledger) || !Array.isArray(capture.resource_ledger.entries) || !capture.resource_ledger.entries.every(ledgerEntryOk)) return false;
|
|
662
|
+
if (!Array.isArray(capture.media) || !capture.media.every(mediaElementOk)) return false;
|
|
663
|
+
const entries = capture.resource_ledger.entries;
|
|
664
|
+
const byId = new Map(entries.map((entry) => [entry.resource_id, entry]));
|
|
665
|
+
if (byId.size !== entries.length) return false;
|
|
666
|
+
const media = capture.media;
|
|
667
|
+
if (new Set(media.map((element) => element.element_index)).size !== media.length) return false;
|
|
668
|
+
const covered = new Set();
|
|
669
|
+
for (const resource of cell.resources) {
|
|
670
|
+
if (!cellResourceShapeOk(resource)) return false;
|
|
671
|
+
const { chain } = resource;
|
|
672
|
+
if (chain[0].resource_id !== resource.resource_id || chain[0].url !== resource.url) return false;
|
|
673
|
+
const expected = bindLedgerChain(resource.resource_id, entries, byId);
|
|
674
|
+
if (expected.status !== "bound") return false;
|
|
675
|
+
const { final } = expected;
|
|
676
|
+
const chainIds = chain.map((hop) => hop.resource_id);
|
|
677
|
+
if (chain.at(-1).resource_id !== final.resource_id
|
|
678
|
+
|| new Set(chainIds).size !== chainIds.length
|
|
679
|
+
|| chainIds.length !== expected.hops.size
|
|
680
|
+
|| !chainIds.every((id) => expected.hops.has(id))) return false;
|
|
681
|
+
for (const [index, hop] of chain.entries()) {
|
|
682
|
+
const entry = byId.get(hop.resource_id);
|
|
683
|
+
if (!entry || hop.url !== entry.url || !Array.isArray(entry.statuses)) return false;
|
|
684
|
+
// A final hop whose request failed with no HTTP response carries no status.
|
|
685
|
+
const noResponse = index === chain.length - 1 && entry.statuses.length === 0 && entry.failed_request_count > 0 && hop.status === null;
|
|
686
|
+
if (!noResponse && !entry.statuses.includes(hop.status)) return false;
|
|
687
|
+
if (!final.match_resource_ids.includes(hop.resource_id)) return false;
|
|
688
|
+
// Every hop but the last answered with a redirect; the last did not.
|
|
689
|
+
if (REDIRECT_STATUSES.has(hop.status) !== (index < chain.length - 1)) return false;
|
|
690
|
+
if (covered.has(hop.resource_id)) return false;
|
|
691
|
+
covered.add(hop.resource_id);
|
|
692
|
+
}
|
|
693
|
+
if (resource.type !== final.resource_type
|
|
694
|
+
|| resource.transferred_bytes !== final.transferred_bytes
|
|
695
|
+
|| resource.declared_bytes !== (final.declared_request_count > 0 ? final.declared_bytes : null)
|
|
696
|
+
|| resource.failed !== (final.failed_request_count > 0)
|
|
697
|
+
|| resource.measurement !== ledgerMeasurement(final)
|
|
698
|
+
|| resource.final_origin_equal !== (final.cross_origin_request_count === 0)) return false;
|
|
699
|
+
}
|
|
700
|
+
if (covered.size !== byId.size) return false;
|
|
701
|
+
// Every <img> identity is null (its currentSrc has no ledger entry: weight
|
|
702
|
+
// not_in_ledger) or exactly the requested href of one resource, whose chain
|
|
703
|
+
// the ledger binds (above) and whose entry an <img> request can produce. A
|
|
704
|
+
// re-cased or unknown id, a redirect or final hop, or a document or script
|
|
705
|
+
// entry does not re-derive.
|
|
706
|
+
const heads = new Set(cell.resources.map((resource) => resource.resource_id));
|
|
707
|
+
for (const image of cell.images) {
|
|
708
|
+
if (image.resource_id === null) continue;
|
|
709
|
+
if (!heads.has(image.resource_id) || !IMAGE_REQUEST_TYPES.has(byId.get(image.resource_id).resource_type)) return false;
|
|
710
|
+
}
|
|
711
|
+
const videoIndexes = media.filter((element) => element.tag_name === "video").map((element) => element.element_index).sort((a, b) => a - b);
|
|
712
|
+
const listedIndexes = cell.videos.map((video) => video.element_index).sort((a, b) => a - b);
|
|
713
|
+
if (!sameJson(listedIndexes, videoIndexes)) return false;
|
|
714
|
+
for (const video of cell.videos) {
|
|
715
|
+
const element = media.find((candidate) => candidate.element_index === video.element_index);
|
|
716
|
+
if (!element) return false;
|
|
717
|
+
const fetched = mediaFetchedResources(element, entries).map((entry) => entry.resource_id).sort();
|
|
718
|
+
if (!sameJson([...video.resource_ids].sort(), fetched)) return false;
|
|
719
|
+
const declaredSameOrigin = element.source_references.some((reference) => captureOrigin(reference.url) === finalOrigin);
|
|
720
|
+
if (video.declared_origin_equal !== declaredSameOrigin) return false;
|
|
721
|
+
}
|
|
722
|
+
return true;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const cellKey = (route, viewport) => JSON.stringify([route, viewport]);
|
|
726
|
+
const uniqueStrings = (list) => Array.isArray(list) && list.length > 0 && list.every(isNonEmptyString) && new Set(list).size === list.length;
|
|
727
|
+
// A subject without its build binding, and whether its other fields are
|
|
728
|
+
// exactly `fields`.
|
|
729
|
+
const unbound = (subject) => {
|
|
730
|
+
if (!isPlainObject(subject)) return subject;
|
|
731
|
+
const { build_fingerprint: _build, ...rest } = subject;
|
|
732
|
+
return rest;
|
|
733
|
+
};
|
|
734
|
+
const hasExactly = (object, fields) => isPlainObject(object) && sameJson(Object.keys(unbound(object)).sort(), [...fields].sort());
|
|
735
|
+
|
|
736
|
+
// The routes of spec pages the run did not capture (no source mapping),
|
|
737
|
+
// listed in the record as uncaptured_routes (absent reads as none). The
|
|
738
|
+
// page_load capture records that pages were skipped only as route_scope
|
|
739
|
+
// "selected", so a list is accepted only then, and only of routes it did not
|
|
740
|
+
// capture. Null when the list is malformed.
|
|
741
|
+
function uncapturedRoutesOf(record) {
|
|
742
|
+
if (!Object.hasOwn(record, "uncaptured_routes")) return [];
|
|
743
|
+
const routes = record.uncaptured_routes;
|
|
744
|
+
if (!Array.isArray(routes)) return null;
|
|
745
|
+
if (!routes.length) return routes;
|
|
746
|
+
return uniqueStrings(routes)
|
|
747
|
+
&& record.subject.route_scope === "selected"
|
|
748
|
+
&& routes.every((route) => route.startsWith("/") && !record.subject.routes.includes(route))
|
|
749
|
+
? routes
|
|
750
|
+
: null;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// The page ids of skipped spec pages whose public route the run could not
|
|
754
|
+
// resolve, listed as uncaptured_page_ids (absent reads as none); accepted, like
|
|
755
|
+
// uncaptured_routes, only under route_scope "selected". Null when malformed.
|
|
756
|
+
function uncapturedPageIdsOf(record) {
|
|
757
|
+
if (!Object.hasOwn(record, "uncaptured_page_ids")) return [];
|
|
758
|
+
const pageIds = record.uncaptured_page_ids;
|
|
759
|
+
if (!Array.isArray(pageIds)) return null;
|
|
760
|
+
if (!pageIds.length) return pageIds;
|
|
761
|
+
return uniqueStrings(pageIds) && record.subject.route_scope === "selected" ? pageIds : null;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// The record's declared subject: exactly the page_load subject fields, each
|
|
765
|
+
// well formed. Its routes × viewports is the declared cell grid.
|
|
766
|
+
function declaredSubjectOk(subject) {
|
|
767
|
+
return hasExactly(subject, PAGE_LOAD_SUBJECT_FIELDS)
|
|
768
|
+
&& isNonEmptyString(subject.campaign_slug)
|
|
769
|
+
&& ["all", "selected"].includes(subject.route_scope)
|
|
770
|
+
&& uniqueStrings(subject.routes)
|
|
771
|
+
&& uniqueStrings(subject.viewports);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const declaredGrid = (subject) => (Array.isArray(subject?.routes) ? subject.routes : [])
|
|
775
|
+
.flatMap((route) => (Array.isArray(subject?.viewports) ? subject.viewports : []).map((viewport) => ({ route, viewport })));
|
|
776
|
+
|
|
777
|
+
// The page_load measurement summary agrees with the declared grid and the
|
|
778
|
+
// captures: it expects every declared cell, counts every capture, and lists
|
|
779
|
+
// nothing missing, duplicated or unexpected; any cell it calls incomplete is
|
|
780
|
+
// a declared one.
|
|
781
|
+
function measurementSummaryOk(measurement, grid, captureCount) {
|
|
782
|
+
const gridKeys = new Set(grid.map(({ route, viewport }) => cellKey(route, viewport)));
|
|
783
|
+
return isPlainObject(measurement)
|
|
784
|
+
&& measurement.expected_capture_count === grid.length
|
|
785
|
+
&& measurement.captured_count === captureCount
|
|
786
|
+
&& ["missing", "duplicate", "unexpected"].every((field) => Array.isArray(measurement[field]) && measurement[field].length === 0)
|
|
787
|
+
&& Array.isArray(measurement.incomplete)
|
|
788
|
+
&& measurement.incomplete.every((entry) => isPlainObject(entry) && gridKeys.has(cellKey(entry.route, entry.viewport)));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// A route capture's own identity and binding, against the record's declared
|
|
792
|
+
// subject: producer and schema, exactly the capture subject fields, the same
|
|
793
|
+
// campaign, a document that stayed on the requested route, and a declared
|
|
794
|
+
// route and viewport. Returns "fail" when any of that does not hold, else
|
|
795
|
+
// "stale" when the capture's build is absent, null or not the current build,
|
|
796
|
+
// else null.
|
|
797
|
+
function captureBindingProblem(capture, subject, currentBuild) {
|
|
798
|
+
const own = capture?.subject;
|
|
799
|
+
if (capture?.schema_version !== ROUTE_CAPTURE_SCHEMA
|
|
800
|
+
|| capture?.performed_by !== QC_PRODUCERS.polish
|
|
801
|
+
|| !hasExactly(own, CAPTURE_SUBJECT_FIELDS)
|
|
802
|
+
|| own.campaign_slug !== subject.campaign_slug
|
|
803
|
+
|| !isNonEmptyString(own.requested_route)
|
|
804
|
+
|| own.final_document_route !== own.requested_route
|
|
805
|
+
|| !subject.routes.includes(own.requested_route)
|
|
806
|
+
|| !subject.viewports.includes(own.viewport)) return "fail";
|
|
807
|
+
return isNonEmptyString(currentBuild) && own.build_fingerprint === currentBuild ? null : "stale";
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const PAGE_NOT_CAPTURED = "page_not_captured";
|
|
811
|
+
|
|
812
|
+
export function readMediaWeight({ record, pageLoad, currentBuild = null, qcStandIns = null, rederivers = null } = {}) {
|
|
813
|
+
if (record === undefined || record === null) return [];
|
|
814
|
+
const measuredAt = validTime(record?.measured_at) ? record.measured_at : null;
|
|
815
|
+
const cells = Array.isArray(record?.cells) ? record.cells.filter(isPlainObject) : [];
|
|
816
|
+
const unreproduced = (subject) => unreproducedRow({ subject, check: subject.check }, E, { leg: "polish", measuredAt });
|
|
817
|
+
const cellResult = (check, route, viewport) => unreproduced({ check, page: route, viewport, key: "cell" });
|
|
818
|
+
// A failed cell: one result per subject it lists, and one per Polish check
|
|
819
|
+
// it lists no subject for, so a failed cell, even one listing nothing, is
|
|
820
|
+
// never silent.
|
|
821
|
+
const failCell = (cell) => {
|
|
822
|
+
const subjects = cellSubjects(cell);
|
|
823
|
+
return [
|
|
824
|
+
...subjects.map(unreproduced),
|
|
825
|
+
...POLISH_CHECKS.filter((check) => !subjects.some((subject) => subject.check === check)).map((check) => cellResult(check, cell?.route ?? null, cell?.viewport ?? null)),
|
|
826
|
+
];
|
|
827
|
+
};
|
|
828
|
+
// A record-level failure: every listed cell fails, and every route ×
|
|
829
|
+
// viewport that the record or page_load declares or captured but no cell
|
|
830
|
+
// lists gets one result per Polish check, so a missing route is never silent.
|
|
831
|
+
const failAll = () => {
|
|
832
|
+
const listed = new Set(cells.map((cell) => cellKey(cell.route, cell.viewport)));
|
|
833
|
+
const unlisted = new Map();
|
|
834
|
+
const note = (route, viewport) => {
|
|
835
|
+
const key = cellKey(route, viewport);
|
|
836
|
+
if (isNonEmptyString(route) && isNonEmptyString(viewport) && !listed.has(key)) unlisted.set(key, { route, viewport });
|
|
837
|
+
};
|
|
838
|
+
for (const { route, viewport } of [...declaredGrid(record?.subject), ...declaredGrid(pageLoad?.subject)]) note(route, viewport);
|
|
839
|
+
for (const capture of Array.isArray(pageLoad?.captures) ? pageLoad.captures : []) note(capture?.subject?.requested_route, capture?.subject?.viewport);
|
|
840
|
+
for (const entry of Array.isArray(pageLoad?.measurement?.missing) ? pageLoad.measurement.missing : []) note(entry?.route, entry?.viewport);
|
|
841
|
+
const rows = [
|
|
842
|
+
...cells.flatMap(failCell),
|
|
843
|
+
...[...unlisted.values()].flatMap(({ route, viewport }) => POLISH_CHECKS.map((check) => cellResult(check, route, viewport))),
|
|
844
|
+
];
|
|
845
|
+
return rows.length ? rows : [unreproduced({ check: "media.weight", page: null, key: "media_weight" })];
|
|
846
|
+
};
|
|
847
|
+
const resolved = resolvePolishRules({ qcStandIns, rederivers });
|
|
848
|
+
if (resolved.status === "missing") return [];
|
|
849
|
+
if (resolved.status !== "loaded") return failAll();
|
|
850
|
+
const { rules } = resolved;
|
|
851
|
+
|
|
852
|
+
// Record level: producer, integrity, thresholds, the declared subject and
|
|
853
|
+
// its binding to page_load, the page_load measurement summary, and the
|
|
854
|
+
// record vocabulary. Any failure makes every result unreproducible.
|
|
855
|
+
if (!isPlainObject(record)
|
|
856
|
+
|| record.schema_version !== MEDIA_WEIGHT_SCHEMA
|
|
857
|
+
|| record.performed_by !== QC_PRODUCERS.polish
|
|
858
|
+
|| !measuredAt
|
|
859
|
+
|| record.integrity !== mediaWeightIntegrity(record)
|
|
860
|
+
|| !sameJson(record.thresholds, rules.thresholds)
|
|
861
|
+
|| !declaredSubjectOk(record.subject)
|
|
862
|
+
|| !isPlainObject(pageLoad)
|
|
863
|
+
|| pageLoad.schema_version !== PAGE_LOAD_SCHEMA
|
|
864
|
+
|| pageLoad.performed_by !== QC_PRODUCERS.polish
|
|
865
|
+
|| !sameJson(unbound(record.subject), unbound(pageLoad.subject))
|
|
866
|
+
|| !Array.isArray(pageLoad.captures)
|
|
867
|
+
|| !measurementSummaryOk(pageLoad.measurement, declaredGrid(record.subject), pageLoad.captures.length)
|
|
868
|
+
|| !Array.isArray(record.cells)
|
|
869
|
+
|| cells.length !== record.cells.length
|
|
870
|
+
|| !recordVocabularyOk(record, rules.vocabulary)
|
|
871
|
+
|| uncapturedRoutesOf(record) === null
|
|
872
|
+
|| uncapturedPageIdsOf(record) === null) return failAll();
|
|
873
|
+
|
|
874
|
+
// The declared grid (routes × viewports) is the capture set and the cell
|
|
875
|
+
// set: exactly one page_load capture and one cell per declared route and
|
|
876
|
+
// viewport, none missing, none added, none twice.
|
|
877
|
+
const gridKeys = declaredGrid(record.subject).map(({ route, viewport }) => cellKey(route, viewport));
|
|
878
|
+
const captureByKey = new Map();
|
|
879
|
+
for (const capture of pageLoad.captures) {
|
|
880
|
+
const key = cellKey(capture?.subject?.requested_route, capture?.subject?.viewport);
|
|
881
|
+
if (!isPlainObject(capture) || captureByKey.has(key)) return failAll();
|
|
882
|
+
captureByKey.set(key, capture);
|
|
883
|
+
}
|
|
884
|
+
const cellKeys = cells.map((cell) => cellKey(cell.route, cell.viewport));
|
|
885
|
+
if (new Set(cellKeys).size !== cellKeys.length
|
|
886
|
+
|| cellKeys.length !== gridKeys.length
|
|
887
|
+
|| captureByKey.size !== gridKeys.length
|
|
888
|
+
|| !gridKeys.every((key) => captureByKey.has(key) && cellKeys.includes(key))) return failAll();
|
|
889
|
+
|
|
890
|
+
// Both enclosing subjects are bound to the current build; an absent, null
|
|
891
|
+
// or other build on either reads stale_binding.
|
|
892
|
+
const recordBound = isNonEmptyString(currentBuild)
|
|
893
|
+
&& record.subject.build_fingerprint === currentBuild
|
|
894
|
+
&& pageLoad.subject.build_fingerprint === currentBuild;
|
|
895
|
+
const results = [];
|
|
896
|
+
// A spec page with no source mapping: one result per 1.3 check and viewport,
|
|
897
|
+
// unexercised / page_not_captured; keyed "cell" on its public route, or
|
|
898
|
+
// "page:<page_id>" with no page when it has no resolvable route.
|
|
899
|
+
const uncaptured = [
|
|
900
|
+
...uncapturedRoutesOf(record).map((route) => ({ page: route, key: "cell" })),
|
|
901
|
+
...uncapturedPageIdsOf(record).map((pageId) => ({ page: null, key: `page:${pageId}` })),
|
|
902
|
+
];
|
|
903
|
+
for (const { page, key } of uncaptured) {
|
|
904
|
+
for (const viewport of record.subject.viewports) {
|
|
905
|
+
for (const check of POLISH_CHECKS) {
|
|
906
|
+
const subject = { check, page, viewport, key };
|
|
907
|
+
const row = unreproducedRow({ subject, check }, PAGE_NOT_CAPTURED, { leg: "polish", measuredAt });
|
|
908
|
+
results.push(recordBound ? row : staleRow(row));
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
for (const cell of cells) {
|
|
913
|
+
const capture = captureByKey.get(cellKey(cell.route, cell.viewport));
|
|
914
|
+
const binding = captureBindingProblem(capture, record.subject, currentBuild);
|
|
915
|
+
if (binding === "fail" || !cellReproduces(cell, capture)) {
|
|
916
|
+
results.push(...failCell(cell));
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
const bound = recordBound && binding === null;
|
|
920
|
+
let derived;
|
|
921
|
+
try {
|
|
922
|
+
derived = rules.evaluate(cell, record.thresholds);
|
|
923
|
+
} catch {
|
|
924
|
+
derived = null;
|
|
925
|
+
}
|
|
926
|
+
// Every derived subject is one the cell lists (cellSubjects), exactly:
|
|
927
|
+
// its own page, viewport and key, and no other field.
|
|
928
|
+
const own = new Set(cellSubjects(cell).map((subject) => canonicalJson(subject)));
|
|
929
|
+
if (!Array.isArray(derived) || !derived.every((item) => ["media.weight", "media.oversize"].includes(item?.check) && validDerived(item, item.check) && own.has(canonicalJson(item.subject)))) {
|
|
930
|
+
results.push(...failCell(cell));
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
for (const item of derived) {
|
|
934
|
+
const members = item.members ?? [];
|
|
935
|
+
const row = {
|
|
936
|
+
schema: QC_RESULT_SCHEMA,
|
|
937
|
+
id: qcResultId(item.subject),
|
|
938
|
+
check: item.check,
|
|
939
|
+
leg: "polish",
|
|
940
|
+
result: item.result,
|
|
941
|
+
reason_code: item.result === "pass" ? null : item.reason_code,
|
|
942
|
+
subject: item.subject,
|
|
943
|
+
state_fingerprint: qcStateFingerprint({ subject: item.subject, state: item.state }),
|
|
944
|
+
observation: isPlainObject(item.observation) ? item.observation : {},
|
|
945
|
+
members,
|
|
946
|
+
accept_eligible: item.accept_eligible && qcAcceptEligible(item.result, members),
|
|
947
|
+
coverage: item.coverage ?? { observed: 1, expected: 1, limits: [] },
|
|
948
|
+
measured_at: measuredAt,
|
|
949
|
+
producer: QC_PRODUCERS.polish,
|
|
950
|
+
};
|
|
951
|
+
results.push(bound ? row : staleRow(row));
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
return results;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// ---------------------------------------------------------------------------
|
|
958
|
+
// Every current result, per leg, and the silence rule
|
|
959
|
+
|
|
960
|
+
const QA_ALWAYS = Object.freeze(["tracking.url", "tracking.order", "tracking.tag"]);
|
|
961
|
+
const QA_CONTENT = Object.freeze(["content_param"]);
|
|
962
|
+
const QA_POLICY = Object.freeze(["policy.presence", "policy.availability"]);
|
|
963
|
+
const POLISH_CHECKS = Object.freeze(qcChecksForLeg("polish"));
|
|
964
|
+
const STORE_POLICY_FIELDS = Object.freeze(Object.keys(STORE_PAGE_MATCHERS));
|
|
965
|
+
|
|
966
|
+
// Applicability is decided without the leg running: the tracking checks
|
|
967
|
+
// always, the content-param check when analytics.params.content is non-empty,
|
|
968
|
+
// the policy-link checks when a store policy field is.
|
|
969
|
+
export function applicableQaChecks(spec) {
|
|
970
|
+
const content = spec?.analytics?.params?.content;
|
|
971
|
+
const hasContent = Array.isArray(content) ? content.length > 0 : isPlainObject(content) && Object.keys(content).length > 0;
|
|
972
|
+
const hasPolicy = STORE_POLICY_FIELDS.some((field) => isNonEmptyString(spec?.campaign?.[field]));
|
|
973
|
+
return [...QA_ALWAYS, ...(hasContent ? QA_CONTENT : []), ...(hasPolicy ? QA_POLICY : [])];
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// A leg has a stage record unless the stage is absent or is the pending
|
|
977
|
+
// placeholder prepare-build seeds (status pending, nothing recorded). Any other
|
|
978
|
+
// stage, whatever its status, is a recorded leg.
|
|
979
|
+
function stageRecorded(stage) {
|
|
980
|
+
if (!isPlainObject(stage)) return false;
|
|
981
|
+
if (stage.status !== "pending") return true;
|
|
982
|
+
return isPlainObject(stage.evidence)
|
|
983
|
+
|| (isPlainObject(stage.identity) && Object.keys(stage.identity).length > 0)
|
|
984
|
+
|| (Array.isArray(stage.outputs) && stage.outputs.length > 0);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const silence = (leg, check, reasonCode) => ({ check, leg, result: "unexercised", reason_code: reasonCode, count: 0, pages: [] });
|
|
988
|
+
|
|
989
|
+
// The handoff coverage entries for applicable checks a leg holds no row for.
|
|
990
|
+
// A leg with no stage record reads leg_not_run. A recorded leg never makes an
|
|
991
|
+
// applicable check silent: no row reads not_captured_by_this_version (the
|
|
992
|
+
// record predates the check, its module is missing or not yet loaded, or the
|
|
993
|
+
// loaded rules gave no row), except that a module that failed to load reads
|
|
994
|
+
// evidence_not_reproducible when the record holds the leg's QC evidence.
|
|
995
|
+
export function handoffCoverage({ report, spec, results = [], qcStandIns = null, rederivers = null }) {
|
|
996
|
+
const entries = [];
|
|
997
|
+
const hasRow = (leg, check) => results.some((row) => row.leg === leg && row.check === check);
|
|
998
|
+
const silentReason = (failed) => (failed ? E : QC_REASON.NOT_CAPTURED);
|
|
999
|
+
|
|
1000
|
+
const qaStage = report?.stages?.qa;
|
|
1001
|
+
const qaChecks = applicableQaChecks(spec);
|
|
1002
|
+
if (!stageRecorded(qaStage)) {
|
|
1003
|
+
for (const check of qaChecks) entries.push(silence("qa", check, QC_REASON.LEG_NOT_RUN));
|
|
1004
|
+
} else {
|
|
1005
|
+
const captured = Array.isArray(qaStage.evidence?.qc_results);
|
|
1006
|
+
for (const check of qaChecks) {
|
|
1007
|
+
if (hasRow("qa", check)) continue;
|
|
1008
|
+
entries.push(silence("qa", check, silentReason(captured && qaRederiver(check, { qcStandIns, rederivers }).status === "failed")));
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const polishStage = report?.stages?.polish;
|
|
1013
|
+
if (!stageRecorded(polishStage)) {
|
|
1014
|
+
for (const check of POLISH_CHECKS) entries.push(silence("polish", check, QC_REASON.LEG_NOT_RUN));
|
|
1015
|
+
} else {
|
|
1016
|
+
const visual = polishStage.evidence?.visual_review;
|
|
1017
|
+
const captured = isPlainObject(visual?.page_load) && visual.media_weight != null;
|
|
1018
|
+
const failed = captured && resolvePolishRules({ qcStandIns, rederivers }).status === "failed";
|
|
1019
|
+
for (const check of POLISH_CHECKS) {
|
|
1020
|
+
if (!hasRow("polish", check)) entries.push(silence("polish", check, silentReason(failed)));
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return entries;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Every current QC result `next` and `checkpoint accept` judge, read from
|
|
1027
|
+
// data already on disk: the doctor run, the Assembly Report, and the full QA
|
|
1028
|
+
// verdict the QA stage names. No request is made.
|
|
1029
|
+
export function readCurrentQcResults({ report, doctor, spec = null, targetRepo, packetPath = null, reportPath = null, qcStandIns = null, rederivers = null }) {
|
|
1030
|
+
const currentBuild = currentBuildFingerprint(report);
|
|
1031
|
+
const doctorResults = (Array.isArray(doctor?.derived?.qc_results) ? doctor.derived.qc_results : []).filter((row) => isPlainObject(row) && row.leg === "doctor");
|
|
1032
|
+
const visual = report?.stages?.polish?.evidence?.visual_review;
|
|
1033
|
+
const polishResults = isPlainObject(visual) && visual.media_weight != null
|
|
1034
|
+
? readMediaWeight({ record: visual.media_weight, pageLoad: visual.page_load, currentBuild, qcStandIns, rederivers })
|
|
1035
|
+
: [];
|
|
1036
|
+
const qaStage = report?.stages?.qa;
|
|
1037
|
+
const qaResults = Array.isArray(qaStage?.evidence?.qc_results)
|
|
1038
|
+
? readQaResults({
|
|
1039
|
+
stageEvidence: qaStage.evidence,
|
|
1040
|
+
stage: qaStage,
|
|
1041
|
+
fullVerdict: readQaFullVerdict({ stage: qaStage, targetRepo, packetPath, reportPath }),
|
|
1042
|
+
currentBuild,
|
|
1043
|
+
qcStandIns,
|
|
1044
|
+
rederivers,
|
|
1045
|
+
})
|
|
1046
|
+
: [];
|
|
1047
|
+
const results = [...doctorResults, ...polishResults, ...qaResults];
|
|
1048
|
+
return { results, coverage: handoffCoverage({ report, spec, results, qcStandIns, rederivers }) };
|
|
1049
|
+
}
|