@nextcommerce/campaigns-os 1.60.0 → 1.61.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 +126 -0
- package/README.md +5 -5
- package/agents/claude/CLAUDE.md +15 -0
- package/agents/codex/AGENTS.md +15 -0
- package/agents/copilot/copilot-instructions.md +15 -0
- package/agents/cursor/campaigns-os.mdc +15 -0
- package/compatibility.json +1 -1
- package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -26
- package/contracts/release-ledger.json +768 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +28 -1
- package/docs/campaign-build-brief.md +16 -0
- package/docs/campaigns-os-build-flow.md +61 -2
- package/docs/campaignspec-authoring-examples.md +116 -0
- package/docs/design-source-package.md +46 -2
- package/docs/entry-points.md +188 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +40 -2
- package/docs/qa-and-test-orders.md +50 -2
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +119 -6
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +3 -1
- package/docs/skills-revision.md +10 -10
- package/docs/source-adapters.md +213 -0
- package/package.json +10 -3
- package/schemas/campaign-design-source-package.v0.schema.json +8 -0
- package/schemas/campaigns-os-readback.v2.schema.json +177 -1
- package/schemas/source-html-manifest.v0.schema.json +3 -0
- 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 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +6 -9
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills/next-campaigns-sdk-bump/SKILL.md +117 -24
- package/skills.json +11 -11
- package/src/brand-theme.mjs +413 -8
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +9 -2
- package/src/built-site-scope.mjs +2 -2
- package/src/cli.mjs +52 -13
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +55 -1
- package/src/doctor/checks.mjs +8 -74
- package/src/doctor/ci.mjs +112 -0
- package/src/doctor/inspect.mjs +18 -0
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/evidence-debt.mjs +119 -0
- package/src/parity/compare.mjs +316 -0
- package/src/parity/design-access.mjs +54 -0
- package/src/parity/element-parity-record.mjs +569 -0
- package/src/parity/probe.mjs +205 -0
- package/src/parity/snapshot.mjs +110 -0
- package/src/png-header.mjs +87 -0
- package/src/polish-browser.mjs +186 -9
- package/src/polish-captures-record.mjs +52 -0
- package/src/polish-deadline.mjs +14 -0
- package/src/polish-gate.mjs +25 -6
- package/src/polish-node.mjs +81 -7
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +606 -0
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +94 -5
- package/src/qa-node.mjs +39 -6
- package/src/qc-accept.mjs +148 -6
- package/src/qc-check-registry.mjs +37 -1
- package/src/qc-results.mjs +404 -16
- package/src/readback.mjs +57 -13
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +94 -4
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/stage-record.mjs +45 -7
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- package/src/visual-reference.mjs +126 -0
package/src/polish-browser.mjs
CHANGED
|
@@ -34,10 +34,48 @@ import {
|
|
|
34
34
|
polishProducerCleanupError,
|
|
35
35
|
polishProducerTimeoutError,
|
|
36
36
|
runWithPolishProducerDeadline,
|
|
37
|
+
VISUAL_READINESS_FONTS_MS, VISUAL_READINESS_AWAIT_MS, VISUAL_SHOT_MS, VISUAL_MAX_CAPTURE_HEIGHT_PX,
|
|
37
38
|
} from "./polish-deadline.mjs";
|
|
38
39
|
|
|
39
40
|
const NAVIGATION_TIMEOUT_MS = 30_000;
|
|
40
41
|
const NETWORKIDLE_TIMEOUT_MS = 5_000;
|
|
42
|
+
const visualMaskSelectors = ["[data-next-timer]", "[data-next-timer-display]", "[data-next-timer-expired]",
|
|
43
|
+
'[data-next-display*="price"]', '[data-next-display*="total"]'];
|
|
44
|
+
|
|
45
|
+
// Self-contained functions execute in an isolated world, never the page world.
|
|
46
|
+
async function visualReady(kind, ms) {
|
|
47
|
+
const started = performance.now();
|
|
48
|
+
const spent = () => Math.min(ms, Math.max(0, performance.now() - started));
|
|
49
|
+
if (kind === "fonts") {
|
|
50
|
+
let timer;
|
|
51
|
+
const status = await Promise.race([document.fonts.ready.then(() => "settled"),
|
|
52
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), ms); })]);
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
return { status, failed_faces: [...document.fonts].filter((face) => face.status === "error").length, waited_ms: spent() };
|
|
55
|
+
}
|
|
56
|
+
let remaining = document.querySelectorAll("[data-next-await]").length;
|
|
57
|
+
if (!remaining) return { status: "not_present", remaining_count: 0, waited_ms: spent() };
|
|
58
|
+
while (remaining && performance.now() - started < ms) {
|
|
59
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.max(0, ms - (performance.now() - started)))));
|
|
60
|
+
remaining = document.querySelectorAll("[data-next-await]").length;
|
|
61
|
+
}
|
|
62
|
+
return { status: remaining ? "timeout" : "settled", remaining_count: remaining, waited_ms: spent() };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function visualGeometry() {
|
|
66
|
+
const rect = (element) => { const r = element.getBoundingClientRect();
|
|
67
|
+
return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height }; };
|
|
68
|
+
const selectors = { timer: "[data-next-timer],[data-next-timer-display],[data-next-timer-expired]",
|
|
69
|
+
price: '[data-next-display*="price"],[data-next-display*="total"]' };
|
|
70
|
+
let capped = false;
|
|
71
|
+
const masks = Object.entries(selectors).map(([group, selector]) => {
|
|
72
|
+
const elements = [...document.querySelectorAll(selector)];
|
|
73
|
+
if (elements.length > 500) capped = true;
|
|
74
|
+
const rects = elements.slice(0, 500).map(rect).filter((r) => r.width > 0 && r.height > 0);
|
|
75
|
+
return { group, count: rects.length, rects };
|
|
76
|
+
});
|
|
77
|
+
return { height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight || 0), masks, mask_cap_reached: capped };
|
|
78
|
+
}
|
|
41
79
|
|
|
42
80
|
const NETWORK_EVENTS = Object.freeze([
|
|
43
81
|
"Network.requestWillBeSent",
|
|
@@ -611,6 +649,8 @@ function closedShadowRoots(root, { frameOwners = false } = {}) {
|
|
|
611
649
|
}
|
|
612
650
|
|
|
613
651
|
const BOUND_ENDED = Symbol("bound ended");
|
|
652
|
+
// Chromium's protocol errors for a read whose execution context or frame went away.
|
|
653
|
+
const CONTEXT_LOST = /execution context was destroyed|cannot find context with specified id|no frame (?:for given id|with given id) found|frame with the given id was not found|target closed|session closed/i;
|
|
614
654
|
// Readability work in a cell ends this long before the adapter cell deadline,
|
|
615
655
|
// so a readability bound, not that deadline, is what stops it.
|
|
616
656
|
const READABILITY_CELL_HEADROOM_MS = 1_000;
|
|
@@ -712,9 +752,15 @@ function boundedProbeSteps({ clock, session, token, started, boundMs, hardMs = I
|
|
|
712
752
|
// function receives the root nodes themselves; with `frameOwners`, also the
|
|
713
753
|
// iframes holding closed roots, see closedShadowRoots). Returns BOUND_ENDED
|
|
714
754
|
// when the bound ended a step, null when a step failed (its execution
|
|
715
|
-
// context destroyed), or { value } of the read.
|
|
716
|
-
|
|
755
|
+
// context destroyed), or { value } of the read. `onFailure`, when given, is
|
|
756
|
+
// told which: "context_lost" for a protocol error naming a destroyed context
|
|
757
|
+
// or frame, "failed" for any other failed step or a thrown exception.
|
|
758
|
+
async function isolatedWorldRead({ steps, mainFrame, worldName, functionDeclaration, leadingArgs, frameOwners = false, closedRoots = true, awaitPromise = false, onFailure = null }) {
|
|
717
759
|
const { send, step } = steps;
|
|
760
|
+
const failed = (error = null) => {
|
|
761
|
+
onFailure?.(CONTEXT_LOST.test(String(error?.message ?? "")) ? "context_lost" : "failed");
|
|
762
|
+
return null;
|
|
763
|
+
};
|
|
718
764
|
const world = await step(() => send("Page.createIsolatedWorld", {
|
|
719
765
|
frameId: mainFrame.id,
|
|
720
766
|
worldName,
|
|
@@ -722,23 +768,24 @@ async function isolatedWorldRead({ steps, mainFrame, worldName, functionDeclarat
|
|
|
722
768
|
}));
|
|
723
769
|
if (world === BOUND_ENDED) return BOUND_ENDED;
|
|
724
770
|
const contextId = world.value?.executionContextId;
|
|
725
|
-
if (world.error || !Number.isInteger(contextId)) return
|
|
726
|
-
const tree = await step(() => send("DOM.getDocument", { depth: -1, pierce: true }));
|
|
771
|
+
if (world.error || !Number.isInteger(contextId)) return failed(world.error);
|
|
772
|
+
const tree = closedRoots ? await step(() => send("DOM.getDocument", { depth: -1, pierce: true })) : { value: {} };
|
|
727
773
|
if (tree === BOUND_ENDED) return BOUND_ENDED;
|
|
728
|
-
if (tree.error) return
|
|
774
|
+
if (tree.error) return failed(tree.error);
|
|
729
775
|
const roots = await step(() => Promise.all(closedShadowRoots(tree.value?.root, { frameOwners })
|
|
730
776
|
.map((backendNodeId) => send("DOM.resolveNode", { backendNodeId, executionContextId: contextId }))));
|
|
731
777
|
if (roots === BOUND_ENDED) return BOUND_ENDED;
|
|
732
778
|
const rootIds = roots.error ? null : roots.value.map((resolved) => resolved?.object?.objectId);
|
|
733
|
-
if (!rootIds || !rootIds.every((objectId) => typeof objectId === "string")) return
|
|
779
|
+
if (!rootIds || !rootIds.every((objectId) => typeof objectId === "string")) return failed(roots.error);
|
|
734
780
|
const evaluated = await step(() => send("Runtime.callFunctionOn", {
|
|
735
781
|
functionDeclaration,
|
|
736
782
|
executionContextId: contextId,
|
|
737
783
|
arguments: [...leadingArgs, ...rootIds.map((objectId) => ({ objectId }))],
|
|
738
784
|
returnByValue: true,
|
|
785
|
+
...(awaitPromise ? { awaitPromise: true } : {}),
|
|
739
786
|
}));
|
|
740
787
|
if (evaluated === BOUND_ENDED) return BOUND_ENDED;
|
|
741
|
-
if (evaluated.error || evaluated.value?.exceptionDetails) return
|
|
788
|
+
if (evaluated.error || evaluated.value?.exceptionDetails) return failed(evaluated.error);
|
|
742
789
|
return { value: evaluated.value?.result?.value };
|
|
743
790
|
}
|
|
744
791
|
|
|
@@ -1024,6 +1071,7 @@ export async function createPolishBrowserAdapter({
|
|
|
1024
1071
|
cleanupDeadlineMs,
|
|
1025
1072
|
startupDeadlineMs,
|
|
1026
1073
|
networkIdleTimeoutMs,
|
|
1074
|
+
seal = null,
|
|
1027
1075
|
} = {}) {
|
|
1028
1076
|
const authCookies = parseAuthCookie(authCookie);
|
|
1029
1077
|
const boundedCellDeadlineMs = boundedPolishDeadline(cellDeadlineMs, POLISH_BROWSER_CELL_DEADLINE_MS);
|
|
@@ -1047,6 +1095,10 @@ export async function createPolishBrowserAdapter({
|
|
|
1047
1095
|
chromium: injectedChromium,
|
|
1048
1096
|
onMissing: polishBrowserMissing,
|
|
1049
1097
|
signal: startupAbort.signal,
|
|
1098
|
+
launchOptions: seal ? {
|
|
1099
|
+
proxy: { server: seal.origin, bypass: `<-loopback>,127.0.0.1:${seal.port}` },
|
|
1100
|
+
args: ["--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1", "--force-webrtc-ip-handling-policy=disable_non_proxied_udp"],
|
|
1101
|
+
} : {},
|
|
1050
1102
|
});
|
|
1051
1103
|
} catch (error) {
|
|
1052
1104
|
if (startupTimedOut) throw polishProducerTimeoutError();
|
|
@@ -1083,7 +1135,7 @@ export async function createPolishBrowserAdapter({
|
|
|
1083
1135
|
// each setup call is a `step` of the caller's bound (boundedProbeSteps); a
|
|
1084
1136
|
// bound that ends during setup makes the cell return `ended()` instead of
|
|
1085
1137
|
// running `work`, and a context created after that is closed.
|
|
1086
|
-
const inCaptureContext = async ({ url, viewport, signal, setup = null }, work) => {
|
|
1138
|
+
const inCaptureContext = async ({ url, viewport, signal, setup = null, deviceScaleFactor, requestPolicy = null }, work) => {
|
|
1087
1139
|
if (closed) throw new Error("Campaigns OS polish capture browser adapter is already closed.");
|
|
1088
1140
|
if (poisonCode === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) throw polishProducerTimeoutError();
|
|
1089
1141
|
if (poisonCode === POLISH_PRODUCER_CLEANUP_ERROR_CODE) throw polishProducerCleanupError();
|
|
@@ -1163,6 +1215,7 @@ export async function createPolishBrowserAdapter({
|
|
|
1163
1215
|
const newContext = () => Promise.resolve(browser.newContext({
|
|
1164
1216
|
viewport: { width: viewport.width, height: viewport.height },
|
|
1165
1217
|
serviceWorkers: "block",
|
|
1218
|
+
...(deviceScaleFactor === undefined ? {} : { deviceScaleFactor }),
|
|
1166
1219
|
})).then((created) => {
|
|
1167
1220
|
if (setupEnded) void Promise.resolve().then(() => created?.close()).catch(() => {});
|
|
1168
1221
|
else context = created;
|
|
@@ -1171,7 +1224,19 @@ export async function createPolishBrowserAdapter({
|
|
|
1171
1224
|
if (setup) await settle(newContext);
|
|
1172
1225
|
else await newContext();
|
|
1173
1226
|
assertActive();
|
|
1174
|
-
if (
|
|
1227
|
+
if (requestPolicy) {
|
|
1228
|
+
await settle(() => context.route("**/*", (route) => {
|
|
1229
|
+
const origin = captureOrigin(route.request().url());
|
|
1230
|
+
if (origin === requestPolicy.allowOrigin) return route.continue();
|
|
1231
|
+
requestPolicy.block(origin, false);
|
|
1232
|
+
return route.abort("blockedbyclient");
|
|
1233
|
+
}));
|
|
1234
|
+
await settle(() => context.routeWebSocket(/.*/, (socket) => {
|
|
1235
|
+
requestPolicy.block(captureOrigin(socket.url().replace(/^ws/, "http")), true);
|
|
1236
|
+
socket.close();
|
|
1237
|
+
}));
|
|
1238
|
+
}
|
|
1239
|
+
if (!seal && authCookies.length > 0) {
|
|
1175
1240
|
const origin = captureOrigin(url);
|
|
1176
1241
|
// Fixed message: a URL containing credentials or query data is never echoed.
|
|
1177
1242
|
if (origin === null) throw new Error("Campaigns OS polish capture requires an HTTP(S) capture URL before applying --auth-cookie.");
|
|
@@ -1219,6 +1284,118 @@ export async function createPolishBrowserAdapter({
|
|
|
1219
1284
|
return observation;
|
|
1220
1285
|
};
|
|
1221
1286
|
return {
|
|
1287
|
+
browserVersion: typeof browser.version === "function" ? `chromium/${browser.version()}` : null,
|
|
1288
|
+
async renderForCompare({ url, viewport, deviceScaleFactor = 1, signal, requestPolicy = null,
|
|
1289
|
+
readiness = {}, probes = [], shots = { actual: true, stabilized: {} }, maxHeightPx = VISUAL_MAX_CAPTURE_HEIGHT_PX,
|
|
1290
|
+
budget = {} } = {}) {
|
|
1291
|
+
if (deviceScaleFactor !== 1) throw new Error("Visual captures require deviceScaleFactor 1.");
|
|
1292
|
+
const captureHeightPx = boundedPolishDeadline(maxHeightPx, VISUAL_MAX_CAPTURE_HEIGHT_PX);
|
|
1293
|
+
// A sealed adapter cannot be used with a weaker per-context policy.
|
|
1294
|
+
const policy = seal ? { allowOrigin: seal.origin } : requestPolicy;
|
|
1295
|
+
const external = policy ? { blocked_count: 0, blocked_websocket_count: 0, blocked_origins: [] } : null;
|
|
1296
|
+
const origins = new Set();
|
|
1297
|
+
const block = (origin, websocket) => {
|
|
1298
|
+
external.blocked_count++; if (websocket) external.blocked_websocket_count++;
|
|
1299
|
+
if (origin && origins.size < 32) origins.add(origin);
|
|
1300
|
+
external.blocked_origins = [...origins].sort();
|
|
1301
|
+
};
|
|
1302
|
+
const clock = isProbeClock(budget.clock) ? budget.clock : realProbeClock;
|
|
1303
|
+
const started = clock.now();
|
|
1304
|
+
return inCaptureContext({ url, viewport, signal, deviceScaleFactor,
|
|
1305
|
+
requestPolicy: policy ? { ...policy, block } : null }, async ({ page, session, awaitActive, cellLeftMs }) => {
|
|
1306
|
+
const skipped = () => ({ status: "skipped", bytes: null, full_height_css: null, crop: null, truncated: false });
|
|
1307
|
+
const result = { status: "navigation_failed", networkidle: { status: "invalid", duration_ms: null },
|
|
1308
|
+
readiness: { fonts: { status: "timeout", failed_faces: 0, waited_ms: 0 },
|
|
1309
|
+
await: { status: "timeout", remaining_count: 0, waited_ms: 0 } }, external, probes: {}, actual: skipped(), stabilized: skipped() };
|
|
1310
|
+
const finish = () => ({ ...result, elapsed_ms: Math.max(0, clock.now() - started) });
|
|
1311
|
+
let response;
|
|
1312
|
+
try { response = await awaitActive(page.goto(url, { waitUntil: "domcontentloaded", timeout: NAVIGATION_TIMEOUT_MS })); }
|
|
1313
|
+
catch { return finish(); }
|
|
1314
|
+
if (response?.status() >= 400) { result.status = "http_error"; return finish(); }
|
|
1315
|
+
const initial = await awaitActive(session.send("Page.getFrameTree"));
|
|
1316
|
+
const mainFrame = initial?.frameTree?.frame;
|
|
1317
|
+
const navEnd = clock.now();
|
|
1318
|
+
try {
|
|
1319
|
+
await awaitActive(page.waitForLoadState("networkidle", { timeout: networkIdleMs }));
|
|
1320
|
+
result.networkidle = { status: "settled", duration_ms: Math.max(0, clock.now() - navEnd) };
|
|
1321
|
+
} catch (error) {
|
|
1322
|
+
if (!timeoutError(error)) throw error;
|
|
1323
|
+
result.networkidle = { status: "timeout", duration_ms: Math.max(0, clock.now() - navEnd) };
|
|
1324
|
+
}
|
|
1325
|
+
// Every read and its document check share the same bounded steps. A
|
|
1326
|
+
// timeout names the bound that ended it, as the readability probe does:
|
|
1327
|
+
// run_budget_exhausted when the run budget was the smaller clock bound
|
|
1328
|
+
// and the cell deadline had not ended it, otherwise probe_timeout.
|
|
1329
|
+
const read = async (probe, awaitPromise = false) => {
|
|
1330
|
+
const begin = clock.now(), token = { cancelled: false };
|
|
1331
|
+
const cellLeft = cellLeftMs(), runLeft = (budget.addedRemainingMs ?? Infinity) - (begin - started);
|
|
1332
|
+
const boundMs = Math.max(0, Math.min(probe.boundMs, cellLeft, runLeft));
|
|
1333
|
+
let steps = null;
|
|
1334
|
+
const timedOutBy = () => (!steps?.hardEnded() && runLeft < Math.min(probe.boundMs, cellLeft) ? "run_budget_exhausted" : "probe_timeout");
|
|
1335
|
+
const out = (status, value = null) => ({ status, value, elapsed_ms: Math.max(0, clock.now() - begin),
|
|
1336
|
+
...(status === "timeout" ? { ended_by: timedOutBy() } : {}) });
|
|
1337
|
+
if (!(boundMs > 0)) return out("timeout");
|
|
1338
|
+
steps = boundedProbeSteps({ clock, session, token, started: begin, boundMs, hardMs: cellLeft });
|
|
1339
|
+
try {
|
|
1340
|
+
let failure = null;
|
|
1341
|
+
const called = await isolatedWorldRead({ steps, mainFrame, worldName: `campaigns-os-visual-${probe.name}`,
|
|
1342
|
+
functionDeclaration: typeof probe.source === "function" ? probe.source.toString() : probe.source,
|
|
1343
|
+
leadingArgs: (Array.isArray(probe.args) ? probe.args : [probe.args]).filter((v) => v !== undefined).map((value) => ({ value })),
|
|
1344
|
+
closedRoots: probe.closedRoots === true, awaitPromise, onFailure: (kind) => { failure = kind; } });
|
|
1345
|
+
if (called === BOUND_ENDED) return out("timeout");
|
|
1346
|
+
// document_changed only on evidence: another main frame or loader,
|
|
1347
|
+
// or a destroyed context. An ordinary probe defect stays failed.
|
|
1348
|
+
const same = await sameMainDocument({ steps, mainFrame });
|
|
1349
|
+
if (same === BOUND_ENDED) return out("timeout");
|
|
1350
|
+
if (!same || failure === "context_lost") return out("document_changed");
|
|
1351
|
+
return called === null ? out("failed") : out("measured", called.value);
|
|
1352
|
+
} catch { return out("failed"); }
|
|
1353
|
+
finally { token.cancelled = true; }
|
|
1354
|
+
};
|
|
1355
|
+
const fontsMs = boundedPolishDeadline(readiness.fontsMs, VISUAL_READINESS_FONTS_MS);
|
|
1356
|
+
const awaitMs = boundedPolishDeadline(readiness.awaitMs, VISUAL_READINESS_AWAIT_MS);
|
|
1357
|
+
for (const [name, ms] of [["fonts", fontsMs], ["await", awaitMs]]) {
|
|
1358
|
+
const observed = await read({ name: `ready_${name}`, source: visualReady, args: [name, ms], boundMs: ms + 100 }, true);
|
|
1359
|
+
if (observed.status === "measured") result.readiness[name] = observed.value;
|
|
1360
|
+
else result.readiness[name].waited_ms = Math.min(ms, observed.elapsed_ms);
|
|
1361
|
+
}
|
|
1362
|
+
for (const probe of probes) {
|
|
1363
|
+
if (probe.select && !probe.select()) continue;
|
|
1364
|
+
result.probes[probe.name] = await read(probe);
|
|
1365
|
+
probe.onResult?.(result.probes[probe.name]);
|
|
1366
|
+
}
|
|
1367
|
+
const layout = await read({ name: "geometry", source: visualGeometry, boundMs: 1500 });
|
|
1368
|
+
// The document loaded: a geometry probe that could not measure is not a navigation failure.
|
|
1369
|
+
if (layout.status !== "measured" || !Number.isFinite(layout.value?.height)) {
|
|
1370
|
+
result.status = layout.status === "document_changed" ? "document_changed"
|
|
1371
|
+
: layout.status === "timeout" ? layout.ended_by : "probe_failed";
|
|
1372
|
+
return finish();
|
|
1373
|
+
}
|
|
1374
|
+
result.status = "rendered";
|
|
1375
|
+
const fullHeight = Math.max(viewport.height, Math.ceil(layout.value.height));
|
|
1376
|
+
const crop = { x: 0, y: 0, width: viewport.width, height: Math.min(fullHeight, captureHeightPx / deviceScaleFactor) };
|
|
1377
|
+
const take = async (stabilized) => {
|
|
1378
|
+
const shot = { status: "failed", bytes: null, full_height_css: fullHeight, crop, truncated: fullHeight > crop.height };
|
|
1379
|
+
const treatment = { animations: "disabled", caret: "hide", mask_color: "#ff00ff", mask_selectors: "visual-masks/v1",
|
|
1380
|
+
masks: layout.value.masks, mask_cap_reached: layout.value.mask_cap_reached };
|
|
1381
|
+
if (stabilized) shot.treatment = treatment;
|
|
1382
|
+
const remainingMs = Math.min(VISUAL_SHOT_MS, cellLeftMs(), (budget.addedRemainingMs ?? Infinity) - (clock.now() - started));
|
|
1383
|
+
if (!(remainingMs > 0)) return { ...shot, status: "timeout" };
|
|
1384
|
+
try {
|
|
1385
|
+
// Screenshot options alone apply treatment, after the actual shot.
|
|
1386
|
+
shot.bytes = await runWithPolishProducerDeadline(() => page.screenshot({ fullPage: true, clip: crop,
|
|
1387
|
+
timeout: VISUAL_SHOT_MS, animations: stabilized ? "disabled" : "allow", caret: stabilized ? "hide" : "initial",
|
|
1388
|
+
...(stabilized ? { mask: visualMaskSelectors.map((selector) => page.locator(selector)), maskColor: "#ff00ff" } : {}) }),
|
|
1389
|
+
{ timeoutMs: remainingMs });
|
|
1390
|
+
shot.status = "captured";
|
|
1391
|
+
} catch (error) { shot.status = timeoutError(error) || error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE ? "timeout" : "failed"; }
|
|
1392
|
+
return shot;
|
|
1393
|
+
};
|
|
1394
|
+
if (shots.actual) result.actual = await take(false);
|
|
1395
|
+
if (shots.stabilized) result.stabilized = await take(true);
|
|
1396
|
+
return finish();
|
|
1397
|
+
});
|
|
1398
|
+
},
|
|
1222
1399
|
// `imageProbe` ({ clock, remainingMs, cellBoundMs, imageCap }) opts the
|
|
1223
1400
|
// cell into the media-weight image probe; the observation then carries
|
|
1224
1401
|
// `imageProbe` beside the page-load fields. `readabilityProbe` ({ clock,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { canonicalJson } from "./polish-capture.mjs";
|
|
3
|
+
|
|
4
|
+
export const CAPTURES_SCHEMA = "campaigns-os-polish-captures/v0";
|
|
5
|
+
export const CAPTURES_PRODUCER = "campaigns-os polish capture";
|
|
6
|
+
const object = (value) => value !== null && typeof value === "object"
|
|
7
|
+
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
8
|
+
const own = (value, key) => object(value) && Object.hasOwn(value, key) ? value[key] : undefined;
|
|
9
|
+
const viewports = new Map([["desktop", [1440, 1200]], ["mobile", [390, 844]]]);
|
|
10
|
+
export function capturesIntegrity(record) {
|
|
11
|
+
const { integrity: _ignored, ...rest } = record || {};
|
|
12
|
+
return `sha256:${createHash("sha256").update(canonicalJson(rest)).digest("hex")}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// A-C4: one actual build capture per planned route; either viewport may supply it.
|
|
16
|
+
export function capturedBuildForEveryRoute(record) {
|
|
17
|
+
const routes = record?.subject?.routes;
|
|
18
|
+
return Array.isArray(routes) && routes.length > 0 && Array.isArray(record?.cells)
|
|
19
|
+
&& routes.every((route) => {
|
|
20
|
+
const path = typeof route === "string" ? route : own(route, "requested_route") ?? own(route, "route");
|
|
21
|
+
return typeof path === "string" && record.cells.some((cell) => object(cell) && cell.side === "build"
|
|
22
|
+
&& cell.route === path && (typeof route === "string" || cell.page_id === route.page_id)
|
|
23
|
+
&& cell.status === "rendered" && cell.actual?.status === "captured");
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Data only: ignored PNG files are deliberately not opened by the gate.
|
|
28
|
+
export function packageCapturesSatisfy(record, report) {
|
|
29
|
+
if (!record || Object.getPrototypeOf(record) !== Object.prototype
|
|
30
|
+
|| record.schema_version !== CAPTURES_SCHEMA || record.performed_by !== CAPTURES_PRODUCER
|
|
31
|
+
|| record.integrity !== capturesIntegrity(record)
|
|
32
|
+
|| !record.subject?.build_fingerprint
|
|
33
|
+
|| record.subject.build_fingerprint !== report?.stages?.assembly?.build_fingerprint
|
|
34
|
+
|| !Array.isArray(record.cells) || !record.cells.length) return false;
|
|
35
|
+
return capturedBuildForEveryRoute(record) && record.cells.every((cell) => {
|
|
36
|
+
if (!object(cell)) return false;
|
|
37
|
+
const viewport = viewports.get(own(cell, "viewport"));
|
|
38
|
+
if (cell.state !== "default_load" || cell.device_scale_factor !== 1
|
|
39
|
+
|| !viewport || cell.width !== viewport[0] || cell.height !== viewport[1]) return false;
|
|
40
|
+
return [cell.actual, cell.stabilized].every((shot) => {
|
|
41
|
+
if (!object(shot)) return false;
|
|
42
|
+
if (shot.status !== "captured") return ["skipped", "failed", "timeout"].includes(own(shot, "status"));
|
|
43
|
+
const crop = shot.crop;
|
|
44
|
+
return Number.isFinite(shot.full_height_css) && shot.full_height_css > 0
|
|
45
|
+
&& crop?.x === 0 && crop.y === 0 && crop.width === cell.width
|
|
46
|
+
&& crop.height === Math.min(shot.full_height_css, 12000)
|
|
47
|
+
&& shot.truncated === (shot.full_height_css > 12000)
|
|
48
|
+
&& shot.png?.width_px === crop.width && shot.png.height_px === crop.height
|
|
49
|
+
&& /^sha256:[a-f0-9]{64}$/.test(shot.png.sha256);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
package/src/polish-deadline.mjs
CHANGED
|
@@ -19,6 +19,20 @@ export const READABILITY_PROBE_CELL_MS = 1_500;
|
|
|
19
19
|
export const READABILITY_PROBE_RUN_MS = 120_000;
|
|
20
20
|
export const READABILITY_CROP_RUN_MS = 30_000;
|
|
21
21
|
export const READABILITY_ADDED_RUN_MS = 300_000;
|
|
22
|
+
export const VISUAL_READINESS_FONTS_MS = 3_000;
|
|
23
|
+
export const VISUAL_READINESS_AWAIT_MS = 5_000;
|
|
24
|
+
export const VISUAL_SHOT_MS = 10_000;
|
|
25
|
+
export const VISUAL_CAPTURE_RUN_MS = 240_000;
|
|
26
|
+
export const VISUAL_DIFF_RUN_MS = 60_000;
|
|
27
|
+
export const VISUAL_ADDED_RUN_MS = 300_000;
|
|
28
|
+
export const VISUAL_MAX_CAPTURE_HEIGHT_PX = 12_000;
|
|
29
|
+
// Element parity bounds, spent inside the visual cells above: the routes a
|
|
30
|
+
// run probes, one probe's bound per side, the probe's own in-page walk, and
|
|
31
|
+
// every parity probe of a run.
|
|
32
|
+
export const PARITY_MAX_ROUTES = 32;
|
|
33
|
+
export const PARITY_PROBE_CELL_MS = 2_000;
|
|
34
|
+
export const PARITY_PROBE_WALK_MS = 1_500;
|
|
35
|
+
export const PARITY_PROBE_RUN_MS = 60_000;
|
|
22
36
|
|
|
23
37
|
export function boundedPolishDeadline(value, fallback) {
|
|
24
38
|
return Number.isSafeInteger(value) && value > 0 && value <= fallback ? value : fallback;
|
package/src/polish-gate.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { packageCapturesSatisfy, capturedBuildForEveryRoute, capturesIntegrity, CAPTURES_SCHEMA, CAPTURES_PRODUCER } from "./polish-captures-record.mjs";
|
|
1
2
|
import { HIDDEN_EAGER_MEDIA_SCOPE } from "./polish-page-load.mjs";
|
|
2
3
|
import { HIDDEN_EAGER_MEDIA_ACTIONS } from "./gate-actions.mjs";
|
|
3
4
|
|
|
@@ -151,7 +152,7 @@ function polishEvidence(stage, report) {
|
|
|
151
152
|
return null;
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
function fieldHasEvidence(value, field) {
|
|
155
|
+
function fieldHasEvidence(value, field, report, newEvidence) {
|
|
155
156
|
if (field === "issues") return Array.isArray(value);
|
|
156
157
|
if (field === "commands") {
|
|
157
158
|
if (!Array.isArray(value) || value.length === 0) return false;
|
|
@@ -159,6 +160,7 @@ function fieldHasEvidence(value, field) {
|
|
|
159
160
|
}
|
|
160
161
|
if (field === "visual_review") {
|
|
161
162
|
if (!isObject(value)) return false;
|
|
163
|
+
if (newEvidence || Object.hasOwn(value, "captures")) return Object.hasOwn(value, "captures") && packageCapturesSatisfy(value.captures, report);
|
|
162
164
|
const screenshots = value.screenshots || value.screenshot_paths || value.paths || value.urls;
|
|
163
165
|
return Array.isArray(screenshots) && screenshots.some(nonEmptyString);
|
|
164
166
|
}
|
|
@@ -308,13 +310,28 @@ function brandBleedResidualKinds(value) {
|
|
|
308
310
|
return kinds;
|
|
309
311
|
}
|
|
310
312
|
|
|
311
|
-
function
|
|
313
|
+
function captureProblem(record, report) {
|
|
314
|
+
if (!record || Object.getPrototypeOf(record) !== Object.prototype || record.schema_version !== CAPTURES_SCHEMA || record.performed_by !== CAPTURES_PRODUCER) return "schema or producer";
|
|
315
|
+
if (record.integrity !== capturesIntegrity(record)) return "integrity";
|
|
316
|
+
if (record.subject?.build_fingerprint !== currentBuildFingerprint(report)) return "build binding";
|
|
317
|
+
if (!Array.isArray(record.cells) || !record.cells.length) return "cells";
|
|
318
|
+
if (!capturedBuildForEveryRoute(record)) return "no captured build screenshot for every planned route";
|
|
319
|
+
return "cell state, viewport, crop or PNG dimensions";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function evidenceProblems(evidence, report, newEvidence) {
|
|
312
323
|
if (!isObject(evidence)) {
|
|
313
324
|
return ["stages.polish.evidence must be an object with the required polish evidence categories."];
|
|
314
325
|
}
|
|
315
326
|
const problems = [];
|
|
316
327
|
for (const field of POLISH_GATE_REQUIRED_EVIDENCE) {
|
|
317
|
-
if (!fieldHasEvidence(evidence[field], field)) {
|
|
328
|
+
if (!fieldHasEvidence(evidence[field], field, report, newEvidence)) {
|
|
329
|
+
if (field === "visual_review" && (newEvidence || Object.hasOwn(evidence[field] || {}, "captures"))) {
|
|
330
|
+
problems.push(Object.hasOwn(evidence[field] || {}, "captures")
|
|
331
|
+
? `stages.polish.evidence.visual_review.captures is not a current package capture record (${captureProblem(evidence[field].captures, report)}); run polish capture.`
|
|
332
|
+
: "stages.polish.evidence.visual_review.captures is missing; record polish needs a package capture by this version; run polish capture.");
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
318
335
|
problems.push(`stages.polish.evidence.${field} is missing or incomplete.`);
|
|
319
336
|
}
|
|
320
337
|
}
|
|
@@ -355,7 +372,7 @@ function commandMentionsBuild(stage, evidence) {
|
|
|
355
372
|
return commands.some((entry) => /next-campaigns-build|campaigns-os\s+next\s+build/i.test(String(isObject(entry) ? entry.command || entry.name || "" : entry)));
|
|
356
373
|
}
|
|
357
374
|
|
|
358
|
-
function evaluateStructuredPolishGate({ report, required = false, now = Date.now(), currentOutputFingerprint = null } = {}) {
|
|
375
|
+
function evaluateStructuredPolishGate({ report, required = false, now = Date.now(), currentOutputFingerprint = null, newEvidence = false } = {}) {
|
|
359
376
|
if (!isObject(report)) {
|
|
360
377
|
return required
|
|
361
378
|
? {
|
|
@@ -599,7 +616,7 @@ function evaluateStructuredPolishGate({ report, required = false, now = Date.now
|
|
|
599
616
|
};
|
|
600
617
|
}
|
|
601
618
|
|
|
602
|
-
const problems = evidenceProblems(evidence, report);
|
|
619
|
+
const problems = evidenceProblems(evidence, report, newEvidence);
|
|
603
620
|
if (problems.length) {
|
|
604
621
|
return {
|
|
605
622
|
status: "blocked",
|
|
@@ -635,6 +652,7 @@ function evaluateStructuredPolishGate({ report, required = false, now = Date.now
|
|
|
635
652
|
current_source_package_material_fingerprint: currentSourcePackageFingerprint,
|
|
636
653
|
assembly_source_package_material_fingerprint: assemblySourcePackageFingerprint,
|
|
637
654
|
performed_by: producer,
|
|
655
|
+
screenshot_evidence: Object.hasOwn(evidence.visual_review, "captures") ? "package_captures" : "legacy_paths",
|
|
638
656
|
waiver: sourcePackageFreshnessWaiver,
|
|
639
657
|
warnings,
|
|
640
658
|
};
|
|
@@ -679,9 +697,10 @@ export function evaluatePolishGate({
|
|
|
679
697
|
required = false,
|
|
680
698
|
now = Date.now(),
|
|
681
699
|
hiddenEagerMediaGate = undefined,
|
|
700
|
+
newEvidence = false,
|
|
682
701
|
currentOutputFingerprint = null,
|
|
683
702
|
} = {}) {
|
|
684
|
-
const base = evaluateStructuredPolishGate({ report, required, now, currentOutputFingerprint });
|
|
703
|
+
const base = evaluateStructuredPolishGate({ report, required, now, currentOutputFingerprint, newEvidence });
|
|
685
704
|
if (!terminalAssembly(report)) return base;
|
|
686
705
|
if (hiddenEagerMediaGate === undefined && !required) return base;
|
|
687
706
|
|
package/src/polish-node.mjs
CHANGED
|
@@ -3,6 +3,10 @@ import { NO_CAPTURABLE_ROUTES_CODE } from "./local-preview-policy.mjs";
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { HIDDEN_EAGER_MEDIA_ACTIONS } from "./gate-actions.mjs";
|
|
5
5
|
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { computeDesignSourcePackageMaterialFingerprint } from "./design-source-package.mjs";
|
|
7
|
+
import { runVisualPass, unavailableCapturesRecord, CAPTURES_SCHEMA, CAPTURES_PRODUCER } from "./polish-visual.mjs";
|
|
8
|
+
import { readRegularFileSync } from "./source-html-server.mjs";
|
|
9
|
+
import { buildElementParityRecord, ELEMENT_PARITY_PRODUCER, ELEMENT_PARITY_SCHEMA } from "./parity/element-parity-record.mjs";
|
|
6
10
|
import { computeBuildFingerprint, describeBuildOutputDrift } from "./built-site-scope.mjs";
|
|
7
11
|
import {
|
|
8
12
|
buildPageLoadCapture,
|
|
@@ -310,18 +314,22 @@ function canonicalJson(value) {
|
|
|
310
314
|
return JSON.stringify(canonicalize(value));
|
|
311
315
|
}
|
|
312
316
|
|
|
313
|
-
// Covers every package-owned capture key: a page_load, media_weight
|
|
314
|
-
// readability record that changes during the
|
|
315
|
-
// attachment.
|
|
317
|
+
// Covers every package-owned capture key: a page_load, media_weight,
|
|
318
|
+
// readability, captures or element_parity record that changes during the
|
|
319
|
+
// browser pass refuses the attachment.
|
|
316
320
|
function conflictToken(visualReview) {
|
|
317
321
|
const hasPageLoad = Object.hasOwn(visualReview, "page_load");
|
|
318
322
|
const hasMediaWeight = Object.hasOwn(visualReview, "media_weight");
|
|
319
323
|
const hasReadability = Object.hasOwn(visualReview, "readability");
|
|
320
|
-
|
|
324
|
+
const hasCaptures = Object.hasOwn(visualReview, "captures");
|
|
325
|
+
const hasElementParity = Object.hasOwn(visualReview, "element_parity");
|
|
326
|
+
if (!hasPageLoad && !hasMediaWeight && !hasReadability && !hasCaptures && !hasElementParity) return "absent";
|
|
321
327
|
const value = {
|
|
322
328
|
...(hasPageLoad ? { value: visualReview.page_load } : {}),
|
|
323
329
|
...(hasMediaWeight ? { media_weight: visualReview.media_weight } : {}),
|
|
324
330
|
...(hasReadability ? { readability: visualReview.readability } : {}),
|
|
331
|
+
...(hasCaptures ? { captures: visualReview.captures } : {}),
|
|
332
|
+
...(hasElementParity ? { element_parity: visualReview.element_parity } : {}),
|
|
325
333
|
};
|
|
326
334
|
return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
|
|
327
335
|
}
|
|
@@ -444,6 +452,7 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
|
|
|
444
452
|
source_package_material_fingerprint: assemblySourcePackageMaterialFingerprint(report),
|
|
445
453
|
},
|
|
446
454
|
current_source_package_material_fingerprint: currentSourcePackageMaterialFingerprint(report),
|
|
455
|
+
source_package_on_disk: sourcePackageOnDisk(packet, resolvedPacketPath),
|
|
447
456
|
page_load_conflict_token: conflictToken(visualReview),
|
|
448
457
|
},
|
|
449
458
|
plan: plan === null ? null : bindingPlanProjection(plan),
|
|
@@ -508,8 +517,11 @@ export function mergePolishPageLoadEvidence(report, pageLoad) {
|
|
|
508
517
|
// readability attaches on its own: only a package-produced record bound to
|
|
509
518
|
// the current build, whose every shared cell carries the integrity of the
|
|
510
519
|
// matching page_load capture; any other value, or none, removes an earlier
|
|
511
|
-
// one.
|
|
512
|
-
|
|
520
|
+
// one. element_parity attaches under its own rule (elementParityAttaches),
|
|
521
|
+
// beside the captures record this call attaches; with a pageLoad, any other
|
|
522
|
+
// value, or none, removes an earlier one, and with no pageLoad it stays as it
|
|
523
|
+
// is.
|
|
524
|
+
export function mergePolishCaptureEvidence(report, { pageLoad = null, mediaWeight = null, readability = null, captures, elementParity, now = null } = {}) {
|
|
513
525
|
let merged;
|
|
514
526
|
if (pageLoad === null || pageLoad === undefined) {
|
|
515
527
|
const { visualReview } = captureReportAncestors(report);
|
|
@@ -537,6 +549,14 @@ export function mergePolishCaptureEvidence(report, { pageLoad = null, mediaWeigh
|
|
|
537
549
|
}
|
|
538
550
|
}
|
|
539
551
|
const visualReview = merged.stages.polish.evidence.visual_review;
|
|
552
|
+
if (pageLoad && captures !== undefined) {
|
|
553
|
+
if (!capturesAttach(captures, { report, pageLoad })) throw new Error("polish capture can attach only package-produced captures for the same page_load capture.");
|
|
554
|
+
visualReview.captures = captures;
|
|
555
|
+
}
|
|
556
|
+
if (pageLoad) {
|
|
557
|
+
delete visualReview.element_parity;
|
|
558
|
+
if (elementParityAttaches(elementParity, { report, captures })) visualReview.element_parity = elementParity;
|
|
559
|
+
}
|
|
540
560
|
delete visualReview.readability;
|
|
541
561
|
if (readabilityAttaches(readability, { buildFingerprint: currentBuildFingerprint(report), pageLoad: visualReview.page_load })) {
|
|
542
562
|
visualReview.readability = readability;
|
|
@@ -544,6 +564,27 @@ export function mergePolishCaptureEvidence(report, { pageLoad = null, mediaWeigh
|
|
|
544
564
|
return merged;
|
|
545
565
|
}
|
|
546
566
|
|
|
567
|
+
function sourcePackageOnDisk(packet, packetPath) {
|
|
568
|
+
if (!packet.design_source_package?.path) return null;
|
|
569
|
+
try { return computeDesignSourcePackageMaterialFingerprint(JSON.parse(readRegularFileSync(resolve(dirname(packetPath), packet.design_source_package.path)).toString("utf8"))); }
|
|
570
|
+
catch { return "unavailable"; }
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function capturesAttach(record, { report, pageLoad }) {
|
|
574
|
+
return isPlainObject(record) && record.schema_version === CAPTURES_SCHEMA && record.performed_by === CAPTURES_PRODUCER
|
|
575
|
+
&& record.subject?.build_fingerprint === currentBuildFingerprint(report)
|
|
576
|
+
&& ["routes", "route_scope", "viewports"].every((key) => canonicalJson(record.subject?.[key]) === canonicalJson(pageLoad?.subject?.[key]));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// A package-produced element_parity record bound to the current build and to
|
|
580
|
+
// the captures record attached in the same merge. Readability plays no part.
|
|
581
|
+
export function elementParityAttaches(record, { report, captures }) {
|
|
582
|
+
const buildFingerprint = currentBuildFingerprint(report);
|
|
583
|
+
return isPlainObject(record) && record.schema_version === ELEMENT_PARITY_SCHEMA && record.performed_by === ELEMENT_PARITY_PRODUCER
|
|
584
|
+
&& Boolean(buildFingerprint) && record.subject?.build_fingerprint === buildFingerprint
|
|
585
|
+
&& isPlainObject(captures) && typeof captures.integrity === "string" && record.subject?.captures_integrity === captures.integrity;
|
|
586
|
+
}
|
|
587
|
+
|
|
547
588
|
function readabilityAttaches(record, { buildFingerprint, pageLoad }) {
|
|
548
589
|
if (!isPlainObject(record)
|
|
549
590
|
|| record.schema_version !== READABILITY_SCHEMA
|
|
@@ -629,7 +670,40 @@ function captureCampaignSlug(packet, report) {
|
|
|
629
670
|
|
|
630
671
|
export async function capturePolishPageLoad(options = {}) {
|
|
631
672
|
const plan = planPolishCapture({ packet: options.packet, baseUrl: options.baseUrl });
|
|
632
|
-
|
|
673
|
+
const result = await runPolishCapture({ ...options, plan });
|
|
674
|
+
if (!options.visual) return result;
|
|
675
|
+
const freeze = (value) => { if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
|
676
|
+
Object.values(value).forEach(freeze); Object.freeze(value);
|
|
677
|
+
} return value; };
|
|
678
|
+
const frozen = freeze(result);
|
|
679
|
+
options.onFrozen?.(frozen);
|
|
680
|
+
// Element parity rides C's visual cells (its probe on both sides of each);
|
|
681
|
+
// its modules load only here, where the plan has a route.
|
|
682
|
+
let parity = null;
|
|
683
|
+
try {
|
|
684
|
+
const importer = options.visual.parityImporter ?? (() => Promise.all([import("./parity/probe.mjs"), import("./parity/snapshot.mjs")]));
|
|
685
|
+
const [probe, snapshot] = await importer();
|
|
686
|
+
parity = { snapshot, run: probe.createParityProbeRun({ routes: plan.routes }) };
|
|
687
|
+
} catch { /* no parity probe: no element_parity record, so the merge removes an earlier one */ }
|
|
688
|
+
let captures;
|
|
689
|
+
try { captures = await runVisualPass({ ...options, ...options.visual, frozen, probes: parity ? [parity.run.probe] : [] }); }
|
|
690
|
+
catch {
|
|
691
|
+
// Even unexpected artifact/dependency failures cannot discard prior work.
|
|
692
|
+
captures = unavailableCapturesRecord({ plan, subject: { ...frozen.page_load.subject,
|
|
693
|
+
source_package_material_fingerprint: currentSourcePackageMaterialFingerprint(options.report) }, measuredAt: options.now });
|
|
694
|
+
}
|
|
695
|
+
let elementParity = null;
|
|
696
|
+
if (parity) {
|
|
697
|
+
// Probe files are written after both visual browsers have closed.
|
|
698
|
+
const targetRepo = options.visual.targetRepo ?? options.targetRepo;
|
|
699
|
+
try {
|
|
700
|
+
elementParity = buildElementParityRecord({ plan, captures, run: parity.run, packet: options.packet,
|
|
701
|
+
packetPath: options.visual.packetPath ?? options.packetPath, buildFingerprint: currentBuildFingerprint(options.report),
|
|
702
|
+
sourceFingerprint: currentSourcePackageMaterialFingerprint(options.report),
|
|
703
|
+
writeFile: (value) => parity.snapshot.writeParityFile(targetRepo, value), measuredAt: options.now ?? new Date() });
|
|
704
|
+
} catch { elementParity = null; }
|
|
705
|
+
}
|
|
706
|
+
return { ...frozen, captures, ...(elementParity ? { element_parity: elementParity } : {}) };
|
|
633
707
|
}
|
|
634
708
|
|
|
635
709
|
// The readability-only capture of a campaign with no capturable page_load
|