@nextcommerce/campaigns-os 1.50.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +426 -0
  2. package/agents/claude/CLAUDE.md +2 -2
  3. package/agents/codex/AGENTS.md +1 -1
  4. package/agents/copilot/copilot-instructions.md +1 -1
  5. package/agents/cursor/campaigns-os.mdc +1 -1
  6. package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
  7. package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
  8. package/campaign-spec/dist/rules/design-source-shape.js +13 -3
  9. package/campaign-spec/dist/rules/sdk-version.js +2 -1
  10. package/compatibility.json +1 -1
  11. package/contracts/commerce-surface-catalog.json +26 -46
  12. package/contracts/effects.v1.json +81 -2
  13. package/contracts/release-ledger.json +906 -0
  14. package/contracts/supported-surface.json +2 -2
  15. package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
  16. package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
  17. package/docs/build-packet.md +93 -9
  18. package/docs/campaign-build-brief.md +25 -1
  19. package/docs/effects.md +6 -0
  20. package/docs/local-setup.md +1 -1
  21. package/docs/orientation-contract-reference.md +1 -1
  22. package/docs/polish-evidence.md +10 -0
  23. package/docs/qa-and-test-orders.md +45 -4
  24. package/docs/runtime-readiness.md +1 -1
  25. package/docs/sdk-storage-compatibility.md +1 -1
  26. package/docs/skills-revision.md +10 -10
  27. package/package.json +1 -1
  28. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  29. package/skills/campaign-readback-classification/SKILL.md +3 -3
  30. package/skills/campaign-run-evidence/SKILL.md +3 -3
  31. package/skills/contribution-intake/SKILL.md +3 -3
  32. package/skills/next-campaigns-build/SKILL.md +3 -3
  33. package/skills/next-campaigns-os/SKILL.md +4 -4
  34. package/skills/next-campaigns-os/references/session-intake.md +7 -3
  35. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  36. package/skills/next-campaigns-polish/SKILL.md +3 -3
  37. package/skills/next-campaigns-qa/SKILL.md +6 -5
  38. package/skills.json +10 -10
  39. package/src/adapter-decision-contract.mjs +1 -1
  40. package/src/brand-theme.mjs +12 -0
  41. package/src/build-brief.mjs +68 -21
  42. package/src/built-site-scope.mjs +39 -6
  43. package/src/built-smoke-qc.mjs +1117 -0
  44. package/src/campaign-identity.mjs +36 -2
  45. package/src/cart-placeholders.mjs +730 -0
  46. package/src/cli.mjs +310 -34
  47. package/src/commercial-journey.mjs +65 -4
  48. package/src/commercial-parity.mjs +6 -1
  49. package/src/doctor/checks.mjs +291 -24
  50. package/src/doctor/inspect.mjs +53 -2
  51. package/src/doctor/next-step.mjs +1 -1
  52. package/src/invocation.mjs +2 -1
  53. package/src/local-preview-policy.mjs +1 -1
  54. package/src/local-proof.mjs +4 -1
  55. package/src/polish-browser.mjs +218 -1
  56. package/src/polish-capture.mjs +1 -1
  57. package/src/polish-media-weight.mjs +492 -0
  58. package/src/polish-node.mjs +96 -4
  59. package/src/progress-node.mjs +5 -1
  60. package/src/qa-browser.mjs +308 -96
  61. package/src/qa-content-params.mjs +889 -0
  62. package/src/qa-node.mjs +104 -12
  63. package/src/qa-order-bump.mjs +22 -1
  64. package/src/qa-policy-links.mjs +1019 -0
  65. package/src/qa-tracking-params.mjs +1389 -0
  66. package/src/qa-url-privacy.mjs +168 -0
  67. package/src/qc-accept.mjs +446 -0
  68. package/src/qc-check-registry.mjs +83 -0
  69. package/src/qc-results.mjs +1049 -0
  70. package/src/sdk-attribute-index.mjs +71 -0
  71. package/src/sdk-markup.mjs +2 -2
  72. package/src/sdk-storage-compatibility.mjs +63 -3
  73. package/src/source-prep.mjs +37 -7
  74. package/src/stage-record.mjs +56 -17
@@ -8,6 +8,7 @@ import { shellToken } from "../shell-token.mjs";
8
8
  import { commitAssemblyReport, recordProducerStageOutcome } from "../stage-ledger.mjs";
9
9
  import { annotateDoctorIssueCauses } from "../finding-cause.mjs";
10
10
  import { DOCTOR_SIDECAR_SCHEMA } from "../doctor-sidecar.mjs";
11
+ import { recordQcResults } from "../qc-results.mjs";
11
12
  import { resolveCampaignWorkspace } from "../campaign-workspace.mjs";
12
13
  import { normalizePublicRouteSlug } from "../route-identity.mjs";
13
14
  import { DEFAULT_PROXY_BASE } from "../spec-fetch.mjs";
@@ -19,6 +20,8 @@ import { UPSELL_SELECTOR_SCOPE, builtPageTypeOverRouteGuess } from "../upsell-se
19
20
  import { CAMPAIGN_IDENTITY } from "../campaign-identity.mjs";
20
21
  import { SDK_MARKUP } from "../sdk-markup.mjs";
21
22
  import { SCRIPT_SYNTAX, collectBuiltScriptSyntaxInputs } from "../built-script-syntax.mjs";
23
+ import { CART_PLACEHOLDERS } from "../cart-placeholders.mjs";
24
+ import { SMOKE_QC } from "../built-smoke-qc.mjs";
22
25
  import { stageIsTerminal } from "../orchestration-stage-contract.mjs";
23
26
  import { evaluatePolishGate } from "../polish-gate.mjs";
24
27
  import { evaluateRecordedHiddenEagerMediaCheckpoint } from "../polish-node.mjs";
@@ -42,6 +45,9 @@ import {
42
45
  recordCampaignIdentityGate,
43
46
  recordScriptSyntaxGate,
44
47
  recordSdkMarkupGate,
48
+ recordCartPlaceholders,
49
+ collectCartPlaceholderPages,
50
+ recordSmokeQc,
45
51
  summarizeCopyMatches,
46
52
  resolveBrandContractOnce,
47
53
  reportBrandContractDefectOnce,
@@ -123,7 +129,7 @@ export async function readDoctorLiveCampaign(args, { fetchImpl = globalThis.fetc
123
129
  });
124
130
  }
125
131
 
126
- export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = undefined } = {}) {
132
+ export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = undefined, qcStandIns = undefined } = {}) {
127
133
  // Non-packet mode (learnings L7): doctor a `campaign-build`'d page-kit
128
134
  // campaign that has only a built _site/ and no full Build Packet. Resolves
129
135
  // scope from the built output and runs the built-output residue/text/
@@ -141,6 +147,8 @@ export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = u
141
147
  // A live campaign read (live-campaign-refs.mjs) the caller already made;
142
148
  // absent, the live ref check is recorded not_run.
143
149
  ...(liveCampaign !== undefined ? { liveCampaign } : {}),
150
+ // In-process QC stand-in checks (tests only; see qc-results.mjs).
151
+ ...(qcStandIns !== undefined ? { qcStandIns } : {}),
144
152
  };
145
153
  const result = runDoctor(packetPath, doctorOptions);
146
154
  // Inspection and recording are separate operations. A laptop's untracked
@@ -232,6 +240,8 @@ export function doctorBuiltOutput(args) {
232
240
  built_pages: scope.pages.map((page) => ({ page_id: page.page_id, type: page.page_type, route: page.route })),
233
241
  doctor_checks: [],
234
242
  checkpoint_gates: [],
243
+ // QC results: every result a QC check recomputed this run.
244
+ qc_results: [],
235
245
  };
236
246
  ready.push(`Resolved ${scope.html_count} built page(s) from ${relFromDir(targetRepo, scope.campaign_dir)} (slug "${scope.slug || "(site root)"}")`);
237
247
 
@@ -344,6 +354,39 @@ export function doctorBuiltOutput(args) {
344
354
  });
345
355
  derived.doctor_checks.push(SCRIPT_SYNTAX);
346
356
 
357
+ // Raw cart placeholders. Same placement, same reasons; a QC
358
+ // check whose warnings no blocker above withholds.
359
+ recordCartPlaceholders({
360
+ subject: {
361
+ public_route_slug: scope.slug || null,
362
+ site_root: relFromDir(targetRepo, scope.campaign_dir),
363
+ },
364
+ pages: collectCartPlaceholderPages(targetRepo, optionalString(args.slug)),
365
+ warnings,
366
+ ready,
367
+ derived,
368
+ });
369
+ derived.doctor_checks.push(CART_PLACEHOLDERS);
370
+
371
+ // Built-output smoke checks. Same placement, same reasons. This path has
372
+ // no Assembly Report and no deploy URL, so the build environment and the
373
+ // deploy base are unknown: the production-only rules and an absolute
374
+ // og:image read unexercised here.
375
+ recordSmokeQc({
376
+ subject: {
377
+ public_route_slug: scope.slug || null,
378
+ site_root: relFromDir(targetRepo, scope.campaign_dir),
379
+ },
380
+ targetRepo,
381
+ pages: collectCartPlaceholderPages(targetRepo, optionalString(args.slug)),
382
+ environment: "unknown",
383
+ deployBase: null,
384
+ warnings,
385
+ ready,
386
+ derived,
387
+ });
388
+ derived.doctor_checks.push(SMOKE_QC);
389
+
347
390
  const synthesized = synthesizeMinimalBuildPacket({
348
391
  schemaVersion: PACKET_SCHEMA,
349
392
  targetRepo,
@@ -417,7 +460,7 @@ export function doctorPacket(packetPath, options = {}) {
417
460
  return result;
418
461
  }
419
462
 
420
- function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath = undefined, outputBaseDir = null, liveCampaign = undefined } = {}) {
463
+ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath = undefined, outputBaseDir = null, liveCampaign = undefined, qcStandIns = null } = {}) {
421
464
  // The Build Context records where prepare-build wrote the report
422
465
  // (--report-out). `next` follows that pointer when no --report is given;
423
466
  // doctor reads the same report so its gates and its next block cannot
@@ -449,6 +492,8 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
449
492
  spec_path: null,
450
493
  doctor_checks: [],
451
494
  checkpoint_gates: [],
495
+ // QC results: every result a QC check recomputed this run.
496
+ qc_results: [],
452
497
  polish_checkpoint_gate: null,
453
498
  // The prepare-build gate `next` acts on, stored like every other gate so
454
499
  // the ladder consumes doctor's evaluation instead of computing its own.
@@ -548,6 +593,12 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
548
593
  ready.push("Hidden eager-media checkpoint not applicable before completed assembly.");
549
594
  }
550
595
 
596
+ // QC stand-in checks reach doctor only through this in-process option
597
+ // (tests); the shipped checks record through recordQcResults themselves.
598
+ for (const check of Array.isArray(qcStandIns?.doctor) ? qcStandIns.doctor : []) {
599
+ recordQcResults({ derived, warnings, results: check() });
600
+ }
601
+
551
602
  // The same fully resolved prepare-build gate the `next` command evaluates:
552
603
  // DSP-required packets, the recorded report path and the context/report
553
604
  // binding checks. A weaker gate here would let doctor name setup or build
@@ -591,7 +591,7 @@ function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGat
591
591
  actions.push("Confirm any rendered promo discount percentage claims against the build request, merchant notes, or CampaignSpec before launch.");
592
592
  }
593
593
  if (codes.has("template_contract.placeholder_text_residue")) {
594
- actions.push("Replace literal template placeholder text (Lorem/Placeholder/TODO/Product Name) with CampaignSpec/design copy before QA; the browser residue gate blocks on these terms.");
594
+ actions.push("Replace literal template placeholder text (Lorem/Placeholder/TODO/Product Name/Benefit one…four) with CampaignSpec/design copy before QA; the browser residue gate blocks on these terms.");
595
595
  }
596
596
  if (codes.has("template_contract.demo_asset_residue")) {
597
597
  actions.push("Re-skin template demo placeholder assets (spacer SVGs, repeated benefit icons, starter imagery) to the campaign's real assets before launch.");
@@ -63,7 +63,7 @@ const COMMANDS = frozen({
63
63
  bundle: { subcommands: ["check"] },
64
64
  standardize: {},
65
65
  theme: { subcommands: ["generate", "inspect", "waive"] },
66
- checkpoint: { subcommands: ["waive"] },
66
+ checkpoint: { subcommands: ["accept", "waive"] },
67
67
  polish: { subcommands: ["capture"] },
68
68
  record: { subcommands: ["build", "deploy", "polish", "setup", "theme"] },
69
69
  "validate-assembly-report": {},
@@ -87,6 +87,7 @@ const SUBCOMMAND_OVERRIDES = frozen({
87
87
  "tooling setup": { class: "inline" },
88
88
  "sdk storage-check": { class: "inspection" },
89
89
  "theme waive": { dryRun: true },
90
+ "checkpoint accept": { dryRun: true },
90
91
  "checkpoint waive": { dryRun: true },
91
92
  "page-kit sync": { dryRun: true },
92
93
  "spec derive": { dryRun: true },
@@ -13,7 +13,7 @@
13
13
  //
14
14
  // Doctor and QA apply this to the gates they evaluate; `next` reads doctor's
15
15
  // gates. Recording a stage (`record polish`) and the waiver commands keep the
16
- // strict gates.
16
+ // strict gates; `record deploy` follows next past a carried-forward polish.
17
17
  import { isLocalServePacket } from "./local-proof.mjs";
18
18
  import { isLoopbackHostname } from "./remit.mjs";
19
19
 
@@ -31,6 +31,9 @@ export const LOCAL_PROOF_BUILD_COMMAND = `CPK_ENV=${LOCAL_PROOF_BUILD_ENVIRONMEN
31
31
  export const LOCAL_PROOF_PARITY_SCOPE = "local_proof.production_parity";
32
32
  export const LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE = "local_proof.build_environment";
33
33
  export const LOCAL_PROOF_PARITY_COMMAND = "campaigns-os page-kit parity --packet <packet>";
34
+ // The command that records the build stage as a development render: record
35
+ // build stamps the fingerprint and writes LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD.
36
+ export const LOCAL_PROOF_RECORD_BUILD_COMMAND = `campaigns-os record build --packet <packet> --build-environment ${LOCAL_PROOF_BUILD_ENVIRONMENT}`;
34
37
  // Where the build stage records which environment it rendered, and where the
35
38
  // parity command records its result. Both live under the assembly stage's
36
39
  // free-form `evidence` object, which the hashed Assembly Report schema already
@@ -43,7 +46,7 @@ export const LOCAL_PROOF_NEVER_EDIT_RULE = "Never edit a generated include (anal
43
46
  // a cross-origin http: dependency — the signature of a protocol-relative
44
47
  // production loader served locally.
45
48
  export function localProofRebuildText() {
46
- return `The served build is a production build over plain HTTP: a cross-origin http: dependency failed to load, which is what a protocol-relative vendor loader (//host/...) does off an http://localhost origin. Rebuild in local proof mode — \`${LOCAL_PROOF_BUILD_COMMAND}\` — record ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} as "${LOCAL_PROOF_BUILD_ENVIRONMENT}", serve the development output, and recapture. ${LOCAL_PROOF_NEVER_EDIT_RULE}`;
49
+ return `The served build is a production build over plain HTTP: a cross-origin http: dependency failed to load, which is what a protocol-relative vendor loader (//host/...) does off an http://localhost origin. Rebuild in local proof mode — \`${LOCAL_PROOF_BUILD_COMMAND}\` — record it with \`${LOCAL_PROOF_RECORD_BUILD_COMMAND}\` (which sets ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} to "${LOCAL_PROOF_BUILD_ENVIRONMENT}"), serve the development output, and recapture. ${LOCAL_PROOF_NEVER_EDIT_RULE}`;
47
50
  }
48
51
 
49
52
  export function isLocalServePacket(packet) {
@@ -17,6 +17,7 @@ import {
17
17
  singleResponseRecord,
18
18
  } from "./polish-capture.mjs";
19
19
  import { launchPackageChromium, PLAYWRIGHT_INSTALL_HINT } from "./browser-launch.mjs";
20
+ import { isProbeClock } from "./polish-media-weight.mjs";
20
21
  import {
21
22
  boundedPolishDeadline,
22
23
  POLISH_BROWSER_CELL_DEADLINE_MS,
@@ -511,6 +512,215 @@ async function collectMediaElements(page) {
511
512
  });
512
513
  }
513
514
 
515
+ // The <img> reader the media-weight image probe runs after network
516
+ // observation closed, in an isolated world: every DOM method, getter and
517
+ // window property it uses is that world's own, so page scripts can neither
518
+ // change what it reads nor see that it ran. It walks the document in
519
+ // shadow-including tree order (an element, then its shadow tree, then its
520
+ // children), reaching open shadow roots through element.shadowRoot and
521
+ // closed ones through `closedRoots` (resolved into this world over CDP); it
522
+ // does not enter an iframe's document. It returns the <img> count,
523
+ // window.devicePixelRatio and, for the first `limits.images` <img>: the
524
+ // element path (the CSS child path from <body>, every step
525
+ // "tag:nth-of-type(n)", with a "#shadow-root" step after a shadow host),
526
+ // currentSrc (http(s) as is; any other scheme as the scheme alone), loading,
527
+ // complete, natural size, the rendered box in CSS px, computed object-fit and
528
+ // whether it is hidden (checkVisibility: it or a flat-tree ancestor is not
529
+ // displayed, or it is not visible). It reads only; it never scrolls and
530
+ // starts no load.
531
+ function readImageElements(limits, ...closedRoots) {
532
+ const closedRootOf = new Map(closedRoots.filter((root) => root?.host).map((root) => [root.host, root]));
533
+ const source = (value) => {
534
+ if (typeof value !== "string" || value === "") return { url: null, svg: false };
535
+ if (/^https?:/i.test(value)) return { url: value.length <= limits.urlLength ? value : "[url-too-long]", svg: false };
536
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value);
537
+ return { url: scheme ? `${scheme[1].toLowerCase()}:` : null, svg: /^data:image\/svg\+xml[;,]/i.test(value) };
538
+ };
539
+ const found = [];
540
+ let observedCount = 0;
541
+ // Work still to do, last first: an element to visit, or a parent (the
542
+ // document, an element or a shadow root) whose children are still to list.
543
+ const pending = [{ parent: document, path: "" }];
544
+ while (pending.length) {
545
+ const item = pending.pop();
546
+ if (item.element) {
547
+ if (item.element instanceof HTMLImageElement) {
548
+ observedCount += 1;
549
+ if (found.length < limits.images) found.push(item);
550
+ }
551
+ pending.push({ parent: item.element, path: item.path });
552
+ const shadowRoot = item.element.shadowRoot ?? closedRootOf.get(item.element);
553
+ if (shadowRoot) pending.push({ parent: shadowRoot, path: `${item.path}>#shadow-root` });
554
+ continue;
555
+ }
556
+ const children = [];
557
+ const counts = new Map();
558
+ for (let child = item.parent.firstElementChild; child; child = child.nextElementSibling) {
559
+ const index = (counts.get(child.tagName) ?? 0) + 1;
560
+ counts.set(child.tagName, index);
561
+ const tag = child.tagName.toLowerCase();
562
+ const path = child === document.body || child === document.documentElement
563
+ ? tag
564
+ : `${item.path ? `${item.path}>` : ""}${tag}:nth-of-type(${index})`;
565
+ children.push({ element: child, path });
566
+ }
567
+ for (let index = children.length - 1; index >= 0; index -= 1) pending.push(children[index]);
568
+ }
569
+ const images = found.map(({ element: image, path }) => {
570
+ const src = source(image.currentSrc);
571
+ const style = getComputedStyle(image);
572
+ const bounds = image.getBoundingClientRect();
573
+ return {
574
+ element_path: path,
575
+ current_src: src.url,
576
+ svg_data: src.svg,
577
+ loading: image.loading,
578
+ complete: image.complete,
579
+ natural: [image.naturalWidth, image.naturalHeight],
580
+ rendered: [bounds.width, bounds.height],
581
+ object_fit: style.objectFit,
582
+ hidden: !image.checkVisibility({ visibilityProperty: true }),
583
+ };
584
+ });
585
+ return { dpr: window.devicePixelRatio, observed_count: observedCount, images };
586
+ }
587
+
588
+ // The backend node ids of the main document's closed shadow roots, from a
589
+ // DOM.getDocument tree read with pierce. Open roots the read reaches itself
590
+ // and user-agent roots are not the page's; an iframe's document is not
591
+ // entered.
592
+ function closedShadowRoots(root) {
593
+ const found = [];
594
+ const pending = [root];
595
+ while (pending.length) {
596
+ const node = pending.pop();
597
+ if (!node || typeof node !== "object") continue;
598
+ if (node.shadowRootType === "closed" && Number.isInteger(node.backendNodeId)) found.push(node.backendNodeId);
599
+ if (Array.isArray(node.shadowRoots)) pending.push(...node.shadowRoots);
600
+ if (Array.isArray(node.children)) pending.push(...node.children);
601
+ }
602
+ return found;
603
+ }
604
+
605
+ const BOUND_ENDED = Symbol("bound ended");
606
+ const realProbeClock = Object.freeze({
607
+ now: () => performance.now(),
608
+ sleep: (ms) => new Promise((resolve) => {
609
+ const timer = setTimeout(resolve, ms);
610
+ timer.unref?.();
611
+ }),
612
+ });
613
+
614
+ function imageRead(value, imageCap) {
615
+ return value && typeof value === "object" && Number.isInteger(value.observed_count) && Array.isArray(value.images)
616
+ && value.images.length <= imageCap
617
+ ? value
618
+ : null;
619
+ }
620
+
621
+ // The media-weight image probe, run after collector.finish so it cannot
622
+ // delay the network window or add to the ledger. Its steps, in order: an
623
+ // isolated world (Page.createIsolatedWorld); the node tree (DOM.getDocument,
624
+ // piercing shadow roots) and each closed shadow root resolved into that world
625
+ // (DOM.resolveNode); the one read (Runtime.callFunctionOn of
626
+ // readImageElements in that world), which is the only source of the listing,
627
+ // geometry, computed style and device pixel ratio; and the Page.getFrameTree
628
+ // re-read. The probe's bound is the smaller of the cell bound and the run
629
+ // budget left. Every step races one deadline on `clock` at that bound, and
630
+ // none starts once it has passed. spent_ms, on `clock` from the first step to
631
+ // the return, is what the run budget is charged. The probe is cancelled when
632
+ // its bound ends it, on its timer or on `clock` whichever is first, or when it
633
+ // returns: every CDP command, and every step's result, checks that first, so
634
+ // a step that settles late issues nothing more. A probe its bound ends reads
635
+ // probe_timeout, or probe_budget_exhausted where the run budget left was the
636
+ // smaller bound, never complete; it names its images only when its read had
637
+ // returned. A cell that starts once the run budget is spent starts no step
638
+ // and reads probe_budget_exhausted with no images. The re-read discards the
639
+ // probe as document_context_changed when the main frame has another id or
640
+ // loaderId than page_load's own re-read found; so does a step that failed
641
+ // (its execution context destroyed).
642
+ // Returns { status, dpr, images, spent_ms }.
643
+ async function probeImageElements(options) {
644
+ const token = { cancelled: false };
645
+ try {
646
+ return await runImageProbe(options, token);
647
+ } finally {
648
+ token.cancelled = true;
649
+ }
650
+ }
651
+
652
+ async function runImageProbe({ session, mainFrame, documentContextChanged, probe }, token) {
653
+ const clock = isProbeClock(probe.clock) ? probe.clock : realProbeClock;
654
+ const limits = { images: probe.imageCap, urlLength: MAX_POLISH_CAPTURE_URL_LENGTH };
655
+ const started = clock.now();
656
+ const spent = () => Math.max(0, clock.now() - started);
657
+ let read = null;
658
+ const outcome = (status) => ({ status, dpr: read?.dpr ?? null, images: read?.images ?? [], spent_ms: spent() });
659
+ if (!(probe.remainingMs > 0)) return { ...outcome("probe_budget_exhausted"), spent_ms: 0 };
660
+ if (documentContextChanged || typeof mainFrame?.id !== "string") return outcome("document_context_changed");
661
+ const boundMs = Math.min(probe.cellBoundMs, probe.remainingMs);
662
+ const boundEnd = started + boundMs;
663
+ // The status of a probe its bound ended.
664
+ const boundEnded = probe.remainingMs < probe.cellBoundMs ? "probe_budget_exhausted" : "probe_timeout";
665
+ // The probe's one guard, run before every command it issues and before it
666
+ // acts on any step's result: the probe is cancelled once the token is, or
667
+ // once the probe clock reaches the bound. The clock is read even when no
668
+ // timer has fired, so a step that settles past the bound ahead of a queued
669
+ // timer issues nothing more; a passed bound cancels the token too.
670
+ const cancelled = () => {
671
+ if (!token.cancelled && clock.now() >= boundEnd) token.cancelled = true;
672
+ return token.cancelled;
673
+ };
674
+ const send = (method, params) => (cancelled()
675
+ ? Promise.reject(new Error("The image probe was cancelled."))
676
+ : session.send(method, params));
677
+ const deadline = clock.sleep(boundMs).then(() => {
678
+ token.cancelled = true;
679
+ return BOUND_ENDED;
680
+ });
681
+ // One probe step: started only inside the bound, and raced against it. A
682
+ // step that settles past the bound reads BOUND_ENDED, not its result.
683
+ const step = async (start) => {
684
+ if (cancelled()) return BOUND_ENDED;
685
+ const settled = await Promise.race([Promise.resolve().then(start).then((value) => ({ value }), (error) => ({ error })), deadline]);
686
+ return cancelled() ? BOUND_ENDED : settled;
687
+ };
688
+
689
+ const world = await step(() => send("Page.createIsolatedWorld", {
690
+ frameId: mainFrame.id,
691
+ worldName: "campaigns-os-polish-image-probe",
692
+ grantUniveralAccess: false,
693
+ }));
694
+ if (world === BOUND_ENDED) return outcome(boundEnded);
695
+ const contextId = world.value?.executionContextId;
696
+ if (world.error || !Number.isInteger(contextId)) return outcome("document_context_changed");
697
+ const tree = await step(() => send("DOM.getDocument", { depth: -1, pierce: true }));
698
+ if (tree === BOUND_ENDED) return outcome(boundEnded);
699
+ if (tree.error) return outcome("document_context_changed");
700
+ const roots = await step(() => Promise.all(closedShadowRoots(tree.value?.root)
701
+ .map((backendNodeId) => send("DOM.resolveNode", { backendNodeId, executionContextId: contextId }))));
702
+ if (roots === BOUND_ENDED) return outcome(boundEnded);
703
+ const rootIds = roots.error ? null : roots.value.map((resolved) => resolved?.object?.objectId);
704
+ if (!rootIds || !rootIds.every((objectId) => typeof objectId === "string")) return outcome("document_context_changed");
705
+ const evaluated = await step(() => send("Runtime.callFunctionOn", {
706
+ functionDeclaration: readImageElements.toString(),
707
+ executionContextId: contextId,
708
+ arguments: [{ value: limits }, ...rootIds.map((objectId) => ({ objectId }))],
709
+ returnByValue: true,
710
+ }));
711
+ if (evaluated === BOUND_ENDED) return outcome(boundEnded);
712
+ if (evaluated.error || evaluated.value?.exceptionDetails) return outcome("document_context_changed");
713
+ read = imageRead(evaluated.value?.result?.value, probe.imageCap);
714
+ if (!read) return outcome("document_context_changed");
715
+ const reread = await step(() => send("Page.getFrameTree"));
716
+ if (reread === BOUND_ENDED) return outcome(boundEnded);
717
+ if (reread.error) return outcome("document_context_changed");
718
+ const frame = reread.value?.frameTree?.frame;
719
+ if (frame?.id !== mainFrame.id || frame?.loaderId !== mainFrame.loaderId) return outcome("document_context_changed");
720
+ if (read.observed_count > probe.imageCap) return outcome("image_cap_reached");
721
+ return cancelled() ? outcome(boundEnded) : outcome("complete");
722
+ }
723
+
514
724
  function preferredResolvedValue(finalValue, initialValue) {
515
725
  return typeof finalValue === "string" && finalValue !== "" ? finalValue : initialValue;
516
726
  }
@@ -676,7 +886,10 @@ export async function createPolishBrowserAdapter({
676
886
  let poisonCode = null;
677
887
  let closePromise = null;
678
888
  return {
679
- async captureRoute({ url, viewport, signal } = {}) {
889
+ // `imageProbe` ({ clock, remainingMs, cellBoundMs, imageCap }) opts the
890
+ // cell into the media-weight image probe; the observation then carries
891
+ // `imageProbe` beside the page-load fields.
892
+ async captureRoute({ url, viewport, signal, imageProbe: probeOptions = null } = {}) {
680
893
  if (closed) throw new Error("Campaigns OS polish capture browser adapter is already closed.");
681
894
  if (poisonCode === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) throw polishProducerTimeoutError();
682
895
  if (poisonCode === POLISH_PRODUCER_CLEANUP_ERROR_CODE) throw polishProducerCleanupError();
@@ -791,12 +1004,16 @@ export async function createPolishBrowserAdapter({
791
1004
  network.responseCollectionStatus = "failed";
792
1005
  network.responses.push(captureProblemRecord("document_context_changed"));
793
1006
  }
1007
+ const imageProbe = probeOptions && typeof probeOptions === "object"
1008
+ ? await awaitActive(probeImageElements({ session, mainFrame, documentContextChanged, probe: probeOptions }))
1009
+ : null;
794
1010
  return {
795
1011
  finalDocumentUrl,
796
1012
  responseCollectionStatus: network.responseCollectionStatus,
797
1013
  networkidle,
798
1014
  mediaElements,
799
1015
  responses: network.responses,
1016
+ ...(imageProbe ? { imageProbe } : {}),
800
1017
  };
801
1018
  }, {
802
1019
  timeoutMs: boundedCellDeadlineMs,
@@ -228,7 +228,7 @@ export function buildPolishCaptureIntegrity(captureProjection) {
228
228
  };
229
229
  }
230
230
 
231
- function resolvedResource(value, { baseUrl } = {}) {
231
+ export function resolvedResource(value, { baseUrl } = {}) {
232
232
  const resolved = resolvedCaptureUrl(value, { baseUrl });
233
233
  return {
234
234
  ...resolved,