@nextcommerce/campaigns-os 1.50.0 → 1.53.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 +474 -0
- package/agents/claude/CLAUDE.md +4 -2
- package/agents/codex/AGENTS.md +3 -1
- package/agents/copilot/copilot-instructions.md +3 -1
- package/agents/cursor/campaigns-os.mdc +3 -1
- 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 +282 -14
- package/contracts/release-ledger.json +1233 -0
- package/contracts/supported-surface.json +2 -2
- 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 +2 -0
- package/docs/build-packet.md +148 -23
- package/docs/campaign-build-brief.md +57 -2
- package/docs/effects.md +14 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +12 -0
- package/docs/progress-snapshots.md +2 -4
- package/docs/qa-and-test-orders.md +47 -4
- package/docs/readback.md +19 -2
- 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/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -3
- package/skills/next-campaigns-os/SKILL.md +11 -20
- package/skills/next-campaigns-os/references/session-intake.md +17 -9
- package/skills/next-campaigns-os-setup/SKILL.md +4 -3
- package/skills/next-campaigns-polish/SKILL.md +7 -5
- package/skills/next-campaigns-qa/SKILL.md +8 -5
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/brand-theme.mjs +33 -20
- package/src/brief-summary.mjs +275 -0
- package/src/build-brief.mjs +229 -30
- 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 +467 -143
- package/src/commercial-journey.mjs +65 -4
- package/src/commercial-parity.mjs +6 -1
- package/src/contrast.mjs +649 -0
- package/src/design-source-publication.mjs +110 -3
- package/src/doctor/checks.mjs +415 -33
- package/src/doctor/inspect.mjs +53 -2
- package/src/doctor/next-step.mjs +16 -7
- package/src/input-currency.mjs +394 -0
- package/src/input-refresh.mjs +708 -0
- 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 +660 -141
- package/src/polish-capture.mjs +1 -1
- package/src/polish-deadline.mjs +7 -0
- package/src/polish-media-weight.mjs +492 -0
- package/src/polish-node.mjs +400 -28
- package/src/polish-readability.mjs +828 -0
- package/src/progress-node.mjs +19 -3
- package/src/qa-browser.mjs +439 -180
- package/src/qa-content-params.mjs +889 -0
- package/src/qa-node.mjs +116 -13
- 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 +482 -0
- package/src/qc-check-registry.mjs +91 -0
- package/src/qc-results.mjs +1341 -0
- package/src/readback.mjs +58 -10
- 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-ledger.mjs +235 -44
- package/src/stage-record.mjs +165 -33
- package/src/tooling-setup.mjs +1 -1
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,
|
|
@@ -27,8 +35,22 @@ import {
|
|
|
27
35
|
POLISH_PRODUCER_TIMEOUT_ERROR_CODE,
|
|
28
36
|
polishProducerCleanupError,
|
|
29
37
|
polishProducerTimeoutError,
|
|
38
|
+
READABILITY_ADDED_RUN_MS,
|
|
39
|
+
READABILITY_CROP_RUN_MS,
|
|
40
|
+
READABILITY_PROBE_CELL_MS,
|
|
41
|
+
READABILITY_PROBE_RUN_MS,
|
|
30
42
|
runWithPolishProducerDeadline,
|
|
31
43
|
} from "./polish-deadline.mjs";
|
|
44
|
+
import {
|
|
45
|
+
buildReadabilityCell,
|
|
46
|
+
buildReadabilityRecord,
|
|
47
|
+
readabilityLimits,
|
|
48
|
+
readabilityObservationOk,
|
|
49
|
+
readabilityRoutes,
|
|
50
|
+
READABILITY_PRODUCER,
|
|
51
|
+
READABILITY_SCHEMA,
|
|
52
|
+
writeReadabilityCrop,
|
|
53
|
+
} from "./polish-readability.mjs";
|
|
32
54
|
import {
|
|
33
55
|
assemblySourcePackageMaterialFingerprint,
|
|
34
56
|
currentSourcePackageMaterialFingerprint,
|
|
@@ -96,6 +118,9 @@ function mappedSpecRoute(value, pageId) {
|
|
|
96
118
|
|
|
97
119
|
const NO_CAPTURABLE_ROUTES_ERROR = "no_capturable_routes";
|
|
98
120
|
|
|
121
|
+
// Whether planPolishCapture refused because every mapping is template stock.
|
|
122
|
+
export const isNoCapturableRoutesError = (error) => error?.code === NO_CAPTURABLE_ROUTES_ERROR;
|
|
123
|
+
|
|
99
124
|
export function planPolishCapture({ packet, baseUrl } = {}) {
|
|
100
125
|
if (!isPlainObject(packet) || !Array.isArray(packet?.source_html?.pages) || packet.source_html.pages.length === 0) {
|
|
101
126
|
throw new Error("polish capture requires packet.source_html.pages mappings.");
|
|
@@ -285,9 +310,20 @@ function canonicalJson(value) {
|
|
|
285
310
|
return JSON.stringify(canonicalize(value));
|
|
286
311
|
}
|
|
287
312
|
|
|
313
|
+
// Covers every package-owned capture key: a page_load, media_weight or
|
|
314
|
+
// readability record that changes during the browser pass refuses the
|
|
315
|
+
// attachment.
|
|
288
316
|
function conflictToken(visualReview) {
|
|
289
|
-
|
|
290
|
-
|
|
317
|
+
const hasPageLoad = Object.hasOwn(visualReview, "page_load");
|
|
318
|
+
const hasMediaWeight = Object.hasOwn(visualReview, "media_weight");
|
|
319
|
+
const hasReadability = Object.hasOwn(visualReview, "readability");
|
|
320
|
+
if (!hasPageLoad && !hasMediaWeight && !hasReadability) return "absent";
|
|
321
|
+
const value = {
|
|
322
|
+
...(hasPageLoad ? { value: visualReview.page_load } : {}),
|
|
323
|
+
...(hasMediaWeight ? { media_weight: visualReview.media_weight } : {}),
|
|
324
|
+
...(hasReadability ? { readability: visualReview.readability } : {}),
|
|
325
|
+
};
|
|
326
|
+
return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
|
|
291
327
|
}
|
|
292
328
|
|
|
293
329
|
function captureReportAncestors(report) {
|
|
@@ -339,6 +375,8 @@ function bindingPlanProjection(plan) {
|
|
|
339
375
|
};
|
|
340
376
|
}
|
|
341
377
|
|
|
378
|
+
// `plan` is null for a campaign with no capturable page_load routes (every
|
|
379
|
+
// mapping is template stock), where the capture measures readability only.
|
|
342
380
|
export function createPolishCaptureBinding({ packet, report, plan, packetPath, targetRepo } = {}) {
|
|
343
381
|
if (!isPlainObject(packet) || packet.schema_version !== "campaign-runtime-build-packet/v0") {
|
|
344
382
|
throw new Error("polish capture requires a current campaign-runtime-build-packet/v0 packet.");
|
|
@@ -408,10 +446,23 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
|
|
|
408
446
|
current_source_package_material_fingerprint: currentSourcePackageMaterialFingerprint(report),
|
|
409
447
|
page_load_conflict_token: conflictToken(visualReview),
|
|
410
448
|
},
|
|
411
|
-
plan: bindingPlanProjection(plan),
|
|
449
|
+
plan: plan === null ? null : bindingPlanProjection(plan),
|
|
450
|
+
readability_routes: readabilityRouteProjection(resolvedTargetRepo, slug),
|
|
412
451
|
});
|
|
413
452
|
}
|
|
414
453
|
|
|
454
|
+
// The built routes readability captures, enumerated again for each binding,
|
|
455
|
+
// so a route set that changes during the browser pass refuses the attachment.
|
|
456
|
+
function readabilityRouteProjection(targetRepo, slug) {
|
|
457
|
+
const built = readabilityRoutes(targetRepo, slug);
|
|
458
|
+
return {
|
|
459
|
+
ok: built.ok === true,
|
|
460
|
+
routes: built.routes,
|
|
461
|
+
uncaptured_routes: built.uncaptured_routes,
|
|
462
|
+
route_enumeration_capped: built.route_enumeration_capped,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
415
466
|
export function assertPolishCaptureBindingUnchanged(initial, current) {
|
|
416
467
|
if (canonicalJson(initial) !== canonicalJson(current)) {
|
|
417
468
|
throw new Error(
|
|
@@ -446,6 +497,88 @@ export function mergePolishPageLoadEvidence(report, pageLoad) {
|
|
|
446
497
|
};
|
|
447
498
|
}
|
|
448
499
|
|
|
500
|
+
// Attaches the records of one capture. page_load attaches as
|
|
501
|
+
// mergePolishPageLoadEvidence does, stamped with `captured_at` when the
|
|
502
|
+
// command passes its clock `now` (the time of the merge, outside every
|
|
503
|
+
// capture's integrity). Its sibling media_weight attaches under the same
|
|
504
|
+
// producer and schema checks, bound to the same page_load subject; a capture
|
|
505
|
+
// without media_weight (an adapter that ran no image probe) removes any
|
|
506
|
+
// earlier media_weight. With no pageLoad (a campaign with no capturable
|
|
507
|
+
// page_load routes), page_load and media_weight stay exactly as they are.
|
|
508
|
+
// readability attaches on its own: only a package-produced record bound to
|
|
509
|
+
// the current build, whose every shared cell carries the integrity of the
|
|
510
|
+
// matching page_load capture; any other value, or none, removes an earlier
|
|
511
|
+
// one.
|
|
512
|
+
export function mergePolishCaptureEvidence(report, { pageLoad = null, mediaWeight = null, readability = null, now = null } = {}) {
|
|
513
|
+
let merged;
|
|
514
|
+
if (pageLoad === null || pageLoad === undefined) {
|
|
515
|
+
const { visualReview } = captureReportAncestors(report);
|
|
516
|
+
merged = {
|
|
517
|
+
...report,
|
|
518
|
+
stages: {
|
|
519
|
+
...report.stages,
|
|
520
|
+
polish: {
|
|
521
|
+
...report.stages.polish,
|
|
522
|
+
evidence: { ...report.stages.polish.evidence, visual_review: { ...visualReview } },
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
} else {
|
|
527
|
+
merged = mergePolishPageLoadEvidence(report, now === null || now === undefined ? pageLoad : { ...pageLoad, captured_at: new Date(now).toISOString() });
|
|
528
|
+
delete merged.stages.polish.evidence.visual_review.media_weight;
|
|
529
|
+
if (mediaWeight !== null && mediaWeight !== undefined) {
|
|
530
|
+
if (!isPlainObject(mediaWeight)
|
|
531
|
+
|| mediaWeight.schema_version !== MEDIA_WEIGHT_SCHEMA
|
|
532
|
+
|| mediaWeight.performed_by !== MEDIA_WEIGHT_PRODUCER
|
|
533
|
+
|| canonicalJson(mediaWeight.subject) !== canonicalJson(pageLoad.subject)) {
|
|
534
|
+
throw new Error("polish capture can attach only package-produced media_weight evidence for the same page_load capture.");
|
|
535
|
+
}
|
|
536
|
+
merged.stages.polish.evidence.visual_review.media_weight = mediaWeight;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const visualReview = merged.stages.polish.evidence.visual_review;
|
|
540
|
+
delete visualReview.readability;
|
|
541
|
+
if (readabilityAttaches(readability, { buildFingerprint: currentBuildFingerprint(report), pageLoad: visualReview.page_load })) {
|
|
542
|
+
visualReview.readability = readability;
|
|
543
|
+
}
|
|
544
|
+
return merged;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function readabilityAttaches(record, { buildFingerprint, pageLoad }) {
|
|
548
|
+
if (!isPlainObject(record)
|
|
549
|
+
|| record.schema_version !== READABILITY_SCHEMA
|
|
550
|
+
|| record.performed_by !== READABILITY_PRODUCER
|
|
551
|
+
|| !buildFingerprint
|
|
552
|
+
|| record.subject?.build_fingerprint !== buildFingerprint
|
|
553
|
+
|| !Array.isArray(record.cells)) return false;
|
|
554
|
+
const captures = Array.isArray(pageLoad?.captures) ? pageLoad.captures : [];
|
|
555
|
+
return record.cells.every((cell) => cell?.page_load_integrity === null || captures.some((capture) => capture?.subject?.requested_route === cell.route
|
|
556
|
+
&& capture?.subject?.viewport === cell.viewport
|
|
557
|
+
&& capture?.integrity?.projection_fingerprint === cell.page_load_integrity));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// The spec pages the plan skips (no source mapping), for media_weight's
|
|
561
|
+
// page_not_captured results: the public route of each one that has a
|
|
562
|
+
// resolvable route, and the page id of each one that has none (a skipped
|
|
563
|
+
// mapping usually carries no page_kit), so no skipped page is left out.
|
|
564
|
+
function uncapturedPages(packet, plan) {
|
|
565
|
+
const captured = new Set(plan.routes.map((route) => route.requested_route));
|
|
566
|
+
const routes = [];
|
|
567
|
+
const pageIds = [];
|
|
568
|
+
for (const mapping of packet.source_html.pages) {
|
|
569
|
+
if (!nonemptyString(mapping?.skip_reason)) continue;
|
|
570
|
+
let route;
|
|
571
|
+
try {
|
|
572
|
+
route = mappedPublicRoute(mapping?.page_kit?.public_route, mapping?.page_id);
|
|
573
|
+
} catch {
|
|
574
|
+
pageIds.push(mapping.page_id);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (!captured.has(route)) routes.push(route);
|
|
578
|
+
}
|
|
579
|
+
return { routes, pageIds };
|
|
580
|
+
}
|
|
581
|
+
|
|
449
582
|
// The fingerprint of the built output the capture binds to. Every way of not
|
|
450
583
|
// having one is a named refusal, parallel to the missing recorded value: no
|
|
451
584
|
// built route root (build has not run here), an output the walk cannot read
|
|
@@ -493,9 +626,41 @@ function captureCampaignSlug(packet, report) {
|
|
|
493
626
|
return packetSlug;
|
|
494
627
|
}
|
|
495
628
|
|
|
496
|
-
export async function capturePolishPageLoad({
|
|
629
|
+
export async function capturePolishPageLoad(options = {}) {
|
|
630
|
+
const plan = planPolishCapture({ packet: options.packet, baseUrl: options.baseUrl });
|
|
631
|
+
return runPolishCapture({ ...options, plan });
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// The readability-only capture of a campaign with no capturable page_load
|
|
635
|
+
// routes (every mapping is template stock): every built route is a
|
|
636
|
+
// readability-only cell; page_load and media_weight are not produced. A
|
|
637
|
+
// browser that cannot start is recorded on every cell.
|
|
638
|
+
export async function capturePolishReadability(options = {}) {
|
|
639
|
+
captureBaseUrl(options.baseUrl);
|
|
640
|
+
return runPolishCapture({ ...options, plan: null });
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Readability for one run: the built routes (readabilityRoutes) when the
|
|
644
|
+
// adapter has a readability probe or did not start, else null (no record is
|
|
645
|
+
// produced).
|
|
646
|
+
function readabilityScope({ adapter, startupFailed, targetRepo, slug, plan, baseUrl }) {
|
|
647
|
+
if (!startupFailed && typeof adapter?.probeReadabilityRoute !== "function") return null;
|
|
648
|
+
if (!nonemptyString(targetRepo)) return null;
|
|
649
|
+
const built = readabilityRoutes(resolve(targetRepo), slug);
|
|
650
|
+
if (!built.ok) return null;
|
|
651
|
+
const base = captureBaseUrl(baseUrl);
|
|
652
|
+
const shared = new Set((plan?.routes || []).map((route) => route.requested_route));
|
|
653
|
+
return {
|
|
654
|
+
...built,
|
|
655
|
+
shared,
|
|
656
|
+
only: built.routes.filter((route) => !shared.has(route)).map((route) => ({ route, url: new URL(route, base).href })),
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function runPolishCapture({
|
|
497
661
|
packet,
|
|
498
662
|
report,
|
|
663
|
+
plan,
|
|
499
664
|
baseUrl,
|
|
500
665
|
headed = false,
|
|
501
666
|
authCookie = null,
|
|
@@ -503,8 +668,10 @@ export async function capturePolishPageLoad({
|
|
|
503
668
|
adapterStartupDeadlineMs,
|
|
504
669
|
captureCellDeadlineMs,
|
|
505
670
|
adapterCloseDeadlineMs,
|
|
671
|
+
probeClock = null,
|
|
672
|
+
targetRepo = null,
|
|
673
|
+
now = new Date(),
|
|
506
674
|
} = {}) {
|
|
507
|
-
const plan = planPolishCapture({ packet, baseUrl });
|
|
508
675
|
const slug = captureCampaignSlug(packet, report);
|
|
509
676
|
const buildFingerprint = currentBuildFingerprint(report);
|
|
510
677
|
if (!buildFingerprint) throw new Error("polish capture requires a current Assembly Report build fingerprint.");
|
|
@@ -522,6 +689,9 @@ export async function capturePolishPageLoad({
|
|
|
522
689
|
);
|
|
523
690
|
|
|
524
691
|
let adapter = null;
|
|
692
|
+
// Failure is tracked apart from its reason: a rejection may carry any value,
|
|
693
|
+
// including null or undefined.
|
|
694
|
+
let adapterStartupFailed = false;
|
|
525
695
|
let adapterStartupError = null;
|
|
526
696
|
let startupTimedOut = false;
|
|
527
697
|
const adapterPromise = Promise.resolve().then(() => createBrowserAdapter({
|
|
@@ -544,14 +714,56 @@ export async function capturePolishPageLoad({
|
|
|
544
714
|
throw new Error("polish capture browser adapter must provide captureRoute() and close().");
|
|
545
715
|
}
|
|
546
716
|
} catch (error) {
|
|
547
|
-
|
|
717
|
+
adapterStartupFailed = true;
|
|
718
|
+
adapterStartupError = error ?? new Error("polish capture browser adapter failed to start.");
|
|
548
719
|
}
|
|
720
|
+
// A browser that did not start leaves every readability-only cell
|
|
721
|
+
// unmeasured: producer_timeout for a startup timeout, else navigation_failed
|
|
722
|
+
// (the status a shared cell reads for the same page_load failure).
|
|
723
|
+
const startupStatus = !adapterStartupFailed ? null
|
|
724
|
+
: adapterStartupError?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE ? "producer_timeout" : "navigation_failed";
|
|
725
|
+
|
|
726
|
+
const scope = readabilityScope({ adapter, startupFailed: adapterStartupFailed, targetRepo, slug, plan, baseUrl });
|
|
727
|
+
const clock = isProbeClock(probeClock) ? probeClock : { now: () => performance.now() };
|
|
728
|
+
// The readability budgets, charged on `clock`: probe time, crop time, and
|
|
729
|
+
// all readability work (shared-cell probes and crops, and readability-only
|
|
730
|
+
// cells end to end). Probe and crop time are subtotals of the added work, so
|
|
731
|
+
// a shared cell's probe and crop time is charged once to its own budget and
|
|
732
|
+
// once to the added budget.
|
|
733
|
+
const spentMs = { probe: 0, crop: 0, added: 0 };
|
|
734
|
+
const readabilityProbe = () => ({
|
|
735
|
+
...(isProbeClock(probeClock) ? { clock: probeClock } : {}),
|
|
736
|
+
cellBoundMs: READABILITY_PROBE_CELL_MS,
|
|
737
|
+
probeRemainingMs: READABILITY_PROBE_RUN_MS - spentMs.probe,
|
|
738
|
+
cropRemainingMs: READABILITY_CROP_RUN_MS - spentMs.crop,
|
|
739
|
+
addedRemainingMs: READABILITY_ADDED_RUN_MS - spentMs.added,
|
|
740
|
+
elementCap: readabilityLimits().elements_per_cell,
|
|
741
|
+
cropsPerCell: readabilityLimits().crops_per_cell,
|
|
742
|
+
});
|
|
743
|
+
const chargeReadability = (observation, addedMs) => {
|
|
744
|
+
if (Number.isFinite(observation?.probe_ms) && observation.probe_ms > 0) spentMs.probe += observation.probe_ms;
|
|
745
|
+
if (Number.isFinite(observation?.crop_ms) && observation.crop_ms > 0) spentMs.crop += observation.crop_ms;
|
|
746
|
+
if (Number.isFinite(addedMs) && addedMs > 0) spentMs.added += addedMs;
|
|
747
|
+
};
|
|
549
748
|
|
|
550
749
|
const captures = [];
|
|
750
|
+
// Per cell, beside its capture: the adapter's observation and image probe,
|
|
751
|
+
// for the media_weight record.
|
|
752
|
+
const cellInputs = [];
|
|
753
|
+
// Readability-only cells, each { route, viewport, status, observation }.
|
|
754
|
+
const readabilityOnly = [];
|
|
755
|
+
let probeSpentMs = 0;
|
|
756
|
+
let probed = false;
|
|
757
|
+
// As for startup, a close failure is tracked apart from its reason.
|
|
758
|
+
let adapterCloseFailed = false;
|
|
551
759
|
let adapterCloseError = null;
|
|
552
760
|
let adapterPoisonProblem = null;
|
|
553
761
|
let observedProducerTimeout = false;
|
|
554
762
|
let adapterClosePromise = null;
|
|
763
|
+
// Whether page_load's cells were all run when the adapter failed to close:
|
|
764
|
+
// a close failure after readability-only cells never changes page_load.
|
|
765
|
+
let closeFailedAfterGrid = false;
|
|
766
|
+
let gridDone = false;
|
|
555
767
|
const closeAdapter = async () => {
|
|
556
768
|
if (!adapter || typeof adapter.close !== "function") return;
|
|
557
769
|
if (!adapterClosePromise) {
|
|
@@ -561,20 +773,51 @@ export async function capturePolishPageLoad({
|
|
|
561
773
|
}
|
|
562
774
|
return adapterClosePromise;
|
|
563
775
|
};
|
|
776
|
+
// A producer timeout or cleanup failure poisons the adapter for every later
|
|
777
|
+
// cell, and closes it.
|
|
778
|
+
const poison = async (error) => {
|
|
779
|
+
const producerTimedOut = error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
|
|
780
|
+
const producerCleanupFailed = error?.code === POLISH_PRODUCER_CLEANUP_ERROR_CODE;
|
|
781
|
+
if (producerTimedOut) observedProducerTimeout = true;
|
|
782
|
+
if ((producerTimedOut || producerCleanupFailed) && adapter && !adapterPoisonProblem) {
|
|
783
|
+
adapterPoisonProblem = producerTimedOut ? "producer_timeout" : "producer_failed";
|
|
784
|
+
try {
|
|
785
|
+
await closeAdapter();
|
|
786
|
+
} catch (closeError) {
|
|
787
|
+
adapterCloseFailed = true;
|
|
788
|
+
adapterCloseError = closeError;
|
|
789
|
+
closeFailedAfterGrid = gridDone;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
};
|
|
564
793
|
try {
|
|
565
|
-
for (const route of plan
|
|
794
|
+
for (const route of plan?.routes || []) {
|
|
566
795
|
for (const viewport of plan.viewports) {
|
|
567
796
|
let observation;
|
|
797
|
+
let cellFailed = false;
|
|
798
|
+
const readabilityShared = scope?.shared.has(route.requested_route) && scope.routes.includes(route.requested_route);
|
|
568
799
|
try {
|
|
569
|
-
if (
|
|
800
|
+
if (adapterStartupFailed) throw adapterStartupError;
|
|
570
801
|
if (adapterPoisonProblem) {
|
|
571
802
|
throw adapterPoisonProblem === "producer_timeout"
|
|
572
803
|
? polishProducerTimeoutError()
|
|
573
804
|
: polishProducerCleanupError();
|
|
574
805
|
}
|
|
575
806
|
const abortController = new AbortController();
|
|
807
|
+
const imageProbe = {
|
|
808
|
+
...(isProbeClock(probeClock) ? { clock: probeClock } : {}),
|
|
809
|
+
remainingMs: MEDIA_PROBE_LIMITS.runBudgetMs - probeSpentMs,
|
|
810
|
+
cellBoundMs: MEDIA_PROBE_LIMITS.cellBoundMs,
|
|
811
|
+
imageCap: MEDIA_PROBE_LIMITS.imageCap,
|
|
812
|
+
};
|
|
576
813
|
observation = await runWithPolishProducerDeadline(
|
|
577
|
-
() => adapter.captureRoute({
|
|
814
|
+
() => adapter.captureRoute({
|
|
815
|
+
url: route.url,
|
|
816
|
+
viewport,
|
|
817
|
+
signal: abortController.signal,
|
|
818
|
+
imageProbe,
|
|
819
|
+
...(readabilityShared ? { readabilityProbe: readabilityProbe() } : {}),
|
|
820
|
+
}),
|
|
578
821
|
{
|
|
579
822
|
timeoutMs: cellDeadlineMs,
|
|
580
823
|
onTimeout() { abortController.abort(); },
|
|
@@ -584,16 +827,8 @@ export async function capturePolishPageLoad({
|
|
|
584
827
|
} catch (error) {
|
|
585
828
|
const browserUnavailable = error?.code === POLISH_BROWSER_UNAVAILABLE_ERROR_CODE;
|
|
586
829
|
const producerTimedOut = error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
if ((producerTimedOut || producerCleanupFailed) && adapter && !adapterPoisonProblem) {
|
|
590
|
-
adapterPoisonProblem = producerTimedOut ? "producer_timeout" : "producer_failed";
|
|
591
|
-
try {
|
|
592
|
-
await closeAdapter();
|
|
593
|
-
} catch (closeError) {
|
|
594
|
-
adapterCloseError = closeError;
|
|
595
|
-
}
|
|
596
|
-
}
|
|
830
|
+
await poison(error);
|
|
831
|
+
cellFailed = true;
|
|
597
832
|
observation = {
|
|
598
833
|
finalDocumentUrl: route.url,
|
|
599
834
|
responseCollectionStatus: "failed",
|
|
@@ -603,6 +838,11 @@ export async function capturePolishPageLoad({
|
|
|
603
838
|
: producerTimedOut ? "producer_timeout" : "producer_failed",
|
|
604
839
|
};
|
|
605
840
|
}
|
|
841
|
+
const probe = !cellFailed && isPlainObject(observation.imageProbe) ? observation.imageProbe : null;
|
|
842
|
+
if (probe) probed = true;
|
|
843
|
+
if (Number.isFinite(probe?.spent_ms) && probe.spent_ms > 0) probeSpentMs += probe.spent_ms;
|
|
844
|
+
if (readabilityShared && !cellFailed) chargeReadability(observation.readability, (observation.readability?.probe_ms || 0) + (observation.readability?.crop_ms || 0));
|
|
845
|
+
cellInputs.push({ route: route.requested_route, viewport: viewport.key, observation: cellFailed ? null : observation, probe });
|
|
606
846
|
captures.push(buildPageLoadCapture({
|
|
607
847
|
buildFingerprint,
|
|
608
848
|
slug,
|
|
@@ -619,23 +859,77 @@ export async function capturePolishPageLoad({
|
|
|
619
859
|
}));
|
|
620
860
|
}
|
|
621
861
|
}
|
|
862
|
+
gridDone = true;
|
|
863
|
+
|
|
864
|
+
// Readability-only cells, after every page_load cell, each in a fresh
|
|
865
|
+
// context, under the producer cell deadline and the run budgets. A cell
|
|
866
|
+
// that starts once the added budget is spent reads run_budget_exhausted;
|
|
867
|
+
// after a producer timeout, every later cell reads producer_timeout and
|
|
868
|
+
// none is tried again.
|
|
869
|
+
for (const { route, url } of scope?.only || []) {
|
|
870
|
+
for (const viewport of POLISH_CAPTURE_VIEWPORTS) {
|
|
871
|
+
const cell = { route, viewport: viewport.key, status: null, observation: null };
|
|
872
|
+
readabilityOnly.push(cell);
|
|
873
|
+
if (startupStatus) {
|
|
874
|
+
cell.status = startupStatus;
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
if (adapterPoisonProblem) {
|
|
878
|
+
cell.status = adapterPoisonProblem === "producer_timeout" ? "producer_timeout" : "navigation_failed";
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
if (!(READABILITY_ADDED_RUN_MS - spentMs.added > 0)) {
|
|
882
|
+
cell.status = "run_budget_exhausted";
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
const started = clock.now();
|
|
886
|
+
let observation = null;
|
|
887
|
+
try {
|
|
888
|
+
const abortController = new AbortController();
|
|
889
|
+
observation = await runWithPolishProducerDeadline(
|
|
890
|
+
() => adapter.probeReadabilityRoute({ route, url }, viewport, { signal: abortController.signal, probe: readabilityProbe() }),
|
|
891
|
+
{
|
|
892
|
+
timeoutMs: cellDeadlineMs,
|
|
893
|
+
onTimeout() { abortController.abort(); },
|
|
894
|
+
},
|
|
895
|
+
);
|
|
896
|
+
} catch (error) {
|
|
897
|
+
await poison(error);
|
|
898
|
+
cell.status = error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE ? "producer_timeout" : "navigation_failed";
|
|
899
|
+
}
|
|
900
|
+
const elapsedMs = Math.max(0, clock.now() - started);
|
|
901
|
+
chargeReadability(observation, elapsedMs);
|
|
902
|
+
if (cell.status) continue;
|
|
903
|
+
// An observation outside the record vocabulary is no measurement: its
|
|
904
|
+
// probe ran out its bound on the clock, or returned nothing.
|
|
905
|
+
if (readabilityObservationOk(observation)) {
|
|
906
|
+
cell.status = observation.status;
|
|
907
|
+
cell.observation = observation;
|
|
908
|
+
} else {
|
|
909
|
+
cell.status = elapsedMs >= READABILITY_PROBE_CELL_MS ? "probe_timeout" : "document_changed";
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
622
913
|
} finally {
|
|
623
|
-
if (adapter && !
|
|
914
|
+
if (adapter && !adapterCloseFailed) {
|
|
624
915
|
try {
|
|
625
916
|
await closeAdapter();
|
|
626
917
|
} catch (error) {
|
|
918
|
+
adapterCloseFailed = true;
|
|
627
919
|
adapterCloseError = error;
|
|
920
|
+
closeFailedAfterGrid = gridDone && readabilityOnly.length > 0;
|
|
628
921
|
}
|
|
629
922
|
}
|
|
630
923
|
}
|
|
631
924
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
925
|
+
const closeProblem = observedProducerTimeout
|
|
926
|
+
|| adapterCloseError?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE
|
|
927
|
+
? "producer_timeout"
|
|
928
|
+
: "producer_failed";
|
|
929
|
+
if (adapterCloseFailed && !closeFailedAfterGrid) {
|
|
637
930
|
captures.length = 0;
|
|
638
|
-
for (const
|
|
931
|
+
for (const input of cellInputs) Object.assign(input, { observation: null, probe: null });
|
|
932
|
+
for (const route of plan?.routes || []) {
|
|
639
933
|
for (const viewport of plan.viewports) {
|
|
640
934
|
captures.push(buildPageLoadCapture({
|
|
641
935
|
buildFingerprint,
|
|
@@ -646,12 +940,29 @@ export async function capturePolishPageLoad({
|
|
|
646
940
|
finalDocumentUrl: route.url,
|
|
647
941
|
responseCollectionStatus: "failed",
|
|
648
942
|
networkidle: { status: "invalid", duration_ms: null },
|
|
649
|
-
producerProblem,
|
|
943
|
+
producerProblem: closeProblem,
|
|
650
944
|
}));
|
|
651
945
|
}
|
|
652
946
|
}
|
|
653
947
|
}
|
|
654
948
|
|
|
949
|
+
const readability = scope ? buildRunReadability({
|
|
950
|
+
scope,
|
|
951
|
+
cellInputs,
|
|
952
|
+
readabilityOnly,
|
|
953
|
+
captures,
|
|
954
|
+
// A browser that did not close cleanly leaves every readability cell
|
|
955
|
+
// unmeasured.
|
|
956
|
+
closeStatus: adapterCloseFailed ? (closeProblem === "producer_timeout" ? "producer_timeout" : "navigation_failed") : null,
|
|
957
|
+
report,
|
|
958
|
+
buildFingerprint,
|
|
959
|
+
slug,
|
|
960
|
+
targetRepo,
|
|
961
|
+
now,
|
|
962
|
+
}) : null;
|
|
963
|
+
const withReadability = (result) => (readability ? { ...result, readability } : result);
|
|
964
|
+
if (plan === null) return withReadability({ plan: null });
|
|
965
|
+
|
|
655
966
|
const routes = plan.routes.map((route) => route.requested_route);
|
|
656
967
|
const viewports = plan.viewports.map((viewport) => viewport.key);
|
|
657
968
|
const pageLoad = buildPolishPageLoadEvidence({
|
|
@@ -662,5 +973,66 @@ export async function capturePolishPageLoad({
|
|
|
662
973
|
viewports,
|
|
663
974
|
captures,
|
|
664
975
|
});
|
|
665
|
-
|
|
976
|
+
// The image probe's sibling record, when the adapter ran the probe. Each
|
|
977
|
+
// cell is stamped with the integrity of its page_load capture.
|
|
978
|
+
if (!probed) return withReadability({ plan, page_load: pageLoad });
|
|
979
|
+
const uncaptured = uncapturedPages(packet, plan);
|
|
980
|
+
const mediaWeight = buildMediaWeightRecord({
|
|
981
|
+
pageLoad,
|
|
982
|
+
cells: cellInputs.map((input) => buildMediaWeightCell({
|
|
983
|
+
...input,
|
|
984
|
+
capture: pageLoad.captures.find((capture) => capture.subject.requested_route === input.route
|
|
985
|
+
&& capture.subject.viewport === input.viewport),
|
|
986
|
+
})),
|
|
987
|
+
uncapturedRoutes: uncaptured.routes,
|
|
988
|
+
uncapturedPageIds: uncaptured.pageIds,
|
|
989
|
+
});
|
|
990
|
+
return withReadability({ plan, page_load: pageLoad, media_weight: mediaWeight });
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// The readability record of one run: one cell per captured built route ×
|
|
994
|
+
// viewport. A shared cell (a route in page_load's grid) carries its probe from
|
|
995
|
+
// the page_load cell and that capture's integrity; it reads producer_timeout
|
|
996
|
+
// or navigation_failed when its page_load cell failed, and document_changed
|
|
997
|
+
// when the cell returned no readability read. Readability-only cells carry
|
|
998
|
+
// their own status.
|
|
999
|
+
function buildRunReadability({ scope, cellInputs, readabilityOnly, captures, closeStatus, report, buildFingerprint, slug, targetRepo, now }) {
|
|
1000
|
+
const writeCrop = (bytes) => writeReadabilityCrop(resolve(targetRepo), bytes);
|
|
1001
|
+
const cells = [];
|
|
1002
|
+
for (const route of scope.routes) {
|
|
1003
|
+
for (const viewport of POLISH_CAPTURE_VIEWPORTS) {
|
|
1004
|
+
const key = viewport.key;
|
|
1005
|
+
if (scope.shared.has(route)) {
|
|
1006
|
+
const input = cellInputs.find((entry) => entry.route === route && entry.viewport === key);
|
|
1007
|
+
const capture = captures.find((entry) => entry.subject.requested_route === route && entry.subject.viewport === key);
|
|
1008
|
+
const failed = (Array.isArray(capture?.problems) ? capture.problems : []).find((problem) => ["producer_timeout", "producer_failed", "browser_unavailable"].includes(problem?.code));
|
|
1009
|
+
const observation = input?.observation?.readability;
|
|
1010
|
+
const status = closeStatus
|
|
1011
|
+
?? (failed ? (failed.code === "producer_timeout" ? "producer_timeout" : "navigation_failed")
|
|
1012
|
+
: readabilityObservationOk(observation) ? observation.status : "document_changed");
|
|
1013
|
+
cells.push(buildReadabilityCell({
|
|
1014
|
+
route,
|
|
1015
|
+
viewport: key,
|
|
1016
|
+
pageLoadIntegrity: capture?.integrity?.projection_fingerprint ?? null,
|
|
1017
|
+
status,
|
|
1018
|
+
observation: status === "measured" ? observation : null,
|
|
1019
|
+
writeCrop,
|
|
1020
|
+
}));
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
const only = readabilityOnly.find((entry) => entry.route === route && entry.viewport === key);
|
|
1024
|
+
const status = closeStatus ?? only?.status ?? "navigation_failed";
|
|
1025
|
+
cells.push(buildReadabilityCell({ route, viewport: key, status, observation: status === "measured" ? only.observation : null, writeCrop }));
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
return buildReadabilityRecord({
|
|
1029
|
+
buildFingerprint,
|
|
1030
|
+
sourceFingerprint: currentSourcePackageMaterialFingerprint(report),
|
|
1031
|
+
slug,
|
|
1032
|
+
routes: scope.routes,
|
|
1033
|
+
uncapturedRoutes: scope.uncaptured_routes,
|
|
1034
|
+
routeEnumerationCapped: scope.route_enumeration_capped,
|
|
1035
|
+
cells,
|
|
1036
|
+
measuredAt: now,
|
|
1037
|
+
});
|
|
666
1038
|
}
|