@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,492 @@
|
|
|
1
|
+
// Media weight and hosting: the sibling stages.polish.evidence.visual_review
|
|
2
|
+
// .media_weight record, built from the same Polish page-load observation as
|
|
3
|
+
// page_load, and the rules every reader re-derives its results with.
|
|
4
|
+
//
|
|
5
|
+
// The record holds measurements only. Its results are never stored: readers
|
|
6
|
+
// (src/qc-results.mjs readMediaWeight) check the record against its page_load
|
|
7
|
+
// capture first, then call MEDIA_WEIGHT_QC_RULES.evaluate on each cell
|
|
8
|
+
// (reached through src/qc-check-registry.mjs). The record stays out of the
|
|
9
|
+
// page_load capture, whose projection and integrity are unchanged.
|
|
10
|
+
//
|
|
11
|
+
// Per cell (one route × viewport):
|
|
12
|
+
// - resources[]: one per requested href, covering every ledger entry of the
|
|
13
|
+
// capture once, each with its redirect chain in transfer order (requested
|
|
14
|
+
// href first, final hop last); every byte field, `measurement`, `failed` and
|
|
15
|
+
// `final_origin_equal` describe the final hop's ledger entry;
|
|
16
|
+
// - images[]: the <img> elements the image probe read after network
|
|
17
|
+
// observation closed (at most 512), with natural and rendered size,
|
|
18
|
+
// object-fit, loading and a hidden-ancestor flag; when the probe did not
|
|
19
|
+
// complete each image keeps only its identity and its geometry is null;
|
|
20
|
+
// - videos[]: one per page_load <video> element, with the ledger entries it
|
|
21
|
+
// fetched and whether a declared source has the document's origin.
|
|
22
|
+
//
|
|
23
|
+
// Two requested hrefs that share a redirect or final hop cannot each own the
|
|
24
|
+
// shared entry: the producer lists both chains as observed, and the reader,
|
|
25
|
+
// which requires every ledger entry in exactly one chain, reads that cell as
|
|
26
|
+
// evidence_not_reproducible (never pass). A requested href whose chain is
|
|
27
|
+
// ambiguous on any hop (bindLedgerChain: one URL answered once and
|
|
28
|
+
// redirected once anywhere in the chain, or a hop that is one chain's final
|
|
29
|
+
// and another chain's start) is bound to none: the producer lists its ledger
|
|
30
|
+
// entries as one-hop chains, the same for every request order, and the
|
|
31
|
+
// reader refuses that cell the same way.
|
|
32
|
+
import {
|
|
33
|
+
captureOrigin,
|
|
34
|
+
captureProblemRecordCode,
|
|
35
|
+
mediaFetchedResources,
|
|
36
|
+
resolvedResource,
|
|
37
|
+
resourceLedgerSort,
|
|
38
|
+
responseRecordResponses,
|
|
39
|
+
} from "./polish-capture.mjs";
|
|
40
|
+
import { MEDIA_WEIGHT_SCHEMA, QC_PRODUCERS, bindLedgerChain, mediaChainBinder, mediaWeightIntegrity } from "./qc-results.mjs";
|
|
41
|
+
|
|
42
|
+
export const MEDIA_WEIGHT_PRODUCER = QC_PRODUCERS.polish;
|
|
43
|
+
|
|
44
|
+
export const MEDIA_WEIGHT_THRESHOLDS = Object.freeze({ image_bytes: 500_000, oversize_factor: 2.0, min_natural_area: 250_000 });
|
|
45
|
+
|
|
46
|
+
export const MEDIA_WEIGHT_VOCABULARY = Object.freeze({
|
|
47
|
+
capture_status: Object.freeze(["complete", "incomplete"]),
|
|
48
|
+
probe_status: Object.freeze(["complete", "document_context_changed", "probe_timeout", "image_cap_reached", "probe_budget_exhausted"]),
|
|
49
|
+
measurement: Object.freeze(["complete", "lower_bound", "unmeasured", "cached"]),
|
|
50
|
+
object_fit: Object.freeze(["fill", "contain", "cover", "none", "scale-down"]),
|
|
51
|
+
loading: Object.freeze(["eager", "lazy", "auto"]),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// The image probe's cost bounds: at most 512 <img> and 500 ms per cell, and
|
|
55
|
+
// at most 10 s of probe time per Polish run. Each cell's probe runs inside
|
|
56
|
+
// the smaller of the cell bound and the run budget left, and the time it
|
|
57
|
+
// takes is charged to the run budget. Cells that start after the run budget
|
|
58
|
+
// is spent start no probe step (probe_budget_exhausted, no images, so their
|
|
59
|
+
// oversize result is the one keyed "cell"). A probe its bound cuts before
|
|
60
|
+
// its one read returns names no image either: its oversize result is the
|
|
61
|
+
// one keyed "cell", with the cut's probe status.
|
|
62
|
+
export const MEDIA_PROBE_LIMITS = Object.freeze({ imageCap: 512, cellBoundMs: 500, runBudgetMs: 10_000 });
|
|
63
|
+
|
|
64
|
+
// A probe clock ({ now(), sleep(ms) }) handed in by a caller; the adapter
|
|
65
|
+
// uses performance.now() and setTimeout otherwise.
|
|
66
|
+
export const isProbeClock = (value) => typeof value?.now === "function" && typeof value?.sleep === "function";
|
|
67
|
+
|
|
68
|
+
const isPlainObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
69
|
+
const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
|
|
70
|
+
const isPair = (value) => Array.isArray(value) && value.length === 2 && value.every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0);
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Producer
|
|
74
|
+
|
|
75
|
+
// The page_load ledger's resource_id for a URL (the sha256 of its canonical
|
|
76
|
+
// href, query included), through the capture's own resolver so it matches the
|
|
77
|
+
// ledger exactly. Null for a URL the ledger cannot hold (data:, blob:,
|
|
78
|
+
// unresolvable, too long).
|
|
79
|
+
function ledgerResourceId(url, documentUrl) {
|
|
80
|
+
return isNonEmptyString(url) ? resolvedResource(url, { baseUrl: documentUrl }).resource_id : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The measurement class of a ledger entry, first match (contract 1.0 Polish
|
|
84
|
+
// reader checks): cached, unmeasured, lower_bound, complete.
|
|
85
|
+
function measurementOf(entry) {
|
|
86
|
+
if (entry.cache_request_count > 0) return "cached";
|
|
87
|
+
if (entry.unmeasured_request_count > 0) return "unmeasured";
|
|
88
|
+
if (entry.canceled_request_count > 0 || entry.partial_request_count > 0) return "lower_bound";
|
|
89
|
+
return "complete";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Each requested href's observed hop sequences in transfer order, from the
|
|
93
|
+
// collector's response records (a redirect chain record keeps its hops in
|
|
94
|
+
// order; the ledger does not), keyed by the requested href's resource_id. A
|
|
95
|
+
// sequence whose hops do not all resolve to ledger entries is left out.
|
|
96
|
+
function observedSequences(responses, byId, documentUrl) {
|
|
97
|
+
const sequences = new Map();
|
|
98
|
+
for (const record of Array.isArray(responses) ? responses : []) {
|
|
99
|
+
if (!isPlainObject(record) || captureProblemRecordCode(record)) continue;
|
|
100
|
+
const hops = responseRecordResponses(record).map((hop) => ({
|
|
101
|
+
resource_id: ledgerResourceId(hop?.url, documentUrl),
|
|
102
|
+
status: Number.isInteger(hop?.status) ? hop.status : null,
|
|
103
|
+
mime_type: typeof hop?.mime_type === "string" ? hop.mime_type.toLowerCase() : null,
|
|
104
|
+
}));
|
|
105
|
+
if (!hops.length || hops.some((hop) => !byId.has(hop.resource_id))) continue;
|
|
106
|
+
if (!sequences.has(hops[0].resource_id)) sequences.set(hops[0].resource_id, []);
|
|
107
|
+
sequences.get(hops[0].resource_id).push(hops);
|
|
108
|
+
}
|
|
109
|
+
return sequences;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Orders two observed hop sequences hop by hop: by resource_id, then status,
|
|
113
|
+
// then MIME type (a missing status or type first).
|
|
114
|
+
function compareSequences(a, b) {
|
|
115
|
+
for (let index = 0; index < Math.min(a.length, b.length); index += 1) {
|
|
116
|
+
const order = a[index].resource_id.localeCompare(b[index].resource_id)
|
|
117
|
+
|| (a[index].status ?? -1) - (b[index].status ?? -1)
|
|
118
|
+
|| (a[index].mime_type ?? "").localeCompare(b[index].mime_type ?? "");
|
|
119
|
+
if (order) return order;
|
|
120
|
+
}
|
|
121
|
+
return a.length - b.length;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The request chain of each requested href, keyed by its resource_id. The
|
|
125
|
+
// ledger decides which chain an href has (bindLedgerChain, the reader's own
|
|
126
|
+
// rule, so the producer and the reader cannot disagree and request order
|
|
127
|
+
// does not matter); the response records only order its hops. An href keeps
|
|
128
|
+
// the observed sequence with exactly the bound hop set and final hop, the
|
|
129
|
+
// least one by compareSequences when several do. An href the ledger does not
|
|
130
|
+
// bind to exactly one chain (ambiguous) keeps none: its entries are listed as
|
|
131
|
+
// one-hop chains below, which the reader refuses.
|
|
132
|
+
function requestChains(entries, byId, responses, documentUrl) {
|
|
133
|
+
const chains = new Map();
|
|
134
|
+
const sequences = observedSequences(responses, byId, documentUrl);
|
|
135
|
+
for (const [requestedId, candidates] of sequences) {
|
|
136
|
+
const binding = bindLedgerChain(requestedId, entries, byId);
|
|
137
|
+
if (binding.status !== "bound") continue;
|
|
138
|
+
const matching = candidates.filter((hops) => hops.at(-1).resource_id === binding.final.resource_id
|
|
139
|
+
&& new Set(hops.map((hop) => hop.resource_id)).size === hops.length
|
|
140
|
+
&& hops.length === binding.hops.size
|
|
141
|
+
&& hops.every((hop) => binding.hops.has(hop.resource_id)));
|
|
142
|
+
if (matching.length) chains.set(requestedId, matching.reduce((least, hops) => (compareSequences(hops, least) < 0 ? hops : least)));
|
|
143
|
+
}
|
|
144
|
+
return chains;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function cellResources(entries, chains) {
|
|
148
|
+
const byId = new Map(entries.map((entry) => [entry.resource_id, entry]));
|
|
149
|
+
const all = new Map(chains);
|
|
150
|
+
const chained = new Set([...chains.values()].flatMap((hops) => hops.map((hop) => hop.resource_id)));
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
if (chained.has(entry.resource_id)) continue;
|
|
153
|
+
all.set(entry.resource_id, [{ resource_id: entry.resource_id, status: Array.isArray(entry.statuses) && entry.statuses.length ? entry.statuses[0] : null, mime_type: null }]);
|
|
154
|
+
}
|
|
155
|
+
return [...all.values()].map((hops) => {
|
|
156
|
+
const first = byId.get(hops[0].resource_id);
|
|
157
|
+
const final = byId.get(hops.at(-1).resource_id);
|
|
158
|
+
return {
|
|
159
|
+
resource_id: first.resource_id,
|
|
160
|
+
url: first.url,
|
|
161
|
+
type: final.resource_type,
|
|
162
|
+
transferred_bytes: final.transferred_bytes,
|
|
163
|
+
declared_bytes: final.declared_request_count > 0 ? final.declared_bytes : null,
|
|
164
|
+
measurement: measurementOf(final),
|
|
165
|
+
failed: final.failed_request_count > 0,
|
|
166
|
+
chain: hops.map((hop) => ({ url: byId.get(hop.resource_id).url, resource_id: hop.resource_id, status: hop.status })),
|
|
167
|
+
final_origin_equal: final.cross_origin_request_count === 0,
|
|
168
|
+
};
|
|
169
|
+
}).sort(resourceLedgerSort);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function cellVideos(capture, entries, documentOrigin) {
|
|
173
|
+
const matchable = entries.filter((entry) => Array.isArray(entry?.match_resource_ids));
|
|
174
|
+
return (Array.isArray(capture?.media) ? capture.media : [])
|
|
175
|
+
.filter((element) => isPlainObject(element) && element.tag_name === "video" && Array.isArray(element.source_references))
|
|
176
|
+
.map((element) => ({
|
|
177
|
+
element_index: element.element_index,
|
|
178
|
+
resource_ids: mediaFetchedResources(element, matchable).map((resource) => resource.resource_id).sort(),
|
|
179
|
+
declared_origin_equal: element.source_references.some((reference) => captureOrigin(reference?.url) === documentOrigin),
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const normalizedLoading = (value) => {
|
|
184
|
+
const token = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
185
|
+
return MEDIA_WEIGHT_VOCABULARY.loading.includes(token) ? token : "auto";
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// The probe's <img> entries for the record. Geometry is kept only from a
|
|
189
|
+
// probe that completed; any other probe keeps each image's identity alone.
|
|
190
|
+
// An <img> binds by the identity it used: its currentSrc's resource_id (query
|
|
191
|
+
// included) when the ledger has that entry, else null (weight not_in_ledger).
|
|
192
|
+
// `vector` is whether its source is SVG: read from a data: URL's own type or
|
|
193
|
+
// from its request chain's final hop; null when neither can be read (a blob:
|
|
194
|
+
// or other non-http source, an http source with no ledger entry or no chain),
|
|
195
|
+
// which the rules never read as pass.
|
|
196
|
+
function cellImages(probe, chains, byId, documentUrl) {
|
|
197
|
+
const complete = probe.status === "complete";
|
|
198
|
+
return (Array.isArray(probe.images) ? probe.images : []).map((image) => {
|
|
199
|
+
const identity = ledgerResourceId(image?.current_src, documentUrl);
|
|
200
|
+
const resourceId = byId.has(identity) ? identity : null;
|
|
201
|
+
const finalHop = resourceId ? chains.get(resourceId)?.at(-1) : null;
|
|
202
|
+
let vector = null;
|
|
203
|
+
if (image?.current_src === "data:") vector = image?.svg_data === true;
|
|
204
|
+
else if (finalHop) vector = Boolean(finalHop.mime_type?.startsWith("image/svg+xml"));
|
|
205
|
+
return {
|
|
206
|
+
resource_id: resourceId,
|
|
207
|
+
element_path: image?.element_path,
|
|
208
|
+
complete: complete ? image?.complete : null,
|
|
209
|
+
natural: complete ? image?.natural : null,
|
|
210
|
+
rendered: complete ? image?.rendered : null,
|
|
211
|
+
object_fit: complete ? image?.object_fit : null,
|
|
212
|
+
loading: normalizedLoading(image?.loading),
|
|
213
|
+
hidden: complete ? image?.hidden : null,
|
|
214
|
+
vector,
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The probe status of a cell whose adapter returned no probe (the cell failed
|
|
220
|
+
// before it): the cell bound ended when the producer timed out, otherwise the
|
|
221
|
+
// document the probe needed was not there.
|
|
222
|
+
function missingProbe(capture) {
|
|
223
|
+
const timedOut = (Array.isArray(capture?.problems) ? capture.problems : []).some((problem) => problem?.code === "producer_timeout");
|
|
224
|
+
return { status: timedOut ? "probe_timeout" : "document_context_changed", dpr: null, images: [] };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function buildMediaWeightCell({ route, viewport, capture, observation = null, probe = null }) {
|
|
228
|
+
const imageProbe = isPlainObject(probe) && MEDIA_WEIGHT_VOCABULARY.probe_status.includes(probe.status) ? probe : missingProbe(capture);
|
|
229
|
+
const documentUrl = observation?.finalDocumentUrl ?? capture?.document_response?.url ?? null;
|
|
230
|
+
const documentOrigin = capture?.document_response?.final_origin ?? null;
|
|
231
|
+
const entries = (Array.isArray(capture?.resource_ledger?.entries) ? capture.resource_ledger.entries : []).filter((entry) => isPlainObject(entry) && isNonEmptyString(entry.resource_id));
|
|
232
|
+
const byId = new Map(entries.map((entry) => [entry.resource_id, entry]));
|
|
233
|
+
const chains = requestChains(entries, byId, observation?.responses, documentUrl);
|
|
234
|
+
return {
|
|
235
|
+
route,
|
|
236
|
+
viewport,
|
|
237
|
+
dpr: typeof imageProbe.dpr === "number" && imageProbe.dpr > 0 ? imageProbe.dpr : null,
|
|
238
|
+
document_origin: documentOrigin,
|
|
239
|
+
page_load_integrity: capture?.integrity?.projection_fingerprint ?? null,
|
|
240
|
+
capture_status: capture?.measurement_status ?? "incomplete",
|
|
241
|
+
probe_status: imageProbe.status,
|
|
242
|
+
resources: cellResources(entries, chains),
|
|
243
|
+
images: cellImages(imageProbe, chains, byId, documentUrl),
|
|
244
|
+
videos: cellVideos(capture, entries, documentOrigin),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// The record for one Polish run. `subject` is the page_load subject;
|
|
249
|
+
// `uncapturedRoutes` are the public routes of spec pages the run skipped
|
|
250
|
+
// because they have no source mapping, and `uncapturedPageIds` the page ids
|
|
251
|
+
// of skipped pages whose public route cannot be resolved. Readers list both
|
|
252
|
+
// as page_not_captured.
|
|
253
|
+
export function buildMediaWeightRecord({ pageLoad, cells, uncapturedRoutes = [], uncapturedPageIds = [], measuredAt = new Date().toISOString() }) {
|
|
254
|
+
const record = {
|
|
255
|
+
schema_version: MEDIA_WEIGHT_SCHEMA,
|
|
256
|
+
performed_by: MEDIA_WEIGHT_PRODUCER,
|
|
257
|
+
measured_at: measuredAt,
|
|
258
|
+
subject: structuredClone(pageLoad.subject),
|
|
259
|
+
thresholds: { ...MEDIA_WEIGHT_THRESHOLDS },
|
|
260
|
+
cells,
|
|
261
|
+
uncaptured_routes: [...new Set(uncapturedRoutes)].sort(),
|
|
262
|
+
uncaptured_page_ids: [...new Set(uncapturedPageIds)].sort(),
|
|
263
|
+
};
|
|
264
|
+
return { ...record, integrity: mediaWeightIntegrity(record) };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// Rules (contract 1.3 Result rules)
|
|
269
|
+
|
|
270
|
+
const CAPTURE_INCOMPLETE = "capture_incomplete";
|
|
271
|
+
// Contract 1.0 Polish reader checks (:163) and 1.3 Observations (:936): a
|
|
272
|
+
// result that cannot be re-derived reads unexercised / evidence_not_reproducible.
|
|
273
|
+
const NOT_REPRODUCIBLE = "evidence_not_reproducible";
|
|
274
|
+
|
|
275
|
+
function invalid(message) {
|
|
276
|
+
throw new TypeError(`media_weight cell ${message}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Scale s of the rendered box over the natural size, by object-fit; cover
|
|
280
|
+
// lets the cropped axis go uncounted.
|
|
281
|
+
function renderedScale(fit, [nw, nh], [rw, rh]) {
|
|
282
|
+
const contain = Math.min(rw / nw, rh / nh);
|
|
283
|
+
if (fit === "fill") return Math.sqrt((rw * rh) / (nw * nh));
|
|
284
|
+
if (fit === "contain") return contain;
|
|
285
|
+
if (fit === "scale-down") return Math.min(1, contain);
|
|
286
|
+
if (fit === "cover") return Math.max(rw / nw, rh / nh);
|
|
287
|
+
if (fit === "none") return 1;
|
|
288
|
+
return invalid(`image object_fit ${JSON.stringify(fit)} is not in the vocabulary`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Weight per resource in a complete cell, first match.
|
|
292
|
+
function weightRule(resource, { video, image, thresholds }) {
|
|
293
|
+
if (resource.failed) return ["unexercised", "not_loaded"];
|
|
294
|
+
if (!resource.final_origin_equal) return resource.measurement === "complete" ? ["pass", null] : ["unexercised", "transfer_partial"];
|
|
295
|
+
if (video) return ["warning", "video_from_document_origin"];
|
|
296
|
+
if (!image) return ["pass", null];
|
|
297
|
+
const over = resource.transferred_bytes > thresholds.image_bytes;
|
|
298
|
+
if (resource.measurement === "complete") return over ? ["warning", "image_over_threshold"] : ["pass", null];
|
|
299
|
+
if (resource.measurement === "lower_bound") {
|
|
300
|
+
if (over) return ["warning", "image_over_threshold"];
|
|
301
|
+
if (resource.declared_bytes !== null && resource.declared_bytes > thresholds.image_bytes) return ["review", "declared_over_threshold_unmeasured"];
|
|
302
|
+
}
|
|
303
|
+
return ["unexercised", "transfer_partial"];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Oversizing per <img> in a cell whose probe completed, first match.
|
|
307
|
+
function oversizeRule(image, { requested, dpr, thresholds }) {
|
|
308
|
+
if (typeof image.complete !== "boolean" || typeof image.hidden !== "boolean" || !isPair(image.natural) || !isPair(image.rendered)) {
|
|
309
|
+
return invalid("image geometry is missing from a completed probe");
|
|
310
|
+
}
|
|
311
|
+
const [nw, nh] = image.natural;
|
|
312
|
+
const [rw, rh] = image.rendered;
|
|
313
|
+
if (image.loading === "lazy" && !requested && !image.complete) return { result: ["unexercised", "lazy_not_requested"] };
|
|
314
|
+
if (!image.complete || nw === 0 || nh === 0) return { result: ["unexercised", "not_loaded"] };
|
|
315
|
+
if (image.vector === true) return { result: ["unexercised", "vector_image"] };
|
|
316
|
+
// A source whose type the capture could not read (vector null) may be SVG.
|
|
317
|
+
if (image.vector === null) return { result: ["unexercised", "not_in_ledger"] };
|
|
318
|
+
if (image.hidden || rw === 0 || rh === 0) return { result: ["unexercised", "not_rendered"] };
|
|
319
|
+
const scale = renderedScale(image.object_fit, image.natural, image.rendered);
|
|
320
|
+
const factor = 1 / (scale * dpr);
|
|
321
|
+
const area = nw * nh;
|
|
322
|
+
const oversized = factor >= thresholds.oversize_factor && area >= thresholds.min_natural_area;
|
|
323
|
+
return { result: oversized ? ["warning", "image_oversized"] : ["pass", null], scale, factor, area };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// The probe statuses a cost cap sets: the image cap, the cell bound and the
|
|
327
|
+
// run budget.
|
|
328
|
+
const PROBE_COST_CAPS = Object.freeze(["image_cap_reached", "probe_timeout", "probe_budget_exhausted"]);
|
|
329
|
+
|
|
330
|
+
// The results of one media_weight cell, judged with `thresholds`: one
|
|
331
|
+
// media.weight result per resource, per unfetched <video> ("video:<index>")
|
|
332
|
+
// and per probed <img> whose currentSrc has no ledger entry
|
|
333
|
+
// ("img:<element_path>"); one media.oversize result per probed <img>
|
|
334
|
+
// ("<resource_id>:<element_path>"), or one keyed "cell" when the cell lists
|
|
335
|
+
// no <img>. Evaluation order: an incomplete capture makes every result
|
|
336
|
+
// capture_incomplete; a probe that did not complete makes every oversize
|
|
337
|
+
// result unexercised with its probe status. A cell a probe cost cap ended
|
|
338
|
+
// (the image cap, the cell bound or the run budget) adds the page_coverage
|
|
339
|
+
// member with that cap's code to every result, so none reads pass and none
|
|
340
|
+
// is accept-eligible.
|
|
341
|
+
export function evaluateMediaWeight(cell, thresholds = MEDIA_WEIGHT_THRESHOLDS) {
|
|
342
|
+
if (!isPlainObject(cell)) invalid("is not an object");
|
|
343
|
+
if (!isPlainObject(thresholds) || !["image_bytes", "oversize_factor", "min_natural_area"].every((field) => Number.isFinite(thresholds[field]) && thresholds[field] > 0)) {
|
|
344
|
+
invalid("thresholds are not the 1.3 thresholds");
|
|
345
|
+
}
|
|
346
|
+
const resources = Array.isArray(cell.resources) ? cell.resources : invalid("has no resources[]");
|
|
347
|
+
const images = Array.isArray(cell.images) ? cell.images : invalid("has no images[]");
|
|
348
|
+
const videos = Array.isArray(cell.videos) ? cell.videos : invalid("has no videos[]");
|
|
349
|
+
const incomplete = cell.capture_status !== "complete";
|
|
350
|
+
const probeComplete = cell.probe_status === "complete";
|
|
351
|
+
if (!incomplete && probeComplete && !(typeof cell.dpr === "number" && cell.dpr > 0)) invalid("has no observed device pixel ratio");
|
|
352
|
+
const cap = PROBE_COST_CAPS.includes(cell.probe_status) ? { key: "page_coverage", result: "unexercised", reason_code: cell.probe_status } : null;
|
|
353
|
+
|
|
354
|
+
const results = [];
|
|
355
|
+
const push = (check, key, [base, baseReason], state, observation) => {
|
|
356
|
+
let result = base;
|
|
357
|
+
let reasonCode = baseReason;
|
|
358
|
+
if (cap && result === "pass") [result, reasonCode] = ["unexercised", cap.reason_code];
|
|
359
|
+
const members = cap ? [{ ...cap }] : [];
|
|
360
|
+
results.push({
|
|
361
|
+
check,
|
|
362
|
+
subject: { check, page: cell.route, viewport: cell.viewport, key },
|
|
363
|
+
result,
|
|
364
|
+
reason_code: result === "pass" ? null : reasonCode,
|
|
365
|
+
members,
|
|
366
|
+
accept_eligible: result === "warning" && members.length === 0,
|
|
367
|
+
coverage: result === "unexercised" ? { observed: 0, expected: 1, limits: [reasonCode] } : { observed: 1, expected: 1, limits: [] },
|
|
368
|
+
state: { reason_code: result === "pass" ? null : reasonCode, ...state },
|
|
369
|
+
observation,
|
|
370
|
+
});
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
for (const resource of resources) {
|
|
374
|
+
if (!isPlainObject(resource) || !Array.isArray(resource.chain) || !resource.chain.length) invalid("resource has no chain");
|
|
375
|
+
}
|
|
376
|
+
// Every element binds to its resource through the one binder: a <video>
|
|
377
|
+
// by each ledger id it fetched, an <img> by its currentSrc's id. A
|
|
378
|
+
// resource that shares a hop with another resource is bound to no element
|
|
379
|
+
// alone: it and every element that names it read evidence_not_reproducible,
|
|
380
|
+
// never a result taken from one of the chains.
|
|
381
|
+
const bind = mediaChainBinder(resources);
|
|
382
|
+
const shared = new Set(resources.filter((resource) => resource.chain.some((hop) => bind(hop?.resource_id).status === "ambiguous")));
|
|
383
|
+
const videoBound = new Set();
|
|
384
|
+
const imageBound = new Set();
|
|
385
|
+
for (const video of videos) {
|
|
386
|
+
for (const id of Array.isArray(video?.resource_ids) ? video.resource_ids : []) {
|
|
387
|
+
const binding = bind(id);
|
|
388
|
+
if (binding.status === "bound") videoBound.add(binding.resource);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
for (const image of images) {
|
|
392
|
+
const binding = bind(image?.resource_id);
|
|
393
|
+
if (binding.status === "bound") imageBound.add(binding.resource);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
for (const resource of resources) {
|
|
397
|
+
const video = videoBound.has(resource);
|
|
398
|
+
const image = resource.type === "image" || imageBound.has(resource);
|
|
399
|
+
const finalUrl = resource.chain.at(-1)?.url ?? null;
|
|
400
|
+
let outcome;
|
|
401
|
+
if (incomplete) outcome = ["unexercised", CAPTURE_INCOMPLETE];
|
|
402
|
+
else if (shared.has(resource)) outcome = ["unexercised", NOT_REPRODUCIBLE];
|
|
403
|
+
else outcome = weightRule(resource, { video, image, thresholds });
|
|
404
|
+
push("media.weight", resource.resource_id, outcome, {
|
|
405
|
+
measurement: resource.measurement,
|
|
406
|
+
transferred_bytes: resource.transferred_bytes,
|
|
407
|
+
declared_bytes: resource.declared_bytes,
|
|
408
|
+
final_url: finalUrl,
|
|
409
|
+
final_origin_equal: resource.final_origin_equal,
|
|
410
|
+
chain: resource.chain,
|
|
411
|
+
}, {
|
|
412
|
+
origin: resource.final_origin_equal ? "same_origin" : "other_origin",
|
|
413
|
+
role: video ? "video" : image ? "image" : "other",
|
|
414
|
+
type: resource.type,
|
|
415
|
+
measurement: resource.measurement,
|
|
416
|
+
transferred_bytes: resource.transferred_bytes,
|
|
417
|
+
declared_bytes: resource.declared_bytes,
|
|
418
|
+
failed: resource.failed,
|
|
419
|
+
final_url: finalUrl,
|
|
420
|
+
final_origin_equal: resource.final_origin_equal,
|
|
421
|
+
chain: resource.chain,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
for (const video of videos) {
|
|
426
|
+
if (Array.isArray(video?.resource_ids) && video.resource_ids.length) continue;
|
|
427
|
+
push("media.weight", `video:${video?.element_index}`, incomplete ? ["unexercised", CAPTURE_INCOMPLETE] : ["unexercised", "not_loaded"], {
|
|
428
|
+
element_index: video?.element_index,
|
|
429
|
+
declared_origin_equal: video?.declared_origin_equal,
|
|
430
|
+
}, { role: "video", element_index: video?.element_index, declared_origin_equal: video?.declared_origin_equal });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const unledgered = new Set();
|
|
434
|
+
for (const image of images) {
|
|
435
|
+
if (bind(image?.resource_id).status !== "absent") continue;
|
|
436
|
+
if (unledgered.has(image?.element_path)) continue;
|
|
437
|
+
unledgered.add(image?.element_path);
|
|
438
|
+
push("media.weight", `img:${image?.element_path}`, incomplete ? ["unexercised", CAPTURE_INCOMPLETE] : ["unexercised", "not_in_ledger"], {
|
|
439
|
+
element_path: image?.element_path,
|
|
440
|
+
}, { role: "image", element_path: image?.element_path, resource_id: image?.resource_id ?? null });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (!images.length) {
|
|
444
|
+
const outcome = incomplete ? ["unexercised", CAPTURE_INCOMPLETE] : probeComplete ? ["pass", null] : ["unexercised", cell.probe_status];
|
|
445
|
+
push("media.oversize", "cell", outcome, { image_count: 0 }, { image_count: 0, probe_status: cell.probe_status });
|
|
446
|
+
}
|
|
447
|
+
for (const image of images) {
|
|
448
|
+
if (!isPlainObject(image) || !isNonEmptyString(image.element_path)) invalid("image has no element_path");
|
|
449
|
+
if (image.vector !== undefined && image.vector !== null && typeof image.vector !== "boolean") invalid("image vector flag is not a boolean or null");
|
|
450
|
+
const binding = bind(image.resource_id);
|
|
451
|
+
const matched = binding.status === "bound" ? binding.resource : null;
|
|
452
|
+
const finalUrl = matched ? matched.chain.at(-1)?.url ?? null : null;
|
|
453
|
+
let outcome;
|
|
454
|
+
let measured = {};
|
|
455
|
+
if (incomplete) outcome = ["unexercised", CAPTURE_INCOMPLETE];
|
|
456
|
+
else if (binding.status === "ambiguous" || shared.has(matched)) outcome = ["unexercised", NOT_REPRODUCIBLE];
|
|
457
|
+
else if (!probeComplete) outcome = ["unexercised", cell.probe_status];
|
|
458
|
+
else {
|
|
459
|
+
const judged = oversizeRule(image, { requested: Boolean(matched), dpr: cell.dpr, thresholds });
|
|
460
|
+
outcome = judged.result;
|
|
461
|
+
measured = { scale: judged.scale ?? null, factor: judged.factor ?? null, natural_area: judged.area ?? null };
|
|
462
|
+
}
|
|
463
|
+
push("media.oversize", `${image.resource_id ?? null}:${image.element_path}`, outcome, {
|
|
464
|
+
natural: image.natural ?? null,
|
|
465
|
+
rendered: image.rendered ?? null,
|
|
466
|
+
object_fit: image.object_fit ?? null,
|
|
467
|
+
dpr: cell.dpr ?? null,
|
|
468
|
+
final_url: finalUrl,
|
|
469
|
+
}, {
|
|
470
|
+
element_path: image.element_path,
|
|
471
|
+
natural: image.natural ?? null,
|
|
472
|
+
rendered: image.rendered ?? null,
|
|
473
|
+
object_fit: image.object_fit ?? null,
|
|
474
|
+
dpr: cell.dpr ?? null,
|
|
475
|
+
loading: image.loading,
|
|
476
|
+
complete: image.complete ?? null,
|
|
477
|
+
hidden: image.hidden ?? null,
|
|
478
|
+
vector: image.vector === undefined ? false : image.vector,
|
|
479
|
+
final_url: finalUrl,
|
|
480
|
+
probe_status: cell.probe_status,
|
|
481
|
+
...measured,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return results;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// The 1.3 rules the QC reader re-derives with (src/qc-check-registry.mjs).
|
|
488
|
+
export const MEDIA_WEIGHT_QC_RULES = Object.freeze({
|
|
489
|
+
thresholds: MEDIA_WEIGHT_THRESHOLDS,
|
|
490
|
+
vocabulary: MEDIA_WEIGHT_VOCABULARY,
|
|
491
|
+
evaluate: evaluateMediaWeight,
|
|
492
|
+
});
|
package/src/polish-node.mjs
CHANGED
|
@@ -10,6 +10,14 @@ import {
|
|
|
10
10
|
normalizePageLoadRoute,
|
|
11
11
|
plainHttpDependencyFailures,
|
|
12
12
|
} from "./polish-capture.mjs";
|
|
13
|
+
import {
|
|
14
|
+
buildMediaWeightCell,
|
|
15
|
+
buildMediaWeightRecord,
|
|
16
|
+
isProbeClock,
|
|
17
|
+
MEDIA_PROBE_LIMITS,
|
|
18
|
+
MEDIA_WEIGHT_PRODUCER,
|
|
19
|
+
} from "./polish-media-weight.mjs";
|
|
20
|
+
import { MEDIA_WEIGHT_SCHEMA } from "./qc-results.mjs";
|
|
13
21
|
import {
|
|
14
22
|
buildPolishPageLoadEvidence,
|
|
15
23
|
evaluateHiddenEagerMediaCheckpoint,
|
|
@@ -285,9 +293,17 @@ function canonicalJson(value) {
|
|
|
285
293
|
return JSON.stringify(canonicalize(value));
|
|
286
294
|
}
|
|
287
295
|
|
|
296
|
+
// Covers both package-owned capture keys: a page_load or media_weight that
|
|
297
|
+
// changes during the browser pass refuses the attachment.
|
|
288
298
|
function conflictToken(visualReview) {
|
|
289
|
-
|
|
290
|
-
|
|
299
|
+
const hasPageLoad = Object.hasOwn(visualReview, "page_load");
|
|
300
|
+
const hasMediaWeight = Object.hasOwn(visualReview, "media_weight");
|
|
301
|
+
if (!hasPageLoad && !hasMediaWeight) return "absent";
|
|
302
|
+
const value = {
|
|
303
|
+
...(hasPageLoad ? { value: visualReview.page_load } : {}),
|
|
304
|
+
...(hasMediaWeight ? { media_weight: visualReview.media_weight } : {}),
|
|
305
|
+
};
|
|
306
|
+
return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
|
|
291
307
|
}
|
|
292
308
|
|
|
293
309
|
function captureReportAncestors(report) {
|
|
@@ -446,6 +462,49 @@ export function mergePolishPageLoadEvidence(report, pageLoad) {
|
|
|
446
462
|
};
|
|
447
463
|
}
|
|
448
464
|
|
|
465
|
+
// Attaches both records of one capture: page_load as
|
|
466
|
+
// mergePolishPageLoadEvidence does, and its sibling media_weight under the
|
|
467
|
+
// same producer and schema checks, bound to the same page_load subject. A
|
|
468
|
+
// capture without media_weight (an adapter that ran no image probe) removes
|
|
469
|
+
// any earlier media_weight, which no longer describes the attached page_load.
|
|
470
|
+
export function mergePolishCaptureEvidence(report, { pageLoad, mediaWeight = null } = {}) {
|
|
471
|
+
const merged = mergePolishPageLoadEvidence(report, pageLoad);
|
|
472
|
+
const { media_weight: _previous, ...visualReview } = merged.stages.polish.evidence.visual_review;
|
|
473
|
+
if (mediaWeight !== null && mediaWeight !== undefined) {
|
|
474
|
+
if (!isPlainObject(mediaWeight)
|
|
475
|
+
|| mediaWeight.schema_version !== MEDIA_WEIGHT_SCHEMA
|
|
476
|
+
|| mediaWeight.performed_by !== MEDIA_WEIGHT_PRODUCER
|
|
477
|
+
|| canonicalJson(mediaWeight.subject) !== canonicalJson(pageLoad.subject)) {
|
|
478
|
+
throw new Error("polish capture can attach only package-produced media_weight evidence for the same page_load capture.");
|
|
479
|
+
}
|
|
480
|
+
visualReview.media_weight = mediaWeight;
|
|
481
|
+
}
|
|
482
|
+
merged.stages.polish.evidence.visual_review = visualReview;
|
|
483
|
+
return merged;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// The spec pages the plan skips (no source mapping), for media_weight's
|
|
487
|
+
// page_not_captured results: the public route of each one that has a
|
|
488
|
+
// resolvable route, and the page id of each one that has none (a skipped
|
|
489
|
+
// mapping usually carries no page_kit), so no skipped page is left out.
|
|
490
|
+
function uncapturedPages(packet, plan) {
|
|
491
|
+
const captured = new Set(plan.routes.map((route) => route.requested_route));
|
|
492
|
+
const routes = [];
|
|
493
|
+
const pageIds = [];
|
|
494
|
+
for (const mapping of packet.source_html.pages) {
|
|
495
|
+
if (!nonemptyString(mapping?.skip_reason)) continue;
|
|
496
|
+
let route;
|
|
497
|
+
try {
|
|
498
|
+
route = mappedPublicRoute(mapping?.page_kit?.public_route, mapping?.page_id);
|
|
499
|
+
} catch {
|
|
500
|
+
pageIds.push(mapping.page_id);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (!captured.has(route)) routes.push(route);
|
|
504
|
+
}
|
|
505
|
+
return { routes, pageIds };
|
|
506
|
+
}
|
|
507
|
+
|
|
449
508
|
// The fingerprint of the built output the capture binds to. Every way of not
|
|
450
509
|
// having one is a named refusal, parallel to the missing recorded value: no
|
|
451
510
|
// built route root (build has not run here), an output the walk cannot read
|
|
@@ -503,6 +562,7 @@ export async function capturePolishPageLoad({
|
|
|
503
562
|
adapterStartupDeadlineMs,
|
|
504
563
|
captureCellDeadlineMs,
|
|
505
564
|
adapterCloseDeadlineMs,
|
|
565
|
+
probeClock = null,
|
|
506
566
|
} = {}) {
|
|
507
567
|
const plan = planPolishCapture({ packet, baseUrl });
|
|
508
568
|
const slug = captureCampaignSlug(packet, report);
|
|
@@ -548,6 +608,11 @@ export async function capturePolishPageLoad({
|
|
|
548
608
|
}
|
|
549
609
|
|
|
550
610
|
const captures = [];
|
|
611
|
+
// Per cell, beside its capture: the adapter's observation and image probe,
|
|
612
|
+
// for the media_weight record.
|
|
613
|
+
const cellInputs = [];
|
|
614
|
+
let probeSpentMs = 0;
|
|
615
|
+
let probed = false;
|
|
551
616
|
let adapterCloseError = null;
|
|
552
617
|
let adapterPoisonProblem = null;
|
|
553
618
|
let observedProducerTimeout = false;
|
|
@@ -565,6 +630,7 @@ export async function capturePolishPageLoad({
|
|
|
565
630
|
for (const route of plan.routes) {
|
|
566
631
|
for (const viewport of plan.viewports) {
|
|
567
632
|
let observation;
|
|
633
|
+
let cellFailed = false;
|
|
568
634
|
try {
|
|
569
635
|
if (adapterStartupError) throw adapterStartupError;
|
|
570
636
|
if (adapterPoisonProblem) {
|
|
@@ -573,8 +639,14 @@ export async function capturePolishPageLoad({
|
|
|
573
639
|
: polishProducerCleanupError();
|
|
574
640
|
}
|
|
575
641
|
const abortController = new AbortController();
|
|
642
|
+
const imageProbe = {
|
|
643
|
+
...(isProbeClock(probeClock) ? { clock: probeClock } : {}),
|
|
644
|
+
remainingMs: MEDIA_PROBE_LIMITS.runBudgetMs - probeSpentMs,
|
|
645
|
+
cellBoundMs: MEDIA_PROBE_LIMITS.cellBoundMs,
|
|
646
|
+
imageCap: MEDIA_PROBE_LIMITS.imageCap,
|
|
647
|
+
};
|
|
576
648
|
observation = await runWithPolishProducerDeadline(
|
|
577
|
-
() => adapter.captureRoute({ url: route.url, viewport, signal: abortController.signal }),
|
|
649
|
+
() => adapter.captureRoute({ url: route.url, viewport, signal: abortController.signal, imageProbe }),
|
|
578
650
|
{
|
|
579
651
|
timeoutMs: cellDeadlineMs,
|
|
580
652
|
onTimeout() { abortController.abort(); },
|
|
@@ -594,6 +666,7 @@ export async function capturePolishPageLoad({
|
|
|
594
666
|
adapterCloseError = closeError;
|
|
595
667
|
}
|
|
596
668
|
}
|
|
669
|
+
cellFailed = true;
|
|
597
670
|
observation = {
|
|
598
671
|
finalDocumentUrl: route.url,
|
|
599
672
|
responseCollectionStatus: "failed",
|
|
@@ -603,6 +676,10 @@ export async function capturePolishPageLoad({
|
|
|
603
676
|
: producerTimedOut ? "producer_timeout" : "producer_failed",
|
|
604
677
|
};
|
|
605
678
|
}
|
|
679
|
+
const probe = !cellFailed && isPlainObject(observation.imageProbe) ? observation.imageProbe : null;
|
|
680
|
+
if (probe) probed = true;
|
|
681
|
+
if (Number.isFinite(probe?.spent_ms) && probe.spent_ms > 0) probeSpentMs += probe.spent_ms;
|
|
682
|
+
cellInputs.push({ route: route.requested_route, viewport: viewport.key, observation: cellFailed ? null : observation, probe });
|
|
606
683
|
captures.push(buildPageLoadCapture({
|
|
607
684
|
buildFingerprint,
|
|
608
685
|
slug,
|
|
@@ -635,6 +712,7 @@ export async function capturePolishPageLoad({
|
|
|
635
712
|
? "producer_timeout"
|
|
636
713
|
: "producer_failed";
|
|
637
714
|
captures.length = 0;
|
|
715
|
+
for (const input of cellInputs) Object.assign(input, { observation: null, probe: null });
|
|
638
716
|
for (const route of plan.routes) {
|
|
639
717
|
for (const viewport of plan.viewports) {
|
|
640
718
|
captures.push(buildPageLoadCapture({
|
|
@@ -662,5 +740,19 @@ export async function capturePolishPageLoad({
|
|
|
662
740
|
viewports,
|
|
663
741
|
captures,
|
|
664
742
|
});
|
|
665
|
-
|
|
743
|
+
// The image probe's sibling record, when the adapter ran the probe. Each
|
|
744
|
+
// cell is stamped with the integrity of its page_load capture.
|
|
745
|
+
if (!probed) return { plan, page_load: pageLoad };
|
|
746
|
+
const uncaptured = uncapturedPages(packet, plan);
|
|
747
|
+
const mediaWeight = buildMediaWeightRecord({
|
|
748
|
+
pageLoad,
|
|
749
|
+
cells: cellInputs.map((input) => buildMediaWeightCell({
|
|
750
|
+
...input,
|
|
751
|
+
capture: pageLoad.captures.find((capture) => capture.subject.requested_route === input.route
|
|
752
|
+
&& capture.subject.viewport === input.viewport),
|
|
753
|
+
})),
|
|
754
|
+
uncapturedRoutes: uncaptured.routes,
|
|
755
|
+
uncapturedPageIds: uncaptured.pageIds,
|
|
756
|
+
});
|
|
757
|
+
return { plan, page_load: pageLoad, media_weight: mediaWeight };
|
|
666
758
|
}
|